待办 App(上):任务列表与数据持久化
系列: HarmonyOS 本地工具 App 实战 · 第 3 篇
引言
待办(Todo)应用是学习数据持久化和列表交互的经典场景。一个合格的待办应用需要支持任务的创建、完成和删除,更重要的是——数据必须在应用退出后依然存在。这要求我们使用本地数据库而非内存存储。
本文将带你从零搭建一个 HarmonyOS 待办应用,重点掌握以下技能:
- 使用 relationalStore 进行本地关系型数据库的增删改查
- 使用 @ObservedV2 + @Trace 实现深层数据观察
- 使用 bindSheet 半模态弹窗实现新增待办
- 使用 Checkbox 实现任务完成状态切换
- 实现分类 Tab 筛选和搜索交互

环境准备
| 项目 | 版本 |
|---|---|
| DevEco Studio | 6.1.1 Release |
| API Level | API 24 |
| 设备 | 真机或模拟器 |
项目结构
entry/src/main/ets/
├── entryability/
│ └── EntryAbility.ets
├── model/
│ └── TodoData.ets // 数据模型 + 枚举
├── db/
│ └── AppDatabase.ets // 全局数据库单例
├── common/
│ └── Constants.ets // 常量定义
└── views/
└── todo/
└── TodoPage.ets // 待办主页
这种分层结构将数据模型、数据库服务、常量和 UI 分离,每个模块职责单一。
核心实现
Step 1: 定义常量
目标: 将应用中使用的常量集中管理,避免魔法字符串散落各处。
// common/Constants.ets
export class AppConstants {
// 数据库
static readonly DB_NAME = 'LocalTools.db';
static readonly DB_VERSION = 1;
// 待办分类
static readonly TODO_CATEGORIES = ['工作', '生活', '学习'];
// 记账分类
static readonly EXPENSE_CATEGORIES = ['餐饮', '交通', '购物', '娱乐', '居住', '医疗', '教育', '其他'];
static readonly INCOME_CATEGORIES = ['工资', '兼职', '投资', '红包', '其他'];
// 天气
static readonly DEFAULT_CITY = '北京';
}
AppConstants 使用 static readonly 定义不可变常量。TODO_CATEGORIES 数组会在分类 Tab 和新增弹窗中复用,确保分类选项始终一致。EXPENSE_CATEGORIES 和 INCOME_CATEGORIES 为记账模块预留,体现了多模块共享常量的设计。
Step 2: 定义数据模型
目标: 使用 @ObservedV2 + @Trace 定义待办数据模型,实现深层属性观察。
// model/TodoData.ets
import { relationalStore } from '@kit.ArkData';
@ObservedV2
export class TodoItem {
@Trace id: number = 0;
@Trace title: string = '';
@Trace category: string = '生活';
@Trace priority: number = 0;
@Trace isCompleted: boolean = false;
@Trace createdAt: number = 0;
static fromRdb(resultSet: relationalStore.ResultSet): TodoItem {
const item = new TodoItem();
item.id = resultSet.getLong(resultSet.getColumnIndex('id'));
item.title = resultSet.getString(resultSet.getColumnIndex('title'));
item.category = resultSet.getString(resultSet.getColumnIndex('category'));
item.priority = resultSet.getLong(resultSet.getColumnIndex('priority'));
item.isCompleted = resultSet.getLong(resultSet.getColumnIndex('is_completed')) === 1;
item.createdAt = resultSet.getLong(resultSet.getColumnIndex('created_at'));
return item;
}
}
export enum TodoCategory {
WORK = '工作',
LIFE = '生活',
STUDY = '学习'
}
export enum TodoPriority {
NONE = 0,
LOW = 1,
MEDIUM = 2,
HIGH = 3
}
@ObservedV2 和 @Trace 是 HarmonyOS 状态管理 V2 的核心装饰器。与 V1 的 @Observed 不同,V2 支持深层属性观察——当 TodoItem 的任意 @Trace 属性(如 isCompleted、title)发生变化时,绑定该对象的 UI 会自动刷新,无需替换整个对象。
这对于待办应用至关重要:用户勾选完成状态时,我们只需修改 todo.isCompleted = true,UI 就会立即响应,而不需要像 V1 那样重新创建对象或替换数组。
static fromRdb() 工厂方法:从 ResultSet 中提取字段并构建 TodoItem 实例。这种方式将数据库映射逻辑封装在模型内部,避免在 UI 层散落大量 resultSet.getString() 调用。注意 is_completed 是 SQLite 中的 INTEGER(0/1),需要转换为 TypeScript 的 boolean。
Step 3: 实现数据库服务
目标: 使用单例模式封装 relationalStore,统一管理数据库初始化和表创建。
// db/AppDatabase.ets
import { relationalStore } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { AppConstants } from '../common/Constants';
const TAG = 'AppDatabase';
export class AppDatabase {
private static instance: AppDatabase;
private store: relationalStore.RdbStore | null = null;
static getInstance(): AppDatabase {
if (!AppDatabase.instance) {
AppDatabase.instance = new AppDatabase();
}
return AppDatabase.instance;
}
getStore(): relationalStore.RdbStore | null {
return this.store;
}
async init(context: Context): Promise<void> {
if (this.store) return;
const config: relationalStore.StoreConfig = {
name: AppConstants.DB_NAME,
securityLevel: relationalStore.SecurityLevel.S1,
};
this.store = await relationalStore.getRdbStore(context, config);
await this.createTables();
hilog.info(0, TAG, 'Database initialized');
}
private async createTables(): Promise<void> {
if (!this.store) return;
// 待办表
await this.store.executeSql(`
CREATE TABLE IF NOT EXISTS todo_item (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT DEFAULT '生活',
priority INTEGER DEFAULT 0,
is_completed INTEGER DEFAULT 0,
created_at INTEGER
)
`);
// 记账表
await this.store.executeSql(`
CREATE TABLE IF NOT EXISTS budget_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
amount REAL NOT NULL,
category TEXT NOT NULL,
note TEXT DEFAULT '',
date TEXT NOT NULL,
created_at INTEGER
)
`);
hilog.info(0, TAG, 'Tables created');
}
}
单例模式:AppDatabase 使用 static instance + getInstance() 确保全局只有一个数据库实例。多个页面(待办、记账)共享同一个 RdbStore,避免重复初始化和连接冲突。
getStore() 访问器:页面通过 AppDatabase.getInstance().getStore() 获取数据库句柄,直接使用 relationalStore.RdbPredicates 进行 CRUD 操作。这种设计将数据库初始化与业务操作解耦——AppDatabase 只负责建库建表,具体的增删改查由各页面自行实现。
关于安全级别:SecurityLevel.S1 表示最低安全级别,适用于非敏感的个人数据。如果你的待办应用涉及机密信息,应使用 S3 或 S4。
关于 init 的幂等性:if (this.store) return; 确保多次调用 init() 不会重复创建数据库。这在应用生命周期中很重要——EntryAbility 的 onCreate 会调用一次,但页面导航时不应再次触发。
Step 4: 实现待办主页
目标: 组装完整的待办页面,实现新增、完成、删除、分类筛选和搜索功能。
// views/todo/TodoPage.ets
import { TodoItem, TodoCategory, TodoPriority } from '../../model/TodoData';
import { AppDatabase } from '../../db/AppDatabase';
import { relationalStore } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
import { AppConstants } from '../../common/Constants';
const TAG = 'TodoPage';
@Component
export struct TodoPage {
@State todoList: TodoItem[] = [];
@State showAddDialog: boolean = false;
@State newTitle: string = '';
@State selectedCategory: string = '生活';
@State selectedPriority: number = 0;
@State searchKeyword: string = '';
@State currentCategory: string = '全部';
@Builder
addTodoSheet() {
Column({ space: 18 }) {
Text('新增待办')
.fontSize(22)
.fontWeight(FontWeight.Bold)
.width('100%')
TextInput({ text: $$this.newTitle, placeholder: '请输入待办内容' })
.height(52)
.maxLength(80)
Column({ space: 8 }) {
Text('分类').fontSize(14).fontColor('#666666').width('100%')
Row({ space: 8 }) {
ForEach(AppConstants.TODO_CATEGORIES, (category: string) => {
Button(category)
.fontColor(this.selectedCategory === category ? Color.White : '#333333')
.backgroundColor(this.selectedCategory === category ? '#1A73E8' : '#EEEEEE')
.onClick(() => { this.selectedCategory = category; })
}, (category: string) => category)
}
.width('100%')
}
Row({ space: 12 }) {
Button('取消')
.layoutWeight(1)
.backgroundColor('#EEEEEE')
.fontColor('#333333')
.onClick(() => { this.showAddDialog = false; })
Button('保存')
.layoutWeight(1)
.enabled(this.newTitle.trim().length > 0)
.onClick(() => { this.addTodo(); })
}
.width('100%')
}
.padding(24)
.width('100%')
}
aboutToAppear(): void {
this.loadTodos();
}
async loadTodos(): Promise<void> {
try {
const store = AppDatabase.getInstance().getStore();
if (!store) return;
const predicates = new relationalStore.RdbPredicates('todo_item');
predicates.orderByDesc('created_at');
if (this.currentCategory !== '全部') {
predicates.equalTo('category', this.currentCategory);
}
const resultSet = await store.query(predicates);
this.todoList = [];
while (resultSet.goToNextRow()) {
this.todoList.push(TodoItem.fromRdb(resultSet));
}
resultSet.close();
} catch (error) {
hilog.error(0, TAG, `Load todos failed: ${JSON.stringify(error)}`);
}
}
async addTodo(): Promise<void> {
if (!this.newTitle.trim()) return;
try {
const store = AppDatabase.getInstance().getStore();
if (!store) return;
const valueBucket: relationalStore.ValuesBucket = {
title: this.newTitle,
category: this.selectedCategory,
priority: this.selectedPriority,
is_completed: 0,
created_at: Date.now(),
};
await store.insert('todo_item', valueBucket);
this.newTitle = '';
this.showAddDialog = false;
await this.loadTodos();
} catch (error) {
hilog.error(0, TAG, `Add todo failed: ${JSON.stringify(error)}`);
}
}
async toggleComplete(item: TodoItem): Promise<void> {
try {
const store = AppDatabase.getInstance().getStore();
if (!store) return;
const valueBucket: relationalStore.ValuesBucket = {
is_completed: item.isCompleted ? 0 : 1,
};
const predicates = new relationalStore.RdbPredicates('todo_item');
predicates.equalTo('id', item.id);
await store.update(valueBucket, predicates);
await this.loadTodos();
} catch (error) {
hilog.error(0, TAG, `Toggle todo failed: ${JSON.stringify(error)}`);
}
}
async deleteTodo(item: TodoItem): Promise<void> {
try {
const store = AppDatabase.getInstance().getStore();
if (!store) return;
const predicates = new relationalStore.RdbPredicates('todo_item');
predicates.equalTo('id', item.id);
await store.delete(predicates);
await this.loadTodos();
} catch (error) {
hilog.error(0, TAG, `Delete todo failed: ${JSON.stringify(error)}`);
}
}
build() {
Column() {
// 标题栏
Row() {
Text('待办')
.fontSize(22).fontWeight(FontWeight.Bold).fontColor('#333333')
Blank()
Button('新增')
.fontSize(14)
.height(36)
.onClick(() => { this.showAddDialog = true; })
}
.width('100%').height(56).padding({ left: 16, right: 16 })
// 分类 Tab
Row({ space: 8 }) {
ForEach(['全部', ...AppConstants.TODO_CATEGORIES], (cat: string) => {
Text(cat)
.fontSize(13)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.fontColor(this.currentCategory === cat ? Color.White : '#666666')
.backgroundColor(this.currentCategory === cat ? '#1A73E8' : '#F0F0F0')
.borderRadius(12)
.onClick(() => {
this.currentCategory = cat;
this.loadTodos();
})
}, (cat: string) => cat)
}
.width('100%').padding({ left: 16, right: 16 })
// 搜索栏
Search({ placeholder: '搜索待办...' })
.width('100%')
.padding({ left: 16, right: 16 })
.margin({ top: 8 })
.onChange((value: string) => { this.searchKeyword = value; })
// 待办列表
List({ space: 8 }) {
ForEach(this.todoList, (item: TodoItem) => {
ListItem() {
Row() {
Checkbox()
.select(item.isCompleted)
.onChange(() => this.toggleComplete(item))
Column({ space: 2 }) {
Text(item.title)
.fontSize(15)
.fontColor(item.isCompleted ? '#999999' : '#333333')
.decoration({ type: item.isCompleted ? TextDecorationType.LineThrough : TextDecorationType.None })
Text(item.category)
.fontSize(12).fontColor('#999999')
}
.margin({ left: 8 }).alignItems(HorizontalAlign.Start)
Blank()
Image($r('sys.symbol.trash'))
.width(16).height(16).fillColor('#FF4D4F')
.onClick(() => this.deleteTodo(item))
}
.width('100%').padding(12).backgroundColor(Color.White).borderRadius(8)
}
}, (item: TodoItem) => `${item.id}`)
}
.width('100%')
.layoutWeight(1)
.padding({ left: 16, right: 16, top: 8 })
}
.width('100%').height('100%').backgroundColor('#F5F5F5')
.bindSheet($$this.showAddDialog, this.addTodoSheet(), {
height: SheetSize.MEDIUM,
showClose: false
})
}
}

关键代码解读
bindSheet 半模态弹窗
.bindSheet($$this.showAddDialog, this.addTodoSheet(), {
height: SheetSize.MEDIUM,
showClose: false
})
bindSheet 是 ArkUI 提供的半模态弹窗 API,相比 CustomDialog 更轻量、更符合移动端交互习惯。它从屏幕底部滑出,不会完全遮挡底层页面。
$$this.showAddDialog:双向绑定控制弹窗的显示/隐藏。$$语法确保弹窗内部修改(如点击取消)能同步回状态变量。this.addTodoSheet():@Builder函数定义弹窗内容。SheetSize.MEDIUM:弹窗高度为屏幕的 50%,适合表单类内容。
Checkbox 完成状态切换
Checkbox()
.select(item.isCompleted)
.onChange(() => this.toggleComplete(item))
Checkbox 是 ArkUI 内置的勾选框组件。select 属性控制选中状态,onChange 在用户点击时触发。toggleComplete 方法将 is_completed 在 0 和 1 之间切换,然后调用 loadTodos() 刷新列表。
分类 Tab 筛选
ForEach(['全部', ...AppConstants.TODO_CATEGORIES], (cat: string) => {
Text(cat)
.fontColor(this.currentCategory === cat ? Color.White : '#666666')
.backgroundColor(this.currentCategory === cat ? '#1A73E8' : '#F0F0F0')
.borderRadius(12)
.onClick(() => {
this.currentCategory = cat;
this.loadTodos();
})
}, (cat: string) => cat)
分类 Tab 使用 ForEach 动态生成,数据源为 ['全部', ...AppConstants.TODO_CATEGORIES],即 ['全部', '工作', '生活', '学习']。选中项显示蓝色背景白色文字,未选中显示灰色背景。点击时更新 currentCategory 并重新查询数据库——loadTodos() 中通过 predicates.equalTo('category', this.currentCategory) 实现分类过滤。
Search 搜索组件
Search({ placeholder: '搜索待办...' })
.onChange((value: string) => { this.searchKeyword = value; })
Search 是 ArkUI 内置的搜索栏组件,自带搜索图标和清除按钮。onChange 回调实时获取输入内容,存入 searchKeyword 状态变量。当前实现仅存储关键词,后续可结合 predicates.contains('title', keyword) 实现数据库层面的模糊搜索。
删除按钮使用系统图标
Image($r('sys.symbol.trash'))
.width(16).height(16).fillColor('#FF4D4F')
.onClick(() => this.deleteTodo(item))
$r('sys.symbol.trash') 引用 HarmonyOS 系统内置的垃圾桶图标,无需额外导入图片资源。fillColor('#FF4D4F') 将图标着色为红色,传达"危险操作"的视觉语义。
relationalStore 的 RdbPredicates
const predicates = new relationalStore.RdbPredicates('todo_item');
predicates.orderByDesc('created_at');
if (this.currentCategory !== '全部') {
predicates.equalTo('category', this.currentCategory);
}
const resultSet = await store.query(predicates);
RdbPredicates 是 relationalStore 提供的查询条件构建器,支持链式调用:
| 方法 | 说明 | 示例 |
|---|---|---|
equalTo |
等于 | predicates.equalTo('category', '工作') |
contains |
包含 | predicates.contains('title', keyword) |
between |
范围 | predicates.between('priority', 0, 1) |
in |
枚举 | predicates.in('id', [1, 2, 3]) |
orderByAsc/Desc |
排序 | predicates.orderByDesc('created_at') |
limitAs/offsetAs |
分页 | predicates.limitAs(20).offsetAs(0) |
CRUD 操作的统一模式
所有增删改查操作都遵循同一模式:获取 store -> 构建 predicates/valuesBucket -> 执行操作 -> 刷新列表。
// 查询
const predicates = new relationalStore.RdbPredicates('todo_item');
const resultSet = await store.query(predicates);
// 插入
const valueBucket: relationalStore.ValuesBucket = { title: '...', category: '...' };
await store.insert('todo_item', valueBucket);
// 更新
const valueBucket: relationalStore.ValuesBucket = { is_completed: 1 };
const predicates = new relationalStore.RdbPredicates('todo_item');
predicates.equalTo('id', item.id);
await store.update(valueBucket, predicates);
// 删除
const predicates = new relationalStore.RdbPredicates('todo_item');
predicates.equalTo('id', item.id);
await store.delete(predicates);
关于 ResultSet 的注意事项:使用完 ResultSet 后必须调用 close() 释放资源。如果不关闭,SQLite 的游标会一直占用数据库连接,长期累积会导致连接池耗尽。
总结
本文介绍了如何从零搭建一个 HarmonyOS 待办应用,重点实现了以下技术点:
- relationalStore 数据持久化:使用 SQLite 数据库存储待办数据,支持增删改查和条件查询
- @ObservedV2 + @Trace:V2 状态管理实现深层属性观察,修改单个属性即可触发 UI 刷新
- AppDatabase 单例:全局共享数据库实例,统一管理建库建表
- bindSheet 半模态弹窗:从底部滑出的轻量弹窗,适合表单输入场景
- 分类 Tab + Search:使用 ForEach 动态生成分类标签,Search 组件提供搜索能力
- Checkbox + 系统图标:Checkbox 实现完成切换,
$r('sys.symbol.trash')使用系统内置图标
关键注意事项:
ResultSet使用后必须调用close(),否则会导致内存泄漏bindSheet使用$$双向绑定,确保弹窗内部关闭能同步状态AppDatabase.init()具有幂等性,多次调用不会重复创建数据库- relationalStore 同一时刻只能有一个写操作,批量写入建议使用事务包裹
下篇预告
下一篇: 待办 App(下):LazyForEach 性能优化与滑动删除
下篇将为待办应用添加
LazyForEach懒加载、@Reusable组件复用、滑动删除交互,以及IDataSource数据源的实现,全面提升列表滚动性能和交互体验。
更多推荐


所有评论(0)