☰
React Native 重渲染与内存泄漏排查:长列表性能优化实战
2026/11/4 10:13:02 网站建设 项目流程

一个两百来行的通讯录列表,数据刚从本地库拉出来,滑起来就是一顿一顿的,低端安卓机上尤其明显。团队里有人第一反应是"加 React.memo",有人翻出教程把 useCallback 往每个回调上糊了一遍,改完一测,帧率几乎没动。更麻烦的是,连续进出这个页面十几次之后,内存曲线一路往上爬,退出页面也不见回落。这类问题我在几个项目里都遇到过,重渲染和内存泄漏经常同时出现,而且会互相掩护——你以为卡顿是渲染太频繁,其实是泄漏的对象越来越多导致 GC 压力飙升;你以为内存涨是列表数据没释放,其实是某个订阅一直挂着,把整个组件树都拽住了。

这篇内容聊的就是这两件事:怎么把"不该渲染的渲染"揪出来并干掉,怎么把"该释放的没释放"定位到具体那一行。适合已经能写 React Native 页面、但对性能问题还停留在"加个 memo 试试"阶段的同学,也适合带团队时想把排查流程规范化下来的朋友。里面涉及的工具、参数、代码都是我实际跑过的路子,数字是基于常见实践给的参考,不是绝对标准。

1. 重渲染的成本到底落在哪一层

1.1 render 函数执行便宜,不等于这次渲染无害

很多人对重渲染的直觉是"我的组件函数里就几行 JSX,执行一下能有多贵"。单看一次函数调用确实便宜,但 React Native 的一次更新是分两段的:render 阶段在 JS 线程里跑,构建出新的 Fiber 树并做 diff;commit 阶段才把差异翻译成原生视图操作,批量发给原生侧。真正容易出问题的是后半段——一旦 props 的引用变了,即使值没变,下游的宿主组件也会被标记为需要更新,原生侧的属性设置、布局重算就跟着来了。

举个最常见的例子。一个列表单元里写了内联样式和内联回调:

function Cell({ item, onPress }) { return ( <Pressable style={{ padding: 12, backgroundColor: '#fff' }} onPress={() => onPress(item.id)} > <Text style={{ fontSize: 16 }}>{item.name}</Text> </Pressable> ); }

每次 Cell 渲染,style是新的对象字面量,onPress是新的函数。如果 Cell 外层套了React.memo,浅比较会认为这两个 props 都变了,直接放行渲染。也就是说,memo 白加了,而且还多了一次比较的开销。这种写法在项目里极其普遍,因为它读起来最自然。

所以判断一个重渲染值不值得治,别只看组件函数体有多短,要看它往下游传了多少个"每次都是新引用"的 props,以及下游有多少个宿主组件。

1.2 一次父组件 setState 的连锁反应

不加任何干预的情况下,父组件状态变化会让整棵子树重新执行 render。假设一个页面有 5 个区块,每个区块平均 20 个组件,那就是 100 次函数调用。单次 0.1ms 算,也有 10ms,一帧 16.7ms 的预算直接没了一半,再加上 diff 和 commit,掉帧几乎是必然的。

更隐蔽的是"状态放错位置"。我见过把输入框的text状态放在页面根组件上的写法,理由是"其他区块也要用"。结果是每敲一个字,整页所有区块跟着渲染一次。后来把那部分状态下放到搜索栏组件内部,只在提交时往上抛一次结果,输入过程中的渲染次数从"每次按键 × 整页"降到"每次按键 × 一个输入框"。

排查这类问题的顺序我一般是这样:先确认是"渲染次数多"还是"单次渲染慢",这两个方向的治法完全不同。前者靠 memo 和状态下放,后者靠减少节点层级、拆分长列表、把重计算挪到 useMemo 里。搞反了方向,改一天也不会有明显收益。

1.3 开发模式和线上包的性能不是一回事

这一点必须先说清楚,否则后面所有测量都是白做。开发模式下 JS 是未压缩的,有额外的校验、有 Fast Refresh 的热更新包装、有更详细的警告,渲染耗时可能是线上包的两到三倍。你在 dev 环境里看到某个组件 render 花了 8ms,线上可能只有 2ms。

我的做法是:功能验证在 dev 环境做,性能数字一律用 release 包或者至少关掉 dev 标志的包来测。安卓上注意别开调试器连 Chrome,那种模式下 JS 跑在浏览器里,跟真机运行时完全是两码事,测出来的数字没有任何参考价值。真要打日志看耗时,用console.log打到原生日志里,或者干脆把耗时数据渲染到屏幕上的一个角标里。

2. 动手之前,先把"谁在重渲染"钉死

2.1 Profiler 的正确读法

React DevTools 里的 Profiler 面板是起点。操作路径是:打开录制,在真机或模拟器上做一次具体交互(比如滑动列表三屏、点击某个按钮),停止录制,然后看两个视图。

火焰图看的是这一帧里哪些组件参与了渲染、各自的耗时占比。排名视图把组件按耗时排序,能快速找到大头。这里面有个容易误读的点:排名第一的组件不一定是问题所在,它可能只是父组件传下来的 props 没稳住,被动地跟着渲染。看到某个 Cell 排名靠前,先往上看它的父组件有没有把新对象传给它。

还有一个信息很关键:每个组件条目上会显示这次渲染的原因。多数情况是"props changed"或者"parent rendered"。如果是"parent rendered"占绝大多数,那治本的位置在父组件;如果是"props changed"且集中在一两个字段,就盯着那个字段的引用稳定性改。

2.2 自己写一个 props 变化探测器

不想装额外依赖的话,一个几十行的自定义 Hook 就够用了,原理就是拿上一次的 props 和这一次做逐字段比较,把变化的字段名打出来:

import { useRef, useEffect } from 'react'; export function useWhyDidYouUpdate(name, props) { const prev = useRef({}); useEffect(() => { const changed = {}; const allKeys = new Set([ ...Object.keys(prev.current), ...Object.keys(props), ]); allKeys.forEach((key) => { if (prev.current[key] !== props[key]) { changed[key] = { from: prev.current[key], to: props[key], }; } }); if (Object.keys(changed).length > 0) { console.log(`[why-render] ${name}`, changed); } prev.current = props; }); }

用的时候把它塞进可疑组件的最上面,useWhyDidYouUpdate('Cell', { item, onPress, style })。跑一遍交互,日志里会把每次变化的字段列出来。这个方法比 Profiler 更细,因为它能告诉你具体是哪个 prop 变了,而不是只知道"变了"。注意它本身会造成一次额外的副作用执行,排查完记得删掉或者用环境变量包一层。

2.3 三个经常被误判成"重渲染"的假阳性

第一个是首次挂载慢。列表第一次进来白屏两秒,很多人当成渲染问题去加 memo,其实那是数据加载和首屏渲染节点太多,该治的是分页和骨架屏。

第二个是图片加载卡顿。远程图片解码在主线程之外,但图片组件的尺寸测量、圆角裁剪会走原生侧,滑到新图片时的一次性卡顿跟 JS 渲染没关系。这种情况该看的是图片缓存策略和占位尺寸。

第三个是原生动画。用Animated的 native driver 跑的动画根本不过 JS 线程,如果动画过程掉帧,问题在原生侧的图层合成,往 JS 方向查是南辕北辙。判断方法很简单:动画播放期间看 JS 线程的占用,如果一条直线,就不用往渲染方向想了。

3. memo、useCallback、useMemo 的使用边界

3.1 React.memo 的浅比较在什么情况下一定会失效

React.memo默认做浅比较,也就是逐个 key 用Object.is比对。这意味着只要 props 里有对象、数组、函数,并且它们是每次渲染时新建的,浅比较就必然判定为"变了"。所以 memo 要生效,前提是往它里面传的引用必须稳定。

我整理过一张表,列出常见 props 类型的稳定化手段和代价:

props 类型常见的引用失稳写法稳定化手段需要额外注意的点
回调函数onPress={() => go(id)}useCallback+ 完整依赖依赖里有对象时依然会变
样式对象style={{ marginTop: 8 }}提到模块顶层或用useMemo模块顶层的常量最省心
数组数据data={list.filter(...)}useMemo包住计算结果依赖数组要写全
配置对象config={{ size: 'lg' }}提到模块顶层需要动态值时用 useMemo
子元素children={<Icon />}一般无解,改用 props 传可考虑提到外层

最后一行值得多说一句。JSX 形式的 children 每次都是新的 React 元素对象,memo 对这个基本无能为力。比较务实的做法是不用 children 传,改成传一个iconName这样的原始值,让子组件内部去渲染。听起来有点绕,但在高频渲染的列表单元里,这个改动收益很直接。

3.2 自定义比较函数的收益和风险

React.memo支持第二个参数传自定义比较函数。当 props 里有"值重要但引用会变"的字段时,这个能力有用:

const Cell = React.memo( function Cell({ item, selectedIds, onToggle }) { return ( <Pressable onPress={() => onToggle(item.id)}> <Text>{item.name}</Text> {selectedIds.has(item.id) ? <CheckMark /> : null} </Pressable> ); }, (prev, next) => { return ( prev.item.id === next.item.id && prev.item.name === next.item.name && prev.selectedIds.has(prev.item.id) === next.selectedIds.has(next.item.id) && prev.onToggle === next.onToggle ); } );

这里selectedIds是一个 Set,每次选中操作都会生成新的 Set,浅比较必然失败,所以改用"这个 item 是否在集合里"的布尔结果来比较。这种写法的风险在于:一旦你漏比较了某个字段,组件就会在需要更新时不更新,表现出来是"点了没反应"或者"数据变了但界面没变",而且极难查。我的原则是自定义比较函数只比较有限的三五个字段,字段多了宁可重构数据结构。

3.3 useCallback 不是给所有函数准备的

useCallback的语义是"依赖不变时返回同一个函数引用"。它本身不减少组件渲染次数,只影响引用相等性。如果这个回调没有传给任何 memo 过的子组件,也没被放进别的 Hook 的依赖数组里,那么包一层 useCallback 纯粹是增加了依赖比较的开销和代码噪音。

我在一个项目里做过对比:给一个列表单元里的 5 个回调全部加上 useCallback,而单元本身没做 memo,结果滚动帧率没有任何变化,反而因为依赖比较多了一点微小开销。后来做的是另一件事——把这些回调统一交给一个 reducer dispatch,dispatch 引用天然稳定,五个回调全删了,代码还短了。

判断标准很简单:往上追一层,看这个回调作为 props 传给了谁,接收方是不是 memo 组件。是,就加;不是,先别加。

3.4 依赖数组写错的四种典型症状

依赖写错导致的 bug 有个共同特点:单次操作看起来正常,连续操作或者切换数据源之后才出问题。我把常见症状列一下,遇到对照着查:

症状大概率原因排查方式
定时器里读到的永远是初始值依赖写了空数组,回调闭包捕获了旧 state改用 ref 存最新值
切换页面后还在用旧数据请求请求函数依赖了 id 但没写进数组把 id 加进依赖,或用 AbortController 取消
下拉刷新时读到的是上一次的页码分页状态没有全部进入依赖用 reducer 收敛分页状态
控制台警告"缺少依赖"但加上就死循环依赖里放了每次渲染都新建的对象把对象拆成原始字段,或用 ref

表格最后一行是重灾区。有人为了消掉警告,在依赖数组里塞了一个对象,结果每次渲染都触发 effect,effect 里又 setState,直接打出无限循环。正确做法是把对象拆成它的原始字段作为依赖,比如config.size、config.mode,而不是整个config。

4. 长列表:参数比 memo 更能决定流畅度

4.1 虚拟化窗口是怎么工作的

FlatList 本质上是 VirtualizedList,只渲染一个"窗口"内的行。这个窗口由windowSize控制,单位是屏幕高度,默认值 21,含义是当前视口上方 10 屏、下方 10 屏的范围内保持挂载。这个默认值在普通信息流上偏保守,意味着可能有几十个单元同时挂在树上,每个都在占内存、都可能触发渲染。

把windowSize调到 7 或 5,挂载的单元数量会明显下降,内存占用和渲染压力都跟着降。代价是快速滑动时可能出现短暂白块,因为新的单元还没来得及渲染。这个平衡点跟你的单元复杂度直接相关:单元里只有文字和一张小图,给 5 都够;单元里有视频封面、复杂阴影、多层嵌套,给 7 更稳妥。

4.2 getItemLayout:固定高度行的隐形加速器

默认情况下,FlatList 不知道每一行有多高,它得通过onLayout回调去实际测量。这意味着滚动过程中会不断产生测量往返,而且scrollToIndex这类操作会因为位置不确定而失败或者跳动。

如果行高是固定的(或者能算出来),直接告诉列表:

const ITEM_HEIGHT = 72; const SEPARATOR = 1; <FlatList data={items} keyExtractor={(item) => item.id} getItemLayout={(_, index) => ({ length: ITEM_HEIGHT, offset: (ITEM_HEIGHT + SEPARATOR) * index, index, })} renderItem={renderItem} />

offset的计算必须把分隔符高度算进去,否则越往后偏移越大,滚到底部会出现位置错乱。这个坑我踩过一次:分隔符用ItemSeparatorComponent加了 1px 的线,getItemLayout里忘了这 1px,滑到第 150 条左右的时候内容直接错位,定位了半天才想起来。

行高不固定怎么办?两个选择:一是设计上妥协,把高度可变的部分(比如多行文本)截断到固定行数;二是用estimatedItemSize这类估算机制,让列表先按估计值布局,测量后再修正。前者最稳,后者在回收复用型列表组件上效果更好。

4.3 renderItem 里最容易被忽略的三个写法

renderItem是每行都要执行一次的函数,它内部的写法直接决定行组件的渲染成本。

第一,不要在renderItem里定义新组件。下面这种写法每次都会创建一个全新的组件类型,React 会认为类型变了,直接把旧组件卸载、新组件挂载,等于每帧都在重建:

// 错误:每次 renderItem 调用都创建新组件类型 renderItem={({ item }) => { const Row = () => <Text>{item.name}</Text>; return <Row />; }}

正确做法是把行组件定义在模块顶层,renderItem只负责传 props。

第二,不要在renderItem里做数据变换。排序、过滤、格式化日期这些操作,应该用useMemo在数据源头算一次,而不是每行算一次。见过一个项目在每行的 renderItem 里跑new Date(...).toLocaleDateString(),200 行就是 200 次时区转换,滑动时肉眼可见地卡。

第三,keyExtractor千万别用数组下标。用下标作为 key,在数据插入或者删除时,React 会把同一个位置的旧组件复用到新数据上,导致状态错配——比如勾选状态跑到了别的行上。用业务上唯一的 id,没有的话在数据入库时生成一个。

4.4 几个参数的实际取舍

参数默认值调到多少代价
windowSize215 ~ 7快速滑动可能出现白块
maxToRenderPerBatch105 ~ 8首屏之外填充变慢
initialNumToRender10覆盖首屏可见行数设置过大会拉长首屏时间
updateCellsBatchingPeriod5030 ~ 50太小会频繁打断交互
removeClippedSubviews平台相关安卓谨慎开部分场景下内容消失

最后一项单独说。removeClippedSubviews的作用是把滚出视口的原生视图从层级里摘掉,理论上省内存。但它在安卓上跟某些嵌套滚动容器、绝对定位元素配合时会出问题,表现是内容莫名为空或者错位。我现在的策略是:默认不开,只有在确认一个简单列表页有内存压力、并且测过各种滑动场景没问题之后,才针对性打开。

5. 内存泄漏:JS 侧和原生侧是两条线

5.1 定时器和订阅:最经典也最常犯

setInterval忘记清除是最常见的泄漏源。组件卸载了,定时器还在跑,回调里通过闭包持有组件的整个作用域,连带 state、props、甚至 DOM 引用一起留在堆上。跑得越久,堆积的回调越多。

useEffect(() => { const timer = setInterval(() => { // 轮询逻辑 }, 3000); return () => clearInterval(timer); }, []);

清理函数必须返回。这一点大家都知道,真正容易漏的是条件分支里的清理:比如在某些判断下提前 return 了,清理函数写在了 return 之后,那就永远执行不到。写的时候养成习惯,useEffect里凡是申请了外部资源,开头就把清理函数准备好,别等到结尾。

同样的模式适用于事件订阅:原生模块的事件监听、设备传感器、键盘、AppState 变化。凡是addListener或者addEventListener,都要配对remove。

5.2 闭包持有旧值:一个更隐蔽的陷阱

这个坑比忘记清理更阴险。看这段代码:

function Counter() { const [count, setCount] = useState(0); useEffect(() => { const timer = setInterval(() => { console.log('当前计数:', count); // 永远打印 0 }, 1000); return () => clearInterval(timer); }, []); return <Text>{count}</Text>; }

依赖数组是空的,定时器回调在挂载那一刻形成了闭包,捕获的是当时的count。后面count再怎么变,回调里读到的还是 0。这时候很多人会"修复"成把count加进依赖数组——结果是每次count变化都要销毁重建定时器,如果count变化很频繁,定时器根本不会完整跑完一个周期。

正确做法是用 ref 存最新值:

const countRef = useRef(count); countRef.current = count; useEffect(() => { const timer = setInterval(() => { console.log('当前计数:', countRef.current); }, 1000); return () => clearInterval(timer); }, []);

ref对象的引用是稳定的,回调读ref.current永远拿到最新值,定时器只创建一次。这个模式在轮询、WebSocket 回调、长按手势这些场景里都适用。

5.3 请求竞态与取消

快速切换页面或者连续搜索时,前一个请求可能在后一个之后才返回,把新数据覆盖掉,或者向已卸载的组件 setState。表现是列表内容闪一下变成旧结果,控制台上偶尔能看到对已卸载组件更新的警告。

useEffect(() => { const controller = new AbortController(); async function load() { try { const res = await fetch(url, { signal: controller.signal }); const data = await res.json(); setData(data); } catch (e) { if (e.name !== 'AbortError') { setError(e); } } } load(); return () => controller.abort(); }, [url]);

关键点在两处:一是AbortController在清理函数里被 abort,请求被真正取消,不只是忽略结果;二是 catch 里要区分主动取消和真实错误,否则用户每次切页面都会看到一条错误提示。

5.4 原生侧的持有:图片、视频、WebView

JS 层面的泄漏能靠快照查,原生侧的持有更麻烦。几个高频场景:

图片缓存没有上限。加载过的远程图片会在原生侧的图片缓存里保留,如果列表里图片 URL 大量不重复(比如用户头像带时间戳参数),缓存会一路涨。检查方法是看图片组件的缓存策略参数,以及确认 URL 里没有无意义的随机参数。

视频播放器和相机实例没有释放。这类原生组件的生命周期需要显式管理,页面卸载时要调用停止播放、释放资源的方法。只靠组件卸载是不够的,原生对象可能还被某个单例持有。

WebView 的引用没断。内嵌网页如果注册了 message 回调,页面卸载后回调仍然持有 JS 侧的引用链,整棵树都释放不掉。稳妥做法是在卸载时把回调置空。

5.5 怎么确认到底有没有泄漏

光看内存数字涨不算证据,正常的缓存增长和泄漏是两回事。我的判断流程是这样:

第一步,确认基线。启动应用,不进任何业务页面,记录一次内存占用,静置三十秒再记一次,看是否稳定。不稳定说明启动阶段就有问题。

第二步,做重复进出测试。进入目标页面,等数据加载完,返回,再进入,重复十次。每轮结束后强制触发一次垃圾回收(开发环境下可以调用相关调试接口),记录内存。理想情况下曲线应该呈锯齿状,每轮回落到接近基线。如果每一轮都比上一轮高一个固定量级,那就是泄漏,而且泄漏量和页面数据规模正相关。

第三步,取堆快照。用 Hermes 的堆快照能力,在重复进出五次之后抓一份,再重复五次抓第二份,对比两份之间新增的对象。重点看那些数量持续增长、并且构造器名字跟你的业务组件同名的实例。一个组件实例本该在卸载后消失,如果快照里还留着几十个,顺着它的引用链往回找持有者,一般能找到那个没清理的订阅或者闭包。

这一步的关键是看"数量随重复次数线性增长"的对象,而不是看"总大小最大的对象"。后者往往只是一张大的图片缓存或者基础库,跟你的泄漏没关系。

6. Context 和状态结构带来的隐形刷新

6.1 Provider 的 value 每次都是新对象

这是 React 里最容易被忽视的全量重渲染来源:

<UserContext.Provider value={{ user, setUser }}> {children} </UserContext.Provider>

每次 Provider 所在的组件渲染,{ user, setUser }就是新的对象字面量,所有消费这个 Context 的组件全部重新渲染,不管user有没有变。如果这个 Provider 放在应用根部,那就是每次根部状态变化,全应用跟着渲染。

两个层面的修法。一是把 value 用useMemo包住,依赖只放真正会变的字段:

const value = useMemo(() => ({ user, setUser }), [user]);

二是更根本的——拆分 Context。把高频变化的值和低频的、稳定的分开放。典型做法是把setUser这类引用天然稳定的 dispatch/action 单独放一个 Context,读取状态放另一个。只用到 dispatch 的组件就永远不用跟着状态变化渲染。

6.2 派生状态不要用 effect 加 state

// 反模式 const [total, setTotal] = useState(0); useEffect(() => { setTotal(items.reduce((s, i) => s + i.price, 0)); }, [items]);

这段代码的问题:一次数据变化,先渲染一次(用旧的 total),effect 执行后再渲染一次。多出来的这一轮渲染,如果 items 很大或者父组件很重,代价不小。而且如果 effect 里还有别的副作用,时序会更复杂。

派生状态的正确做法就是直接算:

const total = useMemo( () => items.reduce((s, i) => s + i.price, 0), [items] );

只在 items 变化时重算,渲染次数不增加。如果计算本身很贵,再考虑把计算挪到数据源的 reducer 里,在数据变更时一次性算好存进去。

6.3 状态该放多高

判断标准是"谁需要读这个状态"。只有某一个组件需要读,就放在它自己内部;几个兄弟组件需要共享,放到最近的公共父级;整个应用需要,才放到根部的全局容器。

实际操作中常见的反向问题:为了"方便其他页面也能用",把一个页面的搜索关键词、分页游标、选中项全部放到了全局状态里。结果是这些高频变化的字段每次变动都能触发全局订阅者更新,而大多数订阅者根本不关心它们。修的时候需要一层层剥,成本不低。所以我在搭建时就倾向于:全局只放真正跨页面共享的、变化频率低的数据,页面内的交互状态一律留在页面里。

7. 一次完整改造的全链路记录

这里用一个虚构的模拟项目把这个流程串一遍。场景是一个带勾选功能的商品清单页,单页数据 300 条,行高固定,行内有图片、标题、价格和勾选框,支持批量勾选。

改造前测到的现象:滑动平均帧率在低端机上大概四十出头,快速下滑有明显白块;批量全选时界面卡住接近一秒;连续进出页面二十次,内存从 180MB 涨到 320MB,退出后不回落。

第一步,定位渲染来源。用前面那个 props 探测器挂在行组件上,日志显示每次勾选任何一项,所有 300 行都报了selectedIds变化。原因很明确:selectedIds是一个 Set,每次勾选都new Set(...),引用变了,所有行的 memo 全部失效。

第二步,改数据结构和比较方式。把selectedIds改成从父组件传isSelected布尔值,行组件用 memo 默认比较就够了。父组件那边改成用useCallback稳定勾选回调,再配合前面 3.2 里提到的自定义比较,只在isSelected真的变化时才让那一行渲染。改完之后勾选单行只触发两行的渲染(取消的那行和选中的那行)。

第三步,处理全选卡顿。原来的实现是一次 setState 塞进 300 个 id,导致 300 行同时渲染。改成两段:先立刻把"全选"这个整体状态切过去,让所有行的勾选框视觉上同步变色(用不依赖行数据的统一状态),数据层面的 id 集合用startTransition或者分批的方式补上。用户感知到的就是"秒变",实际的数据整理在后台完成。

第四步,调列表参数。windowSize从 21 降到 7,maxToRenderPerBatch从 10 降到 6,加上getItemLayout。白块问题通过给行图片设置固定尺寸占位解决,避免加载过程中的尺寸抖动。

第五步,查泄漏。重复进出测试确认存在泄漏,抓两份堆快照对比,发现行组件实例数量随进出次数增长。顺着引用链找到的是一个全局事件总线——页面在挂载时订阅了"数据刷新"事件,但清理函数里只取消了其中一个,漏掉了另一个,导致每次进页面都多一份订阅,订阅回调持有整个组件树。补上取消订阅之后,内存曲线恢复正常锯齿状。

改造后的结果(基于模拟数据,仅作参考):滑动帧率提升到五十以上,白块消失,全选响应控制在 100ms 以内,重复进出二十次内存稳定在 190MB 上下并能回落。

整个过程的投入产出比值得记一下。第五步那个泄漏修复只改了四行代码,但对长时间使用的稳定性影响最大;第二步的结构调整改动面最大,涉及数据流重构,但收益也最明显;参数调优几乎零成本。如果时间有限,我的建议顺序是先修泄漏(改动小、风险低、收益长期),再调参数(零成本),最后才动数据结构。

8. 上线前后怎么验证改动真的有效

改完跑一遍不卡,不代表问题解决了。我一般会准备一组固定动作做回归:冷启动进页面、滑到底再滑回顶、连续勾选五十次、快速反复进出十次、切后台再切回。每次改动前后跑同一组动作,记录三个指标。

指标采集方式关注点
交互响应耗时打点记录点击到首帧响应的间隔单个动作超过 100ms 要查
滑动帧率原生侧帧率监控或第三方性能面板持续低于 50 需要优化
常驻内存重复进出后记录,静置后复测不回落或线性增长即异常

这里有个前提必须强调:所有数据在 release 包上采。开发包的数字只能用来横向对比"改了之前和改了之后哪个更快",不能当作绝对指标。另外,低端机型的表现和旗舰机差异极大,如果产品目标用户集中在低端安卓,测试机型一定要覆盖到,别只用最新款旗舰机验证完就发布。

还有一个容易忘的点:这些优化改动本身可能引入新的问题。memo 加多了可能出现"数据变了界面没变",自定义比较函数写错会出现状态错配,取消订阅写早了会导致该收的消息收不到。所以每一处优化都要配一条对应的功能回归用例,尤其是勾选、分页、实时刷新这三类交互,最容易因为优化而失效。

我个人的体会是,性能优化这件事最忌讳的是"先改再测"。看到一个卡顿就上手加 memo、包 useCallback,改完发现没效果,又去怀疑列表组件、怀疑状态库,来回折腾几天。真正省时间的做法是把测量放在最前面,用探测器把"谁在渲染、为什么渲染、渲染了几次"这三个问题回答清楚,大部分问题在一小时内就能定位到具体那几行代码。至于内存泄漏,养成一个习惯就够了:凡是 useEffect 里申请了外部资源,写第一行代码的时候就把清理函数一起写出来,别留到后面补。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询