読者です 読者をやめる 読者になる 読者になる

スマートフォンWebのフロントエンドを高速化する取り組み

ユーザファースト推進部の丸山(@h13i32maru)です。

先日「撮るレシピ」というサービスを cookpad.com にて公開しました。「撮るレシピ」というサービスは料理本や雑誌のレシピを写真に撮ってクックパッド上に保存できるというものです。料理本や雑誌でレシピを良く見る方はぜひ使ってみてください(Androidアプリ版もあります)。

f:id:h13i32maru:20141023094045p:plain

この「撮るレシピ」は全体公開前に一部のユーザに限定公開をしていました。そして全体公開をするにあたりフロント側のコードを全面的に書き換え高速化を行いました。その結果、最大で30倍高速化することができユーザの使い勝手が向上しました。以下が「書き換え前」と「書き換え後」の計測結果です(Android端末8機種 + iOS3機種で各操作のターンアラウンド時間*1を計測)。

  • 閲覧系
    • 最大: 30倍高速化(4.2秒→0.14秒)
    • 平均: 15.7倍高速化(3.6秒→0.25秒)
  • 更新系
    • 最大: 7倍高速化(2.6秒→0.4秒)
    • 平均: 3.7倍高速化(3.9秒→1.2秒)

というわけで、今回は高速化するために行った「SPAによる画面遷移」「写真のリサイズとローカル表示」「タップ処理の最適化」という内容についてご紹介します。

SPAによる画面遷移

SPA(Sigle Page Application)とは「1つのURLに複数の画面を紐付けて、ユーザ操作に応答して画面を動的に書き換えながらサーバとの通信を必要最低限にしたWebアプリケーション」というものです。ここ数年Web界隈では話題になっている技術です。SPAの「画面遷移時にサーバとの通信を必要としない(or 最小限)」「HTML/JavaScript/CSSの再評価が行われない(or 最小限)」という特徴により画面遷移や操作が高速化されます。

このSPAを実現するにはAngularJSなどのオールインワンなフレームワークを使ったり、Backbone.jsやjQueryなどのフレームワークを組み合わせて使ったりなど様々な方法があります。

画面管理

今回SPAを実現するために、Androidの画面管理を真似るという方法をとりました。Androidの画面管理は主に以下の4つから構成されています。

  • XMLからViewを作るInflate機能
  • 画面の生成、活性、停止、破棄を扱うライフサイクル機能
  • Intentによる画面間呼び出し機能
  • 画面の遷移スタックを管理する機能

これらの機能を簡易的にJavaScriptで実装してSPAを実現しました。実際にどのように書くのかはコードを見てもらったほうが早いと思うので、以下にサンプルコード(CoffeeScript)を示します。

# このサンプルコードではStartPageとSubPageという2つの画面があります。
# StartPageからSubPageに遷移することができ、遷移するときに数値を渡すと、SubPage側で何らかの処理を行います。
# SubPageを離れる(バックする)と処理を行った数値を呼び出し元の画面(StartPage)に渡します。
# StartPageは渡された数値を使って画面を描画しなおします。
# 画面のライフサイクルメソッドは以下のようになっています
# onCreate -> onResumeBefore -> onResume -> onResumeBefore -> onPauseBefore -> onPause -> onPauseAfter -> onDestroy
 
class StartPage extends BasePage # BasePageがすべての画面の基底クラス(AndroidのActivityに相当)
  num: null
 
  onCreate: (data) -> # 画面が生成された時に呼び出されるメソッド(AndroidのActivity#onCreateに相当)
    super(data)
 
    @num = 0
 
    @inflateView('.start_page.template') # 元となるHTMLをクローンしてこの画面専用のViewを作る(AndroidのActivity#setContentViewに相当)
    @handleView() # Viewの各Elementに適切なハンドラ(click, changeなど)を設定する(Androidでボタンなどにリスナを設定する処理に相当)
    @renderView() # この画面の状態から適切なViewをレンダリングする(Androidでは開発者が明示的に実行しなくてもよい。あえて言うならView#invalidate)
 
  onResumeBefore: -> # 画面が活性化するたびに呼び出されるメソッド(AndroidのActivity#onResumeに相当)
    @num++
 
  handleView: ->
    @view.on('click', '.button', @onTapButton)
 
  renderView: ->
    @view.find('.num').text(@num)
 
  onTapButton: (evt) =>
    @next(SubPage, {num: @num}, @onResultFromSubPage) # 別の画面に遷移する(AndroidのActivity#startActivitForResultに相当)
 
  onResultFromSubPage (data) => # 呼び出し先画面から戻ってきた時の処理(AndroidのActivity#onActivityResultに相当)
    @num = data.num
    @renderView()
 
class SubPage extends BasePage
  num: null
 
  onCreate: (data) ->
    super(data)
 
    @num = data.num + Math.random()
    @setResult({num: @num}) # 呼び出し元画面に戻った時に渡す値(AndroidのActivity#setResultに相当)
 
    @inflateView('.sub_page.template')
    @handleView()
    @renderView()
 
  handleView: ->
    @view.on('click', '.back_button', @onTapBackButton)
 
  renderView: ->
    @view.find('.num').text(@num)
 
  onTapBackButton: =>
    @back() # 自身を破棄して呼び出し元画面に戻る(AndroidのActivity#finishに相当)

なぜ既存のフレームワークを使わずにこのような仕組みを作ったかというと、スマートフォンの場合は各画面がすごく単機能になっており、いくつもの画面を行ったり来たりしながらアプリを使うというのが普通だからです。そうなってくると各画面を論理的に分割し、画面間をなるべく疎結合にすることが必須になってきます。さらに各画面は表示/非表示(活性/非活性)を繰り返すことになるので、ライフサイクルという考えも必要になってきます。僕が知るかぎりこのような仕組みをもったフレームワークが存在しなかったため自作したという経緯があります。また、このような仕組みを持ったAndroidに実績があるというのも理由になります。

ちなみにこの仕組はCoffeeScriptで200行程度で実装されており、非常にシンプルなものになっています。

データの状態管理

SPAには画面管理以外にも解決しなければならない問題があります。それはデータの状態管理です。通常のWebページであればステートレス(それぞれの状態に一意のURLが紐づく)なためクライアント(ブラウザ)側で状態を持つことはありません。しかしSPAにすることでデータの状態をもつ必要が出てきます。例えば一覧画面 → 詳細画面 → 編集画面と遷移し、編集画面でアイテムの状態を変更したとします。そうすると一覧画面、詳細画面で表示を変更する必要がでてきます。つまりデータの状態を管理してそれに応じて表示も変更するということです。

このようにクライアント側でデータの状態管理を行うために以下のようにしました。

  • サーバサイドはJSONデータを返すAPIとして実装する
  • クライアントサイドにモデルクラスを実装する

通常のWebページであればサーバは組み立てられたHTMLを返しますが、SPAでは必要なデータだけをJSONで返し、HTMLの組み立てはクライアント側で行います。そしてサーバから取得したJSONをそのまま使うのではなく、そのJSONに対応するモデルクラスを実装します。少し動的な要素があるWebページの場合、サーバからのJSONをObjectにパースしてそのまま使用することがあると思います。しかしある程度規模が大きくなってくるとひずみが出てきます。

例えば「itemのリストからid = 10のitemを取得する」を実装する場合、生ObjectだとUtil.findItem(items, 10)のようになると思います。しかしモデルクラスを実装すればitems.find(10)のように処理の責務を正しくitemsに持たせることができます。クライアントサイドのアプリケーション(Android, iOS, Qtなど)では普通はこのようにモデルクラスを実装すると思います。Webはステートレスなアーキテクチャから始まっているためこのようなモデルクラスを実装する習慣がないのだと思います。そしてこのモデルはサーバサイドのモデルよりも寿命が長く様々なところから操作されます。ここがクライアントサイドを実装する面白さの一つだと思います。*2

余談ですが、データの状態更新と画面更新を自動的に結びつける技術としてデータバインディングという仕組みがあります。もしデータバインディングを採用する場合は保守性、パフォーマンス、チーム開発のしやすさ、既存のコードベースとの相性などを検討する必要があると思います。

SPAに適した条件

このようにしてSPAを実現し高速化を行いました。しかしSPAを実現しやすくするためにはいくつかの条件があります。

  • 画面数が少ない
    • 画面数が多ければ多いほど、扱うデータやHTML/JavaScript/CSSの量が多くなりメモリシビアになります
  • 通常のURL遷移がない
    • 途中でURL遷移してしまうとそれまでのステートがすべて消えてしまうため、前の状態に戻るのが非常に難しくなります
  • SEOを考えなくて良い
    • SPAはひとつのURLに様々な画面が付随し、かつステートを持つためURLベースのクローラと相性が非常に悪くなります
    • サーバサイドでのHTML構築、pushState、#!URLなどを使って解決する方法もあります

これらの条件は必ずしもクリアする必要がなく、SPAによる恩恵とのトレードオフになると思います。

今回の撮るレシピでは画面数は5画面程度、ユーザのみが見れるプライベートなデータなのでSEOは考慮する必要がありませんでした。URLの遷移はcookpad.com下で展開しているので必ず発生するのですが、「他の画面(マイフォルダやトップなど)にURL遷移して戻ってきた場合は撮るレシピのトップから始める」ということにして対応しました。これは撮るレシピから他の画面に遷移したということはユーザのコンテキストが変わったと推測できるので、撮るレシピに戻ってきた時はトップから始めても差し支え無いだろうという判断です。つまりSPAによる恩恵(高速化)と画面遷移のトレードオフを行ったわけです。

その他にもブラウザの履歴機能と相性が悪かったり(進むの実装が難しい)、端末スペックを要求されたりします。撮るレシピでは履歴を進む機能には対応しませんでした。端末スペックはファイルアップロードを行う必要があるため必然的にAndroid4.0以上、iOS6以上となり対応することができました。


これらのSPAを実現する方法はWebアプリケーションとしては新しい実装方法です。ですがクライアントアプリケーションとしては至って普通の内容になっています。

写真のリサイズとローカル表示

撮るレシピでのユーザ操作は主に2つあり「写真を閲覧する」と「写真をアップロードする」です。前者はSPAで快適にすることができました。そこで次に後者を快適にする必要があります。

ユーザの写真サイズ、回線速度

写真のアップロードを快適にするためには「ユーザの写真サイズはどの程度なのか」と「ユーザの端末の回線速度はどの程度なのか」という2つを知る必要がります。これらをcookpad.comにアクセスするユーザに対して計測しました。

  • 写真サイズ(つくれぽで測定)
    • 1MB以上の写真は全体の約40%
    • 一番多かったのは1.8MB付近の写真
  • 回線速度
    • 高速回線(LTE/Wi-Fi)は全体の約80%

高速回線を使ってるユーザがほとんどですが、低速回線を使っているユーザも20%程度います。そしてたとえ高速回線を使っていたとしても1MB以上の写真をアップロードするのは時間と通信料がかかります。特に最近はスマホの通信料は増えておりキャリアによっては通信制限がかかる場合もあります。

では実際にアップロード時間はどの程度かかるのかも見てみます。

  • Nexus5を使って写真をアップロードして計測(写真サイズ: 1.5MB〜2MB)
    • LTE: 6秒〜7秒
    • 3G: 40秒〜50秒

LTEでも結構な時間がかかります。3Gに至ってはサービスとして致命的です。これらを改善するために「リサイズ」と「ローカル表示」を行いました。

リサイズとローカル表示

ここ1, 2年のAndroidやiOSのカメラは8MPx(3264x2448)の解像度を持っています(2014年夏モデルのAndroidは20MPxを持っています)。クックパッドでは写真サイズはサーバ側で1MPx(1000x1000)にリサイズされます。つまり8MPxの解像度でそのままアップロードするのではなくクライアント側で1MPxにリサイズしてからアップロードすればアップロード時間、通信料ともに少なくて済むようになります。技術的な詳細は割愛しますが、ブラウザ側でも写真のリサイズは可能です。しかしAndroid、iOSともに幾つか問題があるのでJavaScriptを使って補助する必要があります。リサイズに関しては写真サービス機能のブラウザ内実装 | 株式会社サイバーエージェントが非常にわかりやすくまとまっています。

そしてこのリサイズが終了した段階でサーバにアップロードするのですが、アップロードと同時にブラウザに表示(ローカル表示)してしまいます。こうすることでユーザの体感的にも処理が速く終わったように見え、他の写真を撮ったり、レシピタイトルを入力している間にアップロードを終わらせます。

これで写真アップロードも快適になったと思ったのですが、例のごとくAndroidブラウザに問題があります。それはリサイズ処理にも時間がかかるということです。Androidブラウザでは4〜5秒、AndroidChromeでは1〜2秒、iOSでは0.2〜0.5秒かかります。5秒前後もかかっていたらリサイズせずにアップロードするのと大差ありません(Androidブラウザが提供する画像処理の機能に問題があるためこれだけの時間がかかってしまいます)。

しかし検証を進めていくとAndroidでは更に別の問題が出てきました。

  • メモリの空き容量が少ない状況では写真のリサイズが失敗する場合がある(Xperia SX, Xperia A2, Nexus5で再現)
  • 一部のギャラリーアプリから写真を選択すると、JavaScriptでFileオブジェクトを扱うことができない(Xperia A2で再現)

このようにAndroidでは写真のリサイズ、ローカル表示が不安定なため現在はiOSのみ対応しています。Androidの対応は今後の改善課題となります。

タップ処理の最適化

スマートフォン版cookpad.comではHTMLの要素がタップされた時のイベントとしてclickイベントが使われています。しかし実はclickイベントはユーザが要素をタップした瞬間には発生せず、300ミリ秒前後待ってからclickイベントが発生します。これはスマートフォンのブラウザではダブルタップすると画面を拡大する機能が付いているため、タップなのか画面拡大なのかを判断するために300ミリ秒待つからです。 つまりイベントの発生順序としては以下のようになります。

  • touch start
  • touch move
  • touch end
  • wait 300ms
  • click

300ミリ秒の遅延というのはかなり大きいものです。サーバサイドでAPIのレスポンスタイムを300ミリ秒速くするのはかなり大変だと思いますが、クライアントサイドではclickイベントをやめてtouchイベントを使うようにすればそれだけ高速化でき費用対効果に優れています。

しかし実際にclickイベントをやめてtouchイベントにする場合にはいくつかの問題があります。

  • clickイベント(aタグによるリンクも含む)とtouchイベントは相性が悪いのでclickイベントを無効化する必要がある
    • 厳密にはtouchイベント直後のclickイベントのみを無効化する
  • スクロールなのかタップなのかを指の動きや時間から判断する必要がある

撮るレシピではclickイベントは一切使わず、すべてtouchイベントでハンドリングしています。この辺りの詳しい内容は300ms tap delay, gone awayを参考にしてください。


まとめ

フロントエンドの改善というとUIやアニメーションなどに手をいれることが真っ先にあげられます。しかし、そもそもスマートフォンWebの場合は動作が遅いという欠点があり、高速化がユーザの使い勝手向上に大きく影響してきます。ここで紹介した高速化の方法以外にも色々な方法があるので、サービスごとに適切な方法を選択してユーザにとって快適なサービスづくりをしていく必要があります。

以上クックパッドで行っているスマートフォンWebのフロントエンドを高速化する取り組みでした。

*1:ユーザが操作を開始してから処理が完了し、ユーザが次の操作ができるようになるまでの時間

*2:この辺りがWebフロントエンドとWebクライアントサイドの境界線かなと思います

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/