在这里插入图片描述
在这里插入图片描述

一个普通大三学生的 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,实现 onCreateonUpgrade,然后在 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 自增主键,titlecontent 存笔记的标题和正文,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.contextUIAbility 继承下来的属性,类型是 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 一个它,传个表名进去,然后链式调用它的各种方法:equalToorderByDesclikeinbetween 等等。最后把这个 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(),就没再出过问题。

第三个,ResultSetRdbStore 不一样,它没有 Promise 接口,方法是同步的。这意味着在遍历 resultSet 的时候不要做耗时操作,否则会卡 UI 线程。我的做法是把数据从 ResultSet 里全部取出来,存到本地数组里,然后再调用 close() 释放资源,最后返回数组。后面的 UI 渲染就只跟数组打交道,不依赖数据库连接了。


二、深入说说 ArkUI 的状态管理——这可能是 HarmonyOS 跟 Android 最大的设计哲学差异

前面说了数据库的事,这部分我想认真聊聊 ArkUI 的状态管理,因为这是我整个开发过程中感悟最深的地方。

2.1 从 Android 的"命令式"到 ArkUI 的"声明式"

如果你做过 Android 原生开发,你应该很熟悉这套流程:

  1. 在 XML 里写好布局,给每个控件一个 id
  2. 在 Activity 或 Fragment 里用 findViewById() 拿到控件的引用
  3. 当数据变化时,手动调用控件的 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]
        ]
    })

linearGradientcolors 参数是一个二维数组,每个元素是 [颜色值, 位置比例]。位置从 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 搭建的三层结构:

  1. 底层:彩虹渐变背景的 Column
  2. 中间层:半透明黑色遮罩(rgba(0, 0, 0, 0.35))——降低背景亮度,让前景内容更突出
  3. 顶层:全部 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 项目的时候就不会重复踩坑了。

  1. 数据库初始化应该更可靠。现在我在 EntryAbility.onCreate 里调用 DatabaseHelper.init(),如果失败只是打了 log,没有弹窗提示用户,也没有重新尝试的逻辑。线上用户遇到这种情况根本不知道发生了什么——打开 App 记了笔记,退出后再打开全没了。应该加一个初始化失败后的重试机制,以及对用户友好的错误提示。

  2. 编辑应该支持"不改颜色就保留原色"。现在的编辑模式里,打开编辑弹窗时颜色球会默认选中该笔记原来的颜色,但如果用户在编辑过程中不小心点到了其他颜色球,想回到"不改"的状态是做不到的。应该加一个"保留原色"的独立按钮或者把颜色选择做成可选而非必选。

  3. 应该利用 @Observed@ObjectLink 做细粒度的卡片更新。现在每次增删改后都是全量 loadNotes(),重新查数据库、重新渲染整个列表。对于只有几十条笔记的场景来说没任何性能问题,但如果笔记多了——比如几百条——这个全量刷新就会明显卡顿。更好的方案是用 @Observed@ObjectLink 把每个 Note 变成一个可观察对象,只更新发生变化的那一张卡片。

  4. 颜色配置应该抽到单独的资源文件里。现在 COLORS 数组是硬编码在 Index.ets 里的。如果能放到 color.json 里通过 $r() 引用,不仅可以在暗色模式下自动切换配色,也方便后续修改。ArkUI 的 $r() 对颜色数组的支持我没有深入研究过,不确定能不能直接引用一组颜色,但至少可以抽到一个独立的配置文件里。

  5. 应该加一个搜索功能。笔记多了之后靠翻列表来找东西肯定不行。配合 RdbPredicateslike 方法做一个简单的关键词搜索——标题或内容匹配就行——体验会好很多。

  6. promptAction.showToast 里的 emoji 在高版本 Android 系统上可能有显示问题。我用了一些 emoji 在 Toast 里做装饰,比如"🎉 添加成功"、“🗑️ 已删除”。但不同系统对 emoji 的渲染不一样,有些老系统可能直接把 emoji 显示成方框。上线前最好在多个设备上验证一下。


写在最后

写完这个项目最大的感受是——HarmonyOS 的声明式 UI 开发思路(ArkUI + ArkTS)确实比 Android 传统的 XML + Java/Kotlin 模式要现代得多。状态驱动 UI 自动更新、组件化的 @Builder、条件渲染代替 Visibility 判断——这些东西一旦习惯了,再回去写 Android 的 findViewByIdsetText 会觉得很别扭。

但它的劣势也很明显:生态不够成熟,文档和社区资源跟 Android 比差距太大了。遇到问题的时候,Android 随便一搜就有 StackOverflow 的答案,HarmonyOS 可能搜半天只找到一个跟你问题不太一样的官方文档页面。而且 SDK 的 API 在不同版本之间变动比较大,同一份代码在 API 13 和 API 14 上可能跑出不一样的结果。这也是为什么我在导入 @kit.ArkData 这件事上折腾了那么久——版本差的坑全踩了一遍。

总的来说,如果是单纯想做个 App 体验一下,HarmonyOS 的开发门槛比我想象的要低。ArkTS 跟 TypeScript 高度相似,有前端基础的话上手很快。声明式 UI 的方式也让界面开发比传统 Android 直观不少。但如果想做一个上线的商业产品——尤其是依赖很多第三方库或者需要复杂性能优化的那种——可能还需要观望一下生态的发展。

以上就是一个普通大学生用 HarmonyOS 做笔记 App 的全部记录了。如果你也在学这个,希望能帮你少踩几个坑。


项目源码 & 运行方式:

  1. 用 DevEco Studio 打开项目
  2. 确保 SDK 版本 ≥ 6.1.1(24)
  3. 直接 Build & Run 即可,不需要额外安装任何第三方依赖
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐