【ArkUI 练中学】第20课:综合实战——从零构建一个完整应用
本节目标
· 综合运用前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 应用。
更多推荐
所有评论(0)