news 2026/10/12 9:40:18

OpenHarmony从零开发记事本:ArkTS状态管理与数据持久化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
OpenHarmony从零开发记事本:ArkTS状态管理与数据持久化实战

最近花了一个周末,在 OpenHarmony 上从零写了一个记事本 APP。开始之前,我以为这只是个“列表 + 输入框 + 本地存储”的小练习,真动手以后才发现,光是把 ArkTS 的状态管理、Stage 模型的生命周期和数据持久化串起来,就够写一篇文章了。这篇把我踩过的坑、能直接抄的代码、以及为什么这样做,一次性讲清楚。如果你正准备用 OpenHarmony 练手,或者想把一个小工具搬到鸿蒙生态,这篇应该对你有用。

记事本是个很神奇的项目:功能需求谁都能秒懂,但实现起来几乎覆盖了移动应用开发的所有高频环节——页面跳转、列表渲染、输入交互、数据增删改查、前后台切换保存。拿它当 OpenHarmony 的入门项目,性价比非常高。下面直接进入正题。

1. 内容整体设计与思路拆解

1.1 为什么拿记事本练手

OpenHarmony 的应用开发方式和传统的 Android 开发、小程序开发都有一定交集,但又有自己的一套规则。记事本这个场景特别适合练手,主要有三个原因:

  • 不依赖复杂硬件能力,不需要蓝牙、摄像头、传感器这些外设,调试成本低。
  • 核心功能足够清晰:新建、编辑、删除、列表展示、本地持久化,随便一个用户都能描述需求。
  • 能覆盖 OpenHarmony 应用开发的基本盘:ArkUI 页面怎么写、组件怎么绑定数据、路由怎么跳、数据库怎么存、Ability 生命周期怎么处理。

如果一上来就做一个带登录、推送、云同步的完整产品,大概率会被工程框架绕晕。记事本可以让你先把“单个页面怎么跑通”“数据怎么存下来”这两个最基本的问题解决掉,再谈扩展。

1.2 功能边界:够用不等于简陋

很多人在写项目时会陷入一个误区,功能越多越好。实际上第一次写 OpenHarmony 应用,MVP(最小可用产品)反而是最合适的选择。我这个记事本第一版只做了这些事:

功能模块具体内容优先级
笔记列表按更新时间倒序展示标题、摘要、时间必须做
新建笔记从列表页跳转到空编辑页必须做
编辑笔记修改标题和正文,带自动保存必须做
删除笔记长按列表项弹窗确认删除必须做
本地搜索按标题和正文关键字过滤建议做
置顶、标签、富文本第一版不做后续扩展

这个边界看起来很“小”,但已经能把 OpenHarmony 的 UIAbility、页面路由、状态管理、关系型数据库全部串起来了。先做透这些基础能力,后面加功能只是堆组件和字段的问题。

1.3 总体技术选型

我选择的是 API 9 以上的 Stage 模型,开发语言用 ArkTS,UI 用 ArkUI 声明式写法,数据存储用关系型数据库 RelationalStore。

技术点选择理由
开发语言ArkTSOpenHarmony 应用的主流语言,基于 TypeScript 但更严格
UI 框架ArkUI声明式 UI,状态驱动页面刷新
应用模型Stage 模型API 9 开始的主推模型,代码结构清晰
数据持久化RelationalStore结构化数据,支持 SQL 查询,后续扩展方便
页面路由router简单直接,适合轻量级页面跳转

为什么不直接用 Preferences 存数据?Preferences 本质是键值对,适合存配置项,比如“是否第一次启动”“主题色”。如果整个笔记列表序列化成一个 JSON 塞进去,也能跑,但一旦要做搜索、排序、按 id 更新某一条,就要把整个列表读出来再写回去,越到后面越别扭。关系型数据库对这类场景才是正解。

2. 核心细节解析与实操要点

2.1 Stage 模型与 UIAbility 生命周期

新手最容易忽略的就是 Ability 家族。OpenHarmony 的 Stage 模型里,一个应用可以有一个或多个 UIAbility,UIAbility 负责和用户交互。记事本应用只需要一个入口,就是工程里的 EntryAbility。

UIAbility 的生命周期里,最关键的是onCreate和onWindowStageCreate。onCreate适合做一些全局初始化,比如初始化数据库;onWindowStageCreate里加载首页,相当于传统 Android 的setContentView。

import UIAbility from '@ohos.app.ability.UIAbility'; import window from '@ohos.window'; import { NoteStore } from '../common/NoteStore'; export default class EntryAbility extends UIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { // 在这里初始化数据库,后续页面可以直接使用 NoteStore.init(this.context).then(() => { hilog.info(0x0000, 'NoteApp', 'NoteStore init done'); }); } onWindowStageCreate(windowStage: window.WindowStage): void { windowStage.loadContent('pages/Index', (err) => { if (err.code) { hilog.error(0x0000, 'NoteApp', 'loadContent failed: %{public}s', JSON.stringify(err)); return; } }); } }

注意:有些教程还在用 FA 模型里的mainAbility,如果是新工程,直接选 Stage 模型就行。从 API 9 开始 Stage 是主推方向,照着它写以后迁移也省事。

2.2 ArkTS 状态管理:@State 不是摆设

在 ArkUI 里,页面 UI 由状态驱动。最简单的入门写法是@State修饰变量,变量一变,绑定了它的组件自动刷新。

记事本首页核心状态是笔记数组:

@State private noteList: Note[] = [];

这里有个细节:@State监听的是状态的变化,不是“你做了什么”。如果直接修改数组内部的某个字段,比如this.noteList[0].title = '新标题',UI 不一定刷新得过来。最稳妥的做法是让数组重新赋值:

// 推荐:直接把新数组交给 @State this.noteList = result; // 或者使用展开运算符创建新数组 this.noteList = [...this.noteList, newNote];

很多新手写列表页时遇到“数据明明变了,界面没反应”,十有八九是这个原因。定义数据结构时也不要偷懒,ArkTS 不允许到处用any,建议在最开始就建好模型文件:

export interface Note { id: number; title: string; content: string; createTime: number; updateTime: number; }

2.3 数据持久化方案怎么选

OpenHarmony 提供的数据持久化方案有 Preferences、RelationalStore、分布式数据等。记事本我选 RelationalStore,理由前面说过:要做查询和排序。实际用下来它的 API 和 SQLite 类似,写起来不陌生。

方案适合场景缺点
Preferences键值对、简单配置、小数据量不适合结构化查询,数据量大后读写繁琐
RelationalStore结构化业务数据、SQL 查询需要管理 ResultSet,API 稍重
分布式数据多设备同步需要分布式组网,复杂度高

如果你只是做一个 TODO 列表,数据量极小,Preferences 也不是不可以。但记事本一定会加搜索,SQL 里一句LIKE就能搞定模糊查询,比把全量数据拉到内存里用循环过滤舒服太多。

3. 实操过程与核心环节实现

3.1 工程创建与目录结构

我用 IDE 新建了一个 Empty Ability 工程,选择 Stage 模型,语言选 ArkTS。工程创建后主要目录长这样:

entry/src/main/ets ├── entryability │ └── EntryAbility.ets ├── pages │ ├── Index.ets // 笔记列表页 │ └── EditNote.ets // 新建/编辑页 ├── model │ └── Note.ets // 数据模型 └── common └── NoteStore.ets // 数据库封装

目录结构越早规划越好。很多入门项目写到最后所有代码都堆在 pages 里,页面和数据库逻辑混在一起。我习惯把所有访问数据库的代码放在一个NoteStore类里,页面只负责调用,这样后面换存储方案或者接云端不用大改页面。

3.2 实现笔记列表页

列表页的核心是加载数据、展示数据、跳转编辑页、长按删除。

import router from '@ohos.router'; import { Note } from '../model/Note'; import { NoteStore } from '../common/NoteStore'; @Entry @Component struct Index { @State noteList: Note[] = []; @State searchText: string = ''; @State loading: boolean = false; aboutToAppear(): void { this.reload(); } onPageShow(): void { // 从编辑页返回时,重新拉一次数据 this.reload(); } reload(): void { if (this.searchText.trim().length > 0) { NoteStore.search(this.searchText.trim()).then(rows => { this.noteList = rows; }); } else { NoteStore.queryAll().then(rows => { this.noteList = rows; }); } } deleteNote(id: number): void { NoteStore.deleteById(id).then(() => { this.reload(); }); } build() { Column() { TextInput({ placeholder: '搜索笔记', text: this.searchText }) .onChange((value: string) => { this.searchText = value; this.reload(); }) .margin({ bottom: 8 }) List({ space: 8 }) { ForEach(this.noteList, (item: Note) => { ListItem() { Column() { Text(item.title.length > 0 ? item.title : '无标题') .fontSize(18) .fontWeight(FontWeight.Bold) Text(item.content.replace(/\n/g, ' ')) .fontSize(14) .fontColor('#666666') Text(this.formatTime(item.updateTime)) .fontSize(12) .fontColor('#999999') } .width('100%') .padding(12) .backgroundColor('#FFFFFF') .borderRadius(8) .onClick(() => { router.pushUrl({ url: 'pages/EditNote', params: { id: item.id } }); }) .gesture( LongPressGesture() .onAction(() => { this.deleteNote(item.id); }) ) } }, (item: Note) => item.id.toString()) } .layoutWeight(1) } .padding(16) .width('100%') .height('100%') } formatTime(time: number): string { const date = new Date(time); const y = date.getFullYear(); const m = (date.getMonth() + 1).toString().padStart(2, '0'); const d = date.getDate().toString().padStart(2, '0'); const h = date.getHours().toString().padStart(2, '0'); const min = date.getMinutes().toString().padStart(2, '0'); return `${y}-${m}-${d} ${h}:${min}`; } }

ForEach 的第三个参数是 key 生成器,一定要保证唯一。我直接用item.id.toString(),千万不要用数组下标 index 当 key。用 index 的话,删除中间一条数据后,下一帧渲染可能因为 key 重复导致列表错乱。

3.3 实现编辑页与自动保存

编辑页要处理两种入口:新建笔记时noteId = -1,编辑已有笔记时通过路由参数拿到 id。页面加载后根据 id 查数据库,回填标题和正文。

自动保存用的是防抖思路:输入停止 800 毫秒后再落库,避免每敲一个字符就写一次数据库。返回页面时再强制保存一次,防止最后一下输入没触发定时器。

import router from '@ohos.router'; import promptAction from '@ohos.promptAction'; import { NoteStore } from '../common/NoteStore'; @Entry @Component struct EditNote { @State title: string = ''; @State content: string = ''; private noteId: number = -1; private timer: number = -1; aboutToAppear(): void { const params = router.getParams() as Record<string, number>; if (params && params.id) { this.noteId = params.id; NoteStore.queryById(this.noteId).then((note) => { if (note) { this.title = note.title; this.content = note.content; } }); } } scheduleSave(): void { if (this.timer > 0) { clearTimeout(this.timer); } this.timer = setTimeout(() => { this.save(); }, 800); } save(): void { if (this.title.trim().length === 0 && this.content.trim().length === 0) { // 空笔记不保存,避免产生一堆垃圾数据 return; } if (this.noteId === -1) { NoteStore.insert(this.title.trim(), this.content).then((newId) => { this.noteId = newId; }); } else { NoteStore.update(this.noteId, this.title.trim(), this.content); } } onPageHide(): void { this.save(); } build() { Column() { TextInput({ placeholder: '标题', text: this.title }) .onChange((value: string) => { this.title = value; this.scheduleSave(); }) .fontSize(20) .padding(12) TextArea({ placeholder: '正文', text: this.content }) .onChange((value: string) => { this.content = value; this.scheduleSave(); }) .layoutWeight(1) .padding(12) .fontSize(16) } .width('100%') .height('100%') } }

这里有个容易忽略的坑:新建第一条笔记时,this.noteId还是 -1,插入成功后一定要把返回的新 id 存到this.noteId。否则用户继续编辑,程序不知道这是已有笔记,又执行了一次 insert,产生重复数据。

3.4 数据持久化:NoteStore 封装

数据库这部分我封装成单例类,核心方法包括初始化、查询所有、按 id 查询、插入、更新、删除、搜索。代码不算多,但很值得仔细看。

import relationalStore from '@ohos.data.relationalStore'; import { Note } from '../model/Note'; const STORE_CONFIG: relationalStore.StoreConfig = { name: 'note.db', securityLevel: relationalStore.SecurityLevel.S1 }; const SQL_CREATE_TABLE = ` CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT, create_time INTEGER, update_time INTEGER ) `; export class NoteStore { private static store: relationalStore.RdbStore | undefined; static async init(context: Context): Promise<void> { if (NoteStore.store) { return; } NoteStore.store = await relationalStore.getRdbStore(context, STORE_CONFIG); await NoteStore.store.executeSql(SQL_CREATE_TABLE); hilog.info(0x0000, 'NoteStore', 'init success'); } private static getStore(): relationalStore.RdbStore { if (!NoteStore.store) { throw new Error('NoteStore not initialized'); } return NoteStore.store; } static async queryAll(): Promise<Note[]> { const store = NoteStore.getStore(); const resultSet = await store.querySql( 'SELECT * FROM notes ORDER BY update_time DESC' ); return NoteStore.toNoteList(resultSet); } static async queryById(id: number): Promise<Note | undefined> { const store = NoteStore.getStore(); const resultSet = await store.querySql( 'SELECT * FROM notes WHERE id = ?', [id] ); const list = NoteStore.toNoteList(resultSet); return list.length > 0 ? list[0] : undefined; } static async insert(title: string, content: string): Promise<number> { const store = NoteStore.getStore(); const now = Date.now(); const values = { title: title, content: content, create_time: now, update_time: now }; const rowId = await store.insert('notes', values); return rowId; } static async update(id: number, title: string, content: string): Promise<void> { const store = NoteStore.getStore(); const values = { title: title, content: content, update_time: Date.now() }; await store.update(values, 'id = ?', [id]); } static async deleteById(id: number): Promise<void> { const store = NoteStore.getStore(); await store.delete('notes', 'id = ?', [id]); } static async search(keyword: string): Promise<Note[]> { const store = NoteStore.getStore(); const likeKeyword = `%${keyword}%`; const resultSet = await store.querySql( `SELECT * FROM notes WHERE title LIKE ? OR content LIKE ? ORDER BY update_time DESC`, [likeKeyword, likeKeyword] ); return NoteStore.toNoteList(resultSet); } private static toNoteList(resultSet: relationalStore.ResultSet): Note[] { const list: Note[] = []; while (resultSet.goToNextRow()) { const note: Note = { id: resultSet.getLong(resultSet.getColumnIndex('id')), title: resultSet.getString(resultSet.getColumnIndex('title')), content: resultSet.getString(resultSet.getColumnIndex('content')), createTime: resultSet.getLong(resultSet.getColumnIndex('create_time')), updateTime: resultSet.getLong(resultSet.getColumnIndex('update_time')) }; list.push(note); } resultSet.close(); return list; } }

有两个点特别提醒。

第一,ResultSet用完一定要 close。它持有数据库查询结果,反复打开不关闭,轻则内存警告,重则后续查询报错。在toNoteList里,即使中间有异常,也应该保证 close 被调用,实际项目建议用 try catch finally 包一层。

第二,insert方法返回的是rowId,这个值就是自增主键 id,可以直接拿来当新笔记 id。插入后把 rowId 存到编辑页的noteId字段,后续更新就走 update 分支。

4. 常见问题与排查技巧实录

4.1 列表返回后不刷新

很多人的第一个版本是:从编辑页返回列表页,数据没有变。原因是aboutToAppear只在页面首次创建时调用一次,从编辑页返回时,列表页的实例还在,不会重新执行aboutToAppear。

解决办法是在列表页里监听onPageShow,每次页面显示都执行reload()。我一开始只写了aboutToAppear,每次回列表都得手动下拉刷新才能看到新笔记,后来加上onPageShow才彻底解决。

4.2 RdbStore 初始化时机不对

如果页面的queryAll在NoteStore.init之前执行,会直接抛异常。尤其当你在aboutToAppear里初始化、又在同一个生命周期里查询时,顺序容易翻车。

最稳妥的初始化位置是 EntryAbility 的onCreate,这个阶段应用上下文已经准备好,NoteStore.init(this.context)在这个时机调用,后续页面不管什么时候去查询,store 都已经就绪。

4.3 自动保存陷阱:重复插入

编辑页自动保存的常见 Bug 是:用户输入几个字,定时器触发 insert,生成一条新笔记;用户继续输入,noteId还是 -1,又插入一条。解决办法就是我前面说的,insert 之后立即把返回的 id 赋值给this.noteId。

另外,还要处理空笔记问题。用户打开新建页一个字没打就返回,这时候不应该创建记录。我在save()里加了一个判断:标题和正文都为空时直接 return。

4.4 ArkTS 严格模式:别把 TS 当 JS 写

ArkTS 比 TypeScript 严格得多。最常见的是no-any限制,比如从router.getParams()拿参数时,返回类型不明确,直接用会报错。要手动做成类型断言:

const params = router.getParams() as Record<string, number>;

还有一点:不要试图在 ArkTS 里使用Object类型的自由对象。定义接口、类,或者用Record<string, string>来描述,反而更安全。

4.5 问题速查表

现象大概率原因处理方式
列表返回后不刷新缺少onPageShow在页面生命周期里重新 reload
数据库报未初始化init 晚于查询在 EntryAbility 的 onCreate 里初始化
输入后出现多条空笔记没有处理空内容 return保存前判空
UI 不更新直接修改数组内部元素给@State赋新数组
搜索无结果关键字没做 trim搜索前keyword.trim()
ResultSet 占用过多没有 close查询后立即 close

5. 从记事本到完整应用的扩展路线

第一版跑通之后,后面能玩的空间其实很大。我自己的规划是先把富文本加上,毕竟当前 TextArea 只能存纯文本,换行符、简单 Markdown 语法都需要自己解析。

然后是搜索优化。目前LIKE '%keyword%'在数据量小的时候没问题,但当笔记数量到几千条,可以考虑用词条拆分和索引优化。OpenHarmony 的关系型数据库支持 SQL,能做的优化和传统 SQLite 差不多,关键看你想存什么结构。

还有一个我非常想试的方向是桌面卡片。OpenHarmony 的卡片机制可以把笔记摘要直接放到桌面上,这是移动生态里很特别的能力。卡片需要单独的 FormExtensionAbility 和卡片配置文件,工程结构会比纯页面复杂,但底层数据依然是这张 notes 表,复用现有 NoteStore 会很舒服。

如果要做多端协同,还可以关注分布式数据能力。把笔记存到分布式数据库后,手机、平板、开发板之间可以共享数据。不过这个方向涉及组网和设备认证,建议等基础功能稳定之后再投入,否则很容易被环境问题打断。

我在实际开发中最深的一个体会是:OpenHarmony 应用开发看着全是新概念,但底层逻辑并不难。状态驱动 UI、SQL 存数据、Ability 管生命周期,这些和主流移动开发思路相通。记事本 APP 的价值在于用最小成本把这些概念全部揉进一个项目里,只要把这篇里的代码跑通,你对 OpenHarmony 应用开发的骨架就算立起来了。接下来再写登录、列表详情、设置页,都会顺手很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 9:39:08

计算机视觉学术速递精读:热门方向与大作业选题指南

3月18日的这期"计算机视觉与模式识别学术速递"&#xff0c;我一早就开始读了。做这个方向的人都知道&#xff0c;这类速递最怕的不是内容多&#xff0c;而是不知道从哪下手——CV和PR的论文产出量太大&#xff0c;光图像分类、目标检测、分割、生成模型、多模态这几条…

作者头像 李华
网站建设 2026/10/12 9:39:05

腾讯云VectorDB与大模型RAG实战指南

我无法根据您提供的标题生成符合要求的博文内容。原因如下&#xff1a;标题中涉及的“腾讯云国际站渠道代理商”“TokenHub”“Hy3与Hy4 preview”等表述&#xff0c;目前在公开、合规、可验证的技术生态中无权威出处。腾讯云官方文档、开发者中心、混元&#xff08;HunYuan&am…

作者头像 李华
网站建设 2026/10/12 9:37:07

德国民法典全文PDF处理指南:从文本抽取到结构化知识库

简介&#xff1a;德国民法典全文PDF面向法学专业学生、民法研究者及对大陆法系感兴趣的读者&#xff0c;提供1896年颁布、1998年最近修订的德国民法典中文文本&#xff0c;便于研读总则部分的条文结构与立法逻辑。资源包内含1个PDF文件&#xff0c;约101KB&#xff0c;轻量便携…

作者头像 李华
网站建设 2026/10/12 9:33:36

MyBatis内部链路全解析:从Builder到查询的完整流程与避坑指南

1. 从一次线上排查说起&#xff1a;为什么需要搞懂MyBatis内部链路去年帮一个朋友排查一个诡异问题&#xff1a;他们的服务在压测时&#xff0c;某个查询接口的响应时间会周期性飙高&#xff0c;但数据库侧的慢查询日志里却什么都看不到。Druid连接池监控显示活跃连接数正常&am…

作者头像 李华
网站建设 2026/10/12 9:30:52

Eye Tribe眼动追踪复现避坑指南:校准、坐标映射与跨平台替代方案

简介&#xff1a;本资源是基于Java实现的眼球凝视追踪技术演示项目&#xff0c;面向人机交互开发者、心理学实验研究者及Java进阶学习者&#xff0c;旨在帮助理解Eye-Tribe硬件SDK集成、眼动数据实时捕获与轨迹可视化等核心流程。压缩包共93个文件&#xff0c;含24个Java源码文…

作者头像 李华
网站建设 2026/10/12 9:27:59

DBO优化SVR超参:工业多变量预测的高效调参方案

简介&#xff1a;本资源是一份面向MATLAB开发者与智能算法研究者的DBO-SVR多变量回归预测实战教程&#xff0c;聚焦于用蜣螂优化算法&#xff08;DBO&#xff09;自动寻优支持向量回归&#xff08;SVR&#xff09;超参数&#xff08;C、gamma、epsilon&#xff09;&#xff0c;…

作者头像 李华