一个天气应用,桌面卡片点击要“秒开”。你按文档配置了预加载,launchType 和阶段都写了,冷启动时间确实从 1.2 秒降到了 400 毫秒左右。然后测试同学报了一个问题:从卡片点进来,如果卡片指定的城市不是上次打开的城市,页面显示的还是旧城市的数据。杀掉应用重进又正常。

你查 onCreate,want.parameters 里明明带着 city,但那段处理逻辑只写在了一个分支里。预加载命中时,onCreate 执行的上下文和正常冷启动不一样;预加载缓存被回收时,实例可能已经存在但数据没刷新;应用已经在后台时,再次点击卡片走的是 onNewWant 而不是 onCreate。预加载优化的是“进程和组件的就绪时间”,它不负责让你的业务入口逻辑在三条不同的启动路径上都正确执行。启动变快了,路径变多了,没对齐的那部分就会以“偶发旧数据”的形式露出来。

预加载只到指定阶段,之后的事它不管

HarmonyOS 的预加载机制从 API 20 开始提供,运行方式很克制:系统根据用户使用习惯,在资源充足时把应用提前加载到某个阶段,实际是否预加载、什么时候预加载,由系统决定,开发者无法干预。当前支持三个阶段:processCreated(进程创建,不触发任何生命周期回调)、abilityStageCreated(触发 AbilityStage.onCreate)、windowStageCreated(进一步触发入口 UIAbility.onCreate 和 onWindowStageCreate)。

预加载期间不显示任何界面,所以官方有一条硬约束:预加载的任何阶段都不应包含界面显示、界面交互或依赖用户可见的操作。这意味着 windowStageCreated 阶段虽然窗口对象已经创建,但你不能假设用户“看得到”任何东西,也不能在预加载路径上做弹窗、动画、权限请求这类动作。

更关键的是,预加载到 windowStageCreated 不等于“用户点击时所有状态都是新的”。系统可能把你预加载到 windowStageCreated,然后进程在后台存活了一段时间,内存压力下被回收了一部分缓存,或者用户点击卡片时传入的 Want 和预加载时预期的参数不一样。预加载提供的是一个更靠前的起点,它不承诺终点处的数据是热的。

PRELOAD 分支和正常启动分支,收口到同一个入口

LaunchReason.PRELOAD 从 API 20 起提供,用来识别当前 UIAbility 是由预加载机制启动的。很多人的处理方式是:在 onCreate 里判断 launchReason,如果是 PRELOAD,就只做最小初始化然后 return;正常启动时再走完整的业务逻辑。

这个结构本身没有错,错在把“预加载阶段不该做的事”和“正式启动阶段该做的事”割裂成了两套不共享的代码。预加载到 windowStageCreated 时,onCreate 已经执行过一次,当时 want 可能还是空的或者只有占位参数。用户点击卡片时,如果实例已经存在,系统会再次拉起这个实例并传入真正的 Want,走的是 onNewWant 而不是重新执行 onCreate。你的 PRELOAD 分支如果只在 onCreate 里处理参数,onNewWant 那条路径上就漏掉了城市参数的更新。

正确的收口方式是把参数处理和业务初始化抽成一个可重复调用的方法,onCreate 和 onNewWant 都调用它,并且在方法内部区分“预加载阶段”和“正式可见阶段”。

typescript

import { UIAbility, Want, AbilityConstant } from '@kit.AbilityKit';

export default class WeatherAbility extends UIAbility {
  private initialized: boolean = false;

  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.handleEntry(want, launchParam.launchReason);
  }

  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 实例已存在,新的 Want 从这里进来,不能只靠 onCreate
    this.handleEntry(want, launchParam.launchReason);
  }

  private handleEntry(want: Want, reason: AbilityConstant.LaunchReason): void {
    const city = want.parameters?.['city'] as string | undefined;

    if (reason === AbilityConstant.LaunchReason.PRELOAD) {
      // 预加载阶段:只做可重建的初始化,不碰 UI 可见操作
      this.prepareBaseResources();
      return;
    }

    // 正式启动或 onNewWant:参数校验放在这里,每次入口都执行
    if (city) {
      this.refreshForCity(city);
    }
    this.enterForeground();
  }
}

onNewWant 里也调 handleEntry,reason 不是 PRELOAD,city 有值就刷新。预加载命中的实例被点击拉起时,这条路径能正确接住卡片传入的城市;预加载缓存被回收、实例重建时,onCreate 走正常分支,同样能接住。不要假设用户点击卡片时一定再次触发 onCreate,这是预加载场景下最容易漏掉的一条。

预加载阶段把业务状态写死,正式打开时没有机会纠正

另一个变体是在预加载阶段就把“当前城市”固化下来。abilityStageCreated 阶段 AbilityStage.onCreate 被触发,有人在这里读一次 AppStorage 里的默认城市,然后基于这个城市预取天气数据。用户点击卡片时指定的城市是另一个,但预加载阶段的数据已经写进了缓存,正式打开时如果没有一个显式的“参数校验 + 缓存失效”步骤,页面就会展示预加载阶段那个默认城市的结果。

预加载数据必须带可校验的身份和版本,命中之后先确认“这份数据是不是当前入口需要的”。官方建议的预加载数据要检查城市是否匹配、是否过期,缓存不存在时先展示轻量首页或已有数据,再异步补齐。预加载只负责“提前准备可能用到的东西”,它不负责“替用户点击那一刻的入口参数做决定”。

onMemoryLevel 里清缓存,不要在回调里销毁 UI

预加载占用的内存不是免费的。系统在内存压力下会回收预加载的进程,应用侧也应当在 AbilityStage.onMemoryLevel 里主动释放非必要缓存。但这里有一条明确的边界:不要在这个回调里直接销毁 UI 组件,它可能阻塞当前进程的主线程。

onMemoryLevel 里该做的是:暂停非必要的预取、丢弃可重建的缓存数据、让仍在进行中的异步预取任务在完成时不再把结果填回去。最后这一条经常被漏掉。Map.clear() 只是移除了 Map 对对象的引用,不等于关联资源立刻归还;如果已经有一个异步任务在跑,它完成之后把数据又写进缓存,等于你刚清完又被填满。

typescript

onMemoryLevel(level: AbilityConstant.MemoryLevel): void {
  if (level >= AbilityConstant.MemoryLevel.MEMORY_LEVEL_LOW) {
    // 暂停新的预取
    this.prefetchEnabled = false;
    // 标记代次,让在途预取结果失效
    this.prefetchGeneration++;
    // 清可重建缓存,但不碰 UI 组件
    this.weatherCache.clear();
  }
}

prefetchGeneration 是一个普通数字,异步预取任务完成时先比对代次,不匹配就直接丢弃结果,不再写入缓存。这和页面异步任务用代次隔离是同一个思路:清理动作发出了,在途的写入必须有办法失效。

预加载不能替代降级路径,三条路都要能走

官方对预加载的定位有一句很诚实的话:预加载只能优化命中时的启动,不能承诺每次点击都“瞬间打开”;卡片入口首先要兼容未预加载、缓存被收回、已有实例再次收到 Want 这三条路径。

这意味着你的首屏必须保留未命中缓存的正常加载路径。预加载命中时,进程和组件已经就绪,直接渲染;预加载未命中或缓存被回收时,走完整冷启动,该加载的加载,该转圈的转圈;实例已存在时,走 onNewWant 更新参数。三条路径共享同一套业务入口逻辑,每一条都能独立完成“拿到正确的城市、展示正确的数据”。不要让“能否正常打开应用”建立在“系统一定预加载、一定保留进程”的假设上。

排查顺序:先看入口参数从哪条路径来,再看预加载阶段写了什么

下次预加载场景下出现“数据不对”或“首屏没有变快”的时候,按这个顺序走。

第一步,在 onCreate 和 onNewWant 里各打一行带 launchReason 和 want.parameters 的日志,连续测试三条路径:全新安装后首次点击卡片、预加载命中后点击卡片、应用已在后台时点击卡片。看每一次参数从哪个入口进来,city 有没有被正确读取。

第二步,确认 PRELOAD 分支里有没有执行任何“写业务状态”的操作。预加载阶段只应该准备可重建的资源,参数的最终校验和状态刷新必须放在正式启动或 onNewWant 的路径上。

第三步,如果内存压力下行为异常,检查 onMemoryLevel 里有没有清缓存、有没有让在途预取失效,以及有没有在里面误操作 UI 组件。

第四步,确认首屏在“完全未预加载”的情况下能正常加载。把预加载配置临时关掉跑一遍,如果这条路径本身就不完整,预加载只是在一个不稳固的基础上做加速。

预加载把进程和组件的就绪时间提前了,但它同时把启动路径从一个变成了三个,把业务状态从“一次初始化”变成了“可能在多个时刻被写入”。PRELOAD 分支只做预热该做的事,参数校验和状态刷新在每一个正式入口上都执行,缓存带身份和版本,内存压力下在途任务能失效。下一次从卡片点进来看到旧城市的时候,先别怀疑预加载没生效——打开 onNewWant 看一眼,那条路径上到底有没有人在处理卡片传来的参数。

Logo

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

更多推荐