我在 HarmonyOS 上写了一个炫彩记事本:从踩坑 relationalStore 到理解 ArkUI 状态管理


一个普通大三学生的 HarmonyOS 开发踩坑记录,包含一次差点通宵的 debug 经历。
写在前面的碎碎念
学校这学期开了一门移动应用开发的课,老师让我们用 HarmonyOS 做个啥都行的东西交上去算平时分。因为之前搞过 Android,知道 SQLite 是移动端本地存储的标配,我就想着做一个笔记 App 练练手——反正增删改查就那么回事,顶多换个 API,能难到哪去?
事实证明我当时想得太天真了。HarmonyOS 这个 relationalStore 跟 Android 的 SQLiteOpenHelper 完全不是一个路子,我光是搞懂它的 Context 传参就折腾了两三个小时。还有它那个 ArkTS 的严格模式,对类型检查跟我以前写 TypeScript 的习惯差太多了,动不动就 red line 糊一脸。
这篇文章算是我做完整个项目之后的一个复盘吧。不会写成那种教科书式的技术文档——那种东西官方文档已经够多了——主要讲讲我自己踩过的坑、绕过的弯、以及后来回头看觉得"要是当时知道这个就好了"的点。如果你也在学 HarmonyOS,或者对 ArkUI 的状态管理机制感兴趣,应该能少走一些弯路。
项目本身不复杂,一个记笔记的 App,支持新建、编辑、删除,每条笔记可以选 8 种颜色,数据存在本地的 SQLite 数据库里。界面我尽量做得花哨了一点——毕竟要交作业嘛,老师第一眼看的就是颜值。
先放个项目结构,有个大概印象:
toInfosProject/
├── AppScope/ # 应用级配置(包名、版本号等)
├── entry/
│ ├── src/main/
│ │ ├── ets/
│ │ │ ├── database/
│ │ │ │ └── DatabaseHelper.ets ← SQLite 数据库封装
│ │ │ ├── entryability/
│ │ │ │ └── EntryAbility.ets ← 入口,数据库初始化
│ │ │ └── pages/
│ │ │ └── Index.ets ← 主页面,所有的 UI 都在这里
│ │ └── resources/ ← 颜色、字符串、图片资源
│ └── module.json5
├── build-profile.json5 ← SDK 版本配置
└── oh-package.json5 ← 依赖管理
代码量不大,三个核心文件加上一些配置,但麻雀虽小五脏俱全。下面我就按照我实际开发的顺序来讲——先是从零搭数据库,然后是 UI,最后说一个让我差点通宵的坑。
一、数据库层:relationalStore 跟你想的不一样
1.1 第一印象:这 API 怎么跟 Android 完全不一样
如果你也是从 Android 转过来的,第一次看到 HarmonyOS 的数据库 API,你的反应大概跟我一样——想骂人。
Android 的 SQLite 用起来是什么样的?你写一个类继承 SQLiteOpenHelper,实现 onCreate 和 onUpgrade,然后在 onCreate 里写 CREATE TABLE 的 SQL,该定义的表结构一目了然。用的时候 getWritableDatabase() 拿到 SQLiteDatabase 对象,直接调用 insert()、query()、update()、delete() 这些方法,全程同步操作,不需要关心什么 Promise 什么 async/await,在主线程调了也不会报错(最多 ANR)。
HarmonyOS 把这个东西叫 relationalStore,属于 @kit.ArkData 这个系统套件,对标的是 Android 的 SQLiteDatabase。但你得用 relationalStore.getRdbStore() 来获取数据库实例,而且它是异步的,返回一个 Promise<RdbStore>。这倒不是坏事——异步避免了主线程阻塞——但问题是整个调用链路都得改成 async/await,不像 Android 那样可以随时随地 db.insert()。
先看下我的数据库建表语句,这个倒是跟 SQLite 标准一样:
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
color_index INTEGER NOT NULL DEFAULT 0,
created_time TEXT NOT NULL DEFAULT (datetime('now','localtime'))
);
五个字段,表结构很简单。id 自增主键,title 和 content 存笔记的标题和正文,color_index 存的不是颜色值,而是颜色数组的下标(0 到 7),created_time 用 SQLite 自带的 datetime 函数生成当前时间。这里有个细节:datetime('now','localtime') 里的 'localtime' 是用来把 UTC 时间转成本地时间的,不加这个的话存的就是格林威治时间,比你手机上的时间慢 8 小时。这个坑我在 Android 上也踩过,所以下意识就加上了。
1.2 Context 的问题,花了我三个小时
HarmonyOS 的 relationalStore.getRdbStore() 需要传两个参数:一个 Context 和一个 StoreConfig。看起来很简单对吧?关键是这个 Context 是哪来的。
我最开始是从 EntryAbility 的生命周期方法 onCreate 里拿到的:
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
DatabaseHelper.getInstance().init(this.context);
}
这里的 this.context 是 UIAbility 继承下来的属性,类型是 UIAbilityContext。我一开始觉得这肯定没问题——UIAbilityContext 不就是 Context 的子类嘛,传进去有啥问题?
然后编译直接炸了,DevEco Studio 告诉我类型不匹配。
当时我就懵了。查了半天文档才发现,在 HarmonyOS 里,relationalStore.getRdbStore() 需要的不是 Ability 级别的 Context,而是 Application 级别的 Context——也就是 this.context.getApplicationContext()。这两种 Context 的区别是这样的:
Ability 级别的 Context 跟具体的 Ability 实例绑定,Ability 销毁了它就没了。如果你用它打开数据库,当用户切到后台,系统回收了这个 Ability 实例,数据库连接也就跟着断了。
Application 级别的 Context 是跟整个应用的生命周期绑定的,只要 App 还没被杀掉,它就一直有效。数据库连接挂在应用级 Context 上,就不会因为页面切换或者 Ability 销毁而断开。
正确的初始化代码:
DatabaseHelper.getInstance().init(this.context.getApplicationContext());
改成这样就好了。就这么一行代码的区别,我在各种论坛和文档里翻了快三个小时。主要是 HarmonyOS 的中文资料真的太少了——而且大部分是机翻的英文文档,读起来比英文原文还费劲。
1.3 单例模式封装,别让数据库连接满天飞
数据库连接是宝贵的资源,如果一个 App 里到处都在创建新的 RdbStore 实例,不仅浪费内存,还有可能因为并发写入导致数据损坏。所以我用了单例模式来管理数据库连接:
export class DatabaseHelper {
private static instance: DatabaseHelper;
private store: relationalStore.RdbStore | null = null;
static getInstance(): DatabaseHelper {
if (!DatabaseHelper.instance) {
DatabaseHelper.instance = new DatabaseHelper();
}
return DatabaseHelper.instance;
}
async init(context: common.Context): Promise<void> {
const config: relationalStore.StoreConfig = {
name: 'ColorfulNotes.db',
securityLevel: relationalStore.SecurityLevel.S1
};
this.store = await relationalStore.getRdbStore(context, config);
await this.store.executeSql(SQL_CREATE_TABLE);
}
private getStore(): relationalStore.RdbStore {
if (!this.store) {
throw new Error('数据库未初始化,请先调用 init()');
}
return this.store;
}
}
这里有两个点值得一提。
第一个是 SecurityLevel.S1。这玩意儿控制的是数据库文件的安全级别,S1 是最低的,表示数据存储在应用沙箱内,不需要额外的加密保护。如果你的 App 存的是用户密码或者支付信息之类的敏感数据,应该用 S3 或者 S4,让系统在硬件安全区里存储密钥。我做的是一个笔记 App,没什么敏感内容,用 S1 就够了。不过后来想想,用户写的东西毕竟是隐私——可能会有人在笔记里写日记啥的——其实用 S2 会更合适。算是一个考虑不周的地方吧。
第二个是 getStore() 这个私有方法。因为 store 的类型是 relationalStore.RdbStore | null,初始值是 null,在 init() 执行完之前它就是空的。后面每次增删改查都要拿到 store 对象,如果不做这个空检查,代码里到处都是 if (this.store) 的判断,又丑又容易漏。用一个 getStore() 统一处理,内部抛异常,调用方得到的就一定不是 null。这种小技巧在 TypeScript 里其实挺常用的,ArkTS 一样能用。
1.4 CRUD 的写法——RdbPredicates 这个设计有点意思
最让我觉得 HarmonyOS 跟 Android 不同的地方,是它查询数据的方式。Android 的 SQLiteDatabase 支持好几种查询方式:你可以直接写 SQL 字符串用 rawQuery(),也可以用 query() 方法传表名、列名、WHERE 条件等参数。HarmonyOS 多了一种方式——RdbPredicates。
async queryAllNotes(): Promise<Note[]> {
const store = this.getStore();
const predicates = new relationalStore.RdbPredicates(TABLE_NAME);
predicates.orderByDesc('created_time');
const resultSet = await store.query(predicates);
const notes: Note[] = [];
while (resultSet.goToNextRow()) {
notes.push({
id: resultSet.getLong(resultSet.getColumnIndex('id')),
title: resultSet.getString(resultSet.getColumnIndex('title')),
content: resultSet.getString(resultSet.getColumnIndex('content')),
colorIndex: resultSet.getLong(resultSet.getColumnIndex('color_index')),
createdTime: resultSet.getString(resultSet.getColumnIndex('created_time'))
});
}
resultSet.close();
return notes;
}
RdbPredicates 说白了就是一个查询条件构造器。你 new 一个它,传个表名进去,然后链式调用它的各种方法:equalTo、orderByDesc、like、in、between 等等。最后把这个 predicates 扔给 store.query(),它内部自动拼成 SQL 执行。
这种设计的好处很明显——避免了手拼 SQL 字符串时容易搞错的单引号转义和 SQL 注入问题。用 Android 的 rawQuery() 时,如果你不小心把用户输入直接拼进 SQL,那就是一个注入漏洞。用 RdbPredicates 就不存在这个问题,因为参数是分开传的,框架帮你做转义。
但坏处也有——不够灵活。比如我想查"标题或内容中包含某关键词"的笔记,用 SQL 可以写 WHERE title LIKE '%keyword%' OR content LIKE '%keyword%',用 RdbPredicates 就得研究半天它的复合条件 API。最后我还是用 store.querySql() 直接写 SQL 做了一些复杂查询。
删除也是如此,用 RdbPredicates 定位要删的记录:
async deleteNote(id: number): Promise<boolean> {
const predicates = new relationalStore.RdbPredicates(TABLE_NAME);
predicates.equalTo('id', id);
const rows = await store.delete(predicates);
return rows > 0;
}
这里注意一件事:store.delete() 返回的不是布尔值,而是被删除的行数。如果返回 0,说明没有匹配的记录被删除——要么是 id 不存在,要么是数据库里没数据。所以用 rows > 0 来判断是否真的删掉了东西。
1.5 ResultSet 的使用注意事项
ResultSet 是 HarmonyOS 关系型数据库的查询结果对象,跟 Android 的 Cursor 长得很像,都得手动遍历和关闭。但它有几个细节跟 Cursor 不一样,我一开始就在这里踩了坑。
第一个,取字段值用的是 类型特定方法,而不是通用的类型转换。你要取整数用 getLong(columnIndex),要取字符串用 getString(columnIndex),不能混用。而且 getColumnIndex('列名') 必须精确匹配建表时的列名,大小写敏感。
第二个,遍历完之后一定要 close()。虽然 HarmonyOS 的文档说 ResultSet 是一个游标,但你不手动 close 的话,底层文件描述符不会释放。多操作几次就会报 too many open files 的异常。这个问题刚开始不明显,因为是单次查询,但如果用户在短时间内反复增删改查,累积起来的未关闭 ResultSet 就会把系统资源耗尽。我在测试的时候连续点了十几次添加按钮,App 直接闪退了,日志里就是这个错误。后来每条查询最后都加上了 resultSet.close(),就没再出过问题。
第三个,ResultSet 跟 RdbStore 不一样,它没有 Promise 接口,方法是同步的。这意味着在遍历 resultSet 的时候不要做耗时操作,否则会卡 UI 线程。我的做法是把数据从 ResultSet 里全部取出来,存到本地数组里,然后再调用 close() 释放资源,最后返回数组。后面的 UI 渲染就只跟数组打交道,不依赖数据库连接了。
二、深入说说 ArkUI 的状态管理——这可能是 HarmonyOS 跟 Android 最大的设计哲学差异
前面说了数据库的事,这部分我想认真聊聊 ArkUI 的状态管理,因为这是我整个开发过程中感悟最深的地方。
2.1 从 Android 的"命令式"到 ArkUI 的"声明式"
如果你做过 Android 原生开发,你应该很熟悉这套流程:
- 在 XML 里写好布局,给每个控件一个 id
- 在 Activity 或 Fragment 里用
findViewById()拿到控件的引用 - 当数据变化时,手动调用控件的 setter 方法更新 UI——
textView.setText(newText)、recyclerView.getAdapter().notifyDataSetChanged()等等
这个模式叫做"命令式 UI"——你告诉框架"去把那个文本框的文字改成这个值",框架就老老实实去改。简单粗暴,但有个问题:当界面复杂到一定程度,同时有很多东西在变化时,你得小心翼翼地保证每一步更新都覆盖到了,漏掉一个就会导致 UI 不一致。
ArkUI 用的是另一套思路——声明式 UI。你不是告诉框架"怎么改",而是告诉它"我是谁,我长什么样",然后把决定权交给框架。框架负责在数据变化的时候自动更新对应的 UI。
具体到代码上,看一个简单的对比。在 Android 中切换一个文本的显示和隐藏,大概是这样:
if (condition) {
textView.setVisibility(View.VISIBLE);
} else {
textView.setVisibility(View.GONE);
}
在 ArkUI 里写同样的逻辑:
if (this.condition) {
Text('显示的文字')
}
你没看错。就是直接用 if 控制是否创建这个组件。condition 的值变了,ArkUI 会自动重新执行 build() 方法,该创建的创建,该不创建的不创建。你不用操心 setVisibility 那一套。
2.2 @State:让变量"可被跟踪"的那条红线
在 ArkUI 里,不是所有变量都能触发 UI 更新。你在组件里定义一个普通的类属性,改了之后 UI 无动于衷。必须用 @State 装饰器来告诉框架——“这个变量的变化你要盯着,变了就要重绘相关的组件”。
我的笔记页面里用了 8 个 @State 变量:
@State notes: Note[] = []; // 笔记列表,这是核心数据
@State showAddDialog: boolean = false; // 是否显示新建/编辑弹窗
@State editTitle: string = ''; // 编辑框的标题内容
@State editContent: string = ''; // 编辑框的正文内容
@State selectedColorIndex: number = 0; // 当前选中的颜色下标
@State isEditing: boolean = false; // 是编辑模式还是新建模式
@State showDeleteConfirm: boolean = false; // 是否显示删除确认弹窗
@State deleteTargetId: number = -1; // 要删除的笔记 ID
@State deleteTargetTitle: string = ''; // 要删除的笔记标题
这些变量任何一个变了,使用了它们的 UI 组件就会自动刷新。这看起来很方便,但背后有一个很容易犯的错误——只有赋值操作被跟踪,数组和对象的内部修改不会被跟踪。
什么意思呢?看这段代码:
// 这样做可以触发更新——直接赋值
this.notes = await this.db.queryAllNotes();
// 这样做不会触发更新——直接修改数组内部元素
this.notes.push(newNote); // 不起作用!
this.notes[0].title = '新标题'; // 也不起作用!
因为 @State 的跟踪机制是浅层的——它只跟踪引用是否变了,不跟踪引用指向的那个对象内部的东西。这也解释了为什么我在增删改之后都用 this.loadNotes() 重新查询数据库并全量替换 this.notes 数组,而不是在本地操作数组。要说效率的话,当然是在本地数组上直接增删更高效——省了一次数据库查询——但那样做的话,就得配合更多的技巧(比如用展开运算符 [...this.notes, newNote] 来强制创建新数组),代码可读性反而下降了。对于一个笔记 App 来说,全量重查的性能开销完全可以忽略。
2.3 @Builder:把卡片抽成一个独立构建单元
ArkUI 有个 @Builder 装饰器,它跟普通函数的最主要区别在于——@Builder 方法内部可以直接访问所在组件的 this,能用 this.xxx 调用组件的状态和方法。普通的 top-level 函数做不到这一点。
我把笔记卡片抽成了这样一个 @Builder:
@Builder
NoteCard(note: Note, index: number) {
Column() {
Row() {
// 左侧彩色竖条——用 COLORS[note.colorIndex].bg 取颜色
Column()
.width(5).height('100%').borderRadius(3)
.backgroundColor(COLORS[note.colorIndex].bg)
.margin({ right: 14 })
Column() {
Text(note.title)
.fontSize(17).fontWeight(FontWeight.Bold)
.fontColor('#333333')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
if (note.content) {
Text(note.content)
.fontSize(13).fontColor('#888888')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
}
Row() {
Text(this.formatTime(note.createdTime))
.fontSize(11).fontColor('#BBBBBB')
Blank()
Button() { Text('✏️').fontSize(14) } // 编辑
.onClick(() => { this.showEditForm(note); })
Button() { Text('🗑️').fontSize(14) } // 删除
.onClick(() => { this.showDeleteDialog(note.id, note.title); })
}
}.layoutWeight(1)
}
}
.borderRadius(16)
.backgroundColor('rgba(255, 255, 255, 0.92)') // 毛玻璃效果的关键
.backdropBlur(10)
}
这里有两个值得展开的点。
第一是 backdropBlur。这个属性实现的是 iOS 上那种毛玻璃效果——不是模糊卡片自己的内容,而是模糊卡片背后的背景。也就是说,卡片是半透明的白色,透过卡片看后面的渐变背景,是带模糊效果的。这个东西在 Android 上实现起来挺麻烦的,要么用 RenderScript(已经被废弃了),要么用第三方库。ArkUI 一行 .backdropBlur(10) 搞定,而且性能还不错,滚动列表的时候没有明显掉帧。
第二是关于 @Builder 里的事件处理。注意编辑和删除按钮的 onClick 里调用的是 this.showEditForm(note) 和 this.showDeleteDialog(note.id, note.title),这些都是 Index 组件里的方法。因为 @Builder 绑定在 Index 组件的上下文里,所以能直接访问 this。这种设计理解之后其实挺顺的——@Builder 不是独立的函数,更像是组件内部的一个"子构建块"。
2.4 弹窗是怎么做的:Stack + 条件渲染 vs Dialog 组件
HarmonyOS 提供了 CustomDialogController 来做弹窗,但我没有用它。不用的原因很简单——在 DevEco Studio 里用 CustomDialogController,跟我当时写的 ArkTS 严格模式有兼容问题,各种类型检查过不去。而且我看了官方文档的示例代码,感觉为了弹一个简单的对话框,要单独写一个 @CustomDialog 类,再在页面里 new 一个 controller,然后绑来绑去,太啰嗦了。
我直接用了 Stack 布局 + 条件渲染的方式:
// 伪代码示意
build() {
Stack() {
// 正常的页面内容(渐变背景、列表等)...
// 弹窗层——条件渲染
if (this.showAddDialog) {
// 半透明遮罩
Column()
.backgroundColor('rgba(0,0,0,0.55)')
.onClick(() => this.cancelEdit())
// 弹窗卡片
Column() {
// 标题、输入框、颜色选择、确认/取消按钮...
}
.backgroundColor('#FFFFFF')
.borderRadius(20)
}
}
}
用 Stack 的好处是——弹窗本身就是在当前页面里的一个覆盖层,共享同一个 ArkUI 组件树,@State 变量天然就能驱动弹窗的显示和隐藏。点击遮罩区域关闭弹窗这个行为也很自然——直接给遮罩层的 Column 绑一个 onClick。
这种方法不是没有代价。最明显的问题是——弹窗里的输入状态(标题文字、正文文字、选中的颜色)会一直占用 @State 变量的空间,哪怕弹窗关了也不会释放。但如果用 CustomDialogController,每次弹窗都是独立的实例,不存在这个问题。不过对于我这个笔记 App 来说,就三个临时状态变量,占不了多少内存,所以这个代价可以接受。
2.5 颜色选择器的交互小细节
在新增和编辑弹窗里有一个颜色选择器,由 8 个圆球组成。我当时想让选中状态有一个明显的视觉反馈——选中的圆球外面套一圈加深的边框。
如果是 Android,我会在点击事件里遍历所有圆球的 View,逐个调用 setStrokeWidth() 之类的方法调整边框。但 ArkUI 的声明式写法彻底改变了思路:
ForEach(COLORS, (color: ColorItem, index: number) => {
Column()
.width(32).height(32).borderRadius(16)
.backgroundColor(color.bg)
.border({
width: this.selectedColorIndex === index ? 3 : 0,
color: color.bg,
style: BorderStyle.Solid
})
.onClick(() => { this.selectedColorIndex = index; })
})
只需要在 border 属性里用三元表达式判断当前下标是否等于 selectedColorIndex,等于就给 3px 的边框,不等于就是 0。点击时修改 selectedColorIndex 的值,框架自动刷新 UI。完全不需要手动去操作任何 DOM-like 的元素——这就是声明式 UI 的好处:聚焦在"是什么"而不是"怎么做"。
三、那个差点让我通宵的坑:@kit.RelationalStore 找不到模块
这一节其实可以算作"吐槽区",但我还是想认真记下来,因为这个问题真的浪费了我太多时间,而且网上几乎搜不到答案。
3.1 怎么回事
最开始导入 relationalStore 我写的是:
import { relationalStore } from '@kit.RelationalStore';
这在逻辑上完全说得通——Ability 用 @kit.AbilityKit,ArkUI 用 @kit.ArkUI,那关系型数据库用 @kit.RelationalStore 没毛病吧?DevEco Studio 直接给我来了一句:
Cannot find module ‘@kit.RelationalStore’ or its corresponding type declarations.
然后因为 import 失败,relationalStore 这个变量变成了 any 类型,接下来所有用到 relationalStore.xxx 的地方全部报红——Use explicit types instead of "any", "unknown"。一眼看过去 9 个红色波浪线,心态直接崩了。
3.2 找原因的过程
我先想的是:“是不是 HarmonyOS 的 Kit 包名大小写不对?” 于是试了各种组合:
@kit.RelationalStore❌@kit.relationalStore❌@kit.Relationalstore❌@kit.ArkData✅ ← 就是这个!
后来翻了 HarmonyOS SDK 的源码目录才搞清楚——关系型数据库(relationalStore)、首选项(preferences)、数据共享(dataShare)这些功能,在 HarmonyOS 里都被归类到了同一个 Kit 下面,叫 ArkData。@kit.ArkData 是一个数据管理的大集合,把数据库相关的所有 API 都囊括进去了。
这个命名其实也说得通——“Ark” 是 HarmonyOS 的技术品牌前缀,ArkUI 管界面、ArkData 管数据、ArkTS 管语言。但问题是,官方文档上的示例代码里有时候写 @kit.RelationalStore 有时候写 @kit.ArkData,甚至有版本写的是旧的 @ohos.data.relationalStore,你根本搞不清哪个是最新标准。而构建脚本(build-profile.json5)里又没有像 Android 的 Gradle 那样显式声明 SDK 组件的依赖,所有 Kit 都是隐式可用的——你能用哪个,不能直接用哪个,得碰运气试。
3.3 跟 Android 相关的对比
相比之下,Android 的依赖管理就清晰多了。Gradle 里你要用 Room(Google 推荐的 SQLite 封装),就在 build.gradle 里加一行:
implementation "androidx.room:room-runtime:2.6.0"
什么东西在哪个包里,一清二楚。Android 因为生态成熟,这类东西的文档和教程遍地都是,随便搜一下就有答案。HarmonyOS 作为一个相对年轻的生态,文档的完备程度确实差了一截——不是说没有文档,而是文档的版本太多,同一个功能在不同 API Level 下的用法可能不一样,但你很难找到一份清楚的对照表。
3.4 正确的导入方式(这是最终版本)
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
总结一下 HarmonyOS 常用的 Kit 导入速查,留给自己以后用:
| 功能 | 导入路径 |
|---|---|
| 关系型数据库、首选项 | import { relationalStore } from '@kit.ArkData' |
| Ability、Context | import { common } from '@kit.AbilityKit' |
| 提示弹窗 Toast | import { promptAction } from '@kit.ArkUI' |
| 日志输出 | import { hilog } from '@kit.PerformanceAnalysisKit' |
| 窗口管理 | import { window } from '@kit.ArkUI' |
四、界面部分:怎么把东西做得"看起来漂亮"
4.1 配色方案的由来
我不想做一个白底黑字的工具型笔记——市面上那种太多了,看起来像程序员手册。我想做的是一个让人打开就想记点东西的、有点"情绪价值"的笔记 App。
背景我选择了彩虹色渐变。不是那种生硬的赤橙黄绿青蓝紫,而是取了 9 个渐变色块,从珊瑚红过渡到橙色、黄色、绿色、青色、蓝色、紫色、粉色,最后回到珊瑚红,形成一个闭环的色彩过渡。因为是 9 个色块,大概每 40° 一个色相变化,视觉上比 7 色的标准彩虹更平滑。
颜色数组的设计是这样的:
const COLORS: ColorItem[] = [
{ bg: '#FF6B6B', light: '#FF8E8E', text: '#FFFFFF' }, // 珊瑚红
{ bg: '#FF8E53', light: '#FFB088', text: '#FFFFFF' }, // 活力橙
{ bg: '#FECA57', light: '#FFE082', text: '#333333' }, // 日光黄
{ bg: '#48DBFB', light: '#7EE8FF', text: '#333333' }, // 天青蓝
{ bg: '#A29BFE', light: '#C4BFFF', text: '#FFFFFF' }, // 薰衣紫
{ bg: '#FF6EC7', light: '#FF9FDB', text: '#FFFFFF' }, // 粉红
{ bg: '#1DD1A1', light: '#5FE0C0', text: '#FFFFFF' }, // 薄荷绿
{ bg: '#54A0FF', light: '#7FBFFF', text: '#FFFFFF' }, // 宝石蓝
];

每个颜色对象有三个属性——bg 是主色调,给卡片左侧的色条和弹窗里的颜色球用的。light 是浅色变体,给按钮的阴影颜色用的——你注意看新建/编辑弹窗里那个"添加笔记"按钮,它的阴影颜色是 color.light,就是说如果你选的是珊瑚红,按钮就会发出粉红色的光晕。这个小细节让整个弹窗的交互反馈很一致——按钮的颜色跟笔记卡片的色条颜色是同色系的。
text 是文字颜色,你看黄色和天青蓝的 text 是深色灰(#333333),其他的是白色。这是根据颜色的亮度决定的——浅色背景上放深色文字才看得清。这个判断在代码里其实没有用到(弹窗里标题和内容输入框是独立的白色背景,不受影响),但如果以后要做深色模式,这个属性就有了用武之地。
4.2 渐变背景层的实现细节
Column()
.width('100%').height('100%')
.linearGradient({
angle: this.rainbowAngle + 180,
colors: [
['#FF6B6B', 0.0],
['#FF8E53', 0.12],
['#FECA57', 0.25],
['#1DD1A1', 0.37],
['#48DBFB', 0.50],
['#A29BFE', 0.62],
['#FF6EC7', 0.75],
['#FF6B6B', 0.87],
['#FF8E53', 1.0]
]
})
linearGradient 的 colors 参数是一个二维数组,每个元素是 [颜色值, 位置比例]。位置从 0.0 到 1.0,表示这个颜色在渐变路径上的位置。比如珊瑚红的 #FF6B6B 在 0% 的位置(最边上),天青蓝 #48DBFB 在 50% 的位置(正中间),最后又回到珊瑚红在 87% 的位置,用橙色在 100% 收尾,让整个渐变首尾无缝衔接。
angle 参数控制渐变的角度,90 是上下,0 是水平。我留了一个 rainbowAngle 的变量,初衷是想让背景可以慢慢旋转,做出那种呼吸灯的效果。后来因为时间关系没有实现这个动画(需要在 aboutToAppear 里用定时器持续修改 rainbowAngle),就只保留了静态的 180° 角度——颜色从左边到右边。以后有时间再把这个动态效果加上的话,背景色彩会像极光一样流动。
4.3 卡片毛玻璃效果
之前提过的 backdropBlur(10),其实还有个前提——卡片背景得是半透明的。如果背景是不透明的白色,那模糊的是纯色的背景,看起来跟没模糊一样。所以卡片的背景色是:
.backgroundColor('rgba(255, 255, 255, 0.92)')
92% 的不透明度,保留了 8% 的透明度,正好让背景的彩色渐变微微透出来,再配合 10px 的模糊半径,效果就很接近 iOS 的毛玻璃了。如果完全透明的话,文字就看不清楚了——背景的彩色渐变会让文字缺乏对比度。92% 是我反复试出来的一个平衡值,既能保留毛玻璃的视觉质感,又不会影响文字的可读性。
4.4 Stack 的三层布局结构
整个页面的布局是用 Stack 搭建的三层结构:
- 底层:彩虹渐变背景的 Column
- 中间层:半透明黑色遮罩(
rgba(0, 0, 0, 0.35))——降低背景亮度,让前景内容更突出 - 顶层:全部 UI 内容——标题栏、颜色快捷栏、笔记列表、浮动按钮、弹窗
这种分层的好处是职责清晰——背景归背景、内容归内容——修改背景效果不会影响内容排版,反之亦然。而且由于 Stack 里的子元素按照代码顺序从底到顶排列,上面的元素自动覆盖下面的元素,不需要 z-index 那一套。
4.5 浮动按钮的霓虹光晕
右下角的粉色加号按钮想要做出那种"好像在发光"的感觉,关键代码就一行:
.shadow({
radius: 20,
color: 'rgba(255, 110, 199, 0.7)',
offsetX: 0,
offsetY: 4
})
shadow 里的 color 不是黑色或灰色,而是跟按钮本身一样的粉色只是加了透明度。这样阴影看起来就像是从按钮本身"溢出来"的光,而不是普通的投影。radius: 20 这个值比一般按钮的阴影大不少,配合 offsetY: 4(向下偏移几乎没有),让光晕均匀地散布在按钮周围。
五、项目结构里一些不太起眼但有用的小配置
5.1 main_pages.json 决定哪些页面被系统注册
HarmonyOS Stage 模型跟 Android 不一样——没有 AndroidManifest.xml 里那种显式的 Activity 声明。页面路由是靠 main_pages.json 来管理的:
{
"src": [
"pages/Index"
]
}
这个文件在 entry/src/main/resources/base/profile/ 下面。如果新建了一个页面文件比如 pages/Detail.ets,需要在这里加上 "pages/Detail",否则运行的时候系统找不到这个页面。这一点如果不注意,新页面做好了死活跳不过去,排查半天发现是这里没加。我一开始也犯过这个错。
5.2 string.json 和 color.json 的设计思路
HarmonyOS 的资源管理借鉴了 Android 的设计——把文字和颜色抽到独立的 JSON 文件里,通过 $r('app.string.app_name') 这样的方式引用。好处是一处修改全局生效,以及支持国际化。
我把启动页的背景色从 #FFFFFF 改成了 #FF6B6B(珊瑚红)。这个颜色会在 App 启动的瞬间显示——就是用户点图标到主界面加载完成之间的那个过渡画面。原来是白色的,改成珊瑚红之后,整个 App 给人的第一印象就跟"炫彩"这个主题统一了。
应用的标题也改成了"炫彩记事本",而不是默认的"应用名称"。这个改动虽然小,但确实让 App 看起来像一个完成品而不是 Demo。
5.3 build-profile.json5 里的 SDK 版本
{
"targetSdkVersion": "6.1.1(24)",
"compatibleSdkVersion": "6.1.1(24)"
}
这个版本号决定你能用哪些 API。6.1.1 是 HarmonyOS NEXT 的 SDK 版本号,括号里的 24 是 API Level。如果你遇到某个 API 没法用或者弃用的警告,先去查一下这个 API 支持的最低 API Level。
六、如果重来一次,我会改什么
写完这个项目之后回头看,有不少地方是可以做得更好的。把这些问题记下来,下次再做 HarmonyOS 项目的时候就不会重复踩坑了。
-
数据库初始化应该更可靠。现在我在
EntryAbility.onCreate里调用DatabaseHelper.init(),如果失败只是打了 log,没有弹窗提示用户,也没有重新尝试的逻辑。线上用户遇到这种情况根本不知道发生了什么——打开 App 记了笔记,退出后再打开全没了。应该加一个初始化失败后的重试机制,以及对用户友好的错误提示。 -
编辑应该支持"不改颜色就保留原色"。现在的编辑模式里,打开编辑弹窗时颜色球会默认选中该笔记原来的颜色,但如果用户在编辑过程中不小心点到了其他颜色球,想回到"不改"的状态是做不到的。应该加一个"保留原色"的独立按钮或者把颜色选择做成可选而非必选。
-
应该利用
@Observed和@ObjectLink做细粒度的卡片更新。现在每次增删改后都是全量loadNotes(),重新查数据库、重新渲染整个列表。对于只有几十条笔记的场景来说没任何性能问题,但如果笔记多了——比如几百条——这个全量刷新就会明显卡顿。更好的方案是用@Observed和@ObjectLink把每个 Note 变成一个可观察对象,只更新发生变化的那一张卡片。 -
颜色配置应该抽到单独的资源文件里。现在
COLORS数组是硬编码在Index.ets里的。如果能放到color.json里通过$r()引用,不仅可以在暗色模式下自动切换配色,也方便后续修改。ArkUI 的$r()对颜色数组的支持我没有深入研究过,不确定能不能直接引用一组颜色,但至少可以抽到一个独立的配置文件里。 -
应该加一个搜索功能。笔记多了之后靠翻列表来找东西肯定不行。配合
RdbPredicates的like方法做一个简单的关键词搜索——标题或内容匹配就行——体验会好很多。 -
promptAction.showToast里的 emoji 在高版本 Android 系统上可能有显示问题。我用了一些 emoji 在 Toast 里做装饰,比如"🎉 添加成功"、“🗑️ 已删除”。但不同系统对 emoji 的渲染不一样,有些老系统可能直接把 emoji 显示成方框。上线前最好在多个设备上验证一下。
写在最后
写完这个项目最大的感受是——HarmonyOS 的声明式 UI 开发思路(ArkUI + ArkTS)确实比 Android 传统的 XML + Java/Kotlin 模式要现代得多。状态驱动 UI 自动更新、组件化的 @Builder、条件渲染代替 Visibility 判断——这些东西一旦习惯了,再回去写 Android 的 findViewById 和 setText 会觉得很别扭。
但它的劣势也很明显:生态不够成熟,文档和社区资源跟 Android 比差距太大了。遇到问题的时候,Android 随便一搜就有 StackOverflow 的答案,HarmonyOS 可能搜半天只找到一个跟你问题不太一样的官方文档页面。而且 SDK 的 API 在不同版本之间变动比较大,同一份代码在 API 13 和 API 14 上可能跑出不一样的结果。这也是为什么我在导入 @kit.ArkData 这件事上折腾了那么久——版本差的坑全踩了一遍。
总的来说,如果是单纯想做个 App 体验一下,HarmonyOS 的开发门槛比我想象的要低。ArkTS 跟 TypeScript 高度相似,有前端基础的话上手很快。声明式 UI 的方式也让界面开发比传统 Android 直观不少。但如果想做一个上线的商业产品——尤其是依赖很多第三方库或者需要复杂性能优化的那种——可能还需要观望一下生态的发展。
以上就是一个普通大学生用 HarmonyOS 做笔记 App 的全部记录了。如果你也在学这个,希望能帮你少踩几个坑。
项目源码 & 运行方式:
- 用 DevEco Studio 打开项目
- 确保 SDK 版本 ≥ 6.1.1(24)
- 直接 Build & Run 即可,不需要额外安装任何第三方依赖
更多推荐



所有评论(0)