实战项目(一):需求拆解与架构设计
📚 鸿蒙专题与实战专栏 · 第 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 的关键实现落到可运行的工程里,并逐个验证上面这三个坑。
专题篇持续更新,点赞 + 收藏 + 关注不迷路。
想深入哪个方向,评论区留言,点赞高的先写。
更多推荐


所有评论(0)