Discord を React Native の新アーキテクチャへ移行:本当に難しいのはスイッチを切り替えた後のロングテール

Discord がアプリを React Native の新アーキテクチャへ移行し、Fabric、Reanimated、Screens などの課題を解決した方法をご紹介します。

日本語
コピー
What It Takes to Migrate Discord to RN's New Architecture 的题图

image

Discord を React Native 新アーキテクチャへ移行する——本当に払うことになるコスト

image

Kamil Delekta•2026年9月9日•14分で読める React Native

Discord を React Native 新アーキテクチャへ移行する——本当に払うことになるコスト

image

Kamil Delekta•2026年9月9日•14分で読める 本記事の内容 そして、最も難しいのはフレームワークの切り替えではなく、切り替えたあとに続く長い尾だという話

背景

Discord のモバイルアプリは、React Native ではなかなか到達できない規模にある。ネイティブ駆動のデータモデルに支えられたチャット画面、独自のレンダリング最適化、10年分積み上がったビジネスロジック、そして1フレームの引っかかりも見逃さないユーザー層。今回の移行が特殊なのは、プラットフォームごとに進めた点だ。まず Android、次に iOS。私たちが関わったのは後者で、本記事もそちらが主題になる。Discord のモバイルチームに約1年張り付いて、iOS を新アーキテクチャへ移した。Reanimated、Screens、Gesture Handler といった React Native の中核ライブラリのメンテナーという立場から、システムの深いところまで踏み込んで調査し、主要ライブラリ・Fabric・Discord のコードが交差する場所で繰り返し顔を出す厄介な問題をデバッグして直した。始める前にひとつ。これは新アーキテクチャの有効化方法を解説する記事ではない。公式の移行ドキュメントに丁寧に書かれているし、ほとんどのアプリとライブラリはとうに移行を終えている。この記事が扱うのは、スイッチを入れた あとに 起きること——ドキュメントが教えてくれない部分だ。そして Discord ほどの規模になると、その「あと」は本当に多い。何年も前から表に出ていたのに気づかれていなかったバグ、本番の実負荷でしか現れないレース、同時に走っている別の移行——どれもが新アーキテクチャ特有の癖を抱えている。

切り替えたあとに続く長い尾

この規模で既存アプリを移行するのは、また別種のプロジェクトだ。コンパイルが通って起動するようにするだけでも大変で、そこに独自ビルドシステムが乗るとさらに大変になる。私たちが加わる前から、Discord のチームはこれに数か月を費やしていた。私たちが引き継いだのはその先——「アプリが起動する」から「置き換える前のアプリと遜色ない」までの間のすべてで、これは新アーキテクチャへ移行するどのアプリにも当てはまる。この隔たりの中に何百もの小さな問題が横たわっていて、その多くは下層のアーキテクチャ変更に遡れる。コードが知らず知らず旧アーキテクチャの前提に依存していて、その前提がもう成り立たなくなっているのだ。同等の体験に戻すために私たちがクローズしたチケットを分類すると、この作業の実像はこうなる:

カテゴリ割合
レンダリング / レイアウト / 見た目47%
クラッシュと安定性16%
ビルド / インフラ / 移行14%
パフォーマンス13%
入力(キーボード / ジェスチャー)11%

もう一度この表を見てほしい。純粋な移行作業(ビルドシステム、コード生成、依存関係の更新)は3番目に大きい塊で、14%にすぎない。残りはすべて、新しいレンダラー下でアプリの挙動が変わり、ユーザーが気づけるようになった箇所だ。レンダリングとレイアウトだけで47%を占める。私たちはその大半を意図的に「バグ」とは呼んでいない。実際、多くはバグではない。新アーキテクチャにおいてもそうだし、Discord のコードにおいてもしばしばそうだ。それらは暗黙の契約が変わった場所なのだ。ビューが画面上のどこにあるか、それがネイティブツリーに存在するかどうか、そしてフレームワークがあなたのネイティブコードをどう見つけるか。

もう成り立たなくなった前提

ここからはそのうち3つの前提を順に見ていく。いずれも何年も前に書かれ、旧アーキテクチャでは問題なく動いていたもので、移行中も誰も手を触れていない。

座標は同じでも、原点が違う

最初の契約は測定だ。ビューにどこにあるか尋ねれば位置が返るが、それは何に対する位置なのか。旧アーキテクチャでは、ビューを測定するとウィンドウ内の位置が返ってきた。すべてがひとつの原点を共有していた。画面左上だ。Fabric では、ビューはその Yoga ルートに対して測定される——そして modal はそれ自体がルートだ。同じビューに対する同じ呼び出しが、そのビューがたまたま modal の中にあるかどうかで違う数値を返すようになった。ほとんどの場合、違いには気づかない。modal の中になければ、ツリーのルートはウィンドウの先頭であり、2つの答えは完全に一致する。コードは動き、数値も正しく見え、かつて2つの座標空間が存在したことを示す兆候は何もない。両者が分かれるのは modal の内部だけで、しかも modal の外にあるものを配置しているときにだけ、目に見えるバグになる。コンテキストメニューがまさにそのケースだ。Discord のコンテキストメニューは FullWindowOverlay の中に描画され、ウィンドウ全体を覆い、位置は長押しした行で測定した座標で決まる。通常の画面から開けば位置は正しい。modal の内部から開くと、その行は modal に対する位置を報告し、overlay はその数値をウィンドウ座標として読むので、メニューがずれる——何にも固定されないまま。小さな例でその形を示そう:

function useContextMenuAnchor() {
 const anchorRef = useAnimatedRef();
 const openMenu = () => {
   // Where is the row I long-pressed?
   const { pageX, pageY } = measure(anchorRef);
   // Position the menu, which lives in a FullWindowOverlay
   // spanning the whole window.
   showMenuAt({ x: pageX, y: pageY });
 };
 return { anchorRef, openMenu };
}

このコードの前提は、座標空間がひとつしか存在しないことだ。pageY は行に対しても overlay に対しても同じ意味を持つ。すべてを window 基準で測っている限り、この前提は成り立つ。修正は小さなネイティブモジュールで、modal の座標を window の座標に変換し、UI スレッド上の worklet から直接呼び出す。旧アーキテクチャ:

新アーキテクチャ:

この種のバグの見分け方:ほとんどの画面では正常で、一部の画面でだけ壊れる。しかも誤差は固定のオフセットで、何か別のものの高さのように見える。

決して完了しなかったジェスチャー

次の契約は同一性だ。いま触っているネイティブビューが、少し後でも同じネイティブビューであるかどうか。これは見つけるのが難しい。バグ報告にそれを指し示す手がかりが何もないからだ。症状はプロダクトのバグとして現れる。音声メッセージを長押しして録音し、指を離しても録音が止まらない。クラッシュもエラーもない——ジェスチャーがただ完了しないだけだ。ジェスチャーのコード自体には何の問題もなく、それを壊した変更はジェスチャーとは無関係に見える。View flattening は Fabric の最適化だ。ある View が自分の host node を必要としない場合——描画するものも、クリップするものも、ネイティブとして存在すべきその他の理由もない場合——Fabric はその作成を省略できる。ネイティブビューは減り、作業量も減る。意図された動作で、通常は表に出ない。見落とされがちなのは、flatten できるかどうかが mount 時に一度だけ決まるわけではないという点だ。それは props の関数であり、commit のたびに再評価される。そして実際のネイティブビューの作成を強制する props のリストは「背景色とボーダー」よりも長い。アクセシビリティ関連の props もそこに含まれる。そのうちのひとつを変えると、flatten されていたビューが本物の host view になり、あるいはその逆になる。React コンポーネントは同じままだ。その下にあるものは、もう同じではない。以下は Discord の事例だ。音声メッセージの録音を始めると、チャット入力欄が再レンダリングされ、支援技術に対して入力欄の残りの部分を隠す——録音ボタンを包むコンテナに accessibilityElementsHiddenimportantForAccessibility を設定する。アクセシビリティとしては正しい振る舞いだ。だがそれは同時に、そのコンテナのネイティブ層での表現を変え、ジェスチャーの途中でその土台となるビューを無効にしてしまう。Pan はキャンセルされ、onFinalize は決して実行されず、ボタンを離しても何も起きない。

// Simplified from the chat input wrapper around VoiceMessageButton.

無関係に見える三つのことが同時に起きる必要がある。アクセシビリティの更新、進行中のジェスチャー、そしてその間でホストツリーを変える Fabric の commit だ。単独で見れば、どれも筋が通っている。アクセシビリティの変更を書いた人に、ジェスチャーのターゲットを考慮する理由は何もない。修正は prop ひとつで済んだ:

collapsable={false} はコンテナを本物のネイティブビューのまま保つ。だからアクセシビリティ props が変わっても、ジェスチャーのターゲットは安定したままだ。旧アーキテクチャ:

新アーキテクチャ:

クラス名ひとつがアプリ全体を凍らせた

最後の契約は、誰も書き残さない種類のものだ。ネイティブコードがどうやって見つけられるか。今回の症状はフリーズだった。クラッシュでも小さな不具合でもない——アニメーション絵文字で埋まったチャンネルをスクロールしている最中に、アプリが固まった。ログには Discord のコードを指すものは何もなく、引き金になりそうな新しいリリースもなかった。関与していたコードは何年も前から存在していた。アニメーション絵文字はレガシーな view manager でレンダリングされていた。旧アーキテクチャ向けに書かれ、Fabric へは一度も移行されていないコンポーネントだ。それが New Architecture でも動いていたのは interop layer のおかげで、interop layer は与えられたコンポーネント名から正しい Objective-C クラスを見つけなければならない。まず高速パスを通る——コンポーネント名を取り、Manager を付け、そのクラスを探す。Discord のクラスは NativeLottieNode で、NativeLottieNodeManager ではない。高速パスは外れた。旧アーキテクチャでは、この名前で問題なかった。あの接尾辞に依存するものは何もなかった。Fabric では二つのことが同時に起き、デッドロックした。1. バックグラウンドスレッドで、Fabric がステッカービューの descriptor を構築している。まずレジストリに書き込みロックをかけ、次に(高速パスが外れたので)bridge にモジュール作成を要求する。これはメインスレッドで同期的に完了しなければならない。つまりロックを握ったまま、メインスレッドを待つ。2. メインスレッドでは、アニメーションがフレームの途中にあり、prop の更新を push している。これには同じレジストリへの読み取りロックが必要だ。書き込みロックは取られているので、待つ。両者が互いを待っている。アプリが止まる。三つのことが同時に起きる必要がある。移行されていないコンポーネント、高速検索を外すクラス名、そしてその瞬間に動いているアニメーションだ。アニメーション絵文字で埋まったチャンネルをスクロールすると、この三つが一度に揃う。

// NativeLottieNode.swift
@objc(NativeLottieNode)
class NativeLottieNode: RCTViewManager {
}

// NativeLottieNode.m
// One name for both the class and the component. Correct for years.
@interface RCT_EXTERN_MODULE (NativeLottieNode, RCTViewManager)

修正はこのクラスの名前を変え、検索を高速パスに乗せつつ、JavaScript が使う名前は変えないことだった:

// NativeLottieNodeManager.swift
@objc(NativeLottieNodeManager)
class NativeLottieNodeManager: RCTViewManager {
}

// NativeLottieNodeManager.m
// Component name for JavaScript stays the same; the class gets the suffix.
@interface RCT_EXTERN_REMAP_MODULE (NativeLottieNode, NativeLottieNodeManager, RCTViewManager)

3 行、2 ファイル。誰も守る必要のなかった命名規則が必須になり、守らなかった代償は警告でもエラーでもない——アプリのフリーズだ。しかもそれは、移行されていないコンポーネント、遅延作成されるモジュール、UI スレッド上のアニメーションが同時にぶつかったときにしか起きない。名前を変えてもデッドロックが直るわけではない。デッドロックが起きるコードに永遠に到達しないようにするだけだ。低速パスはまだ残っている——interop 層の既知の落とし穴で、他のアプリも同じ場所で踏んでいる。それを回避するのは出荷のための正しい判断だ。3 行、リスクなし、ユーザーはその日も使い続けられる。ただしはっきりさせておくべきことがある。interop 層が何のためにあるのかだ。これは、すべての古いコンポーネントを書き直さずにアプリを新アーキテクチャへ移行できるようにするために存在する——移行の途中で足を置く場所であって、長居する場所ではない。この道をまだ通っているコンポーネントはどれも、自分版の問題にぶつかる可能性がある。本当の修正は、より賢い回避策ではなく、コンポーネントを移行して interop 層を通さないようにすることだ。この種の判断のシグナル:フリーズ、クラッシュレポートなし、コールスタックにアプリのコードが一切ない。これは関数が遅いことはまれで、二つのスレッドが互いを待っているのだ。三つの契約、三つの壊れ方。同じ位置が別の原点に解決される。ビューが二回の commit の間で存在しなくなる。フレームワークが名前で見つけられないクラス。まだ他にもある。どの場合でも、コードは書かれたとおりに厳密に動いていた。足元の地面が動いただけだ。

どうやって切り分けたか

計測がなければ、ここまでのどれも成し遂げられなかった。この進め方自体に触れておく価値がある。似たような移行に取り組むチームならどこにでも当てはまるからだ。安定性はクラッシュ報告のパイプラインから来ている。ネイティブクラッシュ、非致命的エラー、アプリのハングを継続的にトリアージし、症状ではなくシグネチャで分類する。ステッカーが固まる問題もそうやって浮かび上がった。ユーザーが送ってきたバグ報告ではなく、繰り返し現れるハングのシグネチャとして、だ。最初から分類を間違えたものは直せない。パフォーマンスは専用に作ったダッシュボードから来ている。CPU と Time to Interactive を第50、第95パーセンタイルで見て、ロールアウト前後のアプリバージョンを比較する。カバレッジは CI から来ている。専用の job が、変更のたびに New Architecture のビルドがコンパイルでき、テストが通ることを保証する。新機能を出す前に、誰でも両方のアーキテクチャで検証できる。数週間後に、自分の変更が片方でしか効いていなかったと気づく羽目にはならない。再現がたいてい一番難しい。旧型デバイスや高リフレッシュレートの画面でしか出ないパフォーマンス問題がいくつかあった。そんなハードウェアを手元に持っている人はほとんどいない。だからコードをマージした瞬間に「直った」と宣言してしまいがちになる。私たちはそうしなかった。クラッシュ率が実際に下がるか、リグレッションが最初に現れたハードウェアで問題が消えたのを誰かがこの目で確認するまで、修正は完了とみなさない。それ以外は完了ではない。

「完了」は動き続ける的だ

Discord ほどの規模では、問題は常に湧いてくる。New Architecture への移行を単独で進めているわけではない。他の移行も並行して走っていて、それぞれが独自の New Architecture の癖を持ち込む可能性がある。 新機能を試す人が全員、New Architecture でも確認しようと覚えているわけではない。だから、その検証をできるだけ簡単に、できるだけ摩擦なくしておくのは自分の責任だ。でなければ、そもそもやってもらえない。そして問題を見つけたときには、修正がすでに存在していることが多い。もっと新しい React Native のバージョンの中に、という形で。残る選択肢は二つ。今のバージョンに自分でパッチを当てるか、React Native のバージョンを上げるか。ローカルパッチのほうがたいてい速いが、技術的負債を背負うことになる。今後のアップグレードのたびに、そのパッチを見直すか書き直すかしなければならない。React Native を上げればその負債は消えるが、今度は新しい New Architecture の癖が一巡り出てくるかもしれない。

この移行がコミュニティに返したもの

この話には Discord の外に及ぶ部分がある。私たちは Discord のスタックでも使っている中核ライブラリをいくつかメンテナンスしている(Reanimated、React Native Screens、Gesture Handler)。だから問題がそのうちのどれかの中にあるときは、アプリ側で回避策を打つのではなく、根本から直せる。ライブラリ側の修正は一つのアプリにとどまらない。その上に作られたすべてのプロジェクトに効く。とはいえ、すべてのバグがライブラリのバグというわけではない。ほとんどはそうではない。私たちが直したものの大半は Discord 自身のコードの中にある。何年も前に書かれた前提で、アーキテクチャが足元で変わるまで問題なく動いていたものだ。ライブラリ本体まで遡れるのは、ごく一部にすぎない。本当の腕の見せどころは、この二つを見分けることにある。修正が自分のアプリに属するならライブラリにパッチを当てにいかない。本当の問題が一つ下の層にあるなら、アプリ側で回避策をこねない。これがこの種の移行の裏にある静かな経済学だ。Discord ほどの規模のアプリは、エコシステムが他に方法のない圧力テストになる。デッドロックは、未移行のコンポーネントと遅延ロードされるモジュールと実行中のアニメーションが同じ瞬間にぶつかって初めて起きる。ジェスチャーターゲットは、押している最中にちょうどアクセシビリティの更新が来たときだけ壊れる。座標空間は、modal の内側でだけずれる。こうしたエッジケースはサンプルアプリには出てこない。本物のアプリを本物の負荷の下で動かして、初めて全部が炙り出される。今日 New Architecture に移行すると2年前よりずっとスムーズなのは、以前のプロジェクトがすでにこのエッジにぶつかってくれたからだ。ライブラリのバグだったものは、その後アップストリームで直っているので、あなたはそもそも踏まない。逆もまた同じ。以前の移行があなたのためにエッジを取り除いてくれたように、あなたが新しいエッジを見つければ、後のチームのためにそれを取り除くことになる。

これから移行するなら

1年前に同じ場所にいたチームに伝えたいことが五つある。

  • 切り替えそのものではなく、ロングテールのために計画を立てる。 アプリがビルドできて起動できるのは、作業のごく一部でしかない。私たちのチケットデータではおよそ7分の1だ。残りは機能のパリティを取り戻す作業で、アプリが大きいほどその差は広い。
  • アプリがネイティブに触れる場所を注視する。 計測、ref、ジェスチャー、命令的な呼び出し、そして interop レイヤーをまだ通っているコード。純粋な React のコードはほぼそのまま移行を通過した。問題は境界に集中する。
  • 予算は修正ではなく切り分けに使う。 ここまでの話はどれも、最後には小さな helper か、一つの prop か、一度のリネームに行き着く。コストはそれを見つけることにある。メニューの位置がずれる原因は座標原点だった。録音が固まる原因はアクセシビリティの prop だった。アプリがフリーズする原因はクラス名に足りないサフィックスだった……症状が指し示す先は、病因からはるか遠い。コードを書く時間ではなく、問題を理解する時間を見込んでおくこと。
  • まず計測、それから最適化。 安定性はクラッシュトラッキングで、パフォーマンスは CPU とロード時間のダッシュボードで、ビルドの失敗は CI で止め、実際に問題を見るには実機を使う。「なんか重い気がする」は、それが起きるのを実際に見られて初めて、本物の修正になる。
  • 直せるなら上流で直す。 最も厄介なバグは、自分のアプリと依存ライブラリの隙間に住んでいる。ローカルで回避すれば今日は速く、これから先ずっと遅い。上流で直せば、次のアップグレードで自分を助け、最初に踏んだ誰かを助ける。自分で直せないなら、せめて報告する。できれば明確な再現手順付きで。それが第一歩であり、それが後に誰かの修正になることも少なくない。

この取り組みは Software Mansion と Discord モバイルエンジニアリングチームの協力によって実現しました。こうした問題をともに深掘りしてくれたオープンソースのメンテナーたちがいてこそ、ここまで来られたのです。

出典: Software Mansion Blog← ホームへ戻る