把 Discord 迁到 React Native 新架构:真正难的是扳开关之后的长尾
了解我们如何帮助 Discord 将其应用迁移到 React Native 的新架构,解决 Fabric、Reanimated、Screens 等方面的挑战。
中文
复制


React Native把 Discord 迁移到 React Native 新架构,真正要付出的是什么

Kamil Delekta•2026年9月9日•14 分钟阅读 React Native
把 Discord 迁移到 React Native 新架构,真正要付出的是什么

Kamil Delekta•2026年9月9日•14 分钟阅读本文内容 以及为什么最难的部分不是切换框架,而是切换之后那条长尾?
背景
Discord 的移动端应用是 React Native 少有人能达到的规模:一个由原生驱动的数据模型支撑的聊天界面、自定义的渲染优化、十年积累下来的业务逻辑,以及一群能察觉出一帧卡顿的用户。Discord 这次迁移的特别之处在于它是按平台逐个做的——先 Android,再 iOS——我们参与的就是后者,也是本文的重点。我们花了将近一年时间嵌入 Discord 的移动端团队,把 iOS 搬到新架构上。作为 Reanimated、Screens、Gesture Handler 这些 React Native 核心库的维护者,我们具备深入系统层面的洞察力,能够调试并修复那些反复出现在关键库、Fabric 与 Discord 代码交界处的棘手问题。开始之前先说明一点:这不是一篇讲怎么开启新架构的文章。官方迁移文档已经写得很清楚了,大多数应用和库也早就迁完了。这篇文章讲的是你按下开关之后发生的一切——文档不会告诉你的那部分。而在 Discord 这样的规模下,“一切”确实很多:藏了许多年、一直摆在明面上的 bug,只在真实生产负载下才出现的竞态,以及同时进行的其他迁移,每一个都带着自己的新架构怪癖。
切换之后的长尾
在这个规模上迁移一个已有应用,是另一类项目。光是让它能编译、能跑起来就不容易,再加上一套自定义构建系统就更不容易。我们加入之前,Discord 的团队已经为此投入了数月。我们从那里接手:从“应用能启动”到“应用和你要替换掉的那个一样好”之间的全部工作——这部分对任何迁移到新架构的应用都适用。这段差距里躺着几百个小问题,大多可以追溯到底层的架构变化:代码悄悄依赖着旧架构的假设,而这些假设已经不成立了。把我们为达到同等体验而关闭的工单归类之后,这项工作的真实面貌是这样的:
| 类别 | 占比 |
|---|---|
| 渲染 / 布局 / 视觉 | 47% |
| 崩溃与稳定性 | 16% |
| 构建 / 基础设施 / 迁移 | 14% |
| 性能 | 13% |
| 输入(键盘 / 手势) | 11% |
再看一眼这张表:真正的迁移工作(构建系统、代码生成、依赖升级)是第三大块,占 14%。剩下的全是应用在新渲染器下行为发生变化、用户能察觉到的地方。光渲染和布局就占了 47%。我们有意不把其中大多数称为“bug”。它们很多确实不是——在新架构里不是,在 Discord 的代码里往往也不是。它们是一些隐式契约发生变化的地方:视图在屏幕上的位置、它是否存在于原生树中,以及框架如何找到你的原生代码。
那些对我们不再成立的假设
接下来我们逐一分析其中三个假设——它们都是多年前写下的,在旧架构上都运行得好好的,迁移过程中也都没被动过。
坐标相同,原点不同
第一个契约是测量——你问一个视图它在哪,它会返回一个位置,但这是相对于什么的位置?在旧架构上,测量一个视图得到的是它在窗口中的位置。所有东西共用一个原点:屏幕左上角。在 Fabric 下,视图是相对于它的 Yoga 根节点测量的——而 modal 本身就是自己的根节点。同一个视图上的同一个调用,现在会因为该视图是否恰好位于 modal 内而返回不同的数值。大多数时候你察觉不到区别。不在 modal 里时,树的根节点就是窗口顶部,两种答案完全一致。代码能跑,数值看着也对,没有任何迹象表明曾经存在两个坐标空间。只有在 modal 内部它们才会分开,而且只有当你定位的东西位于 modal 之外时,才会产生可见的 bug。上下文菜单正是这种情况。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 调用。旧架构:
新架构:
这类 bug 的识别特征:在大多数屏幕上正常,在少数屏幕上出错,而且误差是一个固定偏移量,看起来很像某个其他东西的高度。
那个从未完成的手势
下一个契约是身份——你正在触摸的原生视图,片刻之后是否还是同一个原生视图。这个更难发现,因为 bug 报告里没有任何线索指向它。症状是一个产品 bug:按住录制语音消息,松手,录音却不停止。没有崩溃,没有报错——手势就是从未完成。手势代码本身没有任何问题,而破坏它的那次改动看起来和手势毫无关系。View flattening 是 Fabric 的一项优化:如果某个 View 不需要自己的 host node——没有东西要绘制、要裁剪,也没有其他理由让它以原生形式存在——Fabric 就可能跳过创建。原生视图更少,工作量更小。这是有意为之,通常也看不出来。容易被忽略的是,可扁平化与否并不是在 mount 时一次性决定的。它是 props 的函数,每次 commit 都会重新求值——而强制创建真实原生视图的 props 列表比“背景色和边框”要长。无障碍相关的 props 也在其中。改动其中一个,一个原本被扁平化掉的视图就会变成真实的 host view,或者反过来。React 组件还是同一个。它底下的东西已经不是了。下面是 Discord 的案例。当你开始录制语音消息时,聊天输入框会重新渲染,并对辅助技术隐藏输入框的其余部分——在包裹录音按钮的容器上设置 accessibilityElementsHidden 和 importantForAccessibility。这对无障碍来说是正确的行为。但它同时改变了该容器在原生层的表示方式,从而在手势进行到一半时让手势的底层视图失效。Pan 被取消,onFinalize 从未执行,松开按钮也就没有任何反应。
// Simplified from the chat input wrapper around VoiceMessageButton.
三件看似毫不相干的事必须同时发生:一次无障碍更新、一个进行中的手势,以及一次在两者之间改变宿主树的 Fabric 提交。单独来看,每一件都合情合理。写无障碍改动的人没有任何理由去考虑手势目标。修复只用了一个 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 更新。这需要对同一个注册表加读锁。写锁已被占用,于是它等待。两边都在等对方。应用停住。三件事必须同时发生:一个未迁移的组件、一个错过快速查找的类名,以及那一刻正在运行的动画。滚动一个满是动态表情的频道,会一次性凑齐这三样。
// 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)
三行代码,两个文件。一条从来没人需要遵守的命名约定,变成了硬性要求,而没遵守的代价不是警告也不是报错——是应用卡死,而且只有在未迁移的组件、懒创建的模块和 UI 线程上的动画同时撞到一起时才会触发。改名并不能修复死锁,只是确保我们永远走不到发生死锁的那段代码。慢路径还在——它是 interop 层里一个已知的坑,其他应用也在同一个地方踩过。绕开它对上线来说是正确的选择:三行代码,没有风险,用户当天就能继续用。但有一点要说清楚:interop 层是干什么用的。它的存在是为了让应用不必先把每个旧组件重写一遍就能迁到新架构——它是迁移途中可以踩一脚的地方,不是久留之地。还在走这条路的每个组件,都可能撞上属于自己的那一版问题。真正的修复不是更聪明的绕行方案,而是把组件迁走,让它根本不经过 interop 层。这一类的判断信号:卡死,没有崩溃报告,调用栈里找不到任何应用代码。这很少是某个函数太慢,而是两个线程在互相等。三份契约,三种破坏方式:同一个位置解析到了不同的原点,一个视图在两次提交之间不复存在,一个框架按名字找不到的类。还有更多。每一次,代码都严格按写好的逻辑执行;是它脚下的地面动了。
我们是怎么排查的
没有度量,上面这些一条都做不成。这套流程本身值得讲一遍,因为它对任何做类似迁移的团队都适用。稳定性来自我们的崩溃上报流水线。我们持续分诊原生崩溃、非致命错误和应用卡顿,按签名而不是按症状归类。贴纸卡死就是这么浮出来的——不是用户提交的 bug 报告,而是一个反复出现的卡顿签名。一开始就归错类的东西,你没法修。性能来自专门搭的看板:CPU 和可交互时间,看第 50 和第 95 百分位,对比灰度前后的应用版本。覆盖率来自 CI。一个专门的 job 保证每次改动后新架构构建都能编译、都能跑测试。任何人提交新功能前,都可以在两种架构上各验一遍,而不是几周后才发现自己的改动只在一种架构上有效。复现往往是最难的一环。有几个性能问题只在旧设备或高刷新率屏幕上出现——那些硬件我们大多数人手上并没有——这就很容易让人在代码合并的那一刻就宣布修好了。我们没有这么做。一个修复只有在崩溃率真的降下来,或者在最早出现回归的那台硬件上有人亲眼确认问题消失之后,才算完成。别的都不算。
“完成”是一个不断移动的目标
**在 Discord 这样的规模下,问题总是不断冒出来。你从来不是孤立地迁移到新架构——其他迁移在并行进行,每一个都可能带来自己的新架构怪癖。**不是每个测试新功能的人都会记得对照新架构检查一遍。所以,让这种验证尽可能简单、尽可能低摩擦,是你自己的责任;否则它根本不会发生。而当你真的发现一个问题时,修复往往已经存在了,只是在一个更新的 React Native 版本里。这给你留下两个选择:自己给当前版本打补丁,或者升级 React Native 版本。本地补丁通常更快,但你会背上技术债——未来每次升级都得重新检查或重写它。升级 React Native 能清掉这笔债,但它自己又可能带出一轮新的新架构怪癖。
这次迁移回馈给社区的东西
故事里有一部分超出了 Discord 本身。我们维护着几个同样用在 Discord 技术栈里的核心库(Reanimated、React Native Screens 和 Gesture Handler),所以当问题出在其中某个库里时,我们可以从源头修,而不是在应用里绕过去打补丁。库里的修复不会只留在一个应用里:它会惠及所有基于它构建的项目。而且不是每个 bug 都是库的 bug;大多数都不是。我们修的东西大部分在 Discord 自己的代码里:多年前写下的假设,一直运行得好好的,直到架构在它们脚下变了。只有较小一部分能追溯到库本身。真正的本事是分清这两者——修复该落在你应用里时,别去动库的补丁;真正的问题在下一层时,也别在应用侧搞个绕行方案。这就是这种迁移背后安静的经济学:Discord 这种规模的一个应用,是一场生态系统没有别的办法能跑起来的压力测试。一个死锁,需要未迁移的组件、一个懒加载模块和正在运行的动画在同一瞬间撞在一起。一个手势目标,只有在按下过程中恰好来了一次无障碍更新时才会坏。一个坐标空间,只在 modal 内部才会出现偏差。这样的边角情况不会出现在示例应用里——得是一个真实的应用、在真实负载下,才能把它们全都逼出来。如果你今天迁移到新架构,它比两年前顺畅的部分原因,是更早的项目已经撞上过这些边角——而那些属于库 bug 的,此后已经在上游修掉了,所以你根本不会碰到。反过来也一样:更早的迁移替你清掉了这些边角——而当你发现一个新的,你也在替后面的团队清掉它。
如果你正准备迁移
有五件事,我们想告诉一年前处在同样位置的团队:
- 为长尾问题做计划,而不只是那次切换。 让应用能构建、能启动只是工作中很小的一部分——在我们的工单数据里,大约只占七分之一。其余全是在把差距补回到功能对等——应用越大,这道差距越宽。
- 盯住应用接触原生的地方。 测量、ref、手势、命令式调用,以及任何仍然走 interop 层的代码。纯 React 代码基本原封不动地通过了迁移——问题都集中在边界上。
- 把预算花在排查上,而不是修复上。 上面每一个故事最后都归结为一个小 helper、一个 prop,或者一次重命名。成本在于找到它。菜单位置错乱,结果是坐标原点的问题;录音卡住,是一个无障碍 prop;应用冻结,是类名少了个后缀……这些症状指向的地方都离病因十万八千里。要为理解问题的时间做计划,而不是敲代码的时间。
- 先埋点,再优化。 用崩溃追踪看稳定性,用 CPU 和加载耗时面板看性能,用 CI 拦住构建失败,用真机去真正看到问题。“感觉有点卡”只有在你真能看到它发生时,才会变成一次真正的修复。
- 能往上修就往上修。 最棘手的 bug 就住在你的应用和它依赖的库之间的缝隙里。在本地绕过它们,今天更快,以后永远更慢。往上修,下次升级时帮到你自己,也让别人第一次踩到时受益。如果你自己修不了,至少报上去,最好带上清晰的复现步骤——这是第一步,而且往往就是它,后来变成了别人的修复。
这项工作由 Software Mansion 与 Discord 移动工程团队合作完成,如果没有那些和我们一起深挖这些问题的开源维护者,它不会走到今天这一步。