👋你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 HarmonyOS 的过程都记录在这里。
🛠️主要方向:ArkTS 语言基础、HarmonyOS 原生应用(Stage 模型、UIAbility/ServiceAbility)、分布式能力与软总线、元服务/卡片、应用签名与上架、性能与内存优化、项目实战,以及 Android → 鸿蒙的迁移踩坑与复盘。
🧭内容节奏:从基础到实战——小示例拆解框架认知、专项优化手记、实战项目拆包、面试题思考与复盘,让每篇都有可落地的代码与方法论。
💡 我相信:写作是把知识内化的过程,分享是让生态更繁荣的方式。
如果你也想拥抱鸿蒙、热爱成长,欢迎关注我,一起交流进步!🚀
前言
一个“详情页越点越多”的问题,不一定出在 ArkUI 路由。
在 Stage 模型里,如果业务实际上启动的是新的 UIAbility,那么实例是否复用,首先受module.json5中launchType控制。singleton、multiton和specified三种模式,对“第二次启动同一个 UIAbility 时到底发生什么”给出了完全不同的答案。
这次不铺开讲整个 Ability 生命周期,而是用一个最小场景把问题拆清楚:连续打开文档详情时,同一个文档只保留一个 UIAbility 实例,不同文档允许拥有各自独立的实例。
本文以 HarmonyOS 7.0 为开发背景。华为当前升级适配指南明确说明,HarmonyOS 7.0 对应 API version 26.0.0;UIAbility、UIAbilityContext 等 Stage 模型接口本身并不是 HarmonyOS 7 才出现的新能力,当前文档继续支持这些接口。
一、先看问题:为什么连续打开详情页后,实例越来越多
假设应用有一个文档列表:
- 点击“文档 A”,打开详情;
- 返回列表,再次打开“文档 A”;
- 再打开“文档 B”;
- 接着又打开一次“文档 A”。
如果详情只是 ArkUI 页面之间的路由跳转,需要关注的是页面栈。
但如果每次点击实际调用的是UIAbilityContext.startAbility(),那么首先要确认目标 UIAbility 的启动模式。startAbility()是 UIAbilityContext 提供的 UIAbility 启动接口,当前 API 参考明确说明该接口属于 Stage 模型能力。
这也是一个很容易产生的理解偏差:
ArkUI 页面实例和 UIAbility 实例不是同一层概念。
UIAbility 是 Stage 模型中的应用组件,它有自己的onCreate()、onNewWant()、窗口阶段以及前后台生命周期;ArkUI 页面则运行在相应 UIAbility 所承载的窗口内容中。
所以看到“页面好像重复打开”,不要只检查 Navigation 或 Router。只要业务中出现了startAbility(),就应该继续检查launchType。
二、三种启动模式到底有什么区别
Stage 模型针对 UIAbility 提供singleton、multiton和specified三种启动模式。当前官方启动模式文档还特别说明,过去使用过的名称standard对应现在的multiton。启动模式通过module.json5中 UIAbility 的launchType配置。AbilityStage 官方文档同时明确指出,onAcceptWant()是针对specified启动模式触发的回调。
可以先把三种模式压缩成下面这张表:
| 启动模式 | 再次启动同类型 UIAbility | 已存在实例时的关键回调 | 适合的业务 |
|---|---|---|---|
singleton | 复用已有实例 | onNewWant() | 首页、主入口等只希望存在一个实例的界面 |
multiton | 创建新的实例 | 新实例进入onCreate() | 每次操作都应形成独立实例的场景 |
specified | 由业务 Key 决定复用还是新建 | 复用时onNewWant(),首次创建时onCreate() | 文档、会话等“同一对象复用,不同对象分开”的场景 |
这里真正需要记住的不是三个英文单词,而是三个问题:
系统是直接复用?始终新建?还是先问业务“这次应该属于哪个实例”?
三、singleton:第二次启动为什么不会重新 onCreate
先看最容易理解的singleton。
在module.json5中:
{ "module": { // ... "abilities": [ { "name": "DocumentAbility", "srcEntry": "./ets/documentability/DocumentAbility.ets", "launchType": "singleton" } ] } }当DocumentAbility还不存在时,第一次启动需要创建实例,因此会进入onCreate()。
如果实例已经存在,再次通过startAbility()拉起同一个 UIAbility,系统复用已有实例。这一次不会重新创建对象,而是把新的 Want 交给onNewWant()。
因此下面这样的日志非常有价值:
import{AbilityConstant,UIAbility,Want}from'@kit.AbilityKit';exportdefaultclassDocumentAbilityextendsUIAbility{onCreate(want:Want,launchParam:AbilityConstant.LaunchParam):void{constdocumentId=want.parameters?.['documentId']asstring;console.info(`[DocumentAbility] onCreate documentId=${documentId}`);}onNewWant(want:Want,launchParam:AbilityConstant.LaunchParam):void{constdocumentId=want.parameters?.['documentId']asstring;console.info(`[DocumentAbility] onNewWant documentId=${documentId}`);}}真正需要关注的是:复用实例并不意味着新的启动参数自动变成你的业务状态。
例如第一次打开文档 A,随后又用新的 Want 打开文档 B。因为是singleton,UIAbility 没有重新创建。这时如果业务只在onCreate()中读取documentId,后续 Want 就没有被业务处理。
所以,只要一个 UIAbility 存在被重复拉起、复用实例的可能,onNewWant()就值得认真处理。华为当前官方示例也会同时在onCreate()与onNewWant()中读取新传入的 Want 数据。
四、multiton:为什么越开实例越多
把配置换成:
{ "module": { // ... "abilities": [ { "name": "DocumentAbility", "srcEntry": "./ets/documentability/DocumentAbility.ets", "launchType": "multiton" } ] } }语义就完全变了。
multiton的重点是:每次调用启动接口,都允许创建新的该类型 UIAbility 实例。
因此:
打开文档 A → DocumentAbility 实例 1 再次打开文档 A → DocumentAbility 实例 2 打开文档 B → DocumentAbility 实例 3如果业务本来只是想刷新一个详情界面,却把目标 UIAbility 配成了multiton,就很容易出现“为什么越点实例越多”的现象。
它并不是错误模式。对于确实需要相互独立的实例,multiton正好符合需求。
问题在于,业务语义是否真的等于“每次启动都是一份新的任务”。
如果答案不是,就不应该仅仅因为“这个页面可以打开多次”而选择multiton。
五、真正适合文档场景的是 specified
再来看更有实际价值的一种情况:
我们希望:
文档 A → 一个 DocumentAbility 实例 文档 B → 另一个 DocumentAbility 实例 再次打开文档 A → 回到文档 A 已有实例这不是singleton,因为 A、B 要允许同时存在。
也不是普通的multiton,因为重复打开 A 时又不应该继续创建实例。
这正是specified要解决的问题。
它的核心流程可以理解成:
startAbility(want) ↓ AbilityStage.onAcceptWant(want) ↓ 生成业务实例标识 ↓ 系统检查该标识对应的 UIAbility 是否已经存在 ↓ 存在 不存在 ↓ ↓ onNewWant() onCreate() 复用原实例 创建新实例AbilityStage 是 module 级别的组件容器。官方文档说明,在 HAP 第一次加载时会创建对应的 AbilityStage;其中onAcceptWant()会在启动specified类型的 UIAbility 时参与处理。
六、搭一个“同一个文档只开一个实例”的最小 Demo
假设工程里有:
EntryAbility ↓ 文档列表 DocumentAbility ↓ 文档内容页列表分别提供三个按钮:
打开 DOC-1001 打开 DOC-1002 再次打开 DOC-1001我们希望第三次点击时复用第一次创建的实例。
1. 配置 DocumentAbility
entry/src/main/module.json5中:
{ "module": { "name": "entry", "type": "entry", "srcEntry": "./ets/myabilitystage/MyAbilityStage.ets", "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "launchType": "singleton" }, { "name": "DocumentAbility", "srcEntry": "./ets/documentability/DocumentAbility.ets", "launchType": "specified", "removeMissionAfterTerminate": true } ] } }这里有两个点。
一个是DocumentAbility必须配置:
"launchType": "specified"另一个是 Module 通过:
"srcEntry": "./ets/myabilitystage/MyAbilityStage.ets"指定 AbilityStage 的实现文件。
AbilityStage 并不是 DevEco Studio 默认模板一定自动生成的内容,官方文档给出的开发步骤同样要求按需创建 AbilityStage 文件,再通过 Module 的srcEntry指定入口。
对于specified模式,官方启动模式指导还建议结合业务考虑removeMissionAfterTerminate,用于 UIAbility 生命周期结束后移除相应任务,避免历史任务影响后续实例匹配。
七、从 ArkUI 页面启动 DocumentAbility
列表页真正需要做的事情很简单:把业务 ID 放进 Want。
import{common,Want}from'@kit.AbilityKit';import{BusinessError}from'@kit.BasicServicesKit';@Entry@Componentstruct Index{privateopenDocument(documentId:string):void{constcontext=this.getUIContext().getHostContext()ascommon.UIAbilityContext;constwant:Want={bundleName:'com.example.abilitydemo',abilityName:'DocumentAbility',moduleName:'entry',parameters:{documentId:documentId}};context.startAbility(want).catch((err:BusinessError)=>{console.error(`startAbility failed, code=${err.code}, message=${err.message}`);});}build(){Column({space:16}){Button('打开 DOC-1001').onClick(()=>{this.openDocument('DOC-1001');})Button('打开 DOC-1002').onClick(()=>{this.openDocument('DOC-1002');})Button('再次打开 DOC-1001').onClick(()=>{this.openDocument('DOC-1001');})}.width('100%').padding(24)}}这里不要把documentId只理解成页面参数。
对于specified模式,它马上还会承担另一项职责:参与确定这次启动请求究竟属于哪个 UIAbility 实例。
当前 UIAbilityContext API 参考中,startAbility(want)的 Promise 形式仍是正式接口,系统能力为SystemCapability.Ability.AbilityRuntime.Core;UIAbilityContext 首批 Stage 模型接口从 API version 9 开始提供。本文这个同应用、正常前台启动的最小场景不需要额外声明业务权限。跨应用、后台拉起等情况则受另外的组件启动规则约束,不能由这个示例外推。
八、onAcceptWant:真正决定复用哪个实例的地方
接下来创建:
entry/src/main/ets/myabilitystage/MyAbilityStage.ets代码如下:
import{AbilityStage,Want}from'@kit.AbilityKit';exportdefaultclassMyAbilityStageextendsAbilityStage{onAcceptWant(want:Want):string{if(want.abilityName==='DocumentAbility'){constdocumentId=want.parameters?.['documentId'];if(typeofdocumentId==='string'){constinstanceKey=`DocumentAbility_${documentId}`;console.info(`[MyAbilityStage] onAcceptWant key=${instanceKey}`);returninstanceKey;}}return'default';}}这几行代码就是specified最核心的部分。
打开:
DOC-1001返回:
DocumentAbility_DOC-1001打开:
DOC-1002返回:
DocumentAbility_DOC-1002再次打开:
DOC-1001得到的仍然是:
DocumentAbility_DOC-1001系统因此可以把第三次请求匹配到已经存在的 DOC-1001 对应实例。
这里比较容易理解错:onAcceptWant()不是用来“打开页面”的,也不是替代startAbility()。
它解决的是:
这次启动请求对应哪个 specified UIAbility 实例?
所以 Key 一定要来自真正能够代表业务实例身份的数据。
文档场景可以用稳定的文档 ID;会话窗口可以考虑会话 ID。不要使用每次请求都会变化的随机值,否则从实例匹配的角度看,每次都是不同对象,最终效果就会越来越接近multiton。
九、onCreate、onNewWant、onAcceptWant 的日志怎么读
给DocumentAbility加上最小日志:
import{AbilityConstant,UIAbility,Want}from'@kit.AbilityKit';exportdefaultclassDocumentAbilityextendsUIAbility{onCreate(want:Want,launchParam:AbilityConstant.LaunchParam):void{constdocumentId=want.parameters?.['documentId']asstring;console.info(`[DocumentAbility] onCreate documentId=${documentId}`);}onNewWant(want:Want,launchParam:AbilityConstant.LaunchParam):void{constdocumentId=want.parameters?.['documentId']asstring;console.info(`[DocumentAbility] onNewWant documentId=${documentId}`);// 此处根据新的 Want 更新当前实例需要处理的业务状态。}}按照官方定义分析,依次启动:
DOC-1001 DOC-1002 DOC-1001开发者应该重点观察这样的生命周期关系,而不是把下面内容当成本文已经真机运行得到的固定日志:
onAcceptWant → DocumentAbility_DOC-1001 onCreate → DOC-1001 onAcceptWant → DocumentAbility_DOC-1002 onCreate → DOC-1002 onAcceptWant → DocumentAbility_DOC-1001 onNewWant → DOC-1001第三次没有再创建 DOC-1001 对应的新实例,因此业务上最关键的信号就是:
onNewWant()而不是新的:
onCreate()十、为什么 onNewWant 不能省
很多 UIAbility 复用问题最后都会落到这里。
例如:
onCreate(want:Want):void{this.currentDocumentId=want.parameters?.['documentId']asstring;}如果所有业务初始化都写在onCreate()中,那么当实例被系统复用时,这部分逻辑不会重新执行。
于是开发者可能看到:
确实回到了原来的窗口但窗口内部展示的数据没有按照新的启动请求更新。
正确的思路不是强行把 Ability 改成多实例,而是区分两个生命周期语义:
onCreate → 一个新的 UIAbility 实例被创建 onNewWant → 已经存在的实例收到了新的启动请求如果同一份业务参数在首次创建与实例复用时都需要处理,可以进一步抽成一个私有方法:
privatehandleWant(want:Want):void{constdocumentId=want.parameters?.['documentId']asstring;console.info(`handle documentId=${documentId}`);// 更新业务状态}onCreate(want:Want,launchParam:AbilityConstant.LaunchParam):void{this.handleWant(want);}onNewWant(want:Want,launchParam:AbilityConstant.LaunchParam):void{this.handleWant(want);}这样至少不会因为生命周期入口不同,让首次启动和复用启动走成两套业务逻辑。
十一、三种模式怎么选,不要只看“能不能多开”
实际设计时,可以按业务对象之间的关系判断。
singleton:整个业务只需要一个实例
典型语义是:
无论从哪里进入,都回到同一个 UIAbility。例如应用主入口。
这种情况下,第二次启动时尤其需要关注onNewWant()。
multiton:每次启动天然就是独立任务
语义是:
这一次启动和前面的实例没有复用关系。如果每次启动都必须形成新的独立实例,使用multiton更直接。
但不要为了规避状态刷新问题就直接改成multiton。否则原本应该复用的业务对象,可能不断增加新的 UIAbility 实例。
specified:业务 ID 决定实例身份
它表达的是:
同一个业务对象 → 同一个实例 不同业务对象 → 不同实例文档 ID、编辑对象 ID、独立业务会话等,都比较接近这个模型。
这是三种模式里业务表达能力最强的一种,同时也最容易因为 Key 设计错误而失去效果。
十二、几个容易理解错的地方
1. specified 不是“给 startAbility 多传一个参数”就结束了
Want 中的参数只是业务数据。
真正把业务数据转换成 UIAbility 实例标识的是:
AbilityStage.onAcceptWant()如果只传documentId,却没有配置 AbilityStage 并实现实例 Key 的返回逻辑,就没有完成这个模式最关键的一步。
2. onAcceptWant 和 onNewWant 完全不是一回事
可以简单分工:
onAcceptWant → 决定“应该找哪个实例” onNewWant → 已存在实例真正接收这次新的启动参数前者属于 AbilityStage,后者属于 UIAbility。
AbilityStage 官方开发文档明确把onAcceptWant()描述为 specified UIAbility 启动时使用的事件回调。
3. Key 不稳定,specified 就失去了复用意义
例如:
return`DocumentAbility_${Date.now()}`;从业务上看是在打开同一个文档,但从系统实例匹配角度看,每次 Key 都不同。
结果自然会不断产生不同实例。
这里应该优先使用稳定、唯一、能够代表该业务对象身份的数据。
4. singleton 并不代表业务数据永远不变
singleton 保证的是 UIAbility 的实例复用策略,不是业务参数固定。
新的 Want 仍然可能到来,所以需要处理onNewWant()。
5. UIAbility 多实例和 ArkUI 页面栈不要混在一起排查
如果代码只是:
Navigation / NavPathStack / 页面路由应先从 ArkUI 导航栈排查。
如果出现:
context.startAbility(...)再继续检查 UIAbility 的启动模式和生命周期。
排查层级弄错,很容易在页面代码里改很久,却没有碰到真正控制实例数量的地方。
十三、实际项目中可以按这个顺序排查
遇到“Ability 为什么越开越多”“为什么第二次打开没有重新初始化”这类问题,我更建议按下面的顺序看代码。
第一步,看调用方式。
确认究竟是 ArkUI 页面导航,还是调用了:
startAbility()第二步,看module.json5。
找到目标 UIAbility 的:
"launchType"确认到底是:
singleton multiton specified第三步,看生命周期日志。
至少把下面几个位置打出来:
AbilityStage.onAcceptWant UIAbility.onCreate UIAbility.onNewWant这样很快就能判断系统到底是在创建还是复用。
第四步,如果是 specified,直接打印实例 Key。
检查同一业务对象是否真的返回相同 Key:
DOC-1001 → DocumentAbility_DOC-1001 DOC-1001 → DocumentAbility_DOC-1001而不是:
DOC-1001 → key1 DOC-1001 → key2第五步,检查 onNewWant 的业务处理。
实例复用了,不等于界面状态自动刷新。
真正需要更新的业务参数仍然应该由应用处理。
第六步,再检查任务和生命周期配置。
特别是 specified 场景,还要结合任务移除策略、UIAbility 是否已经被销毁等情况判断。已经销毁的实例当然不存在“继续复用”。
开发经验总结
UIAbility 启动模式看起来只有一个launchType字段,但它实际决定的是实例管理方式。
singleton适合“这一类 UIAbility 只保留一个”的场景。已经存在的实例被再次启动时,需要重点处理onNewWant()。
multiton表达的是“每次启动都可以拥有独立实例”。如果原本希望重复打开同一业务对象仍然复用,就不要用它来回避状态管理问题。
specified更适合文档这种“按业务对象管理实例”的模型。它的关键不只在:
"launchType": "specified"而是这一整条链:
Want 携带业务 ID ↓ AbilityStage.onAcceptWant() ↓ 生成稳定实例 Key ↓ 系统匹配实例 ↓ 新实例 → onCreate() 已有实例 → onNewWant()真正排查“UIAbility 为什么越开越多”时,也建议沿着这条链去找,而不是看到多个界面就直接把问题归到 ArkUI 页面层。
如果你的应用里也存在“同一文档、同一聊天对象、同一业务任务只能拥有一个独立窗口”的要求,可以重点检查现在用于实例识别的 Key 是否稳定,以及复用后有没有完整处理onNewWant()。这两个地方往往比startAbility()本身更值得关注。
📝 写在最后
如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!
我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!
感谢你的阅读,我们下篇文章再见~👋
✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。