Skip to content
调试、测试与性能

调试、测试与性能

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 起的真机自带无线调试,配置一次就告别数据线:

  1. 手机:开发者选项 → 无线调试 → 开启;
  2. 点"使用配对码配对设备",记下 6 位配对码和它的端口;
  3. 开发机执行:
adb pair 192.168.1.20:37123      # 提示后输入 6 位配对码
adb connect 192.168.1.20:40015   # 注意:connect 用"无线调试"主界面显示的端口
adb devices                      # 出现该设备即成功
  1. 把手机的 8081 转发到开发机的 Metro:
adb -s 192.168.1.20:40015 reverse tcp:8081 tcp:8081

USB 连接时同样用 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 层有三个必接的点:

  1. 未捕获的 JS 异常:错误上报 SDK(Sentry 类)初始化时会替换全局 handler,同时保留默认行为;接之前你自己不要用 ErrorUtils.setGlobalHandler 抢坑,接之后确认它转发了原始错误。
  2. Promise rejection:RN 里 unhandled rejection 不会天然红屏也不会天然崩,是最容易漏报的一类,上报 SDK 一般有开关可以捕获。
  3. 渲染错误边界:根组件外面包一层 Error Boundary,局部子树崩了降级成"这一屏加载失败 + 重试”,而不是整个 App 白屏。导航框架层面的错误处理见 04-导航.md。

原则一句话:本地兜底展示,远端全量上报,符号化保证栈可读——三件事缺一件,线上问题就是玄学。

3.5 发版前的 release 包验证

调试工具链在 release 包里整体缺席,这决定了"发版验收"必须单独做一轮,不能拿 debug 的表现外推:

  1. 打出真实 release 产物并安装到一台低端 Android 真机(构建命令见 11-构建与发布.md)——R8 混淆路径只有这条线会暴露,debug 永远正常;
  2. 在这台设备上跑一遍 Maestro 冒烟(4.3),登录、核心列表、支付入口各过一遍;
  3. 故意触发一次可恢复的 JS 错误(比如点一个已知会失败的旧入口),确认上报平台在十几分钟内收到一条栈已符号化的记录——收不到,或收到的是 index.bundle:1:2345,说明 3.1 的 source map 上传没接通,这次发版等于裸奔;
  4. 核对崩溃平台里上一条线上版本的 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 里经常"词不达意",按这个顺序排查能省半小时:

  1. SyntaxError: Unexpected token / Cannot find module(import 阶段):transformIgnorePatterns 白名单缺了这个包,见 5.1;
  2. Invariant: TurboModuleRegistry...(渲染阶段):某个依赖底下摸到了原生模块,给它在 jest.setup.js 里补 mock,见 5.2;
  3. Warning: An update to X inside a test was not wrapped in act(...):断言前状态变更还没 flush,用 waitFor 或 act 包住触发,见 4.5;
  4. 断言等不到元素但肉眼可见:多半是 getByRole 的 name 和实际渲染文案不一致(中间拼了变量),先 screen.debug() 打印当前树确认查询词;
  5. 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. 实战建议

  1. 调试:工具心智一步到位——d 开 DevTools,Sources 断点 + Network + Profiler 三板斧;看到教你装 Flipper 的教程直接忽略。DevTools 连不上先确认设备连的是这台 Metro(r 重载一次最便宜)。
  2. 真机:Android 配一次无线 ADB + adb reverse,从此不插线;日常开发用 development build,把"能连 Metro"和"含原生代码"同时拿到。
  3. 崩溃:source map 只进上报服务不进包;iOS 符号化用与 RN 版本严格对应的 Hermes dSYM;Android 的 mapping.txt 每次构建归档。上线的包没符号表等于没日志。
  4. 测试:金字塔比例按"纯逻辑多测、组件测试用 RNTL 标准姿势、E2E 只留冒烟"来配;jest 报出语法错误先查 transformIgnorePatterns,这是 RN 测试第一大坑。
  5. 升级时:RN 小版本升级后测试大面积失败,先对齐 @react-native/jest-preset 版本;引入 Detox 前确认它对你目标 RN 版本的支持状态。
  6. 性能:低端 Android 真机先测再改。启动优化优先砍首屏 import 图与同步阻塞;列表盯 key、getItemLayout、行内更新范围三件事;内存盯图片位图和忘退订的监听器。
  7. CI:PR 门禁只放 lint + jest(--ci);模拟器/真机类任务放夜间或发布前,别用它们挡 PR 合并。
  8. 红屏:按第 8 节的关键词表处理,反复出现同类红屏说明流程有洞——把对应的坑补进 12-常见陷阱与最佳实践.md 的团队检查清单。
  9. 发版验收:release 包单独验(3.5 的四步),验收通过的标志不是"App 能跑",而是"错误上报里能看到符号化后的栈"。