快手 KRN 鸿蒙适配与性能优化:从大规模迁移到接近原生体验
来源说明:本文根据快手动态化内核负责人张鹏的 CSDN 技术直播整理。内容不是逐字照录,而是在完整转写基础上重组技术主线、修正明显的语音识别错误。文中性能数字均来自直播分享,形如
[00:45:39]的标记对应原视频时间点。
核心结论
- 迁移的首要目标是控制变量:保持上层业务接口稳定,把平台差异收敛到底层,才能让 2,000 多个业务 Bundle 平稳迁移。
- 性能问题来自架构边界,而不只是单点耗时:RN 的命令式更新与 ArkUI 的声明式渲染叠加后,会产生重复 Diff、冗余节点和中间状态。
- 渐进式迁移比一次性重写更可控:核心渲染管线迁到 C++,高频组件优先采用 ArkUI C API,低频组件保留原实现,并通过开关灰度和回退。
- 性能治理必须形成完整闭环:先拆分启动与上屏阶段,再结合并行初始化、Snapshot、预请求、内存治理、监控定位和同步渲染逐层优化。
一次迁移,真正困难的不是“让代码跑起来”
把一套 React Native 框架迁移到鸿蒙,如果只看技术名词,似乎无非是补齐平台接口、重写 UI 层、接入新的线程和事件机制。但快手 KRN 面对的并不是一个刚起步的框架,而是一套已经运行多年、承载海量业务的生产系统。
KRN 大约从 2020 年开始建设,基于 React Native 0.62 深度定制。它不仅提供 RN 内核,还补齐了容器与生命周期、Bundle 发布更新、API 与 Bridge、调试工具、数据采集和运营等完整的平台能力。[00:06:46]
直播披露的规模很能说明问题:
- 承载 2,000 多个业务 Bundle,页面数远高于 Bundle 数;
- 覆盖直播、电商、本地生活、消费、社交、IM 长列表等场景;
- 平台有 200 多个自研功能模块、2,400 多项 API 与 Bridge;
- KRN 内核相对官方 RN 累积了接近 2 万次修改。[00:08:11]
这组数字带来了两个硬约束:
- 上层业务接口和行为必须尽量稳定,一点变化都可能影响 2,000 多个 Bundle。
- 内核和平台层的变化必须能独立验证、灰度替换和安全回退,不能要求所有业务一起回归。
因此,KRN 鸿蒙适配的核心命题不是“能否迁移”,而是:
如何把变化收敛在平台底层,在不扰动上层业务的前提下完成迁移,并把性能重新拉回到接近原生的水平。
技术路线:把变量控制在平台层
项目在 2024 年底启动时,团队讨论过四条路线:[00:10:32]
- 鸿蒙端直接采用 RN 新架构,Android 和 iOS 保留旧架构;
- 把现有 KRN 旧架构内核迁移到鸿蒙;
- 等待 RN on OpenHarmony 官方提供旧架构支持;
- Android、iOS、鸿蒙三端同时升级 RN 新架构。
判断原则非常朴素:面对一个全新的操作系统,尽量少叠加变量。
只在鸿蒙端使用新架构,平台工作量会下降,但业务代码和内核会长期分叉;等待外部支持,时间和可行性无法控制;三端同时升级,则会把平台迁移、内核升级、近 2 万个内核修改和 2,000 多个 Bundle 的适配验证全部叠加,稳定性和节奏都难以管理。
团队最终选择了第二条路线:迁移现有 KRN 内核,把变化尽量压在平台底层。[00:13:06]
这不是理论上的最优解,而是在当时信息和约束下风险最可控的解。分享者也特别提到,如果项目启动时 ArkUI C API 已经成熟,路线选择可能会不同。
迁移拆解:接口稳定、内核重写、跨端逻辑复用
迁移工作同时从“功能模块”和“代码复用”两个维度拆解。
对业务可见的部分必须保持接口和行为一致,包括:
- JS API 与 Bridge;
- Bundle 管理、加载流程和容器接口;
- React 基础包接口;
- 生命周期及异常行为。
而依赖操作系统的内部实现必须在鸿蒙上重做,包括:
- JS 引擎接入;
- 线程模型和调度策略;
- UI 渲染、布局与事件系统;
- 调试、监控和交付链路。
React Reconciler、Yoga、JS Runtime、JSI 等跨端逻辑可以复用或少量适配;ReactInstance、Package、NativeModule 注册管理等与 UI 无关的内核逻辑,可以按照语义迁移到 ArkTS;真正涉及 UI 和系统能力的部分则必须重新设计。[00:15:23]
为了避免项目迅速变成无法定位问题的黑盒,团队按工作流分层推进:
- 先跑通 JS 加载、执行和渲染指令生成;
- 再接 UI Manager、ShadowNode、Yoga、事件和最基础组件,打通最小渲染管线;
- 补齐 Bridge、生命周期、异常处理和更多组件;
- 最后完成调试、监控、异常上报和交付能力。
每一层都有明确输入、输出和测试条件,每次只引入一类新变量。最终,第一版 KRN 内核在一个季度多一点的时间里完成了从 Android 到鸿蒙的迁移。[00:17:29]
AI 在迁移中扮演什么角色
项目启动于 2024 年底,当时的模型上下文、Agent 能力和工具生态远不如现在。团队无法把整个模块直接交给 AI,只能把代码拆到函数级甚至更小的片段,让模型完成大量语法层面的转换,再由人工补齐:
- 空值和类型处理;
- 系统 API 的真实性校验;
- 线程和生命周期;
- JS 对象与 Native 对象的管理。
AI 的价值是减少机械翻译工作,而不是替代架构判断。保障结果正确性的关键,仍然是 RN 内核单测、跨端 UI 树和属性对比,以及持续运行的自动化测试。[00:19:06]
命令式 RN 遇到声明式 ArkUI
RN 旧架构向原生层输出的是命令式操作,例如 CreateView、UpdateView、ManageChildren。在 Android 和 iOS 上,内核可以持有真实 View,随时创建、插入、删除和修改属性。
ArkUI 的声明式模型不同:组件由状态和数据描述,不能像 Android View 一样随时 new、add 和修改对象。项目早期又没有成熟的 ArkUI C API,因此 KRN 在 RN 与真实 ArkUI 组件之间增加了一层 ViewModel。[00:21:16]
ViewModel 保存:
- 组件类型和 React Tag;
- Props 与 LayoutMetrics;
- 父子关系和节点层级;
- 与 ArkUI 状态系统关联的数据。
UI Manager 继续接收 RN 命令,但操作的是 ViewModel;ArkUI 在自己的刷新周期中读取 ViewModel 并构建组件。这样既保留了 RN 旧架构的命令接口,也能通过 ArkUI 声明式语法创建和更新真实 UI。
这个中间层解决了兼容性,却引入了新的性能成本:
- React 已做过一次精确 Diff,ArkUI 状态系统可能再次大范围计算;
- 声明式写法容易增加中间节点,使组件树更深;
- 一次 RN 更新包含多次属性、布局和层级变化,若每次都立即提交,会反复渲染用户看不到的中间状态;
- ViewModel 本身增加了一层数据同步和生命周期管理。
第一轮性能优化:精确更新与批量提交
第一版功能跑通后,团队首先解决两类直接问题。
用 FrameNode 缩小刷新范围
当时 ArkUI C API 尚未成熟,团队先使用 FrameNode 搭建了一层“ArkUI 命令式渲染层”。FrameNode 可以被创建、插入、删除,也能直接更新节点属性和控制布局刷新,因此变化可以限制在真正受影响的节点,不再触发整棵 ArkUI 树的大范围计算。[00:31:29]
代价是 FrameNode 的生命周期、调用顺序、异常回收和平台兼容性都需要 KRN 自己负责。它的主要收益不是单个 API 更快,而是减少隐式 Diff 和不必要的状态传播。
用 Dirty Set 合并一次提交内的多次变化
团队为连续到达的属性、布局和树结构操作增加了一个刷新窗口:
- 窗口内的变化合并到 Dirty Set;
- 同一属性只保留最终状态;
- 对 Create、Insert 等顺序敏感命令设置类似 Memory Barrier 的边界;
- 到刷新点后,再统一提交属性、布局和树结构。
这相当于在渲染管线中增加一次批处理:不改变业务结果,但减少中间状态和重复计算。[00:33:24]
这轮优化后,华为测试中的不达标项从 53 项降到 25 项,首屏整体耗时下降约 30%。但性能仍未完全达标,因为更深层的瓶颈来自 ArkTS 执行效率、ArkUI 节点更新成本,以及布局和测量仍占用 UI 线程。[00:35:40]
第二轮性能优化:核心渲染管线迁移到 C++
2025 年第二季度,ArkUI C API 已经可用。团队没有简单地“把所有组件改成 C API”,而是选择了一条可渐进迁移的混合路线:
- UI Manager、ShadowNode、Yoga 布局、UI 任务队列和事件链路等核心管线迁到 C++;
- 高频核心组件优先改成 C API,先覆盖约 90% 的线上使用量;
- 低频组件继续保留 ArkUI 实现;
- 内核根据组件注册信息创建 C API 节点或 ArkUI 节点,对前端保持同一组件接口;
- 两类节点共享 React Tag、父子关系、Yoga 布局和生命周期。[00:38:20]
这种路线支持按组件灰度迁移和快速回退,但也让内核在相当长时间内维护两套实现,节点管理复杂度显著上升。
混合树最棘手的是事件,而不只是渲染
渲染链路从 React 指向 Native,事件链路却从 UI 反向回到 JS。C API 与 ArkUI 有不同的事件来源、数据结构、Target 表示和生命周期。
团队因此增加 EventAdapter,统一事件结构、目标节点和内容,再交还 React 前端处理冒泡、拦截和冲突。同时验证四类一致性:
- 节点插入、删除和重排;
- Yoga 布局结果;
- 事件顺序和语义;
- 创建、复用、销毁和异常回收等生命周期。
所有相关模块均提供开关,可在 C++/C API 与 ArkTS 版本间灰度切换和回退。第一版内核迁移只用了一个季度多一点,而 C++ 核心渲染管线从设计到落地用了约三个季度,足见其风险和复杂度。[00:40:10]
C API 改造后的数据
直播公布了几组结果:[00:45:39]
| 指标 | 结果 |
|---|---|
| 高端机启动耗时 | 相比 ArkTS 版改善约 42%,相比 Android 快约 10% |
| 低端机启动耗时 | 相比 ArkTS 版改善约 52%,相对 Android 也有进一步提升 |
| 复杂列表卡顿率(高端机) | 下降约 92% |
| 复杂列表卡顿率(低端机) | 下降约 43% |
但 C API 并没有消灭所有瓶颈。简单页面的平均 FPS 可以明显领先 Android,而复杂电商长列表仍落后:高端机约低 23%,低端机约低 32%。原因不是最终绘制,而是复杂页面会触发更多布局和 Bridge 调用;在混合架构下,这些计算仍可能把 UI 线程打满。[00:46:44]
后续方向包括把带自定义 ShadowNode 的组件优先 C API 化、把布局线程迁出 UI 线程、将可迁移的高频 Bridge 放到 C++ 或 Worker 线程,以及继续研究并行化创建和更新 C API 节点。
三端通用的性能方法:先建立分段指标
性能优化不能只盯一个总耗时。KRN 把页面加载拆成多个阶段:[00:52:25]
- T0:容器、线程、JS 引擎、基础包和业务包准备;
- T1:业务 Bundle 编译执行,直到 JS 完成首次 Render;
- T2:关键业务数据返回;
- T3:有意义的首屏结构生成并提交到 Native;
- FMP:布局和 UI 任务完成,内容真正上屏;
- 80/60 阶段:图片、视频等二阶段资源加载后,大部分内容可见。
分段后,性能回退可以被定位到容器、JS、网络、渲染还是资源加载,而不是只看到一个总数字。
T0:并行化冷启动任务
T0 中的插件、动态库、线程、JS Runtime、基础包和业务包缺一不可,因此优化重点不是删步骤,而是减少计算量和串行等待。
KRN 的做法包括:
- Java 部分尽量随宿主内置,插件只保留更适合动态交付的 C++ 内容;
- 记录插件真实安装路径,减少冷启动时重复安装和校验;
- 合并多个 SO,降低 IO 密集阶段的排队;
- 基础包、业务包、Runtime 和 Context 尽可能并行准备;
- 把逻辑依赖关系与代码执行顺序解耦。
V8 Snapshot:把基础包执行从秒级降到几十毫秒
React Reconciler 和 React Native 等基础库体积大,低端机上的解析、编译和执行可达到秒级。KRN 使用 V8 Snapshot,把执行基础包后的堆、全局变量、函数、对象和引用关系序列化,冷启动时直接恢复。
难点在于 RN 会向 JS 注入大量 Native 引用。函数地址、对象地址和外部资源不能直接固化,恢复时必须通过 External Reference 重建,并保证数量、顺序和语义与生成 Snapshot 时完全一致。Native 接口变化时,Snapshot 也必须同步失效或重建。[00:58:19]
T1:Code Cache、Tree Shaking 和延后非关键模块
业务 Bundle 越大,JS 引擎在首屏前需要解析和编译的代码越多。KRN 的优化包括:
- 使用 V8 Code Cache 减少重复编译;
- 通过 Webpack 依赖裁剪和 Tree Shaking 减小 Bundle;
- 首屏只初始化必要的 NativeModule 和 Bridge,非关键任务后移。
全量 Code Cache 并不总是更好:它降低运行时编译,却会增加产物、IO、反序列化、内存峰值和 OOM 风险。平台需要根据 Bundle 大小,在“预编译收益”和“缓存加载成本”间做选择。[01:02:44]
T2:预请求不是“提前发请求”那么简单
RN 的业务请求往往要等 Bundle 编译、全局代码执行、骨架屏渲染之后才发出。KRN 在 Native 触发页面加载时就构造请求,让网络与容器、引擎和 Bundle 初始化并行;JS 真正发起请求时,再与预请求做严格参数匹配,命中则复用回包,否则安全回退。
直播给出的经验数据是:
- 预请求可降低 P90 约 500 毫秒;
- 长连接代理还可进一步减少约 250~300 毫秒连接耗时。[01:10:45]
在 2,000 多个 Bundle 的规模下,请求参数并不是静态配置。参数可能来自页面 URL、容器数据、登录状态、Bridge 返回值,多个请求之间还可能有依赖关系。
因此 KRN 为预请求实现了一个小型表达式引擎:配置随 Bundle 下发,被解析成 AST,支持取值、类型转换、条件分支、递归求值和格式处理。它的真正目标是:在 JS 尚未启动时构造出与最终请求完全一致的数据,并在任何不确定条件下自动回退。[01:11:26]
T3、FMP 和资源阶段
后续阶段还有三类代表性优化:
- PreRequire:结合 PGO,提前加载本应懒加载、但首屏一定会用到的模块;
- 首屏强制提交:业务显式标记首屏结束点,立即刷新前面的 UI 任务,避免同一批次中的长尾计算阻塞上屏;
- 资源预加载:主请求返回后提前拉取图片、视频等首屏资源,减少二阶段内容延迟。[01:16:20]
内存优化:先找到真正的大头
直播以 Android 为例拆解了一个 KRN 引擎的内存构成。首次引擎大约占 41 MB,其中约 19 MB 是可被系统换出的代码页等 Clean 内存,约 22 MB 是 V8 Heap、ShadowNode、UI 实例等 Dirty 内存。框架内核自身只占几 MB,盯着内核代码做极限压缩,收益并不大。[01:18:24]
KRN 的框架侧内存优化集中在四个方向:
- 共享 JS Runtime:单个引擎首次约 40 MB,后续通常还增加 10~20 MB。一个页面存在多个 KRN 容器时,共享 Runtime 是最大的内存杠杆,但必须额外处理全局变量隔离、异常恢复、生命周期和资源释放。
- 治理大块资源:图片按实际展示尺寸压缩,规范动画和 Graphics 资源的缓存、复用和释放。
- 收敛生命周期:页面退出及时释放容器和 Runtime;暂时不可见时触发 GC 和缩堆。
- 建设复合监控:联合 Native、JS Runtime、对象数量和资源信息定位问题。[01:21:00]
监控体系:从“线程卡了”追到“哪段业务代码”
Native APM 可以发现 CPU、内存或 UI 线程异常,却不知道是哪个 RN 页面、哪个 Bundle、哪段 JS 代码造成的。KRN 为此分层补充监控:
- Native 层采集 PSS、RSS、Graphics、代码页等,判断是哪类内存增长;
- JS Runtime 层采集 Heap 分配、使用量、外部引用等;
- 框架层记录 JS 对象、Native 对象、ShadowNode、UI 节点和大图数量;
- 必要时结合 V8 Heap Dump 与 Native Memory Graph;
- 在 JS 调用和 UI 提交前后记录业务场景、队列长度、树深度和布局耗时。
若要采集 JS 堆栈,必须在引擎自己的执行线程中完成。V8 可通过 RequestInterrupt 让引擎在安全上下文执行回调,再读取当前堆栈;JavaScriptCore 没有同类中断 API,需要借助最长执行时间回调,并构造 Error 获得堆栈。[01:25:35]
这套体系的目标,是把“某线程在某时刻卡住”继续推进到“哪个业务 Bundle 的哪段代码在做什么”。
长列表白屏:异步内容生产与同步 UI 消费的冲突
RN 长列表快速滑动时出现白屏,并不一定是代码 Bug,而是两种机制的天然冲突:
- JS 和 Shadow 线程异步生成内容、计算布局;
- RecyclerView、UICollectionView 一类原生列表同步消费 Cell,需要立即知道尺寸并拿到可展示内容。
当 UI 已经滑到下一个 Cell,而 JS 还没有完成渲染时,只能在“阻塞 UI”和“先显示空白”之间选择。RN 为减少卡顿,通常选择后者。[01:29:10]
一次失败但有价值的尝试
团队曾在每个原生 Cell 中放置独立 RootView,并让 JS 异步渲染。实际结果是:
- Cell 已被复用到新位置,旧渲染结果才返回;
- 快速滑动导致大量 Cell 渲染堆积在 JS 队列;
- 停止滑动后,过期内容仍不断刷新;
- JS 管业务状态,Native 管 Cell 调度,形成两个互不同步的状态所有者,破坏了 RN 的单向数据流。
这次失败证明原生 Cell 复用有价值,但不能以双重状态管理为代价。[01:31:22]
同步渲染管线:异步生产、同步读取
重新设计后的管线保留 JS 和 Shadow 层的原有职责,只修改 UI 提交:
- 对标记为“需要同步渲染”的子树,拦截原有 CreateView、UpdateView 等 UI 命令;
- 从 ShadowTree 提取节点位置、属性、类型和层级;
- 通过 Diff 组织成版本化 UI 数据;
- 使用 Copy-on-Write 发布新版本;
- JS/Shadow 是唯一写入方,UI 线程只读,不反向修改状态。
这样,UI 可以随时读取一份完整的旧版本数据,不必等待 JS 或 Shadow 线程,也不会出现空白;新版本准备好后再原子切换。代价是用户可能短暂看到旧内容,但 RN 本身就是异步渲染,这不会进一步放大原有延迟。[01:34:07]
Copy-on-Write 的关键价值是:
- UI 读取无需加锁;
- 版本一致;
- 局部更新只复制变化路径上的少量节点;
- 后台线程可以继续生成下一版本。
更新时先做 Cell 级 Diff,控制列表刷新范围;再在 Cell 内按 View 类型、Tag、属性、布局和子节点递归比较。结构兼容就复用真实 View 并更新属性,不兼容才删除重建。[01:37:46]
这套同步渲染机制不只用于长列表,也适用于任何要求当前帧立即获得可展示内容的场景。原有 RN 组件无须修改,业务仍使用相同的嵌套和事件模型。
最后的经验:平台稳定,底层可演进
整场分享可以归纳为三条主线:
- 迁移:上层接口和行为保持稳定,把变化封装在平台边界之内;
- 性能:建立分段指标、优化手段和监控定位的完整闭环;
- 体验:必要时改造渲染机制,而不只是对单个函数做微优化。
KRN 的最终目标不是单纯追求跨端复用率,也不是把每个平台都强行做成同一种实现,而是:
保持跨端开发效率,同时让用户体验尽可能接近原生。[01:43:54]
这也是大规模跨端基础设施最值得借鉴的一点:上层稳定不意味着底层静止。真正成熟的平台,应该允许底层持续重写、灰度、验证和回退,而业务不必为每次演进重新付费。