简介:这份源代码是一套基于 HarmonyOS 开发的个人日记本应用完整实现,适合正在学习鸿蒙应用开发的初学者或需要快速搭建记录类产品的开发者。应用支持文字、图片、音频、视频等多媒体日记记录,并提供数据加密、智能提醒与多设备同步能力,功能上比普通文本工具更完整。资源包共101个文件,压缩后约391KB,以26个ets页面文件、39个png UI资源为主,辅以11个json、9个json5配置文件及7个ts脚本,覆盖工程配置、页面逻辑与静态素材。从目录结构来看,包含登录页、主页、日记列表、写日记等页面模块,以及数据库操作、下拉刷新等工具封装,清晰展示了HarmonyOS应用从界面搭建、数据持久化到交互优化的完整链路,便于二次开发和学习排错。目前已有437人学习下载,结合完整源码与清晰模块划分,是上手HarmonyOS个人应用开发的实用参考资料。
1. 个人日记本应用凭什么值得用 HarmonyOS 原生源码重写一版
看到“基于 HarmonyOS 开发的一款个人日记本应用 APP 源代码”这个大标题,先纠正一个很容易出现的判断:日记本看似是把 SQLite 换成 RDB、把 RecyclerView 换成 List 就能照搬过来的低难度项目。真把代码铺开之后,麻烦全在看不见的地方:编辑页每敲一个字就去写一次数据库,列表项内容改了却不刷新,老用户升级版本后发现新字段没有加上,备份文件从手机导出后表情符号变成乱码。HarmonyOS 的 ArkTS 对状态管理做得比传统命令式 UI 更直接,同时系统自带的关系型数据库能覆盖绝大多数本地日记场景,恰好适合做这种“数据不多、内容敏感、要长期维护”的工具型 APP。这篇文章按本项目常见的工程做法,从源码工程结构、RDB 数据层、ArkUI 页面到真机签名,把完整链路讲一遍。
2. 源码工程结构与 RDB 数据层:建表、查询和事务一次定好
2.1 先看个人日记本 APP 的工程切分
我收到一份 HarmonyOS 日记本源码后,第一件事不是看页面效果,而是看entry/src/main/ets下面有没有把 store、model、pages 拆开。个人日记本虽然业务不大,但如果把 SQL 语句直接写在IndexPage.ets的按钮里,后面加一个搜索功能都会变得很痛苦。
一个常见的源码结构如下:
entry/src/main/ets/ ├── entryability/EntryAbility.ets ├── pages/ │ ├── IndexPage.ets │ └── EditPage.ets ├── model/DiaryNote.ets ├── store/NoteStore.ets └── common/DateUtils.etspages只负责渲染和事件回调,store/NoteStore.ets负责所有数据库操作,model/DiaryNote.ets放实体定义。这个拆法的收益在真机 Debug 时最明显:页面报错不会拉着 SQL 一起报,SQL 报错也不会牵出 UI 状态问题。
2.2 用 relationalStore 建 diary 表
HarmonyOS 的 RDB 接口对应@ohos.data.relationalStore,在 API 12 工程里通常写成import { relationalStore } from '@kit.ArkData'。建表最小代码是:
// entry/src/main/ets/store/NoteStore.ets import { relationalStore } from '@kit.ArkData'; import { common } from '@kit.AbilityKit'; const DIARY_TABLE = 'diary_table'; export class NoteStore { private store?: relationalStore.RdbStore; async init(context: common.Context): Promise<void> { const config: relationalStore.StoreConfig = { name: 'diary.db', securityLevel: relationalStore.SecurityLevel.S1 }; this.store = await relationalStore.getRdbStore(context, config); await this.store.executeSql(` CREATE TABLE IF NOT EXISTS ${DIARY_TABLE} ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, content TEXT, mood TEXT, weather TEXT, created_time INTEGER NOT NULL, updated_time INTEGER NOT NULL ) `); } }这段代码里securityLevel决定了数据库的安全等级:S1 表示一般数据,S2 到 S3 会引入更严格的访问控制。个人日记内容不属于公开信息,我一般会直接把它调到SecurityLevel.S3,代价是极端情况下数据库加解密的耗时略有增加。建表时把id设为主键并自增,created_time和updated_time存 Unix 毫秒时间戳而不是字符串,排序、按天分组、做备份去重都会省事很多。
下面这张字段表作为源码里的注释放在NoteStore.ets文件头部,比任何文档都有效:
| 列名 | 类型 | 用途 | 源码注意点 |
|---|---|---|---|
id | INTEGER | 主键 | 自增,导入备份时不要硬塞旧 id |
title | TEXT | 日记标题 | 允许为空,但列表页要有兜底文案 |
content | TEXT | 正文 | 不建索引,避免脏数据进入全文检索 |
mood | TEXT | 心情标签 | 建议存固定枚举值,如happy/sad |
weather | TEXT | 天气标签 | 旧版本升级时可能不存在 |
created_time | INTEGER | 创建时间 | 毫秒时间戳 |
updated_time | INTEGER | 修改时间 | 列表排序和 diff key 都依赖它 |
2.3 插入和分页查询:事务与 ResultSet 别踩坑
日记应用最常见的初始化场景是把本地 JSON 备份导入数据库,或者从旧表批量迁移。逐条insert性能很差,正确做法是包一层事务:
async insertMany(notes: DiaryNote[]): Promise<void> { if (!this.store) { return; } this.store.beginTransaction(); try { for (const note of notes) { const values: relationalStore.ValuesBucket = { 'title': note.title, 'content': note.content, 'mood': note.mood, 'weather': note.weather, 'created_time': note.createdTime, 'updated_time': note.updatedTime }; await this.store.insert(DIARY_TABLE, values); } this.store.commit(); } catch (e) { this.store.rollBack(); throw e; } }beginTransaction()和commit()必须成对出现,rollBack()只放在 catch 里。这里有一个容易踩的坑:如果insert循环体里嵌套了其他事务,或者事务内有 await 且中间抛错却忘记 rollBack,后续所有写入都会卡死。我在真机上调试某次迁移时,就是因为事务没回滚导致日记列表只能读不能写。
列表页分页查询是另一个高频代码段:
async queryPage(page: number, pageSize: number): Promise<DiaryNote[]> { const predicates = new relationalStore.RdbPredicates(DIARY_TABLE); predicates.orderByDesc('updated_time') .limitAs(pageSize, page * pageSize); const rs = await this.store!.query(predicates); const result: DiaryNote[] = []; while (rs.goToNextRow()) { result.push({ id: rs.getLong(rs.getColumnIndex('id')), title: rs.getString(rs.getColumnIndex('title')), content: rs.getString(rs.getColumnIndex('content')), mood: rs.getString(rs.getColumnIndex('mood')), weather: rs.getString(rs.getColumnIndex('weather')), createdTime: rs.getLong(rs.getColumnIndex('created_time')), updatedTime: rs.getLong(rs.getColumnIndex('updated_time')) }); } rs.close(); return result; }limitAs的第一个参数是本次取多少条,第二个参数是偏移量。源码里如果不小心把page和pageSize传反,表现形式是列表一直不出数据,而不是崩溃,排查时容易被忽略。rs.close()一定要执行,否则 ResultSet 没释放,下一次打开页面时旧游标还在占用连接。对于规模不大的日记表,每页 20 条足够,不要因为写了queryPage就真的敢一次性SELECT *。
3. ArkUI 页面源码:时间线列表、@Observed 模型与防抖保存
3.1 列表页的 ForEach key 必须带 updated_time
ArkUI 的列表页源码里,最常见的不是List写错,而是ForEach的 key 生成器导致列表项不更新。个人日记本这种内容频繁修改的 APP,千万别只把note.id当作 key。
@Entry @Component struct IndexPage { @State notes: DiaryNote[] = []; private store = new NoteStore(); private page = 0; private pageSize = 20; build() { List({ space: 8 }) { ForEach( this.notes, (note: DiaryNote) => { ListItem() { this.noteItem(note) } }, (note: DiaryNote) => `${note.id}_${note.updatedTime}` ) } .width('100%') .height('100%') .onReachEnd(() => this.loadMore()) } @Builder noteItem(note: DiaryNote) { Column() { Row() { Text(note.title) .fontSize(18) .layoutWeight(1) Text(DateUtils.format(note.updatedTime)) .fontSize(12) .fontColor('#888888') } .width('100%') Text(note.content) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) .fontSize(14) .margin({ top: 4 }) } .padding(12) .backgroundColor('#FFFFFF') .borderRadius(12) .margin({ bottom: 8 }) } }key 生成器里拼上updatedTime后,用户修改日记内容并点击保存,数据库里的updated_time变化,列表项才会被 ArkUI 识别为“这个位置的数据变了”。如果只写note.id,ArkUI 会认为该 key 没有变化,正文已经改了但界面纹丝不动,这是个人日记本源码里最典型的 UI 缓存问题。
3.2 让实体类可观察:@Observed 与 @ObjectLink
列表项数据在子组件里发生局部修改时,只有类被标记为可观察,UI 才会及时刷新。我在源码里通常让DiaryNote继承一个被@Observed修饰的基类,或者在子组件里使用@ObjectLink接收对象:
@Observed export class DiaryNote { title: string = ''; content: string = ''; mood: string = ''; weather: string = ''; updatedTime: number = 0; constructor(init?: Partial<DiaryNote>) { if (init) { Object.assign(this, init); } } }在子组件卡片中声明@ObjectLink note: DiaryNote,父组件里仍然用@State notes: DiaryNote[]持有数组。两者配合后,修改note.title这类深层字段会触发当前组件更新,不用手动重建整个数组。顺带说一句,@State只观察数组项的重置和新增删除,并不会自动观察普通 class 内部字段变化,这个边界用@Observed补齐即可。
3.3 编辑器自动保存要用防抖
日记编辑器是一个典型的高频输入场景。如果TextArea的onChange回调里直接写 RDB,用户快速输入时键盘每敲一个字就触发一次事务,很快就能在 hilog 里看到大量数据库写操作堆积。
处理方式是加 500 毫秒防抖:
private timerId: number = -1; onContentChange(value: string) { this.draft.content = value; if (this.timerId >= 0) { clearTimeout(this.timerId); } this.timerId = setTimeout(() => { this.saveDraft(); this.timerId = -1; }, 500); } aboutToDisappear(): void { if (this.timerId >= 0) { clearTimeout(this.timerId); this.saveDraft(); } }代码逻辑不算复杂,但要注意aboutToDisappear里的兜底:用户输入一段话后马上返回列表,如果只靠防抖的 500 毫秒,页面销毁时定时器被系统终止,最后一次修改就没有落库。clearTimeout后立刻调用一次saveDraft是个人日记本 APP 必须做的收尾动作。编辑器源码还有一个可以顺手优化的地方:saveDraft内部只 update 改过的字段,不要每次把title、content、weather全量塞进ValuesBucket,减少无谓的旧值覆盖。
4. 日记数据的备份恢复与表结构迁移源代码
4.1 用 PRAGMA user_version 做 RDB 升级
HarmonyOS 的 RDB 不是每次都会自动帮你改表,CREATE TABLE IF NOT EXISTS只能保证建表,不能给老表补新列。个人日记本经常会从第一版只存title/content升级到存weather/mood,这时候需要自己维护一个版本号。常见做法是使用 SQLite 自带的PRAGMA user_version:
async migrate(): Promise<void> { const rs = await this.store!.querySql('PRAGMA user_version'); let version = 0; if (rs.goToNextRow()) { version = rs.getLong(0); } rs.close(); if (version < 2) { await this.store!.executeSql( 'ALTER TABLE diary_table ADD COLUMN weather TEXT' ); await this.store!.executeSql('PRAGMA user_version = 2'); } }这段代码要在init()建表之后执行。注意PRAGMA user_version只能表示整数版本号,所以每次升级都写死一个递增的数字。不要把ALTER TABLE直接放在应用启动的主流程之外,因为如果用户设备上既有版本 1 又有版本 0 的表,条件必须用< 2而不是=== 1,否则跳过升级的用户永远等不到新字段。
4.2 导出备份 JSON 并在源码层做字段校验
备份功能是日记应用区别于普通备忘录的核心。我一般不直接把.db文件原样复制给用户,因为数据库文件里可能包含索引碎片和未回收空间,导出为 JSON 后在导入端更可控。
export function buildBackupJson(notes: DiaryNote[]): string { const payload = { app: 'harmony-diary', schemaVersion: 2, exportedAt: Date.now(), notes: notes.map(n => ({ title: n.title, content: n.content, mood: n.mood, weather: n.weather, createdTime: n.createdTime, updatedTime: n.updatedTime })) }; return JSON.stringify(payload); }导出文件不要保存id字段。备份用于换机或重装场景,旧id在新库里没有任何意义,带着反而会让AUTOINCREMENT自增序列错乱。schemaVersion是必须写的,以后备份格式增加字段时,可以用它判断是否要执行兼容转换。
真机上保存备份文件时,把 JSON 先写入应用沙箱,再通过picker.DocumentViewPicker让用户选择保存位置。不要直接在源码里写死一个公共目录路径,一来是应用权限不允许,二来是用户在“文件管理”里也找不到,体验非常差。
4.3 导入恢复时的合并策略
导入备份最容易弄脏数据。源码里我会先解析 JSON,再按createdTime + title做一次去重,最后把不重复的日记批量写入事务:
async importNotes(backupNotes: DiaryNote[]): Promise<number> { let inserted = 0; this.store!.beginTransaction(); try { for (const note of backupNotes) { const predicates = new relationalStore.RdbPredicates(DIARY_TABLE); predicates.equalTo('created_time', note.createdTime) .and() .equalTo('title', note.title); const rs = await this.store!.query(predicates); const exists = rs.goToNextRow(); rs.close(); if (!exists) { await this.insertOne(note); inserted++; } } this.store!.commit(); return inserted; } catch (e) { this.store!.rollBack(); throw e; } }重复判断使用createdTime加title,比单独判断title安全,因为同一天里“周末”出现两次也是合理的。如果旧备份缺少createdTime,导入时可以在解析层补一个当前时间戳,但这样会导致每导一次都生成一条新日记,所以备份导出字段里必须带上时间戳。
5. 把个人日记本 APP 签名装进真机:构建命令与验证技巧
源码写完后最重要的一步是把 HAP 装上真机。DevEco Studio 里直接点运行最省事,但用命令行更可控,也容易在 CI 里复用:
hvigorw clean hvigorw assembleHap --mode module -p product=default hvigorw signHap hdc install -r entry/build/default/outputs/default/entry-default-signed.hap这段命令中,hvigorw clean负责清掉上一轮编译产物,避免增量编译把旧的资源文件带进去;signHap会读取项目中配置的签名文件提示;hdc install -r的-r参数表示覆盖安装,也就是保留应用数据重新安装,日常调试时不需要每次都卸载旧包。
签名这一步是个人日记本源码最容易卡住的地方。调试签名和发布签名不同,真机调试只需要在 DevEco Studio 里把“Automatically generate signature”打开,让 IDE 自动生成证书和 profile 即可,可以不用先申请正式发布证书。如果命令行签名报错,先检查build-profile.json5里signingConfigs指向的.cer、.p7b文件是否存在,再用 DevEco Studio 手动重新生成一次。
以下是真机调试时我常用的验证清单:
| 现象 | 验证方法 | 问题源头 |
|---|---|---|
| 安装报签名错误 | hdc shell bm dump -n com.example.diary查看 bundleName | 签名 profile 和工程包名不一致 |
| 列表改文字后不刷新 | `hdc shell hilog | grep ArkUI` 查看 ForEach key |
| 自动保存掉数据 | 日志中观察saveDraft时间点 | 缺少aboutToDisappear兜底 |
| 数据库升级后崩溃 | `hdc shell hilog | grep Rdb` |
| 备份文件保存失败 | 查看应用沙箱是否有写入权限 | 未先写沙箱再用 DocumentViewPicker 转存 |
最后补一个验证启动页的命令:hdc shell aa start -b com.example.diary -a EntryAbility。如果应用在桌面点开正常但命令启动失败,多半是module.json5里EntryAbility的exported属性为false,调整后再装一次。个人日记本 APP 的源码要从“能编译”到“真机可发布”,这几个点比多写十个页面都要重要。
本文还有配套的精品资源,点击获取