📚 鸿蒙专题与实战专栏 · 第 28 篇 / 共 30 篇(专题篇 21-30)
上一篇:测试体系:单元测试与 UI 测试 | 下一篇:实战项目(二):核心功能实现

目录

实战项目(一):需求拆解与架构设计

一、需求定义(先砍功能)

二、技术选型(全部来自本专栏)

三、目录结构落地

四、数据表设计

五、下一篇预告


最后三篇是一个完整实战:做一个「极简待办」App,从需求到上架全流程走一遍。只用到前 27 篇学过的内容,不加新知识。

一、需求定义(先砍功能)

功能清单只留 5 条,多了做不完:

功能优先级说明
新增待办P0标题 + 可选备注
勾选完成P0状态切换并持久化
删除待办P0长按删除 + 二次确认
按状态筛选P1全部/未完成/已完成
桌面卡片P1显示未完成数量
📌 明确不做:账号体系、云同步、提醒通知、多端协同。第一版刻意做小,才能真做完并上架。

二、技术选型(全部来自本专栏)

需求方案对应篇目
页面导航Navigation第 14 篇
列表渲染List + LazyForEach第 7、19 篇
数据持久化RDB 关系型数据库第 13 篇
跨页状态AppStorage第 12 篇
桌面卡片FormAbility第 23 篇
代码组织三层架构第 16 篇

三、目录结构落地

entry/src/main/ets/
├── entryability/EntryAbility.ets
├── pages/
│   ├── Index.ets            # Navigation 容器
│   └── EditPage.ets         # 新增/编辑页
├── components/
│   ├── TodoItem.ets         # 单条待办
│   └── FilterBar.ets        # 筛选栏
├── viewmodels/
│   └── TodoViewModel.ets    # 全部业务逻辑
├── models/
│   ├── Todo.ets             # 实体
│   └── TodoDao.ets          # RDB 读写
└── common/
    └── Constants.ets

下图展示了从页面到数据的完整调用链路,以及桌面卡片的数据来源:

flowchart TD
    A["View 层<br/>Index.ets / EditPage.ets<br/>TodoItem.ets / FilterBar.ets"] -->|"用户操作<br/>新增 / 勾选 / 删除 / 筛选"| B["ViewModel 层<br/>TodoViewModel.ets<br/>业务逻辑 + 状态管理"]
    B -->|"增删改查调用"| C["DAO 层<br/>TodoDao.ets<br/>SQL 封装"]
    C -->|"executeSql / querySql"| D[("RDB 关系型数据库<br/>todo 表")]
    D -->|"查询结果集"| C
    C -->|"实体对象 Todo"| B
    B -->|"状态变更通知"| A
    D -->|"读取未完成数量"| E["FormAbility<br/>桌面卡片"]
    E -->|"定时刷新 / 事件触发"| D

View 只负责渲染与事件上报,所有业务逻辑收敛在 ViewModel,DAO 统一封装 RDB 读写;桌面卡片不经过 View 层,由 FormAbility 直接读取 RDB 中的未完成数量,保证卡片与页面数据始终一致。

TodoViewModel 关键实现

链路清楚了,落到代码上,ViewModel 就是那个「承上启下」的角色:向上给页面提供状态和方法,向下调用 DAO 完成持久化。下面这段 TodoViewModel.ets 覆盖了初始化、加载列表和三个写操作,注意每个写操作成功后都会触发一次桌面卡片刷新:

import { relationalStore } from '@kit.ArkData';
import { formBindingData, formProvider } from '@kit.FormKit';
import { Todo } from '../models/Todo';
import { TodoDao } from '../models/TodoDao';

export class TodoViewModel {
  private dao: TodoDao | null = null;
  // 当前筛选条件:null 表示全部,true 已完成,false 未完成
  private filter: boolean | null = null;

  // 1. 初始化:拿到 RdbStore 后注入 DAO,再加载首屏数据
  async init(store: relationalStore.RdbStore): Promise<void> {
    this.dao = new TodoDao(store);
    await this.loadList();
  }

  // 2. 加载列表:按当前筛选条件查库,结果写入 AppStorage 供页面订阅
  async loadList(): Promise<void> {
    if (!this.dao) {
      return;
    }
    const list: Todo[] = await this.dao.queryByDone(this.filter);
    AppStorage.setOrCreate('todoList', list);
    // 未完成数量单独存一份,页面和卡片都用得上
    const undoneCount = list.filter((item: Todo) => !item.done).length;
    AppStorage.setOrCreate('undoneCount', undoneCount);
  }

  // 3. 新增:先落库,再刷新列表,最后通知卡片
  async addTodo(title: string, note: string): Promise<void> {
    if (!this.dao) {
      return;
    }
    await this.dao.insert(title, note);
    await this.loadList();
    await this.refreshForm();   // 写操作成功后触发卡片更新
  }

  // 4. 勾选:切换 done 状态并持久化
  async toggleDone(id: number, done: boolean): Promise<void> {
    if (!this.dao) {
      return;
    }
    await this.dao.updateDone(id, done);
    await this.loadList();
    await this.refreshForm();   // 写操作成功后触发卡片更新
  }

  // 5. 删除:二次确认由页面负责,这里只做删除与刷新
  async removeTodo(id: number): Promise<void> {
    if (!this.dao) {
      return;
    }
    await this.dao.deleteById(id);
    await this.loadList();
    await this.refreshForm();   // 写操作成功后触发卡片更新
  }

  // 6. 切换筛选条件:只影响查询,不涉及写库
  async changeFilter(filter: boolean | null): Promise<void> {
    this.filter = filter;
    await this.loadList();
  }

  // 7. 刷新桌面卡片:把最新未完成数量推给 FormAbility
  private async refreshForm(): Promise<void> {
    const count = AppStorage.get<number>('undoneCount') ?? 0;
    const data = formBindingData.createFormBindingData({ undoneCount: count });
    // formId 列表由 FormAbility 在 onAddForm 时缓存,这里遍历刷新
    const formIds: string[] = AppStorage.get<string[]>('formIds') ?? [];
    for (const formId of formIds) {
      await formProvider.updateForm(formId, data);
    }
  }
}
💡 三个写方法的结构完全一致: 调 DAO 落库 → 重新加载列表 → 刷新卡片。把「刷新卡片」收敛到 refreshForm 一个私有方法里,后续要加通知、埋点或防抖,只改一处即可。

四、数据表设计

CREATE TABLE IF NOT EXISTS todo (
  id         INTEGER PRIMARY KEY AUTOINCREMENT,
  title      TEXT    NOT NULL,
  note       TEXT,
  done       INTEGER DEFAULT 0,     -- 0 未完成 1 已完成
  created_at INTEGER NOT NULL
);
-- 索引:按状态+时间查询是最高频操作
CREATE INDEX IF NOT EXISTS idx_todo_done ON todo(done, created_at DESC);
💡 表设计先想清楚「怎么查」,再定字段和索引。这个项目最高频查询是「按状态筛 + 按时间倒序」,所以索引建在 (done, created_at) 上。

表建好了,接下来把 SQL 封装进 TodoDao.ets。下面四个方法覆盖了本项目的全部读写场景,统一使用 relationalStore 的 executeSql 与 querySql 接口:

import { relationalStore } from '@kit.ArkData';
import { Todo } from './Todo';

export class TodoDao {
  private store: relationalStore.RdbStore;

  constructor(store: relationalStore.RdbStore) {
    this.store = store;
  }

  // 1. 新增待办:插入标题、备注与创建时间,done 走默认值 0
  async insert(title: string, note: string): Promise<void> {
    const sql = 'INSERT INTO todo (title, note, created_at) VALUES (?, ?, ?)';
    await this.store.executeSql(sql, [title, note, Date.now()]);
  }

  // 2. 更新完成状态:按 id 把 done 置为 0 或 1
  async updateDone(id: number, done: boolean): Promise<void> {
    const sql = 'UPDATE todo SET done = ? WHERE id = ?';
    await this.store.executeSql(sql, [done ? 1 : 0, id]);
  }

  // 3. 按 id 删除:长按删除 + 二次确认后调用
  async deleteById(id: number): Promise<void> {
    const sql = 'DELETE FROM todo WHERE id = ?';
    await this.store.executeSql(sql, [id]);
  }

  // 4. 按状态筛选并按创建时间倒序:done 传 null 表示查全部
  async queryByDone(done: boolean | null): Promise<Todo[]> {
    const sql = done === null
      ? 'SELECT * FROM todo ORDER BY created_at DESC'
      : 'SELECT * FROM todo WHERE done = ? ORDER BY created_at DESC';
    const args = done === null ? [] : [done ? 1 : 0];
    const resultSet = await this.store.querySql(sql, args);

    const list: Todo[] = [];
    while (resultSet.goToNextRow()) {
      list.push({
        id: resultSet.getLong(resultSet.getColumnIndex('id')),
        title: resultSet.getString(resultSet.getColumnIndex('title')),
        note: resultSet.getString(resultSet.getColumnIndex('note')),
        done: resultSet.getLong(resultSet.getColumnIndex('done')) === 1,
        createdAt: resultSet.getLong(resultSet.getColumnIndex('created_at'))
      });
    }
    resultSet.close();
    return list;
  }
}
💡 四个方法都只做「SQL + 参数」这一件事,业务判断(比如删除前弹确认框、筛选条件切换)全部留在 ViewModel。这样 DAO 可以单独做单元测试,也方便后续换成别的存储方案。

五、常见坑与排查

架构和表都定好了,但真机跑起来往往不是一次就通。下面三个坑在 RDB + 桌面卡片的组合里出现频率最高,提前知道能省下不少排查时间:

现象原因解决方式
页面一进来就报错,日志提示 store 为 undefined 或「database not opened」RDB 未初始化就发起查询:getRdbStore 是异步的,页面 aboutToAppear 里直接调用 DAO 时 store 还没拿到在 EntryAbility.onCreate 或应用启动阶段统一初始化,用 await 拿到 store 后再注入 DAO;页面侧用 await 或状态位等待初始化完成再查询
列表反复刷新后内存持续上涨,长时间使用出现卡顿甚至闪退resultSet 未 close 导致内存泄漏:每次 querySql 都返回新的结果集,用完不关闭会一直占用游标资源查询结束后在 finally 中调用 resultSet.close(),保证异常路径也能释放;不要在循环里重复创建结果集
页面上勾选或新增后,桌面卡片上的未完成数量还是旧值桌面卡片数据不刷新:FormAbility 只在卡片创建或定时刷新时读库,页面内的写操作不会主动通知卡片写操作成功后主动触发卡片更新(updateForm),并配合定时刷新兜底;卡片侧每次刷新都重新查库,不缓存旧数据

这三个问题的共同点是「时序」和「资源」:初始化要早于使用,结果集用完必须释放,数据变更要主动通知到卡片。把这三条当成写代码时的检查清单,大部分 RDB 相关的诡异问题都能在出现前被挡掉。

五、下一篇预告

架构定了,下一篇写代码:ViewModel 与 DAO 的关键实现,以及三个必踩的坑。

六、本篇小结

这一篇没有写一行业务代码,但把「做什么、用什么做、怎么组织、数据怎么存」四件事定死了。回顾一下关键决策:

  • 功能清单砍到 5 条:新增、勾选、删除、筛选、桌面卡片,其中 3 条 P0、2 条 P1;账号体系、云同步、提醒通知、多端协同明确不做。第一版刻意做小,才能真做完并上架。
  • 三层架构的职责边界:View 只负责渲染与事件上报,ViewModel 收敛全部业务逻辑与状态管理,DAO 只做「SQL + 参数」的封装。桌面卡片不经过 View 层,由 FormAbility 直接读库,保证卡片与页面数据一致。
  • 索引建在 (done, created_at) 上:本项目最高频的查询是「按状态筛 + 按时间倒序」,联合索引正好覆盖这个组合,先想清楚「怎么查」再定字段和索引。
  • 三个坑的检查清单:初始化要早于使用(getRdbStore 是异步的)、结果集用完必须 close、写操作成功后主动 updateForm 通知卡片。本质都是「时序」和「资源」问题。

架构定了,下一篇就动手写代码:实战项目(二):核心功能实现,把 ViewModel 与 DAO 的关键实现落到可运行的工程里,并逐个验证上面这三个坑。


专题篇持续更新,点赞 + 收藏 + 关注不迷路。
想深入哪个方向,评论区留言,点赞高的先写。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐