HarmonyOS 7 / API 26 AppFreeze 排查实战:主线程卡住、日志取证和降级恢复一次讲清

AppFreeze 不是“偶发卡一下”这么简单。对用户来说,它表现为点击没反应、页面停住、返回键延迟、列表突然不动;对开发来说,它往往不是某一行代码报错,而是主线程被长任务、同步 IO、过重布局或连续状态刷新堵住了。
HarmonyOS 7 / API 26 做应用稳定性优化时,我会把 AppFreeze 当成一个可复现、可取证、可回归的问题来处理。不要只看一次日志,也不要只把某个循环改短一点就结束。更稳的做法是:先复现,再定位阻塞点,然后把任务切开,最后补上降级和验收。
先判断是不是主线程被堵住
很多卡顿看起来像网络慢,其实是主线程在忙。判断时先看三个信号:
| 信号 | 表现 | 优先怀疑 |
| 点击后按钮没有按压反馈 | UI 事件来不及处理 | 主线程长任务 |
| 列表滑动突然停住 | 布局、图片解码或同步计算太重 | UI 渲染链路 |
| 数据已经返回但页面迟迟不变 | 状态刷新太密或批量 diff 太重 | 状态更新策略 |
| 日志集中在某段循环前后 | 中间没有输出 | 同步循环或同步 IO |
我一般不会一上来就改代码,而是先加一组轻量 trace,把“卡住前后发生了什么”记录清楚。
type FreezeTrace = {
name: string
startedAt: number
endedAt?: number
costMs?: number
}
class FreezeTraceRecorder {
private traces: FreezeTrace[] = []
start(name: string): FreezeTrace {
const trace: FreezeTrace = { name, startedAt: Date.now() }
this.traces.push(trace)
return trace
}
end(trace: FreezeTrace): void {
trace.endedAt = Date.now()
trace.costMs = trace.endedAt - trace.startedAt
console.info('[freeze-trace]', trace.name, trace.costMs + 'ms')
}
dumpSlow(thresholdMs: number): FreezeTrace[] {
return this.traces.filter(item => (item.costMs || 0) >= thresholdMs)
}
}
这段代码不是为了替代系统日志,而是为了把业务链路切清楚:哪段任务开始了、哪段任务结束了、中间花了多少时间。
案例一:同步处理大数组,页面直接卡住
先看一个很常见的写法。接口一次返回几千条数据,页面为了方便,直接同步清洗、分组、排序,然后再刷新 UI。
function buildDisplayList(raw: FoodItem[]): DisplayItem[] {
return raw
.filter(item => item.enabled)
.map(item => ({
id: item.id,
title: item.title.trim(),
score: item.favoriteCount * 2 + item.recentViewCount
}))
.sort((a, b) => b.score - a.score)
}
数据少的时候没问题,数据一多就容易把主线程堵住。更稳的方式是把任务切成小片,让 UI 有机会喘口气。
async function buildDisplayListInChunks(raw: FoodItem[], chunkSize: number): Promise<DisplayItem[]> {
const result: DisplayItem[] = []
for (let start = 0; start < raw.length; start += chunkSize) {
const part = raw.slice(start, start + chunkSize)
for (const item of part) {
if (!item.enabled) {
continue
}
result.push({
id: item.id,
title: item.title.trim(),
score: item.favoriteCount * 2 + item.recentViewCount
})
}
await new Promise(resolve => setTimeout(resolve, 0))
}
return result.sort((a, b) => b.score - a.score)
}
这里的关键不是 setTimeout 本身,而是不要让一个大循环长时间占住 UI 线程。实际项目里可以进一步把排序、分组、索引生成移到后台任务或缓存层,但第一步必须先避免首屏同步大计算。
案例二:状态刷新太密,列表一直在重建
第二类 AppFreeze 经常出现在状态更新里。比如每处理一条数据就更新一次页面:
async function loadListBad() {
const rows = await requestRows()
for (const row of rows) {
this.items.push(convertRow(row))
}
}
这种写法的问题是刷新太碎。数据越多,UI 更新越密,列表越容易反复重建。更好的做法是先在局部变量里完成转换,再一次性替换状态。
async function loadListBetter() {
const rows = await requestRows()
const nextItems: DisplayItem[] = []
for (const row of rows) {
nextItems.push(convertRow(row))
}
this.items = nextItems
}
如果数据非常大,还可以把状态分成“首屏数据”和“完整数据”两段:
async function loadListWithFirstScreen() {
const rows = await requestRows()
const firstScreen = rows.slice(0, 30).map(convertRow)
this.items = firstScreen
const rest = await buildDisplayListInChunks(rows.slice(30), 100)
this.items = firstScreen.concat(rest)
}
这样用户先看到能操作的首屏,后面的数据再补齐。对体验来说,能先滑动、能先点击,比一次性等全部处理完更重要。
本地复现脚本:把卡顿压出来
为了确认优化是不是有效,我会先用一个可控脚本制造压力。
type FoodItem = {
id: string
title: string
enabled: boolean
favoriteCount: number
recentViewCount: number
}
type DisplayItem = {
id: string
title: string
score: number
}
function mockRows(count: number): FoodItem[] {
const rows: FoodItem[] = []
for (let i = 0; i < count; i++) {
rows.push({
id: 'item-' + i,
title: ' 菜谱 ' + i + ' ',
enabled: i % 5 !== 0,
favoriteCount: i % 17,
recentViewCount: i % 23
})
}
return rows
}
async function verifyFreezeOptimization() {
const rows = mockRows(8000)
const recorder = new FreezeTraceRecorder()
const syncTrace = recorder.start('sync-build')
buildDisplayList(rows)
recorder.end(syncTrace)
const chunkTrace = recorder.start('chunk-build')
await buildDisplayListInChunks(rows, 200)
recorder.end(chunkTrace)
console.info('slow traces', recorder.dumpSlow(80))
}
我希望看到的不是“chunk 总耗时一定更短”,而是它不会长时间霸占 UI。同步版本可能一次卡住很久;切片版本总耗时可能差不多,但页面中间有机会响应输入。
降级恢复不能漏
AppFreeze 排查只改性能还不够。还要考虑:如果数据量突然暴涨,或者某个处理任务超时,页面应该怎么恢复。我的做法是给重任务加一个保护层。
async function safeBuildList(raw: FoodItem[]): Promise<DisplayItem[]> {
const start = Date.now()
const fallback = raw.slice(0, 30).filter(item => item.enabled).map(item => ({
id: item.id,
title: item.title.trim(),
score: 0
}))
const result = await Promise.race([
buildDisplayListInChunks(raw, 200),
new Promise<DisplayItem[]>(resolve => {
setTimeout(() => resolve(fallback), 1200)
})
])
console.info('[freeze-guard] build list cost', Date.now() - start, 'ms')
return result
}
这段保护层有一个明确目标:数据处理不能无限占住页面。超过 1200ms 就先给首屏兜底数据,后续可以提示“内容仍在加载”。这样用户不会被一个后台处理任务卡死。
API 26 回归检查
我会把回归标准写得非常明确:
| 检查项 | 通过标准 |
| 8000 条数据同步转换 | 能复现明显耗时 |
| 切片转换 | UI 中间可响应,不出现长时间无反馈 |
| 状态更新 | 不按单条数据反复刷新 |
| 超时保护 | 1200ms 后有兜底结果 |
| 日志取证 | 能输出任务名、耗时、是否超阈值 |
如果这些检查不过,就不要说 AppFreeze 已经优化好了。真正的闭环是:能复现、能定位、能解释、能回归。
日志取证要分三层看
只看一条慢日志,很容易误判。我的习惯是分三层:
| 层级 | 看什么 | 目的 |
| 入口日志 | 页面进入、接口返回、列表开始构建 | 确认卡顿发生在哪段流程 |
| 任务日志 | 每个重任务的开始、结束、耗时 | 找出真正超过阈值的任务 |
| UI 日志 | 首屏是否可见、列表是否可滑动、点击是否响应 | 判断用户是否真的被卡住 |
这三层缺一层都不够。只有入口日志,知道不了循环里发生了什么;只有任务日志,判断不了用户是否能操作;只有 UI 日志,又不知道背后是谁慢。
可以把日志统一成一个格式:
type FreezeLogLevel = 'entry' | 'task' | 'ui'
type FreezeLog = {
level: FreezeLogLevel
name: string
costMs?: number
blocked: boolean
detail: string
}
function printFreezeLog(log: FreezeLog): void {
console.info(
'[app-freeze]',
log.level,
log.name,
'blocked=' + log.blocked,
'cost=' + (log.costMs ?? 0),
log.detail
)
}
然后在关键位置落日志:
printFreezeLog({
level: 'entry',
name: 'pageAppear',
blocked: false,
detail: 'page appeared'
})
printFreezeLog({
level: 'task',
name: 'buildDisplayList',
costMs: 1480,
blocked: true,
detail: 'exceeded 800ms threshold'
})
printFreezeLog({
level: 'ui',
name: 'firstScreen',
blocked: false,
detail: 'shell visible, list can scroll'
})
这样一看就知道:页面能显示,但列表构建超过阈值。修复方向就不是乱改 UI,而是处理列表构建。
任务分级以后,代码评审会更直接
我会把可能引起 AppFreeze 的任务分成四类:
| 类型 | 示例 | 处理策略 |
| 必须同步 | 创建页面外壳、默认空状态 | 保持很小,不做 IO |
| 可以异步 | 拉配置、查缓存、请求列表 | 首帧后执行 |
| 可以切片 | 大数组转换、排序、分组 | 分批处理,中间让出执行权 |
| 应该后移 | 预加载、上报、推荐计算 | 不参与首屏 |
如果评审里看到“必须同步”任务里出现请求、排序、图片处理、大文件读取,就应该直接打回。不是因为这些代码一定错,而是它们不应该出现在首帧关键路径里。
一个比较稳的入口可以写成这样:
async function startPageSafely(): Promise<void> {
renderShell()
void loadBasicData()
void loadRecommendLater()
void reportOpenEventLater()
}
async function loadBasicData(): Promise<void> {
const list = await safeBuildList(await requestRows())
renderList(list)
}
async function loadRecommendLater(): Promise<void> {
setTimeout(async () => {
const recommend = await requestRecommend().catch(() => [])
renderRecommend(recommend)
}, 300)
}
function reportOpenEventLater(): void {
setTimeout(() => {
reportEvent('page_open').catch(() => {})
}, 500)
}
这段代码的取舍很明确:页面外壳先出来,基础数据随后补,推荐和上报都后移。用户打开页面时,应用先保证“能看、能点、能滑”,再处理增强功能。
后续怎么避免
我会把下面几条作为代码评审规则:
- 首屏前不要做大数组同步处理;
- 循环里不要逐条改页面状态;
- 重任务必须有 trace;
- 超过 800ms 的任务必须说明是否能后移;
- 超过 1200ms 的启动后任务必须有兜底;
- 列表数据要优先保证首屏可操作,再补完整内容。
这些规则看起来简单,但能挡住很多后期问题。AppFreeze 很少是一次大改造成的,更多是每天加一点同步逻辑、加一点状态刷新,最后一起把页面拖死。
小结
HarmonyOS 7 / API 26 做 AppFreeze 排查,我不会只盯着某一次卡顿日志看。更可靠的做法是把问题拆成主线程阻塞、状态刷新、任务切片、超时兜底和回归验收几段。每段都有代码、有阈值、有日志,后面再出现卡顿时,团队知道从哪里查,也知道改完以后怎么证明真的好了。
更多推荐

所有评论(0)