不稳定的 useEffect 依赖关系:诊断 React Native 中的电池耗尽问题
了解在 React Native 中,对象依赖关系与缺失的依赖数组是如何导致电池耗尽和地图渲染卡顿的,以及如何进行性能分析,还有三种有效的解决方案。
在模拟器中看起来完美的实时界面,实际交付时仍可能存在导致手机过热、动画卡顿的漏洞,而系统却不会记录任何错误信息。这类问题的常见根源是useEffect的依赖项变化频率远高于其需要处理的数据变化频率。本案例将深入分析一个实际的商品配送追踪界面中的此类漏洞:解释useEffect的存在意义、它在何种情况下是合适的工具、两个小的依赖错误如何共同引发渲染循环、如何通过JavaScript和原生层的分析工具定位问题,以及最终通过哪三项修改解决了该问题。
useEffect存在的原因
在 React 16.8 之前,类组件会将 API 调用、订阅和定时器等副作用分散到三个独立的生命周期方法中:componentDidMount、componentDidUpdate 和 componentWillUnmount。例如,“在页面显示时监听这个套接字”这样的逻辑需求通常需要分散在这三个方法中处理,这导致相关代码零散且容易遗漏某些步骤。
React 16.8 引入了钩子,useEffect 被设计用来统一处理这类逻辑。无需再考虑生命周期阶段,只需描述组件如何与外部系统保持同步,无论是请求、原生监听器、订阅、定时器还是动画帧。该效应在渲染之后执行,可以返回一个清理函数,并且当其依赖数组中的值发生变化时会被重新执行:
useEffect(() => {
// side effect code
return () => {
// cleanup code
};
}, [dependencies]);
在 React Native 中,hook 的使用极为频繁,因为几乎所有与渲染无关的操作都被视为副作用:如 AppState 和 NetInfo 的监听器、Keyboard 事件、位置监控、WebSocket 连接,以及用于相机或蓝牙等功能的原生 SDK。
为何需要遵循相关规范
副作用需要在渲染完成后有地方执行,同时在组件卸载或该副作用再次运行之前有地方进行清理。如果没有这样的结构,就会出现监听器泄漏、订阅重复以及闭包过时的问题,而这些问题的代价远高于正确使用 useEffect 所带来的成本。
何时该使用 useEffect,何时不该用
在 React Native 应用中,合适的用法包括:
- 监听诸如
AppState、NetInfo、Keyboard或Dimensions这样的原生事件源 - 仅在屏幕可见时运行计时器、间隔函数或动画循环
- 在渲染完成后将本地状态与属性或全局存储保持一致
- 在组件加载时,或在当前用户 ID 等关键输入发生变化时重新加载数据
- 以命令式方式驱动原生模块,例如切换位置跟踪、BLE 扫描或摄像头流
不适合使用效应的情况:
- 从属性或状态中获取值。 应在渲染过程中进行计算。
- 响应用户操作,如按钮点击。 应在事件处理函数中处理,而非在监听状态变化的效应中处理。
如需更全面地了解这种同步反模式,可阅读我们关于为何使用 useEffect 同步状态存在风险的文章。
问题所在:导致电池耗尽的追踪界面
想象一下实时配送追踪界面:地图上会实时显示司机的位置,就像外卖应用一样。它在模拟环境中运行正常,在两三台设备上通过了质量检测,随后便发布了。大约两周后,支持工单开始大量涌入:
- 在安卓设备上,用户表示开启定位界面后手机会变热,电池电量在20分钟内大约下降15%。
- 在苹果设备上,用户反映地图显示不流畅:司机标记会在不同位置之间跳变而非平滑移动,滚动速度也很慢。
这两种不同的问题其实源于同一个根本原因。
简化后的组件结构
这是该界面的简化版本。它负责保存司机的位置和订单状态,建立套接字连接,在一个处理流程中订阅位置更新信息,而在另一个处理流程中重新计算预计到达时间。出问题的地方就在这两行代码:
function TrackingScreen({ orderId }) {
const [driverLocation, setDriverLocation] = useState(null);
const [order, setOrder] = useState(fetchOrderSync(orderId)); // returns a new object reference
const socket = useMemo(() => connectSocket(), []); // looked memoized, wasn't the issue
useEffect(() => {
const subscription = LocationSocket.on('update', (loc) => {
setDriverLocation(loc);
});
return () => subscription.remove();
}, [order]); // 🚩 the bug
useEffect(() => {
console.log('Recalculating ETA...');
calculateETA(order, driverLocation);
}); // 🚩 no dependency array at all
return <Map driverLocation={driverLocation} order={order} />;
}
两个独立的问题叠加在了一起。
order每次父组件渲染时都会获得一个新的对象标识。在真实应用中,这个新对象来自层级更上方的钩子,该钩子每次都会将属性传递到一个新的对象中。(简化后的代码片段显示它来自useState,但实际上useState会保留一个稳定的引用;可将那行代码视为上游钩子的替代。)由于订阅效应将order列为依赖项,React 会在每次渲染后都执行清理操作并重新订阅位置套接字,而不仅仅是在订单真正发生变化时。
setDriverLocation触发的渲染。在这个应用中,ETA的计算还会导致其他地方的状态更新,从而形成循环:位置更新、重新渲染、ETA效果、再次状态更新、又一次重新渲染,如此往复。同样的代码在不同平台上会表现出不同的问题。在Android上,套接字会频繁断开又重新连接,这使得无线电模块和CPU几乎持续处于忙碌状态;这才是电池耗尽的真正原因。在iOS上,无线电模块的活动被以不同的方式限制,但持续的重新订阅和渲染循环仍然会给JavaScript线程带来巨大压力,导致地图层不必要的频繁加载,用户会因此感受到卡顿现象。
关于这段代码的补充说明:useState(fetchOrderSync(orderId))会在每次渲染时都调用fetchOrderSync,尽管React仅会在第一次使用时使用其返回结果。如果初始值的获取成本较高,建议传入一个函数,如useState(() => fetchOrderSync(orderId)),这样该函数只会执行一次。
问题的诊断过程
第一步:确认是组件重新渲染,而非地图库的问题
最容易怀疑的对象自然是地图SDK。通过使用与开发时相同的Metro连接来访问React Native应用,React DevTools Profiler很快排除了这一可能性。团队在没有任何交互的情况下,对显示追踪界面的10秒钟进行了性能分析。
录制数据显示,组件树每秒会进行数十次渲染,而后端大约每3到5秒才发送一次新的驱动器位置信息。这种差异便是第一个重要的线索。一般来说,渲染频率应与有意义的数据变化同步;当渲染次数远远超过更新次数时,必定是有人为地触发了这些渲染。
步骤2:查明为何会重复渲染
性能分析工具的排序视图显示TrackingScreen和Map不断交替执行渲染操作。为找出确切的触发原因,暂时引入了小型调试库why-did-you-render。该库会记录导致每次渲染的属性或状态变化,并给出了如下结果:
TrackingScreen re-rendered because of changed props: order
order: Object !== Object (deep equal: true)
“deep equal: true”这一结果才是决定性证据。order的内容并未发生任何实质性的变化,只有它的引用地址发生了改变,因为每次迭代时该对象都会在上游被重新创建。React使用Object.is来比较依赖关系,因此结构相同但新创建的对象始终会被视为发生了变化。
步骤3:观察原生端情况
JavaScript性能分析可以解释渲染过程,但无法说明设备的网络硬件在做什么。在原生端,我们使用了Flipper及其网络插件和自定义日志插件来观察WebSocket的生命周期。日志显示connect和close事件在几秒钟内就反复出现,而非在屏幕保持打开状态期间维持稳定的连接。这证实了每次效果重新运行时,该套接字都会被断开。
Flipper的Hermes调试器提供了进一步的证据:在订阅效应的清理函数中设置的断点触发频率,远远高于真正的卸载或订单变更所能解释的范围。
需要注意的一点是:较新版本的React Native已不再将Flipper作为默认调试工具,而是转而使用React Native DevTools,因此请查阅当前版本的React Native文档以了解推荐的配置方式。通过观察连接生命周期并在清理函数中设置断点的方法,无论使用何种调试工具都适用。
第4步:测量实际的电池与CPU消耗
最后,Android Studio的Profiler结合其CPU和能耗视图以及Flipper的数据,对实际影响进行了量化分析:
- 修复前:即使跟踪界面处于空闲状态,CPU使用率仍持续在35%到40%之间,而能源分析工具由于检测到持续的无线电信号活动,将该应用归类为高电池消耗应用。
- 修复后:空闲状态下的CPU使用率降至大约4%到6%,能源分析工具也不再报告持续的无线电信号使用情况,仅显示与实际更新间隔相一致的短暂、间歇性的信号波动。
修复方案:三项针对性改进
每项改进都针对问题链中的某个环节。
1. 使用原始类型而非对象
只有当订单本身发生变化时才需要重新启动订阅功能,orderId字符串能够准确标识这一点。由于原始类型是通过值进行比较的,因此在多次渲染过程中其值保持不变:
useEffect(() => {
const subscription = LocationSocket.on('update', (loc) => {
setDriverLocation(loc);
});
return () => subscription.remove();
}, [orderId]); // orderId is a primitive string — stable across re-renders
2. 为每个效果声明依赖关系
只有在确实希望该效果在每次渲染后都执行时才省略数组,但这种情况极为罕见。此处应在驱动程序位置发生变化时重新计算预计到达时间:
useEffect(() => {
calculateETA(order, driverLocation);
}, [driverLocation]); // only recalculate when location actually changes
严格来说,该效果还会读取order值,因此react-hooks/exhaustive-deps检查规则会要求将其放入数组中。一旦order被缓存(在后续的修复中实现),将其加入数组是安全的,也能保证效果的准确性,因为只有当顺序真正改变时它才会重新执行。省略该效果所读取的值可能会导致使用过时的数据来计算预计到达时间。
3. 在上游缓存order对象
最后,要稳定该对象的创建位置,使其引用仅在相关字段发生变化时才会改变:
const order = useMemo(() => buildOrder(rawOrderData), [rawOrderData.id, rawOrderData.status]);
请谨慎处理那个依赖列表:由于其中仅包含id和status,即使修改rawOrderData中的其他字段,比如配送地址,也不会生成新的order。但前提是后续没有代码依赖那些其他字段。
在应用了所有三项更改之后,由于每次访问页面时仅建立一次套接字连接,且预计到达时间仅在位置实际移动时才会重新计算,渲染频率也从每秒数十次下降到每隔几秒一次,从而更符合数据的实际流动情况。
给React Native团队的启示
- 假设对象和数组的依赖关系是不稳定的。除非你是用
useMemo或useCallback创建的,否则应预期每次渲染时都会生成新的引用。在适用的情况下,优先使用能够准确表达意图的原始类型依赖,如ID。 - 务必写出依赖数组,切勿屏蔽相关警告规则。
react-hooks/exhaustive-deps正是为捕捉这类错误而存在的。在不理解警告含义的情况下禁用该规则,正是这些问题会流入生产环境的原因;应解决依赖关系的不稳定性。 - 不仅要分析交互行为,还要检测空闲状态下的表现。这个错误仅在无人操作屏幕时出现,而这正是手动测试往往容易忽略的情况。
如果您想进一步了解渲染方面的问题,我们关于导致React不必要的重新渲染的常见模式的文章介绍了相关注意事项。
总结
很少有钩子函数能像 useEffect 那样快速编写,也很少有函数会如此容易在不知不觉中出错。在实时界面中,依赖关系处理错误不仅会导致额外的日志记录,还可能使相关组件持续运行、让 JavaScript 线程过载,最终让应用出现故障却看不出明显错误。保持依赖关系稳定、明确使用数组以及分析空闲状态,这些都是能避免这类问题最严重后果的简单习惯。
相关阅读
- React Native 内存泄漏:追踪 JS 堆与原生内存的占用者 — 了解为何垃圾回收机制无法解决 React Native 应用的原生内存泄漏问题,以及如何找出导致回调函数、JSI 对象和解码后的图像持续存在的原因。