0
0
0

鸿蒙造物者之路 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 章 · 分身术 —— 全书最「鸿蒙」的一章。


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

导演:卡。这一章信息量爆炸。

助理:持久化四种、网络、权限模型、通知、后台任务四类……可以分两章讲。

导演:不能分。 因为它们是同一个东西——“应用怎么跟外部世界打交道,同时不越界”。

助理:权限和后台任务确实是同一个主题的两面。

导演:对。权限管’你能碰什么’,后台任务管’你什么时候能碰’。 合在一起讲,逻辑才完整。

助理:下一章是分布式,是全书的高潮了吧?

导演:是第一个高潮。 分布式是鸿蒙区别于其他系统的根本——如果这本书只能记住一章,就是这一章。

助理:那要讲透。

导演:软总线的发现-连接-组网-传输四步、设备虚拟化、跨端迁移、分布式数据、分布式任务调度——一个都不能少。

助理:还有安全问题吧?跨设备总得认证。

导演:当然。 设备认证是分布式的第一道门,没有它,谁都能连你的设备,那才是灾难。

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

未完待续。。。

支持与分享

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

评论

欢迎来到我的博客!