先说结论:如果你的团队已经会 Flutter,又想在 OpenHarmony 设备上做一个带业务深度的工具类 App,“衣橱管家”这种项目是最合适的练手选题之一。衣橱管理听起来小,但一旦拆开,你会发现它几乎把一个典型业务 App 的核心模块都占了:列表、表单、图片资源、分类筛选、统计图表,还有这里要重点讲的预算管理。预算管理最妙的地方在于,它不是一个简单的增删改查,而是牵扯到状态流转、事务处理、时间窗口(月度重置)、超支预警和可视化,这几样做扎实了,整个项目的含金量直接不一样。
这篇文章不打算讲怎么搭一个 hello world,而是直接围绕“预算管理”这条主线,把我在 Flutter for OpenHarmony 上的完整实现思路、表结构设计、事务代码、平台能力调用、以及真机调试时踩过的坑都写出来。适合两类人看:一类是准备把现有 Flutter 项目迁移到 OpenHarmony 的客户端开发,另一类是刚接触 OpenHarmony、想通过一个完整业务模块理解跨端方案的 Flutter 新手。看完你至少能搞清楚预算模块在衣橱场景里应该怎么设计,以及在 OpenHarmony 上跑 Flutter 到底有哪些隐性成本。
1. 项目背景与整体设计思路
1.1 为什么选择 Flutter 来做 OpenHarmony 应用
把 Flutter 跑在 OpenHarmony 上,底层思路和跑在 Android 上类似:Flutter 引擎负责 Dart 代码执行和自绘 UI,系统能力通过 embedding 层的 platform channel 暴露给 Dart。这意味着 UI 不依赖 ArkUI 的控件树,所以一套界面代码在不同平台上的观感一致性做得非常极致,而且团队不需要为 OpenHarmony 重新培训一套 ArkTS 技能栈。
选 Flutter 还有一个现实原因:应用市场对 OpenHarmony 原生的 ArkUI 开发需求量在涨,但能把 Flutter 迁移到 OpenHarmony 的工程师更少。谁先把这个链路跑通,谁在项目排期上就有优势。而且 Flutter 的渲染引擎是 Skia/Wgpu,在 OpenHarmony 设备上并没有性能明显衰减的问题,特效和动画照常跑。配合社区维护的 flutter_ohos 系列插件,路由、shared_preferences、sqflite 这些常用库都有对应版本,开发效率比从零写 ArkUI 高太多了。
1.2 衣橱管家 App 的核心功能拆解
衣橱管家这类 App,功能可以划成三块:
- 衣物管理:录入衣物的名称、分类(上衣、裤装、裙装、外套等)、品牌、购买价格、购买日期、穿着次数、照片、标签(通勤/休闲/正式)。
- 衣橱统计:按分类看衣物数量占比、按品牌看消费总额、按季节看使用频率。
- 预算管理:设定每月购衣预算,记录每一笔买衣支出,实时展示剩余可用额度,超支时预警。
这三个模块不是孤立的。预算管理必须和衣物录入联动:当用户录入或修改一件衣服时,它的购买价格要同步计入当月预算支出;反过来,当用户在预算页面看到某笔超支记录时,可以一键跳到对应衣物详情。这种跨模块联动,才是业务闭环的价值所在。你做一个账本 App 只能记账,但你做衣橱管家,预算管理是帮用户“控制消费欲望”的工具,这个心智定位是完全不同的。
1.3 预算管理在整个衣橱场景中的价值
预算管理模块在项目里承担的职责,不只是算一个加减法。它要回答用户四个问题:这个月还能买多少钱的衣服?现在已经花到哪里了?每一笔钱花在了什么品类上?哪些品类最容易超支?这四个问题分别对应的技术点就是预算额度设置、支出汇总、购买记录明细、分类占比统计。
从业务设计角度,预算的统计口径也值得提前想清楚。我这边定的是:默认按自然月统计,每件衣物按其购买日期归属到对应月份;支持一次设置年度预算,分配到 12 个月;预算不区分线上支付和线下现金,只看实际成交价格。口径定了,后端的表结构和统计 SQL 才能稳定下来,否则做着做着就会遇到“同一笔钱被算了两次”这种烂账。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 开发环境搭建与工程初始化
2.1 OpenHarmony 设备与 SDK 版本选型
做 Flutter for OpenHarmony 开发,第一步是版本匹配。OpenHarmony 的版本迭代节奏和 Flutter SDK 的 release 不完全同步,社区适配插件(flutter_flutter、flutter_ohos)通常会跟随 OpenHarmony SDK 的 API 版本发布。我实际用的组合是 OpenHarmony 4.1 Release + Flutter 3.22.x + DevEco Studio 4.1 配套的 SDK,API Level 11。这套组合相对稳定,社区插件适配也比较完整。
版本选型上给三个参考标准:
- 优先选 OpenHarmony LTS 或 Release 版本,不要用 Beta 当开发主力,设备端驱动和系统 bug 会让人崩溃。
- Flutter SDK 不要追最新,尽量选 flutter_ohos 插件文档里明确标注支持的版本,宁可低一个小版本也不要让插件和引擎版本错位。
- DevEco Studio 下载的 SDK 和命令行构建工具链要一致,否则 hdc 装包、签名调试都会出奇怪问题。
2.2 初始化 Flutter 工程并添加 OpenHarmony 平台
初始化流程很简单,前提是电脑上已经装好 Flutter SDK(这里提醒下 Flutter 刚装完的同学,SDK 路径一定要加到系统 PATH 里,而且新开的终端才会生效,别在旧终端里反复怀疑人生)。
bash复制# 创建工程
flutter create --org com.example --project-name wardrobe_budget wardrobe_app
# 进入工程目录
cd wardrobe_app
# 添加 OpenHarmony 平台支持(需要先安装 flutter_ohos 的相关工具)
flutter create --platforms ohos .
添加完成后工程里会出现 ohos 目录,里面是完整的 OpenHarmony 工程配置文件。需要注意,Flutter 项目跑在 OpenHarmony 上,Dart 侧代码不需要改,但原生侧的 module.json5 里要声明好应用包名、权限,还有用到的系统能力,比如相册读取、网络权限,这些和 Android 的 AndroidManifest 是同一个思路。
2.3 构建链路中容易踩的环境坑
OpenHarmony 的 Flutter 构建链路比 Android 多一层,除了 Gradle 构建 Android 相关逻辑外,还要经过 DevEco 的 hvigor 构建 ohos 产物。这里有两个高频问题:
第一个是 Flutter 的 Gradle 插件在工程里被命令式 apply。有些工程会在 build.gradle 里写 apply flutter.gradle,在 OpenHarmony 场景下这招不一定好使,因为 flutter_ohos 的构建插件要求用 id 'com.huawei.flutter' 这类方式声明,并要求 Flutter SDK 路径在 gradle.properties 里显式配置。我实际遇到编译报错找不到 flutter.jar,查到最后就是 Gradle 插件声明方式不对。解决方法很直接,检查工程的 settings.gradle 和 ohos 模块的 build.gradle,确认 Flutter 插件用的是 plugin id 方式,而不是旧式的 apply。
第二个是多版本 Flutter 并存导致依赖包下载失败。机器上如果装了多个 Flutter SDK,pub 的缓存路径一旦错乱,依赖就会拉取异常,报错往往不是“找不到包”,而是版本冲突。这里建议用 fvm 管理 Flutter SDK,锁住工程级别的 Flutter/Dart 版本,同时 pubspec 里所有依赖都写死版本范围,不要用 any。依赖库版本不对匹配带来的问题,远比功能逻辑 bug 更难排查。
真机方面,如果你用的是 rk3568 开发板,会发现系统提供了多个设备树 dtb 文件,这个不用慌。跑 OpenHarmony 标准系统的时候一般选 rk3568 标准版对应的 dtb,具体命名可以看烧录工具里默认勾选的那一个。选错设备树的现象是系统起不来或者外设不识别,多试几个能找到对的,不影响后续 Flutter 开发。
3. 预算管理的数据层设计
3.1 本地数据库方案选型:直接用 SQLite 最稳
预算管理模块有大量统计汇总需求,比如按月汇总支出、按分类分组、按价格区间过滤。这种场景用纯 JSON 文件或者 Hive 都不合适,最合适的就是关系型数据库。在 OpenHarmony 的 Flutter 生态里,可选方案有三个:
| 方案 | 类型 | 优点 | 缺点 |
|---|---|---|---|
| sqflite_ohos | SQLite 封装 | 接近 sqflite API,迁移成本低 | 依赖社区维护,API 更新略慢 |
| drift | SQLite ORM | 类型安全,自动迁移,查询语法优雅 | 需要代码生成,构建步骤多一层 |
| Hive | NoSQL 键值 | 轻量,无原生依赖 | 不适合按月统计和关联查询 |
我最后用的是 sqflite_ohos,原因很实际:预算模块的复杂查询不算多,用 sqflite 的原生 SQL 完全能搞定,而 drift 的代码生成在 OpenHarmony 的构建链路里更折腾。对 Flutter 工程师来说,sqflite 的 API 是标准的,社区资料多,出了问题容易搜到答案。性能上,SQLite 在 OpenHarmony 设备上单表几千条数据的聚合查询毫秒级返回,完全不是瓶颈。
3.2 预算与购买记录的表结构设计
预算相关的表我拆成了三张,尽量减少字段冗余。第一张是预算表 budgets,记录每个月的预算总额;第二张是购买记录表 purchase_records,记录每一笔衣物交易;第三张是衣物表 clothing_items,保存衣物的静态信息和图片路径。购买记录和衣物表是一对一关系,因为一笔购买对应一件实体衣物,但如果用户一单买了两件,可以拆成两条 record 指向同一批次,这里我用 order_id 来区分同单购买,方便未来扩展。
sql复制CREATE TABLE budgets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
month TEXT NOT NULL UNIQUE, -- 格式:yyyy-MM
total_limit REAL NOT NULL DEFAULT 0,
category_limit TEXT, -- JSON,例如 {"外套": 800, "鞋包": 500}
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE clothing_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
category TEXT NOT NULL,
brand TEXT,
price REAL NOT NULL DEFAULT 0,
image_path TEXT,
purchase_date TEXT NOT NULL, -- 格式:yyyy-MM-dd
note TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE purchase_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
order_id TEXT,
price REAL NOT NULL DEFAULT 0,
purchase_date TEXT NOT NULL,
category TEXT NOT NULL,
sync_status INTEGER DEFAULT 0, -- 0待同步 1已同步
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(item_id) REFERENCES clothing_items(id) ON DELETE CASCADE
);
注意 budgets 表里 month 字段用 UNIQUE 约束,这样每月只有一条记录,用 INSERT OR REPLACE 或者 ON CONFLICT 处理更新都方便。category_limit 我用 JSON 字符串存储,因为分类是动态的,写死成多列反而不好扩展;查询的时候在 Dart 侧解析 JSON 就行,这个字段的数据量很小,性能不影响。
3.3 本地数据库 + 后端同步的落地思路
衣橱管家不是纯单机应用,预算数据最好能在多设备之间同步。完整做云同步成本高,但 Flutter 项目里“本地数据库 + 后端同步”是常见架构,可以在预算模块里先做最小实现。
我的同步策略是:客户端本地数据库为主,所有写操作先落 SQLite,然后通过接口推送到后端。同步时用增量同步,不拉全量数据。具体来说,每张表都加了 updated_at 字段,本地往服务端推时带上 lastSyncTime,服务端返回这个时间之后变更的数据。同步状态用 sync_status 标记,等待网络成功回调后置为已同步。为了避免复杂冲突,我这边先做“最后写入优先”(last-write-wins),因为个人衣橱数据冲突概率低,不值得上版本向量。
接口层面可以用 REST 风格:
text复制POST /api/sync/budget/push 推送本地变更
POST /api/sync/budget/pull 拉取远端增量
推送和拉取都支持批量 JSON 数组,一次请求传几十条记录,实测在移动网络下也没什么压力。需要注意的一点是,同步动作要放在后台 isolate 里执行,避免解析 JSON 时阻塞 UI 线程。我这里用 compute 函数处理 JSON 序列化和反序列化,数据量大时依然顺滑。
4. 预算管理核心功能实现
4.1 预算额度设置与月度重置逻辑
预算设置界面主要有两块:月度总预算、分类预算。月度总预算比较简单,一个数字输入框 + 保存按钮。分类预算稍微复杂,需要动态展示当前所有衣物分类,每个分类给一个输入框。输入框的初值从 budgets 表的 category_limit JSON 里读取。
保存逻辑要处理“无记录”和“有记录”两种情况:如果 budgets 表里还没当前月份的记录,就执行 INSERT;如果已经有了,就执行 UPDATE。处理方式是写一个 upsert 方法,用 conflict 策略保证原子性。
dart复制Future<void> saveBudget(String month, double totalLimit, Map<String, double> categoryLimit) async {
final db = await database;
await db.insert('budgets', {
'month': month,
'total_limit': totalLimit,
'category_limit': jsonEncode(categoryLimit),
'created_at': DateTime.now().millisecondsSinceEpoch,
'updated_at': DateTime.now().millisecondsSinceEpoch,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
月度重置不需要定时器,而是通过“懒创建 + 懒重置”实现。用户在首页进入预算页时,系统取当前月份 yyyy-MM 作为 key 去查 budgets;如果查不到,就复制上个月的分类预算设置,把已用金额清零,生成新的预算记录。这样即使 App 一个月没打开,用户再次看到预算页时数据也是准的,不需要后台任务。
4.2 购买记录与预算扣减的事务处理
预算模块最容易出错的就是同时写购买记录和更新预算金额。假如先插入了 purchase_records,再更新 budgets 的已用金额,中途某个环节失败,就会出现记录存在但预算没扣减的脏数据。解决办法是开启 SQLite 事务。
dart复制Future<void> addPurchaseTransaction(String month, Item item, PurchaseRecord record) async {
final db = await database;
await db.transaction((txn) async {
await txn.insert('purchase_records', {
'item_id': item.id,
'order_id': record.orderId,
'price': record.price,
'purchase_date': record.purchaseDate,
'category': item.category,
'sync_status': 0,
'created_at': DateTime.now().millisecondsSinceEpoch,
'updated_at': DateTime.now().millisecondsSinceEpoch,
});
final existing = await txn.query(
'budgets',
where: 'month = ?',
whereArgs: [month],
limit: 1,
);
if (existing.isEmpty) {
await txn.insert('budgets', {
'month': month,
'total_limit': record.price,
'category_limit': '{}',
'created_at': DateTime.now().millisecondsSinceEpoch,
'updated_at': DateTime.now().millisecondsSinceEpoch,
});
} else {
await txn.rawUpdate(
'UPDATE budgets SET total_limit = total_limit - ? , updated_at = ? WHERE month = ?',
[record.price, DateTime.now().millisecondsSinceEpoch, month],
);
}
});
}
这里有个设计细节要特别说明:budgets 表里的 total_limit 我直接存的是“剩余可用额度”,而不是“预算总额加已用金额”两个字段分开存。这么做的好处是更新只需要一条 UPDATE,不用先查询再计算;缺点是如果用户改预算总额,需要用新的总额减去已用金额重新计算剩余值。实际体验下来,存剩余额度更顺手,因为整个 UI 的主展示就是剩余额度。
删除购买记录时要做反向操作,同样放在事务里:删除 purchase_records 对应行,同时把 budgets 的剩余额度加回来。注意删除要支持分类维度,比如删掉一件外套,不仅月度剩余额度要加回,该分类的剩余额度也要更新。
4.3 预算进度统计与可视化 UI
预算页的主界面我只放了四个核心元素:本月预算总进度条、分类预算进度列表、最近购买记录、超支预警卡片。不要堆太多花哨的图表,用户一打开就要能回答问题。
总进度条我用 Flutter 自带的 LinearProgressIndicator,通过剩余额度计算出百分比:
dart复制double _remainPercent = remainAmount / totalBudget;
超支状态用颜色区分:剩余 30% 以上绿色、10%-30% 橙色、低于 10% 红色、超支后进度条变成深红并显示“已超支 ¥xx”。这个视觉反馈比任何文案都直接。分类预算进度列表用 ListView.builder 动态渲染,每行左边是分类名,中间是进度条,右边是“已用/剩余”数字。
购买记录列表我用 Slidable 做了左滑删除、点击查看详情。这里有个小细节,删除操作不能只更新数据库,还要同步更新内存里的状态对象,否则 UI 会出现一瞬间旧数据。我用的状态管理是 Riverpod,删除后直接刷新对应 provider 的状态值,列表会自动重建。
5. 平台能力调用与兼容性适配
5.1 通过 Platform Channel 调用 OpenHarmony 相册
衣橱管家 App 里录入衣物必须要选图,这里需要调用 OpenHarmony 的图库能力。在 Flutter 侧用 platform channel 是最直接的方式,原理是 Dart 发消息给 ohos 原生侧,原生侧通过 PhotoAccessHelper 拉起图库选择器,选中图片后再把文件 URI 传回 Dart。
原生侧用 DevEco Studio 打开 ohos 工程,在 ets 文件里实现 channel:
typescript复制import { photoAccessHelper } from '@ohos.file.photoAccessHelper';
import { fileIo } from '@ohos.file.fs';
const CHANNEL = 'wardrobe.app/photo';
function registerPhotoChannel(exec: any) {
exec.on(CHANNEL, async (call: any) => {
if (call.method === 'pickImage') {
const result = await pickImageFromGallery();
call.success(result);
}
});
}
async function pickImageFromGallery(): Promise<string> {
const helper = photoAccessHelper.getPhotoAccessHelper(context);
const options = new photoAccessHelper.PhotoSelectOptions();
options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
const result = await helper.select(options);
if (result.photoUris.length > 0) {
const uri = result.photoUris[0];
const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
// 这里可以把文件复制到应用沙箱,返回应用内路径
return appSandboxPath;
}
return '';
}
Dart 侧调用:
dart复制static const platform = MethodChannel('wardrobe.app/photo');
final String? path = await platform.invokeMethod('pickImage');
这个流程里最容易踩的坑是权限声明。OpenHarmony 的相册读取权限需要两步:在 module.json5 里声明 ohos.permission.READ_IMAGEVIDEO,同时要在运行时用 abilityAccessCtrl 申请用户授权。只声明不运行时申请,调用图库时会直接没有权限弹窗。第二个坑是 select 返回的 uri 不能直接给 Flutter Image.file 用,必须先复制到应用沙箱拿到真实路径,否则图片加载不出来。我在最初版本就吃过这个亏,一度以为 Flutter 侧解析 uri 有问题。
5.2 支付能力与预算模块的联动
热词榜上有一个问题和这里强相关:“flutter 兼容鸿蒙拉起 iap 支付”。如果衣橱管家要做会员或者付费主题,就要接入 OpenHarmony 的 IAP 支付。在 Flutter 侧,通常也是通过 MethodChannel 转到原生,由原生侧调用 account 和 payment 相关接口。
支付成功后的流程,和购买记录录入口径要一致。我的设计是:用户支付会员成功后,系统自动生成一条 specialCate 的“虚拟衣物”购买记录,计入当月预算。有用户可能不理解会员费为什么要记入购衣预算,但从“控制消费”的角度,消费就是消费,分类显示在“服务/会员”下,用户能看清每一笔钱去哪了。
这里给个重要提醒:IAP 支付回调一定要做服务端鉴权验证,不能只信客户端成功回调。预算扣减最好是等服务端确认收款成功后再本地落账,避免客户端被绕过导致账目错乱。当然预算模块本质上是一个个人记账场景,完整闭环可以简化为“本地记录 + 服务端对账”,但绝不能不做。
5.3 真机调试与设备树选择
OpenHarmony 的 Flutter 真机调试和 Android 有个明显区别:命令行工具不是 adb,而是 hdc。首先要确保 pc 端安装了 hdc 工具,并配置环境变量,然后用 hdc 连接开发板或手机。
bash复制# 查看设备列表
hdc list targets
# 安装应用
hdc install path/to/your_app.hap
# 查看日志
hdc log
如果你用的是 rk3568 开发板开机启动不了系统,大概率是烧录时设备树选错了。同一个芯片会有多个 dtb,比如带 HDMI 显示、带 MIPI 屏、带触摸屏的版本都不一样。解决方法是逐个试烧,能正常进桌面、触摸可用、网络可用的那一个就是对的。这里确实没有一劳永逸的办法,但通常官方预编译镜像里默认选中的版本就是标准版,别轻易改。设备树这个问题和 Flutter 无关,但它会耗时耗精力,提前知道能让你少走弯路。
6. 常见问题与排查技巧实录
6.1 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| Flutter 依赖包一直下载失败 | SDK 版本和依赖库不匹配、pub 缓存污染 | 用 fvm 锁版本,pubspec 锁版本,清缓存重试 |
apply flutter.gradle 构建报错 |
Gradle 插件声明方式老套 | 改成 plugin id 声明,检查 gradle.properties 里 Flutter SDK 路径 |
| 调用图库后图片显示不出来 | 直接用了 uri,没有复制到沙箱 | 先调用 fileIo 复制到应用目录,再传路径给 Flutter |
| 预算扣减和购买记录不一致 | 未使用事务 | 把插入 records 和更新 budgets 放进同一个 transaction |
| 页面切换后预算进度条不刷新 | 状态管理没有在删除/新增后触发重建 | 用 Riverpod 或 Provider 管理状态,操作数据库后刷新对应 provider |
| hdc 连不上设备 | 驱动未装、hdc 版本与设备不匹配 | 安装最新 DevEco Studio 内附的 hdc,重启服务 hdc kill -9 再连 |
| 真机跑起来 UI 字体偏小 | Flutter 未正确适配系统字体缩放 | 在 MaterialApp 里设置 builder,读取系统字体比例并应用 |
6.2 性能与包体积优化
在 OpenHarmony 设备上跑 Flutter,性能和包体积是需要主动管理的。包体积方面,Flutter 引擎 + 应用代码的 HAP 产物比普通 ArkUI 原生应用大不少,优化手段主要是裁剪不用的 so 架构。如果你的设备是 rk3568(arm64),在构建时只保留 arm64-v8a,不要默认打包 armeabi-v7a 和 x86_64,体积能减小约 40%。
性能方面,衣橱管家最重的操作是图片加载和统计查询。图片列表建议只用缩略图,压缩质量设定在 85% 以下,宽度统一限制在 720px 内。统计查询虽然已经建了索引,但如果衣物记录上万条,月度汇总的 SQL 也需要注意执行时机。我的做法是在预算页首次加载时异步执行聚合查询,并把结果缓存到内存里,只有新增/删除购买记录时才让缓存失效,这样用户每次进入页面都不会看到 loading 圈。
6.3 反向思考:哪些地方可以做得更轻
预算管理模块也可以做更轻,不一定要先上完整 SQLite。只用 SharedPreferences 存 JSON 也能跑,记录少的时候完全够用,代码量还可以减一半。但一旦上了多设备同步,或者要做按分类按月统计,NoSQL 方案就会非常别扭。我的建议是:如果项目定位是个人工具、数据量撑死几百条,hive 加内存计算确实香;但如果你把衣橱管家当系列产品长期更新,SQLite 带来的扩展性收益是实打实的。做 Flutter 时我们习惯拆 Widget,做数据层时也应该有这种“为未来留接口”的意识。
最后分享一个我实际调试中的小技巧
预算页的列表数据,不要在 build 方法里直接查数据库,要在状态初始化时一次性加载,否则每次 setState 都会触发磁盘 IO,界面会有肉眼可见的卡顿。我后来统一封装了一个 BudgetRepository,所有查询走数据仓库层,Provider 里只持有仓库暴露的 Stream,数据变化时自动通知 UI 重建。这个模式看起来多写几行代码,但到了真机调试和后续加同步功能时,你会感谢这个分层。如果后面有时间,我打算把预算的历史趋势图也加上,用 Flutter 自带的 CustomPaint 画一个折线图,展示连续 6 个月的支出变化。跨平台 UI 一致性做到这个程度,OpenHarmony 和 Android 上的体验几乎没有差别,这也是 Flutter 方案最让人安心的地方。
