面向已掌握 React Native 基础的开发者:从「能跑通 Demo」进阶到「能独立架构、交付、运营复杂生产级 App」。
useState 基础。FlatList 为什么卡、useNativeDriver 原理是什么的开发者。环境前提:能同时构建 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 章——没有测量就没有优化。
| 板块 | 作用 |
|---|---|
| 本章导读 | 一句话说清本章在全书的位置、与前后章的衔接 |
| 本章学习目标 | 开工前明确「学完你能做什么」,读完自检 |
| 核心知识点 | 正文主体:底层原理 → 代码示例 → 逐段讲解 → 对比表格 |
| 典型业务场景 | 用「轻巡记」的真实需求演示知识落地,含完整可运行代码 |
| 常见陷阱 | 生产环境真实踩过的坑:症状、根因、正确做法 |
| 最佳实践 | 企业项目沉淀的工程惯例,从第一天就照做 |
| 本章小结 | 要点回顾 + 一句话记忆点 |
| 动手练习 | 必做,含基础巩固、场景实战与排查思考题 |
一个面向连锁门店/工程巡检场景的离线优先工作台 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 |
如果以上六条你都能自信回答「能」,你就具备了独立负责复杂生产级 RN 应用的能力水位。
本章导读:本章是全书的地基。我们将从「点击图标到首帧」的完整链路出发,逐一拆解 Hermes、旧架构 Bridge、新架构三件套(JSI / TurboModules / Fabric)与线程模型,建立一套「任何现象先问在哪个线程」的分析框架。第 2 章的测量方法论、第 6~8 章的原生桥接、第 9 章的动画分层,全部建立在这次建立的心智模型之上。
学完本章,你应该能够:
原理讲解
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」,而是砍掉了桌面引擎为移动端付出的隐性成本:
hermesc 把 JS 源码编译成 Hermes 字节码(HBC)。设备上不再有 JS 源码的词法/语法解析,也不需要 JIT 把热点函数编译成机器码——这两件事在低端 CPU 上动辄几百毫秒。| 维度 | 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 写了但被构建变体覆盖),运行时全局标记不会。原理讲解
要理解新架构,必须先理解它推翻了什么。旧架构(RN 0.68 之前默认)里,JS 与原生是两个完全隔离的世界,唯一的通道是 Bridge——一条基于消息队列的异步通道:
JS 线程 原生线程(各模块队列)
│ │
│ 1. NativeModules.Foo.bar(x) │
│ 2. 参数 JSON 序列化 │
│ 3. [模块ID, 方法ID, 参数, 调用ID] │
│ ──── 入队 message queue ────► │
│ 4. 反序列化 → 反射找到方法 → 执行
│ │
│ ◄──── 结果 JSON 再入队回传 ───── │
│ 5. 反序列化 → resolve Promise │
它的四个先天缺陷:
代码示例:旧架构下的典型调用与它的天花板。
// 旧架构风格(今天仍存在于大量存量代码与 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)));
}
逐段讲解
JSON.parse(JSON.stringify()))是很多存量代码的真实写法——开发者为了「干净地拷贝」而制造了双倍序列化税。Bridge 时代还留下了一批「心智遗产」值得点名:getConstants() 这种「启动时一次性快照常量」的模式,本质是对「无法同步调用」的妥协;层层嵌套的回调金字塔,是在 Promise 可用前的无奈;「先发事件、JS 侧再拉一次全量数据」的补偿式同步,掩盖了通道不可靠的事实。维护存量代码时认出这些模式,能避免用新架构的标准去误判旧代码的动机——它们不是设计失误,而是当年唯一的选择。
新旧架构总对照:
| 维度 | 旧架构(Bridge) | 新架构(JSI / TurboModules / Fabric) |
|---|---|---|
| 通信模型 | 异步消息队列 | 进程内直接调用(可同步可异步) |
| 数据格式 | JSON 序列化 | 引擎内直接引用,零序列化 |
| 模块初始化 | 启动时全量注册 | 首次访问时懒加载 |
| 类型保障 | 无(字符串约定) | Codegen 编译期强类型 |
| 布局计算 | Yoga 跑在 JS 线程 | Yoga 跑在 C++ 后台线程 |
| 同步测量 | 不可能 | 可能(同步 measure) |
| 启动成本 | 随模块数线性增长 | 只为用到的模块付费 |
| 渲染器 | 原生 Manager + JS 驱动 | C++ 共享 Shadow Tree |
读这张表的方法不是背结论,而是顺着「通信模型的改变」推演其余每一行:数据不用序列化了 → 类型可以编译期对齐 → 模块不再需要启动期全量注册 → 布局可以从 JS 线程解放出来 → 同步测量成为可能。把因果链走通,这张表就长在你的推理里,而不是收藏夹里。
原理讲解
新架构不是「优化过的 Bridge」,而是换了通信的物理基础:JS 与原生运行在同一进程的同一内存空间里,中间隔着一层薄薄的 C++ 抽象——JSI。
JSI(JavaScript Interface):一个描述「JS 引擎能力」的 C++ 抽象接口。任何引擎(Hermes/JSC/V8)只要实现 jsi::Runtime,原生 C++ 代码就能:
jsi::Object、jsi::ArrayBuffer),不需要序列化;get();同步调用为什么在 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——调用是同步完成的,所以函数体必须极快。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 友好得多。原理讲解
新架构下,一次典型交互涉及四类线程。先看全景(以一次「点击卡片 → 打分 → 界面更新」为例):
时间 ───────────────────────────────────────────────────────────────────────────►
UI/主线程 │收集触摸│─派发→│ 空闲(等待 VSync) │──mount 视图变更──│绘制+上屏│
渲染后台线程 │ │ │ Yoga 布局 + diff │ │ │
JS 线程 │ │ │ onPress → render+commit │ │ │
原生模块线程 │ │ │ SQLite 读写 / 定位回调(与上面互不抢占) │ │
各线程的职责与相互关系:
setTimeout/Promise 都在这里。它卡了 = 交互响应与界面更新迟到,但已交给原生的东西(滚动惯性、原生驱动动画)不受影响。补充两个容易忽略的细节。其一是事件如何到达 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 章动画分层的前置知识。onPress 的 setState 要等自旋结束才被处理。原理讲解
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 上屏
「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 → 输入框回显被拖到几十毫秒后,低端机上肉眼可见「打字滞后」。useTransition 包裹路由切换/Tab 切换等大块更新,原理相同:把大更新标记为「可等待」。两者怎么选:useDeferredValue 适合「同一个值的两档优先级」(输入框显示用新值、列表过滤用旧值),改动只发生在数据派生处;useTransition 适合「一批状态更新整体降级」(整页切换、批量打分提交),改动发生在触发处。共同的前提是被降级的渲染必须幂等且无副作用——render 阶段可能被重跑多次,任何写存储、发请求的逻辑放进过渡更新都是定时炸弹。
原理讲解
0.76 起新架构是默认项,但生态不可能一夜切换,于是官方提供了 interop(互操作)层,让未经新架构改造的存量库继续工作:
由此可以给存量库分三个兼容层级,迁移评估时逐个归类:
| 兼容层级 | 特征 | 行为表现 | 决策 |
|---|---|---|---|
| 完全支持 | 提供 Fabric 组件 / TurboModule 实现,包内含 codegenConfig |
原生性能,无适配税 | 放心用 |
| 经 interop 可用 | 仅旧架构实现,社区仍在维护 | 功能可用,通信走旧语义,有适配税 | 可用,关注升级计划 |
| 不再维护 | 长期无更新、issue 明确不支持新架构 | 随时可能在新版本上崩 | 换库或自研(第 7、8 章) |
bridgeless 开启与验证要点:
gradle.properties 中 newArchEnabled=true(Android)、iOS Podfile 属性为对应默认值即可。RN$Bridgeless === true 做断言,别靠猜。NativeModules.X 变 undefined 通常是该库未适配 interop 注册。interop 税怎么量化?一个实用做法:在开发环境对同一个原生方法做 100 次调用计时,比较「确认支持 Fabric/TurboModule 的库」与「经 interop 运行的旧库」的耗散差异;再结合第 2 章的帧采样观察高频调用场景(如扫码预览帧回调)是否引入掉帧。数字拿到手,才谈得上「先用着,等上游适配」还是「现在就换」。
迁移顺序上推荐「先应用、后依赖」:先把项目自身的代码与新架构对齐(本节的环境探针 + 第 7、8 章的自研模块),让团队的思维模型统一;存量三方库按依赖关键度排序逐个验证——关键路径的库(导航、手势、存储)优先,边缘功能库可以带着 interop 税活到有大版本升级的机会。一次性替换所有依赖的「大爆炸迁移」在真实项目里几乎总是以回滚告终。
对应里程碑 M1 项目骨架:新架构 + Hermes + TS 严格模式跑通双端。本节完成三件事:技术选型决策、骨架与严格模式配置、「架构体检」工具——用一段重计算验证线程模型,把本章知识变成可复现的证据。
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 章的原生深水区后自由度的权重陡增。把「当初为什么这么选」写下来,一年后团队才不会在「要不要迁移」上靠印象吵架。
# 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);
}
}
目标: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;| 陷阱 | 典型症状 | 根因 | 正确做法 |
|---|---|---|---|
以为 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 章通道选型) |
useNativeDriver: false 都要在 Code Review 说明理由。HermesInternal / RN$Bridgeless 做启动自检,CI 中断言环境基线,防止配置漂移。as any 强转 NativeModules。本章建立了全书的架构地基:从冷启动链路理解了 Hermes「AOT 字节码 + mmap + 无 JIT」的本质;用旧 Bridge 的四个先天缺陷(异步-only、JSON 税、批处理延迟、全量注册)推导出新架构的必然性;逐一拆解了 JSI(进程内引用与同步调用)、TurboModules(懒加载与强类型)、Fabric(不可变 Shadow Tree 与 commit/mount 两阶段)三大件,并给出四线程模型与「JS 阻塞不连累原生动画」的实验证据。最后在 FieldNote 里完成了 M1 骨架:选型决策、严格模式配置、架构体检工具。
一句话记忆点:新架构的本质是把「跨进程消息」换成「同进程引用」——从此性能问题先问一句话:这行代码跑在哪个线程?
ArchitectureProbe,把 useNativeDriver 改为 false 再观察一次,用两句话解释现象差异。ArchitectureProbe 挂进 FieldNote 的 Debug 面板入口,开启开发菜单的 Perf Monitor,在真机上完成 busyLoop 实验并截图;将截图与 envProbe 输出整理成 M1 验收记录。useRuntimeGuard Hook:开发环境下若非 Hermes 或非 bridgeless 则在屏幕顶部显示红色警告条;写 3 句话说明为什么这个守卫必须只在 __DEV__ 生效、且不能影响生产包体积。上一章 前言 | 下一章 第 2 章 性能测量方法论与渲染原理
本章导读:第 1 章建立的线程模型回答了「代码跑在哪」,本章回答「跑得够不够快、怎么证明」。我们先建立帧预算与指标体系的第一性原理,再盘点 2026 年的工具链,然后深入 React 渲染模型与 re-render 治理——这是第 3~5 章(列表、内存、启动)所有优化工作的测量前提。没有本章的方法论,后面章节的每一个优化数字都不可信。
学完本章,你应该能够:
memo / useMemo / useCallback,并能指出「比不优化更慢」的三种具体场景。原理讲解
一切渲染性能问题都可以归结为一句话:每一帧都必须在下一个 VSync 信号到来之前准备好。
把第 1 章的线程模型代入帧预算,就得到两类掉帧:
| 类型 | 责任线程 | 用户感知 | 典型元凶 |
|---|---|---|---|
| JS 线程掉帧 | JS(Hermes) | 交互响应迟钝、新内容「迟到」、打字滞后;已启动的原生动画和滚动惯性照常 | 重计算、大 commit、序列化、同步原生调用 |
| UI 线程掉帧 | UI/主线程 | 整个画面冻结,包括原生动画、系统转场 | 复杂视图层级 mount、主线程 IO、过度绘制、布局抖动 |
判别方法(承接第 1 章 1.4 节,这里是操作手册):
还有一个口径层面的细节: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 里做排序、格式化日志这类重活——采样器自己把线程占满,是埋点系统最常见的自杀方式。原理讲解
性能指标最大的坑不是「没测」,而是「测了但口径错」。生产级指标体系要同时满足三条:口径明确(怎么算的)、设备与场景锚定(在哪测的)、阈值可验收(多少算过)。
各指标的统计口径:
Application 构造时刻,iOS 取进程启动时刻)为起点,JS 侧首屏 onRender 为终点。均值同样骗人——报告里报 P90。adb shell dumpsys meminfo 读取),iOS 用 footprint(Instruments / os_proc_available_memory 相关口径)。记录场景峰值与增长斜率(如反复进出拍照页的增量),单点数值意义有限。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():前者是单调时钟,不受系统时间跳变影响(对离线设备尤其重要——巡检员的手机时钟可能是错的)。componentDidMount:用户感知的是「我能看到任务列表了」,不是「React 挂载完成了」。最后把指标变成「可执行」的三条工程约定:① 按场景打标——同样的 FPS,任务列表滚动和拍照预览的达标意义不同,每条数据必须带场景标签(screenName + 操作类型),否则聚合出来的是噪声;② 控制采样率——全量上报帧数据会打爆带宽与存储,按会话抽样(如 5%)或只上报超阈值的坏样本;③ 隐私红线——埋点数据里不得包含任务内容、定位等业务数据,只保留性能数值与设备档次信息(第 19 章的合规要求同样约束性能埋点)。
没有后端配合时也不要因此搁置指标体系:PerfTracker 的快照可以先落到本地 MMKV,开发/测试阶段用脚本导出成表格人工汇总。指标体系的价值九成在「口径固定」,一成在「自动化」——口径今天不定,三个月后的所有数字都不可比。
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 章的拆包才是正解,压缩混淆只是止血。
工具选型决策表:
三条工具卫生守则,比「选哪个工具」更重要:
| 你面对的问题 | 首选工具 | 备选 / 补充 |
|---|---|---|
| 谁在多余 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 章双端工具链 |
原理讲解
React 的渲染分两阶段:render(调用组件函数、diff 出变更,可中断可并发)与 commit(把变更一次性应用到宿主,同步不可中断)。在 RN 里,commit 之后还有第 1 章讲过的「经 JSI 提交 Shadow Tree → Fabric mount」两步。「卡」通常不是 render 慢,而是一次巨大的 commit——Profiler 里那条又长又红的 commit 条带就是它。
触发组件 re-render 的只有四种原因:
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>
);
}
逐段讲解
memo 只值得用在「渲染贵 + props 天然稳定」的组件上。判断标准是先量渲染成本(Profiler),再查 props 稳定性(是谁在传函数/对象字面量)。tasks 的引用稳定下来(数据层做结构共享,见后文 TanStack Query 说明),而不是 memoize 结果。HeavyFrame 的插槽走 render prop 且被 memo 包裹。组件边界拆分与状态下放:
// ❌ 高频 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。
把第 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 章的战场)。这张图还有一层用途:给它配一条「现实观测记录」——每排查一个卡顿问题,就把各段的实测耗时记在图旁边,团队会慢慢积累出「本项目在目标机型上的真实预算分布」,它比任何通用经验值都值钱。
原理讲解
工具链解决「开发期定位」,生产质量还需要「线上真相」:用户真机上滚动到底掉不掉帧、哪个交互慢。自建埋点模块的设计分四层:
requestAnimationFrame 间隔采样 JS 线程帧节奏(UI 线程帧率后续可用 Reanimated 的 UI 线程帧回调或原生 FPS 回调补齐,M11 用 Perfetto 交叉验证);mark / measure 记录关键交互区间(点击 → 接口 → 首帧);代码示例(完整模块,约 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 也加上界。使用约定(团队层面):
useEffect 里 start/stop,全局共享单例,禁止每个组件各起一个采样循环;Marks.nativeLaunch),杜绝散落的魔法字符串导致 measure 对不上;snapshot 只读不改状态,flush 成功后由调用方决定是否重置,给上报重试留余地。关于上报策略再补两条实战约定。其一,分级上报:snapshot 只在页面卸载、App 退后台或累计卡顿超阈值时触发 flush,正常运行期间零网络开销——性能埋点自己制造卡顿是最讽刺的事故。其二,坏样本优先:带宽紧张时优先上报 jankCount > 0 的会话与 P99 最差的场景,好样本的统计价值远低于坏样本——这是「分位数思维」在上报侧的自然延伸。
原理讲解
性能优化失败的首要原因不是技术,而是流程:拍脑袋改 → 同时改三处 → 「感觉快了」→ 两周后回退。正确流程是一条强制闭环:
flowchart LR
A["建立基线<br/>同一设备 + release 构建"] --> B["提出假设<br/>单变量、可证伪"]
B --> C["最小改动实验<br/>一次只改一处"]
C --> D{"指标显著改善?<br/>同条件复测 N 次"}
D -->|"是"| E["固化代码 + 写入 ADR<br/>纳入回归门禁"]
D -->|"否"| F["回滚并记录负结果"]
F --> B
E --> A
为什么必须闭环:性能改动常常此消彼长(加 memo 省了渲染却加了比较与内存;拆包省了首屏却多了按需等待)。只有「基线 → 假设 → 实验 → 验证」的数字链能回答「净收益是否为正」,也只有门禁能让收益持续。
条件控制里最容易失守的是设备与系统状态。团队没有统一真机时,至少约定一款参考机型与一台「低端钉子户」(FieldNote 用 2023 年千元机档);云端真机平台可以作为补充,但其 CPU 调度与温控策略与手头真机有差异,跨平台对比时只比趋势不比绝对值。电量低于 20%、设备发烫、后台正在更新应用——这些都会让同一份代码跑出完全不同的数字。把测试条件写进基线报告的表头,是性能工作职业化的第一个标志。
对应里程碑 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 节表)判定:未达标。
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。
// ✅ 优化后:按变化频率与消费面拆分,消费端 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、掉帧计数与包体积等分布敏感口径 |
本章建立了测量的第一性原理:帧预算(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 的治理。
一句话记忆点:先有基线再有优化——没有分位数口径与闭环验证的性能工作,都是玄学。
watchFrames,分别在「空闲」「快速滚动列表」「阻塞 JS 线程 2 秒」三种状态下采样,记录 p50/p90/worst,并解释为什么空闲状态 p50 也会略大于理论帧间隔。onPress,measure 在详情首帧渲染),真机 release 采集 20 次取 P90,与 2.2 节达标线对照并给出是否达标的结论。上一章 第 1 章 深入 RN 架构与线程模型 | 下一章 第 3 章 列表渲染优化
本章导读:列表是 RN 应用最常见的性能重灾区,也是第 2 章测量方法论的第一个实战对象。本章先算清「一个 item 到底花多少钱」,再拆解 VirtualizedList 的窗口化与批处理机制、FlatList 的系统调参方法与 FlashList 的回收池原理,最后在 FieldNote 的 1000 条巡检任务列表上完成从卡顿到达标的完整改造,并为第 4 章的图片内存治理埋下伏笔。
学完本章,你能够:
先建立一个关键认知:在 RN 里渲染一个列表项,成本发生在两层,而且是同时发生的。
【重点】新架构带来的一个重要变化:滚动本身由原生滚动容器在主线程直接驱动,不再等 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 的回收管的是「创建的成本」,这是本章后两条主线。
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 回收。
先给总表,每行都是「默认值 → 会出什么问题 → 建议起点 → 为什么」,这是面试与 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 实战里我们采用「拍平 + 行类型」方案,粘性头交给分组语义更强的实现。
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 | 数百~万级 | 中高 | 支持 | 回收池,快滚与内存表现最优;需验证复用残留 |
列表优化的下半场在数据层。渲染层做得再好,数据源每次都整体换引用,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 请求风暴」的标准解法。
图片列表是列表问题与内存问题的交汇点,本章先解决「快与稳」,第 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 章要抓的那类泄漏。
对应里程碑 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 中出现频率最高的组合拳。
// 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 并加上分组拍平。完整可运行代码:
// 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 固定元素 |
本章从「一个 item 的双重账单」出发:JS 组件树与原生视图树各记一笔,1000 条全量渲染在任何机器上都不可行。VirtualizedList 用窗口化控制「渲染多少」、用批处理控制「渲染节奏」,吞吐上限 ≈ maxToRenderPerBatch / updateCellsBatchingPeriod,空白区域的本质是滚动速度超过吞吐。FlatList 调参的每一项都要能回答「默认值是什么、问题是什么、为什么这么改」;FlashList 用回收池把「创建成本」也省下来,但要管理复用残留;数据层用稳定 key、不可变更新、游标分页与预取托底;图片用固定解码尺寸与稳定 URI 同时解决闪烁与内存。FieldNote 任务列表从 24fps 到 57fps 的过程,就是把这套逻辑逐步落地的过程。
一句话记忆点:列表优化管三本账——窗口化管创建频率,回收管创建成本,稳定 key 管重建范围;图片另有一本内存账,第 4 章接着算。
onViewableItemsChanged 计算最后可见行之后的 10 条任务,对其缩略图执行 Image.prefetch。要求:预取去重(同一 URI 不重复预取)、组件卸载后安全、预取失败静默降级。上一章 第 2 章 性能测量方法论与渲染原理 | 下一章 第 4 章 内存管理与泄漏排查
本章导读:内存是性能的隐形维度——列表滚动几十秒才暴露的卡顿、真机久用后被系统静默杀死,多数根因都落在内存。本章基于第 3 章的图片与列表问题,建立「JS 堆 + 原生内存」的双层心智模型,给出一套 RN 特色泄漏模式清单与双端排查工具链,并在 FieldNote 拍照取证页完成一次 300MB→30MB 的完整排查实战。
学完本章,你能够:
先纠正一个高频误解:「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 里什么也看不出来。
图片是 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」。
以下每个模式都给「最小复现 + 修复」。它们的共同机理只有一句话:本该随页面一起死亡的对象,被一条仍然可达的引用链拴住了。
罚单 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 = []; }, []);
React Native DevTools 的 Memory 面板(JS 堆)。路径:开发模式运行应用 → Dev Menu → Open React Native DevTools → Memory 标签 → 确认目标是 Hermes 堆 → Take heap snapshot。三个核心用法:
#Delta 为正且数量与业务轮次线性相关的类(例如 Photo、Array、你的页面组件名);Android 工具链。三件套按用途选:
adb shell dumpsys meminfo com.fieldnote.app:三十秒定位问题在哪层。重点看 Native Heap、Java Heap、Graphics、PSS Total 四行——Native 涨位图问题,Graphics 涨图层问题,只有 Java Heap 涨才轮到 Java 侧。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 工具链。四条路径按顺序用:
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();
}, []);
工具会变,方法不变。六步法:
「增长」与「泄漏」的区分是方法论的核心判断题:缓存增长是可回收的——它有界、在压力下会被逐出;泄漏是不可回收的——引用链活着,GC 与缓存策略都够不着它。验证手段:手动触发 GC、发出内存告警清缓存、或继续加压,看内存是否回落。曲线形态一眼可判:
(a) 健康基线:峰值随进出波动,谷底每次回到基线附近
内存(MB)
300 ┤ ╭──╮ ╭──╮ ╭──╮
280 ┤ ───────╯ ╰─────────╯ ╰─────────╯ ╰──────── ← 谷底 ≈ 260(常驻缓存)
└────┬────┬─────┬────┬─────┬────┬─────┬────▶
进 出 进 出 进 出 进 出 (拍照页进出)
(b) 泄漏:峰值与谷底同步抬升,退出后无法回落
内存(MB)
600 ┤ ╭──
540 ┤ ╭───╮ ╭──╯
480 ┤ ╭───╮ ╭──╯ ╰─
420 ┤ ╭───╮ ╭──╯
360 ┤ ╭──╮ ╭──╯
300 ┤ ────╯ ╰──╯ ← 每轮谷底抬升 ≈ +15MB,20 轮累计 +300MB
└────┬────┬─────┬────┬─────┬────┬─────┬────▶
进 出 进 出 进 出 进 出
原则一:图片生命周期与页面/列表生命周期解绑。 方向是「页面走了,图片数据不留」:页面级照片数组放页面 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 章);客户端展示前按目标尺寸解码(下采样);缓存层缓存的是「目标尺寸的解码结果」而不是原图。落盘的原图与内存里驻留的位图是两个生命周期的两份资产,前者管证据留存,后者管显示性能,别混为一谈。
对应里程碑 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:典型泄漏形态
$ 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 弹出报告(节选):
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 轮进出,看曲线是否回到基线 |
本章先建立三层视图: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 堆——内存问题十有八九在原生侧,而图片、监听、闭包是最常开出的三张罚单。
上一章 第 3 章 列表渲染优化 | 下一章 第 5 章 启动速度优化
本章导读:第 2 章的方法论与第 3、4 章的列表、内存治理,解决的都是「启动之后」的问题;本章处理用户按下图标后的第一秒——启动是性能优化阶段的收官战场,也是投入产出比最高的一场。我们承接第 1 章 1.1 节的启动全景与第 2 章的测量闭环,把冷启动拆解到每一段的线程归属与耗时特征,再从 JS、原生、bundle 体积三条战线给出优化手段,最后把成果固化为启动预算与回归门禁。本章对 Application / AppDelegate 的改造,也是下一章原生桥接阶段的前哨。
学完本章,你应该能够:
inlineRequires 的收益来源与例外。+load、动态库链接等原生遗留启动负担,给出双端处置方案。adb shell am start -W、Perfetto、Instruments App Launch、os_signpost 与第 2 章 PerfTracker 建立「启动瀑布」报告与线上分位数监控。原理讲解
先把「启动」这个词拆开。工程上必须区分三种启动,它们的链路完全不同,混为一谈是一切启动数据打架的根源:
| 启动类型 | 进程/页面状态 | 典型触发 | 链路差异 | 参考耗时(中端安卓机) |
|---|---|---|---|---|
| 冷启动 | 进程不存在 | 首次安装后点击、被系统杀死后点击、设备重启后 | 完整链路:进程创建 → 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 渲染 + 数据就绪」三项通常合计占一半以上,且全部可控。
逐段解释每一段「在干什么、卡在哪、谁的责任」:
import 了什么不重要,重要的是哪些模块顶层代码在启动时被执行。一个 30 个页面、每个页面顶层都 import 了重库的应用,即使首屏只用到 1 个页面,也要为此付出全部代价。把这张瀑布放进第 2 章 2.5 节的预算视角,启动优化只有两个杠杆:
所有手段都能归到这两类,判断一个优化有没有意义就看它动的是不是关键路径:把 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 章埋点同样理由)。启动测量必须用单调时钟。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 首行的两个点,先保证口径稳定。原理讲解
先回答「为什么 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 执行到哪一页,缺页中断才把哪一页从磁盘调入。这意味着:
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(...) as typeof import(...):TS 下让内联 require 保住类型推导,不牺牲严格模式。runMust 为什么串行:must 层的任务彼此常有隐式依赖(先初始化存储再水合),并行省的几十毫秒远不值得时序风险;should 层才值得并行,且每个任务包 runCatching/try-catch——首帧后的任务失败不应波及彼此(FieldNote 的原生编排器同理,见 5.3)。requestAnimationFrame:单次 rAF 回调只说明本轮 render 完成,commit 与上屏可能还在路上;隔一帧再启动 should 任务,能基本保证不与首帧 mount 抢 JS 线程。它不是精确保证,但比 setTimeout(0) 靠谱得多。JSON.parse,则把 MMKV 的优势全部败光——KV 存储的快,前提是 value 小(存储体系全景在第 14 章)。__DEV__ 下用 Metro 的模块工厂注入给每个模块计一次执行耗时(console.count 级别的 patch 即可),按耗时排序得到「启动期模块执行清单」;release 下的近似替代是 5.1 节的 js_boot mark 加二分法注释验证(注释掉一批 import 看段长变化)。没有这个清单,require 瘦身就是凭感觉删代码。原理讲解
Android 的启动关键路径是一条单行道:Application.onCreate() 在主线程执行完毕 → ContentProvider 初始化 → Activity 创建 → RN 容器。onCreate 里多花 1ms,TTR 就晚 1ms。审计方法是固定的三步:
penaltyLog 暴露主线程 IO)+ Perfetto 主线程泳道,把 onCreate 里的每个 SDK 初始化的耗时抄下来,做成审计表;一张典型的审计表(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 特有的遗留负担:
androidx.multidex 依赖与 attachBaseContext 里的 MultiDex.install(this) 应当删除——依赖审计时顺手查一遍。同时用 R8 的 release 收缩(第 17 章)控制 dex 数量,并利用 RN 自带的 Baseline Profiles(0.7x 起官方内置)让 ART 预编译核心路径,减少运行时 JIT。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();
}));
}
逐段讲解
runCatching + 单独标记:should 层任一 SDK 失败不能拖垮后面的任务,也不能崩进程——启动期的崩溃会让「启动优化」变成「启动事故」。失败标记进 StartupTracer,线上能看到哪个 SDK 在哪些机型上不稳。jsReady.await() 是唯一的跨侧同步点:它把「原生想早点做」与「JS 还没 ready」这对矛盾显式化。没有这个协调的团队经常写出「原生在 onCreate 里注册了 JS 事件监听、JS 侧永远收不到第一条推送 token」这类隐式时序 bug(见本章陷阱表)。Dispatchers.Main.immediate:如果已在主线程就立即执行,避免一次多余的线程切换——afterJsReady 里的任务多数要碰 UI 上下文。snapshot() 上报的分段(5.6 节瀑布报告),确认 onCreate 同步段确实收缩到 100ms 内。原理讲解
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 用启动参数/编译开关关掉其非必需行为。代码示例:瘦身的 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 等异步结果」移植过来(见陷阱表)。+load 的地盘,也是 iOS 启动里最「纯」的一段——你的业务代码还完全没跑。pre-main 的每一毫秒等价于 didFinishLaunching 里的一毫秒,但前者只能靠链接与 +load 治理,后者靠编排;两段都要测,不要只盯一头。原理讲解
「包大=启动慢」是直觉,但传导链有三个环节,各环节成立条件不同:
把这三条传导链变成日常纪律的落点是评审问题清单,PR 里新增一个依赖时问三个问题:
三个答案都能接受,这个依赖才允许进包;体积门禁(FieldNote 第四步)负责兜住前三问漏掉的情况。
所以体积治理的优先级不是「压总数」,而是先砍启动可达子图,再压总体积。审计三件套:
source-map-explorer(第 2 章 2.3 节)按源文件归因体积,找出前 20 名;pnpm why <pkg> 查重复依赖与版本分叉(monorepo 下尤其常见:同一个工具链 3 个版本都在包里);与第 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 用自建标记)
逐段讲解
原理讲解
双端启动测量工具各管一段,先对表再开工:
| 工具 | 平台 | 回答的问题 | 口径注意 |
|---|---|---|---|
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(),让系统指标与业务口径对齐。
温启动与热启动同样纳入监控但分层对待:
线上监控的口径纪律(承接第 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:启动段与交互区间是两类数据,混在一个环形缓冲里会互相挤掉。getDeviceTier() 按 总运行内存 分档(低/中/高),上报带上档位,线上看 P90 必须按档过滤(口径见上)。startupWaterfall() 挂到开发菜单:「导出启动瀑布」一键打印当前会话的分段表,配合 Perfetto 泳道截图贴进启动报告——本章 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 达标线 |
瓶颈的证据链靠系统级工具闭环,不能只凭自建打点:
Trace.beginSection 在此兑现价值,每个 SDK 一个可辨的 slice;三份独立观测分别对应三个修复假设——这也是 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 的门禁三条:
adb shell am start -W -S ×5 取中位数,超 1.6s(达标线 +10% 余量)即失败;iOS 用 XCUITest 的启动指标同口径把关。门禁阈值统一留 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 对齐业务终点 |
inlineRequires + 桶文件清理把 require 段压到首屏可达子图;对顺序敏感模块用 blockList 排除并全量回归。JSON.parse 兆级字符串。本章把启动拆成了一条可测量、可归因的链路:进程创建 → 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 达标),并把收益固化进三道回归门禁。
一句话记忆点:启动是一场编排——首帧之前只允许「必须」,其余一切都有资格等待。
adb shell am start -W -S ×10 取中位数,再用 5.1 节的打点方案(可先只接 onCreate 与 js_boot 两个点)画出简易瀑布,解释 TotalTime 与你测得的 TTR 之间的差值来自哪几段,并说明为什么差值不为零是正常的。am start -W 循环 + 中位数判断 + 体积对比),在 PR 描述里贴出超预算时的处理流程。上一章 第 4 章 内存管理与泄漏排查 | 下一章 第 6 章 双端通信机制全解
本章导读:进入第三阶段「原生桥接」。第 1 章从架构高度回答了「JSI 为什么取代 Bridge」,本章把通信机制摊开到可以动手的程度:每条通道的方向、线程、时序、开销与适用边界。掌握本章后,第 7 章的 TurboModule 与第 8 章的 Fabric 组件只是「把通道用 codegen 脚手架规范地包起来」——通道选错了,脚手架再规范也救不回来。
学完本章,你应该能够:
原理讲解
新架构下 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++ 能力、高频小调用(极度克制) |
这张表的正确用法是「先找行、再看列」:拿到需求先按方向定位到行,再用线程与时序列校验实现可行性,最后用返回语义确认次数预期——三列都对得上,这条通道才进入候选;有任何一列对不上,回到候选集重新找,而不是硬改需求去迁就通道。
三条选型总纲,先记住再往下看:
读这张表还要带上「成本三账本」,任何一次跨端传输都在三处记账:
顺带说清「同步返回」在新架构下的真实开销:虽然 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();
逐段讲解
scrollToIndex={3}),优先状态——它可被 diff、可被恢复;只有「时点敏感、一次即弃」的意图才值得 Commands。StartupMarks.nowSinceLaunchSync——必须发生在 JS 第一条语句);② 能力探测(「本机是否支持某硬件」,答案是内存里的布尔);③ 常量读取(SDK 版本号、编译期配置)。三个共同点:答案在调用发生前就已经在内存里。最后列三个高频选型误区,评审时可直接对号:
setInterval 反复调同步方法查状态——正确做法是原生感知变化后推事件(本章陷阱表第一行);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 就是「通信层技术债」的标记——不必立刻迁移,但每次触碰该模块时顺手换到右列,通信层的现代化就摊销在日常迭代里。
原理讲解
JSI 是一层 C++ 抽象接口,屏蔽 V8/JSC/Hermes 的差异,让原生代码「直接持有并操作 JS 对象」。一个澄清:JSI 是接口,Hermes/JSC/V8 是实现——RN 0.8x 默认的 Hermes 实现了这套接口,理论上任何引擎都能接进来(这也是「引擎可插拔」的技术基础)。第 1 章 1.3 节交代了它的位置,这里下到 API 层。三个核心构件:
get / set / getPropertyNames 的 C++ 对象,在 JS 侧表现为普通对象。⚠️ 关键机制是惰性触发:JS 读 obj.model 这一行代码,才调用 C++ 的 get("model")——属性访问就是函数调用。HostFunction 同理:JS 调用这个「函数」时直接进入 C++。为什么同步调用成为可能:旧架构 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::JSError 在 JS 侧表现为普通异常(可被 try-catch 捕获);Promisified 方法的异常则表现为 reject。两条错误路径都要设计——在 C++ 里抛 JSError 等于在 JS 里 throw,这不是日志,是控制流。if (n < 1 || !args[0].isString()));TurboModule 把这段校验变成 codegen 从 Spec 自动生成——同样的检查,手写一次还是生成一万次,这正是第 7 章要兑现的价值之一。JSON.stringify(__DeviceProbe)、调试器查看变量这类「枚举全部属性」的操作会触发 getPropertyNames + 逐个 get——把「懒」的东西全变成「急」的。别在热路径上对 HostObject 做这类操作。最后给裸 JSI 列一张「常见误用清单」,逐条都来自真实项目的事故:
get 里做 IO 或加锁——同步语义 + 阻塞操作 = 冻结或死锁(6.6 节同步通道故障);原理讲解
新架构下跨端调用的默认线程规则: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 与队列语义诉求;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 线程
});
});
}
逐段讲解
resolve 是安全的。但一旦绕开框架、在 C++ 层直接持有 jsi::Function/jsi::Value(如第三段代码),触碰 Runtime 的每一行都必须在 JS 线程——invokeAsync 就是为此存在。区分「框架帮你编组」与「你自己负责编组」两层,是避免玄学崩溃的关键。shared_ptr<jsi::Function> 而不是值拷贝——jsi 对象不能跨线程拷贝、且必须活到被调用那一刻。回调被原生持有的风险(泄漏、悬挂)在 6.4 的 Callback 通道正式展开。Handler(Looper.getMainLooper()).post、iOS 用 DispatchQueue.main.async,且 post 进去的代码块保持微秒级——它与「用户输入、系统绘制」同一条队列,多占 1ms 都直接变成掉帧(第 2 章 2.1 节)。latch.await() 干等——那是把并发 bug(第 5 章 onCreate 等待问题的近亲)搬进原生侧。Promise.all 不改变线程事实:并发发三个跨端调用,JS 侧只是同时 await 三个 Promise;每个方法体仍在 JS 线程依次开跑、各自把重活 post 出去——原生的真并行取决于你自己的队列设计(三个不同工作队列才并行,同一个串行队列照样排队)。fn-location、iOS 的 com.fieldnote.location),Perfetto 泳道、Xcode 栈、崩溃堆栈里的每一条线程都能对上模块——排查时这是零成本高回报的纪律。Looper.getMainLooper() == Looper.myLooper() 检查与 iOS 的 dispatch_assert_queue 可以埋在「绝不该在主线程跑」的路径里,debug 构建直接崩——把线程契约变成可执行断言,胜过 review 时反复口述。把本节的纪律压成三句口诀,写跨端方法时逐句自检:方法体在 JS 线程,所以它必须短;重活在声明好的工作队列,所以它必须自终结(每条路径都 resolve/reject);收尾在主线程或经 CallInvoker 回 JS,所以它必须轻。三句都过,方法才配提交。
原理讲解
「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 意图。
逐段讲解
RCTEventEmitter 的 startObserving/stopObserving 会随「JS 侧监听器从 0→1 与 1→0」被框架调用——把「开始采集/停止采集」挂在这两个回调上,监听器泄漏的后果就只剩「一个空回调」而不是「一个永不停机的定位源」。Android 侧没有等价钩子,需要自己维护监听计数或在 JS 封装层保证成对调用——这是本章 FieldNote hook 把 stop 写进 effect 清理函数的原因。subscribe 返回前执行」来写,先初始化好状态再注册回调,别依赖「注册完才有事发生」。onLocationBatch、onScanResult),payload 结构一旦发布就当作公共 API——加字段可以(旧消费方无感),改语义不行;真要改,加新事件名并行一个版本,监听方迁移完再下线旧名。事件没有类型检查器替你守住两端一致性,演进纪律就是一致性本身。一个具体案例:FieldNote 定位事件 v1 只有 {lat, lng},v2 需要 accuracy——正确路径是「v2 加字段(旧消费方无感)→ 确认所有消费方升级 → 才考虑收紧类型」;错误路径是「直接改语义」(如把坐标从 WGS-84 悄悄换成 GCJ-02),旧版本消费方会静默产出整体偏移几百米的轨迹。// 原生侧通用合批器:采集线程只 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() } }
}
逐段讲解
把三个变体与 FieldNote 的功能对位看一遍,频率阶梯就清楚了:30fps 相机预览帧 → 原生直驱(不落 JS);10Hz 传感器 → 100ms 合批(进 JS,但批内摊薄);1Hz 定位 → 5s 合批(业务口径主导);用户点击 → 即时通道(Promise/事件,永不缓存)。频率越高的数据,离 JS 越远——这条「高频数据下沉定律」可以直接当设计守则贴在团队墙上,第 9~11 章的动画手势实战会把它的 UI 侧演绎成共享值与 worklet 的完整体系。
原理讲解
新架构的 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 时逐条对照:
ts 是毫秒还是秒?accuracy 是米还是置信半径?),Spec 里写不下的约定写进接口注释——跨端接口的语义模糊是联调期第一大摩擦源;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 节「重活不过线程」的总原则,只是它藏在「跨端调用」的外衣里。对策按优先级:
同一个需求「任务列表页取 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 } 级别的摘要
逐段讲解
uploadFromUri 的深层理由:拍照产生的图片已经存在于原生文件系统(第 4 章讲过解码位图不落 JS 的内存理由),把字节捞进 JS 再交回原生上传,是让数据白跑两个来回;URI 引用让原生直接从文件读,JS 全程只碰几十字节的元数据。原理讲解
通信层的 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);
};
逐段讲解
AtomicInteger——addListener 时 +1,remove 时 −1,归零即停源;JS 封装层(hook)的成对调用是第一道保险,原生计数是第二道。两层中任何一层实现正确,泄漏都不会演变成「永不停机的采集器」。Promise.race 之后悬挂的 Promise 依然挂着(没人能取消它),超时只是让你的业务先走失败路径。真正终结悬挂要靠 Native 侧修复 + 线上监控超时错误的出现率——把超时错误也上报(第 2 章 PerfTracker 的白名单通道可复用),它是通信层健康度的核心指标。useRef 暂存最新值 + requestAnimationFrame 里统一 flush 到 state,把渲染节奏对齐到帧——这是「事件频率」与「渲染频率」解耦的最后一步。故障 × 检测手段映射表(排查时按症状对号入座):
| 故障 | 最先看到的症状 | 检测手段 |
|---|---|---|
| 晚到事件 | 卸载后仍触发 / Profiler 里幽灵 commit | alive 标记 + 日志时间轴对比 |
| 监听器泄漏 | 回调重复执行 ×N | 进出页面 20 次回归 + listener 计数 |
| Promise 悬挂 | 无限 loading | withTimeout 抛错 + 超时率监控 |
| 主线程卡死 | ANR / 全局冻结 | StrictMode / Main Thread Checker / Perfetto |
| 事件风暴 | 滚动掉帧、发热 | Profiler commit 频率 + 事件计数日志 |
| 乱序 | 状态回退、轨迹倒放 | seq 单调断言(debug 构建直接抛错) |
跨端接口 Code Review 自检清单(逐项过,任何一项答不上来就打回;建议存进团队的 PR 模板,让它长在流程里而不是靠记忆):
对应里程碑 M6 定位通道:原生高频定位事件按 5s 节流送达 JS,退出页面无泄漏。这是巡检功能的地基——轨迹与到场判定都由它驱动。
先看 FieldNote 各功能的通道选型全景,定位只是其中一行——这张表建议你在自己项目里也维护一份:
| FieldNote 功能 | 通道 | 语义 | 理由 |
|---|---|---|---|
| 定位流(本章) | 全局 Emitter | 持续、跨页面 | 生命周期跟巡检会话走,不属于任何组件 |
| 扫码结果 | 全局 Emitter | 持续、单页面消费 | 引擎持续产出,页面退订即停(6.6 故障 1/2) |
| 生物识别解锁 | Promise | 恰好一次 | 一次性询问-应答(第 7 章封装) |
| 拍照(相机取景器) | Commands + 事件 | 即发即弃 + 结果回调 | UI 意图走指令,结果走事件(第 8 章落地) |
| 卡片拖拽排序落库 | Props + 方法 | 状态 + 提交 | 排序态由 React 持有,落库走方法(第 11 章) |
定位模块的设计要点三条,代码处处对应:
⚠️ 还有一个中国区特有的坑要提前声明:坐标系。系统 GPS 返回的是 WGS-84 国际坐标,而国内地图渲染(各地图 SDK)普遍使用 GCJ-02 加密坐标——不经转换直接把 WGS-84 点画在地图上,轨迹会整体偏移几百米。FieldNote 的约定:通道内一律 WGS-84(原始事实数据),展示层负责转换——把转换义务放在消费端,上游数据才不会被污染;这条约定同时写进双端模块的接口注释(6.5 节「类型即文档」)。
// 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() 一处,影响面可控。
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 队列调用无需再切线程——框架事件的编组又一次替你处理了线程问题。
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 的 ②③ 两条过滤合起来才是完整的乱序防御。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 章的存储层),跨端通道只传摘要与统计,全量数据等有网后由原生侧直接上传或经查询通道按需取。跨端通道永远只承载「当下要消费的数据」,历史归档走存储,这是通道与存储的分工。
onLocationBatch,10 分钟窗口内事件数应为 120±5(有空批跳过),每批 3~6 点;removeUpdates/stopUpdatingLocation 被调用 20 次;(b)JS 侧 listenerCount 归零;(c)定位线程/GCD 定时器销毁,进程内存曲线回基线;验收不要停留在手测:四条验证项都能写成仪器测试/脚本——事件计数断言(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 时显式清理持有的回调 |
本章把 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 线程。
useScanSubscription,含 seq 过滤、卸载停源;(c)给出逐帧上行 vs 300ms 合批的 60 秒事件量对比表。上一章 第 5 章 启动速度优化 | 下一章 第 7 章 原生模块封装(TurboModule)
本章导读:第 6 章回答了「跨端通信走哪条通道」,本章回答「怎么把它规范地写出来」:用 TS spec 定义契约,让 codegen 在构建期生成双端接口,再以继承生成物的姿势实现 Android(Kotlin)与 iOS(ObjC++ 接口 + Swift 实现)。学完本章,FieldNote 的生物识别与扫码模块将作为 M7 里程碑完整落地;下一章用同一套方法论封装「看得见」的部分——Fabric 原生组件。
学完本章,你应该能够:
原理讲解
旧架构写原生模块,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<Spec><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 即契约」的三重意义:
authenticate(123) 是 TS 编译错;原生忘了实现 biometricTypeName 是 Kotlin/ObjC 编译错。签名漂移(参数顺序、类型)在生成物这一层被强制对齐,不再依赖联调发现。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 章的拷贝成本 |
原理讲解
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。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。
原理讲解
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)
}
}
}
}
逐段讲解:
Dispatchers.Default 声明执行线程,iOS 的对应物就是 methodQueue——返回一条专属串行队列后,spec 的所有方法调用都排队执行。不实现时默认走主队列,等价于「在主线程做所有事」,这正是第 6 章警告的反模式。LAError 映射与 7.2 Kotlin 的 BiometricPrompt 错误映射产出同一套字符串(FN_BIO_*),JS 侧才能写一份分支。这是「双端 API 差异抹平」的第一层:在原生层就把语义对齐,而不是让 JS 处理两套原生错误。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 节的真机差异表会再强调)。
原理讲解
新架构没有一刀切:框架内置了 interop 层——旧的 NativeModule(用 @ReactMethod、constantsToExport 那套写的)在 bridgeless 下依然工作。原理是框架为旧模块保留了一条兼容通道:调用仍被包装成 TurboModule 接口暴露给 JSI,但参数序列化、线程与注册行为保持旧语义(也因此旧模块享受不到 JSI 直调的收益)。注册侧的开关就是 7.2 里 ReactModuleInfo.isTurboModule:true 走新路径,false(或完全没迁移的 ReactPackage)走 interop。⚠️ interop 模块与旧架构一样启动期全量实例化——这正是本章思考题要你估算的启动成本来源之一。
何时值得迁移(按信号决策,而不是「为了新而新」):
| 信号 | 建议 |
|---|---|
| 高频调用(毫秒级间隔)或大 payload | 迁移,JSI 直调收益最大 |
| 双端签名漂移、类型错误频发 | 迁移,spec 契约从根上消灭 |
| 模块内有 constantsToExport 且被频繁读 | 迁移,改为同步方法后成本骤降 |
| 稳定、低频、没人再改 | 可暂缓,interop 下继续跑 |
| 三方库已停止维护 | 迁移或找替代品(别在死代码上叠加) |
迁移步骤清单(新旧对照见下表):
codegenConfig,写 spec(照着旧接口翻译成 TS)。NativeXxxSpec,删掉 getName 手写与 @ReactMethod 注解,Package 里补 ReactModuleInfoProvider 且 isTurboModule = true。.mm 壳实现生成协议 + getTurboModule,旧实现挪进 Swift/ObjC 实现类,保留原 RCT_EXPORT_MODULE 的注册名。constantsToExport 改为 spec 的 readonly 同步方法;调用方 JS 改从 getEnforcing<Spec> 导入。新旧写法对照(迁移时的「翻译表」,也是评审时的检查表):
| 旧架构写法 | 新架构对应 |
|---|---|
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) { /* 原实现 */ }
}
原理讲解
生产级原生模块不应散落在 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 的要点(按重要性排序):
js/、android/src/、ios/、*.podspec,绝不发布 android/build/、ios/build/、.tsbuildinfo——生成物在消费端构建期重新生成,带着旧产物发布会让消费端用到过期接口。react-native、react 声明为 peer,版本范围写成宽松区间(如 0.8x),与宿主 App 的 RN 版本对齐是原生包的头号兼容约束。publishConfig 指向私有 registry;prepublishOnly 挂 lint + 7.6 的单测,防手滑。跨模块事件命名规范:全局事件命名空间是扁平的,两个模块都叫 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' 散落在业务代码里。
原理讲解
跨端模块的调试面分三层:原生断点(逻辑正确性)、模拟器/真机差异(能力与权限)、单测(契约回归)。三层各管一段,缺了任何一层都会出现「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,自测页必现;自测页过了,业务层的嫌疑就集中在事件消费逻辑。
M7 交付两个模块:biometrics(应用冷启动/切后台后的身份验证)与 scanner(无取景 UI 的补扫模式:直接解码并上报,命中即停)。biometrics 的双端实现已在 7.2/7.3 给全,本节补齐 scanner 双端、事件上行、注册与 JS 消费,并把两个模块接进 App。
场景约束(源自产品与第 6 章结论):扫码引擎可能每秒识别多次同一码,必须 300ms 合批 + 单调 seq;监听器生命周期严格绑定页面;权限双端都要在原生入口自检;错误码双端同表。
包结构见 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 异常 | 会话配置异常 | 提示重试 |
// 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 上下文销毁的最后防线。
// 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 表)。
// 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 里断言生成物存在 |
域_模块_原因 规范、双端同表维护;错误码是公共 API,改动要升 minor。域:模块:事件,payload 必带单调 seq;发送端切 JS 队列(Android)/主队列(iOS),订阅端按第 6 章纪律成对清理。currentActivity 现取现用,invalidate 是清理的最后防线,onHostDestroy 是第二道。workspace:*。本章把第 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 是契约不是注释——继承生成物、登记黄页、双端同表错误码,剩下的编译器会替你把关。
authenticate 的参数顺序在 spec 中对调(加一个参数);(b)把 ReactModuleInfo.isTurboModule 改为 false;(c)把 iOS RCT_EXPORT_MODULE 的注册名改成与 spec 不一致。分别说明错误暴露在哪一层(编译期/启动期/运行时),并解释为什么(a)应该在编译期就被拦截。authenticateOrFallback()——生物识别锁定(FN_BIO_LOCKOUT)时自动回退设备凭据(Android 的 DEVICE_CREDENTIAL / iOS 的 .deviceOwnerAuthentication)。要求:spec 增加方法与错误码、双端实现、抹平层暴露统一语义、单测覆盖「锁定→回退成功」与「回退也被拒」两条路径。formats: string[] 参数(如 ['qr', 'code128']),双端解码器按参数配置。同时实现「连续扫描模式」:autoStopOnFirst = false 时用 7.2 的 300ms 合批持续上报,JS 侧按 value 去重(同码 3 秒冷却)。思考:去重放原生还是放 JS?给出你的成本对比。TurboModuleRegistry.getEnforcing 是懒代理,首次属性访问才绑定 HostFunction。逐项列出被推迟或消除的成本(原生对象构造、常量序列化、配置 JSON 体积、黄页查找),并结合第 5 章的启动指标说明:为什么 40 个模块里「只有 5 个会在首屏前被调用」时,这个差异会直接体现在 TTI 上?提示:注意 interop 模块不在懒化范围内。上一章 第 6 章 双端通信机制全解 | 下一章 第 8 章 原生组件封装(Fabric)
本章导读:第 7 章封装的是「服务」(无视图的 TurboModule),本章封装「视图」:让一段真正的原生 UI 成为 React 组件树的一员,参与 Fabric 渲染管线的调度。四要素(Props/事件/Commands/State)的选型与成本是本章主线,FieldNote 的原生相机取景器(M8)将完整落地双端。手势与动画的原生协作在第 9、10 章展开,本章只打通渲染链路与接口设计。
学完本章,你应该能够:
原理讲解
第 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;
逐段讲解:
cameraFace/enableQr/frameColor 是「状态型配置」——变化频率低、值本身就是要持续生效的状态,符合 Props 的语义(8.4 详述)。WithDefault 让 codegen 生成带默认值的可空 setter,缺省时原生拿默认值而不是 null。onScanResult,Android 生成侧事件常量为 top 前缀形态(topScanResult),iOS dispatch 用与 spec 一致的 onScanResult,C++ 层归一。纪律:构建后打开生成产物核对事件名常量,不要靠记忆——这正是本章陷阱表「双端不一致」的预防针。原理讲解
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)
}
}
逐段讲解:
setFace 里值未变直接 return。Fabric 的 Props diff 在 C++ 层已完成一轮,但互操作路径下 setter 仍会在重建挂载等场景被调用,视图内再挡一层,把昂贵的 rebind()(解绑/重绑相机)压到真正的值变化时。| BubblingEventHandler | DirectEventHandler | |
|---|---|---|
| 传递语义 | 沿祖先链冒泡,父级可拦截(phased registration:bubbled/captured) | 只交付给发出事件的视图的处理器 |
| 生成事件名 | top 前缀 + phased 注册名 |
top 前缀 + registrationName(Android 生成侧),iOS 用 spec 原名 |
| 典型场景 | 列表项点击希望被容器统一处理 | 本组件私有通知(onScroll/onScanResult/onPhotoTaken) |
| 误用代价 | 该收敛的通知散成 N 份监听 | 想冒泡收不到,被迫层层透传 |
FieldNote 两个事件都选 direct:扫码与拍照是取景器的私有产出,父级没有合理理由截胡。
updateState 拿到 StateWrapper 存下,预览尺寸就绪时 updateState(map) 触发 ShadowNode 数据更新——这是「原生→C++ 树」的通道(8.4 详述它与事件的区别)。view.id 在 Fabric 挂载时被设置为 ReactTag,是事件与 State 的锚点。prepareToRecycleView(复用前重置)与 onDropViewInstance(丢弃时清理)都必须做状态复位——这是本章陷阱表「复用残留」的正面解法。原理讲解
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 */ }
}
逐段讲解:
updateEventEmitter 在视图挂载/重挂载时由 Fabric 注入,卸载后引用可能失效或被换新。规则有三:① 用成员变量持有 typed emitter,不缓存到静态/全局;② 每次 dispatch 前判空——挂载前和解绑后 _typedEmitter 为空,此时原生事件丢弃是正确行为(没有人订阅);③ emitter 与视图实例同生共死,闭包里用 weakSelf 防止实现层→视图→emitter 的循环持有。== 由 codegen 生成),逐字段比较后才触碰实现层。把「重配会话」这类主线程重活推给 sessionQueue 异步执行,主线程在 updateProps 里只做赋值。prepareToRecycleView 对应),不复位就会把上一屏幕的相机状态、节流时间戳、回调带进下一屏幕——状态重置是复用机制的入场券。原理讲解
四要素对应四个方向与四种生命周期,选型错误的本质是把一种生命周期的数据塞进了另一种生命周期的通道。逐项过:
| 要素 | 方向 | 触发时机 | 成本构成 | 典型场景 | 反面模式 |
|---|---|---|---|---|---|
| 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 拿不到,白写) |
几个关键澄清:
TextInput 的文本高度、PullToRefresh 的内部状态都是这个模式)。如果你要「JS 读到原生数据」,那是事件的职责(或第 6 章的全局 emitter)。FieldNote 的 previewWidth/Height 走 State 的价值在于:容器可以在预览就绪前就按上报尺寸占位,避免相机流首帧到达时的布局跳变——这是布局问题,所以用 State;而「拍照完成、把照片加进检查项」是业务事件,走 onPhotoTaken。enableQr: true 是状态);没有目标值、只是一次性意图的用 Commands(takePhoto 是动作)。检验法:这个调用适合放进 useEffect 依赖吗?适合 → Props;需要「点击才触发、只触发一次」→ Commands。一次 commit 里四要素的时序(帮助建立心智模型):
JS setState → commit:
① Props diff(C++)→ updateProps/setter(双端) [每次都调,必须轻]
② State 更新(若有)→ updateState → Yoga 重布局 [按需]
③ Mounting:视图创建/复用 → 挂载 → updateEventEmitter [挂载时]
④ 运行期:原生产出 → 事件批量上行;JS 意图 → Commands [运行期]
【重点】高频视觉数据的正解:不要用任何跨端通道逐帧搬运。取景框高亮的呼吸动效、地图镜头跟随,都应该在原生自绘(原生动画/Shader),JS 只在必要时同步低频状态(如「动画模式开/关」走 Props);涉及 JS 驱动的共享值直通方案在第 9 章(Reanimated worklet 直连 UI 线程)展开。
原理讲解
复杂原生视图(相机、地图、视频播放器、图表引擎)的封装难点不在「跑起来」,而在「被 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 章合批) |
原理讲解
旧架构组件用 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 数更能预测集成后的维护成本。
M8 交付「任务详情 → 拍照取景器」:自定义取景框高亮(扫码框样式)、取景器内扫码(命中即上报)、拍照落盘。spec 在 8.1 已定,本节给出 JS 封装、双端关键代码补全(8.2/8.3 已给 Manager 与 ComponentView,此处补视图实现细节与接线)。
// 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 章先摘监听再停源) |
本章把第 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 是度量——先问谁消费、多久变一次,再动手。
zoom: WithDefault<number, 0> Props(0-1 连续量)与 setZoom 指令两种接口,分别实现并实测:连续拖动滑块时,Props 方案每秒触发多少次 commit(可用 8.2 的 setter 打 log 统计)?指令方案呢?再用「原生自治 + onZoomChange 事件节流上报」实现第三种。三种方案的帧率与 commit 次数对比表贴进笔记。react-native-pager-view)里嵌取景器,要求相邻页预载时相机不被占用、切出页面会话挂起、切回自动恢复。给出 Android(ViewLifecycleOwner 状态机)与 iOS(didMoveToWindow + AppState)的改动点清单与边界条件(快速滑动、双实例并发、前后台切换叠加)。本章导读:从本章起进入「动画与手势」阶段(第 9~11 章)。动画是 RN 应用掉帧的重灾区,因为它把「每帧 16.67ms」的硬预算压到了最严苛的程度。本章从线程模型出发讲清 RN 动画体系分层与 Reanimated 3.x 的底层架构,再沉淀一套可复用的动效模式,并在 FieldNote 中落地检查项打勾动效;手势与动画的深度联动在第 10 章展开,本章结尾埋好最小联动示例。
先看反面教材,这是初学者最常见也最隐蔽的掉帧写法:
// ❌ 反面教材: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 章讲过线程模型,第 6 章讲过高频数据要合批——这里的问题与它们同源:动画帧更新的全链路串行依赖 JS 线程空闲。只要 JS 线程被列表渲染、JSON 解析或同步 SDK 调用占住几十毫秒,动画帧数据就迟到,UI 线程只能重复上一帧或直接跳帧。更糟的是,setState 路径上每帧都要走完 React 的 render 与 commit,等于把最重的流水线拿来干最轻的活(改一个数字)。
【重点】掉帧的本质不是「计算慢」,而是「帧更新的生产者离显示管线的节拍器太远」。显示器的 vsync 是硬节拍(60Hz 每 16.67ms 一拍,120Hz 每 8.33ms 一拍),谁离节拍近、谁不排队,谁就不掉帧。
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 在动画启动后完全退出帧循环。这就是「驱动下沉」:把逐帧计算从 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 解决了「简单属性循环动画」,但它的表达力天花板很明显:节点图无法写逻辑分支、无法做物理反馈式交互、事件驱动(滚动/手势跟随)要拼 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 节对照实验能做出天壤之别的原因。
Reanimated 3.x 维护着两个 JavaScript 运行时:
两个运行时之间通过 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 是被标记为「可在 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、什么不能:
// ❌ 反例:把 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 是双运行时之间的「同步变量」,理解它的读写时序能避开一大类灵异问题:
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。写法跟随当前版本文档即可,语义不变。
三条语义要点:
.value 严重滞后;两个运行时各自执行各自的函数,跨界调用用 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} />;
}
调度开销的要点:
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,手感全部来自物理量。
时间驱动的四件套,负责所有「确定性节奏」:
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 把共享值的当前值按分段线性关系映射到目标区间,是「一个值驱动多重样式」的核心工具:
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 会在输入越过边界时让样式无限延伸——负宽度、超视口位移大多源于此。
每个动画函数的最后一个参数是完成回调,签名 (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 触发,业务上要区分「播完」与「被顶掉」。
布局动画解决的是 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 期间代码不能再触碰该组件实例。
内建的 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 的并发特性后,布局动画有几个必须知道的时序事实:
「动画在 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 的动画还活着。把它放进回归清单,每个新动画组件合入前跑一次。
adb shell dumpsys gfxinfo <package> 查看长帧直方图(janky frames 与 90th/95th/99th 百分位);| 动画需求 | 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——高频观察、低频通知。
以下五个模式覆盖日常 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 驱动的通用心法:值可以突变,样式永远连续。
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 侧处理。
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 行)。
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 章系统展开。
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 的任务详情页是检查项列表:每项可打分(1~5)+ 备注 + 勾选完成(里程碑 M9)。产品要求勾选时有「打勾被画出来 + 圆圈弹性 + 备注收合」的组合动效,并且千条级列表滚动时不能因为这个组件拖慢 JS 线程。
设计决策:
完整组件:
// 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)}
/>
)}
/>
逐段复盘:
对照实验数据(同一台中端 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 只处理数值与插值 |
本章要点回顾:
一句话记忆点:动画值不下 JS 线程——逐帧的过程归 SharedValue 与 worklet,React 只在开始与结束时知情。
本章导读:手势是「动画的输入端」。第 9 章解决了「帧更新不进 JS 线程」,本章解决另一半:手势事件与仲裁也不进 JS 线程。本章讲清 RN 内置 Responder System 的局限、react-native-gesture-handler(下称 RNGH)的原生识别架构、Gesture API 的组合仲裁,并落地 FieldNote 的卡片左滑操作栏与长按拖拽排序骨架——完整拖拽排序将在第 11 章综合实战中收尾。
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>
);
}
它的三大局限:
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 上继续演进。
每个 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' },
});
三条必须内化的时序规则:
| 手势 | 关键配置 | 进入 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% 的「手势时灵时不灵」。
多个手势共存时,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 把激活权交给你的 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」时,手势已经赢了:滚动被抢、高亮已亮,无法回退。思考题(本章末)会让你完整推演这条链。
这是生产中最常见的仲裁战场。四个手段按优先级排列:
| 手段 | 写法 | 解决什么 |
|---|---|---|
| 方向锁(首选) | 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 线程"]
手势驱动动画的 90% 需求都能套进同一个骨架:
value = base + translation。translation 永远相对手势起点,不累计它就会每次从零跳变;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 成为标配的根本原因。
手势有三个可访问性硬伤:屏幕阅读器用户难以发现、难以精确执行;运动能力受限的用户做不出双指/长按组合;任何用户都无法「看见」一个左滑后面藏着什么。因此每章纪律只有一条:每个手势操作都要有可见的、可聚焦的等价操作。
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)实际走一遍——阅读器开启后触控事件形态改变,很多手势会失效,等价物就是唯一通路。
Gesture.Pan().hitSlop({ left: -24 }) 可以让手势触发区避开边缘,也可以放大小控件的触摸面积;.runOnJS(true) 把整个回调转 JS——只对低频手势这么做,并明白这是性能换功能;import { enableLogging } from 'react-native-gesture-handler';
// 开发环境打开 RNGH 日志(实验性 API,以官方文档为准):注册、冲突与状态迁移可见
if (__DEV__) {
enableLogging(true);
}
FieldNote 的任务列表(里程碑 M10 前半)需要两个交互:任务卡片左滑展开操作栏(归档/删除两颗按钮,跟手展开、松手吸附或直接触发删除),以及长按拖拽排序(本章交付最小骨架,完整版在第 11 章)。
设计决策:
activeOffsetX + failOffsetY,与垂直滚动零冲突;// 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>
)}
逐段复盘:
Math.min(next, 40) 与 0.25 衰减是状态无关的纯函数——同一个位移永远映射到同一个视觉位置,回弹只是最后一段 spring;完整拖拽排序需要「行高测量、其余行让位、落点索引计算、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 反查索引;手势状态放共享值并在行复用时重置 |
本章要点回顾:
一句话记忆点:手势裁决发生在原生,动画发生在 UI 线程,JS 只在开始与结束时知情。
上一章 第 9 章 动画系统与 Reanimated | 下一章 第 11 章 综合实战:复杂交互组件
本章导读:本章是"动画与手势"阶段(第 9、10 章)的毕业设计。我们先补完第 10 章留下的"长按拖拽排序"骨架(M10 后半)到生产级,再新增照片缩放预览与自定义下拉刷新两个案例,最后统一回答三个问题:怎么证明一个交互组件是流畅的、怎么验收它、怎么把它工程化为可复用的独立包。读完本章即可对照附录 B 完成 M11 验收,下一章进入"状态与架构"阶段。
FieldNote 任务详情里的检查项按 sortKey 排序展示,巡检员需要调整评分顺序。拆成可验收的子需求:
【重点】拖拽排序最常见的错误实现是: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' },
});
逐段讲解几个最容易做错的位置:
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 的弹簧从当前值(手指位置)出发,所以落位动画是"免费"的,不需要额外代码。第 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 同款思路),代价是键更长、比较更贵——千条级巡检场景用浮点中点足够。
| 场景 | 可行性 | 要点 |
|---|---|---|
| 固定行高 + 数量有界(分组内几十到一百项) | 直接用本章 ScrollView + 绝对定位 | slots × ROW_H 可预测一切位置,代码量最小 |
| 固定行高 + 数量巨大(千条级主列表) | 不建议整表拖拽 | 交互降级:进"编辑模式"后先筛选/分组,再对小列表排序 |
| 动态高度 | 需要升级 | 把 ROW_H 换成"前缀和数组"(每行高度需测量缓存),槽位查找用二分;让位位移不再恒定 |
| FlashList 回收复用 + 拖拽 | 冲突 | 回收会把离屏行复用给新数据,"行 i 常驻 slot i"的绝对定位假设被破坏;编辑态应切换为非回收的简单列表,或退回 ScrollView |
结论:拖拽排序的适用面是有界的——分组列表(本章场景)用 ScrollView+绝对定位;千条级主列表不该承担拖拽,这是架构约束而不是实现缺陷。
slots(数字数组),样式重算只改 transform,不触发原生布局;提交前零 setState;自动滚动速度用 px/ms 而非 px/帧,兼容 120Hz 屏幕。ORDER BY sort_key 读出)。巡检拍照后,详情页九宫格点击进入全屏预览:捏合缩放(1~5 倍)、放大后单指平移、双击在 1x 与 2.5x 间切换、松手边界回弹、未放大时手势绝不干扰底层列表。三个决策:
fail,不会偷走任何事件。e.scale 是相对"本手势开始时"的倍率。onEnd 若不把 scale 写回 baseScale,第二次捏合会从 1 起跳,产生"突然跳大"的 bug(详见陷阱表)。平移同理。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 即可消除延迟。Simultaneous 组合:双指捏合时往往伴随轻微位移,两者同时识别才能"边捏边挪";互斥会让捏合中的位移丢失。(焦点 - 中心) × (1 - 新倍率/旧倍率) 修正 tx/ty,公式不复杂但建议先跑通本章再叠加。manualActivation(放大才激活),并在激活瞬间 runOnJS(setScrollEnabled)(false) 关掉父滚动、收起时恢复——成本是 JS 状态往返,能不用尽量不用。resizeMode="contain" 的大图缩放走 transform,不解码新尺寸;Modal 卸载时重置共享值,避免下次打开残留旧状态。系统组件开箱即用且带原生手感,但它的视觉与交互被系统锁死:转圈样式不可换、头部不能放品牌动效、无法做"下拉中显示上次同步时间/异常任务数"这类信息型头部、双端外观不一致。当这些是硬需求时才值得自写;选型对比见本节末尾。
bounces={false}、overScrollMode="never"),在"已滚到顶部且向下拖"时由 Pan 手动激活,位移驱动头部。代价是失去原生回弹(见对比表),换来一套代码双端一致。d(x) = M(1 − e^(−x/M))。求导得 d′(0) = 1——起始段 1:1 跟手,之后越拉越"沉",渐近上限 M。一个函数同时给出跟手感与物理上限,且天然防抖(见陷阱表)。idle / pulling / refreshing,加一个"已越过阈值"的瞬时判断。触发判定只放在 onEnd 做一次(pullY >= THRESHOLD 才刷新),绝不在 onUpdate 里边拉边触发——这是快速下拉抖动的根治手段。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 行 + 边界用例) |
| 建议 | 默认选择 | 仅当品牌/信息头是硬需求时使用 |
交互组件的"流畅"必须拿证据说话,口沿用第 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 章的测量方法论。
低端机测试清单:
交互验收卡模板(每个动效组件一张,随 PR 提交):
### 交互验收卡:DraggableSortList
- 手势冲突项:纵向滚动 / 长按 / 系统返回侧滑 —— 结论:SLOP 判负 + activate 锁定,实测无冲突
- 帧率(release、60Hz、机型 XXX):UI 线程 P90 __ms / P99 __ms / 掉帧 __ 帧
- JS 线程:操作期占用 __%(应近 0);useJsBurn 对照:通过 / 未通过
- 内存:进入前后 RSS 增量 __MB;连拖 10 次无持续增长
- 无障碍替代:TalkBack/VoiceOver 提供"上移/下移"按钮路径,已验证
- reduce motion:开启后弹簧即时化、自动滚动保留,功能无缺失
- 低端机(机型):通过 / 未通过(附 trace 文件名)
当动效组件要跨 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 任何业务类型,保持纯净。
把三个案例接进 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,全部勾选才可进下一阶段):
useJsBurn 对照实验通过(JS 线程近 0)| 陷阱 | 典型症状 | 根因 | 正确做法 |
|---|---|---|---|
| 用 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 层 |
useJsBurn 对照实验证明它withSpring(目标槽位) 样式路径,靠状态切换获得免费连续性onFinalize 必须做幂等收尾useFrameCallback + 时间基准速度(px/ms),贴近边缘线性渐增本章把第 9、10 章的手势与动画能力收敛成三个生产级案例:拖拽排序用"槽位映射"把整条交互链路压进 UI 线程,松手才以 sortKey 中点落库;照片预览用"本手值/基准值分离"消灭二次缩放跳变,用 Modal 从物理上完成手势仲裁;自定义刷新用阻尼曲线和一次性阈值判定拿到既跟手又防抖的手感。三个案例共同证明了同一件事:交互性能的上限取决于"每帧干了多少事、在哪条线程干"——把帧级工作留在 UI 线程、把 JS 边界留给事件,帧率证据自然好看。
一句话记忆点:让位改的是数字,不是数据;流畅的证据不是感觉,是验收卡上的 P99。
DraggableSortList 的 SPRING 改为 { damping: 18, stiffness: 120 },观察落位出现明显过冲;再解释 damping/stiffness 各自如何影响手感(提示:阻尼比 < 1 会过冲,刚度决定收敛速度)。然后把行高抽成 rowHeight prop,找出代码里所有"必须随行高联动"的常量并说明原因。DraggableSortList。思考:照片有缩略图、无固定行高语义,你选择"固定 96px 虚拟行高 + 网格 3 列"还是为它扩一版前缀和实现?说明取舍。上一章:第 10 章 手势系统与 Gesture Handler | 下一章:第 12 章 状态管理方案选型
本章导读:交互打磨完成后(第 9~11 章),复杂度开始从"每帧做什么"转向"数据住在哪里"。本章是"状态与架构"阶段的开篇:先建立状态分类学这一方法论基石,再从渲染订阅原理出发横评主流方案,最后给出选型决策树与 FieldNote 的完整状态清单落地。它直接为第 13 章的模块化架构(按模块切状态边界)提供依据,也为第 14~16 章离线同步(Outbox、增量同步)备好服务端状态的心智模型。
原理。状态管理混乱的根源几乎从来不是"库选错了",而是"状态放错了层"。先定义四类:
hoverSlot 就是范例)。判断一个状态住哪,依次问四个问题:**它是谁的事实?谁写它?变化频率多高?要不要活过重启/跨页回放?**第一个问题最关键:事实在服务器的,客户端只配拥有缓存,就绝不该用普通 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 节专门解这一刀。
方案性能差异的根源在订阅粒度:值变化后,谁会被要求重新渲染。
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 |
每个方案给最小代码、原理一句话、适用场景与天花板。判定标准统一为四条:心智负担、样板成本、细粒度更新能力、团队约定强度。
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 心智团队 | 中 |
服务端状态的核心不是"存哪",而是缓存生命周期。TanStack Query 用三个概念覆盖它:
['tasks'] 失效会连带所有列表;['task', id] 精确命中一个详情。键里放影响结果的一切变量(筛选、分页),不放无关字段。staleTime 决定数据多久内"新鲜"(挂载/聚焦不重取);invalidateQueries 把数据标记为脏并触发重取。要区分 staleTime(多久内可信)与 gcTime(不活跃后缓存保留多久)。// 缓存键工厂:层级设计是失效策略的地基
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 管的是"写操作的可靠性",两者互补而非替代。
本书立场(大型 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 章按模块切边界,跨模块只走接口 |
禁止事项清单(评审时逐条对照):
useShallowpartialize 白名单 + version/migrate 管迁移状态分层与依赖方向。把 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 或领域服务),这是本章分层在架构阶段的直接延续。
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); // 没被误伤
});
});
状态时间旅行与调试手法:
devtools(fn, { name: 'prefs' }))接入 Redux DevTools,可回放每一条 action;RTK 原生自带。时间旅行的价值不在炫技,而在复现"状态是怎么一步步错的"。setState 包一层日志中间件(开发期)记录前后快照;用 React DevTools Profiler 确认"这个值变了到底谁重渲染了"——它是验证 12.2 节订阅粒度的实测工具。把 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 层 |
本章建立了两条主线。方法论主线:状态先分类(服务端/客户端/UI/持久化)、再分层(组件/容器/领域/基础设施)、后选型(决策树 + 禁止事项),"状态放错层"比"库选错了"更致命。原理主线:订阅粒度决定性能——Context 广播、外部 store 的 selector 订阅、信号的依赖收集是一条从粗到细的谱系;服务端状态因为"会过期、要失效、有并发"而必须使用带缓存生命周期的工具,这是它与客户端状态永远无法共用一个心智模型的根本原因。FieldNote 落地为:Query 管任务数据(键工厂 + 乐观更新)、Zustand 管会话与设置(mmkv 持久化)、Context 管主题、打分交互留在本地态。
一句话记忆点:先问"这是谁的状态",再问"用什么工具";缓存的生命周期问题,永远不要用存储方案来回答。
tasks 该去哪?draft 该去哪?theme 放 Context 对不对?):const useAppStore = create((set) => ({
tasks: [], theme: 'light', draft: '',
fetchTasks: async () => set({ tasks: await api.getTasks() }),
}));
const AppCtx = createContext(useAppStore);
syncPolicy: 'wifiOnly' | 'always' | 'manual'(旧字段是布尔 syncOnCellular),在 zustand persist 中写好 version/migrate 迁移逻辑,并为迁移写一条单测(提示:旧布尔 true 应映射到哪个新值?)。上一章:第 11 章 综合实战:复杂交互组件 | 下一章:第 13 章 大型项目架构设计与模块化
本章导读:第 12 章解决了"数据住在哪里",本章解决"代码住在哪里"。状态选型确定后,真正让项目失控的往往不是某个库,而是目录结构、依赖方向与模块边界。本章给出从目录演进、四层架构、monorepo 拆分,到模块间通信、路由组织、质量门禁的完整工程方案,是"状态与架构"阶段的收官章。这里重构出的 core-domain 与模块边界,将直接承接第 14~16 章的离线同步体系。
原理。目录结构不是审美问题,而是"改动成本"问题:一次业务变更需要触碰的文件越分散,腐化越快。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/ 是全项目唯一允许知道"接口长什么样、实现是哪一个"的地方,别的任何文件都不做装配。
原理。四层的职责与"回答的问题"各不相同:
单向依赖原则:依赖箭头只能从上往下(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 与领域代码一行不改。
原理。单包结构里,模块边界只有两种守护方式:口头约定和 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 规则)叠加后,"边界"才从公约变成事实。
原理。禁止横向 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 一律先怀疑分层设计错了。
原理。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。
原理。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 不打原生包,分钟级)
目标:把 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 → 边界收口。
六步清单,每步带验证手段:
pnpm install 后 pnpm android 冷启动成功、热更新正常。此步只动配置,风险为零,但它证明 Metro 与 pnpm 布局已通。src/core/domain/** 原样移入 packages/core-domain/src,app 以 workspace:* 引用。验证:pnpm --filter @fieldnote/core-domain test 全绿(领域函数在 13.2 之前就保持零 React,此时直接受益);pnpm typecheck 通过;冒烟跑一遍打分流程。src/infra/ 的 mmkv、Keychain、logger 封装与第 8 章的相机桥接封装。验证:冷启动 + 设置页读写持久化 + 生物识别解锁走通(原生代码不动,只挪 JS 封装,风险集中在 import 路径)。src/shared/ui/。验证:主题切换页视觉走查 + 组件 smoke 测试;此时 Metro 的 watchFolders 已在第 1 步配好,改 shared-ui 热更新应即时生效。pnpm -r lint,把所有违规(横向 import、深路径引用)逐个改为走公共入口或端口。验证:lint 从红转绿,且在 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——这是唯一的"提前量",因为回补成本最高exports 收窄公共 API;"能不导出就不导出"是包设计的默认值navigation.reset;屏幕间传 id 不传对象本章把第 12 章"状态住哪里"的答案扩展成"代码住哪里"的完整方案。三条主线:其一,目录结构从按类型到按功能再到按领域演进,FieldNote 的 core/features/shared/app 形态让"改动收敛"成为常态;其二,四层架构的核心是单向依赖与依赖倒置——领域层零依赖使规则可毫秒级单测、可跨端移植,DI 容器、注册表、事件总线分别接管调用、扩展与广播;其三,工程纪律必须有执法机构——pnpm 物理隔离、exports 入口收窄、ESLint 边界规则、CI 四道闸层层设防,路由作为状态投影消灭了命令式跳转的时序炸弹。FieldNote 完成 M13:monorepo 就位,core-domain 带着完整测试独立演进。
一句话记忆点:架构不是画出一张图,而是让"画错"在 CI 里编译不过——依赖方向决定项目寿命,边界靠工具守护而非口头约定。
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>;
};
TaskRepository 端口(列出今日任务、按 id 取详情、保存评分),分别给出 SQLite 实现与"内存假实现",并用内存假实现写一条 makeScoreCheckItem 用例的集成测试;说明联调阶段如何通过组合根一行切换两个实现。packages/shared-ui 里写了 import { MMKV } from 'react-native-mmkv' 和 import { applyScore } from '@fieldnote/core-domain/src/entities/checkItem',分别指出违反了哪条规则,并给出让它们在 CI 变红的 ESLint 配置片段;如果 core-domain 确实需要一个"持久化键名"的能力,正确的依赖倒置方案是什么?Alert.alert、用 useQuery 缓存中间结果),请从三个角度评估迁移成本——测试成本(想给规则写单测要先搭什么环境?)、依赖扩散(import 图上谁会被迫进包?)、时序耦合(异步逻辑藏在组件生命周期里时,同步引擎怎么拿到它?)。最后回答:什么样的"提前抽象"值得付出,什么样的不值得?判断标准是什么?上一章:第 12 章 状态管理方案选型 | 下一章:第 14 章 本地存储与缓存体系
本章导读:从本章起进入"离线与同步"阶段。FieldNote 宣称"无网全功能可用",背后是三件事:数据放在哪里(本章)、断网时如何保证读写一致(第 15 章离线优先架构)、联网后如何增量同步与合并冲突(第 15、16 章)。本章先解决"放哪里":给出六类存储方案的底层原理、FieldNote 的四类数据落位决策、缓存分层体系与敏感数据工程规范,为后续两章打好存储地基。
原理。选型之前先看清每个方案"数据放在哪、读写走哪条路、瓶颈卡在哪"。
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 | 极小(凭据) | 无 | 无 | 硬件级 | 按条目策略 | 无 |
原理。按"数据形态 × 访问模式"而非"哪个库新"做决策。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 的唯一价值是发现设计错误,不是生产恢复手段。
原理。三层各回答一个时间尺度的问题:内存层(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 防止极旧快照误导用户。
原理。第 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),弱网下可灰度关闭。
原理。敏感数据的工程判断先于技术:哪些数据泄露即事故?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_*> 占位),而不是给门禁开白名单口子,口子一开终会腐化。
主线程 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 就是预算化清理的范本。
目标:完成 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 出厂标准,逐条可测):
user_version 正确、失败时启动走降级而非白屏idx_tasks_plan_status,无全表扫描findDirty 走部分索引,返回条数 = 待同步量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 全异步;大对象走文件系统 |
{ns}:v{ver}:{entity}:{id}:schema 变更换 v 号整体作废,胜过写兼容代码本章完成了"数据放哪里"的全景与落地。原理主线:六类方案的差异本质是"数据放哪、读写走哪条路"——AsyncStorage 的全量 JSON 往返、MMKV 的 mmap + JSI 直连、SQLite 的 B+ 树与事务、Keychain/Keystore 的硬件 backing,决定了它们各就各位。架构主线:FieldNote 四类数据落位(结构化数据 SQLite、小 KV MMKV、凭据 Keychain、大文件文件系统 + 元数据),配套复合索引与部分索引、版本化迁移与降级路径;缓存三层(内存/磁盘/远端)各管一个时间尺度,key 带版本号防脏读,persistQueryClient 只做只读提速。安全主线:凭据零字面量靠 grep 门禁执法,日志出口统一脱敏。M14 交付了可验收的存储层,dirty 标记与 syncState 已就位——第 15 章的离线优先架构与同步引擎将直接在这些表上展开。
一句话记忆点:先问数据的形态与访问模式,再让引擎就位——事实源只有一个,缓存可以很多层,但每一层都要有预算、版本号和清理时机。
EXPLAIN QUERY PLAN 验证你的结论。CACHE_VER 从 v3 升到 v4 且 SettingsStore.syncPolicy 的取值枚举发生变化(wifiOnly 拆分为 wifi/wifi5g),写出读取旧值并迁移到新 key 的逻辑,并说明为什么这种迁移代码要尽量少写——它与"换 v 号整体作废"各自的适用边界在哪里?上一章:第 13 章 大型项目架构设计与模块化 | 下一章:第 15 章 离线优先架构与数据同步
本章导读:第 14 章把数据落进了 SQLite、MMKV 与 Keychain,本章在此基础上解决全书最核心的难题——离线优先架构与数据同步:本地凭什么当权威、变更怎样可靠地到达服务端、服务端变更怎样无遗漏地回来。我们将深入 Outbox 模式、游标增量协议与一整套可靠性机制,并搭建带故障注入的 Mock Server 完成端到端联调;冲突如何裁决,留给第 16 章。
原理讲解
在线优先(online-first)架构里,网络是主干道:UI 发起读写 → 请求 API → 服务端返回 → 回写本地缓存。本地只是缓存的影子,网络失败等于应用不可用,「离线」是一种错误态。
离线优先(offline-first)把这套关系倒转过来:本地 SQLite 是权威数据源,UI 的读写永远只碰本地;网络退居幕后,变成一个异步的复制进程,负责把本地变更推上去、把远端变更拉回来。「离线」是一种常态,不是异常。这个倒转带来三个直接推论:
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 全部落在离线优先一侧,结论是必须离线优先。⚠️ 反例也要认清:实时协作白板、秒杀下单、资金转账这类「正确性依赖服务端实时状态」的业务,强行离线优先会把矛盾推迟到同步时刻爆发,代价更大。
原理讲解
离线优先最要命的问题在写路径:用户改了数据,本地成功了,怎么保证这个变更最终一定到达服务端,且只到达一次?在线优先的做法是「UI → API → 服务端 → 回写缓存」,可靠性押在网络上,失败点横亘在用户操作与结果之间。Outbox 模式(事务性发件箱,transactional outbox)把可靠性挪进本地:
dirty = 1(第 14 章字段);outbox 表插入一条待同步记录;为什么「本地事务先行」比「在线 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 : 对账修复 / 人工重放
nextRetryAt 到期。第 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 采用最终态快照,让「同实体多次变更合并」在入队时就完成了大半。
原理讲解
写路径可靠了,还要解决读回来的问题:服务端的变更(他人操作、后台处理)怎么增量、无遗漏地到达客户端?
游标增量拉取。服务端为每条变更分配单调递增的 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 拉详情补齐。它慢,但简单、无状态,是增量协议的最后一道保险。
推与拉的先后。一轮完整同步是先推后拉还是先拉后推?取舍如下:
同步锁。网络恢复、回前台、冷启动、定时器——多个触发源可能同时到达,若不加互斥,两个同步实例并发跑,会出现重复推送、游标交叉推进等乱象。解法是单飞行(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 记录)。
原理讲解
真实网络是「抖动的、间歇的、随时把进程杀掉的」。同步引擎的可靠性由五件事拼成。
第一,指数退避 + 抖动。失败后立即重试大概率再失败(服务端过载/网络未恢复),重试间隔应随失败次数指数增长并封顶。但纯指数有一个隐患:如果 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() 检查是软性的——真正防并发的是引擎内部的锁,这里的检查只是省一次入队开销。
原理讲解
「App 退到后台也能同步」听起来天经地义,实现上是双端各有一套时机不保证的调度机制,能力差异很大:
| 维度 | Android WorkManager | iOS BGTaskScheduler |
|---|---|---|
| 定位 | 可延迟的后台约束任务 | 系统「学习」使用习惯后的机会性任务 |
| 触发时机 | 约束满足后相对较快;DOZE 模式下集中在维护窗口 | 完全由系统决定,无任何保证;新装 App 常要几小时才给窗口 |
| 网络约束 | 原生支持 NetworkType.CONNECTED |
不提供网络条件约束,需代码内自查连通性 |
| 执行窗口 | 分钟级,较宽松 | refresh 类型约 30 秒量级;processing 类型更长但更吝啬 |
| 退后台立刻执行 | 不保证 | 明确不成立(系统设计如此,防滥用省电) |
| 进程被杀恢复 | WorkManager 自管重试 | 需 App 自行落盘断点(本章 Outbox 天然满足) |
| 调试方式 | adb shell dumpsys jobscheduler、禁电池优化 |
模拟器 + Xcode/lldb 触发命令(以官方文档为准) |
结论要写进架构决策:【重点】「后台必同步」在 iOS 上不成立,在 Android 上也只是「大概率」。因此同步的可靠性主战场必须放在前台——这叫前台化策略:
代码示例:双端后台任务的关键实现。
// 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 秒量级)系统会直接挂起进程——所以后台窗口里只做「拉一页 + 推一小批」,绝不贪多。
原理讲解
同步协议不需要从零发明,业界有几个成熟坐标:
pull(lastPulledAt) 拉服务端变更应用,再把本地变更 push 上去,服务端返回新的时间戳作为游标。协议自带约定(变更集格式、冲突由服务端在 push 时裁决),客户端引擎成熟。局限:协议接管全部同步表的语义,自定义空间中等,且要求后端按协议实现端点。| 方案 | 协议归属 | 冲突处理 | 后端要求 | 定制空间 | 典型适用 |
|---|---|---|---|---|---|
| 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 同步引擎,共四个模块 + 一个 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,靠幂等键安全重放 |
idempotencyKey 入队时生成、持久化、重试复用;服务端去重表按它裁决,客户端永远不重生成。serverVersion;客户端时钟只许出现在 UI 展示里。dirty;变更应用与游标推进绑同一事务;宁可重拉,不可半拉。本章把 FieldNote 从「有本地库」推进到「离线优先」:读路径上,SQLite 是权威、Query 缓存只是视图,数据流单向;写路径上,Outbox 与业务写同事务入队,状态机(pending → inflight → done/failed/decayed)保证落盘即持久、重试即安全;拉路径上,serverVersion 游标增量 + 全量对账兜底,应用与推游标同事务;外围用退避抖动、多触发源、同步锁与后台任务把引擎钉进真实网络环境。冲突只是被检测出来了(dirty 相遇、baseVersion 落后、rejected),如何裁决是第 16 章的主题。
一句话记忆点:本地先落盘,出队必幂等;游标随事务走,同步必加锁。
computeBackoffMs 写单元测试(可跑在 Node 环境用 vitest/jest),验证三件事——attempt=1..10 时均值大致指数增长、返回值永远 ≤ maxMs、同一 attempt 多次调用结果互不相同(抖动生效)。提示:给函数注入随机源参数以便确定性测试。delete(opType 变化),当前合并逻辑有什么隐患?给出你的处理方案。channel 字段(metadata / binary),claimBatch 按通道分别取数,syncEngine 并行跑两条推送链路;验证一张 8MB 照片上传失败重试时,评分元数据照常秒推。提示:两条链路共用同一把同步锁还是各自加锁?想清楚再写。加分项:给 binary 通道实现分片上传与断点续传(会话幂等 + 已确认分片位图持久化),并注入「每传 3 片断网一次」验证恢复后只补缺失分片。本章导读:第 15 章的同步引擎解决了「数据能不能到」,本章解决「到了以后怎么办」——并发修改、乱序到达与重复重放带来的冲突与一致性收尾。我们将建立「避免 → 检测 → 解决」三层防御体系,掌握五种解决策略的判定准则,用幂等、收敛性测试与事务边界守住最终一致的底线,并以 FieldNote 三个真实冲突案例完成全流程推演,为第 17 章的发布铺平数据正确性基础。
原理讲解
离线优先把读写都放在本地,等于允许同一个实体在多个副本上并行演化。冲突由此有三类根源:
serverVersion = 7 修改,随后各自产生了一条「基于 7 的新版本」,服务端只能收下一条——另一条就成了冲突。「冲突」需要一个精确定义,否则讨论会失去焦点。字段级写写冲突(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)+ 版本单调 | 后提交的旧操作后到 |
💡 一个工程直觉:重复投递用幂等吸收,乱序到达用单调版本吸收,真正的冲突只剩「同字段并发写」这一小撮。把前两类处理干净,冲突处理就从「大数据问题」缩水成「小概率业务问题」——这正是本章后续所有机制的分工地图。
原理讲解
成熟的冲突处理不是写一个万能合并函数,而是三层设防,让冲突在越早的层被化解,代价越低:
┌────────────────────── 第一层:避免(设计期,建模时) ──────────────────────┐
│ 单写者原则:检查项按任务分派单人,天然无并发写 │
│ 字段拆分: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 的策略工具箱。
原理讲解:每种策略给判定准则 + 代码片段,按「自动化程度从高到低、适用面从宽到窄」排列。
策略一: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(服务端序) | 单值开关,丢失一次写可接受 |
| 金额 / 删除类操作 | 用户裁决或服务端拒绝 | 错误代价最高,不交给自动化 |
原理讲解
幂等性深入。第 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 的子查询就查不到了。
原理讲解
**版本向量(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 行裁决逻辑;字段级并发检测需要字段版本信息;
复议条件:出现跨端协作编辑(巡检报告共创)或点对点同步(无服务端场景)需求时重评。
原理讲解:契约是第 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 用单一计数器,游标协议因此可以跨实体比较;若未来分片部署,可退化为按实体流单调 + 游标按实体独立,协议不变);客户端上报的任何版本号一律忽略。
三个真实冲突案例,每个都按「场景时序 → 检测过程 → 采用策略 → 代码 → 验证」推演。案例一需要一次存储迁移:
-- 迁移:检查项增加评分历史(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 同事务 |
serverVersion 与客户端 baseVersion 的比较;客户端时钟、本地 updatedAt 一律不参与裁决。baseVersion 与 changedFields,服务端才能区分「同字段冲突」与「不同字段并行」,后者直接自动合并。conflict + merged/current,客户端应用裁决与清 dirty 必须同事务,杜绝循环推送。本章把第 15 章检测出的冲突收拢成一个可维护的裁决体系:根源上区分同字段并发、不同字段并行与重复投递,后者两番分别被幂等键与单调版本吸收;防御上「避免 → 检测 → 解决」三层递进,代价随层数递增;解决上五种策略各有判定准则,FieldNote 用业务规则(后提交生效、状态机只进不退)处理同字段冲突,用字段级合并吸收并行修改,用服务端拒绝处置非法转移;一致性的底线由幂等去重表、收敛性测试、先父后子的事务顺序与孤儿清理共同守住;CRDT 提供了无协调收敛的视野,但服务端权威业务里,「收敛」不等于「合法」,ADR 记录了 FieldNote 的取舍。至此 Stage 6 的数据正确性闭环完成,第 17 章开始,这套引擎将随 App 一起走上双端发布、灰度与热更新的流水线。
一句话记忆点:能避免就不检测,能自动就不人工;一切序,以服务端为准。
mergeFields 写单元测试,至少覆盖四类用例——双方改不同字段、双方改同一字段(有/无字段裁决器)、一方未动、base 为 null 的首次合并;再补一个「以 base 为锚点」的反例测试:把实现里 !eq(m, b) 换成 m !== t,观察哪些用例由绿变红,用它们说明三值比较的必要性。min(mine, theirs)(业务假设:从严复核),替换案例一的后提交生效逻辑;跑一遍收敛性测试确认不引入不收敛;再写一条 ADR 片段,论证两种策略分别适配什么考核文化。conflicts 表读取待裁决项,双列展示 mine/theirs(含字段高亮),用户点选后调用 adjudicate;给队列条目加 48 小时超时——过期自动按 theirs 落地并打标记。验证:制造一条冲突,杀进程重启,确认裁决项仍在、超时逻辑在改系统时间后正确触发(提示:测试时把过期判定注入成可配置时长)。本章导读:你已把 FieldNote 打磨成一个离线优先、架构清晰的 App(第 13 章 monorepo、第 16 章同步引擎),本章开始解决「怎么把它变成可上架的双端安装包,并安全送到用户手里」。签名、多环境、自动化构建、符号化监控、灰度发布,这五件事构成一次生产发版的完整闭环。第 18 章的热更新与拆包,完全建立在本章的签名、构建号与符号体系之上——本章没做好,热更新就是空中楼阁。
先回答一个本质问题: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 两侧,避免两处各写一份。
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 传感器读数(如启用) |
【重点】文案要写成「场景 + 用途」而不是「我们需要权限」。审核员模拟真实使用路径时,申请时机也应滞后到用户触达功能那一刻,而不是启动即弹。
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.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 }}
为什么没有符号表堆栈不可读?三个层面各有各的「翻译表」: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 实战):
双端灰度机制不同,先对比:
| 维度 | 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 占位),实现留给练习。
本节对应里程碑 M17:双端发布流水线落地。产出四件套:三环境配置全集、双端 Fastfile、发版 checklist 卡片、灰度 5% 演练记录。
配置目录如下(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 配置,补救的成本是一次安全事件。
在 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
发版负责人按序勾选,全部通过才允许打 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%
- [ ] 每阶段放大前重读监控看板并留档截图
- [ ] 中止线演练:确认「暂停放量」操作路径可用(谁有权、在哪个后台)
一次真实演练的记录格式(示例数据为演练剧本的预期值):
| 时间点 | 指标 | 当日值 | 门禁 | 判定 |
|---|---|---|---|---|
| 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 命名规则代码化,双端共用一个工具函数生成 |
.env 文件同时喂 Android BuildConfig、iOS xcconfig、JS Config 模块,禁止任何一处出现第二份地址常量本章把 FieldNote 从「能跑的工程」变成「能发布的商品」:Android 侧用「包名 + 签名」确立身份,keystore 生命周期与 Play App Signing 决定生死;AAB 与 R8 分别在分发与体积两端优化,keep 规则守护反射契约。iOS 侧签名信任链三件套(证书、profile、entitlements)缺一不可,xcconfig 多环境与权限文案是过审的隐形门槛。双端之上,react-native-config 打通「配置即代码」的三类划分,fastlane 把版本、构建、符号、分发固化成 tag 触发的一条流水线,Sentry 三件套让崩溃可读,灰度与强制升级让事故可控。
一句话记忆点:发版的本质是「可追溯」——签名让包可追溯、构建号让版本可追溯、符号让崩溃可追溯、灰度让风险可追溯。
-keepattributes Signature 为什么是 JSON 反序列化框架正常工作的前提。v2.*.* 时必须使用 .env.prod 出包,v2.*.*-rc 才允许 staging),不匹配直接 fail。提示:在 Fastfile 里用 ENV["RELEASE_VERSION"] 与 flavor 双重校验,并让校验脚本同时打印即将使用的 API_BASE_URL(脱敏域名即可)。本章导读:第 17 章的发布体系解决「原生变更如何安全到达用户」,本章解决两件它够不到的事:JS 层的缺陷如何不经过商店当天修复(热更新),以及单一大 bundle 如何拖慢启动、如何按业务线隔离(拆包)。两者共享同一个底层事实——RN 应用是「原生二进制 + JS bundle」的分层结构。本章为第 19 章安全加固铺垫了校验、签名与信任根的思想。
一个 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
那为什么原生代码不能热更?三个互相锁死的理由:
推论就是判定表的第一原则:任何触碰原生层的东西(新原生模块、新权限、原生依赖升级、targetSdk 升级)都必须走商店发版——这正是第 17 章流水线存在的意义,热更与发版是互补关系不是替代关系:
| 改动类型 | 热更可行 | 说明 |
|---|---|---|
| JS 业务逻辑、组件、样式 | ✅ | 热更主战场 |
| 图片/字体/静态资源 | ✅ | 随 bundle 或独立资源包下发 |
| 纯 JS 依赖升级(无原生部分) | ⚠️ | 技术可行但需回归,版本错位会炸(见 18.6) |
| 新增/升级含原生代码的 npm 库 | ❌ | 原生侧不存在对应实现 |
| AndroidManifest / Info.plist 变更、新权限 | ❌ | 安装期固化 |
| react-native / 核心库升级 | ❌ | 原生与 JS 必须同升 |
| Metro 配置、runtime version 指纹变化 | ❌ | 本身就是原生发布的一部分 |
Apple 开发者协议中约束下载执行代码的条款是 3.3.2(条款编号以最新协议为准),其长期稳定的内核是两条:下载的解释性代码只能使用 Apple 内置的 WebKit 或 JavaScriptCore 运行;且不得改变应用的主要用途或特性。RN 用系统内置 JS 引擎执行开发者自己随包或经热更下发的自研 JS,长期被业界广泛实践、在「不改变核心用途」的前提下被接受。因此合规热更的边界不是技术边界,而是用途边界。
真实被拒案例的三种典型模式:
国内安卓商店普遍允许热更但有两类要求(具体政策各家差异大、随时调整,以各平台最新政策为准):一是可关闭、可回滚——用户侧或服务端要能停住热更并把风险版本撤下;二是不得利用热更绕过审核——上架审核看到什么功能,热更后还是什么功能(可以修 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、调样式、改文案 | 允许 | 允许 | 走热更 |
| 新增与核心用途一致的子功能 | 灰色,从严判断 | 视平台 | 建议走发版+审核,最稳 |
| 改变核心用途/新增变现路径 | 拒绝 | 拒绝 | 必须发版过审 |
| 新权限、新原生模块 | 必须发版 | 必须发版 | 技术上也做不到热更 |
| 审核期内下发更新 | 关闭开关 | 关闭开关 | 构建期注入 + 远端覆盖 |
expo-updates 是当前生产级热更的主流参考实现,核心机制四个概念:
appVersion(原生版本号即 runtime 版本,原生没升 JS 永远兼容——保守但每次原生发版都会切走热更基线);fingerprint(对原生依赖清单——包含的模块、插件、配置——计算指纹哈希,指纹相同才允许热更,原生依赖一变指纹即变,精准但要求指纹计算稳定)。回滚策略三层兜底:嵌入包(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.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 机制的本质。
先回答「为什么要拆」。第 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 预编译、懒加载优化、更新框架的兼容性都需要自担风险。这是高风险区,不是「高级技巧」。现实的替代方案:
require 推迟到真实使用点(Metro 会对 inlineRequires 顶层延迟化),启动只执行入口可达的最小集。零兼容风险,覆盖多数「拆包动机」的启动诉求。import() 动态导入,RN 社区的动态分包方案(容器加载第二个 bundle)均为非官方实现——可以调研,不建议在核心生产链路赌。「多 bundle」与「拆分 require」的收益对比:
| 维度 | 多 bundle(基础包+业务包) | 拆分 require(单 bundle) |
|---|---|---|
| 启动必载集 | 真正缩小 | 缩小(顶层副作用仍需遍历模块表) |
| 新架构兼容性 | 高风险,官方未支持 | 完全兼容 |
| 业务线独立发版 | 支持(本质优势) | 不支持(全量热更) |
| 维护成本 | 白名单治理 + id 稳定性 + 回归矩阵 | 接近零 |
| 增量更新 | 支持按模块 diff | 不适用(全量包差量由 18.3 差量下载承担) |
| 典型适用场景 | 多业务线大团队、强隔离发版需求 | 绝大多数团队的启动优化诉求 |
💡 结论:多数团队的正确答案是「单 bundle + Hermes + 懒加载 require + expo-updates 全量热更」;多 bundle 只在「多业务线独立发版」是硬需求、且团队有能力维护拆包基建时才值得上。
热更把「发版频率」提升了一个数量级,配套跟不上就会把风险同样提升一个数量级。
版本兼容矩阵。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 章),未演练不发版。
本节对应里程碑 M18:热更新体系与巡检扩展模块按需下发落地。
FieldNote 的改动分流守则(发版前对照,写进 checklist):
| FieldNote 改动 | 通道 | 理由 |
|---|---|---|
| 检查项打分逻辑修正、表单校验调整 | 热更 | 纯 JS,与核心用途一致 |
| 深色主题样式、文案微调 | 热更 | 纯资源与样式 |
| 巡检扩展模块(JS 模板 + 上报逻辑) | 热更 + flag | 见 M18-4,JS 范畴内 |
| 新增「语音备注」依赖原生录音库 | 发版 | 新原生模块 |
| 新增蓝牙外设权限 | 发版 | 权限安装期固化 |
| 同步协议 v3(涉及原生数据库迁移) | 发版 | 原生与 JS 同升,走第 15/17 章流程 |
| 相机取景器性能优化(纯 JS 层) | 热更 | 纯 JS,且属于缺陷修复 |
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 产出的指纹为准。
沿用第 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%,自动冻结放量并在值班群告警。
背景:巡检扩展模块是面向特定行业的专用检查表模板与上报逻辑(纯 JS + 模板 JSON),目标用户只是部分客户;需求是「不用它的客户不为它付启动时间和包体积,用它的客户能较快拿到新模板」。
候选方案:
实现骨架:
// 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 版本并参与签名;下载前校验引擎匹配 |
本章从「RN 是原生二进制 + JS bundle 的分层结构」这一事实出发,建立了热更新的完整心智模型:JS 与资源可换、原生不可换的根因;「清单 → 下载 → 校验 → 原子切换 → 重启 → 回滚」的生命周期;Apple 3.3.2(编号以最新协议为准)与国内商店划定的合规边界;expo-updates 的 channel/runtime version/差量下载/回滚机制与 CodePush 退役后「原理比服务重要」的启示;自建系统的四个关键件——签名清单、原子切换、启动哨兵、稳定分桶灰度;以及 Metro 模块图层面拆包的收益与高风险区,和「单 bundle + 懒加载 + 全量热更」的现实主义答案。FieldNote 用一张更新策略表、一个 fingerprint 基线策略、一次带回滚演练的灰度、一份巡检扩展模块的 ADR,把这四件事落成了可执行的制度。
一句话记忆点:热更新的本质是「把信任根从服务器搬进客户端」——哈希管完整性,签名管可信性,哨兵管可用性,制度管合规性。
bundle-verifier 脚本:输入 bundle zip 与更新清单 JSON,完成 SHA-256 校验并输出结果;再把清单验签补上(用 Node 的 crypto 模块生成一对 ECDSA P-256 测试密钥,模拟发布端签名与客户端验签两侧)。要求私钥只出现在发布端脚本中。markBootSuccess(首帧渲染后调用,清除 pending 并清零计数)与 reportCrashLoop 的埋点事件;并写一个 staging 集成测试——下发一个启动即抛错的测试包,断言连续两次启动后回滚事件被上报、active 指针回到 previous。本章导读:全书最后一章。第 17 章解决了「怎么把 App 安全地送到用户手里」,第 18 章解决了「JS 缺陷如何不经过商店当天修复」——本章回答最后一个问题:当攻击者直接面对你发布的安装包时,你能抬高多少攻击成本。前两章的签名体系与清单验签在这里被整合进完整的威胁模型;第 14 章的存储安全(Keychain/Keystore、字段级加密、日志脱敏、grep 凭据门禁)在本章扩展为端到端的防线。学完本章并完成 FieldNote 落地,即达成里程碑 M19,全书实战闭环。
多数团队的第一次安全加固是被动的:「听说要加混淆」「商店审核要隐私政策」——零散的措施堆在一起,防住了没人攻击的地方,漏掉了真正的要害。正确顺序是反过来的:先建模,再加固。威胁建模只需要回答三个问题:
移动 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 买的是「抓包成本」,签名校验买的是「重打包成本」——没有一项买的是「绝对」。
先校准预期:JS 层混淆的真实收益比你以为的低。RN 发布构建的默认链路是 Metro 打包 + Terser 压缩(改名、去空白、死代码消除)+ Hermes 编译为字节码。这条链路本身就完成了变量名混淆——那还需要什么?问题在于混淆的能力边界:
grep 字符串,比读代码快得多。| 手段 | 抬高什么成本 | 真实收益 | 代价 | 结论 |
|---|---|---|---|---|
| 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 设备 + 异常地理位置 + 频繁签名失败 → 高风险),绝不用它做唯一的功能门禁。
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 的轮换预案等于没有预案。
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 的运维风险必须用制度对冲。历史上大量团队是被自己坑的:证书续期日一到,所有老版本客户端集体断网。四道保险缺一不可:
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 会被海量请求撑爆存储,两者缺一不可。三个设计决策值得点名:
serverVersion 用服务端单调版本是同一原则。抓包检测的诚实边界。有团队想加「检测是否挂着代理/抓包工具」的逻辑:检查系统代理设置、检查 VPN 接口、甚至校验用户代理字符串。这些手段的共同问题是全部可绕过(root 设备上 iptables 透明转发不经过系统代理;工具名可以伪装)。它们的定位与 root 检测相同:弱信号上报可以,作为防线不行。真正的答案是把上一层的 Pinning 与签名做扎实——检测不出抓包,但让抓到的包不可读、不可重放、不可篡改。
委托凭据最小化。呼应第 14 章的存储结论:既然凭据必然落在端上(Keychain/Keystore 只能提高提取门槛,不能归零),就把每次暴露的价值压到最低——访问 Token 有效期压到小时级甚至分钟级,刷新令牌长效但一次一换(rotation,每次刷新作废旧令牌),配合服务端设备指纹绑定与异常吊销。攻击者突破一台设备拿到的,是一个小时后作废、且刷新即被风控感知的短时凭据,而不是一把可用一年的万能钥匙。
权限最小化是合规的起点,也是「防线外包给流程」的典型:混淆和加密防不了「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。这些要点的共同特征是「文案与行为的严格一致」——合规是对照问题,不是安全问题。
加固不是一次性动作,上线后有两套机制让防线「活」下去。
安全响应流程。三个最小组成:① 漏洞报告渠道——对外发布 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 里的内网地址都是高频泄露点;篡改视角要验证「检测后的闭环」(事件有没有真的到服务端、凭据有没有真的被吊销),只看到客户端弹了个提示不算通过。
本节对应里程碑 M19:附录 A 第三张清单全绿;渗透自测(抓包/反编译/篡改)通过。
在 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 过滤与保留期策略 |
设计要点在 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 拒。
双端共用一份安全配置:构建期注入默认 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,逐个记录域名、用途与传输方式,写进隐私台账。
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),混淆改名会让按名注册的模块直接失效。
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 的验收标准是「清单三全绿 + 渗透自测通过」,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) |
本章以威胁建模开篇:四类攻击面(逆向、流量、本地数据、运行时)与「数据在端上的四态」构成防护地图,FieldNote 六大资产逐一落地对策。JS 层校准了混淆的真实收益与 bundle 加密的本质限制,把「必须做」收敛到二次打包校验(Android 签名指纹 + iOS TeamIdentifier);原生层厘清了 R8 的边界、商业加固的类别与 RN 兼容性坑,并把供应链审计与密钥轮换制度化;网络层完成了「TLS 打底 → Pinning 收窄发证方 → 请求签名收窄调用方」的纵深,配齐了证书轮换的运维保险;合规侧以「四方对照」统一了权限、采集、日志与文案;最后用响应决策表与渗透自测三视角,把静态防线变成活流程。至此,FieldNote 完成里程碑 M19,附录 A 三张清单支撑全书 19 个里程碑的验收闭环。
一句话记忆点:安全加固是成本工程——威胁建模决定防谁,分层防护抬高每种攻击的价钱,响应流程保证你输给的是攻击者而不是自己的一次证书轮换。
timingSafeEqual 换成 === 对比,用循环计时实验观察响应时间差,写一段话说明侧信道的原理。PinningSessionDelegate 补全为可运行的完整模块:实现 SecurityConfig 经 TurboModule 从 JS 下发到原生单例的通道、Telemetry.report 的埋点实现(至少含事件类型、host、时间戳),并在 staging 完成一次「关停开关演练」——记录从远端置 false 到客户端实际降级的延迟,分析这条链路上每个环节的缓存与刷新时机。如何使用本附录:本附录是全书 19 章的收口工具,包含三张生产级检查清单,对应三类使用时机——**清单一「性能验收清单」**在性能验收时使用(覆盖第 2~5 章,支撑里程碑 M2~M5);**清单二「发布 Checklist」**在每次发版前逐条过一遍(覆盖第 17~18 章,支撑 M17,也覆盖热更发版日);**清单三「安全加固 Checklist」**在上线前一次性全量验收、并在每次安全事件复盘后重查(覆盖第 19 章,支撑 M19)。使用规则三条:第一,勾选即留证——每条勾选必须在验收报告/发版单/PR 描述中留下证据链接(数据、文件、CI 任务、监控截图),无证据的勾选等于没有勾选;第二,条目里标注的「(第 N 章)」是回溯入口,做不出来就回炉对应章节;第三,三张清单不是一次性的——性能门禁应进 CI 或发版前手动步骤,发布与安全清单应随事故复盘持续增补。与里程碑的对应关系:M2~M5 用清单一,M17 用清单二,M19 用清单三(清单三全绿 + 渗透自测通过 = M19 验收,见附录 B)。
pinningEnabled 可用,staging 实测关停后请求恢复、恢复后 pinning 重新生效(第 19 章)check-cert.sh 输出命中 pin 配置(第 19 章)| 症状 | 应查清单 | 具体组 |
|---|---|---|
| 列表滚动掉帧 | 清单一 | 列表与渲染(辅查:测量与基线确认口径) |
| 冷启动超标 | 清单一 | 启动(辅查:清单二/热更新——是否热更包拖慢启动) |
| 内存持续增长/被杀 | 清单一 | 内存 |
| 页面打开比上版慢 | 清单一 | 测量与基线(先确认是回退还是口径变化) |
| 发布后崩溃栈全是 a.b.c / 内存地址 | 清单二 | 监控与符号 |
| 灰度期崩溃率越线 | 清单二 | 灰度与发布 → 中止线与回滚预案 |
| 热更后白屏/崩溃循环 | 清单二 | 热更新 → 回滚演练与版本兼容 |
| 线上大面积断网(证书轮换日) | 清单二 + 清单三 | 清单三/网络(Pinning 开关与主备 pin)+ 清单二/版本与配置(环境指向) |
| 抓包能解密业务流量 | 清单三 | 网络 → Pinning 与抓包自测项 |
| 渗透测试发现明文凭据 | 清单三 | 凭据与密钥 → grep 门禁与反编译复查项 |
| root 设备提取到敏感数据 | 清单三 | 本地存储 |
| 重签包未被拦截 | 清单三 | 逆向对抗 → 二次打包校验闭环项 |
| 商店审核因隐私被拒 | 清单三 | 合规与响应 → 四方对照项 |
| 依赖包报出高危漏洞 | 清单三 | 逆向对抗 → 依赖审计项 |
「轻巡记 FieldNote」是一款面向连锁门店与工程设施的巡检工作台 App。巡检员每天按计划走访门店,逐项检查设备与陈列,拍照取证、扫码识别资产、填写检查项评分,最终生成巡检报告上传后台。
行业现状决定了它的三个硬约束:
这三个约束天然覆盖了本书六大主题,因此被选为贯穿项目。
| 模块 | 编号 | 需求描述 | 关联章节 |
|---|---|---|---|
| 登录与安全 | 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 |
| 项 | 选型 | 理由 |
|---|---|---|
| 框架 | 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 章完成拆分) | 模块化 |
书中示例以「自建最小实现」优先,现成库用于对照与兜底——目的是让读者理解原理后有能力评估任何三方方案。
每章学完,完成对应里程碑;验收标准写明「做不到就回炉」的量化线。
| # | 里程碑 | 验收标准 | 章节 |
|---|---|---|---|
| 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 |
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 用浮点分数避免拖拽排序后全量重写。
本书不依赖真实后端。所有同步章节使用两种方式联调:
http://localhost:PORT。所有示例中的地址、密钥均为占位符;真实项目里请一律走环境变量或密钥服务,不要把任何可用凭据写进源码。
本附录分三部分:必读官方资料、源码阅读路线、能力进阶方向。链接以 2026 年 9 月为准,若失效请按名称搜索。
| 资源 | 用途 |
|---|---|
| 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 章) |
按「先小后大、先工具后框架」的顺序读,每读完一个写一篇笔记:
Gesture.Pan 封装层 —— 看 TS 侧如何描述手势并下发原生(呼应第 10 章)。CellContainer 的回收调度;对照 VirtualizedList 的窗口化理解差异(第 3 章)。runOnUI 实现,理解「JS 函数如何跨线程执行」(第 9 章)。阅读方法:不逐行通读。先用「断点 + 调用栈」抓主干,再针对一条完整链路(如一次手势回调 → JS worklet)精读。
| 类别 | 工具 | 出现章节 |
|---|---|---|
| 性能 | 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 |
react-native/ReactCommon 目录),理解 Fabric 调度器与 C++ Shadow Tree;有能力可以给生态库提 PR。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 仓库浏览 →