0
0
0

鸿蒙造物者之路 07 · 舞台

文章摘要
|

续写章节|沿用「造物者」系列的武侠讲解体,面向电子信息 / 嵌入式方向的新手

=================前情提要===============

我做出了第一个能看的界面——一张温湿度卡片。点一下刷新,数字会变。

但老陈说它是"死的":不会转场、不会保存状态、按返回键直接退出。

“要让它活过来,得给它一个舞台。”

========================================


这一章你会得到什么

🎯 学完这一章,你应该能:

  • ☐ 说清 FA 模型和 Stage 模型的区别,知道为什么新项目都用 Stage
  • ☐ 背下 UIAbility 的六个回调,知道每个回调该干什么、不该干什么
  • ☐ 分清页面生命周期和 Ability 生命周期,知道刷新数据该放哪
  • ☐ 会用 Want 跨应用跳转、用 router 做应用内路由
  • ☐ 知道重活不能放主线程,会用 TaskPool 或 Worker

**🧰 需要准备:**第 06 章的环境。

**⏱ 预计用时:**约 4 小时

📊 难度:★★★★☆ 概念多,但都是骨架级的


一、从"页面"到"应用"

"你昨天写的那个卡片,严格来说不叫应用。"老陈开场就不客气。

“……它跑起来了啊。”

"跑起来的是页面,不是应用。"老陈说,“我问你几个问题——”

1. 用户按返回键,会怎样?              → 直接退出,没有二次确认
2. 切到后台再回来,数据还在吗?          → 不知道,因为你没管
3. 想再加一个"设置"页面怎么加?          → ?
4. 别的 App 想调起你的界面,行吗?       → ?
5. 想在桌面上放个小卡片,能做吗?        → ?

我答不上来。

"这五个问题,全都要靠 Ability 来解决。"老陈说,“Ability 是鸿蒙应用的’骨架’——你的所有页面,都跑在它里面。”

二、先解决一个历史问题:FA 还是 Stage

“看文档的时候,你会看到两个词:FA 模型和 Stage 模型。”

2.1 为什么会有两套

“鸿蒙早期(API 8 及以前)用的是 FA 模型(Feature Ability)。从 API 9 开始,官方推 Stage 模型,现在所有新项目都用它。”

他在白板上画了对比:

【FA 模型】(老,别学)
    PageAbility    ← 有界面的
    ServiceAbility ← 后台服务
    DataAbility    ← 数据共享
    特点:Ability 与窗口强绑定,一个 PageAbility 一个窗口

【Stage 模型】(新,学这个)
    AbilityStage              ← 模块级容器
      ├─ UIAbility            ← 有界面的
      │    └─ WindowStage     ← 窗口(与 Ability 分离!)
      └─ ExtensionAbility     ← 各种扩展
           ├─ FormExtensionAbility        (桌面卡片)
           ├─ ServiceExtensionAbility     (后台服务)
           ├─ DataShareExtensionAbility   (数据共享)
           └─ ...还有十几种

“最关键的区别,你看出来了吗?”

我盯着看了会儿:“……窗口被单独拆出来了?”

“对。“老陈点头,”**在 Stage 模型里,Ability 和 Window 是分开的。**一个 UIAbility 持有一个 WindowStage,窗口里面再装页面。”

“分开有什么好处?”

"三个好处。"老陈伸出手指:

1. 【跨端】 同一份 Ability 逻辑,在手机上开全屏窗口,
            在手表上开小窗口,在车机上开横屏窗口 —— 逻辑不变

2. 【多窗口】一个 Ability 可以创建多个窗口(比如分屏、悬浮窗)

3. 【职责清晰】Ability 管"生命周期",Window 管"显示",
              两者互不干扰

"所以你新建项目的时候,Model 一定要选 Stage。"老陈强调,“FA 模型的东西,你只在维护老项目时才会碰到。”

三、UIAbility:最常用的那个

"现在看主角。"老陈打开我项目的 EntryAbility.ets。

3.1 一个 UIAbility 长什么样

// entryability/EntryAbility.ets
import UIAbility from '@ohos.app.ability.UIAbility';
import window from '@ohos.window';
import hilog from '@ohos.hilog';
import type Want from '@ohos.app.ability.Want';
import type AbilityConstant from '@ohos.app.ability.AbilityConstant';

export default class EntryAbility extends UIAbility {
  // ① 创建
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    hilog.info(0x0000, 'EntryAbility', 'onCreate');
    // 这里做:全局初始化、读配置、建立数据库连接等
  }

  // ② 窗口创建(关键!)
  onWindowStageCreate(windowStage: window.WindowStage): void {
    hilog.info(0x0000, 'EntryAbility', 'onWindowStageCreate');
    // 加载页面 —— 这是你的界面真正出现的地方
    windowStage.loadContent('pages/Index', (err, data) => {
      if (err.code) {
        hilog.error(0x0000, 'EntryAbility', 'loadContent failed: %{public}s', JSON.stringify(err));
        return;
      }
      hilog.info(0x0000, 'EntryAbility', 'loadContent success');
    });
  }

  // ③ 前台
  onForeground(): void {
    hilog.info(0x0000, 'EntryAbility', 'onForeground');
    // 这里做:恢复数据、重连网络、恢复播放等
  }

  // ④ 后台
  onBackground(): void {
    hilog.info(0x0000, 'EntryAbility', 'onBackground');
    // 这里做:保存数据、暂停播放、释放资源等
  }

  // ⑤ 窗口销毁
  onWindowStageDestroy(): void {
    hilog.info(0x0000, 'EntryAbility', 'onWindowStageDestroy');
  }

  // ⑥ 销毁
  onDestroy(): void {
    hilog.info(0x0000, 'EntryAbility', 'onDestroy');
    // 这里做:最终清理
  }
}

"六个回调,对应它的一生。"老陈画了条时间线:

   启动应用
      │
      ▼
  ① onCreate()              【创建】还没界面
      │
      ▼
  ② onWindowStageCreate()   【建窗口】loadContent 加载首页
      │
      ▼
  ③ onForeground()          【前台】用户能看到、能操作
      │
      ├──────── 用户切到别的 App / 按 Home ────────┐
      ▼                                           │
  ④ onBackground()          【后台】界面不可见     │
      │                                           │
      └──────── 用户切回来 ────────────────────────┘
      │
  ③ onForeground()          【又前台】
      │
      ├──────── 用户关闭 / 系统回收 ───────────────┐
      ▼                                           │
  ⑤ onWindowStageDestroy()  【窗口没了】           │
      ▼                                           │
  ⑥ onDestroy()             【彻底结束】◄──────────┘

3.2 每个回调该干什么

“这张表比背回调顺序重要得多:”

回调 该做的 不该做的
onCreate 全局初始化、读配置、建数据库、注册监听 耗时长操作(会拖慢启动)
onWindowStageCreate loadContent 加载首页 —
onForeground 恢复数据、重连、恢复播放、开始定位 重量级初始化
onBackground 保存数据、暂停播放、停定位、释放资源 长时间耗时(系统可能杀掉你)
onWindowStageDestroy 释放 UI 相关资源 —
onDestroy 最终清理、关闭连接 —

"最容易出问题的是 onBackground。"老陈说,“系统给你做清理的时间是有限的,你在这儿做耗时操作,可能还没做完进程就被杀了。”

“那我要保存很多数据怎么办?”

“两条路:要么平时就增量保存(推荐),要么在 onBackground 里只保存最要紧的状态,剩下的交给后台任务。”

3.3 页面生命周期:另一套

“注意——页面还有自己的一套生命周期,跟你昨天学的 aboutToAppear 是一伙的:”

@Entry
@Component
struct DetailPage {
  @State data: string = '';

  aboutToAppear(): void {
    // 组件即将出现(只调用一次)
    // 适合:初始化数据、发请求
  }

  onPageShow(): void {
    // 页面显示(每次回到这个页面都会调用!)
    // 适合:刷新数据
  }

  onPageHide(): void {
    // 页面隐藏
  }

  onBackPress(): boolean {
    // 返回键按下
    // 返回 true = 我自己处理了,别退出
    // 返回 false = 按系统默认行为(退出)
    return false;
  }

  aboutToDisappear(): void {
    // 组件即将销毁
  }

  build() { /* ... */ }
}

"两套生命周期,怎么对上?"我有点晕。

老陈画了个对照:

【UIAbility 的生命周期】           【页面的生命周期】
      onCreate                          —
          │
  onWindowStageCreate                   —
          │
          └── loadContent ─────────►  aboutToAppear   (首次)
          │                                │
      onForeground                    onPageShow      (首次)
          │                                │
          │                          ┌─────┴─────┐
          │                     用户跳转别的页面
          │                          onPageHide
          │                       (页面还在,只是看不见)
          │                                │
          │                       用户返回这个页面
          │                          onPageShow    ← 【注意:又调了一次!】
          │                                │
   onBackground                           │
   (整个应用进后台)                        │
          │                                │
   onForeground                      onPageShow
          │                                │
     onDestroy                     aboutToDisappear

"关键点在这——"老陈圈出 onPageShow:

aboutToAppear 只在页面创建时调用一次;onPageShow 每次页面显示都调用。

“所以刷新数据放哪儿?”

"放 onPageShow。"老陈说,“因为用户从详情页返回列表页的时候,列表页可能需要刷新——这时候 aboutToAppear 不会被调用,只有 onPageShow 会。”

"这是新手最常搞混的一组。"他补了一句。

四、Want:组件之间的"介绍信"

“应用内部要跳转、应用之间要调用,靠什么?”

“……Intent?”

"思路一样,鸿蒙叫 Want。"老陈说,“Want 就是一份’介绍信’,上面写着:我要找谁、我要干什么、我带了什么参数。”

4.1 Want 里装什么

let want: Want = {
  // —— 明确指定目标(显式 Want)——
  bundleName:  'com.example.myapp',        // 哪个应用
  abilityName: 'EntryAbility',             // 哪个 Ability

  // —— 或者描述意图(隐式 Want)——
  action:   'ohos.want.action.viewData',   // 我要干什么
  entities: ['entity.system.default'],     // 附加类别
  uri:      'dataability://com.example.provider/device/1',  // 数据位置
  type:     'text/plain',                  // 数据类型

  // —— 带参数 ——
  parameters: {
    deviceId:  'dev_001',
    fromPage:  'index',
    myObject:  { name: '客厅灯', state: true }
  }
};

4.2 显式 vs 隐式

【显式 Want】—— 我知道找谁
    bundleName: 'com.example.other'
    abilityName: 'TargetAbility'
    场景:拉起自己应用的另一个 Ability、拉起已知的合作伙伴应用

【隐式 Want】—— 我只说要干什么,系统帮我找
    action: 'ohos.want.action.sendData'
    entities: ['entity.system.default']
    场景:分享到社交软件、用浏览器打开链接、调起相机拍照
          系统弹出"选择用哪个应用"的菜单

"隐式 Want 必须配合 skills 使用。"老陈说,“你如果想让自己的 Ability 能被别人调起,要在 module.json5 里声明:”

{
  "module": {
    "abilities": [
      {
        "name": "ShareAbility",
        "srcEntry": "./ets/shareability/ShareAbility.ets",
        "exported": true,            // ← 允许被外部调用
        "skills": [                  // ← 声明我能处理哪些意图
          {
            "actions": [
              "ohos.want.action.sendData"
            ],
            "entities": [
              "entity.system.default"
            ]
          }
        ]
      }
    ]
  }
}

"exported: true 很关键。"老陈强调,“不加这个,别人调不起来你。 这也是安全设计——默认不对外暴露。”

4.3 启动一个 Ability

import common from '@ohos.app.ability.common';
import { BusinessError } from '@ohos.base';

// 拿到 context(在 UIAbility 里是 this.context,在组件里要取)
let context = getContext(this) as common.UIAbilityContext;

let want: Want = {
  bundleName: 'com.example.myapp',
  abilityName: 'DetailAbility',
  parameters: { deviceId: 'dev_001' }
};

context.startAbility(want).then(() => {
  console.info('启动成功');
}).catch((err: BusinessError) => {
  console.error(`启动失败: ${err.code} ${err.message}`);
});

“目标那边怎么接参数?”

export default class DetailAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 从 want 里取参数
    let deviceId = want.parameters?.deviceId as string;
    console.info(`收到参数 deviceId=${deviceId}`);

    // 存到全局,页面里再取
    AppStorage.SetOrCreate('deviceId', deviceId);
  }
}

五、页面路由:应用内部的跳转

“应用内部页面之间跳转,不用 Want,用路由 router。”

5.1 基本操作

import router from '@ohos.router';

// ① 跳转(压栈,可以返回)
router.pushUrl({
  url: 'pages/Detail',
  params: { deviceId: 'dev_001' }      // 带参数
}).then(() => {
  console.info('跳转成功');
}).catch((err: BusinessError) => {
  console.error(`跳转失败: ${err.code}`);
});

// ② 替换(不压栈,回不去)
router.replaceUrl({ url: 'pages/Login' });

// ③ 返回
router.back();

// ④ 返回并带参数
router.back({ url: 'pages/Index', params: { result: 'ok' } });

// ⑤ 清空历史
router.clear();

5.2 目标页面接收参数

import router from '@ohos.router';

@Entry
@Component
struct Detail {
  @State deviceId: string = '';

  aboutToAppear(): void {
    let params = router.getParams() as Record<string, string>;
    if (params && params['deviceId']) {
      this.deviceId = params['deviceId'];
    }
  }

  build() {
    Column() {
      Text(`设备:${this.deviceId}`)
      Button('返回')
        .onClick(() => { router.back(); })
    }
  }
}

5.3 页面栈:别跳死循环

"路由是栈结构。"老陈画了个图:

pushUrl(A→B)        pushUrl(B→C)        back()

  ┌───┐              ┌───┐              ┌───┐
  │ C │              │ C │ ◄ 栈顶        │   │
  ├───┤              ├───┤              ├───┤
  │ B │ ◄ 栈顶       │ B │              │ B │ ◄ 栈顶
  ├───┤              ├───┤              ├───┤
  │ A │              │ A │              │ A │
  └───┘              └───┘              └───┘

“栈有上限(一般 32 层),跳太多会崩。”

“什么时候会跳太多?”

"最典型的是’登录 → 主页 → 设置 → 退出登录 → 登录 → 主页……'"老陈说,“每循环一次压两层,几次就爆了。”

“怎么解决?”

"用 replaceUrl 代替 pushUrl。"老陈说,“登录页跳主页用 replace——跳过去之后登录页就没了,返回键直接退出应用,不会回到登录页。”

登录 → 主页      用 replaceUrl(回不去登录)
主页 → 详情      用 pushUrl(能返回)
详情 → 编辑      用 pushUrl
编辑 → 主页      用 back() 或 clear + replaceUrl

5.4 路由模式

// Standard(默认):每次都新建页面实例
router.pushUrl({ url: 'pages/Detail' }, router.RouterMode.Standard);

// Single:栈里已存在就复用,不新建
router.pushUrl({ url: 'pages/Detail' }, router.RouterMode.Single);

“详情页一般用 Standard(每次看不同设备);设置页一般用 Single(只需要一个)。”

六、ExtensionAbility:不带界面的那一大家子

"UIAbility 讲完,说另一边。"老陈在白板上写 ExtensionAbility。

“所有’不是主界面’的能力,都归 ExtensionAbility 管。”

类型 干什么 典型场景
FormExtensionAbility 桌面卡片 / 服务卡片 桌面上显示的天气小组件
ServiceExtensionAbility 后台服务 后台下载、音乐播放
DataShareExtensionAbility 数据共享 把本地数据提供给别的应用
InputMethodExtensionAbility 输入法 第三方输入法
BackupExtensionAbility 数据备份恢复 云备份
WorkSchedulerExtensionAbility 延迟任务 定时同步、低电量时才做的工作
StaticSubscriberExtensionAbility 静态广播 开机自启、网络变化监听

"你最可能先接触到的是 FormExtensionAbility(桌面卡片)。"老陈说,“因为鸿蒙的’服务卡片’是它的一大特色——应用不用打开,卡片就能显示实时信息,还能点。”

“那个温湿度卡片,能做成一个桌面卡片吗?”

"能。"老陈说,"流程大概是:

1. 创建 FormExtensionAbility
2. 在 module.json5 里声明 extensionAbilities,type = "form"
3. 写卡片的 UI(用 ArkTS 的卡片专用语法,HML+CSS 或 ArkTS卡片)
4. 在卡片的 onUpdateForm 回调里刷新数据
5. 应用里调用 formProvider.updateForm() 主动刷新

"注意一点——"他补充,“卡片的 UI 能力是受限的,不是所有组件都能用(比如没有 Web、没有视频),这是为了性能和安全。”

七、线程:别把主线程堵死

“最后一块,也是最实用的。”

7.1 主线程不能干重活

"ArkTS 应用的 UI 操作全在主线程(也叫 UI 线程)。"老陈说,“主线程一旦被堵住,界面就卡死。”

// ❌ 在主线程做耗时操作 —— 界面会卡住几秒
Button('读数据')
  .onClick(() => {
    let result = heavyCalculation();    // 耗时 5 秒
    this.data = result;
  })

“任何可能超过几十毫秒的操作,都不能放主线程。”

7.2 两条出路:TaskPool 和 Worker

┌────────────────────────────────────────────┐
│  TaskPool(任务池)                          │
│  · 系统管理线程池,你只管丢任务                │
│  · 适合:短任务(< 3 分钟)、一次性计算         │
│  · 用法简单                                  │
├────────────────────────────────────────────┤
│  Worker(工作线程)                           │
│  · 你自己创建、自己管理生命周期                │
│  · 适合:长任务、需要保持状态、持续通信          │
│  · 用法复杂一点                               │
└────────────────────────────────────────────┘

TaskPool 用法:

import taskpool from '@ohos.taskpool';

// 1. 定义耗时函数(必须是 @Concurrent 标记的函数)
@Concurrent
function heavyCalc(n: number): number {
  let sum = 0;
  for (let i = 0; i < n; i++) { sum += i; }
  return sum;
}

// 2. 丢给任务池执行
Button('计算')
  .onClick(async () => {
    let task = new taskpool.Task(heavyCalc, 100000000);
    let result = await taskpool.execute(task) as number;
    this.data = result;      // 回到主线程更新 UI
  })

“@Concurrent 装饰器是什么意思?”

"标记这个函数可以在多线程里跑。"老陈说,“被标记的函数有约束:不能访问外部变量、不能操作 UI、参数必须可序列化。”

Worker 用法:

import worker from '@ohos.worker';

// 1. 创建 Worker(对应一个独立的 .ts 文件)
let wk = new worker.ThreadWorker('entry/ets/workers/MyWorker.ts');

// 2. 发消息
wk.postMessage({ cmd: 'start', param: 100 });

// 3. 收消息
wk.onmessage = (e: MessageEvents) => {
  console.info(`收到: ${JSON.stringify(e.data)}`);
  this.data = e.data.result;      // 更新 UI
};

// 4. 不用了要销毁
// wk.terminate();
// entry/ets/workers/MyWorker.ts
import worker from '@ohos.worker';

let wkPort = worker.workerPort;

wkPort.onmessage = (e: MessageEvents) => {
  let msg = e.data;
  if (msg.cmd === 'start') {
    // 干重活
    let result = doSomething(msg.param);
    wkPort.postMessage({ result: result });
  }
};

“Worker 长期占着一个线程,不用了必须 terminate(),否则内存泄漏。”

7.3 怎么选

场景 用哪个
一次性计算、图像处理、数据解析 TaskPool
长时间运行的后台任务 Worker
需要频繁与主线程通信 Worker
简单的、几秒内完成的 TaskPool(优先选它)

“口诀:能用 TaskPool 就用 TaskPool,它更省心。”

=================小剧场模式=================

我:老师,我有种感觉——Stage 模型这些东西,以前写安卓的时候也有对应的。

老陈:对,概念都有对应,但设计思路不一样。 你说说看。

我:Ability ≈ Activity,Want ≈ Intent,router ≈ startActivity……

老陈:都对。 但你注意一个根本差异——

我:什么差异?

老陈:安卓的 Activity 是’一个界面’,鸿蒙的 UIAbility 是’一个容器’。

我:容器?

老陈:一个 UIAbility 里面可以装很多个页面,用 router 在里面跳。 而安卓里,跳转通常是 Activity 之间跳。

我:所以鸿蒙的层级是:Ability → Window → Page?

老陈:对。 三层。Activity 只有两层:Activity → 布局。

我:多一层有什么用?

老陈:多一层,就多一个可以变的地方。 同样一个 Ability,在手机上是全屏窗口,在平板电脑上可以是分屏窗口,在车机上是横屏窗口——你的页面代码一行都不用改。

我:……这就是"一次开发多端部署"的具体实现?

老陈:这就是它的具体实现之一。

========================================

八、冷启动、热启动与启动模式

“再补一块你迟早要碰的东西——启动。”

8.1 冷启动 vs 热启动

【冷启动】进程不存在,一切从零开始
   点击图标
     → 创建进程
     → 创建 AbilityStage
     → 创建 UIAbility:onCreate → onWindowStageCreate → onForeground
     → 显示界面
   耗时:长(几百毫秒到几秒)

【热启动】进程还在后台,只是被切回来
   点击图标
     → 复用已有进程
     → UIAbility:onNewWant(如果要传新参数) → onForeground
     → 显示界面
   耗时:短(几十毫秒)

“所以启动时该干的活,要分清楚放哪儿。”

回调 冷启动 热启动 放什么
onCreate ✅ 调用 ❌ 不调用 全局一次性初始化
onWindowStageCreate ✅ 调用 ❌ 不调用 加载页面(只做一次)
onNewWant ❌ ✅ 调用 处理新的 Want 参数(比如从通知点进来的参数)
onForeground ✅ 调用 ✅ 调用 每次回前台都要做的:刷新数据

"最常见的 bug:从通知栏点进来,参数是旧的。"老陈说,“因为你把解析参数的代码写在 onCreate 里了,热启动时 onCreate 根本不走。”

“要写在 onNewWant?”

"两个都写。"他说,“抽成一个方法,onCreate 和 onNewWant 都调它。”

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.handleWant(want);        // ← 抽出来
  }

  onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    this.handleWant(want);        // ← 同样的处理
  }

  private handleWant(want: Want): void {
    let id = want.parameters?.['deviceId'] as string;
    if (id) {
      AppStorage.SetOrCreate('deviceId', id);
    }
  }
}

8.2 三种启动模式(launchType)

“第 07 章速查表提过 launchType,现在讲透。”

模式 行为 适用场景
singleton 全局只有一个实例,再启动就复用(默认) 绝大多数应用
multiton 每次启动都新建实例 多文档编辑器、多开聊天窗口
specified 由你自己决定:给个 key,系统按 key 判断复用还是新建 按内容分实例的(如"每个文档一个实例")

specified 模式怎么用:

// 1. 在 AbilityStage 里实现 onAcceptWant
import AbilityStage from '@ohos.app.ability.AbilityStage';
import type Want from '@ohos.app.ability.Want';

export default class MyAbilityStage extends AbilityStage {
  onAcceptWant(want: Want): string {
    // 返回一个"实例 key"
    // 相同的 key 会复用同一个实例,不同的 key 会新建
    if (want.abilityName === 'DocAbility') {
      return `DocAbility_${want.parameters?.['docId']}`;
    }
    return '';
  }
}
// 2. module.json5 里配置
{
  "abilities": [{
    "name": "DocAbility",
    "launchType": "specified",     // ← 改这里
    ...
  }]
}
效果:
  打开 docId=1  → 创建实例 A(key = DocAbility_1)
  打开 docId=2  → 创建实例 B(key = DocAbility_2)
  再打开 docId=1 → 复用实例 A(不新建)

8.3 启动优化:别让用户等

"启动时间是用户对你应用的第一印象。"老陈说,“几个原则:”

1. 【能延后就延后】
   非首屏必需的初始化,放到首屏显示之后再异步做

2. 【别在 onCreate 里做重活】
   数据库建表、大文件读取、网络请求 —— 都不是启动时该干的

3. 【用 startWindow】
   module.json5 里的 startWindowIcon / startWindowBackground
   系统会在你的界面出来之前先显示这个"启动页",避免白屏

4. 【能缓存就缓存】
   首屏数据先读本地缓存渲染,网络数据到了再刷新

"第 3 条最省事。"老陈说,“配一个启动页图标和背景色,白屏问题立刻消失。”

九、一个完整的例子

"把今天的东西串起来。"老陈说,“做一个设备列表 → 设备详情的两页应用。”

// ============ 1. 数据模型 ============
// model/Device.ets
export interface Device {
  id: string;
  name: string;
  online: boolean;
  temp: number;
}

// ============ 2. 列表页 ============
// pages/Index.ets
import router from '@ohos.router';
import { Device } from '../model/Device';

@Entry
@Component
struct Index {
  @State devices: Device[] = [
    { id: '1', name: '客厅温感', online: true,  temp: 26.5 },
    { id: '2', name: '卧室温感', online: true,  temp: 24.1 },
    { id: '3', name: '阳台温感', online: false, temp: 0 }
  ];

  onPageShow(): void {
    // 每次显示都刷新(从详情返回时也会调用)
    console.info('Index onPageShow');
    // this.refreshData();
  }

  build() {
    Column() {
      Text('我的设备')
        .fontSize(24).fontWeight(FontWeight.Bold)
        .margin({ top: 20, bottom: 12 })

      List({ space: 12 }) {
        ForEach(this.devices, (item: Device) => {
          ListItem() {
            Row() {
              Circle({ width: 10, height: 10 })
                .fill(item.online ? '#0F9D58' : '#CCCCCC')
                .margin({ right: 12 })

              Column() {
                Text(item.name).fontSize(17)
                Text(`${item.temp.toFixed(1)}°C`)
                  .fontSize(13).fontColor('#888888')
              }.alignItems(HorizontalAlign.Start)

              Blank()
              Text('>').fontColor('#CCCCCC')
            }
            .width('100%').padding(16)
            .backgroundColor('#FFFFFF').borderRadius(12)
          }
          .onClick(() => {
            // 跳转并传参
            router.pushUrl({
              url: 'pages/Detail',
              params: { deviceId: item.id, deviceName: item.name }
            });
          })
        }, (item: Device) => item.id)     // ← key 用 id!
      }
      .layoutWeight(1)
    }
    .width('100%').height('100%')
    .padding({ left: 16, right: 16 })
    .backgroundColor('#F5F5F5')
  }
}
// ============ 3. 详情页 ============
// pages/Detail.ets
import router from '@ohos.router';

@Entry
@Component
struct Detail {
  @State deviceId: string = '';
  @State deviceName: string = '';

  aboutToAppear(): void {
    let p = router.getParams() as Record<string, string>;
    if (p) {
      this.deviceId = p['deviceId'] ?? '';
      this.deviceName = p['deviceName'] ?? '';
    }
  }

  onBackPress(): boolean {
    console.info('Detail: 用户按了返回');
    return false;    // false = 让系统处理(返回上一页)
  }

  build() {
    Column({ space: 16 }) {
      Text(this.deviceName).fontSize(24).fontWeight(FontWeight.Bold)
      Text(`ID: ${this.deviceId}`).fontSize(14).fontColor('#888888')

      Button('返回列表')
        .onClick(() => { router.back(); })
    }
    .width('100%').height('100%').padding(20)
  }
}
// ============ 4. module.json5 关键配置 ============
{
  "module": {
    "name": "entry",
    "type": "entry",
    "mainElement": "EntryAbility",
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "launchType": "singleton",      // singleton | multiton | specified
        "description": "$string:EntryAbility_desc",
        "icon": "$media:icon",
        "label": "$string:EntryAbility_label",
        "exported": true,
        "startWindowIcon": "$media:icon",
        "startWindowBackground": "$color:start_window_background",
        "skills": [
          {
            "entities": ["entity.system.home"],
            "actions": ["ohos.want.action.home"]
          }
        ]
      }
    ]
  }
}

"注意 launchType。"老陈补充:

launchType 含义 适用
singleton 全局只有一个实例(默认) 大多数应用
multiton 每次启动都新建实例 多开文档类
specified 由开发者指定 key 决定复用 特殊场景

"这个例子跑通,"老陈说,“你就明白了:Ability 是容器,页面在里面跳,Want 是跨应用的介绍信,router 是应用内的路标。”

十、本章速查

【UIAbility 六回调】
onCreate → onWindowStageCreate → onForeground
        → onBackground → onWindowStageDestroy → onDestroy

【页面五回调】
aboutToAppear → onPageShow → onPageHide → onBackPress
              → aboutToDisappear
关键:aboutToAppear 只一次,onPageShow 每次显示都调

【跳转】
应用内:router.pushUrl / replaceUrl / back
应用间:context.startAbility(want)

【Want】
显式:bundleName + abilityName
隐式:action + entities(要配 skills)
参数:want.parameters

【线程】
主线程不干重活
TaskPool:短任务,@Concurrent + taskpool.execute
Worker:  长任务,new ThreadWorker + postMessage

【组件类型】
UIAbility           有界面
FormExtension       桌面卡片
ServiceExtension    后台服务
DataShareExtension  数据共享

"下一章,"老陈合上电脑,“我们讲应用怎么’记住’东西、怎么联网、怎么能被叫醒。”

“数据、网络、通知?”

"对,还有后台任务和权限。"他说,“这五样加起来,才叫一个’能用’的应用,而不是一个’能看’的界面。”


本章自检

过一遍下面的问题。答不上来的,回去翻对应小节——那些就是你这个章的欠账。

  • ☐ FA 模型和 Stage 模型的核心区别是什么?(提示:窗口)
  • ☐ aboutToAppear 和 onPageShow 都什么时候调用?刷新数据放哪个?
  • ☐ UIAbility 的六个回调按顺序是什么?onBackground 里该干什么?
  • ☐ 从通知栏点进来参数没更新,最可能的原因是什么?
  • ☐ 什么情况下该用 replaceUrl 而不是 pushUrl?
  • ☐ TaskPool 和 Worker 怎么选?

**➡️ 下一站:**第 08 章 · 内外兼修 —— 让它能记住、能联网、能守规矩。


=================剧情片段=================

导演:卡。这一章是骨架,比较硬。

助理:概念确实多——两套模型、六加五共十一个回调、Want、路由、线程。

导演:骨架章都这样。 但这一章有个好处:它把所有概念串成了一条线。

助理:从 Ability 的生死,到页面怎么跳,到重活放哪儿。

导演:对。讲完它,主角写出来的东西,第一次可以叫"应用"了。

助理:下一章是"内外兼修"?

导演:对。数据持久化、网络、通知、后台任务、权限模型。

助理:权限模型是什么?

导演:鸿蒙的应用权限体系。 应用不能随便读你的位置、通讯录、相册——必须申请,用户授权。这跟安卓类似,但鸿蒙多了一个’应用分级(APL)'的概念。

助理:为什么下一章要讲后台任务?

导演:因为这是移动开发的深水区。 用户以为关掉界面应用就停了,其实没有——后台任务是耗电和隐私问题的重灾区,也是面试最爱问的地方。

助理:那下一章会很长。

导演:会。但它决定主角能不能做出一个真正拿得出手的东西。

========================================

未完待续。。。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论

欢迎来到我的博客!