☰
鸿蒙ArkTS记事本开发工程解析:路由、状态与持久化
2026/10/18 19:19:46 网站建设 项目流程

简介:这套工程是学习鸿蒙原生应用的良好范例,UI层与数据层分离,逻辑清晰,基于ArkTS语言开发并兼容HarmonyOS 3.0及以上版本,主要面向鸿蒙入门开发者,也适合需要快速产出自定义笔记工具的工程师,帮助读者快速搭建鸿蒙原生应用开发环境。整套工程共124个文件,以xml界面布局、java业务逻辑、png图标资源、json与gradle工程配置等为主,压缩包仅11.6MB,结构完整、路径标准,在DevEco Studio中导入后即可一键同步和真机调试。功能层面包含本地数据存储逻辑,采用Preferences或轻量级数据库方案,实现记事任务的增、删、改、查,从界面交互到数据持久化形成完整闭环;同时通过entry模块分层组织UI代码,预留基础组件便于换肤或功能扩展,开发者可在此基础上融入标签分类、提醒通知等二次开发需求。工程同步预置完整的gradle构建体系与版本控制、IDE辅助配置,省去新手初始化时间;配套中文说明文档从环境配置、编译步骤到常见问题都做了梳理,适合初学者照着做、随时查,遇到编译报错也能较快定位。目前已有36人学习下载。

1. 鸿蒙记事本为什么值得拆:一份 ArkTS 工程包解决的不只是写条笔记

记事本看着小,真要完整跑在鸿蒙系统上,要涉及的工程配置一点都不少。页面间怎么跳、数据改了界面怎么刷新、一条笔记存到哪里、应用退到后台再回来状态还在不在,这些问题不解决,demo 做得再好看也上不了真机。这份鸿蒙原生记事本开发工程包(HarmonyOS 3+适配)把这几个环节全部打通了,拿到手能直接编译安装,适合刚接触 ArkTS 的开发者对照着学,也适合想快速搭一个基础模板再改业务的人。这篇先把工程包的核心结构和关键参数拆开讲,再把实测中容易翻车的几个位置列出来,照着走可以少折腾一晚上。

2. 环境搭建与工程结构:先把跑通动作做完再改业务代码

拿到工程包第一步别急着看代码,先把运行环境校一遍。HarmonyOS 3+ 的适配意味着工程里部分 API 的签名和旧版本有差异,同一个工程在不同 SDK 版本下编译结果可能完全不同。我一般会先按最小步骤做一次模拟器安装,确认链路通畅后,再进入阅读源码的阶段。

2.1 安装 IDE 与设置 SDK:两个环境变量和一个必查参数

开发工具安装好之后,第一件事是把 SDK 路径配到环境变量里。图形界面的配置向导一般会自动写注册表,但命令行构建时经常读不到,所以手工导出一次是值得的。

# 使用 bash 导出 SDK 路径,以当前机器的实际安装目录为准 export DEVECO_SDK_HOME=/path/to/your/sdk export PATH=$DEVECO_SDK_HOME/toolchains:$PATH # 把配置写进 shell 启动文件,避免每次开终端都要重复导入 echo 'export DEVECO_SDK_HOME=/path/to/your/sdk' >> ~/.bashrc source ~/.bashrc

DEVECO_SDK_HOME是命令行构建工具读取 SDK 位置的主要依据,toolchains目录下装了编译、签名和打包相关的二进制。设置完这两个变量后,可以打开终端执行hvigorw --version确认命令能跑通,如果提示找不到命令,多半是PATH没生效,重开终端再看一次。

这里还有一个必查参数容易被忽略:SDK 的 API 版本。工程包标注了 HarmonyOS 3+ 适配,但你的 IDE 里可能同时装了多个版本的 SDK,默认编译目标可能选到 API 12 甚至更高,导致部分接口报废弃。检查方法是在工程的build-profile.json5里看compileSdkVersion和compatibleSdkVersion两个字段,保持一致再编译。

2.2 工程目录与配置文件:先看清每个目录的职责再动手

工程包的目录结构比普通前端项目多两层,这层梳理清楚了,后面改代码不会迷路。

entry/src/main/ ├── ets/ │ ├── entryability/ │ │ └── EntryAbility.ets │ └── pages/ │ ├── Index.ets │ └── EditPage.ets ├── resources/ │ ├── base/ │ │ ├── element/ │ │ └── media/ │ └── rawfile/ └── module.json5

ets目录放的是 ArkTS 源码,pages下是页面组件,工程包里的列表页和编辑页都在这里。resources/base/media放图片等资源,rawfile放的是不经过编译直接打入 HAP 的文件。我在不少项目里见过把大型图片塞进rawfile导致包体超限的情况,这个后面会细说。

module.json5是整个模块的配置入口,页面的注册、权限声明、后台运行配置都在这里。打开工程后如果发现模拟器白屏,第一反应就去看这个文件里abilities数组的内容,而不是去改页面代码。

2.3 本地运行与调试验证:用一句命令确保工程没有静态错误

IDE 里点运行按钮当然可以跑,但命令行构建能更快暴露配置问题。工程包的根目录下自带hvigorw包装脚本,直接用就比较稳。

./hvigorw assembleHap --mode module -p product=default

这条命令会把当前模块打包成 HAP 文件,输出路径一般在entry/build/default/outputs/下。如果编译过程报错信息指向某个.ets文件,先确认是不是 SDK 版本不匹配导致的 API 变更,而不是直接去改业务逻辑。常见的有@ohos.router迁移到@kit.ArkUI的导入路径变化,工程包里已经做了适配,但换到其他版本 SDK 时容易踩到。

模拟器安装完成后,第一次启动建议观察日志输出里的AppLog标签,页面加载失败、资源找不到这类问题都会在这里留下痕迹。

3. 核心功能拆解:页面路由、状态刷新与本地存储的落地写法

记事本工程包拆开来看,核心就三件事:从列表页进编辑页、编辑内容后刷新列表、把笔记内容持久化到本地。这三件事在原生应用里分别对应路由、状态管理和数据存储,新手容易把它们混在一起写,最后页面能跑但架构很乱。

3.1 页面路由:NavPathStack 比 router 好在哪

工程包里用的是 Navigation 容器加 NavPathStack 的方式做页面跳转,而不是直接使用 router 的pushUrl。原因在于 Navigation 能感知当前页面的生命周期,页面入栈出栈时组件会收到对应的回调,对编辑页这种需要保存草稿的场景非常有用。

// 定义路由栈,绑定到 Navigation 容器 @State private pathStack: NavPathStack = new NavPathStack(); // 列表页里点击一条笔记时跳转编辑页 this.pathStack.pushPathByName('EditPage', { noteId: note.id });

pushPathByName的第一个参数是目标页面的名称,这个名字要和NavDestination注册的name完全一致,大小写敏感。第二个参数是传给编辑页的数据,支持对象字面量,页面里通过NavDestination的入参就能取到。

如果只想替换当前页面而不保留返回栈,用replacePathByName而不是pushPathByName。区别在编辑页保存成功返回列表页时,push会让列表页保留原状态,replace则会新建一个列表页实例,两者行为看起来差不多,但内存占用和页面状态会有差异。默认情况下我建议用push,等业务明确要求“保存后不返回”再换成replace。

3.2 状态管理:@State、@Link 与 @Observed 的选取边界

记事本的数据模型是典型的“列表-详情”结构,列表页展示笔记标题,编辑页修改内容。这里最容易犯的错误是在两个页面各自维护一份数据,保存后列表页拿不到新内容。工程包的处理方式是让数据模型本身被观察,页面之间只传递引用。

@Observed class NoteModel { id: string = ''; title: string = ''; content: string = ''; updateTime: number = 0; constructor(id: string, title: string, content: string) { this.id = id; this.title = title; this.content = content; this.updateTime = Date.now(); } }

@Observed修饰的类,其属性变化能被 UI 感知到。在列表页里,用@State装饰一个NoteModel数组,每一项交给子组件渲染时,子组件用@ObjectLink接收,这样编辑页修改note.content后,列表页里对应组件会自动刷新,不需要手动回调。

@Entry @Component struct NoteListPage { @State notes: NoteModel[] = []; build() { List({ space: 8 }) { ForEach(this.notes, (note: NoteModel) => { ListItem() { NoteItemView({ note: note }) } }, (note: NoteModel) => note.id) } } } @Component struct NoteItemView { @ObjectLink note: NoteModel; // 这里直接使用 note.title,数据变化时组件自动重绘 }

特别提醒一件事:@State对数组的监听是“引用级”的,直接对数组执行splice删除某个元素,UI 不一定会刷新,但在NoteModel对象内部改字段却能正常触发。工程包里对所有数组操作都统一走“先构造新数组再整体赋值”的写法,这个习惯建议保留,能少踩几个隐蔽的坑。

3.3 数据持久化:Preferences 还是 RelationalStore

一条笔记包含标题、正文、更新时间三个字段,数据量不大但需要按时间排序,选存储方案时要在简单和功能之间做取舍。

import preferences from '@ohos.data.preferences'; // 获取 Preferences 实例,name 决定本地文件名 let store = preferences.getPreferencesSync(context, { name: 'note_store' }); // 写入一条笔记,key 在设计时建议加上 id 后缀 store.putSync('note_title_' + note.id, note.title); store.putSync('note_content_' + note.id, note.content); store.putSync('note_time_' + note.id, note.updateTime); store.flush();

上面是 Preferences 的典型写法,putSync只支持基础类型,复杂对象要先序列化成 JSON。优点是代码量少,适合保存用户偏好这类简单配置,缺点是一旦笔记数量变多,key 的管理会变得混乱,查不到“所有超过一周没修改的笔记”这种问题。

工程包在保存笔记上用的是关系型数据库,对应@ohos.data.relationalStore,建表 SQL 放在首次启动时执行:

import relationalStore from '@ohos.data.relationalStore'; const config: relationalStore.StoreConfig = { name: 'note.db', securityLevel: relationalStore.SecurityLevel.S1 }; const store = await relationalStore.getRdbStore(context, config); await store.executeSql( `CREATE TABLE IF NOT EXISTS note ( id TEXT PRIMARY KEY, title TEXT NOT NULL, content TEXT, update_time INTEGER )`, null );

这里securityLevel是必填项,S1 表示仅本应用可访问。executeSql在旧版 API 里需要传空数组作为参数占位,新版可以传null,编译报错时先查 API 版本差异。

我自己划分存储边界的经验是:全局配置类数据用 Preferences,用户产生的结构化数据用关系型数据库。记事本属于后者,虽然用 Preferences 也能凑合跑,但后面要加搜索、按日期筛选功能时就会发现限制很多,早切换早省事。

4. 避坑与调试:六个在鸿蒙记事本开发中反复出现的现场

这一章是把工程包实测过程中最容易让人来回折腾的问题单独拿出来说。每个问题都按现象、原因、解决三步拆开,这些内容在其他教程里不太会被写进文档,但对实际开发非常关键。

4.1 模拟器白屏:页面配置漏了模块注册

现象:应用在模拟器上能安装,启动后屏幕空白,控制台没有明显的报错日志。

原因:新增的编辑页文件建在pages目录下,但module.json5的pages数组里没有注册这个页面的路径。IDE 的自动创建向导有时会漏写这一步,尤其是手动复制文件时最容易出现。

解决:打开entry/src/main/module.json5,检查pages数组是否包含pages/EditPage。路径对不上时,运行时会在日志里打印类似Failed to find page的信息,搜索关键词能更快定位。补上注册后重新编译,这个问题的处理时间通常在五分钟以内。

4.2 真机安装报签名校验失败:证书有效期不是唯一变量

现象:模拟器上一切正常,换到真机安装时提示签名校验失败,安装进度条直接回滚。

原因:证书本身没过期,但签名证书 profile 里声明的bundleName和工程里app.json5配置的不一致。这种不一致多发生在复制工程后改了应用包名、忘了同步证书信息的情况下。

解决:到 IDE 的签名配置页重新生成 profile,生成时确认bundleName与工程配置完全相同。这里要注意包括大小写,系统在这方面是区分对待的。重新签名后再试安装,通常一次就能过。

4.3 列表删除后界面不刷新:数组操作被 @State 漏监听了

现象:删除一条笔记后,数据库里已经查到数据没了,但列表页 UI 还显示着那条笔记,点击也无法再进入编辑页。

原因:@State notes监听的是数组对象的引用,直接调用splice方法修改数组的内容,引用值没有变,UI 框架不会收到更新通知。

解决:先基于旧数组创建新数组,再整体赋值给@State变量:

this.notes = this.notes.filter((item: NoteModel) => item.id !== deletedId);

用filter返回新数组,替换掉原来的引用,UI 就能正常感知。从那以后我要求自己一组装工程,就先把“数组只替换不原地修改”这条规则写进代码规范里,后面省了不少排查时间。

4.4 HAP 体积超限:rawfile 里塞了未压缩的原图

现象:打包时报错提示 HAP 超过平台限制,检查代码也没看到明显的大体积文件。

原因:resources/rawfile目录里放置了从设计稿导出的 PNG 原图,单张超过 2MB,直接被打进安装包导致超限。rawfile的文件不会做压缩处理,放进什么就是什么。

解决:大图先转 WebP 格式再放入resources/base/media,真正需要原始文件的场景才放rawfile。这招对包体积影响很明显,我见过一个项目只做了这步调整,HAP 直接缩小了 40%。

4.5 冷启动白屏超过两秒:同步读库阻塞了首帧渲染

现象:应用刚启动时页面白屏时间偏长,热启动正常,只有冷启动复现。

原因:页面aboutToAppear里用了同步方式读取数据库,数据量大时阻塞了首页的首次渲染。日志里能看到首帧时间戳比正常情况多了几百毫秒。

解决:把读取操作改成异步,让 UI 先画出骨架,数据到位后再刷新列表。一个简单的做法是在页面创建后立刻发起查询,不等待结果返回就直接进入build,查询完成后用状态变量驱动列表更新。这块方案不复杂,但效果非常明显,实测首帧时间能降一半以上。

4.6 页面返回后再进入:编辑页输入框内容还在

现象:从编辑页保存返回列表页,再进入同一条笔记,输入框里残留的是上一次的内容,而不是数据库里的值。

原因:路由栈里保留了之前的编辑页实例,重新进入时没有触发重建。

解决:确认是在返回时调用pathStack.pop()弹出编辑页,并用pushPathByName重新入栈。对应工程包里的写法:

// 保存完成后先出栈,下一次进入会创建新实例 this.pathStack.pop();

如果业务需要保留上次编辑状态,则不弹出页面,用back回到列表页,这样再次进入时输入框内容自然还在。两种方案没有对错,关键是想清楚产品要哪种行为再动手。

5. 进阶:生命周期与并发任务的处理习惯

记事本这类工具型应用,页面简单,但生命周期与异步任务交叉时最容易出现隐蔽问题。比如用户编辑到一半切到后台,回到应用时定时器还在跑;或者页面已经销毁,异步查询的回调还在尝试更新 UI。工程包里针对这两个场景做了比较克制的处理,值得单独说一说。

5.1 页面 Disappear 不等于销毁:定时器必须手动回收

aboutToDisappear回调在页面不可见时触发,但它不代表页面已经释放,只是被切到后台或压入栈底。如果在这个回调里只做了数据保存,却没有处理定时器,返回页面时定时器可能重复注册。

aboutToDisappear() { if (this.timerId) { clearTimeout(this.timerId); this.timerId = -1; } }

类似的还有编辑页的草稿保存,我通常把草稿写入放在aboutToDisappear里,因为切后台是一个合理的保存时机,而只在点击保存按钮时写库,切后台再回来时数据可能丢失。

5.2 异步任务取消:页面销毁后 Promise 回调还在执行

编辑页里加载笔记详情通常是异步操作,等数据返回时用户可能已经返回到列表页。如果不做处理,回调里引用页面状态会触发无效更新,甚至内存泄漏。工程包里用一个简单的标志位来避免这个问题:

private cancelled: boolean = false; async loadNoteDetail(noteId: string) { this.cancelled = false; const data = await getNoteById(noteId); if (this.cancelled) { return; // 页面已销毁,放弃后续更新 } this.title = data.title; this.content = data.content; } aboutToDisappear() { this.cancelled = true; }

这个做法比“判断组件是否销毁”之类的 API 更直观,也更容易在代码评审时看清楚逻辑。从那以后我每次做完一个页面,都会强制走一遍“页面退后台、进入编辑、快速返回”的路径,看日志里有没有无效回调在刷新已销毁的页面。这个习惯帮我挡掉了不少只在真机高负载时才会出现的偶发问题。

希望这篇把工程包的边界和坑都讲清楚了,能帮你在自己的鸿蒙原生应用上少走几段弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询