调试、测试与性能
Web 开发里"打开 F12 就能看"的习惯,到了 React Native 需要重新建立一套心智:代码跑在 Hermes 引擎里,界面是原生 View,网络请求可能走 native 通道。这一章解决三个问题:怎么看清 App 运行时发生了什么(调试)、怎么在发版前证明代码是对的(测试)、怎么让它跑得更顺(性能)。基线为 React Native 0.88 / Expo SDK 58。
1. 调试工具现状:Chrome / Flipper 时代已经结束
先给隔了一段时间回 RN 的人纠偏,这一段最容易踩"看旧教程学到过时工具"的坑:
- Chrome 远程调试:随 Hermes 普及已经失效。Hermes 不是完整 JS 引擎,Chrome 的调试器 attach 不上,老教程里"在浏览器开 http://localhost:8081/debugger-ui"的路径走不通了。
- Flipper:自 RN 0.76 起从模板移除,官方明确推荐迁移到 React Native DevTools。
- React Native DevTools:0.76 起随 RN 发布引入,基于 Chrome DevTools 构建,通过 Hermes 的调试协议直连,不需要任何中间桥。这就是今天调试 RN 的主工具。
所以如果你搜到的文章让你装 Flipper 或者开 debugger-ui,直接关掉那一页。
1.1 React Native DevTools
唤起方式最简单的一种:Metro 终端里按一下 d。
# 终端已经在跑 Metro(npx react-native start 或 npx expo start),设备已连上
d # 在 Metro 终端直接按键,自动拉起 DevTools 窗口设备上也能进:Android 按 Ctrl+M、iOS 模拟器按 Cmd+D 打开开发菜单(Dev Menu),选 “Connect to Metro to inspect with React Native DevTools”。
DevTools 的面板就是 Chrome DevTools 的那套,在 RN 语境下的用法:
| 面板 | 在 RN 里做什么 |
|---|---|
| Elements | 看原生视图树(最终渲染的 UI 层级);React 组件树内嵌了 React DevTools 体验 |
| Console | console.log / console.error 输出、表达式求值 |
| Sources | 对 TS/JS 源码打断点、单步调试(基于 source map) |
| Network | fetch / XMLHttpRequest 的请求记录,网络排查主战场(HTTP 层的坑回看 05-数据获取与网络.md) |
| Profiler | React 组件渲染耗时、重渲染火焰图,性能分析第一入口 |
两个实操提醒:
- Sources 断点挂住的是 JS 线程,设备端 UI 会跟着卡住(动画可能还在跑,因为新架构下 UI 线程独立)。调完务必移除断点,否则同事会来问"你一走 App 就卡死"。
- 打开 DevTools 前确认设备连的就是这台 Metro。连错了(比如开了
--tunnel的另一个实例)表现是 DevTools 里空白或者旧代码,先按r重载。
1.2 LogBox:红屏的收纳箱
开发模式下,任何未捕获的 JS 错误和 console.error 都会变成应用内的红屏弹窗,这个组件叫 LogBox(替代了更早的 YellowBox 体系)。它只有两个按钮,但值得说清语义:
- Dismiss:关掉这一次弹窗。代码问题还在,下次触发还会弹。
- Ignore all:本次运行内压住所有报错。热重载或重启后会恢复。
也有代码级接口 LogBox.ignoreLogs(['子串']) 可以按前缀忽略特定警告。它存在的意义是"某些第三方库的已知噪音",而不是"我自己的报错太多想静音":
注意:不要在业务代码里堆
LogBox.ignoreLogs来消灭红屏。红屏是开发期最便宜的安全网,静音它的代价是线上崩溃。正确动作永远是修,或者确认无害后记录成 TODO。
1.3 Metro 终端快捷键
跑起 Metro 后,终端本身就是一个控制台:
| 按键 | 行为 |
|---|---|
r |
让已连接的设备重新加载 JS(重新拉 bundle,比设备端菜单快) |
d |
打开 React Native DevTools |
m |
打开 React Native 开发菜单(模拟器场景比按 Cmd+D 方便) |
Ctrl+C 按两次 |
退出 Metro |
用 Expo 的话,expo start 在 Metro 之上包了一层设备管理:a / i 分别拉起 Android / iOS 模拟器,c 清屏,按 ? 可以列出当前版本支持的全部按键(不同 SDK 版本键位有出入,以现场提示为准)。
1.4 –tunnel:网络不通时的兜底
调试的前提是设备能访问开发机的 8081 端口。公司 WiFi 开了 AP 隔离、设备在 4G、或者你要给异地同事演示,Metro 就连不上。Expo 的解法:
npx expo start --tunnel # 通过云端隧道,设备扫 QR 即可连,不要求同一网络
npx expo start --lan # 同网段推荐(更快);--localhost 仅限模拟器隧道走云端中转,延迟明显、偶尔抖,只在网络确实受限的时候用。bare CLI 项目没有官方 tunnel,等价做法是自己起反向代理(ngrok / frp 类),方向如此,细节不展开。请求层面的排查(代理、证书抓包)见 05-数据获取与网络.md。
2. 真机调试
模拟器永远代替不了真机:键盘、传感器、省电策略、低端机的帧率只有真机能暴露。
2.1 Android:无线 ADB
Android 11 起的真机自带无线调试,配置一次就告别数据线:
- 手机:开发者选项 → 无线调试 → 开启;
- 点"使用配对码配对设备",记下 6 位配对码和它的端口;
- 开发机执行:
adb pair 192.168.1.20:37123 # 提示后输入 6 位配对码
adb connect 192.168.1.20:40015 # 注意:connect 用"无线调试"主界面显示的端口
adb devices # 出现该设备即成功- 把手机的 8081 转发到开发机的 Metro:
adb -s 192.168.1.20:40015 reverse tcp:8081 tcp:8081USB 连接时同样用 adb reverse tcp:8081 tcp:8081。之后在 App 的开发菜单里选 “Settings → Debug server host & port for device” 填开发机 IP,或者直接信任 adb reverse 的 localhost。
2.2 iOS:Xcode 直连
iOS 真机调试绕不开 Xcode:工程里选好 Team,Cmd+R 会安装一个 debug 构建到手机上。模拟器自动连 localhost:8081;真机需要和 Mac 在同一局域网,首次运行时在开发菜单里把 Metro 地址设为 Mac 的局域网 IP(Expo 的 development build 会在配置阶段处理掉这一步)。遇到 JS 之外的原生问题(引擎崩溃、原生断点),直接在 Xcode 打开 ios/MyApp.xcworkspace 调试——Expo 项目用 expo run:ios 启动,它会自动挂到对应 workspace。
2.3 Release 连不上 Metro,是设计不是故障
Release 构建把 JS bundle 打进包里、去掉开发菜单,天生不连 Metro,也就天生"不可调试"。想要一个"带自研原生代码、又随时能连 Metro"的日常开发包,答案是 development build:Expo 项目用 expo prebuild 生成原生工程后自管构建,产物是 debug 配置、可 attach Metro。这也是官方不建议再用 Expo Go 做日常开发的原因之一——Expo Go 只支持最新的 Expo SDK,而且不能带自定义原生模块(背景见 00-安装与快速开始.md)。
线上环境的问题只能靠"上报"而不是"调试":崩溃采集服务 + release 构建的符号化,下一节讲。
2.4 开发菜单速查
三种唤起方式:Metro 终端按 m、Android 真机 Ctrl+M、iOS 模拟器 Cmd+D(摇一摇在部分设备/模拟器配置下也能触发,别把它当稳定入口)。菜单里高频项:
| 菜单项 | 作用 | 备注 |
|---|---|---|
| Reload | 重拉 bundle | 等价于 Metro 终端的 r |
| Settings → Debug server host & port | 指定 Metro 地址 | 真机连不上 Metro 时第一处要检查的设置 |
| Connect to Metro to inspect | 挂上 RN DevTools | 与终端按 d 殊途同归 |
| Show Element Inspector | 屏幕上点选原生 View | 比 DevTools Elements 快,适合"这个边距是谁的"式排查 |
| Perf / 帧率类开关 | 打开 FPS 计数 | 动画调帧率时配合 07-动画与手势.md 使用 |
注意:开发菜单只存在于 debug / development build。线上用户手机上没有这个菜单,也没有摇一摇——不要把它当成给客服用的"隐藏诊断入口",那需要专门做应用内诊断页(见
12-常见陷阱与最佳实践.md的可观测性条目)。
3. 崩溃与符号化
3.1 JS 错误:栈 + source map
Hermes 执行的是预编译字节码,release 崩溃上报里的 JS 栈默认是字节码位置(index.bundle:1234:567 这种),没有可读性。解法是 bundle 时同时生成 source map,上报平台拿它把栈还原回 TS 源码行:
- Metro / Expo 在打 release bundle 时会产出对应的
.map文件(iOS 的 Xcode 构建脚本会把它放进构建产物目录,Android 在build目录下); - 集成 Sentry 一类错误上报时,流程固定为"打 bundle → 按版本号把 source map 随构建上传到上报服务 → 崩溃进来后自动符号化",具体步骤照各家官方 RN 文档即可;
- 原则:source map 不进包,只进上报服务。它等价于你的源码,打进 release 等于裸奔。
3.2 原生崩溃:Hermes dSYM 与 Android mapping
- iOS:Hermes 自 0.84 起默认使用 V1(Static Hermes 重写版),RN 官方从 0.84 起在 GitHub release 随版本提供 Hermes V1 的 dSYM 符号表。怀疑崩在引擎内部时,下载与你 RN 版本严格对应的 dSYM,加到 Xcode Organizer 的符号化流程或上传到崩溃平台。版本号必须对得上,拿错版本的 dSYM 符号化出来是错乱的栈,比没有更误导人。
- Android:release 开了 R8 混淆,栈里的
a.b.c需要每次构建产出的mapping.txt(android/app/build/outputs/mapping/release/)还原。上传到 Google Play 的 mapping 会被自动处理(Gradle 插件配合),第三方崩溃平台则要自己传。把mapping.txt当构建产物归档,否则一周后的线上崩溃永远查不了。
3.3 ANR 与卡启动:不在 JS 层的问题
“应用没崩但卡住 / 启动特别慢"属于另一类,LogBox 和 JS 断点都帮不上忙。定位路径按层拆开:
- JS 层拖慢(首屏渲染重、列表掉帧):RN DevTools 的 Profiler 看渲染火焰图,Network 面板看首屏是否在等请求;
- Android 系统层:Android Studio Profiler 抓主线程方法 trace,或录 Perfetto 看
Application.onCreate到首帧之间堵在哪;Google Play Console 的 Android vitals 会给出真机 ANR 率,当作发版验收指标看; - iOS 系统层:Xcode Instruments 的 App Launch 模板。
经验上 ANR 和高频"卡启动"的元凶不外乎三样:进程启动阶段同步做大事(初始化 SDK、解密本地数据)、首屏串行等多个网络请求、bundle 太大解析慢。对症的优化在 7.1 展开。
3.4 全局兜底:别让线上错误只剩白屏
开发期有红屏,release 期需要自己接住错误,否则用户看到的就是无提示白屏或闪退。JS 层有三个必接的点:
- 未捕获的 JS 异常:错误上报 SDK(Sentry 类)初始化时会替换全局 handler,同时保留默认行为;接之前你自己不要用
ErrorUtils.setGlobalHandler抢坑,接之后确认它转发了原始错误。 - Promise rejection:RN 里 unhandled rejection 不会天然红屏也不会天然崩,是最容易漏报的一类,上报 SDK 一般有开关可以捕获。
- 渲染错误边界:根组件外面包一层 Error Boundary,局部子树崩了降级成"这一屏加载失败 + 重试”,而不是整个 App 白屏。导航框架层面的错误处理见
04-导航.md。
原则一句话:本地兜底展示,远端全量上报,符号化保证栈可读——三件事缺一件,线上问题就是玄学。
3.5 发版前的 release 包验证
调试工具链在 release 包里整体缺席,这决定了"发版验收"必须单独做一轮,不能拿 debug 的表现外推:
- 打出真实 release 产物并安装到一台低端 Android 真机(构建命令见
11-构建与发布.md)——R8 混淆路径只有这条线会暴露,debug 永远正常; - 在这台设备上跑一遍 Maestro 冒烟(4.3),登录、核心列表、支付入口各过一遍;
- 故意触发一次可恢复的 JS 错误(比如点一个已知会失败的旧入口),确认上报平台在十几分钟内收到一条栈已符号化的记录——收不到,或收到的是
index.bundle:1:2345,说明 3.1 的 source map 上传没接通,这次发版等于裸奔; - 核对崩溃平台里上一条线上版本的 ANR/崩溃率,作为本次发版的 baseline。
4. 测试金字塔:RN 语境下的三层
RN 项目的测试 historically 最难受的是中间层——渲染目标不是 DOM,组件背后还连着一堆原生模块。好在今天的主线组合已经收敛得很清楚:
- 单元测试(纯逻辑):jest,模板自带 jest ^29(注意模板没有跟到 Jest 30 线,插件生态按 29 配);
- 组件测试:@testing-library/react-native;
- E2E:Maestro 或 Detox。
4.1 @react-native/jest-preset:独立发版的含义
RN 模板的 jest.config.js 里只有一行 preset: '@react-native',背后是 @react-native/jest-preset 这个包:它 mock 掉 RN 的运行时(NativeModules、平台桥、图像/字体资源加载),让组件代码能在 Node 里跑起来。自 0.85 起它开始独立发版(0.88 模板里实测依赖为 @react-native/jest-preset@0.88),这是一件对测试体验很有意义的事——preset 对新引擎行为、新原生模块的适配问题,不再必须等 RN 主版本发布才能修,单独 bump 这个包即可。
如果你的 jest 在 RN 小版本升级后突然炸出 Cannot find module ... 或一堆 mock 缺失,先确认 preset 版本是否和 RN 主版本对齐,这是独立发版后新出现的一类"版本错配"问题。
4.2 组件测试标准写法
@testing-library/react-native 的心智和 Web 版一致:按用户能看到的方式查询和交互,不要碰组件内部实例。v12 起查询与断言默认按异步处理:
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { Counter } from '../src/components/Counter';
test('点击 +1 后计数增加', async () => {
render(<Counter initial={0} />);
fireEvent.press(screen.getByRole('button', { name: '+1' }));
await waitFor(() => {
expect(screen.getByText('count: 1')).toBeTruthy();
});
});几个高频惯用法:
render也返回一套getBy*/queryBy*,但优先用全局screen,链式可读性好;getBy找不到直接抛错(适合"断言它该在"),queryBy找不到返回null(适合"断言它不该在"),findBy是异步版;- 交互后要等的东西(状态落定、异步 effect)用
waitFor包,不要写setTimeout干等; - 文本断言写完整文案或用正则,别断言快照里的内部结构。
4.3 E2E 双雄:Maestro 与 Detox
Maestro 用声明式 YAML 描述用户流程,不写代码、不编译插桩,直接驱动真机/模拟器。这两年社区增长明显,新项目普遍拿它做第一套 E2E:
# .maestro/login.yaml
appId: com.example.myapp
---
- launchApp
- tapOn: "用户名"
- inputText: "demo"
- tapOn: "密码"
- inputText: "password"
- tapOn: "登录"
- assertVisible: "首页"maestro test .maestro/login.yaml定位器基于文案或 testID,对 RN 和原生 App 通吃;维护成本低,缺点是拿不到 JS 层状态,复杂时序只能靠重试和等待。
Detox 是 RN 生态自己的 E2E 框架:黑盒方式驱动接近 release 的 App,内置同步机制等 App “安静下来"再做下一步,适合需要精确控制时序的深度回归。它是 RN 原生项目的历史主流,API 成熟;代价是与 RN 版本强耦合,每个 RN 版本上的维护投入随行情的起伏,升级前务必查它当前版本对你目标 RN 版本的支持情况再决策。
常见分工:Maestro 管关键路径冒烟(登录、下单、支付入口),Detox 管少数高价值深度流程;多数团队不会两套都维护。
4.4 截图回归
视觉走样(改了样式没人发现)可以用截图对比补:jest 图片快照、或 Maestro/云真机服务里的截图步骤。一个克制的建议:没有设计走查流程的团队不要铺大面积基线图,截图测试的维护成本会反噬迭代速度,挑首页和核心页即可。
4.5 自定义 Hook 的测试
业务逻辑大多沉淀在自定义 Hook 里,它们不渲染界面,用 renderHook 单测比裹一层壳组件干净得多:
import { renderHook, act } from '@testing-library/react-native';
import { useDebounce } from '../src/hooks/useDebounce';
test('输入停止 300ms 后才提交值', async () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'a' } },
);
expect(result.current).toBe('a'); // 初始值直通
rerender({ value: 'ab' });
act(() => { jest.advanceTimersByTime(299); });
expect(result.current).toBe('a'); // 没到窗口期
act(() => { jest.advanceTimersByTime(1); });
expect(result.current).toBe('ab'); // 到期提交
jest.useRealTimers();
});要点:act 包住一切会让 Hook 产生副作用的驱动(定时器推进、事件触发);涉及异步存储的 Hook(MMKV 类)配合 5.2 的 mock 即可,测试里不需要真实 IO。
5. 测试实战:一套可直接抄的最小配置
下面这套在 0.88 bare 模板和 Expo SDK 58 模板上都成立。
5.1 jest.config.js:preset + 那条著名的白名单
/** @type {import('jest').Config} */
module.exports = {
preset: '@react-native',
transformIgnorePatterns: [
'node_modules/(?!((jest-)?react-native|@react-native(-community)?)/)',
],
setupFiles: ['./jest.setup.js'],
};transformIgnorePatterns 是 RN 测试最高频的公开坑:Jest 默认不转译 node_modules,而 RN 生态的包大多是未编译的 ESM / Flow / TS 源码,import 进来直接 SyntaxError: Unexpected token 'export' 或解析失败。上面这条配方把 react-native、react-native-*、@react-native/*、@react-native-community/* 放进白名单;遇到其他纯 ESM 的第三方库(比如某些工具库),把包名追加进这条正则。报错信息长得"不像测试问题”(像在编译),这是特征:先怀疑 transformIgnorePatterns。
5.2 jest.setup.js:mock 掉原生模块
原生模块在 Jest 的 Node 环境里不存在,要用内存实现顶替。下面三个是 RN 项目最常撞到的:
// react-native-mmkv:用 Map 顶掉真实存储(存储的业务用法见 08-存储与文件.md)
jest.mock('react-native-mmkv', () => {
class MMKV {
constructor() { this.store = new Map(); }
set(key, value) { this.store.set(key, value); }
getString(key) { return this.store.get(key); }
delete(key) { this.store.delete(key); }
}
return { MMKV };
});
// reanimated:官方 mock,把共享值退化成普通值(动画的正式用法见 07-动画与手势.md)
jest.mock('react-native-reanimated', () =>
require('react-native-reanimated/mock'),
);
// gesture-handler:根视图和手势对象降级为透传(同样见 07)
jest.mock('react-native-gesture-handler', () => {
const View = require('react-native/Libraries/Components/View/View');
return {
GestureHandlerRootView: View,
GestureDetector: ({ children }) => children,
Gesture: {
Tap: () => ({ onStart: () => ({}) }),
Pan: () => ({ onStart: () => ({}) }),
},
};
});注意:reanimated / gesture-handler 的官方 mock 形态随版本变过(位置在包内、或通过 preset 提供)。上面是常见的起手写法,落地时以对应库测试章节为准;mock 不对的症状是"测试里动画一触发就崩",而不是断言失败。
5.3 一个完整用例:登录页
// __tests__/LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
import { LoginForm } from '../src/screens/LoginForm';
test('提交时调用登录接口并进入首页', async () => {
const onLogin = jest.fn().mockResolvedValue(true);
render(<LoginForm onLogin={onLogin} />);
fireEvent.changeText(screen.getByPlaceholderText('手机号'), '13800000000');
fireEvent.changeText(screen.getByPlaceholderText('密码'), 'hunter2');
fireEvent.press(screen.getByRole('button', { name: '登录' }));
await waitFor(() => {
expect(onLogin).toHaveBeenCalledWith('13800000000', 'hunter2');
});
});表单控件的受控/非受控写法本身见 06-表单与交互.md;这里值得抄的是测试结构:准备数据 → 模拟输入 → 点按钮 → waitFor 断言副作用。
5.4 跑起来
npm test # 单次
npm test -- --watch # 改一个文件跑一个文件
npm test -- --coverage # 覆盖率,别迷信数字,主要看漏测的分支5.5 测试炸了的标准排错顺序
组件测试失败信息在 RN 里经常"词不达意",按这个顺序排查能省半小时:
SyntaxError: Unexpected token/Cannot find module(import 阶段):transformIgnorePatterns 白名单缺了这个包,见 5.1;Invariant: TurboModuleRegistry...(渲染阶段):某个依赖底下摸到了原生模块,给它在 jest.setup.js 里补 mock,见 5.2;Warning: An update to X inside a test was not wrapped in act(...):断言前状态变更还没 flush,用waitFor或act包住触发,见 4.5;- 断言等不到元素但肉眼可见:多半是
getByRole的 name 和实际渲染文案不一致(中间拼了变量),先screen.debug()打印当前树确认查询词; - RN 小版本升级后大面积红:对齐 @react-native/jest-preset 版本(4.1),再看 preset 的 changelog 里 mock 变动。
6. CI 里怎么跑
原则:JS 层的测试全部在 CI 每次 PR 跑(纯 Node,分钟级反馈);需要模拟器/真机的东西不进 PR 流水线,要么夜间冒烟,要么交给云设备/CI 上的模拟器;原生构建属于发布流水线,见 11-构建与发布.md。
GitHub Actions 的最小骨架:
# .github/workflows/ci.yml
name: CI
on:
pull_request:
push: { branches: [main] }
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx tsc --noEmit # TypeScript-first 项目把类型检查当独立门禁
- run: npm run lint # 模板带 eslint ^8
- run: npm test -- --ci --maxWorkers=2--ci让 Jest 禁止在流水线上静默生成新快照——新快照必须在本地产生并随代码提交,防止"谁也没看过就通过"的快照污染主线。- 类型检查单独一步(
tsc --noEmit),不要指望 lint 或 jest 顺带做——Babel 转译路径下 jest 不看类型,一个类型错误可以到测试阶段才以诡异方式爆出来。 - 缓存键落在 lockfile(
cache: npm),否则每次 CI 重装 node_modules 会拖慢一截。 - Maestro 流程文件(
.maestro/)同样进仓库,由夜间任务或发布前手动触发,不在 PR 门禁里。 - 需要跑 Maestro 冒烟的流水线要么在 Linux runner 上起 Android 模拟器(重、慢、坑多),要么交给云设备服务;多数团队的现实选择是"合并后夜间批次",把成本从每次 PR 挪走。
7. 性能
RN 的性能问题九成集中在四处:启动、列表、内存、动画帧率。先讲心智,再给清单。开工前对号入座,能少走弯路:
| 症状 | 大概率层次 | 首选工具 |
|---|---|---|
| 点开 App 半天进不去 | 启动链路(bundle/首屏阻塞) | Instruments App Launch / Perfetto |
| 滚动列表掉帧 | 重渲染或 JS 线程忙 | DevTools Profiler + FPS 开关 |
| 用久了越来越卡、被系统杀 | 内存(位图/泄漏) | Xcode Memory Graph / Android Profiler |
| 动画卡顿、跟手差 | UI 线程每帧超预算 | 动画调试(见 07-动画与手势.md) |
7.1 启动链路拆解
冷启动大致是:进程创建 → 加载原生库 → 初始化 Hermes → 加载 JS bundle → 渲染首屏 → 首帧。其中你能控制的重量级环节是 bundle 加载和首屏渲染:
- Hermes 字节码:JS 预编译成
.hbc,启动时 mmap 直接执行,省掉文本解析。这一层平台已经替你做完——Android 侧 RN 运行时自 0.74 起预编译(构建 App 不再编译 RN C++ 源码),Hermes 自 0.84 起默认 V1。 - 首包体积是真正的抓手:原则只有两条——缩小首屏 import 图、把非首屏代码延后。React.lazy 那套"分包按需加载"的思路,在 Metro 目前单 bundle + Hermes 字节码的流水线下还不成熟(动态分包能力有限,工具面一直在演进,这里不写死结论);工程上等价且稳的做法是延迟执行而不是指望打包器延迟加载:首屏之外的页面初始化、重依赖的请求,都挪到导航进入之后再触发。
- 首屏同步阻塞是隐形黑洞:同步读大存储(
08-存储与文件.md)、串行等多个网络才肯渲染首屏(应当先出骨架再补数据,见05-数据获取与网络.md)、模块顶层副作用(import 即执行)。 - 先测量再优化:接启动打点(Performance API 或
react-native-performance一类库)拿到"点击图标到首帧可交互"的真实数字,再决定动哪里。低端 Android 真机测,模拟器上的启动时间没有参考价值。
7.2 列表性能检查清单
对应 02-核心组件.md 的列表章节,这里给逐条能过的 checklist:
keyExtractor稳定且唯一——用 index 等于让增删操作退化成整段重排;- 固定行高时提供
getItemLayout,跳过逐行测量; renderItem里不创建新函数、不读全局大对象;行内组件用React.memo或下沉状态,避免一个行内输入让整列表重渲染;- 长列表图片开启回收与合适的缓存窗口(
windowSize); - 数据密度高、带拖拽或双列瀑布流的场景直接用 FlashList 类虚拟化实现,不要手调 FlatList 硬撑(选型见
02-核心组件.md); - 列表滚动联动的动画走共享值 / ScrollView 原生驱动,绝不为每帧
setState(原理见07-动画与手势.md)。
7.3 内存
- 图片位图是大头:解码后的位图是文件体积的数倍(宽×高×4 字节)。降采样显示、别用 4000px 原图渲染 100px 缩略图,带缓存与降采样能力的图片组件优先(
02-核心组件.md)。 - 监听器泄漏的 RN 版:经典"addEventListener 忘清理"在 RN 里对应订阅没 dispose——
useEffect(() => {
const sub = AppState.addEventListener('change', handler);
return () => sub.remove(); // 漏掉这行 = 每次重新挂载泄漏一个订阅
}, []);键盘高度、Dimensions、动画回调、原生事件订阅全都同理:谁订阅,谁负责在清理函数里退订。
- 闭包泄漏:长生命周期的全局回调捕获了大 props/state 快照,组件卸载后整个对象图还被拽着。
- JS 堆可以用 RN DevTools(Chrome DevTools 血统)里的内存面板做快照,但对 Hermes 的支持深度有限;原生层内存(位图、模块句柄)看 Xcode Memory Graph 和 Android Studio Memory Profiler 更实际。
7.4 动画帧率
一句话原则:保持 UI 线程每帧的活足够少。动画放共享线程驱动、避免动画途中做重排和 JS 通信,细节见 07-动画与手势.md。补充一条与渲染性能相关的新变量:React Compiler 已支持 React Native 编译输出(需要配置 babel 插件),可以自动化掉大部分"该不该重渲染"的手工 memo——是否引入以你自己项目的渲染 profiling 数据为准,不是银弹宣传。
7.5 重渲染:定位方法与常见来源
“App 不卡但烫手/掉帧"很多是无效重渲染堆出来的。定位入口是 DevTools Profiler:勾选 “Record why each component rendered”,火焰图上找"这次交互不该出现却出现了"的组件,倒推来源。RN 里高频的四类原因:
- Context 消费面过宽:一个 provider 里塞了用户信息 + 主题 + 全局配置,任何字段变化,所有 consumer 全部重渲染。把低频数据和高频数据拆成两个 context,见
02-核心组件.md的状态下传部分; - props 传了新建对象/函数:父组件每次渲染都
style={{...}}、onPress={() => ...},子组件 memo 失效。样式表提到模块顶层,回调按需用useCallback; - 列表行组件持有整行状态:搜索框输入驱动整列表重渲染——把输入状态下沉到行内或独立组件;
- 订阅过宽:zustand/Redux 类全局 store 不带 selector 地整块订阅,任何字段变都触发。选择器只取用到的字段(模式在各状态方案的文档里都有,见
02-核心组件.md)。
一个务实的底线:不要凭感觉优化。Profiling 之前不存在"该优化"的组件。
8. 常见红屏与报错速查
开发期红屏和构建报错高度模式化,按关键词查:
| 报错关键词 | 原因 | 修复 |
|---|---|---|
VirtualizedList: missing keys for items |
FlatList 数据没给 keyExtractor 或 key 重复 |
补 keyExtractor,保证稳定唯一 |
Child type error. Given type ... Expected ... |
非法嵌套,如 View 塞进 Text、ScrollView 子项类型不对(新架构校验更严) | 按合法层级重排:Text 里只放 Text/虚拟文本,ScrollView 直放 View 类 |
Text strings must be rendered within a <Text> component |
JSX 里裸文本 | 包 <Text>,或从函数返回值里剔除裸字符串 |
| 颜色类红屏(color 格式报错) | 颜色字符串不合法:漏了 #、位数不对、用了 RN 不认的写法 |
统一 #RRGGBB / #RRGGBBAA / rgb() / rgba() |
Unable to resolve module |
没 npm install、路径/别名写错,或 Metro 缓存陈了 |
装依赖;清缓存重启(Expo:npx expo start -c) |
TurboModuleRegistry.getEnforcing: ... could not find a module / Native module not found |
新增原生依赖后没重建原生工程(JS 有、native 没有) | expo prebuild 后重建,或 cd android && ./gradlew / cd ios && pod install 重新构建(原理见 09-原生模块与平台代码.md) |
CocoaPods:pod: command not found / ffi、openssl 类报错 |
Ruby/CocoaPods 环境损坏,或 Apple Silicon 上架构混装 | 重装 cocoapods(官方推荐 Homebrew ruby + gem install cocoapods --pre 按需);必要时 arch -x86_64 pod install |
EADDRINUSE :::8081 |
Metro 端口被上一个实例占用 | 杀掉旧进程,或 --port 8082 |
Require cycle 警告 |
模块互相 import 成环 | 抽公共依赖、把类型与实现分文件 |
Android INSTALL_FAILED_UPDATE_INCOMPATIBLE |
设备上已有同包名但签名不同的 App(debug/release 互装) | 先卸载设备上旧包 |
Cannot find module ... from 'react-native/jest/...'(仅测试时) |
@react-native/jest-preset 与 RN 版本错配 | 对齐 preset 版本(见 4.1) |
Network request failed(Metro 正常仍出现) |
设备到 Metro/后端不可达,或证书/明文 HTTP 被拦截 | adb reverse / 检查 --tunnel、--lan(见 1.4、2.1),证书与 ATS 见 05-数据获取与网络.md |
iOS 真机 Could not attach to developer database / 签名类报错 |
描述文件与 Team 不匹配 | Xcode Signing 里重选 Team;正式签名方案见 11-构建与发布.md |
多数红屏的共性修法是"重启三件套”:重载(r)→ 清 Metro 缓存重启(Expo 下 npx expo start -c)→ 重建原生工程(expo prebuild 后重跑,或双端各自的原生构建)。顺序不要反过来——直接重建原生工程要等十几分钟,而八成的问题是前两步的缓存。
9. 实战建议
- 调试:工具心智一步到位——
d开 DevTools,Sources 断点 + Network + Profiler 三板斧;看到教你装 Flipper 的教程直接忽略。DevTools 连不上先确认设备连的是这台 Metro(r重载一次最便宜)。 - 真机:Android 配一次无线 ADB +
adb reverse,从此不插线;日常开发用 development build,把"能连 Metro"和"含原生代码"同时拿到。 - 崩溃:source map 只进上报服务不进包;iOS 符号化用与 RN 版本严格对应的 Hermes dSYM;Android 的 mapping.txt 每次构建归档。上线的包没符号表等于没日志。
- 测试:金字塔比例按"纯逻辑多测、组件测试用 RNTL 标准姿势、E2E 只留冒烟"来配;jest 报出语法错误先查 transformIgnorePatterns,这是 RN 测试第一大坑。
- 升级时:RN 小版本升级后测试大面积失败,先对齐 @react-native/jest-preset 版本;引入 Detox 前确认它对你目标 RN 版本的支持状态。
- 性能:低端 Android 真机先测再改。启动优化优先砍首屏 import 图与同步阻塞;列表盯 key、getItemLayout、行内更新范围三件事;内存盯图片位图和忘退订的监听器。
- CI:PR 门禁只放 lint + jest(
--ci);模拟器/真机类任务放夜间或发布前,别用它们挡 PR 合并。 - 红屏:按第 8 节的关键词表处理,反复出现同类红屏说明流程有洞——把对应的坑补进
12-常见陷阱与最佳实践.md的团队检查清单。 - 发版验收:release 包单独验(3.5 的四步),验收通过的标志不是"App 能跑",而是"错误上报里能看到符号化后的栈"。