HarmonyOS AppFreeze 冻屏定位实战:从卡死现场到 FaultLog 证据链

实际项目里,冻屏比普通报错更麻烦。用户看到的是“点了没有反应”,开发看到的可能只有一句“页面卡住了”。如果没有现场时间点、页面状态、主线程栈和复现路径,最后很容易变成猜测式修复:把耗时代码挪一挪,发版后同类问题又回来。

这篇文章只解决一个问题:HarmonyOS 应用出现 AppFreeze 时,怎样把“卡死现象”变成可以复盘的证据链。

请添加图片描述

1. 资料定位:先确认 AppFreeze 的边界

AppFreeze 不是“慢一点”,而是应用在指定时间内没有响应用户操作。排查时要先区分三类现象:页面首帧慢、列表滑动掉帧、界面完全无响应。前两类更偏性能优化,第三类才进入冻屏证据链。

资料/工具 用途 本文使用方式
Huawei AppFreeze 指南 理解应用冻结定义和排查方向 建立冻屏边界
FaultLog / 故障日志 获取异常现场和关键时间 对齐用户反馈时间
HiLog 串联业务事件 找到冻结前最后一个动作
DevEco Profiler 观察主线程与耗时调用 复测修复结果

版本边界建议写在排查单里:HarmonyOS NEXT、API 版本、DevEco Studio 版本、设备型号、应用版本号、构建类型。没有这些信息,后续很难判断问题是否和系统版本、设备性能或灰度包有关。

2. 排查流程:不要先改代码,先留现场

请添加图片描述

冻屏问题最怕“复现一次就丢”。更稳的做法是先保留现场,再开始分析。用户反馈里至少要拿到页面、操作步骤、发生时间、是否切后台、是否弱网、是否刚安装升级。

export interface FreezeScene {
  page: string
  action: string
  happenAt: number
  appVersion: string
  deviceType: string
  network: 'wifi' | 'cellular' | 'offline' | 'unknown'
}

export function buildFreezeScene(action: string, page: string): FreezeScene {
  return {
    page,
    action,
    happenAt: Date.now(),
    appVersion: '1.8.0',
    deviceType: 'phone',
    network: 'unknown'
  }
}

这段代码不负责定位根因,只负责把现场记录完整。它的输入来自页面事件,防止用户只说“卡了”却没有时间线;下一层会用 happenAt 去对齐 FaultLog 和业务日志。

3. 工程结构:把冻屏归因分给正确层

请添加图片描述

很多项目排查冻屏会卡在“到底谁负责”。页面只知道用户点了按钮,服务层只知道方法被调用,日志层只看到一个错误。建议把职责拆开:页面记录用户动作,任务层记录耗时段,日志层输出 traceId,复盘层汇总证据。

export interface FreezeTrace {
  traceId: string
  scene: FreezeScene
  beginAt: number
  lastStep: string
}

export class FreezeTraceStore {
  private current?: FreezeTrace

  start(scene: FreezeScene): string {
    const traceId = `${scene.page}-${scene.happenAt}`
    this.current = { traceId, scene, beginAt: Date.now(), lastStep: 'start' }
    return traceId
  }

  mark(step: string): void {
    if (this.current) {
      this.current.lastStep = step
    }
  }

  snapshot(): FreezeTrace | undefined {
    return this.current
  }
}

FreezeTraceStore 拥有的是“当前操作链路”,不是全局业务状态。它信任页面传入的场景,但不信任每一步都能完成,所以保留 lastStep。冻屏发生时,最后一步往往比完整调用链更有价值。

4. 页面入口:按钮事件里不要直接做重活

常见冻屏来源是按钮事件里同步做大任务:读取大文件、解析大 JSON、循环计算、等待网络结果、刷新大量状态。页面事件应该只做轻量校验和任务派发。

@Entry
@Component
struct ReportPage {
  private freezeStore: FreezeTraceStore = new FreezeTraceStore()

  build() {
    Column() {
      Button('生成月度报告')
        .onClick(() => {
          const scene = buildFreezeScene('tap_generate_report', 'ReportPage')
          const traceId = this.freezeStore.start(scene)
          this.freezeStore.mark('dispatch_report_task')
          ReportTaskRunner.enqueue(traceId, { month: '2026-07' })
        })
    }
  }
}

这里的边界很明确:页面只记录动作并派发任务,不解析报表、不读取文件、不等待结果。它防止主线程在点击回调里被长任务占住;下一层 ReportTaskRunner 再决定后台化、分片或失败兜底。

5. 耗时任务:用分片思路拆掉长循环

如果业务必须处理大量数据,不要把完整循环压在一次同步调用里。可以按页处理,每次处理后让出执行机会,并记录当前进度。这样即使用户反馈卡顿,也能看到卡在哪个分片。

export interface ReportJob {
  traceId: string
  month: string
  pageNo: number
  pageSize: number
}

export class ReportTaskRunner {
  static enqueue(traceId: string, input: { month: string }): void {
    const job: ReportJob = { traceId, month: input.month, pageNo: 1, pageSize: 200 }
    this.runPage(job)
  }

  private static runPage(job: ReportJob): void {
    setTimeout(() => {
      const rows = ReportRepository.loadRows(job.month, job.pageNo, job.pageSize)
      ReportCalculator.append(job.traceId, rows)
      if (rows.length === job.pageSize) {
        this.runPage({ ...job, pageNo: job.pageNo + 1 })
      }
    }, 0)
  }
}

这段代码的重点不是 setTimeout 本身,而是“把一个不可中断的大任务变成可观察的小任务”。输入是报表月份和 traceId;它预防长循环占用主线程;下一层需要继续补超时保护和失败回滚。

6. 日志标记:冻结前最后一步要能查到

冻屏日志不要只打“开始”和“结束”。真正有价值的是阶段日志:参数校验、缓存读取、网络请求、数据计算、UI 更新。冻结时没有“结束日志”很正常,但最后一个阶段能告诉你卡在哪。

import { hilog } from '@kit.PerformanceAnalysisKit'

const DOMAIN = 0x1801
const TAG = 'FreezeTrace'

export class FreezeLogger {
  static step(traceId: string, step: string, costMs?: number): void {
    hilog.info(DOMAIN, TAG, 'trace=%{public}s step=%{public}s cost=%{public}d',
      traceId, step, costMs ?? -1)
  }

  static blocked(traceId: string, reason: string): void {
    hilog.error(DOMAIN, TAG, 'trace=%{public}s blocked=%{public}s', traceId, reason)
  }
}

日志字段要少而稳定。traceId 用于串联,step 用于定位阶段,costMs 用于观察耗时。敏感数据不要写入日志;如果业务参数必须出现,也只写枚举值或脱敏后的摘要。

7. 超时保护:让“可能卡住”的调用提前暴露

有些冻屏不是 CPU 大循环,而是同步等待某个资源:锁、文件、网络状态、跨模块回调。对这类调用要设置超时,并把超时记录成可查询事件。

export async function withFreezeTimeout<T>(
  traceId: string,
  step: string,
  task: () => Promise<T>,
  timeoutMs: number
): Promise<T> {
  let timer = 0
  const timeout = new Promise<never>((_, reject) => {
    timer = setTimeout(() => {
      FreezeLogger.blocked(traceId, `${step}_timeout_${timeoutMs}`)
      reject(new Error(`step timeout: ${step}`))
    }, timeoutMs)
  })
  try {
    return await Promise.race([task(), timeout])
  } finally {
    clearTimeout(timer)
  }
}

这个封装拥有异步步骤的超时边界。它信任调用方提供的 step,但不信任任务一定返回。它能防止问题沉默卡住,也能把“冻结前的异常等待”转成日志证据。

8. 复测方式:用同一套动作证明问题收敛

修复冻屏不能只靠开发机手点一次。建议固定脚本动作:打开页面、触发任务、切后台、返回前台、重复点击、弱网再触发。每轮都记录 traceId、耗时、是否出现无响应。

快速连点是冻屏复测里很容易漏的一项。按钮触发长任务时,如果没有入口防抖,用户连续点击会同时创建多个任务,最终表现可能不是一次卡死,而是队列堆积后的连续无响应。

export class ActionGate {
  private runningActions: Set<string> = new Set()

  runOnce(actionKey: string, task: () => void): void {
    if (this.runningActions.has(actionKey)) {
      FreezeLogger.blocked(actionKey, 'duplicate_action_ignored')
      return
    }
    this.runningActions.add(actionKey)
    try {
      task()
    } finally {
      this.runningActions.delete(actionKey)
    }
  }
}

ActionGate 负责入口并发边界。它不关心任务内部耗时,只保证同一个动作不会同时进入多次。它能减少重复点击带来的任务堆积;下一层仍然要处理超时和失败回滚。

复测项 操作 期望结果
高数据量 构造 5000 条报表数据 UI 可继续响应
快速连点 连续点击生成按钮 只进入一个任务
切后台 任务中切后台再回来 进度可恢复或明确失败
弱网 网络慢时触发同步 超时提示而非卡死
export interface FreezeAcceptance {
  traceId: string
  maxStepCostMs: number
  userCanClickBack: boolean
  timeoutVisible: boolean
}

export function assertNoFreeze(result: FreezeAcceptance): void {
  if (!result.userCanClickBack) throw new Error(`页面返回不可用:${result.traceId}`)
  if (result.maxStepCostMs > 1500 && !result.timeoutVisible) {
    throw new Error(`耗时过长但没有超时提示:${result.traceId}`)
  }
}

验收代码用于把体验问题变成条件判断。它不替代真机体验,但能逼迫团队写清楚“什么情况下算修复完成”。

9. 常见误判:冻屏不等于所有性能问题

现象 可能原因 先看哪里 修复方向
首次打开慢 初始化过重 启动耗时记录 懒加载
滑动掉帧 列表复用差 渲染帧耗时 LazyForEach 与缓存
点击无响应 主线程阻塞 FaultLog 和阶段日志 分片与异步化
偶现卡死 锁或等待 最后一步日志 超时与降级
发版后增多 版本差异 崩溃/冻结账本 回滚或热修复

排查时不要把所有“慢”都归成 AppFreeze。先确认是否真的无响应,再看主线程和日志证据。边界越清楚,修复越不容易跑偏。

10. 上线前验收清单

验收项 必须满足的结果
现场字段完整 页面、动作、时间、版本、设备都有
主线程重活移除 点击回调里不做大循环和同步 I/O
阶段日志可串联 traceId 能查到最后一步
超时保护存在 等待型调用不会无限挂起
同场景复测通过 高数据量、弱网、切后台都不再卡死

最终要交付的不是一句“已优化”,而是一条证据链:用户动作发生在哪里,系统记录了什么,主线程卡在何处,代码如何拆分,复测结果如何证明收敛。

冻屏现场复现场景:给读者一组可执行核验

AppFreeze 定位要把用户操作、最后一步日志和主线程栈对齐。只说页面卡死,无法判断是 I/O、锁等待还是长循环。

核验维度 读者需要准备的证据
输入 页面入口、用户动作、关键参数
过程 日志、状态变化、异常分支
输出 UI 表现、回调结果、持久化结果
回归 同场景重复执行后的结果
interface FreezeReplayCase {
  traceId: any
  page: any
  lastStep: any
  mainThreadBlocked: any
}

const replay91: FreezeReplayCase = {
  traceId: 'sample',
  page: 'sample',
  lastStep: 'sample',
  mainThreadBlocked: 'sample',
}

function assertReplay91(item: FreezeReplayCase): void {
  if (item.mainThreadBlocked && item.lastStep.length === 0) throw new Error('主线程阻塞但缺少最后步骤')
}

这组核验把冻屏现场和最后一步日志关联起来,便于把用户反馈转成可定位的主线程问题。

11. 小结:冻屏修复要从证据开始

AppFreeze 排查的关键不是立刻重构,而是先把现场留住。页面记录动作,日志串联阶段,FaultLog 对齐系统现场,任务层拆分耗时路径,最后用同一场景复测。只要这条链路建立起来,冻屏问题就能从“偶现玄学”变成可定位、可修复、可复盘的工程问题。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐