一、前言

做过一段时间鸿蒙开发之后,大部分人都会碰到同一个问题:页面越写越长,逻辑越堆越乱,同样的 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);
}

limitAsoffsetAs 是 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 上,是比较通用的鸿蒙开发实践。

如果跑起来遇到问题,欢迎评论区留言交流。

Logo

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

更多推荐