在OpenHarmony设备上跑Flutter应用,放到两年前还是个“硬核折腾”的事,现在已经是我日常工作的一部分。我最近做了一款生活助手App,里面最关键的一个模块就是待办事项管理。从Flutter环境搭建到OpenHarmony打包上架,完整折腾了三周,踩了不少坑,也整理出一套可以照着走的流程。这篇文章不聊虚的架构理念,就是把我在待办模块中实际用到的技术方案、代码结构和避坑经验完整放出来,给准备在鸿蒙上做Flutter开发的同行一个参考。你不需要是OpenHarmony底层专家,只要写过Flutter、会基本的Dart语法,就能跟上这篇文章的节奏。我会拿一个最小可跑的待办事项模块当主线,把跨端适配过程中每个关键点都过一遍。
1. 项目定位与整体设计思路
1.1 为什么选Flutter来做OpenHarmony应用
先说选型。OpenHarmony出来之后,我身边很多团队都在犹豫:用原生ArkTS开发,还是用跨端框架?我的结论很直接:如果是做业务型App,Flutter的性价比明显更高。原因在于Flutter的渲染机制——它不依赖平台的原生组件树,而是自己维护一套Widget树,再通过Skia或Impeller直接绘制到屏幕上。平台侧只提供一个画布、输入事件和生命周期回调,剩下的UI层全部由Dart代码控制。
这种架构放在OpenHarmony上有一个天然优势:底层适配只需要搞定引擎嵌入和一个稳定的渲染Surface,不需要把ArkUI的每个组件都映射一遍。打个比方,Flutter像是一个自带全套家具的画师,平台只提供一间空屋子和一扇窗,画师自己决定怎么布置;而其他跨端方案更像是让画师去换用房东的家具,房东换什么风格,画师就得跟着改。
再加上OpenHarmony SIG组一直在维护Flutter的ohos分支,包括flutter_flutter、flutter_engine、flutter_packages等仓库,生态在快速补全。我实际开发三周下来,纯Flutter层代码基本没有为鸿蒙做过特殊改动,等同一套代码再编译到Android和iOS,这对小团队来说省下的不是一点半点。
1.2 待办事项模块的需求拆解
生活助手App里,待办事项是用户每天都会打开的模块,需求非常明确,但也正因为常见,才更能暴露跨端适配的问题。我先把MVP需求拆成了两张表,一张功能清单,一张非功能指标。
功能需求:
| 功能 | 说明 | 优先级 |
|---|---|---|
| 新增待办 | 输入标题、描述,选择优先级和截止时间 | P0 |
| 编辑待办 | 点击列表项进入详情编辑 | P0 |
| 完成切换 | 勾选Checkbox切换完成状态 | P0 |
| 删除待办 | 列表项左滑删除 | P0 |
| 本地持久化 | 重启App后数据不丢 | P0 |
| 排序展示 | 未完成优先,按截止时间升序 | P1 |
| 统计摘要 | 首页头部显示今日完成数 | P1 |
非功能指标:
- 冷启动时间不高于2秒(中端设备)
- 离线可用,所有操作本机完成
- 100条待办列表滚动不卡顿、不掉帧
- 包体积控制在25MB以内
这些指标看起来简单,但放到OpenHarmony上,每一项都可能因为适配问题打折。后面我会逐个讲排查和验证的方法。
1.3 技术选型与架构规划
模块划分上,我按feature-first组织代码,待办模块单独放一个feature/todo目录,内部再拆data、domain、presentation三层。选型清单如下:
| 关注点 | 方案 | 理由 |
|---|---|---|
| UI框架 | Flutter Material 3 | 跨端一致性强,组件丰富 |
| 状态管理 | Cubit(Bloc简化版) | 轻量、直观,适合中小模块 |
| 本地存储 | shared_preferences + JSON序列化 | MVP阶段够用,迁移简单 |
| 路由 | Navigator 2.0 + go_router | 支持嵌套导航和状态保持 |
| 原生能力 | MethodChannel + EventChannel | 通知、日期等系统能力必须走通道 |
| 构建产物 | hap包 | OpenHarmony应用分发格式 |
我特意没有在一开始就上重型的数据库方案,比如sqflite或ObjectBox。待办事项MVP的数据量级就是几十条到几百条,shared_preferences存JSON完全够用,而且跨端实现成熟,在OpenHarmony上的适配插件也已经就绪。先跑通流程,再做存储层升级,这是我在这个项目里坚持的节奏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建与工程初始化
2.1 工具链版本搭配:Flutter、OpenHarmony SDK与IDE
跨端开发最怕版本不匹配,OpenHarmony这块比Android严格得多。我踩的第一个坑就是Flutter SDK版本过新,导致ohos插件报出“The current configured Flutter SDK is not known to be fully supported”的警告。这个警告虽然不阻断编译,但后续打包时很容易出现插件接口不兼容的奇奇怪怪问题。
我的建议是直接固定三组版本:
| 组件 | 我使用的版本 | 说明 |
|---|---|---|
| Flutter SDK | 3.22.0-ohos分支 | 来自OpenHarmony SIG维护的flutter_flutter仓库 |
| OpenHarmony SDK | API 12 | 与DevEco Studio 5.0配套 |
| DevEco Studio | 5.0.0 | 官方IDE,用于编译hap和签名 |
| Java | JDK 17 | 鸿蒙构建工具链要求 |
这里关键点是:不要直接用官方flutter stable分支,必须用SIG维护的ohos分支,否则flutter create生成工程时根本没有ohos平台选项。我用fvm做Flutter版本管理,在项目根目录放一个.fvmrc文件锁定版本,团队成员拉代码后一条命令就能切到正确版本。fvm对持续集成也友好,CI里可以直接调fvm flutter命令。
2.2 从零创建Flutter鸿蒙工程
环境就绪后,创建工程的流程分三步。第一步,配置pub镜像源,国内开发者通常需要设置PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL指向国内镜像,否则拉依赖的速度会很折磨人。第二步,在项目根目录执行:
bash复制flutter create --platforms=ohos --org com.example --project-name todo_app .
如果你有一个已有的Android Flutter工程,想加ohos平台支持,也可以直接执行 flutter create --platforms=ohos .,Flutter会自动生成ohos目录。
第三步,用DevEco Studio打开生成的ohos目录,等待Gradle同步完成后,连接鸿蒙设备或模拟器,执行:
bash复制flutter devices
flutter run -d ohos
第一次运行比较慢,因为要编译C++引擎和平台桥接层。我实测在中端开发机上,首次全量编译需要5到10分钟,之后增量编译就快多了,基本在30秒以内。这里有个提速技巧:把ohos目录下的.gradle和build目录加入本地缓存,不要每次clean,避免重复全量编译。
2.3 鸿蒙工程目录结构的关键点
生成之后的工程结构,ohos目录和Android的工程布局是两套逻辑。Android里是src/main/java包结构,ohos里则是entry/src/main/ets包结构,入口是entryability和pages。初次接触的人很容易困惑:我写的Dart代码到底跑在哪?
实际上,Dart代码完全跑在Flutter引擎侧,ohos工程里只有一个壳,负责创建FlutterAbility并承载FlutterView。你在ets目录里打开entryability,会看到类似这样的初始逻辑:创建FlutterAbility实例,把Dart入口指向MainActivity对应的main.dart。也就是说,鸿蒙原生侧不需要再写UI,只要保证壳活着,Flutter页面就正常渲染。
这个壳也是后面做原生能力扩展的挂载点。比如我要注册EventChannel、MethodChannel,就需要在这个Ability的生命周期里做初始化。理解了这一点,后面看插件适配就不容易迷路。
3. 待办事项核心功能的实现
3.1 数据模型与本地持久化
待办事项的模型字段,我按“够用就行”的原则设计,没有过度建模。核心包括id、标题、描述、完成状态、优先级、截止时间、创建时间。
dart复制class TodoItem {
final String id;
final String title;
final String? description;
final bool isCompleted;
final int priority; // 0低 1中 2高
final DateTime? deadline;
final DateTime createdAt;
TodoItem({
required this.id,
required this.title,
this.description,
this.isCompleted = false,
this.priority = 0,
this.deadline,
required this.createdAt,
});
factory TodoItem.fromJson(Map<String, dynamic> json) {
return TodoItem(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String?,
isCompleted: json['isCompleted'] as bool,
priority: json['priority'] as int,
deadline: json['deadline'] != null
? DateTime.parse(json['deadline'] as String)
: null,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'isCompleted': isCompleted,
'priority': priority,
'deadline': deadline?.toIso8601String(),
'createdAt': createdAt.toIso8601String(),
};
}
TodoItem copyWith({String? title, String? description, bool? isCompleted, int? priority, DateTime? deadline}) {
return TodoItem(
id: id,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
priority: priority ?? this.priority,
deadline: deadline ?? this.deadline,
createdAt: createdAt,
);
}
}
持久化层用shared_preferences存储JSON数组。写入的时候把一个List
dart复制class TodoLocalRepository {
static const _key = 'todo_list';
Future<List<TodoItem>> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key);
if (raw == null) return [];
final list = jsonDecode(raw) as List<dynamic>;
return list
.map((e) => TodoItem.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> save(List<TodoItem> items) async {
final prefs = await SharedPreferences.getInstance();
final raw = jsonEncode(items.map((e) => e.toJson()).toList());
await prefs.setString(_key, raw);
}
}
这里有个需要注意的地方:JSON序列化字段一旦确定,尽量不要改,尤其是有历史数据的版本升级场景。我在开发过程中改过一次字段名,导致测试设备上的旧数据全部解析失败,自动回退成了空列表。更稳妥的做法是解析失败时做兜底备份,把原始字符串存到另一个key下,方便排查。
3.2 状态管理:用Cubit接管待办列表
状态管理我选了Cubit而不是完整的Bloc。Cubit把状态变化浓缩成方法调用,不用写一堆Event类,待办模块就这么几个操作,用Bloc反而显得杀鸡用牛刀。如果后续模块膨胀,Cubit也能平滑升级到Bloc。
TodoCubit的核心逻辑不长:
dart复制class TodoCubit extends Cubit<TodoState> {
final TodoLocalRepository _repository;
TodoCubit(this._repository) : super(const TodoState());
Future<void> loadItems() async {
final items = await _repository.load();
emit(state.copyWith(
items: items,
todayCompleted: _countTodayCompleted(items),
));
}
Future<void> addTodo(TodoItem item) async {
final items = [...state.items, item];
await _persistAndEmit(items);
}
Future<void> toggleTodo(String id) async {
final items = state.items
.map((e) => e.id == id ? e.copyWith(isCompleted: !e.isCompleted) : e)
.toList();
await _persistAndEmit(items);
}
Future<void> removeTodo(String id) async {
final items = state.items.where((e) => e.id != id).toList();
await _persistAndEmit(items);
}
Future<void> _persistAndEmit(List<TodoItem> items) async {
final sorted = _sortItems(items);
await _repository.save(sorted);
emit(state.copyWith(
items: sorted,
todayCompleted: _countTodayCompleted(sorted),
));
}
List<TodoItem> _sortItems(List<TodoItem> items) {
final pending = items.where((e) => !e.isCompleted).toList()
..sort((a, b) {
if (a.deadline == null) return 1;
if (b.deadline == null) return -1;
return a.deadline!.compareTo(b.deadline!);
});
final completed = items.where((e) => e.isCompleted).toList();
return [...pending, ...completed];
}
}
页面侧用BlocProvider注入,再通过BlocBuilder监听状态变化。这样UI只管渲染,不用手动刷新列表。我额外加了一个todayCompleted字段,在首页头部展示“今日已完成”,不需要每次去遍历原始列表。
需要注意Cubit在页面销毁时一定要close,否则会内存泄漏。我在依赖注入层统一管理TodoCubit的注册和释放,不在单个Widget里手动管理。
3.3 列表UI与交互细节
UI层用了一个ListView.builder渲染待办项,每一项是自定义的TodoTile,内部包含Checkbox、标题、截止时间和优先级色条。
滑动删除是待办App的标配交互,Flutter里用Dismissible组件实现:
dart复制Dismissible(
key: ValueKey(item.id),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red.shade400,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete_outline),
),
onDismissed: (_) {
context.read<TodoCubit>().removeTodo(item.id);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已删除:${item.title}'),
action: SnackBarAction(label: '撤销', onPressed: () {
context.read<TodoCubit>().addTodo(item);
}),
),
);
},
child: TodoTile(item: item),
)
我建议删除操作必须配撤销入口。Dismissible的滑动手势太顺滑,用户误触概率很高,没有撤销会非常影响体验。
新增和编辑我共用一个TodoEditPage,通过传入可选的TodoItem区分模式。表单使用TextFormField校验标题非空,再加一个日期选择器设置截止时间。这里有个小坑:OpenHarmony上的Flutter日期选择器(showDatePicker)默认样式是Material风格,弹窗在鸿蒙设备上偶发位置偏移。我的解决办法是给showDatePicker显式传入useRootNavigator: false,并把初始日期设为今天,稳定性好很多。
做完基本功能之后,我又处理了一个细节:TabBar点击动画在鸿蒙上会偶发闪烁。Flutter默认的TabBar点击反馈动画在部分OpenHarmony设备上会出现一次半透明的闪白,很影响观感。通过自定义tabAnimationDuration和indicator的decoration,把点击时的快速高亮动画关掉,页面就干净了。这类UI适配问题不致命,但很影响用户第一印象,发布前值得过一遍。
4. 平台适配与性能优化
4.1 MethodChannel与EventChannel:待办提醒的数据通路
待办事项要做提醒,就必须和系统能力打交道。Flutter与鸿蒙原生的通信靠两套通道:MethodChannel用于“一问一答”式调用,比如请求系统日历权限、查询当前时间;EventChannel用于“持续推送”式事件,比如系统通知、倒计时回调。
打个比方,MethodChannel像打电话,你拨过去,对方说一个结论,电话挂断;EventChannel像广播电台,你只要调好频率,电台一直接着播,你随时听。待办提醒的定时逻辑如果用MethodChannel轮询,效率太低,必须用EventChannel订阅。
Dart侧实现:
dart复制class ReminderChannel {
static const EventChannel _channel = EventChannel('com.example.todo/reminder');
Stream<ReminderEvent> get reminderStream {
return _channel.receiveBroadcastStream().map((event) {
final map = Map<String, dynamic>.from(event as Map);
return ReminderEvent.fromJson(map);
});
}
}
鸿蒙侧注册这个通道时,我是在FlutterAbility里通过PluginRegistry注册一个自定义插件,这个插件内部创建EventChannel并设置setStreamListener。这样一来,鸿蒙系统的闹钟服务触发时,就可以把事件推送到Dart层,Dart层再根据事件里的todoId唤起对应的提醒弹窗。
这个通道的设计有一个关键点:事件流一定要在页面生命周期内合理订阅和取消。我刚开始没注意,直接在main函数订阅了全局事件流,结果App退到后台再回来,旧订阅没有释放,新订阅又加上,同一个提醒事件触发了好几个弹窗。后来我把订阅放在应用级Scope里,用StreamSubscription显式管理,才彻底解决。
4.2 Flutter插件适配OpenHarmony的通用流程
待办App里用到的shared_preferences、url_launcher、path_provider这些常见插件,在OpenHarmony上的支持程度参差不齐。我做了个测试,发现shared_preferences有官方ohos实现,url_launcher需要自己适配。
其实插件适配有一套通用流程,我以自己适配url_launcher为例说明。Flutter插件生态里很多是federated插件结构,分为app-facing包、platform interface包和各个平台的implementation包。要做鸿蒙实现,就是新增一个ohos的implementation包。
第一步,在插件工程下创建ohos目录,用DevEco Studio初始化一个鸿蒙插件模块。第二步,实现MethodChannelHandler,核心是把canLaunch、launch这些方法映射到鸿蒙的want/ability能力上。第三步,在插件注册类里把handler绑定到频道名。第四步,在Flutter应用的pubspec.yaml中显式依赖这个鸿蒙实现包。
yaml复制dependencies:
flutter:
sdk: flutter
url_launcher: ^6.3.0
url_launcher_ohos:
path: ./third_party/url_launcher_ohos
整个适配过程,最耗时间的不是写代码,而是理解鸿蒙的Ability启动参数和Android的Intent映射差异。我的经验是:只要能跑通一个最简单的MethodChannel hello world,后面把所有插件按同样模式复制粘贴就行。
这里还要提一下Flutter Impeller。Flutter 3.16之后在Android上默认开启Impeller渲染引擎,OpenHarmony分支也逐步支持。Impeller的价值在于把Shader在运行时编译改为离线预编译,显著减少首帧卡顿。我对比过同一台鸿蒙设备上Skia和Impeller的滚动帧率:加载60帧待办列表,Skia场景偶发十几毫秒的jank,Impeller场景基本稳定在16.6毫秒。如果你的设备GPU支持Vulkan,建议在flutter run时加上--enable-impeller参数验证效果。
4.3 页面切换与状态保持问题
开发中前端同学常问一句话:Navigator切换页面后,会丢失状态吗?答案是分场景的。
默认情况下,Navigator.push一个页面后,原页面的State仍然保留在导航栈中,不会销毁。但如果原页面里有列表滚动位置、表单输入内容这类UI状态,只要你不手动清理,切回来还在。真正会丢状态的是Tab切换,因为Flutter默认的BottomNavigationBar切换Tab时会重建页面。
我的待办列表正好用了首页+列表Tab的结构。为了保证Tab切来切去列表不重建,我用了AutomaticKeepAliveClientMixin:
dart复制class TodoListPage extends StatefulWidget {
@override
State<TodoListPage> createState() => _TodoListPageState();
}
class _TodoListPageState extends State<TodoListPage>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
@override
Widget build(BuildContext context) {
super.build(context);
return BlocBuilder<TodoCubit, TodoState>(
builder: (context, state) {
// 渲染列表
},
);
}
}
还有个场景在鸿蒙上更隐蔽:App切后台被系统回收后,FlutterAbility重建,整个Dart层状态全部丢失。这不算Flutter的bug,而是移动系统的资源回收机制。应对办法就是我在3.1节讲的持久化,只要每次状态变更都落盘,重建后从磁盘恢复即可。这里我要强调:恢复逻辑必须在初始化时同步校验,不能假设preferences一定存在。
4.4 网络与调试相关的适配备忘
生活助手App免不了要请求天气、节假日等远程数据,调试过程中我遇到过一次“抓包失败”的情况。现象是Charles和Flutter的debugNetworkImage都抓不到HTTP请求,排查半天发现不是代理问题,而是应用默认禁止了明文流量。OpenHarmony和Android一样,API 28以上默认禁止cleartext HTTP请求。
解决办法分两层。一层是在鸿蒙工程的module.json5里配置网络权限,显式声明ohos.permission.INTERNET;另一层是如果只是本地调试,可以给调试用的Ability单独开一个usesCleartextTraffic为true的配置,发布包保持默认关闭。注意把网络安全配置和正式包的安全策略分开,避免审核时被拒。
5. 打包发布与常见问题排查
5.1 构建hap包与签名配置
开发调试时用flutter run没问题,但正式发布必须构建hap包。OpenHarmony的发布产物是.hap后缀的应用包,构建方式有两种:一种是在DevEco Studio里直接Build HAPs,另一种是命令行执行:
bash复制flutter build hap --release
构建完成后,release产物在build/ohos/release目录下,里面除了hap还有未签名的中间产物。签名这步是最容易让人卡住的流程,尤其是第一次配置不熟练的时候。
OpenHarmony的签名类似Android的签名,但多了Layer和Profile的概念。我实际用的是自动签名流程:在DevEco Studio里登录账号,选择Automatically generate signature,IDE会帮你创建p12、cer和p7b文件并自动关联。这种方式适合个人开发者。如果团队内多人协作,建议用手动签名,把证书信息统一配置到build-profile.json5里,并在CI里设定签名文件路径。
发布前检查清单我整理成了固定流程:
- 版本号与构建号对照检查,lifecycle App的版本号要和hap里的一致
- 图标、应用名、权限声明逐一核对
- 隐私弹窗和权限使用说明补充完整
- 用release模式做一次全流程回归,不能用debug包上线
5.2 高频编译问题速查表
把我在这个项目里遇到的高频报错整理成一张表,方便你排查。
| 报错/现象 | 触发原因 | 解决方案 |
|---|---|---|
| you are applying flutter's main gradle plugin imperatively using the apply script | 工程中使用了旧式apply script方式引入Flutter Gradle插件 | 改为在plugins块中声明式应用Flutter插件 |
| The current configured Flutter SDK is not known to be fully supported | Flutter SDK版本与ohos分支支持范围不匹配 | 用fvm切换到SIG维护的已测试版本 |
| java.lang.AssertionError: could not close ... | Gradle缓存损坏或依赖下载不完整 | 删除ohos/.gradle目录,重新执行gradle sync |
| 插件方法找不到/插件未生效 | 插件没有在ohos平台注册实现 | 检查插件目录下是否存在ohos实现并重新flutter pub get |
| 调试包启动后白屏 | DevEco Studio调试签名与设备不匹配 | 重新执行自动签名并卸载旧包 |
| HTTP请求失败/抓包无数据 | 明文流量被安全策略拦截 | module.json5中配置INTERNET权限,调试期单独开启cleartext |
这里重点说第一个报错。新版Flutter Gradle插件已经不再推荐用 apply plugin: "com.flutter.gradle.extension" 这种命令式语法,而是改成在settings.gradle里通过plugins块声明。旧项目迁移到ohos平台时容易带上旧语法,一旦出现这个报错,直接把apply语句删掉,然后按官方模板的plugins块写法重写即可。
5.3 实测体验:性能数据与开发效率
项目上线前,我在一台中端鸿蒙设备上做了基础性能测试,数据如下:
| 指标 | 实测结果 |
|---|---|
| 冷启动到首页可交互 | 约1.2秒 |
| 列表滚动帧率 | 60fps稳定,无jank |
| 200条待办内存占用 | 约180MB |
| 构建产物ha p包大小 | 约24MB |
| 首次FVM编译耗时 | 约7分钟 |
对比同模块用ArkUI开发的话,我个人估算要两天左右,用Flutter一天多就跑完了。开发效率的差距主要来自Flutter的热重载,改UI不用重新编译引擎,在OpenHarmony上一样生效。另一个大优势是多端复用:Android、iOS、OpenHarmony三端共用一套Dart业务代码,UI差异只在个别系统行为上做微调。
当然也有短板。Flutter在鸿蒙上的生态还比不上Android,部分长尾插件没有ohos实现,需要自己适配;热重载偶尔会因引擎版本和Dart版本不一致而失效,这种时候只能重启;还有Flutter引擎体积相对大,如果做的是轻量工具类App,包体增加可能是个顾虑。
收尾的一点个人体会
我在OpenHarmony上做Flutter开发的整体感受是:方向对,坑有,但都在可控范围内。待办事项这个模块虽小,却把跨端开发的典型难点都过了一遍——环境组合、持久化、状态管理、平台通道、插件适配、签名打包。回头总结,最值得留意的其实不是某一条具体报错的解法,而是“先跑通最小闭环,再逐步丰富能力”的节奏。
最后分享两个实操中沉淀下来的小技巧。第一个是CI缓存:在持续集成流水线里,把Flutter ohos分支的gclient依赖和ohos目录下的.gradle缓存下来,全量编译时间能从7分钟压到3分钟以内。第二个是关于多端UI一致性:建议在核心Widget上加Golden Test,截图像素级对比三端渲染差异,我在待办列表上就靠这个提前发现了鸿蒙端字体行高的细微偏差。这两个习惯,比多写几百行代码更能提升项目质量。
