1. 项目背景与整体设计思路
先说结论:用 Flutter 写 OpenHarmony 应用完全可行,而且菜谱管理这个项目非常适合作为第一个练手实战。我之所以选这个题材,是因为菜谱管理几乎覆盖了 App 开发的所有基础能力——列表展示、数据持久化、图片处理、搜索筛选、增删改查,一套下来基本把日常开发的高频操作全过了一遍。
1.1 为什么选 Flutter + OpenHarmony 这个组合
OpenHarmony 的北向应用开发,官方主推的是 ArkTS 和 ArkUI。那为什么我还要用 Flutter 来做?三个原因:第一,Flutter 的跨端能力是现成的,一套 Dart 代码后面可以直接跑 Android 和 iOS,对个人开发者来说性价比很高;第二,Flutter 在 OpenHarmony 上的适配已经有官方 SDK 支持,社区也活跃,遇到问题能找到人问;第三,如果你的团队里有 Flutter 经验的人,上手 OpenHarmony 开发的曲线会平缓很多,不用重新学一套 UI 框架。
当然也要说实话,现在的 Flutter for OpenHarmony 还不算 100% 成熟,一些平台通道的插件需要自己适配。但菜谱管理这种偏工具类的 App,主要涉及 UI、数据库、图片和文件操作,这些都是基础能力,适配难度不大。
注意:当前 Flutter SDK for OpenHarmony 的版本迭代比较快,建议直接去 Gitee 上的 flutter_flutter 仓库拉取最新的 dev 分支,配合 DevEco Studio 一起用。
1.2 菜谱管理 App 的功能范围设计
我给我自己定的需求是这样:能添加菜谱,包含菜名、分类(热菜、凉菜、主食、汤羹)、食材清单、步骤描述、成品图;能浏览全部菜谱,按分类或者关键词筛选;能编辑和删除。权限方面做了简化,单机本地使用,不需要登录,数据存在本地。
这个规模对第一次接触 OpenHarmony 适配的人来说刚刚好——有足够的功能量去练习各种组件的用法,但又不至于因为功能太多导致排查问题时无从下手。
1.3 技术选型里几个关键决策
我在做技术选型时,最核心的几个点:
- 数据存储:优先考虑 OpenHarmony 自带的 RelationalStore(关系型数据库),它和 Android 里的 SQLite 很像,SQL 语法大部分通用,迁移成本低。最初也考虑过用 shared_preferences 存 JSON,但菜谱涉及步骤数组和图片路径,JSON 序列化和反序列化在数据量上去后会很痛苦。
- 图片处理:菜谱封面图我直接存本地路径,不搞 Base64 入库。图片压缩用 Flutter 的 image 包做,先把尺寸压到合理范围再存文件。
- 状态管理:没有上 Provider 或者 Riverpod,直接用 setState。原因很简单——项目规模小,状态层级浅,引入状态管理框架反而增加理解成本。
我用表格汇总一下选型决策,方便后面有需要的人直接参考:
| 模块 | 技术方案 | 选择理由 |
|---|---|---|
| UI 框架 | Flutter Widget | 热重载效率高,组件生态成熟 |
| 数据库 | RelationalStore | OpenHarmony 内置,SQL 兼容度高 |
| 图片处理 | image 包 + 文件路径存储 | 避免数据库膨胀,加载性能好 |
| 状态管理 | setState | 项目规模小,避免过度设计 |
| 构建工具 | DevEco Studio + hvigor | OpenHarmony 官方工具链 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建与联调配置的实操细节
说实话,环境搭建是这次实战里踩坑最多的地方,比写业务代码花的时间还多。我觉得有必要把整个过程拆开仔细讲讲,因为很多问题都是环境和配置层面的,网上资料又少,能搜到的也都是零散片段。
2.1 开发环境到底需要装哪些东西
我的开发机是 Windows 11,最终装齐了这么几样东西:DevEco Studio 5.0(这里要注意,后面应用工程会用到)、Flutter SDK 的 OpenHarmony 分支版本、OpenHarmony SDK、Node.js(hvigor 构建依赖它),以及 ohpm 包管理工具。
这里有一个非常关键的坑必须提前交代:普通的稳定版 Flutter SDK 是不支持 OpenHarmony 的。你需要拉取专门适配过的 Flutter SDK,把 flutter 命令装好后,执行 flutter doctor,如果能看到 OpenHarmony 相关的通道,说明 SDK 装对了。
安装配置这块,DevEco Studio 自带了一个 SDK Manager,可以下载 OpenHarmony SDK 和工具链。但 Flutter SDK 需要手动配环境变量,我把几条核心命令贴在下面,方便直接抄作业:
bash复制git clone https://gitee.com/openharmony-sig/flutter_flutter.git
cd flutter_flutter
git checkout dev
export PATH=$PWD/bin:$PATH
flutter config --enable-openharmony
flutter doctor
flutter config --enable-openharmony 这步非常关键,不执行的话 flutter 命令创建的工程里不会有 ohos 平台目录。
2.2 创建工程与 DevEco Studio 的协同
Flutter 工程创建好后,会默认生成 android、ios、web 等平台目录,但不会有 ohos 目录。执行下面的命令补上:
bash复制flutter create --platforms ohos .
这会在工程里生成 ohos 目录。接下来你需要用 DevEco Studio 打开这个 ohos 子目录,而不是整个 Flutter 工程。等 DevEco Studio 完成同步之后,再用命令行或者 DevEco Studio 直接运行到模拟器/真机。
我个人的习惯是:代码热重载和调试用 VS Code 跑 flutter run -d <device>,跑起来之后用 DevEco Studio 来看日志和侧载。两个工具各管一段,效率最高。
2.3 真机运行前必须处理的权限问题
模拟器我用的是 OpenHarmony 的模拟器,但从 5.0 开始,很多 API 行为在模拟器和真机上表现不一样,特别是权限弹窗和数据持久化。所以有条件的话强烈建议直接上真机,比如润和或者 dayu200 这种 RK3568 开发板。
如果你是真机调试,需要在 ohos 工程里的 module.json5 文件中配置权限声明。菜谱管理 App 至少需要这两项:
json复制{
"name": "ohos.permission.READ_IMAGEVIDEO",
"reason": "读取菜谱图片",
"usedScene": {
"abilities": ["MainAbility"],
"when": "inuse"
}
}
这里有个容易漏的地方:OpenHarmony 的权限体系区分 system_grant 和 user_grant 两类。读相册属于 user_grant,需要在代码中动态申请。我第一次跑的时候忘了动态申请,点击选择图片后直接白屏,日志提示权限拒绝。
2.4 编译错误的速查与解决思路
整个搭建过程中,我遇到过几类频率极高的报错,这里先给个速查表:
| 报错关键词 | 原因 | 解决方案 |
|---|---|---|
Unable to locate adb |
DevEco 的 SDK adb 路径未识别 | 在 DevEco 的 SDK 路径下找到 adb,手动加进 PATH |
ohpm install failed |
依赖包未安装 | 在 ohos 目录下执行 ohpm install |
C++ build error in native |
Flutter 引擎 native 编译失败 | 检查 NDK 版本,DevEco 需要特定 NDK 版本 |
sign config missing |
未配置签名 | 在 DevEco 里配置自动签名 |
ERR_INVALID_ARG_TYPE |
Node 版本过低 | 升级 Node.js 到 18 以上 |
这些坑其实都是环境问题,不是你代码写错了。所以建议大家一次把环境按规范装好,避免反复折腾。
2.5 针对 x86 平台的设备树选择
有个细节值得单独说一下:很多人用的是 RK3568 开发板,但大家经常在烧录或编译的时候纠结选哪个设备树(dts)。即使同样是 RK3568,板子的 HDMI、屏幕接口、摄像头接口都可能不一样,所以没有"通用设备树"这回事。
我的做法是:先确认开发板型号和主控板丝印,去官方资料里找对应 dts 的名字,比如 rk3568-evb1-ddr4-v10-linux.dtb 这种。别靠猜,直接看厂家的内核配置文档。编译内核和烧录时选错 dts 会导致启动黑屏或者触摸失灵,排查起来极其痛苦。
3. 菜谱数据模型与本地数据库设计
数据层是整个应用的地基。我在这部分花了不少心思,因为菜谱的结构其实比想象中要复杂一些——它不是单表就能搞定的简单列表。
3.1 数据模型的定义与字段取舍
菜谱的基本字段,我定版为这样一张表:
sql复制CREATE TABLE IF NOT EXISTS recipe (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
category TEXT NOT NULL,
ingredients TEXT NOT NULL,
steps TEXT NOT NULL,
cover_path TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
这里有几个设计上的考虑:
ingredients和steps我用的是逗号分隔的文本,没有拆表。食材清单在真实场景下其实是个列表,但拆表意味着多表联查,对一个本地工具类 App 来说收益不高,用分隔符存储反而是更务实的做法。cover_path存的是图片在沙箱内的绝对路径,不存 Base64。理由前面说过——Base64 会让数据量膨胀 30% 以上,而且查询时无法做懒加载。created_at和updated_at都用INTEGER存毫秒时间戳,排序比文本时间好用得多,显示层自己格式化即可。
3.2 OpenHarmony RelationalStore 的初始化流程
在 Flutter 里调用 RelationalStore,并不能直接一个包搞定,需要走 Platform Channel。我在 ohos/entry/src/main/ets/ 目录下创建了几个原生文件。关键代码如下:
typescript复制// DatabaseHelper.ets
import relationalStore from '@ohos.data.relationalStore';
import hilog from '@ohos.hilog';
const STORE_CONFIG: relationalStore.StoreConfig = {
name: 'recipe.db',
securityLevel: relationalStore.SecurityLevel.S1
};
export class DatabaseHelper {
private store: relationalStore.RdbStore | null = null;
async init(context: Context) {
this.store = await relationalStore.getRdbStore(context, STORE_CONFIG);
await this.store.executeSql(
'CREATE TABLE IF NOT EXISTS recipe (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT NOT NULL, ingredients TEXT NOT NULL, steps TEXT NOT NULL, cover_path TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);'
);
}
async insert(recipe: object): Promise<number> {
let values = new relationalStore.ValuesBucket();
values.put('name', recipe.name);
// ...其他字段
let rowId = await this.store.insert('recipe', values);
return rowId;
}
async queryAll(): Promise<Array<object>> {
let predicates = new relationalStore.RdbPredicates('recipe');
predicates.orderByDesc('created_at');
let resultSet = await this.store.query(predicates);
// 遍历 resultSet,拼成数组返回
return recipes;
}
}
然后在 Flutter 侧用 MethodChannel 调它:
dart复制// recipe_channel.dart
class RecipeChannel {
static const MethodChannel _channel = MethodChannel('com.example.recipe/db');
static Future<int> insert(Map<String, dynamic> recipe) async {
return await _channel.invokeMethod('insert', recipe);
}
static Future<List<Map<String, dynamic>>> queryAll() async {
List<dynamic> result = await _channel.invokeMethod('queryAll');
return result.map((e) => Map<String, dynamic>.from(e)).toList();
}
}
这里有个很核心的细节:OpenHarmony 的 MethodChannel 返回给 Flutter 的数据,如果你直接传一个 Array<object>,里面是 ValuesBucket 或者自定义类,到 Flutter 侧会变成不可解析的结构。所以原生侧必须把每条记录转成纯 Map——key 和 value 都必须是基本类型。我在原生侧写了一个转换函数,手动把 resultSet 的行数据逐行读到 JSON 对象里再返回。
3.3 菜谱搜索的 SQL 实现
搜索功能看起来简单,但实现时有个很影响体验的细节:模糊匹配选哪个字段,怎么排序。我的搜索策略是——菜名权重最高,食材次之,分类最后。对应 SQL 是:
sql复制SELECT * FROM recipe
WHERE name LIKE '%关键词%'
OR ingredients LIKE '%关键词%'
OR category LIKE '%关键词%'
ORDER BY CASE
WHEN name LIKE '%关键词%' THEN 0
WHEN ingredients LIKE '%关键词%' THEN 1
ELSE 2
END, updated_at DESC;
这个排序很关键,它保证了"菜名命中"排在最前面,而不是所有结果混在一起按时间排,体验完全不一样。
我踩过的坑是:在 OpenHarmony 上执行复杂 SQL 时,如果查询条件太多,效率有明显下降。后来发现是 RelationalStore 的默认索引没建好,给 category 和 name 加了索引之后速度快了一个量级:
sql复制CREATE INDEX idx_recipe_name ON recipe(name);
CREATE INDEX idx_recipe_category ON recipe(category);
3.4 数据库升级与版本管理
OpenHarmony 的 RelationalStore 支持 storeVersion 机制。我初版用的是 securityLevel: S1,后面如果要存用户隐私数据,比如云端同步 token,需要升级到 S2。数据库升级要写 onUpgrade 回调,在低版本表结构上做增量迁移:
typescript复制let promise = relationalStore.getRdbStore(context, {
name: 'recipe.db',
securityLevel: relationalStore.SecurityLevel.S1,
encrypt: false,
version: 2, // 从 1 升到 2
}, (err, store) => {
if (!err) {
store.version = 2;
store.executeSql('ALTER TABLE recipe ADD COLUMN remark TEXT');
}
});
重点提示:修改表结构必须在 onUpgrade 里做,不能在初始化 SQL 里改,否则老用户升级后直接崩。
4. 菜谱列表 UI 与交互体验的实现
数据层稳了之后,UI 反而是最出彩的部分。Flutter 的组件生态让我可以很轻松地搭出一个美观实用的界面。
4.1 首页列表的卡片设计与布局
首页我用的是 CustomScrollView + SliverGrid 的双列瀑布流布局,卡片包含三样信息:封面图、菜名、分类角标。核心代码如下:
dart复制class RecipeCard extends StatelessWidget {
final Recipe recipe;
final VoidCallback onTap;
Widget build(BuildContext context) {
return Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 4 / 3,
child: recipe.coverPath.isEmpty
? Container(
color: Colors.grey.shade200,
child: Icon(Icons.restaurant, color: Colors.grey.shade400),
)
: Image.file(
File(recipe.coverPath),
fit: BoxFit.cover,
),
),
Padding(
padding: EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(recipe.name, style: TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 4),
Text(recipe.category, style: TextStyle(color: Colors.orange.shade700)),
],
),
),
],
),
),
);
}
}
几个视觉细节:
- 封面图用
AspectRatio固定宽高比,避免加载失败时卡片高度错乱。 - 卡片圆角用了 16,视觉上柔和一些,和数据密集型工具 App 的调性更搭。
- 分类文字用了橙色系,配合烹饪场景,给用户一个温暖的氛围。
4.2 列表滚动的性能优化
如果你在列表里直接用 Image.file,图片量一多会明显卡顿。我做了两个优化:
第一,用 cached_network_image 的方式思路,改造了一个本地文件缓存的图片组件,做内存级 LRU 缓存,保证滑动时不会因为频繁读 IO 而卡顿。
第二,CustomScrollView 里加 CacheExtent 控制了预加载范围,避免一次性渲染过多卡片导致内存飙升。
dart复制CustomScrollView(
cacheExtent: 500,
slivers: [...]
)
4.3 筛选 Tab 与状态联动
顶部的分类筛选我用的是一个横向滚动的 ChoiceChip 列表。分类数组缓存为常量,切换的时候只用更新 _currentCategory 这一个变量,重新执行的查询函数根据 _currentCategory 决定 SQL 的条件部分。
dart复制List<Recipe> _filterRecipes(List<Recipe> allRecipes) {
if (_currentCategory == '全部') return allRecipes;
return allRecipes.where((r) => r.category == _currentCategory).toList();
}
这里我用的是内存过滤而非重新查库——数据量小,没必要每次筛选都查一次。只有当从数据库加载全量数据时才走 RelationalStore。
4.4 下拉刷新与上拉加载的取舍
我的方案是只做下拉刷新,不做上拉分页。原因很简单:本地数据库的菜谱数量通常不会超过几百条,一次查完放进内存是最简单的解法,分页反而会增加状态复杂度,列表性能问题通过上面的缓存机制已经解决了。
如果后续要支持云端同步,再引入 RefreshIndicator 已有的刷新逻辑,扩展也方便。
5. 图片选择、压缩与文件管理
图片这块真的是实战里最大的坑,没有之一。它牵涉到权限、文件路径、压缩、前端展示等多个环节,而且 OpenHarmony 的图片 API 和 Android 的 MediaStore 不完全一样,需要重新摸一遍。
5.1 从鸿蒙相册选择图片的正确姿势
从 Flutter 侧唤起鸿蒙相册,我最初想找一个现成插件,结果发现 Flutter 社区的 image_picker 对 OpenHarmony 的支持还不完善。最终我选择自己写了一段 Platform Channel 调用。
鸿蒙原生侧的核心代码是调用 photoAccessHelper 的 select 方法:
typescript复制import photoAccessHelper from '@ohos.file.photoAccessHelper';
// 通过 PhotoViewPicker 拉起相册
let photoPicker = new photoAccessHelper.PhotoViewPicker();
let result = await photoPicker.select({
MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE,
maxSelectNumber: 1,
});
let uri = result.photoUris[0];
这里需要注意:select 返回的是类似 file://media/... 的 URI,不是直接可用的文件路径。要读取这个图片内容,你需要通过文件描述符来访问:
typescript复制import fileIo from '@ohos.file.fs';
let file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
let stat = fileIo.statSync(file.fd);
let buffer = new ArrayBuffer(stat.size);
fileIo.readSync(file.fd, buffer);
fileIo.closeSync(file);
然后把这个 buffer 转成 base64 或者字节数组传回 Flutter。我个人是转了 base64 交给 Dart 侧,再在 Dart 侧用 File.writeAsBytes 写入应用沙箱目录。这一步有点绕,但效果是能把"相册里的图"变成"自己 App 里的图",后续展示就完全是 Flutter 侧处理了。
5.2 图片压缩必须做,否则 App 会越来越大
不加压缩的照片,一张动辄 5M 到 10M,几十个菜谱存下来,数据库虽然不膨胀,但文件夹体积很夸张。加载时的内存压力也大——手机上解码一张 4000x3000 的图,内存占用直接上 48MB。
我的压缩方案:拿到原图字节流之后,在 Dart 侧用 image 包解码,统一压缩到最长边 1280,质量调到 80,再写回文件。核心代码如下:
dart复制import 'package:image/image.dart' as img;
Future<String> compressAndSaveImage(Uint8List bytes, String filePath) async {
img.Image image = img.decodeImage(bytes)!;
img.Image resized = img.copyResize(image, width: 1280);
List<int> compressed = img.encodeJpg(resized, quality: 80);
File file = File(filePath);
await file.writeAsBytes(compressed);
return file.path;
}
copyResize 时我只指定了宽度,没有指定高度,image 包会自动按比例缩放,保持图片不变形。压缩后单张图一般是 150KB 到 300KB,完全可接受。
5.3 沙箱路径与缓存目录的管理策略
OpenHarmony 每个应用都有自己的沙箱目录,这比 Android 的公共存储区要干净得多。我的存储策略是:菜谱封面统一存到应用沙箱的 files/covers/ 目录下,文件名用时间戳加短随机数,避免重名覆盖。
dart复制String get coverDir => path.join((await getApplicationDocumentsDirectory()).path, 'covers');
Future<String> generateCoverPath() async {
final dir = Directory(coverDir);
if (!await dir.exists()) {
await dir.create(recursive: true);
}
final filename = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}.jpg';
return path.join(coverDir, filename);
}
注意,不要直接存在缓存目录(cache),因为系统可能会在空间不足时清缓存,你的菜谱封面图就没了。存 documents 目录下才是安全的。
5.4 图片加载失败与占位图的兜底
测试时发现一个很烦的问题:某些从第三方相册导入的图片,其实不是标准 JPEG 格式,后缀名是 .jpg 但内部数据是 PNG 或者其他编码。用 Image.file 直接加载会报错。
我的兜底策略是在卡片组件里加了 errorBuilder,加载失败时显示一个默认的"暂无图片"图标。同时在压缩流程中统一解码重编码,保证写入沙箱的一定是规范的 JPEG。
dart复制Image.file(
File(recipe.coverPath),
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey.shade200,
child: Icon(Icons.broken_image_outlined),
);
},
)
6. 菜谱编辑页与表单校验的实战记录
编辑页是整个 App 交互最重的页面,涉及文本输入、分类选择、图片选择三个核心区域,还要处理新增与编辑两种模式的状态复用。
6.1 新增与编辑的页面复用
我没写两个页面,而是用同一个 RecipeEditPage,通过构造参数传入是否编辑模式和已有的 Recipe 对象:
dart复制class RecipeEditPage extends StatefulWidget {
final Recipe? existingRecipe;
final bool isEditMode;
// ...
}
在 initState 里根据 existingRecipe 初始化各字段的 controller。提交时判断是走"更新"还是"插入"通道。
这个模式的好处是逻辑单点维护,以后改字段时只需要在编辑页改一遍。
6.2 表单校验的坑与体验优化
表单校验我做了三层:必填校验、长度校验、格式校验。核心逻辑写在自定义的 FormValidator 类里:
dart复制class FormValidator {
static String? validateName(String? value) {
if (value == null || value.trim().isEmpty) return '请输入菜名';
if (value.trim().length < 2) return '菜名至少2个字';
return null;
}
static String? validateIngredients(String? value) {
if (value == null || value.isEmpty) return '请至少填入一种食材';
return null;
}
}
一个很重要的体验细节:我并没有在 onChanged 时立刻校验,而是在用户点击保存时才校验。否则用户输第一个字的时候就弹红色错误提示,很烦人。AutovalidateMode.disabled 加上手动触发校验的方式最稳妥。
针对步骤输入,我用了动态加行的方式,用 List<TextEditingController> 管理多行步骤。每行的删除按钮只在该行有内容时才显示,避免误触。
6.3 软键盘遮挡问题
编辑页在真机上最常见的交互 bug 是:弹出软键盘后,页面底部的保存按钮被键盘遮挡,用户根本点不到。我的解决方案是给页面最外层套一个 SingleChildScrollView,并在 Scaffold 上设置 resizeToAvoidBottomInset: true(默认就是 true),这样键盘弹出时整个 body 会收缩,配合滚动视图就能保证按钮在键盘之上。
实战中,resizeToAvoidBottomInset 在某些 ROM 上不生效,我的备用方案是监听 MediaQuery.of(context).viewInsets.bottom,手动给底部按钮加 padding:
dart复制bottomNavigationBar: Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: saveButton,
)
这招在 OpenHarmony 上实测有效。
7. 常见问题排查与性能优化实录
这一章完全来自我真实调式过程中的血泪经验。每一个问题都是花时间定位过的,写出来希望能帮你省时间。
7.1 数据库查询并发冲突
我第一个版本在快速切 tab 时偶尔闪退,看日志发现是两个查询同时操作同一个 RdbStore 实例导致冲突。解决办法是给所有数据库操作加了一个串行队列:
dart复制final _dbQueue = Queue<Future<void>>();
Future<T> _enqueue<T>(Future<T> Function() action) async {
final completer = Completer<T>();
_dbQueue.add(() async {
try {
result = await action();
} catch (e) {
completer.completeError(e);
}
});
// 串行处理
return completer.future;
}
或者说更简单的方式:在原生侧加逻辑锁,确保同一时间只有一个 SQL 操作。考虑到小型应用低频操作,用 Dart 侧串行队列足够了。
7.2 热重载后数据库状态丢失
用 Flutter 热重载(r)时,页面状态能保留,但如果你改了原生代码(比如修改了 DatabaseHelper 里的建表语句),必须完全重启 App(R),否则原生侧的旧代码还在运行,新表结构根本没生效。
我一开始建表语句从 v1 加个字段到 v2 时,热重载后一直查不到新字段,搞得我以为 OpenHarmony 的数据库不支持。后来发现是热重载根本不会重建原生模块。彻底 kill App 再跑就正常了。
7.3 数据库文件路径不对导致"只读"报错
OpenHarmony 的 RelationalStore 默认库文件路径是在应用沙箱的 el1/database 下,这个路径是由系统管理的。我最初是在原生侧自己拼了一个 files 路径去初始化,结果一直报"read-only file system"。
正确的做法是直接用 getRdbStore 传入的 context 下的默认路径,不要去手动干预。如果要备份数据库,可以手动把 recipe.db 从沙箱拷贝到用户可见的位置,但运行时的初始化路径就交给系统。
7.4 启动白屏与首帧优化
Flutter 在 OpenHarmony 上默认的启动流程有一个白屏阶段。优化思路有两个:一个是把 Flutter 引擎的初始化提前,通过 flutterEngine 的预创建;另一个是在 module.json5 里设置合适的启动窗口主题,让启动图尽量接近首屏背景色。
我最终只做了第二个优化,给启动窗口加了背景色,在 resources/base/element/color.json 里将 window_background 颜色改成接近页面主色的 #FFF8F0,白屏观感大大改善。
7.5 列表卡顿掉帧的定位方法
如果你遇到列表滑动掉帧,不要急着优化代码,先确认瓶颈在哪。我在 DevEco Studio 的 Profiler 里抓过性能数据,结果显示瓶颈在图片 IO 而非 UI 绘制。这也验证了我的缓存策略是对的。
定位到瓶颈之后,我把图片加载改成异步解码,并且在滚动开始时不加载任何图片,等滚动停止才显示封面图。这个"滚动停才开始加载"的方案在很多 App 里都有效,我实测滑动流畅度提升明显。
8. Flutter 热重载在 OpenHarmony 上的体验与限制
作为 Flutter 开发者,热重载是提升效率的核心武器。但 OpenHarmony 平台的热重载和 Android/iOS 上有一些差异,这里详细讲讲。
8.1 哪些场景热重载有效,哪些无效
我在实际使用中总结出一个规则:
| 修改类型 | 热重载是否生效 | 说明 |
|---|---|---|
| 修改 Widget 的 build 方法 | 是 | 立即生效,界面刷新 |
| 修改 Dart 中的常量 | 是 | 立即生效 |
| 新增 dart 文件 | 是 | 需要保存后重新 build |
| 修改原生 ETS 代码 | 否 | 必须完整重启 |
| 修改 module.json5 配置 | 否 | 必须重新编译 |
| 修改数据库表结构 | 否 | 必须完整重启 |
这个表很重要,因为它直接影响你的调试习惯。我经常是修改原生代码后忘了重启,跑起来发现"没生效"以为是代码写错了,浪费了不少时间。
8.2 热重载后浏览器没更新的问题
有时候命令行终端提示"Reloaded successfully",但模拟器或真机上画面没变。这个情况在 OpenHarmony 模拟器上出现过多次。我的排查方法是:
- 确认当前连接的是哪个设备,用
flutter devices查看。 - 确认 App 进程是否被系统杀掉,
flutter logs里会有关键错误。 - 用
R(大写)做完整重启,99% 的场景能解决。
如果完整重启都无效,把 ohos 目录下的 build 删掉重新构建。这招基本能解决一切玄学问题。
8.3 断点调试的技巧
VS Code 里给 Dart 侧代码打断点是没问题的,但如果你想断到原生 ETS 侧,需要改用 DevEco Studio 的调试器,而且必须附加进程才行。
实操步骤是:先用命令行 flutter run 启动 App,等 App 跑起来后,在 DevEco Studio 中选择"Attach to Process",就能同时调两个环境。这是排查 Platform Channel 问题的必备技巧。
9. 项目打包发布与签名配置
打包发布是最后一个大关卡。OpenHarmony 应用有自己的一套打包和签名体系,和 Android 的 apk 签名完全不是一回事。
9.1 Debug 签名的自动配置
DevEco Studio 里打开 File -> Project Structure -> Signing Configs,勾选"Automatically generate signature"。它会自动生成调试证书和 profile,不需要自己申请。注意,调试签名只适用于本地 Debug 构建,不能用于发布。
9.2 正式签名与发布包生成
如果要发布到应用市场,需要去 OpenHarmony 的 AppGallery Connect 申请正式证书,在这个流程里你要经历这几个步骤:
- 生成 CSR(证书签名请求):在 DevEco 的
Build -> Generate Key Store里生成 p12 文件。 - 把 CSR 上传到 AGC,申请 profile 文件。
- 下载 profile 文件,配置到工程里。
- 构建发布版 HAP 包。
发布包的构建模式是 Release,在命令行里执行:
bash复制flutter build hap --release
构建产物会生成在 build/ohos/release/ 目录下。如果是通过 DevEco Studio,直接 Build -> Build Hap(s)/APP(s) -> Build Hap(s)。
9.3 版本号管理与多设备适配
module.json5 里的 versionCode 和 versionName 要提前规划,不能到了发布前才想起来。我的习惯是 versionName 跟随功能迭代,versionCode 每次上传加 1。如果你同时要跑手机、平板和开发板,建议在 deviceConfig 里分别设置兼容的最低版本。
10. 后续优化的方向与实践心得
菜谱管理这个项目做到现在这个程度,我已经能正常记录和查询菜谱了,但距离一个"好用的产品"还有距离。按价值排序,我认为后续这几个方向是最值得做的:
第一,数据结构升级。现在 ingredients 和 steps 都是分隔符文本,如果要做"按食材找菜谱"这种比较垂直的功能,数据层需要重构成关联表。不过要谨慎,本地应用能通过内存过滤解决的,尽量不动数据库结构。
第二,引入简单同步能力。现在数据只存在本地,换设备就丢失了。可以基于鸿蒙的分布式数据服务做端到端同步,或者接一个简单的后端。这个收益比较大,因为菜谱数据的丢失是用户最痛的点。
第三,丰富的菜谱导入导出。支持 JSON 文件批量导入导出的功能,实现成本不高,但对内容的沉淀很有价值。
除了这些技术方案上的优化,我最大的实战体会是:OpenHarmony 生态还处于快速成长期,遇到的很多问题网上没有现成答案,但 Flutter 底座是成熟的,所以问题的定位思路基本可以复用 Flutter 的经验。遇到 bug,先确认是 Flutter 侧的问题还是 OpenHarmony 平台差异的问题,再决定从哪头下手排查。
最后再分享一个小技巧:开发过程中用 flutter logs 实时看日志,比 DevEco Studio 的日志面板更直接,尤其适合边操作边看打印的场景。日志里出现 ERROR 别急着处理,结合上下文判断是不是你自己的代码路径,OpenHarmony 系统自己也会打一些噪音日志,容易被带偏。
希望这篇完整的过程记录能帮你少踩几个坑。如果你也在做 Flutter + OpenHarmony 的应用开发,欢迎多交流实际遇到的问题,有些限制只有真机上了之后才看得出来。
