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

一个普通大三学生的 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 原生开发,你应该很熟悉这套流程:

  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、Contextimport { common } from '@kit.AbilityKit'
提示弹窗 Toastimport { 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 搭建的三层结构:

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

  6. 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 的全部记录了。如果你也在学这个,希望能帮你少踩几个坑。


项目源码 & 运行方式:

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

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

更多推荐