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

发布时间:2026/10/12 9:40:38

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 模型开发语言用 ArkTSUI 用 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 模型里一个应用可以有一个或多个 UIAbilityUIAbility 负责和用户交互。记事本应用只需要一个入口就是工程里的 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 查询需要管理 ResultSetAPI 稍重分布式数据多设备同步需要分布式组网复杂度高如果你只是做一个 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 Recordstring, 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): Promisevoid { 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(): PromiseNote[] { 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): PromiseNote | 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): Promisenumber { 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): Promisevoid { 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): Promisevoid { const store NoteStore.getStore(); await store.delete(notes, id ?, [id]); } static async search(keyword: string): PromiseNote[] { 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 Recordstring, number;还有一点不要试图在 ArkTS 里使用Object类型的自由对象。定义接口、类或者用Recordstring, string来描述反而更安全。4.5 问题速查表现象大概率原因处理方式列表返回后不刷新缺少onPageShow在页面生命周期里重新 reload数据库报未初始化init 晚于查询在 EntryAbility 的 onCreate 里初始化输入后出现多条空笔记没有处理空内容 return保存前判空UI 不更新直接修改数组内部元素给State赋新数组搜索无结果关键字没做 trim搜索前keyword.trim()ResultSet 占用过多没有 close查询后立即 close5. 从记事本到完整应用的扩展路线第一版跑通之后后面能玩的空间其实很大。我自己的规划是先把富文本加上毕竟当前 TextArea 只能存纯文本换行符、简单 Markdown 语法都需要自己解析。然后是搜索优化。目前LIKE %keyword%在数据量小的时候没问题但当笔记数量到几千条可以考虑用词条拆分和索引优化。OpenHarmony 的关系型数据库支持 SQL能做的优化和传统 SQLite 差不多关键看你想存什么结构。还有一个我非常想试的方向是桌面卡片。OpenHarmony 的卡片机制可以把笔记摘要直接放到桌面上这是移动生态里很特别的能力。卡片需要单独的 FormExtensionAbility 和卡片配置文件工程结构会比纯页面复杂但底层数据依然是这张 notes 表复用现有 NoteStore 会很舒服。如果要做多端协同还可以关注分布式数据能力。把笔记存到分布式数据库后手机、平板、开发板之间可以共享数据。不过这个方向涉及组网和设备认证建议等基础功能稳定之后再投入否则很容易被环境问题打断。我在实际开发中最深的一个体会是OpenHarmony 应用开发看着全是新概念但底层逻辑并不难。状态驱动 UI、SQL 存数据、Ability 管生命周期这些和主流移动开发思路相通。记事本 APP 的价值在于用最小成本把这些概念全部揉进一个项目里只要把这篇里的代码跑通你对 OpenHarmony 应用开发的骨架就算立起来了。接下来再写登录、列表详情、设置页都会顺手很多。
延伸阅读

更多相关文章

2026/10/12 9:40:38

从时序预测到模型轻量化:工业AI落地的算法实战

1. 这件事为什么值得说:BPAA工业赛道到底考察什么先说说这次拿奖的背景。雪浪云在世界人工智能大会的BPAA(Best Practice in AI Application,即AI应用最佳实践)算法大赛工业赛道拿下全球十强。很多人听到这个奖项,第一…

2026/10/12 9:40:38

DispatcherServlet源码解析:一个Servlet如何撑起Spring MVC九大组件

1. 从请求入口到分发中心:DispatcherServlet在MVC里的真实位置1.1 一次请求的完整生命周期如果你维护过老式的Servlet项目,可能对那种每个URL映射一个Servlet的做法印象深刻:文件上传写一个Servlet,登录校验写一个Servlet&#xf…

2026/10/12 9:40:38

企业级AI智能体落地实战:从Gemini Agent看选型、安全与避坑指南

1. 从两条新闻看企业级AI智能体的真实拐点2026年10月9日这天,AI圈子里有两件事被反复讨论。一件是谷歌正式发布了面向企业场景的Gemini Agent企业智能体产品线,另一件是OpenAI一位安全研究员离职引发的连锁讨论。表面上看这是两条独立新闻,但…

2026/10/12 17:37:54

open-code-review:构建开放协作的代码评审框架与最佳实践

1. 项目缘起与核心定位第一次看到“open-code-review”这个标题,我脑子里蹦出来的第一个念头是:终于有人把这件事拿出来正经聊了。代码评审这件事,在大多数团队里都处于一个尴尬的位置——所有人都承认它重要,但真正做得好的团队少…

2026/10/12 17:37:54

气味合成软件精度测试:借鉴直线电机定位与重复精度方法

大概两个月前,我做了一轮气味合成软件的精度测试,结果被一个非常典型的麻烦卡了好几天:同一份玫瑰配方,上午合成和下午合成,仪器读数几乎一致,但我和同事分别盲嗅之后,都觉得不是同一个味道。问…

2026/10/12 17:37:54

python连接本地SQL server详细图文教程

前言 先说两点形式上的说明。第一,本文不插图片:连接数据库的每一步结果都是文字——驱动列表、报错信息、查询输出,用代码块呈现比截图更精确,也能直接复制。第二,「SQL server」这个写法不严谨,微软的正式…

2026/10/12 17:32:54

DeepSeek行业案例集如何读:从数据流到提示词与效果评估的迁移方法

简介:《DEEPSEEK行业应用案例集》由浙江大学相关团队整理,系统展示DeepSeek大模型在农业、制造业、汽车、手机、智能家居、物流、网络安全、金融、医疗、教育等关键行业的落地实践,面向AI产品经理、企业技术决策者及行业研究人员,…

2026/10/11 0:02:13

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/12 12:01:26

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/12 12:54:07

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/12 0:04:22

绝缘子缺陷检测数据集清洗与工业级训练实战指南

简介:本资源是面向电力AI研发人员、工业视觉工程师及智能巡检系统开发者的绝缘子缺陷检测专用YOLO格式数据集,解决无人机航拍场景下绝缘子破损、污闪、积雪等9类典型缺陷的精准识别与定位难题。数据集共2139张真实巡检图像(含训练/验证/测试集…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑