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;
- 关键任务必须有超时兜底;
- 异步请求必须有批次号;
- 后置任务不能继续同步抢主线程;
- 真机上至少看一次首帧、后台恢复和弱网表现。
真正有效的性能优化不是把代码写得更复杂,而是把任务优先级分清楚。首帧先稳住,非关键任务后置,旧请求不回写,页面启动体验就会稳很多。