HarmonyOS 5.0 自定义组件与状态管理实战:用 RelationalStore 构建可复用的任务看板
做过一段时间鸿蒙开发之后,大部分人都会碰到同一个问题:页面越写越长,逻辑越堆越乱,同样的 UI 在不同页面复制了一遍又一遍。这篇文章想解决的就是这件事。怎么封装真正可复用的自定义组件、组件之间的状态怎么管理、数据怎么用 RelationalStore 存到本地数据库。为什么选 RelationalStore 而不是上一篇用的 Preferences?因为任务看板需要按状态筛选、按时间排序、支持更新
一、前言
做过一段时间鸿蒙开发之后,大部分人都会碰到同一个问题:页面越写越长,逻辑越堆越乱,同样的 UI 在不同页面复制了一遍又一遍。
这篇文章想解决的就是这件事。通过一个任务看板(Todo Board)的完整案例,系统讲清楚三件事:怎么封装真正可复用的自定义组件、组件之间的状态怎么管理、数据怎么用 RelationalStore 存到本地数据库。
为什么选 RelationalStore 而不是上一篇用的 Preferences?因为任务看板需要按状态筛选、按时间排序、支持更新操作,这些场景用键值对存储会很别扭,而关系型数据库处理起来很自然。而且 RelationalStore 是 HarmonyOS 5.0 推荐的本地数据库方案,值得专门花时间掌握。
最终效果:一个支持新增任务、拖动切换状态(待办/进行中/已完成)、按状态筛选、数据本地持久化的任务看板 App。
二、项目结构规划
2.1 功能拆解
拿到需求先别急着写代码,先把页面拆成组件:
- TaskBoard(主页面):整体布局,持有全局任务数据
- StatusTab(状态标签栏):待办/进行中/已完成的切换 Tab,可复用
- TaskCard(任务卡片):单条任务的展示,包含标题、优先级、操作按钮,高度可复用
- AddTaskDialog(添加弹窗):自定义对话框组件
- PriorityBadge(优先级标签):纯展示型小组件
拆完之后再看数据流:主页面持有任务列表,TaskCard 需要触发状态变更,AddTaskDialog 需要回传新任务。这就涉及到父子组件通信,后面会详细讲。
2.2 目录结构
entry/src/main/ets/
├── entryability/
│ └── EntryAbility.ets
├── database/
│ ├── TaskDB.ets // 数据库初始化与连接管理
│ └── TaskDao.ets // 数据访问对象(增删改查)
├── model/
│ └── Task.ets // 任务数据模型
├── pages/
│ └── TaskBoard.ets // 主页面
└── components/
├── TaskCard.ets // 任务卡片
├── StatusTab.ets // 状态标签栏
├── AddTaskDialog.ets // 添加任务弹窗
└── PriorityBadge.ets // 优先级标签

database 层和 model 层分开,是为了让数据结构的定义和数据库操作互不干扰。DAO(Data Access Object)模式在 Android 开发里很常见,鸿蒙里同样适用。
三、数据模型与数据库设计
3.1 任务数据模型
// model/Task.ets
export enum TaskStatus {
TODO = 0, // 待办
IN_PROGRESS = 1, // 进行中
DONE = 2 // 已完成
}
export enum TaskPriority {
LOW = 0,
MEDIUM = 1,
HIGH = 2
}
export interface Task {
id: number;
title: string;
description: string;
status: TaskStatus;
priority: TaskPriority;
createTime: number; // 时间戳
updateTime: number;
}
// 创建新任务的工厂函数,id 由数据库自增,这里给 0 作占位
export function createTask(
title: string,
description: string,
priority: TaskPriority
): Task {
const now = Date.now();
return {
id: 0,
title,
description,
status: TaskStatus.TODO,
priority,
createTime: now,
updateTime: now
};
}
用 enum 代替魔法数字,这样代码的可读性好很多。createTask 工厂函数统一了新任务的初始状态,不用每次手动设置 status 和时间戳。
3.2 数据库初始化
RelationalStore 的使用分两步:先拿到数据库连接,再操作数据。连接这件事只需要做一次,所以用单例管理:
// database/TaskDB.ets
import relationalStore from '@ohos.data.relationalStore';
const DB_CONFIG: relationalStore.StoreConfig = {
name: 'TaskBoard.db',
securityLevel: relationalStore.SecurityLevel.S1
};
// 建表 SQL
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status INTEGER DEFAULT 0,
priority INTEGER DEFAULT 1,
create_time INTEGER NOT NULL,
update_time INTEGER NOT NULL
)
`;
export class TaskDB {
private static store: relationalStore.RdbStore | null = null;
static async init(context: Context): Promise<void> {
if (TaskDB.store) return; // 已初始化则跳过
TaskDB.store = await relationalStore.getRdbStore(context, DB_CONFIG);
await TaskDB.store.executeSql(CREATE_TABLE_SQL);
}
static getStore(): relationalStore.RdbStore {
if (!TaskDB.store) {
throw new Error('TaskDB 尚未初始化,请先调用 init()');
}
return TaskDB.store;
}
}
SecurityLevel.S1 是普通数据的安全级别,适合任务这类非敏感数据。如果存储的是账号密码等敏感信息,需要升级到 S3 或 S4。
executeSql 在数据库已有该表时不会报错(因为用了 CREATE TABLE IF NOT EXISTS),所以每次 init 都执行一遍建表语句是安全的。
3.3 数据访问层(DAO)
把所有数据库操作集中在 TaskDao 里,页面和组件只调用 DAO,不直接碰数据库:
// database/TaskDao.ets
import relationalStore from '@ohos.data.relationalStore';
import { Task, TaskStatus } from '../model/Task';
import { TaskDB } from './TaskDB';
export class TaskDao {
// 新增任务,返回自增 id
static async insert(task: Task): Promise<number> {
const store = TaskDB.getStore();
const values: relationalStore.ValuesBucket = {
title: task.title,
description: task.description,
status: task.status,
priority: task.priority,
create_time: task.createTime,
update_time: task.updateTime
};
return await store.insert('tasks', values);
}
// 查询所有任务,按更新时间倒序
static async queryAll(): Promise<Task[]> {
const store = TaskDB.getStore();
const predicates = new relationalStore.RdbPredicates('tasks');
predicates.orderByDesc('update_time');
const cursor = await store.query(predicates,
['id', 'title', 'description', 'status', 'priority', 'create_time', 'update_time']
);
return TaskDao.cursorToList(cursor);
}
// 按状态筛选任务
static async queryByStatus(status: TaskStatus): Promise<Task[]> {
const store = TaskDB.getStore();
const predicates = new relationalStore.RdbPredicates('tasks');
predicates.equalTo('status', status).orderByDesc('update_time');
const cursor = await store.query(predicates,
['id', 'title', 'description', 'status', 'priority', 'create_time', 'update_time']
);
return TaskDao.cursorToList(cursor);
}
// 更新任务状态
static async updateStatus(id: number, status: TaskStatus): Promise<void> {
const store = TaskDB.getStore();
const values: relationalStore.ValuesBucket = {
status,
update_time: Date.now()
};
const predicates = new relationalStore.RdbPredicates('tasks');
predicates.equalTo('id', id);
await store.update(values, predicates);
}
// 删除任务
static async delete(id: number): Promise<void> {
const store = TaskDB.getStore();
const predicates = new relationalStore.RdbPredicates('tasks');
predicates.equalTo('id', id);
await store.delete(predicates);
}
// 把 ResultSet 游标转换为 Task 数组
private static cursorToList(cursor: relationalStore.ResultSet): Task[] {
const list: Task[] = [];
while (cursor.goToNextRow()) {
list.push({
id: cursor.getLong(cursor.getColumnIndex('id')),
title: cursor.getString(cursor.getColumnIndex('title')),
description: cursor.getString(cursor.getColumnIndex('description')),
status: cursor.getLong(cursor.getColumnIndex('status')),
priority: cursor.getLong(cursor.getColumnIndex('priority')),
createTime: cursor.getLong(cursor.getColumnIndex('create_time')),
updateTime: cursor.getLong(cursor.getColumnIndex('update_time')),
});
}
cursor.close(); // 用完必须 close,否则会内存泄漏
return list;
}
}
这里有个容易忽略的点:ResultSet 用完之后必须调用 close(),不然游标占用的资源不会释放,数据量大时会出现内存问题。
四、自定义组件封装
4.1 优先级标签(纯展示组件)
最简单的组件,只负责展示,没有任何状态:
// components/PriorityBadge.ets
import { TaskPriority } from '../model/Task';
@Component
export struct PriorityBadge {
priority: TaskPriority = TaskPriority.MEDIUM;
private getLabel(): string {
const labels = ['低', '中', '高'];
return labels[this.priority];
}
private getColor(): string {
const colors = ['#22c55e', '#f59e0b', '#ef4444'];
return colors[this.priority];
}
build() {
Text(this.getLabel())
.fontSize(11)
.fontColor(Color.White)
.padding({ left: 8, right: 8, top: 3, bottom: 3 })
.borderRadius(10)
.backgroundColor(this.getColor())
}
}
纯展示组件的特点是:只接收 props,不持有 @State,没有副作用。这类组件可以放心在任何地方复用,不用担心状态污染。
4.2 状态标签栏
StatusTab 需要知道当前选中的是哪个 Tab,选中后通知父组件,这就用到了 @Prop 和回调函数:
// components/StatusTab.ets
// 定义 Tab 配置
interface TabConfig {
label: string;
value: number;
}
const TABS: TabConfig[] = [
{ label: '全部', value: -1 },
{ label: '待办', value: 0 },
{ label: '进行中', value: 1 },
{ label: '已完成', value: 2 },
];
@Component
export struct StatusTab {
@Prop currentTab: number = -1;
onTabChange: (value: number) => void = () => {};
build() {
Row() {
ForEach(TABS, (tab: TabConfig) => {
Column() {
Text(tab.label)
.fontSize(14)
.fontWeight(this.currentTab === tab.value ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.currentTab === tab.value ? '#2563eb' : '#6b7280')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
// 选中下划线
Divider()
.strokeWidth(2)
.color(this.currentTab === tab.value ? '#2563eb' : Color.Transparent)
.margin({ left: 8, right: 8 })
}
.onClick(() => this.onTabChange(tab.value))
})
}
.width('100%')
.backgroundColor(Color.White)
.border({ width: { bottom: 1 }, color: '#e5e7eb' })
}
}
@Prop 是单向数据绑定,父组件的数据变化会同步到子组件,但子组件修改 @Prop 不会影响父组件。这里子组件通过调用 onTabChange 回调来通知父组件切换,父组件再更新数据,形成一个清晰的单向数据流。
4.3 任务卡片
TaskCard 是整个项目里最复杂的组件,需要展示任务信息,还要处理状态切换和删除操作:
// components/TaskCard.ets
import { Task, TaskStatus } from '../model/Task';
import { PriorityBadge } from './PriorityBadge';
@Component
export struct TaskCard {
@Prop task: Task = {} as Task;
onStatusChange: (id: number, status: TaskStatus) => void = () => {};
onDelete: (id: number) => void = () => {};
// 获取下一个状态(循环切换)
private getNextStatus(): TaskStatus {
if (this.task.status === TaskStatus.TODO) return TaskStatus.IN_PROGRESS;
if (this.task.status === TaskStatus.IN_PROGRESS) return TaskStatus.DONE;
return TaskStatus.TODO;
}
private getNextStatusLabel(): string {
if (this.task.status === TaskStatus.TODO) return '开始';
if (this.task.status === TaskStatus.IN_PROGRESS) return '完成';
return '重置';
}
private getStatusColor(): string {
const colors: Record<number, string> = {
0: '#f3f4f6',
1: '#dbeafe',
2: '#dcfce7'
};
return colors[this.task.status] ?? '#f3f4f6';
}
build() {
Column() {
// 卡片头部:标题 + 优先级
Row() {
Text(this.task.title)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.layoutWeight(1)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.decoration({
type: this.task.status === TaskStatus.DONE
? TextDecorationType.LineThrough
: TextDecorationType.None,
color: '#9ca3af'
})
PriorityBadge({ priority: this.task.priority })
}
.width('100%')
// 任务描述
if (this.task.description) {
Text(this.task.description)
.fontSize(13)
.fontColor('#6b7280')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 6 })
.width('100%')
}
// 底部操作区
Row() {
Text(this.formatDate(this.task.updateTime))
.fontSize(11)
.fontColor('#9ca3af')
.layoutWeight(1)
// 状态切换按钮
Button(this.getNextStatusLabel())
.fontSize(12)
.fontColor('#2563eb')
.backgroundColor('#eff6ff')
.borderRadius(12)
.height(28)
.padding({ left: 12, right: 12 })
.onClick(() => {
this.onStatusChange(this.task.id, this.getNextStatus());
})
// 删除按钮
Button('删除')
.fontSize(12)
.fontColor('#ef4444')
.backgroundColor('#fef2f2')
.borderRadius(12)
.height(28)
.padding({ left: 12, right: 12 })
.margin({ left: 8 })
.onClick(() => {
AlertDialog.show({
title: '删除任务',
message: `确定删除「${this.task.title}」吗?`,
confirm: { value: '删除', action: () => this.onDelete(this.task.id) }
});
})
}
.width('100%')
.margin({ top: 10 })
}
.width('100%')
.padding(14)
.backgroundColor(this.getStatusColor())
.borderRadius(12)
.border({ width: 1, color: '#e5e7eb' })
}
private formatDate(ts: number): string {
const d = new Date(ts);
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
}
}
这里有个细节:已完成的任务标题加了删除线(TextDecorationType.LineThrough),这种视觉反馈比状态文字更直观。卡片背景色也随状态变化,待办是灰色、进行中是蓝色、已完成是绿色,一眼就能区分。
4.4 添加任务弹窗
HarmonyOS 的自定义弹窗用 @CustomDialog 装饰器实现,和普通组件写法基本一样:
// components/AddTaskDialog.ets
import { TaskPriority } from '../model/Task';
@CustomDialog
export struct AddTaskDialog {
controller: CustomDialogController = new CustomDialogController({ builder: AddTaskDialog() });
onConfirm: (title: string, description: string, priority: TaskPriority) => void = () => {};
@State title: string = '';
@State description: string = '';
@State selectedPriority: TaskPriority = TaskPriority.MEDIUM;
private priorities = [
{ label: '低优先级', value: TaskPriority.LOW, color: '#22c55e' },
{ label: '中优先级', value: TaskPriority.MEDIUM, color: '#f59e0b' },
{ label: '高优先级', value: TaskPriority.HIGH, color: '#ef4444' },
];
build() {
Column() {
Text('新建任务').fontSize(18).fontWeight(FontWeight.Bold).margin({ bottom: 20 })
// 任务标题
TextInput({ placeholder: '任务标题(必填)' })
.onChange(v => this.title = v)
.width('100%')
.backgroundColor('#f9fafb')
.borderRadius(8)
// 任务描述
TextArea({ placeholder: '任务描述(选填)' })
.onChange(v => this.description = v)
.width('100%')
.height(80)
.backgroundColor('#f9fafb')
.borderRadius(8)
.margin({ top: 12 })
// 优先级选择
Text('优先级').fontSize(13).fontColor('#6b7280')
.alignSelf(ItemAlign.Start).margin({ top: 16, bottom: 8 })
Row() {
ForEach(this.priorities, (p: { label: string; value: TaskPriority; color: string }) => {
Text(p.label)
.fontSize(13)
.fontColor(this.selectedPriority === p.value ? Color.White : p.color)
.backgroundColor(this.selectedPriority === p.value ? p.color : Color.Transparent)
.border({ width: 1, color: p.color })
.borderRadius(16)
.padding({ left: 12, right: 12, top: 6, bottom: 6 })
.margin({ right: 8 })
.onClick(() => this.selectedPriority = p.value)
})
}
.width('100%')
// 操作按钮
Row() {
Button('取消')
.layoutWeight(1)
.backgroundColor('#f3f4f6')
.fontColor('#374151')
.borderRadius(10)
.margin({ right: 8 })
.onClick(() => this.controller.close())
Button('创建')
.layoutWeight(1)
.backgroundColor('#2563eb')
.fontColor(Color.White)
.borderRadius(10)
.onClick(() => {
if (!this.title.trim()) {
promptAction.showToast({ message: '请输入任务标题' });
return;
}
this.onConfirm(this.title.trim(), this.description.trim(), this.selectedPriority);
this.controller.close();
})
}
.width('100%')
.margin({ top: 20 })
}
.padding(24)
.backgroundColor(Color.White)
.borderRadius(16)
.width('90%')
}
}
五、主页面与状态管理
主页面是整个应用的状态中心,负责持有任务数据、协调各子组件之间的通信:
// pages/TaskBoard.ets
import promptAction from '@ohos.promptAction';
import { Task, TaskStatus, createTask, TaskPriority } from '../model/Task';
import { TaskDao } from '../database/TaskDao';
import { TaskCard } from '../components/TaskCard';
import { StatusTab } from '../components/StatusTab';
import { AddTaskDialog } from '../components/AddTaskDialog';
@Entry
@Component
struct TaskBoard {
@State tasks: Task[] = [];
@State currentTab: number = -1; // -1 表示全部
@State isLoading: boolean = true;
// 弹窗控制器
private dialogController: CustomDialogController = new CustomDialogController({
builder: AddTaskDialog({
onConfirm: (title: string, desc: string, priority: TaskPriority) => {
this.addTask(title, desc, priority);
}
}),
alignment: DialogAlignment.Bottom,
offset: { dx: 0, dy: -20 }
});
async onPageShow() {
await this.loadTasks();
}
// 加载任务列表
async loadTasks() {
this.isLoading = true;
if (this.currentTab === -1) {
this.tasks = await TaskDao.queryAll();
} else {
this.tasks = await TaskDao.queryByStatus(this.currentTab as TaskStatus);
}
this.isLoading = false;
}
// 添加任务
async addTask(title: string, description: string, priority: TaskPriority) {
const task = createTask(title, description, priority);
const id = await TaskDao.insert(task);
task.id = id;
// 如果当前是全部或待办 Tab,直接插入列表头部,不用重新查库
if (this.currentTab === -1 || this.currentTab === TaskStatus.TODO) {
this.tasks = [task, ...this.tasks];
}
promptAction.showToast({ message: '任务已创建' });
}
// 切换任务状态
async changeStatus(id: number, status: TaskStatus) {
await TaskDao.updateStatus(id, status);
// 更新本地列表,避免重新查库
const index = this.tasks.findIndex(t => t.id === id);
if (index !== -1) {
// 如果当前不是全部 Tab,状态变了的任务要从列表中移除
if (this.currentTab !== -1) {
this.tasks.splice(index, 1);
this.tasks = [...this.tasks];
} else {
// 全部 Tab,直接更新对应任务的状态
this.tasks[index] = { ...this.tasks[index], status, updateTime: Date.now() };
this.tasks = [...this.tasks];
}
}
}
// 删除任务
async deleteTask(id: number) {
await TaskDao.delete(id);
this.tasks = this.tasks.filter(t => t.id !== id);
promptAction.showToast({ message: '任务已删除' });
}
build() {
Column() {
// 顶部标题栏
Row() {
Text('任务看板').fontSize(20).fontWeight(FontWeight.Bold)
Blank()
Text(`共 ${this.tasks.length} 条`).fontSize(13).fontColor('#9ca3af')
}
.width('100%')
.padding({ left: 20, right: 20, top: 16, bottom: 12 })
.backgroundColor(Color.White)
// 状态 Tab
StatusTab({
currentTab: this.currentTab,
onTabChange: async (value: number) => {
this.currentTab = value;
await this.loadTasks();
}
})
// 任务列表
if (this.isLoading) {
LoadingProgress().width(48).height(48).margin({ top: 60 })
} else if (this.tasks.length === 0) {
Column() {
Text('🗂️').fontSize(48)
Text('暂无任务').fontSize(15).fontColor('#9ca3af').margin({ top: 12 })
}
.margin({ top: 80 })
} else {
List({ space: 10 }) {
ForEach(this.tasks, (task: Task) => {
ListItem() {
TaskCard({
task: task,
onStatusChange: (id: number, status: TaskStatus) => {
this.changeStatus(id, status);
},
onDelete: (id: number) => {
this.deleteTask(id);
}
})
}
}, (task: Task) => task.id.toString())
}
.layoutWeight(1)
.padding({ left: 16, right: 16, top: 8, bottom: 80 })
}
// 底部新建按钮
Button('+ 新建任务')
.width('90%')
.height(48)
.backgroundColor('#2563eb')
.fontColor(Color.White)
.borderRadius(24)
.fontSize(15)
.position({ x: '5%', y: '91%' })
.onClick(() => this.dialogController.open())
}
.height('100%')
.backgroundColor('#f3f4f6')
}
}
这里的状态更新有一个优化点值得说一下:切换任务状态或删除任务时,我没有重新查数据库,而是直接操作内存中的 this.tasks 数组。这样用户操作后界面立刻响应,不会出现一次操作后等待数据库查询再刷新的卡顿感。
但要注意 ArkTS 的数组变更检测机制:直接修改数组的某个元素(this.tasks[index].status = newStatus)不会触发 UI 刷新,必须重新赋值整个数组(this.tasks = [...this.tasks])才能让框架感知到变化。这是刚开始用 ArkTS 时很容易踩的一个坑。
六、EntryAbility 初始化
和上一篇一样,数据库初始化放在 EntryAbility 里:
// entryability/EntryAbility.ets
import { TaskDB } from '../database/TaskDB';
onWindowStageCreate(windowStage: window.WindowStage) {
TaskDB.init(this.context).then(() => {
windowStage.loadContent('pages/TaskBoard');
}).catch((err: Error) => {
// 数据库初始化失败,降级处理
console.error('DB init failed:', err.message);
windowStage.loadContent('pages/TaskBoard');
});
}
这里用 .then().catch() 代替 await,是因为 onWindowStageCreate 本身不是 async 函数,直接 await 会有问题。catch 里做了降级处理,就算数据库初始化失败,页面也能打开,不至于白屏。
七、几个值得关注的设计细节
7.1 @Prop vs @Link 的选择
父子组件数据同步有两种装饰器,很多人分不清楚什么时候用哪个:
- @Prop:单向同步,父 → 子。子组件修改不会影响父组件。适合只读展示,或者子组件通过回调通知父组件修改。
- @Link:双向同步,父子都能改,改了都会同步。适合子组件需要直接修改父组件数据的场景,比如一个输入框绑定父组件的某个字段。
本项目里 TaskCard 接收 task 用的是 @Prop,因为任务的修改(状态变更、删除)都通过回调函数告知父组件处理,不需要子组件直接修改。StatusTab 的 currentTab 也是 @Prop,选中 Tab 通过 onTabChange 回调通知父组件。
这种模式(Props Down, Events Up)让数据流向更清晰,调试起来也方便,出了问题知道去哪里查。
7.2 数据库查询的性能考虑
queryAll 是全量查询,任务量少的时候没问题。如果任务数量可能达到几百甚至上千条,需要加分页:
// 分页查询示例(每页 20 条)
static async queryPage(page: number, pageSize: number = 20): Promise<Task[]> {
const store = TaskDB.getStore();
const predicates = new relationalStore.RdbPredicates('tasks');
predicates.orderByDesc('update_time').limitAs(pageSize).offsetAs(page * pageSize);
const cursor = await store.query(predicates,
['id', 'title', 'description', 'status', 'priority', 'create_time', 'update_time']
);
return TaskDao.cursorToList(cursor);
}
limitAs 和 offsetAs 是 RelationalStore 的链式谓词方法,配合 List 组件的 onReachEnd 回调就能实现上拉加载更多。
7.3 任务数量统计
看板通常需要展示各状态的任务数量,不用每次都全量查询再 filter,可以直接用 SQL COUNT:
// 统计各状态数量
static async countByStatus(): Promise<Record<number, number>> {
const store = TaskDB.getStore();
const sql = `SELECT status, COUNT(*) as cnt FROM tasks GROUP BY status`;
const cursor = await store.querySql(sql);
const result: Record<number, number> = { 0: 0, 1: 0, 2: 0 };
while (cursor.goToNextRow()) {
const status = cursor.getLong(cursor.getColumnIndex('status'));
const cnt = cursor.getLong(cursor.getColumnIndex('cnt'));
result[status] = cnt;
}
cursor.close();
return result;
}
querySql 允许直接执行原始 SQL,适合这类 GROUP BY 聚合查询,比用谓词组合更直接。
八、main_pages.json 配置
别忘了注册页面路由:
{
"src": [
"pages/TaskBoard"
]
}
这个项目只有一个主页面,弹窗用的是 CustomDialogController,不走路由,所以这里只注册 TaskBoard 就够了。


九、总结
回顾一下这篇文章覆盖的核心内容:
组件设计方面:按职责拆分组件,纯展示组件不持有状态,交互组件通过 @Prop + 回调函数与父组件通信,保持数据流向清晰。
状态管理方面:@Prop 适合单向数据展示,@Link 适合双向绑定,回调函数是子向父通知的标准方式。数组更新要整体重新赋值才能触发 UI 刷新,这一点需要牢记。
数据库方面:RelationalStore 比 Preferences 适合需要筛选、排序、聚合的场景。DAO 层封装统一管理 SQL 操作,ResultSet 用完记得 close(),数据库连接用单例管理避免重复初始化。
这套组件 + 数据库的架构模式,不只适用于任务看板,稍作调整就能用在购物清单、日记、健身记录等各类数据管理类 App 上,是比较通用的鸿蒙开发实践。
如果跑起来遇到问题,欢迎评论区留言交流。
更多推荐

所有评论(0)