简介这套工程是学习鸿蒙原生应用的良好范例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 ~/.bashrcDEVECO_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.json5ets目录放的是 ArkTS 源码pages下是页面组件工程包里的列表页和编辑页都在这里。resources/base/media放图片等资源rawfile放的是不经过编译直接打入 HAP 的文件。我在不少项目里见过把大型图片塞进rawfile导致包体超限的情况这个后面会细说。module.json5是整个模块的配置入口页面的注册、权限声明、后台运行配置都在这里。打开工程后如果发现模拟器白屏第一反应就去看这个文件里abilities数组的内容而不是去改页面代码。2.3 本地运行与调试验证用一句命令确保工程没有静态错误IDE 里点运行按钮当然可以跑但命令行构建能更快暴露配置问题。工程包的根目录下自带hvigorw包装脚本直接用就比较稳。./hvigorw assembleHap --mode module -p productdefault这条命令会把当前模块打包成 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 更直观也更容易在代码评审时看清楚逻辑。从那以后我每次做完一个页面都会强制走一遍“页面退后台、进入编辑、快速返回”的路径看日志里有没有无效回调在刷新已销毁的页面。这个习惯帮我挡掉了不少只在真机高负载时才会出现的偶发问题。希望这篇把工程包的边界和坑都讲清楚了能帮你在自己的鸿蒙原生应用上少走几段弯路。本文还有配套的精品资源点击获取