本节目标

· 综合运用前19课所学知识,完成一个完整 HarmonyOS 应用的需求分析、架构设计与功能开发
· 掌握从需求到实现的完整开发流程:需求拆解 → 架构设计 → 数据建模 → UI 实现 → 状态管理 → 持久化 → 性能优化
· 能够独立设计应用的分层架构,合理划分页面层、组件层、数据层与工具层
· 掌握 Navigation + Tabs 的多页面应用骨架搭建方法
· 掌握 Preferences + RDB + 文件存储三种持久化方案的组合使用
· 掌握网络请求与状态管理的联动,实现完整的数据交互链路
· 能够对应用进行性能优化,包括列表懒加载、组件复用、启动优化
· 能够为应用编写基本的单元测试与 UI 测试
· 完成一个可直接运行、具备完整功能与良好体验的 HarmonyOS 应用

一、项目概述

1.1 项目选题:极简待办(Todo)

我们选择“极简待办”作为实战项目。这是一个功能完整但不过于复杂的应用,能够覆盖 HarmonyOS 应用开发的核心知识点。

核心功能:

· 待办列表展示(支持全部/进行中/已完成筛选)
· 新增待办(标题、描述、优先级、截止日期)
· 编辑待办
· 标记完成/取消完成
· 删除待办(滑动删除)
· 搜索待办
· 数据持久化(本地存储)
· 设置页面(主题色、字体大小、排序方式)
· 关于页面(版本信息、隐私政策)

技术要点:

· Navigation + Tabs 多页面架构
· 状态管理 V2(@ObservedV2 + @Trace + @Local + @Param + @Monitor + @Computed)
· RDB 持久化待办数据
· Preferences 保存用户偏好
· 文件存储导出备份
· LazyForEach + @Reusable 长列表优化
· SwipeGesture 滑动删除
· PanGesture 拖拽排序
· 动画与手势联动
· 单元测试与 UI 测试

二、需求分析与功能拆解

2.1 用户故事

故事一:作为一名用户,我希望能够快速添加待办事项,以便记录我需要完成的任务。

故事二:作为一名用户,我希望能够查看所有待办事项,并按状态筛选,以便专注于当前需要处理的任务。

故事三:作为一名用户,我希望能够标记待办为完成,以便追踪我的进度。

故事四:作为一名用户,我希望能够删除不需要的待办,以便保持列表整洁。

故事五:作为一名用户,我希望能够搜索待办,以便快速找到特定任务。

故事六:作为一名用户,我希望能够自定义主题和字体大小,以便获得舒适的视觉体验。

2.2 功能清单

待办列表页:

· 展示待办列表(支持全部/进行中/已完成筛选)
· 新增待办入口(悬浮按钮)
· 搜索框
· 滑动删除
· 点击切换完成状态

新增/编辑待办页:

· 标题输入(必填)
· 描述输入(选填)
· 优先级选择(高/中/低)
· 截止日期选择
· 保存/取消

设置页:

· 主题色选择
· 字体大小调节
· 默认排序方式
· 数据导出
· 清除所有数据

关于页:

· 版本信息
· 隐私政策入口
· 开源许可

2.3 数据模型设计

// model/TodoModel.ets
@ObservedV2
export class Todo {
  @Trace id: number = 0;
  @Trace title: string = '';
  @Trace description: string = '';
  @Trace priority: Priority = Priority.Medium;
  @Trace isCompleted: boolean = false;
  @Trace createdAt: number = 0;
  @Trace dueDate: number = 0;

  constructor(title: string, description: string = '',
              priority: Priority = Priority.Medium,
              dueDate: number = 0) {
    this.title = title;
    this.description = description;
    this.priority = priority;
    this.dueDate = dueDate;
    this.createdAt = Date.now();
  }
}

export enum Priority {
  High = 0,
  Medium = 1,
  Low = 2
}

export enum FilterType {
  All = 'all',
  Active = 'active',
  Completed = 'completed'
}

export enum SortType {
  CreatedDesc = 'created_desc',
  CreatedAsc = 'created_asc',
  PriorityDesc = 'priority_desc',
  DueDateAsc = 'due_date_asc'
}

export interface UserSettings {
  themeColor: string;
  fontSize: number;
  sortType: SortType;
  defaultFilter: FilterType;
}

三、架构设计

3.1 分层架构

entry/src/main/ets/
├── entryability/
│   └── EntryAbility.ets              # 应用入口
├── pages/
│   └── Index.ets                     # 根页面(Navigation + Tabs 骨架)
├── views/
│   ├── HomePage.ets                  # 首页(待办列表)
│   ├── SettingsPage.ets              # 设置页
│   ├── AboutPage.ets                 # 关于页
│   └── TodoEditPage.ets              # 新增/编辑待办页
├── components/
│   ├── TodoItem.ets                  # 待办列表项组件
│   ├── EmptyView.ets                 # 空状态组件
│   ├── LoadingView.ets               # 加载状态组件
│   ├── PriorityTag.ets               # 优先级标签组件
│   └── ConfirmDialog.ets             # 确认对话框组件
├── viewmodel/
│   ├── TodoViewModel.ets             # 待办业务逻辑
│   └── SettingsViewModel.ets         # 设置业务逻辑
├── model/
│   ├── TodoModel.ets                 # 数据模型与枚举
│   └── UserSettings.ets              # 用户设置模型
├── data/
│   ├── RdbHelper.ets                 # RDB 数据库操作
│   ├── PreferencesHelper.ets         # Preferences 操作
│   └── FileHelper.ets                # 文件存储操作
└── utils/
    ├── DateUtil.ets                  # 日期工具
    ├── StringUtil.ets                # 字符串工具
    └── Logger.ets                    # 日志工具

3.2 状态管理方案

应用级状态:AppStorageV2 存储用户设置(主题色、字体大小、排序方式)。

页面级状态:各页面使用 @Local 管理自己的状态。

组件间通信:@Param + @Event 实现父子组件数据传递。

深度观测:Todo 类使用 @ObservedV2 + @Trace,属性变化时精准刷新。

跨层级通信:@Provider + @Consumer 实现 Navigation 页面栈共享。

3.3 路由设计

Navigation(根容器,模式 Auto)
├── NavBar(Tabs 主框架)
│   ├── Tab 1:首页(HomePage)
│   │   └── push → TodoEditPage(新增/编辑)
│   ├── Tab 2:统计(StatsPage)
│   ├── Tab 3:设置(SettingsPage)
│   │   └── push → AboutPage(关于)
│   └── Tab 4:我的(ProfilePage)
└── NavDestination 路由表
    ├── todoEdit → TodoEditPage
    ├── about → AboutPage
    └── detail → TodoDetailPage

四、数据层实现

4.1 RDB 数据库操作

// data/RdbHelper.ets
import { relationalStore } from '@kit.ArkData';
import { Todo, Priority } from '../model/TodoModel';

const STORE_CONFIG: relationalStore.StoreConfig = {
  name: 'TodoApp.db',
  securityLevel: relationalStore.SecurityLevel.S1
};

const CREATE_TABLE_SQL = `
  CREATE TABLE IF NOT EXISTS todos (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    description TEXT,
    priority INTEGER DEFAULT 1,
    is_completed INTEGER DEFAULT 0,
    created_at INTEGER,
    due_date INTEGER DEFAULT 0
  )`;

export class RdbHelper {
  private static store: relationalStore.RdbStore | null = null;

  static async init(context: Context): Promise<void> {
    RdbHelper.store = await relationalStore.getRdbStore(context, STORE_CONFIG);
    await RdbHelper.store.executeSql(CREATE_TABLE_SQL);
  }

  static async insert(todo: Todo): Promise<number> {
    if (!RdbHelper.store) throw new Error('RDB not initialized');
    const values: relationalStore.ValuesBucket = {
      'title': todo.title,
      'description': todo.description,
      'priority': todo.priority,
      'is_completed': todo.isCompleted ? 1 : 0,
      'created_at': todo.createdAt,
      'due_date': todo.dueDate
    };
    return await RdbHelper.store.insert('todos', values);
  }

  static async queryAll(): Promise<Todo[]> {
    if (!RdbHelper.store) throw new Error('RDB not initialized');
    const predicates = new relationalStore.RdbPredicates('todos');
    predicates.orderByDesc('created_at');
    const resultSet = await RdbHelper.store.query(predicates);
    const todos: Todo[] = [];
    try {
      while (resultSet.goToNextRow()) {
        const todo = new Todo(
          resultSet.getString(resultSet.getColumnIndex('title')),
          resultSet.getString(resultSet.getColumnIndex('description')),
          resultSet.getLong(resultSet.getColumnIndex('priority')) as Priority,
          resultSet.getLong(resultSet.getColumnIndex('due_date'))
        );
        todo.id = resultSet.getLong(resultSet.getColumnIndex('id'));
        todo.isCompleted = resultSet.getLong(resultSet.getColumnIndex('is_completed')) === 1;
        todo.createdAt = resultSet.getLong(resultSet.getColumnIndex('created_at'));
        todos.push(todo);
      }
    } finally {
      resultSet.close();
    }
    return todos;
  }

  static async update(todo: Todo): Promise<number> {
    if (!RdbHelper.store) throw new Error('RDB not initialized');
    const predicates = new relationalStore.RdbPredicates('todos');
    predicates.equalTo('id', todo.id);
    const values: relationalStore.ValuesBucket = {
      'title': todo.title,
      'description': todo.description,
      'priority': todo.priority,
      'is_completed': todo.isCompleted ? 1 : 0,
      'due_date': todo.dueDate
    };
    return await RdbHelper.store.update(values, predicates);
  }

  static async delete(id: number): Promise<number> {
    if (!RdbHelper.store) throw new Error('RDB not initialized');
    const predicates = new relationalStore.RdbPredicates('todos');
    predicates.equalTo('id', id);
    return await RdbHelper.store.delete(predicates);
  }

  static async deleteAll(): Promise<number> {
    if (!RdbHelper.store) throw new Error('RDB not initialized');
    const predicates = new relationalStore.RdbPredicates('todos');
    return await RdbHelper.store.delete(predicates);
  }
}

4.2 Preferences 用户设置

// data/PreferencesHelper.ets
import { preferences } from '@kit.ArkData';
import { UserSettings, SortType, FilterType } from '../model/TodoModel';

const PREF_NAME = 'todo_settings';

export class PreferencesHelper {
  private static pref: preferences.Preferences | null = null;

  static init(context: Context): void {
    PreferencesHelper.pref = preferences.getPreferencesSync(context, { name: PREF_NAME });
  }

  static loadSettings(): UserSettings {
    if (!PreferencesHelper.pref) {
      return {
        themeColor: '#007DFF',
        fontSize: 16,
        sortType: SortType.CreatedDesc,
        defaultFilter: FilterType.All
      };
    }
    return {
      themeColor: PreferencesHelper.pref.getSync('themeColor', '#007DFF') as string,
      fontSize: PreferencesHelper.pref.getSync('fontSize', 16) as number,
      sortType: PreferencesHelper.pref.getSync('sortType', SortType.CreatedDesc) as SortType,
      defaultFilter: PreferencesHelper.pref.getSync('defaultFilter', FilterType.All) as FilterType
    };
  }

  static saveSettings(settings: UserSettings): void {
    if (!PreferencesHelper.pref) return;
    PreferencesHelper.pref.putSync('themeColor', settings.themeColor);
    PreferencesHelper.pref.putSync('fontSize', settings.fontSize);
    PreferencesHelper.pref.putSync('sortType', settings.sortType);
    PreferencesHelper.pref.putSync('defaultFilter', settings.defaultFilter);
    PreferencesHelper.pref.flush();
  }
}

五、ViewModel 业务逻辑层

// viewmodel/TodoViewModel.ets
import { Todo, FilterType, SortType, Priority } from '../model/TodoModel';
import { RdbHelper } from '../data/RdbHelper';

@ObservedV2
export class TodoViewModel {
  @Trace todos: Todo[] = [];
  @Trace filterType: FilterType = FilterType.All;
  @Trace sortType: SortType = SortType.CreatedDesc;
  @Trace searchKeyword: string = '';
  @Trace isLoading: boolean = false;

  @Computed
  get filteredTodos(): Todo[] {
    let result = this.todos;

    // 筛选
    if (this.filterType === FilterType.Active) {
      result = result.filter(t => !t.isCompleted);
    } else if (this.filterType === FilterType.Completed) {
      result = result.filter(t => t.isCompleted);
    }

    // 搜索
    if (this.searchKeyword.trim()) {
      const keyword = this.searchKeyword.toLowerCase();
      result = result.filter(t =>
        t.title.toLowerCase().includes(keyword) ||
        t.description.toLowerCase().includes(keyword)
      );
    }

    // 排序
    return this.sortTodos(result);
  }

  @Computed
  get activeCount(): number {
    return this.todos.filter(t => !t.isCompleted).length;
  }

  @Computed
  get completedCount(): number {
    return this.todos.filter(t => t.isCompleted).length;
  }

  private sortTodos(todos: Todo[]): Todo[] {
    const sorted = [...todos];
    switch (this.sortType) {
      case SortType.CreatedDesc:
        return sorted.sort((a, b) => b.createdAt - a.createdAt);
      case SortType.CreatedAsc:
        return sorted.sort((a, b) => a.createdAt - b.createdAt);
      case SortType.PriorityDesc:
        return sorted.sort((a, b) => a.priority - b.priority);
      case SortType.DueDateAsc:
        return sorted.sort((a, b) => {
          if (a.dueDate === 0) return 1;
          if (b.dueDate === 0) return -1;
          return a.dueDate - b.dueDate;
        });
      default:
        return sorted;
    }
  }

  async loadTodos(): Promise<void> {
    this.isLoading = true;
    try {
      this.todos = await RdbHelper.queryAll();
    } finally {
      this.isLoading = false;
    }
  }

  async addTodo(title: string, description: string,
                priority: Priority, dueDate: number): Promise<void> {
    const todo = new Todo(title, description, priority, dueDate);
    const id = await RdbHelper.insert(todo);
    todo.id = id;
    this.todos.push(todo);
  }

  async updateTodo(todo: Todo): Promise<void> {
    await RdbHelper.update(todo);
    const index = this.todos.findIndex(t => t.id === todo.id);
    if (index !== -1) {
      this.todos[index] = todo;
    }
  }

  async toggleComplete(todo: Todo): Promise<void> {
    todo.isCompleted = !todo.isCompleted;
    await RdbHelper.update(todo);
  }

  async deleteTodo(id: number): Promise<void> {
    await RdbHelper.delete(id);
    this.todos = this.todos.filter(t => t.id !== id);
  }

  async clearAll(): Promise<void> {
    await RdbHelper.deleteAll();
    this.todos = [];
  }
}

六、UI 实现

6.1 应用根页面(Navigation + Tabs)

// pages/Index.ets
import { TodoViewModel } from '../viewmodel/TodoViewModel';
import { PreferencesHelper } from '../data/PreferencesHelper';
import { RdbHelper } from '../data/RdbHelper';
import { UserSettings } from '../model/TodoModel';
import { HomePage } from '../views/HomePage';
import { SettingsPage } from '../views/SettingsPage';
import { StatsPage } from '../views/StatsPage';
import { ProfilePage } from '../views/ProfilePage';
import { TodoEditPage } from '../views/TodoEditPage';
import { AboutPage } from '../views/AboutPage';

@Entry
@ComponentV2
struct Index {
  @Local currentTabIndex: number = 0;
  @Local settings: UserSettings = {
    themeColor: '#007DFF',
    fontSize: 16,
    sortType: SortType.CreatedDesc,
    defaultFilter: FilterType.All
  };
  @Provider('pageStack') pageStack: NavPathStack = new NavPathStack();
  @Provider('todoVM') todoVM: TodoViewModel = new TodoViewModel();

  async aboutToAppear(): Promise<void> {
    const context = this.getUIContext().getHostContext() as Context;
    // 初始化数据层
    PreferencesHelper.init(context);
    await RdbHelper.init(context);
    // 加载设置和待办数据
    this.settings = PreferencesHelper.loadSettings();
    await this.todoVM.loadTodos();
  }

  @Builder
  pageMap(name: string, param: object) {
    if (name === 'todoEdit') {
      TodoEditPage({ todoId: param as number })
    } else if (name === 'about') {
      AboutPage()
    }
  }

  build() {
    Navigation(this.pageStack) {
      Tabs({ barPosition: BarPosition.End, index: this.currentTabIndex }) {
        TabContent() {
          HomePage({ settings: this.settings })
        }
        .tabBar(this.tabBuilder('待办', 0))

        TabContent() {
          StatsPage()
        }
        .tabBar(this.tabBuilder('统计', 1))

        TabContent() {
          SettingsPage({
            settings: this.settings,
            onSettingsChange: (newSettings: UserSettings) => {
              this.settings = newSettings;
              PreferencesHelper.saveSettings(newSettings);
            }
          })
        }
        .tabBar(this.tabBuilder('设置', 2))

        TabContent() {
          ProfilePage()
        }
        .tabBar(this.tabBuilder('我的', 3))
      }
      .barHeight(56)
      .onChange((index: number) => {
        this.currentTabIndex = index;
      })
    }
    .title('极简待办')
    .mode(NavigationMode.Auto)
    .navDestination(this.pageMap)
    .hideNavBar(false)
    .backgroundColor('#F5F7FA')
  }

  @Builder
  tabBuilder(title: string, index: number) {
    Column({ space: 4 }) {
      Text(title)
        .fontSize(11)
        .fontColor(this.currentTabIndex === index ? this.settings.themeColor : '#999999')
      if (index === 0 && this.todoVM.activeCount > 0) {
        Text(`${this.todoVM.activeCount}`)
          .fontSize(10)
          .fontColor(Color.White)
          .backgroundColor(Color.Red)
          .borderRadius(8)
          .padding({ left: 5, right: 5, top: 1, bottom: 1 })
      }
    }
    .width('100%')
    .height(56)
    .justifyContent(FlexAlign.Center)
  }
}

6.2 首页(待办列表)

// views/HomePage.ets
import { Todo, FilterType, SortType } from '../model/TodoModel';
import { TodoItem } from '../components/TodoItem';
import { EmptyView } from '../components/EmptyView';
import { TodoViewModel } from '../viewmodel/TodoViewModel';

@ComponentV2
export struct HomePage {
  @Param settings: UserSettings;
  @Consumer('todoVM') todoVM: TodoViewModel;
  @Consumer('pageStack') pageStack: NavPathStack;
  @Local searchKeyword: string = '';

  build() {
    Column() {
      // 搜索框
      Search({ value: this.searchKeyword })
        .width('100%')
        .height(40)
        .placeholder('搜索待办...')
        .onChange((value: string) => {
          this.searchKeyword = value;
          this.todoVM.searchKeyword = value;
        })
        .margin({ bottom: 12 })

      // 筛选标签
      Row({ space: 8 }) {
        this.filterTag('全部', FilterType.All)
        this.filterTag('进行中', FilterType.Active)
        this.filterTag('已完成', FilterType.Completed)
      }
      .width('100%')
      .margin({ bottom: 12 })

      // 待办列表
      if (this.todoVM.isLoading) {
        LoadingView()
      } else if (this.todoVM.filteredTodos.length === 0) {
        EmptyView({ message: this.getEmptyMessage() })
      } else {
        List({ space: 10 }) {
          ForEach(this.todoVM.filteredTodos, (todo: Todo) => {
            ListItem() {
              TodoItem({
                todo: todo,
                fontSize: this.settings.fontSize,
                themeColor: this.settings.themeColor,
                onToggle: () => this.todoVM.toggleComplete(todo),
                onDelete: () => this.todoVM.deleteTodo(todo.id),
                onEdit: () => this.pageStack.pushPathByName('todoEdit', todo.id)
              })
            }
          }, (todo: Todo) => todo.id.toString())
        }
        .layoutWeight(1)
        .width('100%')
      }

      // 悬浮新增按钮
      Button('+')
        .width(56)
        .height(56)
        .borderRadius(28)
        .backgroundColor(this.settings.themeColor)
        .fontSize(28)
        .fontColor(Color.White)
        .position({ end: 20, bottom: 20 })
        .onClick(() => {
          this.pageStack.pushPathByName('todoEdit', 0);
        })
    }
    .width('100%')
    .height('100%')
    .padding(16)
  }

  @Builder
  filterTag(label: string, filter: FilterType) {
    Text(label)
      .fontSize(14)
      .fontColor(this.todoVM.filterType === filter ? Color.White : '#666')
      .backgroundColor(this.todoVM.filterType === filter
        ? this.settings.themeColor : '#E8E8E8')
      .padding({ left: 14, right: 14, top: 6, bottom: 6 })
      .borderRadius(16)
      .onClick(() => {
        this.todoVM.filterType = filter;
      })
  }

  private getEmptyMessage(): string {
    if (this.todoVM.searchKeyword) return '没有找到匹配的待办';
    if (this.todoVM.filterType === FilterType.Completed) return '还没有已完成的待办';
    if (this.todoVM.filterType === FilterType.Active) return '没有进行中的待办';
    return '还没有待办,点击 + 添加';
  }
}

6.3 待办列表项组件

// components/TodoItem.ets
import { Todo, Priority } from '../model/TodoModel';
import { DateUtil } from '../utils/DateUtil';

@ComponentV2
export struct TodoItem {
  @Param todo: Todo;
  @Param fontSize: number = 16;
  @Param themeColor: string = '#007DFF';
  @Event onToggle: () => void = () => {};
  @Event onDelete: () => void = () => {};
  @Event onEdit: () => void = () => {};

  build() {
    Row({ space: 12 }) {
      // 完成状态勾选框
      Text(this.todo.isCompleted ? '✓' : '')
        .width(24)
        .height(24)
        .borderRadius(12)
        .border({ width: 2, color: this.todo.isCompleted ? this.themeColor : '#CCCCCC' })
        .backgroundColor(this.todo.isCompleted ? this.themeColor : Color.Transparent)
        .fontColor(Color.White)
        .fontSize(14)
        .textAlign(TextAlign.Center)
        .onClick(() => this.onToggle())

      // 待办内容
      Column({ space: 4 }) {
        Text(this.todo.title)
          .fontSize(this.fontSize)
          .fontWeight(FontWeight.Medium)
          .fontColor(this.todo.isCompleted ? '#999' : '#333')
          .decoration({
            type: this.todo.isCompleted ? TextDecorationType.LineThrough : TextDecorationType.None
          })
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        if (this.todo.description) {
          Text(this.todo.description)
            .fontSize(this.fontSize - 2)
            .fontColor('#999')
            .maxLines(1)
            .textOverflow({ overflow: TextOverflow.Ellipsis })
        }

        Row({ space: 8 }) {
          PriorityTag({ priority: this.todo.priority })
          if (this.todo.dueDate > 0) {
            Text(DateUtil.formatDate(this.todo.dueDate))
              .fontSize(11)
              .fontColor(DateUtil.isOverdue(this.todo.dueDate) ? '#FF5500' : '#999')
          }
        }
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      // 编辑按钮
      Text('编辑')
        .fontSize(12)
        .fontColor(this.themeColor)
        .onClick(() => this.onEdit())
    }
    .width('100%')
    .padding(14)
    .backgroundColor(Color.White)
    .borderRadius(12)
    .shadow({ radius: 4, color: '#10000000', offsetY: 2 })
    .gesture(
      SwipeGesture({ direction: SwipeDirection.Left, speed: 100 })
        .onAction(() => {
          this.onDelete();
        })
    )
  }
}

@ComponentV2
struct PriorityTag {
  @Param priority: Priority = Priority.Medium;

  build() {
    Text(this.getLabel())
      .fontSize(11)
      .fontColor(Color.White)
      .backgroundColor(this.getColor())
      .padding({ left: 6, right: 6, top: 2, bottom: 2 })
      .borderRadius(4)
  }

  private getLabel(): string {
    switch (this.priority) {
      case Priority.High: return '高';
      case Priority.Medium: return '中';
      case Priority.Low: return '低';
      default: return '中';
    }
  }

  private getColor(): string {
    switch (this.priority) {
      case Priority.High: return '#FF5500';
      case Priority.Medium: return '#FF9500';
      case Priority.Low: return '#34C759';
      default: return '#FF9500';
    }
  }
}

6.4 新增/编辑待办页

// views/TodoEditPage.ets
import { Todo, Priority } from '../model/TodoModel';
import { TodoViewModel } from '../viewmodel/TodoViewModel';

@ComponentV2
export struct TodoEditPage {
  @Param todoId: number = 0;  // 0 表示新增,非0表示编辑
  @Consumer('todoVM') todoVM: TodoViewModel;
  @Consumer('pageStack') pageStack: NavPathStack;

  @Local title: string = '';
  @Local description: string = '';
  @Local priority: Priority = Priority.Medium;
  @Local dueDate: number = 0;
  @Local isEdit: boolean = false;

  aboutToAppear(): void {
    if (this.todoId !== 0) {
      const todo = this.todoVM.todos.find(t => t.id === this.todoId);
      if (todo) {
        this.isEdit = true;
        this.title = todo.title;
        this.description = todo.description;
        this.priority = todo.priority;
        this.dueDate = todo.dueDate;
      }
    }
  }

  build() {
    NavDestination() {
      Column({ space: 20 }) {
        // 标题输入
        TextInput({ placeholder: '待办标题(必填)', text: this.title })
          .width('100%')
          .height(48)
          .fontSize(16)
          .onChange((value: string) => {
            this.title = value;
          })

        // 描述输入
        TextArea({ placeholder: '描述(选填)', text: this.description })
          .width('100%')
          .height(100)
          .fontSize(15)
          .onChange((value: string) => {
            this.description = value;
          })

        // 优先级选择
        Row({ space: 12 }) {
          Text('优先级').fontSize(15)
          this.priorityOption('高', Priority.High)
          this.priorityOption('中', Priority.Medium)
          this.priorityOption('低', Priority.Low)
        }
        .width('100%')

        // 截止日期
        Row({ space: 12 }) {
          Text('截止日期').fontSize(15)
          Text(this.dueDate > 0 ? DateUtil.formatDate(this.dueDate) : '未设置')
            .fontSize(15)
            .fontColor('#666')
          Button('选择')
            .fontSize(13)
            .onClick(() => {
              // 使用 DatePickerDialog 选择日期
              this.getUIContext().showDatePickerDialog({
                start: new Date('2020-01-01'),
                end: new Date('2030-12-31'),
                selected: new Date(),
                onDateAccept: (value: Date) => {
                  this.dueDate = value.getTime();
                }
              });
            })
        }
        .width('100%')

        // 保存按钮
        Button(this.isEdit ? '保存修改' : '添加待办')
          .width('100%')
          .height(48)
          .backgroundColor(this.title.trim() ? '#007DFF' : '#CCCCCC')
          .enabled(this.title.trim().length > 0)
          .onClick(async () => {
            if (this.isEdit) {
              const todo = this.todoVM.todos.find(t => t.id === this.todoId);
              if (todo) {
                todo.title = this.title;
                todo.description = this.description;
                todo.priority = this.priority;
                todo.dueDate = this.dueDate;
                await this.todoVM.updateTodo(todo);
              }
            } else {
              await this.todoVM.addTodo(this.title, this.description, this.priority, this.dueDate);
            }
            this.pageStack.pop();
          })
      }
      .width('100%')
      .height('100%')
      .padding(20)
    }
    .title(this.isEdit ? '编辑待办' : '新增待办')
    .backgroundColor('#F5F7FA')
  }

  @Builder
  priorityOption(label: string, value: Priority) {
    Text(label)
      .fontSize(14)
      .fontColor(this.priority === value ? Color.White : '#666')
      .backgroundColor(this.priority === value ? '#007DFF' : '#E8E8E8')
      .padding({ left: 16, right: 16, top: 6, bottom: 6 })
      .borderRadius(14)
      .onClick(() => {
        this.priority = value;
      })
  }
}

七、性能优化

7.1 列表优化

首页待办列表使用 ForEach 即可满足日常数据量,当数据量超过 500 条时建议升级为 LazyForEach + @Reusable:

// 数据源实现 IDataSource 接口
class TodoDataSource implements IDataSource {
  private listeners: DataChangeListener[] = [];
  private todos: Todo[] = [];

  totalCount(): number { return this.todos.length; }
  getData(index: number): Todo { return this.todos[index]; }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) this.listeners.splice(pos, 1);
  }

  setData(todos: Todo[]): void {
    this.todos = todos;
    this.listeners.forEach(l => l.onDataReloaded());
  }
}

// 列表项使用 @Reusable
@Reusable
@ComponentV2
struct ReusableTodoItem {
  @Param todo: Todo = new Todo('');
  @Param fontSize: number = 16;
  @Param themeColor: string = '#007DFF';

  aboutToReuse(params: Record<string, Object>): void {
    this.todo = params.todo as Todo;
    this.fontSize = params.fontSize as number;
    this.themeColor = params.themeColor as string;
  }

  build() {
    // 列表项 UI
  }
}

7.2 状态管理优化

使用 @Computed 缓存筛选和排序结果,避免每次渲染重新计算:

@Computed
get filteredTodos(): Todo[] {
  // 仅在 todos、filterType、searchKeyword、sortType 变化时重新计算
  // 其他状态变化不会触发此计算
}

使用 @Monitor 监听关键状态变化,执行必要的副作用操作:

@Monitor('filterType')
onFilterChange(monitor: IMonitor): void {
  console.info(`筛选条件从 ${monitor.value()?.before} 变为 ${monitor.value()?.now}`);
}

7.3 启动优化

在 EntryAbility 中只做最轻量初始化,数据加载放到首页的 aboutToAppear 中异步执行:

// EntryAbility.ets
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  // 仅做必要的轻量初始化
}

onWindowStageCreate(windowStage: window.WindowStage): void {
  windowStage.loadContent('pages/Index', (err) => {
    if (!err) {
      // 首帧加载完毕后再异步初始化非关键模块
    }
  });
}

八、测试

8.1 单元测试

// entry/src/ohosTest/ets/test/TodoViewModel.test.ets
import { describe, it, expect, beforeEach } from '@ohos/hypium';
import { TodoViewModel } from '../../../main/ets/viewmodel/TodoViewModel';
import { Todo, Priority, FilterType } from '../../../main/ets/model/TodoModel';

export default function todoViewModelTest() {
  describe('TodoViewModel', () => {
    let vm: TodoViewModel;

    beforeEach(() => {
      vm = new TodoViewModel();
      vm.todos = [
        new Todo('任务A', '', Priority.High),
        new Todo('任务B', '', Priority.Medium),
        new Todo('任务C', '', Priority.Low)
      ];
      vm.todos[0].isCompleted = true;
    });

    it('should filter active todos', () => {
      vm.filterType = FilterType.Active;
      expect(vm.filteredTodos.length).assertEqual(2);
    });

    it('should filter completed todos', () => {
      vm.filterType = FilterType.Completed;
      expect(vm.filteredTodos.length).assertEqual(1);
    });

    it('should search todos by title', () => {
      vm.searchKeyword = '任务A';
      expect(vm.filteredTodos.length).assertEqual(1);
      expect(vm.filteredTodos[0].title).assertEqual('任务A');
    });

    it('should count active todos correctly', () => {
      expect(vm.activeCount).assertEqual(2);
      expect(vm.completedCount).assertEqual(1);
    });

    it('should sort by priority', () => {
      vm.sortType = SortType.PriorityDesc;
      const first = vm.filteredTodos[0];
      expect(first.priority).assertEqual(Priority.High);
    });
  });
}

8.2 UI 测试

// entry/src/ohosTest/ets/test/TodoUiTest.test.ets
import { describe, it, expect } from '@ohos/hypium';
import { Driver, ON } from '@kit.UiTestKit';
import { abilityDelegatorRegistry } from '@kit.TestKit';

const delegator = abilityDelegatorRegistry.getAbilityDelegator();
const bundleName = abilityDelegatorRegistry.getArguments().bundleName;

export default function todoUiTest() {
  describe('TodoUiTest', () => {
    it('should add a new todo', 0, async (done: Function) => {
      const driver = Driver.create();
      // 启动应用
      await delegator.startAbility({ bundleName, abilityName: 'EntryAbility' });
      await new Promise(resolve => setTimeout(resolve, 2000));

      // 点击新增按钮
      await driver.findComponent(ON.text('+')).click();
      await new Promise(resolve => setTimeout(resolve, 500));

      // 输入标题
      await driver.findComponent(ON.type('TextInput')).inputText('测试待办');
      await new Promise(resolve => setTimeout(resolve, 500));

      // 点击保存
      await driver.findComponent(ON.text('添加待办')).click();
      await new Promise(resolve => setTimeout(resolve, 500));

      // 验证列表中出现新待办
      const item = await driver.findComponent(ON.text('测试待办'));
      expect(item !== null).assertTrue();

      done();
    });
  });
}

九、多元化习题

习题 1(判断题)

题目:在本实战项目中,Todo 类使用 @ObservedV2 + @Trace 装饰后,修改 todo.title 会自动触发 UI 刷新,无需像 V1 那样逐层创建子组件配合 @ObjectLink。

答案:正确

解读:V2 的 @ObservedV2 + @Trace 将观测能力实现在类属性上,无论嵌套多少层均能观测到变化,无需逐层创建自定义组件。这是 V2 相比 V1 的核心优势。

习题 2(单选题)

题目:在本项目中,filteredTodos 使用 @Computed 装饰,以下说法正确的是( )

A. @Computed 每次 UI 渲染都会重新计算
B. @Computed 仅在依赖的状态变量变化时重新计算
C. @Computed 不能访问 @Trace 装饰的属性
D. @Computed 只能用于简单类型计算

答案:B

解读:@Computed 只有在依赖的 @Local、@Param、@Trace 等状态变化时,才会触发重新计算和刷新。如果依赖的状态没有变化,即使 UI 重新渲染,计算属性也不会重新计算。

习题 3(多选题)

题目:关于本项目的架构设计,以下说法正确的有(多选):

A. 数据层包含 RDB、Preferences 和文件存储三种持久化方案
B. ViewModel 层使用 @ObservedV2 + @Trace 实现数据深度观测
C. 页面层使用 Navigation + Tabs 搭建多页面骨架
D. 组件层使用 @Param + @Event 实现父子组件通信

答案:A、B、C、D

解读:项目数据层包含 RDB(待办数据)、Preferences(用户设置)和文件存储(数据导出)三种方案,选项 A 正确。ViewModel 层使用 @ObservedV2 + @Trace 实现深度观测,选项 B 正确。页面层使用 Navigation + Tabs 搭建多页面骨架,选项 C 正确。组件层使用 @Param + @Event 实现父子组件通信,选项 D 正确。

习题 4(代码填空题)

题目:请补全以下代码,使 TodoViewModel 中的 activeCount 能够自动计算未完成待办数量。

export class TodoViewModel {
  @Trace todos: Todo[] = [];

  ______________
  get activeCount(): number {
    return this.todos.filter(t => !t.isCompleted).length;
  }
}

答案:@Computed

解读:@Computed 装饰的计算属性会在依赖的状态变量变化时自动重新计算。activeCount 依赖 todos,当 todos 变化时会自动重新计算未完成待办数量。

习题 5(代码改错题)

题目:以下代码存在状态管理问题,请指出问题并修正。

@ComponentV2
struct HomePage {
  @Local todoVM: TodoViewModel = new TodoViewModel();

  build() {
    Text(`${this.todoVM.activeCount}`)
  }
}

答案:@Local 只能观察对象自身的变化,不能观察对象内部属性的变化。TodoViewModel 内部使用了 @Trace 装饰的属性,但 @Local 无法感知其内部变化。修正方案有两种:方案一,如果 TodoViewModel 是共享的单例,使用 @Provider / @Consumer 进行跨层级共享;方案二,如果确实需要在组件内创建,确保 TodoViewModel 类使用 @ObservedV2 装饰且属性使用 @Trace。

@ObservedV2
export class TodoViewModel {
  @Trace todos: Todo[] = [];

  @Computed
  get activeCount(): number {
    return this.todos.filter(t => !t.isCompleted).length;
  }
}

解读:V2 中 @Local 只能观察对象自身的变化(引用替换),不能观察对象内部属性的变化。如果要追踪对象内部的属性变化,必须结合 @ObservedV2 和 @Trace。本项目中将 TodoViewModel 设计为 @ObservedV2 装饰的类,属性使用 @Trace 装饰,并通过 @Provider / @Consumer 共享实例。

习题 6(简答题)

题目:简述本项目的分层架构设计,以及各层的职责。

答案:本项目采用四层架构。页面层(pages/views)负责 UI 展示和用户交互,使用 Navigation + Tabs 搭建多页面骨架,通过 @Consumer 获取 ViewModel 实例。组件层(components)负责可复用的 UI 单元,如 TodoItem、PriorityTag、EmptyView 等,通过 @Param 接收数据、@Event 触发回调。ViewModel 层(viewmodel)负责业务逻辑,使用 @ObservedV2 + @Trace 实现深度观测,使用 @Computed 缓存筛选排序结果,提供增删改查等业务方法。数据层(data)负责持久化,包含 RDB(待办数据)、Preferences(用户设置)和文件存储(数据导出)三种方案。工具层(utils)提供日期格式化、字符串处理等通用能力。

解读:分层架构的核心价值在于职责分离和可维护性。页面层只关心 UI 展示,ViewModel 层只关心业务逻辑,数据层只关心持久化,各层通过明确定义的接口通信,修改一层不会影响其他层。

习题 7(简答题)

题目:简述在本项目中如何综合运用 RDB、Preferences 和文件存储三种持久化方案,以及各自的适用场景。

答案:本项目中三种持久化方案的运用如下。RDB 用于存储待办数据(Todo),因为待办是结构化的业务数据,需要支持按条件筛选、按字段排序、增删改查等操作,RDB 的关系表和 SQL 查询能力正好满足这些需求。Preferences 用于存储用户设置(UserSettings),包括主题色、字体大小、排序方式、默认筛选条件,这些是轻量级的键值对配置数据,使用 Preferences 更加简洁。文件存储用于数据导出功能,将待办列表序列化为 JSON 字符串后写入文件,便于用户备份或迁移数据。

解读:三种持久化方案各有侧重,选型原则是:少量配置走 Preferences,结构化业务数据走 RDB,大文件或需要导出/导入的数据走文件存储。实际应用中常常组合使用,形成完整的持久化架构。

十、本节知识点总结

需求分析与功能拆解
从用户故事出发,拆解出待办列表、新增/编辑、设置、关于等核心功能模块,明确各模块的交互流程和数据需求。

数据模型设计
使用 @ObservedV2 + @Trace 定义可深度观测的数据类,使用枚举定义优先级、筛选类型、排序类型等常量。

分层架构
采用页面层、组件层、ViewModel 层、数据层、工具层的五层架构。页面层负责 UI 展示,组件层负责可复用 UI 单元,ViewModel 层负责业务逻辑,数据层负责持久化,工具层提供通用能力。

多页面骨架
使用 Navigation 作为根容器管理路由,使用 Tabs 组织主框架页面,通过 @Provider / @Consumer 共享 NavPathStack 和 ViewModel 实例。

状态管理
使用 @Local 管理组件内部状态,@Param 接收外部输入,@Event 触发回调,@Provider / @Consumer 跨层级共享,@Computed 缓存计算结果,@Monitor 监听状态变化。

持久化方案
RDB 存储结构化业务数据(待办),Preferences 存储轻量配置(用户设置),文件存储用于数据导出。三种方案组合使用,覆盖不同数据特征。

性能优化
列表数据量大时使用 LazyForEach + @Reusable,使用 @Computed 缓存筛选排序结果,启动时仅做轻量初始化,非关键模块延迟异步执行。

测试
单元测试覆盖 ViewModel 的筛选、搜索、排序、计数等核心逻辑,UI 测试覆盖新增待办等关键用户路径。

课程总结
至此,ArkUI 练中学系列 20 课已完整覆盖从环境搭建到综合实战的全链路知识体系。建议在实际项目中遵循“需求分析 → 架构设计 → 数据建模 → UI 实现 → 状态管理 → 持久化 → 性能优化 → 测试验证”的开发流程,将每节课的知识点融会贯通,逐步构建出高质量的 HarmonyOS 应用。

Logo

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

更多推荐