Carousel by Dropbox: 遅延のない動きを実現する工夫

https://tech.dropbox.com/2014/04/building-carousel-part-i-how-we-made-our-networked-mobile-app-feel-fast-and-local/

1 comment | 1 point | by WazanovaNews 3年弱前 edited


Jshiike 3年弱前 edited | ▲upvoteする | link

Dropboxは、先週iOS、Android向けに写真管理アプリCarouselを発表しました。開発にあたり、まず目標になったのは、デバイスのローカルに保存された写真を閲覧する仕組みのアプリと比べて、パフォーマンスが劣らないこと。その取り組みが同社のエンジニアブログで紹介しています。ポイントとしては、ユーザのアクションを妨げないスムーズさを実現すること。

1) 解決すべき問題点

クラウドに保存された写真を閲覧するCarouselにおいて、まず問題になったのは、

  1. 写真タブにおいて、ユーザのアクションをサーバと同期させる際に、HTTPSリクエストを待機した状態が起きる。例えば、写真をシェアする場合、このような画面になる。写真を削除する場合も同様。ネットワークの接続がよくなければ、後でユーザは再トライする必要がでる。

  2. Dropboxにアップしてない写真、つまりデバイスのローカルにしかない写真の閲覧 & 操作ができない。

2) クライアントアーキテクチャの改善

  • クラウドとローカルの写真を同列に扱うことができるデータモデルの開発。
  • ユーザは100ms程度で遅延を認識してしまうので、ネットワークコールを待機している状態を見せるわけにはいかない。替わりに、ユーザのアクション結果をローカルで先に実現し、後で結果整合性を持たせ、そして他のデバイスにも反映させるという、楽観的レプリケーション方式を採用。

ローカルとサーバのコンテンツがマージしたビューをつくるために、リモートにあるサーバでの変更を反映させてクライアントを最新の状態に保つ必要があった。そのため、HTTP long pollingで変更通知を受取り、Dropboxのdelta API使って変更内容をプルする仕組みとした。前回クライアントがサーバにコールした以降の変更点をdelta APIは返してくる。変更内容を取得すると、最新のサーバのメタデータをSQLiteserver_photosテーブルに書込む。server_photosテーブルはサーバ側に存在する正しいデータのキャッシュとなる。

一方、クライアント側にあるカメラロールスキャナは、各写真データのハッシュを計算し、どれがDropboxにまだアップされていないか把握する。アップが必要な写真はphoto_upload_operationとしてシリアライズ化されSQLiteに送られる。

ビューをレンダリングする前に、クライアント側でのユーザのアクションを三つ目のインプットソースとする。Carousel上でユーザが写真を隠す or 削除した際は、即座にその結果を反映させる。それから非同期に変更内容をサーバに書込む。そのために、HideOperationもしくはDeleteOperationをつくり、SQLiteの内容と一致させる。(この仕様に関するSQLiteの参考図

Carouselでのユーザのアクションは全てOperationとみなされ、最終的にはサーバとシンクされる。メモリ内のOperationキューに置かれ、SQLiteと整合性がとられる。キューごとに専用のOperation同期スレッドが立ち、実行可能な状態になるまで待機し、そしてHTTPSコールサーバに変更を送られる。ビューをレンダリングする度に、ペンディングとなっているOperationを参照し、ユーザの最新のアクションが反映されていることを確認する。delta API経由でサーバに反映されて初めて、それらのOperationを安全に削除できる。アーキテクチャを図にまとめるとこのようになる。

(OperationをDBテーブルに反映させる事例は、本文を直接参照ください。)

実際のところ、photoモデルに変更がある度にUI側がlist_photos()を呼び出し、SQLiteから読込むのはコストがかかる。替わりに、photoモデルをメモリ内に読込み、変更があれば修正(アプリ内のユーザアクションであれ、リモートにあるサーバ側での変更であれ、)するかたちにしている。これは、サーバからの変更を同期するdelta APIの仕組みとそれほど変わらない。早いスピードで対応するために、ディスクとメモリ間に別のレイヤのdeltaを導入したと考えればよい。(10万枚以上の写真をアプリでスムーズに扱うためのこの技術については、次回のエンジニアブログで紹介する。)

Operationの内容をDBテーブルに反映させる事例で紹介した仕組みの根底にあるアイデアは、クライアント側での写真の追加やキャッシュサーバで写真を隠すというアクションは、最終的に写真をアップロードして、サーバ側で非表示ステータスにするというのと同じ結果にするということ。Carouselでデータをレンダリングする度に、キャッシュサーバのステートをまず確認し、ペンディングとなっているOperationをクライアント側でも実行する。非表示や削除の場合は、コンフリクトを解消するために、サーバへの直近の書込みが優先するというルールに従う。

写真がDropboxに既に保存されていれば、サーバIDを保持しているので、問題なく処理できる。各ペンディングOperationはサーバIDをもつことができる。では、Dropboxにまだアップしてない写真に変更がおきたらどうするのか?追加の制約事項として、Dropboxはマルチプラットフォームのサービスなので、Carouselのクライアント以外からアップされる可能性があることにも留意しなくていはいけない。だとしても、その写真に対する全てのペンディングアクションを処理しなくてはいけない。

3) 写真の確認

ローカルにある写真にのみ発生したアクションをサーバとうまく同期するにはいくつかの方法がある。クライアント側のロジックを簡単に把握できるように、シンプルでステートレスな仕組みにしたかった。そのため、デバイス専用IDを導入し、写真を参照するのはLUID (locally unique ID)とした。LUIDであれば、写真をDropboxにアップする前後でIDが変わらない。LUIDには自動的に数字が増えていく整数が適用される。

新しいデバイスをスキャンして、Dropboxにアップすべき写真を見つけたら、LUIDを発行する。そして、そのLUIDは、local_photo_luidsテーブルに追加され、ネイティブカメラロールのIDにもマップされる。

もしサーバ側に新しい写真Sが反映されれば、S.hashがローカルにある写真のハッシュと一致するかどうか確認し、一致しなければLUIDを発行する。そして、そのLUIDはserver_photo_luidsテーブルに追加され、サーバIDにもマップされる。

もしハッシュがローカルにある写真Lと一致すれば、既にDropboxにアップされている写真ということで、サーバのメタデータが利用可能になる。S.photo_luid = L.photo_luidを適用する。また、関連するphoto_upload_operationを作業完了とする。コンフリクトを避けるために(例えば、同じ写真がユーザのDropboxに複数回追加されないように)、同じハッシュを保持した最初のサーバ側の写真がアップロードのOperationを完了し、LUIDを取得することにする。

このロジックで、サーバにあるかどうかに関わらず、安心して特定の写真に対するアクションを実行できる。クライアント側は写真オブジェクトへの対応として、シンプルにLUIDだけを見ていればよい。ローカルの写真がDropboxにアップされた時点で、新しいサーバIDを参照できるようにアップグレードされるという仕組み。

4) 素早くシェアする

LUIDをベースにした場合、Carouselでどのように写真をシェアできるか見てみよう。

ユーザが複数の写真を選択したとしよう。ローカルだけに存在する写真と、既にDropboxにアップされている写真が混在するとする。写真を選択している途中で、Dropboxへのアップロードが完了されたとしても、選択のステータスはそのまま保持される。なぜなら、写真の選択はLUIDベースだから。

ユーザがそれらの写真をシェアする相手を決めたら、そのOperationを作成することができる。

DbxShareOp op(photo_luids, recipients);
op.persist(); // Save the operation to SQLite

次に、会話ビューをレンダリングすると、相手を特定できるユニークな会話ステートがキャッシュサーバから取得される。それから、今までのパターンと同じとおり、このペンディングになっているシェアのOperationをクライアント側でも実行する。(このプロセスの詳細も、実はブログポストになるくらい深い。)

ローカルだけにしか存在しないLUIDがまだ残っていれば(つまり、server_photo_luidにまだエントリーされてなければ)、シェアのアクションはまだサーバに送れないことがわかる。シェアのOperationのキューはスリープモードになり、ローカルだけにしかない写真がDropboxにアップされるまで待つ。また、photo_upload_operationsを「シェアをブロックしている。」とマークするこことで、アップロードのキューの中でそのOperationが優先されるようになる。

Dropboxに残りの写真が全てアップされると、シェアのOperationがサーバ側で実行可能となる。server_photo_luidsのルックアップテーブルからServer IDを確認し、サーバにリクエストを送る。

注目してほしいのは、これらの一連の動作が非同期で実行されていること。よって、ユーザは邪魔されることなく作業を続けられる。スピナー画面を見て待ちぼうけになることもない。


Jan-Mar/2014: ワザノバTop25アクセスランキング


#dropbox

Back