系列: 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_CATEGORIESINCOME_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 属性(如 isCompletedtitle)发生变化时,绑定该对象的 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 表示最低安全级别,适用于非敏感的个人数据。如果你的待办应用涉及机密信息,应使用 S3S4

关于 init 的幂等性if (this.store) return; 确保多次调用 init() 不会重复创建数据库。这在应用生命周期中很重要——EntryAbilityonCreate 会调用一次,但页面导航时不应再次触发。

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 待办应用,重点实现了以下技术点:

  1. relationalStore 数据持久化:使用 SQLite 数据库存储待办数据,支持增删改查和条件查询
  2. @ObservedV2 + @Trace:V2 状态管理实现深层属性观察,修改单个属性即可触发 UI 刷新
  3. AppDatabase 单例:全局共享数据库实例,统一管理建库建表
  4. bindSheet 半模态弹窗:从底部滑出的轻量弹窗,适合表单输入场景
  5. 分类 Tab + Search:使用 ForEach 动态生成分类标签,Search 组件提供搜索能力
  6. Checkbox + 系统图标:Checkbox 实现完成切换,$r('sys.symbol.trash') 使用系统内置图标

关键注意事项:

  • ResultSet 使用后必须调用 close(),否则会导致内存泄漏
  • bindSheet 使用 $$ 双向绑定,确保弹窗内部关闭能同步状态
  • AppDatabase.init() 具有幂等性,多次调用不会重复创建数据库
  • relationalStore 同一时刻只能有一个写操作,批量写入建议使用事务包裹

下篇预告

下一篇: 待办 App(下):LazyForEach 性能优化与滑动删除

下篇将为待办应用添加 LazyForEach 懒加载、@Reusable 组件复用、滑动删除交互,以及 IDataSource 数据源的实现,全面提升列表滚动性能和交互体验。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐