做股票行情类 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'
import { useCallback, useRef } from 'react'
import { setVisibleSymbols, VISIBLE_BUFFER_RADIUS, VISIBLE_DEBOUNCE_MS } from '@/lib/ws'
function useVisibleSubscription(scopeKey: 'list' | 'modal', listRef: React.RefObject<Item[]>) {
const lastIndicesRef = useRef<{ first: number; last: number }>({ first: -1, last: -1 })
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 把"可见区 ± buffer"的品种算出来,提交给订阅层 reconcile
const flushVisible = useCallback((reason: string) => {
const list = listRef.current
const { first, last } = lastIndicesRef.current
if (first < 0 || last < 0 || !list.length) {
setVisibleSymbols(scopeKey, [], `${reason}:empty`)
return
}
const lo = Math.max(0, first - VISIBLE_BUFFER_RADIUS)
const hi = Math.min(list.length - 1, last + VISIBLE_BUFFER_RADIUS)
const symbols: string[] = []
for (let i = lo; i <= hi; i++) {
const s = list[i]?.symbol
if (s) symbols.push(s)
}
setVisibleSymbols(scopeKey, symbols, reason)
}, [scopeKey, listRef])
const onViewableItemsChanged = useCallback((info: { viewableItems: ViewToken<Item>[] }) => {
const viewable = info.viewableItems
if (!viewable.length) {
lastIndicesRef.current = { first: -1, last: -1 }
} else {
let first = Infinity, last = -Infinity
for (const v of viewable) {
if (v.index == null) continue
first = Math.min(first, v.index)
last = Math.max(last, v.index)
}
if (!isFinite(first) || !isFinite(last)) return
lastIndicesRef.current = { first, last }
}
// debounce:滚动中合并多次回调,停止 200ms 后再发 reconcile
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = null
flushVisible('viewable')
}, VISIBLE_DEBOUNCE_MS)
}, [flushVisible])
return { onViewableItemsChanged, flushVisible }
}
光"算出该订阅哪些"还不够,真正发订阅 / 退订协议时还有两个工程问题:滚动来回会产生大量 sub/unsub 协议噪音;切 tab 瞬间会有短暂"全退又全订"。所以订阅层再加一个 flush 调度器做批量合并——订阅立即发、退订冷却 5s 后才发,冷却期内同一品种又滚回来就取消退订:
这一刀直接把"同时活跃的订阅数"从几百压到十几二十个,JSON.parse 的量级和写状态的频率都跟着掉一个数量级。debounce 200ms + 冷却 5s 这两个常量是反复调出来的——太短了滚动有协议噪音,太长了切 tab 残留订阅浪费带宽。
# 四、第二刀per-symbol精准订阅切断心跳rerender
按需订阅解决了"接收多少帧",但还有个更隐蔽的问题:就算只订阅可视区,单元格订阅 store 的方式不对,照样会被无关更新带起重渲染。
最典型的坑是把整个业务 store 当订阅源(黑洞三)。解法是给行情元数据单独切一个 sub-store,让单元格只订阅"自己这个品种"的关键字段,用 zustand 的 useShallow 做浅比较:
这里有个全文最值钱的细节,我用注释专门标了:绝对不要把行情时间戳 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 传进单元格内部条件渲染,而不是在外面切换组件类型:
要点二:主题用快照而不是整对象。 主题对象引用一抖动,全表重 paint。所以在列表层把单元格真正用到的几个字段拍成一个 memo 快照传下去: