做 HarmonyOS 原生应用开发,绕不开的第一个门槛就是 Stage 模型。我在把老的 FA(Feature Ability)工程往 API 12 迁移的过程中踩了不少坑,也把这套新架构重新理解了一遍。如果你正准备用 HarmonyOS 写一个新的原生应用,或者手头还有 FA 时代的存量代码要维护,这篇文章应该能帮你省掉很多折腾时间。
这里先亮观点:Stage 模型不是一次简单的命名升级,而是从组件划分、生命周期管理、任务调度到后台能力边界的全面换轨。API 12(对应 HarmonyOS NEXT 的 SDK 5.0.0(12))之后,它基本是所有新建工程的默认基线。下面我会从组件拆解、生命周期、通信与迁移几个角度,把我自己理解的 Stage 模型完整讲一遍。
1. Stage 模型到底改了什么:从 FA 到 Stage 是一次工程思维换轨
1.1 先弄清楚“应用模型”这个概念
很多刚接触 Stage 的朋友会把它当成一套 UI 框架,其实不是。应用模型(App Model)管的是应用的组件边界、进程分配、生命周期回调、任务组织方式。UI 层是 ArkUI 的事情,应用模型才是决定“你的应用如何被系统拉起、何时被杀掉、多窗口多任务怎么跑”的底层骨架。
FA 模型下,一个 Page Ability 既承担页面入口,又承担业务调度,Service Ability 和 Data Ability 分别处理后台服务与数据共享。这种模型在最初单窗口、单任务形态下够用,但遇到多窗口、任务中心、跨设备协同这些更复杂的场景,就暴露出两个问题:一是能力职责不清晰,前后台边界模糊,系统很难精细化管控资源;二是动态语言带来的静态分析困难,IDE 很难在编译期替开发者拦截错误。
Stage 模型的出现,本质上是把“Ability”这个最小运行单元拆成了两条职责线:UIAbility 只负责被用户看到的部分,ExtensionAbility 只负责没有界面的能力扩展。系统根据组件类型施加不同的生命周期与后台策略,开发者写代码的边界也因此更清晰。
1.2 “Stage”的名字来自“舞台”,不是一个开发阶段
我第一次看到 Stage 模型的命名时,第一反应是“软件工程里的阶段模型”,后来才知道官方取的是“舞台”的含义。一个应用好比一场戏:AbilityStage 是舞台本身,UIAbility 是上场表演的角色,WindowStage 是帷幕与灯光,窗口就是角色脚下的表演区域。
组件层级大致是这样:
AbilityStage(应用级容器)→ UIAbility(入口能力)→ WindowStage(窗口阶段)→ Window(窗口)→ 页面组件树。
这种分层直接体现在代码里。Stage 应用在 module.json5 里通过 mainElement 指定入口 UIAbility,应用级生命周期回调放在继承自 AbilityStage 的类中,不再像 FA 时代那样杂糅在单个 Ability 里。
1.3 从 API 9 到 API 12:Stage 的使用边界
Stage 模型从 API 9 开始可用,但真正成为“新标准”是 API 12 之后。关键版本的变化可以这样理解:
| 版本 | Stage 模型的状态 |
|---|---|
| API 9 | Stage 模型首次引入,FA 模型仍被广泛使用 |
| API 10 | ExtensionAbility 类型开始扩充,桌面卡片、后台服务等能力逐步在新模型落地 |
| API 11 | 窗口与任务管理进一步完善,多实例、specified 模式逐渐成熟 |
| API 12 / SDK 5.0.0(12) | 构建 HarmonyOS NEXT 应用的基线,新建工程统一走 Stage 模型 |
在 API 12 工程里,模块导入风格也变了,推荐使用 Kit 形式的包名,比如能力相关的import { UIAbility, Want, AbilityConstant } from '@kit.AbilityKit'。这套 SDK 对 ArkTS 的编译约束更严格,也把 FA 时代那些可写可不写的配置项强制收敛到模块描述里。对开发者来说,适应的成本是有的,但换来的是更可预期的运行行为。
2. UIAbility、ExtensionAbility 与 AbilityStage:三者各自扮演什么角色
2.1 UIAbility:前台的页面容器
UIAbility 是用户在桌面上点图标后看到的那个能力,可以理解为一个“带窗口的入口”。它在 module.json5 的 abilities 数组中声明,srcEntry 指向入口文件。一个条目对应一个独立能力,可以在系统任务中心里呈现为一个可切换的任务卡片。
典型声明如下:
{ "module": { "name": "entry", "type": "entry", "mainElement": "MainAbility", "abilities": [ { "name": "MainAbility", "srcEntry": "./ets/entryability/MainAbility.ets", "description": "$string:MainAbility_desc", "icon": "$media:icon", "label": "$string:MainAbility_label", "startWindowIcon": "$media:startIcon", "startWindowBackground": "$color:startWindowBackground", "launchType": "singleton", "exported": true, "skills": [ { "entities": ["entity.system.home"], "actions": ["action.system.home"] } ] } ] } }srcEntry 是入口文件的相对路径,系统会根据它加载对应类。一个应用里可以有多个 UIAbility,比如主界面一个、扫码页面一个、视频播放窗口一个。它们之间是独立的运行单位,要相互启动就得通过 Want 显式描述意图。
这里要重点区分:UIAbility 本身不直接画页面。它拿到 WindowStage 后,再通过 loadContent 把页面组件树加载到窗口里。这层解耦非常重要,因为 Ability 的创建与窗口的创建不在同一时间点,所以生命周期里专门拆出了 onWindowStageCreate 回调。
2.2 ExtensionAbility:所有无界面能力的统一出口
ExtensionAbility 是后台与扩展能力的基类,常见子类包括:
- ServiceExtensionAbility:无界面后台服务,适合下载、数据同步、业务计算这类场景
- FormExtensionAbility:桌面卡片
- DataShareExtensionAbility:对外提供数据读写能力
它对应的声明放在 extensionAbilities 数组里,type 字段表示类型。系统对 ExtensionAbility 的后台运行有明确策略:短时任务有系统分配的时间窗口,长时任务需要申请后台权限并配合前台通知,周期性的工作建议交给 WorkScheduler,而不是自己起一个线程池常驻。
说白了,Stage 模型下的后台不是“你想跑多久就跑多久”,而是按场景拿配额。这个设计对用户是好事,对开发者而言就得提前规划好任务类型,否则会上线后被系统回收得措手不及。
2.3 AbilityStage:应用级生命周期的钩子
FA 时代,应用级初始化经常塞在 MainAbility 的 onCreate 里,副作用是 Ability 一旦被销毁,全局初始化也跟着失效。Stage 模型把应用级生命周期单独提出来,由 AbilityStage 承载。
一个典型的 AbilityStage 如下:
import { AbilityStage, Want, Configuration } from '@kit.AbilityKit'; export default class MyAbilityStage extends AbilityStage { onCreate(): void { // 适合做推送通道初始化、日志体系初始化、公共数据库连接池创建 } onAcceptWant(want: Want): string { // specified 启动模式下,这里根据 want 返回一个实例标识 const from = want.parameters?.from as string; return from === 'special' ? 'special' : ''; } onConfigurationUpdated(newConfig: Configuration): void { // 刷新配置,比如深色模式切换 } }在新工程模板里,AbilityStage 通常已经由系统自动挂载,你只需要在继承类里写逻辑即可,不需要手动去改太多配置。它和 UIAbility 的生命周期是不同层级的东西:前者管应用从启动到退出的全程,后者管一个窗口能力的冷暖切换。
2.4 Context 是贯穿一切的通行证
Stage 模型里,Context 是访问系统能力的基础。UIAbilityContext 向上可以拿到 ApplicationContext,横向可以启动其他 Ability、终止自己、获取资源。
例如组件里经常这么用:
import common from '@kit.AbilityKit'; const context = getContext(this) as common.UIAbilityContext; context.startAbility({ bundleName: 'com.example.app', abilityName: 'MainAbility', parameters: { key: 'value' } });Context 还有两个容易被忽略的点:一是系统可能在进程已存在但组件未初始化时直接拉起 Ability,此时的 context 来源可能是“冷启动”路径,需要检查启动参数;二是跨模块通信优先走显式 Want,而不是随手 new 一个全局类,后者在进程回收后状态会全部丢失。
3. 生命周期、启动模式与任务恢复:掌握 Stage 的“运行时剧本”
3.1 一套完整的生命周期帧
UIAbility 从创建到销毁会依次经历这些回调:
| 回调 | 触发时机 | 适合做的事 |
|---|---|---|
| onCreate | 能力实例创建时 | 初始化非 UI 资源、读取启动参数 |
| onWindowStageCreate | 窗口阶段创建时 | loadContent 加载主页面、创建子窗口 |
| onForeground | 进入前台时 | 恢复活动状态、注册前台需要的监听 |
| onBackground | 进入后台时 | 保存临时状态、释放不必要资源 |
| onWindowStageDestroy | 窗口销毁时 | 清理窗口相关资源 |
| onDestroy | 能力销毁时 | 做最终清理 |
不要在这组回调里做耗时操作,是所有生命周期管理的第一原则。我在老工程里见过把文件读写放进 onCreate 的,任务中心切换时经常白屏两秒。首帧渲染要快,Loading 一定要放到 loadContent 之后的数据准备里做,而不是占着生命周期回调。
3.2 启动模式:singleton、standard 与 specified
Stage 模型用 launchType 控制 Ability 实例的创建策略:
- singleton:整个应用只有一个实例。用户从任务中心再次切回时,系统直接复用已有实例,不会重复创建。
- standard:每次 startAbility 都会创建一个新的 Ability 实例,任务中心里会看到多个任务卡片。
- specified:由 AbilityStage.onAcceptWant 返回一个标识字符串。如果已存在相同标识的实例就复用,否则新建。
选择启动模式时要考虑“实例状态”。singleton 适合主界面这类全局只有一个的入口;standard 适合按内容拆开的详情页、阅读器;specified 常用于“不要重复打开同一条详情”的场景。比如点击同一个商品通知,希望跳回已有详情页而不是新建一页,就可以在 onAcceptWant 里以商品 ID 作为返回标识。
3.3 冷启动、热启动与被系统回收后的恢复
Stage 场景下还要区分冷启动与热启动。冷启动指进程不存在,系统需要新建进程;热启动指进程还在,但 Ability 被重建。这两种情况下 onCreate 拿到的 want 参数会存在差异,尤其要处理“用户从任务中心点击卡片恢复被回收的页面”这条路径。
后台长期驻留的 Ability 可能被系统回收以释放内存,系统在回收前会回调 onSaveState,让你有机会把关键状态写进参数。恢复时不要假设内存里的全局变量还在,尽量从持久化来源或启动参数重建页面数据。这一点在迁移 FA 老工程时特别典型:全局变量随手一放,任务切换一多,状态全丢。
3.4 快照:任务中心里看到的其实是 TaskSnapshot
系统任务中心展示的不是实时页面,而是 Ability 的 TaskSnapshot。快照截取的时间点通常在前台稳定阶段。如果你在 onBackground 里立刻改了窗口主题,快照可能暴露敏感信息。虽然系统已有隐私遮挡策略,但开发者还是应该保持“进后台即收敛”的习惯,不要在后台把关键内容留在屏幕上。
4. 组件间通信与数据流转:Want、Context 与事件总线怎么配合
4.1 不同场景的通信选型
组件间通信是 Stage 模型下最容易写乱的地方。我梳理一张选型表:
| 通信场景 | 推荐手段 | 说明 |
|---|---|---|
| 本应用 UIAbility 之间 | startAbility + Want | 适合传递启动参数、跳转意图 |
| 跨应用拉起 Ability | Want + 权限声明 | exported 需显式声明,避免误暴露 |
| UIAbility 与 ExtensionAbility | startServiceExtensionAbility 等 | 长任务注意绑定关系与运行时长 |
| 页面与 Ability 内部状态 | EventHub、AppStorage | 轻量发布订阅或全局状态 |
| 应用级全局数据 | AppStorage、持久化存储 | 不要依赖模块级变量 |
很多新手喜欢直接把页面对象塞进 Want 的 parameters,这是不行的。Want 的 parameters 是键值对格式,值必须满足 JSON 映射,像类实例、回调函数这类带应用上下文的引用类型会被序列化失败或静默丢失。传大数据,优先把数据写入文件、数据库或全局状态,然后把一个轻量的 key 放进 Want。
4.2 Want:一件事的完整意图描述
Want 在 Stage 模型中承担的角色类似“意图描述信封”。它要描述清楚三件事:目标是谁(bundleName、abilityName)、办什么(action)、附带什么(parameters)。启动外部系统组件时,还可能带 entity、uri 等。
给个实际示例:
import { Want } from '@kit.AbilityKit'; let want: Want = { bundleName: 'com.example.entry', abilityName: 'MainAbility', parameters: { from: 'home', userId: 10001 } };从 home 跳来时,MainAbility 的 onCreate 里通过 want.parameters?.from 拿到来源标记,做差异化埋点或展示。这套范式的好处是系统可以在拉起前完成各种校验与调度,开发者只管描述“我要什么”,而不必关心系统底层怎么实现。
4.3 EventHub:Ability 内部的轻量事件总线
UIAbility 实例里内置了一个 EventHub,可以理解为这个 Ability 内部的事件总线。在 Ability 类里可以这样注册:
this.eventHub.on('myEvent', (data: string) => { // 处理 Ability 内部模块发布的事件 });但注意,EventHub 适合在 Ability 类内部对相关业务模块做发布订阅,页面侧我更推荐用 AppStorage 或状态管理去同步数据,而不是绕回 Ability 实例。如果确实要用,就定义事件名常量,不要随手写字符串。页面销毁时一定要 removeListener,不然会出现重复回调、内存泄漏的问题,我实际排查过好几个这样的 case。
4.4 跨设备与分布式数据:Stage 模型的真正长板
Stage 模型为跨设备协同做了更清晰的抽象。UIAbility 可以通过携带 deviceId 的 Want 在远端设备的对应 Ability 上启动,业务组件之间通过系统提供的分布式数据库、分布式键值库共享状态。continueAbility 则可以把当前 Ability 的任务延续到另一个设备,用户在手机上没办完的事情,到平板上接着做。
这些能力恰好解释了为什么 Stage 模型需要精确的生命周期和实例复用策略:跨设备拉起时,远端 Ability 可能是全新创建,也可能是恢复旧实例,生命周期回调的时序不同,状态恢复的路径也就不一样。开发者要把“实例是否存在”和“页面是否可见”分开思考,才不会在跨端切换时一脸懵。
5. 从 FA 迁移到 Stage 的避坑清单与落地建议
5.1 迁移映射表
如果你手头还有 FA 老工程,下面的映射关系可以直接对照:
| FA 模型 | Stage 模型 |
|---|---|
| Ability(Page Ability、Service Ability、Data Ability 等) | UIAbility + ExtensionAbility |
| config.json | module.json5 |
| 全局变量散落在 Ability 中 | AbilityStage + AppStorage + 持久化存储 |
| 窗口能力有限 | WindowStage、loadContent、createSubWindow |
| 任务与 Ability 绑定、回收机制弱 | 任务中心、TaskSnapshot、onSaveState |
建议不要一次性大改。先抽出后台能力改成 ExtensionAbility,再把入口换成 AbilityStage 结构,最后迁移窗口与页面。每步跑一遍回归,比一把梭调整更稳定。
5.2 最容易踩的五个坑
第一个坑,把生命周期方法和业务逻辑混在一起写。onCreate 里做网络请求、文件读写,很容易拖慢首帧。数据准备应该放进页面加载后的异步任务里,Ability 只做能力调配。
第二个坑,startAbility 的目标参数依赖别人的 exported 状态。跨应用调用时,如果目标 Ability 没有声明 exported,启动就会被拒绝。这不是代码逻辑问题,而是模块配置问题,排查时先看 module.json5。
第三个坑,后台任务被系统回收后不恢复状态。Stage 模型下后台配额比 FA 严格,做了长任务却不处理 onSaveState,用户在任务中心点卡片回来发现白屏,这是高频问题。
第四个坑,忽略 EventHub 监听泄漏。页面级回调被多次触发往往是监听没解绑,事件名还散落在代码各处,找起来很费劲。
第五个坑,把不必要的组件设为 exported。Ability 对外的能力边界一定要收敛,能用 exported=false 就不要开,避免被其他应用拉起造成安全风险。
5.3 调试与启动性能复盘
调新工程时,我习惯在每个生命周期回调里加一行 HiLog,确认回调顺序之后再清理掉。观察三个时间点:onCreate 到 onWindowStageCreate 的间隔、loadContent 的返回时间、页面组件树首次渲染完成的时间。基本就能定位问题是生命周期耗时还是组件渲染耗时。
DevEco Studio 的启动分析工具可以直接看首帧耗时。优化思路是:缩短 onCreate 与 onWindowStageCreate 之间的间隔,减少 loadContent 之前的主线程任务,让首帧页面快速出图,把复杂的业务内容放在页面内部的异步数据加载中。这个思路不管是手机、平板还是其他形态设备,都适用。
如果你刚开始接触 Stage 模型,我建议先把它当成“舞台调度系统”来理解,不要急于背 API。跑通一个只有 UIAbility 和 AbilityStage 的 HelloWorld,打印一遍所有生命周期;再加一个 ServiceExtensionAbility 做后台任务;再加一个 specified 模式的详情页复用。三步下来,Stage 模型的核心脉络基本就清楚了。
到 API 12 这个阶段,Stage 模型已经不是新趋势而是事实标准。与其等存量工程被迫切换时手忙脚乱,不如现在就在新项目里把节奏定下来,后面涉及组件、通信、多设备能力时都会顺手很多。