做股票行情类 App 绕不开一个场景:一屏列表里几十个品种,每个品种的买价、卖价、涨跌幅都在通过 WebSocket 长连接每秒推好几次,价格一变还要闪一下涨跌色。功能写出来不难,难的是用户停在行情页两分钟后开始抱怨:手机背面发烫、列表滑动一卡一卡、内存监控里数字只涨不回落。
我接手的那个项目最严重时,iPhone 16 Pro Max 的 Release 包在行情页主线程 51%、JS 线程 36% 双线饱和,FPS 在稳态期周期性掉到 0~10,虽然没触发系统热降频,但持续发烫已经成了投诉点。这篇文章把整个排查和优化过程完整复盘,所有代码已脱敏,核心是四刀解法——它们对任何”高频数据 + 长列表”的场景(行情、弹幕、IM、实时大屏)都通用。
在本篇文章中,我们将从浅入深,一起搞定以下内容:
- 高频行情渲染为什么会同时引发发热、掉帧、内存三个问题
- 用真机
trace定位四个”永不停”的CPU黑洞 - 第一刀:按需订阅,只为可视区品种建立
WebSocket订阅 - 第二刀:per-symbol 精准订阅,切断”心跳让全表
rerender“ - 第三刀:
FlashList渲染稳定性,让滑动复用链不断 - 第四刀:心跳降频 + 边沿侦测 +
AppState启停,杀掉后台空转 - 给
App内置实时入站帧率、断连原因、GC监控面板 - 用量化阈值表验收”优化到底有没有生效”
一、问题现场为什么三个症状一起来
发热、掉帧、内存暴涨看起来是三个问题,根子其实是同一个:CPU 在被持续无意义地烧。
WebSocket 把行情帧推到 JS 线程,每帧都要 JSON.parse、写状态、触发 React 重渲染、再走 Fabric 的 ShadowTree commit 和原生 mount transaction。这条链路里任何一环只要频率失控,就会:
- 发热:
CPU长期高占用,芯片功耗上去了,热量自然来——GC频繁、mount transaction每秒几十次都是元凶。 - 掉帧:主线程被
mount transaction和clipping递归占满,渲染管线挤不进16ms一帧的预算,FPS就塌了。 - 内存暴涨:高频
JSON.parse产生海量临时对象,Hermes堆反复扩张;如果订阅没按需回收,可见区外的几百个品种状态还挂在内存里只涨不回落。
所以优化的总目标只有一句话:让 CPU 只在”真的有价格变化、且这个品种用户真的看得到”的时候才干活,其余时间一律闲着。 四刀解法全是围绕这句话展开的。
二、定位四个永不停的 CPU 黑洞
光说”CPU 高”没用,得拿真机 Release 包的 Instruments trace 把热点钉死。交叉审计 trace 关键字和源码后,我定位到四组”持续运行、永不停”的隐性黑洞:
黑洞一:全局心跳定时器永不停。 一个 80ms(12.5Hz)的全局 setInterval,模块加载就起、息屏后台都不停,每次都写一个 SharedValue,触发每个挂载过的行情文本组件的 useDerivedValue worklet 重算”价格新鲜度”。trace 里 reanimated+worklet 关键字 inclusive 累计 207s,是 18.67s 采样的绝对头部。
黑洞二:Fabric mount transaction 每秒 66 次。 每次 commit 都递归遍历整棵子视图树做 clipping(updateClippedSubviewsWithClipRect 主线程占 17.2%,递归 30+ 层)。源头多半是黑洞一频繁写动画样式属性触发的 ShadowTree commit。
黑洞三:把超大业务 store 当订阅源。 行情单元格订阅了一个杂烩 store(订单、持仓、余额、设置几十个字段都在里面),结果任意业务字段写入(下单、切 tab、HTTP 刷新)都把全部可见单元格的 selector 同步跑一遍。trace 里 Hermes 解释器 34s inclusive。
黑洞四:单元格视图层级太深 + 多层圆角裁剪。 Pressable → View → View → ... → Icon 嵌套 8+ 层,每层 rounded-xl 都触发圆角图片绘制和 clipping 递归向更深扫描,把前三条的 CPU 成本进一步放大。
四条同时存在、叠加放大,导致单独优化任何一条都看不出效果,必须一次性收口。下面按 ROI 从高到低逐刀拆解,整体解法和收口效果先看这张全景图:

三、第一刀按需订阅只为可视区建立订阅
最大的浪费是:列表里有几百个品种,但用户一屏只看得到十几个,却给全部品种都开了 WebSocket 订阅、全部都在接收推送、全部都在 parse 和写状态。
解法是「可视区订阅」:只为当前屏幕可见的品种 + 上下各 5 个缓冲建立订阅,滑出去的退订。FlashList 的 onViewableItemsChanged 给我们可见区索引,debounce 合并滚动过程中的高频回调:
import type { ViewToken, ListRenderItemInfo } from '@shopify/flash-list' |
光”算出该订阅哪些”还不够,真正发订阅 / 退订协议时还有两个工程问题:滚动来回会产生大量 sub/unsub 协议噪音;切 tab 瞬间会有短暂”全退又全订”。所以订阅层再加一个 flush 调度器做批量合并——订阅立即发、退订冷却 5s 后才发,冷却期内同一品种又滚回来就取消退订:
import { FLUSH_DEBOUNCE_MS, UNSUB_COOLDOWN_MS } from '../config' // 50ms / 5000ms |
这一刀直接把”同时活跃的订阅数”从几百压到十几二十个,JSON.parse 的量级和写状态的频率都跟着掉一个数量级。debounce 200ms + 冷却 5s 这两个常量是反复调出来的——太短了滚动有协议噪音,太长了切 tab 残留订阅浪费带宽。
四、第二刀per-symbol精准订阅切断心跳rerender
按需订阅解决了”接收多少帧”,但还有个更隐蔽的问题:就算只订阅可视区,单元格订阅 store 的方式不对,照样会被无关更新带起重渲染。
最典型的坑是把整个业务 store 当订阅源(黑洞三)。解法是给行情元数据单独切一个 sub-store,让单元格只订阅”自己这个品种”的关键字段,用 zustand 的 useShallow 做浅比较:
import { useMemo } from 'react' |
这里有个全文最值钱的细节,我用注释专门标了:绝对不要把行情时间戳 ts 放进 useShallow 的比较字段。 服务端的心跳类 tick 会让 ts 单调推进,但买卖价、diff 完全不变。如果你图省事把 ts 列进比较,结果就是所有可见单元格跟着心跳一起 rerender——24 个单元格 × 12 ticks/s 实测会产生约 290/s 的 mountingTransaction,主线程直接被打爆。需要 ts 的地方就在 useMemo 里用 getState() 实时读,它只用于派生输出字段、不参与订阅触发语义。
五、第三刀FlashList渲染稳定性让复用链不断
订阅和数据派生都精准了,还要保证 FlashList 这一层不自己制造重渲染。这一刀有四个要点。
要点一:renderItem 引用必须稳定。 FlashList 的 renderItem 引用或返回组件类型一变,会把全部可见单元格 unmount → remount,十几个 useSymbolQuote 同帧重建,JS FPS 直接掉到个位数。所以把布局模式作为 prop 传进单元格内部条件渲染,而不是在外面切换组件类型:
const renderItem = useCallback( |
要点二:主题用快照而不是整对象。 主题对象引用一抖动,全表重 paint。所以在列表层把单元格真正用到的几个字段拍成一个 memo 快照传下去:
// 行情 cell 主题快照:仅随涨跌色 / colors 变,避免 theme 整对象引用抖动触发整表重渲 |
要点三:单元格 memo 用自定义相等函数。 默认浅比较挡不住派生对象每帧新建。手写一个”只比视觉相关字段”的相等函数,价格没变就不重渲:
function symbolQuoteVisualEqual(a: SymbolQuoteResult, b: SymbolQuoteResult): boolean { |
要点四:Fabric 下关掉原生裁剪 + 压扁视图层级。 在 mount transaction 频率高的场景,让 RN 跳过”全树 clip 扫描”反而更快。配合把单元格视图嵌套从 8 层压到 5 层以内:
<FlashList |
还有个排序闪烁的坑顺带提一句:
FlashList在data重排时会用单元格复用 + 位移动画保留”原本可见那一项”,结果部分 item 会”闪一下”飘到顶部。单靠scrollToOffset(0)修不掉,因为复用过渡帧已经在原生层画了。叠加一个key={排序状态}让列表在排序切换时整体重挂载,没有任何复用过渡就没有闪烁,代价只在点击瞬间产生一次,可接受。
六、第四刀心跳降频边沿侦测与AppState启停
最后回头收拾黑洞一那个永不停的全局心跳。它不能直接删——价格新鲜度的”由 stale 升级到 frozen“判定需要时间流逝信号,socket 断了也得让单元格自然过渡到 frozen 灰态。所以是”改”不是”删”,三个动作:
动作一:降频。 80ms(12.5Hz)改成 250ms(4Hz)。stale 阈值通常 ≥ 1s,4Hz 完全够用,频率直接降到原来的三分之一。
动作二:按需启停。 setInterval 改成”有监听者才启、最后一个监听者注销就停”,并监听 AppState,进后台 / 锁屏主动停、回前台按当前监听者数决定是否重启:
import { AppState } from 'react-native' |
动作三:边沿侦测,中间帧不写原生属性。 这是降 mount transaction 的核心。原来的实现是把新鲜度三态接到 useAnimatedStyle 的 color,每次心跳写 SharedValue 就触发原生 View 的 color prop 更新 → Fabric mount transaction → 全树 clipping。但新鲜度其实只在阈值跨越那一帧需要切换颜色,中间帧完全不用动原生属性。改成内层 worklet 算离散态(0/1/2),外层只在离散态真正变化时通过一次 runOnJS(setState) 触发渲染:
import { useDerivedValue, runOnJS } from 'react-native-reanimated' |
颜色应用走 React state → style,不再走 useAnimatedStyle。改完 mount transaction 从 66/s 降到接近 1/s(只在新鲜度真切换时)。这一刀是杠杆最大的一刀,主线程占用直接释放二十多个百分点。
七、给 App 内置实时行情监控面板
优化要可观测才能验证。我在测试包里内置了一个”网络检测”面板,实时读 WebSocket 的入站帧率、字节率、断连原因分桶、保活状态——这些数字是判断”行情卡顿到底是渲染问题还是网络问题”的直接证据。采样器只在面板打开时跑:
import { useEffect, useState } from 'react' |
面板里还有一块是连接诊断,把 close 事件按原因分桶——这是把”行情老断”这种模糊抱怨拆成可定位根因的关键。判定一定要用”真实 open/close 计数”,而不是扫日志里的订阅字符串(每 60s 的周期重订也会打那条日志,会被误判成”每 60s 重连一次”):
export interface WsCloseReasons { |
把”连接成功次数””断开次数””重连尝试次数””断开原因分桶””距上次收到服务端消息多久”这些都实时摊在面板上,测试同学不用懂 WebSocket 协议,看一眼颜色就知道”现在是网络问题还是 App 问题”。配合上一篇讲的内存 + GC 面板,发热掉帧的现场证据就齐了。
八、量化验收优化到底有没有生效
老规矩,优化必须用同一台真机、同一套操作脚本、Release 包前后对比,不能凭感觉。这是那次行情页四刀下来的真实验收阈值:
| 指标 | 优化前 | 目标 | 关键字 / 来源 |
|---|---|---|---|
| 主线程 CPU % | 51% | ≤ 30% | Instruments 主线程占比 |
| JS 线程 CPU % | 36% | ≤ 25% | Instruments JS 线程占比 |
updateClippedSubviewsWithClipRect |
76s | ≤ 25s | Instruments 关键字 inclusive |
mountingTransaction |
92s / 5930 次 | ≤ 30s / 2000 次 | Instruments 关键字 inclusive |
reanimated+worklet |
207s / 46k 次 | ≤ 80s / 16k 次 | Instruments 关键字 inclusive |
Yoga roundLayoutResultsToPixelGrid |
1700 次(91/s) | ≤ 600 次(32/s) | Instruments 关键字 occur |
| Core Animation FPS(稳态期) | 0~60 周期震荡 | ≥ 55 持续 | Instruments FPS 表 |
| CPU usage mean %(静置 60s) | 100.5% | ≤ 30% | pymobiledevice3 |
| 入站帧率(静止首页) | 失控 | < 40/s | App 监控面板 |
| Thermal State | Nominal(但发烫) | Nominal 不退化 | 系统状态 |
把 mountingTransaction、worklet、clipping 这三个关键字的 inclusive 时间当作核心 KPI,每改一刀就重采一次 trace 核对它们有没有真的掉下来。四刀全部落地后,主线程和 JS 线程从双饱和回到 30% 以下,稳态 FPS 稳稳压住 55+,手机不再发烫,内存也回落到平稳波动——三个症状一起消失,正好印证了它们本就是同一个根因。
九、四刀的落地顺序与逐刀验证
四刀不是随便挑一刀做就行,落地是有顺序的,而且每一刀都要单独验证生效再做下一刀——这正是性能优化最容易翻车的地方:一口气全改完,最后分不清谁帮了忙、谁帮了倒忙。给一条可直接照搬的落地流程。
第 0 步 · 先让现场可观测。 别急着改代码,先把第七节那个监控面板接进测试包。没有”入站帧率 / GC 次数 / 断连原因”的实时读数,你根本不知道四刀里哪一刀对你的项目最值钱。装好面板,真机 Release 包停在行情页两分钟,记下基线:帧率多少、GC 每分钟多少次、主线程占用多少。
第 1 步 · 先开第一刀(按需订阅),因为它 ROI 最高且最安全。 把”全量订阅”改成”可视区 ± 缓冲订阅”,几乎不碰渲染逻辑,风险最低,却能把接收量和 parse 量直接砍一个数量级。改完盯监控面板:入站帧率应该从失控掉到 < 40/s。这一刀没生效,后面三刀都白搭。
第 2 步 · 再开第二刀(精准订阅),它和第一刀是乘法关系。 第一刀管”收多少帧”,第二刀管”每帧唤醒多少单元格”。重点就一句:把订阅源从大杂烩 store 切到 per-symbol,并且确认时间戳没混进比较字段。改完用 Instruments 录一段,看 mountingTransaction 的 occur 次数有没有断崖式下降——这是第二刀生效的硬证据。
第 3 步 · 第三刀(渲染稳定)配合第二刀一起验。 renderItem 引用稳定、主题快照、memo 自定义相等、关原生裁剪,这几个要点一起上,单独验意义不大。验收看滑动时的 JS FPS 和 updateClippedSubviewsWithClipRect 占主线程的比例。
第 4 步 · 最后开第四刀(心跳治理),收尾后台空转。 前三刀解决”前台干活时别浪费”,第四刀解决”后台和稳态别空转”。降频 + 按需启停 + 边沿侦测三个动作一起做,验收看 App 切后台后 CPU 是否真的归零、稳态期 mountingTransaction 是否降到接近 1/s。
第 5 步 · 整体复测 + 钉死阈值表。 四刀全落地后,用和第 0 步完全相同的操作脚本再测一次,对照下一节的阈值表逐项核对。把前后数据写进 PR,并把命令行采集的 diff 退出码接进 CI,让后人改回任何一刀都会被红灯挡住。
这套顺序的核心逻辑是按 ROI 和风险排序、逐刀留下可验证的证据。慢一点,但每一刀都站得住脚,不会出现”改了一堆、烫还是烫、却不知道问题在哪”的尴尬。
十、最佳实践清单
把四刀和踩过的坑浓缩成可执行规则:
- 只为可视区品种建立订阅,可视区 ± 缓冲,
debounce合并滚动回调,退订加冷却避免协议抖动。 - 单元格只做 per-symbol 精准订阅,用
useShallow浅比较,绝不把单调递增的时间戳放进比较字段。 renderItem引用稳定,布局模式作为 prop 进单元格内部条件渲染,别在外面切组件类型。- 主题传快照不传整对象,单元格
memo用”只比视觉字段”的自定义相等函数。 - 高频
mount transaction场景关掉原生裁剪 + 压扁视图层级,圆角和深嵌套是隐性成本放大器。 - 永不停的定时器要么降频、要么按需启停、要么边沿侦测,后台一定要停。
- 状态变化用边沿侦测,只在离散态真切换那一帧写原生属性,中间帧一律不动。
- 给
App内置可观测面板,把入站帧率、断连原因、GC实时摊开,让验证有据可依。
总结
高频行情渲染的发热、掉帧、内存暴涨从来不是三个独立问题,而是同一个”CPU 被持续无意义地烧”的三种表象。解题思路也只有一句:让 CPU 只在”真有价格变化、且用户真看得到”时才干活。 围绕这句话的四刀——按需订阅砍掉接收量、per-symbol 精准订阅砍掉无关重渲、FlashList 稳定性保住复用链、心跳边沿侦测杀掉后台空转——彼此叠加,缺一不可。
这套方法不止适用于行情,任何”高频数据流 + 长列表”的场景(弹幕、IM、实时监控大屏)都能照搬。核心方法论就两条:先用真机 Release trace 把热点关键字钉死,再用内置监控面板让每一刀的效果可观测、可量化、可回归。性能优化最怕自我感动,把它变成一张能进 CI 的阈值表,才算真正把”用户说手机烫”这件事闭环了。