☰
鸿蒙原生开发实战:从零构建一款极简记录应用的全流程
2026/10/19 10:22:17 网站建设 项目流程

很多人问我,为什么要做一款叫「今天空白」的极简记录应用。起因其实特别简单。有段时间我习惯在睡前把当天值得记的事情写下来,结果每次打开手机上的笔记类软件,都要经历新建文档、选分类、起标题、调格式这一套流程。原本只想写一句话,最后却被流程劝退,干脆不记了。

后来我想明白一件事:记录的最小单位不是“文档”,也不是“分类”,而是“一天”。一天里值得记住的往往只有一两句,我要的是一款打开就能直接写、不写就提醒我“今天还是空的”的应用。于是就有了「今天空白」,一款基于HarmonyOS 6.0从零开发的极简记录应用。这篇文章我会把从工程搭建、数据层设计、核心交互到上架踩坑的完整过程写出来,适合想入门鸿蒙原生开发、或者想了解真实小项目怎么落地的朋友参考。

1. 从“记了没记”这个痛点说起:为什么需要「今天空白」

1.1 主流记录应用的三大通病

市面上的记录类应用功能多到溢出:分类标签、富文本排版、云同步、AI摘要、模板市场……看起来什么都能做,可真正到了睡前想随手记一笔的时候,反而成了负担。我把这种体验总结成三个问题:

第一个是启动路径太长。打开应用、点新建、选分类、起标题、进编辑器、找保存按钮,至少六步。这个流程放在“记录一件小事”的场景里,成本高得离谱。第二个是功能过剩,界面一半以上的按钮我根本用不到,每次打开都要在一堆入口里找到真正要用的那个。第三个是数据归属感弱,记录分散在多个分类里,想回顾昨天写了什么,先要想清楚当时放在了哪里。

我给这类问题起过一个类比:就像你每天回家想把钥匙挂到门口的钩子上,结果钩子不在门口,而在客厅深处一个上锁的抽屉里。次数多了,你就不愿意放钥匙了。日记得不到保存,不是因为你没记,而是因为“记”这个动作本身太难了。

1.2 “今天”是记录的最小单位

做了很多减法之后,我定义出「今天空白」的核心逻辑:

  • 不设标题,日期本身就是标题
  • 不设分类,内容就是全部信息
  • 一天最多一条记录,不存在“在同一个日期下再新增一条”
  • 打开应用直接定位到“今天”,有记录就展示,没记录就显示空白

也就是说,这个应用只有一种打开方式:看到今天。它把所有复杂功能都砍掉,只保留一个动作——“写下今天”。如果今天没写,首页就是空白的,这本身就是一种提醒。后来我甚至觉得,“今天空白”这个应用名,本质上就是产品最核心的文案:它不断告诉用户,今天还没有被留下痕迹。

1.3 为什么选HarmonyOS 6.0来做

之所以选择HarmonyOS 6.0 + ArkTS,而不是跨平台方案,有三个实际考虑。

第一,ArkUI的声明式UI非常适合这种“数据直接映射到界面”的小应用。一条记录查出来,通过状态驱动UI自动刷新,不需要手写一堆view更新逻辑。第二,系统的关系型数据库API和首选项API都很成熟,个人记录类数据天然适合本地存储,不需要服务器,也不用申请网络权限,隐私上非常干净。第三,这个项目足够小,却能完整覆盖工程创建、数据库、UI、打包上架全流程,对想学习鸿蒙原生开发的人来说,是一个非常好的教学样本。

我在这篇文章里不会讲太多“Hello World”级别的概念,而是直接记录真实项目里最重要、也最容易出问题的部分:数据怎么设计,状态怎么管理,上架前有哪些坑。

2. 工程骨架:Stage模型与合理的目录分层

2.1 开发环境准备:版本选择思路

开发「今天空白」用的是官方最新的开发工具链,语言是ArkTS,编译构建工具是hvigor。这里有一个版本选择上的经验:如果目标是新系统独占功能,可以把最低API版本直接设为6.0,省去兼容老版本的大量工作量;如果希望覆盖更多存量设备,则要把最低兼容版本适当调低。

我这边的选择是直接面向HarmonyOS 6.0开发。原因很简单:这是一款工具型极简应用,用户对它的预期就是“在新设备上更流畅”,而不是“在老设备上跑起来”。而且新SDK对ArkUI的组件更新性能、并发调度都有优化,日历列表滑动、键盘弹出这些场景的体验更好。把最低版本定高,意味着彻底放下兼容包袱,代码里可以更放心地使用新的API而不用写分支判断。

2.2 创建工程后的目录结构

创建工程时选择“Empty Ability”模板,生成的Stage模型工程结构很清晰。我在此基础上调整成了下面这样:

entry/src/main/ ├── ets/ │ ├── entryability/ │ │ └── EntryAbility.ets │ ├── pages/ │ │ └── Index.ets │ ├── components/ │ │ ├── DayRecordView.ets │ │ └── EditorView.ets │ ├── model/ │ │ └── DayRecord.ets │ ├── database/ │ │ └── DBHelper.ets │ └── common/ │ └── DateUtils.ets └── resources/ ├── base/ │ ├── element/ │ ├── media/ │ └── profile/ └── dark/

关于分层,我的原则是:页面层只放页面入口,组件层放可复用的UI单元,数据模型单独放,数据库访问全部集中在DBHelper里,日期计算放在工具类里。这样每个文件都很短,职责单一,排查问题时能很快定位到具体位置。尤其对单人维护的小项目来说,清晰比优雅更重要。

2.3 严格控制依赖:只用系统Kit

「今天空白」从创建工程到上架,没有引入任何第三方依赖库。这不是矫情,而是极简应用在工程层面的必然要求。

第三方UI库、日期库、工具库,每引入一个都意味着包体膨胀、启动耗时变长,以及未来版本升级时的潜在兼容风险。而这些库解决的问题,用系统自带的能力和几十行手写代码就能覆盖。日期格式化、月份天数计算、星期偏移,这些逻辑自己写反而更可控,也更能体现系统API的基本功。总体积控制在一个很小的量级,冷启动几乎没有等待感,这本身就是“极简”体验的一部分。

3. 数据层设计:以日期为主键,支撑“空白判断”

3.1 为什么选择关系型数据库而不是Preferences

HarmonyOS提供的用户首选项API(Preferences)适合存键值对,比如“上次打开的日期”“主题设置”这类小配置。但如果用来存记录正文,麻烦就来了:没有SQL查询能力,想按月找出哪些日期写了记录,只能把所有键遍历一遍再过滤,数据量小的时候还凑合,一旦需要按日期排序、统计、按时间范围查询,代码会越写越别扭。

「今天空白」虽然数据量不大,十年也就三千多条,但查询模式很明确:按月查询有记录的日期、按天精确查询某一条记录、按日期排序。这些场景正好是关系型数据库的常规操作。所以我选了系统自带的关系型数据库API,建一张表就够,反正SQL也简单,完全绕不开。

3.2 建表与唯一约束

建表语句如下:

CREATE TABLE IF NOT EXISTS day_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, record_date TEXT NOT NULL UNIQUE, content TEXT NOT NULL DEFAULT '', create_time INTEGER NOT NULL, update_time INTEGER NOT NULL );

这里最关键的是record_date字段的 UNIQUE 约束。它从数据库层面保证“一天最多一条记录”,即使代码逻辑在某处有遗漏,数据库也会拒绝插入重复日期。这比纯靠应用层判断更稳。

在实际初始化数据库的代码里,我会把它封装成一个单例方法:

import { relationalStore } from '@kit.ArkData'; const DB_NAME = 'today_blank.db'; const TABLE_NAME = 'day_record'; let store: relationalStore.RdbStore | undefined = undefined; export async function getStore(): Promise<relationalStore.RdbStore> { if (store) { return store; } const config: relationalStore.StoreConfig = { name: DB_NAME, securityLevel: relationalStore.SecurityLevel.S1 }; store = await relationalStore.getRdbStore(getContext(), config); const sql = `CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( id INTEGER PRIMARY KEY AUTOINCREMENT, record_date TEXT NOT NULL UNIQUE, content TEXT NOT NULL DEFAULT '', create_time INTEGER NOT NULL, update_time INTEGER NOT NULL )`; await store.executeSql(sql); return store; }

很多初学者会在每个函数里重新调用一次getRdbStore,这样会重复创建连接,不仅慢,还容易出现连接管理混乱。我这个封装把连接缓存成了模块级单例,后面所有数据方法都复用同一个连接,性能风险从根上消除。

3.3 核心数据方法的封装

数据访问层只暴露四个方法,足够支撑整个应用:

方法作用
getRecordByDate(date)查询某天记录,返回 undefined 即代表“这天空白”
saveRecord(date, content)不存在则插入,存在则更新
getDatesByMonth(year, month)返回该月有记录的所有日期列表
deleteRecord(date)删除某天记录

查询单日记录的实现是“先查后写”的基础:

export async function getRecordByDate(date: string): Promise<DayRecord | undefined> { const rdbStore = await getStore(); const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.equalTo('record_date', date); const resultSet = await rdbStore.query(predicates, ['record_date', 'content', 'update_time']); try { if (resultSet.goToFirstRow()) { return { date: resultSet.getString(resultSet.getColumnIndex('record_date')), content: resultSet.getString(resultSet.getColumnIndex('content')), updateTime: resultSet.getLong(resultSet.getColumnIndex('update_time')) }; } return undefined; } finally { resultSet.close(); } }

保存记录时,不要直接用insert撞唯一约束,更稳妥的做法是先查一遍再决定插入还是更新:

export async function saveRecord(date: string, content: string): Promise<void> { const existing = await getRecordByDate(date); const rdbStore = await getStore(); if (existing) { const values: relationalStore.ValuesBucket = { content: content, update_time: Date.now() }; const predicates = new relationalStore.RdbPredicates(TABLE_NAME); predicates.equalTo('record_date', date); await rdbStore.update(values, predicates); } else { const values: relationalStore.ValuesBucket = { record_date: date, content: content, create_time: Date.now(), update_time: Date.now() }; await rdbStore.insert(TABLE_NAME, values); } }

按月份查询用于日历圆点标记:构造一个起止日期范围,然后在该范围内查询所有record_date字段,返回一个字符串数组。这个查询非常轻量,即使一次查出三十条也只是几个字节的日期字符串。

3.4 数据库升级策略:从第一天就按规范来

个人项目的数据库表结构不是一开始就设计完美的,很可能发布一两周后就想加字段。我从第一次发布就坚持一个规范:数据库版本号每次变更必须加1,结构变更全部放在onUpgrade回调里执行。

假设 1.0 版本上线后,我想给每条记录增加一个“天气”字段,正确的操作是:把建表语句里同步加上weather TEXT DEFAULT '',这样新安装用户会自动带上这个字段;同时把版本号从1改为2,在升级回调里执行ALTER TABLE day_record ADD COLUMN weather TEXT DEFAULT '',这样老用户升级后也能平滑迁移。

这个策略最容易被忽略,很多人只知道改建表语句、却忘了改版本号,结果老用户升级后依然查不到新字段,运行时报错。数据库问题不像UI问题那样一眼就能看到,往往要等线上用户反馈才暴露,代价很高。

4. 主页实现:日历标记与“今天空白”的显示逻辑

4.1 用Grid自绘一个轻量月历

「今天空白」的主页需要一个月历视图来做日期维度的导航。市面上的日历组件通常很大很重,我不想引入第三方库,干脆用ArkUI的Grid组件自己画一个。

画月历的核心工作有三步:计算这个月有多少天、计算这个月第一天对应星期几、确定需要在上个月末尾补几个空位。补空位的时候,我会把上个月的日期也填进格子,但用淡灰色弱化显示,让用户知道那天属于上个月,点按也能跳转过去。代码里用@State保存当前的年份、月份和一个CalendarDay数组,每次翻月时重新生成数组:

interface CalendarDay { day: number; fullDate: string; isCurrentMonth: boolean; isToday: boolean; hasRecord: boolean; }

Grid渲染的核心片段看起来像这样:

Grid() { ForEach(this.weekTitles, (title: string) => { GridItem() { Text(title) .fontSize(12) .fontColor(Color.Gray) .width('100%') .textAlign(TextAlign.Center) } }) ForEach(this.calendarDays, (item: CalendarDay) => { GridItem() { Column() { Text(`${item.day}`) .fontColor(item.isCurrentMonth ? Color.Black : Color.Gray) .fontWeight(item.isToday ? FontWeight.Bold : FontWeight.Normal) if (item.hasRecord) { Circle() .width(4) .height(4) .fill('#FFB300') } } .width('100%') .padding({ top: 8, bottom: 8 }) .onClick(() => this.selectDate(item)) } }) } .columnsTemplate('1fr 1fr 1fr 1fr 1fr 1fr 1fr')

这里我早期踩过一个布局坑:仅仅给GridItem设置内容还不够,必须把width('100%')和textAlign都打满,否则不同数字宽度会导致格子内文字对不齐。日历是个对像素级对齐很敏感的界面,最稳妥的做法是让每个格子内部的Column和Text都居中,视觉上才会整齐。

4.2 “打开即今天”的定位逻辑

用户打开应用,第一眼看到的必须是今天,而不是上个月或者某个随机日期。这个逻辑听起来简单,但涉及一个细节:应用在后台被重新拉起时,如果跨了自然日,页面可能还停留在昨天的状态,这时候必须重新定位。

我在Index.ets的onPageShow回调里都会调用一次refreshToToday(),核心逻辑是:

  • 取当前系统日期,格式化为yyyy-MM-dd
  • 如果主页的选中日期不是今天,则切换到今天,并重新查询记录
  • 如果恰好是今天,也要重新查询一次,防止从后台恢复时状态过期

这背后的问题是日期状态的“陈旧”问题。状态管理里保存一个selectedDate字符串,它本身不会自动随时间变化,必须靠生命周期回调去刷新。忘记处理跨天恢复,是这类应用最容易出现的Bug。

4.3 空态设计:让“空白”本身成为引导

选中一天后,内容区会查询记录。如果有记录,展示正文;如果没有,展示空态。我不会用“暂无数据”这种冷冰冰的系统文案,而是直接用产品名本身的含义:“今天还是空白的”。

这句话作为空态文案有双重作用:它既是状态提示,也是行动召唤。用户看到空白的页面,自然理解这个应用的核心规则——写下内容,空白就被填充。空态下面放一个“写点什么”的按钮,点击直接进入编辑态,整个流程不超过两步。

留白是这个页面最重要的设计元素。文字偏小、颜色偏淡、四周留出大量空间,让空态不只是“没有内容”,而是有意为之的安静感。很多应用在空态上一味堆引导文案和按钮,反而破坏了静谧氛围,这是极简应用需要反复拿捏的尺度。

5. 记录编辑:无路由的单页编辑态

5.1 展示态与编辑态的切换

「今天空白」整个应用只有一个页面入口,没有用路由栈去维护复杂的页面层级。编辑记录不是在另一个页面里做,而是在主页内容区原地切换状态。

我用一个@State isEditing: boolean区分两种状态:

  • 展示态:显示记录内容、编辑按钮、删除按钮
  • 编辑态:显示输入框、保存按钮、取消按钮

这种设计的最大好处是省掉了页面间数据传递。如果拆成两个页面,就得考虑“从编辑页返回后刷新列表”这种问题,还得维护路由参数。单页内状态切换,数据始终在同一个组件的控制范围内,逻辑上更简单,动画也更容易做连贯。

切换时我用一个轻微的透明度加位移过渡,大概300毫秒。编辑态从下方“浮出”一层,背景有半透明遮罩,让用户感受到这是一次模式切换,而不是打开了一个新页面。

5.2 自动保存与防丢失机制

编辑态最担心的就是用户写了一长段内容,不小心退出去,结果没保存。我做了三层防护。

第一层是输入防抖自动保存:监听TextArea的onChange,每次输入变化时,清除之前的定时器并重新设一个300毫秒的定时器。用户停止输入300毫秒后,静默写入数据库。这样即使用户随后直接杀掉进程,内容也已经保存过了。

第二层是页面退出的强制保存:在onPageHide和onBackPress回调里,如果当前处于编辑态,立刻执行一次保存逻辑。这里要注意,onBackPress必须返回true表示已经处理了返回事件,否则系统默认返回动作会把页面关掉。

第三层是保存状态回显:保存成功后,把编辑态切换回展示态,并立刻重新查询数据库,展示最新内容。用数据库的结果回填UI,而不是用内存里的临时变量,能避免UI与存储不一致的假象。

这三层防护在开发阶段和测试阶段都没有发现问题,上线后也确实没有再出现“写了内容却丢”的反馈,说明投入是值得的。

5.3 删除与误触保护

删除功能的交互很简单:展示态左下角一个“删除”文字按钮,点击后弹出确认对话框。确认框文案是“删除这一天的记录?删除后无法恢复”,确认后执行deleteRecord,然后返回空态。

有朋友建议我加“撤销删除”,但最后没有做。因为已经有确认弹窗这道关卡,撤销属于冗余交互。极简应用的核心是减少用户的决策负担,删除一次记录本来就很轻量,再为它维护一个撤销栈,反而增加复杂度和理解成本。

这个取舍也反映了整体设计态度:不是把能做的功能都堆上去,而是把每个交互的价值想清楚,再决定做不做。

6. 性能与体验细节:极简不等于粗糙

6.1 冷启动与数据加载

「今天空白」冷启动需要做的事就两件:查当月有记录日期,查今天或选中日期的记录。两次查询都是索引级别的单表查询,耗时基本可以忽略。但我仍然做了一些规范,让加载过程稳定可靠:

  • 数据库连接复用单例,避免每次启动重新创建连接
  • 首页在数据没返回前,显示一个静态占位骨架,而不是空白闪屏
  • 查询完成后一次性赋值给@State,避免频繁的局部刷新

实测在测试设备上,冷启动到内容区出现数据,体感上属于“即点即开”。这个结果符合预期,也验证了“系统API够用、不要过度设计”的判断。

6.2 深色模式与主题适配

「今天空白」作为每天可能打开多次的夜间使用应用,深色模式是刚需。我直接跟随系统的深色模式,在资源目录下配置了深色调色板,确保在暗光环境下阅读记录时,不会有一片刺眼的白色弹出来。

配色上,浅色模式是白色背景、深灰文字,强调色用了一个偏暖的黄色,用来标记今天的日期、记录圆点和保存按钮;深色模式则换成深灰背景、浅灰文字,强调色稍微调暗,保持对比度舒适。文字颜色和背景色都不是纯黑纯白,而是带一点灰度,长时间阅读会更柔和。

这里容易犯的错是只调背景色、不调文字色,导致深色模式下文字对比度不足。我的原则是:背景、文字、强调色都要在同一套色板里成对定义,任何颜色都不能只改一半。

6.3 无障碍与字体适配

记录类应用,内容是最核心的资产,所以可读性优先。我做了三件小事:所有按钮和日期格都设置了无障碍文本,例如日期格会声明“12月3日,有记录”或“12月4日,没有记录”;所有尺寸单位使用vp,保证在不同屏幕密度下显示一致;文本组件跟随系统字体大小设置,不固定写死。

第三点很容易被忽略。很多开发者为省事,直接在Text上设置一个固定的fontSize,一旦用户在系统设置里把字体调大,界面文字就会超出布局甚至被截断。在HarmonyOS里,如果不额外指定字号,文本默认跟随系统字体缩放,所以我只对少数装饰性文字用了小号固定值,正文一律用默认字号配合自适应布局。

7. 从开发到上架:我踩过的几个坑

7.1 构建阶段:hvigor版本与本地SDK不一致

项目刚建好的时候,本地编译一直报一个“找不到某SDK组件”的错误。排查了一圈,发现是工程默认的hvigor版本和本地SDK版本不匹配。构建工具链在更新SDK后,会自动生成新的配置文件,但工程里的旧配置没有同步。

解决办法是到SDK管理器中统一SDK版本,并让hvigor重新生成配置文件。这里也引出一个原则:不要手动修改构建配置文件里的版本号,最好通过IDE的开发工具菜单操作,自动生成相对可靠的配置。

7.2 数据库升级:字段变更导致老版本崩溃

这是上线后唯一一个让我冒冷汗的问题。1.0版本上线后,我想给记录增加“天气”字段。第一次做法很简单,直接改了建表SQL,把weather TEXT DEFAULT ''加进CREATE TABLE,然后正常开发、测试、提审。测试机都是新安装,一切正常,结果发版后陆续有老用户反馈应用打不开。

原因就是前面讲过的问题:老用户数据库里根本没有weather列,新代码查询时执行SELECT ... weather ...,数据库直接报字段不存在。因为我在查询方法里没有做字段容错,这个错误一路抛到了UI层,最终导致页面崩溃。

教训两条:第一,表结构变更必须走数据库版本升级机制,不能只改建表语句;第二,数据访问层的每个查询方法,都要对可能出现的字段缺失做异常兜底,避免数据库错误直接炸到页面。这是所有本地数据存储应用都绕不开的一课。

7.3 签名与隐私合规:零权限应用也要写清楚

上架时,「今天空白」不需要申请任何权限,这本身是极大的合规优势。但即使零权限,上架审核仍然要求提供隐私声明。我在隐私政策里写了三件事:所有记录数据仅保存在用户本机、应用不收集任何个人信息、应用不包含任何网络上传行为。

签名方面,真机调试用自动签名,发布前则要生成正式的发布证书,并用命令行工具对应用进行签名。这块流程相对固定,官网文档讲得比较细,我的建议是提前准备好签名所需的证书和描述文件,不要等到提审前才仓促配置。

整个提审过程中最大的感受是:合规不是临时补作业,而是产品设计阶段就要想清楚的事。一个本地优先、无网络权限的应用,在隐私说明上天然比那些到处要权限的应用省心得多。

最后再分享一点实际的体会

做完「今天空白」这个项目,我最深的感受是:极简应用的难点从来不是“加法”,而是“减法”。功能列表一开始列了几十项,最后留下的只有日历、记录、编辑、删除四件事。砍掉每一项时都很纠结,但砍完之后才看清产品真正的样子:它只做一个动作,就是让用户把今天存下来。

技术上花时间最多的,反而落在数据库升级、签名配置这些不起眼的地方,而不是UI和交互。这也提醒我,一个小项目要走完上架流程,真正考验的是工程基本功。

如果你想拿这个项目练手,我的建议是:动手之前先明确列出“不做什么”,然后从数据层开始写,而不是一上来就搭界面。数据模型稳定了,界面再怎么改都有依托。最后分享一个我实际操作中觉得很值的小技巧:把空态文案当成产品引导来设计。“今天空白”这五个字,既说明了状态,也召唤了行动,它比任何新手引导教程都更有效。

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

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

立即咨询