👋 你好,欢迎来到我的博客!我是【菜鸟学鸿蒙】
   我是一名在路上的移动端开发者,正从传统“小码农”转向鸿蒙原生开发的进阶之旅。为了把学习过的知识沉淀下来,也为了和更多同路人互相启发,我决定把探索 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';

export default class DocumentAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    const documentId = want.parameters?.['documentId'] as string;
    console.info(`[DocumentAbility] onCreate documentId=${documentId}`);
  }

  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    const documentId = want.parameters?.['documentId'] as string;
    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
@Component
struct Index {
  private openDocument(documentId: string): void {
    const context =
      this.getUIContext().getHostContext() as common.UIAbilityContext;

    const want: 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';

export default class MyAbilityStage extends AbilityStage {
  onAcceptWant(want: Want): string {
    if (want.abilityName === 'DocumentAbility') {
      const documentId = want.parameters?.['documentId'];

      if (typeof documentId === 'string') {
        const instanceKey = `DocumentAbility_${documentId}`;
        console.info(
          `[MyAbilityStage] onAcceptWant key=${instanceKey}`
        );
        return instanceKey;
      }
    }

    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';

export default class DocumentAbility extends UIAbility {
  onCreate(
    want: Want,
    launchParam: AbilityConstant.LaunchParam
  ): void {
    const documentId = want.parameters?.['documentId'] as string;

    console.info(
      `[DocumentAbility] onCreate documentId=${documentId}`
    );
  }

  onNewWant(
    want: Want,
    launchParam: AbilityConstant.LaunchParam
  ): void {
    const documentId = want.parameters?.['documentId'] as string;

    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'] as string;
}

如果所有业务初始化都写在 onCreate() 中,那么当实例被系统复用时,这部分逻辑不会重新执行。

于是开发者可能看到:

确实回到了原来的窗口

但窗口内部展示的数据没有按照新的启动请求更新。

正确的思路不是强行把 Ability 改成多实例,而是区分两个生命周期语义:

onCreate
→ 一个新的 UIAbility 实例被创建

onNewWant
→ 已经存在的实例收到了新的启动请求

如果同一份业务参数在首次创建与实例复用时都需要处理,可以进一步抽成一个私有方法:

private handleWant(want: Want): void {
  const documentId =
    want.parameters?.['documentId'] as string;

  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() 本身更值得关注。

📝 写在最后

如果你觉得这篇文章对你有帮助,或者有任何想法、建议,欢迎在评论区留言交流!你的每一个点赞 👍、收藏 ⭐、关注 ❤️,都是我持续更新的最大动力!

我是一个在代码世界里不断摸索的小码农,愿我们都能在成长的路上越走越远,越学越强!

感谢你的阅读,我们下篇文章再见~👋

✍️ 作者:菜鸟不学编程
🧵 本文原创,转载请注明出处。

Logo

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

更多推荐