鸿蒙造物者之路 08 · 内外兼修
续写章节|沿用「造物者」系列的武侠讲解体,面向电子信息 / 嵌入式方向的新手
=================前情提要===============
Stage 模型讲完,我的应用有了骨架:Ability 管生死,router 管跳转,TaskPool 管重活。
但老陈说它还只是"能看",不是"能用"。
“真正能用的应用,得会记住东西、会上网、会在你不看它的时候干活、还得守规矩。”
========================================
这一章你会得到什么
🎯 学完这一章,你应该能:
- ☐ 会四种持久化,并知道什么时候该选哪一个
- ☐ 会发网络请求,并理解离线优先为什么是工业级做法
- ☐ 懂权限模型(APL / 权限等级 / 授权方式),会申请权限
- ☐ 会发通知,知道通知渠道是什么
- ☐ 分清四类后台任务,知道哪个该用哪个不该用
**🧰 需要准备:**第 06–07 章环境。想练网络的可以准备一个简易 HTTP 接口。
**⏱ 预计用时:**约 5 小时
📊 难度:★★★★☆ 知识点杂,但都很实用
一、先问一个问题:关掉 App,数据去哪了?
"你那个温湿度应用,"老陈开场就问,“关掉再打开,之前的数据还在吗?”
“……不在。”
“为什么不在?”
“因为存在变量里,进程没了变量就没了。”
"对。"老陈说,“内存是易失的,持久化是非易失的。这是移动开发的第一课。”
他在白板上画了个金字塔:
┌─────────┐
│ 内存 │ 变量、@State
│ 易失 │ 进程没了就没了
├─────────┤
│Preferences│ 轻量 KV:设置、开关
├─────────┤
│ KVStore │ 键值数据库(可分布式)
├─────────┤
│ RDB │ 关系数据库:结构化数据
├─────────┤
│ 文件 │ 大块数据:图片、日志、导出
└─────────┘
持久化
“四种持久化方式,选哪一个?”
二、第一层:Preferences(用户首选项)
“最简单的,键值对。适合存设置项、开关、上次登录的用户名这类小东西。”
2.1 特点
| 项目 | 说明 |
|---|---|
| 数据形态 | 键值对(key-value) |
| 支持类型 | number / string / boolean / 这三种的数组 |
| 容量 | 别存太多,几 KB 到几十 KB 合适 |
| 是否加密 | 跟随应用沙箱 |
| 是否支持分布式 | ❌ 不支持 |
| 底层 | 内存缓存 + 文件落盘 |
“它本质上是:读的时候全量加载进内存,改的时候先改内存,你调 flush() 才写回文件。”
“所以不 flush 会丢?”
"退出应用时系统通常会帮你 flush,但不能指望它。"老陈说,“关键数据改完立刻 flush。”
2.2 代码
import preferences from '@ohos.data.preferences';
import common from '@ohos.app.ability.common';
class PrefManager {
private static STORE_NAME = 'my_app_prefs';
private store: preferences.Preferences | null = null;
// 初始化(异步)
async init(context: common.Context): Promise<void> {
this.store = await preferences.getPreferences(context, PrefManager.STORE_NAME);
}
// 存
async put(key: string, value: preferences.ValueType): Promise<void> {
if (!this.store) return;
await this.store.put(key, value);
await this.store.flush(); // ← 落盘,别忘
}
// 取(取不到返回默认值)
async get(key: string, def: preferences.ValueType): Promise<preferences.ValueType> {
if (!this.store) return def;
return await this.store.get(key, def);
}
// 删
async delete(key: string): Promise<void> {
if (!this.store) return;
await this.store.delete(key);
await this.store.flush();
}
// 监听变化
onChange(callback: (key: string) => void): void {
this.store?.on('change', (key: string) => { callback(key); });
}
}
// ===== 使用 =====
let pref = new PrefManager();
await pref.init(getContext(this));
// 存用户的主题偏好
await pref.put('theme', 'dark');
await pref.put('autoRefresh', true);
await pref.put('lastDeviceId', 'dev_001');
// 取
let theme = await pref.get('theme', 'light') as string;
let auto = await pref.get('autoRefresh', false) as boolean;
“注意它全是异步的,都要 await。”
三、第二层:RDB(关系型数据库)
“要存有结构、能查询、数据量不小的东西,用 RDB。”
3.1 它是什么
"RDB 底层是 SQLite,你写 SQL。"老陈说,“适合存:历史记录、设备列表、采集的时序数据。”
温湿度历史表(readings)
┌────┬─────────────────────┬───────┬───────┬─────────┐
│ id │ timestamp │ temp │ humi │ device │
├────┼─────────────────────┼───────┼───────┼─────────┤
│ 1 │ 2026-09-10 16:00:00 │ 26.5 │ 61 │ dev_001 │
│ 2 │ 2026-09-10 16:05:00 │ 26.3 │ 62 │ dev_001 │
│ 3 │ 2026-09-10 16:10:00 │ 27.1 │ 59 │ dev_001 │
└────┴─────────────────────┴───────┴───────┴─────────┘
3.2 完整代码
import relationalStore from '@ohos.data.relationalStore';
import common from '@ohos.app.ability.common';
// ---------- 数据模型 ----------
export interface Reading {
id?: number;
timestamp: string;
temp: number;
humi: number;
device: string;
}
export class ReadingDB {
private static DB_NAME = 'sensor.db';
private static TABLE = 'readings';
private store: relationalStore.RdbStore | null = null;
// ---------- 建库建表 ----------
async init(context: common.Context): Promise<void> {
const STORE_CONFIG: relationalStore.StoreConfig = {
name: ReadingDB.DB_NAME,
securityLevel: relationalStore.SecurityLevel.S1 // S1~S4 安全级别
};
this.store = await relationalStore.getRdbStore(context, STORE_CONFIG);
// 建表(IF NOT EXISTS 保证重复调用不出错)
const SQL_CREATE = `
CREATE TABLE IF NOT EXISTS ${ReadingDB.TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
temp REAL NOT NULL,
humi REAL NOT NULL,
device TEXT NOT NULL
)
`;
await this.store.executeSql(SQL_CREATE);
// 建索引(按时间查询会更快)
await this.store.executeSql(
`CREATE INDEX IF NOT EXISTS idx_ts ON ${ReadingDB.TABLE}(timestamp)`
);
}
// ---------- 插入 ----------
async insert(r: Reading): Promise<number> {
if (!this.store) return -1;
const valueBucket: relationalStore.ValuesBucket = {
'timestamp': r.timestamp,
'temp': r.temp,
'humi': r.humi,
'device': r.device
};
return await this.store.insert(ReadingDB.TABLE, valueBucket);
}
// ---------- 查询 ----------
async queryByDevice(device: string, limit: number = 100): Promise<Reading[]> {
if (!this.store) return [];
// 用 Predicates 构造查询条件(比手写 SQL 更安全)
let predicates = new relationalStore.RdbPredicates(ReadingDB.TABLE);
predicates.equalTo('device', device) // WHERE device = ?
.orderByDesc('timestamp') // ORDER BY timestamp DESC
.limitAs(limit); // LIMIT ?
let resultSet = await this.store.query(predicates);
return this.parseResultSet(resultSet);
}
// ---------- 聚合查询:求平均温度 ----------
async getAvgTemp(device: string, since: string): Promise<number> {
if (!this.store) return 0;
let predicates = new relationalStore.RdbPredicates(ReadingDB.TABLE);
predicates.equalTo('device', device)
.greaterThanOrEqualTo('timestamp', since);
let resultSet = await this.store.query(predicates, ['AVG(temp) AS avgTemp']);
if (resultSet.rowCount === 0) { resultSet.close(); return 0; }
resultSet.goToFirstRow();
let avg = resultSet.getDouble(resultSet.getColumnIndex('avgTemp'));
resultSet.close();
return avg;
}
// ---------- 删除历史数据(防止表无限增长) ----------
async deleteBefore(date: string): Promise<number> {
if (!this.store) return 0;
let predicates = new relationalStore.RdbPredicates(ReadingDB.TABLE);
predicates.lessThan('timestamp', date);
return await this.store.delete(predicates);
}
// ---------- 解析结果集 ----------
private parseResultSet(rs: relationalStore.ResultSet): Reading[] {
let list: Reading[] = [];
while (rs.goToNextRow()) {
list.push({
id: rs.getLong(rs.getColumnIndex('id')),
timestamp: rs.getString(rs.getColumnIndex('timestamp')),
temp: rs.getDouble(rs.getColumnIndex('temp')),
humi: rs.getDouble(rs.getColumnIndex('humi')),
device: rs.getString(rs.getColumnIndex('device'))
});
}
rs.close(); // ← 必须关闭!
return list;
}
}
"几个必记的点。"老陈敲黑板:
1. ResultSet 用完必须 close(),否则内存泄漏
2. 建表用 CREATE TABLE IF NOT EXISTS
3. 优先用 Predicates 而不是拼 SQL 字符串(防注入)
4. SecurityLevel.S1~S4 表示数据安全级别,越高越安全(S3/S4 需要更高权限)
5. 定期清理历史数据,别让表无限长大
3.3 KVStore 什么时候用
“那 KVStore 呢?”
"KVStore 是键值对数据库,比 Preferences 强,比 RDB 简单。"老陈说,“它最大的本事是支持分布式——多个设备之间自动同步。”
“什么时候用它?”
用 Preferences: 简单的设置项,就几个开关
用 KVStore: 数据量中等 + 【需要在多个设备间同步】
用 RDB: 结构化数据 + 复杂查询 + 事务
用文件: 大文件(图片、音频、导出 CSV)
“第 09 章讲分布式的时候,KVStore 是主角。”
3.4 文件存储:应用沙箱
"金字塔最底下那层还没讲——文件。"老陈说,“在讲它之前,你得先懂沙箱。”
“沙箱是什么?”
“每个应用都有自己的一亩三分地,别人进不来,你也出不去。”
/data/storage/
├── el1/ ← 加密等级 1:开机后即可访问
│ └── bundle/ (适合开机就要读的配置)
│ ├── files/ 应用文件
│ ├── cache/ 缓存(系统可清理)
│ └── preferences/ Preferences 就存在这
└── el2/ ← 加密等级 2:用户首次解锁后才能访问
└── base/ 【大部分数据放这里】
├── files/ 应用文件
├── cache/ 缓存
├── temp/ 临时(随时可能没)
├── database/ RDB 数据库
├── preferences/ Preferences
├── distributedfiles/ 分布式文件(第09章)
└── haps/ 模块相关
“el1 和 el2 的区别,什么时候要关心?”
"做开机自启的服务、或者设备重启后要马上读的配置,放 el1。"老陈说,“因为它不需要用户先解锁。其余绝大部分情况放 el2——更安全。”
“代码里怎么拿到这些路径?”
“通过 context。”
| 属性 | 路径 | 用途 |
|---|---|---|
context.filesDir |
el2/base/files | 长期保存的文件 |
context.cacheDir |
el2/base/cache | 缓存,系统可能清 |
context.tempDir |
el2/base/temp | 临时,随时可能清 |
context.databaseDir |
el2/base/database | 数据库 |
context.preferencesDir |
el2/base/preferences | Preferences |
context.distributedFilesDir |
el2/base/distributedfiles | 分布式文件 |
context.bundleCodeDir |
— | 应用安装目录(只读) |
“**注意:缓存目录里的东西,系统在空间不足时会清掉。**所以别把重要数据放 cache。”
3.5 文件读写代码
import fs from '@ohos.file.fs';
import common from '@ohos.app.ability.common';
class FileHelper {
private ctx: common.Context;
constructor(ctx: common.Context) { this.ctx = ctx; }
// ---------- 写文件 ----------
write(fileName: string, content: string): boolean {
try {
let path = `${this.ctx.filesDir}/${fileName}`;
let file = fs.openSync(path, fs.OpenMode.CREATE | fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC);
fs.writeSync(file.fd, content);
fs.closeSync(file); // ← 必须关!
return true;
} catch (err) {
console.error(`写文件失败: ${JSON.stringify(err)}`);
return false;
}
}
// ---------- 读文件 ----------
read(fileName: string): string {
try {
let path = `${this.ctx.filesDir}/${fileName}`;
let file = fs.openSync(path, fs.OpenMode.READ_ONLY);
let stat = fs.statSync(path);
let buf = new ArrayBuffer(stat.size);
fs.readSync(file.fd, buf);
fs.closeSync(file);
return String.fromCharCode(...new Uint8Array(buf));
} catch (err) {
console.error(`读文件失败: ${JSON.stringify(err)}`);
return '';
}
}
// ---------- 列出目录 ----------
list(): string[] {
try {
return fs.listFileSync(this.ctx.filesDir);
} catch (err) {
return [];
}
}
// ---------- 删除 ----------
delete(fileName: string): boolean {
try {
fs.unlinkSync(`${this.ctx.filesDir}/${fileName}`);
return true;
} catch (err) {
return false;
}
}
// ---------- 检查缓存大小,超了就清 ----------
async clearCacheIfTooBig(maxMB: number): Promise<void> {
let stat = fs.statSync(this.ctx.cacheDir);
// statSync 对目录拿到的 size 不一定准,实际用递归统计
// 这里简化示意
let sizeMB = stat.size / 1024 / 1024;
if (sizeMB > maxMB) {
for (let f of fs.listFileSync(this.ctx.cacheDir)) {
fs.unlinkSync(`${this.ctx.cacheDir}/${f}`);
}
console.info('缓存已清理');
}
}
}
"导出 CSV 日志是个典型用法——"老陈补充:
// 把采集的历史数据导出成 CSV
async exportCsv(readings: Reading[]): Promise<string> {
let ctx = getContext(this) as common.Context;
let helper = new FileHelper(ctx);
let lines: string[] = ['时间,温度,湿度,设备'];
for (let r of readings) {
lines.push(`${r.timestamp},${r.temp},${r.humi},${r.device}`);
}
let fileName = `readings_${Date.now()}.csv`;
helper.write(fileName, lines.join('\n'));
return `${ctx.filesDir}/${fileName}`; // 返回路径,可以分享出去
}
"三条铁律:"老陈总结。
1. 文件句柄必须 close(open/close 配对)
└ "句柄"就是系统发给你的一张"取件码":你凭它操作那个文件。
用完不还,系统就一直替你把文件留着,攒多了就再也打不开新文件了
2. 重要数据放 filesDir,不要放 cacheDir / tempDir
3. 沙箱里的文件其他应用看不到;要分享出去,
得走"文件分享"或"应用间共享"的专用能力,不能硬塞路径
四、网络:让应用能"说话"
“现在让它联网。”
4.1 第一步:申请权限
"联网要权限。"老陈说,“在 module.json5 里声明:”
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
// INTERNET 是 system_grant,安装时自动授予,不用弹窗
},
{
"name": "ohos.permission.GET_NETWORK_INFO",
"reason": "$string:net_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "always" }
}
]
}
}
"好消息:INTERNET 权限是 system_grant,不需要用户授权。"老陈说,“坏消息:涉及用户隐私的权限(位置、麦克风、相册)都得弹窗,而且是运行时弹。”
4.2 HTTP 请求
import http from '@ohos.net.http';
import { BusinessError } from '@ohos.base';
interface SensorPayload {
device: string;
temp: number;
humi: number;
}
class ApiClient {
private baseUrl = 'https://api.example.com';
// ---------- GET ----------
async getLatest(device: string): Promise<Reading | null> {
let httpRequest = http.createHttp();
try {
let response = await httpRequest.request(
`${this.baseUrl}/readings/latest?device=${device}`,
{
method: http.RequestMethod.GET,
header: { 'Content-Type': 'application/json' },
connectTimeout: 10000, // 连接超时 10s
readTimeout: 10000, // 读取超时 10s
expectDataType: http.HttpDataType.STRING
}
);
if (response.responseCode === 200) {
return JSON.parse(response.result as string) as Reading;
}
console.error(`HTTP ${response.responseCode}`);
return null;
} catch (err) {
console.error(`请求失败: ${(err as BusinessError).message}`);
return null;
} finally {
// ← 关键:必须销毁,否则连接泄漏
httpRequest.destroy();
}
}
// ---------- POST ----------
async upload(payload: SensorPayload): Promise<boolean> {
let httpRequest = http.createHttp();
try {
let response = await httpRequest.request(
`${this.baseUrl}/readings`,
{
method: http.RequestMethod.POST,
header: { 'Content-Type': 'application/json' },
extraData: JSON.stringify(payload), // 请求体
expectDataType: http.HttpDataType.STRING
}
);
return response.responseCode === 200 || response.responseCode === 201;
} catch (err) {
console.error(`上传失败: ${(err as BusinessError).message}`);
return false;
} finally {
httpRequest.destroy();
}
}
}
"httpRequest.destroy() 必须调用。"老陈强调,“不销毁,每个请求都留一条连接,几次之后就’too many open files’了。”
4.3 网络状态监听
"移动端和嵌入式最大的区别之一:网络会断。"老陈说,“所以你得监听。”
import connection from '@ohos.net.connection';
class NetMonitor {
private netCon: connection.NetConnection | null = null;
start(onAvailable: () => void, onLost: () => void): void {
this.netCon = connection.createNetConnection();
this.netCon.on('netAvailable', () => {
console.info('网络已连接');
onAvailable(); // 触发:补传离线时缓存的数据
});
this.netCon.on('netLost', () => {
console.warn('网络已断开');
onLost();
});
this.netCon.on('netUnavailable', () => {
console.warn('网络不可用');
});
this.netCon.register(); // ← 别忘了注册
}
stop(): void {
this.netCon?.unregister();
this.netCon = null;
}
}
4.4 断网缓存:真实项目必备
“这是面试常考题:断网时采集的数据怎么办?”
“……丢了?”
"不能丢。"老陈说,“工业场景里丢一条数据可能就是一次质量事故。”
他画了个流程:
采集数据
│
▼
写入本地数据库(RDB)← 标记 synced = 0
│
▼
尝试上传
│
├── 成功 → 标记 synced = 1
│
└── 失败 → 保持 synced = 0,等下次
│
网络恢复时 → 批量上传所有 synced=0 的记录
│
└── 成功后批量标记 synced = 1
“这个模式叫’离线优先(Offline First)'。核心就一句话:先落本地,再同步。”
五、权限模型:为什么我不能随便读通讯录
"讲后台任务之前,先把权限讲透。"老陈说,“这是移动开发的红线区。”
5.1 三层概念
┌─────────────────────────────────────────────┐
│ ① APL(Ability Privilege Level)应用能力等级 │
│ normal 普通应用(第三方应用都是这个) │
│ system_basic 系统基础能力(部分系统应用) │
│ system_core 系统核心能力(只有系统应用) │
├─────────────────────────────────────────────┤
│ ② 权限等级(权限本身有多敏感) │
│ normal 普通:如 INTERNET、振动 │
│ system_basic 敏感:如位置、麦克风、通讯录 │
│ system_core 核心:如重启设备 │
├─────────────────────────────────────────────┤
│ ③ 授权方式(怎么拿到) │
│ system_grant 系统授权:安装时自动给 │
│ user_grant 用户授权:运行时弹窗,用户点同意 │
└─────────────────────────────────────────────┘
“核心规则:普通应用(APL=normal)默认只能申请 normal 级权限。”
“那我要读位置怎么办?”
“位置是 system_basic 级权限——但它是 user_grant 方式,所以普通应用可以申请,只要弹窗让用户同意。”
“如果是 system_basic + system_grant 呢?”
"那就麻烦了,普通应用拿不到。"老陈说,“要么通过 ACL(访问控制列表) 申请提级(需要华为审核),要么你的应用得预装成系统应用。”
5.2 运行时申请权限
import abilityAccessCtrl from '@ohos.abilityAccessCtrl';
import common from '@ohos.app.ability.common';
import { BusinessError } from '@ohos.base';
class PermissionHelper {
private atManager = abilityAccessCtrl.createAtManager();
// ---------- 检查 + 申请 ----------
async request(context: common.UIAbilityContext, permission: string): Promise<boolean> {
try {
// 1. 检查是否已有权限
let grantStatus = await this.atManager.checkAccessToken(
abilityAccessCtrl.createAtManager().getApplicationInfo === undefined
? 0
: 0, // tokenID,实际用 bundleInfo.accessTokenId
permission
);
// 更常见做法:用 requestPermissionsFromUser 一次搞定
let result = await this.atManager.requestPermissionsFromUser(
context,
[permission]
);
// 结果:0 = 已授权,-1 = 拒绝
return result.authResults[0] === 0;
} catch (err) {
console.error(`申请权限失败: ${(err as BusinessError).message}`);
return false;
}
}
// ---------- 检查(不弹窗) ----------
async check(tokenId: number, permission: string): Promise<boolean> {
let status = await this.atManager.checkAccessToken(tokenId, permission);
return status === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED;
}
}
// ===== 在组件里用 =====
@Entry
@Component
struct LocationPage {
@State hasPermission: boolean = false;
@State message: string = '';
async aboutToAppear(): Promise<void> {
let helper = new PermissionHelper();
let context = getContext(this) as common.UIAbilityContext;
this.hasPermission = await helper.request(context, 'ohos.permission.LOCATION');
if (this.hasPermission) {
this.message = '已获得位置权限';
// 开始定位
} else {
this.message = '用户拒绝了位置权限,功能不可用';
// ← 要有降级方案!不能崩
}
}
build() {
Column() {
Text(this.message)
}
}
}
"必须有降级方案。"老陈强调,“用户拒绝权限是常态,你的应用不能因此崩溃或者白屏。”
“另外——“他补充,”**reason 字段要写清楚,这是弹窗上给用户看的那句话。**写’需要权限’这种废话,用户八成会拒绝;写’用于显示你附近的设备’,同意率高得多。”
六、通知:怎么"叫醒"用户
“应用被切到后台,怎么提醒用户?”
import notificationManager from '@ohos.notificationManager';
import { BusinessError } from '@ohos.base';
class NotificationHelper {
// ---------- 发一条普通通知 ----------
async notify(title: string, text: string): Promise<void> {
let request: notificationManager.NotificationRequest = {
id: 1, // 通知 ID,相同 ID 会覆盖
content: {
contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: title,
text: text,
additionalText: '点击查看详情'
}
},
slotType: notificationManager.SlotType.SERVICE_INFORMATION, // 通知渠道
tapDismissed: true // 点击后自动消失
};
try {
await notificationManager.publish(request);
console.info('通知已发出');
} catch (err) {
console.error(`通知失败: ${(err as BusinessError).message}`);
}
}
// ---------- 进度通知(下载/上传用) ----------
async notifyProgress(progress: number, fileName: string): Promise<void> {
let request: notificationManager.NotificationRequest = {
id: 100,
content: {
contentType: notificationManager.ContentType.NOTIFICATION_CONTENT_BASIC_TEXT,
normal: {
title: `正在下载 ${fileName}`,
text: `${progress}%`
}
},
// 进度条模板
template: {
name: 'downloadTemplate',
data: {
progressValue: progress,
progressMaxValue: 100
}
}
};
await notificationManager.publish(request);
}
// ---------- 取消 ----------
async cancel(id: number): Promise<void> {
await notificationManager.cancel(id);
}
}
“slotType 是通知渠道,不同渠道的重要性不同(有的响铃、有的静默)。用户可以单独关闭某一个渠道。”
七、后台任务:最容易被滥用的一块
"最后是重头戏。"老陈坐直了。
“用户按了 Home 键,你的应用就进入后台了。这时候你想继续干活——系统同意吗?”
“……不太同意?”
“系统非常不同意。“老陈说,”**因为后台任务是耗电和隐私问题的元凶。**所以鸿蒙把后台任务分成了四类,每一类都有严格限制。”
7.1 四类后台任务
| 类型 | 能干多久 | 适用场景 | 是否需要申请 |
|---|---|---|---|
| 短时任务(transient) | 最多 3 分钟(配额制) | 保存数据、完成一次上传、发个通知 | 自动(系统给配额) |
| 长时任务(continuous) | 不限(但会显示通知) | 音乐播放、导航、下载、录音、定位追踪 | 要申请 + 必须显示通知 |
| 延迟任务(deferred) | 系统挑时机执行 | 定时同步、日志上报、缓存清理 | 系统调度 |
| 代理提醒(reminder) | 到点触发 | 闹钟、日程提醒、倒计时 | 由系统代管 |
7.2 短时任务
“短时任务是有配额的:每个应用每 24 小时累计有限的时长,单次最多 3 分钟。”
import backgroundTaskManager from '@ohos.resourceschedule.backgroundTaskManager';
import { BusinessError } from '@ohos.base';
class TransientTaskHelper {
private delayInfo: backgroundTaskManager.DelaySuspendInfo | null = null;
// ---------- 申请 ----------
request(): void {
let reason = '正在上传传感器数据';
try {
this.delayInfo = backgroundTaskManager.requestSuspendDelay(reason, () => {
// ← 这个回调在"配额快用完了"时触发
console.warn('短时任务即将超时,赶紧收尾!');
this.finishWork(); // 保存、断连、收尾
this.cancel();
});
console.info(`剩余时长: ${this.delayInfo.delaySuspendTime} ms`);
} catch (err) {
console.error(`申请失败: ${(err as BusinessError).message}`);
}
}
// ---------- 干完活主动释放 ----------
cancel(): void {
if (this.delayInfo) {
backgroundTaskManager.cancelSuspendDelay(this.delayInfo.requestId);
this.delayInfo = null;
}
}
private finishWork(): void {
// 收尾逻辑
}
}
// ===== 在 onBackground 里用 =====
onBackground(): void {
let helper = new TransientTaskHelper();
helper.request(); // 申请 3 分钟
// 赶紧保存数据
this.saveCriticalData();
helper.cancel(); // 干完立刻释放,别占着
}
"用完立刻 cancel。"老陈说,“配额是 24 小时累计的,你占着不用就是在浪费。”
7.3 长时任务
“长时任务必须显示一条常驻通知——这是强制的,为了让用户知道’有个应用在后台跑着’。”
import backgroundTaskManager from '@ohos.resourceschedule.backgroundTaskManager';
import wantAgent from '@ohos.wantAgent';
async startContinuousTask(context: common.UIAbilityContext): Promise<void> {
// 1. 构造 WantAgent(点击通知时打开应用)
let wantAgentInfo: wantAgent.WantAgentInfo = {
wants: [{
bundleName: 'com.example.myapp',
abilityName: 'EntryAbility'
}],
operationType: wantAgent.OperationType.START_ABILITY,
requestCode: 0
};
let agent = await wantAgent.getWantAgent(wantAgentInfo);
// 2. 申请长时任务(类型:数据同步)
await backgroundTaskManager.startBackgroundRunning(
context,
backgroundTaskManager.BackgroundMode.DATA_TRANSFER, // 类型
agent
);
}
// 停止
async stopContinuousTask(context: common.UIAbilityContext): Promise<void> {
await backgroundTaskManager.stopBackgroundRunning(context);
}
“BackgroundMode 有哪些?”
| 模式 | 用途 |
|---|---|
DATA_TRANSFER |
后台下载/上传 |
AUDIO_PLAYBACK |
音乐播放 |
AUDIO_RECORDING |
录音 |
LOCATION |
导航 / 位置追踪 |
BLUETOOTH_INTERACTION |
蓝牙设备通信 |
MULTI_DEVICE_CONNECTION |
分布式多设备连接 |
TASK_KEEPING |
后台计算(如计步) |
“滥用的后果:应用商店审核不通过,或者被系统标记为高耗电应用。”
7.4 延迟任务
“延迟任务最适合’不急’的活——系统会在设备充电、空闲、有网的时候帮你做。”
import workScheduler from '@ohos.resourceschedule.workScheduler';
let workInfo: workScheduler.WorkInfo = {
workId: 1,
bundleName: 'com.example.myapp',
abilityName: 'MyWorkSchedulerExtension',
networkType: workScheduler.NetworkType.NETWORK_TYPE_WIFI, // 只在 WiFi 下
isCharging: true, // 只在充电时
batteryLevel: 30, // 电量 > 30%
repeatCycleTime: 2 * 60 * 60 * 1000, // 每 2 小时
isPersisted: true // 重启后依然有效
};
// 启动
workScheduler.startWork(workInfo);
// 停止
workScheduler.stopWork(workInfo);
"这才是工业级做法。"老陈说,“你的采集设备要在夜里同步数据——用延迟任务,让它自己挑时间,用户无感,还不耗电。”
7.5 代理提醒
“闹钟类需求用代理提醒,由系统统一管理,你的应用甚至不用在后台运行。”
import reminderAgentManager from '@ohos.reminderAgentManager';
let reminder: reminderAgentManager.ReminderRequestAlarm = {
reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_ALARM,
hour: 8,
minute: 0,
daysOfWeek: [1, 2, 3, 4, 5], // 周一到周五
title: '该采集数据了',
content: '设备已就绪',
ringDuration: 10
};
reminderAgentManager.publishReminder(reminder).then((id: number) => {
console.info(`提醒已创建, id=${id}`);
});
=================小剧场模式=================
我:老师,我有个疑问。后台任务限制这么严,那微信是怎么做到随时收消息的?
老陈:它不用后台任务。
我:那用什么?
老陈:推送。 服务器发消息给系统级的推送通道,系统再通知应用。应用全程不需要在后台运行。
我:那为什么有些应用还是要在后台跑?
老陈:因为推送有延迟、有限制、需要服务器配合。 有些应用想要’实时’,就想常驻后台——这是被系统严打的。
我:所以正确的做法是?
老陈:优先级排序:
1. 能用推送就用推送(最省电)
2. 能用代理提醒就用代理提醒(系统代管)
3. 能用延迟任务就用延迟任务(系统挑时机)
4. 实在要实时,用长时任务 + 显示通知(让用户知道)
5. 短时任务只用于"收尾"(保存数据)
我:如果我把短时任务当长时用呢?
老陈:配额耗尽,系统直接冻结你的应用。 而且用户会在电池用量里看到你——第二天就卸载了。
========================================
八、一个完整案例:离线优先的采集应用
"把今天的东西串起来。"老陈说。
/**
* 温湿度采集应用 —— 离线优先架构
* 1. 采集 → 立刻写本地 RDB(synced=0)
* 2. 有网 → 尝试上传,成功则标记 synced=1
* 3. 断网 → 数据留在本地,网络恢复后批量补传
*/
@Entry
@Component
struct CollectPage {
@State temp: number = 0;
@State humi: number = 0;
@State pending: number = 0; // 待上传条数
@State netOk: boolean = false;
@State status: string = '';
private db: ReadingDB = new ReadingDB();
private api: ApiClient = new ApiClient();
private netMon: NetMonitor = new NetMonitor();
async aboutToAppear(): Promise<void> {
await this.db.init(getContext(this));
await this.refreshPending();
// 监听网络
this.netMon.start(
() => { this.netOk = true; this.syncPending(); }, // 有网 → 补传
() => { this.netOk = false; }
);
}
aboutToDisappear(): void {
this.netMon.stop(); // ← 别忘注销监听
}
// ---------- 采集一条 ----------
async collect(): Promise<void> {
let r: Reading = {
timestamp: new Date().toISOString(),
temp: this.temp,
humi: this.humi,
device: 'dev_001'
};
if (!this.netOk) {
this.status = '已离线保存';
return;
}
let ok = await this.api.upload({
device: r.device, temp: r.temp, humi: r.humi
});
this.status = ok ? '上传成功' : '已离线保存';
await this.refreshPending();
}
// ---------- 补传所有待同步数据 ----------
private async syncPending(): Promise<void> {
// 实际项目里加个"同步中"标记,防止并发
this.status = '正在补传...';
// 从 RDB 取所有 synced=0 的记录,逐条 POST
// ...
this.status = '补传完成';
await this.refreshPending();
}
private async refreshPending(): Promise<void> {
// 查 RDB 里 synced=0 的数量
}
build() {
Column({ space: 16 }) {
Row() {
Circle({ width: 10, height: 10 }).fill(this.netOk ? '#0F9D58' : '#E74C3C')
Text(this.netOk ? '在线' : '离线').margin({ left: 8 })
Blank()
Text(`待上传 ${this.pending} 条`).fontSize(13).fontColor('#888888')
}.width('100%')
Text(`${this.temp.toFixed(1)}°C`).fontSize(48).fontWeight(FontWeight.Bold)
Text(`湿度 ${this.humi.toFixed(0)}%`).fontSize(16).fontColor('#666666')
Text(this.status).fontSize(13).fontColor('#999999')
Button('采集一条').width('60%').onClick(() => { this.collect(); })
}
.width('100%').height('100%').padding(20)
}
}
"这个架构的精髓,就一句话——"老陈说。
“先落本地,再同步。”
“数据永远不丢,网络只是加速器,不是必需品。”
九、本章速查
【持久化选型】
Preferences → 设置项、开关(小、简单、非分布式)
KVStore → 中等数据 + 需要跨设备同步(第09章)
RDB → 结构化 + 复杂查询 + 事务
文件 → 大文件(图片/音频/导出)
【网络】
权限:ohos.permission.INTERNET(system_grant,免弹窗)
请求:http.createHttp() → request() → destroy() ← 必须销毁
监听:connection.createNetConnection() + register()
铁律:离线优先 —— 先落本地,再同步
【权限】
APL:normal / system_basic / system_core
等级:normal / system_basic / system_core
授权:system_grant(自动)/ user_grant(弹窗)
申请:requestPermissionsFromUser()
铁律:被拒要有降级方案
【通知】
notificationManager.publish(request)
要设 slotType(渠道),id 相同会覆盖
【后台任务】
短时 transient:≤3分钟,配额制,用于收尾
长时 continuous:不限,必须显示通知,要申请
延迟 deferred:系统挑时机(充电+WiFi+空闲)
代理 reminder:闹钟类,系统代管
铁律:能推送就别后台,能延迟就别长时
"这一章之后,"老陈合上本子,“你的应用才算’能用’。”
“下一章呢?”
老陈眼睛又亮了。
“下一章,是鸿蒙真正的杀手锏。”
“是什么?”
"分布式。"他说,“让你的应用,能同时跑在手机、手表、平板和开发板上——而且它们看起来像一个设备。”
“第 02 章说的那个分布式软总线?”
"对。"老陈站起来,“它终于要出场了。”
本章自检
过一遍下面的问题。答不上来的,回去翻对应小节——那些就是你这个章的欠账。
- ☐ Preferences / KVStore / RDB / 文件,分别在什么场景下用?
- ☐ 「离线优先」的核心流程是什么?断网时数据怎么处理?
- ☐
system_grant和user_grant有什么区别? - ☐ 用户拒绝了权限,你的应用应该怎么办?
- ☐ 短时 / 长时 / 延迟 / 代理提醒,四类后台任务各自的限制是什么?
- ☐ 长时任务有什么强制要求?
- ☐ 说出一句总结:能推送就别后台,能_____就别长时。
**➡️ 下一站:**第 09 章 · 分身术 —— 全书最「鸿蒙」的一章。
=================剧情片段=================
导演:卡。这一章信息量爆炸。
助理:持久化四种、网络、权限模型、通知、后台任务四类……可以分两章讲。
导演:不能分。 因为它们是同一个东西——“应用怎么跟外部世界打交道,同时不越界”。
助理:权限和后台任务确实是同一个主题的两面。
导演:对。权限管’你能碰什么’,后台任务管’你什么时候能碰’。 合在一起讲,逻辑才完整。
助理:下一章是分布式,是全书的高潮了吧?
导演:是第一个高潮。 分布式是鸿蒙区别于其他系统的根本——如果这本书只能记住一章,就是这一章。
助理:那要讲透。
导演:软总线的发现-连接-组网-传输四步、设备虚拟化、跨端迁移、分布式数据、分布式任务调度——一个都不能少。
助理:还有安全问题吧?跨设备总得认证。
导演:当然。 设备认证是分布式的第一道门,没有它,谁都能连你的设备,那才是灾难。
========================================
未完待续。。。


