鸿蒙造物者之路 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)'的概念。
助理:为什么下一章要讲后台任务?
导演:因为这是移动开发的深水区。 用户以为关掉界面应用就停了,其实没有——后台任务是耗电和隐私问题的重灾区,也是面试最爱问的地方。
助理:那下一章会很长。
导演:会。但它决定主角能不能做出一个真正拿得出手的东西。
========================================
未完待续。。。


