Facebookの開発したあるアプリ Moments の構想から開発、クロスプラットフォーム化まで詳細に書いてあっておもしろい。
勝手な解釈の多い意訳。
原文
https://code.facebook.com/posts/498597036962415/under-the-hood-building-moments/
Momentsの中の人 だけど?
- Ashwin Bharambe
- Zack Gomez
- Will Ruben
最近はみんな写真撮ってスマホの中にいれてるよな。でも友達の撮った自分の写真を全部もらったことある?ないでしょ、めんどくさいもん。
だからみんなのそれぞれのスマホで順に記念撮影したりするでしょ?写ってるのは同じ人なのにね(笑)
そもそも友達から自分の写っている写真をもらったとしてもさ、イベント毎に写真を整理するのもめんどうだよね〜。
そこでMomentsをつくろうって思ったの。
友達同士で写真交換しやすくするアプリをつくるぞ!ってな。
交換方法どうしよう
どうやって写真をやりとりするのがいいか考えたんだけど、これだというものがなかったから、Bluetoothとか位置情報とか顔認識とか色々試したよ。
近くにいる人とはBluetoothか位置情報を使って交換できるんだけどさ、問題があるよね。
Bluetoothは交換する端末同士でONにしないといけないし、AndroidとiPhoneの間だとあんまりうまく動かない。
位置情報は混雑した場所だとうまくいかないでしょ。
で、試作バージョンで、顔認識を使ったサジェスト機能を作ってみたんだ。そしたらすっごくいい感じ☆
「撮った写真のなかに友達が写っていたら、友達はその写真が欲しいはずだし、そのイベントの他の写真も欲しいはず〜。」
といった感じで交換をサジェストするの。
元々Facebookには顔認識の技術あるし、まじツイてたね。最近Facebookの人工知能研究チームががんばっているんだよ。
MomentsではFacebookのタグサジェストで使っている最新の顔認識技術を使っているよ。これは世界でもまじすごいやつな。
Facebookのタグはちゃんと設定からOFFにできる。プライバシー保護の観点でね。
Momentsも自分で選択しない限りは勝手に写真がアップロードされないようにしたよ。友達とシェアしたい写真だけがアップロードされればいいからさ。
高速な改善
顔認識っていう正解の「方法」をみつけたけど、まだまだ正解の「UI」はわからなかったんだよな。
だからUIの改善を高速に繰り返す必要があったわけ。そこで開発のフローを短縮し、毎週実際のユーザと一緒に検証してみた。詳細は以下のページで。
んで、そのために開発プロセスの最適化を行ったんだ。
ビジネスロジックはサーバとクライアント側に分かれてるでしょ?でもそうすると製品を変更するとき、両方を変える必要があるよね。サーバとクライアントの両方ともが同時に高速に変更されるとマジ大変なわけ。片方だけが高速に変更されるほうがずっとマシだよね?
そこでサーバのロジック部分を全部クライアントにもってきちゃう。そうすればクライアントの作業だけに集中できるし、開発、実行、検証というサイクルが最速で回せるでしょ?すごいよね。
俺たちはアプリは「サクサク」でなきゃ許せないんだよね。つまり、「送信中でーす」のぐるぐるをできるだけ無くしたい。
え?どうやるかって?ユーザの更新がサーバに保存される前に、もうユーザに表示しちゃえばいいのさ!「たぶんちゃんと送れるだろう」と考えているという意味で、これを「楽観的UI」と呼んでるぜ。
プロダクト作り始めたあとから、楽観的UIにしようと思っても工数かかるしバグだらけになるのがオチだ。
で、俺らははじめからこの設計思想に基づいたってわけ。
これらの2つの要件、
- クライアント主導の開発
- 楽観的UIにする
のなかで、ある大きな決定がされたんだ。
- クライアントでデータモデルを定義
- サーバーでは型を持たないデータとして保存
- クライアントでは型を持ったオブジェクトとして保存(まだサーバに保存されていないような楽観的UIのためのオブジェクト=「楽観的オブジェクト」も含む)
この決定がどんな効果をもたらすのか感じてもらうために、例を挙げよっか。
あるユーザ「アリス」が撮った1枚のバナナの写真。これを別のユーザ「ゴリラ」に見てもらえるように、同期を開始したとしよう。このときアリス側では2つのモデルオブジェクトがつくられる。「写真」と「通知」だ。例えば「通知」はこんな感じになるかな。
"notification" : {
"type": "string",
"senderUUID": "string",
"recipientUUID": "string",
"momentUUID": "string",
"photoUUIDs": "string", // JSON encoded array of photoUUIDs
...
},
アリスは新しく同期した写真を自分のフィード画面でみることができるし、その写真が表示されるべき場所にはどこにでも(これから開く画面も含めて)ちゃんと表示される。これはキャッシュから表示までのデータの取り扱いを Flux みたいにしているおかげね。
訳者注)Fluxの要約(日本語)
http://www.infoq.com/jp/news/2014/05/facebook-mvc-flux
「通知」と「写真」のオブジェクトは型付けされて、楽観的オブジェクトとしてキャッシュに保持される。他の「ユーザー」オブジェクトとか「Moment」オブジェクトに紐付けられて、いい感じのViewModelに変形されて実際の表示に用いられる。
訳者注)ViewModelはAndroidでいうところのListAdapterのItemのようなものか
もちろん楽観的オブジェクトとサーバにあるオブジェクトで、そこから生成されるViewModelは同じになるよ。以下みたいな流れ。
- クライアントは楽観的オブジェクトをキャッシュに入れて表示。
- クライアントは楽観的オブジェクトを「型」と「内容」という汎用的なBLOBに変換し(変換はモデル定義から自動生成されたコードによって行われる)、サーバーに送信。
- サーバーはBLOBを保存し、実際に保存されたBLOBを返す。
- クライアントはそのBLOBを型のあるオブジェクト(=実際にサーバに保存されたオブジェクト)に変換する。
- クライアントはキャッシュ内の楽観的オブジェクトを実際に保存されたオブジェクトで上書きする。
これによってサーバー/クライアント間でデータモデルの型を安全に持ち回しつつ、高速に開発と検証を繰り返すことができる。
さて今度はゴリラがアプリを開く。クライアントは新しいオブジェクトの取得を開始する。毎日、様々な人によって、たくさんの新しいオブジェクトがつくられる。ゴリラはこれらのうち、ごく小さな部分にしか興味はない(おそらくジャングルのことやバナナのことだろう)。
この興味の部分を「キュー」をフォローするという形で表現する。あるキューのメンバーであるユーザはそのキューに属するオブジェクトはすべて見ることができる。オブジェクトは生成されるときに1つのキューに属し、所属先のキューを変更することはできない。例えば、Momentsアプリの中のそれぞれのMomentは各1つのキューと対応している。Momentに参加しているユーザはキューのフォロワーである。このMomentに同期される写真全てはこのキューに置かれる。クライアントはGraphQLクエリで書かれた差分同期方式を使い、最後のクエリ発行以降に生成もしくは更新されたオブジェクトのみをダウンロードする。この場合、ゴリラは新しい「通知」とアリスによって作られたバナナの「写真」オブジェクトのみをダウンロードすることになる。このシステムではクライアントにUI上のもたつきをみせることなく、必要十分なデータのみを裏側でダウンロードさせることができる。
これで「サーバを変えたらクライアントも変える」というアプリあるあるな話を無くすことができた。開発フローが短縮化され、毎週ユーザテストができるようになった。あとついでに、通信できないような状況でもアプリがちゃんと動くようになった。
クロスプラットフォーム
当初から、iOSとAndroidを一緒にリリースすることを目標としていた。とはいえiOSから開発を始めたので、あとからAndroidをがんばった感じね。
開発速度を上げるために、両プラットフォームに共通している部分は共有コードとして書きたい!普通はサーバ側で共通コードを書くんだけど、クライアント側でそれをやりたいんだ。
それにはいろんな方法があるんだけど。俺たちは高速に試行できて、パフォーマンスもよくって、Nativeアプリとして使い心地がいいやつがよかった。えへ。
いろいろ試したところ、UIはプラットフォームのコードで書いて、ビジネスロジックは共有して使えるようにC++で書くことにした。イニシエよりC++は高パフォーマンスだがメモリ管理がめんどくさくて高度な抽象化ができない言語として知られている。でもでもC++11を使えばリファレンスカウンタとしてstd::shared_ptr
が使えるし、ラムダ式、auto
とかがあるから、高パフォーマンスでメモリリークしにくいコードが素早く書けるようになっているんだぜ?
C++で書かれたAPIでやることの切り分けをシンプルにするため、Flux モデルのような一方向のデータの流れを取り入れた。
というわけでAPIは
fire-and-forget
方式の変更を行う関数
- 例
genSyncPhotosToMoment
- 例
- あるViewに必要なViewModelを算出する関数
- 例
genAllPhotosSyncedFromUser
- 例
からなる。
訳者注)
fire-and-forget
というのは実行命令を出して結果を待たずに値を返すようなことを指す。
コードの可読性を維持するため、生データをimmutableなViewModelに変換するまでの処理は関数型っぽく書いてある。処理速度のボトルネックを分析し、変更されていない中間結果を再計算するのを避けるようにキャッシュをつくった。それによって、パフォーマンスも犠牲にすることなく、保守性の高い関数型コードを用いることができた。
訳者注)たぶん、
List<PhotoViewModel> models = rawPhotoListData .filter(new UserFilter('my_uid')) .toViewModels();
みたいに書いてるってことかな?
「中間結果をキャッシュする」というのは生データをViewModelに変換する間のどこかの状態でどこかにメモリキャッシュしてあるということでしょうかね。
C++の共有ライブラリを使うと、C++とプラットフォームの言語とをつなぐコードを生成する必要がある。AndroidではDjinni を使った。オープンソースで、開発はDropbox。こいつでC++で書いたViewModelをJavaに変換する。
俺たちはこのコードジェネレータをちょっといじった。まず、生成されるJavaのモデルがImmutableでParcelableになるようにした。あと、たくさんオブジェクトを作っちゃうとJavaのガベージコレクションが荒ぶるから、JavaのオブジェクトのWeak Referenceをキャッシュしておいて、変更された時のみ生成するようにした。
iOSのViewModelではObjective-CのいいとこでC++を薄いラッパーを書くだけで使えちゃった。
これらのデザインによって俺たちはC++とJavaをつなぐ定型文的なコードの部分を意識することなく、ビジネスロジックやUIそれ自体に集中することができた。
今までのところ、iOSとAndroidの共有しているビジネスロジックのコードの割合は日々増えてきてる。それぞれのプラットフォームでざっと1/3の行数がC++の共有コードだった。この結果として俺たちは新しい機能を追加するときも、少ない工数&少ないバグでできちゃうし、Nativeアプリとしての使い心地の良さと高パフォーマンスも維持できている。
そして2つのプラットフォームに割く時間を柔軟に変更することが出来たため、両OS同時にリリースすることが出来たんだ!
ひゃ〜、今日アメリカでMomentsがiOSとAndroid同時リリースできてホントわくわくしてるよぉ。みんなこのアプリが役に立つとわかってくれるといいな〜フィードバックくれよな〜。これからも高速に開発して、どんどんよくしてくぜ。
あとFacebookの今後の人工知能研究にもわくわくしてる……きっと斬新ですごいことができるようになる……よ。僕らの人工知能研究がどんな感じかは、以下のビデオをみてね。