これは Pierre Cangemi によるゲスト投稿です。Pierre は React Native と TypeScript のスペシャリストで、モバイルアプリ開発に10年以上の経験があり、Tendbble の共同創業者兼CTOです。
先週、あるユーザーに「大チームが作ったように感じる」と言われました。私たちはフロントエンド開発者2人で、1つのコードベースを使い、両プラットフォームに配信しています。数ヶ月前、私の以前の Expo 経験があっても、これをやり遂げられるか自信がありませんでした。
Tendbble はメディア重視のソーシャル動画共有アプリで、数十億ドル規模の既存サービスと正面から競合しています。パフォーマンスは“あると良い”レベルではなく、プロダクトそのものです。ユーザーはスクロール、キャプチャ、トランジションのたびに Instagram、Snapchat、TikTok と比較します。私たちはアプリを限界まで押し上げる必要がありました:リアルタイムのビデオフィード、ジェスチャー駆動のオーバーレイを備えたカスタムカメラ、両プラットフォームで滑らかなアニメーション──すべて1つのコードベースで、フロントエンド2人で実現しました。
アプリ内では、ユーザーが共同で期間限定の投稿に一緒に瞬間を記録します。フィードは無限スクロールの動画列です。カメラが主要な作成面で、リアルタイムコメント、リアクション、位置共有、マップビューが全体をつなぎます。すべての画面はアニメーションが多用され、すべてのインタラクションは即時性が求められます。以下は私たちが構築して学んだことです。
課題:両プラットフォームでの動画重視フィード
Tendbble の核は動画投稿のフィードをスクロールする体験です。各カードは HLS adaptive streaming の動画にユーザー情報、リアクション、コメントをオーバーレイできます。典型的なセッションではユーザーは50〜100件の投稿をスクロールします。
単純に可視カードごとにプレーヤーをマウントしてシステムに任せる方法は、iOS ではしばらくうまく動きますが、ミドルレンジの Android では大失敗になります。複数のビデオプレーヤーがデコード資源を奪い合い、フレーム落ち、異なる動画の音声重複、メモリ増大によるOSによるプロセス殺しが発生します。早い段階で気付いたのは、動画サブシステムは勝手にうまく管理されないということです。動画フィードを作るなら、何をいつ再生し、いつ初期化し、いつ破棄するかをあなたが決める必要があります。
重要なアイデア:いつでも一度に1つの動画
私たちのブレイクスルーは概念的にシンプルでした:アプリ全体で“同時に再生できる動画は1つだけ”というグローバルルールを強制すること。画面単位ではなくアプリ全体でです。投稿がビューポートに入ると、その投稿が再生権を主張し、前に再生していたものは自動的に解放されます。
この単一制約により、音声の重複は完全になくなり、ピークメモリ使用量は半分になり、Android 上のフィードは劇的に滑らかになりました。さらに私たちは“settlement delay”を導入しました:投稿が400ミリ秒以上表示されるまでビデオプレーヤーを作成しません。高速スクロール中に投稿はあまりにも速く流れるため、プレーヤー初期化のコストを正当化できません。代わりにサムネイルに blurhash プレースホルダを表示し、ユーザーは気付きません。
プリロードは厳密に管理し、スクロール方向に1つ先まで、背後は0にしています。expo-video の組み込み HLS サポートと組み合わせることで、メモリを圧迫せずに即時再生感を出せます。結果としてアクティブなビデオ購読数は20〜30件から3〜4件に減りました。
カメラ体験の構築
カメラは Tendbble でユーザーがコンテンツを作る場です。写真と動画を撮り、ジェスチャーで位置や回転を調整するテキストオーバーレイを適用し、共同投稿に共有します。ネイティブカメラアプリと同等の速さと応答性が必要でした。
私たちは react-native-vision-camera を expo-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つの簡単なフックを追加しました:ビューが親から外れたときにセッションを停止するフックと、デイニシャライゼーション時にセッションを停止するセーフティネットです。
この修正からの教訓は幅広いものでした:ネイティブのリソースは React コンポーネントのライフサイクルに従わない場合があるということです。React Navigation と組み合わせてネイティブなカメラ、ビデオ、オーディオライブラリを使う場合は、画面がフォーカスを失ったときに何が実際に起きるかを監査してください。答えは驚くことが多いです。
Reanimated と Skia で 60fps を得る
アプリ全体で200以上のファイルで Reanimated v4 を使用しています。フィードのモード切替、リアルタイムのカウントダウンタイマー、モザイクタイルエディタまで、あらゆるものを支えています。カスタムレンダリングには React Native Skia を組み合わせ、数百人規模のエンジニアが作るアプリに匹敵する磨き上げを可能にしました。
最も重要な教訓は「UI スレッドは神聖である」ということです。アニメーションが JS スレッドに依存すると(一瞬であっても)、JS スレッドがデータ取得やナビゲーション、ビジネスロジック実行で忙しいタイミングにフレーム落ちを招くリスクがあります。
私たちのリアルタイムカウントダウンタイマーは良い例です。ライブクロック、プログレスリング、カウントダウンとショット数の切替を毎フレーム更新しますが、React の再レンダーは一切発生させません。システム全体が Reanimated の worklet とフレームコールバックで UI スレッド上で動き、テキスト更新は TextInput の animated props 経由で行い、React を完全にバイパスします。その結果、JS スレッドが重くてもアニメーションは完璧に滑らかです。
ジェスチャー合成:tap、long-press、drag の共存
これらのジェスチャーを干渉せず共存させるには注意深い合成が必要でした。tap ジェスチャーを、同時に動く long-press + pan の組み合わせとレースさせ、pan は long-press の発火後にのみ有効化するようにしています(manual activation)。ハプティックフィードバックはレート制限して、急速なジェスチャー更新中にオーディオスレッドが過負荷にならないようにしました。
この「手動有効化ゲートでシンプルなジェスチャーと複合ジェスチャーをレースさせる」アプローチは再利用性が高く、スワイプ可能なモードセレクタやドラッグで閉じるモーダルにも適用しています。
Skia を使うべき場面
Skia は Views の代替として乱用するのではなく、CSS や通常の View では不可能、あるいはジャギーになってしまうレンダリングに選択的に使います。
- 処理中カードの回転するグラデーションボーダーは、Reanimated の shared value を Skia の sweep gradient に渡して 60fps 回転(JS 不要)
- スクワイア(squircle)形状は CSS の border-radius では再現できないので、ベジェで手続き的にパス生成
- キャプションはデュアルパスレンダリング(下地にストローク、上にフィル)で、どんな動画背景でも読みやすくする
要点は、Skia は描画、Reanimated は値の駆動に特化させること。タイミングや補間、ジェスチャー反応は Reanimated、視覚出力は Skia に任せ、互いの役割を侵食しないことです。
React Compiler:無料のパフォーマンスブースト
React 19 と React Compiler を採用したことで、ほぼ手間なく測定可能なパフォーマンス向上が得られました。コンパイラが自動的にメモ化を扱ってくれるため、useMemo、useCallback、React.memo をあちこちに置く必要がなくなりました。手書きのメモ化をほとんど取り除き、コンパイラに任せたところ、ファストスクロールや画面遷移時に顕著な改善が出ました。
ただしトレードオフは規律です:コンポーネントやフックは React の純粋性ルールを厳守する必要があります。レンダー中の副作用禁止、props/state のミューテーション禁止。私たちは既にこれらを守っていたので移行はスムーズでしたが、ルールが緩いチームはクリーンアップ作業が必要になるでしょう。
プラットフォーム別のパフォーマンス予算
初期は両プラットフォームで同じアニメーション予算を目標にして失敗しました。iOS の ProMotion ディスプレイは 120fps を描画できますが、多くの Android デバイスは複雑なアニメーションで 60fps を維持するのも厳しいです。
現在はプラットフォームごとに別の設定を維持しています:
- iOS:ダンピングと剛性を調整した spring physics、積極的なプリロード、120fps 目標のアニメーションタイミング
- 低スペック Android:スプリングを無効化、ジェスチャーのスロットル間隔を増やす、イージングを単純化、リストの window サイズを縮小
アプリは両方でレスポンシブに感じられますが、Android でフレームを落としながらハードウェアに合わない目標を追うことはしません。プラットフォームごとに異なるアニメーション品質を提供することは妥協ではなく良いエンジニアリングです。
見えない仕事:オフライン優先とメモリ圧力
Signed URLs とキャッシュの永続化
私たちはクエリキャッシュを 24 時間の max age で永続化しています。ユーザーがオフラインでアプリを開いてもフィードを見られるようにするためです。しかし、メディア URL は AWS の signed URL で TTL は 1 時間です。夜間にバックグラウンドで放置すると、1日古いキャッシュに期限切れの URL が入っていることがあります。フィードはレンダリングされますが、画像と動画は壊れて灰色のプレースホルダになります。
対処法はリハイドレーション時にクエリの鮮度をチェックすることです。キャッシュのどれかが45分より古ければ、すべてを無効化して再フェッチを強制します。古いレイアウトは一瞬スケルトンで表示されますが、壊れたメディアよりずっと良いです。このバグは長時間バックグラウンドしたときにしか現れず、開発中に再現しにくかったので発見が遅れました。
メモリ圧力の管理
メディア重視のアプリではメモリ管理は必須です。ネイティブのメモリ警告に応じる優先度ベースのクリーンアップシステムを作りました。順序は次の通りです:
- ビデオバッファのクリア
- 画像デコードキャッシュのクリア
- クエリキャッシュの段階的クリア
UI ブロッキングを防ぐため遅延を挟んで段階的に行います。アプリがバックグラウンドになったときは積極的にクリーンアップをトリガーします。長時間のスクロール中は、expo-image のデコード済みビットマップキャッシュを定期的にフラッシュして蓄積を防ぎます。
次の取り組み
私たちは次の分野を検討しています:
- フィードカードと詳細ビュー間の Shared Element Transitions(インフラは Reanimated で有効化済みだがまだ本格活用していない)
- スクロール速度予測を使ったスマートな動画プリロード(ゆっくり閲覧時は積極的に、素早いフリック時は抑制)
- アプリキル後も確実に再開するバックグラウンドアップロードキュー(
expo-task-manager をベースに)
- Signed URL 依存を減らしコールドスタートを改善するエッジキャッシュされたメディア
最後に
この1年で得た最大の教訓は「パフォーマンスは設計上の最初の市民であるべき」ということです。小さなチームでも、ネイティブリソースの扱いを慎重に設計し、Reanimated と Skia のようなツールを正しく使い分け、React Compiler の恩恵を受け、プラットフォームごとの現実的な目標に合わせれば、非常に高品質でパフォーマンスに優れた体験を提供できます。
ネイティブと JS の境界で何が起きているかを常に監査し、メモリとリソースのライフサイクルに責任を持つこと。これが私たちが学んだ最も重要なことです。