HarmonyOS 7 API 26 冷启动首帧治理流程图

HarmonyOS 7 / API 26 做性能优化时,冷启动不是只看页面最后能不能打开。真正影响体验的是首帧什么时候出来、首帧出来以后能不能马上点、后台恢复时会不会被旧任务拖住。

我更愿意把冷启动问题拆成三段看:第一段是页面骨架能不能先出来;第二段是首屏必须数据有没有阻塞;第三段是非关键任务有没有抢主线程和生命周期。这样排查比一句“启动慢”更具体,也更容易落到代码上。

先把问题说具体

一个页面启动慢,通常不是单点故障,而是几类动作挤在一起:

  • 页面创建时同步读配置、读数据库、预热图片;
  • 首屏还没出来就开始做曝光统计、推荐计算、缓存清理;
  • 后台恢复时旧请求结果回来,把新状态覆盖掉;
  • 异步任务没有批次号,页面销毁后还在回写 UI;
  • 异常兜底只处理了网络失败,没有处理慢任务和超时。

如果只靠肉眼看“页面能打开”,这些问题很容易漏掉。更稳的做法是给启动链路设规则:哪些任务必须在首帧前完成,哪些任务必须首帧后执行,哪些任务回写前必须确认页面还有效。

案例一:首帧前塞了太多同步任务

下面这种写法很常见,代码看着整齐,但首帧压力很大:

aboutToAppear() {
  this.loadLocalConfig()
  this.queryHomeList()
  this.preloadCoverImages()
  this.reportPageExposure()
}

问题在于这四个动作的重要程度不一样。配置和首屏数据可能是关键任务,图片预热和曝光统计明显不应该抢首帧时间。它们放在一起执行,最后用户看到的就是白屏时间变长。

我会先改成任务分层:

type LaunchTask = {
  name: string
  critical: boolean
  timeoutMs: number
  run: () => Promise<void>
}

class FirstFrameScheduler {
  async run(tasks: LaunchTask[]) {
    const criticalTasks = tasks.filter(task => task.critical)
    const deferredTasks = tasks.filter(task => !task.critical)

    await Promise.all(criticalTasks.map(task => this.runWithTimeout(task)))

    setTimeout(() => {
      deferredTasks.forEach(task => {
        this.runWithTimeout(task).catch(err => {
          console.error(`[launch] ${task.name} failed`, err)
        })
      })
    }, 0)
  }

  private async runWithTimeout(task: LaunchTask) {
    let timer = 0
    const timeout = new Promise<never>((_, reject) => {
      timer = setTimeout(() => reject(new Error(`${task.name} timeout`)), task.timeoutMs)
    })

    try {
      await Promise.race([task.run(), timeout])
    } finally {
      clearTimeout(timer)
    }
  }
}

页面里接入时就很清楚:

aboutToAppear() {
  this.scheduler.run([
    {
      name: 'load-shell-data',
      critical: true,
      timeoutMs: 120,
      run: () => this.loadShellData(),
    },
    {
      name: 'preload-cover-images',
      critical: false,
      timeoutMs: 800,
      run: () => this.preloadCoverImages(),
    },
    {
      name: 'report-exposure',
      critical: false,
      timeoutMs: 500,
      run: () => this.reportExposure(),
    },
  ])
}

这段代码解决的不是“写法好看”问题,而是职责边界问题。首帧前只保留必须任务,非关键任务后置,并且每个任务都有超时兜底。

案例二:后台恢复后旧请求覆盖新状态

冷启动之外,后台恢复也容易出现卡顿和状态错乱。比如页面第一次进入时发了一个请求,用户切后台后又回来,页面重新拉了一次数据。如果旧请求最后才返回,就可能把新数据覆盖掉。

可以用批次号挡住旧结果:

class RequestBatchGuard {
  private currentBatch = 0

  next(): number {
    this.currentBatch += 1
    return this.currentBatch
  }

  valid(batch: number): boolean {
    return batch === this.currentBatch
  }
}

页面请求这样写:

async reloadAfterResume() {
  const batch = this.guard.next()
  const result = await this.repository.loadHomeData()

  if (!this.guard.valid(batch)) {
    return
  }

  this.homeData = result
  this.renderState = 'ready'
}

这个封装很小,但效果直接:旧请求回来以后不能再改页面,新请求结果才有资格更新 UI。对列表页、首页、搜索页、后台恢复页都适用。

用脚本先扫一遍启动链路

下面这个脚本可以放在本地跑,用来检查启动任务是否分层合理。它不替代真机性能测试,但能提前拦住明显风险。

const tasks = [
  { name: 'load-shell-data', phase: 'critical', costMs: 45, sync: false, canDefer: false },
  { name: 'query-rdb-home-list', phase: 'critical', costMs: 128, sync: false, canDefer: false },
  { name: 'preload-large-images', phase: 'deferred', costMs: 210, sync: false, canDefer: true },
  { name: 'report-exposure', phase: 'deferred', costMs: 38, sync: true, canDefer: true },
  { name: 'cleanup-cache', phase: 'deferred', costMs: 180, sync: true, canDefer: true },
]

function inspectLaunch(tasks) {
  return tasks.map(task => {
    const problems = []

    if (task.phase === 'critical' && task.costMs > 100) {
      problems.push('首帧关键任务耗时偏高,需要拆分、缓存或后置')
    }

    if (task.phase === 'deferred' && task.sync) {
      problems.push('后置任务仍然是同步任务,可能抢主线程')
    }

    if (task.phase === 'deferred' && !task.canDefer) {
      problems.push('任务标成后置,但业务上不能延后,需要重新分类')
    }

    return {
      name: task.name,
      passed: problems.length === 0,
      problems,
    }
  })
}

const result = inspectLaunch(tasks)
console.log(JSON.stringify({
  total: result.length,
  failed: result.filter(item => !item.passed).length,
  result,
}, null, 2))

这段脚本会发现三个风险:

{
  "total": 5,
  "failed": 3
}

`query-rdb-home-list` 作为首帧关键任务耗时偏高,应该缓存或拆小;`report-exposure` 和 `cleanup-cache` 虽然后置了,但还是同步任务,容易在首帧后马上造成卡顿。

三种处理方式怎么选

方案 适合场景 好处 风险
全部等完再渲染 强一致后台页、表单提交页 状态完整 首帧慢,体感差
先出骨架再补数据 内容页、首页、列表页 用户等待感低 要处理骨架、失败和旧请求
缓存首屏 + 后台刷新 高频访问页、弱网场景 体感最好 要处理缓存过期和一致性

我更倾向第三种,但前提是缓存策略要清楚。缓存不是为了偷懒,而是为了让用户先看到可用内容,再用后台刷新补齐最新状态。

发布前我会验哪些点

检查项 合格标准
首帧任务 只保留必须数据和页面骨架
非关键任务 图片预热、曝光统计、缓存清理全部后置
请求回写 每次请求带批次号,旧结果不能覆盖新状态
超时兜底 关键任务有超时,不让页面无限等
后台恢复 恢复后重新拉数据,但先取消旧批次
真机检查 看首帧、后台恢复、弱网和异常态

后面怎么避免

我会把冷启动治理当成页面开发的固定检查项,而不是最后压测时才补救:

  • 新页面先列启动任务清单;
  • 给每个任务标记 critical 或 deferred;
  • 关键任务必须有超时兜底;
  • 异步请求必须有批次号;
  • 后置任务不能继续同步抢主线程;
  • 真机上至少看一次首帧、后台恢复和弱网表现。

真正有效的性能优化不是把代码写得更复杂,而是把任务优先级分清楚。首帧先稳住,非关键任务后置,旧请求不回写,页面启动体验就会稳很多。

Logo

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

更多推荐