React Native 进阶实战

RN · Performance · Native · Release · 七阶段路线:基础、性能优化、原生模块、动画、状态架构、离线能力与发布上线,从入门到生产级 RN。
← 返回教程馆
8阶段
23章节
23源文档
vibecoding仓库目录

📊 我的学习进度 本地自动保存

加载中…
🧭 导览0/0
stage1 found0/0
stage2 perfo0/0
stage3 nativ0/0
stage4 anima0/0
stage5 state0/0
stage6 offli0/0
stage7 relea0/0
appendix0/0
💡 每章卡片右上角「✓ 标记已学」;教程间互链已自动映射到本页章节;代码示例请配合 Gitee 仓库 使用。

🧭 React Native 进阶 · 从入门到生产级实战 系统教程 README.md

面向已掌握 React Native 基础的开发者:从「能跑通 Demo」进阶到「能独立架构、交付、运营复杂生产级 App」。

  • 写作基准:2026 年 9 月,React Native 0.8x 系列(0.76 起新架构默认:JSI / Fabric / TurboModule / Hermes),React 19,TypeScript 5.x,Reanimated、Gesture Handler、FlashList 等主流生态库。具体小版本以官方文档为准,书中以「0.8x / 写作时最新稳定版」表述。
  • 读者门槛:已能用 RN 写页面、调用 API、跑通双端真机调试;本书不再解释 JSX 与 useState 基础。
  • 学习方式:按阶段顺序学习,每章固定「学习目标 → 核心知识点 → 典型业务场景 → 常见陷阱 → 最佳实践 → 小结 → 动手练习 → 导航」八个板块。
  • 贯穿实战项目:全书围绕一个完整的生产级项目 —— 「轻巡记 FieldNote」(离线优先的门店/工程巡检工作台 App)展开,每一章都把它向前推进一段,直到签名上架、热更新与安全加固。需求说明书见附录 B。

适合谁读

  • 会写 RN 组件,但说不清 FlatList 为什么卡、useNativeDriver 原理是什么的开发者。
  • 准备接手或正在维护大型 RN 项目,需要补齐原生桥接、架构设计、离线同步能力的开发者。
  • 负责把 RN 应用推向生产环境:要上线热更新、做安全加固、管控发布质量的团队负责人。

环境前提:能同时构建 Android 与 iOS(iOS 需 macOS),建议准备双端真机各一台——性能与原生章节的很多现象模拟器上看不到。


学习路线图

全书分七个阶段、19 章、3 个附录,建议总学习时长 14~20 周(每天 1.5~2 小时)。

阶段 章节 你将学会 建议时长
一、架构地基 第 1 章 深入 RN 架构与线程模型 JSI/Fabric/TurboModule 新架构原理、三线程模型、渲染管线 5~7 天
二、性能优化 第 2 章 性能测量方法论与渲染原理 掉帧原理、性能指标、Profiler 与双端分析工具、re-render 治理 5~7 天
第 3 章 列表渲染优化 VirtualizedList 原理、FlatList 调参、FlashList 与回收机制 5~7 天
第 4 章 内存管理与泄漏排查 内存构成、图片内存公式、泄漏类型与双端排查工具链 5~7 天
第 5 章 启动速度优化 启动链路拆解、Hermes 字节码、懒加载、双端启动优化清单 4~6 天
三、原生桥接 第 6 章 双端通信机制全解 JSI 原理、Promise/回调/事件/指令四种通道、线程调度与选型 5~7 天
第 7 章 原生模块封装(TurboModule) Codegen 流程、双端模块实现、事件与生命周期、发布为 npm 包 7~10 天
第 8 章 原生组件封装(Fabric) Fabric 组件全链路、Props/事件/指令/State、地图与相机组件实战 7~10 天
四、动画与手势 第 9 章 动画系统与 Reanimated 动画分层体系、UI 线程 worklet 原理、布局动画与物理动画 5~7 天
第 10 章 手势系统与 Gesture Handler 手势识别原理、手势仲裁、手势与动画联动、滚动冲突治理 5~7 天
第 11 章 综合实战:复杂交互组件 拖拽排序、缩放预览、自定义刷新三个生产级案例 7~10 天
五、状态与架构 第 12 章 状态管理方案选型 状态分类学、六种方案原理横评、大型项目组合策略 5~7 天
第 13 章 大型项目架构设计与模块化 分层架构、Monorepo 拆包、路由体系、监控与质量门禁 7~10 天
六、离线与同步 第 14 章 本地存储与缓存体系 五类存储方案原理与选型、缓存分层、敏感数据加密 5~7 天
第 15 章 离线优先架构与数据同步 Outbox 模式、增量同步协议、后台任务与重试调度 7~10 天
第 16 章 冲突处理与数据一致性 冲突检测/避免/解决三层策略、幂等性、CRDT 适用边界 5~7 天
七、发布与安全 第 17 章 应用打包与发布 双端签名体系、多环境配置、混淆、灰度发布、fastlane 7~10 天
第 18 章 热更新与多 Bundle 拆包 热更新原理与合规、Metro 分包、基础包+业务包、回滚保护 7~10 天
第 19 章 应用安全加固 bundle 加密、防逆向与加壳、SSL Pinning、凭据管理、合规 5~7 天
附录 附录 A 优化与发布 Checklist 速查 性能/发布/安全三张生产级检查清单 随用随查
附录 B 贯穿项目需求说明书 「轻巡记 FieldNote」完整需求与 19 个里程碑 贯穿全程
附录 C 学习资源与进阶路线 官方资料、源码阅读路线、后续方向 随用随查

依赖关系提示:第 1 章是全书地基(线程模型与 JSI 在第 6~10 章反复引用);第 6~8 章需要原生开发基础(Kotlin/Swift 能读懂即可,书中会给出完整代码);第 15~16 章依赖第 14 章的存储选型;其余章节可按序推进。不要跳过第 2 章——没有测量就没有优化。


如何使用本教程

1. 每章的固定结构

板块 作用
本章导读 一句话说清本章在全书的位置、与前后章的衔接
本章学习目标 开工前明确「学完你能做什么」,读完自检
核心知识点 正文主体:底层原理 → 代码示例 → 逐段讲解 → 对比表格
典型业务场景 用「轻巡记」的真实需求演示知识落地,含完整可运行代码
常见陷阱 生产环境真实踩过的坑:症状、根因、正确做法
最佳实践 企业项目沉淀的工程惯例,从第一天就照做
本章小结 要点回顾 + 一句话记忆点
动手练习 必做,含基础巩固、场景实战与排查思考题

2. 推荐学习节奏

  • 第 1、2、6 章是全书三大地基:架构、测量、通信机制。后面所有内容都建立在其上,务必精读。
  • 代码必须亲手跑:原生章节(7、8)即使照抄也要完整走一遍 Codegen 与编译流程,桥接的坑只有编译过才体会得到。
  • 每个阶段结束回到附录 B 做对应里程碑,做不出来就回炉。
  • 第 11、16、19 章是三个「综合验收点」,建议像做 Code Review 一样逐行理解。

3. 贯穿项目:轻巡记 FieldNote

一个面向连锁门店/工程巡检场景的离线优先工作台 App。它的功能刻意覆盖了复杂生产级 App 的六大技术面:

业务功能 对应知识点 章节
千条级巡检任务列表 + 图片缩略图 列表窗口化、图片内存、回收复用 3、4
拍照取证、扫码识别资产、生物识别锁 原生模块、权限、生命周期 6、7
原生相机取景器(高亮框 + 拍照指令) Fabric 组件、Props/事件/指令 8
任务卡片的滑动手势、拖拽排序、动效 Reanimated、Gesture Handler 9~11
任务/会话/主题三类状态的管理 状态分类与方案组合 12、13
无网巡检、回到有网自动同步 MMKV/SQLite、Outbox、增量协议 14、15
双人同时编辑检查项评分 冲突检测与合并策略 16
双端打包上架、灰度、崩溃监控 签名、混淆、fastlane、rollout 17
JS 热更新、巡检模块按需下发 expo-updates/自建热更、Metro 拆包 18
防抓包、防逆向、密钥不落地 加固、SSL Pinning、Keystore 19

学习成果自检(学完全书你应该能够)

  1. 画出 RN 新架构的线程模型与渲染管线图,并据此解释任意一个掉帧或内存问题的根因。
  2. 用数据驱动的方式做性能优化:建立指标基线 → 定位瓶颈 → 优化 → 验证收益,覆盖列表、内存、启动三大场景。
  3. 独立完成一个双端原生模块(TurboModule)与一个原生组件(Fabric)的封装、发布与文档化。
  4. 为大型项目做出状态管理选型与分层架构设计,并完成 Monorepo 模块化拆分。
  5. 设计并实现一套离线优先的同步引擎,处理增量拉取、冲突合并与数据一致性。
  6. 独立走完双端打包、灰度发布、热更新接入与安全加固的全流程,并能回答审核合规问题。

如果以上六条你都能自信回答「能」,你就具备了独立负责复杂生产级 RN 应用的能力水位。

📄 第 1 章 深入 RN 架构与线程模型 01-rn-architecture-threads.md

本章导读:本章是全书的地基。我们将从「点击图标到首帧」的完整链路出发,逐一拆解 Hermes、旧架构 Bridge、新架构三件套(JSI / TurboModules / Fabric)与线程模型,建立一套「任何现象先问在哪个线程」的分析框架。第 2 章的测量方法论、第 6~8 章的原生桥接、第 9 章的动画分层,全部建立在这次建立的心智模型之上。

本章学习目标

学完本章,你应该能够:

  1. 画出从进程创建到首帧上屏的完整启动链路图,并指出每个阶段所在的线程与常见瓶颈。
  2. 说清 Hermes 字节码的技术本质(AOT 编译、mmap 加载、无 JIT),并解释为什么 RN 把它设为默认引擎。
  3. 描述旧架构 Bridge 的消息队列与 JSON 序列化机制,用它的四个先天缺陷推导出新架构的设计动机。
  4. 解释 JSI 的 HostObject / CallInvoker 机制,说明「同步调用」为什么只在 JSI 下可能。
  5. 说清 Fabric 渲染器的 commit / mount 两阶段与不可变 Shadow Tree,指出布局计算所在线程的变化。
  6. 对任何一个常用 API 或回调,回答「它跑在哪个线程、阻塞它会影响什么」。
  7. 区分 JS 线程阻塞与 UI 线程阻塞在用户感知上的差异,并动手验证两者互不影响的现象。
  8. 判断一个三方库在新架构(bridgeless)下的兼容层级,并完成一个开启新架构与 TS 严格模式的项目骨架。

核心知识点

1.1 从点击图标到首帧:RN 应用运行时全景

原理讲解

RN 应用本质上是一个「原生容器 + 内嵌 JS 引擎」的程序。冷启动链路如下(以 RN 0.8x 新架构、bridgeless 模式为准):

点击图标
  │
  ├─ 1. 进程创建
  │     Android: Zygote fork 出应用进程;iOS: posix_spawn
  │
  ├─ 2. 原生容器初始化
  │     Application/AppDelegate 执行 → 创建 ReactHost/ReactInstance
  │     (0.8x 不再创建 Bridge,直接装配 JSI 运行时环境)
  │
  ├─ 3. JS 引擎加载
  │     dlopen libhermes → mmap 字节码文件(.hbc),无解析、无 JIT 编译
  │
  ├─ 4. 执行 bundle
  │     JS 线程跑模块注册表 → 逐个 require 入口模块 → App 组件首次 render
  │
  ├─ 5. commit
  │     React 在 JS 线程生成首棵 Fiber 树 → 经 JSI 交给 Fabric C++ 渲染器
  │     → Fabric 在后台线程完成 Yoga 布局,产出首棵 Shadow Tree
  │
  ├─ 6. mount
  │     UI/主线程按 Mutation 指令创建原生视图(android.view / UIKit)
  │
  └─ 7. 首帧上屏
        等待下一个 VSync 信号,GPU 合成完成后用户看到画面

【重点】这条链路里有三类成本:原生成本(进程、容器、引擎加载)、JS 成本(模块执行、首次 render/commit)、渲染成本(布局、mount、GPU 合成)。第 5 章做启动优化时会逐一拆分,本章先记住「链路长什么样、各阶段在哪条线程」。

还有一个 0.8x 特有的结构性变化值得知道:bridgeless 启动不再构造 Bridge 与其附属的批量调度器,原生侧由统一的 ReactHost/ReactInstance 装配「JS 引擎 + Fabric 渲染器 + TurboModule 注册表」三件东西。这意味着旧文档里「Bridge 初始化耗时」「模块配置构建耗时」这些经典启动瓶颈在新架构下整段消失——如果你的启动分析报告还在优化它们,说明照抄了过时的方法论。

Hermes 的技术本质

Hermes 是 Meta 为移动端设计的开源 JS 引擎,自 RN 0.7x 起成为默认引擎。它的关键不是「更快地跑 JS」,而是砍掉了桌面引擎为移动端付出的隐性成本:

  • AOT 字节码:打包阶段用 hermesc 把 JS 源码编译成 Hermes 字节码(HBC)。设备上不再有 JS 源码的词法/语法解析,也不需要 JIT 把热点函数编译成机器码——这两件事在低端 CPU 上动辄几百毫秒。
  • mmap 加载:字节码文件直接内存映射进进程,由操作系统按页换入,多个映射页可跨进程共享,天然省内存。
  • 无 JIT 的代价与收益:省掉了 JIT 代码页、元数据与预热期,内存占用显著下降;代价是峰值吞吐不如 V8 这类优化型引擎。移动端 80% 的场景是「短会话 + IO 等待」,峰值吞吐不是瓶颈,启动时间与内存才是——这就是 RN 默认内置 Hermes 的原因。
维度 Hermes JavaScriptCore(Safari 内核) V8
执行模型 AOT 字节码 + 解释执行 解释 + LLInt/Baseline JIT 解释 + 分层 JIT
启动方式 mmap 字节码,无解析 需解析 JS 源码 需解析 JS 源码
内存占用 低(无 JIT 代码页) 中 高
峰值吞吐 中 中 高
移动端启动表现 优 中 一般
调试协议 直接支持 CDP 依赖包装层 直接支持 CDP

💡 方向性提示:Meta 已公布 Static Hermes 方向(字节码进一步向 AOT 原生编译演进),写作时仍在演进中,关注官方发布即可,不影响本章心智模型。

两个与字节码直接相关的工程细节,现在记下、后面章节会反复引用:其一,release 产物是字节码而非源码,线上堆栈是地址而非行号——sourcemap 是把字节码地址翻译回源码位置的关键产物,它的版本必须与字节码严格对应(第 17、18 章的热更新与崩溃还原都依赖这条)。其二,字节码是构建期产物意味着「发出去的代码」和「仓库里的代码」之间隔了一层编译配置,排查线上问题时要以构建产物的构建 ID 为准,而不是以最新源码为准。

代码示例:验证你的应用确实跑在 Hermes + 新架构上。

// src/dev/envProbe.ts
export function probeRuntime(): { engine: string; bridgeless: boolean } {
  // Hermes 注入的内部命名空间:启用时存在,JSC 下为 undefined
  const engine = global.HermesInternal ? 'hermes' : 'other-jsc-like';
  // bridgeless 模式下 RN 会设置全局标记
  const bridgeless = (global as unknown as { RN$Bridgeless?: boolean }).RN$Bridgeless === true;
  if (__DEV__) {
    console.log(`[envProbe] engine=${engine} bridgeless=${bridgeless}`);
  }
  return { engine, bridgeless };
}

逐段讲解

  • global.HermesInternal:Hermes 在全局对象上注入的内部 API 命名空间(如字节码版本信息),是判断引擎最可靠的方式,比读包版本号靠谱。
  • RN$Bridgeless:bridgeless 启动时由 RN 运行时设置的全局布尔标记。它是「真的没有 Bridge」的运行时证据,第 3 节会解释 bridgeless 到底省掉了什么。
  • 建议把 probeRuntime() 接进启动日志甚至 CI 断言——环境悄悄退化(比如某次升级把引擎换回了 JSC)是真实项目里发生过的生产事故。
  • 两行探针比几百字文档可靠:配置页会骗人(hermesEnabled 写了但被构建变体覆盖),运行时全局标记不会。

1.2 旧架构 Bridge:异步消息队列与它的代价

原理讲解

要理解新架构,必须先理解它推翻了什么。旧架构(RN 0.68 之前默认)里,JS 与原生是两个完全隔离的世界,唯一的通道是 Bridge——一条基于消息队列的异步通道:

JS 线程                          原生线程(各模块队列)
   │                                   │
   │ 1. NativeModules.Foo.bar(x)       │
   │ 2. 参数 JSON 序列化               │
   │ 3. [模块ID, 方法ID, 参数, 调用ID]  │
   │    ──── 入队 message queue ────►  │
   │                              4. 反序列化 → 反射找到方法 → 执行
   │                                   │
   │  ◄──── 结果 JSON 再入队回传 ─────  │
   │ 5. 反序列化 → resolve Promise     │

它的四个先天缺陷:

  1. 异步,且只能是异步。JS 与原生不共享内存,靠 JSON 文本交换数据。一次调用的结果要等消息在队列里走完一整圈才能回到 JS,所以旧架构 API 全部是 Promise/回调形态,不存在同步返回值——你无法在 JS 里同步地问原生「这个视图现在多高」。
  2. JSON 序列化税。每次跨桥都要编码/解码。传 1000 条任务数据,序列化本身就能吃掉几十毫秒;图片二进制根本无法高效通过,只能传 URI 让原生自己解码。
  3. 批处理延迟。消息按批(batch)刷出,帧内连续多次调用可能被攒到下一个 flush 周期一起走。单个调用很快,但「一串依赖调用」的延迟是多次排队之和。
  4. 全量模块注册。启动时原生侧会把所有 NativeModule 的方法表打包成配置发给 JS,JS 侧也会实例化大多数模块。模块越多,启动越慢——你为一个扫码功能付出的启动税,和它是「偶尔才用」还是「首屏必需」无关。

代码示例:旧架构下的典型调用与它的天花板。

// 旧架构风格(今天仍存在于大量存量代码与 interop 库中)
import { NativeModules } from 'react-native';

interface LegacyScanner {
  // 只能异步:Bridge 机制决定了不可能有同步返回
  scan(timeoutMs: number): Promise<{ code: string | null }>;
}
const LegacyScanner = NativeModules.LegacyScanner as LegacyScanner;

// ❌ 痛点演示:想要「同步拿到设备常量」在旧架构下做不到,
//    只能在启动时先异步拉一次,再用全局变量缓存——时序 bug 的温床。
let deviceLocale: string | null = null;
async function warmup(): Promise<void> {
  const info = await LegacyScanner.scan(0); // 假设的示例方法
  deviceLocale = info.code;                  // 之后任何同步读取都可能拿到 null
}

// ❌ 痛点演示:大批量数据过桥,序列化税直接体现在 JS 线程耗时上
function sendAllTasks(tasks: Array<{ id: string; note: string }>): void {
  // 1000 条 × 每条几百字节 → JSON 编码 + 跨队列传 + 原生解码
  // 在低端机上这段「看似无辜」的调用可以让 JS 线程卡 30ms+,掉两帧
  NativeModules.LegacyScanner.batchUpload(JSON.parse(JSON.stringify(tasks)));
}

逐段讲解

  • 第一个例子说明异步-only 不是风格选择,而是机制约束:消息要跨线程排队、要等对方反序列化执行完再排队回来,同步语义在队列模型下无从谈起。
  • 第二个例子里的双重序列化(JSON.parse(JSON.stringify()))是很多存量代码的真实写法——开发者为了「干净地拷贝」而制造了双倍序列化税。
  • 【重点】今天你仍会遇到 Bridge 行为:大量老库在新架构下经 interop 适配层运行,行为特征(异步、慢、批处理)与旧架构一致。读懂旧 Bridge,才能读懂这些库的「历史行为」。

Bridge 时代还留下了一批「心智遗产」值得点名:getConstants() 这种「启动时一次性快照常量」的模式,本质是对「无法同步调用」的妥协;层层嵌套的回调金字塔,是在 Promise 可用前的无奈;「先发事件、JS 侧再拉一次全量数据」的补偿式同步,掩盖了通道不可靠的事实。维护存量代码时认出这些模式,能避免用新架构的标准去误判旧代码的动机——它们不是设计失误,而是当年唯一的选择。

新旧架构总对照:

维度 旧架构(Bridge) 新架构(JSI / TurboModules / Fabric)
通信模型 异步消息队列 进程内直接调用(可同步可异步)
数据格式 JSON 序列化 引擎内直接引用,零序列化
模块初始化 启动时全量注册 首次访问时懒加载
类型保障 无(字符串约定) Codegen 编译期强类型
布局计算 Yoga 跑在 JS 线程 Yoga 跑在 C++ 后台线程
同步测量 不可能 可能(同步 measure)
启动成本 随模块数线性增长 只为用到的模块付费
渲染器 原生 Manager + JS 驱动 C++ 共享 Shadow Tree

读这张表的方法不是背结论,而是顺着「通信模型的改变」推演其余每一行:数据不用序列化了 → 类型可以编译期对齐 → 模块不再需要启动期全量注册 → 布局可以从 JS 线程解放出来 → 同步测量成为可能。把因果链走通,这张表就长在你的推理里,而不是收藏夹里。

1.3 新架构三件套:JSI、TurboModules、Fabric

原理讲解

新架构不是「优化过的 Bridge」,而是换了通信的物理基础:JS 与原生运行在同一进程的同一内存空间里,中间隔着一层薄薄的 C++ 抽象——JSI。

JSI(JavaScript Interface):一个描述「JS 引擎能力」的 C++ 抽象接口。任何引擎(Hermes/JSC/V8)只要实现 jsi::Runtime,原生 C++ 代码就能:

  • 直接读写 JS 对象(jsi::Object、jsi::ArrayBuffer),不需要序列化;
  • 把 C++ 对象以 HostObject 形态暴露给 JS——JS 侧看起来是个普通对象,每次属性访问实际触发 C++ 的 get();
  • 用 CallInvoker 把「到 JS 线程上执行一段代码」抽象成可调度的任务,原生代码可以反过来调用 JS 函数。

同步调用为什么在 JSI 下成为可能?因为调用链是 JS 线程 → 直接函数调用 → C++(同线程)→ 返回,全程没有队列、没有跨线程、没有文本编解码。【重点】同步的能力边界也随之而来:同步调用会占住 JS 线程直到返回,所以同步 API 只适合纳秒~微秒级操作(读常量、同步测量),任何 IO 都不该做成同步。

把 C++ 作为共享层还有一层战略意义:一套实现服务双端。渲染器、模块注册、事件派发这些「与平台无关但与引擎强相关」的复杂度被收敛进 C++,Android 与 iOS 只在 MountingManager 和视图创建处保持平台差异。双端行为不一致的经典 Bug(同一份 JS 在两端渲染结果不同、事件时序不同)从架构层面被压缩了——C++ 层是同一份编译产物,差异只可能来自端上那一小截代码。

TurboModules:建立在 JSI 之上的模块系统。两个核心变化:① 懒加载——模块在 JS 首次 require 时才创建原生实例,未使用的模块零启动成本;② 强类型——JS 侧写一份 TypeScript spec,Codegen 在构建期生成双端胶水代码,类型错误从「线上运行时 undefined」提前到「编译不过」。

懒加载的价值随业务规模线性放大。旧架构下模块注册成本 = 模块总数 × 单模块初始化成本,业务每长一截(推送、统计、客服、地图……)启动就慢一截,而且没有任何一个业务方有动力为「别家模块」的启动耗时负责——典型的公地悲剧。懒加载把计费方式改成「谁使用谁付费」,FieldNote 这种「巡检核心 + 十余个外围能力」的应用受益最直接:首屏只装核心链路,扫码和报表导出的模块第一次被点到才创建。

Fabric:C++ 实现的渲染器。React 在 JS 线程 render 出的元素树,经 JSI 变成 C++ Shadow Tree(不可变,每次更新产生新 revision);布局计算(Yoga)移到 C++ 后台线程;最后在 UI 线程应用最小化的视图变更(Mutation)。因为 React 与原生共享同一棵 C++ 树,同步测量布局成为可能,这是旧架构梦寐以求的能力。

Codegen 的角色:构建期读取 TS spec,生成 C++ 结构体、Kotlin/Java 桩、ObjC(++) 桩。它产出的不是「运行时校验器」,而是双端真实参与编译的类型化代码——JS 与原生从此有一份共享的契约。

bridgeless 模式:0.76 起新架构默认开启后,应用启动不再构造任何 Bridge 实例,所有互操作直接走 JSI。对业务代码几乎透明,但意味着:legacy 模块的全量注册、旧式调试基础设施等历史机制彻底退场;存量原生模块通过 interop 层(旧组件被包装成 Fabric ComponentDescriptor、旧模块被包装成 TurboModule 接口)继续工作,能跑,但有适配税。

graph TB
    subgraph JS["JS 侧(Hermes,独立线程)"]
        REACT["React 19 渲染器<br/>render + commit"]
        APPJS["业务代码与依赖"]
    end

    subgraph CPP["C++ 公共层(双端共享同一套实现)"]
        JSI["JSI<br/>jsi::Runtime / HostObject / CallInvoker"]
        TM["TurboModule Manager<br/>懒加载注册表"]
        FAB["Fabric 渲染器<br/>不可变 Shadow Tree + Yoga 布局"]
        CG["Codegen 产物(编译期强类型胶水)"]
    end

    subgraph AND["Android"]
        AMM["MountingManager(UI 线程)"]
        AV["android.view 视图树"]
        NM1["Kotlin 原生模块"]
    end

    subgraph IOS["iOS"]
        IMM["MountingManager(主线程)"]
        UV["UIKit 视图树"]
        NM2["Swift / ObjC 原生模块"]
    end

    APPJS --- REACT
    REACT -->|"对象引用直传,零序列化"| JSI
    JSI --> TM
    TM --> NM1
    TM --> NM2
    JSI --> FAB
    FAB -->|"Mutation 指令"| AMM
    AMM --> AV
    FAB --> IMM
    IMM --> UV
    CG -.->|"约束"| TM
    CG -.->|"约束"| FAB

代码示例:一个最小的 JSI HostObject(C++,节选自典型 TurboModule 的底层形态;Kotlin/Swift 模块由 Codegen 生成的胶水接到这一层,第 7 章完整展开)。

// cpp/DeviceInfoHostObject.h —— 概念示例:把原生设备信息暴露为同步 JS 对象
#include <jsi/jsi.h>
#include <string>

using namespace facebook;

std::string getSystemModel(); // 由各端实现:Android 读 Build.MODEL,iOS 读 utsname

class DeviceInfoHostObject : public jsi::HostObject {
 public:
  // JS 侧每次属性访问都会进入这里——引用直达,没有任何 JSON
  jsi::Value get(jsi::Runtime& rt, const jsi::PropNameID& name) override {
    const std::string prop = name.utf8(rt);
    if (prop == "model") {
      // 直接在 JS 引擎堆上创建字符串,JS 拿到的就是普通 string
      return jsi::String::createFromAscii(rt, getSystemModel());
    }
    if (prop == "uptimeMs") {
      // 返回一个可被 JS 同步调用的函数(HostFunction)
      return jsi::Function::createFromHostFunction(
          rt, jsi::PropNameID::forAscii(rt, "uptimeMs"), 0,
          [](jsi::Runtime& rt, const jsi::Value&, const jsi::Value*, size_t) {
            const int64_t ms = uptimeMillis(); // 各端实现,纳秒级,可同步
            return jsi::Value(static_cast<double>(ms));
          });
    }
    return jsi::Value::undefined();
  }
};

逐段讲解

  • get():HostObject 的属性是惰性求值的——JS 每访问一次 DeviceInfo.model 才执行一次 C++ 分支,访问不存在属性返回 undefined 而不是抛错,这是它和普通对象的语义差异。
  • jsi::String::createFromAscii:字符串直接构造进当前 Runtime 的堆内存。对比旧架构:编码成 JSON → 跨线程 → 解码,路径完全消失。
  • createFromHostFunction:把 C++ lambda 变成 JS 可调用的函数对象。lambda 签名固定为 (Runtime&, this, args, count)。注意这里没有起线程、没有 Promise——调用是同步完成的,所以函数体必须极快。
  • 这个例子同时回答了一个设计问题:为什么新架构能提供「同步测量视图尺寸」这类 API——因为 measure 可以在 JS 线程直接进入 C++ 读共享的 Shadow Tree,无需跨线程询问。

再看 TurboModule 的 JS 侧 spec(Codegen 的输入,第 7 章详解全流程):

// specs/NativeScanner.ts —— 声明式契约,Codegen 据此生成双端代码
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  scan(timeoutMs: number): Promise<{ code: string | null }>;
  // 同步方法:仅限轻量操作,Codegen 会生成同步胶水
  isTorchAvailable(): boolean;
}

export default TurboModuleRegistry.getEnforcing<Spec>('NativeScanner');

逐段讲解

  • Spec extends TurboModule:方法签名就是双端契约。Android 侧 Codegen 生成同名 Kotlin 抽象类,方法参数/返回类型不匹配直接编译失败。
  • getEnforcing<Spec>:懒加载发生在这一步——首次调用才向 TurboModule Manager 要实例;模块不存在时抛错而不是返回 undefined,比旧版 NativeModules.X 的静默 undefined 友好得多。

1.4 线程模型:谁在跑你的代码

原理讲解

新架构下,一次典型交互涉及四类线程。先看全景(以一次「点击卡片 → 打分 → 界面更新」为例):

时间 ───────────────────────────────────────────────────────────────────────────►
UI/主线程      │收集触摸│─派发→│    空闲(等待 VSync)    │──mount 视图变更──│绘制+上屏│
渲染后台线程   │       │      │  Yoga 布局 + diff        │                 │         │
JS 线程        │       │      │ onPress → render+commit │                 │         │
原生模块线程   │       │      │ SQLite 读写 / 定位回调(与上面互不抢占)     │         │

各线程的职责与相互关系:

  1. JS 线程(Hermes):React 的 render 与 commit、全部业务逻辑、事件回调、setTimeout/Promise 都在这里。它卡了 = 交互响应与界面更新迟到,但已交给原生的东西(滚动惯性、原生驱动动画)不受影响。
  2. UI/主线程(Android main / iOS main):收集输入事件、执行 mount(把视图变更应用到原生视图树)、每 VSync 提交绘制。它卡了 = 整个画面冻结,包括系统动画——这是两种阻塞在用户感知上的本质差异。
  3. 渲染相关线程:【重点】新架构把 Yoga 布局从 JS 线程挪到了 C++ 后台线程(旧架构里布局跑在 JS 线程,这是历史上「JS 卡 → 布局也卡」的根源)。另外 Android 还有一个 RenderThread 负责把视图树转成 GPU 指令。
  4. 原生后台线程:NativeModule 方法体默认在各自队列/线程池执行(可指定主线程),IO 类操作都在这里,完成后经事件机制把结果投递回 JS 线程。

补充两个容易忽略的细节。其一是事件如何到达 JS:原生侧(比如传感器回调)通常发生在某个后台线程,RN 会把事件包装后投递到 JS 线程的消息队列排队——所以「高频原生事件」的真实成本不在原生侧而在 JS 侧队列,一个每秒 60 次的位置回调就能让 JS 线程应接不暇,第 6 章的节流设计正是为此。其二是线程阻塞的传染路径:JS 线程阻塞不会冻结 UI,但会推迟「下一批 mount」,于是交互反馈延迟;而 UI 线程阻塞会连 layout 结果的应用一起推迟。两者的修复手段完全不同,这正是 1.4 节判别法的价值。

代码示例 + 线程归属对照表:

API / 回调 所在线程 阻塞它的影响
组件 render、React commit JS 线程 交互响应延迟、更新迟到
onPress / onChangeText / Promise.then JS 线程 同上
setTimeout / setInterval / requestAnimationFrame JS 线程 定时器漂移、rAF 推迟
onLayout JS 线程(原生测量后派发) 收到晚,但不占原生时间
Animated + useNativeDriver: true UI 线程 几乎不受 JS 卡顿影响
Reanimated worklet / useFrameCallback UI 线程 同上
Gesture Handler worklet 回调 UI 线程 手势追踪不受 JS 阻塞连累
Fabric 布局(Yoga)与 diff 渲染后台线程 挂载晚一拍,通常不可感知
mount / 视图变更 / 绘制提交 UI 线程 整个界面冻结
NativeModule 方法体(默认) 原生后台线程 不直接影响界面
原生事件(Emitter)→ JS 回调 投递到 JS 线程 高频事件会打满 JS 线程

⚠️ 这张表是「活的文档」:RN 小版本可能调整个别 API 的调度细节(例如某次升级把某模块从专用队列挪到了线程池),升级后用 1.4 节的实验方法抽查关键路径,别把表格当永久真理。团队实践建议把它放进仓库 Wiki,谁发现偏差谁更新。

// src/dev/ThreadProbe.tsx —— 用最小实验验证「JS 阻塞不连累原生动画」
import { useEffect, useRef, useState } from 'react';
import { Animated, Easing, Pressable, Text, View } from 'react-native';

function busyLoop(ms: number): void {
  const deadline = performance.now() + ms;
  while (performance.now() < deadline) {
    // 纯 CPU 自旋:只占 JS 线程,不碰任何原生调用
  }
}

export function ThreadProbe() {
  const nativeAnim = useRef(new Animated.Value(0)).current; // 将交给原生驱动
  const [label, setLabel] = useState('点击后观察:按钮文字 2 秒后才变');

  useEffect(() => {
    // 关键:useNativeDriver: true → 每帧插值运行在 UI 线程
    const seg = (v: number) =>
      Animated.timing(nativeAnim, { toValue: v, duration: 700, easing: Easing.linear, useNativeDriver: true });
    Animated.loop(Animated.sequence([seg(1), seg(0)])).start();
  }, [nativeAnim]);

  return (
    <View style={{ padding: 24, gap: 16 }}>
      <Animated.View
        style={{
          width: 60, height: 60, borderRadius: 30, backgroundColor: '#2563eb',
          transform: [{ translateX: nativeAnim.interpolate({
            inputRange: [0, 1], outputRange: [0, 220],
          }) }],
        }}
      />
      {/* opacity 同样支持原生驱动;上面的方块在 JS 忙碌时仍应平滑移动 */}
      <Pressable
        onPress={() => {
          setLabel('JS 忙碌中……');
          busyLoop(2000);
          setLabel('JS 恢复了');
        }}
      >
        <Text>{label}</Text>
      </Pressable>
    </View>
  );
}

逐段讲解

  • busyLoop 自旋 2 秒,期间 JS 线程完全无法处理任何回调。
  • 蓝色方块的位移动画用 useNativeDriver: true:JS 只在启动时把动画描述(曲线、时长、起止值)交给原生,之后每一帧的插值由 UI 线程自己算。所以 JS 忙碌期间动画照常平滑——跑一遍这个实验,把现象刻进肌肉记忆。
  • 反过来把 useNativeDriver 改成 false 再跑:插值每帧回 JS 线程执行,JS 忙碌时方块瞬间冻结。同一个动画,驱动线程不同,阻塞表现完全不同——这正是第 9 章动画分层的前置知识。
  • 按钮文字「2 秒后才变」则是 JS 线程阻塞的直接用户感知:onPress 的 setState 要等自旋结束才被处理。

1.5 渲染管线:render → commit → mount

原理讲解

React 官方把渲染分为两个阶段,在 RN 里再加上原生侧的两步,构成完整管线:

sequenceDiagram
    autonumber
    participant U as UI/主线程
    participant J as JS 线程(Hermes)
    participant F as Fabric C++(后台布局)
    U->>U: 收集触摸事件(InputDispatcher / UIKit)
    U->>J: 经事件分发把触摸送达 JS 队列
    J->>J: onPress / setState 触发 render(可中断,可并发)
    J->>J: commit:不可中断,产出 Fiber 树与 EffectList
    J->>F: 经 JSI 提交新 Shadow Tree revision
    F->>F: Yoga 布局计算(后台线程)
    F->>F: 与旧树 diff,产出最小 Mutation 集
    F->>U: mount:在 UI 线程应用视图操作
    U->>U: 绘制并等待下一个 VSync 上屏
  • render 阶段:计算「新的 UI 应该长什么样」。React 19 并发模式下这个阶段可以被中断、按优先级重跑,比如高优先的输入可以打断低优先的大列表重渲染。
  • commit 阶段:把 render 的结果一次性生效(更新 refs、执行 layout effect)。这个阶段是同步不可中断的,所以「一次 commit 太大」会直接表现为 JS 线程长任务——第 2 章的 Profiler 测的就是它。
  • Fabric 的两步:JS commit 之后,C++ 侧拿到一棵新的不可变 Shadow Tree revision;布局与 diff 在后台线程完成后,UI 线程只做「应用最小变更」这一件事。不可变树的意义:diff 过程读的是旧树的稳定快照,不会被并发的修改弄脏;同时任何时刻都存在一棵「当前已挂载树」,同步测量读它即可,不用加锁等待。

「diff 出最小 Mutation 集」值得多说一句:Fabric 比较的是新旧两棵 Shadow Tree 的节点差异,mount 指令按「创建 → 更新 → 删除 → 移动」归类后批量应用。这意味着 React 一次 render 即使牵涉几百个组件,只要最终布局结果相同,原生侧可能一条视图指令都不需要——JS 层的重 render 不必然等于原生的重 mount,两者要分开测量(第 2 章的 Profiler 测前者,Perfetto 看 mount 段)。这也解释了为什么「把大组件树拆细」在 RN 里通常无害:diff 粒度变细不直接增加 mount 成本。

React 19 并发特性在 RN 的落点:并发渲染在旧架构上无法安全工作(Bridge 的异步 mount 没有时序保障),新架构才完整支持。对业务最有感的是过渡更新:

// 场景:1000 条任务按关键词过滤,输入必须跟手,过滤可以慢半拍
import { useDeferredValue, useMemo } from 'react';

function useFilterTasks(all: Task[], keyword: string): Task[] {
  // deferred 在「紧急更新」(输入)之后才重算:
  // 输入框每敲一个字先立即回显,重过滤被排到下一轮 render
  const deferredKeyword = useDeferredValue(keyword);
  return useMemo(
    () => all.filter((t) => t.title.includes(deferredKeyword)),
    [all, deferredKeyword],
  );
}

逐段讲解

  • 没有 useDeferredValue 时:每敲一个字符 → setState → 同步重过滤 1000 条 + 整表 render → 输入框回显被拖到几十毫秒后,低端机上肉眼可见「打字滞后」。
  • 加上之后,React 把「输入回显」视为紧急更新立即处理,把「重过滤」作为过渡更新让路,JS 线程不再被一次大计算阻塞——这是 React 19 在 RN 里最实用的并发能力之一。
  • 💡 另一个落点是 useTransition 包裹路由切换/Tab 切换等大块更新,原理相同:把大更新标记为「可等待」。

两者怎么选:useDeferredValue 适合「同一个值的两档优先级」(输入框显示用新值、列表过滤用旧值),改动只发生在数据派生处;useTransition 适合「一批状态更新整体降级」(整页切换、批量打分提交),改动发生在触发处。共同的前提是被降级的渲染必须幂等且无副作用——render 阶段可能被重跑多次,任何写存储、发请求的逻辑放进过渡更新都是定时炸弹。

1.6 新旧架构共存:interop 层与迁移判断

原理讲解

0.76 起新架构是默认项,但生态不可能一夜切换,于是官方提供了 interop(互操作)层,让未经新架构改造的存量库继续工作:

  • 旧式原生组件 → 被包装成 interop ComponentDescriptor,挂进 Fabric 的组件注册表,视图操作照常走 mount,但丢失 Fabric 特有能力(如 State 驱动的高频更新)。
  • 旧式 NativeModule → 被包装成 TurboModule 接口,方法调用经适配层走原 Bridge 语义(仍是异步、仍有序列化税)。

由此可以给存量库分三个兼容层级,迁移评估时逐个归类:

兼容层级 特征 行为表现 决策
完全支持 提供 Fabric 组件 / TurboModule 实现,包内含 codegenConfig 原生性能,无适配税 放心用
经 interop 可用 仅旧架构实现,社区仍在维护 功能可用,通信走旧语义,有适配税 可用,关注升级计划
不再维护 长期无更新、issue 明确不支持新架构 随时可能在新版本上崩 换库或自研(第 7、8 章)

bridgeless 开启与验证要点:

  1. 0.8x 新建项目默认开启,不需要手动设置;存量项目升级时确认 gradle.properties 中 newArchEnabled=true(Android)、iOS Podfile 属性为对应默认值即可。
  2. 运行时用第 1.1 节的 RN$Bridgeless === true 做断言,别靠猜。
  3. 升级后重点回归:依赖旧 Bridge 行为的库(大量老版地图、推送、统计 SDK)是否工作;NativeModules.X 变 undefined 通常是该库未适配 interop 注册。
  4. 不建议任何新项目关闭新架构——旧架构已进入移除周期,新代码写它等于自建技术债。

interop 税怎么量化?一个实用做法:在开发环境对同一个原生方法做 100 次调用计时,比较「确认支持 Fabric/TurboModule 的库」与「经 interop 运行的旧库」的耗散差异;再结合第 2 章的帧采样观察高频调用场景(如扫码预览帧回调)是否引入掉帧。数字拿到手,才谈得上「先用着,等上游适配」还是「现在就换」。

迁移顺序上推荐「先应用、后依赖」:先把项目自身的代码与新架构对齐(本节的环境探针 + 第 7、8 章的自研模块),让团队的思维模型统一;存量三方库按依赖关键度排序逐个验证——关键路径的库(导航、手势、存储)优先,边缘功能库可以带着 interop 税活到有大版本升级的机会。一次性替换所有依赖的「大爆炸迁移」在真实项目里几乎总是以回滚告终。

典型业务场景:FieldNote 实战

对应里程碑 M1 项目骨架:新架构 + Hermes + TS 严格模式跑通双端。本节完成三件事:技术选型决策、骨架与严格模式配置、「架构体检」工具——用一段重计算验证线程模型,把本章知识变成可复现的证据。

场景 1:RN CLI 与 Expo 的选型决策

FieldNote 需要:自研 Fabric 相机取景器(第 8 章)、自研 TurboModule 扫码(第 7 章)、JS 热更新与多 Bundle 拆包(第 18 章)、按需下发巡检模块。逐项对照:

决策维度 RN CLI Expo(dev build / prebuild) 对 FieldNote 的权重
自研原生模块/组件自由度 完全自由 可行,但要绕 prebuild 配置,改动原生工程需 config plugin 高
多 Bundle / 自建热更新 Metro 分包完全可控 expo-updates 生态成熟但自建拆包路径受限 高
双端工程可维护性 全部自己扛 升级/权限/图标由 Expo 托管,省力 中
团队上手成本 需要 Android/iOS 工程能力 更低 低(团队有原生背景)
生态兼容 全量 极少数库受限 低
结论 选用 放弃 ——

决策记录(ADR)一句话:重度自研原生 + 自建热更链路的项目,选 RN CLI;纯业务 App 用 Expo 托管原生细节收益更大。 这不是优劣题,是匹配题。

另外提醒一点:选型是「现在的最优解」,不是终身契约。表里的权重随项目阶段变化——原型期 Expo 的托管价值最大,进入第 7、8 章的原生深水区后自由度的权重陡增。把「当初为什么这么选」写下来,一年后团队才不会在「要不要迁移」上靠印象吵架。

场景 2:项目骨架与严格模式配置

# 1. 初始化(写作时以官方 CLI 最新版为准)
npx @react-native-community/cli@latest init FieldNote
cd FieldNote

# 2. pnpm 管依赖(第 13 章会拆 monorepo,从现在开始统一工具链)
pnpm install
// tsconfig.json —— TS 5.x 严格模式全开
{
  "extends": "@react-native/typescript-config/tsconfig.json",
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,   // 索引访问返回 T | undefined
    "exactOptionalPropertyTypes": true, // 可选属性不允许显式传 undefined
    "noImplicitOverride": true,
    "verbatimModuleSyntax": true
  }
}
# android/gradle.properties —— 确认新架构开关(0.8x 默认即为 true,写出来是自我声明)
newArchEnabled=true
hermesEnabled=true

⚠️ 不同 RN 小版本的具体配置键可能有差异,以官方升级文档为准;配置只是声明,证据是运行时探针:

顺带解释为什么从第一天就用 pnpm:单体仓库阶段它解决「幽灵依赖」(代码引用了没声明的包),第 13 章拆 Monorepo 时它又是 workspace 协议的基础。工具链晚换一天,迁移成本涨一天——这一条在后续每个工程章节都成立。

💡 noUncheckedIndexedAccess 与 exactOptionalPropertyTypes 是被低估的两个开关:前者强迫你处理「list[0] 可能为 undefined」(第 14~16 章离线同步代码大量受益于这条),后者消灭「显式给必选属性传 undefined」的歧义。类型层把边界问题挡住,运行时才少一分崩溃。

// App.tsx 启动守卫(开发环境)
import { probeRuntime } from './src/dev/envProbe';

if (__DEV__) {
  const { engine, bridgeless } = probeRuntime();
  if (engine !== 'hermes' || !bridgeless) {
    // 配置漂移要在第一时间暴露,而不是在第 3 章测性能时发现
    console.error('[FieldNote] 环境不符合基线:', engine, bridgeless);
  }
}

场景 3:「架构体检」工具——验证线程归属

目标:30 行左右,往任何页面一挂,就能证明两件事——JS 重计算卡 JS 线程、原生驱动动画不受影响。它是 M1 验收材料的一部分,也将在第 9 章被复用为「动画是否真的跑在 UI 线程」的对照组。

// src/dev/ArchitectureProbe.tsx
import { useEffect, useRef, useState } from 'react';
import { Animated, Easing, Pressable, StyleSheet, Text, View } from 'react-native';

function busyLoop(ms: number): void {
  const deadline = performance.now() + ms;
  while (performance.now() < deadline) { /* JS 线程 CPU 自旋 */ }
}

export function ArchitectureProbe() {
  const spin = useRef(new Animated.Value(0)).current;
  const [trace, setTrace] = useState<string[]>([]);

  useEffect(() => {
    Animated.loop(
      Animated.timing(spin, {
        toValue: 1, duration: 1000, easing: Easing.linear, useNativeDriver: true,
      }),
    ).start();
  }, [spin]);

  return (
    <View style={styles.wrap}>
      <Text style={styles.env}>
        {`Hermes=${String(!!global.HermesInternal)} Bridgeless=${String(
          (global as unknown as { RN$Bridgeless?: boolean }).RN$Bridgeless === true,
        )}`}
      </Text>
      <Animated.View
        style={[styles.box, {
          transform: [{ rotate: spin.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }) }],
        }]}
      />
      <Pressable
        onPress={() => {
          setTrace((t) => [...t, `busy@${Math.round(performance.now())}`]);
          busyLoop(2000);
          setTrace((t) => [...t, `done@${Math.round(performance.now())}`]);
        }}
      >
        <Text>阻塞 JS 线程 2 秒</Text>
      </Pressable>
      {trace.map((line) => <Text key={line}>{line}</Text>)}
    </View>
  );
}

const styles = StyleSheet.create({
  wrap: { padding: 24, gap: 12, alignItems: 'center' },
  box: { width: 64, height: 64, borderRadius: 32, backgroundColor: '#2563eb' },
  env: { fontFamily: 'monospace' },
});

预期现象(请在真机上验证并截图存档,这是 M1 验收材料):

观察项 busyLoop 执行期间 busyLoop 结束后
蓝色方块旋转(原生驱动) 继续平滑旋转,无视 JS 忙碌 不变
按钮/文字响应 点击无反应,按压态延迟 立即恢复
trace 两条日志时间差 —— ≈ 2000ms
开发菜单 Perf Monitor 的 UI FPS 稳定接近满帧 ——
Perf Monitor 的 JS FPS(如有显示) 明显下降 恢复

💡 把「Perf Monitor 双 FPS」与现象对应起来:UI FPS 掉 = UI 线程问题;UI FPS 稳但交互迟钝 = JS 线程问题。这个判别法是第 2 章测量方法论的第一个入口。若现象与预期不符(比如原生动画也掉了),先检查是不是低端真机过热降频——设备状态是第 2 章反复强调的测试条件变量。

M1 验收清单(全部满足才算通过,附录 B 的量化要求):

  • [ ] 真机双端安装启动成功,envProbe 输出 engine=hermes bridgeless=true;
  • [ ] tsconfig.json 严格模式全开,且仓库里没有任何新增的 any 断言(CI 可加 tsc --noEmit 门禁);
  • [ ] ArchitectureProbe 三项现象全部复现:原生动画不受 JS 阻塞影响、JS 回调延迟约 2 秒、trace 时间差 ≈ 2000ms;
  • [ ] 能脱稿画出四线程泳道图,并指出 render/commit/布局/mount 各自的位置;
  • [ ] RN CLI 与 Expo 的选型决策写进团队 ADR,附本次对比表。

常见陷阱

陷阱 典型症状 根因 正确做法
以为 setState 同步更新原生视图 onPress 里改 state 后立刻用 ref 量尺寸,拿到旧值 render/commit 在 JS 线程,mount 异步发生在 UI 线程,还有批处理 用 onLayout 回调或 Fabric 同步测量 API 拿布局;不假设「setState 后视图已变」
在 JS 线程做重活,误判为 UI 线程问题(或反之) 掉帧就先去优化动画;或动画卡了却去查 JS 逻辑 线程归属不清,两类阻塞用户感知都像「卡」 先看 Perf Monitor 双 FPS / 泳道图定位线程,再对症下药(见 1.4 节判别法)
拿旧 Bridge 文档照抄新架构代码 NativeModules.X 为 undefined;用了已废弃的双实例启动 API interop 注册方式与 TurboModule 不同,旧文裆描述的机制已移除 查库的新架构支持层级(1.6 节表格);新模块一律 spec + Codegen(第 7 章)
把「JS 驱动动画」与「原生驱动动画」的掉帧归属搞混 useNativeDriver: false 的动画卡顿,却去优化列表渲染 JS 驱动动画每帧插值都回 JS 线程,被业务 JS 卡顿连累 动画默认原生驱动或 Reanimated worklet;JS 驱动仅用于布局类无法原生驱动的属性
不理解 Hermes 字节码导致误判启动问题 以为优化 JS 首行代码能大幅提速;以为「解析 bundle」耗时巨大 Hermes 无解析无 JIT,启动瓶颈常在原生初始化与首帧 mount 用启动分段打点定位真实瓶颈(第 5 章方法论),再决定优化哪一段
把模拟器现象当真机结论 模拟器流畅、真机卡顿(或反之) 模拟器 CPU 指令转译、GPU 栈不同、无真实热节流与内存压力 低端真机为验收基准(FieldNote 非功能需求明确约定)
同步原生调用里做耗时 IO 调一个「同步读取配置」后触摸全迟滞几秒 同步调用占住 JS 线程直到返回,期间一切 JS 回调排队 同步 API 只放微秒级操作;IO 一律 Promise/事件(第 6 章通道选型)

最佳实践

  1. 一切性能问题先问「哪个线程」:回答不出线程归属的优化动作都是赌博。
  2. 新项目直接 bridgeless + 新架构起步,团队内禁止新增旧架构原生代码。
  3. 引入三方库前查新架构兼容层级(1.6 节三级分类),interop 级库要写进技术债清单。
  4. 动画默认原生驱动或 Reanimated worklet;每次写 useNativeDriver: false 都要在 Code Review 说明理由。
  5. 用 HermesInternal / RN$Bridgeless 做启动自检,CI 中断言环境基线,防止配置漂移。
  6. 同步 JSI 能力是稀缺资源:只用于常量读取、同步测量等微秒级操作,绝不放 IO。
  7. 把 Codegen spec 当作 JS 与原生的正式契约,禁止在业务里直接 as any 强转 NativeModules。
  8. 理解「模块懒加载」红利:非首屏依赖(如扫码、报表导出)延迟到首次使用时 require,启动只为你用到的模块付费。
  9. 维护一张团队级「线程归属速查表」(照 1.4 节表格裁剪),新成员入职必读。
  10. 验收一律以低端真机为准,模拟器只用于开发期快速迭代。
  11. 升级 RN 版本后回归线程行为与 interop 库表现——调度细节可能随版本调整,基线要重测(第 2 章会建立回归流程)。

本章小结

本章建立了全书的架构地基:从冷启动链路理解了 Hermes「AOT 字节码 + mmap + 无 JIT」的本质;用旧 Bridge 的四个先天缺陷(异步-only、JSON 税、批处理延迟、全量注册)推导出新架构的必然性;逐一拆解了 JSI(进程内引用与同步调用)、TurboModules(懒加载与强类型)、Fabric(不可变 Shadow Tree 与 commit/mount 两阶段)三大件,并给出四线程模型与「JS 阻塞不连累原生动画」的实验证据。最后在 FieldNote 里完成了 M1 骨架:选型决策、严格模式配置、架构体检工具。

一句话记忆点:新架构的本质是把「跨进程消息」换成「同进程引用」——从此性能问题先问一句话:这行代码跑在哪个线程?

动手练习

  1. 基础巩固:不看本节,默画 FieldNote 的冷启动链路图与四线程泳道图,标注「render / commit / Yoga 布局 / mount」各自所在线程;然后运行 ArchitectureProbe,把 useNativeDriver 改为 false 再观察一次,用两句话解释现象差异。
  2. FieldNote 实战:把 ArchitectureProbe 挂进 FieldNote 的 Debug 面板入口,开启开发菜单的 Perf Monitor,在真机上完成 busyLoop 实验并截图;将截图与 envProbe 输出整理成 M1 验收记录。
  3. FieldNote 实战:为 FieldNote 写一个 useRuntimeGuard Hook:开发环境下若非 Hermes 或非 bridgeless 则在屏幕顶部显示红色警告条;写 3 句话说明为什么这个守卫必须只在 __DEV__ 生效、且不能影响生产包体积。
  4. 思考题:为什么 JSI 能做同步调用而旧 Bridge 不能?提示:从「内存空间是否共享、是否跨线程队列、是否有序列化边界」三点展开。进一步想:这一差异对原生 API 的设计空间意味着什么——哪些能力(如同步测量)因此成为可能,又有哪些操作即使技术上可行也永远不该做成同步?

导航

上一章 前言 | 下一章 第 2 章 性能测量方法论与渲染原理

📄 第 2 章 性能测量方法论与渲染原理 02-performance-methodology.md

本章导读:第 1 章建立的线程模型回答了「代码跑在哪」,本章回答「跑得够不够快、怎么证明」。我们先建立帧预算与指标体系的第一性原理,再盘点 2026 年的工具链,然后深入 React 渲染模型与 re-render 治理——这是第 3~5 章(列表、内存、启动)所有优化工作的测量前提。没有本章的方法论,后面章节的每一个优化数字都不可信。

本章学习目标

学完本章,你应该能够:

  1. 用帧预算(16.7ms / 8.3ms)与 VSync 机制解释掉帧的本质,并区分 JS 线程掉帧与 UI 线程掉帧。
  2. 为项目定义一套生产级指标体系(TTR、FPS 分位数、内存水位、包体积、崩溃率),并给出量化达标线。
  3. 按问题类型选择正确的工具:React Native DevTools、React DevTools Profiler、Perf Monitor、Perfetto、Instruments、bundle 分析。
  4. 解释 React render/commit 两阶段模型,列出触发 re-render 的四种原因。
  5. 正确使用 memo / useMemo / useCallback,并能指出「比不优化更慢」的三种具体场景。
  6. 通过拆分组件边界、状态下放、Context 订阅粒度控制,把 re-render 影响面收缩到最小。
  7. 实现一个约 60 行的自建性能埋点模块(帧率采样 + 交互打点 + 分位数聚合 + 白名单上报)。
  8. 按照「基线 → 假设 → 单变量实验 → 验证 → 回归门禁」的闭环完成一次可复现的性能优化。

核心知识点

2.1 性能第一性原理:帧预算、VSync 与掉帧

原理讲解

一切渲染性能问题都可以归结为一句话:每一帧都必须在下一个 VSync 信号到来之前准备好。

  • 屏幕 60Hz 时刷新间隔是 16.7ms,120Hz 时是 8.3ms——这就是帧预算。注意 120Hz 设备的预算是减半而非不变,这就是为什么「在 60Hz 测试机上流畅的 App 到高刷屏上反而被抱怨卡」。
  • VSync(垂直同步)是显卡/显示控制器发出的节奏信号。Android 侧由 Choreographer 在每个 VSync 回调里执行「输入 → 动画 → 绘制」三件套;iOS 侧由 CADisplayLink 驱动。系统在 VSync 前收齐本帧所有工作,合成后上屏。
  • 掉帧的本质 = 某一帧的准备(JS 计算 + 布局 + mount + 绘制提交)错过了 VSync 截止线。错过一帧,画面显示的还是上一帧内容;错过十帧,就是肉眼可见的冻结。

把第 1 章的线程模型代入帧预算,就得到两类掉帧:

类型 责任线程 用户感知 典型元凶
JS 线程掉帧 JS(Hermes) 交互响应迟钝、新内容「迟到」、打字滞后;已启动的原生动画和滚动惯性照常 重计算、大 commit、序列化、同步原生调用
UI 线程掉帧 UI/主线程 整个画面冻结,包括原生动画、系统转场 复杂视图层级 mount、主线程 IO、过度绘制、布局抖动

判别方法(承接第 1 章 1.4 节,这里是操作手册):

  1. 开发菜单打开 Perf Monitor,观察 UI 与 JS 两个 FPS:UI 掉 JS 不掉 → 查原生侧(层级、overdraw、主线程任务);JS 掉 UI 不掉 → 查 JS 侧(重计算、大 commit)。
  2. 两边同时掉 → 通常是一个重 JS 更新触发了一次昂贵的 mount,按链路(见 2.5 节时序图)逐段定位。

还有一个口径层面的细节:Apple 生态把「用户可感的掉帧」定义为 hitch(挂顿)——不是「这一帧超了预算多少毫秒」,而是「这一帧比预定显示时刻晚了多少毫秒」。两者不等价:略超预算的帧在合成器缓冲下用户可能无感,而晚点超过一帧周期的 hitch 是实打实的顿挫。本书统一用「帧间隔分布 + 长卡顿计数」作为跨端可比口径,但在 iOS 报告里应同时引用 Instruments 的 hitch 数据(2.3 节),它与 Apple 官方的性能验收指标对齐。 3. 怀疑定性结论时上系统级工具:Perfetto 看线程泳道、Instruments 看 hitches(2.3 节)。

代码示例:一个 15 行的帧间隔采样器,直观感受「帧间隔分布」而不是平均值。

// src/perf/watchFrames.ts —— 采样 N 秒内的 JS 线程帧间隔分布
export function watchFrames(seconds: number): Promise<{ p50: number; p90: number; worst: number }> {
  const gaps: number[] = [];
  let last = performance.now();
  const startedAt = last;
  return new Promise((resolve) => {
    const loop = (): void => {
      const t = performance.now();
      gaps.push(t - last);
      last = t;
      if (t - startedAt < seconds * 1000) {
        requestAnimationFrame(loop);
      } else {
        const sorted = [...gaps].sort((a, b) => a - b);
        const at = (p: number): number => sorted[Math.floor(p * sorted.length)] ?? 0;
        resolve({ p50: at(0.5), p90: at(0.9), worst: sorted[sorted.length - 1] ?? 0 });
      }
    };
    requestAnimationFrame(loop);
  });
}

逐段讲解

  • requestAnimationFrame 的回调由 JS 线程调度:理想情况下两次回调间隔约等于帧间隔(16.7ms),某次回调被推迟,说明 JS 线程在那段时间被占住了。所以它测的是 JS 线程的帧节奏,不能代表 UI 线程——这个局限在 2.6 节的 PerfTracker 里会再次强调。
  • 返回 p50 / p90 / worst 而非平均值:平均值会把「59 帧流畅 + 1 帧冻结 1 秒」算成「平均 33ms」,而分位数与最大值让长尾直接现形。这就是 2.2 节指标口径问题的引子。
  • 采样本身有成本:每次回调要做一次 performance.now() 和一次数组 push,量级在微秒级,可以忽略;但不要在 loop 里做排序、格式化日志这类重活——采样器自己把线程占满,是埋点系统最常见的自杀方式。
  • 💡 120Hz 设备上理想 p50 应接近 8.3ms;如果你的采样结果明显偏离,先确认测试条件(见 2.6 节)再怀疑代码。

2.2 生产级指标体系:口径先于数值

原理讲解

性能指标最大的坑不是「没测」,而是「测了但口径错」。生产级指标体系要同时满足三条:口径明确(怎么算的)、设备与场景锚定(在哪测的)、阈值可验收(多少算过)。

各指标的统计口径:

  • 冷/温启动 TTR(Time to Render):冷启动 = 进程不存在,从点击图标到首屏「有意义内容」可见(对 FieldNote 是任务列表首屏卡片渲染完成,不是白屏或骨架屏);温启动 = 进程存活但页面重建。TTR 用打点计算:原生侧进程创建时间(Android 可取 Application 构造时刻,iOS 取进程启动时刻)为起点,JS 侧首屏 onRender 为终点。均值同样骗人——报告里报 P90。
  • FPS 的统计口径:⚠️ 会话平均 FPS 是最常被滥用的指标。正确口径是:掉帧计数(帧间隔超过 2 倍预算的帧数 / 总帧数)+ P90/P99 帧间隔 + 长卡顿次数(间隔 > 100ms,或 iOS 的 hitch 次数)。均值会被「大量流畅帧」稀释,一次 700ms 冻结摊进 60 秒里几乎不可见。
  • 内存水位:Android 用 PSS(可从 adb shell dumpsys meminfo 读取),iOS 用 footprint(Instruments / os_proc_available_memory 相关口径)。记录场景峰值与增长斜率(如反复进出拍照页的增量),单点数值意义有限。
  • 包体积:下载体积口径(Android 按 AAB 分发后用户实际下载的 split APK 体积,iOS 看 IPA 压缩后关键段),不看构建产物裸大小。
  • 崩溃率:crash-free users / crash-free sessions 双口径都记;并区分 JS 崩溃与原生崩溃。

FieldNote 达标线数值表(依据附录 B 非功能需求展开,作为后续所有章节优化的验收基准):

指标 口径 达标线 测量工具
冷启动 TTR 点击图标 → 任务列表首卡片 onRender,中端安卓机 P90 ≤ 1.5s 启动打点 + Perfetto 复核
温启动 TTR 进程存活 → 列表可见,P90 ≤ 600ms 同上
列表滚动流畅度 60Hz 真机滚动 60s:帧间隔 > 33ms 的帧占比 P95 会话 ≤ 5% PerfTracker + Perfetto
高刷设备表现 120Hz 设备同口径 帧间隔 > 16.7ms 帧占比 ≤ 8% 同上
拍照页内存 反复进出 20 次的 PSS 增量 ≤ 30MB dumpsys / Instruments
长卡顿 30s 高频操作内间隔 > 100ms 的 JS 长任务 ≤ 2 次 PerfTracker
基础包体积 Android AAB 下载体积 ≤ 30MB bundle 分析(第 5、18 章)
稳定性 crash-free users(30 天) ≥ 99.5% 崩溃监控(第 17 章接入)

代码示例:TTR 的 JS 侧终点打点(起点在原生侧,第 5 章补全全链路)。

// src/perf/ttr.ts —— TTR 的 JS 侧终点打点
import { useCallback, useRef } from 'react';
import { perf } from './PerfTracker';

// 起点 mark('native_launch') 由原生入口在最早期调用
// (Android Application / iOS AppDelegate 经启动事件桥入,真实实现见第 5 章)
export function useTTR(screenName: string): () => void {
  const reported = useRef(false);
  const onRender = useCallback(() => {
    if (reported.current) return;
    reported.current = true; // 只上报首个有意义渲染,后续 re-render 不污染 TTR
    perf.measure(`ttr_${screenName}`, 'native_launch');
  }, [screenName]);
  return onRender; // 供 <Profiler onRender> 或首屏组件 useEffect 使用
}

逐段讲解

  • reported.current 保证只上报首个有意义渲染,后续任何 re-render 不污染 TTR。
  • 用 performance.now() 而非 Date.now():前者是单调时钟,不受系统时间跳变影响(对离线设备尤其重要——巡检员的手机时钟可能是错的)。
  • 【重点】TTR 的终点必须是「有意义内容」而不是 componentDidMount:用户感知的是「我能看到任务列表了」,不是「React 挂载完成了」。

最后把指标变成「可执行」的三条工程约定:① 按场景打标——同样的 FPS,任务列表滚动和拍照预览的达标意义不同,每条数据必须带场景标签(screenName + 操作类型),否则聚合出来的是噪声;② 控制采样率——全量上报帧数据会打爆带宽与存储,按会话抽样(如 5%)或只上报超阈值的坏样本;③ 隐私红线——埋点数据里不得包含任务内容、定位等业务数据,只保留性能数值与设备档次信息(第 19 章的合规要求同样约束性能埋点)。

没有后端配合时也不要因此搁置指标体系:PerfTracker 的快照可以先落到本地 MMKV,开发/测试阶段用脚本导出成表格人工汇总。指标体系的价值九成在「口径固定」,一成在「自动化」——口径今天不定,三个月后的所有数字都不可比。

2.3 工具链全景(2026 年现状)

React Native DevTools——官方主调试器。0.76 起基于 Chrome DevTools Protocol(CDP) 直连 Hermes,取代了此前的远程 JS 调试与 Flipper(Meta 已将其退役,不再随 RN 维护,新项目不应接入;老项目迁移时用 DevTools + 独立替代库覆盖原 Flipper 插件的能力)。能力:Console、带 sourcemap 的 Sources 断点、Memory 堆快照(第 4 章主力)、集成的 React DevTools 面板。 上手:Metro 运行中按 j,或开发菜单选「Open React Native DevTools」。 读图要点:Memory 面板做三次快照对比找泄漏;Console 里可执行 performance.now()、访问全局状态验证假设。注意部分面板(如 Network)能力以当前版本文档为准。

React DevTools Profiler——re-render 治理主力。火焰图展示一次录制内每个组件渲染了几次、每次渲染耗时多少,勾选「Record why each component rendered」可看到 re-render 原因(props 变化、Context 变化、父组件渲染)。 上手:DevTools 面板切到 Profiler → 点录制 → 操作界面 → 停止。 读图要点:先看「提交次数最多的组件」,再看单次 commit 耗时排行。⚠️ 为什么它会拖慢被测页面:Profiler 在开发模式下给每次渲染插桩计时,本身有可观开销,测出的毫秒数普遍偏大且不能代表 release——用它找「谁在重渲染、为什么」,绝对耗时用生产构建 + 自建打点测(2.5 节)。

why-did-you-render——re-render 告警器。原理:monkey-patch React.createElement,在每次渲染时对组件的 props 做浅比较,若「props 没变却重渲染了」则在 Console 打印原因。 上手:仅开发环境安装,wdyr.ts 中 whyDidYouRender(React, { trackAllPureComponents: true })。 读图要点与局限:只反映开发模式行为;对 hooks 内部状态、Context 粒度的判断有误报漏报;项目长期低维护。💡 2026 年的替代方向是 React Compiler(自动记忆化,RN 0.8x 可经 Babel 插件启用),它能消掉大量手写 memo,但不改变本章的推理方法——你必须先懂为什么要 memo,才能审出编译器覆盖不了的场景。 Perf Monitor——开发菜单内置的实时 FPS/RAM 悬浮窗,UI 与 JS 两列 FPS 分开显示。适合 30 秒内快速验证线程归属(第 1 章 1.4 节实验)。它是开发模式数据,只用于定性,不用于报告。

Android:Perfetto / systrace——系统级真源。帧生命周期、各线程泳道(主线程 RenderThread、JS 线程、GC)一目了然,RN 自身的 React 标记(render/commit 区间)也会出现在 trace 里。 上手(建议 debug 构建录制,user 版部分类别受限):

adb shell perfetto -o /data/misc/perfetto-traces/fn.pftrace -t 15s \
  sched freq idle am wm gfx view binder_driver
adb pull /data/misc/perfetto-traces/fn.pftrace .
# 打开 https://ui.perfetto.dev 拖入分析;也可用 Android Studio Profiler

读图要点:找到主线程的 doFrame 条带,超出 16.7ms 的帧向上下游追;JS 长任务看 JS 线程泳道的连续占用段。把 Perfetto 与自建打点关联的技巧:PerfTracker 的 mark 同时用 console.log 打一条带毫秒时间戳的日志,录制时打开 trace,就能在泳道里对齐「打点时刻 ↔ 线程活动」,快速回答「这 40ms 花在了哪条线程的哪一段」。

iOS:Xcode Instruments——Time Profiler 看 CPU 热点(哪个函数吃掉了帧预算);Core Animation Commits 看提交开销;Animation Hitches 模板直接给出 hitch 类型(commit hitch = 渲染前准备慢、render hitch = GPU 侧慢),这是 iOS 官方认可的「掉帧」统计口径,比自制 FPS 更权威。 上手:Xcode → Product → Profile(⌘I)→ 选模板;真机 release 或 profile 构建录制。

Metro bundle 体积分析——体积问题定位。

# 导出 release bundle 与 sourcemap
npx react-native bundle --platform android --dev false \
  --entry-file index.js --bundle-output /tmp/fn.android.bundle \
  --sourcemap-output /tmp/fn.android.map
# 用 source-map-explorer 或 react-native-bundle-visualizer 按「源文件」归因体积
npx source-map-explorer /tmp/fn.android.bundle /tmp/fn.android.map

读图要点:按体积排序看前 20 个模块;注意「导出 bundle 体积」≠「hermesc 字节码体积」≠「用户下载体积」,汇报统一用 2.2 节口径。分析结论要落到动作:体积大头是某个大依赖 → 找按需加载或替代库;是重复依赖 → 用 pnpm why 查版本分叉;是自己的代码 → 第 18 章的拆包才是正解,压缩混淆只是止血。

工具选型决策表:

三条工具卫生守则,比「选哪个工具」更重要:

  • 任何工具接入前先问「它会不会改变被测系统」——DevTools 长连接、Profiler 插桩都有开销;
  • 测量会话里保持「唯一变量是代码」:关掉无关悬浮窗、断点与冗余日志;
  • 每个结论注明「工具 + 构建类型 + 设备」三元组,缺一个都不算结论。
你面对的问题 首选工具 备选 / 补充
谁在多余 re-render、为什么 React DevTools Profiler why-did-you-render、React Compiler 审查
commit 耗时占比、组件渲染次数 React Profiler(dev)+ 生产构建自建打点 <React.Profiler> 编程式采集
JS 线程还是 UI 线程卡 Perf Monitor 双 FPS 定性 Perfetto 泳道 / Instruments 定案
JS 断点、单步、堆快照 React Native DevTools ——
掉帧根因到函数级 Perfetto(Android)/ Time Profiler(iOS) Animation Hitches
启动分段耗时 自建打点(2.2/2.6 节)+ Perfetto 第 5 章专章展开
包体积归因 source-map-explorer bundle-visualizer、第 18 章拆包
内存增长与泄漏 DevTools Memory 快照 第 4 章双端工具链

2.4 re-render 治理:渲染模型与 memo 的正确用法

原理讲解

React 的渲染分两阶段:render(调用组件函数、diff 出变更,可中断可并发)与 commit(把变更一次性应用到宿主,同步不可中断)。在 RN 里,commit 之后还有第 1 章讲过的「经 JSI 提交 Shadow Tree → Fabric mount」两步。「卡」通常不是 render 慢,而是一次巨大的 commit——Profiler 里那条又长又红的 commit 条带就是它。

触发组件 re-render 的只有四种原因:

  1. 自身 state / reducer 更新;
  2. 父组件 re-render(哪怕 props 完全没变);
  3. 该组件订阅的 Context value 变化;
  4. 外部 store 订阅返回了新引用(Zustand 的 selector、useSyncExternalStore)。

治理思路由此确定:让「变化」发生在尽可能小的子树里。手段按优先级:状态下放(拆组件)> 拆分 Context / 精确订阅 > memo。

代码示例:memo 的三个「比不优化更慢」反例。

// 反例 1:比较成本 > 渲染成本
// TaskCell 渲染只要 0.05ms,但 memo 的浅比较要遍历 18 个 props(含函数与对象)
// 每次 render 都白付一遍比较费——叶子小组件上 memo 是纯亏损
const TaskCell = memo(function TaskCell(/* 18 个 props */) {
  /* ... */
});

// 反例 2:依赖数组每次都变,缓存永远失效
function TaskSummary({ tasks }: { tasks: Task[] }) {
  // summary 每次都重算,还额外付出「存缓存 + 失效判断」的内存与 CPU
  // 比直接算多付一倍成本——memoize 一个「依赖必然变化」的值毫无意义
  const summary = useMemo(() => summarize(tasks), [tasks]);
  return <Text>{summary}</Text>;
}

// 反例 3:children 是每次 render 新建的元素,memo 完全失效
function Parent({ title }: { title: string }) {
  return (
    <HeavyFrame title={title}>
      {/* 子元素在 Parent 每次 render 时都是新对象,HeavyFrame 的 memo 形同虚设 */}
      <ExpensiveChild />
    </HeavyFrame>
  );
}

逐段讲解

  • 反例 1 定律:memo 只值得用在「渲染贵 + props 天然稳定」的组件上。判断标准是先量渲染成本(Profiler),再查 props 稳定性(是谁在传函数/对象字面量)。
  • 反例 2 的正确修法是把 tasks 的引用稳定下来(数据层做结构共享,见后文 TanStack Query 说明),而不是 memoize 结果。
  • 反例 3 的修法是把 children 变成稳定的元素引用(父级用 state/props 传入),或干脆让 HeavyFrame 的插槽走 render prop 且被 memo 包裹。
  • ⚠️ 引用稳定性是 re-render 治理的第一性原理:props / context value / store selector 只要每次都是新引用,下游全白 memo。这也是为什么第 12 章选型时强调 Zustand 的 selector 返回原始值或稳定引用。

组件边界拆分与状态下放:

// ❌ 高频 state 放在父节点:每敲一个字,标题、卡片全量 re-render
function NotePage() {
  const [note, setNote] = useState('');
  return (
    <View>
      <Header />
      <TaskCards />        {/* 无辜受累 */}
      <TextInput onChangeText={setNote} />
    </View>
  );
}

// ✅ 状态下放到唯一受影响的叶子:其余子树与 note 的更新完全无关
function NotePage() {
  return (
    <View>
      <Header />
      <TaskCards />
      <NoteInput />        {/* state 只在它自己的子树里流动 */}
    </View>
  );
}
function NoteInput() {
  const [note, setNote] = useState('');
  return <TextInput value={note} onChangeText={setNote} />;
}

Context 订阅粒度控制:拆分维度有二——按变化频率(低频的主题/设置与高频的草稿态分家)、按消费面(只被列表消费的状态不要进全局 Context)。value 必须经 useMemo 稳定,消费组件用 memo 收口。跨页面共享的服务端数据优先交给 TanStack Query 这类带结构共享的缓存层:数据没变时引用不变,天然免疫「引用抖动」(第 12 章展开)。

列表项 memo 策略(细节在第 3 章展开,这里给原则):

// 列表行:props 全部为原始值或「结构共享后稳定」的对象
const TaskCard = memo(function TaskCard({
  id, title, status, score,
}: { id: string; title: string; status: TaskStatus; score: number | null }) {
  return (/* ... */);
});

function TaskList({ tasks }: { tasks: Task[] }) {
  const openDetail = useCallback(
    (id: string) => navigation.navigate('TaskDetail', { taskId: id }),
    [navigation],
  );
  return (
    <FlashList
      data={tasks}
      renderItem={({ item }) => (
        <TaskCard
          id={item.id}              // 原始值:天然稳定
          title={item.title}
          status={item.status}
          score={item.score}
          // 不传整个 item,避免上游一次浅拷贝就让所有行失效
        />
      )}
    />
  );
}

原则:行组件的 props 尽量原始值化(拆出 id/title/status 传原始类型);回调按 id 派发而不是每行闭包一个函数;数据源引用稳定(结构共享)。三条同时成立,memo 才有收益。

顺带澄清一个与 memo 纠缠不清的伙伴问题——key。key 不影响单行是否 re-render,它决定「这行是不是同一个组件实例」:key 稳定,数据变化走更新路径;key 变了(比如用数组下标当 key 而列表头部插入了数据),整行卸载重建,状态丢失、动画重放,比 re-render 昂贵得多。第 3 章讨论列表虚拟化时会再回到这个话题,本章先记住:key 要用业务 id,memo 才有讨论价值。

拿到一份 Profiler 报告后,按这个顺序把观察翻译成治理动作:① 先看 commit 次数最多的组件——如果它渲染了 200 次,问题往往在「谁让它渲染」而不是「它渲染多贵」;② 再看单次 commit 耗时 Top1——它决定治理的收益上限;③ 对每个热点组件追问 2.4 节的四种原因各占几席——Context 与父级传导的,拆边界;自身 state 高频更新的,状态下放或防抖。这个顺序能避免最常见的资源错配:对着一个每秒渲染一次、渲染 0.3ms 的小组件反复 memo。

2.5 一次点击到屏幕更新的全链路:预算标定

把第 1 章 1.5 节的渲染管线换上预算视角。下图是排障时脑中应持有的地图——每个阶段的预算是工程经验值(非官方规范),用于判断「卡在哪一段」:

sequenceDiagram
    autonumber
    participant T as 触摸(UI 线程)
    participant J as JS 线程
    participant F as Fabric 布局(后台)
    participant V as mount(UI 线程)
    T->>J: 事件收集与派发
    Note over T,J: 预算 ~2ms(含队列等待)
    J->>J: onPress → setState → render + commit
    Note over J: 预算 ~8ms(本帧内完成)
    J->>F: 经 JSI 提交新 Shadow Tree
    F->>F: Yoga 布局 + diff
    Note over F: 预算 ~8ms(与 JS 并行)
    F->>V: 应用最小视图变更
    Note over V: 预算 ~4ms
    V->>V: 等待下一个 VSync 上屏(0~16.7ms)

读图要点:① render+commit 是「串行税」——它直接推迟用户看到反馈的时间;② 布局与 JS 并行是Fabric 的红利,但如果 JS 提交太晚,后台布局再快也要等;③ mount 段超预算通常是视图层级过深或一次性创建大量视图(第 3、4 章的战场)。这张图还有一层用途:给它配一条「现实观测记录」——每排查一个卡顿问题,就把各段的实测耗时记在图旁边,团队会慢慢积累出「本项目在目标机型上的真实预算分布」,它比任何通用经验值都值钱。

2.6 自建性能埋点:PerfTracker 模块

原理讲解

工具链解决「开发期定位」,生产质量还需要「线上真相」:用户真机上滚动到底掉不掉帧、哪个交互慢。自建埋点模块的设计分四层:

  1. 采样:requestAnimationFrame 间隔采样 JS 线程帧节奏(UI 线程帧率后续可用 Reanimated 的 UI 线程帧回调或原生 FPS 回调补齐,M11 用 Perfetto 交叉验证);
  2. 打点:mark / measure 记录关键交互区间(点击 → 接口 → 首帧);
  3. 聚合:环形缓冲 + 分位数,只留统计量与头部样本,控内存;
  4. 上报:白名单域校验(安全红线:出网请求必须限定目标域),本地留存防丢。

代码示例(完整模块,约 60 行):

// src/perf/PerfTracker.ts
type MeasureEvent = { name: string; durationMs: number; ts: number };
type JankEvent = { gapMs: number; ts: number };

const now = (): number => (global.performance?.now?.() ?? Date.now());

function percentile(sorted: number[], p: number): number {
  if (sorted.length === 0) return 0;
  return sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))] ?? 0;
}

class PerfTracker {
  private gaps: number[] = [];
  private janks: JankEvent[] = [];
  private measures: MeasureEvent[] = [];
  private marks = new Map<string, number>();
  private rafId = 0;
  private last = 0;
  private running = false;

  start(): void { // 帧间隔采样:JS 线程帧节奏
    if (this.running) return;
    this.running = true;
    this.last = now();
    const loop = (): void => {
      const t = now();
      const gap = t - this.last;
      this.last = t;
      this.gaps.push(gap);
      if (gap > 32) this.janks.push({ gapMs: gap, ts: Date.now() }); // >2 帧即卡顿
      if (this.gaps.length > 600) this.gaps.splice(0, 300);          // 环形缓冲
      if (this.running) this.rafId = requestAnimationFrame(loop);
    };
    this.rafId = requestAnimationFrame(loop);
  }

  stop(): void { this.running = false; cancelAnimationFrame(this.rafId); }

  mark(name: string): void { this.marks.set(name, now()); }

  measure(name: string, fromMark: string): number {
    const start = this.marks.get(fromMark);
    if (start === undefined) return -1;
    const durationMs = now() - start;
    this.measures.push({ name, durationMs, ts: Date.now() });
    return durationMs;
  }

  snapshot(): Record<string, unknown> {
    const sorted = [...this.gaps].sort((a, b) => a - b);
    return {
      gapP50: percentile(sorted, 0.5),
      gapP90: percentile(sorted, 0.9),
      gapP99: percentile(sorted, 0.99),
      jankCount: this.janks.length,
      worstGapMs: sorted[sorted.length - 1] ?? 0,
      measures: this.measures.slice(-20), // 只带最近的交互区间
    };
  }

  async flush(endpoint: string): Promise<void> { // 上报:域白名单防护
    const host = /^https:\/\/([^/]+)/.exec(endpoint)?.[1] ?? '';
    const allowed = (globalThis.__PERF_ENDPOINT_ALLOWLIST__ ?? []) as string[];
    if (!allowed.includes(host)) {
      if (__DEV__) console.warn('[PerfTracker] 上报域名不在白名单,已拦截:', host);
      return;
    }
    this.janks = this.janks.slice(-50); // 留头部样本,避免上报体膨胀
    await fetch(endpoint, { method: 'POST', body: JSON.stringify(this.snapshot()) });
  }
}

export const perf = new PerfTracker();

逐段讲解

  • now():优先 performance.now() 单调时钟;Hermes 提供该全局 API,Date.now() 仅兜底。
  • 环形缓冲 splice(0, 300):600 个间隔样本覆盖约 10 秒滚动,内存占用恒定——埋点模块自己不能成为性能问题。
  • gap > 32 即「连 2 个 16.7ms 预算都没赶上」,比「平均 FPS」诚实得多;120Hz 设备阈值应调到 > 16.7 并按设备刷新率配置。
  • measure 基于 mark 名而非显式传参,让跨模块打点解耦:启动链路在原生模块里 mark('native_launch'),列表页只管 measure。
  • flush 的白名单校验是安全红线:埋点上报是出网请求,目标域必须来自配置且命中白名单(全局 allowlist 由启动配置注入,第 13 章统一管理)。
  • gaps 只增不减的部分依赖环形缓冲兜底,但 measures 与 marks 是会话级小集合,无需淘汰;若要支持超长会话(如常驻后台采集),给 measures 也加上界。
  • 已知局限(写进模块注释):rAF 采样只反映 JS 线程帧节奏;UI 线程 FPS 需 Reanimated 帧回调(第 9 章)或原生侧 FPS 数据源,交叉验证用 Perfetto。

使用约定(团队层面):

  • 页面级采集在 useEffect 里 start/stop,全局共享单例,禁止每个组件各起一个采样循环;
  • mark 命名用常量收敛(如 Marks.nativeLaunch),杜绝散落的魔法字符串导致 measure 对不上;
  • snapshot 只读不改状态,flush 成功后由调用方决定是否重置,给上报重试留余地。

关于上报策略再补两条实战约定。其一,分级上报:snapshot 只在页面卸载、App 退后台或累计卡顿超阈值时触发 flush,正常运行期间零网络开销——性能埋点自己制造卡顿是最讽刺的事故。其二,坏样本优先:带宽紧张时优先上报 jankCount > 0 的会话与 P99 最差的场景,好样本的统计价值远低于坏样本——这是「分位数思维」在上报侧的自然延伸。

2.7 性能工作的组织方法:闭环才有真相

原理讲解

性能优化失败的首要原因不是技术,而是流程:拍脑袋改 → 同时改三处 → 「感觉快了」→ 两周后回退。正确流程是一条强制闭环:

flowchart LR
    A["建立基线<br/>同一设备 + release 构建"] --> B["提出假设<br/>单变量、可证伪"]
    B --> C["最小改动实验<br/>一次只改一处"]
    C --> D{"指标显著改善?<br/>同条件复测 N 次"}
    D -->|"是"| E["固化代码 + 写入 ADR<br/>纳入回归门禁"]
    D -->|"否"| F["回滚并记录负结果"]
    F --> B
    E --> A
  • 基线:先测后改。没有基线的优化无法证明收益,基线必须锁定条件:同一台设备、release 构建、相同电量温度、相同数据集(FieldNote 用 1000 条固定种子任务)。
  • 假设:把「我觉得列表卡」翻译成可证伪命题,如「假设 TaskCard 缺 memo 导致父级 state 变化时 240 行全量重渲染,贡献了 30ms commit」。假设里必须有线程与阶段归属(第 1 章 + 2.5 节地图)。好的假设还自带「预期数字」:预计治理后 commit 从 43ms 降到 10ms 以内——预测错了恰恰说明理解有偏差,这比「改了果然快了」学到的东西更多。
  • 单变量实验:一次只改一处。同时改了 memo 和拆 Context,就无法归因——归因失败意味着经验无法沉淀。
  • 验证:同条件复测多次取分布,看分位数而不是单次最好成绩;负结果也要记录(「memo 在这个组件上是负收益」和正结果一样有价值)。复测次数别少于 5 次:性能数据天然抖动,单次测量的「提升 10%」大概率是噪声;有条件时记录每次的原始分布,让「显著」有统计依据可查。
  • 回归门禁:把达标线变成 CI 检查——bundle 体积阈值(每 PR 比对)、PerfTracker 快照上报(灰度看 P90 趋势)。性能是会腐化的资产,没有门禁的优化会在三个月的迭代里悄悄蒸发。

为什么必须闭环:性能改动常常此消彼长(加 memo 省了渲染却加了比较与内存;拆包省了首屏却多了按需等待)。只有「基线 → 假设 → 实验 → 验证」的数字链能回答「净收益是否为正」,也只有门禁能让收益持续。

条件控制里最容易失守的是设备与系统状态。团队没有统一真机时,至少约定一款参考机型与一台「低端钉子户」(FieldNote 用 2023 年千元机档);云端真机平台可以作为补充,但其 CPU 调度与温控策略与手头真机有差异,跨平台对比时只比趋势不比绝对值。电量低于 20%、设备发烫、后台正在更新应用——这些都会让同一份代码跑出完全不同的数字。把测试条件写进基线报告的表头,是性能工作职业化的第一个标志。

典型业务场景:FieldNote 实战

对应里程碑 M2 性能基线:接入 PerfTracker,用 Profiler 定位并治理一次 43ms 的列表 commit,产出《FieldNote 性能基线报告》。下面按 2.7 节的闭环走完全程。

第一步:建立基线

固定条件:Pixel 系中端真机(60Hz)、release 构建、1000 条固定种子任务。种子数据的要点:用确定性随机(固定随机种子)生成 id / title / status / score,缩略图用几张本地固定图片轮换——这样每次测量的数据集逐字节一致,排除了「数据不同导致的假差异」,也方便日后把这份脚本直接复用为第 3 章列表测试的夹具。任务列表页接入 PerfTracker:

// src/screens/TaskListScreen.tsx
import { useEffect } from 'react';
import { perf } from '../perf/PerfTracker';

function TaskListScreen() {
  useEffect(() => {
    perf.mark('list_mount');
    perf.start();
    return () => perf.stop(); // 离开页面停止采样,快照随页面卸载输出
  }, []);
  // ... 列表实现(第 3 章展开)
}

30 秒滚动快照:gapP50=16.9ms, gapP90=41ms, gapP99=118ms, jankCount=27。基线落档,达标线(2.2 节表)判定:未达标。

第二步:用 Profiler 定位假设

React DevTools Profiler 录制「切换深色模式」操作,火焰图显示:TaskListScreen 与全部 240 个 TaskCard 在同一次 43ms 的 commit 中全量 re-render,「why rendered」标注为 Context 变化。翻代码,根因是一个巨型 Provider:

// ❌ 优化前:一个 Context 装下所有状态,value 引用必然随任一成员抖动
const AppContext = createContext<{
  settings: Settings;
  tasks: Task[];
  theme: Theme;
}>({ settings: {}, tasks: [], theme: lightTheme });

function AppProvider({ children }: { children: React.ReactNode }) {
  const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
  const [tasks, setTasks] = useState<Task[]>([]);
  // 改一个「字体缩放」开关 → value 新引用 → 订阅了 tasks 的列表也被拖下水
  const value = useMemo(
    () => ({ settings, tasks, theme: settings.dark ? darkTheme : lightTheme }),
    [settings, tasks],
  );
  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

假设成形:设置类低频状态与任务高频数据共用一个 Context,任何设置变化都迫使 240 行卡片重渲染,43ms commit 中约 38ms 来自卡片渲染。 责任线程:JS;责任阶段:commit。

第三步:单变量治理(拆 Context + memo 子树)

// ✅ 优化后:按变化频率与消费面拆分,消费端 memo 收口
const TasksContext = createContext<Task[]>([]);
const ThemeContext = createContext<Theme>(lightTheme);
// settings 使用面窄、变化频率低,独立成 Context;value 引用各自稳定

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [dark, setDark] = useState(false);
  const theme = useMemo(() => (dark ? darkTheme : lightTheme), [dark]);
  const value = useMemo(() => ({ theme, setDark }), [theme]); // 引用仅在 dark 变化时更新
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

// 卡片:props 原始值化 + memo(2.4 节三条原则)
const TaskCard = memo(function TaskCard({
  id, title, status, score,
}: { id: string; title: string; status: TaskStatus; score: number | null }) {
  const { theme } = useContext(ThemeContext);
  return (/* 按 theme 渲染,props 稳定则跳过渲染 */);
});

function TaskListBody() {
  const tasks = useContext(TasksContext); // 只订阅任务数据
  return (
    <FlashList
      data={tasks}
      renderItem={({ item }) => (
        <TaskCard id={item.id} title={item.title} status={item.status} score={item.score} />
      )}
    />
  );
}

改动的单一变量是「Context 结构与卡片 memo」,列表数据源、样式、业务逻辑零改动——保证归因干净。

第四步:验证与报告

同条件复测 5 次取中位数,优化前后对比:

指标 优化前 优化后 说明
切换深色模式的列表 commit 耗时 43ms 6ms Profiler 录制,dev 构建定性
该操作 re-render 组件数 248 13 Profiler 统计
30s 滚动 jankCount(gap>32ms) 27 4 PerfTracker,真机 release
gapP90(JS 帧间隔) 41ms 21ms PerfTracker
gapP99(JS 帧间隔) 118ms 63ms PerfTracker

结论写进《FieldNote 性能基线报告》(M2 验收物):达标线中「滚动流畅度」一项从 9% 帧超标降至 3.1%,判定达标;同时把「Context 拆分规范 + 卡片 memo 三原则」沉淀为团队的 Code Review 检查项,并在 CI 中保留 bundle 体积门禁——这就是 2.7 节闭环的完整落地。

这份基线报告的推荐目录,供你在自己的项目里复用:① 测试环境(设备型号、系统版本、构建类型、数据集种子);② 指标口径与达标线(引用 2.2 节表格);③ 基线数据(首测原始快照);④ 本次问题与假设(43ms commit 的归因链);⑤ 改动说明(单变量);⑥ 验证数据(优化前后对比表);⑦ 遗留问题与下次目标。报告的意义不在「汇报」,而在下一次性能腐化时,有人有据可查地知道标准在哪里。

💡 注意对比表里 dev 与 release 数据分开标注:Profiler 的 43ms→6ms 只用于定性归因,用户可感的收益由 release 真机的 PerfTracker 数据背书——两套数字各司其职,不可混用。

常见陷阱

陷阱 典型症状 根因 正确做法
只看平均 FPS 报告写着「平均 58fps」,用户却抱怨偶发冻住 均值被大量流畅帧稀释,一次 700ms 冻结摊进 60s 几乎不可见 用掉帧计数、P90/P99 帧间隔与长卡顿次数做口径(2.2 节)
用开发模式测性能 dev 下 30ms 的 commit,release 只有 8ms;优化「收益」虚高 dev 构建:插桩、额外校验、无字节码产物、sourcemap 全开 定性用 dev,定量一律 release/生产 profile 构建
无差别 memo 一切 加了一圈 memo 后反而更卡、内存上涨 比较成本 > 渲染成本;依赖数组每次变化使缓存失效;children 引用不稳定使 memo 无效 memo 只用于「渲染贵 + props 稳定」组件;先量渲染成本再动手(2.4 节三反例)
把 Profiler 显示的渲染耗时当作用户感知 「Profiler 说只有 5ms,为什么用户觉得卡」 Profiler 是 dev 插桩数据,且不含 Fabric mount/布局与 GPU 段 Profiler 用于找结构与原因;可感耗时用 release 真机打点 + 系统级工具
调试模式污染测量结论 连着 DevTools 测出的数据与断开后不一致 CDP 连接、Console 序列化、断点探针都占线程 测量时断开调试器、关闭 Perf Monitor 之外的悬浮窗
只在高端机验证 旗舰机全绿,千元机上列表滑动 40fps 高端机 CPU/GPU 余量掩盖了低效实现;FieldNote 目标用户是低端安卓 以 2.2 节达标线锁定的中低端真机为验收基准
优化不做单变量 同时改了 memo、拆了 Context、换了列表库,「快了 40%」 多变量叠加无法归因,无法沉淀可复用经验,回退时无法保留有效改动 按 2.7 节流程一次一处,负结果同样记录
用 FPS 均值做回归门禁 门禁全绿但线上卡顿投诉上升 均值口径对长尾钝感,腐化不可见 门禁指标用 P90/P99、掉帧计数与包体积等分布敏感口径

最佳实践

  1. 先测量后优化:任何优化动作之前,先拿出基线数字与达标线;「感觉快了」不是结论。
  2. 指标口径优先于数值:报告必须写明统计口径(分位数/计数)、设备、构建类型(release)、数据集。
  3. dev 构建只做定性归因,所有对外汇报的数字来自 release 真机。
  4. 定位问题按「Perf Monitor 定线程 → Profiler 定组件 → Perfetto/Instruments 定函数」三段推进,逐级收窄。
  5. memo/useMemo/useCallback 是手术刀不是创可贴:先确认渲染成本与引用稳定性,再决定是否使用。
  6. re-render 治理的优先级:状态下放 > 拆分 Context/精确订阅 > memo;引用稳定性是所有手段的前提。
  7. 服务端状态交给带结构共享的缓存层(TanStack Query),让「数据没变引用不变」成为默认而不是纪律。
  8. 埋点模块自身要恒定内存(环形缓冲)、恒定 CPU(低频采样),并遵守出网白名单红线。
  9. 每次优化写一条 ADR(假设、改动、数据、结论),负结果照记——这是团队的性能资产。
  10. 把达标线落进 CI 门禁(至少 bundle 体积;有设备实验室则加性能快照比对),没有门禁的性能收益会随迭代蒸发。
  11. 测试条件锁死:同一设备、相同电量温度、相同种子数据;换条件等于换基线。
  12. 120Hz 设备单独验证:帧预算减半会让 60Hz 上隐身的成本现形。

本章小结

本章建立了测量的第一性原理:帧预算(16.7ms/8.3ms)与 VSync 定义了「卡」的本质,掉帧必须区分 JS 线程与 UI 线程两种责任方。指标体系的核心是口径:FPS 只看分位数与掉帧计数,TTR 以「有意义内容」为终点,每条指标配设备与达标线(FieldNote 数值表)。工具链按问题选型:Profiler 找 re-render、DevTools 看运行时、Perfetto/Instruments 定函数级根因、bundle 分析管体积。re-render 治理的顺序是「状态下放 > 拆 Context > memo」,引用稳定性是一切的前提。最后用约 60 行的 PerfTracker 补齐线上真相,并以「基线 → 假设 → 单变量实验 → 验证 → 回归门禁」闭环完成了 FieldNote 任务列表页 43ms commit 的治理。

一句话记忆点:先有基线再有优化——没有分位数口径与闭环验证的性能工作,都是玄学。

动手练习

  1. 基础巩固:在真机(60Hz 与 120Hz 各一台,如只有一台则记录刷新率)上运行 2.1 节的 watchFrames,分别在「空闲」「快速滚动列表」「阻塞 JS 线程 2 秒」三种状态下采样,记录 p50/p90/worst,并解释为什么空闲状态 p50 也会略大于理论帧间隔。
  2. FieldNote 实战:把 PerfTracker 接入任务详情页,为「从列表点击 → 详情首帧可见」建立交互打点(mark 在 onPress,measure 在详情首帧渲染),真机 release 采集 20 次取 P90,与 2.2 节达标线对照并给出是否达标的结论。
  3. FieldNote 实战:给设置页接入 React DevTools Profiler,录制「切换深色模式」,找出所有非预期 re-render 的组件,参照 2.4 节手段治理(状态下放或拆 Context + memo),复测并填写一张与本章第四步同构的优化前后对比表。
  4. 思考题:为什么 P99 比平均值重要?请构造一个具体场景:某种性能问题的分布下,平均值显示「达标」而用户持续遭遇严重卡顿——写出该分布的形状(多少帧流畅、多少帧多慢),分别计算平均值与 P99 的差异,并说明你的回归门禁应该盯哪个数字、为什么。

导航

上一章 第 1 章 深入 RN 架构与线程模型 | 下一章 第 3 章 列表渲染优化

📄 第 3 章 列表渲染优化 03-list-optimization.md

本章导读:列表是 RN 应用最常见的性能重灾区,也是第 2 章测量方法论的第一个实战对象。本章先算清「一个 item 到底花多少钱」,再拆解 VirtualizedList 的窗口化与批处理机制、FlatList 的系统调参方法与 FlashList 的回收池原理,最后在 FieldNote 的 1000 条巡检任务列表上完成从卡顿到达标的完整改造,并为第 4 章的图片内存治理埋下伏笔。

本章学习目标

学完本章,你能够:

  1. 从 JS 组件树、Fabric 阴影树、原生视图树三层估算一份列表的渲染与内存成本,判断瓶颈在哪一层;
  2. 解释 VirtualizedList 的窗口化渲染、批处理调度、onEndReached 填充节奏与「空白区域」的形成机理;
  3. 针对 windowSize、initialNumToRender、maxToRenderPerBatch、getItemLayout、keyExtractor 等参数给出有依据的取值,而不是凭感觉抄配置;
  4. 说明 FlashList 单元格复用池为什么比窗口化更省,识别复用残留风险并知道如何化解,以及什么场景不该用 FlashList;
  5. 为列表数据设计稳定的 key、不可变更新与游标分页,并用 TanStack Query 的 infinite query 驱动触底加载与图片预取;
  6. 为含图片的列表设计缩略图尺寸、CDN 裁剪与稳定缓存 key,消除滚动闪烁;
  7. 在 ScrollView / FlatList / SectionList / FlashList 之间做出有依据的选型;
  8. 用帧率、空白率与内存数据验证列表优化效果,形成可复现的优化前后对照报告。

核心知识点

3.1 为什么列表是 RN 性能重灾区:一个 item 的双重账单

先建立一个关键认知:在 RN 里渲染一个列表项,成本发生在两层,而且是同时发生的。

  • JS 层:每个 item 是一棵 React 组件树。React reconciler 要为它创建 React 元素(描述性的对象树)、维护 Fiber 节点、生成 props 与样式对象。这些都是 JS 堆上的对象分配。
  • 原生层(Fabric,0.8x 新架构默认):每次提交(commit)会在 C++ 侧构建对应的 ShadowNode 树,由 Yoga 计算布局,然后在 UI 主线程 创建/更新原生视图(Android 的 View / iOS 的 UIView 及其图层)。旧架构里这一步还要经过 bridge 序列化;bridgeless 之后序列化没有了,但「JS 渲染 + C++ 布局 + 主线程挂载」这三笔账一笔都没少。

【重点】新架构带来的一个重要变化:滚动本身由原生滚动容器在主线程直接驱动,不再等 JS。所以列表出问题时,表现从旧架构的「整个列表冻住」变成了「滚动还顺,但内容迟到」——也就是空白区域(blank area)。你调优的目标函数也随之变了:旧架构看掉帧,新架构看空白率与内容上屏延迟,两者都要看。

用一张账单估算「1000 条全量渲染」的代价(每行按 15 个组件、约 10 个原生视图的中等复杂度估算,数值为量级参考,不是精确值):

成本层 花费的内容 1000 条全量渲染的量级估算
JS 渲染 React 元素、Fiber 节点、props/样式对象、闭包 约 1.5 万~3 万个 React 元素,JS 堆增量 30~90MB
Fabric 阴影树 C++ ShadowNode 节点 + Yoga 布局计算 同量级节点,布局耗时在低端机可达秒级
原生视图挂载 Android View / iOS UIView 及图层、绘制数据 1 万+ 视图,主线程 mount 阻塞,首帧慢数秒
图片(未治理时) 解码后的位图常驻原生内存 见第 4 章:一张原图即可吃掉 40MB+

结论很直接:全量渲染在千条级列表上不可行,必须虚拟化。但要注意,虚拟化只是把「一次性巨账」拆成「滚动过程中的流水账」——每次进出窗口都在反复创建和销毁 JS 组件树与原生视图,这会带来持续的分配压力与 GC 压力(第 4 章会回来算这笔账)。窗口化管的是「创建的频率」,而 FlashList 的回收管的是「创建的成本」,这是本章后两条主线。

3.2 VirtualizedList 原理深入:窗口化、批处理与空白区域

FlatList / SectionList 都建立在 VirtualizedList 之上。理解它,比背参数更重要。

窗口化(windowing):VirtualizedList 从不渲染全部数据,它维护两个东西——完整的数据数组,以及每个 item 的高度元数据(实测值,没有实测值时用平均值估算)。每次滚动事件到来,它根据当前偏移量计算出一个「渲染窗口」:窗口内必须渲染,窗口外只保留高度占位。windowSize 参数就是这个窗口的大小,单位是视口高度的倍数:默认 21,意味着窗口 ≈ 可见 1 屏 + 上方约 10 屏 + 下方约 10 屏。

假设 windowSize = 7(示意,每格 ≈ 1 个视口高度)

 数据方向(第 0 条在最上)
      │
      ▼
 ┌───────────────────────────────┐ ──┐
 │   上方缓冲区(≈3 屏)             │   │  已渲染但不可见:滚回来立即可见,
 │   [cell][cell][cell][cell]...  │   │  但持续占用 JS 堆与原生视图内存
 ├───────────────────────────────┤   │
 │   视口 viewport(1 屏)          │   ├─  渲染窗口 ≈ windowSize × 视口
 │   [cell][cell][cell][cell]...  │   │   (7/2 ≈ 上下各 3 屏)
 ├───────────────────────────────┤   │
 │   下方缓冲区(≈3 屏)             │   │  批处理在此区域按节奏补渲染
 │   [cell][cell][cell][cell]...  │   │
 └───────────────────────────────┘ ──┘
      │
      ▼  窗口之外:完全不渲染,只有高度元数据参与布局计算
   ························································

批处理渲染(batching):滚动停止或滚动事件到来时,VirtualizedList 计算「窗口内需要的 cell − 已渲染的 cell」,差额不是一次渲染完,而是分批:

  • 每个批次最多渲染 maxToRenderPerBatch 个(默认 10);
  • 批次之间间隔 updateCellsBatchingPeriod 毫秒(默认 50)再渲染下一批。

这样做的原因是帧预算:一次性渲染几十个组件可能阻塞 JS 线程几十到几百毫秒,期间所有触摸事件、其他更新全部排队。批处理把大块工作切片,用「总时长换响应性」。吞吐上限大致是 maxToRenderPerBatch / updateCellsBatchingPeriod(默认 10/50ms ≈ 200 cell/s),再乘以 JS 线程的空闲占比——记住这个公式,调参时它就是你的吞吐预算。

onEndReached 的填充节奏:当内容末端进入「距底部不足 onEndReachedThreshold × 视口高度」的范围时触发回调(内部有调用计数防止同一次触底重复触发)。触发后你在回调里请求数据、追加进数据源,VirtualizedList 重新计算窗口并继续批渲染。阈值太小 → 用户每次都滚到贴底才请求,必然看到 loading;太大 → 频繁提前请求,配合无防抖的 setState 就是请求风暴(见本章陷阱表)。

空白区域的根源:一句话——滚动速度超过了批渲染吞吐。快滚时每秒新增的需求可能超过 200 cell/s 的预算上限,加上 JS 线程可能正忙于其他工作(状态更新、图片回调),窗口内该渲染的 cell 排队等待,用户已经滚过去了,于是看到灰白底。两个放大因素:一是 Fabric 的 mount 在主线程排队,JS 渲染完了原生视图也可能迟到;二是没有 getItemLayout 时窗口边界的计算依赖高度估算,估算偏差越大,补渲染的定位越不准。缓解手段全部围绕「提高吞吐 + 降低需求速率 + 缩短定位时间」:调大批次、提前量取中、定高列表给 getItemLayout、用 FlashList 回收。

3.3 FlatList 调参实战:每个参数都要能答出「为什么」

先给总表,每行都是「默认值 → 会出什么问题 → 建议起点 → 为什么」,这是面试与 code review 的高频区:

参数 默认值 常见问题 建议起点 为什么
windowSize 21 21 个视口高度的内容常驻,内存高、离开窗口销毁再销毁重建开销大 7~13 每降 2 约省 1 屏内容;但降太狠空白率上升。中端机 7~10 是常见甜点区
initialNumToRender 10 首帧渲染了首屏之外的多余 item,TTI 变慢 与首屏可见数一致(8~12) 首屏之外的内容此刻用户看不到,渲染它们纯属浪费首帧预算
maxToRenderPerBatch 10 快滚时吞吐不足 → 空白 12~20 提高单批吞吐;过大则单批 JS 工作变长,反而阻塞交互帧
updateCellsBatchingPeriod 50ms 与大 max 组合时批次太密,挤压交互 40~60ms 与 16ms 帧预算博弈:批太密 = 连续长任务
onEndReachedThreshold 0(接近贴底才触发) 触底才请求,连续滚动必等待 0.3~0.5 用提前量换节奏;配合数据层去重防请求风暴
getItemLayout 未启用 定位靠累计高度扫描/估算,scrollToIndex 不准,滚动条跳动 定高列表必开 提供后 index→offset 为 O(1),且免测量;动态高度禁用(见陷阱表)
keyExtractor 取 item.key,否则 index index 键在头部插入/删除时全表错位重渲 业务唯一 id(task.id) React 按 key 对位 diff;稳定 key 是行组件 memo 生效与行内状态保持的前提

几点展开:

removeClippedSubviews 的机制与失效场景。 它的作用是:滚动时把离开可视区域的原生子视图从原生视图树上摘除(React 元素仍挂载在 JS 侧),省的是原生视图树与绘制的账,不是 JS 的账。为什么在虚拟化列表上基本没用?因为 VirtualizedList 已经把窗口外的东西整体卸载了,被窗口化的内容再摘一次等于重复劳动。它真正的失效/负作用场景:行内容溢出自身边界(阴影、角标、超出边界的绝对定位元素)、overflow visible、与变换动画组合时会出现误裁剪——「角标消失」「阴影断层」就是这么来的。⚠️ 不要把它当万能性能开关,虚拟化列表上保持默认关闭即可。

ItemSeparatorComponent 与外边距方案的成本差异。 分隔符有两种写法:

// 方案 A:ItemSeparatorComponent —— 每两行之间多一个组件实例 + 一个原生 View
const Sep = React.memo(function Sep() {
  return <View style={styles.sep} />;
});

<FlatList ItemSeparatorComponent={Sep} /* 其余省略 */ />

// 方案 B:行内 margin —— 0 个额外视图,0 个额外 React 元素
// row: { marginBottom: 8 }

1000 行的列表,方案 A 会多挂载约 999 个分隔符组件和 999 个原生 View,还各占一份布局计算;方案 B 把间距折进行本身的盒模型,成本为零。方案 A 的存在价值是语义化与「按下高亮分隔线」这类交互。⚠️ 无论哪种方案,ItemSeparatorComponent 必须传组件引用而不是 JSX 元素——传元素意味着每次父组件渲染都生成新的元素实例,RN 检测到变化后会把全部分隔符卸载重挂。

ListEmptyComponent / ListFooterComponent。 空态与页脚渲染在 item 窗口之外,不受窗口化影响。页脚最常见用途是触底 loading 指示器;同样建议传组件引用或用 useMemo 固定元素,避免每次数据更新重挂。

inverted 倒排列表(聊天场景)。 实现:RN 对列表容器和每一行同时施加 scaleY: -1 变换,两次翻转相互抵消视觉效果,但内容的排布锚点从顶部变成了底部——新消息追加在数组尾部,视觉上出现在最下方,且内容不足一屏时自动贴底。聊天室的标准写法:

<FlatList
  inverted
  data={messages}
  keyExtractor={(m) => m.id}
  renderItem={MessageRow}
  maintainVisibleContentPosition={{
    minIndexForVisible: 0,
    autoscrollToTopThreshold: 80,
  }}
/>

坑有四个:一是行内阴影、边框在部分 Android 设备上翻转错位;二是行内如果用了侧滑手势(第 11 章)、绝对定位的角标,方向都会跟着翻转,需要反向补偿;三是 onScroll 里的偏移语义跟着倒置,与 Reanimated 的滚动驱动联动手势时容易算错方向;四是 maintainVisibleContentPosition 与 inverted 的组合在不同版本上有过行为差异,上线前用真机实测确认。

多类型 item 与 getItemType。 异构列表(分组头 + 数据行、图文卡 + 纯文本卡)的核心问题:不同类型的行布局结构完全不同。VirtualizedList 提供 getItemType(按 item 返回类型标识),它的意义在两个层面:一是让高度估算按类型分开统计更准;二是 FlashList 这类回收型实现靠类型决定「这个 cell 复用哪个容器模板」——文本行复用文本模板,图文行复用图文模板(FlashList 各版本声明类型的 API 有差异,v1 是 getItemType,v2 有所调整,以官方文档为准)。实践上更推荐先用 TypeScript 联合类型把行模型建模清楚(见 FieldNote 实战),类型系统保证 renderItem 分支穷尽,getItemType 作为运行时提示配合使用。

stickyHeaderIndices 的局限。 它只能把「列表项本身」设为粘性项,意味着 header 必须是 data 的一个元素;局限有三:粘性项一旦滚出渲染窗口,窗口化机制与粘性逻辑可能打架,出现粘性头闪烁或脱落(多发生在无 getItemLayout 的动态高度列表);它不支持 SectionList 那种「分组语义」,多分组场景要自己拍平数据并维护索引;FlashList 对粘性头的支持随版本演进(v1 曾长期不支持),选型前查当版本文档。FieldNote 实战里我们采用「拍平 + 行类型」方案,粘性头交给分组语义更强的实现。

3.4 FlashList:从窗口化到单元格回收

FlatList 窗口化的隐含成本:滚出窗口的行被整体卸载,滚回来再重建。快滚一个来回 = 同一批行反复走「JS 创建组件树 → Fabric 建 ShadowNode → 主线程创建原生视图」全流程,伴随大量临时对象分配与 GC。FlashList(@shopify/flash-list)把策略换成 cell recycling(单元格回收):原生容器(承载一行内容的视图壳)不销毁,滚出视口时只卸载里面的 React 子树,容器本身归还到复用池;新行进入视口时,从池里取出同类型的空闲容器,把新行的 React 子树挂载进去。

为什么回收比窗口化更省?窗口化省的是「不渲染看不见的行」,但每次渲染可见新行仍要从零分配原生视图;回收把原生视图的分配次数压到「池子大小 + 少量扩张」量级——一屏 8 行,池子常态就是 10~20 个容器在循环使用。省下的三笔账:原生视图对象分配与挂载(主线程)、Yoga/ShadowNode 从零建树、以及大量 JS 临时对象带来的 GC 频率。快滚时差距最明显:FlatList 在补渲染,FlashList 在「换内容」。

flowchart TB
    S[新行滚动进入视口] --> Q{复用池中有同类型<br/>空闲 Cell 容器?}
    Q -- 有 --> R[取出容器<br/>卸载上一个 React 子树]
    Q -- 无 --> N[新建容器<br/>仅发生在池扩张时]
    R --> M[渲染新行 React 子树<br/>挂载进同一个原生容器]
    N --> M
    M --> L[布局 / 绘制 / 上屏]
    L --> O[该行滚出视口]
    O --> P[卸载 React 子树<br/>容器归还复用池]
    P --> Q

复用残留问题及化解。 回收的是容器,内容渲染与上屏之间存在时差,极端情况下容器里可能短暂残留上一个 item 的像素或组件状态(闪现旧图片、旧勾选态)。化解思路三条:行组件完全受控——可见性、选中态一律由 props(item 数据)决定,不在行组件内部用 state 存放视觉状态;key 稳定(业务 id),让 React 子树的 diff 结果精确;图片等异步资源必须配占位符,占位符本身也是受控渲染的一部分。还有一条容易被忽略:不要在行组件里做一次性副作用(如 mount 时往全局注册表塞东西),行组件的生命周期比 FlatList 的行短暂得多,它随时会被回收重用。

v1 → v2 的演进。 v1 时代有两个标志性配置:estimatedItemSize 必填(回收与布局估算都依赖这个平均尺寸,估得不准会导致定位抖动与空白),以及 masonry/recyclerLayout 相关的布局属性。v2 做了整体重写:不再强制 estimatedItemSize,布局与回收的自动估算显著增强,API 向 FlatList 进一步收敛,迁移成本大幅下降。具体属性增删与迁移清单以官方迁移指南为准——本书不逐项罗列易过期的细节,但迁移时的验证方法(对比空白率、帧率、内存三项指标)在 FieldNote 实战里完整演示。

与 FlatList 的迁移注意点:换 import;v1 迁 v2 时删掉 estimatedItemSize 及已废弃的布局属性;分隔符注意支持差异(历史版本建议直接用 margin 方案);getItemType 换成当版本的类型声明方式;onViewableItemsChanged 保持稳定引用(配 viewabilityConfigCallbackPairs 或 useRef 固定);最后务必真机回归「复用残留」——重点测图片行、带输入框的行。

什么时候不用 FlashList:item 总数少(<20,一屏多一点)时虚拟化和回收的收益趋近于零,FlatList/ScrollView 更简单;高度完全动态且列表很短、需要依赖内容自然撑开布局时,回收的换内容机制反而添乱;需要 ScrollView 独有能力(缩放、复杂下拉刷新、与头部视差联动)时不要硬套。工具服务于场景,不是新的一定更好。

选型总表(数据量按「单次进列表的数据规模」计):

方案 适用数据量 适用复杂度 动态高度 选择理由与代价
ScrollView + map < 50 条且全量可见 任意 无限制 零虚拟化成本、布局最自然;数据多即劣化最陡
FlatList 数百~数千 中 支持(估算驱动) RN 内置、生态与文档最全;快滚空白率是其短板
SectionList 数百条、强分组语义 中 支持 分组 + 粘性头开箱即用;本质仍是窗口化
FlashList 数百~万级 中高 支持 回收池,快滚与内存表现最优;需验证复用残留

3.5 数据层配合:key 稳定性、不可变更新、分页与预取

列表优化的下半场在数据层。渲染层做得再好,数据源每次都整体换引用,memo 全部失效。

key 稳定性与不可变更新。 三条铁律:key 用服务端唯一 id;本地乐观插入的临时行用 UUID 占位,同步回来后用稳定 id 替换(第 15 章的 Outbox 机制会系统讲这个衔接);更新走不可变路径,绝不变异原对象:

// 反例:原地变异。引用没变,React.memo 的浅比较全数失效,
// 所有行被判定为「没变」→ 该更新的行不更新;或整体换 data 导致全表 diff
tasks[index].status = 'done';
setTasks(tasks);

// 正例:不可变更新。只有目标行产生新引用,其余行原引用不变
setTasks((prev) =>
  prev.map((t) => (t.id === id ? { ...t, status: 'done' } : t)),
);

配合模块级 memo 化的行组件(见 FieldNote),数据更新时真正重渲的只有变化的行。

分页策略:游标 vs 偏移。 列表的数据源策略决定「加载多少、何时加载」:

维度 偏移分页(offset / page) 游标分页(cursor)
参数 page / offset + limit opaque cursor + limit
服务端实现 简单,LIMIT/OFFSET 即可 需要按排序键编码游标(如 serverVersion+id)
数据漂移 翻页期间有插入/删除 → 重复或漏项 天然免疫漂移,基于位置连续读取
随机跳页 支持 不支持(只能顺序前进)
深分页成本 OFFSET 越大越慢 恒定
适用 后台管理、可跳页场景 移动端无限滚动(本教程默认选择)

移动端无限滚动几乎总该选游标:FieldNote 的任务表以 serverVersion 单调递增排序,游标就是「上次读到的 serverVersion+id」,插入新任务不会造成重复。

触底预取与图片 prefetch。 数据页的预取由 TanStack Query 的 infinite query 承担(下一小节);图片的预取用 Image.prefetch(uri)——它触发原生图片管线把资源下载进原生缓存,等行真正渲染时命中缓存直接解码上屏,省掉的是「进窗口才发起网络请求」的串行等待。注意两点:prefetch 也要节制,把即将进入窗口的前 10~20 条预取即可,无差别 prefetch 全列表等于把网络和内存同时打爆(第 4 章的内存账会再次出现);prefetch 的 URI 必须与渲染时的 URI 逐字节一致,否则缓存完全白打。

列表数据源与状态库的配合。 服务端数据交给 TanStack Query(含缓存键、去重、后台刷新),客户端 UI 状态(筛选、选中)交给 Zustand,这是第 12 章的系统话题。本章只需要掌握 infinite query 驱动分页的写法(v5 语法,initialPageParam 为必填):

const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
  queryKey: ['tasks', status],
  initialPageParam: undefined as string | undefined,
  queryFn: ({ pageParam }) => taskApi.fetchTasks({ cursor: pageParam, limit: 50 }),
  getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  staleTime: 60_000,
});

// 渲染前拍平,useMemo 保证引用稳定,行组件 memo 才有意义
const rows = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);

fetchNextPage 自带并发去重(isFetchingNextPage 期间重复调用会被拦下),这正是本章陷阱表里「onEndReached 请求风暴」的标准解法。

3.6 图片列表专项:缩略图、渐进式与防闪烁

图片列表是列表问题与内存问题的交汇点,本章先解决「快与稳」,第 4 章解决「省」。

缩略图与 CDN 尺寸裁剪。 原则:请求的图片边长 = 显示尺寸(pt)× 屏幕倍率,再加少量冗余。例如 84pt 的方形缩略图、3x 屏,请求边长 252px,取 256px 整档。CDN 侧裁剪参数示例:?w=256&h=256&fit=cover&fmt=webp&q=70。为什么按物理像素算?因为屏幕倍率决定一个 pt 实际铺多少物理像素,请求小了模糊,请求大了白付解码内存——这直接连到第 4 章的公式:解码内存 = 解码宽 × 解码高 × 每像素字节数。

渐进式加载。 三种递进:服务端输出 progressive JPEG/AVIF,浏览器式逐行清晰;预生成极小的模糊占位(blurhash/thumbhash 一类),先渲染占位再淡入真图;RN 的 Image 自带淡入(Android 可调 fadeDuration),Android 端 Fresco 管线还支持渐进式 JPEG 开关。列表里最划算的是第二种:占位图尺寸固定、色彩稳定,滚动的视觉连续性最好。FastImage 一类封装库自带优先级与渐进加载能力,取舍见第 4 章 4.2。

稳定的 source 与缓存 key,防闪烁。 滚动时图片闪白,九成是缓存击穿:原生图片缓存按 URI(含全部 query 参数)作为 key,同一个 URI 命中缓存 → 直接复用;URI 变了 → 缓存未命中 → 重新下载、重新解码 → 闪白。三个常见击穿源:URL 里拼了时间戳或随机签名(每次渲染 URI 都不同);行复用时 source 对象每次 render 新建但内容一致(这个其实没事,RN 内部按 uri 字符串比对,真正致命的是字符串本身变);CDN 回源鉴权把签名放在 query 上且短时效过期。正确做法:URI 规范化为不含易变参数的稳定形式,鉴权信息放请求头或由 CDN 回源校验;列表行内 URI 一经生成不再变化。⚠️ 这一条同时是防闪烁与防内存抖动的开关:URI 不稳定 = 缓存条目无限增殖 = 第 4 章要抓的那类泄漏。

典型业务场景:FieldNote 实战

对应里程碑 M3:巡检任务列表页。需求:1000 条级任务、按 planDate 分组(今天/明天/历史)、每行带门店缩略图、下拉刷新、触底加载、点击进详情。性能目标(中端 Android 真机):快滚平均帧率 ≥ 55fps、空白率 < 5%、滚动 60 秒进程内存增量 < 200MB。

第一步:初版(常见错误写法合集)

// ⚠️ 反例:初版 TaskListScreen.bad.tsx —— 每一处都点名问题
import React, { useEffect, useState } from 'react';
import { ActivityIndicator, Image, ScrollView, Text, View } from 'react-native';

export default function TaskListScreenBad() {
  const [tasks, setTasks] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    loadAll();
  }, []);

  async function loadAll() {
    setLoading(true);
    const list = [];
    let cursor;
    // 问题 6:串行把 1000 条全部拉完才渲染,用户盯着空白等
    do {
      const page = await fetch(`https://api.fieldnote.example.com/tasks?cursor=${cursor ?? ''}`)
        .then((r) => r.json());
      list.push(...page.items);            // 问题 7:可变拼接,引用链混乱
      cursor = page.nextCursor;
    } while (cursor);
    setTasks(list);
    setLoading(false);
  }

  return (
    <ScrollView>
      {/* 问题 1:ScrollView 全量渲染 1000 条,无任何虚拟化 */}
      {tasks.map((task, index) => (
        <TaskRowBad key={index} task={task} />  {/* 问题 2:index 作 key */}
      ))}
      {loading && <ActivityIndicator />}
    </ScrollView>
  );
}

// 问题 3:行组件定义在模块层,但内部又……见下面的问题 4/5
function TaskRowBad({ task }) {
  // 问题 5:原图直出,无 CDN 裁剪参数,一张 4000×3000 的门店照片按原尺寸解码
  return (
    <View style={{ flexDirection: 'row', padding: 12 }}>
      <Image source={{ uri: task.storePhotoUrl }} style={{ width: 84, height: 84 }} />
      <View style={{ flex: 1 }}>
        <Text>{task.title}</Text>
        <Text>{task.planDate}</Text>
      </View>
    </View>
  );
}

问题清单:① ScrollView 全量渲染,1000 条无虚拟化;② index 作 key,头部插入新分组时全表错位;③ 数据一次性全拉,首屏等待时间 = 全部分页耗时;④(此例中)行组件虽在模块层,但典型初版常把它定义在 map 回调里,每次渲染产生新组件类型导致全行重挂;⑤ 原图直出,解码内存失控;⑥ 串行循环拉完才渲染;⑦ 数组可变拼接,配合 ② 使 memo 无从谈起。这是我在真实项目 review 中出现频率最高的组合拳。

第二步:FlatList 调参版

// TaskListScreen.flatlist.tsx(节选核心,完整版见第三步)
const FlatTaskRow = React.memo(function FlatTaskRow({ task, onPress }: {
  task: Task;
  onPress: (id: string) => void;
}) {
  return (
    <Pressable style={styles.row} onPress={() => onPress(task.id)}>
      <ThumbImage url={task.storePhotoUrl} />
      <View style={styles.rowBody}>
        <Text style={styles.title} numberOfLines={2}>{task.title}</Text>
        <Text style={styles.meta}>{STATUS_TEXT[task.status]}</Text>
      </View>
    </Pressable>
  );
});

function TaskListScreenFlat() {
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ /* 同第三步 */ });
  const tasks = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);
  const onPress = useCallback((id: string) => {
    navigation.navigate('TaskDetail', { taskId: id });
  }, [navigation]);

  return (
    <FlatList
      data={tasks}
      keyExtractor={(t) => t.id}                    // 修复 ②:稳定业务 id
      renderItem={FlatTaskRow}
      windowSize={9}                                // 21 → 9:内存换流畅的平衡点
      initialNumToRender={12}                       // 与首屏行数一致
      maxToRenderPerBatch={16}                      // 提高单批吞吐
      updateCellsBatchingPeriod={50}
      onEndReachedThreshold={0.4}                   // 提前 0.4 屏触发填充
      removeClippedSubviews={false}                // 明确关闭,交给虚拟化
      onEndReached={() => {
        if (hasNextPage && !isFetchingNextPage) fetchNextPage();  // 修复 ⑥⑦:增量 + 去重
      }}
    />
  );
}

注意行高不固定(标题两行、门店名长度不一),所以故意不用 getItemLayout——这正是「每个参数都要有为什么」:定高才配谈 O(1) 定位,动态高度强行给 layout 只会定位跳动。

第三步:FlashList 版(最终交付)

迁移到 FlashList 并加上分组拍平。完整可运行代码:

// src/features/tasks/TaskListScreen.tsx
import React, { useCallback, useMemo } from 'react';
import { Image, Pressable, StyleSheet, Text, View } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useNavigation } from '@react-navigation/native';
import { taskApi, type Task } from '../../api/taskApi';
import { useTaskFilterStore } from './useTaskFilterStore';

type Row =
  | { kind: 'header'; key: string; title: string }
  | { kind: 'task'; key: string; task: Task };

const STATUS_TEXT: Record<Task['status'], string> = {
  pending: '待巡检',
  in_progress: '进行中',
  done: '已完成',
};

/** 生成稳定的缩略图 URI:固定尺寸 + 固定参数,不含任何易变签名 */
function thumbUri(url: string | null, pt = 84): string | null {
  if (!url) return null;
  const px = Math.round(pt * 3); // 3x 屏:84pt → 252px,取 CDN 整档 256
  const base = url.split('?')[0]; // 规范化:剥掉历史遗留的易变 query
  return `${base}?w=256&h=256&fit=cover&fmt=webp&q=70`;
}

const GroupHeader = React.memo(function GroupHeader({ title }: { title: string }) {
  return (
    <View style={styles.header}>
      <Text style={styles.headerText}>{title}</Text>
    </View>
  );
});

const ThumbImage = React.memo(function ThumbImage({ url }: { url: string | null }) {
  const uri = useMemo(() => thumbUri(url), [url]);
  return (
    <View style={styles.thumb}>
      {uri ? (
        // 占位底色 + 固定尺寸 + 稳定 URI:防复用残留闪烁的两件套
        <Image source={{ uri }} style={styles.thumbImage} resizeMode="cover" fadeDuration={120} />
      ) : (
        <View style={[styles.thumbImage, styles.thumbPlaceholder]} />
      )}
    </View>
  );
});

const TaskRow = React.memo(function TaskRow({
  task,
  onPress,
}: {
  task: Task;
  onPress: (id: string) => void;
}) {
  return (
    <Pressable style={styles.row} onPress={() => onPress(task.id)}>
      <ThumbImage url={task.storePhotoUrl} />
      <View style={styles.rowBody}>
        <Text style={styles.title} numberOfLines={2}>{task.title}</Text>
        <Text style={styles.meta}>{STATUS_TEXT[task.status]}</Text>
      </View>
    </Pressable>
  );
});

const ListEmpty = React.memo(function ListEmpty() {
  return (
    <View style={styles.empty}>
      <Text style={styles.emptyText}>今天没有巡检任务</Text>
    </View>
  );
});

const ListFooter = React.memo(function ListFooter({ busy }: { busy: boolean }) {
  return (
    <View style={styles.footer}>
      <Text style={styles.footerText}>{busy ? '加载中…' : '— 到底了 —'}</Text>
    </View>
  );
});

export default function TaskListScreen() {
  const navigation = useNavigation<{ navigate: (s: 'TaskDetail', p: { taskId: string }) => void }>();
  const status = useTaskFilterStore((s) => s.status);

  const { data, hasNextPage, isFetchingNextPage, fetchNextPage } = useInfiniteQuery({
    queryKey: ['tasks', status] as const,
    initialPageParam: undefined as string | undefined,
    queryFn: ({ pageParam }) =>
      taskApi.fetchTasks({ cursor: pageParam, status, limit: 50 }),
    getNextPageParam: (last) => last.nextCursor ?? undefined,
    staleTime: 60_000,
  });

  // 分组拍平:header 与 task 混排为同一数据流,FlashList 按行类型选择容器模板
  const rows = useMemo<Row[]>(() => {
    const tasks = data?.pages.flatMap((p) => p.items) ?? [];
    const out: Row[] = [];
    let lastDate = '';
    for (const t of tasks) {
      if (t.planDate !== lastDate) {
        lastDate = t.planDate;
        out.push({ kind: 'header', key: `h-${t.planDate}`, title: formatGroupTitle(t.planDate) });
      }
      out.push({ kind: 'task', key: t.id, task: t });
    }
    return out;
  }, [data]);

  const onPress = useCallback(
    (id: string) => navigation.navigate('TaskDetail', { taskId: id }),
    [navigation],
  );

  const renderItem = useCallback(
    ({ item }: { item: Row }) => {
      if (item.kind === 'header') return <GroupHeader title={item.title} />;
      return <TaskRow task={item.task} onPress={onPress} />;
    },
    [onPress],
  );

  const onEndReached = useCallback(() => {
    // useInfiniteQuery 自带去重;这里的条件判断是第二道保险
    if (hasNextPage && !isFetchingNextPage) void fetchNextPage();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    <View style={styles.container}>
      <FlashList
        data={rows}
        keyExtractor={(row) => row.key}
        renderItem={renderItem}
        onEndReached={onEndReached}
        onEndReachedThreshold={0.4}
        ListEmptyComponent={ListEmpty}
        ListFooterComponent={<ListFooter busy={isFetchingNextPage} />}
        contentContainerStyle={styles.content}
      />
    </View>
  );
}

function formatGroupTitle(planDate: string): string {
  const today = new Date().toISOString().slice(0, 10);
  if (planDate === today) return '今天';
  const tomorrow = new Date(Date.now() + 86_400_000).toISOString().slice(0, 10);
  if (planDate === tomorrow) return '明天';
  return planDate;
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#F6F7F9' },
  content: { paddingBottom: 24 },
  header: {
    paddingHorizontal: 16,
    paddingTop: 16,
    paddingBottom: 8,
    backgroundColor: '#F6F7F9',
  },
  headerText: { fontSize: 15, fontWeight: '700', color: '#1F2430' },
  row: {
    flexDirection: 'row',
    alignItems: 'center',
    marginHorizontal: 12,
    marginBottom: 8, // 分隔用 margin,而非 ItemSeparatorComponent:省 999 个视图
    padding: 12,
    borderRadius: 12,
    backgroundColor: '#FFFFFF',
  },
  thumb: { width: 84, height: 84, borderRadius: 8, overflow: 'hidden', backgroundColor: '#E8EAEE' },
  thumbImage: { width: 84, height: 84 },
  thumbPlaceholder: { backgroundColor: '#D9DDE3' },
  rowBody: { flex: 1, marginLeft: 12 },
  title: { fontSize: 15, color: '#1F2430' },
  meta: { marginTop: 4, fontSize: 12, color: '#7A8291' },
  empty: { padding: 48, alignItems: 'center' },
  emptyText: { color: '#7A8291' },
  footer: { padding: 16, alignItems: 'center' },
  footerText: { fontSize: 12, color: '#7A8291' },
});

配套的 API 模块(体现出网白名单防护,详见第 17 章安全清单):

// src/api/taskApi.ts
const API_BASE = process.env.API_BASE_URL ?? '';
// ⚠️ 域名白名单:出网请求只允许命中白名单内的主机
const ALLOWED_HOSTS = new Set(['api.fieldnote.example.com']);

function assertAllowedHost(url: string): void {
  const host = new URL(url).host;
  if (!ALLOWED_HOSTS.has(host)) throw new Error(`blocked host: ${host}`);
}

export type Task = {
  id: string;
  storeId: string;
  planDate: string;
  status: 'pending' | 'in_progress' | 'done';
  title: string;
  storePhotoUrl: string | null;
};

export type TaskPage = { items: Task[]; nextCursor: string | null };

export const taskApi = {
  async fetchTasks(params: { cursor?: string; status?: string; limit: number }): Promise<TaskPage> {
    const qs = new URLSearchParams({
      limit: String(params.limit),
      ...(params.cursor ? { cursor: params.cursor } : {}),
      ...(params.status ? { status: params.status } : {}),
    });
    const url = `${API_BASE}/tasks?${qs.toString()}`;
    assertAllowedHost(url);
    const res = await fetch(url);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return (await res.json()) as TaskPage;
  },
};

三版对照与收益验证

示例数值(中端 Android 真机、冷启动后用第 2 章的方法测得,仅供量级参考,实际以你的机型实测为准):

指标 初版 FlatList 调参版 FlashList 版
首屏可交互时间 4.6s(拉完才渲染) 1.8s 1.5s
快滚平均帧率 24 fps 51 fps 57 fps
快滚帧时长 P95 180ms 33ms 26ms
快滚空白率 14% 6% 3%
滚动 60 秒后进程内存增量 +480MB +160MB +80MB

对照本章知识点复盘每一步收益来源:FlatList 版收益主要来自「虚拟化 + 调参」(windowSize 9、批次 16、稳定 key、margin 分隔、增量分页);FlashList 版的增量收益来自「回收池」(原生视图分配骤减 → 快滚帧率与内存双双改善)与「分组拍平后行类型明确 → 容器模板复用准确」。两张表合起来回答了本章的核心问题:先虚拟化,再调参,量级大或快滚多再回收。

常见陷阱

陷阱 典型症状 根因 正确做法
index 作 key 头部插入新分组后全表重渲;行内输入框内容串行 React 按 key 对位 diff,index 随位置漂移导致 key 错位、旧状态挂到新数据上 用业务唯一 id(task.id);乐观插入的临时行用 UUID 占位
getItemLayout 与动态高度混用 滚动条跳动、scrollToIndex 定位偏移、触底填充错位 声明的固定长度与实际高度不符,累计 offset 全部失真 动态高度不给 getItemLayout;确实需要定位时改用可测量的方案或 FlashList
renderItem 内定义组件或每帧新建闭包 滚动/父渲染时全行卸载重挂、输入框失焦、动画重启 每次渲染产生新的组件类型(引用不同),React 直接走卸载重建而非更新 行组件提升到模块层 + React.memo;回调 useCallback 固定引用
指望 removeClippedSubviews 解决一切 配了没效果,或出现角标消失、阴影断层 它只摘除离屏原生子视图,虚拟化列表本已窗口化,收益为零;对溢出边界的内容误裁剪 保持默认关闭;性能问题从虚拟化/回收/调参解决
onEndReached 中无节流地 setState 触发风暴 触底后连发多次请求、列表反复抖动 滚动事件高频触发回调,无去重;追加数据改变内容长度又可能再次触发 TanStack Query 的 fetchNextPage + isFetchingNextPage 判断;或本地布尔锁
同向嵌套 ScrollView 手势冲突、内层可滚外层不动或闪烁 同方向手势分发歧义,原生层无法判定归属 单一滚动容器;头部内容放 ListHeaderComponent;确需嵌套用正交方向
图片未用稳定 source 滚动时图片反复闪白、内存持续上涨 URI 含易变参数击穿原生缓存,复用行重新下载解码 URI 规范化(无时间戳/随机签名),CDN 参数固定,鉴权走请求头
ListFooter 等传内联元素 每次数据更新页脚闪烁重挂 每次渲染传入新元素实例,被判定为内容变化 传组件引用或 useMemo 固定元素

最佳实践

  1. 先用第 2 章的方法测出基线(首屏 TTI、快滚帧率、空白率、内存增量),再动手调参,每改一项复测一项;
  2. keyExtractor 永远返回业务唯一 id,数据模型里 id 是不可协商的字段;
  3. 行组件一律模块级定义 + React.memo,props 只传 item 数据与稳定回调;
  4. 定高列表才给 getItemLayout;动态高度列表把「高度变化」当成本列表的第一性能风险来管理;
  5. windowSize 从 21 往下调的每一步都用空白率验证,7~13 是多数中端机的甜点区;
  6. 图片先定「解码尺寸」再谈缓存:请求尺寸 = 显示 pt × 屏幕倍率,CDN 裁剪参数固定不变;
  7. 无限滚动优先游标分页,游标用排序键(如 serverVersion+id)编码,天然抗数据漂移;
  8. 预取距离 = 用户阅读当前屏的时间能覆盖的行数(一般 10~20 行),无差别预取全列表是反模式;
  9. 空态、页脚、分隔符都传组件引用,不让每次渲染产生新元素实例;
  10. 数据量上千或快滚频繁的场景直接上 FlashList;一屏内的短列表不要过度优化;
  11. inverted 列表上线前专项测试:侧滑手势方向、阴影边框、offset 语义、maintainVisibleContentPosition;
  12. 每次列表改造都留存「优化前后对照表」,这是团队里最容易复用的性能资产。

本章小结

本章从「一个 item 的双重账单」出发:JS 组件树与原生视图树各记一笔,1000 条全量渲染在任何机器上都不可行。VirtualizedList 用窗口化控制「渲染多少」、用批处理控制「渲染节奏」,吞吐上限 ≈ maxToRenderPerBatch / updateCellsBatchingPeriod,空白区域的本质是滚动速度超过吞吐。FlatList 调参的每一项都要能回答「默认值是什么、问题是什么、为什么这么改」;FlashList 用回收池把「创建成本」也省下来,但要管理复用残留;数据层用稳定 key、不可变更新、游标分页与预取托底;图片用固定解码尺寸与稳定 URI 同时解决闪烁与内存。FieldNote 任务列表从 24fps 到 57fps 的过程,就是把这套逻辑逐步落地的过程。

一句话记忆点:列表优化管三本账——窗口化管创建频率,回收管创建成本,稳定 key 管重建范围;图片另有一本内存账,第 4 章接着算。

动手练习

  1. 基础巩固:写一个 200 条、行高固定 64pt 的纯文本列表。分别用「默认参数」与「getItemLayout + windowSize=7 + initialNumToRender=10」两组配置,用 RN DevTools 的性能面板记录快滚帧率与空白率,写一段 100 字以内的结论说明差异来源。
  2. FieldNote 实战 A:为任务列表增加第二级分组「按门店」(日期分组内再按 storeId 分组,行内显示门店名 header)。要求:保持 FlashList 行类型(header/task)拆分正确,思考两级 header 是否值得做成列表项,还是放在 TaskRow 内部渲染更有利性能,并给出理由。
  3. FieldNote 实战 B:为任务列表实现触底图片预取:基于 onViewableItemsChanged 计算最后可见行之后的 10 条任务,对其缩略图执行 Image.prefetch。要求:预取去重(同一 URI 不重复预取)、组件卸载后安全、预取失败静默降级。
  4. 排查题:列表快速滚动时出现「图片闪烁 + 内存持续上涨」。请写出完整排查步骤(测量工具、观察哪些指标、按什么顺序缩小范围),并指出两个最可能的根因。提示:回忆 3.6 的缓存击穿机制,以及「URI 稳定性」与「复用行重新解码」的关系;必要时提前翻看第 4 章的像素内存公式。

导航

上一章 第 2 章 性能测量方法论与渲染原理 | 下一章 第 4 章 内存管理与泄漏排查

📄 第 4 章 内存管理与泄漏排查 04-memory-management.md

本章导读:内存是性能的隐形维度——列表滚动几十秒才暴露的卡顿、真机久用后被系统静默杀死,多数根因都落在内存。本章基于第 3 章的图片与列表问题,建立「JS 堆 + 原生内存」的双层心智模型,给出一套 RN 特色泄漏模式清单与双端排查工具链,并在 FieldNote 拍照取证页完成一次 300MB→30MB 的完整排查实战。

本章学习目标

学完本章,你能够:

  1. 画出 RN 进程内存的分层视图,说明 JS 堆与原生内存各自包含什么、由谁负责回收、用什么工具观测;
  2. 用像素公式估算任意图片的解码内存,解释「解码尺寸 ≠ 显示尺寸」与屏幕 scale 的真实含义;
  3. 识别并修复七类 RN 特色泄漏:定时器、事件监听、闭包长持有、导航栈持有、无上限单例缓存、原生侧未释放、worklet 大引用;
  4. 使用 React Native DevTools 的 Memory 面板做堆快照对比,并正确阅读 retainer 引用链;
  5. 在 Android 用 Memory Profiler + LeakCanary、在 iOS 用 Instruments + Memory Graph 完成一次端到端排查;
  6. 区分「缓存增长」与「泄漏」,用三次快照法产出可复现的排查报告;
  7. 为大对象(图片、大数组)设计生命周期解绑、带上限的 LRU 缓存与按需解码方案。

核心知识点

4.1 RN 进程内存的三层视图:GC 只管其中一层

先纠正一个高频误解:「JS 没有_leak_就没事」不成立。一个 RN 应用进程的内存至少由三层构成,而 JavaScript 的 GC 只负责第一层:

层 包含什么 谁负责回收 用什么看
JS 堆 JS 对象、字符串、闭包、Fiber 节点、共享值 Hermes GC React Native DevTools 的 Memory 面板
原生堆 C++ 对象(Hermes 引擎自身、Fabric/Yoga 节点、解码位图);Android 上还有 ART 管理的 Java 堆 malloc/free、ART GC、引用计数 dumpsys meminfo / Profiler / vmmap
图形缓冲区 窗口 surface、图层后备存储、GraphicBuffer、GPU 纹理 系统随视图生命周期回收 Android gfxinfo / iOS vmmap 的 IOAccelerator 类目

【重点】跨层重复持有是 RN 应用的特色陷阱:同一张图片可能同时存在四份——JS 里的 URI 字符串、原生内存缓存里的解码位图、磁盘缓存里的编码文件、图层上正在显示的位图拷贝。释放了 JS 引用只影响第一份;只要原生缓存或某个原生对象还引用着解码位图,那几十 MB 就一直在。

Hermes GC 概述(够用即可):Hermes 的回收器是分代设计——新生代对象分配高频、回收频繁且廉价;活过几轮回收的对象晋升到老年代,回收频率低、成本高。近年版本默认启用的并发式回收(Hades 方向)把大部分标记工作移出 JS 线程以降低停顿。对业务开发的两个推论:一,JS 分配速率直接决定 GC 频率,列表滚动时反复创建销毁组件树就是最大的分配源(第 3 章);二,GC 停顿影响的是 JS 侧的渲染与事件响应,表现为空白区域和交互迟滞,而位图、原生监听、图形缓冲区根本不归 GC 管——这就是下一节的主题。

OOM 的两种形态与判别:

形态 机理 表现 判别方法
原生 OOM / 被系统杀死 进程总内存(含 native、graphics)触达系统阈值,被 LMK(Android,按进程优先级与内存分档择杀)或 jetsam(iOS)直接终止 没有 JS 崩溃栈;Android 上进程安静消失,iOS 控制台/设备日志出现 jetsam 事件 dumpsys meminfo、vmmap 看 native 与 graphics 占比;Xcode Organizer / 设备日志找 jetsam 报告
GC 压力(先卡后亡) 分配速率过高 → GC 频繁 → JS 线程反复停顿 → 卡顿累积,最终仍可能被杀 滚动掉帧、空白区域增多、点击响应延迟 DevTools 堆曲线锯齿密集;Profiler 中 GC 耗时占比升高

排查第一问永远是:看进程总内存的构成,而不是只看 JS 堆。JS 堆 40MB 而进程 800MB 的问题,DevTools 里什么也看不出来。

4.2 图片内存专项:一张照片凭什么值 46MB

图片是 RN 应用原生内存的第一大户。它的内存账有一条冷冰冰的公式:

解码内存 ≈ 解码宽 × 解码高 × 每像素字节数(ARGB_8888 为 4 字节)

拿 FieldNote 拍照取证的现实例子算一遍:一张 4000×3000 的手机照片——

4000 × 3000 × 4 B = 48,000,000 B ≈ 45.8 MiB ≈ 一张 46MB

它显示成 84pt 的缩略图,也照样是 46MB。 这就是「解码尺寸 ≠ 显示尺寸」:解码发生在像素域,显示发生在排版域,两者之间没有任何自动换算。第 3 章说「请求尺寸 = 显示 pt × 屏幕倍率」,本质就是在源头控制这条公式的两个乘数。

屏幕 scale 的真相:RN 布局用的是逻辑单位(dp/pt),width: 84 在 3x 屏上实际铺 252 物理像素。位图显示时的清晰度取决于「解码尺寸 ÷ 显示物理尺寸」的比值——小于 1 就模糊,远大于 1 就是白付内存。所以「按物理像素请求图」是清晰度与内存的唯一平衡点,Android 的 RGB_565(2 字节/像素)或硬件位图(存 GPU,不占 CPU 侧堆)只是在这条公式上打折,改变不了量级。

大图下采样:双端解码器都支持按目标尺寸解码——Android 的 Fresco(RN 内置 Image 的底层)可按 resizeMethod="resize" 触发按需缩放解码,iOS 的 ImageIO 可按 kCGImageSourceThumbnailMaxPixelSize 出缩略图;RN 内置的 Image 对 iOS 侧默认并不做目标尺寸下采样,因此第一选择永远是让服务端/CDN 直接出对应尺寸的图(第 3 章 3.6),第二选择才是客户端按需解码(本章 FieldNote 与 4.6 演示)。

双端图片库的缓存层级。无论 Glide/Coil(Android)还是 SDWebImage(iOS),结构都是「内存 → 磁盘 → 网络」三级;理解层级才能解释「为什么有的图秒开、有的图闪一下」:

层 Glide(Android) SDWebImage(iOS)
活动缓存 ActiveResources:引用计数持有正在使用的解码位图 无独立层,由内存缓存与系统图层承担
内存缓存 LRU,容量可配,超限按最近最少使用驱逐 NSCache,收到系统内存告警会自动清空
磁盘缓存 原图/转换结果落盘,策略可配(进 App 的首次加载走这里) 磁盘缓存,可配过期与容量
网络 缓存未命中时回源 缓存未命中时回源

FastImage 一类封装库的取舍:它们把双端成熟管线(Glide / SDWebImage)包进统一的 RN 组件,换来的是下载优先级、渐进式加载、磁盘策略等开箱能力;代价是多一个原生依赖、部分 RN Image 属性不受支持、与 0.8x 新架构的适配要以库的版本文档为准。选型判断一句话:图片密集的列表页值得上成熟管线;低频大图用内置 Image + CDN 缩略图足够。记住无论哪条管线,内存缓存的 key 都是 URI——这又回到了第 3 章的「稳定 source」。

4.3 泄漏模式清单:RN 特色七张罚单

以下每个模式都给「最小复现 + 修复」。它们的共同机理只有一句话:本该随页面一起死亡的对象,被一条仍然可达的引用链拴住了。

罚单 1:定时器未清理。

// ⚠️ 反例:StatusPoller —— interval 只启动不清理
function StatusPoller({ taskId }: { taskId: string }) {
  const [tick, setTick] = useState(0);
  useEffect(() => {
    setInterval(() => setTick((t) => t + 1), 1000);
    // 卸载后 interval 仍在跑:闭包捕获 setTick → 捕获整棵 Fiber 树与 taskId
  }, []);
  return <Text>已同步 {tick} 次</Text>;
}

// 修复:启动与清理成对出现
useEffect(() => {
  const id = setInterval(() => setTick((t) => t + 1), 1000);
  return () => clearInterval(id);
}, []);

setTimeout 同理:卸载后仍会执行一次回调,且回调闭包链上的大对象全程存活。

罚单 2:事件监听未 remove。

// ⚠️ 反例:四种常见订阅,全部丢弃返回的句柄
useEffect(() => {
  AppState.addEventListener('change', handleAppState);        // subscription 丢了
  NetInfo.addEventListener(handleNetChange);                   // unsubscribe 函数丢了
  DeviceEventEmitter.addListener('photo_saved', handlePhoto);  // 同上
  progress.addListener(({ value }) => setProgress(value));     // Animated 值监听
}, []);

// 修复:每个订阅拿到句柄,return 里成对注销
useEffect(() => {
  const s1 = AppState.addEventListener('change', handleAppState);
  const s2 = NetInfo.addEventListener(handleNetChange);
  const s3 = DeviceEventEmitter.addListener('photo_saved', handlePhoto);
  const animId = progress.addListener(({ value }) => setProgress(value));
  return () => {
    s1.remove();
    s2();
    s3.remove();
    progress.removeListener(animId);
  };
}, []);

⚠️ 特别注意 useEffect(async () => {...}) 这种写法:effect 返回的是 Promise 而不是清理函数,return 里的注销永远不会被框架调用。effect 本体保持同步函数,异步逻辑包在内部。

罚单 3:闭包长持有大对象。

// ⚠️ 反例:全局单例持有页面闭包
AnalyticsBridge.setExtrasProvider(() => ({
  photos: pagePhotos,     // 页面级数组,数十 MB
  meta: sessionMeta,
}));
// 页面卸载后:单例 → provider 闭包 → pagePhotos,永远可达

// 修复 1:闭包只暴露最小必要数据(计数、id),大对象不进闭包
AnalyticsBridge.setExtrasProvider(() => ({ photoCount: pagePhotos.length }));
// 修复 2:无论暴露什么,卸载时解除注册
useEffect(() => {
  AnalyticsBridge.setExtrasProvider(provider);
  return () => AnalyticsBridge.setExtrasProvider(null);
}, []);

罚单 4:导航栈持有。 react-navigation 的栈式导航为了「返回即恢复」,默认保留非激活屏幕的组件树与 state(视图层在部分平台/配置下会被 detach,detachInactiveScreens 的默认行为随导航器与平台有差异,以实测为准)。这意味着:栈里的每一层都是常驻内存的活组件。

// ⚠️ 反例 1:大对象塞进路由参数,随栈常驻
navigation.navigate('PhotoPreview', { photoBase64, meta });
// ⚠️ 反例 2:深层栈无限累积,每层都挂着图片状态

// 修复:参数只传 id;栈深度用 reset 收敛
navigation.navigate('PhotoPreview', { photoId });
navigation.reset({ index: 0, routes: [{ name: 'Home' }] });

栈内大图页(预览、相册)额外要求:在 blur 或卸载时机清空页内图片状态,不要指望导航器替你释放。

罚单 5:无上限的单例缓存。

// ⚠️ 反例:只进不出的模块级缓存
export const thumbCache = new Map<string, string>();
export function cacheThumb(id: string, base64: string) {
  thumbCache.set(id, base64); // 生命周期间接等于应用生命周期
}

修复见 4.6 的 LruCache:带上限 + 驱逐回调。凡是模块级 Map/数组,code review 时一律问一句「谁负责让它变小」。

罚单 6:原生侧 listener / 协程 / Handler 未释放。 JS 侧清理得再干净,原生模块自己注册的系统回调不释放照样泄漏,而且这部分 GC 完全无感。Android(Kotlin):

// ⚠️ 反例:回调注册进全局单例,模块销毁也不反注册
class BleScanModule(reactContext: ReactApplicationContext) : NativeBleScanSpec(reactContext) {
  override fun startScan() {
    GlobalListenerHub.add(callback)   // GlobalListenerHub 是 object 单例,只进不出
  }
}

// 修复:资源与模块生命周期绑定,invalidate() 统一回收
class BleScanModule(reactContext: ReactApplicationContext) : NativeBleScanSpec(reactContext) {
  private var handler: Handler? = null
  private var callback: ScanCallback? = null
  // (NativeXxxSpec 基类名以 codegen 生成产物为准)

  override fun startScan() {
    val h = Handler(Looper.getMainLooper())
    handler = h
    callback = scanCallback
    h.postDelayed(pollRunnable, intervalMs)
  }

  // TurboModule 销毁(应用退出/热重载/模块卸载)时框架会调用
  override fun invalidate() {
    handler?.removeCallbacksAndMessages(null)   // 清空消息队列
    callback?.let(GlobalListenerHub::remove)    // 显式反注册
    handler = null
    callback = null
    super.invalidate()
  }
}

iOS(Swift),两个经典环:

// ⚠️ 反例:CADisplayLink 强持有 target 成环,deinit 永远不来
final class FrameMonitor: NSObject {
  private var link: CADisplayLink?
  func start() {
    link = CADisplayLink(target: self, selector: #selector(tick)) // link → self 强引用
    link?.add(to: .main, forMode: .common)
  }
}

// 修复:stop() 里 invalidate 打断环;通知在 deinit 中移除
final class FrameMonitor: NSObject {
  private var link: CADisplayLink?
  deinit { NotificationCenter.default.removeObserver(self) }
  func start() {
    link = CADisplayLink(target: self, selector: #selector(tick))
    link?.add(to: .main, forMode: .common)
  }
  func stop() {
    link?.invalidate()   // 关键:打断 self ← link 的引用环
    link = nil
  }
}

罚单 7:Reanimated 共享值在 worklet 中持有大引用。 共享值与 worklet 闭包由 Reanimated 的原生运行时管理,一旦把大数组塞进共享值,这部分内存就游离在「组件卸载即释放」的常规路径之外:

// ⚠️ 反例:整页照片数组进共享值
const frames = useSharedValue<Photo[]>(allPhotos);
useAnimatedReaction(
  () => scrollY.value,
  () => { /* worklet 访问 frames.value → 数组被运行时长期持有 */ },
  [],
);

// 修复:共享值只存轻量数据(索引、id),大对象留在 JS 侧按 id 查找;
// 卸载时显式置空斩断引用
useEffect(() => () => { frames.value = []; }, []);

4.4 排查工具链:双端完整操作路径

React Native DevTools 的 Memory 面板(JS 堆)。路径:开发模式运行应用 → Dev Menu → Open React Native DevTools → Memory 标签 → 确认目标是 Hermes 堆 → Take heap snapshot。三个核心用法:

  1. Summary 视图:按构造函数聚合,按 Retained Size(该对象被回收后能释放的总大小)降序,头部的几个类就是嫌疑犯;
  2. Comparison 视图:选两份快照做差集,盯 #Delta 为正且数量与业务轮次线性相关的类(例如 Photo、Array、你的页面组件名);
  3. Retainers 引用链:点开可疑对象,从下往上读「谁持有它」,最近的那个全局/单例/静态引用就是断链点。

Android 工具链。三件套按用途选:

  • adb shell dumpsys meminfo com.fieldnote.app:三十秒定位问题在哪层。重点看 Native Heap、Java Heap、Graphics、PSS Total 四行——Native 涨位图问题,Graphics 涨图层问题,只有 Java Heap 涨才轮到 Java 侧。
  • Android Studio Profiler:View > Tool Windows > Profiler → 选中设备与进程 → Memory → Capture heap dump → 按类排序看实例数与 Retained Size,右侧 References 面板直接看引用链;需要看分配来源时用分配录制。
  • LeakCanary:debugImplementation("com.squareup.leakcanary:leakcanary-android:2.x") // 写作时最新稳定版,以官方仓库为准。零配置自动监控 Activity/Fragment/ViewModel/View 等系统生命周期对象,泄漏时弹出带最短引用链的报告。它的工作原理值得理解,因为知道它测什么,才知道它测不到什么:
sequenceDiagram
    participant App as 应用进程
    participant LC as LeakCanary
    participant H as hprof 解析器(Shark)
    App->>LC: 关键对象销毁(onDestroy 等)
    LC->>LC: 为其创建弱引用 + 挂到 ReferenceQueue
    LC->>LC: 等待数秒并主动触发 GC
    alt 弱引用已进入队列
        LC->>App: 对象已可回收,不告警
    else 弱引用仍可达
        LC->>H: dump hprof
        H->>H: 解析对象图,计算到 GC Root 的最短引用链(排除框架合理引用)
        H->>App: 通知栏展示泄漏报告
    end

【重点】LeakCanary 的覆盖边界:它盯的是进入它监视清单的对象(主要是 Android 系统生命周期组件)经 Java/JS 可达性的泄漏。原生 malloc 的内存、图形缓冲区、以及未被监视的自定义单例持有,都不在它的告警范围内。

iOS 工具链。四条路径按顺序用:

  1. Xcode 调试会话中 Debug > Debug Memory Graph:直观看全部存活对象,按类过滤后核对「对象数量是否符合业务预期」(退出页面后仍有 20 个 PreviewController?就是它);
  2. Instruments(⌘I)> Allocations:用 mark generation 技巧做增量分析——进页前打一个标记,进出页面 10 次后再打一个,比较两个 generation 之间仍存活的对象,剩下的就是没被释放的;
  3. Instruments > Leaks:自动轮询引用环,能抓到 retain cycle 但抓不到「无环但被单例拴住」的泄漏;
  4. memgraph 深挖:调试中导出内存图后用命令行分析(工具集随 Xcode 版本演进,以 xcrun 帮助为准):xcrun vmmap --summary x.memgraph 看内存分区构成,xcrun leaks x.memgraph 列引用环。

didReceiveMemoryWarning 的应对。iOS 会在系统压力升高时向应用发内存告警,Android 对应 onTrimMemory 分级回调。把它转成 JS 事件,用来主动丢弃可再生缓存(缩略图、解码结果),是最便宜的止损手段:

// iOS:把系统内存告警转发给 JS(TurboModule 内,基类以 codegen 产物为准)
override init() {
  super.init()
  NotificationCenter.default.addObserver(
    self,
    selector: #selector(onMemoryWarning),
    name: UIApplicationDidReceiveMemoryWarningNotification,
    object: nil
  )
}
@objc private func onMemoryWarning() {
  sendEvent(withName: "memoryWarning", body: nil)
}
// JS:收到告警先丢可再生缓存,把低价值内存交还给系统
useEffect(() => {
  const sub = DeviceEventEmitter.addListener('memoryWarning', () => {
    lruThumbCache.clear();
  });
  return () => sub.remove();
}, []);

4.5 排查方法论:三次快照法与「增长 vs 泄漏」

工具会变,方法不变。六步法:

  1. 固定复现路径:写死设备型号、系统版本、构建变体与操作序列(进页 → 操作 N 次 → 退出,循环 20 轮)。⚠️ debug 变体本身内存虚高,结论性测量用 profile/release 变体;
  2. 三次快照:A = 进页前(先做几轮无关操作让缓存稳定);B = 页内稳定后;C = 退出并触发 GC 后。判定基准:C − A ≈ 0 健康;C − A > 0 且随轮次线性增长,是泄漏;
  3. 快照差集:DevTools Comparison 或 heap dump 对比,找数量与轮次线性相关的类;
  4. retainer 链定位:读引用链,找最近的单例/静态/全局持有者;
  5. 修复:断链(注销、置空、LRU 有界化);
  6. 回归验证:重复 20 轮,看曲线形态,不是看感觉。

「增长」与「泄漏」的区分是方法论的核心判断题:缓存增长是可回收的——它有界、在压力下会被逐出;泄漏是不可回收的——引用链活着,GC 与缓存策略都够不着它。验证手段:手动触发 GC、发出内存告警清缓存、或继续加压,看内存是否回落。曲线形态一眼可判:

(a) 健康基线:峰值随进出波动,谷底每次回到基线附近

 内存(MB)
 300 ┤        ╭──╮         ╭──╮         ╭──╮
 280 ┤ ───────╯  ╰─────────╯  ╰─────────╯  ╰──────── ← 谷底 ≈ 260(常驻缓存)
     └────┬────┬─────┬────┬─────┬────┬─────┬────▶
          进   出     进   出     进   出     进   出    (拍照页进出)

(b) 泄漏:峰值与谷底同步抬升,退出后无法回落

 内存(MB)
 600 ┤                                               ╭──
 540 ┤                                    ╭───╮   ╭──╯
 480 ┤                         ╭───╮   ╭──╯     ╰─
 420 ┤              ╭───╮   ╭──╯
 360 ┤     ╭──╮  ╭──╯
 300 ┤ ────╯  ╰──╯       ← 每轮谷底抬升 ≈ +15MB,20 轮累计 +300MB
     └────┬────┬─────┬────┬─────┬────┬─────┬────▶
          进   出     进   出     进   出     进   出

4.6 大对象治理:解绑生命周期、LRU、按需解码

原则一:图片生命周期与页面/列表生命周期解绑。 方向是「页面走了,图片数据不留」:页面级照片数组放页面 state(卸载即释放),全局 store 只存 id 与元数据;列表滚出窗口的图片交给原生缓存按 LRU 淘汰,而不是 JS 侧再攒一份。反模式是「顺手」把页内数组塞进全局 Zustand/单例「方便返回恢复」——这就是 FieldNote 实战里 +300MB 的元凶之一。

原则二:一切缓存必须带上限。 一个可直接落地的 LRU(按字节限额,驱逐时回调释放 JS 侧引用):

// src/utils/LruCache.ts —— 依赖 Map 的插入序实现 O(1) LRU
export class LruCache<V> {
  private map = new Map<string, { value: V; bytes: number }>();
  private totalBytes = 0;

  constructor(private maxBytes: number) {}

  get(key: string): V | undefined {
    const node = this.map.get(key);
    if (!node) return undefined;
    this.map.delete(key); // 命中即移到末尾(最近使用)
    this.map.set(key, node);
    return node.value;
  }

  set(key: string, value: V, bytes: number): void {
    const old = this.map.get(key);
    if (old) {
      this.totalBytes -= old.bytes;
      this.map.delete(key);
    }
    this.map.set(key, { value, bytes });
    this.totalBytes += bytes;
    while (this.totalBytes > this.maxBytes) {
      const oldest = this.map.entries().next();
      if (oldest.done) break;
      const [k, node] = oldest.value;
      this.map.delete(k);           // 驱逐最久未使用项
      this.totalBytes -= node.bytes;
      // 驱逐回调:如需同时清理原生侧资源,在这里触发
    }
  }

  clear(): void {
    this.map.clear();
    this.totalBytes = 0;
  }
}

// 用法:缩略图缓存上限 16MB,超限自动驱逐
const thumbLru = new LruCache<string>(16 * 1024 * 1024);

原则三:按需解码。 三道关卡按顺序过:CDN 出对应尺寸的图(第 3 章);客户端展示前按目标尺寸解码(下采样);缓存层缓存的是「目标尺寸的解码结果」而不是原图。落盘的原图与内存里驻留的位图是两个生命周期的两份资产,前者管证据留存,后者管显示性能,别混为一谈。

典型业务场景:FieldNote 实战

对应里程碑 M4:拍照取证页(PhotoCaptureScreen)内存排查。页面功能:进入后调起相机(第 8 章实现的自定义相机模块),连续拍摄取证照片,页内网格预览,确认后挂到检查项上。

现象与测量

QA 报告:低端 Android 真机上,该页面反复进出 20 次,进程内存涨约 300MB,第 15 轮左右被系统杀死且无任何崩溃日志。先用 4.5 的方法采样(排查脚本见文末),得到现象曲线:

PhotoCaptureScreen 进出 20 轮 · PSS 采样(每 3 秒)

 540 ┤                                                    ●●●●
 500 ┤                                        ●●●●●●●●●●●
 460 ┤                            ●●●●●●●●●●●
 420 ┤                ●●●●●●●●●●●
 380 ┤       ●●●●●●●●●
 340 ┤   ●●●
 300 ┤  ●
 260 ┤──●──────────────────────────────────────────  基线 240MB
     └──1───3───5───7───9───11──13──15──17──19──▶ 轮次
        ↑ 每轮「进页峰值 ≈ +45MB,退页后仅回落 ≈ 30MB」
          → 谷底每轮抬升 ≈ +15MB:典型泄漏形态

第一步:dumpsys 分层,锁定「哪层在涨」

$ adb shell dumpsys meminfo com.fieldnote.app

  修复前(退出 20 轮后)        修复后(同流程)
  Native Heap    465 MB        Native Heap    192 MB
  Java Heap       38 MB        Java Heap       39 MB
  Graphics        61 MB        Graphics        58 MB
  PSS Total      540 MB        PSS Total      268 MB

Java Heap 与 Graphics 稳定,Native Heap 独涨 300MB → 结论:不是 JS 对象问题(DevTools 大概率什么都看不出来),嫌疑集中在解码位图与原生对象。这一步的意义是避免在错误的层里挖三天。

第二步:LeakCanary 报出第一条泄漏链

LeakCanary 弹出报告(节选):

PhotoCaptureScreen leaked.
├─ PhotoSyncDispatcher (单例)
│   └─ PhotoSyncDispatcher.onDoneListener  ← 页面注册的回调未注销
│       └─ PhotoCaptureScreen (leaking)

对应代码正是罚单 3 + 罚单 1 的组合:页面把同步完成回调注册进了单例,effect 没有清理。

第三步:堆快照对比,揪出未释放的位图

LeakCanary 只解释了一部分。做 DevTools 快照差集(C − A)发现:ShotMeta 数组 20 份常驻——retainer 链指向全局 Zustand store 里的 captureHistory 字段:页面为了「返回恢复」,把页内照片数组直接 push 进了全局 store(罚单 4 + 罚单 5)。再用 Android Studio heap dump 核对原生侧:解码位图中存在多张 4000×3000 的原图(单张 ≈ 46MB),由泄漏链与无下采样的预览加载共同持有。

三处根因齐了:① 监听未注销(真泄漏);② 预览直出原图(无下采样,单张 46MB);③ 页内大数组进了全局缓存(只进不出)。

第四步:三处修复

// 修复① + ③:PhotoCaptureScreen.tsx(关键部分)
function PhotoCaptureScreen({ route }: { route: { params: { taskId: string } } }) {
  // 页面级数据放页面 state:卸载即释放,绝不上进全局 store
  const [shots, setShots] = useState<ShotMeta[]>([]);

  useEffect(() => {
    const sub = DeviceEventEmitter.addListener('photo_captured', onCaptured);
    PhotoSyncDispatcher.setListener(onSyncDone);   // 注册
    return () => {
      sub.remove();                                // 修复①:订阅成对注销
      PhotoSyncDispatcher.setListener(null);       // 修复①:解除单例对页面的持有
    };
  }, []);

  useEffect(() => {
    // 修复③:页面卸载即清空页内缓存(元数据 + 缩略图 LRU)
    return () => {
      setShots([]);
      thumbLru.clear();
    };
  }, []);
  // ……渲染部分省略:网格预览只渲染 256px 缩略图(thumbUri 见第 3 章)
}
// 修复②(Android 侧):落盘预览前按目标长边下采样解码
// (拍照管线本身见第 8 章,这里聚焦内存:位图只以目标尺寸进内存)
fun decodeDownsampled(path: String, targetLongEdge: Int): Bitmap {
  val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
  BitmapFactory.decodeFile(path, bounds)
  var sample = 1
  val longEdge = maxOf(bounds.outWidth, bounds.outHeight)
  while (longEdge / (sample * 2) >= targetLongEdge) sample *= 2
  return BitmapFactory.decodeFile(
    path,
    BitmapFactory.Options().apply { inSampleSize = sample },
  )
}
// 修复②(iOS 侧):ImageIO 按目标长边出缩略图
func downsampledImage(at url: URL, targetLongEdge: CGFloat) -> UIImage? {
  let opts: [CFString: Any] = [
    kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceThumbnailMaxPixelSize: targetLongEdge,
    kCGImageSourceCreateThumbnailWithTransform: true,
  ]
  guard let src = CGImageSourceCreateWithURL(url as CFURL, nil),
        let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, opts as CFDictionary)
  else { return nil }
  return UIImage(cgImage: cg)
}

巡检取证的存证分辨率定为长边 1600(4000×3000 原图落盘保留,但进内存的只有 1600×1200 ≈ 7.3MB 的解码结果;网格预览只用 256px 缩略图 ≈ 0.25MB)。

修复前后对照

指标 修复前 修复后
进页前基线 PSS 240MB 240MB
单轮进出谷底增量 +15MB ≈ 0(缓存命中波动 ±2MB)
20 轮后 PSS 540MB(+300MB) 268MB(+28MB)
其中 Native Heap 增量 +300MB +25MB
JS 堆全程 稳定无异常(本来就正常) 稳定
LeakCanary 1 条泄漏报告 无告警
低端机 20 轮结局 第 15 轮被系统杀死 全程正常

【重点】注意最后一行 JS 堆:修复前后它都完全正常。这直接回答本章标题的隐含问题——内存治理的主战场几乎从来不在 JS。

附:排查用关键代码

# PSS 采样脚本:每 3 秒记录总内存与 Native Heap,用于画出上面的现象曲线
for i in $(seq 1 120); do
  adb shell dumpsys meminfo com.fieldnote.app \
    | grep -E 'Native Heap|TOTAL PSS' \
    | sed 's/^ *//' >> mem-log.csv
  sleep 3
done

LeakCanary 接入一行(debug 变体):debugImplementation("com.squareup.leakcanary:leakcanary-android:2.x") // 写作时最新稳定版,以官方仓库为准。内存告警监听 hook 见 4.4。

常见陷阱

陷阱 典型症状 根因 正确做法
以为 GC 能回收原生内存 DevTools 里干干净净,进程内存却持续上涨 GC 只管 JS 堆;解码位图、原生监听、图形缓冲区归 malloc/引用计数/系统管 用 dumpsys/vmmap 分层观测,原生问题回原生层查
只监控 JS 堆不看进程总内存 「JS 只有 40MB,为什么会被杀」 被杀看的是进程总内存(PSS),JS 堆只是其中一小块 每次排查先看 PSS 构成:Native / Java / Graphics / Code
useEffect 订阅但清理写错位置或遗漏 页面退出后监听仍触发、状态被暗中更新 async effect 的 return 是 Promise 不会被调用;或订阅句柄被丢弃无处调用 remove effect 保持同步函数;每个订阅接住句柄,return 里成对注销
把「URL/磁盘缓存」当「图片内存治理」 图片不重复下载了,但内存照涨 磁盘/URL 缓存解决网络复用,不控制解码位图的驻留尺寸 控制解码尺寸(CDN/下采样)+ 内存缓存带上限
用模拟器验证内存 模拟器上一切正常,真机被杀 模拟器内存、GPU、LMK/jetsam 策略与真机差异巨大 内存结论只在真机 + 目标最低端机型上做出
LeakCanary 无告警就判定无泄漏 报告干净但内存仍涨 它只覆盖被监视的系统生命周期对象;原生 malloc、图形内存、未被监视的单例不在范围 无告警只说明「它盯的对象没事」;继续用快照差集与原生工具排查
页面数据顺手进全局 store「方便返回恢复」 多页叠加后内存随导航深度线性上涨 全局 store 使页面级大对象生命周期间接变成应用级 全局只存 id/元数据;大对象放页面 state,恢复走持久化或重取
内存修复后不做多轮回归 上线后又复发 单轮验证看不到谷底抬升形态 修复后固定跑 20 轮进出,看曲线是否回到基线

最佳实践

  1. 一切内存结论以真机(含目标最低端机型)+ 连续 30 分钟真实场景为准,模拟器只做粗筛;
  2. 监控指标分两层记录:进程 PSS 构成(Native/Java/Graphics)与 JS 堆,二者混谈等于没测;
  3. 每个订阅都有配对清理——把它做成 code review checklist:AppState、NetInfo、Emitter、Animated、定时器、导航事件;
  4. 图片三问:解码尺寸是多少、缓存在哪几层、生命周期跟谁绑定;
  5. 页面级大对象永不进全局 store;全局状态里只允许出现 id 与小元数据;
  6. 任何模块级 Map/数组/单例必须有明确的上限与 reset 路径,review 时强制提问「谁让它变小」;
  7. 原生模块一律实现 invalidate() 式清理(Android 移除 Handler/监听,iOS invalidate DisplayLink/observer);
  8. 共享值与 worklet 只承载轻量数据(索引、id、数值),大数组留在 JS 侧按 id 索引;
  9. 新页面上线前跑「20 次进出」回归,看谷底是否回基线,把曲线截图存档;
  10. 接入系统内存告警(iOS didReceiveMemoryWarning / Android onTrimMemory),收到即清可再生缓存;
  11. 崩溃与性能平台接入 jetsam / LMK 事件上报,被杀也是可观测数据;
  12. 把本章六步法沉淀为团队排查模板,泄漏排查的产出物是「曲线 + 差集 + 断链点」三件套。

本章小结

本章先建立三层视图:JS 堆(Hermes 分代 GC)、原生堆(位图、C++、Java)、图形缓冲区——GC 只管第一层,OOM 看的是进程总内存。图片内存的公式「解码宽 × 解码高 × 4 字节」解释了为什么一张 4000×3000 照片值 46MB 而与显示尺寸无关,也解释了 CDN 裁剪与下采样为什么是第一优先级。七张泄漏罚单(定时器、监听、闭包、导航栈、单例缓存、原生侧、worklet)机理同一句话:本该死亡的对象被引用链拴住。工具链上 DevTools 管 JS 堆快照与 retainer 链,LeakCanary 管系统生命周期对象,dumpsys/vmmap/Instruments 管原生与图形层;三次快照法与「缓存 vs 泄漏」的形态判别把排查变成可复现的流程。FieldNote 拍照页 +300MB→+28MB 的记录证明:三处修复(注销监听、下采样、页内缓存退出即清)胜过十次猜测。

一句话记忆点:GC 只管 JS 堆——内存问题十有八九在原生侧,而图片、监听、闭包是最常开出的三张罚单。

动手练习

  1. 基础巩固:写一个倒计时页面,包含 setInterval、AppState 订阅、DeviceEventEmitter 订阅三类资源。要求卸载后零残留,并用 DevTools 三次快照法(进页前/稳定后/退出后)验证 C − A ≈ 0,附三份快照的差集截图说明。
  2. FieldNote 实战 A:检查项备注需要每 2 秒自动保存一次草稿。要求:轮询只在页面激活时运行(页面 blur 暂停、focus 恢复),且导航栈里存在 10 层其他页面时不会有多份轮询同时跑。提示:结合 AppState 与 useFocusEffect,先想清楚「栈里的非激活屏组件树仍然活着」这一前提。
  3. FieldNote 实战 B:为任务列表的缩略图实现 JS 侧有界缓存:基于 4.6 的 LruCache,缓存解码参数与占位色,缓存上限 16MB;接入 memoryWarning 事件(4.4 的桥接模块)在系统压力时 clear()。验证方式:人为构造 200 张缩略图滚动,对比接入前后进程内存曲线。
  4. 思考题:为什么 RN 应用会在「JS 堆完全正常」的情况下被系统因内存压力杀死?请给出(1)机理层面的解释,涉及 LMK/jetsam 的判定依据;(2)定位这类问题的手段清单,至少包含:分层观测命令、原生堆分析工具、图形内存查看方式各一项,并说明每项手段分别回答什么问题。

导航

上一章 第 3 章 列表渲染优化 | 下一章 第 5 章 启动速度优化

📄 第 5 章 启动速度优化 05-startup-optimization.md

本章导读:第 2 章的方法论与第 3、4 章的列表、内存治理,解决的都是「启动之后」的问题;本章处理用户按下图标后的第一秒——启动是性能优化阶段的收官战场,也是投入产出比最高的一场。我们承接第 1 章 1.1 节的启动全景与第 2 章的测量闭环,把冷启动拆解到每一段的线程归属与耗时特征,再从 JS、原生、bundle 体积三条战线给出优化手段,最后把成果固化为启动预算与回归门禁。本章对 Application / AppDelegate 的改造,也是下一章原生桥接阶段的前哨。

本章学习目标

学完本章,你应该能够:

  1. 完整描述 RN 冷启动从进程创建到首帧的全链路,指出每一段运行在哪个线程、耗时特征与可控性。
  2. 区分 TTR 与 TTI 的口径,用原生侧 marker 与 JS 侧 marker 完成「时间轴并轨」的双端启动打点。
  3. 解释 Hermes 字节码比 JSC 启动快的底层原因(AOT 预编译、mmap 页错误),以及「包体积→启动耗时」传导成立的条件。
  4. 用内联 require 与懒加载把重模块移出启动路径,并说明 Metro inlineRequires 的收益来源与例外。
  5. 审计 Application.onCreate / didFinishLaunching 的 SDK 初始化,用 must/should/lazy 三层编排器重排时序,并与 JS ready 协调。
  6. 识别 MultiDex、+load、动态库链接等原生遗留启动负担,给出双端处置方案。
  7. 用 adb shell am start -W、Perfetto、Instruments App Launch、os_signpost 与第 2 章 PerfTracker 建立「启动瀑布」报告与线上分位数监控。
  8. 为项目制定「启动预算表」,并把启动指标落进 CI 回归门禁,防止优化收益随迭代蒸发。

核心知识点

5.1 冷启动全链路拆解:从 fork 进程到首帧像素

原理讲解

先把「启动」这个词拆开。工程上必须区分三种启动,它们的链路完全不同,混为一谈是一切启动数据打架的根源:

启动类型 进程/页面状态 典型触发 链路差异 参考耗时(中端安卓机)
冷启动 进程不存在 首次安装后点击、被系统杀死后点击、设备重启后 完整链路:进程创建 → Application → RN 容器 → bundle → 渲染 本章的讨论对象
温启动 进程存活,Activity/页面栈被回收 退后台过久被回收后从最近任务返回 跳过进程创建与 Application.onCreate,重走 RN 容器(或仅重建 Activity) 约为冷启动的 1/3~1/2
热启动 进程与页面栈全部存活 切后台后立刻切回 仅把 Activity 带回前台,无任何重建 通常 < 300ms,不计入启动指标

冷启动的完整链路(Android 视角,iOS 差异见 5.4 节),每一段的线程归属与典型耗时占比如下——这是排障时脑中应持有的地图:

t=0        ┌ 进程创建(Zygote fork 出私有进程)           ~4%   系统段,不可控
t≈130ms    ├ Application.onCreate                       ~19%  ★原生战场:SDK 审计
t≈750ms    ├ RN 容器创建(ReactHost + Hermes Runtime)   ~5%   框架段,基本固定
t≈900ms    ├ bundle 加载(.hbc 文件 mmap 映射)          ~4%   随包体增大而增大
t≈1000ms   ├ require 图谱执行(所有顶层模块依次执行)     ~25%  ★JS 战场:Eager require
t≈1750ms   ├ React 渲染(render + commit + Fabric mount)~20%  ★JS 战场:首屏瘦身
t≈2400ms   ├ 首屏数据就绪(SQLite 查询 / 缓存水合)       ~13%  ★数据战场:快照先行
t≈2800ms   └ 首帧绘制上屏(doFrame,等下一个 VSync)      ~10%  受前面所有段挤占
t≈3200ms     TTR:首屏「有意义内容」可见

占比为 FieldNote 在中端安卓真机上的实测分布(release 构建),正好是本章 FieldNote 实战的基线;你自己的项目分布会不同,但「require 执行 + React 渲染 + 数据就绪」三项通常合计占一半以上,且全部可控。

逐段解释每一段「在干什么、卡在哪、谁的责任」:

  • 进程创建:Android 由 Zygote fork——新进程直接继承 Zygote 已初始化好的 ART 虚拟机与框架页(写时复制),所以这一段主要是进程私有页的建立,几十到两百毫秒,应用代码无法介入。iOS 段对应的是 dyld 加载主二进制与动态库(5.4 节展开),这段是可控的——加载了多少动态镜像决定了它的长度。
  • Application.onCreate / AppDelegate:全部是你自己的代码和你集成的 SDK 的代码。⚠️ 它运行在主线程上,且是关键路径——它不返回,后面的所有事(包括 RN 容器创建)都不会开始。这是绝大多数 RN 应用启动慢的第一元凶(FieldNote 基线里这一段 620ms 中有 540ms 是五个 SDK 的同步初始化)。
  • RN 容器创建:创建 ReactHost、启动 Hermes Runtime(bridgeless 模式下没有 Bridge 的初始化,这一段比旧架构明显更短)。框架代码,基本固定,一般不做文章。
  • bundle 加载:Hermes 字节码文件(.hbc)被 mmap 进进程地址空间。注意 mmap 只建立映射,不读数据——真正读盘发生在执行时逐页缺页(page fault)。所以这一段的长度 ≈ 文件大小的 mmap 系统调用成本 + 少量首次缺页,包体积对它的影响是温和的、非线性的(5.5 节展开)。
  • require 图谱执行:JS 线程单线程地执行「入口模块可达的所有顶层模块」。你 import 了什么不重要,重要的是哪些模块顶层代码在启动时被执行。一个 30 个页面、每个页面顶层都 import 了重库的应用,即使首屏只用到 1 个页面,也要为此付出全部代价。
  • React 渲染:第 1 章 1.5 节的渲染管线在启动时的重演。首屏组件树越大、Context Provider 越重、一次性渲染的列表行越多,这一段越长。
  • 首屏数据就绪与首帧绘制:TTR 的口径终点是「有意义内容可见」,所以数据段天然在关键路径上——除非你把数据分层(快照先上屏,全量后补,见 FieldNote 实战修复 3)。首帧绘制排在最后是因为主线程可能仍被前面几段挤占,VSync 排队时间被拉长。

把这张瀑布放进第 2 章 2.5 节的预算视角,启动优化只有两个杠杆:

  • 减少关键路径上的工作:must 层瘦身、require 瘦身、首屏减负——把 200ms 的活干成 50ms;
  • 把工作挪出关键路径:异步初始化、数据后补——让 200ms 的活与别的事并行,或推迟到 TTR 之后。

所有手段都能归到这两类,判断一个优化有没有意义就看它动的是不是关键路径:把 200ms 的工作从主线程挪到并行的后台线程,TTR 省 200ms;把它从启动期挪到 TTR 之后,同样省 200ms;而把它从一个后台线程挪到另一个后台线程,省的是 0。这也是为什么「重构了线程模型但瀑布没变化」的投入应当先叫停。

指标口径:沿用第 2 章 2.2 节的定义并补全打点方式。TTR(Time to Render) = 点击图标 → 首屏有意义内容完成首次渲染(FieldNote 是首张任务卡片可见);TTI(Time to Interactive) = 用户可以正常操作的时刻,工程口径取「TTR 之后,must 级启动任务全部完成、JS 线程无 pending 长任务」。两者的打点需要原生与 JS 两侧的 marker,而它们跑在两个不同原点的单调时钟上,必须并轨:

代码示例:原生侧打点(Android 用 SystemClock.uptimeMillis,iOS 用 signpost)+ JS 侧并轨。

// android/.../startup/StartupTracer.kt —— 原生启动打点器
package com.fieldnote.app.startup

import android.os.SystemClock
import android.os.Trace
import java.util.concurrent.ConcurrentHashMap

object StartupTracer {
    private val marks = ConcurrentHashMap<String, Long>() // 值 = 进程启动起算的毫秒数

    fun mark(name: String) {
        val ts = SystemClock.uptimeMillis()   // 单调时钟,毫秒,原点=系统启动
        marks.putIfAbsent(name, ts - launchElapsed()) // 归一到「进程启动为原点」
        Trace.beginSection(name); Trace.endSection()  // 让 Perfetto 泳道可见
    }
    private fun launchElapsed(): Long = marks.getOrPut("__origin") { 0L } // 见 Application 初始化

    fun snapshot(): Map<String, Long> = marks.toMap()
    fun now(): Long = SystemClock.uptimeMillis()
}
// ios/FieldNote/Startup/StartupTracer.swift —— iOS 侧打点(os_signpost 对齐系统时间轴)
import os.signpost

final class StartupTracer {
    static let shared = StartupTracer()
    private let signposter = OSSignposter(subsystem: "com.fieldnote.app", category: "startup")
    private var marks: [String: Double] = [:] // 值 = 进程启动起算的毫秒数
    private let origin = DispatchTime.now()

    func mark(_ name: String) {
        let elapsed = Double(DispatchTime.now().uptimeNanoseconds - origin.uptimeNanoseconds) / 1_000_000
        if marks[name] == nil { marks[name] = elapsed }
        let state = signposter.beginInterval("startup_\(name)") // Instruments App Launch 可见
        signposter.endInterval("startup_\(name)", state)
    }
    func snapshot() -> [String: Double] { marks }
}
// src/boot/marks.ts —— JS 启动第一条语句执行:并轨原生时间轴
import { StartupMarks } from '../native/StartupMarks'; // 同步 TurboModule,第 6、7 章展开
import { perf } from '../perf/PerfTracker';

export function importNativeMarks(): void {
  perf.mark('js_boot'); // JS 侧自己的起点
  // getSnapshotSync 返回 { native_launch, app_on_create_end, rn_host_ready, ... }
  // 以及 nowSinceLaunch():当前时刻距进程启动的毫秒数(原生单调时钟)
  const nativeNow = StartupMarks.nowSinceLaunchSync();
  for (const [name, nativeTs] of Object.entries(StartupMarks.getSnapshotSync())) {
    perf.importMark(name, nativeTs, nativeNow); // 并轨:换算到 JS 时钟
  }
}
// src/perf/PerfTracker.ts —— 给第 2 章的 PerfTracker 补两个方法
importMark(name: string, nativeTs: number, nativeNow: number): void {
  // 并轨数学:原生 mark 发生在 (nativeNow - nativeTs) 毫秒前,
  // 映射到 JS 单调时钟就是 now() - 那段间隔。两个时钟原点不同但都是单调钟,差值恒定。
  this.marks.set(name, now() - (nativeNow - nativeTs));
}

measureOnce(name: string, fromMark: string, toMark: string): number {
  const from = this.marks.get(fromMark);
  const to = this.marks.get(toMark);
  if (from === undefined || to === undefined) return -1;
  const durationMs = to - from; // 不写入 measures,避免污染交互区间
  return durationMs;
}

逐段讲解

  • 为什么用 uptimeMillis / DispatchTime 而不是 currentTimeMillis / Date:墙钟会被 NTP 校时、用户手动改时间打断——巡检员的手机时钟不可信(第 2 章埋点同样理由)。启动测量必须用单调时钟。
  • 为什么原生打点的值要归一为「距进程启动的毫秒数」而不是绝对时间戳:原生与 JS 的单调时钟原点不同(Android uptimeMillis 原点是系统启动,Hermes 的 performance.now() 原点是运行时创建),绝对时间戳无法直接比较;「相对进程起点的偏移量」是两边都能对齐的中介。importMark 里的并轨数学就是利用「两次单调时钟读数之差跨时钟恒定」这一性质。
  • 为什么 StartupMarks 用同步 TurboModule 调用:它必须发生在 JS 启动的第一条语句——任何异步(哪怕一个 microtask)都会让 js_boot 之后的段划分失真。同步调用的开销是一次 JSI 直调(纳秒级),这是它为数不多「值得用」的场景之一,边界讨论在第 6 章 6.1 节。
  • putIfAbsent / if marks[name] == nil:启动打点只记第一次,防止多进程入口或重复初始化把时间轴写花。
  • Trace.beginSection 与 os_signpost:把自建 marker 同时写进系统 trace,Perfetto/Instruments 的泳道里就能直接看到「自建打点 ↔ 线程活动」的对齐关系——这是第 2 章 2.3 节说的「打点对齐」技巧的启动版。
  • ⚠️ 原生侧 rn_host_ready、bundle_loaded 这类框架内部边界,优先用 RN 自带的 ReactMarker(Android)/ RCT 内部 marker(iOS)挂接;不同 0.8x 小版本的 marker 名有差异,以当前版本源码里的 ReactMarker 定义为准。拿不到就用 Application onCreate 结束 + JS 首行的两个点,先保证口径稳定。

5.2 JS 侧:Hermes 字节码、require 图谱与首屏瘦身

原理讲解

先回答「为什么 Hermes 快」。JSC 启动一个应用的流程是:读 JS 源文本 → 词法/语法分析 → 生成 AST → 编译成字节码 → 再执行。解析成本与源码体积线性相关,且 AST 构建期的内存峰值很高。Hermes 把前四步整体搬到构建期:CI 上用 hermesc 把 JS 预编译成字节码(.hbc),启动时直接执行字节码。

维度 Hermes(新架构默认) JSC(旧默认,已退居可选)
编译时机 构建期 AOT 出 .hbc 字节码 运行时解析源码 → 字节码(热路径再 JIT)
启动要做的事 mmap 字节码 → 直接执行 读源码 → 完整解析 → 编译 → 执行
启动成本与体积的关系 随「实际执行的页数」增长 随「源码总体积」线性增长
内存特征 字节码 file-backed 可分页,无 AST 峰值 解析期 AST/源码常驻,峰值高
峰值性能 无 JIT,热路径略慢 JIT 加持,长时间运行的热循环更快
结论 为启动与内存而生,恰是移动端痛点 为峰值吞吐而生

字节码 mmap 的页错误特性值得单独讲透,它决定了「包体积→启动」的传导方式:.hbc 文件被 mmap 后,内核并不会立刻把整个文件读进内存;CPU 执行到哪一页,缺页中断才把哪一页从磁盘调入。这意味着:

  1. 启动的 IO 成本 ≈ 实际执行到的字节码页数,不是文件大小。一个 10MB 的 bundle,如果启动只执行 20% 的模块,就只付 20% 左右的读盘与反序列化成本。
  2. 这些页是 file-backed 干净页——内存紧张时内核可以直接丢弃(第 4 章 LMK 的语境下这是好事),再次访问重新读盘即可,不占「不可回收内存」。
  3. 所以 bundle 增大对启动的伤害主要传导到两处:require 执行段变长(顶层模块变多)和页错误次数变多(执行到的页变多),而不是「解析变慢」——Hermes 根本没有运行时解析。这是 5.5 节和本章思考题的理论地基。

require 图谱的执行模型:Metro 打包后,每个模块是一个闭包工厂 + 依赖编号表,require(73) 首次调用时才执行第 73 号模块的工厂函数并缓存其导出。ES import 语句被 Babel 编译为模块顶层的 require——这就是问题所在:顶层 import 链上的所有模块,在应用启动时被一口气执行,无论首屏是否用到。内联 require(把 require 移到使用点)本质上是把模块工厂的执行从「启动时刻」推迟到「首次使用时刻」。

启动期还有两类隐性成本要避开:同步 IO(RN 侧的同步存储读取、MMKV 大 value 读取——快但也要按需)与大 JSON 解析(在启动路径上 JSON.parse 几 MB 的配置/缓存,解析本身是纯 CPU 重活,全部落在 JS 线程关键路径上)。

代码示例:Metro 全局内联 require + 手动懒加载 + 延迟初始化策略模式。

// metro.config.js —— 全局内联 require:Babel 把所有模块顶层 require 自动移到使用点
module.exports = {
  transformer: {
    getTransformOptions: async () => ({
      transform: {
        experimentalImportSupport: false,
        inlineRequires: true, // 打开前先跑一轮全量回归:模块执行顺序变了(见逐段讲解)
        inlineRequires: { blockList: { [require.resolve('./src/polyfills/ordering')]: true } },
      },
    }),
  },
};
// ❌ 优化前:顶层 import——ReportScreen 一被启动图谱触及,xlsx 全家桶立即执行
// src/screens/ReportScreen.tsx
import { exportToXlsx } from '../report/exporter'; // 顶层:拖入 ~400KB 工具链

// ✅ 优化后:使用点内联 require——首次点「导出」才付执行成本
export function ReportScreen() {
  const onExport = async (tasks: Task[]): Promise<void> => {
    const { exportToXlsx } = require('../report/exporter') as typeof import('../report/exporter');
    await exportToXlsx(tasks);
  };
  // ...
}
// src/boot/startupTasks.ts —— 延迟初始化策略模式:must / should / lazy 三层
import { InteractionManager } from 'react-native';
import { perf } from '../perf/PerfTracker';

export type StartupTask = { name: string; run: () => void | Promise<void> };

export class StartupQueue {
  private must: StartupTask[] = [];
  private should: StartupTask[] = [];
  private lazyRunners = new Map<string, () => void>();

  registerMust(t: StartupTask): void { this.must.push(t); }   // 首帧前必须完成(串行)
  registerShould(t: StartupTask): void { this.should.push(t); } // 首帧后尽快(可并行、可失败)
  registerLazy(name: string, run: () => void): void { this.lazyRunners.set(name, run); }

  async runMust(): Promise<void> {
    for (const t of this.must) await t.run(); // must 串行:顺序即依赖,不并行
  }

  runAfterTTR(): void {
    // 两次 rAF:第一次对应 render,第二次大概率对应「首帧已上屏」——TTR 之后再开始 should 任务
    requestAnimationFrame(() => requestAnimationFrame(() => {
      InteractionManager.runAfterInteractions(() => {
        this.should.forEach((t) => t.run());
        perf.mark('tti'); // should 清空 = JS 侧 TTI 口径(5.1 节定义)
      });
    }));
  }
}

// lazy 层的通用姿势:首次访问才构建,之后返回同一实例
export function lazyInit<T>(factory: () => T): () => T {
  let instance: T | undefined;
  return () => (instance ??= factory());
}
// ❌ 启动路径上的大 JSON:进首页前先 await 一个几 MB 的缓存水合
function App() {
  const [ready, setReady] = useState(false);
  useEffect(() => {
    (async () => {
      const raw = await storage.getStringAsync('draft_cache_all'); // ~4MB JSON
      hydrateAll(JSON.parse(raw)); // 纯 CPU 重活,全在 TTR 关键路径上
      setReady(true);
    })();
  }, []);
  return ready ? <Root /> : <Splash />;
}

// ✅ must 只水合首屏必需的最小集;其余按页面进入时水合
function App() {
  const [ready, setReady] = useState(false);
  useEffect(() => {
    hydrateTheme();            // MMKV 同步读单个 key,微秒级
    hydrateLastRoute();        // 同上
    setReady(true);            // 立刻放行渲染
    startupQueue.runAfterTTR(); // 其余水合进 should 层
  }, []);
  return ready ? <Root /> : <Splash />;
}

逐段讲解

  • inlineRequires 的收益来源:它把每个模块的顶层 require 自动下移到「首次使用点」,等于把 5.1 节瀑布里的 require 段从「启动时全量执行」改成「按需执行」。例外:个别模块依赖「先于其他模块执行」的副作用顺序(polyfill、全局 monkey-patch、样式注册),顺序被打乱会出诡异 bug——blockList 把这些模块排除在内联之外。打开后必须全量回归一遍再合入。
  • 内联 require 的代价不是零:首次调用时该模块的执行成本发生在交互现场。所以懒加载的对象要选「启动不用、使用时用户有心理预期」的模块(导出、报表、设置);首屏就要用的模块内联了反而把成本挪到了 TTR 之前。判据一句话:按「首次使用的时刻」分配成本,而不是按「代码的组织位置」。
  • require(...) as typeof import(...):TS 下让内联 require 保住类型推导,不牺牲严格模式。
  • runMust 为什么串行:must 层的任务彼此常有隐式依赖(先初始化存储再水合),并行省的几十毫秒远不值得时序风险;should 层才值得并行,且每个任务包 runCatching/try-catch——首帧后的任务失败不应波及彼此(FieldNote 的原生编排器同理,见 5.3)。
  • 两次 requestAnimationFrame:单次 rAF 回调只说明本轮 render 完成,commit 与上屏可能还在路上;隔一帧再启动 should 任务,能基本保证不与首帧 mount 抢 JS 线程。它不是精确保证,但比 setTimeout(0) 靠谱得多。
  • MMKV vs 大 JSON:MMKV 读单个 key 是微秒级 protobuf 解码,可以进 must 层;把几十 MB 状态塞进一个 key 再整体 JSON.parse,则把 MMKV 的优势全部败光——KV 存储的快,前提是 value 小(存储体系全景在第 14 章)。
  • require 段怎么测:__DEV__ 下用 Metro 的模块工厂注入给每个模块计一次执行耗时(console.count 级别的 patch 即可),按耗时排序得到「启动期模块执行清单」;release 下的近似替代是 5.1 节的 js_boot mark 加二分法注释验证(注释掉一批 import 看段长变化)。没有这个清单,require 瘦身就是凭感觉删代码。

5.3 原生侧(Android):Application.onCreate 审计与异步编排

原理讲解

Android 的启动关键路径是一条单行道:Application.onCreate() 在主线程执行完毕 → ContentProvider 初始化 → Activity 创建 → RN 容器。onCreate 里多花 1ms,TTR 就晚 1ms。审计方法是固定的三步:

  1. 实测:StrictMode(penaltyLog 暴露主线程 IO)+ Perfetto 主线程泳道,把 onCreate 里的每个 SDK 初始化的耗时抄下来,做成审计表;
  2. 三问:首帧前必须吗?能在后台线程吗?真正被首次使用是什么时候?
  3. 重排:按 must/should/lazy 三层归位。

一张典型的审计表(FieldNote 基线,5 个 SDK 全在 onCreate 同步初始化):

SDK / 任务 onCreate 中的耗时 首帧前必须? 归层 依据
崩溃监控核心初始化 110ms 是(要捕获启动期崩溃) must 必须最早,但只 init 核心 handler
SQLite 连接与迁移检查 60ms 是(首屏数据依赖) must 检查要早,但迁移可异步
推送 SDK init + 注册 180ms 否 should + afterJsReady 注册要上报 JS 侧 token,JS ready 前做了也白做
统计/埋点 SDK 95ms 否 should 首帧前没有事件可报
热更新检查 70ms 否 should(且限 Wi-Fi) 第 18 章:检查结果影响的是下一次启动
扫码引擎模型加载 25ms 否 lazy 首次进扫码页才需要

补充两个 Android 特有的遗留负担:

  • MultiDex:minSdk < 21 时代,classes.dex 超 64K 方法需要 legacy MultiDex,首次启动要跑一遍额外的 dex 提取与优化,代价是秒级。RN 0.8x 的 minSdk 已在 24 档,用原生 multidex,旧的 androidx.multidex 依赖与 attachBaseContext 里的 MultiDex.install(this) 应当删除——依赖审计时顺手查一遍。同时用 R8 的 release 收缩(第 17 章)控制 dex 数量,并利用 RN 自带的 Baseline Profiles(0.7x 起官方内置)让 ART 预编译核心路径,减少运行时 JIT。
  • ContentProvider 插件初始化:很多 SDK 声明了自启动的 ContentProvider,在 onCreate 之前执行。审计时用 adb shell am start 的日志或 Startup Library 的诊断确认「谁在你代码之前跑了」。

App Startup 库的思想值得了解(不一定引入):它把散落各处的自启动 ContentProvider 收敛到一个 InitializationProvider,初始化器之间声明依赖形成 DAG,并支持「手动初始化」模式——注册但不立即执行,等代码在合适的时机调 initializeComponent()。这正是 must/should/lazy 编排的官方化表达;我们的编排器只是把这个思想内联到自己的代码里,少一个依赖。

与 JS ready 的时序协调是编排器最容易被忽略的部分:有些任务(推送 token 上报、路由配置下发)需要 JS 侧的监听器/上下文就位才有意义——原生侧急着做,结果 JS 还没起来,事件丢了或重复做。正确姿势:原生编排器把这些任务挂在 jsReady 信号后面,JS 在首帧渲染后调一次同步 TurboModule 方法触发(通道细节第 6 章展开)。

代码示例:Kotlin 启动编排器(约 50 行)+ Application 接入。

// android/.../startup/StartupOrchestrator.kt
package com.fieldnote.app.startup

import android.app.Application
import kotlinx.coroutines.*
import java.util.concurrent.Executors

object StartupOrchestrator {
    private val bgExecutor = Executors.newSingleThreadExecutor()
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
    private val jsReady = CompletableDeferred<Unit>() // JS 首帧信号

    /** JS 首帧渲染后由 TurboModule 调用(第 7 章给出规范封装) */
    fun signalJsReady() { jsReady.complete(Unit) }

    fun launch(app: Application) {
        // ① must 层:主线程同步串行,预算 < 100ms。只放「不做就出错」的任务
        StartupTasks.must(app).forEach { task ->
            val t0 = StartupTracer.now()
            task.run()
            StartupTracer.mark("must_${task.name}")
        }
        // ② should 层:后台线程与 JS 启动并行,互不影响、允许失败
        scope.launch {
            for (task in StartupTasks.should(app)) {
                runCatching { withContext(Dispatchers.IO) { task.run() } }
                    .onFailure { StartupTracer.mark("should_fail_${task.name}") }
            }
            // ③ afterJsReady 层:依赖 JS 上下文的任务,等首帧信号再跑
            jsReady.await()
            withContext(Dispatchers.Main.immediate) {
                StartupTasks.afterJsReady(app).forEach { it.run() }
            }
            StartupTracer.mark("startup_all_done")
        }
        // lazy 层不在这里出现:各 SDK 门面内部用双重检查锁按需初始化
    }
}
// android/.../MainApplication.kt —— onCreate 收缩为三行
class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        StartupTracer.mark("app_on_create_begin")
        StartupOrchestrator.launch(this)  // 内部先跑 must 层
        StartupTracer.mark("app_on_create_end")
    }
}
// src/boot/bootstrap.ts —— JS 首帧后通知原生(并与 5.2 的 StartupQueue 串起来)
import { FieldNoteBootstrap } from '../native/FieldNoteBootstrap';

export function notifyJsReady(): void {
  requestAnimationFrame(() => requestAnimationFrame(() => {
    FieldNoteBootstrap.signalJsReady(); // TurboModule 同步调用,见第 6 章线程分析
    startupQueue.runAfterTTR();
  }));
}

逐段讲解

  • must 层为什么还留在主线程:因为这些任务本身就是后续一切的前提(存储、崩溃捕获),且每个已被压到 10~60ms。⚠️ must 层的纪律是「预算制」:总预算 100ms,超了就说明有任务该降层,而不是「优化并行度」。
  • 审计工具的两个盲区:StrictMode 只抓主线程磁盘与网络违规,抓不到主线程上的纯 CPU 重活(如同步加解密);Perfetto 能看到每段时长但默认分不清「哪个 SDK」。组合拳是给每个 SDK 初始化包一层 trace slice + 耗时日志(StartupTracer 已内置),审计表里的每个数字都有出处,评审时才吵得起来。
  • runCatching + 单独标记:should 层任一 SDK 失败不能拖垮后面的任务,也不能崩进程——启动期的崩溃会让「启动优化」变成「启动事故」。失败标记进 StartupTracer,线上能看到哪个 SDK 在哪些机型上不稳。
  • jsReady.await() 是唯一的跨侧同步点:它把「原生想早点做」与「JS 还没 ready」这对矛盾显式化。没有这个协调的团队经常写出「原生在 onCreate 里注册了 JS 事件监听、JS 侧永远收不到第一条推送 token」这类隐式时序 bug(见本章陷阱表)。
  • Dispatchers.Main.immediate:如果已在主线程就立即执行,避免一次多余的线程切换——afterJsReady 里的任务多数要碰 UI 上下文。
  • bgExecutor 与 scope 的分工:scope 管编排(谁先谁后、等待谁),执行体统一丢给 IO 线程——编排逻辑不占用任何 SDK 的线程池,避免「SDK 还没初始化,编排器先把它的线程池占满」的间接死锁。
  • 💡 线上验证编排是否生效:崩溃监控 SDK 里加一条「进程启动 → 首帧」的自定义事件,或直接看 StartupTracer 经 snapshot() 上报的分段(5.6 节瀑布报告),确认 onCreate 同步段确实收缩到 100ms 内。

5.4 原生侧(iOS):dyld、+load 与 didFinishLaunching 瘦身

原理讲解

iOS 的「进程创建」段比 Android 可控得多,因为它由你链接的库决定。dyld(动态链接器)启动时要做:解析主二进制的加载命令 → 逐个加载动态镜像(每个 framework/dylib 都是一次 mmap + 重定位/绑定)→ 执行所有 ObjC 类的 +load 方法 → 才进入 main()。三个结论:

维度 静态库(链接进主二进制) 动态库(.framework/.dylib 独立镜像)
dyld 工作量 随主二进制一次加载 每个镜像单独 load + rebase/bind,镜像数越多越慢
启动开销 几乎只有页错误 显著:镜像越多 pre-main 越长
包体积 主二进制变大,但无重复符号问题 每个库独立签名与元数据开销
更新粒度 随 App 整体 可独立(但 App Store 分发下意义有限)
实践 RN 官方模板已默认静态链接 Pod 仅系统框架与确有需要的少数三方库
  • +load 清理:+load 在 main 之前逐类串行执行,且无法延迟——每个 +load 都是纯启动税。排查:Instruments App Launch 模板看 pre-main 时长;代码全局搜 + (void)load。修法:能改成 +initialize(首次收到消息时才执行)就改;第三方 SDK 的 +load 用启动参数/编译开关关掉其非必需行为。
  • didFinishLaunching 瘦身:与 Android onCreate 同理,它是主线程关键路径。RN 应用这里必须做的只有:恢复窗口与根控制器、创建 RN 容器并启动 bundle 加载。SDK 初始化按 5.3 节同一套 must/should/lazy 编排(Swift 实现与 Kotlin 同构,FieldNote 双端共用一份分层清单)。
  • 预热与预加载:正确的是「首帧后预热」——立刻发出首屏预取请求(网络栈、DNS、TLS 握手在用户无感时完成)、预热图片解码器;错误的是「启动中预热」——预创建一堆 ViewController、提前解码图片,只会用内存换幻觉。判断标准与 JS 侧一致:预热的产物必须在 TTR 的关键路径上被消费,否则就是浪费。

代码示例:瘦身的 AppDelegate + 首帧后触发 should 层。

// ios/FieldNote/AppDelegate.swift
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(
        _ application: UIApplication,
        willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        StartupTracer.shared.mark("will_finish_launching") // 最早的用户代码点
        // must 层:崩溃捕获、存储迁移检查(与 Android 的 must 清单一一对应)
        StartupOrchestrator.runMust()
        return true
    }

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
    ) -> Bool {
        // 只做首帧必需:window + RN 容器。SDK 初始化一律不在这里出现
        self.window = UIWindow(frame: UIScreen.main.bounds)
        let rootVC = RNViewControllerFactory.make() // 内部创建 ReactHost/RCTRootView 并启动加载 bundle
        self.window?.rootViewController = rootVC
        self.window?.makeKeyAndVisible()

        // should 层:等首帧信号(由 JS 侧 notifyJsReady 经桥接触发,与 Android 同一事件)
        StartupOrchestrator.runShouldAfterFirstFrame()
        StartupTracer.shared.mark("did_finish_launching")
        return true
    }
}
// ios/FieldNote/Startup/StartupOrchestrator.swift —— should 层调度(节选)
final class StartupOrchestrator {
    static func runShouldAfterFirstFrame() {
        // 后台队列执行 SDK 初始化;jsReady 信号由桥接模块在 JS 首帧后置位(第 6 章展开)
        DispatchQueue.global(qos: .userInitiated).async {
            StartupTasks.should.forEach { task in
                autoreleasepool { task() } // SDK init 常创建 ObjC 对象,包一层自动释放池
            }
            self.jsReady.wait() // 与 Kotlin 的 CompletableDeferred.await 同构
            DispatchQueue.main.async { StartupTasks.afterJsReady.forEach { $0() } }
            StartupTracer.shared.mark("startup_all_done")
        }
    }
}

逐段讲解

  • willFinishLaunching vs didFinishLaunching:前者更早,适合「越早越好」的 must 层(尤其崩溃捕获——晚了就丢启动期崩溃);后者承担窗口与 RN 容器创建。两个都保持瘦身,SDK 一律不出现。
  • autoreleasepool:should 层跑在 GCD 后台队列——不经过 UIKit 事件循环的线程没有系统托管的自动释放池,SDK 初始化如果创建大量临时 ObjC 对象,内存会持续抬升直到方法返回。包一层是廉价保险。
  • jsReady.wait() 的纪律:等待必须发生在后台队列(这里已是),⚠️ 绝不能在主线程等——那是把 Android 的「onCreate 里 CountDownLatch 等异步结果」移植过来(见陷阱表)。
  • pre-main 的测量口径:Instruments App Launch 把「进程起点 → main()」单独成段(pre-main),这正是 dyld 与 +load 的地盘,也是 iOS 启动里最「纯」的一段——你的业务代码还完全没跑。pre-main 的每一毫秒等价于 didFinishLaunching 里的一毫秒,但前者只能靠链接与 +load 治理,后者靠编排;两段都要测,不要只盯一头。
  • 双端编排清单必须同构:must/should/afterJsReady 三层的成员在双端应一一对应(FieldNote 用一份共享清单文档描述,双端各自实现)。清单不同构的结果是「Android 快了 iOS 没快」这类对不上账的验收,以及跨侧时序 bug 只在单一平台复现。
  • 模拟器陷阱提前预警:模拟器与真机的 dyld 路径、磁盘速度、CPU 调度完全不同,模拟器冷启动普遍偏快且抖动不同——iOS 启动结论只在真机 profile/release 构建上做(见本章陷阱表)。

5.5 bundle 体积与启动:从字节到 TTR 的传导链

原理讲解

「包大=启动慢」是直觉,但传导链有三个环节,各环节成立条件不同:

  1. 体积 → require 执行时间:成立条件是「新增体积位于入口可达的顶层模块」。往 app 里加一个 2MB 的库,只要它在懒加载路径上,require 段几乎不变;放在顶层 import 链上,就是实打实的执行时间。
  2. 体积 → 页错误与 IO:Hermes 下执行到的页才读盘(5.2 节),所以这一项正比于「实际执行的页数」,且冷启动首轮受磁盘冷热影响大——同一个包,第二次冷启动明显快(页缓存热了),测基线要意识到这一点。
  3. 体积 → 运行时元数据:模块注册表、字符串表等随体积增长,运行时初始化成本温和上升。

把这三条传导链变成日常纪律的落点是评审问题清单,PR 里新增一个依赖时问三个问题:

  1. 它在启动可达子图里吗?(看它落在 import 链上,还是懒加载路径上)
  2. 它给运行时元数据加了多少?(模块数、字符串表的增量)
  3. 有没有更轻的替代?(优先复用功能重叠的既有依赖)

三个答案都能接受,这个依赖才允许进包;体积门禁(FieldNote 第四步)负责兜住前三问漏掉的情况。

所以体积治理的优先级不是「压总数」,而是先砍启动可达子图,再压总体积。审计三件套:

  • source-map-explorer(第 2 章 2.3 节)按源文件归因体积,找出前 20 名;
  • pnpm why <pkg> 查重复依赖与版本分叉(monorepo 下尤其常见:同一个工具链 3 个版本都在包里);
  • 对「重量级但低频」的依赖做替代或懒化:日期库全量 locale → 轻量库 + 按需、全量图表库 → 按图表类型拆包引入、整包 SDK → 按需子包。

与第 18 章的分工必须说清楚,两者经常被混为一谈:

关注点 本章(第 5 章) 第 18 章(拆包与热更新)
目标 少执行:启动路径上的代码量与时序 拆着加载:业务包按需下发与更新
手段 内联 require、依赖审计、首屏瘦身、启动编排 Metro 自定义分包、基础包+业务包、按需下载、版本回滚
关键指标 require 段耗时、TTR 首屏资源命中率、动态包加载耗时、热更成功率
关系 拆包的前提是「启动可达子图已被梳理干净」 拆包后动态包加载时序要回到本章瀑布里验证

⚠️ 拆包对启动是双刃剑:把低频业务拆出去能让基础包变小、require 段变短;但如果首屏依赖被错误拆进动态包,启动要多一次「下载/解密/执行动态包」的串行等待,反而变慢。这正是本章思考题要讨论的方向之一。

代码示例:一次依赖审计的完整命令流。

# 1) 导出 release bundle 与 sourcemap(第 2 章 2.3 节同款)
npx react-native bundle --platform android --dev false \
  --entry-file index.js --bundle-output /tmp/fn.android.bundle \
  --sourcemap-output /tmp/fn.android.map

# 2) 按源文件归因体积,导出 CSV 排序看 Top20
npx source-map-explorer /tmp/fn.android.bundle /tmp/fn.android.map --json > /tmp/size.json

# 3) 查重复依赖(monorepo 必做)
pnpm why lodash
pnpm why moment

# 4) 对照启动可达子图:在 index.js 临时打日志,统计启动期实际执行的模块数
#    (Metro __DEV__ 下每个模块工厂可注入 console.count,release 用自建标记)

逐段讲解

  • 第 2 步的归因结论必须结合「是否在启动可达子图」再行动:不在子图的,懒化或留给第 18 章拆包;在子图的,替代或删除——两列清单分开管。
  • 第 4 步的模块计数有个注意点:Metro 的模块编号在两次构建间不稳定,统计口径用「模块数」而不是「模块编号区间」;且该 patch 只进 dev 诊断构建,不进 release。
  • 💡 把「启动期执行模块数」加进体积门禁的价值比 bundle 字节数更直接:字节受压缩与死代码影响波动,而「启动执行了多少模块」与 require 段耗时接近线性,是更诚实的代理指标。
  • 第 3 步的版本分叉在 pnpm monorepo 下(第 13 章拆分)靠 catalog 统一版本,是零成本的结构性瘦身。
  • 第 4 步的「启动期实际执行模块数」是比包体积更直接的 require 段指标:FieldNote 优化前后从 1402 → 517 个模块,这个数字进启动预算表(5.6 节)。

5.6 启动测量与监控:工具、瀑布报告与预算表

原理讲解

双端启动测量工具各管一段,先对表再开工:

工具 平台 回答的问题 口径注意
adb shell am start -W Android 系统口径的冷启动耗时(TotalTime) 到「第一帧绘制完成」为止,不含数据就绪;必须 -S 强杀进程才是冷启动
reportFullyDrawn() Android 告诉系统「内容真正就绪」,接入 Vitals/Macrobenchmark 口径 在首屏数据可见处调用,否则系统拿第一帧当终点
Perfetto 启动泳道 Android 每段跑在哪条线程、各多少 ms 用 5.1 的 Trace marker 对齐;am + sched + view 类别
Instruments App Launch iOS pre-main(dyld/+load)到首帧的分段 真机 profile 构建;模拟器数据无效
os_signpost iOS 自定义区间并进系统时间轴 与 Instruments 的 point of interest 轨道关联
PerfTracker 启动瀑布 双端 业务口径 TTR/TTI + 线上分位数 打点并轨后才有意义(5.1 节)

am start -W 的使用与解读(Android 冷启动测量的第一入口):

# -S:先强杀目标进程,保证冷启动;--user 0 明确用户域
adb shell am start -W -S --user 0 -n com.fieldnote.app/.MainActivity
# 输出关键字段:
#   Status: ok
#   LaunchState: COLD      ← 确认是冷启动(WARM/HOT 说明进程没杀干净)
#   TotalTime: 2413        ← Activity 创建到第一帧绘制完成(系统口径,≠ TTR)
#   WaitTime: 2489         ← 含系统等待,一般不用

注意 TotalTime 与业务 TTR 的差值:前者止于第一帧(可能是白屏/骨架),后者止于有意义内容。把骨架屏当首帧上报,是启动数据最常见的造假方式——你会看到一个永远达标的漂亮数字。Android 侧的正确做法是在首屏数据就绪处调 activity.reportFullyDrawn(),让系统指标与业务口径对齐。

温启动与热启动同样纳入监控但分层对待:

  • 温启动(进程存活、页面重建):P90 单独设线,FieldNote 为 600ms(见第 2 章达标线表);
  • 热启动(进程与页面栈全在):不计入启动指标,但监控「切回首帧」的恢复耗时,可以抓到第 4 章说的后台内存问题;
  • 分界判定:由原生入口在进程内持久化的启动标记记录本次是 cold 还是 warm,随瀑布一起上报,线上才不至于把三类数据搅在一起。

线上监控的口径纪律(承接第 2 章 2.7 节):只看分位数——P50 看大盘、P90 定达标、P99 抓异常;按设备分档(按运行内存分 3 档)分别统计,混档聚合会把低端机的恶化藏进高端机的改善里;采样 5% 且在 TTI 之后才上报(启动期最不该干的就是发网络请求);与上周比而不是与历史均值比,避免季节性污染。

启动预算表是把「启动」变成可管理工程的方法:先给 TTR 定总预算(FieldNote:1500ms),再按 5.1 瀑布把预算逐段分配,每个段的负责人对「超预算」负责:

段 预算 must/should/lazy 责任 当前(优化后)
进程创建 150ms — 系统 120ms
Application.onCreate(must 层) 120ms must 原生 90ms
RN 容器 + bundle 加载 300ms — 框架 240ms
require 图谱执行 300ms must 仅首屏可达 JS 260ms
React 渲染 + mount 280ms 首屏 12 卡 JS 250ms
首屏数据就绪 150ms 快照先行 数据层 110ms
首帧绘制上屏 200ms — UI 90ms
合计(TTR 预算) 1500ms 1160ms

代码示例:用第 2 章的 PerfTracker 输出启动瀑布报告。

// src/perf/startupReport.ts
import { perf } from './PerfTracker';

type Segment = { name: string; from: string; to: string; ms: number };

// 段定义集中一处:改名只改这里,杜绝魔法字符串(第 2 章使用约定)
const STARTUP_SEGMENTS: ReadonlyArray<[string, string, string]> = [
  ['进程创建→onCreate', 'native_launch', 'app_on_create_end'],
  ['onCreate→RN 容器', 'app_on_create_end', 'rn_host_ready'],
  ['容器→JS 首行', 'rn_host_ready', 'js_boot'],
  ['JS 首行→TTR', 'js_boot', 'ttr_task_list'],
  ['TTR→TTI', 'ttr_task_list', 'tti'],
];

export function startupWaterfall(): Segment[] {
  const rows = STARTUP_SEGMENTS.map(([name, from, to]) => ({
    name, from, to,
    ms: perf.measureOnce(name, from, to), // 5.1 节补充的方法
  })).filter((s): s is Segment => s.ms >= 0);

  const total = rows.reduce((acc, s) => acc + s.ms, 0);
  return rows
    .map((s) => ({ ...s, pct: Math.round((s.ms / total) * 100) }))
    .sort((a, b) => b.ms - a.ms); // 最长的段排前面——报告先看它
}

export async function reportStartup(endpoint: string): Promise<void> {
  const snapshot = { waterfall: startupWaterfall(), deviceTier: getDeviceTier() };
  await perf.flush(endpoint); // flush 内含域名白名单校验(第 2 章安全红线)
  void snapshot; // 组包后走统一的白名单上报通道(第 13 章收敛)
}

逐段讲解

  • measureOnce 不写入 PerfTracker 的 measures:启动段与交互区间是两类数据,混在一个环形缓冲里会互相挤掉。
  • 段定义以常量表集中维护:mark 名是跨模块契约(原生、JS、报告脚本三方引用),散落必对不上——第 2 章的「mark 命名用常量收敛」在启动场景是强制项。
  • getDeviceTier() 按 总运行内存 分档(低/中/高),上报带上档位,线上看 P90 必须按档过滤(口径见上)。
  • 💡 把 startupWaterfall() 挂到开发菜单:「导出启动瀑布」一键打印当前会话的分段表,配合 Perfetto 泳道截图贴进启动报告——本章 FieldNote 实战的所有表格都出自这套流水线。

典型业务场景:FieldNote 实战

对应里程碑 M5 启动达标:冷启动 TTR ≤ 1.5s(中端安卓机 P90),且列出每项优化与收益。本章按 2.7 节闭环走完全程:测量 → 假设 → 单变量修复 → 验证归因 → 门禁。

第一步:建立基线与启动瀑布

固定条件:中端安卓真机(60Hz)、release 构建、1000 条固定种子任务(第 2 章种子脚本复用)、复测 10 次取 P50。adb shell am start -W 显示 TotalTime ≈ 2.9s,但那只是第一帧;PerfTracker 瀑布给出业务口径:冷启动 TTR P50 = 3.2s(P90 = 4.1s)。分段:

段 耗时 占比 初步归因
进程创建(Zygote fork) 120ms 4% 系统
Application.onCreate 620ms 19% 5 个 SDK 同步初始化占 540ms
RN 容器 + bundle 加载 270ms 8% 框架
require 图谱执行 790ms 25% 1402 个模块在启动期执行
React 渲染 + Fabric mount 630ms 20% 首屏大 Context + 全量列表渲染
首屏数据就绪 420ms 13% SQLite 全量 1000 条查询阻塞首帧
首帧绘制上屏 350ms 11% 主线程被前面几段挤占
合计 TTR 3200ms 100% P90 = 4100ms,远超 1.5s 达标线

瓶颈的证据链靠系统级工具闭环,不能只凭自建打点:

  • Perfetto 泳道显示主线程在 onCreate 段被三个 SDK 的同步调用占满——5.1 节的 Trace.beginSection 在此兑现价值,每个 SDK 一个可辨的 slice;
  • React DevTools Profiler 的启动录制显示首帧 commit 渲染了约 1000 个 TaskCard(组件计数在 Profiler 顶部可读);
  • 模块计数日志(5.5 节的注入统计)显示启动期执行了 1402 个模块,其中约 885 个与首屏路由无关。

三份独立观测分别对应三个修复假设——这也是 2.7 节「假设必须可证伪」的实操形态:每条假设都能被一个独立的观测推翻。

三个瓶颈假设成形(每条带预期数字,2.7 节纪律):① SDK 同步初始化贡献 540ms,编排后预期 onCreate 段降到 100ms 内;② Eager require 图谱贡献 790ms,瘦身预期减半;③ 首屏渲染与数据共 1050ms,快照先行 + 首屏拆分预期砍到 400ms 内。

第二步:逐项修复

修复 1:SDK 延迟初始化编排器(原生,5.3 节落地)

// android/.../startup/StartupTasks.kt —— 分层清单即文档
object StartupTasks {
    fun must(app: Application): List<() -> Unit> = listOf(
        { CrashGuard.install(app) },          // 110ms → 核心 handler 版 38ms:只装捕获钩子
        { TaskDb.openWithMigrationCheck() },  // 60ms:连接 + 存在性检查;迁移异步
    )
    fun should(app: Application): List<() -> Unit> = listOf(
        { Analytics.init(app) },              // 95ms → 后台
        { HotUpdate.prefetchManifest(app) },  // 70ms → 后台,且仅 Wi-Fi(第 18 章)
    )
    fun afterJsReady(app: Application): List<() -> Unit> = listOf(
        { PushBridge.registerAndForward(app) }, // 180ms:需要 JS 侧监听 token 事件
    )
    // 扫码引擎(25ms)走 lazy:ScanEngine.getInstance() 双重检查锁,首次进扫码页才加载
}

同步段从 620ms 收缩到 90ms。推送注册挪到 jsReady 之后的附带收益:修掉了一个真实 bug——原来在 onCreate 里注册的 token 回调,常在 JS 监听器就位前触发,token 丢失后要等下次前台才补报。

修复 2:require 图谱瘦身(JS)

// ❌ 优化前:App.tsx 从桶文件取页面——任何页面的 import 都会执行全部 14 个页面
import { TaskListScreen, CameraScreen, ReportScreen /* ...14 个 */ } from './src/screens';

// src/screens/index.ts(桶文件)——罪魁祸首,删除
// export { default as CameraScreen } from './CameraScreen'; // 拖入相机+权限+扫码引擎 JS 侧
// export { default as ReportScreen } from './ReportScreen'; // 拖入 xlsx 工具链
// ✅ 优化后:导航器按路由直接引用;重库在使用点内联(5.2 节模式)
import TaskListScreen from './src/screens/TaskListScreen';
import TaskDetailScreen from './src/screens/TaskDetailScreen';
// 相机/报表/设置等页面同样直接引用——执行权交给路由,而不是交给桶文件

// ReportScreen 内部:xlsx 链首次导出才 require(5.2 节代码)
// 依赖审计附带收益:lodash 3 个版本分叉收敛(pnpm catalog)、moment → dayjs,
// bundle -1.8MB,mmap 页错误随之减少

启动期执行模块数 1402 → 517,require 段 790ms → 260ms。

修复 3:首屏拆分与快照渲染(JS)

// src/screens/TaskListScreen.tsx —— TTR 只对「首屏可见的 12 张卡片」负责
const FIRST_BATCH = 12;

function TaskListScreen() {
  const onFirstRender = useTTR('task_list'); // 第 2 章:只记首个有意义渲染
  // 第一层:MMKV 快照(上次列表前 100 条的元数据)同步读,先让屏幕有真内容
  const [tasks, setTasks] = useState<Task[]>(() => taskSnapshot.read() ?? []);
  const [fullyLoaded, setFullyLoaded] = useState(false);

  useEffect(() => {
    onFirstRender(); // 快照已进入渲染 → TTR 终点(有意义内容,不是骨架屏)
    // 第二层:全量查询推迟到首帧交互空闲之后
    const cancellable = InteractionManager.runAfterInteractions(async () => {
      const rows = await taskRepo.listAll(); // SQLite 全量(第 14 章展开存储层)
      setTasks(rows);
      setFullyLoaded(true);
      taskSnapshot.write(rows.slice(0, 100)); // 给下一次冷启动供快照
    });
    return () => cancellable.cancel();
  }, []);

  return (
    <FlashList
      data={tasks}
      estimatedItemSize={72}
      // 首帧只挂载 FIRST_BATCH 张卡片,其余滚动时再挂(第 3 章窗口化)
      renderItem={({ item }) => <TaskCard id={item.id} title={item.title}
        status={item.status} score={item.score} />}
      ListFooterComponent={fullyLoaded ? null : <SnapshotFooter />}
    />
  );
}

快照方案有一个必须想清楚的正确性问题:快照可能过期。上次会话之后,服务器上的任务可能已变更(状态推进、新任务下发)。这里的一致性策略是刻意的「先旧后新」:快照只用于填充 TTR 关键路径的秒级窗口,全量查询在交互空闲期立刻补上并覆盖——用户最多在首屏看到几秒内的旧状态,而巡检场景下任务状态的变化频率远低于秒级;真离线时(第 14 章)全量查询也只走本地 SQLite,快照与库的数据最终一致。⚠️ 如果业务对首屏时效敏感(如接单抢单类),快照必须降级为「骨架 + 缓存元数据」,不允许把过期业务数据当真内容渲染。

配合第 2 章已完成的 Context 拆分(设置/主题与任务数据分家),首帧渲染的组件数从全量 1000 卡降到首屏 12 卡,渲染+mount 段 630ms → 250ms;数据段因为「快照先行、全量后补」,关键路径上只剩 MMKV 微秒级读取,420ms → 110ms。

第三步:验证与收益归因

同机同条件复测 10 次取 P50,逐项归因(单变量:每项修复独立提交、独立复测):

优化项 改动段 收益(P50) 原理
修复 1:SDK 延迟编排 onCreate 620→90ms -530ms 同步关键路径只剩 must 层;should 与 JS 启动并行,afterJsReady 等 JS 首帧信号
修复 2:require 瘦身 + 依赖审计 require 790→260ms;bundle 段 270→240ms -560ms 启动只执行首屏路由可达子图(模块 1402→517);包体 -1.8MB 减少页错误
修复 3:首屏拆分 + 快照渲染 渲染 630→250ms;数据 420→110ms -690ms 首帧只挂载 12 卡 + MMKV 快照先上屏;全量数据挪出 TTR 关键路径
连带收益:主线程解堵 绘制段 350→90ms -260ms onCreate 与 mount 不再挤占主线程,首帧 VSync 排队时间消失
合计 TTR 3200→1160ms(≈1.1s) -2040ms P90 = 1.42s ≤ 1.5s 达标线,M5 验收通过;温启动 P90 = 480ms ≤ 600ms 亦达标

收益表里刻意区分「直接收益」与「连带收益」两类:前三项是各自段的直接缩短,第四项是主线程解堵后绘制段的被动改善——它不属于任何单项修复,但真实存在且必须入账,否则总收益对不上(单变量归因要求「各项之和 ≈ 总差值」,差出来的部分就是耦合效应,写清楚比假装不存在更诚实)。

iOS 侧优化清单与收益(iPhone 中端真机、profile 构建、P50,基线 2.4s):

优化项 收益 说明
+load 清理(2 个 SDK) -70ms +load 在 main 前串行执行、无法并行;改为 +initialize 按需触发
didFinishLaunching 瘦身 -190ms 只留窗口 + RN 容器;SDK 走同一套 must/should/afterJsReady 清单
第三方动态库改静态链接 -110ms dyld 少 4 个镜像的 load/rebase/bind,pre-main 缩短
require 瘦身(与 Android 同一份 JS) -430ms JS 改动双端共享——这是 RN 的结构性红利
首屏拆分与快照渲染(同上) -560ms mount 与数据段同步缩短
合计 2.4s → 1.04s P90 = 1.19s

第四步:回归门禁

性能是会腐化的资产(2.7 节),启动尤其如此——每个新需求都想往启动路径上塞一行代码。FieldNote 的门禁三条:

  1. CI 机器门禁:设备农场跑 adb shell am start -W -S ×5 取中位数,超 1.6s(达标线 +10% 余量)即失败;iOS 用 XCUITest 的启动指标同口径把关。
  2. 体积门禁:bundle 体积与「启动期执行模块数」(5.5 节的注入统计)进 CI 对比,增量超 2% 需要注释说明。
  3. 线上灰度门禁:PerfTracker 按 5% 采样上报启动瀑布,灰度期对比上周 P90(分设备档),劣化超 10% 自动告警并冻结发布(第 17 章灰度流程衔接)。

门禁阈值统一留 10% 余量不是宽松,而是承认测量本身有噪声:同机同条件复测的启动耗时天然有 ±3%~5% 的抖动(页缓存冷热、后台任务偶发),阈值贴着达标线会把每次正常波动都变成红灯,红灯狼来了三次,门禁就死了。门禁的可信度来自「报警必真」,这与第 2 章「复测不少于 5 次」是同一条统计纪律。

常见陷阱

陷阱 典型症状 根因 正确做法
用 Debug 构建测启动 「优化了 40%」,上线无感;数字忽大忽小 dev 构建:无字节码、模块按需编译、sourcemap 插桩、日志全开,与 release 是两个世界 定性用 dev,一切对外数字用 release 真机(第 2 章口径)
在 JS ready 前做重 IO/大 JSON 解析 TTR 里「莫名」的 200~500ms,Perfetto 显示 JS 线程启动即满载 hydration、配置解析、缓存重建被串进启动关键路径 must 只水合最小集(单 key MMKV);其余进 should/lazy(5.2 节 StartupQueue)
把骨架屏/白屏当首帧上报 系统口径(TotalTime)全绿,用户投诉「打开要等」 上报终点挂在第一帧或组件 mount,而非有意义内容 TTR 终点 = 首屏数据可见的 onRender;Android 配合 reportFullyDrawn()
异步初始化的隐式时序依赖 闪退集中在冷启动前几秒:原生在 onCreate 注册回调,JS 未就位时事件已发出 任务挪到异步后,原有的「同步天然保证的顺序」被打破,且无显式依赖声明 每个跨侧任务显式挂 jsReady/await 链;should 层 runCatching 隔离(5.3 节)
在 onCreate/onCreate 里等异步结果 主线程卡死、ANR 用 CountDownLatch/future.get 在主线程等后台任务,绕过了编排的意义 主线程只做 must;跨侧等待只发生在后台队列(jsReady.await 在协程里)
iOS 用模拟器冷启动数据下结论 模拟器快且稳,真机差一倍且抖动 模拟器的磁盘、dyld 路径、温控调度与真机完全不同 iOS 启动结论只在真机 + profile/release 构建(Instruments App Launch)
优化后没有回归门禁 三个月后启动悄悄回到 2.5s,没人知道哪次改动干的 每个新需求都在启动路径上加一行代码,无门禁则无人拦截 CI 启动耗时 + 体积 + 模块数三道门禁(FieldNote 第四步),阈值留 10% 余量
把 am start -W 的 TotalTime 当 TTR 汇报 原生团队与 JS 团队数据对不上,互相甩锅 TotalTime 止于第一帧绘制,TTR 止于有意义内容,口径不同 报告双口径并列并注明定义;系统口径配 reportFullyDrawn 对齐业务终点

最佳实践

  1. 启动优化的第一动作永远是建立双口径基线:系统口径(am start -W / Instruments)+ 业务口径(PerfTracker TTR/TTI),后续所有数字挂在同一条瀑布上。
  2. 原生 marker 与 JS marker 必须并轨时间轴(单调钟差值换算),否则瀑布分段全是错位的。
  3. Application.onCreate / didFinishLaunching 实行「预算制」:同步段只留 must 层且总预算 100ms,超预算的任务降层而不是优化。
  4. SDK 初始化三问决策:首帧前必须吗?能后台吗?首次使用是什么时候?——答案分别是否/是/晚于首帧的,一律 should 或 lazy。
  5. 任何跨侧启动任务显式声明依赖(jsReady 信号、await 链),禁止依赖「同步代码天然有序」的隐式保证。
  6. JS 侧用 inlineRequires + 桶文件清理把 require 段压到首屏可达子图;对顺序敏感模块用 blockList 排除并全量回归。
  7. 启动期 KV 读取遵守「小 value 进 must、大 value 进 should」;启动路径禁止 JSON.parse 兆级字符串。
  8. TTR 终点永远是有意义内容;骨架屏只做视觉过渡,绝不进入任何上报口径。
  9. 包体积治理先砍「启动可达子图」,再谈总体积;拆包(第 18 章)前后必须回到启动瀑布验证,防止首屏依赖掉进动态包。
  10. 线上启动监控只看分位数、按设备分档、TTI 后采样上报;与上周比,不与历史均值比。
  11. 每项优化独立提交、独立复测(单变量),收益毫秒数与原理写进 ADR——FieldNote 收益表就是逐项归因的产出物。
  12. 三道回归门禁(启动耗时、体积/模块数、灰度 P90)随 M5 验收同时落地;没有门禁的优化默认会回退。

本章小结

本章把启动拆成了一条可测量、可归因的链路:进程创建 → Application/AppDelegate → RN 容器 → bundle 加载(Hermes 字节码 mmap,执行到哪页读哪页)→ require 图谱执行 → React 渲染 → 首屏数据 → 首帧上屏,TTR/TTI 两条口径经原生-JS 时间轴并轨后全程可打点。三条战线的打法各不相同:JS 侧靠 Hermes 字节码 + 内联 require + 首屏瘦身实现「少执行」;原生侧靠 must/should/lazy 编排把 SDK 挪出主线程关键路径,并用 jsReady 信号解决跨侧时序;体积侧先砍启动可达子图再压总量,与第 18 章「拆着加载」分工明确。测量上 am start -W/Perfetto/Instruments/os_signpost 各管一段,PerfTracker 瀑布报告统一业务口径。FieldNote 用四项修复把冷启动 TTR 从 3.2s 压到 1.1s(P90 1.42s 达标),并把收益固化进三道回归门禁。

一句话记忆点:启动是一场编排——首帧之前只允许「必须」,其余一切都有资格等待。

动手练习

  1. 基础巩固:对一个全新的 RN 模板应用(Hermes 开启)做冷启动测量:adb shell am start -W -S ×10 取中位数,再用 5.1 节的打点方案(可先只接 onCreate 与 js_boot 两个点)画出简易瀑布,解释 TotalTime 与你测得的 TTR 之间的差值来自哪几段,并说明为什么差值不为零是正常的。
  2. FieldNote 实战 A:FieldNote 设置页当前在启动路径上全量水合 MMKV 的 12 个 key(含几个上百 KB 的大 value)。按 5.2 节 StartupQueue 把它们重排为「must 只读主题 + 语言,其余 should」,真机 release 复测 TTR 前后各 10 次取 P50,并解释:为什么「同步 MMKV 读取」也必须按需而不是一把梭。
  3. FieldNote 实战 B:为 FieldNote 落地 5.6 节的「启动预算表」:给出每一档设备的预算分配(低端机总预算放宽到多少?为什么不能同一条线?),并把 CI 门禁写成脚本(am start -W 循环 + 中位数判断 + 体积对比),在 PR 描述里贴出超预算时的处理流程。
  4. 思考题:为什么「更小的 bundle」不一定总是「更快的启动」?请举出两个反例并分别给出判断方法。提示方向:反例一从「拆包把首屏依赖拆进动态包」出发,分析加载时序变成了什么样;反例二想想两个体积相同、但一个 80% 模块在启动可达子图、另一个只有 20% 的 bundle,它们的启动差异由什么决定。判断方法统一指向同一条原则——回到 5.1 的瀑布看分段,而不是看文件大小。

导航

上一章 第 4 章 内存管理与泄漏排查 | 下一章 第 6 章 双端通信机制全解

📄 第 6 章 双端通信机制全解 06-native-communication.md

本章导读:进入第三阶段「原生桥接」。第 1 章从架构高度回答了「JSI 为什么取代 Bridge」,本章把通信机制摊开到可以动手的程度:每条通道的方向、线程、时序、开销与适用边界。掌握本章后,第 7 章的 TurboModule 与第 8 章的 Fabric 组件只是「把通道用 codegen 脚手架规范地包起来」——通道选错了,脚手架再规范也救不回来。

本章学习目标

学完本章,你应该能够:

  1. 画出 JS 与 Native 之间全部通道的地图,并为任意一个跨端需求指出该走哪条通道、为什么。
  2. 解释 JSI 的 HostObject 机制(get/set/调用)、CallInvoker 的线程切换职责,以及「同步调用成为可能」的底层原因与代价。
  3. 编写一个最小的 C++ HostObject,说出 jsi::Value 各类型的跨端转换成本。
  4. 描述一次跨端调用经过的全部线程,并在 Android/iOS 上用正确的姿势切线程、把结果送回 JS。
  5. 在 Promise、Callback、Event Emitter、Commands 四种上行方式中做出选型,说清各自的生命周期与一次性语义。
  6. 对高频数据(定位/传感器/扫码流)设计「原生侧缓冲合并」方案,并判断何时该走「不落 JS」的原生直驱路线。
  7. 识别 codegen 类型系统的约束与大 payload 的真实成本,为二进制数据选择正确策略。
  8. 诊断六类通信层故障(晚到事件、监听器泄漏、Promise 悬挂、主线程卡死、事件风暴、乱序)并给出修复。

核心知识点

6.1 通信全景:一张地图看懂所有通道

原理讲解

新架构下 JS 与 Native 不再有那个异步消息队列(第 1 章 1.2 节),但「通道」不止一条——不同通道的差异在于方向、线程、时序与生命周期。先上地图:

flowchart LR
    subgraph JS["JS 侧(Hermes,JS 线程)"]
        J1["业务代码"]
        J2["TurboModule 代理对象"]
        J3["React 组件树"]
    end
    subgraph CPP["C++ 层(JSI)"]
        C1["jsi::Runtime"]
        C2["HostObject / HostFunction"]
        C3["CallInvoker(回 JS 线程的调度器)"]
    end
    subgraph NAT["原生世界"]
        N1["TurboModule 实现<br/>Kotlin / Swift"]
        N2["Fabric 组件<br/>ComponentDescriptor / ViewManager"]
        N3["全局事件源<br/>RCTEventEmitter 等"]
        N4["C++ 能力<br/>算法 / 解码 / 加密"]
    end
    subgraph UI["UI 线程(主线程)"]
        U1["原生视图"]
    end
    J2 -->|"① 方法调用:Promise / Callback / 同步返回"| N1
    N1 -->|"结果经 CallInvoker 编组回 JS 线程"| C3
    C3 --> C1
    J3 -->|"② Props:随 commit 下行(声明式、可 diff)"| N2
    N2 -->|"③ 事件:UI 线程产生、批量上行"| J3
    J3 -->|"④ Commands:命令式指令直达 UI 线程"| N2
    N3 -->|"⑤ 全局事件广播:任意线程发、JS 线程收"| J1
    J1 -->|"⑥ JSI 直调:同步、参数级开销"| C2
    C2 --> N4
    N2 --> U1

六条通道的完整对照——本章后面各节就是按这张表逐行展开的:

通道 方向 线程与时序 传输开销 返回语义 适用边界
① TurboModule 方法(Promise) JS→Native→JS 异步;结果由框架编组回 JS 线程 参数一次转换 恰好一次结果 IO、重活的标准姿势
① TurboModule 方法(Callback) JS→Native→JS 同上 同上 约定至多一次 老接口兼容;新代码优先 Promise
① TurboModule 方法(同步返回) JS→Native 同步,阻塞 JS 线程直到返回 参数级(JSI 直调) 即时 只允许轻量、无 IO、无锁等待的读取
② Fabric Props JS→Native 随 commit 批量下行 结构化 diff,增量 持续声明(无「返回」) UI 状态描述
③ Fabric 事件 Native(UI)→JS UI 线程产生,可能合帧后批量上行 每事件一次转换 持续、多帧 组件交互反馈(按压、滑动、加载完成)
④ Commands JS→Native 指令调度到 UI 线程 极小 单向即发(无返回) 命令式 UI 意图:focus、scrollTo、拍照
⑤ 全局事件 Emitter Native→JS 广播 任意线程发出,JS 线程逐个回调 每订阅者一次投递 0..n 次、持续 无组件归属的系统类事件:定位、网络、电池
⑥ JSI 直调(HostObject) 双向 同步,在发起方线程执行 参数级 即时 C++ 能力、高频小调用(极度克制)

这张表的正确用法是「先找行、再看列」:拿到需求先按方向定位到行,再用线程与时序列校验实现可行性,最后用返回语义确认次数预期——三列都对得上,这条通道才进入候选;有任何一列对不上,回到候选集重新找,而不是硬改需求去迁就通道。

三条选型总纲,先记住再往下看:

  • JS 发起的一律走 ①:凡是「JS 要一个结果」或「JS 要触发一个动作」,都是 TurboModule 方法;方法内部决定用 Promise 还是同步返回(判据见 6.2)。
  • Native 发起且与某个组件无关的,走 ⑤;与组件相关的走 ③。判断标准是事件的生命周期跟谁绑定——跟着视图生死走的用 Fabric 事件,跟着 App/会话走的用全局事件。
  • 操作 UI 用 ②④,永远不要用 ①⑤ 绕过 Fabric 去直接摆弄视图——声明式系统的状态一旦被命令式旁路,就会出现「React 认为的样子」与「屏幕实际的样子」分叉,下一帧 diff 无法自愈。

读这张表还要带上「成本三账本」,任何一次跨端传输都在三处记账:

  • 转换账:参数/返回值的类型转换与拷贝(6.2 节的类型表决定单价);
  • 唤醒账:JS 线程从空闲被唤醒处理一次消息的固定成本(合批省的就是这笔);
  • GC 账:跨界产生的临时对象最终都要回收(大 payload 的高峰与长尾都在这)。

顺带说清「同步返回」在新架构下的真实开销:虽然 JSI 直调消除了序列化,但 codegen 的胶水仍要做参数校验与 JSI 值物化——单次在亚微秒到几微秒级。它比 Promise 路径便宜的不是「快了一个数量级的吞吐」,而是省掉了线程切换与两次调度;这份便宜只在「高频、轻量、同步语义必需」时才兑现,滥用作 IO 的代价就是 6.1 示例里那种全 App 冻结。

方向不对称是这套架构的既定事实,值得单独点破:六条通道里,「Native 主动发起、要求 JS 给一次答复」的通道并不存在——Native 无法像 JS 调 Native 那样直接调用 JS 函数。需要这种语义时只能用组合拳:Native 发事件(⑤)并携带请求 id,JS 收到后调 TurboModule 方法(①)应答。这一不对称塑造了大量原生 SDK 的 JS 封装形态(「先 addListener 再 start」),也解释了为什么 Native 回调 JS 的唯一途径是「往 JS 线程排一个任务」(CallInvoker),而不是「打一个电话」。而且它不是设计缺陷,是刻意的单向依赖:启动时序上 Native 先于 JS 存在(第 5 章的瀑布里 JS 启动排在原生初始化之后),若 Native 可以随时调用 JS,每个原生模块都要处理「JS 还没就绪时对空说话」的窗口;单向依赖把这个问题收敛成「JS ready 后主动拉一次状态」这一种成熟模式。

旧架构教条的新架构校准:Bridge 时代「每次跨端都是序列化 + 跨线程队列」,教条是「能不跨端就不跨端、跨端必批量」。新架构下方法调用是 JSI 直调、参数按 codegen 类型转换,单次调用便宜了一个数量级,教条的前半句过时了;但后半句依然成立——批量的智慧从「省序列化」变成了「省唤醒」:每次上行仍伴随一次 JS 线程唤醒与一次 React 处理,高频数据合批(6.4 节)依然是铁律。理解这条演进线,迁移旧代码时就不会矫枉过正——该合的批一个都不能少合。

代码示例:同一个「获取当前定位一次」的需求,三种错误通道 vs 一种正确通道。

// ❌ 通道③ 同步返回:JS 线程被阻塞到定位完成(定位要几秒!)——整个 App 冻结
const lat = FieldNoteLocation.getLatitudeSync();
// ❌ 通道② Props 下行:定位不是 UI 状态描述,Props 没有「请求-应答」语义
<MapView latitude={latitude} />  // Props 只能「告诉」视图,不能「问」视图要数据
// ❌ 通道⑥ JSI 直调:定位引擎在 Kotlin/Swift 而非 C++,同步语义同样阻塞 JS 线程
const lat = global.__LocationProbe?.currentLatitude();
// ✅ 通道① Promise:JS 发起的请求-应答,Native 侧自由切线程,结果异步回 JS
const { lat, lng } = await FieldNoteLocation.getCurrentPosition();

逐段讲解

  • 同步返回的「致命诱惑」:它写起来最顺手,于是总有人拿它做 IO。判据一句话:同步通道的预算是微秒级——读一个内存态、算一个纯函数可以;碰磁盘、网络、锁、跨线程等待的,一律 Promise。6.2 节会从 JSI 机制层面解释为什么。
  • Props 与 Commands 的边界(②vs④):状态用 Props(「缩略图列表是这 12 张」),意图用 Commands(「立即滚动到顶部」)。当一个「意图」也能用状态表达时(scrollToIndex={3}),优先状态——它可被 diff、可被恢复;只有「时点敏感、一次即弃」的意图才值得 Commands。
  • 「拉」与「推」的本质区别:「JS 问一次」是拉(发起方明确、答案有限次),「持续数据流」是推(次数不可预知)。本章所有通道选型混乱,八成源于把「推」的需求塞进了「拉」的通道——比如用 Promise 承载持续事件,第二个事件就无处安放。
  • 同步通道的三个合法用例,为它正名:① 启动期并轨打点(第 5 章 StartupMarks.nowSinceLaunchSync——必须发生在 JS 第一条语句);② 能力探测(「本机是否支持某硬件」,答案是内存里的布尔);③ 常量读取(SDK 版本号、编译期配置)。三个共同点:答案在调用发生前就已经在内存里。
  • 全局事件(⑤)是本章 FieldNote 实战的主角:定位流不属于任何组件(巡检全程都要),且多页面可能同时关心,Emitter 的多对多广播语义正好匹配。它的生命周期陷阱(监听器配对)在 6.6 节急诊室展开。

最后列三个高频选型误区,评审时可直接对号:

  1. 用轮询模拟推送:JS 侧 setInterval 反复调同步方法查状态——正确做法是原生感知变化后推事件(本章陷阱表第一行);
  2. 用全局事件承载组件交互:按钮按压、滑块变化走 ⑤,导致「谁在听、听多久」无人管理——组件交互的生命周期跟视图走,走 ③;
  3. 用 Props 传一次性指令:shouldScrollToTop={true} 之类——第二次触发时值没变,Props 不 diff 不生效,指令永远只能发一次。时点敏感的意图请走 Commands。

附录:旧架构通信习惯的迁移对照。维护存量项目的读者可以按表逐行校准——旧模式不是「错了」,而是在新架构下有了更便宜或更安全的对应物:

旧架构习惯 新架构对应 迁移要点
NativeModules.X 直接调用 codegen Spec 生成的强类型代理 类型检查从运行时提前到编译期(6.5 节)
RCT_EXPORT_MODULE + @ReactMethod TurboModule Spec(Kotlin/Swift 实现) 第 7 章全流程;方法签名受 codegen 类型约束
RCTEventEmitter.sendEventWithName 新架构 EventEmitter<T>(或经 interop 沿用) 事件 payload 获得 schema;本章 Emitter 语义不变
NativeAppEventEmitter(组件私有事件) Fabric 组件事件(③通道) 生命周期跟随组件,不再需要手动配对
UIManager.dispatchViewManagerCommand Fabric Commands(④通道) 指令获得类型签名,目标锁定具体视图实例
getConstants() 返回常量表 Spec 常量 / 同步 TurboModule 方法 常量仍是同步语义的合法用例(本节三个用例之一)
Promise 在 bridge 队列 resolve 直接在后台线程 resolve(框架编组) 不再需要手动切回 JS 线程(6.3 节两层编组的区分)

这张表还有一个隐藏用法:评审存量代码时,看到左列 API 就是「通信层技术债」的标记——不必立刻迁移,但每次触碰该模块时顺手换到右列,通信层的现代化就摊销在日常迭代里。

6.2 JSI 深入:HostObject、CallInvoker 与同步调用

原理讲解

JSI 是一层 C++ 抽象接口,屏蔽 V8/JSC/Hermes 的差异,让原生代码「直接持有并操作 JS 对象」。一个澄清:JSI 是接口,Hermes/JSC/V8 是实现——RN 0.8x 默认的 Hermes 实现了这套接口,理论上任何引擎都能接进来(这也是「引擎可插拔」的技术基础)。第 1 章 1.3 节交代了它的位置,这里下到 API 层。三个核心构件:

  • HostObject:一个实现 get / set / getPropertyNames 的 C++ 对象,在 JS 侧表现为普通对象。⚠️ 关键机制是惰性触发:JS 读 obj.model 这一行代码,才调用 C++ 的 get("model")——属性访问就是函数调用。HostFunction 同理:JS 调用这个「函数」时直接进入 C++。
  • CallInvoker:一个绑定在 Runtime 实例上的任务队列,把「要在 JS 线程执行的工作」排进 JS 线程。任何后台线程想把结果交还 JS(调回调、resolve Promise),都必须经它编组——这是 6.3 节线程模型的枢纽。
  • jsi::Value:JS 值在 C++ 侧的统一表示。它不是拷贝数据的容器,而是引用 + 类型标签——或引用 JS 堆上的对象,或持有原始值。

为什么同步调用成为可能:旧架构 Bridge 是「序列化→跨线程队列→反序列化」,本质是异步消息传递,同步必须自旋等待(慢且易死锁)。JSI 之下 JS 与 Native 在同一进程、共享同一个 Runtime,get() 就是一次普通 C++ 函数调用——没有队列、没有序列化,纳秒到微秒级完成。但「同步」意味着 JS 线程原地等你返回:这正是同步通道预算必须极小的底层原因——预算不是约定的礼貌,而是「你占着全场唯一能执行 JS 的线程」这一事实的数学后果。

CallInvoker 值得再多说两句,第 7 章的全部样板代码都要用它:它暴露 invokeAsync(fn)(把 fn 排进 JS 线程后立即返回)与 invokeSync(fn)(阻塞当前线程,直到 fn 在 JS 线程执行完毕)。⚠️ invokeSync 有一个经典死锁:从 JS 线程调 invokeSync——任务排在 JS 线程的队列里,而 JS 线程正卡在这里等它执行,条件永不满足。规则:JS 线程上只用 invokeAsync;invokeSync 只允许从「确定 JS 线程此刻空闲」的语境调用(现实中几乎不存在),生产代码一律 invokeAsync。

HostObject 与 TurboModule 的关系也要对齐:TurboModule 在 JS 侧暴露的对象,本质就是一个带 schema 的 HostObject——codegen 生成的 C++ 胶水在 get 层按方法名分发、按类型签名转换参数、做校验。理解了本节的裸 HostObject,第 7 章的 codegen 产物就没有任何魔法:生成代码做的事,就是这里的 get/set/call 加上类型系统。

维度 裸 HostObject(本节) codegen TurboModule(第 7 章)
类型安全 手写转换,错在运行时 Spec 声明 + 编译期生成校验
方法分发 自己写 if/else 链 codegen 按 Spec 生成
线程/回调 全手动(CallInvoker) 框架胶水接好
接入成本 低(几行 C++),但双端接线样板多 走标准脚手架,可发布 npm 包
适用 极简探测、热点小函数 一切业务模块

还有一条寿命规则要单独交代:jsi::Runtime 会随「reload」销毁重建(开发菜单 Reload、热更新加载新 bundle,第 18 章),所有 HostObject 与注册随之重来。如果你在 C++ 侧缓存了指向旧 Runtime 的引用或把 JS 对象存进了全局静态变量,reload 后就是悬空指针——这也是「C++ 层全局状态」必须挂在模块实例上(随模块 invalidate 重建)而不是 static 变量上的原因。

jsi::Value 的类型体系与转换成本:

jsi 类型 JS 侧对应 跨端成本 说明
undefined / null 同名 零 纯类型标签
bool boolean 零
number number 零 底层是 double,寄存器级传递
String string UTF 转换 + 分配 utf8(rt) 产生新 C++ 字符串;高频调用的主要成本项
Object object 惰性代理 HostObject 按属性逐个物化,不访问不转换
Array Array 逐元素访问 长度 O(1),遍历 N 元素就是 N 次 get
Function function 可持久化 HostFunction 包装;被 C++ 持有会延长 JS 对象寿命(泄漏点)
ArrayBuffer TypedArray/ArrayBuffer 视实现 二进制现状见 6.5 节

代码示例:一个约 40 行的最小 HostObject(C++),暴露设备信息与一个纯函数校验和。

// cpp/DeviceProbe.h —— 最小 JSI HostObject:同步、无 IO、纳秒级预算
#include <jsi/jsi.h>
#include <string>
using namespace facebook;

class DeviceProbe : public jsi::HostObject {
 public:
  // ① get:JS 读任意属性时触发(惰性、同步、在 JS 线程)
  jsi::Value get(jsi::Runtime& rt, const jsi::PropNameID& name) override {
    const std::string prop = name.utf8(rt);              // 属性名转换:一次 UTF 开销
    if (prop == "model")    return jsi::String::createFromAscii(rt, deviceModel());
    if (prop == "uptimeMs") return jsi::Value(uptimeMs());    // double,零成本
    if (prop == "crc32") {                                  // 暴露一个 HostFunction
      return jsi::Function::createFromHostFunction(
          rt, jsi::PropNameID::forAscii(rt, "crc32"), 1,
          [](jsi::Runtime& rt, const jsi::Value&, const jsi::Value* args, size_t n) -> jsi::Value {
            if (n < 1 || !args[0].isString())
              throw jsi::JSError(rt, "crc32(str) 需要一个字符串参数");
            return jsi::Value(static_cast<double>(crc32(args[0].asString(rt).utf8(rt))));
          });
    }
    return jsi::Value::undefined();
  }
  // ② set:本对象只读——用抛错把「误写」变成显式错误而非静默失效
  void set(jsi::Runtime& rt, const jsi::PropNameID& name, const jsi::Value&) override {
    throw jsi::JSError(rt, "DeviceProbe." + name.utf8(rt) + " 是只读的");
  }
  // ③ 枚举属性:Object.keys 与调试器需要
  std::vector<jsi::PropNameID> getPropertyNames(jsi::Runtime& rt) override {
    return { jsi::PropNameID::forAscii(rt, "model"), jsi::PropNameID::forAscii(rt, "uptimeMs"),
             jsi::PropNameID::forAscii(rt, "crc32") };
  }
};
// 注册:挂到 globalThis.__DeviceProbe(挂接时机经 TurboModule 的 getBindingsInstaller 完成,
// 完整脚手架与自动链接见第 7 章)。挂接动作发生在 Runtime 创建后、JS bundle 执行前,
// 所以 JS 侧第一条语句就能访问它——第 5 章的启动打点正是依赖这个时序。
void installProbe(jsi::Runtime& rt) {
  rt.global().setProperty(
      rt, "__DeviceProbe",
      jsi::Object::createFromHostObject(rt, std::make_shared<DeviceProbe>()));
}
// JS 侧使用:注意每次属性访问都是一次跨语言函数调用
declare const __DeviceProbe: { model: string; uptimeMs: number; crc32(s: string): number };
console.log(__DeviceProbe.model, __DeviceProbe.crc32('abc')); // → 各触发一次 C++ 调用

逐段讲解

  • get 里的 name.utf8(rt) 与 String::createFromAscii 是这段代码仅有的转换成本——对照类型表理解:属性名与返回字符串各付一次 UTF 转换,uptimeMs 这种 number 零成本。高频通道的第一原则:传数字与布尔,少传字符串,绝不传大对象。
  • set 抛错而不是静默忽略:HostObject 的 set 默认什么都不做(JS 侧表现为「赋值了但读回来还是旧值」),这是排查时的经典迷宫;显式抛错把问题暴露在发生地。
  • jsi 对象的寿命规则:所有 jsi 对象只在所属 Runtime 内存活、不跨线程传递。「把一个 jsi::Value 存起来稍后用」必须想清楚两件事——稍后是哪个线程(必须是 JS 线程)、期间 Runtime 是否还活着(与第 4 章 invalidate 语义同源)。这正是 CallInvoker 回调里持有 shared_ptr 的原因。
  • 与 GC 的交互:HostObject 属性被 JS 侧反复物化时(每帧读同一个属性),每次都是一次跨界调用加临时对象分配,给 GC 添柴。热路径上的属性访问应先取一次存局部变量,或干脆下沉为一次 HostFunction 调用传结果——把「N 次属性访问」压缩成「1 次函数调用 + N 个参数」。
  • 心智模型:HostObject ≈ 一个更严格的 Proxy:JS 侧的 Proxy 是「拦截属性访问执行任意代码」,HostObject 是它的跨语言版本——但代价模型完全不同(Proxy 拦截在 JS 引擎内,HostObject 拦截要过 C++ 边界与可能的线程边界)。把 Proxy 上学的直觉搬过来没问题,把 Proxy 的性能直觉搬过来不行。
  • 属性访问即调用,为什么平时无感:普通 JS 对象查属性走隐藏类/内联缓存(纳秒级),HostObject 查属性要穿过 C++ 边界(亚微秒级)。单次差百倍但绝对值都小,差距只在「循环里每帧查上千次」的热路径才兑现成毫秒——这就是「高频小调用」边界划在「热路径重复访问」而不是「偶尔一次」的原因。
  • 错误如何过界:jsi::JSError 在 JS 侧表现为普通异常(可被 try-catch 捕获);Promisified 方法的异常则表现为 reject。两条错误路径都要设计——在 C++ 里抛 JSError 等于在 JS 里 throw,这不是日志,是控制流。
  • HostFunction 的校验做在哪一层:本例在 HostFunction 内部手写(if (n < 1 || !args[0].isString()));TurboModule 把这段校验变成 codegen 从 Spec 自动生成——同样的检查,手写一次还是生成一万次,这正是第 7 章要兑现的价值之一。
  • ⚠️ 惰性物化有一层反向含义:JSON.stringify(__DeviceProbe)、调试器查看变量这类「枚举全部属性」的操作会触发 getPropertyNames + 逐个 get——把「懒」的东西全变成「急」的。别在热路径上对 HostObject 做这类操作。
  • 什么时候值得写裸 JSI:C++ 已有的能力(现成解码器、算法库)、纳秒级纯计算、极致高频的小调用。业务逻辑永远走 TurboModule——类型安全、codegen、可维护性都值得那点开销(第 7 章展开)。

最后给裸 JSI 列一张「常见误用清单」,逐条都来自真实项目的事故:

  1. 在 get 里做 IO 或加锁——同步语义 + 阻塞操作 = 冻结或死锁(6.6 节同步通道故障);
  2. 把 jsi::Value 存进 static 变量——reload 后指向已销毁的 Runtime,悬空崩溃(本节寿命规则);
  3. 在非 JS 线程触碰 Runtime——必须经 CallInvoker,别「只是读一下」;
  4. 用 HostObject 暴露上百个业务方法——该用 TurboModule 的场景用错了工具,类型与校验全丢;
  5. 高频轮询式属性访问——「N 次属性访问」应折叠为「1 次 HostFunction 调用」。

6.3 线程调度:一次跨端调用的线程之旅

原理讲解

新架构下跨端调用的默认线程规则:TurboModule 方法体运行在 JS 线程上(调用方线程),Fabric 事件与视图操作运行在 UI 线程,两者之间靠 CallInvoker 与各自的调度原语换手。方法体默认在 JS 线程,是「调用方线程同步执行」的自然结果——JSI 直调没有「派发」动作,JS 在哪调用,方法体就在哪开跑。好处是轻方法零切换成本;代价是方法体里任何阻塞都直接吃掉 JS 线程帧预算,所以把「post 出去」变成每个方法体的肌肉记忆。用一张泳道图看一次完整的「点击原生按钮 → JS 处理 → 更新原生视图」:

sequenceDiagram
    autonumber
    participant U as UI 线程(主线程)
    participant W as 原生工作队列<br/>(Executor / GCD 后台)
    participant J as JS 线程
    Note over U: 用户点击原生按钮(Fabric 视图的触控事件)
    U->>U: 事件冒泡 → 生成 Fabric 事件(轻量,不重活)
    U->>J: 事件批量上行(合帧后派发)
    J->>J: onPress 执行:调 TurboModule 方法<br/>(方法体此刻就在 JS 线程)
    J->>W: 方法体内部:把重活 post 到工作队列
    Note over J: JS 线程立即释放——等结果期间照样渲染
    W->>W: IO / 解码 / 计算(真正的耗时段)
    W->>J: 经 CallInvoker 编组:invokeAsync(带结果)
    J->>J: 回调/Promise 执行:setState → render + commit
    J->>U: commit 产物:新 Shadow Tree → Fabric mount
    U->>U: 下一 VSync 上屏

读图要点:① 重活从不在 JS 线程与 UI 线程上发生——这两条线程一个管逻辑一个管画面,都是「不能等」的;② 「回到 JS」只有一条正规通道:CallInvoker;③ UI 线程上的操作必须极轻(事件打包、视图属性赋值),重活一律 post 出去;④ 全图只有一处「用户代码主动发起的线程切换」——方法体里把重活 post 到工作队列(J→W 那一格),其余换手都是框架行为,这也是「线程策略主要在你的方法体里」的含义。

把线程全景补完整,才能解释每一次切换的代价。Android 侧与本节相关的线程:主线程(UI 与输入)、JS 线程(Hermes 执行与 render/commit,由 RuntimeExecutor 调度)、Binder 线程池(跨进程调用的回调到达点)、以及你自己声明的工作线程。切换手段按场景选择:

  • Handler(Looper.getMainLooper()):需要回主线程收尾时(更新视图、发通知);
  • HandlerThread + Handler(looper):需要专属串行队列承载模块自有状态(本章 FieldNote 定位模块的选择——采集与合批同队列,天然串行免竞争);
  • Executors / 协程 Dispatchers:纯计算与 IO,无 UI 与队列语义诉求;
  • ⚠️ 别在 Binder 回调线程里干重活:那是系统借给你的线程,用完即还,长任务应转发到自己的工作队列。

iOS 侧对应 GCD 队列与 QoS(Quality of Service)选择,QoS 决定 CPU/IO 调度优先级与能耗评分:

  • .userInitiated:用户正在等结果的短任务(本节 loadHeader);
  • .utility:可感知但不紧急的长任务(定位合批、日志压缩);
  • .background:用户无感的维护任务(预取、清理);
  • ⚠️ 错用 .userInitiated 跑 60 分钟长任务,会被系统节能机制点名——长任务的 QoS 宁低勿高。

双端切换原语的对照表,写代码时按行取用:

需求 Android iOS 注意
回主线程收尾 Handler(mainLooper).post DispatchQueue.main.async 代码块保持微秒级
专属串行队列 HandlerThread + Handler(looper) DispatchQueue(label:) 串行队列 模块自有状态天然免竞争
并发计算/IO Executors / 协程 Dispatchers global(qos:) 并发度按资源声明
延迟/周期任务 Handler.postDelayed DispatchSourceTimer 停止时必须显式移除/取消
线程身份确认 Looper.myLooper() / 线程名 Thread.isMainThread / 断点栈 联调先看「我在哪条线程」

线程切换本身不是免费的:一次上下文切换约几微秒到几十微秒,外加目标线程的缓存冷启动与跨线程数据交接(引用/锁)。切换的代价永远小于「错误线程上的重活」,但它也足以让「每秒几百次来回蹦」的设计破产——这正是高频数据必须合批的线程侧依据(6.4 节)。联调时如何观察线程:Perfetto 泳道按线程名对号(fn-location、JS 线程、main);Xcode 调试导航器(⌘6)实时看每条队列的 CPU 占用;「我在哪条线程」的疑问永远先于「为什么慢」。

最后回答一个高频追问:为什么 Android 用 HandlerThread 而 iOS 用 GCD 队列,思路却不完全对称? Android 的 HandlerThread 是「自带消息队列的线程」,Handler 往里 post 的任务与系统回调(如定位回调指定 looper)天然同队列串行——这正是采集与合批要串行时它的价值;iOS 的 GCD 串行队列也能做到任务串行,但系统回调(CLLocationManager)不认 GCD 队列,只认 RunLoop——所以 iOS 的串行需要拆成「主线程收回调 + GCD 队列做重活」两段。平台 API 的宿主要求决定了线程拓扑,照抄另一端的方案前先问「回调往哪投递」。

代码示例:Promise 方法在双端的标准写法——方法体在 JS 线程,重活立刻 post 到后台,resolve 由框架编组回 JS。

// Android:方法体在 JS 线程执行;重活交给单线程 Executor
@ReactMethod
fun loadHeader(taskId: String, promise: Promise) {
  executor.execute {                        // ① 重活出 JS 线程
    try {
      val header = ReportRepo.loadHeader(taskId)  // ② 磁盘 IO 在后台
      promise.resolve(header)               // ③ 框架内部编组:JS 线程消费结果
    } catch (e: Exception) {
      promise.reject("LOAD_FAILED", e.message, e) // ④ 失败路径也必须终结
    }
  }
}
// iOS 同构:GCD 后台队列执行,resolve/reject 闭包由框架编组回 JS(与 Kotlin 版逐行对应)
func loadHeader(_ taskId: String,
                resolve: @escaping RCTPromiseResolveBlock,
                rejecter: @escaping RCTPromiseRejectBlock) {
  DispatchQueue.global(qos: .userInitiated).async {
    do { resolve(try ReportRepo.loadHeader(taskId: taskId)) }
    catch { rejecter("LOAD_FAILED", error.localizedDescription, error) }
  }
}
// 更底层:C++ TurboModule 里「回到 JS」的正规通道就是 CallInvoker
// (TurboModule 基类持有 jsInvoker_,第 7 章的 codegen 会把它接好)
void loadHeader(jsi::Runtime& rt, const jsi::Value& taskId, const jsi::Value& cb) {
  auto callback = std::make_shared<jsi::Function>(cb.getObject(rt).asFunction(rt));
  std::string id = taskId.asString(rt).utf8(rt);
  worker_->post([id, callback, invoker = jsInvoker_] {  // ① 后台线程干重活
    std::string json = heavyIo(id);                     // ② IO 在后台
    invoker->invokeAsync([callback, json = std::move(json)](jsi::Runtime& rt2) {
      callback->call(rt2, jsi::String::createFromUtf8(rt2, json)); // ③ 编组回 JS 线程
    });
  });
}

逐段讲解

  • Kotlin/Swift 的 Promise 为什么「看起来」不用管线程:框架层的 Promise 实现内部已做「切回 JS 线程」的编组,所以后台线程 resolve 是安全的。但一旦绕开框架、在 C++ 层直接持有 jsi::Function/jsi::Value(如第三段代码),触碰 Runtime 的每一行都必须在 JS 线程——invokeAsync 就是为此存在。区分「框架帮你编组」与「你自己负责编组」两层,是避免玄学崩溃的关键。
  • 「回到 JS」其实有三个层级,从高到低:① codegen 生成的胶水(Promise/Callback 的编组已内置,第 7 章);② CallInvoker(自己写 C++ 时的正规通道,本节第三段);③ RuntimeExecutor(RN 容器级别的调度原语,框架自身用它管理 JS 线程任务)。业务代码只会用到前两层;看到第三层出现在业务 PR 里,基本可以判定是绕开了框架自己造轮子。
  • 闭包持有 jsi 对象的寿命问题:第三段代码用 shared_ptr<jsi::Function> 而不是值拷贝——jsi 对象不能跨线程拷贝、且必须活到被调用那一刻。回调被原生持有的风险(泄漏、悬挂)在 6.4 的 Callback 通道正式展开。
  • 为什么 Executor 用单线程:报告仓库的 SQLite 访问需要串行化(并发写锁竞争比慢更糟)。并行度取决于资源语义——原则是每个模块声明自己的执行语义,而不是共用一个全局池(全局池饱和会互相拖死)。
  • UI 线程的纪律:需要在主线程收尾的操作(更新视图、发通知),Android 用 Handler(Looper.getMainLooper()).post、iOS 用 DispatchQueue.main.async,且 post 进去的代码块保持微秒级——它与「用户输入、系统绘制」同一条队列,多占 1ms 都直接变成掉帧(第 2 章 2.1 节)。
  • 不要用「等」解决「序」:任务 A 完成后才能跑 B,正确姿势是把 B 排进 A 的完成回调(串行队列/协程链),而不是在 A 里 latch.await() 干等——那是把并发 bug(第 5 章 onCreate 等待问题的近亲)搬进原生侧。
  • Promise.all 不改变线程事实:并发发三个跨端调用,JS 侧只是同时 await 三个 Promise;每个方法体仍在 JS 线程依次开跑、各自把重活 post 出去——原生的真并行取决于你自己的队列设计(三个不同工作队列才并行,同一个串行队列照样排队)。
  • 线程名就是文档:给自建线程/队列起有意义的名字(本章的 fn-location、iOS 的 com.fieldnote.location),Perfetto 泳道、Xcode 栈、崩溃堆栈里的每一条线程都能对上模块——排查时这是零成本高回报的纪律。
  • 开发期主线程断言:Android 的 Looper.getMainLooper() == Looper.myLooper() 检查与 iOS 的 dispatch_assert_queue 可以埋在「绝不该在主线程跑」的路径里,debug 构建直接崩——把线程契约变成可执行断言,胜过 review 时反复口述。
  • 高频数据的线程策略预告:定位/传感器回调先落到采集线程(本节的后台队列),由它在固定节奏下合批再上行——而不是每个数据点各触发一次「跨线程 + 跨端」。完整合批实现在本章 FieldNote 实战;UI 消费场景的「共享值直通」(完全不进 JS 线程)为第 9 章 Reanimated 铺垫。

把本节的纪律压成三句口诀,写跨端方法时逐句自检:方法体在 JS 线程,所以它必须短;重活在声明好的工作队列,所以它必须自终结(每条路径都 resolve/reject);收尾在主线程或经 CallInvoker 回 JS,所以它必须轻。三句都过,方法才配提交。

6.4 上行通知四通道:Promise、Callback、Event、Commands

原理讲解

「Native 有结果要告诉 JS」有四种姿势,差异全在次数语义与生命周期:

通道 次数语义 JS 线程成本 生命周期归属 典型场景 风险
Promise 恰好一次(resolve 或 reject) 一次唤醒 随本次调用,无需清理 一次性 IO 结果 分支漏终结 → 悬挂
Callback 至多一次(约定) 一次唤醒 闭包被 Native 持有直到调用 成功/失败双回调的老接口 不调用 → 泄漏;调两次 → 崩溃或静默失效
Event Emitter 0..n 次、持续 每事件一次唤醒 监听器显式 add/remove,与订阅方生死绑定 定位流、扫码、系统广播 监听器泄漏、事件风暴
Commands 0..n 次、单向 零(无结果回 JS) 即发即弃 focus/scrollTo/拍照 无返回值,结果要另走事件

四种通道各给一个比喻帮记忆,也顺便锁死语义:Promise 是问答(一问一答,答案唯一);Callback 是留条子(留了联系方式,对方最多按一次门铃);Event Emitter 是广播站(你调好频道就一直播,不听记得关收音机);Commands 是遥控器(按了就生效,遥控器不回答你)。比喻记语义,表格记参数——评审跨端接口时两者都用。

四种通道还能从「谁负责记住状态」角度理解:Promise/Callback 的状态(是否已 resolve/调用)由 Native 持有、用完即弃;Emitter 的状态(监听器集合)由两端共同持有——JS 持句柄、Native 持回调引用,任何一端忘了清理,另一端无能为力;Commands 没有状态。这就是为什么 Emitter 是四者中唯一需要「配对纪律」的:它的生命周期天然分散在两端。

选型决策树:要「一次结果」→ Promise;是「持续数据流」→ Event Emitter(并用 6.6 的配对纪律);是「让 UI 做一件事」→ Commands;Callback 只在维护既有接口或双回调语义(成功/失败分开传)时使用——它的「至多一次」约定没有任何机制强制,只能靠纪律,所以新代码一律 Promise。

最后把「轮询 vs 事件」的账算给读者看,因为它是选型误区的重灾区:以电池电量监听为例,JS 侧 100ms 轮询一次同步方法——每次唤醒含跨端往返约 0.3ms,一分钟 600 次就是 180ms 纯税,外加每分钟 600 次线程调度与电量评分恶化;而原生感知变化后推送,电量变化一天不过几十次,成本几乎为零。轮询的本质是「用固定成本对冲未知频率」,当事件频率低且可被原生感知时,这是纯粹的浪费;只有原生侧无法感知变化(对方是个黑盒)时,轮询才是合理兜底。

代码示例:Callback 的一次性语义与生命周期陷阱(理解它才能安全维护老接口)。

// ❌ 隐患版:扫描器永不吐结果,这个闭包就被永久持有 → 泄漏;调两次则直接崩溃
@ReactMethod
fun subscribeScan(callback: Callback) {
  scanner.onResult { code -> callback(code) }
}
// ✅ 修复三件套:AtomicBoolean 机械保证「至多一次」;超时任务兜底终结;
// scanner.stop() 时移除监听器与 postDelayed,让闭包可被 GC
// Event Emitter 的正确生命周期(JS 侧):add 与 remove 严格成对
useEffect(() => {
  const emitter = new NativeEventEmitter(NativeFieldNoteScan);
  const sub = emitter.addListener('onScanResult', handleResult);
  return () => sub.remove(); // 组件卸载 = 退订;原生侧 last listener 移除后停止采集
}, []);
// Commands 的形态:命令式意图,无返回值(Fabric 组件全链路见第 8 章)
const viewRef = useRef<React.ElementRef<typeof CameraView> | null>(null);
const snap = () => viewRef.current?.takePhoto({ quality: 'high' }); // 即发即弃

Commands 的调用器由 codegen 从组件 Spec 生成:你写的是 takePhoto({...}) 这样的方法调用,codegen 把它编译成「携带指令名与参数、调度到目标视图 UI 线程」的指令包——所以 Commands 天然类型安全,且不需要你持有原生模块引用,只需要组件 ref。它与其他通道最大的不同是接收方是某个具体的视图实例,而方法调用/全局事件的接收方是模块——这个区别决定了它只能承载 UI 意图。

逐段讲解

  • Emitter 的原生侧生命周期(iOS):RCTEventEmitter 的 startObserving/stopObserving 会随「JS 侧监听器从 0→1 与 1→0」被框架调用——把「开始采集/停止采集」挂在这两个回调上,监听器泄漏的后果就只剩「一个空回调」而不是「一个永不停机的定位源」。Android 侧没有等价钩子,需要自己维护监听计数或在 JS 封装层保证成对调用——这是本章 FieldNote hook 把 stop 写进 effect 清理函数的原因。
  • Emitter 的扇出成本要算清楚:事件发出后按订阅者逐个投递——1 个事件 × 3 个订阅者 = 3 次 JS 回调(类型转换只有一次,分发是三次)。所以「订阅面收窄」不只是礼貌:无人订阅时原生应停源,多页订阅时每页都付回调成本。事件数据保持小而稳定(6.5 节),扇出才不放大成灾。
  • Callback 可能「同步到达」:即使接口约定异步,原生实现完全可能在调用线程立刻回调(缓存命中、快速失败分支)——JS 侧的回调函数要按「可能已在 subscribe 返回前执行」来写,先初始化好状态再注册回调,别依赖「注册完才有事发生」。
  • Commands 与 Props 的时序差异:Props 变更随下一帧 commit 生效(可被 diff 合并),Commands 则绕过 diff 尽快到达 UI 线程——这就是「时点敏感」的语义来源。若指令需要在特定布局之后执行(如「渲染完新列表再滚到底」),应等 onLayout/commit 完成的事件再发指令,而不是发完指令就假设一切就绪。
  • 事件通道的命名与版本化:事件名用「模块名 + 领域 + 过去式/现在时」约定(onLocationBatch、onScanResult),payload 结构一旦发布就当作公共 API——加字段可以(旧消费方无感),改语义不行;真要改,加新事件名并行一个版本,监听方迁移完再下线旧名。事件没有类型检查器替你守住两端一致性,演进纪律就是一致性本身。一个具体案例:FieldNote 定位事件 v1 只有 {lat, lng},v2 需要 accuracy——正确路径是「v2 加字段(旧消费方无感)→ 确认所有消费方升级 → 才考虑收紧类型」;错误路径是「直接改语义」(如把坐标从 WGS-84 悄悄换成 GCJ-02),旧版本消费方会静默产出整体偏移几百米的轨迹。
  • Commands 为什么无返回值:指令走「JS → UI 线程」的单向调度,刻意不设计应答——需要结果时(拍出的照片),由原生完成后再发事件(③或⑤)回 JS。「指令 + 事件」是一对固定搭档,第 8 章的相机组件会完整呈现这个组合。
  • 事件到达的中间层:Fabric 事件在 UI 线程产生后并不逐个直飞 JS——框架会合帧批派(与 VSync 对齐),这是框架替你做的第一层合批;全局 Emitter 则几乎直发。所以「组件事件天然比全局事件便宜」是有机制支撑的:能用组件事件承载的交互反馈,别用全局事件广播。
  • 高频数据的通用缓冲模式见下方 BatchEmitter,完整业务化实现在 FieldNote 实战。
// 原生侧通用合批器:采集线程只 append,固定节奏 flush
class BatchEmitter<T>(
  private val scope: CoroutineScope,
  private val intervalMs: Long,       // 合批窗口:定位 5s / 传感器 100ms
  private val maxBatch: Int,          // 批上限:背压防无界
  private val emit: (List<T>) -> Unit,
) {
  private val buffer = ArrayList<T>(maxBatch)
  private val lock = Object()
  private var job: Job? = null
  fun start() {
    job = scope.launch {
      while (isActive) {
        delay(intervalMs)
        val batch: List<T>
        synchronized(lock) {
          if (buffer.isEmpty()) continue     // 空批跳过:事件本身有成本
          batch = ArrayList(buffer); buffer.clear()
        }
        emit(batch)                          // 经 TurboModule 事件通道上行
      }
    }
  }
  fun push(item: T) = synchronized(lock) {
    buffer.add(item)
    if (buffer.size > maxBatch) buffer.removeAt(0)  // 背压:丢最旧,保最新
  }
  fun stop() { job?.cancel(); synchronized(lock) { buffer.clear() } }
}

逐段讲解

  • 背压策略是「丢最旧保最新」:定位轨迹的最新位置永远比历史位置更有价值;如果业务真要全量轨迹,采集端本就该有本地持久化(第 14 章),跨端通道不该承担无限队列。
  • 合批器与 Outbox 是同一思想的两次落地:本节合批器的「缓冲 + 固定节奏 + 上限丢弃」,与第 15 章离线同步的 Outbox(操作入队 + 网络恢复后批量重放 + 幂等重试)在结构上同源——区别只在缓冲的对象(数据点 vs 操作)与消费时机(固定节奏 vs 联网触发)。学完本章再看第 15 章,你会发现同步引擎里没有新哲学,只有更复杂的时序。
  • 「恰好一次」怎么验证:给 Promise 接口写一个契约测试——对每个分支路径调用一次,断言 resolve/reject 恰好触发一次(用标志位捕获第二次尝试);原生实现改一行都可能引入双终结路径,这个测试就是护栏。
  • 空批跳过:固定节奏 flush 在无数据期间不发空事件——事件本身有成本(JS 唤醒 + React 处理),空事件是纯浪费。
  • 三个变体按「JS 侧多需要这些数据」分层:① 节流(throttle):固定频率采样,适合状态展示;② 合批(本例):攒一批一次给,适合轨迹、列表;③ 共享值直通:数据只被 UI 消费时(如扫码框倾斜角度驱动取景器动画),走 UI 线程的共享值通道,完全不进 JS——它把「JS 线程成本」直接降为零,机制在第 9 章 Reanimated 展开。
  • 合批窗口怎么定:下界由「最慢的业务消费者」决定(到场判定分钟级 → 5s 足够),上界由「数据的新鲜度要求」决定(实时性要求秒级 → 窗口 ≤ 2s),中间取交集;若上下界不相交(既要秒级实时又怕事件风暴),说明需求里藏着「直通」——按上一条分层处理,别硬塞一个折中窗口。
  • 什么时候「不落 JS」更优:三个判据同时成立——数据的消费方是纯视觉(人眼看、动画用);JS 侧不需要它参与业务决策;数据频率高到会挤压帧预算(>10Hz 量级)。典型例子:相机预览帧直接喂给原生渲染管线或 UI 线程动画,JS 只拿低频摘要(识别结果、最终照片)。反过来,只要 JS 要拿数据做业务逻辑(存库、打分、上传),就必须过 JS——合批是你唯一的缓冲手段。

把三个变体与 FieldNote 的功能对位看一遍,频率阶梯就清楚了:30fps 相机预览帧 → 原生直驱(不落 JS);10Hz 传感器 → 100ms 合批(进 JS,但批内摊薄);1Hz 定位 → 5s 合批(业务口径主导);用户点击 → 即时通道(Promise/事件,永不缓存)。频率越高的数据,离 JS 越远——这条「高频数据下沉定律」可以直接当设计守则贴在团队墙上,第 9~11 章的动画手势实战会把它的 UI 侧演绎成共享值与 worklet 的完整体系。

6.5 序列化与类型边界:能过桥的是什么

原理讲解

新架构的 codegen 为每条 TurboModule 接口生成类型校验与转换代码,这是它优于旧 Bridge 的地方——但代价是边界上只认识一套固定类型:

类型 能否跨端 说明
string / number / boolean 是 基础盘
数组 / 嵌套对象 是 元素类型必须是声明过的;对象走 codegen 的严格类型
可空 是 ? 标注,codegen 生成 null 检查

可选字段的设计倾向值得单独说:能用「默认值语义」就不要用可空——speed: number(无速度传 0)比 speed?: number(下游处处判空)对消费方友好得多;可空留给真正「缺失即信息」的字段(如「尚无定位」的 last?: LocationPoint)。每多一个可空字段,下游就多一处 NPE 风险。 | 枚举 / 联合类型 | 是 | 较新版本支持;版本差异以官方 codegen 文档为准 | | Promise<T> / 回调函数 | 是 | 异步语义载体(6.4 节) | | Date | 否 | 转时间戳 number(毫秒)过界,JS 侧再还原 | | Map / Set / 类实例 | 否 | 转普通对象/数组;原生侧的自定义类不认识 | | 函数 | 仅作回调 | 不能把「JS 对象的方法」当数据传 | | ArrayBuffer / TypedArray | 有限支持 | 较新版本开始进入 codegen,可用性以当前文档为准(见下) |

为什么 Map/Set、类实例过不了界:跨端传的是结构化数据,其本质要求是两端都能独立重建该值——类实例携带行为与方法表,Map/Set 携带特定迭代协议,另一侧语言没有对应物。这不是实现偷懒,而是边界的设计立场:跨界的是数据,不是对象。

由此推出一组接口设计规范,写 Spec 时逐条对照:

  1. 类型即文档:字段名说清单位(ts 是毫秒还是秒?accuracy 是米还是置信半径?),Spec 里写不下的约定写进接口注释——跨端接口的语义模糊是联调期第一大摩擦源;
  2. 枚举封闭、可演进:枚举新增值时旧版本 JS 会拿到不认识的值——消费方必须走「未知值降级」分支而不是抛错;
  3. 时间戳统一毫秒 UTC、坐标统一 WGS-84(中国区地图厂商坐标系差异见 FieldNote 实战)——两个最容易出「神秘偏移」的领域必须全局一刀切;
  4. 布尔参数改枚举:start(true, false, true) 三个布尔没人读得懂,start({ mode: 'gps', foregroundOnly: true }) 才是接口。

codegen 校验还有一层容易忽略的价值:它发生在 C++ binding 层,是白捡的安全网。旧 Bridge 时代参数类型错误要到运行时深处才炸(且常以「undefined is not an object」这种谜语形态出现);新架构下类型不符在跨界瞬间被拦截,错误信息直接指向接口签名。把接口设计期的类型谈判交给 codegen(第 7 章),是它比裸 JSI 值钱的地方。

大 payload 的真实成本要按「三次拷贝 + 一次 GC」理解:JS 对象 → 转换层序列化(第一份拷贝)→ 原生侧反序列化成 Kotlin/Swift 对象(第二份)→ 业务处理再生成第三份;全程短命对象暴涨,GC(JS 侧)与原生分配器同时承压。一次传 1000 个任务对象(每对象十几个字段)的实测体感是「调用瞬间 JS 线程卡 100~300ms」——这违反 6.3 节「重活不过线程」的总原则,只是它藏在「跨端调用」的外衣里。对策按优先级:

  1. 分页:一次 50 条,用游标续传;
  2. 传引用不传数据:跨端只传 id 列表,数据各自从本地存储读(JS 读 SQLite,Native 读同一库——同一台手机上的两个读者);
  3. 原生侧预聚合:JS 要的是「每个门店的未完成任务数」,就让 Native 聚合好再传 20 个数字,而不是传 1000 条原始记录让 JS 自己数;
  4. 大数据永不跨端:图片、文件、音视频走 URI/句柄(见下)。

同一个需求「任务列表页取 1000 条任务」两种 payload 策略的成本账,数字量级是实测量级:

策略 跨端传输量 JS 线程占用 原生侧分配 总账
全量对象下行 1000 个对象(约数百 KB 结构化数据) 转换 100~300ms + 大批短命对象 GC 反序列化第二份完整拷贝 卡顿可见、内存尖峰
传 id + 双端各查 1000 个字符串 id(约 30KB) <5ms 各自查库(SQLite 连接已在) 用户无感
原生预聚合 20 个统计值(<1KB) 忽略不计 聚合查询 最优,但只适用于「要汇总不要明细」

二进制数据的现状:旧 Bridge 时代二进制只能转 Base64——体积 +33%、转换消耗 CPU、内存里同时存在原始数据与文本两份,是出了名的性能坟场。新架构下 JSI 层面具备直传 ArrayBuffer 的能力(TypedArray 本质是 JS 堆上的连续内存),较新版本的 codegen 也开始支持 ArrayBuffer 参数,但生态尚未完全对齐,具体约束以写作时官方文档为准。工程上的可靠策略与版本无关:

// ❌ 把照片字节读进 JS 再传给原生上传:+33% 体积、双份内存、JS 线程两次拷贝
const bytes = await fetch(photoUri).then(r => r.arrayBuffer());
await PhotoUploader.uploadBytes(bytes, meta);
// ✅ 数据不落 JS:JS 只递「引用 + 元数据」,编码/压缩/上传全部在原生侧完成
await PhotoUploader.uploadFromUri(photoUri, {
  maxWidth: 1600, quality: 0.8, endpoint: UPLOAD_ENDPOINT, // 出网地址经白名单校验(第 19 章)
});
// 返回值只有 { remoteUrl, bytes } 级别的摘要

逐段讲解

  • 类型边界表的实践用法:写接口文档时先过一遍这张表,凡「否」的行就地给出替代(Date→时间戳、Map→对象),把边界谈判前置到设计期而不是联调期。
  • 「传引用」模式的两个坑:一是一致性——双端各查各库,读到的是同一份数据的不同时刻快照,跨端一致性要靠业务协议(如统一以 serverVersion 为准,第 16 章);二是缓存失效——JS 侧改了数据,Native 侧若持旧缓存,决策就基于旧值。规则:引用模式下的「写」只走一端,另一端每次现查。
  • payload 成本的测量方法:在调用两侧各打一个 mark(第 2 章 PerfTracker),差值即「转换+传输+处理」总账;再与「传 id 列表 + 双端各查」的同任务对比,你就有了一张自己的 payload 定价表——比任何经验法则都硬。
  • uploadFromUri 的深层理由:拍照产生的图片已经存在于原生文件系统(第 4 章讲过解码位图不落 JS 的内存理由),把字节捞进 JS 再交回原生上传,是让数据白跑两个来回;URI 引用让原生直接从文件读,JS 全程只碰几十字节的元数据。
  • Base64 为什么恰好 +33%:3 字节二进制编成 4 个 ASCII 字符(6 bit/字符),4/3 = 1.333——这是编码规则决定的数学下限,任何实现都绕不开;再加上编解码的 CPU 与「原文 + 编文」两份内存。它不是「有点慢」,是结构性劣势。
  • 压缩值不值:给跨端 payload 套 gzip 的收益 = 传输量下降,代价 = 双端压缩/解压 CPU。结构化 JSON 里重复键多,压缩率通常不错,但毫秒级的 CPU 开销发生在 JS 线程或采集线程上——先测转换账单(本节「测量方法」),确认 payload 真是大头再谈压缩;大多数场景的正确解是少传(策略 1-3),不是传了再压。
  • 💡 判断 payload 是否「过大」的口诀:超过一屏 UI 所需的数据量,就该怀疑。列表首屏需要 12 条,你传了 1000 条——即使转换只要 80ms,那也是 5 帧的预算。
  • 还有一条终级路线:数据的消费在原生,数据就不必过桥。地图渲染轨迹、图片列表、图表绘制——如果最终消费者是原生视图(Fabric 组件或纯原生页面),让原生直接读库/读文件驱动渲染,JS 只发「画什么」的指令与摘要。第 8 章的 Fabric 组件会把这条路走成标准工程形态。

6.6 通信层故障急诊室:六种故障的复现与修复

原理讲解

通信层的 bug 有一个共同特征:症状在 JS,根因在跨端时序。跨端通道的异步性让三个直觉全部失效——你假设事件按发生顺序到、恰好一次、跟组件同生共死。以下逐个给出复现与修复,每条修复都附验收标准;排查时按「症状 → 故障编号」对号入座(章末映射表),不要从第一条读到第六条碰运气。另外别以为 ⑥ JSI 同步通道没有时序故障——它有自己的一族:在 get() 里拿锁,而锁正被另一个后台任务持有 → JS 线程原地挂起,整个 App 冻结且无任何异步痕迹;这也是同步通道「无锁等待」纪律的另一半含义(6.1 节的预算说的是时间,这里说的是锁的所有权)。

故障 1:事件晚于组件卸载到达。复现:扫码页用 500ms 超时退出,退出后 300ms 扫码结果才到——setState 打在已卸载组件上(React 18+ 不再告警,但更新静默丢失;若闭包里还有导航调用就是真 bug)。修复三层:存活标记、退订、通知原生停源。

useEffect(() => {
  let alive = true;                                  // ① 标记
  const sub = emitter.addListener('onScanResult', (r) => {
    if (!alive) return;                              // ② 晚到事件丢弃
    handleResult(r);
  });
  return () => {
    alive = false;
    sub.remove();                                    // ③ 退订
    void NativeFieldNoteScan.stop();                 // ④ 停掉事件源(源头治理)
  };
}, []);
// 可见性变体:页面「活着但不可见」(导航栈下层)时是否停源,取决于业务——
// 用 useFocusEffect 的 focus/blur 事件替代 unmount 信号即可,其余模式不变。

故障 2:监听器泄漏(add 了没 remove)。复现:任务详情页在 if (someCondition) 分支里 addListener,条件为假时没有句柄可 remove;或 async effect 的清理写错位置(第 4 章同款坑的通信层变体)。症状随导航次数放大:进出 5 次后每次扫码回调执行 5 遍。修复:句柄必接、清理必在 effect 的同步 return 里;原生侧配合实现「最后一个监听器移除即停采集」(iOS 的 startObserving/stopObserving 就是这个语义),把泄漏后果的上限从「永久运行的事件源」降为「一个空回调」。

故障 3:Promise 悬挂未 resolve。复现:Native 方法有 4 个分支,其中「任务不存在」分支直接 return 忘了 reject——JS 侧 await 永不返回,loading 转圈到天荒地老。顺带一个衍生语义:JS 侧没有取消 Promise 的机制——用户退出页面后,原生的重活照样跑完、Promise 照样 resolve(结果被丢弃)。需要「取消」语义的接口(如取消上传),要在参数里带代次 token(generation id),原生侧发现代次过期就提前终止并 resolve(false),而不是等它自然跑完。修复悬挂本身的两条纪律 + 一个兜底:

// 纪律 1:Native 侧每条路径都必须终结(resolve 或 reject 二选一)
fun sync(taskId: String, promise: Promise) {
  if (!TaskDb.exists(taskId)) {
    promise.reject("NOT_FOUND", "任务不存在: $taskId"); return  // ← 最容易漏的分支
  }
  executor.execute {
    try { promise.resolve(TaskDb.doSync(taskId)) }
    catch (e: Throwable) { promise.reject("SYNC_FAILED", e.message, e) } // Throwable!
  }
}
// 纪律 2:JS 侧对关键跨端调用包超时——悬挂从「永远」降为「最多 N 秒」
export function withTimeout<T>(p: Promise<T>, ms: number, tag: string): Promise<T> {
  return Promise.race([
    p,
    new Promise<never>((_, rej) =>
      setTimeout(() => rej(new Error(`[bridge] ${tag} 超时 ${ms}ms`)), ms)),
  ]);
} // 用法:await withTimeout(FieldNoteSync.sync(id), 8000, 'sync');

⚠️ Kotlin 侧写 catch (e: Exception) 而不是 Throwable 会漏掉 Error(如 OutOfMemoryError)——它让 Promise 既不 resolve 也不 reject,悬挂换了个马甲。

故障 4:主线程被原生重活卡死。复现:某 SDK 的初始化在主线程做同步加解密,点击任何按钮 800ms 无响应,Perfetto 主线程泳道一条长条。iOS 主线程重活的三个惯犯:主线程读 UserDefaults 的大值与同步写、第一次访问某单例时触发的重初始化(+initialize 的隐性成本)、Core Data 的同步 fetch;Android 的惯犯则是 SharedPreferences 首次全量加载、同步 Bitmap 解码、Room 主线程查询。修复:6.3 节的线程纪律——主线程只允许视图操作与事件打包;用 StrictMode(Android)与 Main Thread Checker(Xcode)在开发期把违规暴露出来。

故障 5:事件风暴。复现:把加速度计 10Hz 数据逐点上行走 Emitter,3 个订阅者 → JS 每秒被唤醒 30 次,每次都触发一次 setState + 渲染,列表滚动开始掉帧。修复组合拳:原生侧合批(6.4 节 BatchEmitter,10Hz → 每 100ms 一批 1 点)+ JS 侧更新合并(useRef 暂存 + rAF 对齐刷新)+ 订阅面收窄(只在当前页面订阅)。极端场景的终解是 6.4 节的「不落 JS」直通。

故障 6:假设事件有序到达。复现:合批上线后,重启竞态或一次重传导致旧批次晚于新批次到达,地图上轨迹「倒放」。修复:给事件带单调递增 seq,消费端做单调过滤:

const lastSeq = useRef(-1);
const onBatch = (batch: LocationBatch) => {
  if (batch.seq <= lastSeq.current) return; // 「到达顺序」不可信,「seq」可信
  lastSeq.current = batch.seq; apply(batch);
};

逐段讲解

  • 故障 1 的修复用了三层防御,优先级是「停源 > 退订 > 标记」:源头停了,后两层只是保险。只做 JS 层标记、任由原生继续采集上报,是把 CPU 与电量白白烧在「注定被丢弃」的事件上——移动端上这是电量问题,不只是正确性问题。
  • 故障 2 的原生侧兜底思路:Android 没有框架级监听计数钩子,模块自己维护一个 AtomicInteger——addListener 时 +1,remove 时 −1,归零即停源;JS 封装层(hook)的成对调用是第一道保险,原生计数是第二道。两层中任何一层实现正确,泄漏都不会演变成「永不停机的采集器」。
  • 故障 5 的速率算一笔具体账:10Hz 源 × 3 订阅者 = 每秒 30 次 JS 回调;若每次回调带一次 setState 且渲染 5ms,则每秒 150ms 的 JS 线程时间被固定吃掉——60fps 的帧预算每秒只有 1000ms,这 15% 还没算业务逻辑。合批到 1Hz 后,同一笔账变成每秒 3 次 × 5ms = 15ms(1.5%),从「显著卡顿」回到「无感」。
  • 故障 3 的超时包装有个细节:Promise.race 之后悬挂的 Promise 依然挂着(没人能取消它),超时只是让你的业务先走失败路径。真正终结悬挂要靠 Native 侧修复 + 线上监控超时错误的出现率——把超时错误也上报(第 2 章 PerfTracker 的白名单通道可复用),它是通信层健康度的核心指标。
  • 故障 5 的「JS 侧更新合并」值得单独记:原生合批把事件频率降到 10Hz,如果每次都 setState,React 仍然每 100ms 渲染一次。useRef 暂存最新值 + requestAnimationFrame 里统一 flush 到 state,把渲染节奏对齐到帧——这是「事件频率」与「渲染频率」解耦的最后一步。
  • 把六种故障的防御连成两个线上指标:跨端调用超时率(故障 3 的暴露面)与事件丢弃率(背压与乱序过滤的触发次数,故障 5、6 的暴露面),复用第 2 章白名单上报,按设备档位看分位数——通信层健康度从此可观测,而不是等用户截图。
  • 每个修复也要有验收标准:晚到→「卸载后零 setState 断言」;泄漏→「20 次进出计数归零」;悬挂→「超时率 <0.1%」;卡死→「主线程无 StrictMode 违规」;风暴→「每秒回调数上限断言」;乱序→「seq 倒序注入无倒放」。故障修完不做断言,三个月后就是新的偶发问题。
  • 时序类 bug 的复现方法论:它们「偶发且难复现」是因为依赖真实时序抖动,所以复现手段就是人为制造时序——开发构建里留一个调试开关,把事件延迟(如 800ms)、打乱(seq 倒序注入)、放大(10 倍频率)三种模式注入到 Emitter 通道;FieldNote 实战第五步的验证项就是按这个思路设计的。能被开关稳定复现的 bug,才算真正修好。

故障 × 检测手段映射表(排查时按症状对号入座):

故障 最先看到的症状 检测手段
晚到事件 卸载后仍触发 / Profiler 里幽灵 commit alive 标记 + 日志时间轴对比
监听器泄漏 回调重复执行 ×N 进出页面 20 次回归 + listener 计数
Promise 悬挂 无限 loading withTimeout 抛错 + 超时率监控
主线程卡死 ANR / 全局冻结 StrictMode / Main Thread Checker / Perfetto
事件风暴 滚动掉帧、发热 Profiler commit 频率 + 事件计数日志
乱序 状态回退、轨迹倒放 seq 单调断言(debug 构建直接抛错)

跨端接口 Code Review 自检清单(逐项过,任何一项答不上来就打回;建议存进团队的 PR 模板,让它长在流程里而不是靠记忆):

  1. 参数里有没有超过一屏 UI 所需的数据量?
  2. 方法体的每条分支是否都 resolve/reject(含 catch Throwable)?
  3. 重活是否 post 出了 JS 线程与主线程?
  4. 事件是否带 seq 与业务归属 id?
  5. 监听器的 add/remove 是否成对、卸载是否停源?
  6. Native 持有的回调/监听器在 invalidate 时是否全部释放?
  7. 高频源是否合批?背压策略是什么(丢最旧还是拒绝)?
  8. 二进制是否走 URI 而不是字节过界?

典型业务场景:FieldNote 实战

对应里程碑 M6 定位通道:原生高频定位事件按 5s 节流送达 JS,退出页面无泄漏。这是巡检功能的地基——轨迹与到场判定都由它驱动。

先看 FieldNote 各功能的通道选型全景,定位只是其中一行——这张表建议你在自己项目里也维护一份:

FieldNote 功能 通道 语义 理由
定位流(本章) 全局 Emitter 持续、跨页面 生命周期跟巡检会话走,不属于任何组件
扫码结果 全局 Emitter 持续、单页面消费 引擎持续产出,页面退订即停(6.6 故障 1/2)
生物识别解锁 Promise 恰好一次 一次性询问-应答(第 7 章封装)
拍照(相机取景器) Commands + 事件 即发即弃 + 结果回调 UI 意图走指令,结果走事件(第 8 章落地)
卡片拖拽排序落库 Props + 方法 状态 + 提交 排序态由 React 持有,落库走方法(第 11 章)

定位模块的设计要点三条,代码处处对应:

  1. start/stop 幂等——AppState 快速切换、页面重进会触发连续调用,幂等让 JS 侧无须维护调用序;
  2. 采集与合批同队列——专用后台线程承载缓冲,主线程与 JS 线程零负担;
  3. 三层退场——JS 卸载停源、退后台停采、invalidate 兜底,任何一层失效都不泄漏。

⚠️ 还有一个中国区特有的坑要提前声明:坐标系。系统 GPS 返回的是 WGS-84 国际坐标,而国内地图渲染(各地图 SDK)普遍使用 GCJ-02 加密坐标——不经转换直接把 WGS-84 点画在地图上,轨迹会整体偏移几百米。FieldNote 的约定:通道内一律 WGS-84(原始事实数据),展示层负责转换——把转换义务放在消费端,上游数据才不会被污染;这条约定同时写进双端模块的接口注释(6.5 节「类型即文档」)。

第一步:原生侧实现(Kotlin)

// android/.../location/FieldNoteLocationModule.kt
package com.fieldnote.app.location

import android.annotation.SuppressLint
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Handler
import android.os.HandlerThread
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule

class FieldNoteLocationModule(reactContext: ReactApplicationContext) :
  ReactContextBaseJavaModule(reactContext) {

  companion object {
    const val NAME = "FieldNoteLocation"; const val EVENT_BATCH = "onLocationBatch"
    private const val INTERVAL_MS = 1000L      // 采集 1Hz:够轨迹用,省电
    private const val FLUSH_MS = 5000L         // 5s 合批上行(M6 验收口径)
    private const val MAX_BUFFER = 200         // 背压上限:丢最旧
  }

  private val thread = HandlerThread("fn-location").apply { start() }
  private val handler = Handler(thread.looper)  // 采集与合批都在专用线程
  private val buffer = ArrayList<LocationPoint>(MAX_BUFFER)
  private var trackingId: String? = null
  private var seq = 0L
  private var listener: LocationListener? = null

  override fun getName() = NAME
  @ReactMethod
  fun start(id: String, promise: Promise) {
    if (trackingId != null) { promise.resolve(false); return }  // 幂等:已启动直接返回
    val lm = reactApplicationContext.getSystemService(LocationManager::class.java)
    if (lm == null || !lm.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
      promise.reject("NO_GPS", "定位服务不可用"); return        // 每条路径都终结(故障 3)
    }
    @SuppressLint("MissingPermission")   // 权限请求流程在第 7 章统一封装
    val l = LocationListener { location -> onFix(location) }
    listener = l
    trackingId = id; seq = 0
    lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, INTERVAL_MS, 0f, l, thread.looper)
    handler.postDelayed(::flush, FLUSH_MS)
    promise.resolve(true)
  }
  private fun onFix(location: Location) {         // 采集回调:O(1),只进缓冲
    synchronized(buffer) {
      buffer.add(LocationPoint(location.latitude, location.longitude,
        location.accuracy, location.speed, location.time))
      if (buffer.size > MAX_BUFFER) buffer.removeAt(0)  // 背压:丢最旧保最新
    }
  }  private fun flush() {                           // 专用线程:转换 + 上行
    val batch: List<LocationPoint>
    synchronized(buffer) {                        // 锁内只做「摘取」,转换在锁外——采集不被阻塞
      if (buffer.isEmpty()) { handler.postDelayed(::flush, FLUSH_MS); return }
      batch = ArrayList(buffer); buffer.clear()
    }
    val payload = Arguments.createMap().apply {
      putString("trackingId", trackingId)
      putLong("seq", ++seq)                       // 单调序号(故障 6)
      putArray("points", Arguments.createArray().apply {
        batch.forEach { p -> pushMap(Arguments.createMap().apply {
          putDouble("lat", p.lat); putDouble("lng", p.lng); putDouble("ts", p.ts.toDouble())
          putDouble("accuracy", p.accuracy); putDouble("speed", p.speed)
        }) }
      })
    }
    reactApplicationContext
      .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
      ?.emit(EVENT_BATCH, payload)
    handler.postDelayed(::flush, FLUSH_MS)        // 链式续排:flush 只此一条调度链
  }
  @ReactMethod
  fun stop(promise: Promise) {
    val lm = reactApplicationContext.getSystemService(LocationManager::class.java)
    listener?.let { lm?.removeUpdates(it) }       // 停系统定位源
    listener = null
    handler.removeCallbacks(::flush)              // 停合批定时器
    synchronized(buffer) { buffer.clear() }       // 清残余缓冲
    val had = trackingId != null; trackingId = null
    promise.resolve(had)
  }
  override fun invalidate() {                     // JS 上下文销毁:最后防线
    stop(Promise.resolve(null))
    thread.quitSafely()
  }
}

实现讲解:权限请求刻意没有出现在 start 里(@SuppressLint("MissingPermission") 是自首不是解决)——权限弹窗是 UI 决策,应由页面在调用 start 之前完成(第 7 章统一封装权限模块),定位模块只对「结果」负责。requestLocationUpdates 显式传入 thread.looper:系统回调直接落在采集线程——若不指定,回调会落在创建 Listener 的线程(JS 线程),每个定位点都会打扰一次 React。数据结构 LocationPoint 是普通 data class(lat/lng/accuracy/speed/ts 五字段),全模块无第三方依赖。三个常量都是调参点:INTERVAL_MS(采集频率)向上调省电但轨迹变粗,FLUSH_MS(合批窗口)按 6.4 节公式由业务定,MAX_BUFFER(背压上限)按「最坏情况下的缓冲时长」反推——200 点 ÷ 1Hz = 200 秒,足够覆盖最长的合批停顿而不爆内存。

选型说明——为什么用系统 LocationManager 而不是 Google 的 FusedLocationProvider:Fused 融合定位更智能省电,但要求设备带 GMS;FieldNote 的巡检设备群里有大量无 GMS 的国产安卓(第 2 章锁定的低端机档正是重灾区),依赖 GMS 意味着这些设备上模块直接不可用。系统 API 精度与功耗逊一筹,但在目标设备 100% 可用——原生选型的第一原则是「目标设备覆盖面」,不是「单机最优」。如果日后确认全量设备都有 GMS,替换点收敛在 start() 一处,影响面可控。

第二步:原生侧实现(Swift)

iOS 实现与 Kotlin 逐方法对应(start/stop/invalidate/合批/背压一一映射),差异点集中在平台 API 的「脾气」上:CLLocationManager 的线程宿主要求、GCD 定时器替代 Handler、NSLock 替代 synchronized、RCTEventEmitter 替代 RCTDeviceEventEmitter。对照着读,顺便校验你对 6.3 节线程纪律的理解是否双端一致。

// ios/FieldNote/Location/FieldNoteLocationModule.swift
import CoreLocation

@objc(FieldNoteLocation)
final class FieldNoteLocationModule: RCTEventEmitter {
  private let locationManager = CLLocationManager()
  private var buffer: [[String: Any]] = []
  private var trackingId: String?
  private var seq = 0
  private let lock = NSLock()
  private var flushTimer: DispatchSourceTimer?
  private let flushInterval: UInt64 = 5           // 5s 合批(与 Android 对齐)
  private let maxBuffer = 200

  override static func requiresMainQueueSetup() -> Bool { false }
  override func supportedEvents() -> [String]! { ["onLocationBatch"] }
  @objc(start:resolve:rejecter:)
  func start(_ id: String,
             resolve: @escaping RCTPromiseResolveBlock,
             rejecter: @escaping RCTPromiseRejectBlock) {
    guard trackingId == nil else { resolve(false); return }   // 幂等
    guard CLLocationManager.locationServicesEnabled() else {
      rejecter("NO_GPS", "定位服务不可用", nil); return       // 每条路径都终结
    }
    trackingId = id; seq = 0
    // ⚠️ CLLocationManager 的 delegate 回调投递到启动线程的 RunLoop,
    // 而 GCD 队列线程没有 RunLoop——必须在主线程启动,回调才能持续到达
    DispatchQueue.main.async {
      self.locationManager.delegate = self
      self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters
      self.locationManager.startUpdatingLocation()
    }
    let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
    timer.schedule(deadline: .now() + flushInterval, repeating: flushInterval)
    timer.setEventHandler { [weak self] in self?.flush() }    // 合批在后台队列
    timer.resume()
    flushTimer = timer
    resolve(true)
  }
  @objc(stop:resolve:rejecter:)
  func stop(_ resolve: @escaping RCTPromiseResolveBlock,
            rejecter: @escaping RCTPromiseRejectBlock) {
    DispatchQueue.main.async { self.locationManager.stopUpdatingLocation() }
    flushTimer?.cancel(); flushTimer = nil
    lock.lock(); buffer.removeAll(); lock.unlock()
    let had = (trackingId != nil); trackingId = nil
    resolve(had)
  }
  override func invalidate() {                    // JS 上下文销毁:最后防线
    stop({ _ in }, rejecter: { _, _, _ in })
    super.invalidate()
  }
  private func flush() {
    lock.lock()
    guard trackingId != nil, !buffer.isEmpty else { lock.unlock(); return }
    let batch = buffer; buffer.removeAll()
    lock.unlock()
    seq += 1
    let payload: [String: Any] = ["trackingId": trackingId as Any, "seq": seq, "points": batch]
    sendEvent(withName: "onLocationBatch", body: payload)     // 全局事件通道上行
  }
}

extension FieldNoteLocationModule: CLLocationManagerDelegate {
  func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    // 采集回调在主线程:只做 O(1) 缓冲,转换与上行留给 flush 的后台队列
    let points = locations.map { loc -> [String: Any] in
      ["lat": loc.coordinate.latitude, "lng": loc.coordinate.longitude,
       "accuracy": loc.horizontalAccuracy, "speed": loc.speed,
       "ts": loc.timestamp.timeIntervalSince1970 * 1000]
    }
    lock.lock()
    buffer.append(contentsOf: points)
    if buffer.count > maxBuffer { buffer.removeFirst(buffer.count - maxBuffer) }
    lock.unlock()
  }
}

实现讲解:iOS 侧最反直觉的一行是必须在主线程启动 CLLocationManager——它的 delegate 回调投递到启动线程的 RunLoop,而 GCD 队列线程没有 RunLoop,从后台队列启动会导致回调永不到达且无任何报错。这类「线程宿主」要求在 iOS 原生 API 里相当常见(CLLocationManager、CADisplayLink、Timer),遇到「回调不来了」先查线程宿主。分工上与 Android 同构:主线程只做 O(1) 缓冲,转换与上行在 utility 队列——主线程的账单只有微秒级。

这里顺带把 RunLoop 与 GCD 的关系说透:RunLoop 是线程的「事件循环引擎」(处理 timer、source、observer),GCD 队列只是「任务暂存与调度」——GCD 后台线程跑完当前任务就休眠,不会转 RunLoop。依赖「持续事件循环」的 API 必须住在有 RunLoop 的线程(主线程或自建 NSThread),依赖「任务即来即走」的 API 用 GCD 即可——给 API 选线程宿主,先问它要哪种循环。setEventHandler 里的 [weak self] 则是另一层保险:timer 被 module 持有、event handler 又引用 module,强引用即成环,invalidate 永远不会被调用——内存治理(第 4 章)的规则在原生侧同样生效。

payload 字段的设计讲解(对照 6.5 节「类型即文档」):accuracy 必须随行——没有置信半径的位置点在消费端无法做质量分级,等下游需要时再补就是全链路改造;speed 让到场判定能区分「步行到场」与「车内路过」,业务价值大、采集成本为零;ts 用毫秒 UTC(与 6.5 节规范一致),由系统定位时间戳直接换算,不用本地合成时刻——巡检对「事实时间」的要求高于「到达时间」。每个字段都回答「下游拿它做什么」,回答不了的字段就不要跨端。sendEvent 本身是线程安全的(内部完成到 JS 线程的派发),在 utility 队列调用无需再切线程——框架事件的编组又一次替你处理了线程问题。

第三步:JS 侧封装 useLocationSubscription

hook 承担 JS 侧的三道防御(对应 6.6 节故障 1/2/6 的修复模式:晚到过滤、成对清理、seq 单调),外加一条高频数据惯例(故障 5 的 rAF 合帧)。先看代码,逐行讲解在后:

// src/features/patrol/useLocationSubscription.ts
import { useEffect, useRef, useState } from 'react';
import { AppState, NativeEventEmitter, NativeModules } from 'react-native';

export type LocationPoint = {
  lat: number; lng: number; accuracy: number; speed: number; ts: number;
};
export type LocationBatch = { trackingId: string; seq: number; points: LocationPoint[] };

// 本章为聚焦通信本体用 interop 方式访问;规范的 codegen Spec 封装见第 7 章
const FieldNoteLocation =
  NativeModules.FieldNoteLocation as {
    start(id: string): Promise<boolean>;
    stop(): Promise<boolean>;
  };

export function useLocationSubscription(
  trackingId: string,
  { activeWhenForeground = true }: { activeWhenForeground?: boolean } = {},
): { points: LocationPoint[]; last: LocationPoint | null; error: Error | null } {
  const [points, setPoints] = useState<LocationPoint[]>([]);
  const [last, setLast] = useState<LocationPoint | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const lastSeq = useRef(-1);           // 单调过滤(故障 6)
  const aliveRef = useRef(true);        // 晚到事件过滤(故障 1)
  const bufferRef = useRef<LocationPoint[]>([]); // 高频缓冲 + rAF 合帧(故障 5)

  useEffect(() => {
    aliveRef.current = true;
    const emitter = new NativeEventEmitter(FieldNoteLocation);
    const subscription = emitter.addListener('onLocationBatch', (batch: LocationBatch) => {
      if (!aliveRef.current) return;                       // ① 晚到/卸载后:丢弃
      if (batch.trackingId !== trackingId) return;         // ② 串台防御(旧会话事件)
      if (batch.seq <= lastSeq.current) return;            // ③ 乱序防御
      lastSeq.current = batch.seq;
      bufferRef.current = bufferRef.current.concat(batch.points);
      setLast(batch.points[batch.points.length - 1] ?? null); // 最新点立即生效
      requestAnimationFrame(() => {                        // 轨迹渲染合并到下一帧
        if (!aliveRef.current || bufferRef.current.length === 0) return;
        const merged = bufferRef.current; bufferRef.current = [];
        setPoints((prev) => [...prev, ...merged].slice(-1000)); // 有界轨迹
      });
    });
    const startOrStop = (state: string): void => {
      if (state === 'active') void FieldNoteLocation.start(trackingId).catch(setError);
      else if (activeWhenForeground && state === 'background') {
        void FieldNoteLocation.stop().catch(setError);     // 前台约定:退后台即停
      }
    };
    void FieldNoteLocation.start(trackingId).catch(setError); // start 幂等,重复调用安全
    const appState = AppState.addEventListener('change', startOrStop);
    return () => {                                         // 退场三连:顺序即逻辑
      aliveRef.current = false;
      appState.remove();
      subscription.remove();                               // 先摘监听
      void FieldNoteLocation.stop();                       // 再停原生源(源头治理)
    };
  }, [trackingId, activeWhenForeground]);

  return { points, last, error };
}

页面侧的接入只需两行:

function PatrolScreen() {
  const { points, last, error } = useLocationSubscription(patrolId);
  if (error) return <GpsErrorHint error={error} />;
  return <TrackMap points={points} current={last} />;
}

hook 讲解:返回值刻意只有「追加的轨迹点 + 最新点 + 错误」——它是数据通道,不是地图组件;渲染策略(抽稀、平滑、聚合)交给消费方。AppState 恢复时直接调 start,因为原生侧幂等(已启动 resolve(false))——把幂等做在通道两端,竞态就失去了着力点,JS 侧不需要任何「当前是否已启动」的状态判断。

三个实现细节值得展开:

  • 为什么用 NativeEventEmitter 而不是 DeviceEventEmitter:两者在 Android 上等价(都是全局广播),但 iOS 上 NativeEventEmitter(模块) 会让框架统计「该模块的监听器数」——从 0→1 触发 startObserving、1→0 触发 stopObserving(6.4 节的泄漏兜底)。用裸 DeviceEventEmitter 等于放弃这层生命周期联动,原生的「无人订阅即停采」就没地方挂了。
  • 为什么 seq 要和 trackingId 配对:seq 在每次 start 时归零重计,单独的 seq 在「中断后重开」场景会自己骗自己(新一轮的 seq=1 会被当成旧重复丢弃);(trackingId, seq) 复合才是全序键。hook 的 ②③ 两条过滤合起来才是完整的乱序防御。
  • hook 怎么测试:NativeEventEmitter/NativeModules 都来自 react-native 包,jest 里 mock 掉即可在单测中模拟「乱序批次、串台事件、卸载后晚到」三种注入(对应三条过滤),hook 的防御逻辑不需要真机就能回归——双端代码才需要设备农场。

第四步:为什么要合批——事件量与帧预算对比

同一趟 60 分钟巡检(GPS 1Hz)三种上行策略的账本:

策略 上行事件数/60min JS 被唤醒次数 每次唤醒成本 60min 累计开销 帧预算影响
逐点上行 3600 3600 线程切换 + 转换 + 分发 + setState ≈ 0.5ms ≈1.8s 纯开销 + 3600 组短命对象 GC + 3600 次 React 更新 每 1s 撕开一条缝,滚动中高概率撞上
5s 合批(本章) 720 720 同上,但 5 点摊薄 ≈0.36s + GC 压力降 80% 批间完整让出帧预算,滚动无感
共享值直通(第 9 章,仅 UI 消费) 0 0 无 0 最低;但 JS 拿不到数据,业务决策做不了

结论:只要 JS 需要数据做业务(轨迹存库、到场判定、离线上报),合批是必须的缓冲层;只有纯视觉消费才值得走直通。FieldNote 的 5s 是业务口径(到场判定精度要求分钟级)与工程口径(事件量)的交点——合批窗口永远由「最慢的业务消费者」决定,而不是「最快的生产者」。

补充一个真实约束:合批把「逐点上行的带宽成本」也一起省了——但「省下的点」去哪了?本方案是直接丢弃(轨迹场景可接受);如果你的业务要求逐点全量落库(如争议仲裁需要完整轨迹),正确做法是采集端本地持久化 + 摘要上行:Native 把每个点写入本地队列(第 14 章的存储层),跨端通道只传摘要与统计,全量数据等有网后由原生侧直接上传或经查询通道按需取。跨端通道永远只承载「当下要消费的数据」,历史归档走存储,这是通道与存储的分工。

第五步:验证 M6 验收线

  • 5s 节流生效:日志过滤 onLocationBatch,10 分钟窗口内事件数应为 120±5(有空批跳过),每批 3~6 点;
  • 无泄漏:进出巡检页 20 次(复用第 4 章「20 次进出」回归法),结束后(a)原生日志确认 removeUpdates/stopUpdatingLocation 被调用 20 次;(b)JS 侧 listenerCount 归零;(c)定位线程/GCD 定时器销毁,进程内存曲线回基线;
  • 晚到安全:退出页面后人为等待 6s,确认无任何 setState 触发(React Profiler 零 commit);
  • 乱序安全:调试开关把 seq 打乱注入,轨迹渲染无倒放。

验收不要停留在手测:四条验证项都能写成仪器测试/脚本——事件计数断言(10 分钟窗口跑 2 分钟采样按比例折算)、进出页面 20 次的 UI 自动化、延迟注入开关下的「零 commit」断言,全部挂进 CI 后,M6 的「无泄漏」就从「这次测了没问题」变成「每次提交都被证明没问题」。这也是附录 A Checklist 里「原生模块验收」一行的完整形态:验收标准的归宿是自动化,不是记忆。

联调期三个高频疑问,提前给答案:模拟器定位默认停在固定坐标,直接验证到场判定会得到「全绿但全错」的结论——用模拟器的 GPX 路线注入模拟移动;accuracy 是米制置信半径,到场判定必须带上它(如 <50m 才算到场),否则室内漂移会把误报打进数据;定位源的选择——GPS 精度高耗电、网络定位(fused)精度低省电,巡检轨迹用 GPS、后台签到可用网络源,同一模块暴露 provider 参数即可,不必拆两个模块。测试环境还要留意 GPS 冷启动(TTFF):首次定位可能要 10~30 秒,测试脚本要先「热身」拿到首个 fix 再开始计时断言,否则第一批数据全是环境噪声。hook 侧的 error 状态只承载「start/stop 的失败」(无权限、无 GPS),「事件迟迟不来」不是 error——它是数据缺失,由 UI 层用「最近位置时间戳」判断,两种异常不要混在一个状态里。

两个边界问题也要交底。本模块为什么只做前台定位:巡检锁屏后继续采集需要前台服务(Android Foreground Service)与常驻通知、iOS 的 background location 更新模式与对应的 App Store 审核说明——这些是权限与平台政策层面的工程,属于第 7 章(权限流程)与第 17 章(上架合规)的管辖,通信通道本身与前台/后台无关,边界清晰才好分别验证。性能预算自检(模块自己的账,进 Code Review):采集回调 O(1) 且 <0.1ms;flush 的 5 点转换与 payload 构造 <1ms;每批 payload 约 500B(6.5 节口诀:远小于一屏所需);空窗口零事件。轨迹数据的持久化与离线上报属于同步引擎的领地,在第 14、15 章接手——本章通道交付的是「结构化、有序、有界、坐标系明确的定位事件流」,它恰好是下游需要的形状。

常见陷阱

陷阱 典型症状 根因 正确做法
在 JS 发起高频事件轮询原生 setInterval(100ms) 调 getBatterySync 之类,滚动掉帧、发热 每次轮询都是一次跨端唤醒 + 同步占用 JS 线程;JS 的「问」永远不如原生的「推」 原生侧感知变化后经 Emitter 推送,频率由原生节流控制
监听器 add 了没 remove 进出页面 N 次后回调执行 N 遍;卸载后仍在触发 条件分支里 add、句柄丢弃、async effect 清理写错(第 4 章同源) 句柄必接 + effect 同步 return 里 remove;原生侧 last listener 即停采集
在原生主线程做重活 点击无响应 800ms、ANR、列表滚动冻结 主线程做 IO/解码/同步加密——它与用户输入、绘制同队列 主线程只留视图操作与事件打包;重活 post 到专用队列(6.3 节纪律)
假设事件有序到达 轨迹倒放、旧状态覆盖新状态、偶发且难复现 合批/多线程/重试让到达顺序≠发生顺序 事件携带单调 seq,消费端单调过滤(6.6 故障 6)
跨端传大 JSON 调用一次卡 100~300ms,内存尖峰后 GC 抖动 三次拷贝 + 短命对象暴涨;千条记录的序列化是纯 JS 线程重活 分页、传 id 引用双端各查各库、原生侧预聚合;图片走 URI(6.5 节)
Promise 在非 JS 线程 resolve 引发的问题 偶发崩溃或回调丢失,只在特定机型复现 绕过框架直接触碰 jsi/Runtime 却不在 JS 线程执行 框架 Promise(codegen 生成)可跨线程 resolve;自持 jsi 对象必须经 CallInvoker 的 invokeAsync 编组(6.3 节)
同步 TurboModule 里做 IO 整个 App 冻结直至 IO 完成 同步调用占住 JS 线程(全场唯一能执行 JS 的线程) 同步通道预算微秒级;任何 IO 一律 Promise + 后台线程
Callback 被调用两次或不调用 「bridge 回调已调用」崩溃;或闭包永被持有直至泄漏 「至多一次」只是约定,无机制强制;泄漏分支永不释放 AtomicBoolean/flag 机械保证一次;超时兜底终结;stop 时显式清理持有的回调

最佳实践

  1. 先查 6.1 的通道地图再动手:JS 发起走 TurboModule 方法、组件相关的 Native→JS 走 Fabric 事件、系统级持续数据走全局 Emitter、UI 意图走 Commands、JSI 直调只留给 C++ 既有能力。
  2. 同步跨端调用的预算是微秒级:无 IO、无锁等待、无大对象转换;默认所有业务调用都该是 Promise。
  3. 方法体把重活立刻 post 出 JS 线程,结果经框架编组或 CallInvoker 回 JS;主线程只允许微秒级收尾。
  4. 四通道的次数语义背下来:Promise 恰好一次、Callback 至多一次、Emitter 0..n 次、Commands 即发即弃——每条分支路径都要「终结」。
  5. 高频数据三件套:原生侧合批(固定节奏 + 空批跳过 + 丢最旧背压)、JS 侧 rAF 合帧渲染、纯视觉消费走共享值直通(第 9 章)。
  6. 事件带单调 seq、带业务归属 id(trackingId):乱序与串台在源头就可被识别,而不是在 UI 上被看见。
  7. 监听器生命周期与组件/页面严格绑定:add/remove 成对写进同一层,卸载时先摘监听再停原生源——顺序不能反。
  8. 原生模块实现 invalidate 级清理(Android removeUpdates/removeCallbacks,iOS stopUpdatingLocation/cancel timer),它是 JS 上下文销毁时的最后防线。
  9. 大 payload 的默认答案是不传:分页、传 id 各查各库、原生预聚合;数据尺寸的评审口诀是「超过一屏 UI 所需就该怀疑」。
  10. 二进制永不落 JS:URI 引用 + 原生侧完成编码上传;Base64 是旧架构的伤疤,不要在新代码里复刻。
  11. 每个跨端模块声明自己的执行队列语义(专用 HandlerThread / GCD 队列),不共享全局池,避免互相饱和拖死。
  12. 把「跨端调用超时率」与「事件丢弃率」接入线上监控(复用第 2 章白名单上报通道):它们是通信层健康度的两个第一指标。
  13. 双端实现必须逐方法对应、逐字段对齐(字段名、单位、坐标系、精度),任何一侧「顺手多传/少传一个字段」都是未来联调事故的种子。

本章小结

本章把 JS↔Native 的全部通道放进了一张地图:TurboModule 方法(Promise/Callback/同步返回)、Fabric Props 下行、Fabric 事件上行、Commands 指令、全局事件广播、JSI 直调——选型的三个锚点是方向(谁发起)、生命周期(跟谁生死)、次数语义(恰好一次/至多一次/持续/即发)。方向不对称(Native 不能直调 JS)不是缺陷而是单向依赖的刻意设计;旧架构的通信习惯都有更便宜或更安全的新对应物,迁移对照表可以当评审工具用。JSI 层面,HostObject 的属性访问即函数调用、CallInvoker 是「回到 JS 线程」的唯一正规通道,同进程直接调用解释了同步为何可能、也解释了同步预算为何必须微秒级。线程纪律收敛为一句话:重活只发生在工作队列,主线程与 JS 线程只做必须的事;「框架编组」与「自己编组」两层必须分清。高频数据的答案是原生侧合批(固定节奏、空批跳过、丢最旧背压),纯视觉消费则走不落 JS 的直通路线;合批窗口由最慢的业务消费者决定,历史归档交给存储层。六种故障(晚到、泄漏、悬挂、卡死、风暴、乱序)的共同根因是异步性摧毁了顺序、次数、生命周期三个直觉,每项修复都要落成可断言的验收标准。FieldNote 定位模块按此交付:Kotlin/Swift 双端 5s 合批、seq 防乱序、幂等 start/stop、三层退场清理,M6 验收的「5s 节流送达、退出无泄漏」全部落地。本章刻意用 interop 方式访问原生模块——把「通道」与「脚手架」分离后,第 7 章的 codegen 就只是给这套通信逻辑穿上类型安全的外壳。

一句话记忆点:选通道先问三件事——谁发起、跟谁生死、几次;写线程先记一句话——重活永不落在主线程与 JS 线程。

动手练习

  1. 基础巩固:把 6.2 节的 DeviceProbe 挂到自己的应用里(可先用 RN 社区的 JSI 模板工程简化接线),然后写一个基准脚本:分别测「JS 侧纯函数 CRC32」与「HostObject crc32」处理 10KB 字符串 ×1000 次的耗时,解释结果为什么可能互有胜负(提示:数据落 JS 堆的成本、字符串 UTF 转换、Hermes 的内联优化),并据此给出「什么计算值得下沉 C++」的判断标准。
  2. FieldNote 实战 A:为扫码功能设计事件通道:扫码引擎每秒最多识别 5 次预览帧。要求(a)原生侧用 6.4 节 BatchEmitter 模式按 300ms 合批并做置信度过滤(<0.9 丢弃);(b)JS 侧封装 useScanSubscription,含 seq 过滤、卸载停源;(c)给出逐帧上行 vs 300ms 合批的 60 秒事件量对比表。
  3. FieldNote 实战 B:给本章定位模块增加「暂停/恢复 + 会话续传」:App 切后台时不丢数据(flush 一次后挂起定时器),切回前台恢复采集;trackingId 变化时旧会话数据先 flush 再切换。写出双端改动点与边界条件清单(提示:注意 synchronized/NSLock 的临界区范围,以及 AppState 状态与原生 stop 的竞态)。
  4. 思考题:如果让你设计「摄像头预览帧流」的 JS 接口(30fps、720p YUV,每帧约 1.4MB),你会选哪条通道?为什么其余通道不合适?请逐条排除:TurboModule 方法(频率与 payload)、Promise/Callback(次数语义)、全局事件(JS 线程预算:30fps × 每帧转换成本是多少毫秒,16.7ms 帧预算还剩多少)、Props/Commands(方向不符)。最后给出你的架构:哪些留在原生、JS 侧拿到什么粒度的数据、用什么通道送达。

导航

上一章 第 5 章 启动速度优化 | 下一章 第 7 章 原生模块封装(TurboModule)

📄 第 7 章 原生模块封装(TurboModule) 07-turbo-modules.md

本章导读:第 6 章回答了「跨端通信走哪条通道」,本章回答「怎么把它规范地写出来」:用 TS spec 定义契约,让 codegen 在构建期生成双端接口,再以继承生成物的姿势实现 Android(Kotlin)与 iOS(ObjC++ 接口 + Swift 实现)。学完本章,FieldNote 的生物识别与扫码模块将作为 M7 里程碑完整落地;下一章用同一套方法论封装「看得见」的部分——Fabric 原生组件。

本章学习目标

学完本章,你应该能够:

  1. 说出 spec → codegen → 原生实现 → 注册 → JS 调用的全链路,并指出每个环节的产物落在磁盘的什么位置、由谁在什么时机生成。
  2. 编写一个类型完整、双端可实现、事件命名合规的 TurboModule spec,并解释「spec 即契约」把哪类错误从运行期提前到了编译期。
  3. 用 Kotlin 完整实现 Android 侧模块:Promise/同步方法/常量、事件发送、Activity Result API 权限申请与生命周期介入、指定执行线程。
  4. 用 ObjC++ 接口 + Swift 实现 iOS 侧模块,说清 .mm 文件为何必要、methodQueue 对应 Android 的什么、事件经 RCTEventEmitter 如何上行。
  5. 判断存量 NativeModule 是否值得迁移,并按新旧对照表与迁移清单在 interop 模式下完成迁移。
  6. 把模块组织为「js/android/ios 三目录」的本地 npm 包,解释 autolinking 在双端各自的发现与链接机制,以及发布到私有 registry 的关键约束。
  7. 设计 JS 侧抹平层:统一接口、能力探测、错误码归一,把平台差异收敛到一处。
  8. 用「原生断点 + mock spec 单测 + 原生自测页」三件套完成模块的调试与验证。

核心知识点

7.1 spec 即契约:Codegen 全链路原理

原理讲解

旧架构写原生模块,JS 侧拿到的 NativeModules.Xxx 是一个运行期从「模块配置 JSON」里查出来的对象——方法名拼错、参数顺序写反、原生压根没实现,全部要到运行时才炸,而且炸在 JS 侧只显示 undefined is not a function。新架构把这件事倒过来:你先用 TypeScript 写一份 spec(规格说明),codegen 在构建期读它,生成双端的接口与胶水代码;原生实现必须继承/实现生成物,签名不匹配直接编译失败。契约的「守约成本」从联调期前移到了构建期,这是 TurboModule 相对旧 NativeModule 的第一收益(性能收益——方法不再经 JSON 序列化——在第 6 章已经讲过)。

全链路如下:

flowchart TB
    subgraph SRC["你的代码"]
        S1["NativeBiometrics.ts<br/>(spec:接口 + 类型)"]
        S2["BiometricsModule.kt<br/>继承生成物实现"]
        S3["RCTNativeBiometrics.mm + BiometricsImpl.swift<br/>实现生成协议"]
        S4["package.json codegenConfig"]
    end
    S4 --> CG
    S1 --> CG
    subgraph CG["Codegen(构建期工具)"]
        C1["解析 TS spec → 中间 Schema"]
        C2["生成 Android:Java 接口/抽象类"]
        C3["生成 iOS:ObjC 协议 + C++ TurboModule 基类"]
    end
    subgraph BUILD["构建系统"]
        B1["Gradle:generateCodegenArtifactsFromSchema"]
        B2["CocoaPods:pod install + install_modules_dependencies"]
    end
    C2 --> B1
    C3 --> B2
    B1 --> S2
    B2 --> S3
    subgraph REG["注册(启动期)"]
        R1["Android:BaseReactPackage<br/>ReactModuleInfoProvider"]
        R2["iOS:RCT_EXPORT_MODULE 注册名<br/>+ getTurboModule 接进 JSI"]
    end
    S2 --> R1
    S3 --> R2
    R1 --> J["JS 侧:TurboModuleRegistry.getEnforcing&lt;Spec&gt;<br/>返回 JSI 代理,方法按需绑定"]
    R2 --> J
    J -.->|"调用:jsi::HostFunction 直达原生"| S2
    J -.-> S3

spec 长这样(FieldNote 生物识别模块,M7 将完整落地):

// packages/native-kit/js/NativeBiometrics.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  // 同步方法:JSI 直调,微秒级预算,禁止 IO(第 6 章纪律)
  readonly biometricTypeName(): 'strong' | 'weak' | 'faceID' | 'touchID' | 'none';

  // 异步方法:Promise 通道,恰好一次终结
  readonly isAvailable(): Promise<boolean>;
  readonly authenticate(reason: string): Promise<boolean>;
}

// 注册名 = getEnforcing 的参数 = 原生 RCT_EXPORT_MODULE / 模块 NAME
export default TurboModuleRegistry.getEnforcing<Spec>('FieldNoteBiometrics');

让 codegen 看见这份 spec,靠的是包内 package.json 的 codegenConfig:

{
  "name": "@fieldnote/native-kit",
  "version": "0.1.0",
  "main": "js/index.ts",
  "codegenConfig": {
    "name": "FieldNoteKitSpec",
    "type": "modules",
    "jsSrcsDir": "js",
    "android": { "javaPackageName": "com.fieldnote.nativekit" }
  }
}

逐段讲解:

  • type:modules(本章)、components(第 8 章)、all(两者都含)。一个包只能有一个 codegenConfig,因此「模块 + 组件」混合的库用 all。
  • jsSrcsDir:codegen 只扫描这个目录下、以 Native 开头命名的 .ts/.tsx(模块 spec 的硬性命名要求;组件 spec 建议同样命名)。⚠️ spec 文件放错目录或名字不带 Native 前缀,codegen 会静默忽略——构建照样成功,但生成物缺失,继承它的原生类编译失败,报错信息往往指不到真正的根因。
  • name:生成 Spec 产物 pod/库名(iOS 侧对应 FieldNoteKitSpec),android.javaPackageName 决定生成 Java 代码的包名。

生成产物长什么样、在哪(不同 RN 版本路径略有差异,定位产物最快的方法是构建后全局搜索 spec 类名,如 NativeBiometricsSpec):

packages/native-kit/android/build/generated/sources/codegen/
└── java/com/fieldnote/nativekit/
    ├── NativeBiometricsSpec.java        # 抽象基类:方法签名 + @ReactMethod 注解
    └── NativeScannerSpec.java

packages/native-kit/ios/build/generated/ios/
└── FieldNoteKitSpec/
    ├── NativeBiometricsSpec.h           # ObjC 协议 @protocol NativeBiometricsSpec
    ├── NativeBiometricsSpec.mm          # JSI 胶水(C++ 实现,.mm 编译)
    └── NativeBiometricsSpecJSI.h        # C++ TurboModule 基类(jsi::Runtime 直连层)

生成物节选(简化示意,随 RN 版本略有差异,以构建产物为准):

// 生成:NativeBiometricsSpec.java
public abstract class NativeBiometricsSpec extends ReactContextBaseJavaModule
    implements TurboModule {

  public static final String NAME = "FieldNoteBiometrics";

  public NativeBiometricsSpec(ReactApplicationContext reactContext) {
    super(reactContext);
  }

  @Override
  public String getName() { return NAME; }

  @ReactMethod
  public abstract void isAvailable(Promise promise);

  @ReactMethod
  public abstract void authenticate(String reason, Promise promise);

  public abstract String biometricTypeName();  // 同步方法:走 JSI 直调,不带 @ReactMethod
}
// 生成:NativeBiometricsSpec.h
@protocol NativeBiometricsSpec <RCTBridgeModule>
- (void)isAvailable:(RCTPromiseResolveBlock)resolve
             reject:(RCTPromiseRejectBlock)reject;
- (void)authenticate:(NSString *)reason
             resolve:(RCTPromiseResolveBlock)resolve
              reject:(RCTPromiseRejectBlock)reject;
- (NSString *)biometricTypeName;
@end

【重点】「spec 即契约」的三重意义:

  1. 类型错误编译期暴露:JS 调 authenticate(123) 是 TS 编译错;原生忘了实现 biometricTypeName 是 Kotlin/ObjC 编译错。签名漂移(参数顺序、类型)在生成物这一层被强制对齐,不再依赖联调发现。
  2. 双端同源:Android 与 iOS 的接口都从同一份 TS 生成,「双端签名不一致」这类最难排查的故障失去了存在空间。
  3. 调用面即注册面:spec 里的每个方法对应生成物里的一个 jsi::HostFunction,JS 侧代理对象的属性访问才触发绑定——这也是本章末思考题(按需查找)的伏笔。

spec 类型系统常用类型对照(类型错误在 codegen 阶段就会报):

spec 类型 Android 生成 iOS 生成 说明
string String NSString *
number double double JS number 统一按 Double 处理
Int32 / Float int / float int / float 定宽,codegen 校验整型
boolean boolean BOOL
Promise<T> Promise RCTPromiseResolveBlock/RejectBlock 异步通道,恰好一次
(x) => void Callback RCTResponseSenderBlock 至多一次回调(第 6 章)
WithDefault<T, D> 可空 + 默认 可空 + 默认 Props 默认值(第 8 章常用)
UnsafeObject ReadableMap id(NSDictionary) 弱类型透传,能用强类型就别用
Array<T> ReadableArray NSArray 大数组注意第 6 章的拷贝成本

7.2 Android 实现(Kotlin)

原理讲解

Android 侧的实现 = 继承生成的 Spec 抽象类 + 在 Package 里登记模块元信息。继承生成物后你不需要再写 @ReactMethod(注解在生成物里),签名漂移由编译器拦截;登记信息(ReactModuleInfoProvider)则是注册体系的「黄页」,注册名必须与 spec 中 getEnforcing 的参数一致。

完整实现(生物识别模块;androidx.biometric 依赖在包的 android/build.gradle 中添加):

// packages/native-kit/android/src/main/java/com/fieldnote/nativekit/BiometricsModule.kt
package com.fieldnote.nativekit

import android.os.Handler
import android.os.Looper
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch

class BiometricsModule(reactContext: ReactApplicationContext) :
    NativeBiometricsSpec(reactContext) {  // 继承 codegen 生成物:签名由编译器强制对齐

  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
  private val mainHandler = Handler(Looper.getMainLooper())

  override fun getName() = NAME  // NAME 由生成物提供,来自 spec 的注册名

  // —— 同步方法:JSI 直调,微秒级预算,只做内存查询 ——
  override fun biometricTypeName(): String {
    val manager = BiometricManager.from(reactApplicationContext)
    val strong = manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)
    val weak = manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
    return when {
      strong == BiometricManager.BIOMETRIC_SUCCESS -> "strong"
      weak == BiometricManager.BIOMETRIC_SUCCESS -> "weak"
      else -> "none"
    }
  }

  // —— Promise 方法:把重活立刻丢出调用线程,结果恰一次终结 ——
  override fun isAvailable(promise: Promise) {
    scope.launch {
      promise.resolve(biometricTypeName() != "none")  // 框架 Promise 可跨线程 resolve(第 6 章)
    }
  }

  override fun authenticate(reason: String, promise: Promise) {
    val activity = currentActivity
    if (activity !is FragmentActivity) {
      // ⚠️ 每条非正常路径都必须 reject:漏一个分支 = JS 侧 loading 永转
      promise.reject(E_NO_ACTIVITY, "宿主不是 FragmentActivity,无法弹出系统认证")
      return
    }
    // BiometricPrompt 是 UI 能力:必须在主线程构造与展示
    mainHandler.post {
      val prompt = BiometricPrompt(
        activity,
        ContextCompat.getMainExecutor(activity),
        object : BiometricPrompt.AuthenticationCallback() {
          override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            promise.resolve(true)                       // 分支 1:成功(终态)
          }
          override fun onAuthenticationError(code: Int, errString: CharSequence) {
            promise.reject(toErrorCode(code), errString.toString())  // 分支 2:错误(终态)
          }
          // 分支 3:onAuthenticationFailed 是「单次识别失败,可继续重试」,不是终态
          // —— 绝不能在这里 resolve/reject,否则弹框还在、Promise 已死
        },
      )
      val info = BiometricPrompt.PromptInfo.Builder()
        .setTitle("验证身份")
        .setSubtitle(reason)
        .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
        .build()
      prompt.authenticate(info)
    }
  }

  override fun invalidate() {  // JS 上下文销毁时的最后防线(第 6 章)
    scope.cancel()
    super.invalidate()
  }

  private fun toErrorCode(code: Int) = when (code) {
    BiometricPrompt.ERROR_USER_CANCELED -> E_USER_CANCEL
    BiometricPrompt.ERROR_LOCKOUT, BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> E_LOCKOUT
    BiometricPrompt.ERROR_NO_BIOMETRICS, BiometricPrompt.ERROR_NO_DEVICE_CREDENTIAL -> E_UNAVAILABLE
    else -> E_FAILED
  }

  companion object {
    const val NAME = "FieldNoteBiometrics"
    const val E_UNAVAILABLE = "FN_BIO_UNAVAILABLE"
    const val E_LOCKOUT = "FN_BIO_LOCKOUT"
    const val E_USER_CANCEL = "FN_BIO_USER_CANCEL"
    const val E_FAILED = "FN_BIO_FAILED"
    const val E_NO_ACTIVITY = "FN_NO_ACTIVITY"
  }
}

逐段讲解:

  • 错误码规范:FN_<域>_<原因> 全大写蛇形。JS 侧要按错误码做分支(锁定引导、静默取消等),所以错误码是 API 的一部分,必须写进 spec 文档、双端逐一对齐(M7 的错误码对照表见 FieldNote 一节)。
  • 线程模型:scope = Dispatchers.Default 是模块专属执行线程(对应 iOS 的 methodQueue,见 7.3);authenticate 里弹出系统认证框是纯 UI 操作,显式 mainHandler.post 回主线程。主线程上只允许发生「展示弹框」这一件事,IO/解码/加密一律留在 Default。
  • Promise 分支纪律:onAuthenticationFailed 不终结是本例最容易写错的地方——它表示「再试一次」。终态分支恰好覆盖「成功 + 错误码」,与第 6 章「Promise 恰好一次」的语义闭环。

权限申请用 Activity 结果 API。模块没有 UI 也没有生命周期,借宿主 Activity 的 activityResultRegistry 注册 launcher:

// 放在 ScannerModule 中(M7 完整版);此处展示通用模式
override fun requestCameraPermission(promise: Promise) {
  val activity = currentActivity
    ?: return promise.reject(E_NO_ACTIVITY, "无宿主 Activity")
  if (ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.CAMERA)
      == PackageManager.PERMISSION_GRANTED) {
    promise.resolve(true); return
  }
  val registry = (activity as? ComponentActivity)?.activityResultRegistry
    ?: return promise.reject(E_NO_ACTIVITY, "宿主不是 ComponentActivity")
  // 稳定 key:同一 key 重复注册会替换旧 launcher(而不是叠加多个回调)
  registry.register(
    "fieldnote.camera.permission",
    ActivityResultContracts.RequestPermission(),
  ) { granted -> promise.resolve(granted) }
    .launch(Manifest.permission.CAMERA)
}

⚠️ 这个模式的边界:register + launch 要求 Activity 处于 RESUMED 之后。JS 调用瞬间必然 RESUMED(用户刚点了按钮),因此对「一问一答」式申请够用;但若申请后 Activity 重建(旋转、内存回收),launcher 随旧实例失效。跨重建的长流程要么在 onHostResume 里用稳定 key 重挂回调,要么改用 RN 提供的 PermissionAwareActivity.requestPermissions(ReactActivity 已实现),两条路都比「把权限状态记在 JS 内存里」可靠。

生命周期介入有两个监听器,用途不同:

init {
  reactApplicationContext.addLifecycleEventListener(this)   // 宿主前后台/销毁
  reactApplicationContext.addActivityEventListener(this)    // onActivityResult / onNewIntent
}

// LifecycleEventListener:Activity 生命周期
override fun onHostResume() { /* 恢复采集、重建 launcher */ }
override fun onHostPause() { /* 挂起采集、停传感器 */ }
override fun onHostDestroy() { /* 兜底释放(invalidate 之外的第二道防线) */ }

// ActivityEventListener:需要拿 onActivityResult 结果的场景
// (如跳转系统页拍照返回),重写 onActivityResult 处理 requestCode

【重点】Android 线程模型一句话:模块方法先落到自己声明的执行器(Default/专用 HandlerThread),主线程只做视图与系统 UI;emit 事件前切回 JS 队列:

private fun emitScanResult(payload: WritableMap) {
  reactApplicationContext.runOnJSQueueThread {
    reactApplicationContext
      .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
      .emit("fieldnote:scanResult", payload)
  }
}

最后是注册。一个包挂多个模块时,ReactModuleInfoProvider 的 map 就是黄页(M7 的包里同时注册 biometrics 与 scanner):

// packages/native-kit/android/src/main/java/com/fieldnote/nativekit/FieldNoteKitPackage.kt
package com.fieldnote.nativekit

import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider

class FieldNoteKitPackage : BaseReactPackage() {
  override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
    when (name) {
      BiometricsModule.NAME -> BiometricsModule(reactContext)
      ScannerModule.NAME -> ScannerModule(reactContext)
      else -> null
    }

  override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
    mapOf(
      BiometricsModule.NAME to ReactModuleInfo(
        BiometricsModule.NAME,
        BiometricsModule.NAME,
        canOverrideExistingModule = false,
        needsEagerInit = false,
        isCxxModule = false,
        isTurboModule = true,      // ⚠️ false 则走 interop(7.4 节),构造方式完全不同
      ),
      ScannerModule.NAME to ReactModuleInfo(
        ScannerModule.NAME,
        ScannerModule.NAME,
        canOverrideExistingModule = false,
        needsEagerInit = false,
        isCxxModule = false,
        isTurboModule = true,
      ),
    )
  }
}

(ReactModuleInfo 的构造参数随 RN 版本有增减,以你版本的生成模板为准;关键是 isTurboModule = true。)App 接入只需在依赖里加上本地包(monorepo 下 "@fieldnote/native-kit": "workspace:*",autolinking 会自动发现并调用 getModule——机制见 7.5),无需改 MainApplication。

7.3 iOS 实现(ObjC++ 接口 + Swift 实现)

原理讲解

iOS 侧的现实是:生成的接口与 JSI 胶水是 C++,Swift 无法直接 include C++ 头文件,所以社区通行结构是两层——

  • RCTNativeXxx.h/.mm(ObjC++):实现生成的 NativeXxxSpec 协议,负责「注册名 + getTurboModule 接线 + methodQueue」,把调用转发给 Swift;
  • XxxImpl.swift:真正的业务实现,经 @objc 暴露给 ObjC++。

⚠️ 为什么必须是 .mm:getTurboModule 的签名含 facebook::react::TurboModule、ObjCTurboModule::InitParams 等 C++ 类型,.m 文件按纯 C 编译无法理解它们;.mm 才会按 ObjC++ 编译。而 Swift 侧同样不能 include C++ 头,所以 Swift 只被 ObjC++ 调用、不反向 include 生成物——两层各守一半。

podspec 是 codegen 接线的关键:

# packages/native-kit/ios/RNFieldNoteKit.podspec
require "json"

package = JSON.parse(File.read(File.join(__dir__, "..", "package.json")))

Pod::Spec.new do |s|
  s.name         = "RNFieldNoteKit"
  s.version      = package["version"]
  s.summary      = "FieldNote 原生能力包"
  s.license      = "MIT"
  s.authors      = { "FieldNote Team" => "dev@example.invalid" }
  s.platforms    = { ios: "15.1" }
  s.source       = { :git => "https://example.invalid/fieldnote/native-kit.git", :tag => "#{s.version}" }

  s.source_files = "**/*.{h,m,mm,swift}"
  s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES" }  # 让 -Swift.h 桥接头可用

  # 关键:把 React-Codegen 的头文件与产物接进本 pod
  # (模块的头文件搜索路径、生成代码的编译都在这一步挂上)
  install_modules_dependencies(s)
end

ObjC++ 壳(实现生成协议 + 接进 JSI):

// packages/native-kit/ios/RCTNativeBiometrics.h
#import <Foundation/Foundation.h>
// 生成物协议(pod install 后在 Pods/生成目录中确认确切路径)
#import "FieldNoteKitSpec/NativeBiometricsSpec.h"

NS_ASSUME_NONNULL_BEGIN

@interface RCTNativeBiometrics : NSObject <NativeBiometricsSpec>
@end

NS_ASSUME_NONNULL_END
// packages/native-kit/ios/RCTNativeBiometrics.mm   ← 注意扩展名 .mm
#import "RCTNativeBiometrics.h"
#import "BiometricsImpl-Swift.h"   // Swift 实现经模块化桥接头暴露(DEFINES_MODULE=YES)

using namespace facebook::react;

@implementation RCTNativeBiometrics {
  FieldNoteBiometricsImpl *_impl;
}

// 注册名必须与 spec 中 getEnforcing('FieldNoteBiometrics') 一致
RCT_EXPORT_MODULE(FieldNoteBiometrics)

- (instancetype)init {
  if (self = [super init]) {
    _impl = [FieldNoteBiometricsImpl new];
  }
  return self;
}

// 等价于 Android 的「指定执行线程」:本模块所有方法都串行在这条队列上执行
- (dispatch_queue_t)methodQueue {
  return dispatch_queue_create("com.fieldnote.nativekit.biometrics", DISPATCH_QUEUE_SERIAL);
}

- (NSString *)biometricTypeName {
  return [_impl biometricTypeName];
}

- (void)isAvailable:(RCTPromiseResolveBlock)resolve
             reject:(RCTPromiseRejectBlock)reject {
  [_impl isAvailableWithResolve:resolve reject:reject];
}

- (void)authenticate:(NSString *)reason
             resolve:(RCTPromiseResolveBlock)resolve
              reject:(RCTPromiseRejectBlock)reject {
  [_impl authenticateWithReason:reason resolve:resolve reject:reject];
}

// 新架构接线:把 ObjC 实例包装成 JSI TurboModule。
// 这一行依赖 C++ 类型,是 .mm 存在的核心理由。
- (std::shared_ptr<TurboModule>)getTurboModule:(const ObjCTurboModule::InitParams &)params {
  return std::make_shared<NativeBiometricsSpecJSI>(params);
}
@end

Swift 实现:

// packages/native-kit/ios/BiometricsImpl.swift
import Foundation
import LocalAuthentication
import React

@objc(FieldNoteBiometricsImpl)
public final class BiometricsImpl: NSObject {

  @objc public func biometricTypeName() -> String {
    let ctx = LAContext()
    var error: NSError?
    if ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) {
      return ctx.biometryType == .faceID ? "faceID" : "touchID"
    }
    return "none"
  }

  @objc public func isAvailable(resolve: @escaping RCTPromiseResolveBlock,
                                reject: @escaping RCTPromiseRejectBlock) {
    if biometricTypeName() != "none" {
      resolve(true)
    } else {
      reject("FN_BIO_UNAVAILABLE", "设备不支持生物识别或未录入", nil)
    }
  }

  @objc public func authenticate(reason: String,
                                 resolve: @escaping RCTPromiseResolveBlock,
                                 reject: @escaping RCTPromiseRejectBlock) {
    let ctx = LAContext()
    var evalError: NSError?
    guard ctx.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &evalError) else {
      reject("FN_BIO_UNAVAILABLE", evalError?.localizedDescription ?? "不可用", evalError)
      return
    }
    // 系统弹框由系统调度;回调块里 Promise 可直接终结(框架会编组回 JS 线程)
    ctx.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,
                       localizedReason: reason) { success, error in
      if success {
        resolve(true)
      } else {
        let code: String
        switch (error as? LAError)?.code {
        case .userCancel, .systemCancel, .appCancel: code = "FN_BIO_USER_CANCEL"
        case .authenticationFailed:                  code = "FN_BIO_FAILED"
        case .biometryLockout:                       code = "FN_BIO_LOCKOUT"
        default:                                     code = "FN_BIO_UNAVAILABLE"
        }
        reject(code, error?.localizedDescription ?? "认证失败", error)
      }
    }
  }
}

逐段讲解:

  • methodQueue:Android 侧我们用 Dispatchers.Default 声明执行线程,iOS 的对应物就是 methodQueue——返回一条专属串行队列后,spec 的所有方法调用都排队执行。不实现时默认走主队列,等价于「在主线程做所有事」,这正是第 6 章警告的反模式。
  • 错误码逐一对齐:Swift 的 LAError 映射与 7.2 Kotlin 的 BiometricPrompt 错误映射产出同一套字符串(FN_BIO_*),JS 侧才能写一份分支。这是「双端 API 差异抹平」的第一层:在原生层就把语义对齐,而不是让 JS 处理两套原生错误。
  • Promise 块的线程:evaluatePolicy 的回调块由系统在私有队列调用,直接调用 resolve/reject 是安全的——codegen 生成的 Promise 胶水内部会编组回 JS 线程(第 6 章结论的落地)。

事件发送(M7 扫码模块会用到):新模块的事件仍走 RCTEventEmitter 子类 + supportedEvents。要点是单一共享实例 + 白名单事件名 + 主队列发送:

// FieldNoteEventEmitter.h
#import <React/RCTEventEmitter.h>
@interface FieldNoteEventEmitter : RCTEventEmitter
+ (instancetype)shared;
- (void)sendScanResult:(NSString *)value seq:(NSInteger)seq ts:(double)ts;
@end
// FieldNoteEventEmitter.mm
#import "FieldNoteEventEmitter.h"

@implementation FieldNoteEventEmitter

RCT_EXPORT_MODULE(FieldNoteEventEmitter)

+ (instancetype)shared {
  static FieldNoteEventEmitter *shared = nil;
  static dispatch_once_t onceToken;
  dispatch_once(&onceToken, ^{ shared = [FieldNoteEventEmitter new]; });
  return shared;
}

// ⚠️ 发送的事件名必须在此白名单内,否则运行时抛异常
- (NSArray<NSString *> *)supportedEvents {
  return @[@"fieldnote:scanResult", @"fieldnote:locationBatch"];
}

- (void)sendScanResult:(NSString *)value seq:(NSInteger)seq ts:(double)ts {
  [self sendEventWithName:@"fieldnote:scanResult"
                     body:@{ @"value": value, @"seq": @(seq), @"ts": @(ts) }];
}
@end

发送方在主队列调用 sendScanResult(框架内部再编组到 JS 线程);JS 侧用 new NativeEventEmitter(NativeScanner) 订阅——完整钩子见 FieldNote 一节,那里会补上第 6 章强调的监听器清理。

权限:iOS 没有「运行时授权码回调」这一层,授权模型是 Info.plist 用途文案 + 系统弹框:

Info.plist Key 何时必需 文案要点
NSFaceIDUsageDescription 使用 FaceID(TouchID 不需要) 说明为什么需要,如「用于解锁巡检工作台」
NSCameraUsageDescription 使用相机(扫码/拍照) 「用于扫描资产码与巡检拍照」
NSMicrophoneUsageDescription 录视频才需要 只拍照片不需要,不要多要

⚠️ 缺 Key 是直接崩溃(TCC 强校验,第一次访问即 abort),不是失败回调。这是 iOS 原生模块最经典的「真机才能复现」问题——模拟器上权限模型宽松,崩溃往往只在真机/Ad Hoc 包出现(7.6 节的真机差异表会再强调)。

7.4 旧架构 NativeModule 与 interop:存量怎么办

原理讲解

新架构没有一刀切:框架内置了 interop 层——旧的 NativeModule(用 @ReactMethod、constantsToExport 那套写的)在 bridgeless 下依然工作。原理是框架为旧模块保留了一条兼容通道:调用仍被包装成 TurboModule 接口暴露给 JSI,但参数序列化、线程与注册行为保持旧语义(也因此旧模块享受不到 JSI 直调的收益)。注册侧的开关就是 7.2 里 ReactModuleInfo.isTurboModule:true 走新路径,false(或完全没迁移的 ReactPackage)走 interop。⚠️ interop 模块与旧架构一样启动期全量实例化——这正是本章思考题要你估算的启动成本来源之一。

何时值得迁移(按信号决策,而不是「为了新而新」):

信号 建议
高频调用(毫秒级间隔)或大 payload 迁移,JSI 直调收益最大
双端签名漂移、类型错误频发 迁移,spec 契约从根上消灭
模块内有 constantsToExport 且被频繁读 迁移,改为同步方法后成本骤降
稳定、低频、没人再改 可暂缓,interop 下继续跑
三方库已停止维护 迁移或找替代品(别在死代码上叠加)

迁移步骤清单(新旧对照见下表):

  1. 在包里补 codegenConfig,写 spec(照着旧接口翻译成 TS)。
  2. Android:模块类改继承 NativeXxxSpec,删掉 getName 手写与 @ReactMethod 注解,Package 里补 ReactModuleInfoProvider 且 isTurboModule = true。
  3. iOS:新建 .mm 壳实现生成协议 + getTurboModule,旧实现挪进 Swift/ObjC 实现类,保留原 RCT_EXPORT_MODULE 的注册名。
  4. constantsToExport 改为 spec 的 readonly 同步方法;调用方 JS 改从 getEnforcing<Spec> 导入。
  5. 双端错误码/事件名按 7.2/7.3 的规范统一(迁移是顺手还技术债的最佳时机)。
  6. 用 7.6 的自测页逐方法回归,重点验证 Promise 分支与事件时序。

新旧写法对照(迁移时的「翻译表」,也是评审时的检查表):

旧架构写法 新架构对应
extends ReactContextBaseJavaModule extends NativeXxxSpec(codegen 生成物)
@ReactMethod 逐个标注 覆写生成物方法,无需注解
getName() 手写字符串 复用生成物 NAME
constantsToExport() / getConstants() spec 的 readonly xxx(): T 同步方法
ReactPackage.createNativeModules(全量 new) BaseReactPackage.getModule(按需 new)
NativeModules.Xxx 导入 TurboModuleRegistry.getEnforcing<Spec>('Xxx')
iOS 全在 .m 里 RCT_EXPORT_METHOD .mm 壳 + 生成协议实现(+ Swift 实现类)
事件 sendEventWithName 裸字符串 同样可用,但统一 域:模块:事件 前缀(7.5)

一个真实的迁移 diff 通常小得惊人——旧的类体几乎不动,只是「继承谁」变了:

// 旧:class TorchModule(reactContext: ReactApplicationContext) :
//       ReactContextBaseJavaModule(reactContext) { ... @ReactMethod ... }
// 新:只改继承与包注册,方法体原样搬入
class TorchModule(reactContext: ReactApplicationContext) :
    NativeTorchSpec(reactContext) {  // 生成物
  override fun isOn(): Boolean = torchOn
  override fun toggle(promise: Promise) { /* 原实现 */ }
}

7.5 工程化:包结构、autolinking 与发布

原理讲解

生产级原生模块不应散落在 App 工程里,而应组织为独立包——哪怕只服务一个 App。理由:① 强制 spec/实现/文档成套;② 依赖方向清晰(业务 → 包),第 13 章 monorepo 拆分时直接平移;③ 单包可独立跑 7.6 的自测页与单测。标准三目录结构:

packages/native-kit/
├── package.json                 # codegenConfig + files 白名单 + peerDependencies
├── js/                          # TS 侧:spec + 抹平层(业务只 import 这里)
│   ├── NativeBiometrics.ts
│   ├── NativeScanner.ts
│   ├── useScanner.ts
│   └── index.ts
├── android/
│   ├── build.gradle             # androidx.biometric、zxing-core、CameraX 依赖
│   └── src/main/
│       ├── AndroidManifest.xml  # <uses-permission android:name="android.permission.CAMERA"/>
│       └── java/com/fieldnote/nativekit/
│           ├── FieldNoteKitPackage.kt
│           ├── BiometricsModule.kt
│           └── ScannerModule.kt
└── ios/
    ├── RNFieldNoteKit.podspec   # install_modules_dependencies 接线 codegen
    ├── RCTNativeBiometrics.h / .mm
    ├── RCTNativeScanner.h / .mm
    ├── FieldNoteEventEmitter.h / .mm
    ├── BiometricsImpl.swift
    ├── ScannerImpl.swift
    └── RNFieldNoteKit-Info.plist  # 权限文案(或并入主工程 Info.plist)

autolinking 的双端发现与链接机制(构建期自动完成,理解它才能定位「包没被链上」的问题):

App 的 package.json 依赖树(monorepo:workspace 链接到 packages/*)
      │  pnpm install
      ▼
RN CLI Autolinking(构建期扫描)
      │  对每个依赖问三个问题:
      │    ① 有 android/ 目录或 gradle 工程?→ Android 候选
      │    ② 有 *.podspec?→ iOS 候选
      │    ③ 有 codegenConfig?→ 为它跑 codegen 生成产物
      ├─ Android:生成 settings.gradle include + 依赖注入 + autolinking.json
      │     Gradle task「generateCodegenArtifactsFromSchema」→ NativeXxxSpec.java
      │     运行期由 BaseReactPackage.getModule 按名取模块
      └─ iOS:Podfile 的 use_native_modules! → 逐个 pod install
            podspec 的 install_modules_dependencies → 挂上生成 Spec 产物
            运行期由 RCT_EXPORT_MODULE 注册名 + getTurboModule 接进 JSI

⚠️ monorepo 常见坑:App 的 package.json 依赖里必须显式列出本地包(workspace:*),只放在 packages/ 下不等于被链接。iOS 侧改 podspec 后要重跑 pod install;Android 侧改 codegenConfig 后要 clean 一次生成目录——「改了 spec 没生效」九成卡在这里。

发布到私有 registry 的要点(按重要性排序):

  1. files 白名单:只发布 js/、android/src/、ios/、*.podspec,绝不发布 android/build/、ios/build/、.tsbuildinfo——生成物在消费端构建期重新生成,带着旧产物发布会让消费端用到过期接口。
  2. peerDependencies:react-native、react 声明为 peer,版本范围写成宽松区间(如 0.8x),与宿主 App 的 RN 版本对齐是原生包的头号兼容约束。
  3. 原生行为变更也要升 minor:JS API 没变但 Kotlin/Swift 行为变了,semver 的 patch 语义会骗过消费端的升级策略;建议原生包内部约定「动了原生 = minor」。
  4. publishConfig 指向私有 registry;prepublishOnly 挂 lint + 7.6 的单测,防手滑。
  5. 事件名与错误码作为公共 API 写进 README:消费方要依赖它们写分支(见下)。

跨模块事件命名规范:全局事件命名空间是扁平的,两个模块都叫 scanResult 就会串台(第 6 章故障 5 的变体)。规范:

<域名>:<模块>:<事件>     →  fieldnote:scanner:scanResult

(本章示例用 fieldnote:scanResult 双词简化版;模块一多就升级为三段式。)payload 一律带单调 seq——这是第 6 章立的规矩,迁移到 codegen 后不变。

最后是 JS 侧抹平层。原则:平台判断只出现在抹平层这一处,业务拿到的是统一语义:

// packages/native-kit/js/index.ts —— 业务只 import 这个文件
import { Platform } from 'react-native';
import NativeBiometrics from './NativeBiometrics';
import NativeScanner from './NativeScanner';
import type { ScanResultEventData } from './NativeScanner';

export type { ScanResultEventData };
export type BiometryKind = 'faceID' | 'touchID' | 'strong' | 'weak' | 'none';

export const Biometrics = {
  // 能力探测:不支持的平台收敛为 'none',业务无需写平台分支
  kind(): BiometryKind {
    if (Platform.OS !== 'android' && Platform.OS !== 'ios') return 'none';
    return NativeBiometrics.biometricTypeName();
  },
  async authenticate(reason: string): Promise<boolean> {
    try {
      return await NativeBiometrics.authenticate(reason);
    } catch (e: any) {
      if (e?.code === 'FN_BIO_UNAVAILABLE') return false;  // 「不可用」是业务态
      throw e;                                             // 其余(取消/锁定)必须上抛
    }
  },
};

export const Scanner = {
  async ensurePermission(): Promise<boolean> {
    return NativeScanner.requestCameraPermission();
  },
  async start(): Promise<void> { return NativeScanner.startScan(); },
  stop(): void { NativeScanner.stopScan(); },
};

抹平层的两条铁律:① 错误分类要在抹平层语义化(UNAVAILABLE → false,用户取消 → 异常上抛,别让业务拿原生错误码字典);② 能力探测在边缘做一次(kind()),不要让 Platform.OS === 'ios' 散落在业务代码里。

7.6 调试与测试

原理讲解

跨端模块的调试面分三层:原生断点(逻辑正确性)、模拟器/真机差异(能力与权限)、单测(契约回归)。三层各管一段,缺了任何一层都会出现「JS 单测全绿、真机必崩」的尴尬。

原生断点:Android Studio 用「Attach Debugger to Android Process」选中 App 进程,Kotlin 断点直接打在模块方法里;Xcode 用 Product → Attach to Process。原生断点不受 JS 调试器影响,即使 App 跑在 Hermes release 字节码上也照常工作。定位「JS 调了但原生没收到」时,先在生成物调用的入口断点(如 Kotlin 的方法首行、ObjC 的方法首行)确认调用是否到达,再向上追溯注册名是否一致——99% 的「没反应」是注册名或黄页登记不一致,而不是调用丢失。

模拟器与真机的真实差异(M7 两个模块全部踩线):

能力 模拟器 真机
生物识别 AVD 可用控制台模拟指纹(fingerprint touch);FaceID 仅部分机型近似 完整;权限文案缺失的崩溃只在真机出现
相机 虚拟场景,AVCaptureSession/CameraX 能跑但成像无意义 真实,需覆盖权限拒绝分支
扫码识别 模拟器相机对真实二维码对焦差,基本不可用 可用,需实测远近距离与弱光
手电筒 无 有(torch 指令只能在真机验证)

结论:权限分支、错误码路径、平台差异在模拟器验证;成像质量、识别率、安全相关(生物识别)必须真机验收。CI 上可以用模拟器跑「权限拒绝 → FN_PERMISSION」这类确定性分支。

JS 侧单测:mock 掉 spec 代理,测抹平层的逻辑(错误分类、能力收敛、事件过滤)。spec 代理来自 TurboModuleRegistry,测试里直接 mock 模块文件:

// packages/native-kit/js/__tests__/biometrics.test.ts
import { Biometrics } from '../index';

jest.mock('../NativeBiometrics', () => ({
  __esModule: true,
  default: {
    biometricTypeName: jest.fn().mockReturnValue('strong'),
    isAvailable: jest.fn().mockResolvedValue(true),
    authenticate: jest.fn(),
  },
}));

import NativeBiometrics from '../NativeBiometrics';

describe('Biometrics 抹平层', () => {
  it('不可用收敛为 false,不向业务抛错', async () => {
    (NativeBiometrics.authenticate as jest.Mock).mockRejectedValue(
      Object.assign(new Error('不可用'), { code: 'FN_BIO_UNAVAILABLE' }),
    );
    await expect(Biometrics.authenticate('测试')).resolves.toBe(false);
  });

  it('用户取消必须上抛,不能被吞成 false', async () => {
    (NativeBiometrics.authenticate as jest.Mock).mockRejectedValue(
      Object.assign(new Error('取消'), { code: 'FN_BIO_USER_CANCEL' }),
    );
    await expect(Biometrics.authenticate('测试')).rejects.toThrow('取消');
  });

  it('锁定错误码原样上抛(业务要做引导)', async () => {
    (NativeBiometrics.authenticate as jest.Mock).mockRejectedValue(
      Object.assign(new Error('locked'), { code: 'FN_BIO_LOCKOUT' }),
    );
    await expect(Biometrics.authenticate('测试')).rejects.toMatchObject({ code: 'FN_BIO_LOCKOUT' });
  });
});

这份测试的价值在第二条:抹平层把「取消」错误上抛是刻意设计(业务要区分「没验成」与「用户反悔」),回归测试把它钉死,防止后人「好心」把它吞掉。

原生自测页:一个仅 dev 构建可达的调试屏,把模块每个方法变成按钮直调,任何真机/模拟器到手 30 秒内完成冒烟:

// src/dev/NativeKitDebugScreen.tsx(dev 构建专用,发布裁剪见第 19 章)
import React, { useState } from 'react';
import { Button, ScrollView, Text } from 'react-native';
import { Biometrics, Scanner } from '@fieldnote/native-kit';

export function NativeKitDebugScreen() {
  const [log, setLog] = useState<string[]>([]);
  const push = (line: string) =>
    setLog(l => [`[${new Date().toISOString().slice(11, 23)}] ${line}`, ...l].slice(0, 60));

  return (
    <ScrollView>
      <Button title="biometrics.kind" onPress={() => push(`kind=${Biometrics.kind()}`)} />
      <Button
        title="authenticate"
        onPress={() =>
          Biometrics.authenticate('自测')
            .then(ok => push(`auth=${ok}`))
            .catch(e => push(`auth ERR code=${e?.code} msg=${e?.message}`))
        }
      />
      <Button
        title="camera permission"
        onPress={() =>
          Scanner.ensurePermission()
            .then(ok => push(`cam=${ok}`))
            .catch(e => push(`cam ERR code=${e?.code}`))
        }
      />
      <Button
        title="startScan → 3s 后 stop"
        onPress={async () => {
          await Scanner.start();
          push('scan started');
          setTimeout(() => {
            Scanner.stop();
            push('scan stopped');
          }, 3000);
        }}
      />
      {log.map((l, i) => (
        <Text key={i}>{l}</Text>
      ))}
    </ScrollView>
  );
}

自测页与 useScanner(FieldNote 一节)共用抹平层,因此它同时是抹平层的集成测试——抹平层有 bug,自测页必现;自测页过了,业务层的嫌疑就集中在事件消费逻辑。

典型业务场景:FieldNote 实战(M7:生物识别解锁 + 资产扫码)

M7 交付两个模块:biometrics(应用冷启动/切后台后的身份验证)与 scanner(无取景 UI 的补扫模式:直接解码并上报,命中即停)。biometrics 的双端实现已在 7.2/7.3 给全,本节补齐 scanner 双端、事件上行、注册与 JS 消费,并把两个模块接进 App。

场景约束(源自产品与第 6 章结论):扫码引擎可能每秒识别多次同一码,必须 300ms 合批 + 单调 seq;监听器生命周期严格绑定页面;权限双端都要在原生入口自检;错误码双端同表。

场景一:包结构与 spec(契约先行)

包结构见 7.5。两个 spec 文件:

// packages/native-kit/js/NativeScanner.ts
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  readonly requestCameraPermission(): Promise<boolean>;
  readonly startScan(): Promise<void>;   // 权限不足时 reject FN_PERMISSION
  readonly stopScan(): void;             // 幂等:未启动时调用无副作用
  readonly isScanning(): boolean;
}

export default TurboModuleRegistry.getEnforcing<Spec>('FieldNoteScanner');

/** 事件 payload 契约:双端字段名/类型/单位必须逐字段对齐(第 6 章纪律) */
export interface ScanResultEventData {
  value: string;  // 码值
  seq: number;    // 单调递增,消费端乱序过滤
  ts: number;     // 毫秒时间戳(epoch ms)
}

export const SCAN_EVENT = 'fieldnote:scanResult';

错误码契约(README 与 spec 注释同步维护):

错误码 Android 来源 iOS 来源 JS 语义
FN_BIO_UNAVAILABLE ERROR_NO_BIOMETRICS / 能力查询失败 canEvaluatePolicy == false 不支持或未录入,可引导降级
FN_BIO_LOCKOUT ERROR_LOCKOUT(_PERMANENT) LAError.biometryLockout 多次失败锁定,引导输设备密码
FN_BIO_USER_CANCEL ERROR_USER_CANCELED userCancel/systemCancel/appCancel 用户主动取消,静默处理
FN_BIO_FAILED ERROR_NEGATIVE_BUTTON 等 authenticationFailed 单次认证失败,可重试
FN_NO_ACTIVITY currentActivity == null — 宿主不可用,重试
FN_PERMISSION 相机权限被拒 authorizationStatus 非 authorized 引导去设置页
FN_CAMERA_FAILED CameraX 异常 会话配置异常 提示重试

场景二:Android ScannerModule(Kotlin,完整)

// packages/native-kit/android/src/main/java/com/fieldnote/nativekit/ScannerModule.kt
package com.fieldnote.nativekit

import android.Manifest
import android.content.pm.PackageManager
import android.os.SystemClock
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.WritableMap
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong

class ScannerModule(reactContext: ReactApplicationContext) :
    NativeScannerSpec(reactContext) {

  private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
  private val scanning = AtomicBoolean(false)
  private val seq = AtomicLong(0)
  @Volatile private var lastEmitAtMs = 0L
  private var cameraProvider: ProcessCameraProvider? = null

  // 解码线程:专属、守护线程;绝不占用主线程与 JS 线程(第 6 章纪律)
  private val analysisExecutor = Executors.newSingleThreadExecutor { r ->
    Thread(r, "fieldnote-qr").apply { isDaemon = true }
  }
  private val reader = MultiFormatReader().apply {
    setHints(mapOf(DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE)))
  }

  override fun getName() = NAME

  override fun requestCameraPermission(promise: Promise) {
    val activity = currentActivity
      ?: return promise.reject(E_NO_ACTIVITY, "无宿主 Activity")
    if (ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.CAMERA)
        == PackageManager.PERMISSION_GRANTED) {
      promise.resolve(true); return
    }
    val registry = (activity as? ComponentActivity)?.activityResultRegistry
      ?: return promise.reject(E_NO_ACTIVITY, "宿主不是 ComponentActivity")
    registry.register(
      "fieldnote.camera.permission",
      ActivityResultContracts.RequestPermission(),
    ) { granted -> promise.resolve(granted) }
      .launch(Manifest.permission.CAMERA)
  }

  override fun startScan(promise: Promise) {
    val activity = currentActivity
      ?: return promise.reject(E_NO_ACTIVITY, "无宿主 Activity")
    // 原生入口自检权限:JS 层的状态可能过期或被绕过(纵深防御)
    if (ContextCompat.checkSelfPermission(reactApplicationContext, Manifest.permission.CAMERA)
        != PackageManager.PERMISSION_GRANTED) {
      promise.reject(E_PERMISSION, "相机权限未授予"); return
    }
    if (!scanning.compareAndSet(false, true)) { promise.resolve(null); return }  // 幂等 start
    scope.launch(Dispatchers.Main) {   // CameraX 绑定要求主线程
      try {
        val provider = suspendCancellableCoroutine { cont ->
          val future = ProcessCameraProvider.getInstance(activity)
          future.addListener({ cont.resume(future.get()) }, ContextCompat.getMainExecutor(activity))
        }
        cameraProvider = provider
        provider.unbindAll()
        val analysis = ImageAnalysis.Builder()
          .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) // 丢帧背压(第 6 章)
          .build()
        analysis.setAnalyzer(analysisExecutor, ::analyze)
        provider.bindToLifecycle(activity, CameraSelector.DEFAULT_BACK_CAMERA, analysis)
        promise.resolve(null)
      } catch (t: Throwable) {
        scanning.set(false)
        promise.reject(E_CAMERA_FAILED, t.message, t)
      }
    }
  }

  private fun analyze(image: ImageProxy) {
    try {
      if (!scanning.get()) return
      val plane = image.planes[0]
      val bytes = ByteArray(plane.buffer.remaining()).also { plane.buffer.get(it) }
      val source = PlanarYUVLuminanceSource(
        bytes, image.width, image.height, 0, 0, image.width, image.height, false)
      val text = try {
        reader.decodeWithState(BinaryBitmap(HybridBinarizer(source))).text
      } catch (_: NotFoundException) {
        null
      } finally {
        reader.reset()
      }
      if (text != null) maybeEmit(text)
    } finally {
      image.close()  // ⚠️ ImageProxy 必须归还,否则分析流水线卡死
    }
  }

  // 300ms 合批 + 单调 seq:与 iOS 严格同构(第 6 章合批模式)
  private fun maybeEmit(value: String) {
    val now = SystemClock.elapsedRealtime()
    synchronized(this) {
      if (now - lastEmitAtMs < 300) return
      lastEmitAtMs = now
    }
    val payload: WritableMap = Arguments.createMap().apply {
      putString("value", value)
      putInt("seq", seq.incrementAndGet().toInt())
      putDouble("ts", System.currentTimeMillis().toDouble())
    }
    reactApplicationContext.runOnJSQueueThread {
      reactApplicationContext
        .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
        .emit(EVENT_SCAN_RESULT, payload)
    }
  }

  override fun stopScan() {
    scanning.set(false)
    scope.launch(Dispatchers.Main) { cameraProvider?.unbindAll() }
  }

  override fun isScanning(): Boolean = scanning.get()

  override fun invalidate() {
    scanning.set(false)
    analysisExecutor.shutdown()
    scope.cancel()
    super.invalidate()
  }

  companion object {
    const val NAME = "FieldNoteScanner"
    const val EVENT_SCAN_RESULT = "fieldnote:scanResult"
    const val E_NO_ACTIVITY = "FN_NO_ACTIVITY"
    const val E_PERMISSION = "FN_PERMISSION"
    const val E_CAMERA_FAILED = "FN_CAMERA_FAILED"
  }
}

逐段讲解:requestCameraPermission 与 startScan 双入口自检权限(纵深防御);compareAndSet 保证幂等 start/stop(第 6 章的会话语义);解码在专属线程、合批节流、seq 单调——三个第 6 章的套路在此合体;invalidate 关停执行器与协程,是 JS 上下文销毁的最后防线。

场景三:iOS Scanner(ObjC++ 壳 + Swift 实现)

// packages/native-kit/ios/ScannerImpl.swift
import AVFoundation
import React

@objc(FieldNoteScannerImpl)
public final class ScannerImpl: NSObject, AVCaptureMetadataOutputObjectsDelegate {
  private let session = AVCaptureSession()
  private let queue = DispatchQueue(label: "com.fieldnote.scanner")   // 模块专属队列(= methodQueue 对应物)
  private let metadataOutput = AVCaptureMetadataOutput()
  private var configured = false
  private var running = false
  private var seq: Int64 = 0
  private var lastEmitAt: TimeInterval = 0

  @objc public var onResult: ((String, Int64, Double) -> Void)?

  @objc public func requestCameraPermission(resolve: @escaping RCTPromiseResolveBlock,
                                            reject: RCTPromiseRejectBlock) {
    switch AVCaptureDevice.authorizationStatus(for: .video) {
    case .authorized:      resolve(true)
    case .notDetermined:   AVCaptureDevice.requestAccess(for: .video) { resolve($0) }
    default:               resolve(false)
    }
  }

  @objc public func start(resolve: @escaping RCTPromiseResolveBlock,
                          reject: @escaping RCTPromiseRejectBlock) {
    // 原生入口自检权限(纵深防御,与 Android 一致)
    guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized else {
      return reject("FN_PERMISSION", "相机权限未授予", nil)
    }
    queue.async {
      do {
        try self.configureIfNeeded()
        if !self.session.isRunning { self.session.startRunning() }
        self.running = true
        resolve(true)
      } catch {
        reject("FN_CAMERA_FAILED", error.localizedDescription, error)
      }
    }
  }

  private func configureIfNeeded() throws {
    guard !configured else { return }
    guard let device = AVCaptureDevice.default(for: .video),
          let input = try? AVCaptureDeviceInput(device: device),
          session.canAddInput(input) else {
      throw NSError(domain: "FieldNoteScanner", code: 1,
                    userInfo: [NSLocalizedDescriptionKey: "无法打开相机"])
    }
    session.addInput(input)
    guard session.canAddOutput(metadataOutput) else {
      throw NSError(domain: "FieldNoteScanner", code: 2,
                    userInfo: [NSLocalizedDescriptionKey: "无法添加解码输出"])
    }
    session.addOutput(metadataOutput)
    metadataOutput.setMetadataObjectsDelegate(self, queue: queue)
    metadataOutput.metadataObjectTypes = [.qr]
    configured = true
  }

  @objc public func stop() {
    queue.async {
      if self.session.isRunning { self.session.stopRunning() }
      self.running = false
    }
  }

  @objc public func isScanning() -> Bool { running }

  // 解码回调在 queue 上:300ms 合批 + seq,与 Android 同构
  public func metadataOutput(_ output: AVCaptureMetadataOutput,
                             didOutput metadataObjects: [AVMetadataObject],
                             from connection: AVCaptureConnection) {
    guard running,
          let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
          obj.type == .qr, let value = obj.stringValue else { return }
    let now = Date().timeIntervalSince1970
    if now - lastEmitAt < 0.3 { return }
    lastEmitAt = now
    seq += 1
    onResult?(value, seq, now * 1000)
  }
}
// packages/native-kit/ios/RCTNativeScanner.mm
#import "RCTNativeScanner.h"
#import "FieldNoteEventEmitter.h"
#import "ScannerImpl-Swift.h"

using namespace facebook::react;

@implementation RCTNativeScanner {
  FieldNoteScannerImpl *_impl;
}

RCT_EXPORT_MODULE(FieldNoteScanner)

- (instancetype)init {
  if (self = [super init]) {
    _impl = [FieldNoteScannerImpl new];
    _impl.onResult = ^(NSString *value, NSInteger seq, double ts) {
      dispatch_async(dispatch_get_main_queue(), ^{
        [FieldNoteEventEmitter.shared sendScanResult:value seq:seq ts:ts];
      });
    };
  }
  return self;
}

- (dispatch_queue_t)methodQueue {
  return dispatch_queue_create("com.fieldnote.nativekit.scanner", DISPATCH_QUEUE_SERIAL);
}

- (void)requestCameraPermission:(RCTPromiseResolveBlock)resolve
                         reject:(RCTPromiseRejectBlock)reject {
  [_impl requestCameraPermissionWithResolve:resolve reject:reject];
}

- (void)startScan:(RCTPromiseResolveBlock)resolve
           reject:(RCTPromiseRejectBlock)reject {
  [_impl startWithResolve:resolve reject:reject];
}

- (void)stopScan {
  [_impl stop];
}

- (BOOL)isScanning {
  return [_impl isScanning];
}

- (std::shared_ptr<TurboModule>)getTurboModule:(const ObjCTurboModule::InitParams &)params {
  return std::make_shared<NativeScannerSpecJSI>(params);
}
@end

(RCTNativeScanner.h 与 RCTNativeBiometrics.h 同构:继承 NSObject、声明 NativeScannerSpec 协议,略。)Android 的 gradle 依赖需补:androidx.camera:camera-*、com.google.zxing:core;iOS 无第三方依赖(AVFoundation 系统能力)。相机权限进主工程或包内 Info.plist(NSCameraUsageDescription,7.3 表)。

场景四:JS 侧统一消费——useScanner 与解锁门

// packages/native-kit/js/useScanner.ts
import { useEffect, useRef, useState } from 'react';
import { NativeEventEmitter, NativeModule } from 'react-native';
import NativeScanner, { SCAN_EVENT } from './NativeScanner';
import type { ScanResultEventData } from './NativeScanner';

// 类型桥接:TurboModule 代理与旧 NativeModule 类型体系尚未完全统一,
// cast 集中收敛在抹平层这一处,不允许扩散
interface ScanEvents { 'fieldnote:scanResult': ScanResultEventData }
const emitter = new NativeEventEmitter<ScanEvents>(NativeScanner as unknown as NativeModule);

export interface UseScannerOptions {
  active: boolean;              // 由页面状态驱动(进入补扫模式 = true)
  onResult: (value: string) => void;
  autoStopOnFirst?: boolean;    // 补扫语义:命中即停
}

export function useScanner({ active, onResult, autoStopOnFirst = true }: UseScannerOptions) {
  const [permission, setPermission] = useState<'unknown' | 'granted' | 'denied'>('unknown');
  const subRef = useRef<{ remove(): void } | null>(null);
  const seqRef = useRef(0);
  const onResultRef = useRef(onResult);
  onResultRef.current = onResult;  // 监听器只挂一次,回调始终最新(避免 stale closure)

  useEffect(() => {
    if (!active) return;
    let cancelled = false;
    (async () => {
      const granted = await NativeScanner.requestCameraPermission().catch(() => false);
      if (cancelled) return;
      setPermission(granted ? 'granted' : 'denied');
      if (!granted) return;
      subRef.current = emitter.addListener(SCAN_EVENT, e => {
        if (e.seq <= seqRef.current) return;      // 单调过滤:乱序事件直接丢弃(第 6 章)
        seqRef.current = e.seq;
        if (autoStopOnFirst) NativeScanner.stopScan();  // 先停源再回调,防回调里再触发
        onResultRef.current(e.value);
      });
      await NativeScanner.startScan();
      if (cancelled) NativeScanner.stopScan();    // 竞态补救:await 期间已被卸载
    })();
    return () => {                                // 卸载清理:先摘监听,再停原生源(顺序不能反)
      cancelled = true;
      subRef.current?.remove();
      subRef.current = null;
      NativeScanner.stopScan();
    };
  }, [active, autoStopOnFirst]);

  return { permission };
}

业务接线(任务详情页顶部「补扫」入口 + App 锁):

// features/inspection/QuickScanPanel.tsx
import React, { useState } from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { useScanner } from '@fieldnote/native-kit';

export function QuickScanPanel({ onAssetScanned }: { onAssetScanned: (code: string) => void }) {
  const [scanning, setScanning] = useState(false);
  const { permission } = useScanner({
    active: scanning,
    autoStopOnFirst: true,
    onResult: code => {
      setScanning(false);
      onAssetScanned(code);   // 资产码交给详情页逻辑(离线查询本地 SQLite,见第 14 章)
    },
  });

  if (permission === 'denied') {
    return <Text>相机权限被拒,请到系统设置开启</Text>;
  }
  return (
    <TouchableOpacity onPress={() => setScanning(true)}>
      <Text>{scanning ? '正在识别…' : '补扫资产码'}</Text>
    </TouchableOpacity>
  );
}
// app/AppLockGate.tsx —— 生物识别解锁门(M7 验收点之一)
import React, { useCallback, useState } from 'react';
import { Button, Text, View } from 'react-native';
import { Biometrics } from '@fieldnote/native-kit';

export function AppLockGate({ children }: { children: React.ReactNode }) {
  const [locked, setLocked] = useState(Biometrics.kind() !== 'none');
  const unlock = useCallback(async () => {
    try {
      if (await Biometrics.authenticate('解锁巡检工作台')) setLocked(false);
    } catch (e: any) {
      if (e?.code === 'FN_BIO_LOCKOUT') {
        // 锁定:引导走设备凭据/密码回退(策略见第 17 章安全设计)
      }
      // FN_BIO_USER_CANCEL:用户反悔,静默
    }
  }, []);

  if (!locked) return <>{children}</>;
  return (
    <View>
      <Text>巡检数据受保护,请验证身份</Text>
      <Button title="使用生物识别解锁" onPress={unlock} />
    </View>
  );
}

M7 验收标准:① 冷启动与切后台回前台,生物识别正确放行/拦截;② FN_BIO_LOCKOUT 后 UI 给出锁定引导;③ 补扫模式 300ms 内同码不重复上报,退出页面后 logcat/Instruments 无相机与监听器残留;④ 权限被拒路径双端给出可引导提示。全部指标可在 7.6 的自测页上预演。

常见陷阱

陷阱 典型症状 根因 正确做法
spec 与原生实现签名不一致 运行时方法找不到、undefined is not a function,或参数错位 没继承生成物而是手写 @ReactMethod 旧类;或注册名与 getEnforcing 参数不一致 永远继承 codegen 生成物让编译器对齐;构建后对照生成产物核对注册名
在原生主线程做 IO/网络/解码 点击 800ms 无响应、ANR、列表滚动冻结 主线程与用户输入、绘制同一队列(第 6 章) 方法体先把重活 post 到模块专属执行器(Default/HandlerThread/methodQueue),主线程只留 UI
事件名无前缀 两个模块互相收到对方事件,偶发「幽灵数据」 全局事件命名空间扁平共享 统一 域:模块:事件 命名 + supportedEvents 白名单 + payload 带 seq
权限只在 JS 申请,原生直接用 偶发 SecurityException / iOS 静默无流 JS 内存中的权限状态会过期;时序竞态 原生入口自检权限(纵深防御),JS 申请只是体验层
模块单例持有 Activity/View 引用 页面退出后内存不降;旋转后操作崩溃「Activity 已销毁」 模块生命周期=App,Activity 生命周期=页面,前者持有后者必泄漏 只经 currentActivity 现取现用;invalidate/onHostDestroy 里置空并释放资源
iOS 忘写 Info.plist 用途文案 第一次访问相机/FaceID 直接崩溃(非模拟器常见) TCC 在系统层强校验用途文案 plist Key 清单化检查(7.3 表);真机验收必过权限路径
Promise 有的分支没 resolve/reject 调用悬挂,loading 永转;Callback 版本则闭包泄漏 early return 漏 reject;把「可重试事件」误当终态(如 onAuthenticationFailed) 每条路径画终态表;用错误码规范逐分支核对(M7 错误码表)
spec 文件命名不合规 构建成功但生成物缺失,原生侧报「找不到符号」 codegen 按目录+Native 前缀匹配文件,不匹配则静默跳过 spec 放 jsSrcsDir 且以 Native 开头;CI 里断言生成物存在

最佳实践

  1. 先定 spec 再写原生:spec 是唯一契约,双端实现、JS 抹平层、README、单测全部从它派生。
  2. 永远继承 codegen 生成物实现,不手写注解;构建后全局搜一次生成类名,确认产物存在(CI 也这么做)。
  3. 每个模块声明专属执行线程(Kotlin Dispatcher / iOS methodQueue),主线程与 JS 线程只做必须的事——第 6 章线程纪律的工程化落点。
  4. Promise 分支先画终态表再写代码:成功、每类错误码、以及「不算终态的可重试事件」三类逐一覆盖。
  5. 错误码用 域_模块_原因 规范、双端同表维护;错误码是公共 API,改动要升 minor。
  6. 事件命名 域:模块:事件,payload 必带单调 seq;发送端切 JS 队列(Android)/主队列(iOS),订阅端按第 6 章纪律成对清理。
  7. 权限三件套齐全:JS 体验层申请、原生入口自检、plist/Manifest 声明,缺一不可。
  8. 模块绝不持有 Activity/View 引用;currentActivity 现取现用,invalidate 是清理的最后防线,onHostDestroy 是第二道。
  9. 双端差异在原生层或抹平层收敛,业务代码禁止出现平台分支;能力探测只做一次。
  10. 存量模块按 7.4 信号表决策迁移:高频/大 payload/类型事故频发优先,稳定低频可暂缓。
  11. 包结构固定 js/android/ios 三目录 + codegenConfig,业务只 import 抹平层入口;monorepo 中依赖显式声明 workspace:*。
  12. 调试三件套常备:原生断点验到达、自测页验行为、mock spec 单测钉死抹平层语义;权限与错误码路径在 CI 模拟器跑,成像与识别率真机验收。

本章小结

本章把第 6 章的通道选型落成了可交付的工程产物:spec 即契约,codegen 在构建期生成双端接口,类型错误从运行期前移到编译期;Android 以「继承 Spec 抽象类 + BaseReactPackage 黄页注册」实现,iOS 以「.mm 壳接 JSI + Swift 实现」分工,methodQueue 与专属 Dispatcher 是同一条线程纪律的双端表达。权限、生命周期、事件发送三件套都有固定姿势:Activity 结果 API + 入口自检、Lifecycle/ActivityEventListener 双监听器、域:模块:事件 前缀 + seq。interop 让存量 NativeModule 平滑过渡,迁移按信号表决策、按对照表执行。工程化层面,js/android/ios 三目录包 + autolinking 双端发现机制 + 抹平层能力探测,让模块可复用、可发布、可测试。FieldNote 的生物识别与扫码模块按此交付:错误码双端同表、扫码 300ms 合批 + 单调 seq、useScanner 先摘监听再停源,M7 验收全部落地。一句话总结本章与第 6 章的关系:第 6 章教你选对通道,本章教你把通道封进类型安全的盒子里。

一句话记忆点:spec 是契约不是注释——继承生成物、登记黄页、双端同表错误码,剩下的编译器会替你把关。

动手练习

  1. 基础巩固:把 7.2/7.3 的 biometrics 模块接入你的练习工程并跑通自测页,然后故意做三个破坏实验并记录失败形态:(a)把 authenticate 的参数顺序在 spec 中对调(加一个参数);(b)把 ReactModuleInfo.isTurboModule 改为 false;(c)把 iOS RCT_EXPORT_MODULE 的注册名改成与 spec 不一致。分别说明错误暴露在哪一层(编译期/启动期/运行时),并解释为什么(a)应该在编译期就被拦截。
  2. FieldNote 实战 A:为 biometrics 模块增加「降级链」能力:authenticateOrFallback()——生物识别锁定(FN_BIO_LOCKOUT)时自动回退设备凭据(Android 的 DEVICE_CREDENTIAL / iOS 的 .deviceOwnerAuthentication)。要求:spec 增加方法与错误码、双端实现、抹平层暴露统一语义、单测覆盖「锁定→回退成功」与「回退也被拒」两条路径。
  3. FieldNote 实战 B:给 scanner 增加多码格式支持(QR + Code128):spec 增加 formats: string[] 参数(如 ['qr', 'code128']),双端解码器按参数配置。同时实现「连续扫描模式」:autoStopOnFirst = false 时用 7.2 的 300ms 合批持续上报,JS 侧按 value 去重(同码 3 秒冷却)。思考:去重放原生还是放 JS?给出你的成本对比。
  4. 思考题:TurboModule 把「模块查找」从旧架构的启动期全量注册改成了按需创建。请估算一个 40 模块 App 在新架构下启动期少做了哪些事:旧架构每个模块启动时要经历「实例化原生对象 → 构造函数副作用(线程/注册表/常量)→ 汇总模块配置 JSON → JS 侧 NativeModules 全量可访问」;新架构 TurboModuleRegistry.getEnforcing 是懒代理,首次属性访问才绑定 HostFunction。逐项列出被推迟或消除的成本(原生对象构造、常量序列化、配置 JSON 体积、黄页查找),并结合第 5 章的启动指标说明:为什么 40 个模块里「只有 5 个会在首屏前被调用」时,这个差异会直接体现在 TTI 上?提示:注意 interop 模块不在懒化范围内。

导航

上一章 第 6 章 双端通信机制全解 | 下一章 第 8 章 原生组件封装(Fabric)

📄 第 8 章 原生组件封装(Fabric) 08-fabric-components.md

本章导读:第 7 章封装的是「服务」(无视图的 TurboModule),本章封装「视图」:让一段真正的原生 UI 成为 React 组件树的一员,参与 Fabric 渲染管线的调度。四要素(Props/事件/Commands/State)的选型与成本是本章主线,FieldNote 的原生相机取景器(M8)将完整落地双端。手势与动画的原生协作在第 9、10 章展开,本章只打通渲染链路与接口设计。

本章学习目标

学完本章,你应该能够:

  1. 画出 JS 元素 → ShadowNode → ComponentDescriptor → 原生 View 的 Fabric 渲染链路,并说清 Fabric 组件与 TurboModule 的本质区别。
  2. 编写包含 Props/事件/Commands/State 四要素的组件 spec,并用 codegen 生成物核对双端接口。
  3. 用 Kotlin 实现 Android 侧:ViewManager 互操作路径、Props setter、事件分发(bubbling 与 direct)、receiveCommand、State 写回与视图回收重置。
  4. 用 ObjC++ + Swift 实现 iOS 侧:RCTComponentViewProtocol 的 updateProps/updateState/handleCommand、事件 emitter 的持有与时机、ComponentView 注册。
  5. 为任意跨端交互在四要素中做成本选型,并解释「为什么高频视觉数据不走 Props」。
  6. 应用复杂原生视图(地图/相机)的封装模式:View 复用与状态重置、手势交还原生、生命周期与屏幕挂载、不透明视图优化。
  7. 评估三方原生组件库的新架构兼容性,并把旧架构 requireNativeComponent 组件按对照表迁移。
  8. 排查「事件对象结构与 spec 不一致」「指令名双端不一致」两类典型故障。

核心知识点

8.1 Fabric 组件全链路:从 JSX 到原生 View

原理讲解

第 1 章讲过 Fabric 的两级树:React 在 JS 线程产出元素树,C++ 层由 ShadowNode 组成影子树并跑 Yoga 布局,最后 Mounting 阶段把 diff 变成原生视图操作。TurboModule 是这个管线之外的「服务」——你调用它,它执行;Fabric 组件则是管线之内的「节点」——它被 commit 调度、被 Yoga 测量、被 Mount 挂载。这个位置差异决定了一切行为差异:组件不能「自己决定什么时候被调用」(由渲染管线驱动),却拥有 TurboModule 没有的能力(参与布局、被复用回收、随树 diff)。

渲染链路全图:

flowchart TB
    subgraph JS["JS 线程(Hermes)"]
        A["JSX:FieldNoteCameraView<br/>props = { enableQr: true, ... }"] --> B["React 渲染 → commit"]
    end
    B -->|"元素变更进入 C++"| C["ComponentDescriptor<br/>创建/克隆 ShadowNode"]
    C --> D["Props struct<br/>(codegen 生成的不可变对象)"]
    C --> E["Yoga 布局<br/>(State 可参与布局输入)"]
    D --> F["Commit 完成 → Mounting 阶段"]
    F --> G1["Android:ViewManager 互操作挂载<br/>create/update/事件"]
    F --> G2["iOS:RCTComponentViewProtocol<br/>updateProps / updateState"]
    subgraph NAT["原生视图层(主线程)"]
        H["Android View(CameraX)"]
        I["iOS UIView(AVFoundation)"]
    end
    G1 --> H
    G2 --> I
    H -->|"事件:批量上行 → JS 处理器"| A
    I -->|"事件:批量上行 → JS 处理器"| A
    J["JS:Commands(指令)"] -->|"直达 Mounting,作用于视图"| G1
    J --> G2

与 TurboModule 的本质区别逐项对照:

维度 TurboModule(第 7 章) Fabric 组件(本章)
本质 无视图的服务,方法调用 渲染树节点,参与管线
生命周期 模块级单例,App 存续期 视图实例级,可被回收复用
驱动方 JS 主动调用 渲染管线调度(commit→mount),事件上行
通信通道 方法/Promise/同步返回/事件 Props 下行、事件上行、Commands、State
codegen type modules components(混合包用 all)
状态归属 模块内存自管 ShadowNode 状态参与 C++ 树与布局
典型故障 Promise 悬挂、线程阻塞 回收状态残留、commit 风暴

组件 spec(FieldNote 相机取景器,M8 契约):

// packages/native-kit/js/NativeCameraView.ts
import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent';
import codegenNativeCommands from 'react-native/Libraries/Utilities/codegenNativeCommands';
import type {
  ColorValue,
  DirectEventHandler,
  Double,
  Int32,
  WithDefault,
} from 'react-native/Libraries/Types/CodegenTypes';
import type { HostComponent, ViewProps } from 'react-native';
import type { ElementRef } from 'react';

export interface ScanResultEventData {
  readonly value: string;
  readonly seq: Int32;
  readonly ts: Double;
}

export interface PhotoTakenEventData {
  readonly localPath: string;
  readonly width: Double;
  readonly height: Double;
}

export interface NativeCameraViewProps extends ViewProps {
  /** 前/后置,声明式配置 → 用 Props */
  cameraFace?: WithDefault<'front' | 'back', 'back'>;
  /** 是否开启取景器内扫码,声明式开关 → Props */
  enableQr?: WithDefault<boolean, false>;
  /** 扫码框高亮颜色(ColorValue 在 Android 生成 ARGB int,iOS 生成 UIColor*) */
  frameColor?: ColorValue;
  /** 扫码命中:原生→JS 的一次性通知 → Direct 事件 */
  onScanResult?: DirectEventHandler<ScanResultEventData>;
  /** 拍照完成:携带落盘路径 → Direct 事件 */
  onPhotoTaken?: DirectEventHandler<PhotoTakenEventData>;
}

/** State:原生测量写回、参与布局;JS 侧不直接读它(见 8.4) */
export interface CameraViewState {
  readonly previewWidth: Int32;
  readonly previewHeight: Int32;
}

type NativeType = HostComponent<NativeCameraViewProps>;

/** Commands:JS→原生的命令式动作(拍照/开关手电) */
export interface CameraViewCommands {
  takePhoto(viewRef: ElementRef<NativeType>, saveTo: string): void;
  toggleTorch(viewRef: ElementRef<NativeType>, on: boolean): void;
}

export const Commands: CameraViewCommands = codegenNativeCommands<CameraViewCommands>({
  commands: ['takePhoto', 'toggleTorch'],
});

// 第二泛型传 State 以便 codegen 生成 State struct
//(不同版本读取 State 的方式可能为第二泛型或同名导出,以生成产物为准)
export default codegenNativeComponent<NativeCameraViewProps, CameraViewState>(
  'FieldNoteCameraView',
) as NativeType;

逐段讲解:

  • Props 全部声明式:cameraFace/enableQr/frameColor 是「状态型配置」——变化频率低、值本身就是要持续生效的状态,符合 Props 的语义(8.4 详述)。WithDefault 让 codegen 生成带默认值的可空 setter,缺省时原生拿默认值而不是 null。
  • 事件是 Direct:扫码命中、拍照完成是「该视图自己的通知」,父级不该替它监听(bubbling 的适用场景见 8.2 的对比表)。
  • Commands 是命令式意图:拍照是一个「动作」而非「状态」——它没有可以 diff 的值,也不该因为重渲染被重复触发,所以用指令而不是 Props。
  • 事件名约定:spec 写 onScanResult,Android 生成侧事件常量为 top 前缀形态(topScanResult),iOS dispatch 用与 spec 一致的 onScanResult,C++ 层归一。纪律:构建后打开生成产物核对事件名常量,不要靠记忆——这正是本章陷阱表「双端不一致」的预防针。

8.2 Android 实现(Kotlin,ViewManager 路径)

原理讲解

Android 侧新架构下,官方主流路径仍是 ViewManager:codegen 生成 XxxManagerSpec 抽象类(setter、事件常量、指令分发声明都在里面),你的 Manager 继承它,Fabric 通过 ViewManager 互操作挂载视图。「纯 Fabric 路径」(自定义 ShadowNode + Descriptor,涉及 C++)只在需要自定义 Yoga 行为/特殊布局时才值得做,绝大多数相机、地图、图表类组件用互操作路径即可。iOS 侧没有这层缓冲,直接是 Fabric 原生的 ComponentView(8.3),这正是双端实现风格不对称的原因。

视图类(CameraX 封装,含扫码与拍照):

// packages/native-kit/android/src/main/java/com/fieldnote/nativekit/FieldNoteCameraView.kt
package com.fieldnote.nativekit

import android.content.Context
import android.graphics.BitmapFactory
import android.graphics.Color
import android.widget.FrameLayout
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import java.io.File
import java.util.concurrent.Executor

/**
 * 视图本体只做「视图的事」:布局、状态、把原生事件抛给回调。
 * 线程纪律:CameraX 绑定在主线程;解码/落盘在相机执行器;回调抛给 Manager 分发事件。
 */
class FieldNoteCameraView(context: Context) : FrameLayout(context) {

  /** Manager 注入:视图不直接依赖 React 类型,保持可复用与可测试 */
  var onScanResult: ((value: String, seq: Int, ts: Double) -> Unit)? = null
  var onPhotoTaken: ((path: String, w: Double, h: Double) -> Unit)? = null
  var onPreviewSize: ((w: Int, h: Int) -> Unit)? = null

  private var previewView: androidx.camera.view.PreviewView =
    androidx.camera.view.PreviewView(context)

  private val lifecycleOwner = ViewLifecycleOwner()
  private var cameraProvider: ProcessCameraProvider? = null
  private var imageCapture: ImageCapture? = null

  // —— Props 状态:由 Manager 的 setter 调用,本视图自行 diff ——
  private var faceFront = false
  private var qrEnabled = false
  private var frameColorValue: Int = Color.parseColor("#00E5FF")

  private val analysisExecutor = Executor { r -> Thread(r, "fieldnote-cam-qr").apply { isDaemon = true } }
  private val seq = java.util.concurrent.atomic.AtomicInteger(0)
  @Volatile private var lastEmitAtMs = 0L

  init {
    addView(previewView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT))
  }

  fun setFace(front: Boolean) {
    if (faceFront == front) return          // 自己再 diff 一次:重绑定成本高,值没变就别动
    faceFront = front
    rebind()
  }

  fun setQrEnabled(enabled: Boolean) {
    if (qrEnabled == enabled) return
    qrEnabled = enabled
    rebind()
  }

  fun setFrameColor(color: Int) {
    frameColorValue = color
    invalidate()  // 实际实现里由覆盖层绘制扫码框;此处仅示意状态生效
  }

  /** 挂载到窗口:attach 即 RESUMED(相机随视图挂载启动,与屏幕生命周期绑定) */
  override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    lifecycleOwner.attach()
    rebind()
  }

  override fun onDetachedFromWindow() {
    lifecycleOwner.detach()   // ⚠️ 相机必须在 detach 时释放,否则后台仍占用摄像头
    super.onDetachedFromWindow()
  }

  /** 视图回收复用前由 Manager 调用:清状态,防止「新挂载点看到旧画面/旧回调」 */
  fun resetForReuse() {
    cameraProvider?.unbindAll()
    cameraProvider = null
    imageCapture = null
    lastEmitAtMs = 0L
    faceFront = false
    qrEnabled = false
    frameColorValue = Color.parseColor("#00E5FF")
    onScanResult = null
    onPhotoTaken = null
    onPreviewSize = null
  }

  fun takePhoto(saveTo: String) {
    val capture = imageCapture ?: return
    val file = safeTargetFile(saveTo) ?: return   // 路径白名单校验,见下
    val options = ImageCapture.OutputFileOptions.Builder(file).build()
    capture.takePicture(options, analysisExecutor, object : ImageCapture.OnImageSavedCallback {
      override fun onImageSaved(output: ImageCapture.OutputFileResults) {
        // 只读尺寸(inJustDecodeBounds 不加载位图到内存)
        val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
        BitmapFactory.decodeFile(file.absolutePath, opts)
        onPhotoTaken?.invoke(file.absolutePath, opts.outWidth.toDouble(), opts.outHeight.toDouble())
      }
      override fun onError(exception: ImageCaptureException) {
        // 失败也是一次「终结」:可扩展 onPhotoTaken 的错误负载;此处从简
      }
    })
  }

  fun setTorch(on: Boolean) {
    cameraProvider?.let { provider ->
      provider.cameraControl?.enableTorch(on)
    }
  }

  private fun rebind() {
    val provider = cameraProvider ?: return
    provider.unbindAll()
    val preview = Preview.Builder().build().also {
      it.setSurfaceProvider(previewView.surfaceProvider)
    }
    val capture = ImageCapture.Builder()
      .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
      .build()
    imageCapture = capture
    val useCases = mutableListOf<androidx.camera.core.UseCase>(preview, capture)
    if (qrEnabled) {
      val analysis = ImageAnalysis.Builder()
        .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) // 丢帧背压
        .build()
      analysis.setAnalyzer(analysisExecutor, ::analyzeQr)
      useCases.add(analysis)
    }
    provider.bindToLifecycle(
      lifecycleOwner,
      if (faceFront) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA,
      *useCases.toTypedArray(),
    )
    // 预览尺寸写回 State(经 Manager 转交 StateWrapper)
    previewView.post {
      onPreviewSize?.invoke(previewView.width, previewView.height)
    }
  }

  private fun analyzeQr(image: androidx.camera.core.ImageProxy) {
    // 解码逻辑与第 7 章 ScannerModule.analyze 相同:zxing + 300ms 合批 + seq,
    // 命中后经 onScanResult 回调交 Manager 分发;此处省略重复实现
    image.close()
  }

  /** 出于安全:只允许写入 App 缓存/文件目录,拒绝路径穿越 */
  private fun safeTargetFile(saveTo: String): File? {
    val base = context.filesDir.canonicalFile
    val target = File(base, saveTo).canonicalFile
    return if (target.path.startsWith(base.path)) target else null
  }

  /** 把宿主视图生命周期桥接成 CameraX 需要的 LifecycleOwner(通行技巧) */
  private class ViewLifecycleOwner : LifecycleOwner {
    private val registry = LifecycleRegistry(this)
    override val lifecycle: Lifecycle get() = registry
    fun attach() { registry.currentState = Lifecycle.State.RESUMED }
    fun detach() { registry.currentState = Lifecycle.State.DESTROYED }
  }
}

(说明:takePhoto 用 inJustDecodeBounds 只读尺寸、不加载位图进内存;safeTargetFile 把落盘路径约束在 App 文件目录内,拒绝路径穿越。相机装配的其余细节按需实现,骨架——线程归属、生命周期锚点、回调出口——是完整可落地的。)

Manager(继承 codegen 生成物):

// packages/native-kit/android/src/main/java/com/fieldnote/nativekit/FieldNoteCameraViewManager.kt
package com.fieldnote.nativekit

import com.facebook.react.bridge.ReadableArray
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.Event
import com.facebook.react.uimanager.events.RCTModernEventEmitter
import com.facebook.react.viewmanagers.FieldNoteCameraViewManagerSpec  // codegen 生成

class FieldNoteCameraViewManager(reactContext: ReactApplicationContext) :
    FieldNoteCameraViewManagerSpec<FieldNoteCameraView>(reactContext) {

  override fun getName() = "FieldNoteCameraView"

  override fun createViewInstance(reactContext: ThemedReactContext): FieldNoteCameraView =
    FieldNoteCameraView(reactContext).also { view ->
      // 事件出口:视图只抛回调,Manager 负责包装成 Fabric 事件
      view.onScanResult = { value, seq, ts -> dispatchScanResult(reactContext, view, value, seq, ts) }
      view.onPhotoTaken = { path, w, h -> dispatchPhotoTaken(reactContext, view, path, w, h) }
      view.onPreviewSize = { w, h -> writePreviewSizeState(view, w, h) }
    }

  // —— Props setter:生成物声明的抽象方法,逐个覆写 ——
  override fun setCameraFace(view: FieldNoteCameraView, value: String?) {
    view.setFace(value == "front")   // WithDefault 生成的 setter 是可空类型
  }

  override fun setEnableQr(view: FieldNoteCameraView, value: Boolean) {
    view.setQrEnabled(value)
  }

  override fun setFrameColor(view: FieldNoteCameraView, value: Int) {
    view.setFrameColor(value)   // ColorValue → ARGB int
  }

  // —— Commands:命令式意图直达视图 ——
  override fun receiveCommand(view: FieldNoteCameraView, commandName: String, args: ReadableArray?) {
    when (commandName) {
      "takePhoto" -> view.takePhoto(args?.getString(0) ?: "photos/photo.jpg")
      "toggleTorch" -> view.setTorch(args?.getBoolean(0) ?: false)
      else -> throw IllegalArgumentException("Unknown command: $commandName")
    }
  }

  // —— State 写回入口:Fabric 把 StateWrapper 交给你,保存后由视图侧触发更新 ——
  private val stateWrappers = java.util.concurrent.ConcurrentHashMap<Int, com.facebook.react.uimanager.StateWrapper>()

  override fun updateState(
    view: FieldNoteCameraView,
    props: com.facebook.react.bridge.ReadableMap?,
    stateWrapper: com.facebook.react.uimanager.StateWrapper,
  ): Any? {
    stateWrappers[view.id] = stateWrapper   // view.id 即 ReactTag(Fabric 挂载时设置)
    return super.updateState(view, props, stateWrapper)
  }

  private fun writePreviewSizeState(view: FieldNoteCameraView, w: Int, h: Int) {
    val wrapper = stateWrappers[view.id] ?: return
    val map = com.facebook.react.bridge.Arguments.createMap().apply {
      putInt("previewWidth", w)
      putInt("previewHeight", h)
    }
    wrapper.updateState(map)   // 触发 ShadowNode State 更新 → 参与 C++ diff/布局
  }

  // —— 事件分发:bubbling 与 direct 的差异在事件名注册,dispatch 统一走 EventDispatcher ——
  private fun dispatchScanResult(
    reactContext: ThemedReactContext,
    view: FieldNoteCameraView,
    value: String, seq: Int, ts: Double,
  ) {
    val surfaceId = UIManagerHelper.getSurfaceId(reactContext)
    val payload = com.facebook.react.bridge.Arguments.createMap().apply {
      putString("value", value)
      putInt("seq", seq)
      putDouble("ts", ts)
    }
    UIManagerHelper.getEventDispatcher(reactContext, surfaceId)?.dispatchEvent(
      ScanResultEvent(surfaceId, view.id, payload),
    )
  }

  private fun dispatchPhotoTaken(/* 同构,事件名换 topPhotoTaken,字段为 localPath/width/height */) {}

  /** Fabric 回收复用视图前的重置钩子(0.7x 起支持) */
  override fun prepareToRecycleView(
    reactContext: ThemedReactContext,
    view: FieldNoteCameraView,
  ): FieldNoteCameraView? {
    stateWrappers.remove(view.id)
    view.resetForReuse()
    return super.prepareToRecycleView(reactContext, view)
  }

  override fun onDropViewInstance(view: FieldNoteCameraView) {
    stateWrappers.remove(view.id)
    view.resetForReuse()   // 彻底丢弃时同样先重置,相机随之释放
    super.onDropViewInstance(view)
  }

  companion object {
    // 事件名以生成产物常量为准(direct 事件在生成代码中核对注册名)
    const val EVENT_SCAN_RESULT = "topScanResult"
    const val EVENT_PHOTO_TAKEN = "topPhotoTaken"
  }
}

/** Fabric 事件的最小实现:payload 在 dispatch 时上交 */
internal class ScanResultEvent(
  surfaceId: Int,
  viewTag: Int,
  private val payload: com.facebook.react.bridge.WritableMap,
) : Event<ScanResultEvent>(surfaceId, viewTag) {
  override fun getEventName() = FieldNoteCameraViewManager.EVENT_SCAN_RESULT
  override fun dispatch(rctEventEmitter: RCTModernEventEmitter) {
    rctEventEmitter.receiveEvent(viewTag, FieldNoteCameraViewManager.EVENT_SCAN_RESULT, payload)
  }
}

逐段讲解:

  • setter 自己 diff:setFace 里值未变直接 return。Fabric 的 Props diff 在 C++ 层已完成一轮,但互操作路径下 setter 仍会在重建挂载等场景被调用,视图内再挡一层,把昂贵的 rebind()(解绑/重绑相机)压到真正的值变化时。
  • 事件面 bubbling vs direct:两种事件类型的差异不在 dispatch 代码,而在生成物注册的事件常量与冒泡语义:
BubblingEventHandler DirectEventHandler
传递语义 沿祖先链冒泡,父级可拦截(phased registration:bubbled/captured) 只交付给发出事件的视图的处理器
生成事件名 top 前缀 + phased 注册名 top 前缀 + registrationName(Android 生成侧),iOS 用 spec 原名
典型场景 列表项点击希望被容器统一处理 本组件私有通知(onScroll/onScanResult/onPhotoTaken)
误用代价 该收敛的通知散成 N 份监听 想冒泡收不到,被迫层层透传

FieldNote 两个事件都选 direct:扫码与拍照是取景器的私有产出,父级没有合理理由截胡。

  • State 写回:updateState 拿到 StateWrapper 存下,预览尺寸就绪时 updateState(map) 触发 ShadowNode 数据更新——这是「原生→C++ 树」的通道(8.4 详述它与事件的区别)。view.id 在 Fabric 挂载时被设置为 ReactTag,是事件与 State 的锚点。
  • 回收与销毁双钩子:prepareToRecycleView(复用前重置)与 onDropViewInstance(丢弃时清理)都必须做状态复位——这是本章陷阱表「复用残留」的正面解法。

8.3 iOS 实现(ObjC++ + Swift,Fabric 原生路径)

原理讲解

iOS 是 Fabric 的原生路径:组件视图直接实现 RCTComponentViewProtocol(基类 RCTViewComponentView),由 RCTComponentViewFactory 按组件名创建与复用。Fabric 调用你的时机固定且高频:挂载时 updateEventEmitter,每次 commit 命中时 updateProps/updateState,指令到达时 handleCommand。理解「这些方法何时被调、以什么频率被调」是 iOS 侧不写崩的前提。

组件视图:

// packages/native-kit/ios/RCTFieldNoteCameraView.h
#import <React/RCTViewComponentView.h>
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface RCTFieldNoteCameraView : RCTViewComponentView
@end

NS_ASSUME_NONNULL_END
// packages/native-kit/ios/RCTFieldNoteCameraView.mm
#import "RCTFieldNoteCameraView.h"
#import <React/RCTConversions.h>
#import <React/RCTComponentViewFactory.h>
// codegen 生成物(构建后在 Pods/生成目录确认确切头文件名):
#import "FieldNoteKitSpec/FieldNoteCameraViewComponentDescriptor.h"
#import "FieldNoteKitSpec/FieldNoteCameraViewEventEmitters.h"
#import "FieldNoteKitSpec/FieldNoteCameraViewProps.h"
#import "FieldNoteKitSpec/FieldNoteCameraViewStates.h"
#import "CameraViewImpl-Swift.h"

using namespace facebook::react;

@implementation RCTFieldNoteCameraView {
  CameraViewImpl *_impl;
  std::shared_ptr<const FieldNoteCameraViewEventEmitter> _typedEmitter;
  std::shared_ptr<const FieldNoteCameraViewShadowNode::ConcreteState> _concreteState;
}

+ (void)load {
  [super load];
  // 注册到 ComponentViewFactory(新模板亦可能提供 Cls() 函数式注册,
  // 以你版本的生成模板注释为准)
  [RCTComponentViewFactory.currentComponentViewFactory
      registerComponentViewClass:[RCTFieldNoteCameraView class]];
}

- (instancetype)initWithFrame:(CGRect)frame {
  if (self = [super initWithFrame:frame]) {
    _impl = [[CameraViewImpl alloc] init];
    _impl.onView = self;
    __weak RCTFieldNoteCameraView *weakSelf = self;   // ⚠️ 弱引用破循环
    _impl.onScanResult = ^(NSString *value, NSInteger seq, double ts) {
      [weakSelf dispatchScanResult:value seq:seq ts:ts];
    };
    _impl.onPhotoTaken = ^(NSString *path, double w, double h) {
      [weakSelf dispatchPhotoTaken:path width:w height:h];
    };
    _impl.onPreviewSize = ^(double w, double h) {
      [weakSelf reportPreviewSize:CGSizeMake(w, h)];
    };
  }
  return self;
}

// —— Props:每次 commit 命中本节点都会调用 ——
- (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps {
  [super updateProps:props oldProps:oldProps];   // 保留基类对通用 Props 的处理
  const auto old = std::static_pointer_cast<FieldNoteCameraViewProps const>(oldProps);
  const auto next = std::static_pointer_cast<FieldNoteCameraViewProps const>(props);

  if (!oldProps || old->cameraFace != next->cameraFace) {
    _impl.cameraFront = (next->cameraFace == "front");
  }
  if (!oldProps || old->enableQr != next->enableQr) {
    _impl.enableQr = next->enableQr;
  }
  if (!oldProps || old->frameColor != next->frameColor) {
    _impl.frameColor = RCTUIColorFromSharedColor(next->frameColor);
  }
  // 【重点】updateProps 是高频路径:这里只允许赋值与轻量 diff,
  // 重活(重配会话)交给实现层自己做值判断与队列调度
}

// —— 事件 emitter:挂载/重挂载时注入,可能为空!——
- (void)updateEventEmitter:(EventEmitter::Shared const &)eventEmitter {
  [super updateEventEmitter:eventEmitter];
  _typedEmitter = std::static_pointer_cast<const FieldNoteCameraViewEventEmitter>(eventEmitter);
}

// —— State:Fabric 下发当前 State,保存以便之后写回 ——
- (void)updateState:(State::Shared const &)state {
  [super updateState:state];
  _concreteState = std::static_pointer_cast<const FieldNoteCameraViewShadowNode::ConcreteState>(state);
}

// —— Commands:指令名与 Android 严格一致 ——
- (void)handleCommand:(const NSString &)commandName withArgs:(const NSArray &)args {
  if ([commandName isEqualToString:@"takePhoto"]) {
    [_impl takePhotoWithSaveTo:[args firstObject] ?: @"photos/photo.jpg"];
    return;
  }
  if ([commandName isEqualToString:@"toggleTorch"]) {
    [_impl setTorchOn:[[args firstObject] boolValue]];
    return;
  }
  [super handleCommand:commandName withArgs:args];
}

- (void)dispatchScanResult:(NSString *)value seq:(NSInteger)seq ts:(double)ts {
  if (!_typedEmitter) return;   // ⚠️ 挂载前/卸载后 emitter 不可用:静默丢弃而非崩溃
  FieldNoteCameraViewOnScanResult payload;   // 结构体名以生成的 EventEmitters.h 为准
  payload.value = std::string(value.UTF8String);
  payload.seq = (int)seq;
  payload.ts = ts;
  _typedEmitter->onScanResult(std::move(payload));
}

- (void)dispatchPhotoTaken:(NSString *)path width:(double)w height:(double)h {
  if (!_typedEmitter) return;
  FieldNoteCameraViewOnPhotoTaken payload;
  payload.localPath = std::string(path.UTF8String);
  payload.width = w;
  payload.height = h;
  _typedEmitter->onPhotoTaken(std::move(payload));
}

// —— State 写回:原生测量 → ShadowNode(与 Android StateWrapper 同语义)——
- (void)reportPreviewSize:(CGSize)size {
  if (!_concreteState) return;
  _concreteState->updateState([size](const FieldNoteCameraViewState::Data &old) {
    auto data = old;
    data.previewWidth = (int)size.width;
    data.previewHeight = (int)size.height;
    return std::make_shared<FieldNoteCameraViewState::Data>(data);
  });
}

- (void)prepareForRecycle {
  [_impl reset];      // 复用前重置:停会话、清回调与节流状态(对应 Android resetForReuse)
  _typedEmitter = nullptr;
  _concreteState = nullptr;
  [super prepareForRecycle];
}

- (void)dealloc {
  [_impl reset];
}
@end

Swift 实现层(AVFoundation,关键结构):

// packages/native-kit/ios/CameraViewImpl.swift
import AVFoundation
import UIKit

@objc(CameraViewImpl)
public final class CameraViewImpl: NSObject {
  private let session = AVCaptureSession()
  private let sessionQueue = DispatchQueue(label: "com.fieldnote.camera")
  private let photoOutput = AVCapturePhotoOutput()
  private let metadataOutput = AVCaptureMetadataOutput()
  private var previewLayer: AVCaptureVideoPreviewLayer?
  private var configuredFace: String?
  private var seq: Int = 0
  private var lastEmitAt: TimeInterval = 0

  @objc public var onView: UIView?
  @objc public var cameraFront = false   // didSet 里做值判断后重配(重活不入 updateProps)
  @objc public var enableQr = false
  @objc public var frameColor: UIColor = .systemGreen
  @objc public var onScanResult: ((String, Int, Double) -> Void)?
  @objc public var onPhotoTaken: ((String, Double, Double) -> Void)?
  @objc public var onPreviewSize: ((Double, Double) -> Void)?

  public func reset() {
    sessionQueue.async {
      self.session.stopRunning()
      self.seq = 0
      self.lastEmitAt = 0
      self.configuredFace = nil
    }
  }

  @objc public func takePhoto(saveTo: String) {
    sessionQueue.async { [weak self] in
      guard let self, self.session.isRunning else { return }
      // 路径白名单:只允许写 App 沙盒相对路径(与 Android safeTargetFile 同语义)
      let base = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
      let target = base.appendingPathComponent(saveTo)
      let settings = AVCapturePhotoSettings()
      self.photoOutput.capturePhoto(with: settings, delegate: self)
      self.pendingTarget = target
    }
  }

  @objc public func setTorch(on: Bool) {
    sessionQueue.async {
      guard let device = AVCaptureDevice.default(for: .video), device.hasTorch else { return }
      try? device.lockForConfiguration()
      device.torchMode = on ? .on : .off
      device.unlockForConfiguration()
    }
  }

  // 会话配置:attach 后由视图触发(此处从简:属性变化时重配,细节省略输入输出装配)
  private func reconfigureIfNeeded() { /* 输入/输出/previewLayer 装配,同样在 sessionQueue */ }
}

逐段讲解:

  • 事件 emitter 的持有与时机(本章 iOS 侧最重要的知识点):updateEventEmitter 在视图挂载/重挂载时由 Fabric 注入,卸载后引用可能失效或被换新。规则有三:① 用成员变量持有 typed emitter,不缓存到静态/全局;② 每次 dispatch 前判空——挂载前和解绑后 _typedEmitter 为空,此时原生事件丢弃是正确行为(没有人订阅);③ emitter 与视图实例同生共死,闭包里用 weakSelf 防止实现层→视图→emitter 的循环持有。
  • updateProps 的 diff 责任:Fabric 给你新旧两份 Props(生成 struct 的 == 由 codegen 生成),逐字段比较后才触碰实现层。把「重配会话」这类主线程重活推给 sessionQueue 异步执行,主线程在 updateProps 里只做赋值。
  • prepareForRecycle:Fabric 在 iOS 侧会回收复用 ComponentView 实例(与 Android prepareToRecycleView 对应),不复位就会把上一屏幕的相机状态、节流时间戳、回调带进下一屏幕——状态重置是复用机制的入场券。

8.4 四要素深入:Props / 事件 / Commands / State 的选型与成本

原理讲解

四要素对应四个方向与四种生命周期,选型错误的本质是把一种生命周期的数据塞进了另一种生命周期的通道。逐项过:

要素 方向 触发时机 成本构成 典型场景 反面模式
Props JS → 原生 每次 commit(声明式 diff 后交付) Props struct 分配 + C++ diff + setter 调用 状态型配置:外观、模式、开关 每帧下发动效/位置值(制造 commit 风暴)
事件 原生 → JS 原生产生,Mounting 后批量上行 payload 构造 + 事件调度 + JS 处理器执行 结果型通知:扫码命中、拍照完成 帧级回调(JS 线程被打爆,且丢失批量优化)
Commands JS → 原生 调用即发(异步、无返回值) 极轻:一次跨端调用直达视图 动作型意图:takePhoto、play、seek 用指令传状态(绕过声明式,diff 失效)
State 原生 → ShadowNode 原生测量变化 触发 C++ diff 与可能的重新布局;不进 JS 堆 布局参与型度量:文本测量、预览尺寸 把业务数据塞进 State(JS 拿不到,白写)

几个关键澄清:

  • State 不是「给 JS 的数据」。State 的消费方是 ShadowNode 与 Yoga:原生把度量结果写回,C++ 树据此重新布局(TextInput 的文本高度、PullToRefresh 的内部状态都是这个模式)。如果你要「JS 读到原生数据」,那是事件的职责(或第 6 章的全局 emitter)。FieldNote 的 previewWidth/Height 走 State 的价值在于:容器可以在预览就绪前就按上报尺寸占位,避免相机流首帧到达时的布局跳变——这是布局问题,所以用 State;而「拍照完成、把照片加进检查项」是业务事件,走 onPhotoTaken。
  • Props vs Commands 的分界:能被描述为「值」且需要持续生效的用 Props(enableQr: true 是状态);没有目标值、只是一次性意图的用 Commands(takePhoto 是动作)。检验法:这个调用适合放进 useEffect 依赖吗?适合 → Props;需要「点击才触发、只触发一次」→ Commands。
  • 事件 vs State 的分界:消费方是 JS 业务逻辑 → 事件;消费方是布局/渲染管线 → State。

一次 commit 里四要素的时序(帮助建立心智模型):

JS setState → commit:
  ① Props diff(C++)→ updateProps/setter(双端)     [每次都调,必须轻]
  ② State 更新(若有)→ updateState → Yoga 重布局      [按需]
  ③ Mounting:视图创建/复用 → 挂载 → updateEventEmitter [挂载时]
  ④ 运行期:原生产出 → 事件批量上行;JS 意图 → Commands [运行期]

【重点】高频视觉数据的正解:不要用任何跨端通道逐帧搬运。取景框高亮的呼吸动效、地图镜头跟随,都应该在原生自绘(原生动画/Shader),JS 只在必要时同步低频状态(如「动画模式开/关」走 Props);涉及 JS 驱动的共享值直通方案在第 9 章(Reanimated worklet 直连 UI 线程)展开。

8.5 复杂视图的实践模式(地图/相机类)

原理讲解

复杂原生视图(相机、地图、视频播放器、图表引擎)的封装难点不在「跑起来」,而在「被 React 的生命周期反复折腾还能活」。五个模式:

模式 1:View 复用与状态重置。 Fabric 会复用视图实例(列表滚动、页面切换都可能触发),prepareToRecycleView(Android)/prepareForRecycle(iOS)是唯一的重置点。重置清单必须覆盖:相机/地图等昂贵资源的释放与重建、节流时间戳与 seq、事件回调置空、内部 UI 状态(覆盖层、动画)。⚠️ 漏掉任何一项,「第二个挂载点显示上一屏内容」这类幽灵 bug 就会只在列表场景复现——这也是复用类 bug 难查的原因:它依赖滚动路径,与代码逻辑无关。

模式 2:避免 JS 高频驱动原生。 评审问题永远是「这个值每秒变多少次?谁消费?」。相机曝光、地图镜头这类连续量:原生自治 + JS 只发模式切换;确实需要 JS 侧驱动的连续量(如联动动画):走第 9 章的 worklet 直通,不走 Props。

模式 3:手势交还原生。 取景器里的捏合变焦、地图双指缩放,是原生手势识别器(UIGestureRecognizer / ScaleGestureDetector)的领地。JS 侧不要抢:Android 上必要时 requestDisallowInterceptTouchEvent(true) 阻止父级拦截,iOS 上处理好手势互斥(gestureRecognizerShouldBegin);与 RNGH 的共存策略(何时让 JS 手势系统接管)在第 10 章展开,本章只立原则:纯原生交互留在原生,跨组件协作的手势才进 JS。

模式 4:生命周期与屏幕挂载。 相机会话的生命周期锚点应该是视图挂载而不是 App 前后台:onAttachedToWindow 启动、onDetachedFromWindow 释放(示例里的 ViewLifecycleOwner 桥接就是为这个);iOS 侧对应 didMoveToWindow 判断。App 前后台(AppState)另作一层节流(退后台暂停预览)。双锚点都处理,才能同时满足「进 ViewPager 的相邻页不占相机」与「切微信要释放」。

模式 5:不透明视图与层级优化。 相机预览、地图这类全幅覆盖层:确保 isOpaque = true 且自身铺满不透明背景,GPU 可以跳过其后的合成;父容器避免半透明叠加与离屏渲染(masksToBounds + 圆角 的经典坑)。合成层级每深一层,挂载与滚动的合成开销都在涨(第 2 章的渲染预算同样适用于原生视图)。

三层驱动方式的选择收拢为一张表(结合模式 1-5 使用):

方式 适用 成本 风险
Props 低频同步 模式/开关/外观 一次 commit 一份 高频化即 commit 风暴
Commands 一次性动作 拍照/seek/开关 极轻,无返回值 滥用会绕过声明式心智
原生自治 + 事件上报 连续交互(缩放/曝光) JS 零参与 事件需节流(第 6 章合批)

8.6 requireNativeComponent 对照与三方库评估

原理讲解

旧架构组件用 requireNativeComponent('Name') + PropTypes 声明视图,无 codegen、无类型、事件靠 NativeEventEmitter 手工文档。interop 层让这些组件在 Fabric 下继续工作:旧 ViewManager 被包成 Fabric 可挂载的形态(视图操作经互操作层转换)。与模块迁移同理:能用,但享受不到 ShadowNode 原生路径与编译期契约。

对照与迁移(「翻译表」):

旧架构写法 新架构对应
requireNativeComponent('X') codegenNativeComponent<Props, State>('X')
PropTypes/flow 手写文档 TS interface(codegen 校验)
UIManager.dispatchViewManagerCommand(tag, id, args) Commands.takePhoto(ref, args)(类型化)
事件常量口头约定 生成物事件常量(构建后核对)
ViewManager.getExportedCustomDirectEventTypeConstants spec 的 DirectEventHandler 声明
iOS RCTViewManager ComponentView(RCTComponentViewProtocol)

迁移步骤与第 7 章 7.4 同构:补 codegenConfig(type 改 components 或 all)→ 写 spec → Android Manager 改继承生成 ManagerSpec、事件常量对齐生成物 → iOS 拆 ComponentView(.mm + Swift 实现)→ JS 换导入并用类型收编所有调用点 → 自测页回归四要素。

三方原生组件库评估清单(新架构时代的尽调表):

检查项 通过标准
新架构支持 声明支持 Fabric/bridgeless,或提供 codegen spec(看仓库 package.json 的 codegenConfig)
维护活跃度 近半年有 release;issue 有响应;新架构兼容 issue 的处理态度
TypeScript 质量 spec 类型完整(不是 any/object 一把梭),事件负载有类型
回收复用处理 视图有状态重置钩子(搜 prepareToRecycleView / prepareForRecycle)
视图性能 不依赖 JS 逐帧驱动;高频数据走原生自治
权限与隐私 plist/Manifest 声明完整,权限流程可定制
双端功能对齐 平台差异文档化,有抹平层或明确标注

评估结论写进技术选型记录:四要素的接口质量(尤其 State 与回收复用)比 star 数更能预测集成后的维护成本。

典型业务场景:FieldNote 实战(M8:原生相机取景器)

M8 交付「任务详情 → 拍照取景器」:自定义取景框高亮(扫码框样式)、取景器内扫码(命中即上报)、拍照落盘。spec 在 8.1 已定,本节给出 JS 封装、双端关键代码补全(8.2/8.3 已给 Manager 与 ComponentView,此处补视图实现细节与接线)。

场景一:JS 封装组件(含 ref 与事件消费)

// packages/native-kit/js/FieldNoteCamera.tsx
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
import type { ElementRef } from 'react';
import NativeCameraView, { Commands } from './NativeCameraView';
import type { NativeCameraViewProps, PhotoTakenEventData, ScanResultEventData } from './NativeCameraView';

export type { ScanResultEventData, PhotoTakenEventData };

export interface FieldNoteCameraHandle {
  takePhoto(saveTo: string): void;
  setTorch(on: boolean): void;
}

type Props = Omit<NativeCameraViewProps, 'onScanResult' | 'onPhotoTaken'> & {
  onScanResult?: (data: ScanResultEventData) => void;   // 抹平层剥掉 nativeEvent 包装
  onPhotoTaken?: (data: PhotoTakenEventData) => void;
};

export const FieldNoteCamera = forwardRef<FieldNoteCameraHandle, Props>(function FieldNoteCamera(
  { onScanResult, onPhotoTaken, ...rest }: Props,
  ref,
) {
  const hostRef = useRef<ElementRef<typeof NativeCameraView>>(null);

  useImperativeHandle(ref, () => ({
    takePhoto: saveTo => {
      if (hostRef.current) Commands.takePhoto(hostRef.current, saveTo);
    },
    setTorch: on => {
      if (hostRef.current) Commands.toggleTorch(hostRef.current, on);
    },
  }), []);

  return (
    <NativeCameraView
      ref={hostRef}
      {...rest}
      onScanResult={e => onScanResult?.(e.nativeEvent)}    // ⚠️ 事件负载在 nativeEvent 里
      onPhotoTaken={e => onPhotoTaken?.(e.nativeEvent)}
    />
  );
});

【重点】e.nativeEvent 是事件消费的第一坑:spec 里 DirectEventHandler<ScanResultEventData> 的类型参数描述的是 nativeEvent 的内容,JS 处理器拿到的外层是事件对象。抹平层在此剥壳,业务永远拿到纯数据——这正是「事件对象结构与 spec 不一致」陷阱在封装层的预防。

场景二:双端视图实现的关键补全

Android 侧 analyzeQr 的完整实现(与第 7 章 ScannerModule 同构,唯一差别是结果走视图回调而非全局事件):

// FieldNoteCameraView.kt 内补全
private val reader = com.google.zxing.MultiFormatReader().apply {
  setHints(mapOf(com.google.zxing.DecodeHintType.POSSIBLE_FORMATS
      to listOf(com.google.zxing.BarcodeFormat.QR_CODE)))
}

private fun analyzeQr(image: androidx.camera.core.ImageProxy) {
  try {
    if (!qrEnabled) return
    val plane = image.planes[0]
    val bytes = ByteArray(plane.buffer.remaining()).also { plane.buffer.get(it) }
    val source = com.google.zxing.PlanarYUVLuminanceSource(
      bytes, image.width, image.height, 0, 0, image.width, image.height, false)
    val text = try {
      reader.decodeWithState(com.google.zxing.BinaryBitmap(
          com.google.zxing.common.HybridBinarizer(source))).text
    } catch (_: com.google.zxing.NotFoundException) { null } finally { reader.reset() }
    if (text != null) {
      val now = android.os.SystemClock.elapsedRealtime()
      synchronized(this) { if (now - lastEmitAtMs < 300) return; lastEmitAtMs = now }
      val s = seq.incrementAndGet()
      onScanResult?.invoke(text, s, System.currentTimeMillis().toDouble())
    }
  } finally {
    image.close()
  }
}

iOS 侧照片落盘与事件(补 AVCapturePhotoCaptureDelegate 与预览层装配的关键段):

// CameraViewImpl.swift 补全
// 以下两个存储属性并入类体(Swift 的 extension 不能声明存储属性):
//   private var pendingTarget: URL?
//   private var photoDelegateProxy: PhotoProxy?

public func attachToView() {
  sessionQueue.async { [weak self] in
    guard let self, let host = self.onView else { return }
    if self.previewLayer == nil {
      let layer = AVCaptureVideoPreviewLayer(session: self.session)
      layer.videoGravity = .resizeAspectFill
      layer.frame = host.bounds
      host.layer.addSublayer(layer)
      self.previewLayer = layer
    }
    self.reconfigureIfNeeded()
    if !self.session.isRunning { self.session.startRunning() }
    DispatchQueue.main.async {
      self.onPreviewSize?(Double(host.bounds.width), Double(host.bounds.height))
    }
  }
}

extension CameraViewImpl: AVCapturePhotoCaptureDelegate {
  public func photoOutput(_ output: AVCapturePhotoOutput,
                          didFinishProcessingPhoto photo: AVCapturePhoto,
                          error: Error?) {
    guard error == nil,
          let data = photo.fileDataRepresentation(),
          let target = pendingTarget else { return }
    try? data.write(to: target)
    let w = Double(photo.resolvedSettings.photoDimensions.width)
    let h = Double(photo.resolvedSettings.photoDimensions.height)
    onPhotoTaken?(target.path, w, h)
    pendingTarget = nil
  }
}

(相机装配的完整细节——输入输出校验、方向、对焦——按需实现;本章要求的是结构真实可落地:会话在专属队列配置、事件经 300ms 合批、路径白名单、挂载/回收时启停,这些骨架都已在位。)

场景三:业务接线(任务详情页)

// features/inspection/CheckItemPhotoSection.tsx
import React, { useRef, useState } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { FieldNoteCamera } from '@fieldnote/native-kit';
import type { FieldNoteCameraHandle } from '@fieldnote/native-kit';

export function CheckItemPhotoSection({ taskId, itemId }: { taskId: string; itemId: string }) {
  const cameraRef = useRef<FieldNoteCameraHandle>(null);
  const [torch, setTorch] = useState(false);
  const lastCodeRef = useRef<string | null>(null);

  return (
    <View>
      <FieldNoteCamera
        style={styles.camera}
        cameraFace="back"
        enableQr                       // 取景器内顺带扫码识别资产
        frameColor="#00E5FF"
        onScanResult={data => {
          if (data.value === lastCodeRef.current) return;  // 同码去重(业务级)
          lastCodeRef.current = data.value;
          // 命中资产码:绑定到当前检查项(离线写 SQLite + Outbox,第 14 章)
        }}
        onPhotoTaken={data => {
          // localPath 进 Photo 表:syncState='pending',联网后由 Outbox 驱动上传(第 14/15 章)
        }}
      />
      <View style={styles.row}>
        <TouchableOpacity
          style={styles.btn}
          onPress={() => cameraRef.current?.takePhoto(`photos/${taskId}/${itemId}-${Date.now()}.jpg`)}
        >
          <Text>拍照</Text>
        </TouchableOpacity>
        <TouchableOpacity
          style={styles.btn}
          onPress={() => {
            const next = !torch;
            setTorch(next);                  // JS 状态留给 UI 图标
            cameraRef.current?.setTorch(next); // 实际开关走 Commands
          }}
        >
          <Text>{torch ? '关灯' : '补光'}</Text>
        </TouchableOpacity>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  camera: { width: '100%', aspectRatio: 4 / 3, backgroundColor: '#000' },
  row: { flexDirection: 'row', gap: 12, marginTop: 8 },
  btn: { paddingHorizontal: 16, paddingVertical: 8, backgroundColor: '#263238', borderRadius: 8 },
});

M8 验收标准:① Props 变化(切前后置)即时生效且不重复重配;② enableQr 关闭后事件停发;③ 拍照指令命中且落盘路径在沙盒内;④ 事件负载 nativeEvent 字段与 spec 逐字段一致;⑤ 从列表进出 20 次后无相机占用残留、无事件重复;⑥ 状态写回生效(首帧前容器不跳变)。

常见陷阱

陷阱 典型症状 根因 正确做法
用 Props 传高频变化的值 列表/取景器掉帧,JS 线程满载 每个 props 变化都是一次完整 commit(C++ diff + mount 调度) 连续量原生自绘自治;JS 只同步低频模式;JS 驱动场景走第 9 章共享值直通
事件对象结构与 spec 不一致 e.value 恒为 undefined,或字段名对不上 原生手工拼 payload 与 spec 漂移;JS 忘了 e.nativeEvent payload 以 spec struct 为准逐字段写;抹平层统一剥 nativeEvent;自测页断言字段
在 updateProps 里做重活 每次交互卡一下,commit 越多越卡 updateProps 每次 commit 都会调,重配/IO 放这里等于每帧重活 只做轻量 diff 与赋值;重活交实现层队列异步执行且自行判值
指令名/常量双端不一致 takePhoto 在 iOS 静默无效(default 分支吞掉) 手写两份字符串,改了一端忘了另一端 指令名单一来源(spec);构建后核对生成常量;自测页覆盖全部指令
原生视图持有 JS 回调造成泄漏 页面退出后原生仍回调,偶发崩溃或内存不降 闭包强持有 view/emitter,生命周期超出挂载期 回调用弱引用(weakSelf);emitter 由框架注入不外传;prepareForRecycle/dealloc 清空
忘记处理 View 回收复用的状态重置 第二个挂载点显示上一屏画面/旧扫码结果 Fabric 复用视图实例,内部状态随实例带走 prepareToRecycleView/prepareForRecycle 全量重置(资源、节流、回调、UI 状态)
卸载后仍 dispatch 事件 偶发崩溃或事件静默丢失且难复现 emitter 在挂载前/解绑后不可用,代码未判空 dispatch 前判空 emitter;卸载时同步停原生事件源(第 6 章先摘监听再停源)

最佳实践

  1. 先问「谁消费、多久变一次」再选要素:状态→Props、一次性意图→Commands、业务结果→事件、布局度量→State;选型写进 spec 注释。
  2. updateProps/updateState 是高频路径:只允许轻量 diff 与赋值,重活进模块专属队列且自行判值去重。
  3. 事件名以 codegen 生成物常量为唯一事实,构建后核对;payload 逐字段对齐 spec struct,带单调 seq。
  4. 双端指令名、事件负载、错误码用同一份 spec/文档派生,禁止各自手写字符串。
  5. 视图复用重置清单化:资源、节流时间戳、seq、回调、内部 UI 状态五类,prepareToRecycleView/prepareForRecycle 一次写全。
  6. 相机/地图等昂贵资源的生命周期锚定视图挂载(onAttachedToWindow/didMoveToWindow),App 前后台另作节流层。
  7. 连续交互留在原生(手势识别器自治),JS 只接结果事件;跨组件手势协作交给第 10 章的 RNGH 方案。
  8. 全幅覆盖层 isOpaque + 不透明背景,避免半透明叠加与离屏渲染(圆角+裁剪组合尤甚)。
  9. 高频视觉数据永不跨端逐帧搬运:原生动画自绘,或第 9 章的 worklet 直通;跨端只同步模式开关。
  10. 路径/URL 类入参做白名单校验(沙盒内相对路径),拒绝穿越——原生组件是攻击面的一部分(呼应第 17 章)。
  11. 三方原生库按 8.6 清单尽调,重点看 codegen 支持、回收复用处理与维护活跃度,结论落文档。
  12. 自测页覆盖四要素全矩阵:每个 Props 可改、每个指令可发、每个事件可触发、State 可观察(布局占位),作为 M8 的验收工装。

本章小结

本章把第 7 章的「服务封装」推进到「视图封装」:Fabric 组件是渲染树的节点,被 commit 调度、被 Yoga 测量、被 Mount 挂载,因此它的接口不是方法而是四要素——Props 承载声明式状态、事件承载原生→JS 的结果通知、Commands 承载 JS→原生的命令式意图、State 承载原生→ShadowNode 的布局度量。选型的判据始终是「谁消费、多久变一次」:高频连续量留在原生自治,跨端只走低频同步与一次性动作;updateProps 是每次 commit 都会经过的高频路径,重活放进去就是自己制造卡顿。Android 走 ViewManager 互操作路径(生成 ManagerSpec + 事件 Event + StateWrapper),iOS 走 Fabric 原生路径(RCTComponentViewProtocol + typed emitter),双端不对称是历史路径而非设计缺陷,spec 契约把两边钉在同一张图纸上。复用回收机制要求每次挂载都从干净状态开始,重置清单是复用机制的入场券。FieldNote 相机取景器按此交付:Props 配置取景器、指令拍照与补光、事件上报扫码与照片、State 上报预览尺寸,四要素各就其位。

一句话记忆点:Props 是状态、事件是结果、指令是意图、State 是度量——先问谁消费、多久变一次,再动手。

动手练习

  1. 基础巩固:给 FieldNoteCameraView 增加 zoom: WithDefault<number, 0> Props(0-1 连续量)与 setZoom 指令两种接口,分别实现并实测:连续拖动滑块时,Props 方案每秒触发多少次 commit(可用 8.2 的 setter 打 log 统计)?指令方案呢?再用「原生自治 + onZoomChange 事件节流上报」实现第三种。三种方案的帧率与 commit 次数对比表贴进笔记。
  2. FieldNote 实战 A:实现「扫码命中后 3 秒内同码不重复上报」:分别在原生(节流状态进视图,回收时重置)与 JS(lastCodeRef)两个位置实现,并说明当页面存在两个取景器实例时两种实现的行为差异。结论写清:去重状态应该属于视图实例还是业务会话?
  3. FieldNote 实战 B:为取景器接入「页面挂载生命周期」:在 ViewPager 式的检查项轮播(react-native-pager-view)里嵌取景器,要求相邻页预载时相机不被占用、切出页面会话挂起、切回自动恢复。给出 Android(ViewLifecycleOwner 状态机)与 iOS(didMoveToWindow + AppState)的改动点清单与边界条件(快速滑动、双实例并发、前后台切换叠加)。
  4. 思考题:拍照按钮的「按下动效」(按下缩小 5%、松开回弹)应该用 Props 驱动、指令驱动还是原生自绘?请从三个角度做成本分析:(a)驱动频率——按住期间每秒多少次跨端通信,16.7ms 帧预算还剩多少;(b)实现复杂度——JS 侧与原生侧各要写多少胶水;(c)正确性——松开瞬间的取消/竞态三种方案各如何保证。最后给出你的选择,并说明它与第 9 章 Reanimated 方案(JS 驱动但 worklet 直跑 UI 线程)的边界:什么情况下值得把动效下沉进这个原生组件、什么情况下不该。

导航

上一章 第 7 章 原生模块封装(TurboModule) | 下一章 第 9 章 动画系统与 Reanimated

📄 第 9 章 动画系统与 Reanimated 09-animation-reanimated.md

本章导读:从本章起进入「动画与手势」阶段(第 9~11 章)。动画是 RN 应用掉帧的重灾区,因为它把「每帧 16.67ms」的硬预算压到了最严苛的程度。本章从线程模型出发讲清 RN 动画体系分层与 Reanimated 3.x 的底层架构,再沉淀一套可复用的动效模式,并在 FieldNote 中落地检查项打勾动效;手势与动画的深度联动在第 10 章展开,本章结尾埋好最小联动示例。

本章学习目标

  • 能复述 setState 驱动动画的完整成本链,并解释它为什么天然容易掉帧;
  • 能解释 useNativeDriver 的工作原理,以及为什么原生驱动只覆盖 transform/opacity 等少数属性;
  • 能描述 Reanimated 3.x 的双运行时架构:worklet 序列化、闭包捕获规则与 SharedValue 的跨线程语义;
  • 能用 withSpring/withTiming/withSequence/withDelay/withRepeat/interpolate/cancelAnimation 编排复杂动效,并完成回调 Promise 化封装;
  • 能用 entering/exiting/layout 与 Keyframe 实现布局动画,并说清其在 React 18/19 并发渲染下的时序;
  • 能用 JS 忙循环对照实验与帧率工具证明「动画确实运行在 UI 线程」;
  • 能实现弹性按压、数字滚动、骨架屏 shimmer、进出编排、手势跟随五类高频动效模式;
  • 能为 FieldNote 实现检查项打勾动效:全程无逐帧 setState,JS 线程满载时依旧不掉帧。

核心知识点

9.1 动画体系分层:setState、Animated 与 Reanimated

setState 驱动动画:一条注定拥挤的成本链

先看反面教材,这是初学者最常见也最隐蔽的掉帧写法:

// ❌ 反面教材:JS 线程逐帧 setState
function PulseCard() {
  const [opacity, setOpacity] = useState(0);

  useEffect(() => {
    let v = 0;
    let dir = 1;
    const id = setInterval(() => {
      v += dir * 0.02;
      if (v >= 1 || v <= 0) dir = -dir;
      setOpacity(v); // 每秒约 60 次 React 重渲染
    }, 16);
    return () => clearInterval(id);
  }, []);

  return <View style={[styles.card, { opacity }]} />;
}

这段代码「能跑」,但在真实业务里几乎必掉帧。把一次透明度更新从产生到上屏拆开,是一条 7 环成本链:

  1. 定时器回调在 JS 线程事件循环里排队——JS 线程忙时,这一步就有延迟;
  2. setState 触发组件 render,产出新元素树;
  3. reconciler 做 diff,生成更新;
  4. 新架构下提交到 C++ 层的影子树(shadow tree)做合并与比对;
  5. commit 阶段把变更挂载(mount)到原生视图;
  6. 属性写入 UI 线程的视图;
  7. UI 线程执行布局与绘制。

第 1 章讲过线程模型,第 6 章讲过高频数据要合批——这里的问题与它们同源:动画帧更新的全链路串行依赖 JS 线程空闲。只要 JS 线程被列表渲染、JSON 解析或同步 SDK 调用占住几十毫秒,动画帧数据就迟到,UI 线程只能重复上一帧或直接跳帧。更糟的是,setState 路径上每帧都要走完 React 的 render 与 commit,等于把最重的流水线拿来干最轻的活(改一个数字)。

【重点】掉帧的本质不是「计算慢」,而是「帧更新的生产者离显示管线的节拍器太远」。显示器的 vsync 是硬节拍(60Hz 每 16.67ms 一拍,120Hz 每 8.33ms 一拍),谁离节拍近、谁不排队,谁就不掉帧。

Animated 与 useNativeDriver:第一次「驱动下沉」

RN 内置的 Animated API 是对上述问题的第一次架构回应。它的思路是:把动画描述成一棵节点图(Value、timing/spring、interpolate、Animated.View 各是节点),然后整体交给别处执行,而不是每帧回 JS 求值。

import { useEffect, useRef } from 'react';
import { Animated, Easing, View } from 'react-native';

function NativePulse() {
  const progress = useRef(new Animated.Value(0)).current;

  useEffect(() => {
    const loop = Animated.loop(
      Animated.sequence([
        Animated.timing(progress, {
          toValue: 1,
          duration: 800,
          easing: Easing.inOut(Easing.quad),
          useNativeDriver: true, // 关键:驱动下沉到原生
        }),
        Animated.timing(progress, {
          toValue: 0,
          duration: 800,
          easing: Easing.inOut(Easing.quad),
          useNativeDriver: true,
        }),
      ]),
    );
    loop.start();
    return () => loop.stop();
  }, [progress]);

  return (
    <Animated.View style={[styles.card, { opacity: progress }]} />
  );
}

开启 useNativeDriver 后的通信路径是:

  • JS 侧构建动画节点图,一次性序列化下发(新架构下经 Native Animated 的 TurboModule/JSI 通道);
  • 原生侧 Native Animated 模块持有这棵节点图,在每帧显示回调里自行插值;
  • 插值结果直接写入视图属性,不再返回 JS。

JS 在动画启动后完全退出帧循环。这就是「驱动下沉」:把逐帧计算从 JS 线程搬到离 vsync 更近的位置。

那为什么原生驱动只支持 transform/opacity 这类属性?看一张属性分类表:

属性 类别 useNativeDriver 原因
transform(translate/scale/rotate) 合成属性 支持 由 GPU 合成层处理,不触发测量与重排
opacity 合成属性 支持 同上
width/height/margin/padding 布局属性 不支持 改动必须经 Yoga 重新布局,牵动父子兄弟子树
left/top(绝对定位) 布局属性 不支持 同上,且重排代价更高
backgroundColor 绘制属性 不支持 原生动画模块未实现该通道(Reanimated 可以驱动,见后文)
文本内容 排版属性 不支持 文本测量与排版归 React/Yoga 管线所有

【重点】根本原因是职责边界:原生 Animated 模块只做「属性直写」,它不拥有布局能力。width 一变,Yoga 要重新 measure/layout 整个受影响子树——这不是「写一个属性」能表达的动作,所以布局属性永远无法走这条路。而 transform/opacity 属于合成器友好属性,图层已经存在,改它们只是改 GPU 合成参数,天然适合逐帧驱动。

Animated 与 Reanimated 的关系与取舍

Animated 解决了「简单属性循环动画」,但它的表达力天花板很明显:节点图无法写逻辑分支、无法做物理反馈式交互、事件驱动(滚动/手势跟随)要拼 Animated.event 节点图且能力有限。Reanimated 的思路更激进:不搬运「动画定义」,直接把「代码」搬到 UI 侧执行——这就是 worklet。

维度 Animated + useNativeDriver Reanimated 3.x
执行位置 原生动画模块内插值(UI 侧) UI 侧的 worklet 运行时(独立小型 JS 运行时)
可驱动属性 仅 transform/opacity 等少数 任意样式属性(含 backgroundColor,width 亦可但注意布局成本)
逻辑能力 节点图组合,无分支/循环 任意 JS 逻辑(在 worklet 内)
事件驱动(滚动/手势) Animated.event 节点图,能力有限 滚动/手势回调即 worklet,天然联动
布局动画 无 entering/exiting/layout/Keyframe
与手势系统配合 需要手工搭桥 与 RNGH Gesture API 原生一体化(第 10 章)
新项目建议 仅维护遗留代码 默认选择

新架构(0.76+ 默认)下 Reanimated 是官方生态事实标准:Fabric 与 worklet 共享同一套 JSI 通道,属性直写零序列化。后文如无说明,「动画」均指 Reanimated。

线程泳道对比:一张图看懂差别

下面这张泳道图是本章最重要的图:同样的呼吸动画,setState 驱动与 worklet 驱动在两个线程上的行为完全不同。

sequenceDiagram
    participant JS as JS 线程(React 运行时)
    participant UI as UI 线程(渲染与合成)

    Note over JS,UI: 方案 A:setState 逐帧驱动
    loop 每一帧,预算 16.67ms
        JS->>JS: 定时器回调里计算新值
        JS->>JS: setState 触发 render 与 reconcile
        JS->>UI: commit 后提交属性更新
        UI->>UI: 布局与绘制
        Note over JS,UI: JS 繁忙时本帧数据迟到,掉帧
    end

    Note over JS,UI: 方案 B:worklet 在 UI 侧驱动
    JS->>UI: 启动动画(一次性下发定义与初值)
    loop 每一帧,与 vsync 对齐
        UI->>UI: worklet 计算插值或物理模拟
        UI->>UI: 直接更新视图属性
    end
    Note over JS,UI: JS 忙或不忙,动画都不受影响

方案 B 里 JS 线程唯一的工作是「启动」。之后每一帧都在 UI 侧闭环完成——这正是 9.5 节对照实验能做出天壤之别的原因。

9.2 Reanimated 3.x 架构深入:UI 侧的第二个 JS 运行时

双运行时与 JSI

Reanimated 3.x 维护着两个 JavaScript 运行时:

  • React 运行时:React、reconciler、你的业务代码所在,就是第 1 章里的「JS 线程」;
  • UI 运行时:一个独立的小型 JS 引擎实例,跑在 UI 侧,与显示帧回调(vsync)对齐,worklet 在这里执行。

两个运行时之间通过 JSI(JavaScript Interface,第 7 章详述)通信:SharedValue 是 JSI 宿主对象,双端直读直写,不走 JSON 序列化,也没有老 Bridge 的异步批处理延迟。用一张架构图表示:

┌────────────── JS 侧(React 世界) ──────────────┐
│  React 组件树                                   │
│    ├─ useSharedValue ───────┐                   │
│    ├─ useAnimatedStyle ─────┤ 编译期序列化 worklet │
│    └─ useDerivedValue ──────┘                   │
│              SharedValue(JSI 宿主对象)          │
└───────────────┬────────────────────────────────┘
                │ JSI 直读直写(无序列化、无 Bridge)
┌───────────────▼────────── UI 侧(显示管线世界)──┐
│  UI 运行时(小型 JS 引擎,与 vsync 对齐)          │
│    ├─ worklet 代码在这里执行                      │
│    ├─ 帧回调 / 手势事件在此触发                    │
│    └─ useAnimatedStyle 重算 ──► 属性直写          │
│  Fabric 视图树 ◄────── 属性直写 ──────┘           │
└─────────────────────────────────────────────────┘

💡 不同平台与 Reanimated 小版本对「UI 运行时宿主在哪个线程」的安排有过演进(例如如何避免长任务阻塞主线程),记住架构事实即可:UI 运行时独立于 React 运行时,且与显示节拍对齐;具体宿主线程以写作时官方文档为准。

worklet 的序列化与闭包捕获

worklet 是被标记为「可在 UI 运行时执行」的函数。react-native-reanimated/plugin 这个 babel 插件在编译期把每个 worklet 转换成一个可序列化对象:函数源码字符串 + 闭包捕获表 + 元信息。运行时首次调用时,UI 运行时用这份序列化产物重建出函数。这也是为什么该插件必须放在 babel 插件列表最后——它要在所有其他转换完成后才能正确提取代码:

// babel.config.js
module.exports = {
  presets: ['@react-native/babel-preset'],
  plugins: [
    // ...其他插件
    'react-native-reanimated/plugin', // 必须最后一个
  ],
};
// 注:Reanimated 4.x 起工作负载拆分到 react-native-worklets 包,
// 插件路径有变化,以官方安装文档为准。

「编译期序列化 + 闭包捕获」直接决定了什么能带进 worklet、什么不能:

  • 能带:基本类型(number/string/boolean/null)、其他 worklet 函数、SharedValue、编译期可分析的导入引用(Reanimated 自己导出的函数都是 worklet);
  • 不能带:不可序列化的宿主对象(DOM 不存在于 UI 运行时)、普通(未 worklet 化)函数——调用会直接报错;async/await 在早期版本不支持,新版本有放宽,以官方文档为准;
  • 有代价的捕获:任何被引用的闭包变量都会进捕获表。捕获巨型数组或大对象,等于在编译产物里塞一份完整数据拷贝,并在运行时跨运行时传输。
// ❌ 反例:把 10 万条数据捕获进 worklet
const allScores = Array.from({ length: 100_000 }, (_, i) => (i % 5) + 1);

function AvgBadge() {
  const t = useSharedValue(0);

  const avgOf = (arr: number[]) => {
    'worklet';
    let s = 0;
    for (const v of arr) s += v; // 整个数组被序列化进闭包捕获表
    return s / arr.length;
  };

  const style = useAnimatedStyle(() => ({
    opacity: interpolate(t.value, [0, 1], [0.4, 1]),
    width: avgOf(allScores), // 顺手在样式里算了平均值 —— 巨型数组随之进 worklet
  }));
  // ...
}

// ✅ 正例:JS 线程算好,只把结果(一个数字)交给 worklet
function AvgBadgeGood() {
  const t = useSharedValue(0);
  const avg = useMemo(
    () => allScores.reduce((a, b) => a + b, 0) / allScores.length,
    [],
  );
  const style = useAnimatedStyle(() => ({
    opacity: interpolate(t.value, [0, 1], [0.4, 1]),
    width: avg, // avg 是 number,捕获成本为零
  }));
  // ...
}

捕获大对象的三个代价:首帧序列化开销(动画首次启动卡 50~200ms 是典型症状)、双份内存(JS 侧与 UI 侧各一份)、GC 压力(每次 worklet 重建/热更都可能重复传输)。原则:跨线程只传标量与下标,重计算留在创建它的线程。

SharedValue 的跨线程语义

SharedValue 是双运行时之间的「同步变量」,理解它的读写时序能避开一大类灵异问题:

const progress = useSharedValue(0);

// JS 线程写入:投递到 UI 侧,通常下一帧前生效
progress.value = 0.5;

// JS 线程读取:拿到的是「最近同步到 JS 侧」的值
// UI 侧刚刚改写的值可能还没同步回来 —— 读到的是快照,不是实时值
console.log(progress.value);

// UI 线程(worklet 内)读写:
// 3.x 惯用 progress.value;较新的 3.x 版本引入了 .get()/.set(),
// 4.x 方向推荐显式 get/set。写法跟随当前版本文档即可,语义不变。

三条语义要点:

  1. 写是发布,读是快照。JS 侧写 → 排队到 UI;UI 侧写 → 经 JSI 直接改写并通知样式重算。跨线程「写完立刻读」永远不可靠;
  2. 动画期间不要从 JS 读它做业务判断。动画值每帧都在 UI 侧变化,JS 侧的 .value 严重滞后;
  3. 共享值只承载「逐帧过程」。业务状态(选中了哪一项)该用 state 还是用 state——低频、需要触发重渲染的数据归 React,逐帧插值归 SharedValue。

runOnJS / runOnUI:线程切换的代价

两个运行时各自执行各自的函数,跨界调用用 runOnJS 与 runOnUI:

import { runOnJS, runOnUI, useSharedValue, withTiming } from 'react-native-reanimated';

function BridgeDemo() {
  const progress = useSharedValue(0);

  // 普通函数:活在 React 运行时
  const reportDone = (final: number) => {
    console.log(`动画结束于 ${final}`); // 这里可以 setState、可以调 SDK
  };

  const startOnUI = () => {
    'worklet';
    progress.value = withTiming(1, { duration: 500 }, (finished) => {
      if (finished) {
        runOnJS(reportDone)(progress.value); // UI → JS:异步排队,下一轮事件循环执行
      }
    });
  };

  const start = () => {
    runOnUI(startOnUI)(); // JS → UI:把 worklet 投递到 UI 运行时队列
  };

  return <Button title="启动" onPress={start} />;
}

调度开销的要点:

  • runOnJS 不是同步调用:调用只是「投递」,真正执行要等 React 运行时的事件循环空闲。JS 繁忙时投递会堆积(背压),因此 runOnJS 只适合低频通知(动画结束、状态切换),绝不适合逐帧回调;
  • runOnUI 同理:投递到 UI 运行时消息队列,轻量但参数按值传递,别传大对象;
  • 参数与返回值都会被「搬运」,搬运的是标量和小结构。需要高频同步大量数据时,正确姿势是放进 SharedValue,而不是来回传参。

9.3 动画原语与编排

withSpring:把界面当物理系统

withSpring 用弹簧物理(质量-阻尼-刚度)驱动数值到目标,速度与「手感」来自物理参数而不是时长:

参数 物理含义 调参直觉
stiffness 刚度(回复力系数) 越大越「硬」,到达目标越快
damping 阻尼(能量耗散速率) 越大回弹越少,接近临界值时无过冲
mass 质量(惯性) 越大越「沉」,加速与减速都慢
velocity 初速度 手势松手时传入手速,动画「接着手势继续」(第 10 章高频使用)
overshootClamping 是否禁止过冲 true 时无回弹,到位即停
restDisplacementThreshold / restSpeedThreshold 静止判定阈值 决定动画何时「收尾」,影响完成回调触发时机

判断回弹强度用阻尼比:ζ = damping / (2 × √(mass × stiffness))。ζ < 1 欠阻尼(有回弹),ζ = 1 临界(最快无过冲),ζ > 1 过阻尼(缓慢无过冲)。默认参数为 damping 10 / stiffness 100 / mass 1,对应 ζ ≈ 0.5,有轻微回弹。

// 配方一:轻点反馈 —— 快、硬、几乎不过冲
// ζ = 20 / (2×√(0.8×420)) ≈ 0.55,但刚度大,过冲微小且被高刚度迅速拉回
withSpring(1, { damping: 20, stiffness: 420, mass: 0.8 });

// 配方二:卡片入位 —— 轻微回弹,有生命感
withSpring(0, { damping: 16, stiffness: 220 });

// 配方三:果冻回弹 —— 明显过冲,用于「完成」时刻
// ζ = 4 / (2×√(1×260)) ≈ 0.12,强欠阻尼,弹两下才停
withSpring(1, { damping: 4, stiffness: 260 });

调参方法:先从配方起步,只动一个参数看效果;要「快而不弹」加 damping,要「弹」减 damping,要「跟手」接 velocity。不要先调 duration——spring 根本没有 duration,手感全部来自物理量。

withTiming / withSequence / withDelay / withRepeat

时间驱动的四件套,负责所有「确定性节奏」:

import { Easing, withDelay, withRepeat, withSequence, withTiming } from 'react-native-reanimated';

// withTiming:固定时长插值,easing 控制节奏曲线
sv.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.cubic) });

// withSequence:串联多段,前一段结束才开始下一段
sv.value = withSequence(
  withTiming(1.1, { duration: 120 }),   // 先放大
  withTiming(1, { duration: 120 }),     // 再收回 —— 经典「pulse」
);

// withDelay:延迟启动(编排错峰入场的主力)
sv.value = withDelay(200, withTiming(1, { duration: 300 }));

// withRepeat:重复。次数 -1 表示无限;reverse=true 时奇偶往返
sv.value = withRepeat(
  withTiming(1, { duration: 800, easing: Easing.inOut(Easing.quad) }),
  -1,   // 无限次
  true, // 往返(否则每次都从 0 跳回起点再开始)
);

interpolate:区间映射与三种越界行为

interpolate 把共享值的当前值按分段线性关系映射到目标区间,是「一个值驱动多重样式」的核心工具:

import { Extrapolation, interpolate } from 'react-native-reanimated';

const width = useDerivedValue(() =>
  interpolate(
    progress.value,
    [0, 0.2, 0.8, 1],     // 输入分段
    [0, 80, 80, 120],     // 输出分段:快速展开 → 平台期 → 缓慢加宽
    Extrapolation.CLAMP,
  ),
);

输入值落在区间外时的三种行为:

Extrapolation 行为 输入 1.2 时的结果(映射 [0,1]→[0,100]) 典型用途
EXTEND(默认) 沿端点斜率线性延伸 120 物理跟手(延伸感)
CLAMP 夹取到端点值 100 进度条、位移上限(最常用)
IDENTITY 区间外返回输入本身 1.2 与其他映射拼接的过渡区

【重点】生产代码里 interpolate 几乎都应该显式传 Extrapolation.CLAMP。默认 EXTEND 会在输入越过边界时让样式无限延伸——负宽度、超视口位移大多源于此。

cancelAnimation、完成回调与 Promise 化

每个动画函数的最后一个参数是完成回调,签名 (finished?: boolean) => void:finished 为 true 表示自然播完,false 表示被 cancelAnimation 或新动画打断。基于此可以把动画封装成 Promise,让动效可以串进 async 业务流:

import {
  cancelAnimation, withSpring, withTiming,
  type SharedValue,
} from 'react-native-reanimated';

type SpringCfg = Parameters<typeof withSpring>[1];
type TimingCfg = Parameters<typeof withTiming>[1];

export function animateTo(
  sv: SharedValue<number>,
  target: number,
  opts: { mode?: 'timing' | 'spring'; config?: TimingCfg | SpringCfg } = {},
): Promise<boolean> {
  const { mode = 'timing', config } = opts;
  return new Promise((resolve) => {
    cancelAnimation(sv); // 先取消旧动画,避免两个动画打架
    sv.value =
      mode === 'spring'
        ? withSpring(target, config as SpringCfg, (finished) => resolve(!!finished))
        : withTiming(target, config as TimingCfg, (finished) => resolve(!!finished));
  });
}

// 用法:await animateTo(scale, 1, { mode: 'spring' }) 后再弹确认框

注意两点:启动新动画前先 cancelAnimation 是纪律(否则两个动画同时驱动一个值,行为未定义);被打断的动画回调会以 finished=false 触发,业务上要区分「播完」与「被顶掉」。

9.4 布局动画:entering / exiting / layout

三个标签各管一件事

布局动画解决的是 React 渲染管线的「突变」问题:挂载、卸载、重排都是一瞬间完成的,而布局动画把它们变成 UI 线程上的连续插值。

import Animated, {
  FadeInDown, FadeOutUp, LinearTransition,
} from 'react-native-reanimated';

function TodoList({ items }: { items: { id: string; title: string }[] }) {
  return (
    <View>
      {items.map((it) => (
        <Animated.View
          key={it.id}
          entering={FadeInDown.springify().damping(15)}          // 挂载入场
          exiting={FadeOutUp.duration(180)}                     // 卸载退场
          layout={LinearTransition.springify().damping(20)}     // 位置/尺寸重排
          style={styles.row}
        >
          <Text>{it.title}</Text>
        </Animated.View>
      ))}
    </View>
  );
}
标签 触发时机 谁在驱动 典型用途 主要注意
entering 组件挂载 Reanimated 在 UI 线程从初始态插值到自然态 新项入场 首帧可能闪现自然态,可用 builder 的 withInitialValues 修正
exiting 组件从树中卸载 React 已移除它,原生视图被 Reanimated 托管到动画结束后才真正销毁 删除收合 动画期间组件逻辑已卸载,其 state/回调不可用
layout 位置或尺寸变化 对比 commit 前后快照做 FLIP 式位移补间 列表重排、高度收合 代价与「可见受影响元素数」成正比

exiting 的托管机制值得单独记住:setState 移除组件的那一刻,React 侧的卸载立即完成(effects 清理、引用失效),只是原生视图被 Reanimated 延迟销毁来播完动画。这意味着 exiting 期间代码不能再触碰该组件实例。

Keyframe:自定义关键帧

内建的 FadeIn/SlideOut 不够用时,Keyframe 允许声明任意百分比处的样式快照:

import { Keyframe } from 'react-native-reanimated';

const popIn = new Keyframe({
  0:   { opacity: 0, transform: [{ scale: 0.6 }, { rotate: '-8deg' }] },
  60:  { opacity: 1, transform: [{ scale: 1.08 }, { rotate: '3deg' }] },
  100: { opacity: 1, transform: [{ scale: 1 }, { rotate: '0deg' }] },
})
  .duration(420)
  .withCallback((finished) => {
    // 与普通动画回调同语义:finished=false 表示被打断
  });

// 用法与 entering 相同
<Animated.View entering={popIn} />;

React 18/19 并发渲染下的时序

新架构搭配 React 18/19 的并发特性后,布局动画有几个必须知道的时序事实:

  1. 卸载即失能。exiting 动画播放时组件已经卸载——在 exiting 期间访问组件 state、调用其回调都是错的。需要「退场后提交业务」时,把回调挂在父组件或用 Keyframe 的 withCallback;
  2. 并发可中断渲染。transition/startTransition 的中断重放会让 entering/layout 的触发时点后移,动画以「最终 commit」为准,中间态不播动画;
  3. StrictMode 双挂载。开发模式下 entering 会触发两次,别在 entering 上做一次性副作用;
  4. 与列表结合的性能红线。layout 动画代价 O(可见受影响项)。给千条级列表的容器套 layout,任何数据变化都会让全部可见项参与补间;正确做法是把 entering/exiting 收在 item 级,layout 只用于真正会变尺寸的最小容器。FlashList 的回收复用与 exiting/重排动画存在相互影响(复用会打断退场),必须在目标版本上实测并以官方兼容性说明为准。

9.5 性能验收:证明动画真的在 UI 线程

busy loop 对照实验

「动画在 UI 线程跑」不能靠信仰,要靠实验。下面这个开发用组件提供三个按钮:两个动画 + 一个把 JS 线程钉死 3 秒的忙循环:

// __perf__/AnimationExperiment.tsx —— 仅开发环境使用
import { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import Animated, {
  Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming,
} from 'react-native-reanimated';

function burnJsThread(ms: number): number {
  const end = Date.now() + ms;
  let i = 0;
  while (Date.now() < end) {
    i += Math.sqrt(i + 1); // 纯 CPU 忙循环,让 JS 线程满载 3 秒
  }
  return i;
}

export function AnimationExperiment() {
  const [jsOpacity, setJsOpacity] = useState(0); // 方案 A:setState 驱动
  const sv = useSharedValue(0);                  // 方案 B:worklet 驱动

  const startA = () => {
    let v = 0;
    let dir = 1;
    const id = setInterval(() => {
      v += dir * 0.05;
      if (v >= 1 || v <= 0) dir = -dir;
      setJsOpacity(v); // 每帧一次 re-render
    }, 16);
    setTimeout(() => clearInterval(id), 10_000);
  };

  const startB = () => {
    sv.value = withRepeat(
      withTiming(1, { duration: 800, easing: Easing.inOut(Easing.quad) }),
      -1,
      true,
    );
  };

  const uiStyle = useAnimatedStyle(() => ({ opacity: 0.2 + 0.8 * sv.value }));

  return (
    <View style={styles.wrap}>
      <View style={[styles.box, { opacity: jsOpacity }]} />
      <Animated.View style={[styles.box, styles.green, uiStyle]} />
      <Button title="A:setState 逐帧动画" onPress={startA} />
      <Button title="B:worklet 动画" onPress={startB} />
      <Button title="JS 线程满载 3 秒" onPress={() => burnJsThread(3000)} />
      <Text>先启动动画,再按满载:观察两个方块谁先停摆。</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  wrap: { gap: 12, padding: 24 },
  box: { width: 120, height: 80, borderRadius: 12, backgroundColor: '#3B82F6' },
  green: { backgroundColor: '#10B981' },
});

预期结果(中端 Android 真机、60Hz 的量级示例,具体数值随设备变化):

实验 setState 驱动(灰块) worklet 驱动(绿块)
JS 空闲 ~60fps 流畅 60fps 流畅
JS 满载 3s 期间 明显冻结,常掉到个位数 fps 完全平滑,肉眼无差异
动画期间组件 re-render 与帧率同量级(≈60 次/秒) 0 次

这是第 1 章线程模型最直接的验证:JS 线程死锁时,只有不依赖 JS 的动画还活着。把它放进回归清单,每个新动画组件合入前跑一次。

帧率验证工具(第 2 章方法论的动画落地)

  • Dev Menu 的 Perf Monitor:看 JS 与 UI 两条线程的实时帧率,对照实验用它是首选;
  • Android:adb shell dumpsys gfxinfo <package> 查看长帧直方图(janky frames 与 90th/95th/99th 百分位);
  • iOS:Xcode Instruments 的 Animation Hitches / Time Profiler 定位掉帧归属线程;
  • re-render 观测:React DevTools 的 Highlight updates + Profiler,确认动画期间没有重渲染;
  • 动画内窥:Reanimated 的 useFrameCallback 可以在 UI 线程逐帧取样共享值,用来绘制调试 HUD。

动画期间避免 re-render 清单

动画需求 setState 做法(成本) SharedValue 做法(成本)
逐帧位移/缩放/透明度 每帧 render + reconcile + commit 0 次 re-render,UI 线程直写
滚动联动视差 onScroll 里 setState,帧帧重渲染 useAnimatedScrollHandler,UI 线程闭环
手势跟随 Responder 回调里 setState 手势回调即 worklet(第 10 章)
值变化触发副作用 useEffect 监听 state useAnimatedReaction:UI 线程观察,必要时 runOnJS 一次性通知

useAnimatedReaction 是「动画值 → 副作用」的标准桥:prepare 函数返回被观察值,值变化时 react 函数在 UI 线程执行,需要进入 JS 世界时再 runOnJS——高频观察、低频通知。

9.6 常用动效模式手册

以下五个模式覆盖日常 80% 的动效需求,代码可直接复用。统一约定:动画值只进 SharedValue,state 只承载业务状态。

模式一:弹性按压按钮

import { Pressable, PressableProps, StyleSheet } from 'react-native';
import Animated, {
  useAnimatedStyle, useSharedValue, withSpring,
} from 'react-native-reanimated';

export function PressableScale({ style, children, ...rest }: PressableProps) {
  const pressed = useSharedValue(0); // 0 未按 / 1 按下

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { scale: withSpring(1 - pressed.value * 0.08, { damping: 18, stiffness: 400 }) },
    ],
    opacity: 1 - pressed.value * 0.35,
  }));

  return (
    <Pressable
      onPressIn={() => (pressed.value = 1)}
      onPressOut={() => (pressed.value = 0)}
      {...rest}
    >
      <Animated.View style={[style, animatedStyle]}>{children}</Animated.View>
    </Pressable>
  );
}

要点:按压状态本身是 0/1 阶跃值,但样式经 withSpring 平滑,于是「状态跳变」呈现为「物理过渡」——这是 SharedValue 驱动的通用心法:值可以突变,样式永远连续。

模式二:数字滚动(useDerivedValue + interpolate)

import { useEffect } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
  Easing, useDerivedValue, useSharedValue, withTiming,
} from 'react-native-reanimated';

export function RollingNumber({ value, duration = 600 }: { value: number; duration?: number }) {
  const progress = useSharedValue(0);

  useEffect(() => {
    progress.value = withTiming(value, {
      duration,
      easing: Easing.out(Easing.cubic),
    });
  }, [value, duration, progress]);

  // useDerivedValue:派生值。任何被它依赖的共享值变化都会触发重算
  const text = useDerivedValue(() => `${Math.round(progress.value)}`);

  return <Animated.Text style={styles.num}>{text}</Animated.Text>;
}

const styles = StyleSheet.create({
  num: { fontSize: 32, fontWeight: '700', fontVariant: ['tabular-nums'] },
});

⚠️ 数字格式化(千分位、货币符号)如果必须做,用简单的字符串拼接在 worklet 内完成;Intl.NumberFormat 依赖宿主环境,UI 运行时未必可用,复杂格式请回到 JS 侧处理。

模式三:骨架屏 shimmer

import { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
  Easing, interpolate, useAnimatedStyle, useSharedValue, withRepeat, withTiming,
} from 'react-native-reanimated';

export function Shimmer({ width = 200, height = 16 }: { width?: number; height?: number }) {
  const t = useSharedValue(0);

  useEffect(() => {
    t.value = withRepeat(
      withTiming(1, { duration: 1200, easing: Easing.inOut(Easing.quad) }),
      -1,
      false,
    );
  }, [t]);

  const highlight = useAnimatedStyle(() => ({
    transform: [{ translateX: interpolate(t.value, [0, 1], [-width, width]) }],
  }));

  return (
    <View
      style={{
        width, height, borderRadius: 8, overflow: 'hidden', backgroundColor: '#E5E7EB',
      }}
    >
      <LinearGradient
        colors={['transparent', '#FFFFFFB3', 'transparent']}
        start={{ x: 0, y: 0 }}
        end={{ x: 1, y: 0 }}
        style={[StyleSheet.absoluteFill, highlight]}
      />
    </View>
  );
}

⚠️ 无限 shimmer 必须「谁用谁可停」:列表退出前台、对应骨架卸载时应 cancelAnimation,否则返回页面后 UI 线程仍在空转(见常见陷阱表第 6 行)。

模式四:页面元素进出编排(withSequence + withDelay)

import { Button, StyleSheet, View } from 'react-native';
import Animated, {
  interpolate, useAnimatedStyle, useSharedValue,
  withDelay, withRepeat, withSequence, withTiming,
} from 'react-native-reanimated';

function NoticeBar() {
  const bar = useSharedValue(0);   // 整条:透明度 + 纵向揭示
  const icon = useSharedValue(0);  // 图标:缩放 + 弹跳
  const text = useSharedValue(0);  // 文本:左移入场

  // 编排写法:三个值三条时间线,错峰全靠 withDelay
  const replay = () => {
    bar.value = 0; icon.value = 0; text.value = 0;
    bar.value = withSequence(withTiming(1, { duration: 200 }));
    icon.value = withDelay(120, withSequence(
      withTiming(1, { duration: 160 }),
      withRepeat(withTiming(0.75, { duration: 260 }), 2, true), // 弹两次提醒
      withTiming(1, { duration: 160 }),
    ));
    text.value = withDelay(260, withTiming(1, { duration: 220 }));
  };

  const barStyle = useAnimatedStyle(() => ({
    opacity: interpolate(bar.value, [0, 1], [0, 1]),
    transform: [{ scaleY: interpolate(bar.value, [0, 1], [0.2, 1]) }],
  }));
  const iconStyle = useAnimatedStyle(() => ({
    opacity: icon.value,
    transform: [{ scale: interpolate(icon.value, [0, 1], [0.5, 1]) }],
  }));
  const textStyle = useAnimatedStyle(() => ({
    opacity: text.value,
    transform: [{ translateX: interpolate(text.value, [0, 1], [-12, 0]) }],
  }));

  return (
    <View style={{ gap: 12, padding: 24 }}>
      <Animated.View style={[styles.bar, barStyle]}>
        <Animated.Text style={[styles.icon, iconStyle]}>✓</Animated.Text>
        <Animated.Text style={[styles.text, textStyle]}>已同步 12 项改动</Animated.Text>
      </Animated.View>
      <Button title="重播编排" onPress={replay} />
    </View>
  );
}

const styles = StyleSheet.create({
  bar: {
    flexDirection: 'row', alignItems: 'center', gap: 8,
    backgroundColor: '#ECFDF5', borderRadius: 12, padding: 12,
  },
  icon: { color: '#10B981', fontSize: 18, fontWeight: '700' },
  text: { color: '#065F46' },
});

编排的三条纪律:每条时间线先显式归零再赋新动画;错峰用 withDelay 而不是靠时长的巧合;withRepeat 放进 withSequence 中段做「有限次强调」,次数永远写死而不是 -1。

模式五:依赖手势的跟随动画(第 10 章的最小铺垫)

先给出「手势值 → 动画」的最小闭环,让你在本章就能跑通;手势系统本身的仲裁、阈值、组合在第 10 章系统展开。

import { StyleSheet } from 'react-native';
import Animated, {
  useAnimatedStyle, useSharedValue, withSpring,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

export function FollowDot() {
  const x = useSharedValue(0);
  const y = useSharedValue(0);
  const baseX = useSharedValue(0); // 上一次停泊位置(offset 累计模式)
  const baseY = useSharedValue(0);

  const pan = Gesture.Pan()
    .onBegin(() => {
      baseX.value = x.value;
      baseY.value = y.value;
    })
    .onUpdate((e) => {
      // 手势回调默认就是 worklet:这里运行在 UI 运行时
      x.value = baseX.value + e.translationX;
      y.value = baseY.value + e.translationY;
    })
    .onFinalize(() => {
      x.value = withSpring(0, { damping: 16, stiffness: 220 });
      y.value = withSpring(0, { damping: 16, stiffness: 220 });
    });

  const style = useAnimatedStyle(() => ({
    transform: [{ translateX: x.value }, { translateY: y.value }],
  }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={[styles.dot, style]} />
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  dot: {
    width: 64, height: 64, borderRadius: 32,
    backgroundColor: '#3B82F6', alignSelf: 'center',
  },
});

注意运行环境:RNGH 要求根组件包在 GestureHandlerRootView 内,且需要安装 react-native-gesture-handler。这个例子之所以丝滑,是因为从手指到屏幕的整条链路(手势事件 → 值更新 → 样式直写)全部发生在 UI 侧,JS 线程全程没有参与——第 10 章会解释这套机制的完整原理。

典型业务场景:FieldNote 实战

FieldNote 的任务详情页是检查项列表:每项可打分(1~5)+ 备注 + 勾选完成(里程碑 M9)。产品要求勾选时有「打勾被画出来 + 圆圈弹性 + 备注收合」的组合动效,并且千条级列表滚动时不能因为这个组件拖慢 JS 线程。

设计决策:

  • 勾选是低频业务状态,用 state 承载并向上提交;逐帧过程(描边进度、弹跳、透明度)全部走 SharedValue;
  • 打勾用 SVG 路径的 strokeDashoffset 从「画不出」插值到「画完」,比透明度渐显高级且同样零逐帧 setState;
  • 备注区用 entering/exiting 收合,外层卡片用 layout 标签承接高度变化;
  • 无任何逐帧 setState、无 withRepeat 残留。

完整组件:

// src/features/inspection/components/CheckItem.tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Svg, { Path } from 'react-native-svg';
import Animated, {
  Extrapolation,
  FadeInDown,
  FadeOutUp,
  interpolate,
  interpolateColor,
  LinearTransition,
  useAnimatedProps,
  useAnimatedStyle,
  useSharedValue,
  withSequence,
  withSpring,
  withTiming,
} from 'react-native-reanimated';

const AnimPath = Animated.createAnimatedComponent(Path);

// 对勾路径总长取一个略大于实际长度的值:
// strokeDashoffset = PATH_LEN 时笔画完全「收起」,0 时完全「画出」
const PATH_LEN = 28;

export type CheckItemProps = {
  title: string;
  /** 检查项得分 1~5 */
  score: number;
  note?: string;
  done: boolean;
  onToggle: (next: boolean) => void;
};

export function CheckItem({ title, score, note, done, onToggle }: CheckItemProps) {
  // ① 打勾绘制进度:0→1,驱动 SVG 描边、圆环颜色、分数徽标,全程 UI 线程
  const check = useSharedValue(0);
  // ② 弹跳能量:勾选瞬间给圆环一次 spring 过冲
  const bounce = useSharedValue(0);

  const handleToggle = () => {
    const next = !done;
    if (next) {
      check.value = 0; // 显式归零再启动,防止连点时动画接续错乱
      check.value = withTiming(1, { duration: 220 });
      bounce.value = withSequence(
        withTiming(0, { duration: 40 }),
        withSpring(1, { damping: 4, stiffness: 260 }), // 强欠阻尼:明显过冲回弹
      );
    } else {
      check.value = withTiming(0, { duration: 150 });
      bounce.value = withTiming(0, { duration: 150 });
    }
    onToggle(next); // 低频状态切换走 React;逐帧动画值从不进 setState
  };

  // ③ 圆环:底色随 check 从灰到绿;缩放由 bounce 驱动,spring 过冲 >1 制造弹性
  const ringStyle = useAnimatedStyle(() => ({
    backgroundColor: interpolateColor(check.value, [0, 1], ['#D1D5DB', '#10B981']),
    transform: [{ scale: interpolate(bounce.value, [0, 1], [0.82, 1]) }],
  }));

  // ④ 对勾描边:strokeDashoffset 从 PATH_LEN → 0,视觉上「一笔画出」
  const pathProps = useAnimatedProps(() => ({
    strokeDashoffset: interpolate(check.value, [0, 1], [PATH_LEN, 0], Extrapolation.CLAMP),
  }));

  // ⑤ 分数徽标:随完成度提亮并轻微放大
  const scoreStyle = useAnimatedStyle(() => ({
    opacity: interpolate(check.value, [0, 1], [0.55, 1], Extrapolation.CLAMP),
    transform: [{ scale: interpolate(check.value, [0, 1], [0.94, 1], Extrapolation.CLAMP) }],
  }));

  return (
    <Animated.View
      layout={LinearTransition.springify().damping(20).stiffness(220)}
      style={styles.card}
    >
      <Pressable
        style={styles.row}
        onPress={handleToggle}
        accessibilityRole="checkbox"
        accessibilityState={{ checked: done }}
        accessibilityLabel={`检查项 ${title},${done ? '已完成' : '未完成'},得分 ${score}`}
      >
        <Animated.View style={[styles.ring, ringStyle]}>
          <Svg width={20} height={20} viewBox="0 0 32 32">
            <AnimPath
              d="M 8 16 L 14 22 L 24 9"
              stroke="#FFFFFF"
              strokeWidth={3.5}
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeDasharray={PATH_LEN}
              fill="none"
              animatedProps={pathProps}
            />
          </Svg>
        </Animated.View>

        <View style={styles.texts}>
          <Text style={[styles.title, done && styles.titleDone]} numberOfLines={2}>
            {title}
          </Text>
          {note !== undefined && (
            <Animated.View entering={FadeInDown.duration(180)} exiting={FadeOutUp.duration(150)}>
              <Text style={styles.note} numberOfLines={1}>
                {note || '(无备注)'}
              </Text>
            </Animated.View>
          )}
        </View>

        <Animated.View style={[styles.scorePill, scoreStyle]}>
          <Text style={styles.scoreText}>{score}</Text>
        </Animated.View>
      </Pressable>
    </Animated.View>
  );
}

const styles = StyleSheet.create({
  card: {
    backgroundColor: '#FFFFFF',
    borderRadius: 12,
    marginHorizontal: 12,
    marginVertical: 4,
    elevation: 1,
  },
  row: { flexDirection: 'row', alignItems: 'center', padding: 12, gap: 12 },
  ring: {
    width: 28, height: 28, borderRadius: 14,
    alignItems: 'center', justifyContent: 'center',
  },
  texts: { flex: 1, gap: 2 },
  title: { fontSize: 15, color: '#111827' },
  titleDone: { color: '#9CA3AF', textDecorationLine: 'line-through' },
  note: { fontSize: 12, color: '#6B7280' },
  scorePill: {
    minWidth: 32, borderRadius: 16, paddingVertical: 4,
    backgroundColor: '#EEF2F7', alignItems: 'center',
  },
  scoreText: { fontSize: 13, fontWeight: '700', color: '#334155' },
});

在详情页列表中的接入(配合 FlashList):

<FlashList
  data={checkItems}
  estimatedItemSize={64}
  keyExtractor={(item) => item.id}
  renderItem={({ item }) => (
    <CheckItem
      title={item.title}
      score={item.score}
      note={item.note}
      done={item.done}
      onToggle={(next) => inspectionStore.setItemDone(item.id, next)}
    />
  )}
/>

逐段复盘:

  • ①②:两个共享值各司其职——check 是「叙事进度」(画勾),bounce 是「物理反馈」(弹跳)。它们只在按压瞬间启动,天然无残留;
  • ③:interpolateColor 在两个颜色间插值,颜色属于绘制属性,Animated 的原生驱动做不了,Reanimated 直接驱动——这就是 9.1 节属性表的活例子;
  • ④:SVG 路径描边动画是「画出」类动效的通用手法:strokeDasharray 设为路径长,动画 strokeDashoffset 即可;路径真实长度约 25,取 28 留余量;
  • 勾选切换时 onToggle 只调用一次——React 只知道「完成与否」,不知道「画到哪了」;
  • 卡片的 layout 标签让备注行增删时高度变化平滑收合;备注区的 entering/exiting 只作用于单个小组件,不放大到列表容器。

对照实验数据(同一台中端 Android 真机、60Hz,busy loop 满载 JS 线程 3 秒;量级示例,验收以你自己设备的测量为准):

指标 setState 逐帧版(对照实现) 本节 worklet 版
JS 空闲时连续勾选 20 项 流畅,但每次勾选连带多次 re-render 流畅,re-render 仅每项 1 次
JS 满载 3s 期间连续勾选 平均帧率 ≈ 8~15fps,动画肉眼可见冻结 平均帧率 ≈ 58~60fps,无感知差异
长帧(>32ms)次数 / 百次勾选 大量 50~200ms 长帧 ≈ 0
动画期间本组件 re-render 次数 与帧率同量级 0

这张表就是 9.5 节方法论落到 FieldNote 的验收记录。JS 线程满载并不罕见——千条列表滚动、离线包解压、同步引擎批量写入(第 14 章起)都会占它,动画必须对 JS 线程的坏天气免疫。

常见陷阱

陷阱 典型症状 根因 正确做法
在 worklet 里调用非 worklet 函数 报错 Tried to synchronously call a non-worklet function on the UI thread babel 只序列化带 worklet 标记的函数,UI 运行时里根本没有它 给函数体加 'worklet' 指令;确需 JS 环境的能力时用 runOnJS(fn)()
用 setState 更新动画值 动画期间 CPU 高、列表掉帧、DevTools 里 re-render 刷屏 逐帧过程被塞进 React 渲染管线,每帧走 render+commit 换 useSharedValue + useAnimatedStyle,state 只承载业务状态
在样式/渲染里把 .value 读出来当普通值用,或混用 .value 与新版 .get()/.set() 语义 样式停在某帧不动;升级版本后 lint 报弃用警告 render 期间读 .value 拿到的是一次性快照,不会随动画更新;新版本以 .get()/.set() 为推荐 API(以官方文档为准) 样式一律经 useAnimatedStyle 在 worklet 内引用共享值;写法跟随当前安装版本的官方文档
闭包捕获巨型数组/大对象 动画首次启动卡 50~200ms,内存占用翻倍 babel 把捕获物序列化进 worklet,数据在两个运行时各存一份 JS 侧算好只传结果标量;确需共享大量数据放 SharedValue
布局动画套在长列表或其容器上 任一数据变化,全列表可见项一起「抽搐」重排 layout 代价 O(可见受影响项),容器级标签把作用域放大 entering/exiting 收在 item 级;layout 只用于最小变尺寸容器;FlashList 场景实测
withRepeat(-1) 未清理 返回页面后 CPU 仍高,动画对象残存 无限动画不会自行结束,组件卸载不等于动画停止 useEffect cleanup 里 cancelAnimation(sv),或改为事件驱动
在动画完成回调里高频 setState(如 withRepeat 每轮回调) 周期性掉帧、re-render 风暴 每次重复都触发回调 → 进 JS → setState → 渲染 回调只做低频收尾;高频反应改用 useAnimatedReaction + 条件去抖
在 worklet 里使用 Intl/宿主专有 API 或 async 链 报错或行为不一致 UI 运行时是精简环境,可用 API 是子集 格式化与异步编排留在 JS 侧;worklet 只处理数值与插值

最佳实践

  1. 新项目默认 Reanimated;Animated + useNativeDriver 只用于维护遗留代码,且优先迁移到 Reanimated;
  2. 「逐帧更新的东西一律 SharedValue」——state 承载业务状态,不承载动画过程;
  3. useAnimatedStyle 内只做轻计算(插值、四则运算);重计算放 useDerivedValue 派生,或提前在 JS 算好;
  4. spring 优先于 timing:物理参数让动效可被手势「接管」(第 10 章),timing 用于确定性节奏;参数从配方起步再微调;
  5. interpolate 显式传 Extrapolation.CLAMP,除非明确需要 EXTEND 的延伸感;
  6. 布局动画最小作用域:entering/exiting 到 item,layout 到最小变尺寸容器;千条级列表入场不做逐项 stagger;
  7. 每个新动画组件合入前跑一次 busy loop 对照实验(9.5 节),并记录帧率数据;
  8. 所有 withRepeat 必须可取消:useEffect cleanup 里 cancelAnimation,或由手势/业务事件驱动启停;
  9. worklet 函数保持短小(几十行内),大逻辑拆成 worklet 化的工具函数复用;闭包只捕标量;
  10. 「动画值 → 副作用」用 useAnimatedReaction,不写「共享值 + useEffect 轮询」;
  11. 动效时长遵守平台直觉:反馈类 100~200ms,过渡类 200~350ms;连续快速操作(打勾、翻页)不做入场动画;
  12. 动效参数集中导出为 motion tokens(spring/timing 常量表),禁止魔法数散落各组件。

本章小结

本章要点回顾:

  • setState 驱动动画掉帧的根源是帧更新链路串行依赖 JS 线程;Animated + useNativeDriver 把插值下沉到原生,但只覆盖 transform/opacity 等合成属性,因为原生模块不拥有布局能力;
  • Reanimated 的答案是双运行时:UI 侧跑一个独立小型 JS 运行时,worklet 在编译期被序列化、运行时在 UI 侧重建;闭包捕获决定成本,SharedValue 是跨线程同步变量(写是发布、读是快照),runOnJS/runOnUI 是有调度开销的低频通道;
  • 动画原语:withSpring 用阻尼比调手感,withTiming/Sequence/Delay/Repeat 负责节奏,interpolate 的三种越界行为里 CLAMP 是默认保险,完成回调可 Promise 化;
  • 布局动画三标签中 exiting 的「视图托管」机制决定了卸载即失能;并发渲染下以最终 commit 为准;
  • 验收靠 busy loop 对照实验与帧率工具,动画期间 re-render 应为 0。

一句话记忆点:动画值不下 JS 线程——逐帧的过程归 SharedValue 与 worklet,React 只在开始与结束时知情。

动手练习

  1. 基础巩固(呼吸灯):实现一个「设备在线状态指示灯」组件:绿色圆点以正弦感的节奏在 opacity 0.3~1 之间呼吸。要求使用 withRepeat + interpolate,说明 reverse 参数与次数 -1 的含义,并保证组件卸载后动画停止(写 cleanup)。进阶:把周期做成可配置 prop,观察 withRepeat 嵌套 withTiming 的行为。
  2. FieldNote 实战 A(快门按钮):为自定义相机取景器(见第 8 章组件)实现快门按钮:按下缩放到 0.88 并压暗,松手 spring 回弹;触发拍照时整屏白闪(白色覆盖层 opacity 用 withSequence 从 1 快速衰减到 0)。要求全程零逐帧 setState,并说明为什么白闪不用 withTiming 反向播放。
  3. FieldNote 实战 B(完成度滚动 + 列表入场):在任务详情页顶部加一个「完成 n/m 项」的 RollingNumber(参考模式二扩展:先插值到 n,分母 m 静态显示);列表首屏入场时前 10 项做 40ms 错峰的 FadeInDown(entering 的 .delay()),第 11 项起直接显示。提示:错峰入场只对小集合做,想想为什么。
  4. 排查/思考题:为什么 Reanimated 选择「把一个小 JS 运行时搬到 UI 线程」,而不是「把原生动画的逐帧回调搬回 JS 线程」?请从两个角度分析:(a) 帧预算——60Hz/120Hz 下每帧分别只有多少毫秒,逐帧跨线程往返要付出什么;(b) 线程职责——显示节拍由谁拥有、动画计算放在谁的旁边才能保证不排队。最后回答:如果 JS 线程已经忙到平均 12ms/帧,两种方案各自的帧率上限在哪里?

导航

上一章 第 8 章 原生组件封装(Fabric) | 下一章 第 10 章 手势系统与 Gesture Handler

📄 第 10 章 手势系统与 Gesture Handler 10-gestures.md

本章导读:手势是「动画的输入端」。第 9 章解决了「帧更新不进 JS 线程」,本章解决另一半:手势事件与仲裁也不进 JS 线程。本章讲清 RN 内置 Responder System 的局限、react-native-gesture-handler(下称 RNGH)的原生识别架构、Gesture API 的组合仲裁,并落地 FieldNote 的卡片左滑操作栏与长按拖拽排序骨架——完整拖拽排序将在第 11 章综合实战中收尾。

本章学习目标

  • 能说清 Responder System 的裁决流程与三大局限,解释为什么复杂交互组件不应建立在它之上;
  • 能描述 RNGH 的核心思路:平台原生手势识别器 + UI 线程裁决 + 事件经 JSI 直达 worklet;
  • 能画出手势状态机 begin→active→end/fail/cancel,并据此推断任意回调组合的触发顺序;
  • 能熟练配置 Tap/LongPress/Pan/Pinch/Rotation/Fling 的阈值参数,理解「激活阈值/失败阈值」构成的方向锁;
  • 能用 Race/Exclusive/Simultaneous、requireExternalGestureToFail、manualActivation 治理手势冲突,处理与 ScrollView/FlashList 滚动的共存;
  • 能落地「手势 × 动画」标准范式:offset 累计、松手落位判定(含速度)、cancel 兜底;
  • 能为手势操作补齐可访问性等价物,并规避系统返回手势边缘的误触;
  • 能在 FieldNote 中实现左滑操作栏与长按拖拽排序骨架:手势期间零 setState、零逐帧 JS 通信。

核心知识点

10.1 为什么需要 Gesture Handler:Responder System 的原理与局限

Responder System:一套跑在 JS 里的触摸仲裁

RN 内置的触摸体系是 Responder System。它的全部裁决都发生在 JS 线程:

触点按下
   │ ① 系统触摸事件进入原生主线程
   ▼
RN 根视图捕获触摸流,打包派发
   │ ② 事件进入 JS 线程(新架构经 JSI,延迟比旧 Bridge 小,
   │    但「裁决在 JS」这个结构没变)
   ▼
JS 线程:Responder 协商
   │  逐层询问 capture/bubble 两侧的 shouldSetResponder
   │  (onStartShouldSetResponder / onMoveShouldSetResponderCapture ...)
   │  裁决出全局唯一的 Responder
   ▼
onResponderGrant / onResponderMove 逐帧回调(JS)
   │ ③ 每帧:JS 计算 → setState / props 更新
   ▼
UI 线程渲染

用 Responder System 手写一个「左滑删除」,就是这种残缺形态:

// ❌ 用 Responder System 手写左滑:裁决慢、逐帧 setState、与滚动打架
function LegacyRow() {
  const [dx, setDx] = useState(0);
  const startX = useRef(0);

  return (
    <View
      onStartShouldSetResponder={() => true}
      onMoveShouldSetResponder={() => true}
      onResponderGrant={(e) => { startX.current = e.nativeEvent.pageX; }}
      onResponderMove={(e) =>
        setDx(e.nativeEvent.pageX - startX.current) // 逐帧 setState
      }
      onResponderRelease={() => setDx(0)}
    >
      <View style={{ transform: [{ translateX: dx }] }}>
        <Text>任务卡片</Text>
      </View>
    </View>
  );
}

它的三大局限:

  1. 裁决在 JS。触摸发生后,要不要响应、谁来响应,都要等 JS 线程执行协商函数。JS 繁忙时(列表渲染、解析),手势的「第一反应」延迟明显,用户感知为「钝」;
  2. 逐帧回调在 JS。onResponderMove 每帧进 JS,配合 setState 就回到第 9 章批判过的那条成本链;
  3. 与原生手势天然竞争。ScrollView 的滚动是原生识别器在驱动的,Responder 的协商与它互相拉扯,嵌套场景(横滑列表里的竖滚、滚动列表里的左滑行)极易出现「抢」与「僵」。

RNGH 的核心思路:把裁决下沉到原生

RNGH 的做法是把各平台成熟的原生手势识别器(Android 的 GestureDetector/OnTouchListener 体系、iOS 的 UIGestureRecognizer 体系)桥接进 RN,让手势的识别与仲裁在原生(UI 线程)完成;识别成功后的事件,在新架构下经 JSI 直接递给 worklet——也就是第 9 章的 UI 运行时。于是「手指到像素」的整条链路可以完全不经过 JS 线程:

维度 Responder System RNGH 2.x Gesture API
仲裁位置 JS 线程 原生(UI 线程)
识别能力 手写逻辑(长按计时、位移阈值全靠自实现) 平台识别器内置(阈值、多点、速度)
逐帧事件去向 JS worklet(UI 运行时),需要时才 runOnJS
与滚动视图的仲裁 JS 层拉扯,微妙易碎 原生阈值与激活/失败机制,可配置
回调执行环境 JS worklet(装了 Reanimated 时)
适合场景 简单点击 一切复杂交互组件

【重点】RNGH 与 Reanimated 是一对共生体:手势回调在原生被识别,然后以 worklet 的形式在 UI 运行时执行,更新 SharedValue 驱动样式——识别、计算、渲染三段全部在 UI 侧闭环。这也是为什么装了 Reanimated 时,Gesture API 的回调里可以直接写插值与 spring。

使用前置条件只有一条:整棵树包在 GestureHandlerRootView 内(通常放在导航容器外层):

// App.tsx —— RNGH 的全局根
import { GestureHandlerRootView } from 'react-native-gesture-handler';

export default function App() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <NavigationContainer>{/* ... */}</NavigationContainer>
    </GestureHandlerRootView>
  );
}

历史包袱的说明:RNGH 1.x 与 2.x 早期以「组件」形式暴露手势(PanGestureHandler、TapGestureHandler),配合 createNativeWrapper 包装原生视图参与手势协商。这些旧 API 仍在维护、用于兼容,但新代码一律使用本章的 Gesture API + GestureDetector:它把「创建手势 → 配置 → 组合 → 绑定」收进一组链式调用,类型更完整,也只在这个 API 上继续演进。

10.2 手势识别原理与 Gesture API 全家桶

状态机:begin→active→end/fail/cancel

每个 RNGH 手势实例在原生层是一台状态机。触点序列驱动它在状态间迁移,每个迁移对应一类回调:

stateDiagram-v2
    [*] --> UNDETERMINED: 等待触点
    UNDETERMINED --> BEGIN: 触点落下,成为候选 onBegin
    BEGIN --> ACTIVE: 满足激活阈值 onStart
    BEGIN --> FAIL: 超时或触发失败阈值
    ACTIVE --> END: 手势正常完成 onEnd
    ACTIVE --> CANCEL: 被系统或更高优先级手势接管
    END --> UNDETERMINED: 复位 onFinalize
    FAIL --> UNDETERMINED: 复位 onFinalize
    CANCEL --> UNDETERMINED: 复位 onFinalize

回调与状态的对应关系,用一段可运行的小组件验证(手势回调默认是 worklet,所以日志文本放进 SharedValue 直接显示):

import { StyleSheet, Text } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
import { Gesture, GestureDetector, State } from 'react-native-gesture-handler';

export function LifecycleDemo() {
  const last = useSharedValue('等待触点');

  const pan = Gesture.Pan()
    .onBegin(() => { last.value = 'BEGIN   触点落下(候选)'; })
    .onStart(() => { last.value = 'ACTIVE  激活'; })
    .onUpdate(() => { last.value = 'ACTIVE  更新中'; })
    .onEnd(() => { last.value = 'END     正常结束'; })
    .onFinalize((e) => {
      // onFinalize 在 END/FAIL/CANCEL 三种终态都会触发,用于兜底
      if (e.state === State.FAILED) last.value = 'FAIL    未满足阈值';
      if (e.state === State.CANCELLED) last.value = 'CANCEL  被接管';
    });

  const style = useAnimatedStyle(() => ({ opacity: 1 }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={[styles.pad, style]}>
        <Animated.Text style={styles.text}>{last}</Animated.Text>
      </Animated.View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  pad: { height: 120, borderRadius: 16, backgroundColor: '#EEF2F7', padding: 16 },
  text: { fontSize: 14, color: '#334155' },
});

三条必须内化的时序规则:

  1. onBegin ≠ 激活。BEGIN 只表示「这个手势成了候选」,还没赢;onStart 才对应 ACTIVE;
  2. onEnd 只在 END 触发。被滚动接管、被系统中断走的是 CANCEL,onEnd 不会执行——清理逻辑必须放在 onFinalize;
  3. onFinalize 是总收尾。无论哪种终态都会触发,是「绝不停留在中间态」的兜底位置。

六个基础手势的配置面

手势 关键配置 进入 ACTIVE 的时机 高频回调
Tap numberOfTaps、maxDist、maxDuration 松手且全部阈值满足 onEnd(event, success)
LongPress minDuration(默认 500ms)、maxDist 按住达到时长且位移未超限 onStart / onEnd
Pan activeOffsetX/Y、failOffsetX/Y、minDistance、minPointers/maxPointers、minVelocity、activateAfterLongPress 位移越过激活阈值且未触发失败阈值 onUpdate / onEnd(含 velocityX/Y)
Fling direction(可用位或组合)、numberOfPointers 速度与方向同时达标(快速滑动) onStart
Pinch —(默认即用) 双指间距发生变化 onUpdate(scale, velocity)
Rotation — 双指角度发生变化 onUpdate(rotation, velocity)

激活阈值与失败阈值是 Pan 的灵魂,两者配合构成方向锁:

import { Gesture, Direction } from 'react-native-gesture-handler';

// 横向拖拽条:横向 ±10px 内先到则激活;纵向先动 8px 则永久判负
const horizontalPan = Gesture.Pan()
  .activeOffsetX([-10, 10])
  .failOffsetY([-8, 8]);

// 纵向拖拽:镜像配置
const verticalPan = Gesture.Pan()
  .activeOffsetY([-10, 10])
  .failOffsetX([-8, 8]);

// 双击点赞
const doubleTap = Gesture.Tap()
  .numberOfTaps(2)
  .maxDuration(350)
  .onEnd((_e, success) => {
    if (success) runOnJS(like)();
  });

// 长按多选:位移超 12px 判负,避免「想拖却触发了长按」
const longPress = Gesture.LongPress()
  .minDuration(400)
  .maxDist(12)
  .onStart(() => { /* 进入选择态 */ });

// 左右 fling 翻页
const fling = Gesture.Fling()
  .direction(Direction.LEFT | Direction.RIGHT)
  .numberOfPointers(1)
  .onStart((e) => { /* 判断 e.x 落在哪半屏决定翻页方向 */ });

💡 阈值的本质是给原生仲裁器的输入:越界即裁决,不进 JS。把阈值想清楚再写代码,能消灭 90% 的「手势时灵时不灵」。

10.3 手势组合与仲裁

三种组合语义

多个手势共存时,RNGH 用组合器声明它们的竞争关系:

组合器 语义 典型场景
Gesture.Race(a, b) 赛跑:谁先激活谁赢,其余整场判负 长按抢到则平移放弃
Gesture.Exclusive(a, b) 排他:依序尝试,前面的判负才轮到后面 双击 vs 单击(双击优先)
Gesture.Simultaneous(a, b, ...) 并行:各自识别互不阻塞 缩放 + 旋转 + 平移三合一
// 双击 vs 单击:必须 Exclusive,且双击放前面
const doubleTap = Gesture.Tap().numberOfTaps(2).onEnd((_e, s) => { if (s) runOnJS(like)(); });
const singleTap = Gesture.Tap().onEnd((_e, s) => { if (s) runOnJS(openDetail)(); });
const taps = Gesture.Exclusive(doubleTap, singleTap);
// 代价:单击要等双击窗口(约 300ms)判负后才触发,这是双击语义的固有延迟

// 图片查看器三合一
const transform = Gesture.Simultaneous(pinch, rotation, pan);

跨组件引用手势需要 withRef + requireExternalGestureToFail:例如「行内单击按钮」要等「列表级双击手势」判负后才能激活,避免双击第二落点误触按钮:

import { useRef } from 'react';
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler';

function RowActions({ onOpen }: { onOpen: () => void }) {
  const listDoubleTap = useRef<GestureType>(null); // 由列表级 GestureDetector 通过 withRef 暴露

  const open = Gesture.Tap()
    .requireExternalGestureToFail(listDoubleTap) // 等外部双击判负
    .onEnd((_e, s) => { if (s) runOnJS(onOpen)(); });

  return (
    <GestureDetector gesture={open}>
      <Text style={styles.openBtn}>打开</Text>
    </GestureDetector>
  );
}

配套的两个外部关系声明:

  • simultaneousWithExternalGesture(ref):允许与外部手势(如滚动容器)并行识别,由各自阈值决定胜负;
  • blocksExternalGesture(ref):本手势激活期间阻止外部手势激活(例如拖拽小球时压住滚动)。

manualActivation:激活条件自己写

默认情况下,每个手势按内置规则自动激活。manualActivation 把激活权交给你的 worklet:在 touches 回调里调用 stateManager.activate()/fail() 决定接管或认输。它是「长按才可拖拽」「特定区域才可拖」这类需求的唯一正解:

import { Gesture, GestureDetector, State } from 'react-native-gesture-handler';
import { useSharedValue } from 'react-native-reanimated';

function ManualPanDemo() {
  const heldSince = useSharedValue(0);
  const startY = useSharedValue(0);

  const pan = Gesture.Pan()
    .manualActivation(true)
    .maxPointers(1)
    .onTouchesDown((e) => {
      heldSince.value = Date.now(); // worklet 内可用 Date.now()
      startY.value = e.changedTouches[0].absoluteY;
    })
    .onTouchesMove((e, stateManager) => {
      const held = Date.now() - heldSince.value > 300;
      const moved = Math.abs(e.changedTouches[0].absoluteY - startY.value) > 6;
      if (held && moved) {
        stateManager.activate(); // 满足「长按 + 有位移」才接管
      } else if (moved) {
        stateManager.fail();     // 不满足就立刻判负,把机会还给滚动
      }
    })
    .onUpdate((e) => { /* 激活后才会来 */ })
    .onFinalize(() => { /* 兜底复位 */ });

  return <GestureDetector gesture={pan}>{/* ... */}</GestureDetector>;
}

为什么不用普通 onStart 里判断时长?因为普通 Pan 的激活条件是位移阈值——手指一动它就 ACTIVE 了,等你在 onStart 里发现「还没按满 300ms」时,手势已经赢了:滚动被抢、高亮已亮,无法回退。思考题(本章末)会让你完整推演这条链。

与 ScrollView / FlashList 滚动的冲突治理

这是生产中最常见的仲裁战场。四个手段按优先级排列:

手段 写法 解决什么
方向锁(首选) activeOffsetX + failOffsetY(或镜像) 横滑行不误触发竖滚,竖滚先动则横滑判负
activateAfterLongPress Pan.activateAfterLongPress(300) 按住 300ms 才允许拖,短促滑动全让给滚动
simultaneousWithExternalGesture 传入滚动容器 ref 需要与滚动并行的手势(如边缘拖拽)不互斥
manualActivation stateManager 自控 任意自定义接管条件
import { useRef } from 'react';
import { ScrollView } from 'react-native';

function ListWithSwipe() {
  const scrollRef = useRef<ScrollView>(null);

  const swipe = Gesture.Pan()
    .simultaneousWithExternalGesture(scrollRef) // 与滚动并行识别,胜负交给阈值
    .activeOffsetX([-12, 12])                   // 横向越过 12px 才参与竞争
    .failOffsetY([-10, 10])                     // 纵向先动 10px 则判负
    .maxPointers(1);

  return (
    <ScrollView ref={scrollRef}>
      <GestureDetector gesture={swipe}>{/* 行 */}</GestureDetector>
    </ScrollView>
  );
}

嵌套滚动的方向锁同理:竖列表里嵌横向选择条,内层横向 ScrollView 的内容上再叠手势时,保持「内层管横、外层管竖」,两侧都写全激活/失败阈值,让原生仲裁器在最早的越界时刻完成裁决。

仲裁全景图

把「手指 → 原生仲裁 → worklet → JS」的关系画成一张图:

flowchart TB
    F["手指触点(每帧)"] --> R
    subgraph native["原生层(UI 线程):仲裁在这里完成"]
        R["手势识别器池:Tap / Pan / LongPress / Pinch ..."]
        S["ScrollView / FlashList 原生滚动"]
    end
    R -- "激活竞争:activeOffset / failOffset / requireToFail" --> S
    S -- "滚动接管则手势判 CANCEL" --> R
    R -- "激活后事件经 JSI 直达,不进 JS" --> W
    subgraph ui["UI 运行时:worklet"]
        W["onUpdate / onEnd 更新 SharedValue"]
    end
    W -- "useAnimatedStyle 属性直写" --> V["视图树"]
    W -- "松手提交 / 导航等低频动作" --> J["runOnJS:此刻才进 JS 线程"]

10.4 手势 × 动画联动:标准范式

范式三件套:offset 累计、落位判定、cancel 兜底

手势驱动动画的 90% 需求都能套进同一个骨架:

  1. offset 累计:每次手势开始时把「当前停泊位置」存进 base,更新时 value = base + translation。translation 永远相对手势起点,不累计它就会每次从零跳变;
  2. 落位判定:onEnd 里同时使用位移与速度(e.translationX + e.velocityX)判定落点,spring 带初速度过渡,动画「接着手势继续」;
  3. cancel 兜底:onFinalize 里处理 CANCEL(被滚动/系统接管),把状态弹回最近的合法停泊位。
import { StyleSheet } from 'react-native';
import Animated, {
  useAnimatedStyle, useSharedValue, withSpring, withTiming,
} from 'react-native-reanimated';
import { Gesture, GestureDetector, State } from 'react-native-gesture-handler';

const SPRING = { damping: 20, stiffness: 260 };
const GRID = 80; // 吸附网格

export function SnapChip() {
  const x = useSharedValue(0);
  const y = useSharedValue(0);
  const baseX = useSharedValue(0);
  const baseY = useSharedValue(0);
  const scale = useSharedValue(1);

  const pan = Gesture.Pan()
    .activeOffsetX([-8, 8])
    .failOffsetY([-12, 12])
    .maxPointers(1)
    .onStart(() => {
      baseX.value = x.value; // ① offset 累计:记住停泊位
      baseY.value = y.value;
      scale.value = withTiming(1.08, { duration: 100 });
    })
    .onUpdate((e) => {
      x.value = baseX.value + e.translationX;
      y.value = baseY.value + e.translationY;
    })
    .onEnd((e) => {
      // ② 落位判定:位置 + 速度共同决定目标,spring 接续手势速度
      const tx = baseX.value + e.translationX + e.velocityX * 0.1;
      const ty = baseY.value + e.translationY + e.velocityY * 0.1;
      const gx = Math.round(tx / GRID) * GRID;
      const gy = Math.round(ty / GRID) * GRID;
      baseX.value = gx;
      baseY.value = gy;
      scale.value = withSpring(1, SPRING);
      x.value = withSpring(gx, { ...SPRING, velocity: e.velocityX * 0.1 });
      y.value = withSpring(gy, { ...SPRING, velocity: e.velocityY * 0.1 });
    })
    .onFinalize((e) => {
      // ③ cancel 兜底:被滚动或系统接管时回到最近停泊位
      if (e.state === State.CANCELLED) {
        scale.value = withSpring(1, SPRING);
        x.value = withSpring(baseX.value, SPRING);
        y.value = withSpring(baseY.value, SPRING);
      }
    });

  const style = useAnimatedStyle(() => ({
    transform: [
      { translateX: x.value },
      { translateY: y.value },
      { scale: scale.value },
    ],
  }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={[styles.chip, style]} />
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  chip: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#3B82F6' },
});

注意 withSpring(target, { velocity }):spring 的初速度来自手势,这是「跟手感」的点睛之笔——物理连续,而不是动画生硬接管。

线程泳道:一次拖拽的完整旅程

sequenceDiagram
    participant F as 手指(系统触控)
    participant N as 原生识别器(UI 线程)
    participant W as worklet(UI 运行时)
    participant V as 视图树(UI 线程)

    F->>N: 触点移动(每帧)
    N->>N: 阈值判定与仲裁(不进 JS)
    N->>W: onUpdate 事件(JSI 直达)
    W->>W: 更新 SharedValue
    W->>V: useAnimatedStyle 重算并直写属性
    Note over W,V: 全程不经过 JS 线程与 React 渲染
    W-->>F: 松手:落位 spring(仍在 UI 线程)
    W->>W: onEnd 判定,必要时 runOnJS 提交业务

与第 9 章的泳道图对照:动画的「帧生产者」从 JS 换成了 worklet,手势事件的「来源」也从 JS 换成了原生识别器——两个来源在 UI 运行时会合,这就是 Reanimated + RNGH 成为标配的根本原因。

10.5 可访问性与手势替代

手势是隐藏功能,必须给非手势等价物

手势有三个可访问性硬伤:屏幕阅读器用户难以发现、难以精确执行;运动能力受限的用户做不出双指/长按组合;任何用户都无法「看见」一个左滑后面藏着什么。因此每章纪律只有一条:每个手势操作都要有可见的、可聚焦的等价操作。

import { Pressable, Text } from 'react-native';

// 手势的等价物三件套:无障碍标签、无障碍动作、可见入口
<Pressable
  onPress={handleToggle}
  accessibilityRole="checkbox"
  accessibilityLabel={`检查项 ${title}`}
  accessibilityState={{ checked: done }}
  accessibilityActions={[
    { name: 'activate', label: '切换完成状态' },
    { name: 'longpress', label: '快捷打分' },
  ]}
  onAccessibilityAction={(e) => {
    switch (e.nativeEvent.actionName) {
      case 'activate': handleToggle(); break;
      case 'longpress': openQuickScore(); break;
    }
  }}
>
  <Text>{title}</Text>
</Pressable>

配套的产品手段:长按菜单(弹出明确列出所有操作的 ActionSheet/菜单,同时充当「手势说明书」);被手势覆盖的功能提供按钮副本(如已展开行的「归档」「删除」本身就是按钮);校验时用屏幕阅读器(iOS VoiceOver / Android TalkBack)实际走一遍——阅读器开启后触控事件形态改变,很多手势会失效,等价物就是唯一通路。

与系统手势的边界

  • iOS 侧缘右滑返回(interactivePopGesture)与 Android 预测性返回占用屏幕左右边缘约 20~30dp 的触发带。业务横向手势(轮播翻页、行左滑)要避开这条带:内容布局尊重 safe area insets,全屏横向手势用 hitSlop 收缩触发区;
  • hitSlop 是双向的:Gesture.Pan().hitSlop({ left: -24 }) 可以让手势触发区避开边缘,也可以放大小控件的触摸面积;
  • 系统级打断永远优先:来电、控制中心、分屏拖动会直接给手势 CANCEL——这就是 10.4 节把 cancel 兜底列為纪律的原因。

10.6 手势性能与调试

性能清单

  1. 手势期间零 setState。onUpdate 每帧触发,任何 setState 都把逐帧过程拉回 React 渲染管线。逐帧值只进 SharedValue;业务通知只在 onEnd/onFinalize 用一次 runOnJS;
  2. 避免逐帧 runOnJS。它有事件循环调度开销(第 9 章),手势期间每帧 runOnJS 等于把「事件直达 worklet」的架构优势全部退回 JS。需要 JS 侧持续感知时,用 useAnimatedReaction 观察 + 条件去抖后一次性通知;
  3. 回调必须留在 worklet。Gesture 回调默认是 worklet(装了 Reanimated 时);如果回调里用了非 worklet 能力(导航、SDK),显式 .runOnJS(true) 把整个回调转 JS——只对低频手势这么做,并明白这是性能换功能;
  4. 回调里不做重计算。onUpdate 里只做插值与赋值;重的换算在 JS 预处理成查找表,或在 useDerivedValue 里做。

调试手段

import { enableLogging } from 'react-native-gesture-handler';

// 开发环境打开 RNGH 日志(实验性 API,以官方文档为准):注册、冲突与状态迁移可见
if (__DEV__) {
  enableLogging(true);
}
  • 状态打点:像 10.2 节的 LifecycleDemo 一样,把手势状态打进 SharedValue 显示在界面上——比 console.log 更真实,因为它不经过 JS;
  • worklet 日志:worklet 里可用 console.log(经代理输出到 Metro),高频回调里慎用(每帧序列化日志本身也耗预算);
  • 对照法:同一个手势组件,注释掉 onUpdate 里的 setState(如果有),用 Dev Menu Perf Monitor 对比帧率差值,量化「手势里那行 setState」的代价;
  • 视觉锚点:开发期给激活态加边框颜色变化(SharedValue 驱动),肉眼确认激活时机是否符合阈值预期。

典型业务场景:FieldNote 实战

FieldNote 的任务列表(里程碑 M10 前半)需要两个交互:任务卡片左滑展开操作栏(归档/删除两颗按钮,跟手展开、松手吸附或直接触发删除),以及长按拖拽排序(本章交付最小骨架,完整版在第 11 章)。

左滑操作栏:SwipeableRow

设计决策:

  • 仲裁交给阈值方向锁:activeOffsetX + failOffsetY,与垂直滚动零冲突;
  • 位移上限与阻尼:左滑越界按 0.25 衰减(有「拉到头」的弹性),右拉最多 40px(防飞出);
  • 落位判定同时看位移与速度:快速 fling 直接触发删除;
  • 按钮不进手势区:GestureDetector 只包内容行,按钮是普通 Pressable,天然无 onPress 竞争;
  • 停泊状态用 SharedValue(open),不用 state——展开与否不需要触发 React 重渲染。
// src/features/tasks/components/SwipeableRow.tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import Animated, {
  Extrapolation,
  interpolate,
  runOnJS,
  useAnimatedStyle,
  useDerivedValue,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';
import { Gesture, GestureDetector, State } from 'react-native-gesture-handler';

const SPRING = { damping: 22, stiffness: 280 };
const BTN_W = 76;              // 单颗操作按钮宽度
const OPEN_X = -(BTN_W * 2);   // 完全展开时内容行位移(向左为负)
const DELETE_TRIGGER = 160;    // 松手直接触发删除的位移阈值

type Props = {
  children: React.ReactNode;
  onArchive: () => void;
  onDelete: () => void;
};

export function SwipeableRow({ children, onArchive, onDelete }: Props) {
  const x = useSharedValue(0);        // 内容行当前位移
  const startX = useSharedValue(0);   // 本次手势开始时的位移(offset 累计)
  const open = useSharedValue(false); // 停泊状态:SharedValue 而非 state

  const archive = () => {
    open.value = false;
    x.value = withSpring(0, SPRING);
    onArchive();
  };

  const remove = () => {
    open.value = false;
    x.value = withSpring(0, SPRING);
    onDelete(); // 第 11 章升级为「行退场动画 + 延迟删除」
  };

  // 落位判定:worklet。同时考虑位移与速度
  const settle = (velocityX: number) => {
    'worklet';
    const flingLeft = velocityX < -500;
    if (x.value < -DELETE_TRIGGER || (flingLeft && x.value < -BTN_W)) {
      open.value = false;
      x.value = withSpring(0, SPRING);
      runOnJS(remove)();                       // 越过删除阈值:触发删除
    } else if (x.value < -BTN_W / 2 || (flingLeft && x.value < 0)) {
      open.value = true;
      x.value = withSpring(OPEN_X, SPRING);    // 吸附到完全展开
    } else {
      open.value = false;
      x.value = withSpring(0, SPRING);         // 回弹关闭
    }
  };

  const pan = Gesture.Pan()
    .activeOffsetX([-12, 12])  // 横向越过 12px 才参与激活:给垂直滚动让路
    .failOffsetY([-10, 10])    // 纵向先移动 10px 则本手势永久判负:方向锁
    .minPointers(1)
    .maxPointers(1)            // 忽略多指,防止第二根手指落下导致位移跳变
    .onStart(() => {
      startX.value = x.value;
    })
    .onUpdate((e) => {
      const next = startX.value + e.translationX;
      // 位移上限:右拉最多 40;左滑越界按 0.25 阻尼衰减,杜绝「飞出」
      x.value =
        next < OPEN_X
          ? OPEN_X + (next - OPEN_X) * 0.25
          : Math.min(next, 40);
    })
    .onEnd((e) => settle(e.velocityX))
    .onFinalize((e) => {
      // onEnd 只覆盖 END;被滚动接管或系统打断走 CANCEL,必须在这里兜底
      if (e.state === State.CANCELLED) {
        x.value = withSpring(open.value ? OPEN_X : 0, SPRING);
      }
    });

  // 展开进度 0→1:驱动按钮视差(一个值驱动多重样式)
  const progress = useDerivedValue(() =>
    interpolate(-x.value, [0, -OPEN_X], [0, 1], Extrapolation.CLAMP),
  );

  const rowStyle = useAnimatedStyle(() => ({
    transform: [{ translateX: x.value }],
  }));

  const btnOpacity = useAnimatedStyle(() => ({
    opacity: interpolate(progress.value, [0.2, 1], [0, 1], Extrapolation.CLAMP),
  }));

  const archiveScale = useAnimatedStyle(() => ({
    transform: [
      { scale: interpolate(progress.value, [0, 1], [0.6, 1], Extrapolation.CLAMP) },
    ],
  }));

  const deleteScale = useAnimatedStyle(() => ({
    transform: [
      { scale: interpolate(progress.value, [0, 1], [0.6, 1], Extrapolation.CLAMP) },
    ],
  }));

  return (
    <View style={styles.wrap}>
      {/* 操作栏垫底:普通 Pressable,不在 GestureDetector 内,无事件竞争 */}
      <View style={styles.actions} pointerEvents="box-none">
        <Animated.View style={[btnOpacity, archiveScale]}>
          <Pressable style={[styles.btn, styles.archiveBtn]} onPress={archive}
            accessibilityRole="button" accessibilityLabel="归档该任务">
            <Text style={styles.btnText}>归档</Text>
          </Pressable>
        </Animated.View>
        <Animated.View style={[btnOpacity, deleteScale]}>
          <Pressable style={[styles.btn, styles.deleteBtn]} onPress={remove}
            accessibilityRole="button" accessibilityLabel="删除该任务">
            <Text style={styles.btnText}>删除</Text>
          </Pressable>
        </Animated.View>
      </View>

      {/* 手势只包内容行 */}
      <GestureDetector gesture={pan}>
        <Animated.View style={[styles.front, rowStyle]}>{children}</Animated.View>
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  wrap: { position: 'relative', overflow: 'hidden', borderRadius: 12 },
  actions: {
    ...StyleSheet.absoluteFillObject,
    flexDirection: 'row',
    justifyContent: 'flex-end',
  },
  btn: { width: BTN_W, height: '100%', alignItems: 'center', justifyContent: 'center' },
  archiveBtn: { backgroundColor: '#F59E0B' },
  deleteBtn: { backgroundColor: '#EF4444' },
  btnText: { color: '#FFFFFF', fontWeight: '700', fontSize: 14 },
  front: { backgroundColor: '#FFFFFF' },
});

在 FlashList 的 renderItem 中接入:

renderItem={({ item }) => (
  <SwipeableRow
    onArchive={() => taskStore.archive(item.id)}
    onDelete={() => taskStore.remove(item.id)}
  >
    <TaskCard task={item} />
  </SwipeableRow>
)}

逐段复盘:

  • 仲裁链:手指横移 → 原生识别器在 ±12px 处裁决激活 → onUpdate 进 worklet → x.value 更新 → rowStyle 直写。垂直滚动则因 failOffsetY 在纵向先动时让对方赢,两组阈值互斥,原生层一次分出胜负;
  • 为什么按钮不卡:Math.min(next, 40) 与 0.25 衰减是状态无关的纯函数——同一个位移永远映射到同一个视觉位置,回弹只是最后一段 spring;
  • 为什么关不掉也能回正:CANCEL 兜底读 open 决定吸附方向,被下拉刷新接管、系统中断都不会卡在半开;
  • 可访问性:归档/删除是带无障碍标签的真实按钮;TaskCard 内部还应提供菜单等价物(见 10.5 节)。

长按拖拽排序:最小骨架(第 11 章接口预告)

完整拖拽排序需要「行高测量、其余行让位、落点索引计算、onReorder 提交与持久化、与滚动的最终仲裁」,留给第 11 章综合实战。本章交付契约接口 + 手势骨架:manualActivation 进入、跟随位移、松手回弹。

// src/features/tasks/components/DragHandle.tsx
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Animated, {
  interpolate,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
  runOnJS,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

/** 第 11 章将基于此契约实现完整拖拽排序 */
export type DragSortContract = {
  /** 拖拽确认落位:from 原索引,to 目标索引(含列表重排与同步队列提交) */
  onReorder: (from: number, to: number) => void;
  /** 拖拽开始:用于抬起行高亮与触觉反馈 */
  onDragStart?: (index: number) => void;
  itemHeight: number;
};

const LONG_PRESS_MS = 300;

export function DragHandle({ index, contract }: { index: number; contract: DragSortContract }) {
  const lift = useSharedValue(0);    // 0 未拖拽 / 1 已抬起(驱动阴影与缩放)
  const dy = useSharedValue(0);      // 跟随位移
  const heldSince = useSharedValue(0);
  const touchStartY = useSharedValue(0);

  const reportStart = (i: number) => contract.onDragStart?.(i);

  const pan = Gesture.Pan()
    .manualActivation(true)          // 激活时机自己决定:长按 300ms 后才可拖
    .maxPointers(1)
    .onTouchesDown((e) => {
      heldSince.value = Date.now();
      touchStartY.value = e.changedTouches[0].absoluteY;
    })
    .onTouchesMove((e, stateManager) => {
      const held = Date.now() - heldSince.value > LONG_PRESS_MS;
      const moved = Math.abs(e.changedTouches[0].absoluteY - touchStartY.value) > 6;
      if (held && moved) {
        stateManager.activate();     // 「长按 + 有位移」才接管
        if (lift.value === 0) {
          lift.value = withTiming(1, { duration: 120 });
          runOnJS(reportStart)(index);
        }
      } else if (moved) {
        stateManager.fail();         // 不满足就立刻判负,把移动还给滚动
      }
    })
    .onTouchesCancel((_e, stateManager) => stateManager.fail())
    .onUpdate((e) => {
      // 骨架版:只跟随不交换。让位动画与索引交换在第 11 章实现
      dy.value = e.translationY;
    })
    .onFinalize(() => {
      lift.value = withSpring(0);
      dy.value = withSpring(0);
      // 骨架版不调用 onReorder:需要先测量落点索引(第 11 章)
    });

  const style = useAnimatedStyle(() => ({
    transform: [
      { translateY: dy.value },
      { scale: interpolate(lift.value, [0, 1], [1, 1.03]) },
    ],
    // 抬起视觉:双端各自的原生阴影
    elevation: lift.value * 8,        // Android
    shadowOpacity: lift.value * 0.25, // iOS
    zIndex: Math.round(lift.value * 10),
  }));

  return (
    <GestureDetector gesture={pan}>
      <Animated.View style={[styles.handle, style]}>
        <View style={styles.grip}>
          <Text style={styles.gripText}>≡</Text>
        </View>
      </Animated.View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  handle: { backgroundColor: '#FFFFFF', borderRadius: 12 },
  grip: { width: 40, height: 48, alignItems: 'center', justifyContent: 'center' },
  gripText: { fontSize: 20, color: '#94A3B8' },
});

给第 11 章留出的接口就是 DragSortContract:onReorder 的语义(from/to 索引 + 持久化与同步队列提交)、itemHeight 约定、onDragStart 钩子。第 11 章要做的事在这个骨架上补齐:拖拽中计算目标索引、让其余行用 layout/共享值让位、松手时调用 onReorder 并把 dy 精确回弹到新位置——手势与仲裁的底层已经全部就绪。

常见陷阱

陷阱 典型症状 根因 正确做法
在 GestureDetector 外层再包 onPress 组件(或反之) 点击偶发失效、拖拽后误触发点击 RNGH 与 Responder 两套手势体系竞争,Responder 的裁决要进 JS 手势区域内统一用 Gesture.Tap() 处理点击;确认根组件包了 GestureHandlerRootView
手势回调里逐帧 setState 拖动时掉帧、React DevTools 刷屏 onUpdate 每帧进 React 渲染管线,抵消了原生仲裁的优势 onUpdate 只碰 SharedValue;业务提交在 onEnd/onFinalize 一次性 runOnJS
Pan 没设位移上限 卡片被拖出屏幕、数字变负数爆炸 translationX 无界,样式无限延伸 onUpdate 内夹取 + 越界阻尼衰减;或 interpolate 显式 CLAMP
忘记处理 cancel/finalize 滚动接管后卡片停在半开、抬起态不恢复 onEnd 只在 END 触发;CANCEL 不走 onEnd onFinalize 里按 State.CANCELLED 兜底,弹回最近合法停泊位
同区域多个手势仲裁配置错误 手势时灵时不灵,换设备行为不同 都没有阈值,裁决顺序取决于事件到达次序 每个方向性手势写全 activeOffset/failOffset;冲突组合显式用 Exclusive/Race + requireExternalGestureToFail
业务手势做在系统返回边缘 侧滑返回与轮播翻页互相误触 iOS 侧缘返回 / Android 预测性返回占用左右边缘触发带 横向手势避开边缘(尊重 safe area insets),必要时 hitSlop 收缩触发区
忘记 GestureHandlerRootView 所有手势完全无响应并有警告 GestureDetector 的原生包装依赖根视图的触摸分发 根组件(导航容器外层)包 GestureHandlerRootView
在 FlashList 复用行上用闭包 index 直接驱动拖拽 复用后拖拽操作了错误的行 列表回收复用,组件实例与旧索引脱钩 用 item 唯一 key 反查索引;手势状态放共享值并在行复用时重置

最佳实践

  1. 新交互组件一律 Gesture API + GestureDetector,不再新增 Responder 回调或旧版 XxxGestureHandler 组件;
  2. GestureHandlerRootView 放在根组件、导航容器外层,全 App 只包一次;
  3. 每个方向性 Pan 都显式写全激活阈值与失败阈值(方向锁),不允许「裸 Pan」上生产;
  4. 多指场景显式声明 minPointers/maxPointers;左滑行类组件固定 maxPointers(1);
  5. 位移永远有上限与越界阻尼;落位判定同时使用位移与速度,spring 传 velocity 实现物理连续;
  6. onFinalize 兜底一切非正常结束(CANCEL/FAIL),任何手势组件都不允许停在中间态;
  7. 逐帧回调零 setState、零逐帧 runOnJS;业务提交只在 onEnd/onFinalize 发生一次;
  8. 同区域多手势写清仲裁关系(Exclusive/Race/Simultaneous + requireExternalGestureToFail),不做「碰运气组合」;
  9. 每个手势操作提供可访问性等价物:无障碍标签、accessibilityActions、可见按钮或长按菜单;用屏幕阅读器实测一遍;
  10. 不在系统返回手势边缘(safe area 之外约 20~30dp)布置横向业务手势;
  11. 开发期开 enableLogging 与状态打点;激活阈值抽成常量集中调参,真机逐项验证;
  12. 与滚动的冲突先用四个治理手段(方向锁、activateAfterLongPress、simultaneousWithExternalGesture、manualActivation)排查,再考虑改布局结构。

本章小结

本章要点回顾:

  • Responder System 的裁决与逐帧回调都在 JS,三大局限(裁决慢、逐帧进 React、与原生滚动竞争)决定了复杂交互必须换体系;RNGH 把平台原生识别器桥接进 RN,仲裁在 UI 线程完成,事件经 JSI 直达 worklet;
  • 手势状态机是理解一切回调时序的钥匙:onBegin 只是候选,onStart 才是激活,onEnd 只覆盖正常结束,onFinalize 是三种终态的总收尾;
  • 仲裁工具箱:Race/Exclusive/Simultaneous 表达竞争语义,requireExternalGestureToFail/simultaneousWithExternalGesture/blocksExternalGesture 处理跨组件关系,manualActivation 把激活权交回业务,四个手段治理滚动冲突;
  • 「手势 × 动画」范式三件套:offset 累计、位移+速度的落位判定、cancel 兜底;识别、计算、渲染三段全程不进 JS;
  • 手势功能必须有可访问性等价物,且不得侵占系统返回手势的边缘地带。

一句话记忆点:手势裁决发生在原生,动画发生在 UI 线程,JS 只在开始与结束时知情。

动手练习

  1. 基础巩固(图片变换三合一):实现一个可 Pinch 缩放、Rotation 旋转、Pan 平移的图片容器(Gesture.Simultaneous 组合),再叠加双击复位(Gesture.Exclusive 把双击放前面)。要求:缩放/旋转有上下限、位移有边界、全部落位用带速度的 spring。提示:三个手势的共享值互相独立,样式里合并进同一个 transform。
  2. FieldNote 实战 A(长按快捷打分):给第 9 章的 CheckItem 增加「长按弹出快捷打分菜单(1/3/5)」:LongPress 与单击不冲突(Exclusive),长按激活时分数徽标 spring 放大提示。同时为该操作补齐 accessibilityActions 等价物,并说明屏幕阅读器开启时你的验证步骤。
  3. FieldNote 实战 B(滚动共存验收):把 SwipeableRow 接入千条级 FlashList,处理三个边界:多指触控不跳变、快速 fling 直接触发删除、与下拉刷新共存不互抢。写出你的验证清单(每个边界对应什么操作序列、观察什么现象),并说明如果出现「左滑时页面跟着竖滚」你会先检查哪个配置。
  4. 思考题:「长按 300ms 后才进入拖拽」为什么不能用普通 Pan 的 onStart 判断实现?请逐步推演不用 manualActivation 时会发生什么:(a) 普通 Pan 的激活条件是什么、手指按下并移动后谁先激活;(b) 激活那一刻滚动视图发生了什么、为什么已激活的手势无法「撤销让贤」;(c) 在 onStart 里发现「按住时长不足 300ms」时,你能做的补救有哪些、各自的副作用是什么。最后说明 manualActivation 的 stateManager.fail() 为什么能干净地解决这个问题。

导航

上一章 第 9 章 动画系统与 Reanimated | 下一章 第 11 章 综合实战:复杂交互组件

📄 第 11 章 综合实战:复杂交互组件 11-interaction-lab.md

本章导读:本章是"动画与手势"阶段(第 9、10 章)的毕业设计。我们先补完第 10 章留下的"长按拖拽排序"骨架(M10 后半)到生产级,再新增照片缩放预览与自定义下拉刷新两个案例,最后统一回答三个问题:怎么证明一个交互组件是流畅的、怎么验收它、怎么把它工程化为可复用的独立包。读完本章即可对照附录 B 完成 M11 验收,下一章进入"状态与架构"阶段。

本章学习目标

  • 能解释"槽位映射"与"setState 重排"两种拖拽排序架构的本质差异,并估算两者每帧成本量级
  • 能用 manualActivation + SharedValue 实现长按进入拖拽、邻行让位、边缘自动滚动、松手落位的完整链路
  • 能用 sortKey 浮点中点策略把排序结果落库,说清精度边界与再均衡时机
  • 能实现 Pinch + Pan + 双击的照片预览,正确处理基准值重置、边界回弹与滚动互斥仲裁
  • 能实现三态自定义下拉刷新,掌握阻尼曲线推导与阈值"一次性判定"
  • 能用 Perfetto/Instruments 采集帧率证据,并设计 JS 线程满载对照实验
  • 能按"交互验收卡"完成帧率、内存、无障碍替代与 reduce motion 降级的验收
  • 能把三个案例接线进 FieldNote 的列表页与任务详情页,并通过 M11 里程碑清单

核心知识点

11.1 案例一:可拖拽排序的任务分组列表(补完 M10 后半)

需求拆解

FieldNote 任务详情里的检查项按 sortKey 排序展示,巡检员需要调整评分顺序。拆成可验收的子需求:

  1. 长按 260ms 进入拖拽:普通滑动必须照常滚动,不能误触;
  2. 被拖行跟手:手指在哪行在哪,延迟为零;
  3. 邻行让位:被拖行经过时,中间的行平滑腾出空位;
  4. 边缘自动滚动:拖到可视区上/下边缘附近,列表按节奏滚动,且滚动中让位关系持续正确;
  5. 松手落位 + 持久化:落位动画一次到位;数据层只写"被移动的那一项",不整表重写;
  6. 全程不经过 JS 线程:第 1~4 步任何一帧都不允许触发 React 渲染。

架构决策:为什么是"槽位映射"而不是"数据重排"

【重点】拖拽排序最常见的错误实现是:onUpdate 里发现手指换了行就 setState 重排数组。这条路的成本链条是——JS 线程 setState → React reconcile 整个列表 → 生成新 shadow tree → 原生侧重新布局。每次槽位变化(一秒可达十几次)都完整走一遍,中端机上百行列表必然掉帧。而正确的做法是把"谁在第几个槽位"抽象成一个纯数字数组 slots,放在 Reanimated 的 SharedValue 里:

  • 被拖行:translateY = 手指内容坐标 - 按点偏移,直接跟手;
  • 其他行:translateY = slots[i] * ROW_H,用 withSpring 追踪自己的槽位;
  • 让位 = 只改 slots 里几个数字,由 UI 线程的样式重算完成动画;
  • 业务数据(data)在松手前一个字都不动。
            0     56    112   168   224   280   (px,内容坐标)
槽位视图    │ s0  │ s1  │ s2  │ s3  │ s4  │
原始行序    │ 行0 │ 行1 │ 行2 │ 行3 │ 行4 │
                ……长按 行0 下移到 s3……
让位后      │     │ 行1 │ 行2 │行0拖 │ 行4 │
slots = [3, 0, 1, 2, 4]      ← 唯一真值源:行 → 槽位
普通行      translateY = slots[i] × ROW_H
被拖行      translateY = fingerContentY − grabOffset   (直接跟手)

整个交互管线如下——注意除了"发令"和"提交"两个点,所有箭头都闭合在 UI 线程内:

flowchart LR
  A[手指按下] -->|静止 260ms| B[LongPress 成立<br/>震动 + isDragging=true]
  A -->|位移超过 SLOP| F[Pan 判负 fail<br/>ScrollView 正常滚动]
  B --> C[Pan 手动激活 activate<br/>锁定原生滚动]
  C -->|手指移动| D[updateHover<br/>悬停槽位变化则让位]
  C -->|进入边缘区| E[帧循环自动滚动<br/>速度随贴近边缘渐增]
  E --> D
  D --> G[松手 onEnd<br/>spring 落位 + runOnJS 提交顺序]

其余关键决策及理由:

  • 用 manualActivation 而不是 activeOffsetY:进入拖拽的条件是"长按成立",这是一个时间条件,声明式偏移阈值表达不了;且必须能在长按前"保持未决、超阈值判负",把滚动让给 ScrollView(第 10 章的状态机仲裁知识直接落地)。
  • 自动滚动用 useFrameCallback 做帧循环:手势事件只在手指移动时到达,手指静止贴边时不会有事件,必须由帧循环驱动;速度按"距边缘的距离"线性渐增,并用 timeSincePreviousCall 换算成与帧率无关的位移。
  • 提交只发生一次:onEnd 里把 slots 排序成 id 序列,runOnJS 交给父组件;期间零 setState。

完整代码(单文件组件)

// src/components/DraggableSortList.tsx
// 槽位映射式拖拽排序:跟手、让位、自动滚动全部运行在 UI 线程,
// JS 线程只在三个事件点参与:长按震动、松手提交、数据同步。
import React, { memo, useCallback, useEffect } from 'react';
import { ScrollView, StyleSheet, Text, Vibration, View } from 'react-native';
import Animated, {
  runOnJS,
  scrollTo,
  useAnimatedRef,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useFrameCallback,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

export interface SortItem {
  id: string;
  title: string;
}

const ROW_H = 56;        // 固定行高:本方案成立的前提
const EDGE_ZONE = 96;    // 距上/下边缘多少 px 进入自动滚动区
const MAX_SPEED = 0.5;   // 自动滚动最大速度(px/ms),贴边时达到
const SLOP = 12;         // 位移判负阈值:超过则判定为"想滚动"而非"想拖拽"
const SPRING = { damping: 32, stiffness: 420 };

function clamp(v: number, min: number, max: number): number {
  'worklet';
  return Math.min(max, Math.max(min, v));
}

/** 让位算法:把 row 的槽位改为 newSlot,夹在中间的行各让一格。
 *  只写数字,不渲染、不改业务数据。 */
function moveRow(slots: number[], row: number, newSlot: number): void {
  'worklet';
  const old = slots[row];
  if (old === newSlot) return;
  for (let i = 0; i < slots.length; i++) {
    if (i === row) continue;
    if (old < newSlot && slots[i] > old && slots[i] <= newSlot) slots[i] -= 1;
    else if (old > newSlot && slots[i] >= newSlot && slots[i] < old) slots[i] += 1;
  }
  slots[row] = newSlot;
}

// ---------------------------------------------------------------- 行

interface RowProps {
  item: SortItem;
  index: number;
  slots: Animated.SharedValue<number[]>;
  activeRow: Animated.SharedValue<number>;
  isDragging: Animated.SharedValue<boolean>;
  scrollOffset: Animated.SharedValue<number>;
  grabOffset: Animated.SharedValue<number>;
  fingerY: Animated.SharedValue<number>;
}

const Row = memo(function Row({
  item, index, slots, activeRow, isDragging, scrollOffset, grabOffset, fingerY,
}: RowProps) {
  const haptic = useCallback(() => Vibration.vibrate(8), []);

  // 每行一个长按手势:只负责"发令"。位移跟踪与提交都由外层 Pan 负责。
  const longPress = Gesture.LongPress()
    .minDuration(260)
    .onStart((e) => {
      if (isDragging.value) return;
      isDragging.value = true;
      activeRow.value = index;
      // LongPress 挂在行上:e.y 是手指在行内的偏移,即 grabOffset
      grabOffset.value = e.y;
      // 同步换算出手指在可视区中的 y,避免首帧跳变
      fingerY.value = slots.value[index] * ROW_H + e.y - scrollOffset.value;
      runOnJS(haptic)();
    })
    .onEnd(() => {          // 长按成立后又直接松手(没发生拖拽)也要收尾
      if (!isDragging.value) return;
      isDragging.value = false;
      activeRow.value = -1;
    });

  const animStyle = useAnimatedStyle(() => {
    const isActive = isDragging.value && activeRow.value === index;
    const translateY = isActive
      ? // 被拖行:跟手 = 手指可视区 y + 滚动补偿 - 按点偏移
        fingerY.value + scrollOffset.value - grabOffset.value
      : // 其他行:追踪自己的槽位;落位时同一条路径,从手指处平滑归位
        withSpring(slots.value[index] * ROW_H, SPRING);
    return {
      transform: [{ translateY }],
      zIndex: isActive ? 10 : 0,
      elevation: isActive ? 10 : 0,
      shadowOpacity: isActive ? 0.18 : 0,
    };
  });

  return (
    <GestureDetector gesture={longPress}>
      <Animated.View style={[styles.row, animStyle]}>
        <Text style={styles.title}>{item.title}</Text>
        <Text style={styles.hint}>长按拖动</Text>
      </Animated.View>
    </GestureDetector>
  );
});

// ---------------------------------------------------------------- 列表

interface Props {
  data: SortItem[];
  onReorder: (orderedIds: string[]) => void;
}

export function DraggableSortList({ data, onReorder }: Props) {
  const scrollRef = useAnimatedRef<ScrollView>();
  const scrollOffset = useSharedValue(0);
  const viewportH = useSharedValue(0);
  const contentH = data.length * ROW_H;

  const slots = useSharedValue<number[]>(data.map((_, i) => i));
  const isDragging = useSharedValue(false);
  const activeRow = useSharedValue(-1);
  const hoverSlot = useSharedValue(-1);
  const grabOffset = useSharedValue(0);
  const fingerY = useSharedValue(0);
  const downX = useSharedValue(0);
  const downY = useSharedValue(0);
  const scrollSpeed = useSharedValue(0); // px/ms;0 = 不滚

  const commit = useCallback((ids: string[]) => onReorder(ids), [onReorder]);

  const scrollHandler = useAnimatedScrollHandler((e) => {
    scrollOffset.value = e.contentOffset.y;
  });

  // —— 由手指位置推断悬停槽位(换算到内容坐标)——
  const slotUnderFinger = (): number => {
    'worklet';
    const center = fingerY.value + scrollOffset.value - grabOffset.value + ROW_H / 2;
    return clamp(Math.floor(center / ROW_H), 0, data.length - 1);
  };

  // —— 让位:仅悬停槽位变化时执行一次,成本是 O(n) 次数字写入 ——
  const updateHover = (): void => {
    'worklet';
    if (activeRow.value < 0) return;
    const s = slotUnderFinger();
    if (s === hoverSlot.value) return;
    const next = [...slots.value];
    moveRow(next, activeRow.value, s);
    slots.value = next;               // 触发所有行的样式重算(仍在 UI 线程)
    hoverSlot.value = s;
  };

  // —— 自动滚动帧循环:滚动 + 滚动引发的让位重算 ——
  const frameCallback = useFrameCallback((info) => {
    const dt = Math.min(info.timeSincePreviousCall, 48);
    if (scrollSpeed.value !== 0) {
      const maxOffset = Math.max(0, contentH - viewportH.value);
      const next = clamp(scrollOffset.value + scrollSpeed.value * dt, 0, maxOffset);
      scrollOffset.value = next;      // 先记账,onScroll 事件随后对齐
      scrollTo(scrollRef, 0, next, false);
    }
    updateHover();                    // 手指不动、内容在滚 → 让位也要跟进
  }, false);                          // 不自启:拖拽开始时 setActive(true)

  const pan = Gesture.Pan()
    .manualActivation(true)
    .shouldCancelWhenOutside(false)
    .onTouchesDown((e) => {
      const t = e.allTouches[0];
      if (!t) return;
      downX.value = t.absoluteX;
      downY.value = t.absoluteY;
    })
    .onTouchesMove((e, stateManager) => {
      if (isDragging.value) {
        stateManager.activate();      // 长按已成立 → Pan 接管,滚动被锁
        return;
      }
      const t = e.allTouches[0];
      if (!t) return;
      const dx = t.absoluteX - downX.value;
      const dy = t.absoluteY - downY.value;
      if (dx * dx + dy * dy > SLOP * SLOP) {
        stateManager.fail();          // 位移超阈值 = 想滚动:判负,让位给 ScrollView
      }
      // 阈值内保持未决:等长按成立
    })
    .onStart((e) => {
      fingerY.value = e.y;            // Pan 挂在外层容器:y 即可视区坐标
      hoverSlot.value = slots.value[activeRow.value];
      frameCallback.setActive(true);
    })
    .onUpdate((e) => {
      fingerY.value = e.y;
      updateHover();
      // 边缘检测 → 设定滚动速度(离边缘越近越快,线性渐增)
      const toTop = fingerY.value;
      const toBottom = viewportH.value - fingerY.value;
      const maxOffset = Math.max(0, contentH - viewportH.value);
      if (toTop < EDGE_ZONE && scrollOffset.value > 0) {
        scrollSpeed.value = -MAX_SPEED * (1 - toTop / EDGE_ZONE);
      } else if (toBottom < EDGE_ZONE && scrollOffset.value < maxOffset) {
        scrollSpeed.value = MAX_SPEED * (1 - toBottom / EDGE_ZONE);
      } else {
        scrollSpeed.value = 0;
      }
    })
    .onEnd(() => {
      frameCallback.setActive(false);
      scrollSpeed.value = 0;
      isDragging.value = false;       // 样式切回 withSpring(槽位):从手指处平滑落位
      const order = slots.value
        .map((slot, idx) => ({ slot, idx }))
        .sort((a, b) => a.slot - b.slot);
      runOnJS(commit)(order.map((o) => data[o.idx].id));
    })
    .onFinalize(() => {
      // 手势被系统打断(来电、下拉通知等)同样要收尾,避免状态卡死
      frameCallback.setActive(false);
      scrollSpeed.value = 0;
      isDragging.value = false;
      activeRow.value = -1;
    });

  // 外部数据变化(含提交后的重排)→ 槽位回归恒等映射;拖拽中不重置
  useEffect(() => {
    if (!isDragging.value) slots.value = data.map((_, i) => i);
  }, [data, isDragging, slots]);

  return (
    <GestureDetector gesture={pan}>
      <View
        style={styles.fill}
        onLayout={(e) => (viewportH.value = e.nativeEvent.layout.height)}
      >
        <Animated.ScrollView
          ref={scrollRef}
          onScroll={scrollHandler}
          scrollEventThrottle={16}
          showsVerticalScrollIndicator={false}
        >
          <View style={{ height: contentH }}>
            {data.map((item, i) => (
              <Row
                key={item.id}
                item={item}
                index={i}
                slots={slots}
                activeRow={activeRow}
                isDragging={isDragging}
                scrollOffset={scrollOffset}
                grabOffset={grabOffset}
                fingerY={fingerY}
              />
            ))}
          </View>
        </Animated.ScrollView>
      </View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  fill: { flex: 1, overflow: 'hidden' },
  row: {
    position: 'absolute',
    top: 0,
    left: 8,
    right: 8,
    height: ROW_H,
    justifyContent: 'center',
    paddingHorizontal: 16,
    borderRadius: 12,
    backgroundColor: '#fff',
    shadowColor: '#000',
    shadowRadius: 8,
    shadowOffset: { width: 0, height: 2 },
    elevation: 0,
  },
  title: { fontSize: 15, color: '#111' },
  hint: { position: 'absolute', right: 16, fontSize: 12, color: '#999' },
});

逐段讲解几个最容易做错的位置:

  • 长按与 Pan 的接力:LongPress 只负责把 isDragging 置真并算出 grabOffset;Pan 用 manualActivation 在 onTouchesMove 里决策——长按成立就 activate(此后原生滚动被取消,拖拽与滚动天然互斥),位移超阈值就 fail(Pan 本轮终止,ScrollView 正常滚动)。若长按成立后没拖直接松手,LongPress.onEnd 兜底复位。💡 RNGH 还提供声明式的 Pan().activateAfterLongPress(ms),语义等价;本章手写是为了让第 10 章的仲裁模型可见。
  • onFinalize 必须收尾:来电、通知下拉都会打断手势,只写 onEnd 会让 isDragging 卡在 true,列表从此"点不动也拖不了"。
  • 帧循环里先记账再 scrollTo:scrollOffset.value = next 保证同一帧内的让位计算用的是最新偏移,随后真实 scroll 事件会对齐到同值;若等事件到达再更新,自动滚动期间让位会滞后一帧,表现为"一冲一停"。
  • 落位复用同一条样式路径:松手后 isDragging=false,被拖行样式切回 withSpring(slots[i]*ROW_H)——Reanimated 的弹簧从当前值(手指位置)出发,所以落位动画是"免费"的,不需要额外代码。

数据持久化:sortKey 浮点中点策略

第 10 章的数据模型里 CheckItem.sortKey 是浮点分数。落库时只写被移动的那一项:新 sortKey 取前后邻居的中点。

// src/features/checklist/sortKey.ts
/** 新 sortKey = 前后邻居中点。只写一行,避免整组 UPDATE。 */
export function midpointSortKey(prev?: number, next?: number): number {
  if (prev == null && next == null) return 1000;   // 空组:任意基准
  if (prev == null) return next! - 512;            // 挪到最前:留头部空间
  if (next == null) return prev + 512;             // 挪到最后
  return (prev + next) / 2;                        // 中点
}

为什么避免全量重写?100 项挪一项,全量重写 = 100 条 UPDATE + 100 条同步日志(每条都要走第 14 章的 Outbox、按 serverVersion 做冲突检测);中点策略 = 1 条 UPDATE + 1 条日志,冲突面缩小两个数量级。

精度边界:float64 有约 15~16 位有效数字。反复在同一对邻居之间插入,间距每次减半,约 50 次后才逼近精度极限;SQLite 的 REAL 同为 float64,JSON 序列化(同步链路)也能保住 15 位。工程上的护栏是:当最小相邻间距 < 1e-6 时触发一次"再均衡"(sortKey = 1000 * i,整组一次 UPDATE),安排在同步空闲期执行。💡 若同步协议由后端团队主导且对浮点敏感,可换 LexoRank 式的字符串序键(Jira 同款思路),代价是键更长、比较更贵——千条级巡检场景用浮点中点足够。

与 FlashList 的配合要点

场景 可行性 要点
固定行高 + 数量有界(分组内几十到一百项) 直接用本章 ScrollView + 绝对定位 slots × ROW_H 可预测一切位置,代码量最小
固定行高 + 数量巨大(千条级主列表) 不建议整表拖拽 交互降级:进"编辑模式"后先筛选/分组,再对小列表排序
动态高度 需要升级 把 ROW_H 换成"前缀和数组"(每行高度需测量缓存),槽位查找用二分;让位位移不再恒定
FlashList 回收复用 + 拖拽 冲突 回收会把离屏行复用给新数据,"行 i 常驻 slot i"的绝对定位假设被破坏;编辑态应切换为非回收的简单列表,或退回 ScrollView

结论:拖拽排序的适用面是有界的——分组列表(本章场景)用 ScrollView+绝对定位;千条级主列表不该承担拖拽,这是架构约束而不是实现缺陷。

性能要点与验收

  • 性能要点:让位只写 slots(数字数组),样式重算只改 transform,不触发原生布局;提交前零 setState;自动滚动速度用 px/ms 而非 px/帧,兼容 120Hz 屏幕。
  • 验收:长按 260ms 触发且普通滑动永不误触;拖拽全程 JS 线程占用接近 0(见 11.4 对照实验);让位与落位无跳帧、无闪跳;杀进程重进顺序保持(sortKey 落库 + ORDER BY sort_key 读出)。

11.2 案例二:照片网格 + 缩放手势预览

需求拆解与架构决策

巡检拍照后,详情页九宫格点击进入全屏预览:捏合缩放(1~5 倍)、放大后单指平移、双击在 1x 与 2.5x 间切换、松手边界回弹、未放大时手势绝不干扰底层列表。三个决策:

  1. 预览放进 Modal——这是最便宜也最稳的"仲裁":Modal 打开后,底层 FlashList 根本不在手势树里,物理上不存在冲突;预览内部未放大时让 Pan 主动 fail,不会偷走任何事件。
  2. 基准值(base)与本手值(当前值)分离*——⚠️ Pinch 的 e.scale 是相对"本手势开始时"的倍率。onEnd 若不把 scale 写回 baseScale,第二次捏合会从 1 起跳,产生"突然跳大"的 bug(详见陷阱表)。平移同理。
  3. 越界用橡皮筋、松手用弹簧回弹——拖动中允许轻微越界(跟手感),onEnd 夹回合法范围再用 spring 收敛。

完整代码

// src/components/PhotoLightbox.tsx
import React, { useCallback, useEffect } from 'react';
import { Modal, StyleSheet, Text, useWindowDimensions, View } from 'react-native';
import Animated, {
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

const MIN_SCALE = 1;
const MAX_SCALE = 5;
const TAP_ZOOM = 2.5;
const SPRING = { damping: 30, stiffness: 300 };

function clamp(v: number, min: number, max: number): number {
  'worklet';
  return Math.min(max, Math.max(min, v));
}

/** 越界"橡皮筋":合法区间内原样返回,越界部分阻尼衰减(渐近 limit) */
function rubber(v: number, limit: number): number {
  'worklet';
  if (limit <= 0) return 0;
  const over = Math.abs(v) - limit;
  if (over <= 0) return v;
  return Math.sign(v) * (limit + limit * (1 - 1 / ((over / limit) * 0.55 + 1)));
}

interface Props {
  visible: boolean;
  uri?: string;
  onClose: () => void;
}

export function PhotoLightbox({ visible, uri, onClose }: Props) {
  const { width: W, height: H } = useWindowDimensions();

  // 当前值与"本手势开始时的基准值"分离——这是缩放不跳变的关键
  const scale = useSharedValue(1);
  const baseScale = useSharedValue(1);
  const tx = useSharedValue(0);
  const ty = useSharedValue(0);
  const baseTx = useSharedValue(0);
  const baseTy = useSharedValue(0);

  const reset = useCallback(() => {
    scale.value = 1; baseScale.value = 1;
    tx.value = 0; ty.value = 0; baseTx.value = 0; baseTy.value = 0;
  }, []);
  useEffect(() => { if (!visible) reset(); }, [visible, reset]);

  // 缩放为 s 时允许的最大平移量(超出屏幕的部分 / 2)
  const boundsX = (s: number): number => {
    'worklet';
    return Math.max(0, (s * W - W) / 2);
  };
  const boundsY = (s: number): number => {
    'worklet';
    return Math.max(0, (s * H - H) / 2);
  };

  const pinch = Gesture.Pinch()
    .onUpdate((e) => {
      scale.value = clamp(baseScale.value * e.scale, MIN_SCALE, MAX_SCALE);
      const bx = boundsX(scale.value);
      const by = boundsY(scale.value);
      tx.value = clamp(baseTx.value, -bx, bx);   // 图变小 → 位移同步收拢
      ty.value = clamp(baseTy.value, -by, by);
    })
    .onEnd(() => {
      if (scale.value < 1.05) {
        // 接近原大 → 整体弹回 1,并把基准一并归零
        scale.value = withSpring(1, SPRING);
        tx.value = withSpring(0, SPRING);
        ty.value = withSpring(0, SPRING);
        baseScale.value = 1; baseTx.value = 0; baseTy.value = 0;
      } else {
        baseScale.value = scale.value;   // ⚠️ 基准值重置:不做,二次捏合必跳变
        baseTx.value = tx.value;
        baseTy.value = ty.value;
      }
    });

  const pan = Gesture.Pan()
    .manualActivation(true)               // 仅放大时接管;否则判负,不干扰任何手势
    .onTouchesMove((_e, sm) => {
      if (scale.value > 1.05) sm.activate();
      else sm.fail();
    })
    .onUpdate((e) => {
      tx.value = rubber(baseTx.value + e.translationX, boundsX(scale.value));
      ty.value = rubber(baseTy.value + e.translationY, boundsY(scale.value));
    })
    .onEnd(() => {
      const bx = boundsX(scale.value);
      const by = boundsY(scale.value);
      const cx = clamp(tx.value, -bx, bx);       // 边界回弹:夹回合法范围
      const cy = clamp(ty.value, -by, by);
      tx.value = withSpring(cx, SPRING);
      ty.value = withSpring(cy, SPRING);
      baseTx.value = cx; baseTy.value = cy;      // 基准同步到回弹终点
    });

  const doubleTap = Gesture.Tap()
    .numberOfTaps(2)
    .onEnd(() => {
      const zoomed = scale.value > 1.05;
      const s = zoomed ? 1 : TAP_ZOOM;
      scale.value = withSpring(s, SPRING);
      tx.value = withSpring(0, SPRING);
      ty.value = withSpring(0, SPRING);
      baseScale.value = s; baseTx.value = 0; baseTy.value = 0;
    });

  const singleTap = Gesture.Tap()
    .numberOfTaps(1)
    .onEnd(() => { runOnJS(onClose)(); });

  const gesture = Gesture.Simultaneous(
    pinch,
    pan,
    Gesture.Exclusive(doubleTap, singleTap),   // 双击优先;判负后单击才关闭
  );

  const imgStyle = useAnimatedStyle(() => ({
    transform: [
      { translateX: tx.value },
      { translateY: ty.value },
      { scale: scale.value },
    ],
  }));

  return (
    <Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
      <GestureDetector gesture={gesture}>
        <View style={styles.backdrop}>
          <Animated.Image
            source={{ uri }}
            style={[styles.img, { width: W, height: H }, imgStyle]}
            resizeMode="contain"
          />
          <Text style={styles.hint}>双击放大 / 复位 · 单击关闭 · 捏合缩放</Text>
        </View>
      </GestureDetector>
    </Modal>
  );
}

const styles = StyleSheet.create({
  backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.95)' },
  img: { alignSelf: 'center' },
  hint: { position: 'absolute', bottom: 48, alignSelf: 'center', color: '#888', fontSize: 12 },
});

逐段讲解:

  • Gesture.Exclusive(doubleTap, singleTap):单击要等双击判负(约 300ms)才成立,所以"单击关闭"有轻微延迟——这是所有图库 App 的共同代价,换取双击可用。若不需要单击关闭,删掉 singleTap 即可消除延迟。
  • Pan 与 Pinch 用 Simultaneous 组合:双指捏合时往往伴随轻微位移,两者同时识别才能"边捏边挪";互斥会让捏合中的位移丢失。
  • 💡 进阶:目前双击/捏合都围绕图片中心缩放。要做"以指焦点缩放",需在捏合时按 (焦点 - 中心) × (1 - 新倍率/旧倍率) 修正 tx/ty,公式不复杂但建议先跑通本章再叠加。
  • 💡 若预览必须内嵌在 ScrollView 里(不能弹 Modal),仲裁方案是:Pan 保持 manualActivation(放大才激活),并在激活瞬间 runOnJS(setScrollEnabled)(false) 关掉父滚动、收起时恢复——成本是 JS 状态往返,能不用尽量不用。

性能要点与验收

  • 性能要点:手势-动画闭环全在 UI 线程;resizeMode="contain" 的大图缩放走 transform,不解码新尺寸;Modal 卸载时重置共享值,避免下次打开残留旧状态。
  • 验收:二次捏合无跳变(基准值重置生效);放大后平移有橡皮筋阻尼、松手回弹一次到位;双击放大/复位往返一致;未放大时 Pan 必判负,网格滚动与双击都不受干扰。

11.3 案例三:自定义下拉刷新

为什么不用系统 RefreshControl

系统组件开箱即用且带原生手感,但它的视觉与交互被系统锁死:转圈样式不可换、头部不能放品牌动效、无法做"下拉中显示上次同步时间/异常任务数"这类信息型头部、双端外观不一致。当这些是硬需求时才值得自写;选型对比见本节末尾。

架构决策

  1. 手势驱动而非纯滚动偏移驱动:iOS 的 bounce 偏移依赖系统,Android 需另开 overScroll,双端口径难统一。本章统一为:子列表关闭原生弹性(bounces={false}、overScrollMode="never"),在"已滚到顶部且向下拖"时由 Pan 手动激活,位移驱动头部。代价是失去原生回弹(见对比表),换来一套代码双端一致。
  2. 阻尼曲线:下拉距离到头部位移的映射用 d(x) = M(1 − e^(−x/M))。求导得 d′(0) = 1——起始段 1:1 跟手,之后越拉越"沉",渐近上限 M。一个函数同时给出跟手感与物理上限,且天然防抖(见陷阱表)。
  3. 三态与阈值一次性判定:idle / pulling / refreshing,加一个"已越过阈值"的瞬时判断。触发判定只放在 onEnd 做一次(pullY >= THRESHOLD 才刷新),绝不在 onUpdate 里边拉边触发——这是快速下拉抖动的根治手段。
  4. 文案/透明度等帧级 UI 也留在 UI 线程:用两条文案按 pullY 与阈值比较做透明度交叉,不需要 JS 状态参与。

完整代码

// src/components/PullToRefreshContainer.tsx
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Animated, {
  cancelAnimation,
  runOnJS,
  useAnimatedReaction,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
  withRepeat,
  withSpring,
  withTiming,
} from 'react-native-reanimated';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';

type Phase = 'idle' | 'pulling' | 'refreshing';

const THRESHOLD = 72;   // 触发阈值(也是刷新时头部呈现高度)
const MAX_PULL = 160;   // 阻尼渐近线:手指拉再远,头部最多 160px
const SLOP = 10;

/** 下拉阻尼:小位移近似 1:1 跟手(d'(0)=1),越拉越沉,渐近 MAX_PULL */
function damp(x: number): number {
  'worklet';
  if (x <= 0) return 0;
  return MAX_PULL * (1 - Math.exp(-x / MAX_PULL));
}

type ScrollHandler = ReturnType<typeof useAnimatedScrollHandler>;

interface Props {
  refreshing: boolean;
  onRefresh: () => void;
  header?: React.ReactNode;
  children: (scrollHandler: ScrollHandler) => React.ReactElement; // 子列表必须接上 onScroll
}

export function PullToRefreshContainer({ refreshing, onRefresh, header, children }: Props) {
  const pullY = useSharedValue(0);
  const phase = useSharedValue<Phase>('idle');
  const scrollOffset = useSharedValue(0);
  const spin = useSharedValue(0);
  const startY = useSharedValue(0);

  const scrollHandler = useAnimatedScrollHandler((e) => {
    scrollOffset.value = e.contentOffset.y;
  });

  const startRefresh = () => onRefresh();

  const beginRefresh = () => {
    'worklet';
    phase.value = 'refreshing';
    cancelAnimation(spin);
    spin.value = withRepeat(withTiming(-360, { duration: 900 }), -1, false);
  };

  // refreshing 由外部数据层驱动:true → 呈现头部;false → 收起
  useAnimatedReaction(
    () => refreshing,
    (cur, prev) => {
      if (cur && !prev && phase.value !== 'refreshing') {
        beginRefresh();
        pullY.value = withTiming(THRESHOLD);
      }
      if (!cur && prev && phase.value === 'refreshing') {
        phase.value = 'idle';
        cancelAnimation(spin);
        spin.value = 0;
        pullY.value = withSpring(0);
      }
    },
    [refreshing],
  );

  const pan = Gesture.Pan()
    .manualActivation(true)
    .onTouchesDown((e) => {
      const t = e.allTouches[0];
      if (t) startY.value = t.absoluteY;
    })
    .onTouchesMove((e, sm) => {
      const t = e.allTouches[0];
      if (!t) return;
      const dy = t.absoluteY - startY.value;
      if (phase.value !== 'refreshing' && scrollOffset.value <= 0 && dy > SLOP) {
        sm.activate();                 // 顶部 + 向下拖 → 接管
      } else if (Math.abs(dy) > SLOP) {
        sm.fail();                     // 其余情况全部让位给滚动
      }
    })
    .onUpdate((e) => {
      pullY.value = damp(e.translationY - SLOP);
      spin.value = (pullY.value / THRESHOLD) * 180;   // 拉动期:旋钮感
      phase.value = 'pulling';
    })
    .onEnd(() => {
      if (pullY.value >= THRESHOLD) {  // ⚠️ 阈值只在这里判定一次,杜绝抖动
        beginRefresh();
        pullY.value = withSpring(THRESHOLD, { damping: 26, stiffness: 320 });
        runOnJS(startRefresh)();
      } else {
        phase.value = 'idle';
        pullY.value = withSpring(0);
        spin.value = withSpring(0);
      }
    })
    .onFinalize(() => {
      if (phase.value === 'pulling') { // 被打断也要收回
        phase.value = 'idle';
        pullY.value = withSpring(0);
        spin.value = withSpring(0);
      }
    });

  const contentStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: pullY.value }],      // 纯 transform,零逐帧布局
  }));
  const headerStyle = useAnimatedStyle(() => ({
    opacity: Math.min(1, pullY.value / THRESHOLD),
  }));
  const spinnerStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${spin.value}deg` }],
  }));
  const idleText = useAnimatedStyle(() => ({ opacity: pullY.value < THRESHOLD ? 1 : 0 }));
  const armedText = useAnimatedStyle(() => ({ opacity: pullY.value >= THRESHOLD ? 1 : 0 }));

  return (
    <GestureDetector gesture={pan}>
      <View style={styles.fill}>
        <Animated.View style={[styles.header, headerStyle]} pointerEvents="none">
          {header ?? (
            <View style={styles.center}>
              <Animated.View style={[styles.knobWrap, spinnerStyle]}>
                <View style={styles.knob} />
              </Animated.View>
              <Animated.Text style={[styles.text, idleText]}>下拉刷新</Animated.Text>
              <Animated.Text style={[styles.text, styles.overlay, armedText]}>
                松手立即刷新
              </Animated.Text>
            </View>
          )}
        </Animated.View>
        <Animated.View style={[styles.fill, contentStyle]}>
          {children(scrollHandler)}
        </Animated.View>
      </View>
    </GestureDetector>
  );
}

const styles = StyleSheet.create({
  fill: { flex: 1 },
  header: {
    position: 'absolute',
    top: 0, left: 0, right: 0,
    height: THRESHOLD,
    justifyContent: 'center',
  },
  center: { alignItems: 'center' },
  knobWrap: { width: 26, height: 26, marginBottom: 6 },
  knob: {
    flex: 1, borderRadius: 13, borderWidth: 3,
    borderColor: '#4a90d9', borderTopColor: 'transparent',
  },
  text: { fontSize: 12, color: '#666' },
  overlay: { position: 'absolute', bottom: -16 },
});

逐段讲解:useAnimatedReaction 监听受控属性 refreshing——刷新由数据层(第 12 章起是 TanStack Query 的 isRefetching)驱动,组件只负责把布尔值翻译成进场/收起动画,这是受控组件的标准切分。头部本体固定高度、露出量由内容容器的 translateY 控制,全程纯 transform,没有逐帧布局。子列表通过 render prop 拿到 scrollHandler 并接到自己的 onScroll 上,容器才能知道"是否已在顶部"。

与系统方案的选型对比

维度 系统 RefreshControl 自定义(本章)
视觉/信息定制 不可(系统转圈) 完全可控(品牌动效、同步信息头)
双端一致性 两套原生外观 一套代码一致表现
原生手感 iOS 弹簧、Android EdgeEffect 现成 需自己模拟;本章关闭原生弹性换取统一
无障碍 系统朗读 需自行补 accessibility 标签与完成播报
维护成本 零 自担(约 120 行 + 边界用例)
建议 默认选择 仅当品牌/信息头是硬需求时使用

11.4 交互组件的性能验收方法

交互组件的"流畅"必须拿证据说话,口沿用第 2 章的 PerfTracker(P90/P99),这里补齐系统级取证手段。

帧率证据采集(release 包 + 真机,debug 包的 dev 校验开销会污染结论):

# Android:抓 20s trace(拖拽操作覆盖其中 10s 以上)
adb shell perfetto -o /data/misc/perfetto-traces/drag.pftrace -t 20s \
  sched freq gfx view input am wm
adb pull /data/misc/perfetto-traces/drag.pftrace
# iOS:Instruments → Time Profiler + Animation Hitches 模板,真机 release 配置录制

截图归档要点:① 保留时间刻度与选区框,能看出"哪一段是操作期";② 线程泳道名可见(Android 看 RenderThread 与 UI 线程的 Choreographer#doFrame,bridgeless 下 JS 跑在 mqt_js);③ 掉帧标红段放大并标注帧号;④ 同一操作保留"静止基线段 + 操作段"对比;⑤ .pftrace/.trace 文件与验收卡一起入库存档。健康标准:操作期内 UI/Render 线程帧耗时 P90 ≤ 8ms(60Hz 下留一半余量给系统),P99 ≤ 16ms;JS 泳道在操作期应近似平坦(这正是第 9 章"手势动画不回 JS"的可见证据)。

JS 线程满载对照实验:人为把 JS 线程打满,验证交互不受影响——这是区分"UI 线程实现"与"setState 实现"最直观的实验:

// 临时注入:每 8ms 忙算 12ms,把 JS 线程压满(仅实验用,禁止入库)
function useJsBurn(on: boolean) {
  React.useEffect(() => {
    if (!on) return;
    let stop = false;
    const burn = () => {
      if (stop) return;
      const t = Date.now();
      while (Date.now() - t < 12) Math.sqrt(Math.random());
      setTimeout(burn, 8);
    };
    burn();
    return () => { stop = true; };
  }, [on]);
}

预期:本章三个组件在 useJsBurn(true) 下依旧满帧(手势在 UI 线程闭环);作为对照,把 updateHover 换成 setState 重排的版本会立刻掉帧——这个对照实验本身就是最有说服力的架构证明,呼应第 1、9 章的测量方法论。

低端机测试清单:

  • release + Hermes(JIT 预热差异大,禁用 debug 结论);
  • 指定最低支持档真机(如 2GB RAM 的上上代 Android),不用旗舰代劳;
  • 关闭省电/性能模式干扰,清后台,记录设备温度(降频会污染后半段数据);
  • 每个用例重复 ≥10 次取 P90/P99,冷启动后第一次交互单独记录;
  • 断网与联网各测一轮,排除网络回调打断动画的变量。

交互验收卡模板(每个动效组件一张,随 PR 提交):

### 交互验收卡:DraggableSortList
- 手势冲突项:纵向滚动 / 长按 / 系统返回侧滑 —— 结论:SLOP 判负 + activate 锁定,实测无冲突
- 帧率(release、60Hz、机型 XXX):UI 线程 P90 __ms / P99 __ms / 掉帧 __ 帧
- JS 线程:操作期占用 __%(应近 0);useJsBurn 对照:通过 / 未通过
- 内存:进入前后 RSS 增量 __MB;连拖 10 次无持续增长
- 无障碍替代:TalkBack/VoiceOver 提供"上移/下移"按钮路径,已验证
- reduce motion:开启后弹簧即时化、自动滚动保留,功能无缺失
- 低端机(机型):通过 / 未通过(附 trace 文件名)

11.5 交互组件的工程化:抽成独立包的接口设计

当动效组件要跨 App 复用(FieldNote 之外还有巡检大屏等),按三条标准抽包:

受控 / 非受控。默认非受控(组件内部持有 slots/pullY,松手时回调提交),同时给受控出口让特殊业务接管:

// 包对外只暴露三类东西:组件、类型、高级钩子
export { DraggableSortList } from './DraggableSortList';
export { PhotoLightbox } from './PhotoLightbox';
export { PullToRefreshContainer } from './PullToRefreshContainer';
export type {
  SortItem,
  DraggableSortListProps,     // data + onReorder(非受控);
} from './types';             // 可选 order?: string[] 受控槽位、onDragStateChange 状态回调
export { useDragController } from './useDragController'; // 业务方自绘 UI 时用

回调边界:传"事件",不传"帧"。跨过 JS 边界的信息必须是离散事件(onReorder、onDragStateChange、onRefresh),绝不暴露每帧坐标——那是 SharedValue 的领地。一旦组件把帧级数据推给 JS,UI 线程架构就名存实亡。

动画关闭降级(尊重系统"减少动态效果")。Reanimated 3.5+(写作时已属 3.x 稳定线,以官方文档为准)内置了两条路:每动画的 reduceMotion: ReduceMotion.System 配置,以及 useReducedMotion() 钩子;无该版本的工程用 AccessibilityInfo 手动降级:

import { AccessibilityInfo } from 'react-native';
import { useReducedMotion, withTiming, withSpring } from 'react-native-reanimated';

function useHandFeel() {
  const reduced = useReducedMotion();       // 旧版本可用 AccessibilityInfo 监听 reduceMotionChanged 替代
  return useCallback(
    (target: number) =>
      reduced
        ? withTiming(target, { duration: 0 })   // 即时到位:保留功能,去掉装饰性运动
        : withSpring(target, SPRING),
    [reduced],
  );
}

降级原则:去动效,不去功能——拖拽仍可完成(位移即时化)、自动滚动保留(它是功能不是装饰)、下拉刷新照常触发(只省略过渡)。抽包配置上,peerDependencies 声明 react、react-native、react-native-reanimated、react-native-gesture-handler,由宿主 App 决定版本;包内禁止 import 任何业务类型,保持纯净。

典型业务场景:FieldNote 实战

把三个案例接进 App。接线原则:屏幕只做"数据 ↔ 组件事件"的翻译,动效细节全部封在组件内。

1. 任务详情:检查项分组挂载拖拽排序 + sortKey 落库

// src/features/checklist/CheckItemsBoard.tsx
import React, { useCallback } from 'react';
import { DraggableSortList, SortItem } from '@/components/DraggableSortList';
import { useChecklistStore } from './checklistStore';

export function CheckItemsBoard({ taskId }: { taskId: string }) {
  const items: SortItem[] = useChecklistStore(
    (s) => s.byTask[taskId] ?? EMPTY,
  );
  const commitOrder = useChecklistStore((s) => s.commitOrder);

  const handleReorder = useCallback(
    (orderedIds: string[]) => {
      // store 内部:diff 出被移动项 → 前后邻居 sortKey 中点 →
      // UPDATE check_items SET sort_key = ?, dirty = 1 WHERE id = ?
      // dirty 标记的同步交给离线链路(第 14 章起),本章只保证本地落库正确
      commitOrder(taskId, orderedIds);
    },
    [commitOrder, taskId],
  );

  return <DraggableSortList data={items} onReorder={handleReorder} />;
}
// checklistStore 中提交动作的核心(示意)
commitOrder: (taskId, orderedIds) => {
  const old = get().byTask[taskId]?.map((i) => i.id) ?? [];
  const movedIdx = orderedIds.findIndex((id, i) => old[i] !== id);
  if (movedIdx < 0) return;
  const prevKey = db.getSortKey(orderedIds[movedIdx - 1]);   // 无则 undefined
  const nextKey = db.getSortKey(orderedIds[movedIdx + 1]);
  db.run(
    'UPDATE check_items SET sort_key = ?, dirty = 1 WHERE id = ?',
    [midpointSortKey(prevKey, nextKey), orderedIds[movedIdx]],
  );
  set((s) => ({ byTask: { ...s.byTask, [taskId]: reload(taskId) } }));
},

2. 任务详情:照片九宫格 + 预览接线

// src/features/photo/PhotoGrid.tsx(节选)
const [previewUri, setPreviewUri] = useState<string | null>(null);

return (
  <View>
    <FlashList
      data={photos}
      numColumns={3}
      estimatedItemSize={96}
      renderItem={({ item }) => (
        <Pressable onPress={() => setPreviewUri(item.localPath)}>
          <Thumbnail photo={item} size={96} />
        </Pressable>
      )}
    />
    <PhotoLightbox
      visible={previewUri !== null}
      uri={previewUri ?? undefined}
      onClose={() => setPreviewUri(null)}
    />
  </View>
);

3. 列表页:替换系统刷新(FlashList + render prop)

// src/features/tasks/TaskListScreen.tsx(节选)
<PullToRefreshContainer refreshing={isRefetching} onRefresh={refetch}>
  {(scrollHandler) => (
    <FlashList
      data={tasks}
      estimatedItemSize={72}
      renderItem={TaskRow}
      onScroll={scrollHandler}        // 类型上做一次兼容断言;scrollEventThrottle 必须给
      scrollEventThrottle={16}
      bounces={false}                  // 自定义刷新的前提:关掉原生弹性
      overScrollMode="never"
    />
  )}
</PullToRefreshContainer>

M11 验收清单(对照附录 B,全部勾选才可进下一阶段):

  • [ ] 拖拽全链路零 setState,useJsBurn 对照实验通过(JS 线程近 0)
  • [ ] 让位/落位动画 UI 线程 P90 ≤ 8ms、P99 ≤ 16ms,trace 已归档
  • [ ] 自动滚动节奏平滑无"一冲一停";松手一次弹簧落位无跳变
  • [ ] 杀进程重进,检查项顺序保持(sortKey 中点落库 + dirty 标记正确)
  • [ ] Pinch 二次捏合无跳变;边界回弹正常;未放大时预览不干扰任何滚动
  • [ ] 自定义刷新三态正确;快速下甩越过阈值无抖动;刷新中不可再拉
  • [ ] 系统开启"减少动态效果"后:三案例动效即时化、功能无缺失
  • [ ] TalkBack/VoiceOver 下排序有"上移/下移"替代路径,预览可关闭
  • [ ] 低端真机通过交互验收卡全部条目

常见陷阱

陷阱 典型症状 根因 正确做法
用 setState 重排实现让位 中端机百行列表拖拽掉帧,JS 线程飙满 每次槽位变化都走 render→reconcile→原生布局全链路 槽位映射:SharedValue 数字数组 + transform,数据只在松手时提交一次
自动滚动与手势 update 时序抖动 贴边滚动"一冲一停",让位滞后一帧 只在 scroll 事件里更新偏移,帧内计算用了旧值;或速度按"每帧"而非按时间 帧循环内先记账再 scrollTo;速度用 px/ms × timeSincePreviousCall
Pinch 后未重置基准值 第二次捏合瞬间跳变到错误倍率 e.scale 是相对本手势开始的增量,baseScale 未更新 onEnd 把当前 scale/tx/ty 写回 base*;弹回 1 时基准一并归零
自定义刷新快速下甩越过阈值抖动 快速一甩直接触发刷新,或头部来回弹 在 onUpdate 里边拉边判定阈值,且原始位移未做阻尼 阻尼曲线压速度 + 阈值只在 onEnd 判定一次
reduce motion 未处理 系统"减少动态效果"开启后 App 仍在大幅运动,上架审核风险 组件只实现了弹簧路径 useReducedMotion/AccessibilityInfo 降级:去动效不去功能
拖拽把滚动也带起 一按住列表就跟着滚,或拖拽中内容双重移动 Pan 提前激活抢了滚动,或激活后未取消原生滚动 manualActivation + 未决/判负决策;激活即接管(RNGH 自动取消原生手势)
手势被打断后组件卡死 来电/通知后列表既不能拖也不能滚 只写了 onEnd,onFinalize 没收尾 isDragging/scrollSpeed/帧循环的复位统一放 onFinalize(幂等)
拖拽包内残留业务类型 组件无法在第二个 App 复用 回调签名里混入 Task/CheckItem 等领域类型 包只认 id + 展示字段(泛型约束),业务映射留在 App 层

最佳实践

  • 交互热路径(手势回调、动画、滚动)全部留在 UI 线程,JS 只接收离散事件;用 useJsBurn 对照实验证明它
  • "谁在哪个槽位"用一个 SharedValue 数字数组表达,它是唯一真值源;业务数据在提交前保持只读
  • 让位、落位、回弹共用同一条 withSpring(目标槽位) 样式路径,靠状态切换获得免费连续性
  • 长按拖拽:LongPress 发令 + Pan manualActivation 接力;onFinalize 必须做幂等收尾
  • 自动滚动用 useFrameCallback + 时间基准速度(px/ms),贴近边缘线性渐增
  • 排序落库用浮点中点策略,只写被移动项;设最小间距阈值触发再均衡
  • 拖拽排序只用于"固定行高、数量有界"的列表;千条级主列表拒绝整表拖拽
  • 缩放类组件强制分离"本手值/基准值",onEnd 必须回写基准
  • 自定义刷新:阻尼函数给跟手感与上限,阈值判定只出现在 onEnd;受控属性驱动进场/收起
  • 每个动效组件配一张交互验收卡(手势冲突/帧率/JS 占用/内存/无障碍/reduce motion/低端机),随 PR 归档 trace
  • 尊重系统减少动态效果:去动效不去功能,降级路径要写进组件而不是调用方
  • 抽包三件套:组件 + 类型 + 高级钩子;peerDependencies 管版本;包内零业务 import

本章小结

本章把第 9、10 章的手势与动画能力收敛成三个生产级案例:拖拽排序用"槽位映射"把整条交互链路压进 UI 线程,松手才以 sortKey 中点落库;照片预览用"本手值/基准值分离"消灭二次缩放跳变,用 Modal 从物理上完成手势仲裁;自定义刷新用阻尼曲线和一次性阈值判定拿到既跟手又防抖的手感。三个案例共同证明了同一件事:交互性能的上限取决于"每帧干了多少事、在哪条线程干"——把帧级工作留在 UI 线程、把 JS 边界留给事件,帧率证据自然好看。

一句话记忆点:让位改的是数字,不是数据;流畅的证据不是感觉,是验收卡上的 P99。

动手练习

  1. 基础巩固:把 DraggableSortList 的 SPRING 改为 { damping: 18, stiffness: 120 },观察落位出现明显过冲;再解释 damping/stiffness 各自如何影响手感(提示:阻尼比 < 1 会过冲,刚度决定收敛速度)。然后把行高抽成 rowHeight prop,找出代码里所有"必须随行高联动"的常量并说明原因。
  2. FieldNote 实战(排序落库):为检查项分组接入本章拖拽组件,实现"被移动项 sortKey = 前后邻居中点 + dirty=1"的落库;再写一个用例:连续 30 次把同一项插到同两位邻居之间,验证你的再均衡触发条件会被命中(提示:打印最小相邻间距)。
  3. FieldNote 实战(照片多选排序):给照片九宫格加"长按进入多选,多选中可拖拽调整照片顺序",直接复用 DraggableSortList。思考:照片有缩略图、无固定行高语义,你选择"固定 96px 虚拟行高 + 网格 3 列"还是为它扩一版前缀和实现?说明取舍。
  4. 排查/思考题:让位动画为什么必须用"位置映射"而不是"每次重排数据"?请分别估算 100 项列表下两种方案每次槽位变化的成本量级——映射方案:O(k) 次数字写入 + k 行 transform 重算(k 为被让位的行数,通常 1),发生在哪条线程、耗时什么量级;重排方案:setState → 100 行组件 re-render → reconcile → shadow tree 提交 → 原生布局,按每行渲染 0.5~3ms 估算总耗时,并说明它落在哪条线程、对手指跟手性的影响。给出你的结论与测量方案。

导航

上一章:第 10 章 手势系统与 Gesture Handler | 下一章:第 12 章 状态管理方案选型

📄 第 12 章 状态管理方案选型 12-state-management.md

本章导读:交互打磨完成后(第 9~11 章),复杂度开始从"每帧做什么"转向"数据住在哪里"。本章是"状态与架构"阶段的开篇:先建立状态分类学这一方法论基石,再从渲染订阅原理出发横评主流方案,最后给出选型决策树与 FieldNote 的完整状态清单落地。它直接为第 13 章的模块化架构(按模块切状态边界)提供依据,也为第 14~16 章离线同步(Outbox、增量同步)备好服务端状态的心智模型。

本章学习目标

  • 能用"服务端/客户端/UI/持久化"四分类法给任意状态归类,并判定它应住在哪一层
  • 能解释 Context 广播模型、外部 store + useSyncExternalStore 细粒度订阅、信号思想的实现差异与性能后果
  • 能说出主流方案(Zustand、Jotai、Redux Toolkit、MobX、Valtio 等)的原理一句话、适用场景与规模天花板
  • 能用 TanStack Query 的缓存键、失效、乐观更新心智模型管理服务端状态
  • 能按决策树为小/中/大型项目选定状态组合,并写出本团队的禁止事项清单
  • 能设计"组件态→容器态→领域态→基础设施态"的单向依赖,并接好 mmkv 持久化
  • 能为状态写 selector 单测,用时间旅行与调试工具定位状态问题
  • 能为 FieldNote 产出完整状态清单与"为什么不用 Redux"的 ADR 决策记录

核心知识点

12.1 状态分类学:先分类,再选型

原理。状态管理混乱的根源几乎从来不是"库选错了",而是"状态放错了层"。先定义四类:

  1. 服务端状态:远端数据在客户端的副本(任务列表、检查项、照片元信息)。它有三个本地状态没有的属性——会过期(别人可以改它)、需要失效与重取、有并发请求问题(同一份数据多个请求在飞)。它关心的核心问题是缓存生命周期,不是"怎么存"。
  2. 客户端状态:UI 编排所需的跨组件状态(会话、同步开关、当前筛选)。事实源在本机,生命周期由 App 控制。
  3. UI 状态:本组件内的瞬时状态(输入草稿、按压态、拖拽中的悬停槽位)。离开组件即销毁,其中每帧变化的高频部分应该进一步下放到 SharedValue(第 11 章的 hoverSlot 就是范例)。
  4. 持久化:不是一个独立类别,而是任何类别都可以叠加的属性——主题(客户端态 + 持久化)、会话令牌(客户端态 + 持久化 + 敏感)、Outbox(领域数据 + 持久化)。"要不要活过重启"与"它是哪类状态"是两个正交的问题。

判断一个状态住哪,依次问四个问题:**它是谁的事实?谁写它?变化频率多高?要不要活过重启/跨页回放?**第一个问题最关键:事实在服务器的,客户端只配拥有缓存,就绝不该用普通 store 去"存"它。

flowchart TD
  S[一个新状态出现了] --> Q1{它是服务端数据的副本或派生吗}
  Q1 -->|是| A[TanStack Query<br/>queryKey + 缓存 + 失效]
  Q1 -->|否| Q2{离开组件就销毁吗}
  Q2 -->|是| B[组件本地 useState / useReducer]
  Q2 -->|否| Q3{每帧或每次击键都在变吗}
  Q3 -->|是| C[留在本地<br/>高频部分下放 SharedValue 或订阅点]
  Q3 -->|否| Q4{重启后要还在吗}
  Q4 -->|是| D[Zustand + persist(mmkv)<br/>敏感值进 Keychain/Keystore]
  Q4 -->|否| Q5{跨多层树共享的低频配置吗}
  Q5 -->|是| E[Context]
  Q5 -->|否| B

把 FieldNote 的状态全量清点并归类,这张表就是本章实战部分的施工图:

状态 例子 分类 归属
任务列表 / 详情 tasks、task detail、检查项 服务端状态 TanStack Query
当前筛选 / 排序 storeId、planDate、只看异常 路由态(可回放的客户端态) 导航参数为主,跨页共享时入 Zustand
主题 dark / light / 跟随系统 持久化客户端态(低频) Context + mmkv
会话 已登录、用户名、角色 持久化客户端态(敏感部分单独管) Zustand;token 进 Keychain/Keystore
同步开关 仅 Wi-Fi 同步、后台同步开关 持久化客户端态 Zustand + persist(mmkv)
Outbox 待同步队列 本地写操作日志 持久化领域态 SQLite,同步引擎独占(第 14 章)
打分中的临时分值 分数面板当前按住的分值 UI 瞬时态 组件本地 state,确认时才提交
拖拽悬停槽位 第几行被拖过 UI 瞬时态(帧级高频) Reanimated SharedValue(第 11 章)
表单草稿 未提交的巡检备注 本地态(可丢弃 / 可草稿化) 本地 state;要防崩溃丢失时入 mmkv 草稿键

⚠️ 表里最容易被放错的是"任务列表":新手常用 Zustand 存 tasks,从此陷入手写刷新、手动去重、下拉后数据错乱的泥潭——因为它本质是缓存,而 Zustand 没有缓存生命周期的概念。12.4 节专门解这一刀。

12.2 渲染订阅原理:Context、外部 store 与信号

方案性能差异的根源在订阅粒度:值变化后,谁会被要求重新渲染。

Context 的广播模型。Provider 的 value 变化时,React 会让所有消费该 Context 的组件重渲染,不做逐消费者的相等性判断。这是实现机制决定的:Context 是"值注入"而非"订阅查询",消费侧只有"我从哪个 Context 读值"这一条信息,没有"我读了这个值里的哪个字段"。所以把高频值放进 Context,等于每次变化向整棵子树广播。

外部 store + useSyncExternalStore 的细粒度订阅。React 18+ 提供的 useSyncExternalStore(subscribe, getSnapshot) 是外部 store 的标准接入契约:订阅函数注册监听,getSnapshot 返回当前快照,React 在每次渲染后对比快照,不一致就重渲染。细粒度由此而来——快照可以是 selector 的结果:useStore(s => s.a) 只在 s.a 的引用变化时才触发渲染,s.b 变化与它无关。代价是契约很严格:getSnapshot 每次必须返回引用稳定的值(返回新对象/新数组会导致"快照永远不等"而死循环或频繁重渲染),selector 的结果需要缓存与浅比较(Zustand 的 useShallow 就是干这个的)。

信号(Signals)思想。Solid、@preact/signals 等把依赖收集做到更细:组件渲染时记录"读了哪些信号",信号变化时只重跑受影响的读取点,甚至跳过整个组件函数。它是"订阅粒度"谱系上更激进的一档;React 生态里可用但需接受与并发特性的磨合成本,本章作为概念认知,不作为 FieldNote 的选型。

Context(广播)                          外部 store + selector(订阅)
<Provider value={{ a, b }}>              store = { a, b }
  ├─ Comp1 读 a ← a 或 b 变都重渲染        ├─ useStore(s => s.a) ← 只有 Comp1 关心 a
  └─ Comp2 读 b ← a 或 b 变都重渲染        └─ useStore(s => s.b) ← 只有 Comp2 关心 b
一次变化 → 全部 consumer 重渲染           一次变化 → selector 结果变了的才渲染

用最小的 useSyncExternalStore 实现看清契约:

// 手写一个 20 行的 mini-store,理解 Zustand 的内核
import { useSyncExternalStore } from 'react';

function createStore<T>(initial: T) {
  let state = initial;
  const listeners = new Set<() => void>();
  return {
    getState: () => state,
    setState: (patch: Partial<T>) => {
      state = { ...state, ...patch };
      listeners.forEach((l) => l());          // 通知,但不决定谁渲染
    },
    subscribe: (l: () => void) => {
      listeners.add(l);
      return () => listeners.delete(l);
    },
  };
}

export function useStore<T, R>(store: ReturnType<typeof createStore<T>>, selector: (s: T) => R): R {
  // ⚠️ selector 必须返回引用稳定的值;返回新建对象会破坏快照比较
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.getState()),
  );
}

订阅粒度对比:

机制 订阅单位 值变化时 粒度 典型代表
Context Provider 的整个 value 所有 consumer 重渲染 粗 ThemeContext
useSyncExternalStore + selector selector 结果的引用 结果变了才渲染 中→细 Zustand
原子化 单个 atom 只通知读到该 atom 的组件 细 Jotai
可观察对象 被读取的属性(依赖追踪) 属性级精确通知 细 MobX
信号 读取点(依赖收集) 可跳过整个组件函数 最细 Solid / @preact/signals
Redux connect/mapState mapState 结果浅比较 变了才渲染 中 react-redux

12.3 方案横评:原理一句话与规模天花板

每个方案给最小代码、原理一句话、适用场景与天花板。判定标准统一为四条:心智负担、样板成本、细粒度更新能力、团队约定强度。

Context + useReducer——原理:React 内置的值注入 + 归约器,零依赖。

const SyncCtx = createContext<{ syncOnCellular: boolean; toggle: () => void } | null>(null);

function SyncProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(
    (s: { syncOnCellular: boolean }, a: 'toggle') => ({ ...s, syncOnCellular: !s.syncOnCellular }),
    { syncOnCellular: false },
  );
  const value = useMemo(() => ({ ...state, toggle: () => dispatch('toggle') }), [state]);
  return <SyncCtx.Provider value={value}>{children}</SyncCtx.Provider>;
}

适用:低频跨树配置(主题、语言)。天花板:小。一旦 value 高频变化或字段增多,广播成本和"一个 value 对象"的引用管理会失控。

Zustand——原理:模块级外部 store + useSyncExternalStore,selector 决定订阅粒度,中间件(persist/devtools/immer)按需叠加。

import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';

interface PrefsState {
  syncOnCellular: boolean;
  hapticsOn: boolean;
  setSyncOnCellular: (v: boolean) => void;
}
export const usePrefs = create<PrefsState>()((set) => ({
  syncOnCellular: false,
  hapticsOn: true,
  setSyncOnCellular: (v) => set({ syncOnCellular: v }),
}));

// 组件里:selector 订阅,字段级粒度
const cellular = usePrefs((s) => s.syncOnCellular);
// 需要多个字段时用 useShallow 保持引用稳定
const { syncOnCellular, hapticsOn } = usePrefs(
  useShallow((s) => ({ syncOnCellular: s.syncOnCellular, hapticsOn: s.hapticsOn })),
);
// 组件外直接读写(同步引擎、工具函数里很常用)
usePrefs.getState().setSyncOnCellular(true);

适用:中大型项目的客户端全局态。天花板:大(配合模块化拆 store,见第 13 章)。它没有强约定,规模上去后需要团队自律(本节末的禁止事项清单就是自律的一部分)。

Jotai——原理:原子化,状态拆成最小单元 atom,派生 atom 自动追踪依赖。

import { atom, useAtomValue, useSetAtom } from 'jotai';

const storeIdAtom = atom<string>('A');
const onlyAbnormalAtom = atom(false);
// 派生 atom:只依赖上面两个,任一变化自动重算
const filterAtom = atom((get) => ({ storeId: get(storeIdAtom), onlyAbnormal: get(onlyAbnormalAtom) }));

function FilterBar() {
  const setStore = useSetAtom(storeIdAtom);
  const filter = useAtomValue(filterAtom);
  return <Text onPress={() => setStore('B')}>当前:{filter.storeId}</Text>;
}

适用:大量独立小状态 + 复杂派生(表单联动、可编辑列表)。天花板:中。atom 零散,需要按域组织文件结构,否则"原子风暴"难以检索。

Redux Toolkit(RTK)——原理:单一 store + action 驱动 + 不可变更新的强约定,RTK Query 顺带解决服务端状态。

import { configureStore, createSlice } from '@reduxjs/toolkit';

const prefsSlice = createSlice({
  name: 'prefs',
  initialState: { syncOnCellular: false },
  reducers: {
    setSyncOnCellular: (s, a) => { s.syncOnCellular = a.payload; }, // immer 让你"直接改"
  },
});
export const store = configureStore({ reducer: { prefs: prefsSlice.reducer } });
// RTK Query:服务端状态的一体化方案(与 TanStack Query 同位竞争)
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const api = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: process.env.EXPO_PUBLIC_API_BASE ?? 'https://api.example.com' }),
  tagTypes: ['Task'],
  endpoints: (b) => ({
    getTasks: b.query({ query: (f) => ({ url: '/tasks', params: f }), providesTags: ['Task'] }),
  }),
});

适用:大型团队、需要统一约定与强 devtools 的项目。天花板:很大,但样板税与学习曲线也随之而来。选 RTK 的核心理由应是"约定与团队",不是性能。

MobX——原理:可观察对象 + 依赖追踪,observer 组件按属性级精确更新,允许"直接改"的心智。

import { makeAutoObservable } from 'mobx';
import { observer } from 'mobx-react-lite';

class SessionStore {
  signedIn = false;
  userId: string | null = null;
  constructor() { makeAutoObservable(this); }
  signIn(id: string) { this.signedIn = true; this.userId = id; }
}
export const session = new SessionStore();

export const SessionBadge = observer(() => {
  return <Text>{session.signedIn ? session.userId : '未登录'}</Text>; // 只订阅被读取的属性
});

适用:领域模型清晰、喜欢 OOP 风格的团队;与 TS 装饰器配置有磨合成本。天花板:大。

Valtio——原理:proxy 包装状态,useSnapshot 按读取路径追踪,写法是"直接改"。适合从 Vue 心智迁移的团队,体量小,RN 社区占有率低于前几者,了解即可。

Recoil 警示:Meta 已宣布停止积极维护(写作时仓库已归档)。它曾是原子化的代表方案,但新项目不应再选;已有项目按第 13 章的模块边界逐步迁往 Jotai 或 Zustand。

方案横评总表:

方案 原理一句话 样板成本 TS 体验 调试工具 适合场景 规模天花板
Context+useReducer 内置值注入+归约 低 好 无专用 低频跨树配置 小
Zustand 外部 store+selector 订阅 低 好 devtools 中间件 客户端全局态 大
Jotai 原子化+派生 低 好 专用 devtools 大量小状态+派生 中
Redux Toolkit 单 store+action 约定 高 好 最强 devtools/时间旅行 大团队强约定 很大
RTK Query RTK 内建的服务端缓存 高 好 同上 已选 RTK 的团队 很大
MobX 可观察对象+依赖追踪 中 中 spy/trace OOP 领域模型 大
Valtio proxy+按路径追踪 低 中 一般 Vue 心智团队 中

12.4 TanStack Query:服务端状态的心智模型

服务端状态的核心不是"存哪",而是缓存生命周期。TanStack Query 用三个概念覆盖它:

  1. 缓存键(queryKey):缓存的地址。键是层级数组,前缀匹配是失效的基础——['tasks'] 失效会连带所有列表;['task', id] 精确命中一个详情。键里放影响结果的一切变量(筛选、分页),不放无关字段。
  2. 陈旧与失效(staleTime / invalidate):staleTime 决定数据多久内"新鲜"(挂载/聚焦不重取);invalidateQueries 把数据标记为脏并触发重取。要区分 staleTime(多久内可信)与 gcTime(不活跃后缓存保留多久)。
  3. 乐观更新:本地先改缓存(快)、请求失败回滚(稳)、成功后失效对账(真)。这是第 9 章检查项打勾动效在数据层的正确搭档——动画不等于数据已同步,乐观更新负责让两者观感一致。
// 缓存键工厂:层级设计是失效策略的地基
export const taskKeys = {
  all: ['tasks'] as const,
  lists: () => [...taskKeys.all, 'list'] as const,
  list: (f: TaskFilter) => [...taskKeys.lists(), f] as const,
  details: () => [...taskKeys.all, 'detail'] as const,
  detail: (id: string) => [...taskKeys.details(), id] as const,
};

// 列表查询:键携带筛选;切换筛选时 keepPreviousData 保住旧数据避免闪空
export function useTasks(filter: TaskFilter) {
  return useQuery({
    queryKey: taskKeys.list(filter),
    queryFn: () => api.fetchTasks(filter),
    staleTime: 60_000,
    placeholderData: keepPreviousData,
  });
}

// 打分乐观更新:cancel → 快照 → 本地改 →(失败回滚 / 结束失效对账)
export function useScoreCheckItem(taskId: string) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (v: { itemId: string; score: number; note?: string }) =>
      api.scoreCheckItem(v),
    onMutate: async (v) => {
      await queryClient.cancelQueries({ queryKey: taskKeys.detail(taskId) });
      const prev = queryClient.getQueryData<TaskDetail>(taskKeys.detail(taskId));
      queryClient.setQueryData<TaskDetail>(taskKeys.detail(taskId), (old) =>
        old
          ? {
              ...old,
              items: old.items.map((it) =>
                it.id === v.itemId ? { ...it, score: v.score, note: v.note ?? it.note } : it,
              ),
            }
          : old,
      );
      return { prev };
    },
    onError: (_e, _v, ctx) => {
      if (ctx?.prev) queryClient.setQueryData(taskKeys.detail(taskId), ctx.prev);
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: taskKeys.detail(taskId) });
      queryClient.invalidateQueries({ queryKey: taskKeys.lists() }); // 前缀失效:徽标计数等派生联动
    },
  });
}

两点 RN 特有说明:refetchOnWindowFocus 在 RN 里没有"窗口焦点"概念,等效物是监听 AppState 回前台触发重取(Query 写作时最新版对 RN 的支持口径以官方文档为准);离线行为(请求队列、networkMode)本章只建立概念,真正的离线优先架构在第 14~16 章用 Outbox + 增量同步完整实现——Query 管的是"读缓存的体验",Outbox 管的是"写操作的可靠性",两者互补而非替代。

12.5 选型决策树、组合策略与状态分层

本书立场(大型 RN 生产项目):TanStack Query 管服务端状态 + Zustand 管客户端全局态 + Context 只放低频跨树配置 + 组件本地 state 优先。它不是唯一正解,但它让"两类状态、两套工具"的边界最清晰,且每一层都可被第 13 章的模块化直接继承。

项目规模 服务端状态 客户端全局 跨树配置 说明
小(个人工具,<10 页) TanStack Query 组件本地为主 可省 不引入任何全局 store 也能活
中(10~30 页) TanStack Query Zustand Context FieldNote 的形态(本章实战)
大(多模块/多团队) TanStack Query 或 RTK Query 二选一 Zustand 或 RTK(按团队约定) Context + 模块内私有 store 第 13 章按模块切边界,跨模块只走接口

禁止事项清单(评审时逐条对照):

  1. 不用全局 store 存表单逐字符输入——每次击键广播全树,本地 state + 提交时才入 store
  2. 不把服务端数据手抄进 Zustand/Redux——那是没有失效机制的劣化缓存
  3. 不在 selector/getSnapshot 里返回每次新建的对象数组——引用不稳定导致重渲染甚至死循环,多字段用 useShallow
  4. 不 persist 整个大 store——用 partialize 白名单 + version/migrate 管迁移
  5. 不跨模块直接 set 对方状态——只暴露语义化 action,保持第 13 章的模块边界
  6. 不为"未来可能需要"提前引入 Redux——抽象税要等规模真到了再交
  7. 不把 token 等敏感值 persist 进 mmkv——进 Keychain/Keystore(第 17 章展开)
  8. 不在 render 里写 store——副作用进 action/事件回调

状态分层与依赖方向。把 12.1 的分类落成四层,依赖只能自上而下:

flowchart TD
  L1[组件态<br/>输入草稿 / 按压态 / 动画 SharedValue] --> L2[容器态<br/>页面编排 / 派生数据 / 列表筛选]
  L2 --> L3[领域态<br/>任务缓存 Query / 会话与设置 Zustand]
  L3 --> L4[基础设施态<br/>SQLite / mmkv / 网络客户端 / 日志]

单向原则的含义:下层绝不 import 上层——领域态不知道某个页面存在,基础设施态不知道任何业务概念。违规的典型信号是"store 文件里出现了 navigation.navigate"或"UI 组件里直接拼 SQL"。第 13 章按模块切分时,跨模块通信只允许发生在领域态层(通过语义化 action 或领域服务),这是本章分层在架构阶段的直接延续。

12.6 持久化衔接与测试调试

persist 与存储选型的衔接。Zustand 的 persist 中间件不关心存储介质,只要适配 StateStorage 三件套。FieldNote 用 mmkv(同步、快、压缩,呼应第 14 章的选型理由),敏感值例外走 Keychain/Keystore:

// src/stores/prefsStore.ts
import { MMKV } from 'react-native-mmkv';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import type { StateStorage } from 'zustand/middleware';

const mmkv = new MMKV({ id: 'fieldnote-prefs' });

const mmkvStorage: StateStorage = {
  setItem: (key, value) => mmkv.set(key, value),
  getItem: (key) => mmkv.getString(key) ?? null,
  removeItem: (key) => mmkv.delete(key),
};

interface PrefsState {
  syncOnCellular: boolean;
  hapticsOn: boolean;
  schemaVersion: number;
  setSyncOnCellular: (v: boolean) => void;
}

export const usePrefs = create<PrefsState>()(
  persist(
    (set) => ({
      syncOnCellular: false,
      hapticsOn: true,
      schemaVersion: 1,
      setSyncOnCellular: (v) => set({ syncOnCellular: v }),
    }),
    {
      name: 'prefs',
      storage: createJSONStorage(() => mmkvStorage),
      version: 1,
      // ⚠️ 白名单持久化:函数与临时字段一律不落盘
      partialize: (s) => ({
        syncOnCellular: s.syncOnCellular,
        hapticsOn: s.hapticsOn,
        schemaVersion: s.schemaVersion,
      }),
      migrate: (persisted, version) => {
        // 字段演进示例:v0 没有 schemaVersion
        const p = persisted as Partial<PrefsState>;
        return { ...p, schemaVersion: version < 1 ? 1 : version } as PrefsState;
      },
    },
  ),
);

selector 单测。selector 是纯函数,测试不需要渲染组件——直接操作 vanilla store:

// src/stores/selectors.test.ts
import { usePrefs } from './prefsStore';
import { selectShouldSyncNow } from './selectors';

describe('selectShouldSyncNow', () => {
  it('蜂窝同步关闭且当前是蜂窝网络 → false', () => {
    usePrefs.setState({ syncOnCellular: false });
    expect(selectShouldSyncNow({ type: 'cellular' }, usePrefs.getState())).toBe(false);
  });
  it('同步动作不残留:set 后页码等无关字段不受影响', () => {
    usePrefs.setState({ syncOnCellular: false, hapticsOn: true });
    usePrefs.getState().setSyncOnCellular(true);
    expect(usePrefs.getState().syncOnCellular).toBe(true);
    expect(usePrefs.getState().hapticsOn).toBe(true);   // 没被误伤
  });
});

状态时间旅行与调试手法:

  • Zustand 挂 devtools 中间件(devtools(fn, { name: 'prefs' }))接入 Redux DevTools,可回放每一条 action;RTK 原生自带。时间旅行的价值不在炫技,而在复现"状态是怎么一步步错的"。
  • TanStack Query 用社区 devtools 面板观察缓存树:每个键的陈旧度、命中率、在途请求一目了然;排查"列表怎么没刷新"第一件事就是看缓存键是否真的匹配。
  • 通用手法:给 setState 包一层日志中间件(开发期)记录前后快照;用 React DevTools Profiler 确认"这个值变了到底谁重渲染了"——它是验证 12.2 节订阅粒度的实测工具。

典型业务场景:FieldNote 实战

把 12.1 的状态清单逐项落地,形成 App 的状态骨架。

1. 任务数据:TanStack Query(缓存键 + 列表/详情联动)

// src/data/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: 2, staleTime: 30_000 } },
});
// App 根部:<QueryClientProvider client={queryClient}>…</QueryClientProvider>
// 任务列表页:筛选是路由参数 → 键里带上,天然实现"每个筛选一份缓存"
function TaskListScreen({ route }: { route: { params: TaskFilter } }) {
  const filter = route.params;
  const { data, isPending, isFetching, refetch } = useTasks(filter);
  // isPending:无缓存数据;isFetching:在途(含后台重取)——两者分开驱动骨架屏与刷新头
  if (isPending) return <TaskListSkeleton />;
  return (
    <PullToRefreshContainer refreshing={isFetching} onRefresh={refetch}>
      {(scrollHandler) => (
        <FlashList
          data={data ?? []}
          estimatedItemSize={72}
          renderItem={TaskRow}
          onScroll={scrollHandler}
          scrollEventThrottle={16}
        />
      )}
    </PullToRefreshContainer>
  );
}

联动规则写进约定:详情页的写操作,失效详情 + 前缀失效列表(见 12.4 的 onSettled),这样列表角标的"异常任务数"永远与详情一致,不需要任何手动同步代码。

2. 会话与同步开关:Zustand + persist(mmkv)

// src/stores/sessionStore.ts
interface SessionState {
  signedIn: boolean;
  userId: string | null;
  displayName: string;
  signIn: (u: { userId: string; displayName: string }) => void;
  signOut: () => void;
}
export const useSession = create<SessionState>()(
  persist(
    (set) => ({
      signedIn: false,
      userId: null,
      displayName: '',
      signIn: (u) => set({ signedIn: true, ...u }),
      signOut: () => set({ signedIn: false, userId: null, displayName: '' }),
    }),
    {
      name: 'session',
      storage: createJSONStorage(() => mmkvStorage),
      partialize: (s) => ({ signedIn: s.signedIn, userId: s.userId, displayName: s.displayName }),
    },
  ),
);
// ⚠️ 访问令牌不在这里:登录时写入 Keychain/Keystore,网络层按需读取(第 17 章)

同步开关复用 12.6 的 usePrefs;离线同步引擎(第 14 章)在启动时用 usePrefs.getState().syncOnCellular 读取配置,并用 usePrefs.subscribe 监听变化实时切换策略——客户端状态与同步引擎的正确交接方式是"读快照 + 订阅变化",而不是把引擎状态也塞进同一个 store。

3. 主题:Context(低频跨树配置的标准样本)

// src/theme/ThemeContext.tsx
const ThemeCtx = createContext<Theme>(lightTheme);

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [mode] = useState<Mode>(() => loadThemeMode());       // mmkv 读取,启动一次
  const theme = useMemo(
    () => (mode === 'dark' ? darkTheme : lightTheme),
    [mode],
  );
  return <ThemeCtx.Provider value={theme}>{children}</ThemeCtx.Provider>;
}
export const useTheme = () => useContext(ThemeCtx);
// 为什么不用 Zustand:主题变化以"天"为单位,不需要订阅粒度优化;
// 换来的是任意深度组件一行 useTheme() 的简洁。高频值绝不能学它。

4. 检查项打分:本地态 + 乐观更新

// 分数面板:选择过程中纯本地态(帧级 UI),确认才进数据层
function ScoreSheet({ taskId, item }: { taskId: string; item: CheckItem }) {
  const [draft, setDraft] = useState<number | null>(null);   // 本地态:可丢弃
  const score = useScoreCheckItem(taskId);                    // 12.4 的乐观 mutation
  return (
    <View>
      {[1, 2, 3, 4, 5].map((n) => (
        <Star key={n} active={(draft ?? item.score) >= n} onPress={() => setDraft(n)} />
      ))}
      <Button
        label="确认评分"
        onPress={() => {
          if (draft == null) return;
          score.mutate({ itemId: item.id, score: draft, note: item.note });
          setDraft(null);
        }}
      />
    </View>
  );
}

离线语义此处先立骨架:mutate 失败会回滚缓存,但巡检要求"无网可打分",所以第 14 章会把这条写操作改为"先落 SQLite + dirty 标记 + Outbox 排队",Query 的乐观更新退化为读缓存的表现层。现在只需记住边界:本章的乐观更新服务联网体验,离线写可靠性是 Outbox 的职责。

5. 决策记录:为什么不用 Redux(ADR 摘要)

> ADR-012 · 2026-09 · 状态管理 · 已采纳
>
> 背景:FieldNote 任务数据来自服务端,需要缓存失效与乐观更新;客户端存在会话、
> 设置等跨页状态;团队 4 人,双端各 1 人 + 2 名 RN。
>
> 决策:服务端状态用 TanStack Query;客户端全局态用 Zustand(persist 到 mmkv);
> 低频跨树配置(主题)用 Context;其余优先组件本地 state。不引入 Redux/RTK。
>
> 理由:(1) 服务端状态的核心是缓存生命周期,Query 内建失效/重试/去重/乐观更新,
> 我们无需自建;(2) 客户端全局态字段少(<20),Zustand 的 selector 订阅粒度足够,
> 且能在组件外读写,便于同步引擎接入;(3) RTK 的价值在大型团队的强约定与统一
> devtools,我们的规模用不上,却要为 action 样板与单 store 组织付出持续成本。
>
> 后果:放弃 Redux 生态开箱的时间旅行体验,用 zustand devtools 中间件弥补;
> 新人需要理解"两类状态两种工具"的边界,以本章分类法 + 本 ADR 作为评审依据;
> 若未来团队扩张到多模块并行(第 13 章),按模块边界拆 Zustand store 即可,
> 不需要推翻本决策。

常见陷阱

陷阱 典型症状 根因 正确做法
服务端状态手抄进全局 store 列表"偶发不同步",到处手写 refetch;下拉后数据错乱 复制了远端副本却没接失效/去重/并发控制 服务端状态交 TanStack Query,store 只放真正的客户端状态
Context 塞高频值 输入框每敲一个字全页重渲染 Context 广播模型无逐消费者过滤 高频值留本地或下放 SharedValue;Context 只放低频配置
selector 里做重计算且无缓存 组件莫名频繁重渲染,甚至渲染死循环 每次返回新对象/新数组,快照引用永不相等 selector 保持原子字段;聚合值用 useShallow 或在 store 外 memo 化
persist 整个大 store 启动变慢、敏感信息泄漏、字段演进全量冲突 白名单缺失,临时态/函数被序列化 partialize 白名单 + version/migrate;敏感值走 Keychain/Keystore
跨模块直接互改对方状态 改 A 模块状态导致 B 模块 bug,调用链无法追踪 store 之间互相 import 对方 setState 模块只暴露语义化 action/领域服务,跨模块通信走领域态层(第 13 章)
为"未来可能需要"引入 Redux 样板代码占比飙升,简单需求改动跨 4 个文件 用架构应对假想的规模 决策树判定当前规模;规模真到了再演进,ADR 记录触发条件
getSnapshot 返回新引用 useSyncExternalStore 触发 "getSnapshot should be cached" 警告并狂渲染 在 getSnapshot 内部 map/filter 快照预计算缓存于 store 或 selector 层

最佳实践

  • 新状态先过四问:谁的事实?谁写?变化频率?要不要活过重启——再决定去处
  • 服务端状态只用带缓存生命周期的工具(TanStack Query/RTK Query),永远手写不出正确的失效
  • 缓存键用层级工厂函数设计(all/lists/list/details/detail),失效策略建立在前缀匹配上
  • 客户端全局态选 Zustand 时,字段级 selector 订阅,多字段聚合必须 useShallow
  • Context 是低频配置专用通道:主题、语言、特性开关;塞进去的每个值都要回答"它多久变一次"
  • 组件本地 state 是默认起点,向上晋升要有理由(跨页、持久化、跨模块),而不是提前设计
  • persist 必须白名单(partialize)+ 版本号迁移(version/migrate),敏感值隔离到 Keychain/Keystore
  • 状态分四层:组件态→容器态→领域态→基础设施态,依赖单向,下层不知上层存在
  • 乐观更新三步固定:cancel → 快照 → 本地改;onError 回滚、onSettled 失效对账
  • selector 是纯函数,直接用 vanilla store 单测;渲染性能问题用 React DevTools Profiler 实证
  • 团队维护一份禁止事项清单与 ADR,选型决策可追溯、可演进,而不是口头约定
  • 新项目不选 Recoil;了解信号思想但不为"更细粒度"提前支付磨合成本

本章小结

本章建立了两条主线。方法论主线:状态先分类(服务端/客户端/UI/持久化)、再分层(组件/容器/领域/基础设施)、后选型(决策树 + 禁止事项),"状态放错层"比"库选错了"更致命。原理主线:订阅粒度决定性能——Context 广播、外部 store 的 selector 订阅、信号的依赖收集是一条从粗到细的谱系;服务端状态因为"会过期、要失效、有并发"而必须使用带缓存生命周期的工具,这是它与客户端状态永远无法共用一个心智模型的根本原因。FieldNote 落地为:Query 管任务数据(键工厂 + 乐观更新)、Zustand 管会话与设置(mmkv 持久化)、Context 管主题、打分交互留在本地态。

一句话记忆点:先问"这是谁的状态",再问"用什么工具";缓存的生命周期问题,永远不要用存储方案来回答。

动手练习

  1. 基础巩固:下面这段代码把服务端任务列表抄进了 Zustand 并在 Context 里放了输入框草稿,请指出至少三处分类错误,并按四分类法改造(提示:tasks 该去哪?draft 该去哪?theme 放 Context 对不对?):
    const useAppStore = create((set) => ({
      tasks: [], theme: 'light', draft: '',
      fetchTasks: async () => set({ tasks: await api.getTasks() }),
    }));
    const AppCtx = createContext(useAppStore);
    
  2. FieldNote 实战(筛选与联动):为"按门店 + 日期 + 只看异常"三个筛选维度设计任务列表的 queryKey 工厂;实现"详情页把某检查项改成异常后,列表页徽标计数自动更新"的失效联动,并说明为什么用前缀失效而不是手动改列表缓存。
  3. FieldNote 实战(持久化与迁移):把同步开关扩展为 syncPolicy: 'wifiOnly' | 'always' | 'manual'(旧字段是布尔 syncOnCellular),在 zustand persist 中写好 version/migrate 迁移逻辑,并为迁移写一条单测(提示:旧布尔 true 应映射到哪个新值?)。
  4. 排查/思考题:为什么"服务端状态"和"客户端状态"用同一个工具管理会越来越痛?请分别从缓存失效与并发重试两个角度分析——失效角度:客户端状态没有"过期"概念,而服务端状态的一切体验都建立在"何时可信、何时重取"上,混在一起后失效逻辑写在哪、由谁触发?并发重试角度:同一任务详情的两个在途请求返回顺序颠倒时怎么办?UI 状态需要这种去重/竞态治理吗?最后回答:一个只管"存取"的 store 缺了哪两层能力,导致痛点随规模放大。

导航

上一章:第 11 章 综合实战:复杂交互组件 | 下一章:第 13 章 大型项目架构设计与模块化

📄 第 13 章 大型项目架构设计与模块化 13-project-architecture.md

本章导读:第 12 章解决了"数据住在哪里",本章解决"代码住在哪里"。状态选型确定后,真正让项目失控的往往不是某个库,而是目录结构、依赖方向与模块边界。本章给出从目录演进、四层架构、monorepo 拆分,到模块间通信、路由组织、质量门禁的完整工程方案,是"状态与架构"阶段的收官章。这里重构出的 core-domain 与模块边界,将直接承接第 14~16 章的离线同步体系。

本章学习目标

  • 能识别按类型、按功能、按领域三代目录结构的适用规模,并在腐化信号出现前完成结构升级
  • 能按 UI / 应用 / 领域 / 基础设施四层划分职责,保证依赖方向单向,并解释"领域层不依赖 React"的三重意义
  • 能搭建 pnpm workspace monorepo,写好 Metro 配置,并用 ESLint 规则机械化守护模块边界
  • 能在禁止横向 import 的约束下,用依赖注入、事件总线、服务注册表完成模块间通信并做出正确选型
  • 能用 react-navigation 组织分层导航器与类型安全的 param list,把路由当作认证与数据状态的"投影"而非状态源
  • 能搭建错误边界三层防护、分级日志、feature flag 模块与 CI 四道质量闸门
  • 能把单包 FieldNote 重构为 monorepo,并说出拆分顺序的决策依据与每一步的验证手段
  • 能写出"领域规则 + 单测"的纯 TS 模块,为第 15~16 章的同步引擎准备可移植的业务内核

核心知识点

13.1 目录结构演进史:按类型 → 按功能 → 按领域

原理。目录结构不是审美问题,而是"改动成本"问题:一次业务变更需要触碰的文件越分散,腐化越快。RN 项目的目录通常经历三代演进。

第一代按类型分层:components/、hooks/、services/、screens/、utils/。项目小于 10 个页面时非常直观,因为它与"我此刻要写一个组件"的动作完全同构。但规模上去后会出现散弹式修改(Shotgun Surgery):给"扫码"加一个震动反馈,要同时改 services/scanner.ts、components/ScanFrame.tsx、hooks/useScan.ts、screens/ScanScreen.tsx——功能的内聚性被类型边界切碎了。

腐化信号清单:components/ 里出现同名前缀集群(TaskCard、TaskHeader、TaskFilter……全是单场景"伪通用组件");hooks/ 和 utils/ 变成万物垃圾桶;两个屏幕出现复制粘贴的逻辑;新人问"这个逻辑在哪"没人答得上来。

第二代按功能模块:features/scanning/、features/tasks/……每个模块内部再自行分层。改动收敛到模块内,删除一个模块成为可能。但新问题随之出现:模块间横向 import——tasks 模块"顺手"import 了 scanning 的内部工具函数,三个月后模块之间连成网状,边界名存实亡。这一代的腐化信号是:grep "from '../../scanning" 的结果越来越长;模块的 index.ts 之外到处是深路径引用。

第三代按领域 + 分层:把纯粹的业务规则(与 UI、存储、网络都无关的部分)抽到领域层,把技术实现(SQLite、HTTP、原生桥)压到基础设施层,功能模块只保留 UI 与应用编排。这是 13.2 四层架构与 13.3 monorepo 的前置形态。

结构 适用规模 优点 典型腐化信号
按类型分层 < 10 页面 / 1~2 人 与编码动作同构,上手零成本 散弹式修改;utils 万物桶;伪通用组件堆积
按功能模块 10~40 页面 / 3~8 人 改动收敛、模块可删可测 模块横向 import 连成网;边界只剩口头约定
按领域 + 分层 40+ 页面 / 多团队 / 多端复用 领域规则可单测可移植;依赖单向可守护 前期抽象税;需要纪律与工具(本章 13.3/13.6 解决)

FieldNote 单包期的最终目录树如下——注意它是"第三代"在单包内的形态,domain/ 从第一天起就不 import React,这正是 13.3 能把它整体搬进 monorepo 包的原因:

fieldnote/
├─ metro.config.js            # Metro 配置(monorepo 化时在此扩展,见 13.3)
├─ tsconfig.json              # 严格 TS 配置(见 13.6)
└─ src/
   ├─ app/                    # 组合根:导航器、Provider、DI 装配、入口组件
   │  ├─ navigation/          # 导航器分层与类型(见 13.5)
   │  ├─ di.ts                # 组合根:唯一的"接口→实现"装配点
   │  └─ App.tsx              # 根组件:错误边界 + Provider 树
   ├─ core/
   │  └─ domain/              # 领域层:实体、业务规则、端口接口。零 React 依赖
   │     ├─ checkItem.ts      # 评分校验、状态流转(见 13.2)
   │     ├─ task.ts           # 任务状态机
   │     └─ ports.ts          # 仓储/时钟/日志等接口(基础设施实现它们)
   ├─ features/               # 功能模块:只含 UI 与应用编排
   │  ├─ tasks/
   │  │  ├─ ui/               # 屏幕、列表卡片、筛选栏
   │  │  ├─ application/      # use case 编排、查询键工厂、hooks
   │  │  └─ infra/            # 该模块私有的仓储实现(SQLite DAO 调用)
   │  ├─ checkitems/          # 检查项:打分面板、备注编辑
   │  ├─ scanning/            # 扫码:取景器、资产识别
   │  ├─ camera/              # 自定义相机(第 8 章的 Fabric 组件在此接 UI)
   │  ├─ auth/                # 生物识别解锁、会话守卫
   │  └─ settings/            # 主题、同步开关
   ├─ shared/
   │  ├─ ui/                  # 真正跨模块复用的展示组件(Button/Toast/Tag)
   │  └─ lib/                 # 跨模块工具(日期、金额格式化)
   └─ infra/                  # 跨模块基础设施:SQLite 连接、HTTP 客户端、mmkv、日志
      ├─ db/
      ├─ http/
      └─ logger/

逐段讲解三个关键取舍:features/ 内部允许出现私有 infra/(模块私有的数据访问),但跨模块的数据访问必须走领域层端口;shared/ 与 core/ 是两类完全不同的公共物——前者是"可复用的零件",后者是"业务的事实",混在一起就会变成垃圾场(见本章陷阱表第一行);app/ 是全项目唯一允许知道"接口长什么样、实现是哪一个"的地方,别的任何文件都不做装配。

13.2 分层架构:依赖单向,领域层不认识 React

原理。四层的职责与"回答的问题"各不相同:

  1. UI 层(screens / components / navigation):回答"长什么样、怎么交互"。持有 UI 态(第 12 章的分类学),把用户意图翻译成对应用层的调用。
  2. 应用层(use case / hooks / query 编排):回答"一次业务操作由哪几步组成"。编排领域对象、仓储与外部服务,控制事务与失效,不含业务规则本身。
  3. 领域层(entities / rules / ports):回答"业务上什么是对的"。评分范围、低于 3 分必须写备注、任务状态流转——这些规则哪怕换掉 React、换掉 SQLite 也依然成立。它同时定义端口(仓储、时钟、日志的接口),供基础设施实现。
  4. 基础设施层(db / http / 原生桥 / 日志):回答"技术上怎么做"。实现领域层定义的端口,是唯一 import 原生模块的地方。

单向依赖原则:依赖箭头只能从上往下(UI → 应用 → 领域),基础设施实现领域层的端口(依赖倒置),因此领域层没有任何出向依赖——它不知道 UI 的存在,不知道 SQLite 的存在,甚至不知道自己在跑在手机上。

flowchart TD
  ROOT["app 组合根<br/>DI 装配 / Provider / 导航器"] --> UI["UI 层<br/>屏幕 / 组件 / 导航"]
  ROOT --> INF["基础设施层<br/>SQLite / HTTP / 原生桥 / 日志"]
  UI --> APP["应用层<br/>use case 编排 / hooks"]
  APP --> DOM["领域层<br/>实体 / 业务规则 / 端口接口"]
  INF --> DOM
  DOM["领域层没有任何出向依赖"]

【重点】"领域层不依赖 React"的三重意义:可测试——领域规则是纯函数,用 vitest 毫秒级跑完数千条用例,不需要渲染器、不需要模拟器;可移植——同一份规则可以给未来的服务端校验、CLI 数据修复工具、甚至小程序端复用;生命周期免疫——不随组件树挂载卸载,不存在"组件卸载后规则跑到一半"的问题。反过来,一旦领域层 import 了 React,以上三条全部作废,且这条污染会顺着依赖箭头向上扩散到所有层。

用一个完整例子把四层串起来:检查项打分。业务规则——分值必须是 1~5 的整数;低于 3 分必须填写备注说明原因;已锁定(任务已提交合并,见第 16 章)的检查项不可再打分;打出 1~2 分时检查项状态流转为"异常上报"。

// src/core/domain/checkItem.ts —— 不 import react、react-native、网络、存储
export type Score = 1 | 2 | 3 | 4 | 5;
export type CheckItemStatus = 'pending' | 'scored' | 'escalated' | 'locked';

export interface CheckItemProps {
  id: string;
  taskId: string;
  name: string;
  sortKey: number;
  score: Score | null;
  note: string;
  serverVersion: number;
  dirty: boolean;
}

export type ScoreRejectReason =
  | 'OUT_OF_RANGE'      // 非 1~5 整数
  | 'NOTE_REQUIRED'     // 低分必须写备注
  | 'LOCKED'            // 已提交合并,禁止修改
  | 'UNCHANGED';        // 与当前分值相同,无需变更

export type ScoreResult =
  | { ok: true; item: CheckItemProps; event: { type: 'itemScored'; itemId: string; score: Score } }
  | { ok: false; reason: ScoreRejectReason };

export const NOTE_REQUIRED_BELOW = 3;

export function applyScore(
  item: CheckItemProps,
  next: Score,
  note?: string,
): ScoreResult {
  if (item.status === 'locked') return { ok: false, reason: 'LOCKED' };
  if (!Number.isInteger(next) || next < 1 || next > 5) {
    return { ok: false, reason: 'OUT_OF_RANGE' };
  }
  if (next === item.score) return { ok: false, reason: 'UNCHANGED' };
  if (next < NOTE_REQUIRED_BELOW && !(note ?? item.note).trim()) {
    return { ok: false, reason: 'NOTE_REQUIRED' };
  }
  const status: CheckItemStatus = next <= 2 ? 'escalated' : 'scored';
  return {
    ok: true,
    item: { ...item, score: next, note: note ?? item.note, status, dirty: true },
    event: { type: 'itemScored', itemId: item.id, score: next },
  };
}

逐段讲解:所有输入输出都是可序列化的数据(Props 进、Result 出),没有类继承、没有副作用,因此单测只需要构造对象字面量;dirty: true 是第 12 章 Outbox 心智模型的落点——领域层不写库、不发请求,它只负责宣布"这份变更尚未同步";event 是领域事件的雏形,第 15 章的同步引擎与埋点都从它出发;拒绝原因用可辨识联合而不是抛异常,因为"低分没写备注"是业务上预期的分支,不是异常,UI 需要针对它给出表单提示。

应用层把规则编排成一次完整的用例——校验、落库、进 Outbox、失效查询缓存:

// src/features/checkitems/application/scoreCheckItem.ts
import { applyScore, type Score } from '../../../core/domain/checkItem';
import type { CheckItemRepository, Clock, Logger, Outbox } from '../../../core/domain/ports';

export function makeScoreCheckItem(deps: {
  repo: CheckItemRepository;   // 端口:实现在 infra
  outbox: Outbox;              // 端口:第 15 章的同步引擎消费它
  clock: Clock;                // 端口:测试时注入固定时钟
  logger: Logger;
}) {
  return async function scoreCheckItem(itemId: string, score: Score, note?: string) {
    const item = await deps.repo.findById(itemId);
    if (!item) return { ok: false as const, reason: 'OUT_OF_RANGE' as const }; // 仓储层 NotFound 在 UI 侧统一处理
    const result = applyScore(item, score, note);
    if (!result.ok) return result;
    await deps.repo.save(result.item);
    await deps.outbox.enqueue({
      opType: 'checkitem.score',
      entity: 'checkItem',
      entityId: itemId,
      payload: { score, note: result.item.note, clientRev: 1 },
      idempotencyKey: `${itemId}:${result.item.score}`,
    });
    deps.logger.info('checkitem.scored', { itemId, score });
    return result;
  };
}

UI 层只做三件事:拿到用例、调 TanStack Query 的 mutation、把拒绝原因翻译成人话:

// src/features/checkitems/ui/useScoreCheckItem.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { taskKeys } from '../../tasks/application/queryKeys';
import { useServices } from '../../../app/di';

export function useScoreCheckItem() {
  const { scoreCheckItem } = useServices();
  const qc = useQueryClient();
  return useMutation({
    mutationFn: (v: { itemId: string; score: Score; note?: string }) =>
      scoreCheckItem(v.itemId, v.score, v.note),
    onSuccess: () => qc.invalidateQueries({ queryKey: taskKeys.all }), // 第 12 章的前缀失效
  });
}
// 组件里:result.reason === 'NOTE_REQUIRED' 时聚焦备注输入框并高亮

⚠️ 注意依赖方向:ui → application → domain,domain 一无所知;infra 的 SQLite 仓储实现了 CheckItemRepository 端口,由组合根注入。换掉 SQLite 甚至换成"直接调服务端"的联调模式,UI 与领域代码一行不改。

13.3 Monorepo 与模块化:pnpm workspace + Metro + ESLint 边界

原理。单包结构里,模块边界只有两种守护方式:口头约定和 code review——都会失效。monorepo 的本质是把边界变成构建系统的事实:每个包有自己的 package.json,dependencies 字段就是"我允许依赖谁"的机器可读声明;exports 字段就是"我对外暴露什么"的第一道防线。pnpm 的严格符号链接布局(每个包只能看到自己声明过的依赖)在物理上阻止了"顺手的依赖"。同时 monorepo 让跨包重构原子化(一次提交同时改接口与实现)、让领域包可以被 CI 单独跑测试。反过来说,单人 + 5 个页面的项目拆 monorepo 是纯负资产——多出的配置成本换不来任何边界收益。

pnpm workspace 骨架(FieldNote 最终形态为 app / packages/shared-ui / packages/core-domain / packages/native-kit):

# pnpm-workspace.yaml —— 仓库根
packages:
  - 'app'
  - 'packages/*'
// 根 package.json —— 只放跨包命令与公共 devDependencies
{
  "name": "fieldnote-monorepo",
  "private": true,
  "scripts": {
    "android": "pnpm --filter app android",
    "ios": "pnpm --filter app ios",
    "lint": "pnpm -r lint",
    "typecheck": "pnpm -r typecheck",
    "test": "pnpm -r test --run",
    "build:ts": "pnpm -r build:ts"
  },
  "devDependencies": {
    "typescript": "^5.x",
    "eslint": "写作时最新稳定版,以官方文档为准"
  }
}

每个包用 exports 锁死公共入口,这是"禁止深路径 import"的第一道防线:

// packages/core-domain/package.json(节选)
{
  "name": "@fieldnote/core-domain",
  "main": "src/index.ts",                 // 直接指向源码,Metro 负责转译
  "types": "src/index.ts",
  "exports": { ".": "./src/index.ts" },   // 只暴露根入口,深路径在解析层就被拒绝
  "dependencies": {}                      // 注意:是空的。领域包零运行时依赖
}

Metro 与 monorepo 的配合是拆分能否落地的关键——默认配置只认 app 目录,改了 packages/shared-ui 的代码热更新不会生效,node_modules 里的软链也可能解析失败。完整配置:

// app/metro.config.js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const path = require('path');

const appRoot = __dirname;
const workspaceRoot = path.resolve(appRoot, '..');

const config = {
  watchFolders: [workspaceRoot],          // ① 监听整个 workspace,packages 改代码才触发热更
  resolver: {
    // ② pnpm 严格布局:模块解析要同时看 app 本地与 workspace 根的 node_modules
    nodeModulesPaths: [
      path.resolve(appRoot, 'node_modules'),
      path.resolve(workspaceRoot, 'node_modules'),
    ],
    // ③ 关闭逐级向上查找,防止解析到 workspace 之外的 node_modules(iOS 尤其重要)
    disableHierarchicalLookup: true,
  },
  transformer: {
    getTransformOptions: async () => ({
      transform: { experimentalImportSupport: false, inlineRequires: true },
    }),
  },
};

module.exports = mergeConfig(getDefaultConfig(appRoot), config);

逐段讲解:① 没有 watchFolders,Metro 的文件监听只覆盖 app/,你改 @fieldnote/shared-ui 的一个按钮样式,模拟器纹丝不动,这是 monorepo 化后最高频的"灵异问题";② pnpm 不像 npm/yarn 那样把依赖提升到一根扁平的 node_modules,第三方包在磁盘上是符号链接,Metro 需要同时拿到 app 与根两级的解析路径;③ 关闭层级查找后解析路径完全由 ② 决定,行为确定可复现。原生侧另有两条配套纪律:原生库只声明在 app 的 package.json(自动链接与 pod install 以 app 为根),workspace 里的纯 TS 包永远不要引原生模块;个别自动链接找不到的库,用 react-native.config.js 的 dependencies 显式指路。写作时版本的 Metro 已默认支持符号链接解析,旧版本如遇解析问题再查官方文档。

依赖声明统一用 workspace:* 协议——它表示"用 workspace 里的这个包",安装快且永远不会误装一个叫同名的 npm 包:

// app/package.json(节选)
{
  "dependencies": {
    "@fieldnote/core-domain": "workspace:*",
    "@fieldnote/native-kit": "workspace:*",
    "@fieldnote/shared-ui": "workspace:*"
  }
}

模块边界规则用一张表说死,这是团队公约,也是下面 ESLint 配置的规格书:

包 允许依赖 禁止依赖 理由
core-domain 无(至多 TS 标准库) react、react-native、一切原生与网络库 领域是可移植内核,依赖即污染
native-kit core-domain shared-ui、app 原生封装只服务领域与上层,不知道 UI 存在
shared-ui core-domain(仅类型) native-kit、app、任何业务模块 展示组件不含业务;需要能力时由 props 注入回调
app 全部 无 组合根,唯一做装配的地方
flowchart TD
  APP["app<br/>组合根 + 全部屏幕"] --> SUI["@fieldnote/shared-ui<br/>通用展示组件 / 主题"]
  APP --> NKIT["@fieldnote/native-kit<br/>mmkv / Keychain / 相机封装"]
  APP --> CDOM["@fieldnote/core-domain<br/>实体 / 规则 / 端口"]
  SUI --> CDOM
  NKIT --> CDOM
  SUI -. "禁止互相依赖" .- NKIT

最后用 ESLint 把表格变成 CI 里会红的规则(eslint-plugin-boundaries):

// eslint.config.mjs(仓库根,节选)
import boundaries from 'eslint-plugin-boundaries';

export default [
  {
    files: ['packages/**/src/**/*.{ts,tsx}', 'app/src/**/*.{ts,tsx}'],
    plugins: { boundaries },
    settings: {
      'boundaries/elements': [
        { type: 'core-domain', pattern: 'packages/core-domain/src/**' },
        { type: 'native-kit',  pattern: 'packages/native-kit/src/**' },
        { type: 'shared-ui',   pattern: 'packages/shared-ui/src/**' },
        { type: 'app',         pattern: 'app/src/**' },
      ],
    },
    rules: {
      'boundaries/element-types': ['error', {
        default: 'disallow',
        rules: [
          { from: 'core-domain', allow: [] },
          { from: 'native-kit',  allow: ['core-domain'] },
          { from: 'shared-ui',   allow: ['core-domain'] },
          { from: 'app',         allow: ['app', 'core-domain', 'native-kit', 'shared-ui'] },
        ],
      }],
      'boundaries/external': ['error', {
        default: 'allow',
        rules: [{ from: 'core-domain', disallow: ['react', 'react-native', 'react-native-*'] }],
      }],
    },
  },
  {
    files: ['app/src/**/*.{ts,tsx}'],
    rules: {
      'no-restricted-imports': ['error', {
        patterns: [{
          group: ['@fieldnote/*/src/**'],
          message: '只允许 import 包根的公共 API,禁止深入内部路径。',
        }],
      }],
    },
  },
];

逐段讲解:element-types 把上表四行规则逐字翻译成 lint 错误,external 则守住"core-domain 不许碰 react/react-native"这条底线——它比人眼可靠;no-restricted-imports 补上第二道防线,即使某个包忘了配 exports,从 app 深入 @fieldnote/shared-ui/src/... 也会直接报错。💡 三道防线(pnpm 物理隔离、exports 解析限制、ESLint 规则)叠加后,"边界"才从公约变成事实。

13.4 模块间通信:DI、事件总线、服务注册表

原理。禁止横向 import 之后,scanning 模块需要 auth 模块的"当前用户"、tasks 模块需要 photo 模块的"拍照能力",怎么办?答案是依赖倒置:需要方依赖接口,提供方实现接口,组合根负责把实现塞给需要方。接口放在哪里?放在"需要方看得见、提供方也允许依赖"的那一层——通常是 core-domain 的 ports.ts。落到工程上有三种载体。

方案一:依赖注入(DI)容器。一个极小的容器(约 40 行)就够用,不需要引第三方框架:

// packages/core-domain/src/di/container.ts —— 零依赖,可放进领域包
export interface ServiceKey<T> { readonly id: symbol; readonly name: string }

export function createKey<T>(name: string): ServiceKey<T> {
  return Object.freeze({ id: Symbol(name), name });
}

type Factory<T> = (c: Container) => T;

export class Container {
  private factories = new Map<symbol, Factory<unknown>>();
  private instances = new Map<symbol, unknown>();

  register<T>(key: ServiceKey<T>, factory: Factory<T>): this {
    this.factories.set(key.id, factory as Factory<unknown>);
    return this;
  }

  resolve<T>(key: ServiceKey<T>): T {
    const hit = this.instances.get(key.id);
    if (hit !== undefined) return hit as T;
    const factory = this.factories.get(key.id);
    if (!factory) throw new Error(`[DI] 服务未注册: ${key.name}`);
    const instance = factory(this);          // 惰性创建 + 单例缓存
    this.instances.set(key.id, instance);
    return instance as T;
  }

  override<T>(key: ServiceKey<T>, factory: Factory<T>): this { // 测试替身注入点
    this.factories.set(key.id, factory as Factory<unknown>);
    this.instances.delete(key.id);
    return this;
  }

  reset(): void { this.instances.clear(); }  // 登出、切账号、单测重建
}

组合根(app)是唯一知道"接口 → 实现"映射的地方:

// app/src/app/di.ts
export const ClockKey = createKey<Clock>('Clock');
export const TaskRepoKey = createKey<TaskRepository>('TaskRepository');
export const ScoreCheckItemKey = createKey<ScoreCheckItem>('ScoreCheckItem');

export const container = new Container()
  .register(ClockKey, () => ({ now: () => Date.now() }))
  .register(TaskRepoKey, (c) => new SQLiteTaskRepository(c.resolve(ClockKey)))
  .register(ScoreCheckItemKey, (c) =>
    makeScoreCheckItem({
      repo: c.resolve(TaskRepoKey),
      outbox: c.resolve(OutboxKey),
      clock: c.resolve(ClockKey),
      logger: c.resolve(LoggerKey),
    }));

方案二:事件总线。发布订阅让模块互不认识也能通信,但要清醒地认识到它的代价:

type FieldNoteEvents = {
  'auth:expired': void;
  'sync:stateChanged': { phase: 'idle' | 'syncing' | 'error' };
};
export const bus = new EventEmitter<FieldNoteEvents>();
// auth 模块:bus.emit('auth:expired', undefined)
// 任意模块:bus.on('auth:expired', () => router.toLogin())

慎用理由有三:时序不可控——emit 时订阅者是否存在、先后顺序都无人保证,"竞态"从并发问题变成了架构问题;类型安全有限——事件名与载荷的对应关系靠声明表自觉维护,改载荷字段不会让忘记更新的订阅者编译报错;生命周期泄漏——组件订阅后忘记取消(useEffect 清理函数漏写)是内存泄漏与重复回调的头号来源。事件总线只适合旁路场景:埋点、日志、通知角标刷新这类"没有人等待结果"的广播。业务主流程用它,等于在架构里修了一条无人检修的暗渠。

方案三:服务注册表。比 DI 更轻:模块在启动时把自己的公共能力显式注册到一个静态对象上,消费方按接口取用。适合"能力由某模块提供、但消费方不确定"的场景(例如 sync 引擎消费任意模块注册的 EntitySyncAdapter)。

方案 耦合方向 类型安全 生命周期 适用场景
DI 容器 需要方 → 接口,组合根装配 强(ServiceKey 携带类型) 容器管理,可 override/reset 有状态服务、仓储、用例;FieldNote 默认方案
服务注册表 提供方注册,消费方取用 中(接口约定) 启动期注册,进程内常驻 可扩展点(同步适配器、工具注册)
事件总线 完全解耦,双向匿名 弱 订阅者自管,易泄漏 埋点/日志等旁路广播;禁入业务主流程

选型口诀:调用关系用 DI,扩展点用注册表,广播用事件,三者都救不了的横向 import 一律先怀疑分层设计错了。

13.5 路由架构:路由是状态的投影,不是状态源

原理。react-navigation 的导航状态本质上也是一块 store(内部由 reducer 管理),新手最常见的腐化是把它当成第三事实源:登录后 navigation.reset 跳三跳,登出时又 reset 回去,401 时再补一刀——命令式跳转散落各处,每处都是一根"时序耦合"的引线。正确的姿势是把路由当作状态的投影:认证状态住在 auth store(第 12 章已定:Zustand + persist),任务数据住在 Query 缓存,导航器只是根据这些事实渲染出对应的树。事实变了,投影自动变,App 里没有一行"跳转到登录页"的命令式代码。

先做类型安全——param list 是导航层的"接口声明":

// app/src/app/navigation/params.ts
export type RootStackParamList = {
  Auth: undefined;                 // 未登录栈
  Main: undefined;                 // 已登录主栈
};
export type MainTabParamList = { Tasks: undefined; Profile: undefined };
export type TaskStackParamList = {
  TaskList: undefined;
  TaskDetail: { taskId: string };
  Camera: { checkItemId: string }; // 拍照必须知道为哪个检查项拍
};

// 全局注入类型,导航 hook 全链路提示
declare global {
  namespace ReactNavigation {
    interface RootParamList extends RootStackParamList {}
  }
}

登录守卫用条件渲染实现,认证状态是唯一事实源:

// app/src/app/navigation/RootNavigator.tsx
export function RootNavigator() {
  const status = useAuthStore((s) => s.status);        // 'anonymous' | 'locked' | 'authenticated'
  const bootstrapped = useAuthStore((s) => s.bootstrapped);
  if (!bootstrapped) return <SplashScreen />;          // 冷启动恢复 Token 期间
  return (
    <NavigationContainer linking={linking} theme={theme}>
      <RootStack.Navigator screenOptions={{ headerShown: false }}>
        {status === 'authenticated' ? (
          <RootStack.Screen name="Main" component={MainStack} />
        ) : (
          <RootStack.Screen name="Auth" component={AuthStack} />
        )}
      </RootStack.Navigator>
    </NavigationContainer>
  );
}

深链配置把外部 URL 也纳入"投影"——URL 参数只做入口载荷,落进对应屏幕的 params,不另设状态:

// app/src/app/navigation/linking.ts
export const linking: LinkingOptions<RootStackParamList> = {
  prefixes: ['fieldnote://', 'https://fieldnote.example.com'], // 域名白名单:只接受自有域名
  config: {
    screens: {
      Main: { screens: { Tasks: { screens: { TaskDetail: 'task/:taskId' } } } },
    },
  },
};

最后一块拼图是401 的统一处理:HTTP 客户端收到 401 时只做一件事——authStore.signOut()(或触发刷新,第 17 章展开)。认证状态变更后,上面的条件渲染自动把整棵导航树换成 AuthStack,"正在编辑的页面"随之卸载。深链在未登录时打进来,让守卫先呈现登录页、登录成功后重新处理深链意图(把 pendingDeeplink 存进 auth store,这里不展开)。

【重点】由此得出三条纪律:一、不把路由参数复制进全局 store(参数进了 store 就有两个事实源,返回重进时谁说了算?);二、屏幕间传数据优先传 id,屏幕自己按 id 取数(第 12 章 Query 缓存按 key 取,天然支持);三、禁止在业务代码里散布 navigation.reset,切换"世界状态"(登录/登出/切门店)只改 store。

13.6 质量基础设施:错误边界、日志、feature flag 与 CI 门禁

原理。React 的错误在渲染期沿组件树向上冒泡,被最近的 ErrorBoundary 捕获——所以边界放得越高,捕获半径越大,恢复粒度越粗。FieldNote 分三层:组件级(相机、扫码这类高风险部件局部降级,页面其余部分照常)、页面级(整页兜底,提供重试与返回)、全局级(最终防线,上报后给出"重启安全模式")。同时必须清醒:错误边界只能捕获渲染期错误,事件回调、异步 Promise、原生崩溃都不走这条路——异步错误靠 Promise rejection 监听与 APM 上报,原生崩溃靠崩溃收集服务(第 17 章展开)。

// app/src/shared/ErrorBoundary.tsx
class Boundary extends React.Component<{
  onError: (e: Error) => void;
  fallback: (retry: () => void) => React.ReactNode;
  children: React.ReactNode;
}, { error: Error | null }> {
  state = { error: null as Error | null };
  static getDerivedStateFromError(error: Error) { return { error }; }
  componentDidCatch(error: Error) { this.props.onError(error); } // 只上报,不吞
  retry = () => this.setState({ error: null });
  render() { return this.state.error ? this.props.fallback(this.retry) : this.props.children; }
}

// 使用:组件级局部降级
<Boundary onError={logger.error} fallback={(retry) => <ScanFallback onRetry={retry} />}>
  <ScanFrame />
</Boundary>

日志与监控的接入位分三处:统一 logger(分级 + 脱敏 + 按 __DEV__ 切换 transport)、全局边界 onError、Sentry 类 APM 的 init。DSN 一律来自环境变量:

// packages/native-kit/src/logger/index.ts(节选)
const REDACT = /(authorization|token|refreshToken|password)["':=\s]+\S+/gi;
const redact = (msg: string) => msg.replace(REDACT, '$1=***');
export const logger = {
  debug: (m: string, c?: object) => { if (__DEV__) console.debug(redact(m), c); },
  info:  (m: string, c?: object) => transport.info(redact(m), c),
  warn:  (m: string, c?: object) => transport.warn(redact(m), c),
  error: (m: string, c?: object) => { transport.error(redact(m), c); apm.captureMessage(m, c); },
};
// app/src/App.tsx:Sentry.init({ dsn: process.env.SENTRY_DSN }) 包在 try 里,
// 且 Sentry.wrap(App) 置于最外层,保证未捕获异常也有归属。

feature flag 的核心设计不是"怎么开",而是怎么关掉并清理。每个 flag 必须带过期时间,用一条单测强制清理——没有过期机制的 flag 库,最终都会变成没人敢删的祖传代码:

// packages/core-domain/src/flags.ts
type FlagDefs = {
  'scanner.v2': { default: boolean; expiresAt: string; desc: string };
  'offlinePack.beta': { default: boolean; expiresAt: string; desc: string };
};
export const FLAGS: FlagDefs = {
  'scanner.v2':        { default: false, expiresAt: '2026-12-31', desc: '新取景器' },
  'offlinePack.beta':  { default: false, expiresAt: '2026-10-31', desc: '离线图片包' },
};
export type FlagName = keyof FlagDefs;

// CI 单测:过期 flag 让测试失败,逼迫开发者在到期前删除或续期
export function assertNoExpiredFlags(now: number = Date.now()) {
  for (const [name, def] of Object.entries(FLAGS)) {
    if (now > new Date(def.expiresAt).getTime()) {
      throw new Error(`flag ${name} 已过期,请删除代码或续期:${def.desc}`);
    }
  }
}

TS 严格配置与 CI 四道闸把以上一切固化成流程。tsconfig 关键项:strict: true、noUncheckedIndexedAccess: true(数组索引访问强制判空,离线同步代码大量下标操作,这条能挡住一整类崩溃)、exactOptionalPropertyTypes: true。CI 流水线四道闸缺一不可:

# .github/workflows/quality.yml(节选)
jobs:
  quality:
    steps:
      - run: pnpm install --frozen-lockfile
      - run: pnpm -r lint          # 闸门 1:含 boundaries 边界规则
      - run: pnpm -r typecheck     # 闸门 2:tsc --noEmit
      - run: pnpm -r test --run    # 闸门 3:含领域规则单测 + assertNoExpiredFlags
      - run: pnpm --filter app bundle:android   # 闸门 4:可构建性(bundle 不打原生包,分钟级)

典型业务场景:FieldNote 实战(里程碑 M13:单包重构为 monorepo)

目标:把 13.1 的单包 FieldNote 拆成 app / packages/core-domain / packages/native-kit / packages/shared-ui 四个包,全程保持"每天都能发版"。

拆分顺序决策。先 core-domain 还是先 shared-ui?答案由依赖图决定:先拆"依赖最少、被依赖最多"的包。core-domain 依赖为零(纯 TS),被所有包依赖,拆它时无人阻塞;且它的验证手段是纯单测,不需要真机、不需要原生构建,红绿反馈以秒计。shared-ui 则相反——它依赖 react/react-native,验证需要 Metro 配置先就位、需要真机视觉走查,成本高一个数量级;而且它依赖 core-domain 的类型,先拆它会被迫连坐。于是顺序定为:配置骨架 → core-domain → native-kit → shared-ui → 边界收口。

六步清单,每步带验证手段:

  1. 建骨架,不挪代码:新增 pnpm-workspace.yaml、根 package.json、app/metro.config.js(13.3 全文),app 原地不动。验证:pnpm install 后 pnpm android 冷启动成功、热更新正常。此步只动配置,风险为零,但它证明 Metro 与 pnpm 布局已通。
  2. 拆 core-domain:把 src/core/domain/** 原样移入 packages/core-domain/src,app 以 workspace:* 引用。验证:pnpm --filter @fieldnote/core-domain test 全绿(领域函数在 13.2 之前就保持零 React,此时直接受益);pnpm typecheck 通过;冒烟跑一遍打分流程。
  3. 拆 native-kit:迁移 src/infra/ 的 mmkv、Keychain、logger 封装与第 8 章的相机桥接封装。验证:冷启动 + 设置页读写持久化 + 生物识别解锁走通(原生代码不动,只挪 JS 封装,风险集中在 import 路径)。
  4. 拆 shared-ui:迁移 src/shared/ui/。验证:主题切换页视觉走查 + 组件 smoke 测试;此时 Metro 的 watchFolders 已在第 1 步配好,改 shared-ui 热更新应即时生效。
  5. 边界收口:接入 13.3 的 ESLint 配置,跑 pnpm -r lint,把所有违规(横向 import、深路径引用)逐个改为走公共入口或端口。验证:lint 从红转绿,且在 CI 常驻。
  6. 文档与固化:更新依赖方向表到仓库 README,四道闸并入流水线。验证:提一个故意违反边界的 PR,确认 CI 拦截。

重构前后对比(app 目录视角):

重构前(单包,13.1 形态)              重构后(monorepo)
fieldnote/                            fieldnote/
└─ src/                               ├─ app/
   ├─ app/          (组合根)        │  └─ src/          (瘦身后的组合根:导航/DI/屏幕壳)
   ├─ core/domain/  (领域,零 React) ├─ packages/
   ├─ features/     (功能模块)      │  ├─ core-domain/  ← 原 core/domain(含测试)
   ├─ shared/       (UI+工具)      │  ├─ native-kit/   ← 原 infra 的技术封装
   └─ infra/        (技术封装)      │  └─ shared-ui/    ← 原 shared/ui
                                      └─ packages/core-domain/src/index.ts 只导出公共 API

packages/core-domain 的实际代码——ports 与测试是两个此前没有实体化的产物:

// packages/core-domain/src/index.ts —— 公共 API 面要小:能不导出就不导出
export * from './entities/checkItem';
export * from './entities/task';
export * from './ports';
export * from './di/container';
export * from './flags';
// packages/core-domain/src/ports.ts —— 领域需要的最小能力面
export interface Clock { now(): number }
export interface Logger {
  debug(m: string, c?: object): void; info(m: string, c?: object): void;
  warn(m: string, c?: object): void;  error(m: string, c?: object): void;
}
export interface CheckItemRepository {
  findById(id: string): Promise<CheckItemProps | null>;
  save(item: CheckItemProps): Promise<void>;
}
export interface Outbox {
  enqueue(op: { opType: string; entity: string; entityId: string; payload: unknown; idempotencyKey: string }): Promise<void>;
}
// packages/core-domain/src/__tests__/checkItem.test.ts —— vitest,无需任何 RN 环境
import { describe, expect, it } from 'vitest';
import { applyScore } from '../entities/checkItem';

const base = {
  id: 'ci1', taskId: 't1', name: '灭火器压力表', sortKey: 1,
  score: null, note: '', serverVersion: 7, dirty: false,
} as const;

describe('applyScore 评分规则', () => {
  it('正常打 4 分:状态流转为 scored,置 dirty', () => {
    const r = applyScore(base, 4);
    expect(r.ok).toBe(true);
    if (r.ok) expect(r.item).toMatchObject({ score: 4, status: 'scored', dirty: true });
  });
  it('打 1 分且无备注:拒绝 NOTE_REQUIRED,不产生变更', () => {
    const r = applyScore(base, 1);
    expect(r).toEqual({ ok: false, reason: 'NOTE_REQUIRED' });
  });
  it('打 2 分且补备注:流转为 escalated(异常上报)', () => {
    const r = applyScore(base, 2, '压力表指针在红区');
    expect(r.ok).toBe(true);
    if (r.ok) expect(r.item.status).toBe('escalated');
  });
  it('已 locked 的检查项拒绝任何打分', () => {
    const r = applyScore({ ...base, status: 'locked', score: 3 }, 5, 'x');
    expect(r).toEqual({ ok: false, reason: 'LOCKED' });
  });
});

重构后的收益可以量化:领域包测试从"依赖模拟器 CI 跑 8 分钟"变为"纯 Node 跑 3 秒";shared-ui 的按钮改样式,热更新直达模拟器;新同事读懂依赖方向表即可定位任何代码;第 15 章的同步引擎将直接以 core-domain 的端口为对接面,不再关心 UI。

常见陷阱

陷阱 典型症状 根因 正确做法
shared 变成垃圾场 shared/ui 里堆满 TaskCard 等单场景组件;改动 shared 频繁打破其他模块 无准入标准,"以后可能复用"成了搬运许可 准入标准写成 lint/评审清单:至少两个不相关模块使用、不含业务语义、props 不出现业务类型;单场景组件留在模块内
循环依赖 构建期警告;undefined is not a function 只在特定启动顺序出现;改 A 模块 B 模块崩 模块互相 import 形成环 用 madge --circular 或 dpdm 在 CI 检测;破环用依赖倒置——把共享的接口下沉到 core-domain,双方都改为依赖接口
把导航状态复制进全局 store 返回重进页面数据错乱;登出后 store 里还留着"当前页任务 id" 导航参数被抄进 Zustand,出现两个事实源 路由只存 id 类参数;屏幕按 id 从 Query 缓存取数;"世界状态"变更(登录/登出)只改 store,由条件渲染完成投影
跨模块直接 import 对方内部路径 重构某模块内部结构,三个不相干模块编译报错 深路径绕过了 index.ts 公共入口 package.json exports 限定入口 + no-restricted-imports 禁 */src/**;确需的能力提升为接口注册
错误边界包住一切 界面白块但控制台无错误;真实异常被 fallback 吞掉无从排查 全局边界层级过低 + onError 未上报 三层边界各司其职;onError 必须接 logger/APM;开发环境 fallback 直接抛出原始错误
feature flag 无清理机制 代码里几十个 if(flag);没人知道 flag 还要不要;关闭后行为不回退 只有开没有关,过期无人管 flag 定义表强制 expiresAt + 过期测试守护;移除 flag 的 PR 与开启它的 PR 同等优先级
DI 容器变成全局变量池 任何地方 container.resolve(...),依赖关系在类型上不可见 容器被当成了服务定位器随处调用 resolve 只出现在组合根与极薄的 useServices() hook;模块构造函数/hook 参数显式接收依赖
拆 monorepo 一步到位 拆到一半原生链接崩了、Metro 解析挂了,回滚不了 把配置变更与代码搬迁混在一个大 PR 里 严格六步法:每步独立提交、独立验证手段、可单独回滚

最佳实践

  • 目录结构按规模演进,不提前支付第三代抽象税;但 domain/ 从第一天就不 import React——这是唯一的"提前量",因为回补成本最高
  • 依赖方向单向是硬约束:UI → 应用 → 领域,基础设施实现领域端口;用 eslint-plugin-boundaries 在 CI 强制,不靠自觉
  • 领域层输出"数据进、数据出"的纯函数与可辨识联合结果,不用异常表达业务分支
  • monorepo 拆分先做"纯配置骨架"验证 Metro/pnpm 通路,再按"依赖最少、被依赖最多、验证最便宜"的顺序逐包迁移
  • 每个包用 exports 收窄公共 API;"能不导出就不导出"是包设计的默认值
  • 模块通信三件套分层使用:调用关系 DI、扩展点注册表、旁路广播事件总线;事件总线永不进入业务主流程
  • 路由是状态的投影:登录守卫用条件渲染,禁止散布 navigation.reset;屏幕间传 id 不传对象
  • 错误边界三层部署(组件/页面/全局),onError 必接上报;开发环境让错误裸奔以便第一时间暴露
  • 日志统一走 logger:分级、脱敏、按环境切 transport;token 等敏感值永远只出现在 Keychain/Keystore
  • feature flag 必须带 expiresAt 并用 CI 单测守护过期;删 flag 与加 flag 同优先级
  • CI 四道闸(lint/typecheck/test/bundle)是架构的执法机构;boundary 违规、循环依赖、过期 flag 都应让流水线变红
  • 领域规则 100% 单测覆盖关键分支(合法流转 + 每种拒绝原因),测试文件与实体同包存放

本章小结

本章把第 12 章"状态住哪里"的答案扩展成"代码住哪里"的完整方案。三条主线:其一,目录结构从按类型到按功能再到按领域演进,FieldNote 的 core/features/shared/app 形态让"改动收敛"成为常态;其二,四层架构的核心是单向依赖与依赖倒置——领域层零依赖使规则可毫秒级单测、可跨端移植,DI 容器、注册表、事件总线分别接管调用、扩展与广播;其三,工程纪律必须有执法机构——pnpm 物理隔离、exports 入口收窄、ESLint 边界规则、CI 四道闸层层设防,路由作为状态投影消灭了命令式跳转的时序炸弹。FieldNote 完成 M13:monorepo 就位,core-domain 带着完整测试独立演进。

一句话记忆点:架构不是画出一张图,而是让"画错"在 CI 里编译不过——依赖方向决定项目寿命,边界靠工具守护而非口头约定。

动手练习

  1. 基础巩固:把下面这段代码按四层架构重排(提示:calculateOverdue 属于哪层?fetchTasks 的 try/catch 该住哪?TaskCard 为什么不该 import HTTP 客户端?),并说出每处移动后依赖箭头的变化:
    // TaskCard.tsx
    const TaskCard = ({ task }) => {
      const overdue = Date.now() > new Date(task.planDate).getTime() + 86400_000;
      const onPress = async () => {
        const detail = await fetch(`${API}/tasks/${task.id}`);
        saveToLocalCache(task.id, detail);
        navigation.navigate('TaskDetail', { taskId: task.id });
      };
      return <Text onPress={onPress}>{task.name}{overdue ? '(逾期)' : ''}</Text>;
    };
    
  2. FieldNote 实战(端口与替换):为 tasks 模块设计 TaskRepository 端口(列出今日任务、按 id 取详情、保存评分),分别给出 SQLite 实现与"内存假实现",并用内存假实现写一条 makeScoreCheckItem 用例的集成测试;说明联调阶段如何通过组合根一行切换两个实现。
  3. FieldNote 实战(边界守护):假设有人在 packages/shared-ui 里写了 import { MMKV } from 'react-native-mmkv' 和 import { applyScore } from '@fieldnote/core-domain/src/entities/checkItem',分别指出违反了哪条规则,并给出让它们在 CI 变红的 ESLint 配置片段;如果 core-domain 确实需要一个"持久化键名"的能力,正确的依赖倒置方案是什么?
  4. 排查/思考题:为什么"领域层不依赖 React"在 monorepo 拆分之前就该坚持?如果一开始违反了(比如领域函数里直接 Alert.alert、用 useQuery 缓存中间结果),请从三个角度评估迁移成本——测试成本(想给规则写单测要先搭什么环境?)、依赖扩散(import 图上谁会被迫进包?)、时序耦合(异步逻辑藏在组件生命周期里时,同步引擎怎么拿到它?)。最后回答:什么样的"提前抽象"值得付出,什么样的不值得?判断标准是什么?

导航

上一章:第 12 章 状态管理方案选型 | 下一章:第 14 章 本地存储与缓存体系

📄 第 14 章 本地存储与缓存体系 14-storage-cache.md

本章导读:从本章起进入"离线与同步"阶段。FieldNote 宣称"无网全功能可用",背后是三件事:数据放在哪里(本章)、断网时如何保证读写一致(第 15 章离线优先架构)、联网后如何增量同步与合并冲突(第 15、16 章)。本章先解决"放哪里":给出六类存储方案的底层原理、FieldNote 的四类数据落位决策、缓存分层体系与敏感数据工程规范,为后续两章打好存储地基。

本章学习目标

  • 能说清 AsyncStorage、MMKV、SQLite、WatermelonDB、文件系统、Keychain/Keystore 各自"数据放哪、怎么读写、瓶颈在哪"
  • 能用六维选型矩阵(数据量/查询/响应式/加密/多进程/体积)为任意数据集选定存储方案
  • 能为 FieldNote 写出带索引设计与版本化迁移的 SQLite schema,并解释复合索引与部分索引的取舍
  • 能搭建"内存 → 磁盘 → 远端"三层缓存,设计带命名空间与版本号的缓存 key,正确使用 persistQueryClient
  • 能实现图片磁盘缓存的 LRU 上限清理,并落地 CDN 尺寸变体与离线预下发策略
  • 能划清 Keychain/Keystore、SQLCipher、字段级加密三者边界,落实"凭据零字面量"的 grep 门禁
  • 能避开主线程 IO、无事务批量写、缓存目录泄漏式增长等存储性能陷阱
  • 能为 FieldNote 完成四类数据的落位实现并通过"存储验收清单"

核心知识点

14.1 存储方案全景与底层原理

原理。选型之前先看清每个方案"数据放在哪、读写走哪条路、瓶颈卡在哪"。

AsyncStorage:原生侧的字符串 KV 存储(Android 侧基于 SQLite、iOS 侧基于打包文件,实现细节随版本演进,以官方文档为准),JS 侧只收发字符串。它的成本模型是"全量 JSON 往返":存对象要先 JSON.stringify,读取后整个字符串跨边界传输再 JSON.parse——改一个 10MB 列表里的一个字段,也要序列化 10MB、传输 10MB、解析 10MB。瓶颈:值越大越痛;没有部分更新、没有查询、没有并发控制。定位:少量小体积的简单 KV(主题、开关),且永远不存敏感数据。

MMKV:腾讯开源的高性能 KV,react-native-mmkv 通过 JSI 让 JS 直连 C++ 实现,不经桥接序列化。快的三重原因:mmap 把文件直接映射进进程地址空间,读写像访问内存一样,写入经 page cache 由 OS 异步落盘,省去 read/write 系统调用与用户态缓冲;JSI 直连省掉桥接的 JSON 编解码;值按 key 独立编码存取,改哪个 key 只动那段数据。写入是同步的(调用返回即已写入 mmap),这带来确定性的同时意味着大 value 会阻塞 JS 线程。局限:只能按 key 取值,没有条件查询;文件整体有损坏风险(进程被杀在写一半时,靠 CRC 校验发现并丢弃重建——数据可能丢,必须有降级策略,见 14.6);多进程访问需额外设计(14.6 详述)。定位:高频读写的小中型 KV、设置项、缓存元数据。

SQLite:嵌入式关系型数据库,单文件落盘,B+ 树索引、事务(ACID)、SQL 查询一应俱全。它是"列表 + 条件查询 + 分页"场景的唯一正解:WHERE planDate = ? AND status = ? ORDER BY ... LIMIT 50 这类查询 MMKV 无法表达。事务把 N 次写合成一次 fsync,是批量写入的性能杠杆。RN 生态两个主流绑定:expo-sqlite 与 op-sqlite,取舍见下表。定位:结构化业务数据的事实源(FieldNote 的 tasks/checkItems/photos)。

维度 expo-sqlite op-sqlite
出身与集成 Expo 官方 SDK 组件,裸 RN 也可用 社区高性能库,纯 JSI 直连
性能定位 足够快,API 友好,同步/异步双 API 极致取向,几乎全同步 + 更薄封装
加密 较新版本提供基于 SQLCipher 的加密能力(以官方文档为准) 内建支持 SQLCipher
生态 与 Expo 工具链、dev client 集成顺滑 支持 libsql 等扩展,玩法更多
选型建议 用 Expo 或追求稳 → 选它 纯 RN CLI、性能敏感、要加密 → 选它

WatermelonDB 与 Realm。WatermelonDB 建在 SQLite 之上,核心卖点是懒加载 + 响应式:列表查询返回惰性集合,配合观察者按需拉行,适配"数万行级别的超大列表 + 数据变更自动刷新 UI"。代价是自带一套模型/适配器体系与约束(schema 定义、字段类型受限),引入即接受其框架。Realm 是对象数据库(无需 SQL,对象即行),性能好、有响应式查询,但引入现状需谨慎:其配套云同步服务在近年经历重大路线调整,本地库虽仍可用,新项目引入前务必确认维护状态与社区方向。FieldNote 千条级任务 + 自定义同步引擎(第 15 章)不需要它们的响应式抽象,直接用 SQLite 保持掌控力。

文件系统。图片、导出的报表、离线包这类大而不可查询的 blob,正确归宿是文件系统(应用沙盒目录),数据库只存元数据(路径、大小、校验和、同步状态)。理由在 14.2 展开并与本章练习呼应。

Keychain(iOS)/ Keystore(Android)。不是"又一个 KV",而是由硬件保护的密钥与凭据存储:iOS 的 Keychain 条目由受 Secure Enclave 保护的密钥加密,支持按设备/按解锁访问等级;Android 的 Keystore 生成不可导出的密钥(TEE/StrongBox 硬件 backing),应用被 root 提取也拿不到明文密钥。react-native-keychain 统一两端 API。定位:token、刷新凭据、生物识别绑定密钥——并且只放这些,它是安全边界不是缓存。

六维选型矩阵(FieldNote 数据全量对照):

方案 数据量 查询需求 响应式 加密 多进程 包体积
AsyncStorage 小(KB 级) 无 无 无 无 已内置生态
MMKV 小~中(单值 MB 内) 仅按 key 无(需手动同步 UI) 支持加密 受限,需谨慎 ~百 KB 级
SQLite 大(十万行级) SQL 全能力 无(可自建通知) SQLCipher 类 WAL 下读写并发良好 库体积小
WatermelonDB 超大列表 有(受限模型) 内建观察者 依赖底层 依赖底层 中
文件系统 大 blob 无 无 可自行加密 沙盒内共享 无
Keychain/Keystore 极小(凭据) 无 无 硬件级 按条目策略 无

14.2 FieldNote 存储架构决策:四类数据落位

原理。按"数据形态 × 访问模式"而非"哪个库新"做决策。FieldNote 的四类数据与落位如下,配一张分层架构图(数据类别 → 存储引擎 → 其上的缓存层):

数据 形态与访问模式 落位 关键理由
tasks / checkItems / photos 元数据 结构化、千条级、按门店+日期+状态筛选分页、批量事务写 SQLite 条件查询 + 索引 + 事务,MMKV 表达不了
设置项 / 同步游标 / 缓存元数据 小 KV、高频读写、无需查询 MMKV 同步毫秒级,游标每条记录都小且独立
Token / 刷新凭据 / 生物识别密钥 极小、安全攸关 Keychain/Keystore 硬件保护,root/越狱设备上也难提取
照片文件 大 blob、不可查询、可重建可淘汰 文件系统 + 数据库元数据表 数据库存 BLOB 会撑爆读写路径(练习 4)
flowchart LR
  subgraph Data["数据类别"]
    A["任务/检查项/照片元数据"]
    B["设置/同步游标/缓存元数据"]
    C["Token/生物识别密钥"]
    D["照片文件/离线包"]
  end
  subgraph Cache["缓存层"]
    M1["内存缓存 Map"]
    M2["(直读)"]
  end
  subgraph Store["存储引擎"]
    S1[("SQLite")]
    S2[("MMKV")]
    S3[("Keychain/Keystore")]
    S4[("文件系统")]
  end
  A --> M1 --> S1
  B --> M2 --> S2
  C --> S3
  D --> S4
  S4 -. 尺寸/路径等元数据写回 .-> S1

Schema 设计。完整 DDL 如下,重点是两处索引设计:

PRAGMA journal_mode = WAL;          -- 见 14.6
PRAGMA foreign_keys = ON;

CREATE TABLE IF NOT EXISTS tasks (
  id            TEXT PRIMARY KEY,   -- 服务端 id,客户端不生成
  storeId       TEXT NOT NULL,
  planDate      TEXT NOT NULL,      -- YYYY-MM-DD
  status        TEXT NOT NULL,      -- pending | in_progress | submitted | merged
  serverVersion INTEGER NOT NULL DEFAULT 0,  -- 服务端单调版本,用于增量同步
  updatedAt     INTEGER NOT NULL    -- 本地毫秒时间戳,仅用于展示与缓存淘汰
);

-- 复合索引 ①:列表页主查询 WHERE planDate = ? AND status IN (...) ORDER BY planDate
CREATE INDEX IF NOT EXISTS idx_tasks_plan_status ON tasks(planDate, status);
-- 索引 ②:按门店看当日任务
CREATE INDEX IF NOT EXISTS idx_tasks_store_plan ON tasks(storeId, planDate);

CREATE TABLE IF NOT EXISTS check_items (
  id            TEXT PRIMARY KEY,
  taskId        TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  name          TEXT NOT NULL,
  sortKey       INTEGER NOT NULL,
  score         INTEGER,
  note          TEXT NOT NULL DEFAULT '',
  serverVersion INTEGER NOT NULL DEFAULT 0,
  dirty         INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_checkitems_task_sort ON check_items(taskId, sortKey);
-- 部分索引 ③:只收录 dirty = 1 的行,同步引擎"找待上传项"时索引体积极小
CREATE INDEX IF NOT EXISTS idx_checkitems_dirty ON check_items(taskId) WHERE dirty = 1;

CREATE TABLE IF NOT EXISTS photos (
  id            TEXT PRIMARY KEY,
  checkItemId   TEXT NOT NULL REFERENCES check_items(id) ON DELETE CASCADE,
  localPath     TEXT,               -- 文件系统相对路径;上传成功后仍保留
  remoteUrl     TEXT,
  syncState     TEXT NOT NULL,      -- local | uploading | synced | failed
  serverVersion INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_photos_checkitem ON photos(checkItemId);

逐段讲解索引设计。复合索引为什么是 (planDate, status) 而不是 (status, planDate):SQLite 的复合索引遵循最左前缀原则,索引列顺序应让"等值条件列在前、排序/范围列在后"。列表页的主查询是"某天的某几种状态",两列都是等值;把 planDate 放首位还能让 ORDER BY planDate 直接按索引序返回,免掉一次排序。若把 status 放首位,筛选"已完成"(全门店全日期)时确实更快,但那是低频查询——索引为高频查询服务。(storeId, planDate)同理服务于"按门店看当日"。

dirty 部分索引:WHERE dirty = 1 让索引只包含未同步行。同步引擎每次唤醒都要"找出所有 dirty 行",正常情况下这个集合很小(几十条),普通索引却是全表规模;部分索引让该查询的索引体积与扫描成本只正比于"待同步量"而非"总数据量"。⚠️ 部分索引要求查询条件与建索引的 WHERE 精确匹配(WHERE dirty = 1),写成 dirty <> 0 就走不上了。

版本化迁移。SQLite 没有"ORM 自动迁移",工程做法是用 PRAGMA user_version 记录当前版本,迁移按版本号线性执行:

type Migration = { version: number; up: string[] };
const MIGRATIONS: Migration[] = [
  { version: 1, up: [/* 14.2 的全部 DDL */] },
  { version: 2, up: [
    // 例:v2 为任务增加同步优先级。SQLite 的 ALTER 能力有限(不能删列/改列类型),
    // 破坏性变更必须走"建新表 → INSERT INTO ... SELECT 拷贝 → DROP 旧表 → RENAME"流程
    'ALTER TABLE tasks ADD COLUMN syncPriority INTEGER NOT NULL DEFAULT 0;',
  ] },
];

export async function migrate(db: Db) {
  const row = await db.getFirst<{ user_version: number }>('PRAGMA user_version;');
  let current = row?.user_version ?? 0;
  for (const m of MIGRATIONS.filter((x) => x.version > current)) {
    await db.exec('BEGIN IMMEDIATE;');
    try {
      for (const sql of m.up) await db.exec(sql);
      await db.exec(`PRAGMA user_version = ${m.version};`);
      await db.exec('COMMIT;');
      current = m.version;
    } catch (e) {
      await db.exec('ROLLBACK;');
      throw e; // 迁移失败直接抛,由启动层决定走降级(见 14.6)还是崩溃上报
    }
  }
}

回滚路径问题要正面回答:SQLite 迁移的业界现实是"向前修复"——down 脚本只能覆盖加列这类可逆操作,破坏性变更无法真正回滚。FieldNote 的策略是三层:迁移前把 .db 文件整体拷贝一份(本地库通常只有几 MB,成本可忽略);每个迁移在 CI 用内存库跑"v0 → vN → 用 vN 语义校验数据";线上迁移失败时启动层降级(只读模式 + 引导用户重装同步),绝不带着半迁移的 schema 继续写。开发期演练 down 的唯一价值是发现设计错误,不是生产恢复手段。

14.3 缓存分层体系:内存 → 磁盘 → 远端

原理。三层各回答一个时间尺度的问题:内存层(Map)回答"这一帧、这一次滚动",微秒级但容量小、进程死即失;磁盘层(MMKV/文件)回答"这次会话、这次安装",毫秒级且活过重启;远端回答"事实是什么",秒级且权威。读路径逐层下降、写路径逐层上升,任何一层都不得跳过下一层直接成为事实源。

读:组件 ──▶ 内存缓存(命中即返回) ──▶ 磁盘缓存(命中即回填内存) ──▶ 远端
写:组件 ──▶ SQLite(事实源,事务) ──▶ 失效内存层 ──▶ (第15章)Outbox 同步远端

TTL 与 stale-while-revalidate。缓存的价值随时间衰减,策略要区分数据的新鲜度容忍度:门店列表这种准静态数据,TTL 可以给到小时级;任务详情是会被别人改的服务端状态,用 stale-while-revalidate——先返回旧值保证即时可用,同时后台请求新值,到达后替换并通知 UI。这正好是 TanStack Query 的 staleTime + 后台 refetch 语义,我们在自建缓存中复刻同样的心智模型:

// packages/native-kit/src/cache/memoryCache.ts
export class MemoryCache<T> {
  private map = new Map<string, { value: T; expiresAt: number }>();
  constructor(private max = 300, private ttlMs = 60_000) {}

  get(key: string): { value: T; stale: boolean } | undefined {
    const hit = this.map.get(key);
    if (!hit) return undefined;
    this.map.delete(key); this.map.set(key, hit); // Map 迭代序 = 插入序,重插即 LRU 触碰
    return { value: hit.value, stale: Date.now() > hit.expiresAt };
  }

  set(key: string, value: T): void {
    if (this.map.size >= this.max) {
      const oldest = this.map.keys().next().value; // 淘汰最久未用
      if (oldest !== undefined) this.map.delete(oldest);
    }
    this.map.set(key, { value, expiresAt: Date.now() + this.ttlMs });
  }

  invalidatePrefix(prefix: string): void {
    for (const k of this.map.keys()) if (k.startsWith(prefix)) this.map.delete(k);
  }
}

缓存 key 设计规范。命名格式统一为 {ns}:v{schemaVersion}:{entity}:{id},例如 thumb:v3:task:t_1001、cursor:v1:sync:u_42。三条规则:命名空间隔离模块,杜绝 tasks 与 photos 的 key 撞车,也支持按前缀批量失效;版本号是防脏读的关键——序列化结构或字段语义变更时把 v 号 +1,旧缓存读不到新代码要的形状,天然失效,胜过写迁移代码去兼容旧 JSON;id 放最后便于前缀扫描。MMKV 没有前缀删除 API,所以"换 v 号整体作废 + 惰性清理"比"逐 key 删除"更现实。

与 persistQueryClient 的关系与边界。第 12 章确立了 TanStack Query 管服务端状态,其内存缓存可以用 persistQueryClient 持久化到 MMKV,用于冷启动恢复:App 重启后列表秒出旧数据再后台刷新,这正是三层缓存的磁盘层。但边界必须划清:它持久化的是查询缓存的序列化快照,不是可编辑的事实源——用户离线时对它做编辑(改评分、删照片),这些变更没有结构、没有 dirty 标记、没有 Outbox,第 15 章的同步引擎无从消费。所以 FieldNote 的规则是:可编辑的业务数据以 SQLite 为事实源(15/16 章),persistQueryClient 只服务"只读展示的启动提速"。持久化快照本身也要带版本号(持久化 key 里含 schema version),并设置 maxAge 防止极旧快照误导用户。

14.4 图片缓存专项(承接第 4 章)

原理。第 4 章从内存角度分析过图片的成本:一张 1080p 解码后的位图约 8MB(宽×高×4 字节),是普通 JS 对象的百万倍。原生图片库(如 FastImage / expo-image)内部维护两层缓存:内存 LRU(解码后的位图,按 budget 淘汰,命中即免解码)与磁盘层(原始文件/解码缓存,命中免网络但需解码)。应用层要做的不是重造这两层,而是三件事:控制 CDN 侧尺寸、控制磁盘总量、离线预下发。

CDN 尺寸变体策略:列表缩略图请求 ?w=256 的变体,详情页请求 ?w=1024,原图仅在"查看大图"时按需拉取。传输体积差 10 倍,解码内存差 16 倍——尺寸参数是性价比最高的图片优化,先于任何客户端魔法。下发 URL 由服务端模板生成,客户端拼接时校验域名在白名单内(安全红线:任何拼 URL 的代码都要防注入,只允许 https://cdn.fieldnote.example.com 前缀)。

磁盘 LRU 上限清理是应用层真正要写的代码。完整实现如下(约 60 行,依赖以接口注入便于单测):

// packages/native-kit/src/cache/imageDiskCache.ts
type Entry = { file: string; size: number; lastAccess: number };
interface FsAdapter {
  exists(path: string): Promise<boolean>;
  stat(path: string): Promise<{ size: number } | null>;
  unlink(path: string): Promise<void>;
  list(dir: string): Promise<string[]>;
  touch(path: string): Promise<void>; // 更新 mtime,供冷启动后按文件时间重建排序
}

export class ImageDiskCache {
  private index = new Map<string, Entry>();
  constructor(private fs: FsAdapter, private dir: string,
              private budget = 200 * 1024 * 1024) {}

  async warmup(): Promise<void> {                       // 冷启动重建索引
    let total = 0;
    for (const f of await this.fs.list(this.dir)) {
      const st = await this.fs.stat(f);
      if (!st) continue;
      const mtime = await this.mtimeOf(f);
      this.index.set(f, { file: f, size: st.size, lastAccess: mtime });
      total += st.size;
    }
    await this.evict(total);                            // 超预算立即收敛
  }

  pathFor(url: string): string {                        // 规范化哈希做文件名;URL 变体不同 → 文件不同
    const name = `${hashUrl(url)}.img`;
    return `${this.dir}/${name}`;
  }

  async get(url: string): Promise<string | null> {      // 命中即触碰(LRU 关键)
    const p = this.pathFor(url);
    if (!(await this.fs.exists(p))) return null;
    const e = this.index.get(p);
    if (e) e.lastAccess = Date.now();
    await this.fs.touch(p);
    return p;
  }

  async put(url: string, sizeBytes: number): Promise<string> {
    const p = this.pathFor(url);
    this.index.set(p, { file: p, size: sizeBytes, lastAccess: Date.now() });
    await this.evict(this.totalSize());
    return p;
  }

  private totalSize(): number {
    let s = 0; for (const e of this.index.values()) s += e.size; return s;
  }

  private async evict(current: number): Promise<number> {
    let freed = 0;
    if (current <= this.budget) return freed;
    const sorted = [...this.index.values()].sort((a, b) => a.lastAccess - b.lastAccess);
    for (const e of sorted) {
      if (current - freed <= this.budget) break;
      await this.fs.unlink(e.file);                     // 先删盘再改索引,失败不删索引(下次重建兜底)
      this.index.delete(e.file);
      freed += e.size;
    }
    return freed;
  }

  private async mtimeOf(f: string): Promise<number> {
    try { const t = await statMtimeMs(f); return t; } catch { return Date.now(); }
  }
}

逐段讲解四个关键点:get 命中即更新 lastAccess 并 touch 文件 mtime——LRU 的"最近使用"必须真实反映访问,否则清理会误删热门缩略图;evict 先删文件再改索引,unlink 失败时索引保留该条目,下次 warmup 重建时以盘上事实为准,索引永远向文件系统对齐而不是相反;预算建议 100~300MB 并可配置,巡检类 App 图片多为缩略图,200MB 可存数千张;hashUrl 用规范化 URL 的哈希(同一 URL 稳定映射同一文件名),CDN 变体参数不同自然落到不同文件、被独立淘汰。

离线图片包(预下发):巡检员出发前往往已经在无网环境,FieldNote 在有网时按"今日任务列表 → 关联检查项 → 缩略图变体"逐级解析出 URL 清单,串行限速预下载进 ImageDiskCache,并在数据库 photos 表把 syncState 与本地路径落好。预下发是有上限的主动行为(只预下发当日任务相关图片),与 LRU 的被动淘汰互补;执行开关挂到 13 章 feature flag(offlinePack.beta),弱网下可灰度关闭。

14.5 敏感数据处理:Keychain、加密库与脱敏

原理。敏感数据的工程判断先于技术:哪些数据泄露即事故?Token、刷新凭据、生物识别绑定的密钥——泄露意味着冒充用户;巡检照片与评分本身是业务数据,泄露属隐私事件但影响可控。分层对策:

第一层,凭据进 Keychain/Keystore。判定标准就一条:攻击者拿到设备文件系统访问权(备份、root、越狱)后能否提取。AsyncStorage/MMKV/SQLite 文件在备份与 root 下都可读,因此 token 类凭据只能进硬件保护的 Keychain/Keystore(14.1 已述其 backing)。iOS 侧用 accessible 等级控制何时可读(如仅在设备解锁后),Android 侧 Keystore 密钥不可导出,可绑定生物识别(新录入指纹/面容即失效)。

第二层,库级加密:SQLCipher 类方案。整库透明加密(页级 AES),需要 expo-sqlite 的加密能力或 op-sqlite 的 SQLCipher 支持(14.1 表)。适用场景:库里整体都是敏感业务数据、需要"一刀切"防护。代价:读写性能有可感开销(页级加解密)、密钥管理成为新单点——库密钥本身必须住在 Keychain/Keystore,绝不能硬编码。字段级加密则只对敏感列(如检查项备注里可能出现的身份证号)在 JS 层用 AES-GCM 加密后入库。取舍:全库加密防丢失面广但开销大、粒度粗;字段级加密开销小、可对特定列做,但容易漏(忘记加密的列、索引列泄漏明文、日志打印原文)。FieldNote 的选择:暂不引入 SQLCipher(业务数据敏感度中等、性能优先),备注中的隐私字段用字段级加密;一旦客户合规要求升级,迁移到 SQLCipher 的入口已留好(DAO 层收敛了所有 SQL)。

第三层,日志脱敏。日志是经常被遗忘的泄露面:console.log(response) 会把 token 打进设备日志。规范三条:logger 出口统一正则脱敏(13.6 的 REDACT);敏感对象提供 toSafeJSON(),调试时只打指纹(如 token 的哈希前 8 位),不打原文、不打掩码中间段;release 构建关闭 debug 级 transport。

第四层,凭据零字面量的工程化门禁。安全红线的落实不能靠自觉,用 grep 在 CI 里机械检查:

#!/usr/bin/env bash
# scripts/check-secrets.sh —— CI 第 0 道闸:凭据零字面量
# 命中即失败:形如 apiKey/secret/password/token 后跟赋值字面量的代码
PATTERN="(api[_-]?key|secret|password|passwd|token)[\"']?\s*[:=]\s*[\"'][A-Za-z0-9_\-\.]{12,}[\"']"
VIOLATIONS=$(grep -rnE "$PATTERN" \
  --include='*.ts' --include='*.tsx' --include='*.kt' --include='*.swift' --include='*.gradle' \
  app/src packages 2>/dev/null)
if [ -n "$VIOLATIONS" ]; then
  echo "发现疑似凭据字面量:"; echo "$VIOLATIONS"; exit 1
fi
# 允许清单:process.env.* / Constants.expoConfig?.extra / <YOUR_*>' 占位均不会命中上述模式

正则会误伤测试夹具里的假 token——处理方式是规范假值形态(如 test-token-0000... 拆开写或用 <YOUR_*> 占位),而不是给门禁开白名单口子,口子一开终会腐化。

14.6 存储的性能与坑

主线程 IO 禁令。RN 的 JS 线程一卡全 app 卡(第 1 章的线程模型)。凡是同步 API(MMKV 的同步读写、部分库的同步 SQLite 接口)都必须审视数据量:MMKV 读写 KB 级设置项是微秒级、安全;但用它存一个 5MB 的 JSON 字符串,序列化 + mmap 写就是几十毫秒的 JS 线程停顿。规则:单值 > 100KB 就不该进 MMKV,转文件系统 + 元数据;SQLite 一律走异步 API。

大批量写入必须事务包装。SQLite 每个独立写事务都隐含一次落盘,同步引擎首灌 2000 个任务时,逐条 INSERT 是 2000 次 fsync 级等待;包进一个事务后合并为一次,通常快 1~2 个数量级:

await db.exec('BEGIN IMMEDIATE;');
try {
  for (const t of tasks) await insertTaskStmt(t);
  await db.exec('COMMIT;');
} catch (e) {
  await db.exec('ROLLBACK;'); throw e;
}

MMKV 多进程注意。MMKV 核心支持多进程访问,但 react-native-mmkv 的典型用法是单进程内多实例(按 id 分库)。需要跨进程共享(Android 的独立进程 Service、iOS 的 App Group 扩展如分享插件/Widget)时,必须显式用多进程模式初始化并理解其文件锁开销;不确定就不跨进程——FieldNote 的原则是所有持久化收敛在主进程,扩展进程需要数据时经接口请求,而不是直接开同一个 mmkv 文件。

SQLite WAL 模式。默认 journal 模式下写会阻塞读;WAL(write-ahead log)把写追加到 -wal 文件、读走主库快照,实现"一写多读并发",正是 FieldNote 的形态(UI 频繁读 + 后台同步间歇批量写)。开启后注意:WAL 是库级持久属性,设一次即可;定期 checkpoint 把 -wal 合并回主库(默认自动触发,App 退到后台时手动执行一次 PRAGMA wal_checkpoint(TRUNCATE); 可控制文件体积);busy_timeout 给写冲突留等待余地。

存储泄漏式增长。永不清理的缓存目录是移动 App 的慢性病:图片缓存、日志文件、导出的临时文件各涨各的,几个月后"存储空间不足"工单上门。对策三条:每类缓存有明确预算(图片 200MB、日志 20MB)并在启动/退后台时执行 LRU 清理;日志滚动写入(按大小轮转、只留 N 份);设置页暴露"缓存占用 + 一键清理",既是功能也是运维观测面。14.4 的 ImageDiskCache 就是预算化清理的范本。

典型业务场景:FieldNote 实战(里程碑 M14:四类数据落位)

目标:完成 SQLite 建库与 DAO、MMKV 封装、Token 存取与生物识别联动、图片缓存接入,产出可验收的存储层。代码位于 packages/native-kit(13 章拆出的技术封装包)。

一、SQLite 建库与 DAO 层。连接、迁移、事务三合一的 db 模块:

// packages/native-kit/src/db/db.ts
import * as SQLite from 'expo-sqlite'; // op-sqlite 场景 API 形态相似,替换此 import 与打开方式即可
import { migrate } from './migrations';

export type Db = SQLite.SQLiteDatabase;

export async function openDb(name = 'fieldnote.db'): Promise<Db> {
  const db = await SQLite.openDatabaseAsync(name);
  await db.execAsync('PRAGMA journal_mode = WAL;');
  await db.execAsync(`PRAGMA busy_timeout = 3000;`);
  await migrate(db);            // 14.2 的版本化迁移
  return db;
}

DAO 收敛所有 SQL——领域层只见 CheckItemRepository 端口,这里是其 SQLite 实现:

// packages/native-kit/src/db/taskDao.ts
export function createTaskDao(db: Db) {
  const listByDateStmt = 'SELECT * FROM tasks WHERE planDate = ? AND status IN (?, ?, ?) ORDER BY planDate LIMIT ? OFFSET ?';

  return {
    async listByDate(planDate: string, statuses: string[], limit: number, offset: number) {
      return db.getAllAsync<TaskRow>(listByDateStmt, [planDate, ...statuses, limit, offset]);
    },
    async findById(id: string): Promise<TaskRow | null> {
      return db.getFirstAsync<TaskRow>('SELECT * FROM tasks WHERE id = ?', [id]) ?? null;
    },
    async upsertMany(tasks: TaskRow[]): Promise<void> {   // 首灌/增量同步都用它:事务 + 预编译
      await db.execAsync('BEGIN IMMEDIATE;');
      try {
        for (const t of tasks) {
          await db.runAsync(
            `INSERT INTO tasks (id, storeId, planDate, status, serverVersion, updatedAt)
             VALUES (?, ?, ?, ?, ?, ?)
             ON CONFLICT(id) DO UPDATE SET
               storeId = excluded.storeId, planDate = excluded.planDate,
               status = excluded.status, serverVersion = excluded.serverVersion,
               updatedAt = excluded.updatedAt`,
            [t.id, t.storeId, t.planDate, t.status, t.serverVersion, t.updatedAt],
          );
        }
        await db.execAsync('COMMIT;');
      } catch (e) { await db.execAsync('ROLLBACK;'); throw e; }
    },
  };
}

export function createCheckItemDao(db: Db) {
  return {
    async listByTask(taskId: string) {
      return db.getAllAsync<CheckItemRow>(
        'SELECT * FROM check_items WHERE taskId = ? ORDER BY sortKey', [taskId]);
    },
    async findDirty(limit = 50) {           // 部分索引 idx_checkitems_dirty 专门服务这条
      return db.getAllAsync<CheckItemRow>(
        'SELECT * FROM check_items WHERE dirty = 1 LIMIT ?', [limit]);
    },
    async saveScored(item: CheckItemRow) {
      await db.runAsync(
        'UPDATE check_items SET score = ?, note = ?, dirty = 1 WHERE id = ?',
        [item.score, item.note, item.id]);
    },
    async clearDirty(id: string) {          // 第 15 章同步成功后调用
      await db.runAsync('UPDATE check_items SET dirty = 0 WHERE id = ?', [id]);
    },
  };
}

photos 的 DAO 与之同构(listByCheckItem、updateSyncState),此处从略——重点注意 photos 表只存 localPath 与元数据,文件本体由 14.4 的缓存目录管理,这是"文件系统 + 元数据表"落位的直接体现。

二、MMKV 封装:命名空间 + 类型安全。散落的 mmkv.setString('x', ...) 是键名灾难的起点,统一封装为带注册表的类型安全门面:

// packages/native-kit/src/storage/kv.ts
import { MMKV } from 'react-native-mmkv';

export const kv = new MMKV({ id: 'fieldnote.app.v1' });       // id 即命名空间
const CACHE_VER = 'v3';                                       // 缓存 schema 版本,见 14.3

function stringField(key: string, def = '') {
  return {
    get: () => kv.getString(key) ?? def,
    set: (v: string) => kv.set(key, v),
    clear: () => kv.delete(key),
  };
}
function jsonField<T>(key: string) {
  return {
    get: (): T | undefined => { const s = kv.getString(key); return s ? JSON.parse(s) as T : undefined; },
    set: (v: T) => kv.set(key, JSON.stringify(v)),
    clear: () => kv.delete(key),
  };
}

export const SyncCursorStore = jsonField<Record<string, string>>(`${CACHE_VER}:cursor:sync`);
export const SettingsStore = {
  syncPolicy: stringField('settings:syncPolicy', 'wifiOnly'),
  theme: stringField('settings:theme', 'system'),
};
// 使用:SyncCursorStore.set({ tasks: '2026-09-01T00:00:00Z' })

类型安全的来源是"字段注册表":所有 key 只在封装文件里出现一次,调用方拿到的是强类型的 get/set 门面,键名拼错、类型错用都是编译错误。

三、SecureStore 封装与生物识别解锁联动:

// packages/native-kit/src/storage/tokenStore.ts
import * as Keychain from 'react-native-keychain';

const SERVICE = 'com.fieldnote.tokens';
interface Tokens { access: string; refresh: string }

export const TokenStore = {
  async save(tokens: Tokens): Promise<void> {
    await Keychain.setGenericPassword('tokens', JSON.stringify(tokens), {
      service: SERVICE,
      accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, // iOS:不随备份/iCloud 迁移
      securityLevel: Keychain.SECURITY_LEVEL.SECURE_HARDWARE,         // Android:尽力硬件 backing
    });
  },
  async loadWithBiometry(): Promise<Tokens | null> {   // 生物识别门闩:读取即触发系统弹窗
    const res = await Keychain.getGenericPassword({
      service: SERVICE,
      authenticationPrompt: { title: '解锁 FieldNote', cancel: '取消' },
    });
    return res ? JSON.parse(res.password) as Tokens : null;
  },
  async clear(): Promise<void> {
    await Keychain.resetGenericPassword({ service: SERVICE });
  },
};
// packages/native-kit/src/auth/useBiometricUnlock.ts —— 与第 12 章 auth store 的联动
export function useBiometricUnlock() {
  const setStatus = useAuthStore((s) => s.setStatus);
  return useMutation({
    mutationFn: async () => {
      const tokens = await TokenStore.loadWithBiometry(); // 用户通过生物识别才拿得到
      if (!tokens) throw new Error('UNLOCK_CANCELLED');
      return tokens;
    },
    onSuccess: (tokens) => { applyTokens(tokens); setStatus('authenticated'); }, // 投影自动切栈
    onError: () => setStatus('locked'),  // 失败留在锁屏,可重试或走密码兜底
  });
}

启动时序:TokenStore.loadWithBiometry 成功且 token 未过期 → authenticated;本地有 token 但需要用户在场确认 → locked(渲染生物识别按钮);无 token → anonymous。导航器不参与判定(13.5 的投影原则),只消费 auth store 的状态。

四、图片磁盘缓存接入。用 14.4 的 ImageDiskCache,注入 expo-file-system 适配器并挂到启动流程:

// packages/native-kit/src/cache/setup.ts(节选)
const fs: FsAdapter = {
  exists: (p) => FileSystem.getInfoAsync(p).then((r) => r.exists),
  stat: (p) => FileSystem.getInfoAsync(p).then((r) => r.exists ? { size: r.size ?? 0 } : null),
  unlink: (p) => FileSystem.deleteAsync(p, { idempotent: true }),
  list: (dir) => FileSystem.readDirectoryAsync(dir).then((xs) => xs.map((x) => `${dir}/${x}`)),
  touch: (p) => FileSystem.touchAsync(p, { modificationTime: Date.now() / 1000 }),
};
export const imageCache = new ImageDiskCache(fs, `${FileSystem.cacheDirectory}thumbs/`, 200 * 1024 * 1024);
// App 启动:await imageCache.warmup()(放在启动任务的低优先级阶段,不阻塞首帧)

预下发(有网时执行,开关受 feature flag 控制):解析今日任务 → 收集 photo.remoteUrl 的 ?w=256 变体 → 校验白名单域名 → FileSystem.downloadAsync(url, imageCache.pathFor(url)) → imageCache.put(url, size)。列表渲染时 get(url) 命中即离线可见,未命中交给原生库的磁盘缓存兜底。

五、存储验收清单(M14 出厂标准,逐条可测):

  • 冷启动迁移:v1 库升级到当前版本,数据完整、user_version 正确、失败时启动走降级而非白屏
  • 首灌性能:2000 任务 + 2 万检查项入库(单事务)在主流机型 < 2s,期间 UI 不掉帧(异步执行)
  • 列表查询:千条任务按 planDate+status 分页查询 EXPLAIN QUERY PLAN 走 idx_tasks_plan_status,无全表扫描
  • 同步取数:findDirty 走部分索引,返回条数 = 待同步量
  • 敏感数据:root/越狱模拟环境下遍历沙盒文件,找不到明文 token(在 Keychain/Keystore)
  • 缓存预算:写入超过 200MB 图片后触发 LRU 清理,磁盘占用收敛到预算内
  • 键名规范:全仓 grep mmkv.set|mmkv.getString 仅出现在 kv.ts 封装文件内
  • 无凭据字面量:check-secrets.sh 通过

常见陷阱

陷阱 典型症状 根因 正确做法
大 JSON 存 AsyncStorage 反复序列化 列表页每次读写卡 200ms+;改一个字段全量往返 AsyncStorage 只有"整存整取"的字符串语义 结构化列表进 SQLite;AsyncStorage 只留小 KV;确需暂存大对象用文件系统
用 MMKV 存需要查询的列表数据 "按日期筛选任务"写成全量读出后 JS 内存过滤,数据量一涨就卡 MMKV 只有按 key 取值,没有条件查询 条件查询归 SQLite;MMKV 只存"整取整存"的小对象(设置、游标)
token 存 AsyncStorage root 设备/备份提取出明文凭据;安全审计不过 把安全存储当成了"另一个 KV" Token/刷新凭据/生物识别密钥只进 Keychain/Keystore,且不落任何日志
缓存无上限导致磁盘占满 用户工单"App 占 2GB";系统杀后台越来越频繁 每个功能都在写缓存,没人负责清理 每类缓存定预算 + LRU 清理(14.4);设置页暴露占用与一键清理
SQLite 无索引全表扫描 千条数据列表查询 300ms;数据翻倍后不可用 DDL 顺手写完没设计索引;EXPLAIN 从没看过 为高频查询建复合索引(等值在前);用 EXPLAIN QUERY PLAN 验收;dirty 查询用部分索引
mmap 库文件损坏无降级策略 极端断电/被杀后启动必崩(CRC 校验失败抛异常) 假设"写进去就永远在",没有 Plan B 启动捕获损坏 → 备份坏文件用于诊断 → 重建库 → 从远端重新同步;数据可重建是前提(第 15 章)
迁移脚本无回滚路径 迁移跑到一半失败,schema 半新半旧,App 永久打不开 假设迁移永远成功;没有降级决策 迁移前整库备份 + 事务化迁移 + 失败降级(只读模式/引导重同步);开发期演练 down 只为发现设计错误
主线程同步大 IO 打开详情页瞬间 JS 线程冻结,动画掉帧 MMKV 塞了大 value / 误用同步 SQLite API 单值 >100KB 不进 MMKV;SQLite 全异步;大对象走文件系统

最佳实践

  • 先按"形态 × 访问模式"分类数据,再选存储引擎;六维矩阵(量/查询/响应式/加密/多进程/体积)过一遍再写 DDL
  • SQLite 是结构化业务数据的唯一事实源;MMKV 只做小 KV(设置、游标、缓存元数据);AsyncStorage 仅在生态库强制要求时使用
  • DDL 与索引一起设计:等值列在前、排序列在后的复合索引;"找待同步项"用部分索引;上线前用 EXPLAIN QUERY PLAN 逐条验收
  • 迁移用 PRAGMA user_version 线性版本化;迁移前整库备份、迁移中事务包裹、迁移失败有降级路径
  • 缓存三层各司其职:内存管帧、磁盘管会话、远端管事实;可编辑数据永远不走"缓存层当事实源"的捷径
  • 缓存 key 统一 {ns}:v{ver}:{entity}:{id}:schema 变更换 v 号整体作废,胜过写兼容代码
  • persistQueryClient 只用于冷启动提速的只读快照,带版本号与 maxAge;离线编辑一律落 SQLite + Outbox(第 15 章)
  • 图片三件事:CDN 尺寸变体、磁盘 LRU 预算清理、离线预下发有上限且受 feature flag 控制
  • 凭据四层防护:Keychain/Keystore 存凭据、按需选 SQLCipher/字段级加密、日志出口统一脱敏、CI grep 门禁保证凭据零字面量
  • 所有持久化 API 调用收敛到 native-kit 的封装层,业务代码不直接 import 存储库——为加密升级、换库留出唯一改动点
  • 批量写必须事务包装;SQLite 开 WAL 并在退后台时 checkpoint;MMKV 单值控制在 100KB 内
  • 每类缓存有预算、有清理时机、有观测面;"存储空间不足"在变成工单之前就该在设置页可见

本章小结

本章完成了"数据放哪里"的全景与落地。原理主线:六类方案的差异本质是"数据放哪、读写走哪条路"——AsyncStorage 的全量 JSON 往返、MMKV 的 mmap + JSI 直连、SQLite 的 B+ 树与事务、Keychain/Keystore 的硬件 backing,决定了它们各就各位。架构主线:FieldNote 四类数据落位(结构化数据 SQLite、小 KV MMKV、凭据 Keychain、大文件文件系统 + 元数据),配套复合索引与部分索引、版本化迁移与降级路径;缓存三层(内存/磁盘/远端)各管一个时间尺度,key 带版本号防脏读,persistQueryClient 只做只读提速。安全主线:凭据零字面量靠 grep 门禁执法,日志出口统一脱敏。M14 交付了可验收的存储层,dirty 标记与 syncState 已就位——第 15 章的离线优先架构与同步引擎将直接在这些表上展开。

一句话记忆点:先问数据的形态与访问模式,再让引擎就位——事实源只有一个,缓存可以很多层,但每一层都要有预算、版本号和清理时机。

动手练习

  1. 基础巩固:一个设置页把"用户全部偏好 JSON"(约 800KB,含嵌套配置数组)存 AsyncStorage,每次修改任意一项都要 stringify 全量写回。请指出三处问题,并给出迁移方案(提示:哪些字段该拆成 MMKV 的独立 key?哪个字段其实是需要查询的结构化数据?哪个字段根本该进 Keychain?)。
  2. FieldNote 实战(索引与查询):为"巡检主管看某门店某天所有异常检查项(score <= 2)"设计查询与索引:写出 SQL、分析该走哪个索引、判断是否需要新建索引(提示:score <= 2 是范围条件,想想它与 (taskId, sortKey) 索引的关系;异常项占比很低,是否可以考虑别的索引形态?)。再用 EXPLAIN QUERY PLAN 验证你的结论。
  3. FieldNote 实战(缓存版本化):给 MMKV 封装补上"schema 版本迁移"能力:当 CACHE_VER 从 v3 升到 v4 且 SettingsStore.syncPolicy 的取值枚举发生变化(wifiOnly 拆分为 wifi/wifi5g),写出读取旧值并迁移到新 key 的逻辑,并说明为什么这种迁移代码要尽量少写——它与"换 v 号整体作废"各自的适用边界在哪里?
  4. 排查/思考题:为什么"图片文件本身"不该以 BLOB 存进 SQLite,而应文件系统 + 元数据表?请从三个角度分析——读取路径(列表滚动时 SQLite 读 BLOB 会发生什么?对比文件系统 + 原生图片库的解码管线,谁让 JS 线程参与了不该参与的工作?)、内存与缓存(BLOB 的整行读出与 LRU 淘汰模型如何冲突?原生图片库的内存/磁盘双层缓存还怎么生效?)、备份与迁移(200MB 图片进库后,14.2 的"迁移前整库备份"会变成什么代价?换 SQLCipher 加密时加密的是谁?)。最后回答:什么样的二进制数据适合进 BLOB?(提示:小、无独立生命周期、强事务绑定。)

导航

上一章:第 13 章 大型项目架构设计与模块化 | 下一章:第 15 章 离线优先架构与数据同步

📄 第 15 章 离线优先架构与数据同步 15-offline-sync.md

本章导读:第 14 章把数据落进了 SQLite、MMKV 与 Keychain,本章在此基础上解决全书最核心的难题——离线优先架构与数据同步:本地凭什么当权威、变更怎样可靠地到达服务端、服务端变更怎样无遗漏地回来。我们将深入 Outbox 模式、游标增量协议与一整套可靠性机制,并搭建带故障注入的 Mock Server 完成端到端联调;冲突如何裁决,留给第 16 章。

本章学习目标

  • 能阐述离线优先与在线优先的架构哲学差异,并为一个业务做出「是否必须离线优先」的判断
  • 能实现「本地事务先写 + Outbox 同事务入队」的可靠写路径,并说清它为什么比在线 API 优先可靠
  • 能设计基于 serverVersion 单调游标的增量拉取协议,并用全量对账做一致性兜底
  • 能实现指数退避 + 抖动重试、网络感知触发、前后台生命周期挂载、批处理合并等可靠性机制
  • 能说明 WorkManager 与 BGTaskScheduler 的能力边界,解释为什么「后台必同步」在 iOS 上不成立
  • 能横向评估 WatermelonDB、PowerSync、CRDT 类方案,给出自建同步协议的决策依据
  • 能搭建只监听 localhost 的 Node Mock Server,完成「离线改三条数据 → 联网自动收敛」的演练
  • 能识别客户端时钟排序、重复推送、覆盖未同步变更、JS 线程阻塞等典型陷阱并给出规避方案

核心知识点

15.1 离线优先:本地是权威数据源,远端是同步对象

原理讲解

在线优先(online-first)架构里,网络是主干道:UI 发起读写 → 请求 API → 服务端返回 → 回写本地缓存。本地只是缓存的影子,网络失败等于应用不可用,「离线」是一种错误态。

离线优先(offline-first)把这套关系倒转过来:本地 SQLite 是权威数据源,UI 的读写永远只碰本地;网络退居幕后,变成一个异步的复制进程,负责把本地变更推上去、把远端变更拉回来。「离线」是一种常态,不是异常。这个倒转带来三个直接推论:

  1. 读路径不经过网络。列表、详情秒开,无网照常;「数据新不新鲜」不再由 UI 关心,由同步引擎负责。
  2. 写路径是「本地受理」。用户操作即落盘成功,之后由 Outbox(15.2)重放到服务端。网络失败对用户透明。
  3. 数据流必须单向:网络 → SQLite → TanStack Query 缓存 → UI。任何绕过 SQLite 直接把网络响应塞进 Query 缓存的做法,都会造成「两个真相」。

UI 层的读策略常被归为三种模式,离线优先对它们的态度并不相同:

读模式 数据来源 时效性 离线表现 在离线优先中的定位
cache-first 只读缓存/本地库 取决于同步引擎 完全可用 默认模式,本地库即缓存
stale-while-revalidate 先给旧值,后台取新值 最终一致 可用(旧值) 被「同步引擎写库后 invalidate」取代,不再直连网络
network-only 每次请求网络 最新 不可用 仅用于强实时场景(如登录态校验、支付前余额确认)

代码示例:FieldNote 任务列表的读路径。

// src/features/tasks/hooks/useTaskList.ts
import { useQuery } from '@tanstack/react-query';
import { taskRepo } from '@/db/repositories/taskRepo';

// 离线优先的读路径:queryFn 只读本地 SQLite,永不直接请求网络。
// 「新鲜度」由同步引擎负责——它把服务端变更写进 SQLite 后,
// invalidate 这里,UI 自然刷新(见 15.3 时序图最后两步)。
export function useTaskList(storeId: string) {
  return useQuery({
    queryKey: ['tasks', storeId],
    queryFn: () => taskRepo.listByStore(storeId),   // 只读 SQLite
    staleTime: Infinity,  // 本地是权威,缓存不存在「过期」的概念
    gcTime: Infinity,     // 进程内常驻,切页不丢
  });
}

// 同步引擎应用完服务端变更后唯一要做的事:
queryClient.invalidateQueries({ queryKey: ['tasks'] });

逐段讲解:queryFn 是这条链路上唯一的数据口子,只认 SQLite——这就是「本地是权威」的代码化。staleTime: Infinity 不是偷懒,而是声明语义:本地库的数据没有新旧之分,只有「同步进度」之分;进度由 SyncCursor(第 14 章 MMKV)表达,而不是由 Query 缓存的过期时间表达。invalidateQueries 让 UI 重查本地库,数据流保持单向,Query 缓存退化成「SQLite 的内存视图」,两层之间永远不会打架。

另一个推论是 UI 策略:既然写操作是「本地受理、稍后同步」,UI 必须把同步进度可视化——未同步记录加 dirty 角标、列表头展示「N 条待同步 / 上次同步时间」。否则用户会把「本地已保存」误解为「服务端已收到」,出了分歧就是信任事故。

那么什么业务值得付出离线优先的架构代价?逐维度判断:

判断维度 倾向在线优先 倾向离线优先 FieldNote 的情况
使用现场网络 稳定 Wi-Fi/5G 弱网/无网是常态 门店后场、地下工程、边远巡检点
写操作的时效容忍 写必须立刻被服务端看见 可接受分钟级延迟同步 巡检结果现场记录,回传可延迟
冲突代价与频率 频繁且代价高 低频且可业务裁决 双人同巡同店是小概率,评分可裁决
数据能否本地完整表达 强依赖服务端计算 实体模型可整体落到本地 任务/检查项/照片纯业务数据
服务端可改造性 已有强一致 API 可加幂等/版本号 第 13 章 monorepo,服务端可配套演进

五个维度 FieldNote 全部落在离线优先一侧,结论是必须离线优先。⚠️ 反例也要认清:实时协作白板、秒杀下单、资金转账这类「正确性依赖服务端实时状态」的业务,强行离线优先会把矛盾推迟到同步时刻爆发,代价更大。

15.2 Outbox 模式:写路径的可靠性基石

原理讲解

离线优先最要命的问题在写路径:用户改了数据,本地成功了,怎么保证这个变更最终一定到达服务端,且只到达一次?在线优先的做法是「UI → API → 服务端 → 回写缓存」,可靠性押在网络上,失败点横亘在用户操作与结果之间。Outbox 模式(事务性发件箱,transactional outbox)把可靠性挪进本地:

  1. 业务写操作在 SQLite 里落盘,同时给行打 dirty = 1(第 14 章字段);
  2. 同一个事务内,往 outbox 表插入一条待同步记录;
  3. 后台同步引擎按序取出 Outbox 记录重放给服务端;
  4. 服务端确认(幂等去重)后,删除 Outbox 记录、清 dirty。

为什么「本地事务先行」比「在线 API 优先」可靠?因为用户操作的原子性被压进了本地事务边界:业务数据与发件记录要么同时存在、要么同时不存在。网络失败不再丢操作——记录躺在磁盘上,重试多少次都行;App 崩溃也不丢——落盘即持久,重启后继续重放。SQLite 在这里扮演了「与业务数据共享事务边界的消息队列」,这正是 Outbox 模式的本质:凡是和业务写不在同一个原子边界内的队列,都可能丢消息;唯一不会丢的队列,是业务数据本身所在的那个库。

Outbox 记录是一条带状态机的小生命:

stateDiagram-v2
    [*] --> pending : 业务写事务内入队(同事务!)
    pending --> inflight : 到期批取(FIFO 保因果序)
    inflight --> [*] : ok / duplicate → 删除记录 + 清 dirty
    inflight --> pending : 网络失败 / 5xx → 退避+抖动后重试
    inflight --> [*] : conflict → 记录冲突后出队(第 16 章裁决)
    inflight --> failed : 422 rejected(业务校验不过,重试无意义)
    failed --> decayed : attempts 超上限(毒丸隔离)
    pending --> decayed : attempts 超上限
    decayed --> pending : 对账修复 / 人工重放
  • pending:待发送,等 nextRetryAt 到期。
  • inflight:已取出正在推送。若此时进程被杀,记录停在 inflight——结果未知,但幂等键保证「重放安全」,冷启动时统一重置回 pending(15.4)。
  • done:服务端确认。实现上直接删除记录,表里不保留尸体。
  • failed:收到确定性拒绝(如 422 业务校验不过),退避后仍会重试几次(服务端状态可能在变),超限转 decayed。
  • decayed:毒丸隔离。停止自动重试,避免一条永远失败的操作堵住队列(FIFO 头部阻塞),等待全量对账或人工处理。

第 14 章的 outbox 表结构(id, opType, entity, entityId, payload, idempotencyKey, attempts, nextRetryAt)需要补一列表达状态:

-- 存储迁移:outbox 增加 state 列
ALTER TABLE outbox ADD COLUMN state TEXT NOT NULL DEFAULT 'pending';
-- 调度主索引:按状态 + 到期时间 + 创建顺序取出
CREATE INDEX IF NOT EXISTS idx_outbox_dispatch
  ON outbox(state, nextRetryAt, createdAt);

代码示例:业务写与入队同事务——这是整个模式中最关键的一致性保证。

// src/db/outbox.ts
import { uuid } from '@/core-domain/uuid';

export type Entity = 'tasks' | 'checkItems' | 'photos';
export type OpType = 'upsert' | 'delete';

/** 事务内入队:只能由业务写函数在「同一个事务」里调用 */
export function enqueueInTx(
  tx: Tx,
  op: { opType: OpType; entity: Entity; entityId: string; payload?: unknown },
): void {
  // 合并优化:同实体同 id 已有待发记录时,只更新 payload,复用旧的
  // idempotencyKey——对服务端来说这仍是「同一次逻辑操作」(见 15.4)
  const existing = tx.execute(
    `SELECT id FROM outbox
      WHERE entity = ? AND entityId = ? AND state IN ('pending','failed')
      LIMIT 1`,
    [op.entity, op.entityId],
  );
  if (existing.rows.length > 0) {
    tx.execute(
      `UPDATE outbox SET payload = ?, opType = ?, nextRetryAt = ? WHERE id = ?`,
      [JSON.stringify(op.payload ?? null), op.opType, Date.now(), existing.rows[0].id],
    );
    return;
  }
  tx.execute(
    `INSERT INTO outbox
       (id, opType, entity, entityId, payload, idempotencyKey,
        attempts, nextRetryAt, state, createdAt)
     VALUES (?, ?, ?, ?, ?, ?, 0, ?, 'pending', ?)`,
    [
      uuid(), op.opType, op.entity, op.entityId,
      JSON.stringify(op.payload ?? null),
      uuid(),           // ⚠️ 入队时生成一次,此后重试、重启都不得更换
      Date.now(), Date.now(),
    ],
  );
}
// src/features/checks/repositories/checkItemRepo.ts
import { enqueueInTx } from '@/db/outbox';

/** 改评分:业务写 + 入 Outbox,同一个 SQLite 事务,要么都成、要么都不成 */
export function updateCheckItemScore(
  tx: Tx, id: string, score: number, note: string,
): void {
  // 1) 业务数据:写终值 + 标 dirty(dirty = 本地有未推送变更)
  tx.execute(
    `UPDATE checkItems SET score = ?, note = ?, dirty = 1 WHERE id = ?`,
    [score, note, id],
  );
  // 2) 发件记录:同一事务内入队
  enqueueInTx(tx, {
    opType: 'upsert',
    entity: 'checkItems',
    entityId: id,
    // payload 存「最终态快照」而非增量:重放时服务端直接覆盖为该值,
    // 同一实体的多次编辑天然可合并(15.4),也不存在增量乱序拼接问题
    payload: { id, score, note },
  });
}

逐段讲解:updateCheckItemScore 接收一个事务句柄 tx,两条语句共享同一个提交点——这是第 14 章封装 withTransaction 的用法。关键点有三个:其一,入队与业务写在同一事务是防止「数据改了但永远不会同步」的唯一手段,反面时序在动手练习里让你亲手构造;其二,idempotencyKey 在入队时刻生成并随记录持久化,重试、重启、换网络都不变,服务端靠它去重(协议细节见第 16 章),【重点】如果每次重试重新生成键,服务端会把同一次操作当成多次新操作,去重彻底失效;其三,payload 采用最终态快照,让「同实体多次变更合并」在入队时就完成了大半。

15.3 增量同步协议:游标拉取、对账兜底与调度时序

原理讲解

写路径可靠了,还要解决读回来的问题:服务端的变更(他人操作、后台处理)怎么增量、无遗漏地到达客户端?

游标增量拉取。服务端为每条变更分配单调递增的 serverVersion(客户端时钟不可信,所以排序基准必须是服务端序号)。客户端按实体维护游标(第 14 章 SyncCursor(userId, cursors: { tasks, checkItems, photos })),每次拉取请求「版本号大于游标的变更」,服务端升序返回,客户端应用后推进游标。协议骨架:

GET /sync/pull?cursors={"tasks":100,"checkItems":88,"photos":41}&limit=200
→ 200 { changes: [ { entity, id, serverVersion, data|null } ],
        nextCursors: { tasks: 105, checkItems: 92, photos: 41 },
        hasMore: true }

游标推进有一条铁律:【重点】应用变更与推进游标必须在同一个本地事务里。两者分离就会出现「半拉状态」——游标推到了、数据没写进去(下次从新游标开始,中间这段永久丢失),或数据写进去了、游标没推(下次重拉一遍,靠幂等吸收,浪费但不出错)。宁可重拉,不可漏拉。

全量对账兜底。增量协议的盲区是「服务端绕过版本号改了数据」:运维修数、历史数据迁移、bug 修复补数据,都可能造成服务端有变更但版本号没有单调体现。所以要有兜底——定期(如 24 小时一次或用户手动下拉触发)做分页对账:服务端分页返回 {id, serverVersion} 清单,客户端逐条与本地比对,发现缺失或落后就按 id 拉详情补齐。它慢,但简单、无状态,是增量协议的最后一道保险。

推与拉的先后。一轮完整同步是先推后拉还是先拉后推?取舍如下:

  • 先推后拉(FieldNote 选择):本地变更尽快上服务器,减少「本地长期滞留未推送变更」的时间窗——滞留越久,与他人修改相遇的概率越大;且推送成功后拉取,服务端返回的变更已包含对他人视角的完整状态。代价是推送可能撞上他人并发修改,产生冲突——但冲突是第 16 章要正面解决的问题,而不是逃避它的理由。
  • 先拉后推:先吸收他人变更再写,冲突更少,但写路径延迟变高(用户改完要等一轮拉取才能推送),并且拉到的变更可能与本地未推送变更相交,处理复杂度并没有消失,只是换了个位置。

同步锁。网络恢复、回前台、冷启动、定时器——多个触发源可能同时到达,若不加互斥,两个同步实例并发跑,会出现重复推送、游标交叉推进等乱象。解法是单飞行(single-flight)互斥:任何时刻至多一个同步实例在执行,后来的触发直接放弃(在跑的那轮会覆盖它想做的事)。

时序图:一次完整同步(推 → 拉 → 应用 → UI 刷新)。

sequenceDiagram
    autonumber
    participant UI as UI(TanStack Query)
    participant SE as SyncEngine
    participant OB as Outbox(SQLite)
    participant LDB as 本地库
    participant SRV as 同步服务端

    Note over SE: 触发源:网络恢复 / 回前台 / 冷启动 / 后台任务
    SE->>SE: 获取同步锁(single-flight)
    SE->>OB: claimBatch(20)(pending 且到期,FIFO)
    OB-->>SE: 一批待推送记录(idempotencyKey、baseVersion)
    SE->>SRV: POST /sync/push(operations[])
    SRV-->>SE: results[](ok / duplicate / conflict / rejected)
    SE->>LDB: ok → 删 Outbox 记录 + 清 dirty + 写回 serverVersion
    loop 游标分页,直到 hasMore = false
        SE->>SRV: GET /sync/pull?cursors=…&limit=200
        SRV-->>SE: changes[] + nextCursors + hasMore
        SE->>LDB: 同一事务:应用变更(先查 dirty)+ 推进游标
        SE->>SE: yield 让出 JS 线程,避免连续大事务阻塞 UI
    end
    SE->>UI: queryClient.invalidateQueries()
    UI->>LDB: 重查 SQLite
    LDB-->>UI: 已收敛的最新数据

代码示例:同步锁与拉取阶段。

// src/sync/mutex.ts
// 单飞行互斥:任意时刻至多一个同步实例在跑。
// 后来的调用排队在前一个之后执行(而不是并发执行)。
let chain: Promise<unknown> = Promise.resolve();

export function runExclusive<T>(task: () => Promise<T>): Promise<T> {
  const next = chain.then(task, task); // 前一个失败不阻断后一个
  chain = next.then(() => undefined, () => undefined);
  return next;
}
// src/sync/pull.ts
async function pullPhase(userId: string): Promise<void> {
  for (;;) {
    const cursors = readSyncCursor(userId);       // MMKV(第 14 章)
    const res = await api.pull(cursors, PULL_PAGE_SIZE);
    if (res.changes.length === 0) break;

    // 【重点】应用变更 + 推进游标,同一事务:崩溃时一起回滚,
    // 下次从旧游标重拉——宁可重拉(幂等吸收),绝不半拉
    await db.withTransaction(tx => {
      for (const change of res.changes) applyChangeInTx(tx, change);
      writeSyncCursorInTx(tx, userId, res.nextCursors);
    });

    if (!res.hasMore) break;
    await yieldToUI();   // 分批让出 JS 线程(见 15.4 陷阱)
  }
}

function applyChangeInTx(tx: Tx, change: PullChange): void {
  // ⚠️ 关键防线:本地该行有未推送变更(dirty = 1)时不能无脑覆盖,
  // 否则用户离线期间的操作会被服务端旧值冲掉。
  // 「拉取与推送相遇」就是冲突,记入冲突队列,交给第 16 章裁决
  const local = tx.execute(
    `SELECT dirty FROM ${change.entity} WHERE id = ?`,
    [change.id],
  );
  if (local.rows.length > 0 && local.rows[0].dirty === 1 && change.data !== null) {
    queueConflictInTx(tx, change);
    return;
  }
  if (change.data === null) {
    // 墓碑:服务端已删除(孤儿清理,第 16 章 16.4 展开)
    tx.execute(`DELETE FROM ${change.entity} WHERE id = ?`, [change.id]);
    return;
  }
  upsertInTx(tx, change.entity, change.data, change.serverVersion);
}

逐段讲解:runExclusive 把所有触发源收敛成一条执行链,无论哪个触发源先到,同步体永远串行。pullPhase 的循环体只有两个动作——拉一页、同事务应用并推游标——事务边界刻意小到「一页」,既保证原子性又不至于让单个事务过长锁库。applyChangeInTx 先查 dirty 是本章埋下、第 16 章展开的第一道冲突检测线;墓碑分支处理服务端删除,同时要记得清理 Outbox 中对该实体的 pending 推送(推一个已删除的实体会换来 410/404,徒增 decayed 记录)。

15.4 可靠性机制:重试、网络感知、生命周期与批处理

原理讲解

真实网络是「抖动的、间歇的、随时把进程杀掉的」。同步引擎的可靠性由五件事拼成。

第一,指数退避 + 抖动。失败后立即重试大概率再失败(服务端过载/网络未恢复),重试间隔应随失败次数指数增长并封顶。但纯指数有一个隐患:如果 1000 个客户端在同一时刻失败,它们的第 N 次重试仍会同时发生,形成重试风暴(thundering herd)。解法是加抖动(jitter)——把退避值变成一个区间内的随机数,把重试点打散。工业界常用 full jitter:delay = random(0, min(cap, base × 2^attempt))。

第二,网络感知触发。@react-native-community/netinfo 监听连通性变化,恢复时触发同步。⚠️ 它只是触发源之一,绝不是唯一:iOS 退后台后 JS 事件可能根本收不到;VPN 抖动会产生假恢复;系统休眠中监听器不工作。

第三,生命周期挂载。回前台(AppState → active)触发一次,冷启动无条件触发一次——冷启动触发是进程被杀场景的关键兜底:上一轮同步做到一半被杀,Outbox 与游标都安全落盘了(第 14 章),重启后从断点继续。

第四,崩溃/强杀安全。Outbox 记录与业务数据同库同事务,落盘即持久;唯一需要显式处理的是 inflight 残留——崩溃时记录已取出、结果未知,冷启动统一重置为 pending。因为推送带幂等键,重放最多换来服务端的 duplicate 应答,不会产生副作用。

第五,批处理与合并 + 限流与公平性。同一实体的多次变更(改评分 → 补备注 → 再调评分)应合并为最新终态推送——15.2 的快照 payload 与入队合并已解决大半;出队时也可按 entityId 去重只推每实体最后一条。批次大小要有上限(如单批 20 条),照片这类大 payload 必须走独立通道(Outbox 加 channel 字段区分 metadata / binary),避免一张 8MB 的图堵住几千条元数据的因果队列。队列顺序永远按 createdAt FIFO,保证「先关闭任务、后归档任务」这类因果序不被打乱。

binary 通道内部还要再分片:照片按 1~2MB 分片上传,每个分片请求以 uploadSessionId + 分片序号 作为幂等键,服务端按会话聚合、校验全量哈希后合并;客户端持久化「已确认分片位图」,重试只补缺失分片——弱网下把「整个文件重传」的成本降到「补一两片」。会话的开启同样幂等(同一文件内容哈希开启会话返回同一 uploadSessionId),元数据条目要等全部分片确认后才出队清除,避免「图传了一半、任务先完成」的中间态。

代码示例:完整的退避计算与多触发源挂载。

// src/sync/retry.ts

/** 指数退避 + Full Jitter:
 *  delay = random(0, min(cap, base * 2^(attempt-1)))
 *  - 指数:失败越多,间隔越长,给系统恢复留时间
 *  - 封顶:重试间隔不能无限膨胀,5 分钟是合理的节奏上限
 *  - 抖动:把成千上万客户端的重试点打散,防重试风暴
 */
export function computeBackoffMs(
  attempt: number,        // 已失败次数,从 1 开始
  baseMs = 1_000,
  maxMs = 5 * 60_000,
): number {
  const capped = Math.min(maxMs, baseMs * 2 ** (attempt - 1));
  return Math.floor(Math.random() * capped);
}

/** 毒丸判定:超过上限进入 decayed,停止自动重试(见 15.2 状态机) */
export function isDecayed(attempts: number, maxAttempts = 10): boolean {
  return attempts >= maxAttempts;
}
// src/sync/useSyncTriggers.ts
import { useEffect } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
import { AppState } from 'react-native';
import { syncEngine } from './syncEngine';

export function useSyncTriggers(userId: string | null) {
  useEffect(() => {
    if (!userId) return;

    // 触发源 1:网络恢复。⚠️ 只是之一,不是唯一
    const unsubNet = NetInfo.addEventListener((state: NetInfoState) => {
      if (state.isConnected === true && syncEngine.isIdle()) {
        void syncEngine.sync(userId);
      }
    });

    // 触发源 2:回到前台(冷启动后首次 active、后台切回都会命中)
    const appState = AppState.addEventListener('change', s => {
      if (s === 'active' && syncEngine.isIdle()) void syncEngine.sync(userId);
    });

    // 触发源 3:冷启动立即同步——进程被杀后恢复的第一现场
    void syncEngine.sync(userId);

    // 触发源 4/5:定时兜底(如每 10 分钟)、关键用户操作后主动触发,
    // 由各自的业务代码调用 syncEngine.sync(userId),此处不展开

    return () => { unsubNet(); appState.remove(); };
  }, [userId]);
}

逐段讲解:computeBackoffMs 三个参数分别对应「从多长开始、涨多快、涨到哪」;注意随机下界是 0——full jitter 的特点,平均等待是指数值的一半,但重试点完全分散。useSyncTriggers 把生命周期与同步引擎解耦:引擎本身只暴露 sync() 与 isIdle(),触发策略集中在 hook 里,便于测试与替换。isIdle() 检查是软性的——真正防并发的是引擎内部的锁,这里的检查只是省一次入队开销。

15.5 后台任务:WorkManager、BGTaskScheduler 与「前台化」策略

原理讲解

「App 退到后台也能同步」听起来天经地义,实现上是双端各有一套时机不保证的调度机制,能力差异很大:

维度 Android WorkManager iOS BGTaskScheduler
定位 可延迟的后台约束任务 系统「学习」使用习惯后的机会性任务
触发时机 约束满足后相对较快;DOZE 模式下集中在维护窗口 完全由系统决定,无任何保证;新装 App 常要几小时才给窗口
网络约束 原生支持 NetworkType.CONNECTED 不提供网络条件约束,需代码内自查连通性
执行窗口 分钟级,较宽松 refresh 类型约 30 秒量级;processing 类型更长但更吝啬
退后台立刻执行 不保证 明确不成立(系统设计如此,防滥用省电)
进程被杀恢复 WorkManager 自管重试 需 App 自行落盘断点(本章 Outbox 天然满足)
调试方式 adb shell dumpsys jobscheduler、禁电池优化 模拟器 + Xcode/lldb 触发命令(以官方文档为准)

结论要写进架构决策:【重点】「后台必同步」在 iOS 上不成立,在 Android 上也只是「大概率」。因此同步的可靠性主战场必须放在前台——这叫前台化策略:

  1. 冷启动立即同步:进程被杀后的第一恢复点,成本最低、命中最高。
  2. 交互触发:进入任务列表/详情、提交巡检等关键动作后顺手触发——用户正在用 App,正是前台窗口。
  3. 通知引导:待同步数 > 0 时在前台展示 badge;Android 可在用户主动开启的「同步提醒通知」里放一个立即同步 action。
  4. 后台任务作为锦上添花:能跑到就赚(WorkManager 命中率显著更高),跑不到有前台策略兜底。

代码示例:双端后台任务的关键实现。

// android/app/src/main/java/com/fieldnote/sync/SyncWorker.kt
package com.fieldnote.sync

import android.content.Context
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.TimeUnit

class SyncWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
  override suspend fun doWork(): Result = withContext(Dispatchers.Default) {
    // 经由 Turbo Module 桥到 JS:发事件 → JS 侧调用 syncEngine.sync()
    SyncBridge.triggerSync()
    Result.success()
  }

  companion object {
    fun enqueue(context: Context) {
      val request = OneTimeWorkRequestBuilder<SyncWorker>()
        .setConstraints(
          Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED) // 有网才跑
            .build()
        )
        .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
        .build()
      WorkManager.getInstance(context).enqueueUniqueWork(
        "fieldnote-sync", ExistingWorkPolicy.REPLACE, request
      )
    }
  }
}
// ios/FieldNote/SyncBackgroundTask.swift
import BackgroundTasks
import Foundation

func registerSyncTask() {
  // 必须在应用启动早期(didFinishLaunching)注册,晚了注册直接失效
  BGTaskScheduler.shared.register(forTaskWithIdentifier: "com.fieldnote.sync", using: nil) { task in
    SyncBridge.triggerSync { task.setTaskCompleted(success: true) }
    scheduleSyncTask()  // 用掉一次窗口后,记得为下一次排期
  }
}

func scheduleSyncTask() {
  let request = BGAppRefreshTaskRequest(identifier: "com.fieldnote.sync")
  // earliestBeginDate 只是「最早」,系统想什么时候给就什么时候给
  request.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60)
  try? BGTaskScheduler.shared.submit(request)
}
<!-- ios/FieldNote/Info.plist(片段) -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
  <string>com.fieldnote.sync</string>
</array>
<key>UIBackgroundModes</key>
<array>
  <string>processing</string>
</array>

逐段讲解:Android 侧 CoroutineWorker + 网络约束 + 指数退避由 WorkManager 托管,enqueueUniqueWork 用 REPLACE 策略避免重复排队;JS 侧通过一个薄的原生模块 SyncBridge 接收事件后调用 syncEngine.sync(),桥接写法沿用第 9~11 章的原生模块范式。iOS 侧有三个硬性规则:注册必须发生在启动早期、Info.plist 必须声明 identifier、每次执行完必须重新 submit 排期(否则只有一次机会)。Swift 的 SyncBridge.triggerSync 回调里调用 setTaskCompleted,超时(约 30 秒量级)系统会直接挂起进程——所以后台窗口里只做「拉一页 + 推一小批」,绝不贪多。

15.6 现成方案横评:什么时候自建,什么时候别自建

原理讲解

同步协议不需要从零发明,业界有几个成熟坐标:

  • WatermelonDB 同步协议:pull/push 两阶段。客户端先 pull(lastPulledAt) 拉服务端变更应用,再把本地变更 push 上去,服务端返回新的时间戳作为游标。协议自带约定(变更集格式、冲突由服务端在 push 时裁决),客户端引擎成熟。局限:协议接管全部同步表的语义,自定义空间中等,且要求后端按协议实现端点。
  • PowerSync:服务端接 Postgres/Mongo 等主库,声明式规则定义「哪些数据同步到哪些客户端」,客户端本地 SQLite 是主库的镜像切片;客户端上传走自定的 backend connector。省掉协议自建,代价是服务端架构绑定与订阅成本。
  • CRDT 类(Replicache / Zero 的思想、Automerge / Yjs):客户端权威的本地存储 + 服务端权威变更流的 rebase(Replicache/Zero),或数学上保证无协调收敛的真 CRDT。适合协作型场景,不适合强业务规则领域(第 16 章 16.5 详谈)。
  • 自建:FieldNote 的选择,理由在表后。
方案 协议归属 冲突处理 后端要求 定制空间 典型适用
WatermelonDB 内置 pull/push 服务端在 push 时裁决 按协议实现端点 中 RN/React 全家桶、后端可配合
PowerSync 托管流式同步 上传走 connector,服务端裁决 Postgres/Mongo 等为主库 小~中 想省协议、主库明确
Replicache/Zero 变更流 + rebase 服务端 push handler 裁决 任意(自实现端点) 中 重交互、准实时协作感
Yjs/Automerge 真 CRDT 数学收敛,无需裁决 任意 relay 大 协作文档、白板
自建(本章) 完全自持 业务规则 + 服务端裁决 自由 无限 领域规则强、长期维护

什么时候不该自建,要诚实:MVP 快速验证期(协议 bug 的排查成本远超接入成本);后端已有成熟同步设施;数据天然 CRDT 友好(协作文本);团队没有专人愿意长期维护协议(同步协议的 bug 都长在「极偶发、难复现」的角落)。FieldNote 仍然选择自建,依据是:领域规则密集(评分裁决、状态机),通用协议装不下这些规则;服务端必须是权威(考核与审计);第 13 章 monorepo 的 core-domain 已把领域模型与 React 解耦,协议实现可以复用它;以及本书的教学目标本身。💡 一个务实的中间态:协议自建,但状态机、幂等、退避这些通用件照抄业界成熟形态——本章你写的每一行,都有 WatermelonDB 协议文档或 AWS AppSync 文献作为原型参照。

典型业务场景:FieldNote 实战(里程碑 M15:同步引擎闭环)

本节把前五节拼成可运行的 FieldNote 同步引擎,共四个模块 + 一个 Mock Server + 一次端到端演练。数据库接口沿用第 14 章封装:db.execute(sql, params) 返回 { rows },db.withTransaction(fn) 内用 tx.execute,任何异常整体回滚。

模块一:outbox.ts——入队、取出、标记

// src/sync/outbox.ts
import { db } from '@/db/database';
import { uuid } from '@/core-domain/uuid';
import { computeBackoffMs, isDecayed } from './retry';

export type Entity = 'tasks' | 'checkItems' | 'photos';
export type OpType = 'upsert' | 'delete';
export type OutboxState = 'pending' | 'inflight' | 'failed' | 'decayed';

export interface OutboxRow {
  id: string;
  opType: OpType;
  entity: Entity;
  entityId: string;
  payload: string;          // 最终态快照 JSON
  idempotencyKey: string;   // 逻辑操作唯一键,重试复用
  attempts: number;
  nextRetryAt: number;
  state: OutboxState;
  createdAt: number;
}

export const outbox = {
  /** 入队(事务内版本,供业务写函数调用),见 15.2 enqueueInTx */
  enqueueInTx,

  /** 取出一批到期记录并置 inflight。FIFO:保证业务操作因果序 */
  async claimBatch(limit: number): Promise<OutboxRow[]> {
    return db.withTransaction(async tx => {
      const now = Date.now();
      const res = await tx.execute(
        `SELECT * FROM outbox
          WHERE state = 'pending' AND nextRetryAt <= ?
          ORDER BY createdAt ASC LIMIT ?`,
        [now, limit],
      );
      const rows = res.rows as OutboxRow[];
      for (const r of rows) {
        await tx.execute(
          `UPDATE outbox SET state = 'inflight' WHERE id = ?`, [r.id],
        );
      }
      return rows;
    });
  },

  /** 服务端确认:done 即删除,表里不保留尸体 */
  async markDone(row: OutboxRow): Promise<void> {
    await db.execute(`DELETE FROM outbox WHERE id = ?`, [row.id]);
  },

  /** 失败:attempts + 1,退避到期后回 pending;超限转 decayed */
  async markFailed(row: OutboxRow): Promise<void> {
    const attempts = row.attempts + 1;
    const state: OutboxState = isDecayed(attempts) ? 'decayed' : 'pending';
    await db.execute(
      `UPDATE outbox SET attempts = ?, state = ?, nextRetryAt = ? WHERE id = ?`,
      [attempts, state, Date.now() + computeBackoffMs(attempts), row.id],
    );
  },

  /** 确定性拒绝 / 人工隔离 */
  async markDecayed(row: OutboxRow): Promise<void> {
    await db.execute(
      `UPDATE outbox SET state = 'decayed' WHERE id = ?`, [row.id],
    );
  },

  /** 冷启动恢复:inflight 是崩溃残留(结果未知),幂等键保证重放安全 */
  async resetInflightOnBoot(): Promise<void> {
    await db.execute(
      `UPDATE outbox SET state = 'pending' WHERE state = 'inflight'`,
    );
  },

  /** UI 展示用:各状态计数 */
  async counts(): Promise<{ pending: number; decayed: number }> {
    const res = await db.execute(
      `SELECT state, COUNT(*) AS n FROM outbox
        WHERE state IN ('pending','failed','decayed') GROUP BY state`,
    );
    const sum = (names: string[]) =>
      (res.rows as { state: string; n: number }[])
        .filter(r => names.includes(r.state))
        .reduce((acc, r) => acc + r.n, 0);
    return {
      pending: sum(['pending', 'failed']),
      decayed: sum(['decayed']),
    };
  },
};

模块二:retry.ts——15.4 已给出 computeBackoffMs 与 isDecayed,此处直接复用。

模块三:syncEngine.ts——推送 + 拉取 + 状态广播

// src/sync/syncEngine.ts
import { db } from '@/db/database';
import { outbox, OutboxRow } from './outbox';
import { readSyncCursor, writeSyncCursor } from './cursor'; // MMKV 封装
import { queueConflictInTx } from './conflictQueue';        // 第 16 章展开
import { api } from './api';
import { runExclusive } from './mutex';
import { useSyncStore } from '@/store/syncStore';
import { queryClient } from '@/app/queryClient';

const PUSH_BATCH_SIZE = 20;
const PULL_PAGE_SIZE = 200;

class SyncEngine {
  private syncing = false;

  isIdle(): boolean { return !this.syncing; }

  /** 所有触发源的唯一入口:single-flight 防并发实例 */
  sync(userId: string): Promise<void> {
    return runExclusive(async () => {
      if (this.syncing) return;
      this.syncing = true;
      useSyncStore.getState().setStatus('syncing');
      try {
        await this.pushPhase(userId);   // 先推:本地变更尽快上服务器(15.3 取舍)
        await this.pullPhase(userId);   // 后拉:吸收他人与后台变更
        useSyncStore.getState().setStatus('idle');
        useSyncStore.getState().setLastSyncedAt(Date.now());
        queryClient.invalidateQueries(); // 数据流:网络→SQLite→Query→UI
      } catch (e) {
        useSyncStore.getState().setStatus('error');
        // 吞掉异常:失败不惊扰 UI,等下一个触发源自然重试
      } finally {
        this.syncing = false;
      }
    });
  }

  // ---- 推送阶段 ----
  private async pushPhase(userId: string): Promise<void> {
    for (;;) {
      const batch = await outbox.claimBatch(PUSH_BATCH_SIZE);
      if (batch.length === 0) return;

      const operations = batch.map(row => ({
        opType: row.opType,
        entity: row.entity,
        entityId: row.entityId,
        baseVersion: readLocalVersion(row.entity, row.entityId), // 本地最后见过的服务端版本
        payload: row.payload,
        idempotencyKey: row.idempotencyKey,
      }));

      let res;
      try {
        res = await api.push(userId, operations);
      } catch (e) {
        // 网络层失败:整批退避重试(幂等键保证重放安全)
        for (const row of batch) await outbox.markFailed(row);
        throw e;
      }

      // 逐条按幂等键回填结果(协议保证 results 与 operations 一一对应)
      for (const result of res.results) {
        const row = batch.find(r => r.idempotencyKey === result.idempotencyKey)!;
        switch (result.status) {
          case 'ok':
          case 'duplicate':           // 去重命中 = 之前已成功,等同 ok
            await outbox.markDone(row);
            await clearDirtyAndSetVersion(
              row.entity, row.entityId, result.serverVersion!,
            );
            break;
          case 'conflict':
            // 冲突不是失败:记录后出队,交给第 16 章裁决
            await outbox.markDone(row);
            await queueConflict(row.entity, row.entityId, result.conflict!);
            break;
          case 'rejected':            // 422:业务校验不过,重试无意义
            await outbox.markDecayed(row);
            break;
        }
      }
      await yieldToUI();
    }
  }

  // ---- 拉取阶段:15.3 pullPhase / applyChangeInTx,此处复用 ----
  private async pullPhase(userId: string): Promise<void> { /* 15.3 实现 */ }
}

export const syncEngine = new SyncEngine();

// 大批量处理时让出 JS 线程,避免掉帧(见陷阱表第 4 条)
export function yieldToUI(): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, 0));
}

clearDirtyAndSetVersion 在一个事务里完成「清 dirty + 写回服务端版本号」,与 15.3 的游标推进同构:状态推进与数据写入永远绑在同一个事务里。

模块四:hooks/useSyncStatus.ts——UI 展示

// src/store/syncStore.ts(Zustand,承接第 12 章的组合)
import { create } from 'zustand';

interface SyncState {
  status: 'idle' | 'syncing' | 'error';
  pendingCount: number;
  lastSyncedAt: number | null;
  setStatus: (s: SyncState['status']) => void;
  setLastSyncedAt: (t: number) => void;
  setPendingCount: (n: number) => void;
}

export const useSyncStore = create<SyncState>(set => ({
  status: 'idle',
  pendingCount: 0,
  lastSyncedAt: null,
  setStatus: status => set({ status }),
  setLastSyncedAt: lastSyncedAt => set({ lastSyncedAt }),
  setPendingCount: pendingCount => set({ pendingCount }),
}));
// src/features/sync/components/SyncBadge.tsx
import { View, Text } from 'react-native';
import { useSyncStore } from '@/store/syncStore';

export function SyncBadge() {
  const status = useSyncStore(s => s.status);
  const pending = useSyncStore(s => s.pendingCount);
  if (status === 'idle' && pending === 0) return null;
  return (
    <View>
      <Text>
        {status === 'syncing' ? '同步中…'
          : status === 'error' ? '同步失败,将自动重试'
          : `${pending} 条待同步`}
      </Text>
    </View>
  );
}

Mock Server 完整代码(约 200 行,Node 内置模块零依赖)。⚠️ 安全约束:只监听 127.0.0.1 回环地址,仅供本机联调;真实同步服务必须走 HTTPS 并在网关层做域名与来源白名单校验。

// server/mock-server.js
// FieldNote Mock Server —— 本书联调专用
// 安全约束:仅监听 127.0.0.1 回环地址;勿改为 0.0.0.0 暴露到局域网
// 故障注入:请求头 x-fault = 500 | timeout | reorder | concurrent(第 16 章)
const http = require('node:http');

const PORT = Number(process.env.MOCK_SYNC_PORT ?? 8787);
const HOST = '127.0.0.1'; // 永远不要监听 0.0.0.0

// ---- 内存「数据库」:三张表 + 全局单调版本号 ----
let versionCounter = 0;
const nextVersion = () => ++versionCounter;

const db = {
  tasks: new Map(),      // id -> { id, storeId, planDate, status, serverVersion }
  checkItems: new Map(), // id -> { id, taskId, name, sortKey, score, note, serverVersion }
  photos: new Map(),     // id -> { id, checkItemId, remoteUrl, serverVersion }
};
const tableOf = { tasks: db.tasks, checkItems: db.checkItems, photos: db.photos };

// ---- 幂等去重表:idempotencyKey -> 首次响应(协议核心,第 16 章深入) ----
const idempotency = new Map();

// ---- 种子数据 ----
function seed() {
  const t1 = { id: 'task-1', storeId: 'store-1', planDate: '2026-09-29',
    status: 'submitted', serverVersion: nextVersion() };
  const t2 = { id: 'task-2', storeId: 'store-1', planDate: '2026-09-29',
    status: 'draft', serverVersion: nextVersion() };
  db.tasks.set(t1.id, t1);
  db.tasks.set(t2.id, t2);
  const c1 = { id: 'ci-1', taskId: 'task-1', name: '冷柜温度', sortKey: 1024,
    score: 3, note: '', serverVersion: nextVersion() };
  const c2 = { id: 'ci-2', taskId: 'task-1', name: '地面清洁', sortKey: 2048,
    score: 5, note: '', serverVersion: nextVersion() };
  db.checkItems.set(c1.id, c1);
  db.checkItems.set(c2.id, c2);
}
seed();

// ---- 故障注入 ----
function faultOf(req) {
  return req.headers['x-fault'] ?? process.env.FAULT ?? '';
}

// ---- GET /sync/pull:按 serverVersion 游标增量拉取 ----
function handlePull(req, res, url) {
  let cursors = {};
  try { cursors = JSON.parse(url.searchParams.get('cursors') ?? '{}'); }
  catch { res.writeHead(400); return res.end('bad cursors'); }
  const limit = Math.min(Number(url.searchParams.get('limit') ?? 200), 500);

  const changes = [];
  const nextCursors = {};
  let hasMore = false;
  for (const [entity, table] of Object.entries(tableOf)) {
    const since = cursors[entity] ?? 0;
    const rows = [...table.values()]
      .filter(r => r.serverVersion > since)
      .sort((a, b) => a.serverVersion - b.serverVersion); // 升序:单调保证
    if (faultOf(req) === 'reorder') rows.reverse();       // 故障注入:乱序返回
    const page = rows.slice(0, limit);
    if (rows.length > limit) hasMore = true;
    for (const r of page) {
      changes.push({ entity, id: r.id, serverVersion: r.serverVersion,
        data: r.deleted ? null : r }); // deleted: 墓碑
    }
    nextCursors[entity] = page.length
      ? Math.max(...page.map(r => r.serverVersion)) : since;
  }
  writeJson(res, 200, { changes, nextCursors, hasMore });
}

// ---- POST /sync/push:幂等去重 + baseVersion 冲突检测 ----
function handlePush(req, res, body) {
  const fault = faultOf(req);
  if (fault === '500' && Math.random() < 0.5) {
    return writeJson(res, 500, { error: 'injected' });
  }
  if (fault === 'timeout') {
    // 挂住不响应,触发客户端超时重试(演示幂等键的价值)
    return; // 故意不写回
  }

  const results = [];
  for (const op of body.operations ?? []) {
    // 故障注入:concurrent —— 处理前模拟「他人已并发修改」(第 16 章验证用)
    if (fault === 'concurrent' && op.entity === 'checkItems') {
      const row = tableOf[op.entity].get(op.entityId);
      if (row) { row.serverVersion = nextVersion(); row.touchedBy = 'concurrent'; }
    }

    // 1) 幂等去重:命中则原样返回首次结果(status 标记为 duplicate)
    const seen = idempotency.get(op.idempotencyKey);
    if (seen) { results.push({ ...seen, status: 'duplicate' }); continue; }

    const table = tableOf[op.entity];
    if (!table) { results.push({ idempotencyKey: op.idempotencyKey,
      status: 'rejected', reason: 'UNKNOWN_ENTITY' }); continue; }

    const current = table.get(op.entityId);
    const baseVersion = op.baseVersion ?? 0;

    // 2) 冲突检测:服务端当前版本比客户端 base 新 → 有人先改过(第 16 章裁决)
    if (op.opType === 'upsert' && current && current.serverVersion > baseVersion) {
      const result = { idempotencyKey: op.idempotencyKey, status: 'conflict',
        serverVersion: current.serverVersion,
        conflict: { current, currentVersion: current.serverVersion } };
      idempotency.set(op.idempotencyKey, result);
      results.push(result);
      continue;
    }

    // 3) 正常写入:分配新的单调版本号
    let serverVersion;
    if (op.opType === 'delete') {
      table.delete(op.entityId);
      serverVersion = nextVersion();
    } else {
      const data = JSON.parse(op.payload);
      const row = { ...(current ?? {}), ...data,
        serverVersion: nextVersion() };
      table.set(row.id, row);
      serverVersion = row.serverVersion;
    }
    const result = { idempotencyKey: op.idempotencyKey, status: 'ok',
      serverVersion };
    idempotency.set(op.idempotencyKey, result);
    results.push(result);
  }
  writeJson(res, 200, { results }); // 批接口用语义状态,不用整体 409(第 16 章契约)
}

// ---- HTTP 骨架 ----
function writeJson(res, code, body) {
  res.writeHead(code, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(body));
}

const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://${HOST}:${PORT}`);
  if (url.pathname === '/sync/pull' && req.method === 'GET')
    return handlePull(req, res, url);
  if (url.pathname === '/sync/push' && req.method === 'POST') {
    let raw = '';
    req.on('data', c => (raw += c));
    req.on('end', () => {
      try { return handlePush(req, res, JSON.parse(raw || '{}')); }
      catch { return writeJson(res, 400, { error: 'bad json' }); }
    });
    return;
  }
  if (url.pathname === '/debug/state' && req.method === 'GET') {
    // 联调观察口:导出全部数据(仅本机)
    return writeJson(res, 200, {
      version: versionCounter,
      tasks: [...db.tasks.values()],
      checkItems: [...db.checkItems.values()],
      photos: [...db.photos.values()],
    });
  }
  writeJson(res, 404, { error: 'not found' });
});

server.listen(PORT, HOST, () =>
  console.log(`mock sync server on http://${HOST}:${PORT} (loopback only)`));

端到端演练:离线改三条数据 → 联网自动收敛

# 1. 启动 Mock Server 与 App(EXPO_PUBLIC_SYNC_URL=http://127.0.0.1:8787)
node server/mock-server.js
# Android 模拟器内访问宿主机用 10.0.2.2:8787,或 adb reverse tcp:8787 tcp:8787
adb reverse tcp:8787 tcp:8787

# 2. 登录后断网
adb shell cmd connectivity airplane-mode enable
# iOS:真机开飞行模式;模拟器可用 Network Link Conditioner 切 100% Loss

# 3. 离线操作三条数据:
#    ① 检查项 ci-1 评分 3 → 5   ② ci-1 备注补一句  ③ task-1 新增一张照片
#    观察:UI 立即显示新值(本地权威),SyncBadge 显示「3 条待同步」

# 4. 观察日志(pending = 3,全部是 outbox 记录)
adb logcat | grep -i "fieldnote-sync"

# 5. 恢复网络
adb shell cmd connectivity airplane-mode disable

# 6. 预期收敛序列(日志):
#    [netinfo] isConnected=true → trigger sync
#    [push] claim 3 rows → POST /sync/push → 3 ok
#    [push] clear dirty x3, outbox empty
#    [pull] cursor {tasks:2, checkItems:4, photos:0} → 0 changes
#    [ui] invalidateQueries → SyncBadge 消失

# 7. 在宿主机验证服务端状态
curl http://127.0.0.1:8787/debug/state
# 预期:ci-1.score = 5、note = 新值;photos 表多一条记录;版本号单调增长

再演练一次故障注入:重启 Mock Server 时设 FAULT=500 node server/mock-server.js,重复上述步骤,观察推送先失败、退避间隔逐次拉长(1s → 2s → 4s…封顶 5 分钟)、最终成功——这就是 15.4 各机制协同的样子。FAULT=timeout 则会演示超时重试命中幂等去重返回 duplicate 的全过程。

常见陷阱

陷阱 典型症状 根因 正确做法
用客户端时钟排序/裁胜负 换一台时钟偏慢的手机,后改的数据反而被旧数据覆盖 客户端时钟不可信(偏移可达分钟级甚至更多),updatedAt 排出的序是假的 排序与裁决只用服务端单调 serverVersion;客户端时间仅用于展示
推送非幂等,重试产生重复记录 一条检查项在服务端出现两份、照片重复上传 超时后客户端重试,服务端把两次请求当两次新操作 idempotencyKey 入队时生成并持久化,重试复用;服务端建去重表(第 16 章)
拉取应用时覆盖本地未推送变更 用户离线改的评分,联网后被服务端旧值冲掉 应用变更前没查 dirty,把「服务端真相」当成了唯一真相 applyChangeInTx 先查 dirty,dirty=1 走冲突队列(第 16 章)
同步在 JS 线程大批量执行 联网瞬间列表掉帧、点击无响应 一轮同步把几千行变更塞进一个事务、一个宏任务里跑完 分页拉取 + 每页一个事务,页间 yieldToUI() 让出线程;图片走独立通道
网络抖动造成「半拉」状态 中断后部分数据丢失,且再也不会同步回来 游标推进与变更应用不在同一事务,崩溃/断网后游标前进而数据未落 应用变更与推进游标绑同一事务,失败整体回滚,下次重拉(幂等吸收)
把「网络恢复事件」当唯一触发 iOS 退后台/被杀后数据长期不同步 netinfo 在后台不投递事件、VPN 抖动会产生假信号 多触发源:冷启动、回前台、交互后、定时兜底、后台任务(尽力而为)
同步不加互斥锁 偶发重复推送、游标倒退 netinfo 与回前台同时触发,两个同步实例并发跑 single-flight 锁,所有触发源收敛到一个串行入口
崩溃后 inflight 记录永久滞留 重启后待同步数不减、一直「同步中」 inflight 是取 出后崩溃的残留,无人认领 冷启动 resetInflightOnBoot() 重置为 pending,靠幂等键安全重放

最佳实践

  • 本地 SQLite 是唯一权威数据源,UI 读写在任何网络状态下都不等网络;网络只是后台复制进程。
  • Outbox 入队与业务写必须在同一个 SQLite 事务里——这是「永不丢用户操作」的全部秘密。
  • idempotencyKey 入队时生成、持久化、重试复用;服务端去重表按它裁决,客户端永远不重生成。
  • 排序、裁决、游标一律基于服务端单调 serverVersion;客户端时钟只许出现在 UI 展示里。
  • 应用服务端变更前必查 dirty;变更应用与游标推进绑同一事务;宁可重拉,不可半拉。
  • 重试用指数退避 + full jitter 并封顶;超限转 decayed 隔离毒丸,防 FIFO 头部阻塞。
  • 同步入口全局唯一,single-flight 互斥;所有触发源(网络、生命周期、定时、交互)都汇到它。
  • 冷启动无条件触发一次同步,并重置 inflight 残留——这是进程被杀后的第一恢复点。
  • 大批量变更分页 + 分事务 + 页间 yield;照片等大 payload 走独立通道,不阻塞元数据队列。
  • 同步状态(syncing / pendingCount / lastSyncedAt)通过 Zustand 向 UI 广播,让用户看得见同步进度。
  • 后台任务按「锦上添花」定位:Android WorkManager 尽力调度,iOS BGTaskScheduler 不做任何指望,前台化策略兜底。
  • 每个故障模式都要有演练脚本:断网、500、超时、乱序注入跑一遍,比一百条代码评审意见有效。

本章小结

本章把 FieldNote 从「有本地库」推进到「离线优先」:读路径上,SQLite 是权威、Query 缓存只是视图,数据流单向;写路径上,Outbox 与业务写同事务入队,状态机(pending → inflight → done/failed/decayed)保证落盘即持久、重试即安全;拉路径上,serverVersion 游标增量 + 全量对账兜底,应用与推游标同事务;外围用退避抖动、多触发源、同步锁与后台任务把引擎钉进真实网络环境。冲突只是被检测出来了(dirty 相遇、baseVersion 落后、rejected),如何裁决是第 16 章的主题。

一句话记忆点:本地先落盘,出队必幂等;游标随事务走,同步必加锁。

动手练习

  1. 基础巩固:为 computeBackoffMs 写单元测试(可跑在 Node 环境用 vitest/jest),验证三件事——attempt=1..10 时均值大致指数增长、返回值永远 ≤ maxMs、同一 attempt 多次调用结果互不相同(抖动生效)。提示:给函数注入随机源参数以便确定性测试。
  2. FieldNote 实战(一):实现「同实体合并」的完整验证。离线状态下对同一检查项连续改 5 次评分,确认 outbox 中只有 1 条记录且 payload 是第 5 次的终态;联网后确认服务端只收到 1 次推送。再思考:如果 5 次里混入一次 delete(opType 变化),当前合并逻辑有什么隐患?给出你的处理方案。
  3. FieldNote 实战(二):把照片推送拆到独立通道。给 outbox 加 channel 字段(metadata / binary),claimBatch 按通道分别取数,syncEngine 并行跑两条推送链路;验证一张 8MB 照片上传失败重试时,评分元数据照常秒推。提示:两条链路共用同一把同步锁还是各自加锁?想清楚再写。加分项:给 binary 通道实现分片上传与断点续传(会话幂等 + 已确认分片位图持久化),并注入「每传 3 片断网一次」验证恢复后只补缺失分片。
  4. 排查/思考题:为什么 Outbox 入队必须与业务写入在同一个 SQLite 事务里?请构造一个「分两个事务」时崩溃丢数据的具体时序——标出每一步(业务写提交 / 入队前 / 崩溃点 / 重启后),说明哪条数据永远失去了同步机会;再反过来构造「先入队后业务写」时序,说明会产生什么脏推送。完成后对照 15.2 的状态机检查你的推演。

导航

上一章:第 14 章 本地存储与缓存体系 | 下一章:第 16 章 冲突处理与数据一致性

📄 第 16 章 冲突处理与数据一致性 16-conflict-consistency.md

本章导读:第 15 章的同步引擎解决了「数据能不能到」,本章解决「到了以后怎么办」——并发修改、乱序到达与重复重放带来的冲突与一致性收尾。我们将建立「避免 → 检测 → 解决」三层防御体系,掌握五种解决策略的判定准则,用幂等、收敛性测试与事务边界守住最终一致的底线,并以 FieldNote 三个真实冲突案例完成全流程推演,为第 17 章的发布铺平数据正确性基础。

本章学习目标

  • 能精确定义「冲突」:区分同字段并发修改、不同字段并行修改与重复投递三类情形
  • 能运用「避免 → 检测 → 解决」三层防御体系,从建模期就开始压缩冲突面
  • 能按判定准则在 LWW、字段级合并、三路合并、业务规则合并与用户裁决之间选型并实现
  • 能实现服务端幂等去重表与 409 冲突语义的同步契约
  • 能设计收敛性测试,用伪随机双端操作序列验收最终一致性
  • 能说明版本向量的并发判定原理与 CRDT 三性质,并论证什么场景不该用 CRDT
  • 能处理冲突解决后的 dirty 清理、版本推进、事务顺序与孤儿数据等收尾问题

核心知识点

16.1 冲突从哪来:三类根源与精确定义

原理讲解

离线优先把读写都放在本地,等于允许同一个实体在多个副本上并行演化。冲突由此有三类根源:

  1. 并发写同一实体:A、B 两端都在「见过同一个版本」之后各自修改。用版本区间说:A 和 B 都基于 serverVersion = 7 修改,随后各自产生了一条「基于 7 的新版本」,服务端只能收下一条——另一条就成了冲突。
  2. 变更乱序到达:重试、多端并发推送、网络重排,使变更到达服务端的顺序 ≠ 实际发生的顺序。典型表现是「回退假象」:用户先把状态从 draft 改成 submitted,再改成 approved,两条推送因重试乱序,服务端先收到 approved 再收到 submitted。
  3. 重放与重复:超时重试让同一操作多次到达(第 15 章的幂等键已解决大部分),但重放叠加乱序会放大前两类问题——重复的 DELETE 与重复的 UPDATE 交织时,结果取决于去重是否严格按幂等键落地。

「冲突」需要一个精确定义,否则讨论会失去焦点。字段级写写冲突(write-write conflict):同一字段被两个副本在未交换版本的区间内都修改——base = v7 时,A 改了 score,B 也改了 score。并行修改(parallel modification):并发发生,但修改的字段集合不相交——A 改 score,B 改 note。后者多数情况下可以自动合并,甚至不该叫冲突;真正的裁决压力只在前者。⚠️ 还有一类容易漏判的:读-写依赖冲突——A 基于某字段的旧值做了业务决策(「看到库存 10 件,下单 3 件」),B 在此期间改掉了这个值。这类冲突无法靠字段比对发现,只能靠业务规则或服务端校验兜住,FieldNote 里最典型的是任务状态机(16.3 业务规则合并)。

类型 触发条件 后果 处理层次 FieldNote 例子
同字段并发写 双端基于同一版本改同一字段 必有一方被覆盖或需人工裁决 检测 + 解决 双人同时给 ci-1 打分
不同字段并行写 并发但字段集合不相交 理论上无损 避免层即可吸收,自动合并 本地补照片/备注,后台改任务日期
读-写依赖 一端基于另一端旧值做决策 业务规则被击穿 检测 + 业务规则 本地把已 approved 的任务改回 draft
重复投递 超时重试、多次到达 脏数据(不是冲突) 幂等键去重 同一次评分推送到达两次
乱序到达 重试/并发推送 回退假象 因果序(FIFO)+ 版本单调 后提交的旧操作后到

💡 一个工程直觉:重复投递用幂等吸收,乱序到达用单调版本吸收,真正的冲突只剩「同字段并发写」这一小撮。把前两类处理干净,冲突处理就从「大数据问题」缩水成「小概率业务问题」——这正是本章后续所有机制的分工地图。

16.2 三层防御:避免、检测、解决

原理讲解

成熟的冲突处理不是写一个万能合并函数,而是三层设防,让冲突在越早的层被化解,代价越低:

┌────────────────────── 第一层:避免(设计期,建模时) ──────────────────────┐
│  单写者原则:检查项按任务分派单人,天然无并发写                              │
│  字段拆分:score / note 分列存储,别把整个检查项塞进一个大 JSON 字段          │
│  实体拆分:照片独立成表,与任务/检查项的修改天然不相交                        │
│  分片分配:storeId 隔离各自数据、sortKey 浮点段位避免排序冲突                │
└───────────────────────────────▽────────────────────────────────────────────┘
                     漏网的并发写进入下一层
┌────────────────────── 第二层:检测(运行期,推送/拉取时) ──────────────────┐
│  推送带 baseVersion(本地最后见过的 serverVersion)→ 服务端比对当前版本       │
│  拉取应用前查 dirty → 本地未推送变更与服务端变更相遇,进冲突队列              │
│  幂等键去重 → 重复投递在这里被吸收,不升级为冲突                             │
└───────────────────────────────▽────────────────────────────────────────────┘
                     检测确认的「同字段并发写」进入下一层
┌────────────────────── 第三层:解决(裁决期,冲突已发生) ───────────────────┐
│  自动:LWW(服务端序)/ 字段级合并 / 三路合并 / 业务规则合并(16.3)          │
│  人工:冲突队列 + 裁决 UI + 超时兜底默认策略                                 │
└─────────────────────────────────────────────────────────────────────────────┘

避免层最便宜也最容易被忽视。FieldNote 的检查项按任务分派单人(单写者),双人评分是业务明确允许的例外,所以冲突面被压到「单人任务内的 score 字段」这一小块;照片独立成表(第 14 章设计),「补照片」永远不会和「改任务状态」在同一实体上相遇;sortKey 用浮点分数(第 14 章),两端各插一条排序项只是各自占用一段小数区间,不需要重排整个列表。

检测层的核心机制是「带 baseVersion 的推送」——它是 HTTP 条件请求 If-Match / ETag 思想在同步协议里的对应物:客户端声明「我基于哪个版本做的修改」,服务端拿它与当前版本比对,落后即并发。检测必须基于服务端序:serverVersion 由服务端分配、单调递增,客户端只许存储转发,不许自己生成——否则检测本身就成了新的冲突源。

代码示例:推送操作携带 baseVersion,服务端做第一道裁决。

// 服务端:push 处理器中的冲突检测(与第 15 章 mock server 呼应)
function adjudicate(op: PushOperation): PushResult {
  const current = tableOf[op.entity].get(op.entityId);

  // 重复投递:幂等键命中,直接返回首次结果(不是冲突)
  const seen = idempotency.get(op.idempotencyKey);
  if (seen) return { ...seen, status: 'duplicate' };

  // 检测:客户端 baseVersion 落后于服务端当前版本 → 有人先改过
  if (op.opType === 'upsert' && current && current.serverVersion > op.baseVersion) {
    return resolveConflict(op, current); // 进入第三层(16.3)
  }
  // 无并发:正常落库,分配新版本号
  return applyWrite(op);
}

逐段讲解:检测的判据只有一行——current.serverVersion > op.baseVersion。它回答的不是「谁的修改更好」,而是「在我修改期间,世界变过没有」。变了,才需要第三层出场;没变,哪怕两端相隔一周先后修改,也依次落库无冲突。这就是版本号对比优于时间戳对比的根本原因:它度量的是因果区间,不是物理时间。检测层放行的冲突,交给 16.3 的策略工具箱。

16.3 解决策略工具箱:五种策略,各自适配什么

原理讲解:每种策略给判定准则 + 代码片段,按「自动化程度从高到低、适用面从宽到窄」排列。

策略一:Last-Write-Wins(LWW,服务端序)

判定准则:字段无内在可合并语义(布尔开关、单选状态)、丢失一次写代价可接受、修改频率低。

// 服务端 LWW:以服务端接收序为准——后到达者分配更大的 serverVersion,自然胜出
function applyWrite(op: PushOperation): PushResult {
  const row = { ...JSON.parse(op.payload), serverVersion: nextVersion() };
  tableOf[op.entity].set(row.id, row);
  return { idempotencyKey: op.idempotencyKey, status: 'ok',
    serverVersion: row.serverVersion };
}

⚠️ LWW 的时钟陷阱:不要用 updatedAt(客户端时钟)比大小。两端时钟偏移可达分钟级,离线设备可能偏移小时级——「后发生」的修改会带一个「更早」的时间戳,LWW 就会反直觉地让旧值胜出,且这类 bug 永远无法在时钟正确的开发机上复现。唯一可信的序是服务端接收序(单调 serverVersion),客户端时间只用于展示「修改于几点几分」。

策略二:字段级合并

判定准则:实体字段多、双端修改的字段大概率不相交。双方改不同字段各自保留,同字段退化给字段级裁决器。

// 通用字段级合并:base 是共同祖先(可为 null),mine 是本地,theirs 是服务端
type Resolver<T> = (mine: T, theirs: T, base?: T) => T;

export function mergeFields<T extends Record<string, unknown>>(
  base: T | null,
  mine: T,
  theirs: T,
  resolvers: { [K in keyof T]?: Resolver<T[K]> } = {},
): T {
  const merged = { ...theirs };                       // 缺省取服务端
  const keys = new Set([...Object.keys(mine), ...Object.keys(theirs)]);
  for (const k of keys) {
    const m = mine[k], t = theirs[k], b = base?.[k];
    const mineChanged = !eq(m, b);                    // 与 base 比,不是互相比!
    const theirsChanged = !eq(t, b);
    if (mineChanged && theirsChanged) {
      merged[k] = resolvers[k] ? resolvers[k]!(m, t, b) : theirs[k];
    } else if (mineChanged) {
      merged[k] = m;                                  // 只有我改了 → 保留我的
    }
    // 只有对方改了:merged 已是 theirs[k],无需动
  }
  return merged;
}

关键在「与 base 比」:直接互相比较 mine.score !== theirs.score 无法区分「对方改了」和「对方从来没动过这个字段」。三值比较(base/mine/theirs)才能精确知道每个字段被谁动过。

策略三:三路合并(three-way merge)

判定准则:同字段双方都改、但改动发生在不同「位置」(长文本、结构化列表)。它是字段级合并的深化:以 base 为锚点,把两边的改动块拼到一起。

base  (v7)    : score = 3    note = ""          status = "submitted"
mine  (本地)  : score = 5    note = "墙面破损"   status = "submitted"
theirs (服务端): score = 3    note = "墙面破损"   status = "approved"

逐字段三路判定(都和 base 比):
  score : mine≠base, theirs=base → 取 mine     → 5
  note  : mine≠base, theirs=base → 取 mine     → "墙面破损"
  status: theirs≠base, mine=base → 取 theirs   → "approved"
─────────────────────────────────────────────────────────
merged (v9)   : score = 5    note = "墙面破损"   status = "approved"

文本三路合并(diff3 思想:行级/字符级 diff 后拼接非重叠改动块)在移动端的实现成本与包体积需要评估,社区有现成实现可选(以官方文档为准),本章不展开细节。结构化合并对 FieldNote 更重要:列表排序用浮点 sortKey 后,两端各插一条新项只是各占一段小数区间,三路合并退化为「并集 + 各自保留 sortKey」——这是第 14 章埋下的设计红利。

策略四:业务规则合并

判定准则:字段有业务语义,纯技术合并会产出「合法但错误」的值。FieldNote 两条核心规则:

// 规则一:双人评分——后提交方生效(业务上复核发生在后),双方值进历史
// checkItems 增加 scoreHistory 列(迁移见 FieldNote 实战案例一)
function resolveScoreConflict(
  current: CheckItem,          // 先提交者的落库值
  incoming: { score: number }, // 后提交者(正在推送)
): CheckItem {
  const history = JSON.parse(current.scoreHistory ?? '[]');
  history.push({ score: current.score, at: current.serverVersion });
  return { ...current, score: incoming.score, scoreHistory: JSON.stringify(history) };
}

// 规则二:任务状态机只进不退——非法转移由服务端直接拒绝,不存在「合并」
const ALLOWED: Record<TaskStatus, TaskStatus[]> = {
  draft:     ['submitted'],
  submitted: ['approved', 'rejected'],
  approved:  ['archived'],   // approved → draft 不在表内 = 非法回退
  rejected:  ['submitted'],
  archived:  [],
};
export function canTransition(from: TaskStatus, to: TaskStatus): boolean {
  return ALLOWED[from]?.includes(to) ?? false;
}

为什么状态机不能合并?因为「合并非法转移」等于把业务漏洞写进协议:已 approved 的任务被客户端误改回 draft,合并的最好结果也只是把错误状态合法化。服务端权威裁决(拒绝 + 下发当前状态)才是正确答案——这正是 FieldNote 实战案例三。

策略五:用户裁决

判定准则:自动策略都可能出错、且错误代价高(删除、金额、评语)。做法是冲突入队、UI 呈现双方值、用户选择,必须有超时兜底(见陷阱表)。

// 冲突裁决:应用胜出值 + 清 dirty + 推进版本号,三件事同一事务原子完成
export async function adjudicate(
  conflictId: string, choice: 'mine' | 'theirs',
): Promise<void> {
  await db.withTransaction(async tx => {
    const c = await getConflict(tx, conflictId);
    const winner = choice === 'mine' ? c.mineJson : c.theirsJson;
    upsertInTx(tx, c.entity, JSON.parse(winner), c.currentVersion);
    clearDirtyInTx(tx, c.entity, c.entityId);      // ⚠️ 忘清 dirty = 循环推送
    tx.execute(`DELETE FROM conflicts WHERE id = ?`, [conflictId]);
  });
  queryClient.invalidateQueries();
}

策略选择业务对照表:

数据 / 字段 推荐策略 理由
任务状态 status 业务规则(状态机 + 服务端裁决) 转移合法性是业务语义,LWW/合并都会合法化错误
检查项评分 score 业务规则(后提交生效 + 历史保留) 双人评分是业务允许的并发,需留痕
检查项备注 note 字段级合并(含三路) 长文本、双人补写概率低,不交即不冲突
照片集合 集合并集 + 墓碑 独立实体,天然无字段冲突
列表排序 sortKey 三路合并 + 浮点插段 并发插入只占小数区间
全局配置项 LWW(服务端序) 单值开关,丢失一次写可接受
金额 / 删除类操作 用户裁决或服务端拒绝 错误代价最高,不交给自动化

16.4 一致性保障机制:幂等、收敛、事务边界与孤儿

原理讲解

幂等性深入。第 15 章约定了幂等键,现在补全服务端实现:一张去重表 + 事务内的「先占位、后执行」。

CREATE TABLE idempotency_keys (
  key           TEXT PRIMARY KEY,     -- 客户端 idempotencyKey
  request_hash  TEXT NOT NULL,        -- 请求指纹:同 key 不同 payload 视为客户端 bug
  response_json TEXT NOT NULL,        -- 首次执行的响应,命中时原样返回
  created_at    INTEGER NOT NULL      -- TTL 清理:超过重试窗口(如 7 天)删除
);
// 服务端幂等执行骨架(事务内)
async function executeIdempotent(op: PushOperation): Promise<PushResult> {
  const fingerprint = hash(op.entity + op.entityId + op.payload);
  const inserted = await db.run(
    `INSERT INTO idempotency_keys (key, request_hash, response_json, created_at)
     VALUES (?, ?, ?, ?)
     ON CONFLICT(key) DO NOTHING`,
    [op.idempotencyKey, fingerprint, '/* 占位 */', Date.now()],
  );
  if (!inserted.changes) {
    const prev = await db.get(
      `SELECT request_hash, response_json FROM idempotency_keys WHERE key = ?`,
      [op.idempotencyKey],
    );
    if (prev.request_hash !== fingerprint) {
      return { idempotencyKey: op.idempotencyKey, status: 'rejected',
        reason: 'IDEMPOTENCY_KEY_REUSED' };  // 同键不同内容:客户端 bug,拒绝
    }
    return JSON.parse(prev.response_json);   // 命中:原样返回首次结果
  }
  const result = await doApply(op);          // 真正执行业务写入
  await db.run(`UPDATE idempotency_keys SET response_json = ? WHERE key = ?`,
    [JSON.stringify(result), op.idempotencyKey]);
  return result;
}

幂等的边界要诚实:不是所有操作都能靠键去重。「上传一张图」幂等(同 key 同图覆盖);「库存减一」不幂等(减两次就是减二),必须改写成条件更新(UPDATE stock SET n = n - 3 WHERE id = ? AND n >= 3)让副作用本身只发生一次;「发送通知」天然不幂等,要么接受重复,要么给通知也配业务键。设计同步协议时先问每个操作:重放一次,世界会不会变坏?

收敛性验收。最终一致的「最终」必须可验证:双端各自执行任意操作序列、经过任意多轮同步后,忽略 serverVersion 与 dirty,逐实体逐字段相等。验证方法是收敛性测试——种子化伪随机、双端随机操作 100 轮、穿插故障、最后全量同步并 diff(完整思路见 FieldNote 实战末尾)。它一次写好,之后每次改协议都跑一遍,是同步引擎的回归保险。

事务边界与外键一致性。拉取应用变更时,实体之间有引用关系:checkItems.taskId → tasks.id、photos.checkItemId → checkItems.id。若同一批变更里父记录与子记录分属不同事务,应用途中崩溃就会留下悬空引用。规则:同一事务内先父后子(tasks → checkItems → photos),跨实体游标在同一事务里推进。

孤儿数据处理。服务端删除父实体后,客户端拉到墓碑,必须级联清理本地子实体,并顺手清理 Outbox 中针对这些子实体的 pending 推送——否则客户端会去推送一条父已不存在的记录,换来 rejected,白白制造 decayed。

function applyChangeInTx(tx: Tx, change: PullChange): void {
  if (change.data === null) {
    // 墓碑:级联删除 + 清理指向它的待推送记录(防孤儿推送)
    if (change.entity === 'tasks') {
      tx.execute(`DELETE FROM checkItems WHERE taskId = ?`, [change.id]);
      tx.execute(`DELETE FROM photos WHERE checkItemId IN
                    (SELECT id FROM checkItems WHERE taskId = ?)`, [change.id]);
      tx.execute(`DELETE FROM outbox
                   WHERE entity = 'checkItems' AND entityId IN
                     (SELECT id FROM checkItems WHERE taskId = ?)`, [change.id]);
    }
    tx.execute(`DELETE FROM ${change.entity} WHERE id = ?`, [change.id]);
    return;
  }
  /* 同 15.3:先查 dirty,非 dirty 才 upsert */
}

⚠️ 顺序有讲究:先清 outbox(用子查询定位)再删子表,反过来子表先空,清理 outbox 的子查询就查不到了。

16.5 CRDT 与版本向量:开阔视野,也守住边界

原理讲解

**版本向量(version vector)**回答一个问题:两个副本的修改是「一前一后」还是「互不知情的并发」?每个副本维护 (replicaId → counter) 的向量,每次本地修改把自己的 counter 加一,同步时合并双方向量(逐分量取 max)。比较两个向量:V1 所有分量 ≤ V2 且至少一个严格小于 → V1 发生在 V2 之前;各有分量大于对方 → 不可比,即并发——这就是冲突检测的数学基础。FieldNote 的 serverVersion 单调号其实是版本向量的退化形态:存在中心服务端时,所有副本的序被坍缩进一个全局序号,检测从「向量比较」简化为「整数比较」——这正是服务端权威架构送的第一份红利。

**CRDT(Conflict-free Replicated Data Type)**是一类满足三条性质的数据结构:交换(merge(a,b) = merge(b,a),到达顺序无关)、结合(merge(merge(a,b),c) = merge(a,merge(b,c)),分组无关)、幂等(merge(a,a) = a,重复同步无关)。三条性质合起来:副本间不需要任何协调,只要最终交换过全部更新,就必然收敛到同一个状态。例子:G-Counter(各副本只增自己的分量,读时求和)、OR-Set(集合并 + 添加唯一位点标记 + 墓碑删除)、LWW-Register(带时间戳的寄存器——注意它的「last」需要一个全局可比的序,又绕回了时钟问题,工程上常用混合逻辑时钟,此处点到为止)。文本 CRDT(Yjs / RGA 类)为每个字符分配唯一且有序的位置标识,实现离线协作编辑的字符级合并,在移动端可行,但内存占用、大文档性能与序列化体积都要按场景压测。

诚实的边界——什么场景不该用 CRDT:其一,服务端权威业务。CRDT 保证「数学上收敛」,不保证「业务上合法」——审批流被自动合并出一个从未被批准过的状态,收敛了也是事故;权限、审计、考核类数据必须由服务端裁决。其二,强一致事务。CRDT 给的是最终一致,跨实体不变量(「检查项总数不得超过 50」)无法仅靠 merge 维持。其三,需要可解释的裁决。CRDT 的合并是黑盒,「为什么变成这个值」很难向业务方解释;业务规则合并每一步都可审计。

FieldNote 不用 CRDT 的 ADR(架构决策记录):

# ADR-007:FieldNote 采用「服务端裁决 + 业务规则合并」,不引入 CRDT

背景:巡检评分与任务状态机均为强业务语义字段;服务端是考核与审计权威;
预估冲突频率低(双人同巡同店同项为小概率事件);团队规模小(2 后端 + 2 移动端)。

决策:检测用 baseVersion + 服务端版本比对(16.2);解决用业务规则
(后提交生效、状态机只进不退、字段级合并)+ 极少量人工裁决队列。

理由:
1. 服务端权威性:CRDT 收敛 ≠ 业务合法,状态机非法转移必须由服务端拒绝;
2. 冲突频率低:为低频事件引入副本 ID、计数器向量与墓碑 GC 的维护成本不划算;
3. 团队规模:CRDT 调试门槛高,出现「为什么收敛成这个值」时无人兜底。

后果:服务端承担约 200 行裁决逻辑;字段级并发检测需要字段版本信息;
复议条件:出现跨端协作编辑(巡检报告共创)或点对点同步(无服务端场景)需求时重评。

16.6 服务端契约设计:把本章一切写进接口

原理讲解:契约是第 15 章引擎与本章裁决逻辑的接缝,字段一一给出语义,避免「联调靠猜」。

// ---- 拉取 ----
export interface PullRequest {
  userId: string;
  cursors: Partial<Record<Entity, number>>; // 每类实体独立游标(第 14 章 SyncCursor)
  limit?: number;                            // 服务端会二次钳制
}
export interface PullChange {
  entity: Entity;
  id: string;
  serverVersion: number;                  // 该变更的版本号,应用后本地保存
  data: Record<string, unknown> | null;   // null = 墓碑(服务端已删除)
}
export interface PullResponse {
  changes: PullChange[];                  // 升序返回;乱序注入用于测试客户端健壮性
  nextCursors: Partial<Record<Entity, number>>;
  hasMore: boolean;
}
PullResponse 字段 语义 客户端义务
changes[].data = null 服务端已删除(墓碑) 级联删本地 + 清理相关 outbox(16.4)
nextCursors 每实体推进到的新游标 与变更应用绑同一事务写入
hasMore 是否还有未拉完的页 循环拉取直到 false,页间 yield
// ---- 推送 ----
export interface PushOperation {
  opType: 'upsert' | 'delete';
  entity: Entity;
  entityId: string;
  baseVersion: number;      // 本地最后见过的服务端版本(冲突检测依据)
  payload: string;          // 最终态快照 JSON
  idempotencyKey: string;   // 逻辑操作唯一键(幂等去重)
}
export type PushResultStatus = 'ok' | 'duplicate' | 'conflict' | 'rejected';
export interface PushResult {
  idempotencyKey: string;
  status: PushResultStatus;
  serverVersion?: number;   // ok / duplicate:落库后的新版本
  conflict?: {              // status = conflict 时必填
    current: Record<string, unknown>;    // 服务端当前值
    currentVersion: number;
    merged?: Record<string, unknown>;    // 服务端已自动合并时直接给结果
  };
  reason?: string;          // rejected 的业务原因码(如 ILLEGAL_TRANSITION)
}
export interface PushResponse { results: PushResult[]; }
PushResult.status 语义 客户端动作(第 15 章已实现)
ok 首次执行成功 删 outbox + 清 dirty + 写回 serverVersion
duplicate 幂等命中,此前已成功 同 ok(幂等键重放的安全网)
conflict 服务端检出并发写,已按规则裁决 应用 conflict.merged(或 current),清 dirty
rejected 业务校验拒绝,重试无意义 转 decayed / 按原因码丢弃本地变更

错误码规范:批推送接口用 200 + 逐条 status(一批里可以成败混合);HTTP 状态码保留给协议层——200 成功、409 语义冲突(单条 REST 端点或整批无法处理时的兜底,body 内必带冲突详情)、410 Gone 引用的父实体已删除、422 请求结构/校验错误、429 限流(配合客户端退避)、5xx 可重试服务端错误。版本号分配规则:只由服务端在落库事务内分配,全局单调(FieldNote 用单一计数器,游标协议因此可以跨实体比较;若未来分片部署,可退化为按实体流单调 + 游标按实体独立,协议不变);客户端上报的任何版本号一律忽略。

典型业务场景:FieldNote 实战(里程碑 M16:冲突全流程推演)

三个真实冲突案例,每个都按「场景时序 → 检测过程 → 采用策略 → 代码 → 验证」推演。案例一需要一次存储迁移:

-- 迁移:检查项增加评分历史(JSON 数组),双人评分留痕展示用
ALTER TABLE checkItems ADD COLUMN scoreHistory TEXT NOT NULL DEFAULT '[]';

案例一:双人同时改同一检查项评分(同字段冲突 → 业务规则)

场景时序:巡检员 A 与质检 B 都在 serverVersion = 7 时看到 ci-1 的 score = 3;B 在线先把 3 改成 4 提交(服务端落库 v8);A 离线把 3 改成 5,十分钟后联网推送(baseVersion = 7)。

检测过程:服务端 current.serverVersion(8) > baseVersion(7) → 检出并发;比对变更字段集,双方改的都是 score → 同字段冲突,进入业务裁决。

采用策略:后提交方生效 + 双方历史保留——业务上 A 是复核角色、复核发生在后,让 A 的值生效符合流程;同时把 B 的值压进 scoreHistory,UI 在详情页展示「当前 5 分,历史:4 分(质检)」。

// 服务端:同字段冲突裁决
function resolveConflict(op: PushOperation, current: CheckItem): PushResult {
  const incoming = JSON.parse(op.payload);
  if (op.entity === 'checkItems' && incoming.score != null) {
    const merged = resolveScoreConflict(current, incoming); // 16.3 规则一
    merged.serverVersion = nextVersion();
    tableOf.checkItems.set(merged.id, merged);
    return { idempotencyKey: op.idempotencyKey, status: 'conflict',
      serverVersion: merged.serverVersion,
      conflict: { current: strip(current), currentVersion: current.serverVersion,
        merged: strip(merged) } };
  }
  /* 其他实体的裁决分支(案例二、三) */
}

// 客户端:应用裁决结果(第 15 章 pushPhase 的 conflict 分支实现)
async function applyMergedConflict(
  row: OutboxRow, result: PushResult,
): Promise<void> {
  const { merged, currentVersion } = result.conflict!;
  await db.withTransaction(async tx => {
    upsertInTx(tx, row.entity, merged!, currentVersion); // 服务端结果即权威
    clearDirtyInTx(tx, row.entity, row.entityId);        // 不清 = 循环推送
  });
  queryClient.invalidateQueries({ queryKey: ['checkItems'] });
}

验证:FAULT=concurrent node server/mock-server.js,离线改 ci-1 评分后联网,日志应出现 push 1 row → conflict → apply merged v9,详情页展示双方评分历史;再次启动 App 确认 outbox 为空、dirty 全零(无循环推送)。

案例二:离线补照片与后台改期并行(不同字段 → 自动合并)

场景时序:A 离线巡检,给 task-1 补拍一张照片(photos 新记录,独立实体),并把 task-1 从 draft 提交为 submitted(changedFields = ['status']);与此同时后台调度在线把 task-1 的 planDate 改到下周(v9,changedFields = ['planDate'])。

检测过程:A 联网推送 task-1(baseVersion = 7 < 9)→ 检出并发;但字段集合 {status} 与服务端已改字段 {planDate} 不相交 → 自动合并,不升级为冲突。补拍的照片是独立实体,推送时 baseVersion 比对直接通过,根本不进裁决。

采用策略:字段级自动合并——客户端改的字段取客户端,服务端改的字段保留服务端,合并产物分配新版本。

// 服务端:字段级并发判定(需要字段级版本信息,示意用行内简化实现)
function mergeParallelWrite(op: PushOperation, current: Task): PushResult {
  const changedFields: string[] = op.changedFields ?? [];      // 客户端声明改动字段
  const serverTouched: string[] = current.touchedFields ?? []; // 服务端记录的近期改动
  const overlap = changedFields.filter(f => serverTouched.includes(f));
  if (overlap.length === 0) {
    const merged = { ...current,
      ...pick(JSON.parse(op.payload), changedFields),
      serverVersion: nextVersion(),
      touchedFields: [...serverTouched, ...changedFields] };
    tableOf.tasks.set(merged.id, merged);
    return { idempotencyKey: op.idempotencyKey, status: 'conflict',
      serverVersion: merged.serverVersion,
      conflict: { current: strip(current), currentVersion: current.serverVersion,
        merged: strip(merged) } };
  }
  return resolveConflict(op, current); // 字段相交 → 案例一的裁决路径
}

💡 这里能看清「避免层」的回报:照片独立成表(第 14 章)让本案一半的改动天然免疫冲突;payload 带上 changedFields 声明(快照语义的补充),让服务端能做字段级判定而不是整行覆盖——整行覆盖会把服务端的 planDate 一起冲掉,那才是真事故。

验证:离线补照片 + 提交任务,curl http://127.0.0.1:8787/debug/state 确认服务端 task-1 的 status = submitted 且 planDate 是后台改的新值,photos 多一条;客户端拉取后 UI 状态与日期都正确。

案例三:任务状态机回退冲突(非法转移 → 服务端拒绝,客户端丢弃本地)

场景时序:服务端已把 task-1 置为 approved(v10);巡检员 A 的手机离线,因 UI 误操作把 task-1 改回 draft(本地 dirty = 1,outbox 入队);联网后推送(baseVersion = 7)。

检测过程:10 > 7 检出并发;进一步做状态机校验:canTransition('approved', 'draft') = false → 非法转移。

采用策略:服务端权威拒绝,客户端丢弃本地改动作废。注意这不是合并:合并非法转移等于把漏洞写进协议。客户端动作是把服务端当前状态应用为权威、清 dirty、给用户一条明确提示。

// 服务端:状态机校验分支(在 resolveConflict 之前)
if (op.entity === 'tasks' && op.opType === 'upsert') {
  const next = JSON.parse(op.payload).status;
  if (next != null && current && !canTransition(current.status, next)) {
    return { idempotencyKey: op.idempotencyKey, status: 'rejected',
      reason: 'ILLEGAL_TRANSITION',
      serverVersion: current.serverVersion, current: strip(current) };
  }
}

// 客户端:rejected 且原因码为 ILLEGAL_TRANSITION 时的处理
case 'rejected':
  if (result.reason === 'ILLEGAL_TRANSITION') {
    await db.withTransaction(async tx => {
      upsertInTx(tx, row.entity, result.current!, result.serverVersion!);
      clearDirtyInTx(tx, row.entity, row.entityId); // 作废本地,不是重试
    });
    toast('本地改动与最新审批状态冲突,已同步服务端最新版本');
  } else {
    await outbox.markDecayed(row); // 其他业务拒绝:隔离待查
  }
  break;

验证:离线把 approved 任务改回 draft,联网推送,日志应出现 rejected:ILLEGAL_TRANSITION → discard local, apply v10;重新进详情页,状态为 approved,本地无待同步项。若把 canTransition 临时改成恒真,本例会变成一条合法推送——反向验证了裁决位置必须留在服务端。

Mock Server 并发修改注入的验证代码:第 15 章 mock 已有 FAULT=concurrent(推送前模拟他人抬高版本)。补一个针对同字段的变体,放在 handlePush 的注入分支里:

// server/mock-server.js 追加:强制同字段冲突(验证案例一)
// 用法:x-fault: concurrent-score —— push 处理前,他人先改同一检查项的 score
if (fault === 'concurrent-score' && op.entity === 'checkItems') {
  const row = tableOf.checkItems.get(op.entityId);
  if (row) {
    row.score = (row.score ?? 3) === 5 ? 4 : 5; // 改成与客户端不同的值
    row.scoreHistory = JSON.stringify([
      ...JSON.parse(row.scoreHistory ?? '[]'),
      { score: row.score, at: row.serverVersion },
    ]);
    row.serverVersion = nextVersion();          // 抬高版本 → 必然检出并发
  }
}
# 验证脚本:三次推送分别对应三个案例
curl -X POST http://127.0.0.1:8787/sync/push -H "Content-Type: application/json" \
  -H "x-fault: concurrent-score" \
  -d '{"operations":[{"opType":"upsert","entity":"checkItems","entityId":"ci-1",
       "baseVersion":7,"payload":"{\"id\":\"ci-1\",\"score\":5,\"note\":\"\"}",
       "idempotencyKey":"<YOUR_TEST_KEY_1>"}]}'
# 预期:status=conflict,conflict.merged.score 为裁决值,scoreHistory 含先提交方分值

收敛性测试思路(伪随机双端 100 轮后 diff 相等):

// server/convergence.test.ts(思路示意,Node 环境直连 mock server 内存结构)
// 核心:同一颗种子可复现;两端操作序列互相不可见;最终必须收敛
function convergenceOnce(seed: number) {
  const rnd = mulberry32(seed);
  const A = new FakeClient('A', deepClone(seedDb)), B = new FakeClient('B', deepClone(seedDb));
  for (let round = 0; round < 100; round++) {
    const c = rnd() < 0.5 ? A : B;
    // 随机业务操作:改评分 / 改备注 / 提交或驳回任务 / 加照片
    c.randomOp(rnd);
    // 随机穿插同步动作与故障:只 push、只 pull、超时重试(幂等键复用)、乱序
    if (rnd() < 0.3) server.pushFrom(c, /* fault */ pickFault(rnd));
    if (rnd() < 0.3) server.pullTo(c);
  }
  // 收官:两端全量同步至服务端真相
  server.pushFrom(A); server.pushFrom(B); server.pullTo(A); server.pullTo(B); server.pullTo(A);
  // 断言:忽略 serverVersion / dirty / scoreHistory 的顺序差异,逐实体逐字段相等
  expect(snapshot(A.db)).toEqual(snapshot(B.db));
}
for (const seed of Array.from({ length: 20 }, (_, i) => i)) convergenceOnce(seed);

三点提醒:操作生成器只允许真实合法的业务动作(非法转移不生成,那是案例三的裁决路径,不该靠收敛测试发现);快照比较要剔除同步元数据;一次不过时用种子精确定位失败序列——这也是把随机做成「伪随机」的全部意义。

常见陷阱

陷阱 典型症状 根因 正确做法
用 updatedAt 做 LWW 时钟偏慢的手机后改先输,旧数据覆盖新数据 客户端时钟偏移分钟到小时级,「时间最新」不代表「发生最晚」 LWW 只用服务端接收序(单调 serverVersion),客户端时间仅展示
把「无冲突」建立在「用户不会同时操作」的假设上 上线首周即出现双人评分互冲、数据静默丢失 巡检高峰双人巡同店是常态;离线放大了时间窗 按冲突是常态设计:避免层压面、检测层常开、解决层有预案
冲突解决后忘记清 dirty 同一条记录反复推送、反复收到 conflict,同步永不收敛 裁决只应用了胜出值,没重置「本地有未推送变更」标记 应用胜出值 + 清 dirty + 推进版本号三件事绑同一事务(16.3 adjudicate)
幂等键每次重试重新生成 服务端出现重复记录、duplicate 永远不出现 键在请求层生成而非入队层持久化,重试被当成新逻辑操作 键入队时生成并随 outbox 落盘,重试、重启都复用(15.2)
合并后版本号沿用旧值 合并结果被下一轮 pull 当作旧变更再次覆盖,或游标漏推 客户端合并后直接沿用 baseVersion 写回,服务端也不知道新版本存在 合并产物必须由服务端分配新 serverVersion,客户端只存不造
人工裁决 UI 无超时兜底 冲突队列堆积,相关实体一直「待处理」,用户流失后彻底无人处理 把人工裁决当成了必经同步步骤,同步被裁决阻塞 裁决异步化:同步先按默认策略落地,人工 UI 可事后改判;队列条目带过期自动采用默认值
幂等去重表无限膨胀 服务端存储持续增长、查询变慢 只写不清,键的量随历史操作线性增长 去重表带 TTL(超过最大重试窗口如 7 天清理);客户端退避封顶即 decayed,不会无限重试
客户端自行生成/推进 serverVersion 偶发游标错乱、拉取漏变更,难复现 本地代码把版本号当普通字段写入,或合并后沿用旧值 serverVersion 对客户端只读:只接受服务端下发,落库与清 dirty 同事务

最佳实践

  • 先分层再写码:能靠建模避免的冲突(单写者、字段/实体拆分、sortKey 段位)绝不留给检测层,能自动合并的绝不打扰用户。
  • 冲突的检测依据只有一个:服务端单调 serverVersion 与客户端 baseVersion 的比较;客户端时钟、本地 updatedAt 一律不参与裁决。
  • 推送必带 baseVersion 与 changedFields,服务端才能区分「同字段冲突」与「不同字段并行」,后者直接自动合并。
  • 幂等去重表按「先占位、后执行、命中回放」实现,键冲突但指纹不同要拒绝,而不是静默覆盖。
  • 服务器裁决一律返回 conflict + merged/current,客户端应用裁决与清 dirty 必须同事务,杜绝循环推送。
  • 状态机、审批、权限类字段只认服务端校验,客户端预校验只做体验优化,绝不当安全边界。
  • 收敛性测试作为协议的回归门禁:种子化伪随机、双端百轮、穿插故障、忽略元数据后全量 diff。
  • 拉取应用变更先父后子同事务;处理墓碑时级联清理子表与指向它的 outbox 记录,防孤儿推送。
  • 人工裁决是异步通道不是同步步骤:默认策略先行,事后改判;队列条目必须有超时兜底。
  • 每条同步协议变更都问一遍:重放安全吗(幂等)?乱序安全吗(单调)?并发安全吗(检测)?崩溃安全吗(落盘)?
  • 用 ADR 记录冲突策略决策(含复议条件),半年后接手的人需要知道「为什么不用 CRDT」比「用了什么」更重要。

本章小结

本章把第 15 章检测出的冲突收拢成一个可维护的裁决体系:根源上区分同字段并发、不同字段并行与重复投递,后者两番分别被幂等键与单调版本吸收;防御上「避免 → 检测 → 解决」三层递进,代价随层数递增;解决上五种策略各有判定准则,FieldNote 用业务规则(后提交生效、状态机只进不退)处理同字段冲突,用字段级合并吸收并行修改,用服务端拒绝处置非法转移;一致性的底线由幂等去重表、收敛性测试、先父后子的事务顺序与孤儿清理共同守住;CRDT 提供了无协调收敛的视野,但服务端权威业务里,「收敛」不等于「合法」,ADR 记录了 FieldNote 的取舍。至此 Stage 6 的数据正确性闭环完成,第 17 章开始,这套引擎将随 App 一起走上双端发布、灰度与热更新的流水线。

一句话记忆点:能避免就不检测,能自动就不人工;一切序,以服务端为准。

动手练习

  1. 基础巩固:为 mergeFields 写单元测试,至少覆盖四类用例——双方改不同字段、双方改同一字段(有/无字段裁决器)、一方未动、base 为 null 的首次合并;再补一个「以 base 为锚点」的反例测试:把实现里 !eq(m, b) 换成 m !== t,观察哪些用例由绿变红,用它们说明三值比较的必要性。
  2. FieldNote 实战(一):实现「评分取较严值」策略变体——双人评分冲突时取 min(mine, theirs)(业务假设:从严复核),替换案例一的后提交生效逻辑;跑一遍收敛性测试确认不引入不收敛;再写一条 ADR 片段,论证两种策略分别适配什么考核文化。
  3. FieldNote 实战(二):实现人工裁决 UI。从 conflicts 表读取待裁决项,双列展示 mine/theirs(含字段高亮),用户点选后调用 adjudicate;给队列条目加 48 小时超时——过期自动按 theirs 落地并打标记。验证:制造一条冲突,杀进程重启,确认裁决项仍在、超时逻辑在改系统时间后正确触发(提示:测试时把过期判定注入成可配置时长)。
  4. 排查/思考题:FieldNote 为什么选择「服务端裁决 + 业务规则合并」而不是 CRDT?请从三个维度论证——团队规模(谁能调试收敛异常)、服务端权威性(哪些字段数学收敛会变成业务事故)、冲突频率(低频事件值不值得上向量与墓碑的维护成本);最后回答:出现什么项目条件时,你会推翻这个决定并改用 CRDT 或混合方案?写成一段可放进 ADR 的复议条款。

导航

上一章:第 15 章 离线优先架构与数据同步 | 下一章:第 17 章 应用打包与发布

📄 第 17 章 应用打包与发布 17-packaging-release.md

本章导读:你已把 FieldNote 打磨成一个离线优先、架构清晰的 App(第 13 章 monorepo、第 16 章同步引擎),本章开始解决「怎么把它变成可上架的双端安装包,并安全送到用户手里」。签名、多环境、自动化构建、符号化监控、灰度发布,这五件事构成一次生产发版的完整闭环。第 18 章的热更新与拆包,完全建立在本章的签名、构建号与符号体系之上——本章没做好,热更新就是空中楼阁。

本章学习目标

  • 能从非对称密码学的角度解释 Android 签名机制,说明签名校验在安装与更新链路中的作用
  • 能规划 keystore 的生成、备份与托管策略(含 Play App Signing),说清私钥丢失的后果与补救路径
  • 能解释 AAB 与 APK 的分发差异,为国内商店场景选择正确的出包方式
  • 能配置 R8 混淆,定位「反射库发布后崩溃」的根因并补齐 keep 规则
  • 能用 Gradle flavor 与 build configuration + xcconfig 在双端落地 dev/staging/prod 三环境
  • 能编写双端 fastlane 流水线(版本自增 → 构建 → 符号上传 → 分发),凭据零字面量
  • 能接入 Sentry 类监控的 mapping.txt / dSYM / sourcemap 上传,并建立崩溃率门禁
  • 能设计灰度发布方案:监控项、中止线与远端配置驱动的强制升级

核心知识点

17.1 Android 发布体系:签名、AAB 与 R8

先回答一个本质问题:Android 凭什么确认「这个更新包确实是你的 App 发的」?

答案是非对称密码学。发布时你用私钥对 APK 的内容摘要进行签名;安装时系统用 APK 内嵌证书中的公钥验证签名。验证通过同时证明两件事:包内容自签名以来未被篡改(完整性),签名者持有对应私钥(身份)。v2 签名方案在 APK Signing Block 中记录对整个文件字节流的摘要——改动任何一字节即校验失败;v3 方案增加了密钥轮换证明链(旧私钥签名声明新私钥),让「换钥匙」成为有据可查的操作而不是断崖式断裂。

签名在更新链路中更关键:设备上已安装 com.example.fieldnote,来了一个更新包,系统的第一件事不是解压安装,而是比对新旧包的签名证书是否同源。不一致直接拒绝,抛出 INSTALL_FAILED_UPDATE_INCOMPATIBLE。所以 Android 世界里 App 的真实身份是「包名 + 签名」二元组:包名只是地址,签名才是身份证。

【重点】keystore(私钥)丢失是不可逆灾难:无法给存量用户发任何更新,只能换包名重新上架,等于从零开始。keystore 生命周期管理的核心是「私钥永离单点」:

# 生成发布密钥(有效期长于 App 预期寿命,一般 10000 天以上)
keytool -genkeypair -v \
  -storetype PKCS12 \
  -keystore fieldnote-release.keystore \
  -alias fieldnote-release \
  -keyalg RSA -keysize 2048 -validity 10000
# 密码交互输入,之后立即执行三件事:
# 1. 备份到离线介质,至少两份异地存放
# 2. 登记到团队密钥清单(谁持有、放在哪、如何找回)
# 3. 密码本身只进密码管理器或 CI Secret,绝不进仓库

对 Play 渠道,Google 提供 Play App Signing:由 Google 生成并托管 app signing key(真正打进 APK 的钥匙),开发者只持有 upload key(向 Play 上传的身份凭证)。upload key 丢失可走身份验证后重置,App 本体不受影响——这是把「丢失灾难」降级为「流程事故」的机制设计。⚠️ 国内商店不支持这类托管,keystore 必须自己冷备,双保险缺一不可。

versionCode 是系统判断「能不能更新」的整数(必须大于已装版本才可覆盖安装),versionName 是给人看的字符串。生产策略:versionCode 单调递增且永不复用,versionName 走语义化版本,两者用同一 tag 推导:

// android/app/build.gradle(片段)
// 构建号与 iOS 同源:CI 运行号,天然单调、双端不冲突
def ciBuildNumber = (System.getenv("CI_RUN_NUMBER") ?: "1") as Integer
// tag 注入语义化版本:v2.4.1 -> [2, 4, 1]
def ver = (System.getenv("RELEASE_VERSION") ?: "0.0.0")
        .replaceFirst("^v", "").split("\\.")

android {
    defaultConfig {
        applicationId "com.example.fieldnote"
        versionName "${ver[0]}.${ver[1]}.${ver[2]}"
        // 大版本占高位,语义化升级后 code 明显变大;末段给 CI 修正空间
        versionCode ((ver[0] as Integer) * 100000)
                + (ver[1] as Integer) * 1000
                + (ciBuildNumber % 1000)
    }
}

AAB(Android App Bundle)的定位经常被误解:它不是安装格式,是分发中间格式。AAB 交给 Play 后,Play 服务端按每台设备的 ABI、屏幕密度、语言动态生成 split APK 组合下发——设备要什么下什么。APK 是全量自包含安装包。差异如下:

维度 AAB APK
本质 上传给商店的分发描述 直接安装的完整包
用户实际体积 Play 按设备裁剪,显著更小 全量资源进设备
动态交付 支持按需模块(Play Feature Delivery) 不支持
本地分发/国内商店 不接受,需 bundletool 转 universal APK 直接支持
验包调试 间接(bundletool 模拟设备生成 APKs) 直接 adb install

国内商店只收 APK,通用做法是 bundletool build-apks --mode=universal 后抽取安装包,或干脆用 Gradle 直接打 APK。这是本章思考题的伏笔:Google 强推 AAB 是把体积优化收归服务端,而国内分发链路里不存在 Play 这个「按需下发器」。

R8 是 AGP 内置的 shrinker,做三件事:树摇(从入口做可达性分析删除不可达代码)、优化(内联、去虚化)、混淆(重命名为 a.b.c)。问题恰在重命名:RN 的 JNI/JSI 层按类名字符串查找原生模块,Fresco、OkHttp 等库大量使用反射。R8 的可达性分析读不懂字符串引用,会把反射目标改名或删除,运行时直接 ClassNotFoundException——这就是「反射库发布后必炸」的根因。keep 规则的作用是向 R8 声明「这些名字是契约,不许动」:

# android/app/proguard-rules.pro
# RN 0.8x:核心库大多自带 consumer rules(随 AAR 自动生效),
# 以下保留社区长期推荐的全局兜底与自研代码补充
-keep class com.facebook.react.** { *; }      # 桥接/模块注册按名查找
-keep class com.facebook.hermes.** { *; }     # Hermes JNI 入口
-keep class com.facebook.jni.** { *; }        # JNI 基础设施

# 自研原生模块:JS 侧按名字查找的注册类必须保名
-keep class com.example.fieldnote.nativemodules.** { *; }

# 反射型三方库目标(JSON 反序列化按字段名反射)
-keep class com.example.fieldnote.data.model.** { *; }
-keepattributes Signature, InnerClasses, EnclosingMethod

# 混淆映射随构建产出,每次必须归档(见 17.5)
-printmapping build/outputs/mapping/release/mapping.txt

逐段解释:前三条保护的是运行时按名字回调 RN 的入口(模块注册表),删了就是「发布版所有原生模块找不到」;data.model 一组保护被 Gson/Moshi 类框架反射序列化的字段名;-keepattributes Signature 保留泛型签名,否则框架拿不到 List<Task> 的元素类型。第三方库优先相信其自带的 consumer rules,只在崩溃证据指向某库时再补 keep,不要无脑 keep class ** { *; }——那等于关掉混淆。

Gradle flavor 是多环境的原生载体,它把「服务器地址、应用名、包名后缀」变成构建期常量:

// android/app/build.gradle(片段)
android {
    flavorDimensions "env"
    productFlavors {
        dev {
            dimension "env"
            applicationIdSuffix ".dev"      // 三环境可同机共存
            resValue "string", "app_name", "FieldNote Dev"
            buildConfigField "String", "API_BASE_URL", '"https://dev-api.example.com"'
        }
        staging {
            dimension "env"
            applicationIdSuffix ".staging"
            resValue "string", "app_name", "FieldNote Staging"
            buildConfigField "String", "API_BASE_URL", '"https://staging-api.example.com"'
        }
        prod {
            dimension "env"
            resValue "string", "app_name", "FieldNote"
            buildConfigField "String", "API_BASE_URL", '"https://api.example.com"'
        }
    }
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            signingConfig signingConfigs.release
            proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"),
                          "proguard-rules.pro"
        }
    }
}

为什么地址注入选 buildConfigField 而不是 JS 常量?因为最早的请求(崩溃监控初始化、启动打点)发生在 JS bundle 执行之前,原生侧必须先知道「我是谁」。这也正是 17.3 节 react-native-config 存在的意义:把 .env 的值同时注入原生与 JS 两侧,避免两处各写一份。

17.2 iOS 发布体系:签名链、多环境与上架流程

iOS 的签名比 Android 更严苛,因为它是一条信任链:开发者生成密钥对并发起证书签名请求(CSR)→ Apple CA 用其根证书签发开发/分发证书 → codesign 用该证书私钥对 Mach-O 二进制、资源、entitlements 整体签名 → 设备安装时沿「App 签名 → 证书 → Apple 根证书」逐级验证。链条上任何一环缺失或不匹配,安装即失败——这就是「缺一个都装不上」的原因:iOS 不存在「未签名也能侧载」的口子(开发者模式下的本地运行也要经过 Developer Mode 与 profile 校验)。

Provisioning Profile(描述文件) 把三样东西钉在一起:哪张证书可以签、哪个 App ID(Bundle ID + entitlements)、(开发/Ad Hoc 场景下)哪些设备可以装。安装时系统同时校验:签名来自 profile 所列证书、Bundle ID 匹配、entitlements 与 profile 声明一致。证书、profile、entitlements 三者互为条件,缺一或错配都是同一种报错噪音——A valid provisioning profile for this executable was not found。

Profile 类型 证书 设备限制 用途
Development Apple Development ≤100 台/类型 真机调试
Ad Hoc Apple Distribution 注册设备(上限同上) 内部安装包、外包验收
App Store Apple Distribution 无限制 TestFlight 与商店
Enterprise 企业证书 无限制 仅限企业内部分发,政策约束极严

多环境用 build configuration + xcconfig 承载。Release 之外拆出三套 configuration,再配三个 scheme,每个 scheme 绑定一组 Debug/Release configuration:

# ios/Configs/FieldNote.Dev.xcconfig
#include "Pods/Target Support Files/Pods-FieldNote/Pods-FieldNote.debug.xcconfig"
BASE_URL = https://dev-api.example.com
APP_DISPLAY_NAME = FieldNote Dev
PRODUCT_BUNDLE_IDENTIFIER = com.example.fieldnote.dev
SENTRY_ENV = development

# ios/Configs/FieldNote.Staging.xcconfig
#include "Pods/Target Support Files/Pods-FieldNote/Pods-FieldNote.staging.xcconfig"
BASE_URL = https://staging-api.example.com
APP_DISPLAY_NAME = FieldNote Staging
PRODUCT_BUNDLE_IDENTIFIER = com.example.fieldnote.staging
SENTRY_ENV = staging

# ios/Configs/FieldNote.Prod.xcconfig
#include "Pods/Target Support Files/Pods-FieldNote/Pods-FieldNote.release.xcconfig"
BASE_URL = https://api.example.com
APP_DISPLAY_NAME = FieldNote
PRODUCT_BUNDLE_IDENTIFIER = com.example.fieldnote
SENTRY_ENV = production

Info.plist 里用 $(BASE_URL)、$(APP_DISPLAY_NAME) 引用这些变量,构建时由 Xcode 替换为字面量。JS 侧通过 17.3 的 react-native-config 拿到同一份值——双端与 JS 三处同源,而不是三处各配一套。

上架主流程:Archive(Release configuration 打包 xcarchive)→ 上传 App Store Connect → Apple 处理(含符号化)→ TestFlight 分发内测 → 提交审核 → 过审后选择分阶段发布。TestFlight 内测(团队成员)无需审核即可用,外部测试需一次轻量审核;正式提审走完整审核。

隐私方面有两件事过审前必须做:隐私清单(PrivacyInfo.xcprivacy) 声明 required reason API 的使用原因(如 UserDefaults、文件时间戳等系统强制项),以及数据收集类别(App 隐私标签);权限用途文案——iOS 审核对「权限文案含糊」的拒绝率极高,每个 key 都要写清「为什么需要、用来做什么」。FieldNote 用到的对照表:

Info.plist key 触发权限 FieldNote 文案要点
NSCameraUsageDescription 相机 扫描资产码与巡检拍照,明确「仅用于任务拍摄」
NSPhotoLibraryUsageDescription 相册读取 从相册选择现场照片附加到巡检记录
NSPhotoLibraryAddUsageDescription 保存到相册 导出带签名的巡检报告图
NSLocationWhenInUseUsageDescription 定位 记录巡检点位坐标,仅在使用期间获取
NSFaceIDUsageDescription Face ID 解锁本地巡检数据(呼应生物识别解锁功能)
NSBluetoothAlwaysUsageDescription 蓝牙 连接现场 BLE 传感器读数(如启用)

【重点】文案要写成「场景 + 用途」而不是「我们需要权限」。审核员模拟真实使用路径时,申请时机也应滞后到用户触达功能那一刻,而不是启动即弹。

17.3 多环境配置工程化:react-native-config 与「配置即代码」

react-native-config 的工作原理分两条注入路径:Android 侧由 Gradle 插件在配置阶段读取 ENVFILE 环境变量指定的 .env 文件,把键值写进 BuildConfig 字段;iOS 侧由一个 build phase 脚本把 .env 内容生成为临时 xcconfig 并合并进构建,变量最终落进 Info.plist。JS 侧则通过原生模块暴露一个启动时一次性快照的同步读取接口。两条路径意味着:值是构建期固化的,改环境变量必须重新出包——这既是限制也是安全特性:生产包里的地址不可能被运行时注入篡改。

于是配置要按敏感度分成三类,这是「配置即代码」的核心纪律:

类别 示例 存放位置 进 git?
代码常量 功能开关默认值、UI 文案、主题 token 源码内 是,随评审变更
环境配置 API 地址、Sentry 项目标识、灰度开关地址 .env.<env>(仓库内提交模板与占位,真实值按环境管理) 模板是,真实值视敏感度
机密 keystore 密码、商店 API Key、上传凭据、推送证书 CI Secret / 密钥管理服务 绝不

真实凭据只放 CI Secret 或密钥服务,源码与构建脚本零字面量——本章所有示例遵守这条红线,<YOUR_*> 均为占位:

# .env.dev(提交仓库;真实值可按团队策略管理)
APP_ENV=dev
API_BASE_URL=https://dev-api.example.com
SENTRY_DSN=<YOUR_SENTRY_DSN_DEV>
SENTRY_ENV=development
UPDATE_SERVER_URL=https://updates-dev.example.com
FEATURE_SYNC_V2=true

# .env.staging
APP_ENV=staging
API_BASE_URL=https://staging-api.example.com
SENTRY_DSN=<YOUR_SENTRY_DSN_STAGING>
SENTRY_ENV=staging
UPDATE_SERVER_URL=https://updates-staging.example.com
FEATURE_SYNC_V2=true

# .env.prod
APP_ENV=prod
API_BASE_URL=https://api.example.com
SENTRY_DSN=<YOUR_SENTRY_DSN_PROD>
SENTRY_ENV=production
UPDATE_SERVER_URL=https://updates.example.com
FEATURE_SYNC_V2=false   # 生产默认关,远端配置可开
// android/app/build.gradle:react-native-config 的 Android 注入路径
// project.ext.envConfigFiles 声明 flavor 与 env 文件的映射
project.ext.envConfigFiles = [
    dev:     ".env.dev",
    staging: ".env.staging",
    prod:    ".env.prod",
]
apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"

android {
    defaultConfig {
        // JS 与原生同源:BuildConfig.API_BASE_URL 与 JS 读到的是同一个值
        resValue "string", "build_config_package", "com.example.fieldnote"
    }
}
// packages/core-domain/src/config.ts —— JS 侧唯一入口,禁止散落直接 import Config
import Config from 'react-native-config';

function requireEnv(key: keyof typeof Config): string {
  const value = Config[key];
  if (value === undefined) {
    // 【重点】缺配置要在启动期炸,而不是运行到某处静默 undefined
    throw new Error(`[Config] 缺少环境变量: ${key},检查 .env 与 flavor 映射`);
  }
  return value;
}

export const AppConfig = {
  env: Config.APP_ENV ?? 'dev',
  apiBaseUrl: requireEnv('API_BASE_URL'),
  sentryDsn: Config.SENTRY_DSN ?? '',
  updateServerUrl: requireEnv('UPDATE_SERVER_URL'),
  featureSyncV2: Config.FEATURE_SYNC_V2 === 'true',
} as const;

逐段解释:envConfigFiles 把 flavor 与 env 文件绑定,出哪个 flavor 就读哪份文件,杜绝「staging 包读了 prod 地址」这类错位;requireEnv 把「配置缺失」从运行期隐式 undefined 提前为启动期显式崩溃——宁可开发期炸,不可生产期飘。⚠️ .env 文件里也只放环境配置级内容,任何密钥连 .env.dev 都不允许出现(密钥属于第三类,走 CI Secret 注入构建命令而非文件)。

17.4 构建自动化:fastlane 双端流水线

先看一张完整发版流水线图,本节与 17.5、17.6 都挂在这条链上:

flowchart TD
    A[功能合入 main<br>CI 门禁通过] --> B[打 tag v2.4.1]
    B --> C{CI 触发发版流水线}
    C --> D[Android lane<br>R8 混淆 bundleRelease]
    C --> E[iOS lane<br>Archive + 上传处理]
    D --> F[符号上传<br>mapping.txt + JS sourcemap]
    E --> G[dSYM 上传]
    F --> H[Play 内部轨道<br>staged rollout 5%]
    G --> I[TestFlight 内测<br>分阶段发布 1%]
    H --> J{灰度监控看板<br>崩溃率/启动P90/同步失败率}
    I --> J
    J -- 越线 --> K[中止灰度<br>热更修复或 hotfix 发版]
    J -- 观察期达标 --> L[阶梯放大 5→20→50→100]
    K --> M[复盘归档<br>mapping/dSYM/bundle 留档]
    L --> M

fastlane 用两条 lane 固化这条链。版本自增、构建、符号上传、分发全部脚本化,凭据全部走环境变量:

# fastlane/Fastfile
default_platform(:android)

# 双端构建号同源:CI_RUN_NUMBER 由 CI 注入;本地构建回退到时间戳保证单调
def build_number
  ENV["CI_RUN_NUMBER"] || Time.now.strftime("%y%m%d%H")
end

platform :android do
  desc "Android 发布:bundle + 符号上传 + Play 内部轨道"
  lane :release do
    # 签名密码零字面量:从 CI Secret 注入环境变量
    # 环境要求:RELEASE_STORE_FILE / RELEASE_STORE_PASSWORD /
    #          RELEASE_KEY_ALIAS / RELEASE_KEY_PASSWORD 均来自 CI Secret
    sh("echo", "signing with upload key from env") # 不回显任何值

    gradle(
      task: "bundle",
      build_type: "Release",
      flavor: "prod",
      properties: {
        "android.injected.version.code" => build_number,          # 覆盖 versionCode
        "android.injected.version.name" => ENV["RELEASE_VERSION"] # v2.4.1
      }
    )

    # mapping.txt 上传:R8 混淆包没有它,崩溃栈就是 a.b.c
    sh("npx", "sentry-cli", "upload-proguard",
       "--org", ENV["SENTRY_ORG"], "--project", ENV["SENTRY_PROJECT"],
       "--version", ENV["RELEASE_VERSION"], "--build-number", build_number)

    # 上传 Play 内部轨道做灰度起点
    upload_to_play_store(
      track: "internal",                                  # internal -> alpha -> production
      aab: "android/app/build/outputs/bundle/prodRelease/app-prod-release.aab",
      skip_upload_metadata: true,
      skip_upload_images: true,
      skip_upload_screenshots: true
    )
  end
end

platform :ios do
  desc "iOS 发布:证书 + Archive + dSYM + TestFlight"
  lane :release do
    setup_ci

    # 证书与 profile 全走 match(私库地址与密码来自 CI Secret 环境变量)
    match(
      type: "appstore",
      readonly: true,
      git_url: ENV["MATCH_GIT_URL"],
      app_identifier: ENV["BUNDLE_ID"] # com.example.fieldnote
    )

    increment_build_number(build_number: build_number)

    build_app(
      scheme: "FieldNote-Prod",
      configuration: "Release",
      xcargs: "SENTRY_ENV=production"
    )

    # dSYM 上传:没它 iOS 堆栈只有内存地址
    sh("npx", "sentry-cli", "upload-dsym",
       "--org", ENV["SENTRY_ORG"], "--project", ENV["SENTRY_PROJECT"],
       "build/FieldNote.xcarchive/dSYMs/*.dSYM")

    # App Store Connect API Key 全走环境变量,不落 json 文件
    app_store_connect_api_key(
      key_id: ENV["ASC_KEY_ID"],
      issuer_id: ENV["ASC_ISSUER_ID"],
      key: ENV["ASC_API_KEY_CONTENT"],   # base64 的 .p8 内容,由 CI Secret 注入
      is_key_content_base64: true
    )
    upload_to_testflight(skip_waiting_for_build_processing: false)
  end
end

逐段解释:android.injected.version.* 是 Gradle 官方的版本覆盖入口,让 CI 而不是源码持有最终版本号,避免「改了 tag 忘改 gradle」;match readonly: true 保证 CI 只消费证书不重写;build_number 双端同源(CI 运行号),Android versionCode 与 iOS CFBundleVersion 数值一致,排查问题时「同号同版」。⚠️ iOS 的 increment_build_number 与 Android 的 versionCode 用同一策略,是双端构建号管理最省心的方案;备选方案对比:

策略 单调性 双端一致性 缺点
CI 运行号(推荐) CI 保证 同号同版 依赖 CI 环境变量
时间戳 yymmddHH 基本单调 一致 并发出包同小时撞号
源码手写自增 靠纪律 易漂移 人为错误高发源

CI 触发策略保持最简:tag 即发版。v* 格式 tag 推送触发上述双端 lane;main 分支只跑测试与构建冒烟,不出包。这样「能出包的状态」永远有 tag 指向,产物可追溯:

# .github/workflows/release.yml(骨架)
name: release
on:
  push:
    tags: ["v*"]
jobs:
  android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: bundle exec fastlane android release
        env:
          RELEASE_VERSION: ${{ github.ref_name }}
          CI_RUN_NUMBER: ${{ github.run_number }}
          RELEASE_STORE_FILE: ${{ secrets.RELEASE_STORE_FILE }}       # base64 keystore
          RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }}
          RELEASE_KEY_ALIAS: ${{ secrets.RELEASE_KEY_ALIAS }}
          RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
  ios:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
      - run: bundle exec fastlane ios release
        env:
          RELEASE_VERSION: ${{ github.ref_name }}
          CI_RUN_NUMBER: ${{ github.run_number }}
          MATCH_GIT_URL: ${{ secrets.MATCH_GIT_URL }}
          MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
          ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
          ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
          ASC_API_KEY_CONTENT: ${{ secrets.ASC_API_KEY_CONTENT }}
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}

17.5 崩溃监控与符号化

为什么没有符号表堆栈不可读?三个层面各有各的「翻译表」:R8 混淆后 com.example.fieldnote.sync.SyncEngine.enqueue 变成 a.b.c,mapping.txt 记录原名到混淆名的映射;iOS 崩溃栈只有内存地址,dSYM 携带 DWARF 调试信息把地址翻译回函数名与行号;JS bundle 压缩后堆栈只有行列号,sourcemap 把行列号还原回 TS 源码。发版时不上传这三件套,等于故障当天你收到的是一串「a.b.c crashed at 0x1a2b3c」——监控约等于没有。

符号文件 作用对象 产生时机 丢失后果
mapping.txt Android R8 混淆栈 每次 minifyEnabled 构建 原生崩溃不可读,且该版本永久不可恢复
dSYM iOS 地址符号化 每次 Archive 同上;bitcode 时代已过,Archive 即含全量
sourcemap JS 压缩栈 Metro 打包(--sourcemap) JS 崩溃只剩行列号
// android/app/build.gradle:Sentry Gradle 插件自动上传 mapping + sourcemap
plugins { id "io.sentry.android.gradle" version "4.x.x" } // 写作时以官方文档为准

sentry {
  autoUploadProguardMapping = true
  uploadNativeSymbols = true
  includeNativeSources = true
  telemetry = false
}
# 或纯 CLI 路线(CI 中双端通用),凭据走 SENTRY_AUTH_TOKEN 环境变量
# Android:mapping + sourcemap
npx sentry-cli releases new "$RELEASE_VERSION-android-$CI_RUN_NUMBER"
npx sentry-cli releases files "$RELEASE_VERSION-android-$CI_RUN_NUMBER" \
  upload-sourcemaps android/app/build/generated/sourcemaps/prodRelease/index.android.bundle.map \
  --strip-prefix "file://$PWD" --rewrite
# iOS:dSYM + sourcemap
npx sentry-cli upload-dsym "$PWD/ios/build/*.dSYM"

关键纪律:release 名必须与崩溃上报端一致。Sentry 靠 release 标签把崩溃事件匹配到符号文件,Android 端通常拼「版本 + 构建号」,JS 端再叠加 bundle 标识(第 18 章热更后这一位尤其重要)。任何一侧改名,符号化就静默失效。

发版 checklist 的监控门禁部分(完整版见 FieldNote 实战):

  • 发版前:符号三件套全部上传并能在 Sentry 后台反解一条测试栈
  • 发版后 1 小时:会话崩溃率(崩溃会话数/总会话数)示例门禁 > 0.3% 即暂停灰度放大(阈值按业务定,巡检类工具可放宽到 0.5%,交易类应 < 0.1%)
  • 发版后 24 小时:对比上一版本同指标;新增 issue 按设备/系统版本聚合,头部设备优先归因
  • 【重点】崩溃率门禁必须是「机器可执行」的:接告警机器人,越线自动通知发版群,而不是靠人记得看

17.6 灰度发布与强制升级

双端灰度机制不同,先对比:

维度 Android Staged Rollout iOS Phased Release
机制 Play 按设备随机比例放量,可手动提升 固定 7 天日程 1/2/5/10/20/50/100%,可暂停
暂停 可 Halt,已发布比例的用户仍持有新版 可暂停(最长 30 天),同样无召回
回滚 不可撤回,只能发更高 versionCode 修复 不可撤回,只能发新版修复
按需控制 配合 Play API 可做更细的轨道策略 基本黑盒,只能暂停/继续
灰度外的兜底 内部轨道 + 测试轨道先验证 TestFlight 先验证

【重点】两个平台都不能「真回滚」:灰度出问题后的标准动作是停止放量 + 加速修复(热更走第 18 章,原生修复走 hotfix 版本)。灰度计划必须预设中止线,而不是等出事再讨论。FieldNote 的监控项与判定:

监控项 采集来源 观察窗口 中止线(示例)
会话崩溃率 Sentry 1h / 24h > 0.3% 立即暂停
启动耗时 P90 启动打点 24h 较上版劣化 > 20% 暂停
同步失败率 同步引擎埋点(第 15 章) 24h > 2% 暂停并查协议兼容
热更成功率 更新服务端(第 18 章) 每次放量 < 95% 冻结热更
关键转化(任务提交率) 业务埋点 72h 相对下降 > 5% 复盘决策

强制升级是灰度之外的第二道安全网,核心实现是「远端配置最低可用版本」:服务端下发 minSupportedVersion,App 启动时比对,低于门槛就弹出不可关闭的全屏升级层。为什么放远端而不是写死?写死的门槛要生效还是得发版——而你恰恰是在「这次发版出了问题」时才需要它:

// packages/core-domain/src/force-update.ts
export function compareVersion(a: string, b: string): number {
  const pa = a.split('.').map(Number);
  const pb = b.split('.').map(Number);
  for (let i = 0; i < 3; i++) {
    if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) - (pb[i] ?? 0);
  }
  return 0;
}

export async function fetchMinVersion(apiBaseUrl: string): Promise<string> {
  // 远端配置接口;无网时回退到内置默认值(宁可漏升级,不可误杀离线巡检员)
  try {
    const res = await fetch(`${apiBaseUrl}/config/min-version`);
    const { minSupported } = (await res.json()) as { minSupported: string };
    return minSupported;
  } catch {
    return '0.0.0';
  }
}
// app/src/ForceUpdateGate.tsx
import { useMemo } from 'react';
import { Modal, View, Text, Linking, Pressable, StyleSheet } from 'react-native';
import { AppConfig, compareVersion, fetchMinVersion } from '@fieldnote/core-domain';
import { useAppVersion } from './useAppVersion';

export function ForceUpdateGate({ children }: { children: React.ReactNode }) {
  const current = useAppVersion();
  const [minVersion, setMinVersion] = useAsyncEffect(fetchMinVersion, AppConfig.apiBaseUrl);
  const blocked = useMemo(
    () => compareVersion(current, minVersion) < 0,
    [current, minVersion],
  );

  return (
    <>
      {children}
      <Modal visible={blocked} animationType="fade" onRequestClose={() => {}}>
        <View style={styles.container}>
          <Text style={styles.title}>需要更新后继续使用</Text>
          <Text style={styles.body}>
            当前版本 {current} 低于最低可用版本 {minVersion}。
            离线数据已安全保存,更新后自动恢复同步。
          </Text>
          <Pressable style={styles.btn} onPress={() => Linking.openStore(current)}>
            <Text style={styles.btnText}>立即更新</Text>
          </Pressable>
        </View>
      </Modal>
    </>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 32, backgroundColor: '#111318' },
  title: { color: '#fff', fontSize: 22, fontWeight: '700', marginBottom: 12 },
  body: { color: '#aab', lineHeight: 20, marginBottom: 24 },
  btn: { backgroundColor: '#3b82f6', borderRadius: 8, paddingVertical: 14, alignItems: 'center' },
  btnText: { color: '#fff', fontWeight: '600' },
});

逐段解释:onRequestClose={() => {}} 吃掉安卓返回键,保证阻断层不可绕过;useAsyncEffect 为自封装 hook(练习题要求补齐);文案里强调「离线数据已保存」,呼应第 14 章的本地持久化——强制升级最怕用户担心数据丢失而卸载重装,提前说明能显著降低流失。跳转商店用 Linking 拼 Play/App Store 链接(域名为 example.com 占位),实现留给练习。

典型业务场景:FieldNote 实战

本节对应里程碑 M17:双端发布流水线落地。产出四件套:三环境配置全集、双端 Fastfile、发版 checklist 卡片、灰度 5% 演练记录。

M17-1 三环境配置全集

配置目录如下(Android flavor 映射与 iOS scheme 映射见 17.2/17.3):

.env.dev / .env.staging / .env.prod     # 见 17.3,内容即 FieldNote 真实用例
android/app/build.gradle                # flavor: dev/staging/prod + 签名 env 化
ios/Configs/FieldNote.{Dev,Staging,Prod}.xcconfig
packages/core-domain/src/config.ts      # JS 侧唯一配置入口

Android 签名配置的 env 化写法(零字面量):

// android/app/build.gradle:signingConfig 从环境变量读取
android {
    signingConfigs {
        release {
            // CI Secret 注入;本地构建从本地 gradle.properties(gitignore)或 shell 导出
            storeFile file(System.getenv("RELEASE_STORE_FILE") ?: "release.keystore")
            storePassword System.getenv("RELEASE_STORE_PASSWORD") ?: ""
            keyAlias System.getenv("RELEASE_KEY_ALIAS") ?: ""
            keyPassword System.getenv("RELEASE_KEY_PASSWORD") ?: ""
        }
    }
}

⚠️ 一旦发现任何提交里出现过密码字面量:立即轮换密码(keystore 密码可改)、清洗 git 历史、检查该密码曾流经的所有系统。预防的成本是几分钟的 env 配置,补救的成本是一次安全事件。

M17-2 双端发版 fastlane(FieldNote 定稿版)

在 17.4 基础上,FieldNote 增加「产物归档」与「飞书/钉钉机器人通知」(示例用 webhook 变量,不含真实地址):

platform :android do
  lane :release do
    gradle(task: "bundle", build_type: "Release", flavor: "prod",
           properties: { "android.injected.version.code" => build_number,
                         "android.injected.version.name" => ENV["RELEASE_VERSION"] })

    # 国内渠道包:prod flavor 直接出 universal APK(商店分发用)
    gradle(task: "assemble", build_type: "Release", flavor: "prod")

    sh("npx", "sentry-cli", "upload-proguard",
       "--org", ENV["SENTRY_ORG"], "--project", ENV["SENTRY_PROJECT"],
       "--version", "#{ENV['RELEASE_VERSION']}-android", "--build-number", build_number)

    # 产物 + mapping 归档到内部分发目录(第 18 章热更也要从这个归档取 embedded bundle)
    sh("./scripts/archive-release.sh", "android", ENV["RELEASE_VERSION"], build_number)

    upload_to_play_store(track: "internal", aab: lane_context[SharedValues::GRADLE_AAB_OUTPUT_PATH])
    notify_release_bot("android", ENV["RELEASE_VERSION"])  # 内部 lane:POST Webhook URL 环境变量
  end
end

M17-3 发版 checklist 卡片

发版负责人按序勾选,全部通过才允许打 tag:

## FieldNote 发版 checklist(v<VERSION>)

### 发版前(tag 之前)
- [ ] 需求全量合入且 CI 门禁绿(单测 + 类型检查 + lint)
- [ ] staging 环境完整回归:登录 → 任务列表 → 巡检打分 → 拍照 → 扫码 → 离线同步 → 冲突合并
- [ ] 三环境冒烟:dev/staging/prod 包各自启动,确认 API_BASE_URL 指向正确
- [ ] versionCode / build number 策略检查:大于线上最新,同源生成
- [ ] R8 构建后冒烟:原生模块(相机/扫码/生物识别)全部可用
- [ ] 隐私清单与权限文案复核:本版新增权限均已声明用途
- [ ] 热更服务端确认:本版 runtimeVersion 指纹已登记(第 18 章)
- [ ] 上一版符号(mapping/dSYM/sourcemap)已确认可反解

### 出包后
- [ ] 符号三件套上传 Sentry 并反解测试栈成功
- [ ] 产物与 mapping 归档:archive-release.sh 退出码 0
- [ ] TestFlight / Play 内部轨道先装真机验证

### 灰度期间
- [ ] 5% 放量,观察窗口 24h:崩溃率 < 0.3%、启动 P90 无劣化、同步失败率 < 2%
- [ ] 每阶段放大前重读监控看板并留档截图
- [ ] 中止线演练:确认「暂停放量」操作路径可用(谁有权、在哪个后台)

M17-4 灰度 5% 演练记录表

一次真实演练的记录格式(示例数据为演练剧本的预期值):

时间点 指标 当日值 门禁 判定
T+1h 会话崩溃率 0.12% < 0.3% 继续观察
T+1h Sentry 新增 issue 2(均为低频 UI 边角) 无 P0 继续观察
T+6h 启动 P90 1420ms(上版 1450ms) 劣化 < 20% 通过
T+24h 同步失败率 0.4% < 2% 通过,放大至 20%
T+72h 任务提交转化 相对 +0.8% 下降 < 5% 通过,放大至 50% → 100%
演练中止线 (模拟注入)崩溃率 0.9% 触发 机器人通知 + 暂停放量 中止流程 8 分钟内完成,达标

演练必须包含一次故意触达中止线的剧本(构造崩溃或拉低指标),验证「机器人通知 → 值班响应 → 后台暂停」全链路。没演练过的中止线等于没有中止线。

常见陷阱

陷阱 典型症状 根因 正确做法
keystore 密码写进 gradle 提交仓库 仓库扫描器告警;离职员工仍可签包 把本地便利性带进了版本控制 密码走 CI Secret / 本地 gitignore 文件;泄露立即轮换并清历史
debug 签名包发生产 用户安装后无法覆盖正式版;商店拒收 出包命令用了错误的 buildType/flavor 组合 CI 固定 lane 出包,禁止手敲 gradle 命令发版;包名与签名在产物校验步骤复核
R8 后崩溃栈不可读 崩溃全是 a.b.c,无法定位 mapping.txt 未随发版归档/上传 Gradle 插件自动上传 + 构建脚本归档;每次发版反解一条测试栈验证
iOS 忘传 dSYM iOS 崩溃只有内存地址 Archive 后没有符号上传步骤,或 release 名不匹配 fastlane lane 固化上传;Sentry 后台定期核对「未符号化事件数」
versionCode 回退导致无法更新 用户报「装不上更新」 CI 重跑导致构建号变小,或手写版本号回退 versionCode 由 CI 运行号单调生成;出包前校验大于线上最大值,否则 fail fast
灰度无中止线 出问题后群内吵架才定阈值,响应超时 把灰度当流程仪式而不是风控手段 发版前锁定监控项与阈值;演练一次「触线即停」全流程
测试环境地址打进生产包 生产用户连接 staging 数据库,数据串污染 环境映射配置错位(flavor 读错 env 文件) 三环境同机冒烟是发版门禁;启动期打印环境标识(debug 包才显示);config 模块启动校验
只传了 sourcemap 忘了 release 关联 JS 崩溃依然不可读 上传了文件但 release 标签与运行时不一致 release 命名规则代码化,双端共用一个工具函数生成

最佳实践

  • 签名私钥「三处两份」:本地开发用 debug、CI 用 Secret 注入、离线冷备至少两份异地;Play 渠道启用 Play App Signing 把灾难降级为事故
  • versionCode 与 iOS 构建号同源生成(CI 运行号),单调递增、永不复用、双端可对账
  • 出包只认 CI:任何人工 gradle/xcodebuild 发版动作都被视为流程违例
  • 三环境配置「一处定义、三处消费」:.env 文件同时喂 Android BuildConfig、iOS xcconfig、JS Config 模块,禁止任何一处出现第二份地址常量
  • 机密零字面量:keystore 密码、API Key、上传凭据只存在于 CI Secret 或密钥服务;发现泄漏立即轮换而非删除提交
  • R8 构建后必跑「原生模块冒烟」:相机、扫码、生物识别、同步引擎在混淆包里各跑一遍
  • 符号三件套(mapping/dSYM/sourcemap)随每次构建归档并上传,用反解测试栈验证闭环
  • 崩溃率门禁机器化:告警机器人接 Sentry,越线自动通知,不依赖人肉盯盘
  • 灰度永远是「5% 观察 → 阶梯放大」,放大前重读看板;双端都明白:灰度可以暂停,不能回滚
  • 强制升级门槛放远端配置,App 内置兜底默认值;文案向用户说明数据安全
  • 发版 checklist 是代码资产而非聊天记录:随仓库演进,每次事故回填新条目
  • 每个版本归档产物 + 符号 + bundle 指纹,为第 18 章热更与回滚提供地基

本章小结

本章把 FieldNote 从「能跑的工程」变成「能发布的商品」:Android 侧用「包名 + 签名」确立身份,keystore 生命周期与 Play App Signing 决定生死;AAB 与 R8 分别在分发与体积两端优化,keep 规则守护反射契约。iOS 侧签名信任链三件套(证书、profile、entitlements)缺一不可,xcconfig 多环境与权限文案是过审的隐形门槛。双端之上,react-native-config 打通「配置即代码」的三类划分,fastlane 把版本、构建、符号、分发固化成 tag 触发的一条流水线,Sentry 三件套让崩溃可读,灰度与强制升级让事故可控。

一句话记忆点:发版的本质是「可追溯」——签名让包可追溯、构建号让版本可追溯、符号让崩溃可追溯、灰度让风险可追溯。

动手练习

  1. 基础巩固:为 FieldNote 设计完整的 versionCode 方案。要求:用 CI 运行号 + 语义化版本推导;写一个出包前校验脚本(版本号必须大于线上最大值,否则退出码非 0);并解释 R8 keep 规则中 -keepattributes Signature 为什么是 JSON 反序列化框架正常工作的前提。
  2. FieldNote 实战:实现防呆 CI——发版流水线第一步校验「当前 tag 与 ENVFILE 匹配」(tag 为 v2.*.* 时必须使用 .env.prod 出包,v2.*.*-rc 才允许 staging),不匹配直接 fail。提示:在 Fastfile 里用 ENV["RELEASE_VERSION"] 与 flavor 双重校验,并让校验脚本同时打印即将使用的 API_BASE_URL(脱敏域名即可)。
  3. FieldNote 实战:灰度 20% 阶段,第 15 小时你发现同步失败率从 0.4% 跳到 3.1%,Sentry 出现一条高频 issue(设备聚合显示集中在 Android 12)。请写出 30 分钟内的决策流程:中止线判定 → 暂停放量的具体操作与权限 → 归因路径(先查哪份符号、先看哪个指标)→ 与热更修复(第 18 章)和 hotfix 发版的取舍依据。
  4. 排查/思考题:Google 强推 AAB,但 RN 社区(尤其国内团队)仍有大量 APK 需求——为什么?请从「分发链路里谁负责按需下发」(Play 服务端 vs 商店/自分发)、国内商店的包规范要求、以及渠道号/多渠道包统计需求三个角度分析;最后回答:如果 FieldNote 需要同时上架 Google Play 与 5 家国内商店,你的出包矩阵(格式 × 渠道 × 签名)会怎么设计,CI 里如何避免组合爆炸?

导航

上一章:第 16 章 冲突处理与数据一致性 | 下一章:第 18 章 热更新与多 Bundle 拆包

📄 第 18 章 热更新与多 Bundle 拆包 18-hot-update-bundles.md

本章导读:第 17 章的发布体系解决「原生变更如何安全到达用户」,本章解决两件它够不到的事:JS 层的缺陷如何不经过商店当天修复(热更新),以及单一大 bundle 如何拖慢启动、如何按业务线隔离(拆包)。两者共享同一个底层事实——RN 应用是「原生二进制 + JS bundle」的分层结构。本章为第 19 章安全加固铺垫了校验、签名与信任根的思想。

本章学习目标

  • 能解释 RN 热更新可行的根本原因(JS bundle 与原生二进制分离)与「下载 → 校验 → 切换 → 重启 → 回滚」完整生效链路
  • 能判断哪些改动可热更、哪些必须发版,并给出 Apple 3.3.2 条款(编号以最新协议为准)与国内商店政策的合规依据
  • 能接入 expo-updates 并解释 channel / runtime version 的绑定机制、差量下载与回滚策略
  • 能设计自建热更系统:更新清单、SHA-256 + 签名校验、双目录原子切换、连续崩溃自动回滚
  • 能解释 Metro 模块图与拆包的关系,说明公共依赖为何必须进基础包、稳定 moduleId 为何是增量 diff 的前提
  • 能对「多 bundle」与「拆分 require」做收益/风险对比,并在新架构现状下为项目做出诚实选型
  • 能建立热更工程化配套:版本兼容矩阵、feature flag 保护、按 bundle 版本的崩溃归因与回滚演练制度

核心知识点

18.1 热更新原理:为什么 JS 可以换、原生不能换

一个 RN 应用在设备上其实是两层东西:原生二进制(Java/Kotlin 编译成 DEX 再被 ART 转成本地机器码、Swift/ObjC 编译成 Mach-O)与 JS bundle(Metro 打包产物,作为资源文件放在沙盒里)。⚠️ bundle 的形态取决于引擎:第 1 章讲过,Hermes 没有 JS 解析器,只认 hermesc 预编译的字节码文件(.hbc),运行时零源码解析;只有 JSC 构建才直接执行源码。这决定了热更「下载的到底是什么」——不是 JS 源码,而是与 App 内引擎版本匹配的 .hbc(18.4 详述)。操作系统在安装时对前者做了代码签名校验,签名绑定的是二进制本身;而后者只是一个数据文件,引擎「从哪个路径读哪个文件」完全由代码在运行时决定——把 bundle 文件换掉、再让引擎读新路径,就完成了热更新,不触碰系统的签名校验。

完整生效链路是一条状态机:检查清单 → 下载 → 校验 → 原子切换 → 重启生效 → 回滚保护。每一步都有失败分支,设计目标只有两条:新包坏了绝不能让 App 起不来;旧包必须始终可用作回滚底座。

flowchart TD
    A[启动 / 切前台] --> B{查询更新清单<br>runtimeVersion 匹配?}
    B -- 无更新 / 不匹配 --> C[加载当前 bundle<br>embedded 或上次良好版本]
    B -- 有更新 --> D[下载差异包/全量包]
    D --> E{SHA-256 完整性校验<br>+ 清单签名验证}
    E -- 失败 --> F[丢弃新包, 保留现版本<br>上报更新失败]
    E -- 通过 --> G[写入独立 bundle 目录<br>原子切换指针 pointer]
    G --> H[下次冷启动加载新 bundle]
    H --> I{启动哨兵:<br>完成前是否再次崩溃?}
    I -- 连续崩溃 >= 2 --> J[自动回滚: 指针指回<br>上一良好版本并上报]
    I -- 启动正常 --> K[标记新版本为良好<br>继续灰度放大]
    J --> C

那为什么原生代码不能热更?三个互相锁死的理由:

  1. 已编译:原生代码在打包时已编译成目标架构机器码,运行时不存在「重新解释」的入口;替换机器码等于替换二进制本体。
  2. 签名绑定:iOS 对动态库加载与内存可执行页有极严限制(非苹果签名的可执行代码直接拒绝),Android 也在安装期完成完整性校验——运行时注入未签名代码在两个平台都撞在安全模型上。
  3. 配置固化:AndroidManifest 的权限声明、iOS 的 entitlements 在安装时写入系统记录,运行时改不了。

推论就是判定表的第一原则:任何触碰原生层的东西(新原生模块、新权限、原生依赖升级、targetSdk 升级)都必须走商店发版——这正是第 17 章流水线存在的意义,热更与发版是互补关系不是替代关系:

改动类型 热更可行 说明
JS 业务逻辑、组件、样式 ✅ 热更主战场
图片/字体/静态资源 ✅ 随 bundle 或独立资源包下发
纯 JS 依赖升级(无原生部分) ⚠️ 技术可行但需回归,版本错位会炸(见 18.6)
新增/升级含原生代码的 npm 库 ❌ 原生侧不存在对应实现
AndroidManifest / Info.plist 变更、新权限 ❌ 安装期固化
react-native / 核心库升级 ❌ 原生与 JS 必须同升
Metro 配置、runtime version 指纹变化 ❌ 本身就是原生发布的一部分

18.2 平台合规边界:Apple 3.3.2 与国内安卓商店

Apple 开发者协议中约束下载执行代码的条款是 3.3.2(条款编号以最新协议为准),其长期稳定的内核是两条:下载的解释性代码只能使用 Apple 内置的 WebKit 或 JavaScriptCore 运行;且不得改变应用的主要用途或特性。RN 用系统内置 JS 引擎执行开发者自己随包或经热更下发的自研 JS,长期被业界广泛实践、在「不改变核心用途」的前提下被接受。因此合规热更的边界不是技术边界,而是用途边界。

真实被拒案例的三种典型模式:

  1. 改变核心用途:审核时是巡检工具,热更后变成电商商城——直接踩 3.3.2 第二条,且有下架风险。
  2. 绕过审核引入新能力:审核版本没有支付功能,热更后解锁内部支付或内容分发,绕开了审核对这类功能的政策检查。
  3. 下载可执行代码:下发二进制、动态库或自带解释器执行下载代码——技术上不可行,政策上一票否决。

国内安卓商店普遍允许热更但有两类要求(具体政策各家差异大、随时调整,以各平台最新政策为准):一是可关闭、可回滚——用户侧或服务端要能停住热更并把风险版本撤下;二是不得利用热更绕过审核——上架审核看到什么功能,热更后还是什么功能(可以修 bug、调体验,不能换业务)。稳妥姿势是给热更系统装一个总开关,审核期与政策收紧期可一键关闭:

// packages/core-domain/src/hot-update-gate.ts
// 热更总开关:远端配置优先,内置默认兜底;审核期强制关闭
export interface UpdateGateConfig {
  enabled: boolean;            // 服务端一键停更
  allowInReview: boolean;      // 审核 flag:App Store 审核期间不检查更新
  minDelayMinutes: number;     // 可选:错峰限速
}

export async function resolveUpdateGate(env: string): Promise<UpdateGateConfig> {
  const defaults: UpdateGateConfig = { enabled: true, allowInReview: false, minDelayMinutes: 0 };
  try {
    const res = await fetch(`https://updates.${'example.com'}/gate?env=${env}`);
    return { ...defaults, ...(await res.json()) };
  } catch {
    return defaults; // 无网/服务不可用:保守放行默认值,绝不因配置失败阻断启动
  }
}

把「审核期开关」落地为构建期常量 + 远端可覆盖的组合:App Store 审核包通过 xcconfig 注入 UPDATE_DISABLED_DURING_REVIEW=true,运行时与远端 gate 配置取与运算。合规决策表:

改动 Apple 国内商店 结论
修 bug、调样式、改文案 允许 允许 走热更
新增与核心用途一致的子功能 灰色,从严判断 视平台 建议走发版+审核,最稳
改变核心用途/新增变现路径 拒绝 拒绝 必须发版过审
新权限、新原生模块 必须发版 必须发版 技术上也做不到热更
审核期内下发更新 关闭开关 关闭开关 构建期注入 + 远端覆盖

18.3 生产方案:expo-updates 的工作机制与 CodePush 的历史地位

expo-updates 是当前生产级热更的主流参考实现,核心机制四个概念:

  • runtime version:「这版 JS 能跑在哪个原生版本上」的绑定声明。两种策略:appVersion(原生版本号即 runtime 版本,原生没升 JS 永远兼容——保守但每次原生发版都会切走热更基线);fingerprint(对原生依赖清单——包含的模块、插件、配置——计算指纹哈希,指纹相同才允许热更,原生依赖一变指纹即变,精准但要求指纹计算稳定)。
  • channel:发布目标维度(production / staging),build 与 update 通过 channel 对齐,同一二进制可区分灰度轨道。
  • 更新清单(manifest):服务端返回的元数据,含本次更新的 id、runtimeVersion、以及每个模块文件的哈希列表。
  • 差量下载:客户端比对本地已有文件的哈希,只下载缺失部分——多次热更的用户流量从「全量 bundle」降到「只拉变更模块」。

回滚策略三层兜底:嵌入包(embedded bundle) 永远是底线——商店包里打包的那份 bundle 不会被热更覆盖,任何下载的新版本出问题都可退回;启动哨兵检测新版本连续崩溃自动回退;manifest 层面可直接下发「回滚到某历史版本」的指令。bare workflow(非 Expo 管理的原生工程)同样可接入:

// app.json(bare 工程由 expo-updates 读取;字段位置随 SDK 版本略有迁移,以官方文档为准)
{
  "expo": {
    "updates": {
      "url": "https://u.example.com/fieldnote/manifest",
      "runtimeVersion": { "policy": "fingerprint" },
      "fallbackToCacheTimeout": 0,
      "checkAutomatically": "ON_LOAD"
    }
  }
}
// eas.json:build profile 与发布 channel 对齐
{
  "build": {
    "staging":    { "channel": "staging",    "distribution": "internal" },
    "production": { "channel": "production" }
  }
}
// app/src/UpdateBanner.tsx:前台检查 + 用户无感后台下载 + 下次冷启动生效
import { useEffect } from 'react';
import * as Updates from 'expo-updates';

export function useHotUpdate() {
  useEffect(() => {
    if (__DEV__) return;
    (async () => {
      try {
        const check = await Updates.checkForUpdateAsync();
        if (!check.isAvailable) return;
        await Updates.fetchUpdateAsync();          // 后台下载,不阻塞启动
        // 【重点】不调用 reloadAsync 打断用户:记一个待生效标记,
        // 下次冷启动自然生效;仅「紧急修复」才提示用户立即重启
        await track('update_fetched', { manifestId: check.manifest?.id });
      } catch (e) {
        await track('update_error', { message: String(e) });
      }
    })();
  }, []);
}

bare 集成要点:npx expo install expo-updates(对齐版本)、Pods/Gradle 依赖由 autolinking 处理、原生侧的更新元数据(URL、runtimeVersion)由 config plugin 写入 AndroidManifest/Info.plist,JS 侧用上面的公开 API。细节以官方文档为准——会读机制比会背 API 重要。

CodePush 的历史地位值得单独一段:它曾是 RN 热更事实标准,托管在微软 App Center;App Center 于 2025 年退役后官方服务停止,社区出现了自托管 server 与若干商业替代。这段历史的教学价值在于:任何热更服务(CodePush、EAS Update、自建)剥掉外衣都是同一套「清单 → 下载 → 校验 → 切换 → 回滚」流程的产品化。理解 18.4 的原理,你才能评估任何第三方服务——比如它的清单有没有签名、回滚是不是只有嵌入包一层。

18.4 自建热更新系统设计(原理教学为主)

本节以教学为目的拆解一个最小可用自建系统,生产上优先用成熟方案;但拆开后你会发现它与 18.3 没有任何概念差。

更新清单服务。清单是整个系统的信任锚点,schema 设计要一次到位:

{
  "runtimeVersion": "fp-3f8a1c2d",
  "engine": "hermes-hbc-2026.05",
  "bundleId": "b-20260929-1422-7f3e",
  "size": 2411520,
  "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
  "signature": "<YOUR_BASE64_SIGNATURE>",
  "url": "https://cdn.example.com/bundles/b-20260929-1422-7f3e.zip",
  "rolloutPercent": 5,
  "mandatory": false
}

下载对象是什么:字节码格式与引擎兼容。清单里 url 指向的不是一个 JS 源码文件,而是 CI 在构建机上用 hermesc 编译出的 .hbc 字节码产物(生产流水线里这一步与第 17 章的构建同源)。Hermes 的字节码格式随引擎版本演进:旧引擎读到新格式的字节码,结果不是「兼容降级」而是加载直接失败。expo-updates 用 runtimeVersion(fingerprint 指纹涵盖引擎与原生依赖清单)从源头绑定这层兼容;自建系统必须把引擎标识显式放进清单(schema 的 engine 字段,构建期从 gradle/xcconfig 注入当前 App 的引擎标识),让它参与签名与客户端校验——engine 与本机不匹配的清单直接拒收,连下载都不必发起。它与 18.6 的版本兼容矩阵是同一件事的两个粒度:兼容矩阵管「JS 能力 ↔ 原生接口」,engine 字段管「字节码 ↔ 引擎」。

下载与校验:为什么哈希不够。SHA-256 校验回答的是「下载到的文件 = 清单描述的文件」,它能防下载损坏与 CDN 篡改;但清单本身是 HTTP 服务返回的——如果清单服务被劫持,攻击者可以同时替换清单和 bundle,恶意包的哈希与新清单自洽,SHA-256 校验照样全绿。防住这一层的机制是非对称签名:发布端用私钥对清单的规范化内容签名,客户端内置公钥验签。信任根从「服务器没被黑」收缩为「私钥没泄露」——服务器可以被黑,但没有私钥就签不出合法清单,客户端验签即拒。这与第 17 章 APK 签名是同一个密码学原理,也是第 19 章「信任根内置于客户端」思想的预演。

// packages/native-kit/src/update-verify.ts(教学骨架,WebCrypto;生产建议下沉原生层验签)
export interface UpdateManifest { /* 同上 schema */ }

function canonical(manifest: Omit<UpdateManifest, 'signature'>): string {
  // 规范化:固定字段顺序 + 无空白,保证签名与验签两侧字节一致
  const ordered = {
    runtimeVersion: manifest.runtimeVersion,
    engine: manifest.engine,
    bundleId: manifest.bundleId,
    size: manifest.size,
    sha256: manifest.sha256,
    url: manifest.url,
    rolloutPercent: manifest.rolloutPercent,
    mandatory: manifest.mandatory,
  };
  return JSON.stringify(ordered);
}

export async function verifyManifest(
  manifest: UpdateManifest,
  publicKeyPem: string,
): Promise<boolean> {
  const key = await importPem(publicKeyPem);                 // 导入内置公钥(随包分发,不联网)
  const data = new TextEncoder().encode(
    canonical({ ...manifest, signature: undefined } as never),
  );
  const sig = base64ToBytes(manifest.signature);
  return crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, sig, data);
}
校验机制 防下载损坏 防 CDN 篡改 防清单服务劫持
仅 SHA-256 ✅ ⚠️(清单同步被换则失效) ❌
HTTPS + SHA-256 ✅ ✅ ❌(HTTPS 只保传输层,服务本身被黑无解)
清单签名 + SHA-256 ✅ ✅ ✅(私钥不泄露前提下)
签名 + SHA-256 + 证书锁定 ✅ ✅ ✅(额外防传输层中间人,见第 19 章)

FieldNote 的落地约定:更新清单服务与 bundle/模板 CDN 域名,与 API 域名共用同一份 Pinning 配置、同一套证书到期监控与紧急关停开关(第 19 章)——更新通道是「会替你执行代码」的通道,防护等级只应更高。

下载器:前置校验、断点续传与磁盘预检。生产上这一步由 expo-updates 的原生下载器承担;自建时把它当一道「弱网文件传输」工程来做,要点五条,缺一条都是线上事故:

// packages/native-kit/src/update-download.ts(教学骨架)
// ⚠️ RN 的 fetch 不支持流式响应与 Range 续传:生产实现用 expo-file-system 的
// createDownloadResumable(封装了 Range 断点)或原生 DownloadManager / URLSession 下载任务
const MAX_SIZE = 64 * 1024 * 1024;
const ALLOWED_HOSTS = new Set(['cdn.example.com', 'updates.example.com']); // 构建期注入,与 Pinning 配置同源管理

/** 前置校验:清单签名已锁定 URL 的「内容」,这里再锁「形式」——纵深防御不嫌多 */
export function assertDownloadable(raw: string): URL {
  const url = new URL(raw);
  if (url.protocol !== 'https:') throw new Error('scheme rejected');             // 只允许 https
  if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error('host not allowed');     // host 白名单
  if (url.username || url.password || url.port) throw new Error('url rejected'); // 拒绝用户信息与非默认端口伪装
  return url;
}

export async function downloadBundle(manifest: UpdateManifest, destPath: string): Promise<void> {
  const url = assertDownloadable(manifest.url);
  if (manifest.size > MAX_SIZE) throw new Error('size rejected');                // 清单声明体积先过闸
  if (await freeDiskSpace() < manifest.size * 1.2) throw new Error('disk full'); // 磁盘预检留 20% 余量
  const existing = await fileSize(destPath);
  if (existing >= manifest.size) await unlink(destPath);                         // 相同或更大视为损坏,重来
  const resumeFrom = existing < manifest.size ? existing : 0;                    // 断点续传:已下载字节为起点
  const res = await fetchRange(url, resumeFrom);                                 // 带 Range 头,超时用 AbortSignal
  if (res.status !== 200 && res.status !== 206) throw new Error(`download failed: ${res.status}`);
  await appendToFile(destPath, res.body);                                        // 流式落盘,禁止整包进内存
  if ((await fileSize(destPath)) !== manifest.size) throw new Error('size mismatch');
  if ((await sha256File(destPath)) !== manifest.sha256) {                        // 先比大小再比哈希,省一次全量读
    await unlink(destPath);
    throw new Error('sha256 mismatch');
  }
}

失败重试直接复用第 15 章的 computeBackoffMs(指数退避 + 抖动);下载全程在后台进行、不阻塞启动——fallbackToCacheTimeout: 0 的含义就是「不等新包,先用当前版本跑起来」。

存储与原子切换。目录布局采用「多版本共存 + 指针文件」:

<filesDir>/updates/
  ├── bundles/
  │     ├── b-20260929-1422-7f3e/     # 新版本(完整解压校验后才算数)
  │     └── b-20260921-0810-2c9d/     # 上一良好版本(回滚底座,绝不先删)
  └── pointer.json                    # { "active": "b-20260921-0810-2c9d", "previous": "..." }

切换的原子性来自文件系统的 rename 语义:新包先解压到独立目录并校验通过,再用一次 rename 写入 pointer.json(同分区 rename 是原子的),同时把清单哈希写为 bundle 目录内的哈希侧车文件(.sha256,供启动加载前复核),绝不允许「先删旧包再写新指针」的中间态。清理策略保留 N=2 个版本——删掉旧的意味着「新版本坏了的时候,回滚无包可回」,只能退到嵌入包并丢掉所有热更迭代。磁盘膨胀的正确的打开方式是「保留上限 + 启动期清理」,而不是「下完就删旧的」。

启动时选择 bundle 的原生逻辑。Android 侧在 Application 早期、RN 初始化之前决定 bundle 来源:

// android/app/src/main/java/com/example/fieldnote/UpdateSelector.kt(教学骨架)
class UpdateSelector(private val ctx: Context) {
    private val prefs = ctx.getSharedPreferences("fn_updates", Context.MODE_PRIVATE)

    fun resolveBundlePath(): String {
        val active = prefs.getString("active_bundle", null) ?: return embeddedPath()
        val bundle = File(ctx.filesDir, "updates/bundles/$active/index.android.bundle")
        // 加载前复核完整性:对照清单写入的哈希侧车文件,
        // 防「写入后落盘被截断 / 清理工具误伤」的长尾——故障消灭在加载前,比崩溃后回滚便宜得多
        val digest = File(bundle.parent, "index.android.bundle.sha256")
        if (!bundle.exists() || !digest.exists()) return embeddedPath()
        if (sha256Of(bundle) != digest.readText().trim()) return embeddedPath()
        return bundle.absolutePath
    }

    // 启动哨兵:进入 Application 时置 pending,JS 首帧渲染回调后清除并清零计数
    fun onStartBoot() {
        if (prefs.getBoolean("boot_pending", false)) {
            val fails = prefs.getInt("consec_fails", 0) + 1
            prefs.edit().putInt("consec_fails", fails).putBoolean("boot_pending", true).apply()
            if (fails >= 2) rollback()   // 连续两次启动即崩:指针回退到 previous
        } else {
            prefs.edit().putBoolean("boot_pending", true).apply()
        }
    }

    fun onBootSuccess() {
        // 当前版本证明健康:previous <- active,计数清零
        prefs.edit()
            .putString("previous_bundle", prefs.getString("active_bundle", null))
            .putInt("consec_fails", 0)
            .putBoolean("boot_pending", false)
            .apply()
    }

    private fun rollback() { /* active <- previous,并上报回滚事件 */ }
}

iOS 侧对称实现:AppDelegate 在构造 RN bridge 前用同样的指针逻辑返回 bundle URL,哨兵状态存 UserDefaults,「启动成功」以首帧渲染回调(或自定义 JS ready 通知)为准。两端共用同一个协议:pending 标记 + 连续失败计数 + previous 指针,阈值 2 次是「误杀率」与「止损速度」的平衡;且都在选择 bundle 时做一次哈希复核——指针指向的文件与侧车哈希不符(写入后损坏、被清理工具删了半截)就退嵌入包,把故障消灭在加载之前。

灰度策略。按用户/设备稳定分桶:hash(userId) % 100 < rolloutPercent,哈希入参固定保证同一用户在放量爬坡中始终命中同一版本(sticky),否则会出现「今天有更新明天没有」的闪烁体验。放量决策完全在服务端清单层完成,客户端只认清单——这也是 18.3 中 channel/manifest 机制的本质。

18.5 多 bundle 与拆包策略

先回答「为什么要拆」。第 5 章讲过启动优化的「少执行」:bundle 越大,解析与执行模块定义的时间越长——Hermes 虽然把解析成本压得很低(字节码预编译),但模块注册与顶层副作用仍按需或按序执行;千条任务列表 + 相机 + 扫码 + 同步引擎的全量 bundle 让「启动时必须载入的代码」包含了大量当前用不到的业务。拆包的三个动机:启动速度(缩小必载集)、包体积(业务线各自演进)、业务隔离发版(不同业务线的热更节奏解耦)。

Metro 的模块图是理解拆包的前提。Metro 打包四阶段:entry 解析出发,按依赖关系做 resolution(路径解析、平台后缀 .android.ts 选择),transform(Babel/Hermes 编译每个模块),最后 serializer 把模块序列化为产物。产物本质是一个「模块注册表」:__d(工厂函数, moduleId, 依赖id数组) 的数组加上 require 运行时。所谓拆包,就是把这张模块图按归属切成多个产物文件,运行时先加载基础包再加载业务包,共享同一张运行时模块表。

公共依赖必须进基础包的原因在此处:模块按 moduleId 注册到全局表,如果 React 同时出现在基础包和业务包,运行时就会存在两个 React 实例——Context 跨包传递失效、Hook 状态分裂、单例(第 16 章的同步引擎实例)变成两份。这类 bug 的典型表现是「单包运行正常,拆包后偶发 Invalid hook call / Context undefined」,且只在特定路由组合下复现。

                        Metro 模块图(单入口)
                              │
        ┌─────────────────────┴──────────────────────┐
        │                基础包 base.bundle           │
        │  react / react-native / hermes runtime     │
        │  shared-ui / core-domain / native-kit      │
        │  (全部公共依赖, moduleId 稳定分配 1..999)    │
        └─────────────────────┬──────────────────────┘
              moduleId 1..999 │        │ moduleId 1000+
        ┌─────────────────────┘        └───────────────────┐
        │                                                  │
┌───────┴────────┐                               ┌─────────┴───────┐
│ 业务包 A        │                               │ 业务包 B         │
│ 巡检扩展模块     │                               │ 其他业务线        │
│ 只含增量模块     │                               │ 只含增量模块      │
└────────────────┘                               └─────────────────┘
   ⚠️ 业务包内绝不允许再打一份 react —— 两个 React 实例会让
   Context / Hook 状态分裂,产生只拆包才有的诡异运行时异常

稳定 moduleId 是拆包的地基。Metro 默认给模块分配自增序号,id 取决于遍历顺序——多打一个依赖,后续所有模块的 id 整体漂移。基础包与业务包分两次构建,id 对不上就是运行时模块查找失败;增量更新想按模块 diff 也无从谈起。createModuleIdFactory 用文件路径锚定 id,让「同一文件在两次构建中得到同一 id」:

// metro.config.js
module.exports = {
  transformer: {
    getTransformOptions: async () => ({ transform: { inlineRequires: true } }),
  },
  serializer: {
    createModuleIdFactory: () => {
      // 路径 -> id 的稳定映射:同一路径永远同一 id,跨构建不漂移
      const fileToId = new Map();
      let nextId = 0;
      return (path) => {
        if (!fileToId.has(path)) {
          // 基础包模块占低位段;业务包模块占高位段,天然隔离
          const id = path.includes('/node_modules/react/') || path.includes('/packages/')
            ? nextId++            // 教学示意:真实划分用白名单集合判断
            : 100000 + nextId++;
          fileToId.set(path, id);
        }
        return fileToId.get(path);
      };
    },
  },
};

有了稳定 id,业务包的增量构建才成立:基础包集合固定后,业务包产物只含 id ≥ 高位段的模块,两次业务包构建之间按模块哈希 diff 即可生成差量包。基础包的模块集合如何圈定?用白名单显式声明(react、react-native、shared-ui、core-domain、native-kit 及其传递依赖),第一次构建后导出路径清单,业务包构建时用 processModuleFilter 过滤:

// metro.business.config.js:业务包序列化时剔除基础包已包含的模块
const baseModulePaths = new Set(require('./base-modules.json')); // 基础包构建后导出
module.exports = {
  serializer: {
    createModuleIdFactory: /* 同上 */,
    processModuleFilter: (module) => !baseModulePaths.has(module.path),
  },
};

业务包构建出来后,运行时加载在概念上是这样的:bundle 产物里的 __d(工厂函数, id, deps) 就是往全局模块表注册一条记录,「加载业务包」等于把业务包的注册条目追加进基础包已建的表,再用基础包导出的 require 去取业务模块:

// 概念演示(教学用,非可用实现)
function loadBusinessBundle(code) {
  const registry = global.__r;                    // 基础包运行时留下的模块表
  const define = global.__d;                      // 基础包的模块注册函数
  new Function('__d', code)(define);              // 执行业务包,向表里追加条目
  return require(EXTENSION_ENTRY_ID);             // 按稳定 id 取业务包入口
}

它能工作取决于三个前提同时成立:业务包与基础包共享同一个运行时实例(模块表、require、React 都只有一份)、模块 id 在两侧构建中不漂移(上文稳定 moduleId)、以及引擎允许在运行后继续注册与执行新模块。三个前提在新架构下都变得脆弱——这正是下面要讨论的现状。

到这里技术路径是通的,但必须诚实地给出新架构下的现状:多 bundle 的运行时加载(先 eval 基础包、再动态加载业务包并注入模块表)在旧 Bridge 时代有成熟的社区方案;0.8x 新架构(JSI、bridgeless)下,官方并未提供稳定的多 bundle 动态加载支持,相关社区方案均停留在实验阶段,与 Hermes 预编译、懒加载优化、更新框架的兼容性都需要自担风险。这是高风险区,不是「高级技巧」。现实的替代方案:

  1. 单 bundle + 懒加载 require:保持一份 bundle,但把重业务的 require 推迟到真实使用点(Metro 会对 inlineRequires 顶层延迟化),启动只执行入口可达的最小集。零兼容风险,覆盖多数「拆包动机」的启动诉求。
  2. expo-updates 全量替换 + 多 channel/多目标:用完整 bundle 的热更代替运行时拼接,业务线隔离用「不同 channel 下发不同人群的全量包」近似实现。
  3. 路由级 code splitting 的边界:Metro 不支持标准 import() 动态导入,RN 社区的动态分包方案(容器加载第二个 bundle)均为非官方实现——可以调研,不建议在核心生产链路赌。

「多 bundle」与「拆分 require」的收益对比:

维度 多 bundle(基础包+业务包) 拆分 require(单 bundle)
启动必载集 真正缩小 缩小(顶层副作用仍需遍历模块表)
新架构兼容性 高风险,官方未支持 完全兼容
业务线独立发版 支持(本质优势) 不支持(全量热更)
维护成本 白名单治理 + id 稳定性 + 回归矩阵 接近零
增量更新 支持按模块 diff 不适用(全量包差量由 18.3 差量下载承担)
典型适用场景 多业务线大团队、强隔离发版需求 绝大多数团队的启动优化诉求

💡 结论:多数团队的正确答案是「单 bundle + Hermes + 懒加载 require + expo-updates 全量热更」;多 bundle 只在「多业务线独立发版」是硬需求、且团队有能力维护拆包基建时才值得上。

18.6 热更新的工程化配套

热更把「发版频率」提升了一个数量级,配套跟不上就会把风险同样提升一个数量级。

版本兼容矩阵。JS 与原生并非任意组合都兼容:JS 调用了新版本原生模块的接口、原生期待新 bundle 的通信协议,都会错位。矩阵要显式声明并可执行:

// packages/core-domain/src/compat.ts
// native 版本 -> 允许加载的 JS 版本区间(示例)
export const COMPAT: Record<string, string> = {
  '2.3.0': '>=2.3.0 <2.4.0',
  '2.4.0': '>=2.4.0 <2.5.0',
};

export function canApply(nativeVersion: string, jsVersion: string): boolean {
  const range = COMPAT[nativeVersion];
  if (!range) return false; // 未知原生版本:保守拒绝热更,用嵌入包
  return satisfies(jsVersion, range); // 语义化区间判断(semver 工具函数)
}

用 fingerprint 策略的团队(18.3)可以把矩阵问题转化为指纹比对——原理相同:服务端清单下发前先做一次兼容判定,把不兼容的更新挡在下载之前,而不是靠客户端加载失败再回滚。

feature flag 保护。热更内容的黄金守则:新逻辑先用 flag 包裹,出问题热更一次「只翻 flag」即可回退,不需要整包回滚。flag 的默认值进构建(进 git、随评审变更),远端配置只做运行时覆盖:

// packages/core-domain/src/flags.ts
export const DEFAULT_FLAGS = {
  newScoringPanel: false,   // 热更带入的新功能:默认关,远端灰度开启
  syncV3Protocol: false,
} as const;
export type FlagName = keyof typeof DEFAULT_FLAGS;

export function resolveFlag(name: FlagName, remote: Partial<Record<FlagName, boolean>>): boolean {
  return remote[name] ?? DEFAULT_FLAGS[name]; // 远端可覆盖,默认值兜底
}

监控。四个必须有的指标,全部按 bundle 版本维度聚合:更新检查量、下载成功率、生效率(下载成功且冷启动加载成功)、以及按 bundle 版本切分的崩溃率——新 bundle 的崩溃率显著高于嵌入包基线时自动冻结该版本放量。Sentry 侧把 release 名带上 bundleId(第 17 章 release 命名规则的延伸),崩溃归因才能落到具体热更版本:

await track('update_check',    { runtimeVersion, activeBundle });
await track('update_applied',  { bundleId, coldStartMs });
await track('update_rollback', { bundleId, consecFails });
// Sentry release: `${appVersion}-js-${bundleId}`

演练制度。回滚机制平时是「看不见的代码」,出事时才知道它是不是能用——所以每次发版前必须在 staging 演练一遍回滚剧本:下发一个故意有问题的测试包 → 观察启动哨兵连续 2 次失败后自动回滚 → 验证 App 恢复到上一良好版本 → 清理演练数据。演练结果写进发版 checklist(第 17 章),未演练不发版。

典型业务场景:FieldNote 实战

本节对应里程碑 M18:热更新体系与巡检扩展模块按需下发落地。

M18-1 更新策略表

FieldNote 的改动分流守则(发版前对照,写进 checklist):

FieldNote 改动 通道 理由
检查项打分逻辑修正、表单校验调整 热更 纯 JS,与核心用途一致
深色主题样式、文案微调 热更 纯资源与样式
巡检扩展模块(JS 模板 + 上报逻辑) 热更 + flag 见 M18-4,JS 范畴内
新增「语音备注」依赖原生录音库 发版 新原生模块
新增蓝牙外设权限 发版 权限安装期固化
同步协议 v3(涉及原生数据库迁移) 发版 原生与 JS 同升,走第 15/17 章流程
相机取景器性能优化(纯 JS 层) 热更 纯 JS,且属于缺陷修复

M18-2 runtime version 策略

FieldNote 选择 fingerprint 策略,理由:appVersion 策略下每次原生发版都换基线,灰度期的「2.4.0(1) 与 2.4.0(2) 同基线」反而做不到;fingerprint 以原生依赖清单哈希为准,只有原生依赖真正变化才切基线:

// app.json
{
  "expo": {
    "updates": {
      "url": "https://updates.example.com/fieldnote/manifest",
      "runtimeVersion": { "policy": "fingerprint" },
      "fallbackToCacheTimeout": 0,
      "checkAutomatically": "ON_LOAD"
    }
  }
}

运行约束写进制度:任何 PR 改动原生依赖(新增含原生的库、改 config plugin、改 AndroidManifest/Info.plist)必须重算指纹并在 PR 描述标注「runtimeVersion 将变更」;热更服务端在发版日先登记新指纹,再开始放量。⚠️ 指纹计算必须纳入 CI 校验(本地与 CI 的依赖解析顺序差异可能产生不同指纹),以 CI 产出的指纹为准。

M18-3 灰度发布演练记录

沿用第 17 章灰度框架,热更叠加自己的观察层。一次 5% → 100% 完整演练:

阶段 动作 观察指标 判定
准备 新 bundle 上传、清单签名、staging 全功能回归 校验通过、反解测试栈成功 进入放量
5% 放量 24h 更新生效率 99.2%;bundle 崩溃率 0.08%(嵌入包基线 0.11%) 健康,放大
25% 放量 24h 生效率 98.9%;无新增 P0 issue 健康,放大
50% 放量 24h 生效率 99.0%;任务提交转化无波动 健康,放大
100% 全量 观察 48h 收口 完成
回滚演练 staging 下发坏包 哨兵 2 次失败自动回滚,App 恢复上一良好版本,回滚事件上报延迟 40s 通过,记录留档

冻结线与第 17 章对齐:bundle 崩溃率 > 嵌入包基线 2 倍,或生效率 < 95%,自动冻结放量并在值班群告警。

M18-4 巡检扩展模块按需下发的决策记录

背景:巡检扩展模块是面向特定行业的专用检查表模板与上报逻辑(纯 JS + 模板 JSON),目标用户只是部分客户;需求是「不用它的客户不为它付启动时间和包体积,用它的客户能较快拿到新模板」。

候选方案:

  • A. 独立业务 bundle:按 18.5 做基础包+业务包拆分,模块独立构建独立加载。评估:启动与体积收益真实,但新架构下运行时动态加载是官方未支持的高风险区,与 Hermes/热更框架的兼容要自行兜底,维护成本超出团队承受——否决。
  • B. 更新包携带模块 + feature flag 控制入口:模块代码随全量 bundle 热更下发,入口由 flag 控制(未开通客户 flag 关闭、入口不渲染)。启动开销靠懒加载 require 压到接近零;模板 JSON(大头体积)走独立资源 URL 懒下载。选定。
  • C. 商店发版下发模块:合规最稳但节奏慢,且把「行业模板的快速迭代」绑死在发版周期上——与需求冲突,作为兜底通道保留。

实现骨架:

// packages/inspection-extension/src/index.ts —— 模块自身是纯 JS 包
export function InspectionExtensionScreen() { /* 行业专用检查表 + 上报逻辑 */ }
export const TEMPLATE_MANIFEST_URL = 'https://cdn.example.com/inspection-templates/index.json';

// app/src/tasks/InspectionEntry.tsx —— flag 守卫 + 懒加载入口
import { resolveFlag } from '@fieldnote/core-domain';
import { useRemoteFlags } from '@fieldnote/core-domain';

export function InspectionEntry() {
  const flags = useRemoteFlags();
  if (!resolveFlag('inspectionExtension', flags)) return null; // 未开通客户零成本

  const Extension = require('@fieldnote/inspection-extension')
    .InspectionExtensionScreen;                    // 懒加载:进入入口才执行模块顶层
  return <Extension />;
}

配套约束:模块只允许依赖基础集合(shared-ui / core-domain)以维持单 bundle 内依赖整洁;模板 JSON 首次进入入口时下载并缓存(复用第 14 章的存储层);flag 关闭即「功能不可见」,flag 走远端配置可秒级停用——合规上这属于「核心用途一致的子功能」,不触碰 18.2 的边界。决策记录全文随 ADR 入库,当新架构提供稳定的多 bundle 支持时重新评估方案 A。

常见陷阱

陷阱 典型症状 根因 正确做法
热更了原生配置项 热更显示成功,行为无变化,反复「重发」 改动落在原生侧(buildConfigField、plist、原生模块参数),热更只换了 JS 建立改动分流表(M18-1);热更前自问「这份 diff 有没有原生文件」
runtime version 写死 原生升级后热更永不匹配,或旧 App 误装新 JS 后崩溃 把 runtimeVersion 硬编码成常量,未随原生变更联动 用 appVersion/fingerprint 策略自动生成;CI 校验指纹一致性
无签名校验只查哈希 单测全绿却存在供应链风险:清单服务被劫持即可植入恶意包 把「完整性」误当「可信性」 清单私钥签名 + 客户端内置公钥验签;私钥只放发布基础设施
热更后旧 bundle 未清理 磁盘占用持续膨胀,收到低端设备存储告警 只下载不清理,或清理策略「删到只剩当前」 保留 N=2 个版本(回滚底座),启动期按上限清理
拆包共享依赖版本不一致 拆包后偶发 Invalid hook call / Context undefined 基础包与业务包各自解析出不同版本的 react 白名单锁定公共依赖进基础包;lockfile 统一(第 13 章 monorepo);拆包回归矩阵
回滚机制从未演练 出事时发现回滚指针从未生效/上报缺失 回滚代码平时不运行,缺陷不可见 发版前 staging 必演练回滚剧本;演练记录进 checklist
热更绕过审核改核心业务 审核被拒甚至下架 用热更解锁审核时不存在的能力(新变现路径、新业务形态) 审核期关闭热更;改动分流表把「新能力」判给发版通道
差量更新依赖本地缓存哈希表 差量下载永远退化为全量,流量翻倍 本地模块哈希记录未持久化或版本切换时被误清 哈希清单与 bundle 目录同生命周期持久化,清理时一起管理
拿 JS 源码包当热更产物 自建通道热更「成功」,重启却加载失败回退嵌入包,日志报非法字节码 Hermes 只认 .hbc 字节码,运行时不解析 JS 源码(见 18.1) CI 用 hermesc 出字节码产物;清单携带 engine 版本并参与签名;下载前校验引擎匹配

最佳实践

  • 先记判定表再谈技术:触碰原生(新模块/权限/依赖/协议迁移)一律发版;纯 JS 且不改变核心用途才进热更通道
  • runtime version 用 fingerprint 策略,CI 产出指纹并校验,PR 改动原生依赖必须标注基线变更
  • 清单必须私钥签名、客户端内置公钥验签;哈希只管完整性,签名才管可信性;私钥只放发布基础设施并配轮换预案
  • 热更产物是与原生 Hermes 版本匹配的 .hbc 字节码(CI hermesc 出包);清单显式携带 engine 版本并参与签名,下载前校验引擎匹配
  • 下载器五件套:https + host 白名单前置校验、Range 断点续传、磁盘空间预检、流式落盘、先比大小再比哈希;重试复用第 15 章退避抖动
  • 切换必须原子:独立目录 + 校验通过后一次 rename 写指针;保留上一良好版本作为回滚底座,清理上限 N=2
  • 启动哨兵 + 连续崩溃计数(阈值 2)自动回滚,「启动成功」以 JS 首帧为准,两端协议一致
  • 热更新逻辑一律 feature flag 包裹:默认值进 git 随评审变更,远端可秒级停用,回退优先「翻 flag」而非整包回滚
  • 灰度按用户哈希稳定分桶(sticky),服务端清单层控制放量;bundle 崩溃率高于嵌入包基线 2 倍自动冻结
  • 监控四指标(检查/下载成功/生效/按 bundle 版本崩溃率)缺一不可,Sentry release 名带 bundleId
  • 生产方案优先成熟实现(expo-updates 口径),自建只作为原理与兜底能力;换任何服务商前先审计它的清单签名与回滚机制
  • 更新清单与 CDN 域名纳入 SSL Pinning、证书到期监控与紧急关停开关,与 API 域名同一份配置管理(第 19 章)
  • 新架构下不做运行时多 bundle 拼接:启动诉求用懒加载 require 解决,业务隔离用多 channel 全量包近似,多 bundle 等官方稳定支持
  • 每次发版前在 staging 演练回滚剧本并留档;未演练的回滚等于没有回滚
  • 审核期与政策收紧期可一键关闭热更(构建期注入 + 远端 gate 双层开关)

本章小结

本章从「RN 是原生二进制 + JS bundle 的分层结构」这一事实出发,建立了热更新的完整心智模型:JS 与资源可换、原生不可换的根因;「清单 → 下载 → 校验 → 原子切换 → 重启 → 回滚」的生命周期;Apple 3.3.2(编号以最新协议为准)与国内商店划定的合规边界;expo-updates 的 channel/runtime version/差量下载/回滚机制与 CodePush 退役后「原理比服务重要」的启示;自建系统的四个关键件——签名清单、原子切换、启动哨兵、稳定分桶灰度;以及 Metro 模块图层面拆包的收益与高风险区,和「单 bundle + 懒加载 + 全量热更」的现实主义答案。FieldNote 用一张更新策略表、一个 fingerprint 基线策略、一次带回滚演练的灰度、一份巡检扩展模块的 ADR,把这四件事落成了可执行的制度。

一句话记忆点:热更新的本质是「把信任根从服务器搬进客户端」——哈希管完整性,签名管可信性,哨兵管可用性,制度管合规性。

动手练习

  1. 基础巩固:为 FieldNote 实现一个 bundle-verifier 脚本:输入 bundle zip 与更新清单 JSON,完成 SHA-256 校验并输出结果;再把清单验签补上(用 Node 的 crypto 模块生成一对 ECDSA P-256 测试密钥,模拟发布端签名与客户端验签两侧)。要求私钥只出现在发布端脚本中。
  2. FieldNote 实战:给 FieldNote 的热更系统补全「启动哨兵」的 JS 侧:实现 markBootSuccess(首帧渲染后调用,清除 pending 并清零计数)与 reportCrashLoop 的埋点事件;并写一个 staging 集成测试——下发一个启动即抛错的测试包,断言连续两次启动后回滚事件被上报、active 指针回到 previous。
  3. FieldNote 实战:巡检扩展模块上线两周后,某客户报告「模块入口点了没反应」。已知该客户 flag 已开通、热更生效率 100%。请设计排查路径:入口组件的渲染条件、懒加载 require 是否抛错(错误边界如何捕获)、模板 JSON 下载失败时模块的降级行为;给出需要埋的三个点位让这类问题下次能直接从监控定位。
  4. 排查/思考题:为什么「校验 bundle 哈希」不能防住「更新清单服务被劫持」的攻击?要防住需要引入什么机制(说明信任根放在哪里、私钥如何保管与轮换)?再想一想:这套「客户端内置可信公钥、服务端无私钥即无法伪造」的思想,与第 19 章应用安全加固中的哪个机制是同构的(提示:第 17 章 APK 签名、第 19 章的证书锁定都指向同一个词——信任链的根应该在哪里)?

导航

上一章:第 17 章 应用打包与发布 | 下一章:第 19 章 应用安全加固

📄 第 19 章 应用安全加固 19-security-hardening.md

本章导读:全书最后一章。第 17 章解决了「怎么把 App 安全地送到用户手里」,第 18 章解决了「JS 缺陷如何不经过商店当天修复」——本章回答最后一个问题:当攻击者直接面对你发布的安装包时,你能抬高多少攻击成本。前两章的签名体系与清单验签在这里被整合进完整的威胁模型;第 14 章的存储安全(Keychain/Keystore、字段级加密、日志脱敏、grep 凭据门禁)在本章扩展为端到端的防线。学完本章并完成 FieldNote 落地,即达成里程碑 M19,全书实战闭环。

本章学习目标

  • 能按「资产 → 攻击者能力 → 防护措施」的方法为移动 App 做威胁建模,并理解「安全是概率与成本问题」的现实主义立场
  • 能说清移动 App 的四类攻击面(逆向分析、流量窃听篡改、本地数据窃取、运行时篡改/调试)各自的技术路径与对策边界
  • 能评估 JS 层防护的真实收益:Metro minify 的能力边界、控制流扁平化的代价、bundle 加密的本质限制
  • 能实现二次打包校验:Android 签名指纹比对与 iOS 代码签名信息校验的双端完整代码
  • 能实现 SSL Pinning(公钥锁定)双端方案,并配齐证书到期监控与远端紧急关停开关
  • 能设计并实现防重放请求签名协议(时间戳 + nonce + HMAC),密钥走安全存储、源码零字面量
  • 能落实数据与隐私合规:权限最小化、采集最小化、日志脱敏自动化、隐私文案与实际行为一致
  • 能建立上线后的安全响应流程(热更 vs 发版决策)与渗透自测方法论(抓包/反编译/篡改三视角)

核心知识点

19.1 威胁建模先行:先想清楚防谁,再谈怎么防

多数团队的第一次安全加固是被动的:「听说要加混淆」「商店审核要隐私政策」——零散的措施堆在一起,防住了没人攻击的地方,漏掉了真正的要害。正确顺序是反过来的:先建模,再加固。威胁建模只需要回答三个问题:

  1. 资产:什么东西泄露或被破坏后会产生真实损失?(不是所有数据都值得同等保护)
  2. 攻击者能力:谁能接触到什么?一个拿到二手手机的普通人、一个在同一 Wi-Fi 下抓包的人、一个会逆向工具链的专业团队,能力完全不同。
  3. 防护措施:针对「这种能力 × 这个资产」的组合,选性价比最高的对策。

移动 App 的攻击面可以收敛为四类,本章后续所有手段都能挂到这四类上:

攻击面 攻击者做的事 典型工具/路径(类别) 对策归属
逆向分析 解包看代码、找密钥、改逻辑重打包 反编译工具、资源解包、字符串提取 19.2 / 19.3
流量窃听篡改 在网络路径上偷看、篡改、重放请求 抓包代理 + 自签 CA 19.4
本地数据窃取 拿到设备或备份文件后读沙盒 备份提取、root/越狱文件访问 19.5(+第 14 章底座)
运行时篡改/调试 把 App 跑在受控环境里挂钩子、改内存 动态注入框架、模拟器集群 19.2 / 19.3 / 服务端风控

【重点】先建立一个贯穿全章的图景:同一条数据在端上有四种存在状态,每种状态的攻击者能力不同,所以防护手段完全不同。很多「安全方案看起来做了但没用」的事故,根因都是把某个状态的防护错配到了另一个状态(比如给磁盘数据上了 HTTPS、给内存数据上了加密)。

flowchart LR
    P[巡检数据产生<br>拍照 / 评分 / 备注] --> M[状态一 内存<br>明文存在无法避免<br>生命周期最短化<br>日志出口统一脱敏]
    M --> D[状态二 磁盘<br>SQLite 字段级加密<br>照片进私有目录<br>凭据只进 Keychain/Keystore]
    D --> N[状态三 网络<br>TLS + 公钥锁定<br>请求签名防重放<br>短时 Token]
    D --> B[状态四 备份与迁移<br>iOS THIS_DEVICE_ONLY<br>Android allowBackup=false<br>退出登录全量清除]

逐态解释:内存态无法加密——CPU 执行明文指令、渲染明文文本,任何「内存加密」在执行瞬间都要先解密,所以内存态的目标不是保密而是缩短暴露时间窗(用完即释放、不打印原文)。磁盘态是第 14 章的主战场:按敏感度分层落地(Keychain/Keystore 存凭据、字段级加密存隐私列、SQLite/MMKV 存业务数据)。网络态是 19.4 的主战场。备份态最容易被遗忘:iOS 备份会带走 Keychain 之外的一切沙盒内容、Android allowBackup=true 时 adb backup/云备份同样如此——敏感数据要么靠访问等级排除出备份,要么靠加密让备份中的密文不可读。

用这套方法给 FieldNote 做第一轮快速建模(四大核心资产,完整六资产版见 M19-1):

资产 攻击者能力假设 主要威胁 对策
巡检照片 拿到备份文件或 root 设备 照片含门店内部环境与 EXIF 定位 私有目录 + 备份排除 + EXIF 策略(19.5)
客户信息(联系人/备注) 读沙盒数据库 隐私字段明文落盘 字段级加密(第 14 章)+ 日志脱敏
Token / 刷新凭据 备份提取 + 抓包 凭据重放冒充用户 Keychain/Keystore + 短时 Token + 请求签名(19.4)
同步数据(Outbox/SQLite) 离线设备物理接触 拿到未同步的业务明文 库选型分层(第 14 章)+ 设备管理策略

最后是立场问题,它决定你对后面每一节的态度:安全是概率与成本问题,不是有/无的开关。客户端代码完整地运行在攻击者控制的设备上,密钥、算法、校验逻辑最终都存在于设备的某个角落——理论上任何端上防护都能被足够强的攻击者绕过。所以加固的目标从来不是「绝对安全」,而是把攻击成本抬高到收益之上:让「随手为之」的攻击者放弃,让自动化工具链失效,把剩余风险交给服务端风控与监控兜底。用这个标尺衡量,你就能理解本章每个手段的定位:混淆买的是「阅读成本」,Pinning 买的是「抓包成本」,签名校验买的是「重打包成本」——没有一项买的是「绝对」。

19.2 JS 层防护:混淆、加密与完整性自检

先校准预期:JS 层混淆的真实收益比你以为的低。RN 发布构建的默认链路是 Metro 打包 + Terser 压缩(改名、去空白、死代码消除)+ Hermes 编译为字节码。这条链路本身就完成了变量名混淆——那还需要什么?问题在于混淆的能力边界:

  • 字符串不会被混淆。API 路径、埋点事件名、存储键名、协议字段名都是字符串字面量,而业务逻辑的绝大多数「情报」恰恰在字符串里。攻击者反编译后 grep 字符串,比读代码快得多。
  • 属性名与跨语言接口名不能改。新架构下 JS 与原生通过 JSI 按名字符串约定接口(TurboModule 名、方法名),混淆器不敢动它们,等于把模块清单直接暴露。
  • 控制流扁平化代价极高。把 if/while 重写成 switch 状态机能显著抬高静态阅读成本,但代价是:字节码体积膨胀、解释执行性能下降(Hermes 无 JIT,每条多余指令都是真金白银的 JS 线程时间,第 5 章的启动预算会被直接吃掉)、崩溃栈与 sourcemap 对不上导致线上问题不可定位。
手段 抬高什么成本 真实收益 代价 结论
Metro/Terser 默认 minify 阅读逻辑 低(默认就有,字符串全保留) 无 保留
字符串加密/数组化插件 字符串静态搜索 低-中(运行时必然还原,hook 一次全出) 体积+启动性能+可调试性 一般不用
控制流扁平化 静态逻辑分析 中 字节码膨胀、JS 线程性能、崩溃定位崩塌 仅核心算法局部使用
bundle 加密 直接解包读 bundle 中 启动解密开销、密钥仍在端上 按行业要求决策
原生商业加固(19.3) DEX/SO 层逆向 高 启动开销、RN 兼容风险 有预算与合规诉求时评估

bundle 加密:值得做,但要知道它防的是什么。实现思路是:bundle 以密文形态随包分发(或热更下发),原生层持有解密密钥(Keystore/Keychain 保护),启动时解密后交给引擎:

// android/.../EncryptedBundleHost.kt(教学骨架)
class EncryptedBundleHost(private val ctx: Context) {
    fun resolveBundlePath(): String {
        val encrypted = File(ctx.filesDir, "bundles/app.bundle.enc")
        if (!encrypted.exists()) return "assets://index.android.bundle"  // 嵌入明文包兜底
        val key = BundleKeyManager.loadKey()          // 密钥在 Keystore,代码与配置里都没有
        val plain = File(ctx.cacheDir, "app.bundle.tmp")
        AesGcm.decryptToFile(encrypted, plain, key)   // 启动解密:耗时与包体积线性,约几十毫秒级
        plain.deleteOnExit()                          // 引擎读完后即删,缩小明文窗口
        return plain.absolutePath                     // ReactNativeHost 的 getJSBundleFile 返回此路径
    }
}

逐段解释:密钥由原生 BundleKeyManager 从 Keystore 派生(不可导出密钥包裹数据密钥,第 14 章 Keystore 模式),JS 侧全程接触不到;解密产物写临时文件交给引擎,deleteOnExit 在进程退出时清除。⚠️ 两个必须正视的限制:其一,明文窗口无法归零——引擎执行时 bundle 必然以明文存在于内存,动态注入框架 hook 一次解密函数即可拿到明文;其二,冷启动成本——解密时间叠加在第 5 章的启动预算上,低端机必须实测。还有一个容易被忽略的合规维度:涉及密码学技术实现的应用在部分市场/渠道有登记或申报要求,落地前以最新监管要求为准确认。

它的真实定位是:防「解包即得」的大众化攻击与竞品直接抽取代码,不防专业逆向。值得做的条件(思考题会再回到这里):行业合规明确要求、bundle 内含真实经济价值的算法/内容、且你能接受启动开销——三者至少居其一。

二次打包校验:本章 JS 层唯一「必须做」的事。热更新之所以安全,是因为第 18 章把信任根放在了签名私钥;而安装包本身的信任根是操作系统在安装期验证的签名。攻击者改完代码重新打包,必须用自己的证书重签——于是「当前运行包的签名 ≠ 发布方签名」就成了可检测的信号。双端实现:

// android/app/src/main/java/com/example/fieldnote/security/IntegrityGuard.kt
package com.example.fieldnote.security

import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import java.security.MessageDigest

/**
 * 二次打包校验:启动时比对「当前安装包的签名证书指纹」与构建期注入的期望值。
 * 期望指纹由 CI 从发布 keystore 导出后注入 BuildConfig——源码里只有占位符。
 */
object IntegrityGuard {

    data class VerifyResult(val isValid: Boolean, val reason: String)

    fun verify(context: Context): VerifyResult {
        val pm = context.packageManager
        val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            val info = pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
            info.signingInfo?.takeIf { !it.hasMultipleSigners() }?.signingCertificateHistory
        } else {
            @Suppress("DEPRECATION")
            pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES).signatures
        } ?: return VerifyResult(false, "no_signature")

        // 计算当前签名证书的 SHA-256 指纹:签名链上第一张证书即发布证书
        val digest = MessageDigest.getInstance("SHA-256")
            .digest(signatures.first().toByteArray())
        val current = digest.joinToString("") { "%02x".format(it) }

        val expected = BuildConfig.SIGNING_FINGERPRINT
        return VerifyResult(
            isValid = current.equals(expected, ignoreCase = true),
            reason = if (current.equals(expected, true)) "ok" else "signature_mismatch",
        )
    }
}
// android/app/build.gradle(defaultConfig 片段)——指纹来自 CI Secret,仓库只有占位符
def signingFp = System.getenv("FN_SIGNING_FINGERPRINT")
        ?: "0000000000000000000000000000000000000000000000000000000000000000"
android {
    defaultConfig {
        buildConfigField "String", "SIGNING_FINGERPRINT", "\"${signingFp}\""
    }
}
// ios/FieldNote/Security/IntegrityGuard.swift
import Foundation
import Security

enum IntegrityGuard {
    /// 期望 TeamIdentifier 经 xcconfig 从 CI 注入 Info.plist,不进源码
    static func verify() -> (isValid: Bool, reason: String) {
        guard let bundleURL = Bundle.main.bundleURL else { return (false, "no_bundle") }

        var staticCode: SecStaticCode?
        guard SecStaticCodeCreateWithPath(bundleURL as CFURL, [], &staticCode) == errSecSuccess,
              let code = staticCode else { return (false, "code_create_failed") }

        // 第一步:校验签名结构自洽(资源是否被改过)。注意:攻击者重签后签名依然自洽,
        // 这一步只能抓「改了没重签」的粗心篡改,不是真正的防线
        guard SecCodeCheckValidity(code, [], nil) == errSecSuccess else {
            return (false, "signature_invalid")
        }

        // 第二步:提取签名中的 TeamIdentifier 与期望值比对——防第三方重签的真正检测点
        let flags = SecCSFlags(rawValue: kSecCSSigningInformation)
        guard let infoCF = SecCodeCopySigningInformation(code, flags)?.takeRetainedValue(),
              let info = infoCF as? [String: Any],
              let teamId = info[kSecCodeSignerInfoTeamIdentifier as String] as? String else {
            return (false, "no_team_id")
        }
        let expected = Bundle.main.object(forInfoDictionaryKey: "ExpectedTeamIdentifier") as? String ?? ""
        return teamId == expected ? (true, "ok") : (false, "team_id_mismatch")
    }
}

逐段讲解三个关键点。Android 侧:GET_SIGNING_CERTIFICATES(API 28+)拿到的是安装时系统记录的签名链——注意它读的是系统已验证过的安装信息,不是重新解析 APK 文件,因此无法被「改包不改系统记录」绕过;指纹即发布证书的 SHA-256,与第 17 章 keystore 一一对应,CI 在签名后用 keytool -list 导出并注入。iOS 侧:SecCodeCheckValidity 只回答「签名结构是否自洽」,重签后的包依然自洽,所以真正的检测点是 TeamIdentifier 比对——每个开发者账号的 Team ID 是唯一的,第三方重签必然换成他自己的 Team ID。失败后的行为:不要闪退、不要砖机(见 19.6 的渗透自测——攻击者 patch 掉一个「失败即退出」的 if 是 5 分钟的事),正确做法是上报事件 + 按服务端风控决策降级,让服务端把不可信设备的 Token 吊销掉——信任判定收归云端,端上信号只是输入。别忘了给 IntegrityGuard 加 keep 规则:它是按类名注册的原生模块,R8 改名即失效(第 17 章规则)。

至于 root/jailbreak 检测:它的价值是风险信号而非防线——检测手段与隐藏手段(Magisk 模块类方案、越狱隐藏插件)是持续多年的猫鼠游戏,误报率也不为零。合理用法:检测结果作为事件上报,参与服务端风险评分(root 设备 + 异常地理位置 + 频繁签名失败 → 高风险),绝不用它做唯一的功能门禁。

19.3 原生层防护:R8 的贡献与局限、商业加固与供应链

R8 对安全的贡献是真实但有限的。第 17 章已经配置了 R8:树摇删除不可达代码、混淆把 com.example.fieldnote.sync.SyncEngine 变成 a.b.c。对攻击者而言,R8 的贡献是:类名/方法名不可读、未使用的调试逻辑被删除、配合 mapping.txt(它本身也是敏感文件,只应在你的发布基础设施与监控平台,不要进公开渠道)。局限同样明显:R8 不混淆字符串常量——buildConfigField 里的 API 地址、strings.xml 里的第三方 key、注解与反射点全部原样保留;也不做控制流保护。一句话:R8 是「免费附赠的阅读成本」,不是保密方案。由此推出一条工程红线:任何凭据都不该指望 R8 替你藏住——它藏不住。

商业加固方案:只讲类别,不背书厂商。国内市场有一类商业产品(360 加固、腾讯乐固、爱加密等均属此类)在 R8 之上提供更强的原生层保护,技术手段大致三类:

类别 原理 防护对象 与 RN 的关系
DEX 加壳/抽取 加密 classes.dex,运行时由壳程序解密加载;抽取式进一步把方法体抽走、执行时逐个回填 DEX 静态反编译 与 RN 无直接冲突,但壳会接管启动流程
VMP 虚拟化 把选定方法编译为自定义虚拟机指令,运行时由内置 VM 解释执行 核心算法逻辑 成本高,只对少量方法使用
SO 混淆 对 native 库做控制流/字符串混淆(OLLVM 类技术) C/C++ 层(Hermes 引擎、自研 so) Hermes so 体积大,混淆后启动开销需实测

⚠️ 加壳与 RN 的兼容性坑是采用前必须实测的:其一,壳程序通常替换 Application 入口并在启动早期介入——而 RN 的启动路径上有 SoLoader 加载、ReactNativeHost 初始化、以及第 18 章的热更 bundle 路径选择逻辑,全部挂在 Application 生命周期上,壳的介入顺序可能破坏它们(典型症状:加固后热更包加载失效、启动耗时翻倍);其二,壳的解压/校验时间直接叠加在冷启动预算上,对第 5 章的 1.5s 线是真实威胁;其三,壳对 so 的处理与 64 位、分架构拆分(AAB 按设备下发)的兼容性要按目标渠道逐个验证。结论与 18.5 的多 bundle 决策同构:有明确的行业/客户合规要求、且预算与测试能力跟得上,才上商业加固;上了就必须把「加固版 × 双平台 × 热更链路」纳入回归矩阵。iOS 侧对应的是符号混淆与商业壳(运行时防护/反调试类),同样只作类别性认知——iOS 系统的签名与沙盒模型本身就是更强的一层,App Store 分发的包被重签篡改的门槛天然高于国内 Android 渠道包。

安全不是清单而是流程——流程里两件必须制度化的事:

第一,依赖审计。现代 RN 项目的依赖树动辄上千个包,供应链是真实攻击面(typosquatting 抢注相似包名、维护者账号被盗推恶意版本、postinstall 脚本执行任意代码)。工程化落地三件套:

# .github/workflows/security.yml(片段)—— 依赖审计进 CI 门禁
- name: 依赖审计
  run: pnpm audit --prod --audit-level high     # 高危及以上直接失败
- name: lockfile 变更复核
  run: |
    if ! git diff --exit-code origin/main -- pnpm-lock.yaml; then
      echo "⚠️ lockfile 有变更:PR 描述必须说明新增/升级的依赖与理由"
    fi

配套纪律:最小依赖原则(能用 30 行自研代码解决的不引一个新包,第 13 章模块化让这件事有了归属地);新包引入前看三样——周下载量与维护活跃度、是否声明 postinstall 脚本、package name 是否与知名包高度相似;pnpm 可对不可信包禁用生命周期脚本;lockfile 是唯一事实源,CI 与本地必须逐字节一致。

第二,密钥轮换预案。密钥体系不是建好就完,每个密钥都要回答「泄露了怎么办」:

密钥 泄露影响 轮换机制
Android 发布 keystore 无法给存量用户发更新(第 17 章) v3 签名密钥轮换链;Play 渠道走 Play App Signing 的 upload key 重置
热更清单签名私钥(第 18 章) 可向全量用户植入任意 JS 客户端内置新旧公钥集合并行验签,新私钥生效后旧公钥随下个版本退役
请求签名设备密钥(19.4) 冒充该设备请求 服务端吊销 + 强制重新登录下发新密钥
SSL Pinning 公钥 (公钥无泄密面,只有遗失风险) 离线多份冷备;轮换流程见 19.4
第三方服务 API key 被盗用产生费用 在服务商侧限定域名/应用 + 定期轮换

这份表本身要进团队文档并指定每一项的负责人——没有 owner 的轮换预案等于没有预案。

19.4 网络层防护:Pinning、请求签名与凭据最小化

HTTPS 是底线,不是防护。TLS 解决的是「传输路径上的陌生人无法偷看篡改」,但它有两个天然缺口:其一,HTTPS 验证的是「证书链可信」,而证书可信 ≠ 证书是你的——设备上被安装信任的 CA(企业代理、用户配置描述文件、被导入的系统证书)签出的「合法证书」照样通过校验;其二,HTTPS 对「合法客户端里的恶意逻辑」无能为力——拿到凭据的攻击者根本不需要破 TLS,直接用你的 API 就行。所以网络层的纵深是三层:TLS 打底 → Pinning 把「谁有资格给我发证书」收窄到我的 CA → 请求签名把「谁有资格调用 API」收窄到持有设备密钥的客户端。

SSL Pinning:证书锁定 vs 公钥锁定。锁定的对象是关键选择:

维度 证书锁定(pin 整张证书) 公钥锁定(pin SPKI)✅ 推荐
锁定对象 证书 DER 的哈希 证书内公钥(SubjectPublicKeyInfo)的哈希
证书续期(同钥换证) ❌ 立即失效 ✅ 不受影响
换密钥对 ❌ 失效 ❌ 失效(必须预铺新 pin)
运维风险 高(续期即事故) 中(换钥才需预案)
适用 内部固定证书/私有 CA 公网生产域名的默认选择

选择公钥锁定的原因很直接:证书会到期续期,但密钥对通常多年不变——pin SPKI 让「每年换证书」这种高频运维操作不再触发事故。实现上的铁律是至少两个 pin:当前主公钥 + 离线保管、从未部署的备份公钥。只 pin 一个 key 等于把「证书轮换」升级成「全量断网」单点。

Android 侧(OkHttp CertificatePinner,自动对证书链逐张计算 SPKI 哈希比对)与 iOS 侧(URLSession delegate 手动校验):

// android/.../network/NetworkModule.kt —— pin 配置来自 SecurityConfig(远端可覆盖,见 M19-3)
fun buildOkHttp(securityConfig: SecurityConfig): OkHttpClient {
    val builder = CertificatePinner.Builder()
    if (securityConfig.pinningEnabled) {
        for (pin in securityConfig.pins) {          // 每个域名主 + 备两条 "sha256/<base64>"
            builder.add(pin.host, pin.sha256)
        }
    } // pinningEnabled=false:紧急关停,退化为仅系统校验(不添加任何 pin)
    return OkHttpClient.Builder()
        .certificatePinner(builder.build())
        .addInterceptor(ErrorReportInterceptor())   // 捕获 SSLPeerUnverifiedException 上报 pin 失败事件
        .build()
}
// ios/FieldNote/Network/PinningSessionDelegate.swift
import CryptoKit
import Foundation
import Security

final class PinningSessionDelegate: NSObject, URLSessionDelegate {
    var config: SecurityConfig     // 与 Android 共用同一份远端/默认配置

    func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
                    completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
        guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
              let trust = challenge.protectionSpace.serverTrust else {
            completionHandler(.performDefaultHandling, nil); return
        }
        // 第一步:系统证书链校验。Pinning 不替代系统校验,是叠加其上的额外约束
        var cfError: CFError?
        guard SecTrustEvaluateWithError(trust, &cfError) else {
            completionHandler(.cancelAuthenticationChallenge, nil); return
        }
        let host = challenge.protectionSpace.host

        // 第二步:紧急关停开关——远端关闭时退化为仅系统校验,并上报降级事件
        guard config.pinningEnabled, !pinnedHashes(for: host).isEmpty else {
            Telemetry.report("pinning_disabled", host: host)
            completionHandler(.useCredential, URLCredential(trust: trust)); return
        }

        // 第三步:公钥锁定——证书链上任一证书的 SPKI 指纹命中配置 pin 即通过
        guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
              chain.contains(where: { pinnedHashes(for: host).contains(spkiSha256($0)) }) else {
            Telemetry.report("pin_failure", host: host)   // 上报后取消,不给降级请求
            completionHandler(.cancelAuthenticationChallenge, nil); return
        }
        completionHandler(.useCredential, URLCredential(trust: trust))
    }

    private func pinnedHashes(for host: String) -> [String] {
        config.pins.filter { $0.host == host }.map(\.sha256)
    }

    /// SPKI SHA-256 = ASN.1 头 + 裸公钥字节的哈希(与 Android/openssl 算法同源,结果可互换)
    private func spkiSha256(_ cert: SecCertificate) -> String {
        guard let key = SecCertificateCopyKey(cert),
              let keyData = SecKeyCopyExternalRepresentation(key, nil) as Data? else { return "" }
        // ASN.1 头与密钥类型/长度相关:此处示例 EC P-256 的 26 字节固定前缀;
        // RSA-2048 等其他类型的前缀不同,生产实现建议直接采用成熟库(TrustKit 类)的映射表
        let ecP256Header: [UInt8] = [0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE,
                                     0x3D, 0x02, 0x01, 0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D,
                                     0x03, 0x01, 0x07, 0x03, 0x42, 0x00]
        var spki = Data(ecP256Header)
        spki.append(keyData)
        return Data(SHA256.hash(data: spki)).base64EncodedString()
    }
}

逐段讲解:顺序不能反——必须先做系统链校验再做 pin 比对,pin 是叠加约束不是替代(否则一张自签的「匹配 pin 的证书」也能通过)。失败路径必须上报:pin_failure 事件是判断「攻击者中间人」还是「我自己配置事故」的唯一依据。iOS 的 SPKI 计算是两端实现里最容易出错的点:SecKeyCopyExternalRepresentation 返回裸公钥字节,而业界通行的 SPKI 指纹 = 固定 ASN.1 头 + 裸公钥,头按密钥类型区分——这也是生产上建议直接用成熟 pinning 库的原因。

Pinning 的范围必须覆盖「会替你做决策」的全部域名,不能只锁 API 域名。至少三类要纳入同一份 pin 配置与同一套证书到期监控:其一,更新清单服务与 bundle/模板 CDN——第 18 章的更新通道「会替你执行代码」,防护等级只应更高;清单签名能挡住伪造内容,Pinning 是签名之外防流量替换的最后防线。其二,崩溃与监控上报端点。其三,第三方 SDK 实际使用的上报域名——这一类最常被遗漏,评估第三方 SDK 时「它连哪些域名、能否配置代理」是必查项。FieldNote 的 pin 配置表以域名为键集中维护,更新域名与 API 域名共用同一份配置与同一套轮换预案,任何新增域名必须先过 Pinning 评审才允许出现在网络层白名单里。

flowchart TD
    A[发起 HTTPS 请求] --> B[TLS 握手<br>系统证书链校验]
    B -- 失败 --> Z[取消连接<br>上报证书校验失败]
    B -- 通过 --> C{公钥锁定<br>SPKI 指纹命中配置 pin?}
    C -- 通过 --> F[正常请求]
    C -- 失败 --> D{远端开关<br>pinningEnabled}
    D -- true --> Z2[取消并上报 pin_failure]
    D -- false 紧急关停 --> E[降级为仅系统校验<br>上报 pinning_disabled]

Pinning 的运维风险必须用制度对冲。历史上大量团队是被自己坑的:证书续期日一到,所有老版本客户端集体断网。四道保险缺一不可:

  1. 备份 pin:客户端配置里永远有「当前主 + 离线备份」两个公钥的 pin。
  2. 轮换顺序:新公钥的 pin 先随版本铺开 → 监控新 pin 覆盖率 → 覆盖达标后服务器才切换证书 → 老版本靠旧 pin 期间由紧急开关兜底。
  3. 到期监控:CI 定时任务检查证书剩余有效期,低于 30 天告警;并在 staging 用「新证书对现有 pin 配置」预演。
  4. 紧急关停开关:远端配置 pinningEnabled=false 可在分钟级让全量客户端退回系统校验——这是事故时的止血阀,代价(期间可被持合法 CA 证书者抓包)远小于全量断网。
# scripts/check-cert.sh —— CI 定时任务:到期监控 + pin 预演
DOMAIN="api.example.com"
# 1) 到期天数检查(低于 30 天失败告警)
openssl s_client -servername "$DOMAIN" -connect "$DOMAIN:443" </dev/null 2>/dev/null \
  | openssl x509 -noout -enddate
# 2) 计算当前线上证书的 SPKI 指纹——输出必须能在 pin 配置(主或备)中找到
openssl s_client -connect "$DOMAIN:443" -servername "$DOMAIN" </dev/null 2>/dev/null \
  | openssl x509 -pubkey -noout | openssl pkey -pubin -outform DER \
  | openssl dgst -sha256 -binary | openssl base64

防重放:时间戳 + nonce + HMAC 请求签名。Pinning 防的是「路上的人」,请求签名防的是「拿着合法连接发恶意请求的人」。协议设计一句话版:把请求的关键要素拼成一个规范串,用只有端和服务器知道的密钥做 HMAC,服务器重算比对。FieldNote 的规范串约定:

canonical = METHOD + "\n"
          + PATH + QUERY(按字典序) + "\n"
          + TIMESTAMP(秒级) + "\n"
          + NONCE(一次性随机串) + "\n"
          + SHA256_HEX(BODY)          # GET/无 body 时为空串的哈希
signature = BASE64(HMAC-SHA256(DEVICE_SECRET, canonical))
请求头:  X-FN-Timestamp / X-FN-Nonce / X-FN-Signature

服务器四步校验:时间窗(±5 分钟)→ nonce 未出现过(TTL 至少两倍时间窗)→ 重算 HMAC → timing-safe 比对。时间戳挡住窗外的重放,nonce 挡住窗内的重放——只有时间戳会被「窗口内 5 分钟抓包重放」打穿,只有 nonce 会被海量请求撑爆存储,两者缺一不可。三个设计决策值得点名:

  • 密钥从哪来:登录成功后,服务端生成 per-device 随机密钥,经 TLS+Pinning 通道下发给客户端,客户端存入 Keychain/Keystore(下文 M19-2 完整实现)。密钥绝不写进源码、绝不过 JS 常量——bundle 是攻击者第一个解包的东西(19.2 已论证)。
  • 时钟不可信:客户端时钟可以被用户随意改,所以签名时间戳用「登录时获取的服务器时间 + 本地流逝偏移」校准——与第 15 章 serverVersion 用服务端单调版本是同一原则。
  • HMAC 而非 RSA 签名:HMAC 是对称的,服务器拿同一把钥验算,快且实现简单;代价是服务器也能「伪造」,所以 per-device 密钥把影响面限制在单设备。

抓包检测的诚实边界。有团队想加「检测是否挂着代理/抓包工具」的逻辑:检查系统代理设置、检查 VPN 接口、甚至校验用户代理字符串。这些手段的共同问题是全部可绕过(root 设备上 iptables 透明转发不经过系统代理;工具名可以伪装)。它们的定位与 root 检测相同:弱信号上报可以,作为防线不行。真正的答案是把上一层的 Pinning 与签名做扎实——检测不出抓包,但让抓到的包不可读、不可重放、不可篡改。

委托凭据最小化。呼应第 14 章的存储结论:既然凭据必然落在端上(Keychain/Keystore 只能提高提取门槛,不能归零),就把每次暴露的价值压到最低——访问 Token 有效期压到小时级甚至分钟级,刷新令牌长效但一次一换(rotation,每次刷新作废旧令牌),配合服务端设备指纹绑定与异常吊销。攻击者突破一台设备拿到的,是一个小时后作废、且刷新即被风控感知的短时凭据,而不是一把可用一年的万能钥匙。

19.5 数据与隐私合规

权限最小化是合规的起点,也是「防线外包给流程」的典型:混淆和加密防不了「App 自己申请了不该有的权限」这个入口。原则三条:清单与用途一一对应(AndroidManifest/Info.plist 里每个权限都能说出业务理由)、运行时按需申请(在功能使用场景弹窗并先给理由,绝不在启动时打包申请)、拒绝有降级(用户不给权限,功能降级而不是罢工——后者也是商店审核的常见拒绝理由)。

权限 FieldNote 用途 申请时机 拒绝后降级
CAMERA 拍照取证、扫码 进入拍照/扫码页时 手动输入文字描述
精确定位(如启用签到) 辅助门店签到确认 点击「签到」动作时 手动选择门店
通知 同步完成/任务提醒 设置页主动开启 无推送,靠前台刷新
外部存储读写 ❌ 不申请 — 一律使用应用沙盒目录(第 14 章)

采集最小化与文案一致性。合规审查的核心逻辑是对照:隐私政策声明的采集项 ↔ iOS 隐私清单(Privacy Manifest,声明 API 使用类别与数据采集)↔ 商店隐私问卷 ↔ 实际抓包看到的上报内容,四方必须一致。任何「文档说没有、网络里有」的字段都是事故。落地动作:维护一张「采集字段台账」(字段、目的、去向、保留期),每次新增埋点/接 SDK 时同步更新;第三方 SDK 的采集行为要清点进台账(它们在你的进程里发你域名之外的流量——19.6 渗透自测会专门看这个)。

日志脱敏的自动化。第 14 章立了规矩:日志出口统一脱敏、敏感对象只打指纹。本章把它固化成一个不可绕过的封装——所有日志必须经过这一个出口:

// packages/core-domain/src/logger/redact.ts —— PII 与凭据的统一脱敏规则
const RULES: Array<[RegExp, string]> = [
  [/\b(Bearer|Basic)\s+[\w.\-+/=]{8,}/gi, '$1 <REDACTED>'],                       // 认证头
  [/(access[_-]?token|refresh[_-]?token|password|secret|authorization)(["']?\s*[:=]\s*["']?)[^"',}\s]+/gi, '$1$2<REDACTED>'],
  [/\b1[3-9]\d{9}\b/g, '<PHONE>'],                                                // 手机号
  [/\b\d{6}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]\b/g, '<IDCARD>'], // 身份证
];

export function redact(input: string): string {
  return RULES.reduce((s, [re, to]) => s.replace(re, to), input);
}

/** 敏感对象不打原文也不打掩码中间段:打指纹(哈希前 8 位)——可对账、不可复原 */
export async function fingerprint(value: string): Promise<string> {
  const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
  return Array.from(new Uint8Array(buf).slice(0, 4))
    .map(b => b.toString(16).padStart(2, '0')).join('');
}
// packages/core-domain/src/logger/index.ts —— 唯一日志出口:脱敏 → 分级 → transport
import { redact } from './redact';

type Level = 'debug' | 'info' | 'warn' | 'error';
const RELEASE_MIN_LEVEL: Level = 'warn';            // release 构建:debug/info 直接不外发
const LEVEL_ORDER: Record<Level, number> = { debug: 0, info: 1, warn: 2, error: 3 };

function emit(level: Level, msg: string, meta?: Record<string, unknown>) {
  if (LEVEL_ORDER[level] < LEVEL_ORDER[RELEASE_MIN_LEVEL] && !__DEV__) return;
  const line = redact(`${msg} ${meta ? JSON.stringify(meta) : ''}`);   // 脱敏在出口统一做
  if (__DEV__) console[level === 'debug' ? 'log' : level](line);
  nativeLogSink.write(level, line);                 // 原生日志收口,便于按级别清理
}

export const logger = {
  debug: (m: string, meta?: Record<string, unknown>) => emit('debug', m, meta),
  info:  (m: string, meta?: Record<string, unknown>) => emit('info', m, meta),
  warn:  (m: string, meta?: Record<string, unknown>) => emit('warn', m, meta),
  error: (m: string, meta?: Record<string, unknown>) => emit('error', m, meta),
};

逐段讲解:规则表里认证头与凭据类字段直接替换为 <REDACTED>(原文与掩码都不留),手机号/身份证走类别掩码;fingerprint 解决「运维需要核对是不是同一个 Token 但不能看原文」的矛盾。出口层先按级别丢弃再脱敏再分发,保证没有任何一条路径能绕过 redact——这就是 14.5 「日志是经常被遗忘的泄露面」的工程化答案。配合第 14 章的 grep 凭据门禁,形成「源码零字面量 + 日志零明文」的双闸。

国内合规要点概览(以最新监管要求为准的口径):个人信息保护法的框架下,重点盯五件事——明示同意(首次启动隐私政策弹窗,同意前不采集,实测抓包验证)、最小必要(拒绝权限不得导致与该权限无关的功能不可用)、单独同意场景(向第三方提供个人信息等)、SDK 清单披露(嵌入的第三方 SDK 及其采集行为)、未成年人保护。应用商店审核侧的高频拒绝类别:超范围收集、强制索权、隐私政策与实际行为不符、未披露 SDK。这些要点的共同特征是「文案与行为的严格一致」——合规是对照问题,不是安全问题。

19.6 上线后:安全响应与渗透自测

加固不是一次性动作,上线后有两套机制让防线「活」下去。

安全响应流程。三个最小组成:① 漏洞报告渠道——对外发布 security 邮箱(含加密通道说明),对内明确接收人、响应 SLA 与分级标准;② 分级决策表——收到报告后第一步是定位它落在哪一层,走什么通道修复:

漏洞位置 即时止损 修复通道 依据
JS 业务逻辑漏洞 远端 flag 关闭功能 热更 纯 JS、不改变核心用途(第 18 章判定表)
请求签名/协议缺陷 服务端开关临时放宽/拦截 发版 协议实现涉及原生层
Pinning 配置事故 远端紧急关停开关 配置(分钟级) 19.4 运维设计
凭据/密钥泄露 吊销 + 轮换 + 强制重登录 服务端 + 强制升级 19.3 轮换预案
原生依赖 CVE 评估可达性(未必可利用) 发版 原生层无法热更

这张表是第 18 章「热更 vs 发版」判定表在安全语境下的延伸:通道选择的依据不是「哪个快」,而是「漏洞在那一层」——JS 层的漏洞走发版是浪费,原生层的漏洞走热更是自欺。③ 复盘归档——每次事件记录时间线、根因、补偿措施,并回写到附录 A 清单三(checklist 的条目应该随事故增长)。

渗透自测:发版前自己扮演一次攻击者。方法论是三个视角各走一遍核心流程,每条结论都要落成可复测的记录(这就是 M19 的「抓包/反编译/篡改通过」):

# 视角一:抓包——攻击者坐在同一个网络里
# 在测试设备安装代理 CA 后走完登录/同步/拍照上传全流程
# 期望:业务域连接被 Pinning 拒绝(日志出现 pin_failure);开发构建除外
# 同时观察:第三方 SDK 往哪些域名发什么——它们不受你的 pinning 保护,要单独清点

# 视角二:反编译——攻击者拿到了 APK
apktool d fieldnote-release.apk -o out/                 # 解包资源
jadx out/ -d src/                                        # DEX 反编译
unzip -o fieldnote-release.apk "assets/*" -d assets_out/
strings assets_out/assets/index.android.bundle | grep -iE "key|secret|token|http"
strings lib/arm64-v8a/*.so | grep -iE "key|secret|internal\\."   # ⚠️ 原生层也要查
# 检查点:无 API key、无内网地址、无调试凭据;strings.xml/图片元数据一并过目

# 视角三:篡改——攻击者改完重新签名
apktool b out/ -o modified.apk
jarsigner -keystore attacker.jks modified.apk attacker   # 用攻击者自己的证书重签
adb install modified.apk
# 期望:启动完整性自检失败 → 上报 repack_detected → 服务端吊销该设备凭据 → 降级模式

三个视角各有一个最常被漏掉的盲区,单独点名:抓包视角要清点第三方 SDK 流量(你的 API 锁死了,某个 SDK 明文上报设备信息照样泄露);反编译视角要查原生资源而不止 JS——strings.xml 里的 key、图片 EXIF 里的 GPS、so 里的内网地址都是高频泄露点;篡改视角要验证「检测后的闭环」(事件有没有真的到服务端、凭据有没有真的被吊销),只看到客户端弹了个提示不算通过。

典型业务场景:FieldNote 实战

本节对应里程碑 M19:附录 A 第三张清单全绿;渗透自测(抓包/反编译/篡改)通过。

M19-1 威胁建模表(六大资产)

在 19.1 四资产快查表的基础上补全热更信任链与隐私日志两个资产,作为 FieldNote 安全设计的总纲(评审留档,附录 A 清单三逐条对应):

资产 攻击者能力假设 主要威胁 对策(端/管/云)
Token 与刷新凭据 备份提取、root 读文件、抓包 凭据被盗用重放 端:Keychain/Keystore + 短时 Token;管:Pinning;云:设备指纹绑定 + 异常吊销
巡检照片 拿到备份/沙盒文件 门店环境照片 + EXIF 定位外泄 端:私有目录、备份排除、拍摄剥离位置元数据;云:短时签名 URL 下载
客户信息(联系人/备注) 读 SQLite 文件 隐私字段明文落盘 端:字段级加密(第 14 章);管:不进日志(M19-5);云:按角色可见性
同步数据(SQLite/Outbox) 物理接触离线设备 未同步业务数据被读 端:库分层(第 14 章)+ 退出登录清除;云:增量游标绑定账号
热更信任链(清单私钥/bundle) 解包、劫持更新服务 植入恶意 JS 包 端:清单验签(第 18 章)+ 二次打包校验(M19-4);管:Pinning 覆盖更新域;云:私钥托管发布基础设施
用户行为与隐私日志 读设备日志、读监控后台 PII/凭据进日志与第三方监控 端:日志出口脱敏(M19-5);云:监控平台 PII 过滤与保留期策略

M19-2 请求签名模块(双端原生 HMAC + JS 封装)

设计要点在 19.4 已定,这里落完整实现。原则重申:密钥只在原生安全存储中存在,JS 拿不到也传不了明文密钥;签名计算全部下沉原生,JS 只负责组装请求。TurboModule 的 codegen spec 与注册模板见第 7 章,此处聚焦协议:

// packages/native-kit/src/NativeRequestSigner.ts —— codegen spec
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export interface Spec extends TurboModule {
  /** 仅登录成功后调用一次:密钥经 TLS+Pinning 通道下发,存入 Keychain/Keystore,此后 JS 不再持有 */
  storeDeviceSecret(secret: string): Promise<void>;
  /** 签名:原生内部完成 body 哈希与 canonical 拼装,返回 HMAC 签名 */
  sign(method: string, path: string, timestamp: number, nonce: string, body: string): Promise<string>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('RequestSigner');
// packages/native-kit/android/src/main/java/com/fieldnote/security/RequestSignerModule.kt
package com.fieldnote.security

import com.facebook.react.bridge.Promise
import com.fieldnote.nativekit.NativeRequestSignerSpec
import java.security.MessageDigest
import java.util.Base64
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

class RequestSignerModule(reactContext: ReactApplicationContext) :
    NativeRequestSignerSpec(reactContext) {

  override fun getName() = NAME

  override fun storeDeviceSecret(secret: String, promise: Promise) {
    try {
      // SecretStore 为第 14 章 SecureStore 的原生封装:Keystore 不可导出密钥加密后落盘
      SecretStore.write(reactApplicationContext, SECRET_KEY, secret.toByteArray(Charsets.UTF_8))
      promise.resolve(null)
    } catch (e: Exception) { promise.reject("STORE_ERROR", e) }
  }

  override fun sign(method: String, path: String, timestamp: Double,
                    nonce: String, body: String, promise: Promise) {
    try {
      val secret = SecretStore.read(reactApplicationContext, SECRET_KEY)
          ?: throw IllegalStateException("device secret missing")
      val bodyHash = MessageDigest.getInstance("SHA-256")
          .digest(body.toByteArray(Charsets.UTF_8))
          .joinToString("") { "%02x".format(it) }
      val canonical = "$method\n$path\n${timestamp.toLong()}\n$nonce\n$bodyHash"
      val mac = Mac.getInstance("HmacSHA256")
      mac.init(SecretKeySpec(secret, "HmacSHA256"))
      val sig = mac.doFinal(canonical.toByteArray(Charsets.UTF_8))
      promise.resolve(Base64.getEncoder().encodeToString(sig))
    } catch (e: Exception) { promise.reject("SIGN_ERROR", e) }
  }

  companion object {
    const val NAME = "RequestSigner"
    private const val SECRET_KEY = "req_sign_secret"
  }
}
// packages/native-kit/ios/RequestSignerModule.swift
import CryptoKit
import Foundation

@objc(RequestSignerModule)
final class RequestSignerModule: NSObject {
  private static let secretKey = "req_sign_secret"

  @objc static func requiresMainQueueSetup() -> Bool { false }

  @objc(storeDeviceSecret:resolver:rejecter:)
  func storeDeviceSecret(_ secret: String,
                         resolver: @escaping RCTPromiseResolveBlock,
                         rejecter: @escaping RCTPromiseRejectBlock) {
    do {
      try SecretStore.write(Self.secretKey, Data(secret.utf8))   // 第 14 章 Keychain 封装
      resolver(nil)
    } catch { rejecter("STORE_ERROR", error.localizedDescription, error) }
  }

  @objc(sign:path:timestamp:nonce:body:resolver:rejecter:)
  func sign(_ method: String, path: String, timestamp: Double, nonce: String, body: String,
            resolver: @escaping RCTPromiseResolveBlock,
            rejecter: @escaping RCTPromiseRejectBlock) {
    guard let secret = try? SecretStore.read(Self.secretKey) else {
      rejecter("NO_SECRET", "device secret missing", nil); return
    }
    let bodyHash = SHA256.hash(data: Data(body.utf8))
        .map { String(format: "%02x", $0) }.joined()
    let canonical = "\(method)\n\(path)\n\(Int(timestamp))\n\(nonce)\n\(bodyHash)"
    let mac = HMAC<SHA256>.authenticationCode(for: Data(canonical.utf8),
                                              using: SymmetricKey(data: secret))
    resolver(Data(mac).base64EncodedString())
  }
}
// packages/core-domain/src/net/signed-fetch.ts —— JS 侧唯一请求入口
import RequestSigner from '@fieldnote/native-kit/src/NativeRequestSigner';

// 白名单域:只签自家 API。mock server(localhost,第 15 章)与第三方 API 不签
const SIGNED_HOSTS = new Set(['api.example.com']);
let serverTimeOffsetSec = 0;   // 登录响应携带 serverTime 时校准,客户端时钟不可信(第 15 章)

export function calibrateServerTime(serverTimeSec: number) {
  serverTimeOffsetSec = serverTimeSec - Date.now() / 1000;
}

export async function signedFetch(url: string, init: RequestInit = {}): Promise<Response> {
  const u = new URL(url);
  if (!SIGNED_HOSTS.has(u.host)) return fetch(url, init);   // 非白名单域原样透传

  const method = (init.method ?? 'GET').toUpperCase();
  const body = typeof init.body === 'string' ? init.body : '';
  const timestamp = Math.floor(Date.now() / 1000 + serverTimeOffsetSec);
  const nonce = globalThis.crypto.randomUUID();

  const signature = await RequestSigner.sign(method, u.pathname + u.search, timestamp, nonce, body);

  const headers = new Headers(init.headers);
  headers.set('X-FN-Timestamp', String(timestamp));
  headers.set('X-FN-Nonce', nonce);
  headers.set('X-FN-Signature', signature);
  return fetch(url, { ...init, headers, body });
}

服务端(第 15 章的本地 Mock Server)对应验签,用于联调与渗透自测的重放用例:

// mock-server/verify-signature.js —— 验签示意(生产实现放在网关层)
const crypto = require('crypto');
const WINDOW_SEC = 300;
const seen = new Map();   // nonce -> expireAt;生产用 Redis SETNX + TTL

function verify(req, deviceSecret) {
  const ts = Number(req.headers['x-fn-timestamp'] ?? 0);
  const nonce = String(req.headers['x-fn-nonce'] ?? '');
  const sig = String(req.headers['x-fn-signature'] ?? '');
  if (Math.abs(Date.now() / 1000 - ts) > WINDOW_SEC) return { ok: false, reason: 'timestamp' };
  if (seen.get(nonce) > Date.now()) return { ok: false, reason: 'replay' };

  const bodyHash = crypto.createHash('sha256').update(req.rawBody ?? '').digest('hex');
  const canonical = `${req.method}\n${req.url}\n${ts}\n${nonce}\n${bodyHash}`;
  const expected = crypto.createHmac('sha256', deviceSecret).update(canonical).digest('base64');
  const ok = sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));   // 防 timing 侧信道
  if (ok) seen.set(nonce, Date.now() + WINDOW_SEC * 2 * 1000);
  return { ok, reason: ok ? 'ok' : 'signature' };
}

逐段讲解四个必须一致的细节:canonical 逐字节一致——换行符、query 排序、body 编码(UTF-8)任何一侧不一致,验签必挂,联调期最常见 bug 就在这里;body 哈希在服务端从原始字节重算,不信任客户端传来的哈希;timingSafeEqual 防逐字节猜测的侧信道;nonce 存储的 TTL ≥ 2 倍时间窗(窗内防重放所需的最小寿命)。M19 渗透自测对应的三个断言用例:重放 10 分钟前的完整请求 → timestamp 拒;重放 1 分钟内请求 → replay 拒;改 body 中 score 字段后原签名重发 → signature 拒。

M19-3 SSL Pinning 双端落地 + 紧急关停开关

双端共用一份安全配置:构建期注入默认 pin(主 + 备),远端配置可运行时覆盖——默认值保证「服务器不可达时仍安全」,远端覆盖保证「配置事故时能止血」:

// packages/core-domain/src/security-config.ts
export interface PinEntry { host: string; sha256: string }   // "sha256/<base64>",与 openssl 输出同源
export interface SecurityConfig {
  pinningEnabled: boolean;      // 远端紧急关停开关:证书轮换事故时的止血阀
  pins: PinEntry[];             // 主 pin + 备份 pin(备份私钥离线冷备,从未部署)
}

// 构建期注入的默认值:占位指纹,真实值来自 CI Secret(第 14 章零字面量门禁覆盖此文件)
export const DEFAULT_SECURITY_CONFIG: SecurityConfig = {
  pinningEnabled: true,
  pins: [
    { host: 'api.example.com', sha256: 'sha256/<YOUR_PRIMARY_SPKI_BASE64>' },
    { host: 'api.example.com', sha256: 'sha256/<YOUR_BACKUP_SPKI_BASE64>' },
  ],
};

// 远端配置获取:失败时保守回退默认值(宁要安全默认,不要开放默认)
export async function fetchSecurityConfig(): Promise<SecurityConfig> {
  try {
    const res = await fetch('https://config.example.com/security.json');
    const remote = await res.json();
    return { ...DEFAULT_SECURITY_CONFIG, ...remote };
  } catch {
    return DEFAULT_SECURITY_CONFIG;
  }
}

Android 侧消费配置的方式见 19.4 的 buildOkHttp;iOS 侧 PinningSessionDelegate 持有同一份 SecurityConfig(JS 拉取远端后经 TurboModule 下发原生单例)。关停开关的生效路径必须演练一次:staging 把开关置 false → 客户端下次拉取配置后请求走「仅系统校验」→ 日志出现 pinning_disabled 事件 → 恢复 true 后重新生效。没有演练过的止血阀等于没有止血阀(第 18 章回滚演练的同款纪律)。运维三件套照 19.4 执行:备份 pin、轮换顺序(新 pin 随版本铺开 → 覆盖率达标 → 切证书)、check-cert.sh 到期监控进 CI 定时任务。另有一条渗透自测产出:第三方 SDK 域名清单——它们不走你的 pinning,逐个记录域名、用途与传输方式,写进隐私台账。

M19-4 二次打包校验模块

19.2 已给出双端原生实现,这里补 JS 侧编排与失败策略:

// app/src/security/integrity.ts
import IntegrityGuard from '@fieldnote/native-kit/src/NativeIntegrityGuard';

export async function runIntegrityCheck(): Promise<void> {
  const result = await IntegrityGuard.verify();   // 双端实现见 19.2
  if (result.isValid) return;

  // 策略:不闪退、不砖机。攻击者 patch 掉「失败即退出」只需几分钟,
  // 真正的闭环是服务端:吊销凭据 + 按设备拒绝敏感接口
  await reportSecurityEvent('repack_detected', { reason: result.reason });
  applyDegradedMode('readonly');                  // 本地降级:保留离线查看,敏感写入走服务端门禁
}
// 调用点:App 启动流程的延后阶段(第 5 章启动分级:不阻塞首帧,但早于首次同步)

验收动作(M19 渗透自测的「篡改」视角):本地改一个字符串资源 → 用测试证书重签 → 安装启动 → 确认三件事:repack_detected 事件出现在监控后台、Mock Server 拒绝该设备的签名请求、App 进入只读降级而非闪退。同时把 IntegrityGuard、RequestSignerModule 加入 keep 规则(第 17 章 proguard-rules.pro),混淆改名会让按名注册的模块直接失效。

M19-5 日志脱敏封装

19.5 的 redact + 唯一出口 logger 直接落地为 FieldNote 全仓规范,外加两条 FieldNote 特有约束:其一,巡检照片路径只打文件名指纹——路径含门店 ID 与时间,属于业务敏感信息;其二,Sentry 类监控的 beforeSend 钩子复用同一个 redact,保证「本地日志」与「上报崩溃」两个出口同一套规则:

// app/src/monitor/sentry-before-send.ts
import { redact } from '@fieldnote/core-domain/src/logger/redact';

export function beforeSend(event: SentryEvent): SentryEvent {
  if (event.request?.data) {
    event.request.data = redact(JSON.stringify(event.request.data));   // 请求体脱敏后再上报
  }
  return event;   // 崩溃栈本身保留:定位故障需要它(第 17 章符号化的意义)
}

验收动作:debug 与 release 各装一包,走完「登录 → 打分 → 上传」流程后用 adb logcat / Console.app 抽查——找不到 Token、手机号、身份证明文;能看到的是 <REDACTED> 与指纹。

M19-6 附录 A 第三张清单验收记录

M19 的验收标准是「清单三全绿 + 渗透自测通过」,FieldNote 的验收记录如下(逐组对应附录 A 清单三):

分组 关键证据 状态
凭据与密钥 CI grep 门禁绿;反编译 strings 双端复查零命中;密钥清单与轮换预案归档并指定 owner ✅
网络 主备 pin 生效(openssl 预演脚本输出命中配置);关停开关 staging 演练通过;签名重放/篡改/时间窗外三断言用例通过 ✅
本地存储 root 模拟环境抽查沙盒无明文 token;日志抽查脱敏生效;allowBackup=false 与 iOS THIS_DEVICE_ONLY 核对 ✅
逆向对抗 重签包触发上报+降级+服务端吊销闭环;pnpm audit 零高危;第三方 SDK 域名清单入台账 ✅
合规与响应 权限-用途对照表评审通过;隐私政策逐字段对照抓包结果一致;漏洞报告渠道与响应决策表发布 ✅

常见陷阱

陷阱 典型症状 根因 正确做法
把 API key 硬编码当安全(哪怕混淆) 「代码混淆过了所以 key 是安全的」;key 被盗刷计费 R8/Terser 不混淆字符串;bundle 解包后 grep 一眼即得 任何凭据不进源码与构建配置:走服务端代理、密钥服务下发、或直接不引入(19.3)
Pinning 未留紧急开关 证书续期日当天全量用户断网,只能发 hotfix 等审核 只配了一个 pin、没做关停机制与到期监控 主备双 pin + pinningEnabled 远端开关 + 30 天到期监控 + 轮换顺序预案(19.4)
签名算法密钥放进 JS 「防重放」上线后抓包看一眼 bundle 就拿到 HMAC 密钥 把密钥当普通常量写在 TS 里,忘了 bundle 是攻击者第一个解包的东西 密钥走登录通道下发进 Keychain/Keystore,签名下沉原生模块(M19-2)
用混淆代替权限最小化 加固厂商全部拉满,商店审核仍因「申请了不用的权限」被拒 混淆防的是逆向分析,防不了「App 自己索取过多」的入口 权限清单逐条对照业务理由,运行时按需申请,拒绝有降级(19.5)
反编译只查 JS 不查原生资源 JS 干净,但 strings.xml 里有测试环境密码、图片 EXIF 带定位 攻击面认知停留在 JS 层 渗透自测覆盖 DEX、so 的 strings、资源文件与图片元数据(19.6)
合规文案与实际采集不一致 商店审核被拒/被通报;用户投诉「没说过的权限」 隐私政策是模板抄的,没和埋点、SDK 行为做对照 采集字段台账 + 四方对照(政策/隐私清单/商店问卷/抓包),新增埋点同步更新(19.5)
root 检测当唯一防线 检测失败即闪退;被 patch 一次后永久失效且无感知 把「检测到」当成了「防住了」 检测结果作为信号上报,信任判定收归服务端风控,降级而非砖机(19.2)
防重放只做时间戳 抓包后 5 分钟窗口内原样重放成功下单/改分 时间戳挡窗外、nonce 才挡窗内,只做了一个 时间戳 + nonce + HMAC 三件套,nonce TTL ≥ 2 倍窗口(19.4)
把 HTTPS 当已完成的防护 「我们全站 HTTPS 了」;企业代理/用户 CA 环境下流量照样被解密 HTTPS 验证的是证书链可信,不是「证书是我的」 TLS 打底 + Pinning 收窄发证方 + 请求签名收窄调用方(19.4)

最佳实践

  • 先做威胁建模再动手:资产 → 攻击者能力 → 防护措施,每项措施都能回答「它在抬高哪类攻击的哪种成本」
  • 用同一把标尺看所有手段:安全买的是攻击成本不是绝对安全,为「理论可绕过」焦虑,不为「未做」行动
  • 凭据零字面量是硬门禁:源码、构建配置、native 资源三层都过 grep,测试假值规范形态而不是给门禁开口子(第 14 章)
  • JS 层防护保持克制:默认 minify + Hermes 打底,控制流扁平化只考虑核心算法局部;bundle 加密按行业要求与启动预算决策并留档
  • 二次打包校验必做且必闭环:端上检测 + 事件上报 + 服务端吊销 + 降级模式,不闪退不砖机
  • 商业加固(如选用)先过兼容性实测:加固版 × 双平台 × 热更链路 × 启动预算,四项全过再放量
  • 依赖审计进 CI:pnpm audit 高危门禁 + lockfile 变更强制说明 + 新包引入三查(活跃度/postinstall/包名相似性)
  • 密钥清单指定 owner:每把密钥都写清泄露影响与轮换机制,没有 owner 的预案等于没有预案
  • Pinning 用公钥锁定:主备双 pin、新 pin 先铺开再切证书、30 天到期监控、远端紧急关停开关并演练
  • 请求签名三件套不可拆:时间戳挡窗外重放、nonce 挡窗内重放、HMAC 保完整;canonical 规范两侧逐字节一致并用 timing-safe 比较
  • 日志只有一个出口:出口统一脱敏、release 关 debug 级、敏感对象打指纹不打掩码,监控上报复用同一规则
  • 渗透自测三视角发版前必走:抓包(含第三方 SDK 流量清点)、反编译(含原生资源)、篡改(含服务端闭环验证),结论落成可复测记录

本章小结

本章以威胁建模开篇:四类攻击面(逆向、流量、本地数据、运行时)与「数据在端上的四态」构成防护地图,FieldNote 六大资产逐一落地对策。JS 层校准了混淆的真实收益与 bundle 加密的本质限制,把「必须做」收敛到二次打包校验(Android 签名指纹 + iOS TeamIdentifier);原生层厘清了 R8 的边界、商业加固的类别与 RN 兼容性坑,并把供应链审计与密钥轮换制度化;网络层完成了「TLS 打底 → Pinning 收窄发证方 → 请求签名收窄调用方」的纵深,配齐了证书轮换的运维保险;合规侧以「四方对照」统一了权限、采集、日志与文案;最后用响应决策表与渗透自测三视角,把静态防线变成活流程。至此,FieldNote 完成里程碑 M19,附录 A 三张清单支撑全书 19 个里程碑的验收闭环。

一句话记忆点:安全加固是成本工程——威胁建模决定防谁,分层防护抬高每种攻击的价钱,响应流程保证你输给的是攻击者而不是自己的一次证书轮换。

动手练习

  1. 基础巩固:为 FieldNote 的 Mock Server 补全签名验签的测试套件:编写四个用例——合法请求通过、时间窗外重放被拒、窗内重放被拒(nonce)、篡改 body 后原签名重发被拒;并把 timingSafeEqual 换成 === 对比,用循环计时实验观察响应时间差,写一段话说明侧信道的原理。
  2. FieldNote 实战:把 19.4 的 iOS PinningSessionDelegate 补全为可运行的完整模块:实现 SecurityConfig 经 TurboModule 从 JS 下发到原生单例的通道、Telemetry.report 的埋点实现(至少含事件类型、host、时间戳),并在 staging 完成一次「关停开关演练」——记录从远端置 false 到客户端实际降级的延迟,分析这条链路上每个环节的缓存与刷新时机。
  3. FieldNote 实战:对 FieldNote 的 release 包执行一次完整反编译自检(apktool + jadx + strings 三步),建立「泄露物清单」:列出每一处疑似敏感字符串、它所属的层(JS/资源/DEX/so)、判定结果(确认无害/需整改),并对需整改项逐条修复后复测。注意把图片元数据(EXIF)检查纳入清单——巡检照片功能决定了这一步对 FieldNote 不是可选的。
  4. 排查/思考题:为什么「运行时解密 bundle」无法做到绝对安全?请从「密钥最终必须在客户端某处存在」出发完整论证:引擎执行前 bundle 必然经历什么状态?密钥在设备上的哪个环节暴露?动态注入框架最省力的攻击点在哪?论证之后,再回答它的另一面——在什么条件下它依然值得做?(提示:回顾 19.2 的收益标尺与 19.1 的「概率与成本」立场,并想一想第 18 章清单验签与本章哪些机制共享同一个信任根设计。)

导航

上一章:第 18 章 热更新与多 Bundle 拆包 | 下一章:附录 A 优化与发布 Checklist 速查

📄 附录 A · 优化与发布 Checklist 速查 A-checklists.md

如何使用本附录:本附录是全书 19 章的收口工具,包含三张生产级检查清单,对应三类使用时机——**清单一「性能验收清单」**在性能验收时使用(覆盖第 2~5 章,支撑里程碑 M2~M5);**清单二「发布 Checklist」**在每次发版前逐条过一遍(覆盖第 17~18 章,支撑 M17,也覆盖热更发版日);**清单三「安全加固 Checklist」**在上线前一次性全量验收、并在每次安全事件复盘后重查(覆盖第 19 章,支撑 M19)。使用规则三条:第一,勾选即留证——每条勾选必须在验收报告/发版单/PR 描述中留下证据链接(数据、文件、CI 任务、监控截图),无证据的勾选等于没有勾选;第二,条目里标注的「(第 N 章)」是回溯入口,做不出来就回炉对应章节;第三,三张清单不是一次性的——性能门禁应进 CI 或发版前手动步骤,发布与安全清单应随事故复盘持续增补。与里程碑的对应关系:M2~M5 用清单一,M17 用清单二,M19 用清单三(清单三全绿 + 渗透自测通过 = M19 验收,见附录 B)。


清单一 · 性能验收清单(第 2~5 章)

A1.1 测量与基线

  • [ ] 所有数据来自 release 构建 + 真机,debug 包数据不作为验收依据(第 2 章)
  • [ ] 核心三项指标(滚动帧率、页面打开耗时、冷启动)均有 P50/P90/P99 分布,不以平均值替代(第 2 章)
  • [ ] 《性能基线报告》入库(M2),本轮数据与上一版基线对比,无指标回退超过 10%(第 2 章)
  • [ ] 低端机(千元机档)与主流机各测一轮,低端机数据单独记录(附录 B 非功能需求)
  • [ ] 帧率口径统一:以 Perfetto/Instruments 丢帧统计为准而非目测;120Hz 设备按满帧口径记录(第 2、11 章)
  • [ ] 已知非 JS 线程瓶颈(如 WebView、原生过渡页)单独标注豁免范围与理由(第 2 章)
  • [ ] 性能回归门禁已进 CI 或固化在发版前手动步骤,有明确不达标即拦截的线(第 2 章)
  • [ ] 每项优化有前后对照数据,杜绝「感觉变快了」式结论(第 2 章)

A1.2 列表与渲染

  • [ ] 长列表使用 Flash List 类虚拟化方案,估算条目尺寸等关键参数已按列表形态复核(第 3 章)
  • [ ] 1000 条任务 + 缩略图场景滚动 P90 掉帧低于预算线(M3 验收口径)(第 3 章)
  • [ ] renderItem 无闭包重建导致的子组件全量重渲染:子组件 memo 化且 props 引用稳定(第 3、12 章)
  • [ ] 列表数据更新(打分、归档)只重渲染受影响条目,Profiler 录制确认(第 12 章)
  • [ ] 列表图片使用缩略图尺寸而非原图,滚动中低优先级加载(第 3、4 章)
  • [ ] 空态/加载态/骨架屏不引入布局抖动与滚动位置跳动(第 3 章)
  • [ ] 高频交互回调(点击、打分)不阻塞 UI 线程:Reanimated worklet 或稳定引用处理(第 3、9 章)
  • [ ] 滚动期间 JS 线程无长任务:Perfetto 中 JS thread 主 slice 基本落在帧预算内(第 2、3 章)

A1.3 内存

  • [ ] 拍照页反复进出 20 次内存增量 ≤ 30MB(M4 口径),Instruments/Perfetto 证据留档(第 4 章)
  • [ ] 图片缓存有上限配置与淘汰策略,缓存清理入口可用(第 4、14 章)
  • [ ] 原生模块事件流(相机/扫码/定位)在页面卸载时停止,LeakCanary/Instruments 无泄漏报告(第 4、6、7 章)
  • [ ] 全局监听(AppState/Keyboard/事件订阅/Timer)unmount 全部清理,grep 复查无裸订阅(第 4 章)
  • [ ] 大对象(照片 blob、全量 JSON、报表数据)不常驻内存,用后即释放(第 4 章)
  • [ ] 离线同步批量任务有分页与背压,不会一次性载入全量数据集(第 15 章)
  • [ ] 已接入系统内存告警回调:低内存时主动清理内存层缓存(第 4 章)
  • [ ] 内存测试覆盖后台驻留恢复场景:长时间后台后回前台无异常增长(第 4 章)

A1.4 启动

  • [ ] 冷启动 P90 ≤ 预算线(FieldNote:1.5s,千元机档)(M5 验收口径)(第 5 章)
  • [ ] 启动阶段任务已分级:必须/延后/按需,首帧前无延后任务执行(第 5 章)
  • [ ] inlineRequires/懒加载已开启,启动路径无重业务顶层副作用(第 5、18 章)
  • [ ] Hermes 已启用,发布包为字节码产物并抽查确认(第 5 章)
  • [ ] 首帧前的网络请求收敛为首屏必需最小集,其余延后(第 5、12 章)
  • [ ] 启动 trace 已录制并逐段复核:无意外同步 IO、无意外反射与初始化(第 5 章)
  • [ ] 冷启动/温启动/热启动口径分开记录,监控按同口径上报(第 5 章)
  • [ ] 线上启动耗时监控已接,按机型分桶可查(第 5、17 章)

清单二 · 发布 Checklist(第 17~18 章)

A2.1 版本与配置

  • [ ] versionName 语义化、versionCode 单调递增且未复用,与 release tag 同源(第 17 章)
  • [ ] 生产构建指向 prod 环境:API_BASE_URL、Sentry 项目、热更 channel 全部核对(第 17 章)
  • [ ] dev 调试菜单/开发者入口在 release 构建不可达(grep 源码 + 真机手测)(第 17 章)
  • [ ] 本地 Mock Server 地址(localhost)不出现在 prod 配置与白名单域中(第 15、17 章)
  • [ ] 隐私政策版本号与本次功能变更同步更新(第 19 章)
  • [ ] 应用名称、图标、启动屏、商店版本文案已终审(第 17 章)

A2.2 构建与签名

  • [ ] 双端一条命令/CI 流水线出包,无手工步骤残留(第 17 章)
  • [ ] 签名凭据全部来自 CI Secret,仓库 grep 凭据门禁为绿(第 14、17 章)
  • [ ] Android R8 minifyEnabled true,keep 规则经发布版冒烟验证无反射崩溃(第 17 章)
  • [ ] mapping.txt 与版本号绑定归档(第 17 章)
  • [ ] iOS Archive 含 dSYM 且已上传(第 17 章)
  • [ ] JS sourcemap 已上传监控平台(第 17 章)
  • [ ] 出包格式与分发渠道匹配:Play 走 AAB、国内商店走 APK(bundletool 转换路径验证过)(第 17 章)
  • [ ] 构建产物哈希与构建参数记录留档,可追溯到 CI 任务(第 17 章)

A2.3 监控与符号

  • [ ] 崩溃率门禁线已配置,当前候选版本崩溃率低于基线(第 17 章)
  • [ ] 符号三件套(mapping/dSYM/sourcemap)上传完成,并抽测一条线上栈反解成功(第 17 章)
  • [ ] 启动耗时、同步失败率看板在线且数据正常流入(第 17 章)
  • [ ] 告警通知接入值班群,并用一次真实告警验证触达(第 17 章)
  • [ ] Sentry release 命名含 bundleId,热更崩溃可归因(第 18 章)

A2.4 灰度与发布

  • [ ] 灰度起点 5% 且观察 24h,越线自动冻结规则已启用(第 17 章)
  • [ ] 中止线明确并可执行:崩溃率超基线 2 倍 / 启动 P90 超标 / 同步失败率超标(第 17 章)
  • [ ] 强制升级与远端配置 gate 就绪,紧急止血路径演练过(第 17、19 章)
  • [ ] 商店材料(截图、描述、隐私问卷、分级问卷)与当前版本一致(第 17 章)
  • [ ] 回滚预案成文:暂停放量、下架、强制回退的操作步骤与责任人明确(第 17 章)

A2.5 热更新

  • [ ] 热更清单私钥签名 + 客户端内置公钥验签通过,私钥只在发布基础设施(第 18 章)
  • [ ] 热更产物为 .hbc 字节码(CI hermesc 出包),清单 engine 版本与原生一致且在下载前校验匹配(第 18 章)
  • [ ] 下载链路弱网演练通过:Range 断点续传、磁盘预检、下载失败不阻塞启动(第 18 章)
  • [ ] staging 回滚演练:下发坏包 → 连续 2 次崩溃自动回滚 → 恢复上一良好版本并上报(第 18 章)
  • [ ] runtimeVersion 指纹与线上基线核对一致(以 CI 产出为准)(第 18 章)
  • [ ] 新 bundle 按版本崩溃率看板就绪,冻结线与嵌入包基线挂钩(第 18 章)
  • [ ] App Store 审核包已关闭更新检查,审核期热更 gate 确认(第 18 章)
  • [ ] 本地保留版本 N=2 的清理策略生效,磁盘无持续膨胀(第 18 章)
  • [ ] 本次热更内容过改动分流表:纯 JS、不改变核心用途(第 18 章)
  • [ ] 灰度分桶按用户哈希稳定(sticky),无「今天有明天没有」的闪变(第 18 章)

清单三 · 安全加固 Checklist(第 19 章)

A3.1 凭据与密钥

  • [ ] CI grep 凭据门禁绿:api key/secret/password/token 零字面量,测试假值用规范占位形态(第 14 章)
  • [ ] Token/刷新凭据仅存 Keychain/Keystore,不出现在 AsyncStorage/MMKV/SQLite 明文中(第 14 章)
  • [ ] 请求签名设备密钥经登录通道下发并存安全存储,源码与 bundle 中无该密钥(第 19 章)
  • [ ] 反编译产物 strings 复查:无 API key、无内网地址、无测试凭据(JS/资源/DEX/so 四层都查)(第 19 章)
  • [ ] 密钥清单登记完整:发布 keystore、热更私钥、Pinning 备份公钥、第三方 key 均有 owner 与轮换预案(第 19 章)
  • [ ] 测试环境与生产环境凭据隔离,测试凭据泄露不影响生产(第 17、19 章)
  • [ ] 第三方服务 key 在服务商侧限定了域名/应用范围(第 19 章)

A3.2 网络

  • [ ] 生产域启用公钥锁定(SPKI):主 pin + 离线备份 pin 都在配置中(第 19 章)
  • [ ] Pinning 范围覆盖更新清单/CDN 与监控上报域名,更新域名的到期监控与紧急关停开关与 API 域名同源管理(第 18、19 章)
  • [ ] 远端紧急关停开关 pinningEnabled 可用,staging 实测关停后请求恢复、恢复后 pinning 重新生效(第 19 章)
  • [ ] 证书到期监控在跑:剩余有效期 < 30 天告警;check-cert.sh 输出命中 pin 配置(第 19 章)
  • [ ] 请求签名四断言通过:合法请求通过、时间窗外重放拒、窗内重放拒(nonce)、篡改 body 拒(第 19 章)
  • [ ] 明文 HTTP 已禁用:Android networkSecurityConfig + iOS ATS 核对,cleartext 不放行(第 19 章)
  • [ ] 第三方 SDK 流量已清点:域名、用途、是否受 pinning 保护,记录进隐私台账(第 19 章)
  • [ ] 抓包自测:测试设备装代理 CA 后走核心流程,业务域连接被 Pinning 拒绝并上报 pin_failure(第 19 章)

A3.3 本地存储

  • [ ] 敏感字段(备注中的隐私信息)字段级加密,密钥在安全存储(第 14 章)
  • [ ] root/越狱模拟环境下抽查沙盒文件,无明文 token 与敏感业务数据(第 14 章)
  • [ ] 备份排除生效:Android allowBackup=false 或备份规则排除敏感文件;iOS 敏感条目 THIS_DEVICE_ONLY(第 19 章)
  • [ ] 日志出口统一脱敏:PII/凭据正则 + 指纹化,release 关闭 debug 级 transport,监控上报复用同一 redact(第 19 章)
  • [ ] 设备日志抽查(logcat/Console):无 token、无手机号、无身份证明文(第 19 章)
  • [ ] 退出登录清理路径可用:Token、缓存、待同步数据处理策略明确(第 14 章)
  • [ ] 照片位置元数据策略明确:拍摄剥离 EXIF 定位或上报前有明确约定(第 19 章)

A3.4 逆向对抗

  • [ ] 二次打包校验闭环验证:重签包启动 → repack_detected 上报 → 服务端吊销凭据 → 只读降级,全链路实测一次(第 19 章)
  • [ ] root/jailbreak 检测作为信号上报参与风控,未作为唯一功能门禁(第 19 章)
  • [ ] JS bundle 加密决策已留档:做或不做,附行业依据与启动开销实测(第 19 章)
  • [ ] 商业加固决策已留档(如选用):加固版 × 双平台 × 热更 × 启动预算四项兼容实测(第 19 章)
  • [ ] 依赖审计门禁在跑:pnpm audit 高危零容忍,lockfile 变更在 PR 强制说明(第 19 章)
  • [ ] postinstall 脚本清单审查过,不可信包禁用生命周期脚本(第 19 章)
  • [ ] 渗透自测三视角(抓包/反编译/篡改)记录归档,每条结论可复测(M19)(第 19 章)

A3.5 合规与响应

  • [ ] 权限清单与实际使用逐条对照:无「申请了不用」的权限,运行时按需申请且有拒绝降级(第 19 章)
  • [ ] iOS 隐私清单(Privacy Manifest)与第三方 SDK 采集项核对一致(第 19 章)
  • [ ] 隐私政策逐字段对照实际采集行为一致(政策/隐私清单/商店问卷/抓包四方对照)(第 19 章)
  • [ ] 首次启动同意前无采集:抓包实测隐私弹窗确认前无任何上报(第 19 章)
  • [ ] 漏洞报告渠道已发布(security 邮箱与加密通道说明),响应 SLA 与分级标准文档化(第 19 章)
  • [ ] 热更 vs 发版的安全响应决策表并入预案,各漏洞层的止损动作明确(第 19 章)
  • [ ] 个人信息保护影响评估留档,SDK 清单与采集台账随版本更新(第 19 章)

快速定位表

症状 应查清单 具体组
列表滚动掉帧 清单一 列表与渲染(辅查:测量与基线确认口径)
冷启动超标 清单一 启动(辅查:清单二/热更新——是否热更包拖慢启动)
内存持续增长/被杀 清单一 内存
页面打开比上版慢 清单一 测量与基线(先确认是回退还是口径变化)
发布后崩溃栈全是 a.b.c / 内存地址 清单二 监控与符号
灰度期崩溃率越线 清单二 灰度与发布 → 中止线与回滚预案
热更后白屏/崩溃循环 清单二 热更新 → 回滚演练与版本兼容
线上大面积断网(证书轮换日) 清单二 + 清单三 清单三/网络(Pinning 开关与主备 pin)+ 清单二/版本与配置(环境指向)
抓包能解密业务流量 清单三 网络 → Pinning 与抓包自测项
渗透测试发现明文凭据 清单三 凭据与密钥 → grep 门禁与反编译复查项
root 设备提取到敏感数据 清单三 本地存储
重签包未被拦截 清单三 逆向对抗 → 二次打包校验闭环项
商店审核因隐私被拒 清单三 合规与响应 → 四方对照项
依赖包报出高危漏洞 清单三 逆向对抗 → 依赖审计项

📄 附录 B · 贯穿项目需求说明书:轻巡记 FieldNote B-capstone-project.md

1. 项目背景

「轻巡记 FieldNote」是一款面向连锁门店与工程设施的巡检工作台 App。巡检员每天按计划走访门店,逐项检查设备与陈列,拍照取证、扫码识别资产、填写检查项评分,最终生成巡检报告上传后台。

行业现状决定了它的三个硬约束:

  1. 网络不可靠:门店地下室、电梯井、新装修场地经常没有信号,App 必须完全离线可用。
  2. 数据会冲突:同一家店可能有两名巡检员先后检查,补传数据时与后台已更新的记录冲突。
  3. 设备参差不齐:一线员工用的多是低价安卓机,性能与启动速度必须按低端机标准验收。

这三个约束天然覆盖了本书六大主题,因此被选为贯穿项目。

2. 功能需求总览

模块 编号 需求描述 关联章节
登录与安全 F1 账号密码登录 + 生物识别二次解锁(Face ID / 指纹),Token 存安全存储 7、14、19
任务列表 F2 按「今日待巡 / 全部 / 已完成」分组的任务列表,支持千条级数据与缩略图流畅滚动 2、3、4
任务详情 F3 任务信息 + 检查项清单(每项 1~5 分评分 + 备注 + 拍照) 9、12
拍照取证 F4 自定义相机取景器:扫码框高亮、轻按对焦、拍照;照片本地压缩缓存 7、8、14
扫码识别 F5 扫描资产二维码自动定位到对应检查项 6、7
快捷操作 F6 卡片左滑归档/删除(带动效);长按拖拽调整检查项顺序 10、11
主题与设置 F7 深色模式、字体缩放、同步开关;设置项即时生效 12、13
离线同步 F8 无网全功能可用;联网后自动增量同步;后台定时补传 14、15
冲突处理 F9 同一检查项双人评分冲突时按策略合并,必要时提示人工裁决 16
报告与分享 F10 巡检完成生成报告(本地渲染 + 上传) 12
发布运营 F11 双端应用商店上架、灰度发布、崩溃监控、JS 热更新、巡检模块按需下发 17、18
安全加固 F12 防抓包(SSL Pinning)、防二次打包、密钥不落地、日志脱敏 19

3. 非功能需求

  • 性能:中端安卓机(2023 年千元机档)上,冷启动 ≤ 1.5s、任务列表滚动稳定 60fps(120Hz 设备满帧)、拍照页反复进出 20 次内存增长 ≤ 30MB。
  • 可靠性:同步引擎断电/强杀不丢数据;同一操作重放不产生重复记录(幂等)。
  • 体积:基础包 Android ≤ 30MB(AAB 下载体积);巡检扩展模块按需下发。
  • 安全:静态扫描无硬编码凭据;抓包工具默认无法解密流量;keystore 密码只存在于 CI Secret。
  • 兼容:Android 8.0+ / iOS 15+;新旧架构只按新架构(bridgeless)支持。

4. 技术基线

项 选型 理由
框架 React Native 0.8x 新架构 + TypeScript 5.x 全书基准
列表 @shopify/flash-list 千条级任务与图片流
动画/手势 react-native-reanimated + react-native-gesture-handler UI 线程动效
服务端状态 TanStack Query 缓存、重试、失效管理
客户端状态 Zustand 会话/设置等轻量全局态
结构化存储 SQLite(expo-sqlite 或 op-sqlite 均可) 任务/检查项关系数据
KV 存储 react-native-mmkv 设置、同步游标
安全存储 Keychain / Keystore(封装为 SecureStore 模块) Token、密钥
路由 react-navigation(native-stack 为主) 大型项目默认解
工程 pnpm Monorepo(本书第 13 章完成拆分) 模块化

书中示例以「自建最小实现」优先,现成库用于对照与兜底——目的是让读者理解原理后有能力评估任何三方方案。

5. 里程碑与章节对应(19 个)

每章学完,完成对应里程碑;验收标准写明「做不到就回炉」的量化线。

# 里程碑 验收标准 章节
M1 项目骨架 新架构 + Hermes + TS 严格模式跑通双端;能画出自己的线程模型图 1
M2 性能基线 用 Profiler 与双端工具产出《FieldNote 性能基线报告》(含 FPS/内存/启动三项数据) 2
M3 列表达标 1000 条任务 + 缩略图列表滚动无可见卡顿;写出调参记录 3
M4 内存达标 拍照页 20 次进出内存增量达标;能复述一次排查过程 4
M5 启动达标 冷启动 ≤ 1.5s;列出每项优化与收益 5
M6 定位通道 原生高频定位事件按 5s 节流送达 JS,退出页面无泄漏 6
M7 扫码模块 TurboModule 双端实现,发布为本地 npm 包可被 autolinking 安装 7
M8 相机组件 Fabric 相机组件:Props 控制、拍照指令、扫码结果事件全链路 8
M9 打勾动效 检查项完成的弹性动效跑在 UI 线程(JS 卡顿时不掉帧) 9
M10 滑动操作 左滑操作栏 + 拖拽排序,与列表滚动无手势冲突 10
M11 交互验收 三大交互组件在低端机上帧率达标,附 Perfetto/Instruments 证据 11
M12 状态落地 状态清单 + 方案组合落地,无一处「全量 store 重渲染」 12
M13 模块化 完成 Monorepo 拆分:app / shared-ui / core-domain / native 套件 13
M14 存储分层 四类数据落入正确存储;含敏感项加密与缓存清理策略 14
M15 同步引擎 Outbox + 增量协议跑通:飞行模式改数据→恢复网络→自动收敛 15
M16 冲突验收 三个冲突案例用例全部通过(详见第 16 章场景) 16
M17 发布流水线 双端一条命令出包;灰度 5% 发布演练完成;崩溃监控上线 17
M18 热更新 热更新链路(含签名校验与回滚)演练通过;巡检模块拆包下发成功 18
M19 安全验收 附录 A 第三张清单全绿;渗透自测(抓包/反编译/篡改)通过 19

6. 数据模型(供同步与冲突章节共用)

Task(任务)        id, storeId, planDate, status, serverVersion, updatedAt
CheckItem(检查项) id, taskId, name, sortKey, score(1-5|null), note, serverVersion, dirty
Photo(照片)       id, checkItemId, localPath, remoteUrl, syncState
SyncCursor         userId, cursors: { tasks, checkItems, photos }
Outbox             id, opType, entity, entityId, payload, idempotencyKey, attempts, nextRetryAt

约定:serverVersion 为服务端单调递增版本号(客户端时钟不可信);dirty 标记未同步本地变更;idempotencyKey 用于服务端幂等去重;sortKey 用浮点分数避免拖拽排序后全量重写。

7. 假数据与联调

本书不依赖真实后端。所有同步章节使用两种方式联调:

  1. 本地 Mock Server:用 Node 写一个 ~200 行的增量同步服务(书中给出完整代码),跑在 http://localhost:PORT。
  2. 故障注入:Mock Server 支持人为制造 500、超时、乱序响应与「他人并发修改」,用于验证重试与冲突逻辑。

所有示例中的地址、密钥均为占位符;真实项目里请一律走环境变量或密钥服务,不要把任何可用凭据写进源码。

📄 附录 C · 学习资源与进阶路线 C-learning-resources.md

本附录分三部分:必读官方资料、源码阅读路线、能力进阶方向。链接以 2026 年 9 月为准,若失效请按名称搜索。

C.1 必读官方资料

资源 用途
React Native 官方文档 - The New Architecture 第 1、6~8 章的权威参照:Fabric、TurboModule、Codegen 全流程
React Native 官方文档 - Performance 官方性能总览,与第 2~5 章互为补充
Reanimated 官方文档 worklet、共享值、布局动画的 API 权威来源(第 9~11 章)
Gesture Handler 官方文档 手势 API 与底层机制(第 10~11 章)
FlashList GitHub 回收复用机制 README 与源码注释(第 3 章)
Metro 打包器文档 理解 bundle 构建与分包(第 18 章)
TanStack Query 文档 服务端状态缓存的完整心智模型(第 12、15 章)
Hermes 引擎仓库 字节码与 GC 行为(第 4、5 章)
Android 官方 - App 性能 与 Apple - Instruments User Guide 原生侧性能与内存工具的权威手册(第 2、4、5 章)

C.2 值得精读的开源项目(源码阅读路线)

按「先小后大、先工具后框架」的顺序读,每读完一个写一篇笔记:

  1. react-native-mmkv —— 最小的 JSI 直连示例:一个 C++ HostObject 如何暴露给 JS。读完你就真正理解了 JSI(呼应第 6 章)。
  2. react-native-gesture-handler 的 Gesture.Pan 封装层 —— 看 TS 侧如何描述手势并下发原生(呼应第 10 章)。
  3. @shopify/flash-list —— CellContainer 的回收调度;对照 VirtualizedList 的窗口化理解差异(第 3 章)。
  4. react-native-reanimated —— 重点读 worklet 的序列化与 runOnUI 实现,理解「JS 函数如何跨线程执行」(第 9 章)。
  5. react-navigation(react-native-screens) —— 原生屏幕容器如何减少 JS 层负担,是「原生组件封装」的绝佳范例(第 8、13 章)。
  6. expo-updates —— 生产级热更新的完整实现:下载、校验、切换、回滚(第 18 章自建方案的对照物)。
  7. WatermelonDB 的同步协议文档与实现 —— 生产级离线同步协议的参考设计(第 15、16 章)。

阅读方法:不逐行通读。先用「断点 + 调用栈」抓主干,再针对一条完整链路(如一次手势回调 → JS worklet)精读。

C.3 工具箱速览

类别 工具 出现章节
性能 React Native DevTools(Profiler/Memory)、Perfetto、Xcode Instruments、LeakCanary 2、4、5
网络 Charles/Proxyman/mitmproxy(抓包自测用) 16、19
构建 fastlane、Gradle Profiler、Xcode Build Timeline 17
热更新 expo-updates、自建签名校验脚本 18
安全 npm audit、MobSF(静态扫描)、keytool / codesign 19
监控 Sentry(或自建上报)、Crashlytics 2、17

C.4 进阶方向(学完本书之后)

  • 深度方向:读 RN 渲染器源码(react-native/ReactCommon 目录),理解 Fabric 调度器与 C++ Shadow Tree;有能力可以给生态库提 PR。
  • 广度方向:跨端对照——把第 13 章的领域层设计迁移到 Flutter/KMP 项目,理解「架构的可移植性」。
  • 工程方向:CN 上云的原生 CI(自建构建机矩阵)、bundle 分析自动化、性能回归门禁(把附录 A 清单做成 CI 检查)。
  • 学术方向:CRDT 与本地优先(Local-first)软件的论文路线:Kleppmann 的 Local-first software 论文是起点(呼应第 16 章)。

C.5 常见问题

Q:书中的版本号过时了怎么办? 本书的原理部分(线程模型、JSI、回收复用、Outbox、分包)生命周期远长于 API 细节。遇到 API 变动,以官方文档为准,原理不变。

Q:能用 Expo 开发生产级应用吗? 可以,且 2026 年这是官方推荐路线。本书示例刻意保持「RN CLI / Expo 皆可」:涉及原生代码的章节给出原生工程视角,使用 Expo 时对应能力由预编译(expo prebuild)或 Config Plugin 达成,原理相同。

Q:旧架构项目还要学吗? 0.76 起新架构默认、旧架构逐步移除。新项目一律新架构;存量项目按第 1 章的 interop 知识制定迁移计划即可,不必深学旧 Bridge 的实现细节。

📁 配套文件目录

rn-advanced-tutorial/
  README.md
  appendix/
    A-checklists.md
    B-capstone-project.md
    C-learning-resources.md
  stage1-foundation/
    01-rn-architecture-threads.md
  stage2-performance/
    02-performance-methodology.md
    03-list-optimization.md
    04-memory-management.md
    05-startup-optimization.md
  stage3-native/
    06-native-communication.md
    07-turbo-modules.md
    08-fabric-components.md
  stage4-animation/
    09-animation-reanimated.md
    10-gestures.md
    11-interaction-lab.md
  stage5-state-arch/
    12-state-management.md
    13-project-architecture.md
  stage6-offline/
    14-storage-cache.md
    15-offline-sync.md
    16-conflict-consistency.md
  stage7-release/
    17-packaging-release.md
    18-hot-update-bundles.md
    19-security-hardening.md

完整源码 Gitee 仓库浏览 →