パフォーマンス優先の動画フィードを実現:TendbbleがExpoでリアルタイムソーシャルアプリを構築した方法

2人のフロントエンド開発者が同じReact Nativeコードベースで動画SNSアプリTendbbleを作り、単一プレイヤー戦略でミッドレンジAndroid上で50〜100件の投稿のスワイプフィードを支えた。

日本語
コピー
Shipping a performance-first video feed: How Tendbble built a real-time social app with Expo

この記事は Pierre Cangemi によるゲスト投稿です。React Native と TypeScript の専門家で、モバイルアプリ開発歴は10年以上、Tendbble の共同創業者兼 CTO を務めています。

先週、あるユーザーから「大手チームが作ったアプリみたいだ」と言われた。開発しているフロントエンドは2人だけ。コードベースは1つ。両方のプラットフォームに同時にリリースしている。

数か月前の時点では、以前の Expo の経験があっても、自分たちにできるかどうか確信はなかった。Tendbble はメディアコンテンツの比重が大きいソーシャル動画共有アプリで、相手は評価額数百億ドルの巨人たちだ。こういうプロダクトでは、パフォーマンスは加点要素ではなく、パフォーマンスそのものがプロダクトだ。

ユーザーはスワイプするたび、撮影するたび、トランジションを目にするたびに、私たちを Instagram、Snapchat、TikTok と比べる。だからアプリを限界まで押し上げる必要があった。リアルタイムの動画ストリーミング、ジェスチャーで動くオーバーレイを備えたカスタムカメラ、両プラットフォームで滑らかなアニメーション。すべて同じコードベースから、フロントエンド開発者2人で。

アプリの中で、ユーザーは協働の期限付き投稿を通じて瞬間を一緒に記録する。フィードは動画の無限スクロール。カメラが主要な作成導線だ。リアルタイムのコメント、インタラクション、位置情報の共有、マップビューがそれらをつなぐ。どの画面もアニメーションを多用していて、すべての操作が即座に感じられなければならない。

開発の過程で学んだことを書いていく。

課題:両プラットフォームでの動画中心のフィード

Tendbble の中核体験は、動画投稿のフィードをスワイプして見ることだ。各カードには HLS アダプティブストリーミングを使う動画が載り、その上にユーザー情報、インタラクション、コメントが重なる。典型的な利用では、ユーザーは50〜100件の投稿をスワイプする。

最も素朴な方法は、表示中のカードごとに動画プレイヤーを1つ付けて、あとはシステムに任せるやり方だ。iOS ではしばらくもつ。ミッドレンジの Android 端末では破綻する。複数の動画プレイヤーがデコード資源を取り合い、フレーム落ちが起き、別々の動画の音声が重なり、メモリが上がり続けて最終的に OS にアプリを殺される。

動画サブシステムは放っておいても面倒を見てくれない、と早い段階で悟った。動画フィードを作るなら、何を再生し、いつ初期化し、いつ破棄するかを自分で決めるしかない。

中心となる考え方:同時に再生できる動画は常に1つだけ

突破口は概念としては単純だった。アプリ全体でグローバルなルールを1つ強制し、同時に再生できる動画を常に1つに限る。画面ごとに1つではなく、アプリ全体で1つだ。ある投稿がビューポートに入ると再生権を取得し、それまで再生していたものは自動的に解放される。

この制約ひとつで音声の重なりは完全になくなり、ピークメモリ使用量は半分になり、Android でのフィードはずっと滑らかになった。

だがそこで止まらなかった。遅延発動の仕組みも入れた。投稿が400ミリ秒見えて初めて、動画プレイヤーを作る。高速スクロールでは投稿は一瞬で通り過ぎるので、プレイヤーの初期化コストを払う価値はない。その間は blurhash のプレースホルダー付きサムネイルで埋める。ユーザーにはまったく気づかれない。

プリロードは厳しく制御している。スクロール方向に沿って次の投稿を1つだけ先読みし、前方はゼロ。expo-video の HLS サポートと組み合わせることで、再生は即時に感じられ、メモリも圧迫しない。アクティブな動画購読は20〜30個から3〜4個に減った。

カメラ体験を作り込む

カメラは Tendbble のユーザーがコンテンツを作る場所だ。写真や動画を撮影し、ジェスチャーで位置と回転を調整できるテキストオーバーレイを加え、協働投稿に共有する。ネイティブのカメラアプリと同じくらい速く、同じくらい追従しなければならない。ユーザーはまさにその基準で私たちを測っているからだ。

expo-camera ではなく react-native-vision-camera を選んだ理由は具体的に3つある。きめ細かなコーデック選択(iOS では H.265、Android では H.264)、複数の物理レンズにアクセスしてジェスチャー駆動のズームシステムと組み合わせられること、そして撮影中のメモリ使用量を大幅に減らすバッファ圧縮オプションだ。

カメラ画面には撮影ボタンがあり、押した時間で写真と動画を切り替える。タップで写真、長押しで動画を撮影し、Skia で描画したプログレスリングが残り録画時間を示す。ダブルタップでインカメラとアウトカメラを切り替える。ピンチでズームし、0.5x、1x、2x でスナップする。

テキストオーバーレイについては、字幕をネイティブ層で写真や動画に合成するカスタム Expo ネイティブモジュールを書いた。字幕エディタでユーザーがテキストをドラッグ、回転、拡大縮小し、ネイティブモジュールがそのオーバーレイを最終メディアファイルにフル解像度で焼き込む。多くのアプリが妥協して使うスクリーンショット方式の画質劣化を避けられる。

原因究明に数週間かかったメモリリーク

リリース後、おかしなことに気づいた。カメラを開いて閉じるを繰り返したユーザーは、アプリがどんどん重くなり、最後にはクラッシュする。メモリは上がり続け、二度と下がらない。

Instruments でプロファイリングして犯人を突き止めた。ビューが階層から外れるとき、react-native-vision-camera v4.7.3 が AVCaptureSession を停止しないのだ。React Navigation は戻るナビゲーションの性能のために画面をメモリに残し、すぐには解放しない。その結果、カメラはバックグラウンドで見えないまま動き続け、ユーザーがとっくに離れた後もメモリと GPU のリソースを食い続ける。

このライブラリには、ビューが階層から外れたときのクリーンアップ処理も、初期化解除時にキャプチャセッションを停止する処理もなかった。patch-package で修正し、シンプルなフックを2つ足した。ビューが親から外れたときにセッションを停止するものと、初期化解除時にセッションを停止するものだ。後者は保険として入れている。

Vision Camera 導入前

ビジョンカメラ前

ビジュアルカメラのパッチ適用後

ビジュアルカメラの後

この教訓は、修正そのものよりも覚えておく価値がある。ネイティブのリソースは、React のコンポーネントライフサイクルが期待どおりに動く場所ではない。React Navigation を使っていて、ネイティブのカメラ・動画・音声ライブラリを組み込んでいるなら、画面がフォーカスを失ったときに実際に何が起きているかを調べてみるといい。意外な結果が出るかもしれない。

Reanimated と Skia で 60fps を出す

アプリ全体の 200 以上のファイルで Reanimated v4 を使っている。フィードのモードスイッチャーからリアルタイムのカウントダウン、モザイクコラージュのエディタまで、裏側はすべてこれだ。カスタムレンダリングに React Native Skia を組み合わせることで、数百人のエンジニアが支えるアプリならではの操作感を実現するための道具が揃った。

最も重要な教訓はこれだ。UI スレッドは神聖である。アニメーションが JS スレッドに依存した瞬間——たとえ一瞬でも——JS スレッドがデータ取得やナビゲーションのトランジション、ビジネスロジックの実行で忙しければ、フレーム落ちのリスクがある。

リアルタイムのカウントダウンはいい例だ。リアルタイムの時計、プログレスリング、カウントダウンと撮影枚数の切り替えを表示し、毎フレーム更新される。これらはどれも React の再レンダリングを引き起こさない。システム全体は UI スレッド上で、Reanimated の worklet とフレームコールバックを使って動いている。テキストの更新は TextInput の animated props 経由で行い、React を完全に迂回している。その結果、JS スレッドの負荷が高くてもアニメーションは滑らかなままだ。

ジェスチャーの組み合わせ:タップ、長押し、ドラッグを共存させる

これらのジェスチャーを衝突させずに共存させるには、慎重な組み合わせが要る。タップジェスチャーを「長押し + パン」の複合ジェスチャーと競合させ、手動アクティベーションを採用した——長押しが発火して初めて、パンが有効になる。触覚フィードバックは頻度を制限し、素早いジェスチャー更新がオーディオスレッドを圧迫しないようにした。

シンプルなジェスチャーと、手動アクティベーションでゲートした複合ジェスチャーを競合させるこのやり方は、後にアプリ全体で再利用できるようになった。スワイプ可能なモードセレクターも、ドラッグで閉じるモーダルも、同じ仕組みを使っている。

Skia が CSS にできないこと

Skia は選択的に使っている。Views の代わりではなく、ほかの方法では実現できない、あるいはカクつくレンダリングを担わせるために。カード上の回転するグラデーションのボーダーは、Reanimated の shared value が Skia の sweep gradient を駆動している(60fps で回転し、JS を一切経由しない)。squircle 形状はプログラムで生成した Bézier パスを使い、CSS の border radius では再現できない滑らかな角丸を出している。字幕エディタのテキストオーバーレイは 2 パスでレンダリングし、ストローク層を塗りつぶし層の下に敷くことで、どんな動画の背景でも字幕がはっきり読める。

要はこうだ。レンダリングは Skia に、値の駆動は Reanimated に。両者は本来補完し合う。Reanimated がタイミング、補間、ジェスチャーへの応答を担い、Skia が視覚的な出力を担う。どちらも相手の仕事を奪わない。

React Compiler:ただで手に入る性能向上

React 19 にアップグレードし React Compiler を導入すると、ほとんど手間をかけずに無視できない性能向上が得られた。コンパイラがメモ化を自動で処理するので、コードベースに散らばっていた手書きの useMemo、useCallback、React.memo はすべて不要になった。手書きのメモ化はほとんど削除し、コンパイラに任せている。

効果が最もはっきり出るのは高速スクロールと画面遷移で、以前はこうした場面で不要な re-render が積み重なってフレーム落ちになっていた。コンパイラは、私たちがずっと手作業で追いかけていた性能バグをまるごと一類、消してくれた。メディアが密集し、JS スレッド上の 1 ミリ秒が重要なアプリにとって、この余裕は大きい。

代償は規律だ。コンポーネントと hook は React の純関数のルールを厳密に守らなければならない。レンダリング中の副作用は禁止、props や state の変更も禁止。もともとこれらのパターンに従っていたので移行は順調だったが、規約の緩いチームはコードを整理する覚悟が要る。

プラットフォーム別の性能予算

初期に犯した間違いは、両プラットフォームに同じアニメーション予算を割り当てたことだ。ProMotion ディスプレイを積んだ iOS デバイスは 120fps で動くが、多くの Android デバイスは複雑なアニメーションで 60fps すら安定しない。

今は 2 つの独立した性能設定を維持している。iOS ではバネ物理のアニメーションを使い、減衰と剛性を調整し、プリロードを積極的に行い、アニメーションのタイミングは 120fps を目標に決める。ローエンドの Android ではバネアニメーションを完全に無効化し、ジェスチャーのスロットル間隔を広げ、より単純なイージング曲線に切り替え、リストのウィンドウを狭める。

どちらのプラットフォームでもアプリは指に追従して感じられるが、Android でハードウェアが到底支えられない予算に合わせてフレームを落とすことはしない。これは発想の転換だ。プラットフォームごとに異なるアニメーション品質を届けるのは妥協ではなく、エンジニアリングとして正しいやり方である。

見えない仕事:オフラインファーストとメモリプレッシャー

署名付き URL とキャッシュの永続化

クエリキャッシュを永続化し、最長 24 時間保持する。これでユーザーがオフラインでアプリを開いてもフィードが見られる。だがメディアの URL は AWS の署名付き URL で、TTL はわずか 1 時間だ。

アプリが一晩中バックグラウンドにあった後、再水和されるのは前日分のキャッシュデータで、中の URL はとうに期限切れになっている。フィードはレンダリングされるが、画像も動画もすべて壊れ、灰色のプレースホルダーで埋め尽くされる。

我々の修正は、再ハイドレーション時にクエリの鮮度をチェックするというものだ。キャッシュデータが45分を超えていれば、すべて無効化して強制的に再取得する。古いレイアウトはスケルトンスクリーンとして一瞬表示され、その間に新しいデータが読み込まれる。壊れたメディアが画面中に並ぶよりはるかにマシだ。仕組みは単純だが、このバグは長時間バックグラウンドに置いた後でしか出ないため、開発中に捕まえるのは難しかった。

メモリプレッシャーの管理

メディアを大量に扱うアプリにとって、メモリ管理は選択肢ではない。我々はネイティブのメモリ警告に反応する、優先度ベースのクリーンアップシステムを作った。まず動画バッファ、次に画像デコードキャッシュ、最後にクエリキャッシュを解放する。各ステップの間に遅延をずらし、UIをブロックしないようにしている。アプリがバックグラウンドに入ったときは、こちらから能動的にクリーンアップを起動する。長時間スクロールしているときは、expo-image のデコード済みビットマップキャッシュを定期的に掃き出し、溜まり込まないようにする。

次にやること

体験をさらに前に進めるため、いくつかの方向を模索している:

  • フィードカードと詳細ページ間の共有要素トランジション。Reanimated 側の基盤はすでに有効になっているが、まだ使い切れていない

  • より賢い動画プリロード:スクロール速度から予測し、ゆっくり見ているときは積極的に先読みし、速くスワイプしているときは抑える

  • expo-task-manager によるバックグラウンドアップロードキュー。アプリが kill されても確実に再開できるようにする

  • メディアアセットのエッジキャッシュ。署名付き URL への依存を減らし、コールドスタートの性能を改善する

最後にいくつか

この1年で一番大きかった収穫は、特定の API や最適化テクニックの話ではなく、本当の仕事がどこで起きるかという話だ。

Expo とそのエコシステム expo-videoexpo-imageReanimatedSkiaReact Compiler は、土台の部分をよくやってくれる。HLS 再生は動くし、画像キャッシュも動くし、アニメーションは UI スレッドで走り、memoization は自動だ。マネージドワークフローなら、日々の開発で Xcode や Android Studio に触れる必要はまったくない。

難しいのは、これらのツールの外側にあるすべてだ。いつ動画プレイヤーを生成し、いつ破棄するかを見極めること。React Navigation のライフサイクルとネイティブ view のライフサイクルは別物だと理解すること。iOS と Android ではアニメーションの予算が違うと受け入れること。一晩中バックグラウンドで動かした後にしか現れないキャッシュバグを捕まえること。

こうした判断こそが、アプリを大きなチームが作ったかのように感じさせる——たとえそれが、10億ドルの評価の競合に挑む2人の無謀な開発者であっても。Expo は我々にレバレッジを与え、プラットフォームの低レイヤーの配管と格闘するのではなく、こうした判断に集中できるようにしてくれた。小さなチームが野心的なことをやるには、これがすべてだ。

出典: Expo Blog← ホームへ戻る