鸿蒙 HarmonyOS 待办清单应用开发实战 ------ 基于 ArkTS + SQLite 的完整教程
一、前言
在快节奏的现代生活中,待办清单(Todo List) 是最基础也最实用的生产力工具之一。无论是学生管理学习任务、上班族跟踪工作进度,还是家庭主妇规划日常采购,一个清爽好用的待办清单应用都能让生活井井有条。
本文将带你从零到一,使用 HarmonyOS(鸿蒙系统) 的 ArkTS 语言和 Stage 模型,结合 SQLite 关系型数据库,打造一个功能完整、交互流畅的待办清单应用。
全文约 10000 字,涵盖项目架构、数据库设计、CRUD 操作、UI 构建、状态管理、定时提醒、筛选过滤等方方面面,所有代码均来自真实可运行的鸿蒙项目,你可以直接对照学习并在 DevEco Studio 中验证。
二、项目概览
2.1 功能清单
在动手编码之前,我们先来明确这个待办清单应用需要实现哪些功能:
| 功能模块 | 详细功能 | 优先级 |
|---|---|---|
| 新增待办 | 填写标题、描述、选择优先级、设置截止日期 | ⭐ 核心 |
| 查看列表 | 以卡片形式展示所有待办事项 | ⭐ 核心 |
| 编辑待办 | 修改已有待办的标题、描述、优先级、截止日期 | ⭐ 核心 |
| 删除待办 | 删除不再需要的待办事项(含确认弹窗) | ⭐ 核心 |
| 完成切换 | 通过 Checkbox 切换待办的完成/未完成状态 | ⭐ 核心 |
| 筛选过滤 | 按「全部」「待完成」「已完成」三种视图筛选 | ⭐ 重要 |
| 优先级管理 | 高/中/低三级优先级,不同颜色标识 | ⭐ 重要 |
| 截止日期 | 设置截止日期,逾期自动标记提醒 | ⭐ 重要 |
| 定时提醒 | 每 30 秒检查是否有今天截止的未完成待办 | ⭐ 加分 |
| 数据统计 | 顶部统计栏显示总计 / 待完成 / 已完成数量 | ⭐ 加分 |
| 数据持久化 | 使用 SQLite 关系型数据库存储所有数据 | ⭐ 核心 |
2.2 技术栈
| 技术 | 用途 | 说明 |
|---|---|---|
| ArkTS | 开发语言 | 基于 TypeScript 的声明式 UI 语言 |
| Stage 模型 | 应用模型 | HarmonyOS 3.0+ 主推的应用开发模型 |
@ohos.data.relationalStore |
数据库 API | 鸿蒙系统提供的 SQLite 关系型数据库接口 |
@State / @Builder |
状态管理与 UI 构建 | ArkTS 声明式 UI 的核心装饰器 |
CustomDialog |
自定义弹窗 | 用于新增/编辑待办的对话框 |
setInterval / clearInterval |
定时器 | 实现定时提醒检查 |
promptAction.showToast |
Toast 提示 | 操作结果的轻量反馈 |
三、项目结构
Demo0528/
├── AppScope/ # 应用级配置
│ └── app.json5 # 应用全局配置
├── entry/ # 主 Entry 模块
│ ├── src/main/
│ │ ├── ets/
│ │ │ ├── entryability/
│ │ │ │ └── EntryAbility.ets # Ability 入口
│ │ │ └── pages/
│ │ │ ├── Index.ets # 首页(入口引导页)
│ │ │ └── TodoList.ets # 待办清单主页面(1082 行)
│ │ ├── module.json5 # 模块配置
│ │ └── resources/ # 资源文件
│ ├── build-profile.json5
│ └── hvigorfile.ts
├── hvigor/ # 构建配置
├── build-profile.json5 # 全局构建配置
└── hvigorfile.ts # 构建脚本
3.1 关键文件说明
EntryAbility.ets:应用的入口 Ability,在onWindowStageCreate中直接加载pages/TodoList作为首页。Index.ets:一个简单的引导页,展示应用名称并提供"进入待办清单"按钮。实际运行时 EntryAbility 直接跳过了它,但在开发调试阶段可作为入口参考。TodoList.ets:应用的核心文件,包含数据库管理、自定义弹窗、主页面 UI 构建等全部逻辑,共 1082 行。
3.2 EntryAbility 配置
typescript
// EntryAbility.ets
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/TodoList', (err) => {
if (err.code) {
hilog.error(DOMAIN, 'testTag', 'Failed to load the content. Cause: %{public}s', JSON.stringify(err));
return;
}
hilog.info(DOMAIN, 'testTag', 'Succeeded in loading the content.');
});
}
这里直接将 TodoList 页面作为应用启动后的首页加载,跳过了 Index 引导页,用户打开应用就能直接看到待办清单。
四、数据持久化层设计
4.1 为什么选择 SQLite
在鸿蒙应用开发中,数据持久化方案主要有以下几种:
| 方案 | 适用场景 | 特点 |
|---|---|---|
| Preferences(首选项) | 键值对存储,适合少量配置 | 异步、轻量、不支持复杂查询 |
| SQLite (relationalStore) | 结构化数据,CRUD 操作频繁 | 关系型数据库,支持 SQL 查询、排序、过滤 |
| 分布式数据库 | 多设备数据协同 | 基于 SQLite,支持跨设备同步 |
| 文件存储 | 大文件、图片、日志 | 直接读写文件系统 |
对于待办清单这种需要频繁进行增删改查、排序、筛选的结构化数据场景,SQLite 是最合适的选择。鸿蒙系统提供了 @ohos.data.relationalStore API,封装了 SQLite 的底层操作,使用起来非常方便。
4.2 数据库表结构设计
typescript
const DB_NAME: string = 'todo_app.db';
const TABLE_NAME: string = 'todo';
const DB_VERSION: number = 1;
数据库建表 SQL:
sql
CREATE TABLE IF NOT EXISTS todo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
priority INTEGER DEFAULT 2, -- 1=高, 2=中, 3=低
dueDate TEXT DEFAULT '',
isCompleted INTEGER DEFAULT 0, -- 0=未完成, 1=已完成
createdTime TEXT DEFAULT ''
);
各字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
id |
INTEGER (PRIMARY KEY AUTOINCREMENT) | 主键,自增 |
title |
TEXT (NOT NULL) | 待办标题,必填 |
description |
TEXT (DEFAULT '') | 待办描述,可选 |
priority |
INTEGER (DEFAULT 2) | 优先级:1=高, 2=中, 3=低 |
dueDate |
TEXT (DEFAULT '') | 截止日期,格式:yyyy-MM-dd |
isCompleted |
INTEGER (DEFAULT 0) | 完成状态:0=未完成, 1=已完成 |
createdTime |
TEXT (DEFAULT '') | 创建时间,ISO 格式时间戳 |
4.3 数据模型(TypeScript 接口)
我们定义了两个接口来映射前后端的数据:
typescript
// 完整的待办事项(含 id)
interface TodoItem {
id: number;
title: string;
description: string;
priority: Priority;
dueDate: string;
isCompleted: number;
createdTime: string;
}
// 新增待办时的输入数据(不含 id 和 createdTime,由数据库自动生成)
interface InsertTodoItem {
title: string;
description: string;
priority: Priority;
dueDate: string;
isCompleted: number;
}
将"完整数据"和"新增数据"分离的设计,可以让类型系统更好地帮助我们避免错误------新增时不需要传 id 和 createdTime,这两个字段由数据库自动管理。
4.4 TodoDatabase 类 ------ 数据库管理核心
TodoDatabase 类封装了对 todo 表的所有操作,对外提供清晰的异步方法接口。
4.4.1 初始化与建表
typescript
class TodoDatabase {
private rdbStore: relationalStore.RdbStore | null = null;
async init(context: Context): Promise<void> {
if (this.rdbStore) {
return; // 避免重复初始化
}
const config: relationalStore.StoreConfig = {
name: DB_NAME,
securityLevel: relationalStore.SecurityLevel.S1,
};
this.rdbStore = await relationalStore.getRdbStore(context, config);
this.rdbStore.version = DB_VERSION;
await this.createTable();
console.info('[TodoDB] 数据库初始化成功');
}
private async createTable(): Promise<void> {
const sql = `CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
priority INTEGER DEFAULT ${Priority.MEDIUM},
dueDate TEXT DEFAULT '',
isCompleted INTEGER DEFAULT 0,
createdTime TEXT DEFAULT ''
)`;
await this.rdbStore!.executeSql(sql);
console.info('[TodoDB] 数据表创建/验证完成');
}
}
这里有几个设计要点:
- 单例模式 :全局只创建一个
TodoDatabase实例(const todoDB = new TodoDatabase()),避免重复打开数据库连接。 - 幂等建表 :使用
CREATE TABLE IF NOT EXISTS,无论应用启动多少次,建表操作都是安全的。 - 延迟初始化 :
init()方法需要Context参数,在组件aboutToAppear生命周期中调用,确保获取到正确的应用上下文。
4.4.2 查询所有待办(带排序)
typescript
async queryAll(): Promise<TodoItem[]> {
const predicates = new relationalStore.RdbPredicates(TABLE_NAME);
predicates.orderByAsc('isCompleted'); // 未完成的排在前面
predicates.orderByAsc('priority'); // 优先级高的排在前面
predicates.orderByAsc('dueDate'); // 截止日期早的排在前面
const resultSet = await this.rdbStore!.query(predicates);
// ... 遍历 resultSet 构造 TodoItem 数组
}
排序策略非常讲究用户体验:
- 第一排序(isCompleted 升序):未完成(0)排在已完成(1)前面,用户最关心的是还需要完成的事情。
- 第二排序(priority 升序):高优先级(1)排在中(2)前,中排在低(3)前,确保紧急事项优先展示。
- 第三排序(dueDate 升序):截止日期早的排在前面,进一步细化排序。
这种"三级排序"策略确保了列表的展示顺序总是:未完成的先按优先级排,同级内按截止日期排,最后才是已完成项。
4.4.3 新增待办
typescript
async insert(item: InsertTodoItem): Promise<number> {
if (!item.title.trim()) {
throw new Error('标题不能为空');
}
const value: relationalStore.ValuesBucket = {
title: item.title.trim(),
description: item.description.trim(),
priority: item.priority,
dueDate: item.dueDate,
isCompleted: item.isCompleted,
createdTime: new Date().toISOString(),
};
const rowId = await this.rdbStore.insert(TABLE_NAME, value);
return rowId;
}
- 输入校验:在数据库层就做空标题校验,双重保障数据完整性。
- 自动时间戳 :
createdTime在插入时自动生成,无需调用方传入。 - 修剪空白 :
trim()去除用户输入的首尾空格。
4.4.4 更新与切换完成状态
typescript
async update(item: TodoItem): Promise<void> {
if (!item.title.trim()) {
throw new Error('标题不能为空');
}
const value: relationalStore.ValuesBucket = {
title: item.title.trim(),
description: item.description.trim(),
priority: item.priority,
dueDate: item.dueDate,
isCompleted: item.isCompleted,
};
const predicates = new relationalStore.RdbPredicates(TABLE_NAME);
predicates.equalTo('id', item.id);
await this.rdbStore.update(value, predicates);
}
async toggleCompleted(id: number, currentStatus: number): Promise<void> {
const value: relationalStore.ValuesBucket = {
isCompleted: currentStatus === 0 ? 1 : 0,
};
const predicates = new relationalStore.RdbPredicates(TABLE_NAME);
predicates.equalTo('id', id);
await this.rdbStore.update(value, predicates);
}
update 用于编辑待办时更新全部字段,而 toggleCompleted 专门用于 Checkbox 点击------它只看当前状态,做了 0→1 或 1→0 的翻转,无需调用方自己计算新状态。
4.4.5 删除待办
typescript
async delete(id: number): Promise<void> {
const predicates = new relationalStore.RdbPredicates(TABLE_NAME);
predicates.equalTo('id', id);
await this.rdbStore.delete(predicates);
}
4.4.6 查询今天截止的未完成任务(提醒用)
typescript
async queryDueTodayActive(): Promise<TodoItem[]> {
const today = this.getTodayStr();
const predicates = new relationalStore.RdbPredicates(TABLE_NAME);
predicates.equalTo('isCompleted', 0);
predicates.equalTo('dueDate', today);
const resultSet = await this.rdbStore.query(predicates);
// ... 遍历 resultSet 构造列表
}
private getTodayStr(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
这个方法专为定时提醒功能设计,只查询"今天截止且未完成"的待办事项。数据库层面的过滤比在应用层过滤效率高得多,特别是当数据量较大时。
五、枚举与工具函数
5.1 优先级枚举
typescript
enum Priority {
HIGH = 1,
MEDIUM = 2,
LOW = 3,
}
Priority 枚举的值同时是数据库中的 priority 字段值,1 表示高优先级、2 表示中、3 表示低。这样设计的好处是:排序时直接按数值升序就能从高到低排列,无需额外映射。
对应的显示函数:
typescript
function getPriorityLabel(p: Priority): string {
switch (p) {
case Priority.HIGH: return '高';
case Priority.MEDIUM: return '中';
case Priority.LOW: return '低';
default: return '中';
}
}
function getPriorityColor(p: Priority): ResourceColor {
switch (p) {
case Priority.HIGH: return '#FF3B30'; // iOS 红色
case Priority.MEDIUM: return '#FF9500'; // iOS 橙色
case Priority.LOW: return '#007AFF'; // iOS 蓝色
default: return '#007AFF';
}
}
三种优先级的配色致敬了 iOS 系统级的语义颜色:
| 优先级 | 颜色 | 色值 | 语义 |
|---|---|---|---|
| 高 | 红色 | #FF3B30 |
紧急、警告、需要立即处理 |
| 中 | 橙色 | #FF9500 |
重要但不紧急 |
| 低 | 蓝色 | #007AFF |
常规、待定、可延后 |
5.2 筛选枚举
typescript
enum FilterType {
ALL = 0, // 全部
ACTIVE = 1, // 待完成
COMPLETED = 2,// 已完成
}
function getFilterLabel(filter: FilterType): string {
switch (filter) {
case FilterType.ALL: return '全部';
case FilterType.ACTIVE: return '待完成';
case FilterType.COMPLETED: return '已完成';
default: return '全部';
}
}
5.3 日期工具函数
typescript
/** 将 yyyy-MM-dd 格式日期转为中文显示 */
function formatDate(dateStr: string): string {
if (!dateStr) return '无截止日期';
const parts = dateStr.split('-');
if (parts.length !== 3) return dateStr;
return `${parts[0]}年${parseInt(parts[1], 10)}月${parseInt(parts[2], 10)}日`;
}
/** 判断是否逾期(日期小于今天) */
function isOverdue(dateStr: string): boolean {
if (!dateStr) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
const due = new Date(dateStr);
due.setHours(0, 0, 0, 0);
return due.getTime() < today.getTime();
}
/** 判断是否今天截止 */
function isDueToday(dateStr: string): boolean {
if (!dateStr) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
const due = new Date(dateStr);
due.setHours(0, 0, 0, 0);
return due.getTime() === today.getTime();
}
重要细节 :日期比较时一定要用 setHours(0, 0, 0, 0) 将时间归零,否则 new Date() 包含当前时分秒,直接比较 getTime() 会导致逻辑错误。例如,当前时间是 2025 年 6 月 5 日 14:30,new Date('2025-06-05').getTime() 是当天 00:00:00 的时间戳,如果不归零,new Date() 的 14:30 比 00:00 大,就会错误地认为 2025-06-05 是"已过去"的日期。
六、自定义弹窗 ------ TodoEditDialog
新增和编辑待办使用同一个自定义弹窗组件 TodoEditDialog,通过 editItem 参数区分两种模式。
6.1 组件定义与状态
typescript
@CustomDialog
struct TodoEditDialog {
controller: CustomDialogController;
editItem?: TodoItem; // 编辑模式传入;新增模式为 undefined
onConfirm: (title: string, description: string, priority: Priority, dueDate: string) => void;
@State title: string = '';
@State description: string = '';
@State priority: Priority = Priority.MEDIUM;
@State dueDate: string = '';
@State titleError: string = '';
@State showDatePicker: boolean = false;
@State pickYear: number = new Date().getFullYear();
@State pickMonth: number = new Date().getMonth() + 1;
@State pickDay: number = new Date().getDate();
}
关键设计思路:
- 双向复用 :新增和编辑使用同一个弹窗组件,通过
editItem是否为空区分模式。弹窗标题和确认按钮文案也随模式变化。 onConfirm回调:将确认操作委托给父组件处理,符合"子组件负责 UI,父组件负责逻辑"的职责分离原则。- 本地状态 :弹窗内部的
title、description、priority、dueDate等状态独立于主页面,在弹窗关闭时自动释放。
6.2 生命周期:aboutToAppear
typescript
aboutToAppear(): void {
if (this.editItem) {
// 编辑模式:用已有数据填充表单
this.title = this.editItem.title;
this.description = this.editItem.description;
this.priority = this.editItem.priority;
this.dueDate = this.editItem.dueDate;
// 解析已有日期到年份/月份/日期选择器
if (this.dueDate) {
const parts = this.dueDate.split('-');
if (parts.length === 3) {
this.pickYear = parseInt(parts[0], 10);
this.pickMonth = parseInt(parts[1], 10);
this.pickDay = parseInt(parts[2], 10);
}
}
}
// 新增模式保持默认值
}
在 aboutToAppear(类似 React 的 componentDidMount)中初始化表单数据,如果是编辑模式就将已有数据填入各字段,如果是新增模式就保持默认状态。
6.3 弹窗 UI 布局
弹窗的 UI 从上到下依次是:
┌──────────────────────────────────────┐
│ 编辑待办 / 新增待办 │ ← 标题
│ ┌────────────────────────────────┐ │
│ │ 请输入待办标题 * │ │ ← 标题输入(TextInput)
│ └────────────────────────────────┘ │
│ ┌────────────────────────────────┐ │
│ │ 描述(可选) │ │ ← 描述输入(TextArea)
│ └────────────────────────────────┘ │
│ │
│ 优先级 ○ 高 ○ 中 ○ 低 │ ← 三选一 Radio
│ │
│ 截止日期 2025年6月5日 📅 ✕ │ ← 日期选择
│ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │
│ │ 年 [2025] 月 [06] 日 [05] │ │ ← 日期选择器(展开后)
│ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 取消 │ │ 保存/添加 │ │ ← 底部按钮
│ └──────────┘ └──────────┘ │
└──────────────────────────────────────┘
标题输入
typescript
TextInput({ placeholder: '请输入待办标题 *', text: this.title })
.height(44)
.fontSize(15)
.placeholderColor('#C7C7CC')
.onChange((val: string) => {
this.title = val;
this.titleError = ''; // 用户重新输入时清除错误提示
})
.borderRadius(10)
.backgroundColor('#F2F2F7')
.padding({ left: 12, right: 12 })
- 标题输入框使用
#F2F2F7浅灰背景,营造 iOS 系统风格的输入框质感。 - 10px 圆角柔和视觉。
onChange中同时清除错误提示,让错误提示在用户修正时即时消失。
错误提示
typescript
if (this.titleError) {
Text(this.titleError)
.fontSize(12)
.fontColor('#FF3B30')
.width('100%')
.margin({ top: 4, bottom: 0 })
}
错误提示仅在 titleError 非空时显示,不影响正常状态下的布局。
优先级选择
typescript
Row() {
Text('优先级').fontSize(15).fontColor('#3A3A3C')
Blank()
ForEach([Priority.HIGH, Priority.MEDIUM, Priority.LOW], (p: Priority) => {
Row() {
Radio({ value: `${p}`, group: 'priority' })
.checked(this.priority === p)
.width(18).height(18)
.onChange(() => { this.priority = p; })
Text(getPriorityLabel(p))
.fontSize(14)
.fontColor(getPriorityColor(p))
.margin({ left: 4 })
}
.margin({ left: p === Priority.HIGH ? 0 : 12 })
})
}
- 使用
Radio单选组件,同一个group名称确保三选一互斥。 - 文字颜色使用对应的优先级颜色,让视觉和语义统一。
ForEach遍历枚举值,保证代码的简洁性和扩展性。
日期选择器
日期选择器是本应用中最复杂的 UI 组件之一。它采用"折叠式"设计------默认只显示日期文字和按钮,点击 📅 按钮后展开年/月/日输入框:
typescript
// 日期显示行
Row() {
Text('截止日期').fontSize(15).fontColor('#3A3A3C')
Blank()
Text(this.dueDate ? formatDate(this.dueDate) : '选择日期')
.fontSize(14)
.fontColor(this.dueDate ? '#1C1C1E' : '#C7C7CC')
.margin({ right: 8 })
Button() { Text('📅').fontSize(16) }
.width(36).height(36).borderRadius(18)
.backgroundColor('#F2F2F7')
.onClick(() => { this.showDatePicker = !this.showDatePicker; })
if (this.dueDate) {
Button() { Text('✕').fontSize(14).fontColor('#8E8E93') }
.width(36).height(36).borderRadius(18)
.backgroundColor('#F2F2F7')
.margin({ left: 6 })
.onClick(() => { this.dueDate = ''; this.showDatePicker = false; })
}
}
// 展开的日期选择器
if (this.showDatePicker) {
Column() {
Row() {
// 年输入
Column() {
Text('年').fontSize(12).fontColor('#8E8E93')
TextInput({ text: `${this.pickYear}` })
.height(36).width(80)
.onChange((val: string) => {
const n = parseInt(val, 10);
if (!isNaN(n) && n >= 2020 && n <= 2099) { this.pickYear = n; }
})
}
// 月输入(类似)
// 日输入(类似)
}
// 确认日期按钮
Button('确定日期')
.onClick(() => {
this.dueDate = `${this.pickYear}-${String(this.pickMonth).padStart(2, '0')}-${String(this.pickDay).padStart(2, '0')}`;
this.showDatePicker = false;
})
}
}
设计细节:
- 折叠式设计:日期选择器默认收起,不占用弹窗空间。只有用户点击 📅 按钮时才展开,这种渐进式披露(Progressive Disclosure)的设计模式让界面保持简洁。
- 清除按钮:当已有日期时,显示 ✕ 按钮一键清除日期,方便用户取消截止日期设置。
- 输入校验:年份限制在 2020-2099 年之间,月份限制在 1-12,日期根据年月动态计算最大值(支持闰年)。
底部操作按钮
typescript
Row() {
Button('取消')
.height(44).borderRadius(22)
.backgroundColor('#F2F2F7').fontColor('#007AFF')
.layoutWeight(1)
.onClick(() => { this.controller.close(); })
Blank().width(16)
Button(this.editItem ? '保存' : '添加')
.height(44).borderRadius(22)
.backgroundColor('#007AFF').fontColor('#FFFFFF')
.layoutWeight(1)
.onClick(() => {
if (!this.title.trim()) {
this.titleError = '标题不能为空';
return; // 阻止关闭弹窗
}
this.onConfirm(this.title.trim(), this.description.trim(), this.priority, this.dueDate);
this.controller.close();
})
}
layoutWeight(1):两个按钮等宽分布,视觉平衡。- 标题校验 :点击确认时先检查标题是否为空,为空则显示错误提示并 阻止弹窗关闭,用户修改后再次点击才能提交。
- 文字联动:确认按钮的文字在新增和编辑模式下自动切换为"添加"和"保存"。
七、主页面 TodoList 的实现
TodoList 是整个应用的核心组件,包含数据管理、生命周期、UI 构建等全部逻辑。
7.1 组件状态
typescript
@Entry
@Component
struct TodoList {
@State todoList: TodoItem[] = []; // 全部待办数据
@State currentFilter: FilterType = FilterType.ALL; // 当前筛选
@State isLoading: boolean = true; // 加载状态
private addDialogController: CustomDialogController | null = null;
private editDialogController: CustomDialogController | null = null;
private reminderTimer: number = -1;
private remindedToday: Set<number> = new Set();
}
@State todoList:驱动 UI 渲染的核心数据。每当数据发生变化(增、删、改、切换状态),loadData()重新从数据库获取最新数据,@State自动触发 UI 重新渲染。@State currentFilter:当前选中的筛选条件,切换时自动刷新列表展示。@State isLoading:用于控制加载中状态的显示。remindedToday:Set 集合存储已提醒过的待办 ID,避免同一条待办重复提醒。
7.2 生命周期管理
typescript
aboutToAppear(): void {
this.initDatabase();
}
aboutToDisappear(): void {
this.clearReminderTimer();
}
private async initDatabase(): Promise<void> {
try {
const context = getContext(this);
await todoDB.init(context);
await this.loadData();
this.startReminderTimer();
} catch (err) {
console.error(`[TodoList] 数据库初始化失败: ${JSON.stringify(err)}`);
promptAction.showToast({ message: '数据库初始化失败', duration: 2000 });
} finally {
this.isLoading = false;
}
}
private async loadData(): Promise<void> {
try {
this.todoList = await todoDB.queryAll();
} catch (err) {
console.error(`[TodoList] 数据加载失败: ${JSON.stringify(err)}`);
promptAction.showToast({ message: '数据加载失败', duration: 2000 });
}
}
生命周期流程:
aboutToAppear
↓
initDatabase ──→ 初始化 SQLite
↓
loadData ──→ 查询全部待办 → 更新 @State todoList
↓
startReminderTimer ──→ 启动定时提醒
↓
isLoading = false ──→ 渲染列表
这种"先加载数据,再渲染 UI"的时序设计,避免了白屏或空数据闪烁的问题。
7.3 定时提醒系统
定时提醒是本应用的一个亮眼功能,它可以在后台周期性检查是否有今天截止但未完成的待办,并通过 Toast 提醒用户。
typescript
private startReminderTimer(): void {
// 每 30 秒检查一次
this.reminderTimer = setInterval(() => {
this.checkDueReminder();
}, 30000);
// 启动后 1 秒立即检查一次,避免等待
setTimeout(() => { this.checkDueReminder(); }, 1000);
}
private clearReminderTimer(): void {
if (this.reminderTimer !== -1) {
clearInterval(this.reminderTimer);
this.reminderTimer = -1;
}
}
private async checkDueReminder(): Promise<void> {
try {
const items = await todoDB.queryDueTodayActive();
for (const item of items) {
if (!this.remindedToday.has(item.id)) {
this.remindedToday.add(item.id);
// 延迟 500ms 弹出,避免阻塞 UI
setTimeout(() => {
promptAction.showToast({
message: `⏰ 今天截止: ${item.title}`,
duration: 3000,
});
}, 500);
}
}
} catch (err) {
console.error(`[TodoList] 提醒检查失败: ${JSON.stringify(err)}`);
}
}
设计要点:
- 间隔 30 秒:频率适中,既不会太频繁消耗性能,也不会太久错过提醒。
- 立即首次检查:启动后 1 秒立即检查一次,让用户打开应用就能收到当天提醒,无需等待 30 秒。
- 去重机制 :
remindedTodaySet 确保同一条待办只提醒一次,避免重复打扰用户。 - 非阻断式:查询操作是异步的,延迟 500ms 弹出 Toast 也是异步的,不会阻塞主线程。
- 静默容错:提醒检查失败时只在控制台打印错误,不弹 Toast 干扰用户。
7.4 筛选逻辑
typescript
getFilteredList(): TodoItem[] {
if (this.currentFilter === FilterType.ALL) {
return this.todoList;
}
const target = this.currentFilter === FilterType.COMPLETED ? 1 : 0;
return this.todoList.filter(item => item.isCompleted === target);
}
getTotalCount(): number {
return this.todoList.length;
}
getActiveCount(): number {
return this.todoList.filter(item => item.isCompleted === 0).length;
}
getCompletedCount(): number {
return this.todoList.filter(item => item.isCompleted === 1).length;
}
筛选和统计逻辑都基于 @State todoList 进行计算,ArkTS 的响应式系统会确保这些计算属性在数据变化时自动重新求值。
7.5 CRUD 操作
新增待办
typescript
private async addTodo(title: string, description: string, priority: Priority, dueDate: string): Promise<void> {
try {
const data: InsertTodoItem = {
title, description, priority, dueDate, isCompleted: 0,
};
await todoDB.insert(data);
await this.loadData(); // 重新加载列表
promptAction.showToast({ message: '✅ 添加成功', duration: 1500 });
} catch (err) {
promptAction.showToast({ message: `添加失败: ${(err as Error).message}`, duration: 2000 });
}
}
编辑待办
typescript
private async editTodo(item: TodoItem): Promise<void> {
try {
await todoDB.update(item);
await this.loadData();
promptAction.showToast({ message: '✅ 更新成功', duration: 1500 });
} catch (err) {
promptAction.showToast({ message: `更新失败: ${(err as Error).message}`, duration: 2000 });
}
}
切换完成状态
typescript
private async toggleTodo(id: number, currentStatus: number): Promise<void> {
try {
await todoDB.toggleCompleted(id, currentStatus);
await this.loadData();
} catch (err) {
promptAction.showToast({ message: '状态切换失败', duration: 2000 });
}
}
删除待办(带确认)
typescript
private async deleteTodo(id: number): Promise<void> {
try {
await todoDB.delete(id);
this.remindedToday.delete(id); // 清除提醒记录
await this.loadData();
promptAction.showToast({ message: '🗑️ 已删除', duration: 1500 });
} catch (err) {
promptAction.showToast({ message: '删除失败', duration: 2000 });
}
}
confirmDelete(item: TodoItem): void {
AlertDialog.show({
title: '删除待办',
message: `确定要删除「${item.title}」吗?`,
autoCancel: true,
primaryButton: { value: '取消', action: () => {} },
secondaryButton: {
value: '删除',
fontColor: '#FF3B30',
action: () => { this.deleteTodo(item.id); },
},
});
}
删除确认 :使用 AlertDialog.show 弹出系统级确认框。"删除"按钮使用红色(#FF3B30)传达危险操作的语义,让用户在做删除操作时多一次确认机会,避免误删。
7.6 UI 构建
整体布局
typescript
build() {
Column() {
this.buildHeader() // 顶部导航栏
this.buildStatsBar() // 统计栏
this.buildFilterBar() // 筛选标签
if (this.isLoading) {
LoadingProgress() // 加载中
} else if (this.getFilteredList().length === 0) {
this.buildEmptyState() // 空状态
} else {
this.buildTodoList() // 待办列表
}
Blank().height(20) // 底部安全区
}
.width('100%')
.height('100%')
.backgroundColor('#F2F2F7')
}
页面结构分为五个区域,从上到下依次是:
┌──────────────────────────────────┐
│ 📋 我的待办 + 添加 │ ← 顶部导航栏(buildHeader)
├──────────────────────────────────┤
│ 📊 总计 ⏳ 待完成 ✅ 已完成 │ ← 统计栏(buildStatsBar)
├──────────────────────────────────┤
│ [全部] [待完成] [已完成] │ ← 筛选标签(buildFilterBar)
├──────────────────────────────────┤
│ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │
│ │ 待办卡片 1 │ │
│ │ 待办卡片 2 │ │ ← 待办列表(buildTodoList)
│ │ 待办卡片 3 │ │ 或 空状态(buildEmptyState)
│ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │
├──────────────────────────────────┤
│ │ ← 底部安全区
└──────────────────────────────────┘
顶部导航栏
typescript
@Builder
buildHeader() {
Row() {
Text('📋 我的待办')
.fontSize(22).fontWeight(FontWeight.Bold).fontColor('#1C1C1E')
Blank()
Button() {
Text('+ 添加')
.fontSize(15).fontColor('#FFFFFF').fontWeight(FontWeight.Medium)
}
.height(36).borderRadius(18).backgroundColor('#007AFF')
.padding({ left: 16, right: 16 })
.onClick(() => { this.openAddDialog(); })
}
.width('100%')
.padding({ left: 20, right: 20, top: 56, bottom: 12 })
.backgroundColor('#FFFFFF')
}
- 顶部 padding
top: 56:为状态栏让出空间(类似 iOS 的 Safe Area)。 - iOS 风格导航栏:左侧标题,右侧操作按钮,符合移动端用户的操作习惯。
- 圆角按钮:36px 高度 + 18px borderRadius = 胶囊按钮,风格现代。
统计栏
typescript
@Builder
buildStatsBar() {
Row() {
this.buildStatItem('📊 总计', this.getTotalCount(), '#007AFF')
Blank()
this.buildStatItem('⏳ 待完成', this.getActiveCount(), '#FF9500')
Blank()
this.buildStatItem('✅ 已完成', this.getCompletedCount(), '#34C759')
}
.width('100%')
.padding({ left: 20, right: 20, top: 12, bottom: 12 })
.backgroundColor('#FFFFFF')
.margin({ bottom: 8 })
}
@Builder
buildStatItem(label: string, count: number, color: ResourceColor) {
Column() {
Text(`${count}`)
.fontSize(24).fontWeight(FontWeight.Bold).fontColor(color)
Text(label)
.fontSize(12).fontColor('#8E8E93').margin({ top: 2 })
}
.alignItems(HorizontalAlign.Center)
}
三个统计块等宽分布,使用 Blank() 在 Row 中均分空间。每个统计块的数字采用不同的颜色:
| 统计项 | 颜色 | 色值 | 含义 |
|---|---|---|---|
| 总计 | 蓝色 | #007AFF |
中性、信息 |
| 待完成 | 橙色 | #FF9500 |
警告、需要注意 |
| 已完成 | 绿色 | #34C759 |
完成、正向 |
筛选标签栏
typescript
@Builder
buildFilterBar() {
Row() {
ForEach([FilterType.ALL, FilterType.ACTIVE, FilterType.COMPLETED], (filter: FilterType) => {
Button() {
Text(getFilterLabel(filter))
.fontSize(14)
.fontWeight(this.currentFilter === filter ? FontWeight.Medium : FontWeight.Regular)
.fontColor(this.currentFilter === filter ? '#FFFFFF' : '#3A3A3C')
}
.height(32).borderRadius(16)
.backgroundColor(this.currentFilter === filter ? '#007AFF' : '#E5E5EA')
.padding({ left: 16, right: 16 }).margin({ right: 8 })
.onClick(() => { this.currentFilter = filter; })
})
}
.width('100%')
.padding({ left: 20, right: 20, bottom: 8 })
}
- 胶囊式标签:32px 高度 + 16px borderRadius,风格统一。
- 选中态高亮:选中时蓝色背景白色文字,未选中时浅灰色背景深色文字,对比清晰。
- 点击切换筛选条件 :仅修改
currentFilter状态变量,不请求数据库,列表展示由getFilteredList()实时计算。
空状态
typescript
@Builder
buildEmptyState() {
Column() {
Blank().layoutWeight(1)
Text('📝').fontSize(64)
Text('暂无待办事项').fontSize(16).fontColor('#8E8E93').margin({ top: 12 })
Text('点击右上角「添加」按钮创建第一条待办')
.fontSize(13).fontColor('#C7C7CC').margin({ top: 6 })
Blank().layoutWeight(1)
}
.width('100%').layoutWeight(1)
}
- 使用
Blank().layoutWeight(1)将内容垂直居中。 - 友好的文案引导用户操作,而不是让用户面对白屏不知所措。
- 三个层级:Emoji → 主文案 → 引导文案,视觉重点明确。
待办列表
typescript
@Builder
buildTodoList() {
Scroll() {
Column() {
ForEach(this.getFilteredList(), (item: TodoItem) => {
this.buildTodoCard(item)
Blank().height(10)
})
Blank().height(40) // 底部留白,便于最后一项滚动查看
}
.width('100%')
.padding({ left: 16, right: 16 })
}
.layoutWeight(1)
.width('100%')
}
外层 Scroll 包裹 Column,当待办项超出屏幕高度时用户可滚动查看。底部 40px 留白确保最后一项不会被底部工具栏遮挡。
7.7 待办卡片设计
每个待办事项都以卡片形式展示,这是整个应用 UI 设计的核心。
typescript
@Builder
buildTodoCard(item: TodoItem) {
Column() {
Row() {
// ─── 左侧:Checkbox ───
Checkbox()
.select(item.isCompleted === 1)
.width(22).height(22)
.shape(CheckBoxShape.ROUNDED_SQUARE)
.selectedColor('#34C759')
.onChange((val: boolean) => {
this.toggleTodo(item.id, item.isCompleted);
})
Blank().width(12)
// ─── 中间:标题 + 描述 + 标签 ───
Column() {
// 标题
Text(item.title)
.fontSize(16).fontWeight(FontWeight.Medium)
.fontColor(item.isCompleted === 1 ? '#C7C7CC' : '#1C1C1E')
.decoration({
type: item.isCompleted === 1 ? TextDecorationType.LineThrough : TextDecorationType.None
})
.maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis })
// 描述(可选)
if (item.description) {
Text(item.description)
.fontSize(13).fontColor('#8E8E93')
.margin({ top: 4 })
.maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
}
Blank().height(6)
// 标签行:优先级 + 截止日期 + 逾期标记
Row() {
// 优先级标签
Text(getPriorityLabel(item.priority))
.fontSize(11).fontColor('#FFFFFF')
.backgroundColor(getPriorityColor(item.priority))
.borderRadius(4)
.padding({ left: 6, right: 6, top: 2, bottom: 2 })
Blank().width(8)
// 截止日期
if (item.dueDate) {
Text(formatDate(item.dueDate))
.fontSize(12)
.fontColor(
isOverdue(item.dueDate) && item.isCompleted === 0 ? '#FF3B30'
: isDueToday(item.dueDate) && item.isCompleted === 0 ? '#FF9500'
: '#8E8E93'
)
}
// 逾期标记
if (isOverdue(item.dueDate) && item.isCompleted === 0) {
Text('⚠️ 已逾期').fontSize(11).fontColor('#FF3B30').margin({ left: 6 })
}
// 今日截止标记
if (isDueToday(item.dueDate) && item.isCompleted === 0) {
Text('🔔 今天截止').fontSize(11).fontColor('#FF9500').margin({ left: 6 })
}
}
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
// ─── 右侧:编辑 + 删除按钮 ───
Column() {
Button() { Text('✏️').fontSize(14) }
.width(32).height(32).borderRadius(16).backgroundColor('#F2F2F7')
.onClick(() => { this.openEditDialog(item); })
Blank().height(6)
Button() { Text('🗑️').fontSize(14) }
.width(32).height(32).borderRadius(16).backgroundColor('#FFF0F0')
.onClick(() => { this.confirmDelete(item); })
}
.alignItems(HorizontalAlign.Center)
}
.width('100%')
.alignItems(VerticalAlign.Top)
.padding({ left: 14, right: 14, top: 14, bottom: 14 })
}
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(14)
.shadow({ radius: 6, color: 'rgba(0,0,0,0.04)', offsetY: 2 })
}
卡片布局解析
每张卡片从左到右分为三个区域:
┌────────────────────────────────────────────────────┐
│ ┌────┐ ┌────────────────────┐ ┌────┐ │
│ │ ✅ │ │ 购买生活用品 │ │ ✏️ │ │
│ │ │ │ 去超市买牛奶和面包 │ │ 🗑️ │ │
│ │ │ │ 中 2025年6月5日 │ └────┘ │
│ └────┘ └────────────────────┘ │
└────────────────────────────────────────────────────┘
左侧 Checkbox:
- 使用
Checkbox组件,采用ROUNDED_SQUARE样式(圆角方形),比纯圆形更现代。 - 选中色为
#34C759(iOS 绿色),与完成态的语义一致。 - 完成状态通过
.select()控制,点击通过onChange回调处理。
中间内容区:
- 标题 :已完成项使用灰色(
#C7C7CC)加删除线(LineThrough),视觉上明确标记"已划掉"。 - 描述:可选,最多显示 2 行,超出显示省略号。
- 标签行:包含优先级标签、截止日期、逾期/今日截止标记。
右侧操作区:
- 编辑按钮:浅灰背景的 ✏️ 图标,位置固定,方便用户快速定位。
- 删除按钮 :浅红背景(
#FFF0F0)的 🗑️ 图标,红色背景暗示危险操作。
卡片样式
typescript
.backgroundColor('#FFFFFF')
.borderRadius(14)
.shadow({ radius: 6, color: 'rgba(0,0,0,0.04)', offsetY: 2 })
- 白色背景 + 14px 圆角 + 柔和阴影,是典型的 iOS 卡片风格。
- 阴影参数
color: 'rgba(0,0,0,0.04)'透明度极低,营造"悬浮但不刺眼"的视觉效果。 - 卡片与卡片之间使用 10px 间距(在
ForEach循环中插入Blank().height(10))。
日期颜色语义
截止日期的颜色根据剩余时间动态变化:
| 状态 | 颜色 | 色值 | 文案 |
|---|---|---|---|
| 正常(还有时间) | 灰色 | #8E8E93 |
2025年6月5日 |
| 今天截止 | 橙色 | #FF9500 |
🔔 今天截止 |
| 已逾期 | 红色 | #FF3B30 |
⚠️ 已逾期 |
这种颜色即信息的设计让用户一眼就能识别待办的紧急程度,无需仔细阅读日期数据。
7.8 对话框打开控制
typescript
openAddDialog(): void {
this.addDialogController = new CustomDialogController({
builder: TodoEditDialog({
editItem: undefined, // 新增模式
onConfirm: (title, description, priority, dueDate) => {
this.addTodo(title, description, priority, dueDate);
},
}),
autoCancel: true, // 点击弹窗外区域自动关闭
customStyle: true, // 自定义样式
cornerRadius: 20,
width: '90%',
});
this.addDialogController.open();
}
openEditDialog(item: TodoItem): void {
this.editDialogController = new CustomDialogController({
builder: TodoEditDialog({
editItem: item, // 编辑模式,传入已有数据
onConfirm: (title, description, priority, dueDate) => {
this.editTodo({
id: item.id, title, description, priority, dueDate,
isCompleted: item.isCompleted,
createdTime: item.createdTime,
});
},
}),
autoCancel: true,
customStyle: true,
cornerRadius: 20,
width: '90%',
});
this.editDialogController.open();
}
每次打开弹窗时都创建一个新的 CustomDialogController 实例,这样可以确保每次打开都是"干净"的状态。
八、页面路由配置
8.1 main_pages.json
json
{
"src": [
"pages/TodoList"
]
}
由于 EntryAbility 直接加载 TodoList 页面,路由表中只需注册这一个页面即可。
8.2 Index.ets(引导页)
虽然入口直接跳过了 Index 页面,但它依然保留在项目中作为独立的引导入口:
typescript
@Entry
@Component
struct Index {
build() {
Column() {
Text('📋 待办清单应用')
.fontSize(28).fontWeight(FontWeight.Bold).fontColor('#1a1a2e')
Text('管理您的日常任务')
.fontSize(14).fontColor('#666666')
.margin({ top: 8, bottom: 40 })
Button('进入待办清单')
.type(ButtonType.Capsule)
.width(220).height(48)
.backgroundColor('#007aff')
.fontColor(Color.White).fontSize(16).fontWeight(FontWeight.Medium)
.onClick(() => {
router.pushUrl({ url: 'pages/TodoList' });
})
}
.width('100%').height('100%')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
.backgroundColor('#f5f5f5')
}
}
这个页面作为一个简洁的启动屏,展示了应用名称和一句描述,用户点击按钮后跳转到待办清单页面。
九、UI 设计深度解析
9.1 整体设计风格
本应用采用了 iOS 风格的 UI 设计语言,主要体现在以下方面:
| 设计元素 | 应用中的体现 |
|---|---|
| 卡片式布局 | 每个待办事项都是一个白色圆角卡片 |
| 大面积留白 | 列表项之间 10px 间距,卡片内 14px padding |
| 柔和阴影 | 卡片的 shadow 透明度仅 4%,非常克制 |
| 系统字体 | 使用系统默认字体,无衬线 |
| 蓝色主色调 | #007AFF 是 iOS 系统蓝,用于导航和选中态 |
| 底部安全区 | 20px 底部留白适配全面屏 |
9.2 颜色体系
主色(导航/按钮/选中):#007AFF ------ 可靠、专业
成功(完成状态): #34C759 ------ 完成、正向
警告(待完成): #FF9500 ------ 需要注意
危险(删除/逾期): #FF3B30 ------ 紧急、不可逆
文字主色: #1C1C1E ------ 高对比度
文字次要: #8E8E93 ------ 说明文字
文字禁用: #C7C7CC ------ 已完成的标题
页面背景: #F2F2F7 ------ 浅灰,突出卡片
卡片背景: #FFFFFF ------ 纯白
这套配色完全遵循了 iOS Human Interface Guidelines 的颜色体系,确保了系统级的一致性和良好的可读性。
9.3 圆角系统
弹窗外圆角: 20px (最外层)
卡片圆角: 14px (待办卡片)
输入框圆角: 10px (弹窗中的输入框)
按钮圆角: 18px/16px/22px (胶囊风格)
复选框圆角: ROUNDED_SQUARE (圆角方形)
圆角值的大小与组件的"层次"相关:越靠外的组件圆角越大,形成嵌套的视觉节奏。
9.4 字体层级
| 元素 | 字号 | 字重 | 颜色 |
|---|---|---|---|
| 导航标题 | 22px | Bold | #1C1C1E |
| 统计数字 | 24px | Bold | 语义色 |
| 待办标题 | 16px | Medium | 完成态 #C7C7CC / 未完成 #1C1C1E |
| 待办描述 | 13px | Regular | #8E8E93 |
| 标签文字 | 11px | Regular | #FFFFFF |
| 筛选文字 | 14px | Medium/Regular | 选中 #FFFFFF / 未选 #3A3A3C |
| 统计标签 | 12px | Regular | #8E8E93 |
| 空状态提示 | 16px/13px | Regular | #8E8E93 / #C7C7CC |
字号的递进关系(11 → 12 → 13 → 14 → 16 → 22 → 24)形成了清晰的视觉层级,用户一眼就能区分标题、正文和辅助信息。
十、状态管理与数据流
10.1 单向数据流
ArkTS 采用单向数据流的设计模式,数据的流动方向是:
用户操作 → 事件回调 → 状态变量更新 → UI 重新渲染
具体到本应用:
用户点击 Checkbox
↓
onChange 回调 → toggleTodo(id, status)
↓
todoDB.toggleCompleted(id, status) → SQLite UPDATE
↓
loadData() → todoDB.queryAll() → @State todoList 更新
↓
UI 自动重新渲染(标题颜色变化、完成态切换)
10.2 @State 驱动的响应式更新
ArkTS 中 @State 装饰器的核心特性:
- 自动追踪 :当
@State变量的值发生变化时,框架自动记录哪些组件依赖了该变量。 - 最小化更新:只重新渲染依赖了发生变化的状态变量的组件,而非整个页面。
- 同步更新:状态变量的修改和 UI 的重新渲染在同一个帧内完成,不会出现"闪烁"。
在本应用中,@State todoList: TodoItem[] 是唯一的"数据源",所有 UI 展示(列表、统计、筛选)都基于这个数组进行计算。
10.3 数据流示意图
┌─────────────┐
│ TodoList │
│ @State │
│ todoList │ ←──── 数据源
└──────┬──────┘
┌────────────────┼────────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌────────────┐ ┌──────────────┐
│ 列表展示 │ │ 统计栏 │ │ 筛选条件 │
│ (Filtered)│ │ (Counts) │ │ (currentFilter)
└──────────┘ └────────────┘ └──────────────┘
│
▼
┌──────────┐
│ 数据库 │
│ SQLite │
└──────────┘
10.4 数据持久化策略
本应用的数据持久化采用"读-写-读"模式:
- 读 :应用启动时
aboutToAppear→initDatabase→loadData→ 全量读取到内存。 - 写 :每次增/删/改操作后,立即重新
loadData刷新内存数据。 - 内存即界面 :
@State todoList持有内存中的全量数据,UI 展示直接基于内存数据计算。
这种策略的优点:
- 实现简单:无需设计复杂的状态管理方案。
- 数据一致:每次操作后立即从数据库刷新,保证 UI 与数据库同步。
- 适合小数据量:待办清单通常不会超过几百条记录,全量加载性能完全 OK。
但当数据量极大时(如数万条),可以考虑引入分页加载和增量更新策略。
十一、异常处理与用户体验
11.1 多层次的异常处理
本应用在三个层面做了异常处理:
数据库层
typescript
async insert(item: InsertTodoItem): Promise<number> {
if (!item.title.trim()) {
throw new Error('标题不能为空');
}
// ...
}
数据库层校验数据合法性,抛出业务语义的错误。
业务逻辑层
typescript
private async addTodo(...): Promise<void> {
try {
await todoDB.insert(data);
await this.loadData();
promptAction.showToast({ message: '✅ 添加成功', duration: 1500 });
} catch (err) {
promptAction.showToast({ message: `添加失败: ${(err as Error).message}`, duration: 2000 });
}
}
业务逻辑层捕获数据库层的异常,将技术错误转化为用户可读的 Toast 提示。
UI 层
typescript
onClick(() => {
if (!this.title.trim()) {
this.titleError = '标题不能为空';
return;
}
this.onConfirm(...);
this.controller.close();
})
UI 层面进行输入校验,在提交前拦截空标题,通过红色错误文字即时反馈。
11.2 用户反馈设计
| 操作 | 反馈方式 | 反馈内容 |
|---|---|---|
| 新增成功 | Toast | ✅ 添加成功 |
| 编辑成功 | Toast | ✅ 更新成功 |
| 删除成功 | Toast | 🗑️ 已删除 |
| 删除操作 | AlertDialog 确认框 | 确定要删除「xxx」吗? |
| 空标题提交 | 红色文字提示 | 标题不能为空 |
| 数据库初始化失败 | Toast | 数据库初始化失败 |
| 数据加载失败 | Toast | 数据加载失败 |
| 今天截止提醒 | Toast | ⏰ 今天截止: xxx |
所有的用户操作都有明确的反馈,无论是成功、失败还是需要用户确认,都不会让用户陷入"操作没反应"的困惑中。
11.3 加载与空状态
- 加载中 :首次进入应用时显示
LoadingProgress旋转加载指示器,告知用户"数据正在加载"。 - 空数据:当没有任何待办事项时,显示友好的空状态界面,引导用户创建第一条待办。
- 加载完成:加载完成后才展示列表,避免数据闪烁。
十二、性能优化
12.1 数据库查询优化
- 索引利用 :
id字段是主键,具有自动索引。isCompleted和dueDate虽然没有显式创建索引,但在数据量不大的场景下性能足够。 - 分次查询 :提醒检查使用专门的
queryDueTodayActive方法(带isCompleted=0 AND dueDate=today过滤条件),而非查询全部数据后再在内存中过滤。
12.2 UI 渲染优化
- ForEach 列表渲染 :ArkTS 的
ForEach组件会对列表项进行键值追踪,当数据变化时只重新渲染发生变化的项,而非整个列表。 - 条件渲染 :使用
if/else条件渲染加载状态、空状态和列表,避免同时渲染不需要的组件。 - maxLines + Ellipsis:标题和描述设置了最大行数和省略号,防止长文本撑破卡片布局。
12.3 内存管理
- 定时器清理 :在
aboutToDisappear生命周期中清理setInterval,避免组件销毁后定时器仍然运行。 - 弹窗控制器释放:每次关闭弹窗后,控制器自动释放相关资源。
- remindedToday 清理 :删除待办时同步清除
remindedToday中的记录,避免内存泄漏。
十三、常见问题与排错
13.1 数据库初始化失败
现象:应用启动后显示"数据库初始化失败"Toast。
可能原因:
Context获取不正确:getContext(this)必须在组件生命周期内调用。- 存储权限不足:检查
module.json5中是否配置了存储权限。 - 数据库文件损坏:尝试卸载应用后重新安装。
13.2 数据操作后列表不刷新
现象:新增或编辑待办后,列表没有变化。
检查步骤:
- 确认
loadData()在addTodo/editTodo/deleteTodo方法中被调用。 - 确认
todoList变量使用了@State装饰器。 - 检查控制台是否有错误日志。
typescript
private async addTodo(...): Promise<void> {
try {
await todoDB.insert(data);
await this.loadData(); // ← 关键:重新加载数据
promptAction.showToast({ message: '✅ 添加成功', duration: 1500 });
} catch (err) { ... }
}
13.3 提醒没有弹出
现象:设置了今天截止的待办,但没有收到 Toast 提醒。
检查:
- 确认待办的
dueDate格式正确(yyyy-MM-dd,如2025-06-05)。 - 确认待办的
isCompleted为 0(未完成)。 - 确认当前设备的时间设置正确。
- 检查控制台是否有
[TodoList] 提醒检查失败的日志。
13.4 日期选择器验证不通过
现象:输入年份或月份后,确定日期按钮无反应。
原因 :日期选择器的输入框 onChange 中有校验逻辑,超出范围的数字会被忽略。
typescript
.onChange((val: string) => {
const n = parseInt(val, 10);
if (!isNaN(n) && n >= 2020 && n <= 2099) {
this.pickYear = n;
}
// 如果输入不合法,值不会改变
})
13.5 弹窗样式异常
现象:弹窗显示为系统默认样式,没有圆角和自定义布局。
检查:
- 确认
CustomDialogController配置了customStyle: true。 - 确认弹窗组件使用了
@CustomDialog装饰器。 - 检查弹窗组件的
build()方法中是否使用了正确的布局容器。
十四、扩展与改进方向
14.1 功能扩展
本应用已经实现了一个待办清单的核心功能,未来可以从以下方向继续扩展:
| 扩展方向 | 实现思路 | 难度 |
|---|---|---|
| 分类/标签 | 增加 category 字段,支持按类别筛选 |
⭐⭐ |
| 提醒通知 | 使用 @ohos.backgroundTaskManager 实现后台推送通知 |
⭐⭐⭐ |
| 数据导出 | 将待办数据导出为 CSV 或 JSON 文件 | ⭐⭐ |
| 主题切换 | 支持深色模式、自定义主题色 | ⭐⭐ |
| 排序自定义 | 允许用户按创建时间、优先级、截止日期等自定义排序 | ⭐⭐ |
| 搜索功能 | 在列表上方增加搜索栏,实时过滤 | ⭐⭐ |
| 子任务 | 每个待办项下可以添加多个子任务 | ⭐⭐⭐ |
| 重复任务 | 支持每日/每周/每月重复的周期性待办 | ⭐⭐⭐ |
| 云同步 | 使用华为云或自定义后端实现多设备数据同步 | ⭐⭐⭐⭐ |
14.2 代码架构优化
当前版本将所有代码集中在 TodoList.ets 文件中(1082 行),虽然在小项目中可以接受,但随着功能增加,建议进行模块拆分:
ets/pages/
├── TodoList.ets # 主页面(UI + 状态管理)
├── model/
│ ├── TodoItem.ts # 数据模型接口
│ ├── Priority.ts # 优先级枚举
│ └── FilterType.ts # 筛选枚举
├── database/
│ └── TodoDatabase.ts # 数据库操作类
├── component/
│ └── TodoEditDialog.ets # 自定义弹窗组件
└── utils/
└── dateUtils.ts # 日期工具函数
14.3 性能优化建议
- 虚拟列表 :当待办数量超过 100 条时,建议使用
LazyForEach替代ForEach实现虚拟滚动。 - 增量加载:首次只加载最近 50 条数据,用户滚动到底部时自动加载更多。
- 防抖搜索:搜索功能中,使用防抖(debounce)减少频繁查询。
- 数据库索引 :为
isCompleted和dueDate字段添加显式索引,加快查询速度。
十五、总结
15.1 知识点回顾
本文从零到一实现了一个完整的鸿蒙待办清单应用,覆盖了以下核心知识点:
| 知识点 | 详细内容 |
|---|---|
| ArkTS 组件体系 | @Entry, @Component, @CustomDialog, @Builder, build() |
| 状态管理 | @State 装饰器驱动的响应式 UI |
| SQLite 数据库 | @ohos.data.relationalStore 的增删改查操作 |
| 数据模型设计 | TodoItem / InsertTodoItem 接口分离 |
| 自定义弹窗 | CustomDialog + 自定义样式 + 回调通信 |
| 表单校验 | 输入前校验 + 错误提示展示 |
| 列表渲染 | ForEach + 卡片式布局 |
| 筛选功能 | 枚举 + 内存过滤 |
| 定时任务 | setInterval + 生命周期管理 |
| 日期处理 | 格式化、比较、逾期/今日判断 |
| 交互反馈 | Toast + AlertDialog 双层反馈 |
| 异常处理 | try/catch + 用户友好的错误提示 |
15.2 设计哲学回顾
一个好的待办清单应用,应该做到以下几点:
- 启动即用:打开应用直接看到待办列表,零等待、零操作。
- 操作即得:新增、编辑、删除、切换状态都有即时反馈,不用等待网络请求。
- 一目了然:颜色、图标、文字共同传达信息,用户无需阅读细节就能判断紧急程度。
- 无情提醒:定时检查逾期和今日截止任务,不让用户错过重要事项。
- 轻松管理:筛选功能让用户在不同场景下只关注自己需要的待办。
15.3 结语
鸿蒙生态正处于快速发展的阶段,