最近我一直在折腾 Flutter for OpenHarmony 这套链路,手里的项目是一个文件转换助手 App。整个界面没有用传统的列表页加详情页那种结构,而是把所有操作单元都做成了“功能卡片组件”:选文件是一张卡片、选转换格式是一张卡片、调参数是一张卡片、看转换进度还是一张卡片。这玩意儿跑通以后,我的感受是 Flutter 在 OpenHarmony 上已经从“能不能跑”进入到“好不好用”的阶段,但真正磨人的不是 Flutter 框架本身,而是功能卡片组件背后的状态管理、平台通道对接、渲染引擎适配这些细节。
这篇内容主要面向三类人:正在做 Flutter for OpenHarmony 的一线开发者、想评估跨端方案的技术负责人、以及被“功能卡片组件”这个概念绕晕的初学者。我会把文件转换助手 App 里卡片组件的设计思路、实现过程、踩坑记录全部翻出来讲,代码可以抄,思路可以直接复用。
1. 功能卡片组件在文件转换App里的定位:先把结构拆清楚再动手
1.1 文件转换助手里的卡片到底是什么
我最早说“功能卡片组件”的时候,团队里有人以为是要做 OpenHarmony 桌面那种服务卡片(Form),其实不是。App 内部的功能卡片,本质上是一个“高内聚的交互单元”:一块圆角矩形区域,承载一个独立功能模块,自带标题、状态、操作按钮和数据展示。
在文件转换助手这个具体场景里,卡片按职责分成这么几种:
- 文件选择卡片:点击后拉起系统文件选择器,展示已选文件的名称、大小、类型图标,支持多选和移除。
- 格式参数卡片:展示“从什么格式转成什么格式”的选择控件,比如 PDF 转 Word、图片转 WebP,附带分辨率、画质等参数项。
- 转换进度卡片:展示单个文件的转换进度条、当前状态文案、取消/重试按钮。
- 批量队列卡片:展示多个任务的排队列表、整体进度、成功失败统计。
- 结果输出卡片:转换完成后展示输出路径、文件大小、耗时,提供“再转一个”快捷操作。
这五类卡片不是孤立存在的。文件选择卡片出数据,格式参数卡片出规则,两者合并后生成“任务”,任务进入队列卡片渲染,点开始后跑进度卡片,完成后落到结果卡片。整个过程像一条流水线,每个卡片就是一个工位,数据在卡片之间单向流动。
这种设计的好处是,后期加新功能(比如加一个音频提取卡片)不用动老代码,照着卡片接口再实现一张就行。坏处也很明显,就是如果一开始状态归属没划分清楚,几个卡片之间互相传值会把代码写得又臭又长。
1.2 为什么在OpenHarmony上用Flutter而不是ArkUI
这个问题几乎每次分享都会被问。OpenHarmony 的原生开发是 ArkTS 加 ArkUI,页面效果很顺滑,但我们的核心诉求是跨端复用。文件转换助手不只在 OpenHarmony 上跑,后面还要覆盖 Android、iOS,甚至桌面端。Flutter 的优势在于一套 Dart 代码能把 UI 和业务逻辑全带走,每个端只需要薄薄一层原生插件。
团队技能栈也是一个现实因素。组里大部分人之前都在写 Flutter,ArkTS 的学习成本虽然不高,但要把整个 App 用 ArkUI 重写一遍,少说也要一两个月。用 Flutter 的话,ArkUI 只用来做外壳工程和插件注册,主要业务代码全部复用,节奏完全不一样。
再从渲染一致性看。OpenHarmony 的 ArkUI 渲染效果和 Android 原生还是有一些细微差别的,尤其中文字体排版、阴影分层这些细节。Flutter 自带 Skia/Impeller 渲染引擎,同样是自绘,在 OpenHarmony 上能还原出和 Android 一致的效果。这一点对设计稿还原要求高的项目来说非常省心。
当然 Flutter 在 OpenHarmony 上的方案也有代价,比如很多 ArkUI 能力(服务卡片、系统设置项、部分系统弹窗)Flutter 侧拿不到,需要自己在原生侧包一层通道。这个后面我会细说。
1.3 卡片组件分层设计:容器卡片与内容插槽分离
功能卡片组件最容易踩的坑,是把所有样式都堆在一个 Widget 里。我第一版就是这么干的,文件选择卡片里塞了10组条件判断,改一个状态要滚半天代码。后来重构成“容器 + 插槽”模式,清爽很多:
class FunctionCard extends StatelessWidget { final String title; final Widget content; final Widget? action; final VoidCallback? onTap; const FunctionCard({ super.key, required this.title, required this.content, this.action, this.onTap, }); @override Widget build(BuildContext context) { return Card( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(16), child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(title, style: Theme.of(context).textTheme.titleMedium), if (action != null) action!, ], ), const SizedBox(height: 12), content, ], ), ), ), ); } }FunctionCard 是通用容器,负责外框、标题栏、点击反馈。每个具体卡片只需要实现自己的content部分。比如文件选择卡片的内容是一个“已选文件列表 + 添加按钮”,格式参数卡片的内容是“两个下拉框 + 参数滑块”,进度卡片的内容是“进度条 + 状态文案”。
这样做的好处有三个。第一,全局卡片风格统一,改圆角、改阴影只动一个文件。第二,状态隔离,每张卡片的内部状态(比如文件列表、进度值)不会泄漏到别的组件。第三,组合灵活,批量队列卡片可以由任务进度卡片作为子组件循环出来。
状态归属上,我的原则是:能放卡片内部的状态就不往上提。文件选择卡片自己维护“当前选中了哪些文件”这个局部状态;只有跨卡片共享的状态(比如整个任务的转换状态)才放到页面级或者全局状态管理器里。这个原则做下来,后期加需求时状态来源非常清晰,不用翻着代码猜一个变量是从哪来的。
2. 功能卡片组件的核心设计点:数据状态、视觉规范、通信机制一次说清
2.1 卡片数据模型与任务状态机
功能卡片组件不能光有好看的外壳,它要能正确表达业务数据。我在项目里定义了三个核心模型:文件项(FileItem)、转换任务(ConvertTask)、任务状态(TaskStatus)。
enum TaskStatus { pending, // 排队中 converting, // 转换中 completed, // 已完成 failed, // 失败 canceled, // 已取消 } class FileItem { final String path; final String name; final int size; final String mimeType; const FileItem({ required this.path, required this.name, required this.size, required this.mimeType, }); } class ConvertTask { final String id; final FileItem source; final String targetFormat; final TaskStatus status; final double progress; final String? outputPath; final String? errorMessage; const ConvertTask({ required this.id, required this.source, required this.targetFormat, this.status = TaskStatus.pending, this.progress = 0, this.outputPath, this.errorMessage, }); ConvertTask copyWith({ TaskStatus? status, double? progress, String? outputPath, String? errorMessage, }) { return ConvertTask( id: id, source: source, targetFormat: targetFormat, status: status ?? this.status, progress: progress ?? this.progress, outputPath: outputPath ?? this.outputPath, errorMessage: errorMessage ?? this.errorMessage, ); } }状态机是整个卡片组件的灵魂。转换任务的状态流转是单向的:
- 创建任务时状态是 pending。此时卡片展示“等待转换”,可以取消。
- 点击开始或自动开始后,状态变为 converting。此时进度卡片展示进度条,并提供“取消转换”按钮。
- 转换成功后状态变为 completed。结果卡片展示输出路径和文件信息,并提供“再转一个”按钮。
- 转换过程中出错,状态变为 failed。进度卡片变成红色的错误提示,按钮变成“重试”。
- 用户主动取消,状态变为 canceled。这个状态是终态,只能重新创建任务。
为什么要用枚举而不是布尔值?因为布尔值表达不了“中途失败”和“已完成”两个互斥但并存的语义。用bool isFinished的话,你还得再加一个bool isFailed,然后组合出4种可能,很容易漏掉“完成且失败”这种非法状态。枚举加状态机,非法状态从编译层面就不存在。
这里还有个关键设计:ConvertTask 是不可变对象,状态更新通过copyWith生成新对象。配合 Flutter 的setState或者状态管理框架,卡片只在数据变化时重建,不会因为无关字段改动而闪烁。
2.2 卡片视觉体系与OpenHarmony主题适配
功能卡片组件的美丑直接决定用户愿不愿意点你。我项目里定了一套视觉规范,不复杂,但执行得很严格。
- 卡片圆角:统一 16dp。过大显得傻白甜,过小不够精致。
- 内边距:左右 16dp,上下 20dp。内容区内部再用 12dp 间距分隔元素。
- 阴影:默认一层浅阴影(
elevation: 1),按压时抬升到 3 做反馈。不要用彩虹阴影,一眼假。 - 底色:浅色模式下用纯白,深色模式下用
#1C1C1E这种层级色。重点是卡片底色和页面背景要有区分。 - 标题字号:用系统的
titleMedium,内容区正文用bodyLarge,辅助说明用bodySmall。字体单位直接复用 Material 主题,不要自己写fontSize: 16到处乱塞。
OpenHarmony 上有一个坑必须提前说:Flutter 的dp和 ArkUI 的vp在大部分设备上是一一对应的,但部分中低端设备在系统级缩放设置不一样时,两边可能会出现轻微偏差。跨端联调的时候,UI 同事在 OpenHarmony 原生页面里调好了间距,到了 Flutter 卡片里发现整体偏大/偏小,就是缩放基准不一致导致的。正确做法是统一以设计稿的vp/120为基准,Flutter 侧用逻辑像素直接跟设计稿对齐,原生侧用vp()方法做换算,两边结论一致才放行。
还有一个深色模式适配问题。功能卡片组件如果只做了浅色,在 OpenHarmony 深色模式下会显得非常突兀。实现上我建议直接用Theme.of(context).colorScheme.surface作为卡片底色,让卡片自动跟随全局主题,而不是手动写Color(0xFFFFFFFF)。这样后续接入系统深色切换时零成本。
2.3 组件通信:状态管理与平台通道的双通道协作
功能卡片组件之间通信,我项目里用了两层方案。
第一层是 Flutter 内部的状态传递,数据量不大,我直接用ChangeNotifier加InheritedNotifier实现了一个轻量级全局 Store。文件选择卡片更新文件列表后,通知格式卡片刷新;任务状态改变后,通知队列卡片刷新统计。选型时考虑过provider、Riverpod、BLoC/Cubit,但项目节奏紧、团队规模小,原生ChangeNotifier够用。这里分享一个选型心得:状态管理框架不是越重越好,如果你的卡片组件只有少量跨页面共享状态,不要为了“未来可能复杂”引入一堆依赖。等状态真的复杂了,接口留好,再换框架也不迟。
不过既然热搜词里也有flutter cubit和flutter 组件通信这两个,我可以多说一句。如果大家团队的规范是统一用flutter_bloc,那么在做功能卡片组件时建议把事件先定义为 sealed class,让卡片的 UI 层只关注 state,不关注 event。比如进度卡片只监听ConvertState(progress: 0.6)这个状态来刷新进度条,至于底层是 EventChannel 传上来还是本地模拟的,UI 层不关心。
第二层是 Flutter 和 OpenHarmony 原生之间的通信。文件选择、读取文件信息、执行转换任务这些能力 Flutter 侧没有,必须走平台通道:
- MethodChannel:用于一次性调用,比如拉起文件选择器、获取文件大小、发起转码任务。
- EventChannel:用于持续监听,比如转码进度回调、批量任务队列状态变化。
class NativeBridge { static const _pickerChannel = MethodChannel('com.fileconverter/picker'); static const _progressChannel = EventChannel('com.fileconverter/progress'); static Future<FileItem?> pickFile() async { try { final result = await _pickerChannel.invokeMethod<Map<Object?, Object?>>( 'pickFile', {'type': 'document'}, ); if (result == null) return null; return FileItem( path: result['path'] as String, name: result['name'] as String, size: result['size'] as int, mimeType: result['mimeType'] as String, ); } on PlatformException catch (e) { debugPrint('pickFile error: ${e.message}'); return null; } } static Stream<double> progressStream() { return _progressChannel .receiveBroadcastStream() .map((event) => (event as num).toDouble()); } }原生侧注册这个通道时,要确保在 FlutterView 加载完成后再调用PluginRegistry的注册方法,否则会报“channel not found”。后面实操部分我会给出具体代码。
3. 实操实录:手把手实现文件转换助手的功能卡片组件
3.1 环境准备与工程搭建:Flutter for OpenHarmony 的SDK链路
先说结论:OpenHarmony 上的 Flutter 不是官方 Flutter SDK 开箱即用那种体验,需要切换到带有 OpenHarmony 适配的分支。目前社区常见的做法是从 OpenHarmony SIG 维护的仓库拉flutter_flutter和flutter_engine,这两个仓库里有完整的鸿蒙适配补丁,包括渲染层、插件注册层、事件循环层。
具体步骤我整理成了一份可以直接抄的操作清单:
- 准备 Ubuntu 或者 DevEco Studio 的 Linux 构建环境。Windows 上也可以搭,但交叉编译 OpenHarmony 的 so 库时容易碰壁,建议直接用 Linux。
- 拉取特定版本的 Flutter SDK,比如 3.22 分支。这里提醒一句:网上有人分享“Flutter 3.44”“3.4x”这种版本号,很多是厂商 fork 的分支,跟官方版本号不是一回事。如果只看帖子里的版本号去下载,大概率装完发现
flutter doctor各种报错。认准flutter_flutter仓库的 openharmony 分支即可。 - 配置
OHOS_SDK_HOME环境变量,指向你本地的 OpenHarmony SDK 路径,同时把ohos工具链加入 PATH。 - 克隆官方模板项目,或者直接创建 flutter 项目后手动添加
ohos目录。建议用 OpenHarmony 模板项目起步,里面已经把hvigorfile、module.json5、MainAbility这些配好了,省去踩坑。 - 在
ohos目录下执行hvigor构建,首次构建会拉取大量依赖,耐心等。
构建过程中,我也被“当前配置的 Flutter SDK 未知是否完全受支持”这类 warning 吓过几次。其实这个是版本匹配检查的提示,只要你的 engine 和 framework 是同一套 openharmony 分支,这个 warning 忽略即可,不影响产物。真正要注意的是不能混用:Flutter 侧跑的是官方 3.22,engine 侧却是 openharmony 3.10 的分支,那构建产物一定会崩。
环境搭建完成后,建议先在模拟器上跑一个最简单的Text('Hello OpenHarmony')页面,确认渲染正常后再开始写卡片。这样每加一层复杂度,排查范围都很明确。
3.2 文件选择卡片的完整实现:原生通道与卡片UI协作
文件选择卡片的核心不是 UI,而是怎么拿到一个真实的文件路径。OpenHarmony 上 Flutter 侧没有现成的file_picker插件可用,我从头写了一个 MethodChannel 通道。
原生侧(Ability 或 Plugin 中)注册通道的代码逻辑是这样的:
// MainAbility.ts 或者自定义Plugin中 private registerFilePickerChannel(): void { this.flutterEngine.getPluginRegistry().register(FilePickerPlugin()) } export class FilePickerPlugin implements FlutterPlugin { onAttachToEngine(binding: FlutterPluginBinding): void { binding.getBinaryMessenger().setMessageHandler( 'com.fileconverter/picker', (message) => { const call = message as MethodCall if (call.method === 'pickFile') { const type = call.argument('type') // 调用OpenHarmony的DocumentPicker或FilePicker能力 // 拿到uri -> 解析为沙箱路径 -> 回传 return Promise.resolve({ path: filePath, name: fileName, size: fileSize, mimeType: mimeType, }) } return Promise.resolve(null) } ) } }一定要记得在onDetachFromEngine里做卸载清理,不然页面销毁后原生侧还持有 Flutter 侧的 messenger 引用,后面回收会有内存泄漏风险。
Flutter 侧的卡片组件 UI 就是插槽的实战:
class FilePickerCard extends StatelessWidget { final List<FileItem> selectedFiles; final ValueChanged<List<FileItem>> onChanged; const FilePickerCard({ super.key, required this.selectedFiles, required this.onChanged, }); Future<void> _pickFiles() async { final file = await NativeBridge.pickFile(); if (file == null) return; onChanged([...selectedFiles, file]); } @override Widget build(BuildContext context) { return FunctionCard( title: '选择文件', action: TextButton.icon( onPressed: _pickFiles, icon: const Icon(Icons.add), label: const Text('添加'), ), content: selectedFiles.isEmpty ? Text( '尚未选择文件', style: Theme.of(context).textTheme.bodyMedium, ) : Column( children: selectedFiles .map((f) => ListTile( leading: const Icon(Icons.insert_drive_file), title: Text(f.name), subtitle: Text('${(f.size / 1024).toStringAsFixed(1)} KB'), trailing: IconButton( icon: const Icon(Icons.close), onPressed: () { onChanged( selectedFiles.where((e) => e.path != f.path).toList(), ); }, ), )) .toList(), ), ); } }注意我这里的onChanged是直接把新的文件列表回调出去,状态由父级持有,而不是卡片内部自己维护。因为后面格式转换卡片还需要根据这个文件列表来判断“能不能转”“转成什么格式”,这两个卡片之间不能各管各的。设计卡片组件时一定要先想清楚哪些状态是“卡片自治”的,哪些是“页面共享”的,这个划分比写代码本身更重要。
3.3 转换进度卡片的实现:进度流、动画与异常状态
转换进度卡片是文件转换助手里最“活泼”的一张卡,因为它要响应原生侧不断回调的进度值,还要处理暂停、失败、重试之间的状态切换。我的实现分三层:数据层(进度流)、组件层(动画)、状态层(TaskStatus)。
数据层直接订阅NativeBridge.progressStream()。这里有个细节,EventChannel在 Flutter 侧连续收到事件时,如果 UI 线程正在重建,会出现丢帧或者进度跳变。我建议在监听回调里做一次节流,只保留每 100 毫秒最新一次进度值:
StreamSubscription<double>? _sub; void _listenProgress() { _sub = NativeBridge.progressStream().listen((progress) { _lastProgress = progress; // 节流到帧边界更新 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() {}); } }); }); }组件层我直接用AnimationController来做平滑过渡,让进度条从旧值动画到新值,避免生硬跳变:
class _ProgressCardState extends State<ProgressCard> with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); } @override void didUpdateWidget(covariant ProgressCard oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.task.progress != widget.task.progress) { _controller.animateTo(widget.task.progress.clamp(0.0, 1.0)); } } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return LinearProgressIndicator( value: _controller.value, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, minHeight: 8, ); }, ); } }这里有个很隐蔽的坑:didUpdateWidget里判断oldWidget.task.progress != widget.task.progress时,如果同时传入的还有errorMessage等字段,且progress不变的失败回调(比如失败时原生侧把进度定格在 0.8),动画就不会触发,卡片视觉上会停留在 80%。所以正确做法是把进度值和状态值分开判断,状态变化时单独刷新 UI,进度值变化时才驱动动画。
异常状态我做成了一张“卡片内嵌监测视图”:当task.status == failed时,进度条下方出现错误文案和重试按钮;当canceled时,展示灰色提示“任务已取消”。这些分支都放在同一个小部件里,通过switch (task.status)分发,不写多个if嵌套。
3.4 批量任务卡片与状态恢复:不让页面切换毁掉进度
批量队列卡片是一个ListView,里面每一项都是一个小号进度卡片。列表本身谈不上难,难的是“切走再切回来,队列还在不在”。
Flutter 的Navigator.push切换页面时,默认情况下旧页面会被端盖,State虽然保留,但如果被dispose了(比如从路由栈移除),状态就彻底丢失。OpenHarmony 上的表现和其他平台一样。我在热词里看到flutter navigator切换页面后,会丢失状态吗这个问题,这里可以明确回答:会,取决于你路由实现。push到新页面后旧页面还在栈里,状态不丢;但如果你用的是pushAndRemoveUntil、popUntil这种方式把旧页面移出栈,状态肯定丢。
批量卡片场景下的解法有三个:
- 使用
IndexedStack包住主页面的几个 Tab,切换 Tab 不销毁页面。 - 在卡片组件里混入
AutomaticKeepAliveClientMixin,配合PageView或TabBarView时能保证页面存活。 - 核心状态交给全局 Store,而不是页面内部。切换路由后,Store 还在,新页面初始化时重新读取任务列表即可。
我项目中批量队列用的是第三种方案,因为任务列表是真正的全局共享状态,不应该跟着某一个 Widget 的生死存亡走。同时,我给每个转换任务生成唯一id,原生侧在转换过程中也会持有这个 id,Flutter 重建页面后通过任务 id 重新订阅进度,而不是靠订阅时机去“碰运气”。这样即使整个队列卡片销毁重建,进度值恢复也不会乱。
批量列表还有一个性能细节:如果列表项里包含实时进度条,不能每 100 毫秒全量setState整个 ListView,否则低端设备上会明显掉帧。我的做法是把每项卡片设计成独立的StatefulWidget,用ValueChanged<double>或ListenableBuilder只更新对应项的进度条。列表框架本身用ListView.builder+ 固定 item 高度,不嵌套不撑开,滚动性能才能稳住。
3.5 可选延伸:与 OpenHarmony 桌面服务卡片联动
功能卡片组件做完以后,我们组还接了一个“桌面卡片显示转换进度”的需求。这里要泼一盆冷水:OpenHarmony 的桌面服务卡片(就是桌面上的小组件)是用 ArkTS/ArkUI 写的,Flutter 页面没法直接“生成”一张桌面服务卡片。Flutter 的flutter_form组件目前也没有官方 OpenHarmony 支持。
所以我能给的方案是数据联动:
- Flutter 侧在转换进度变化时,通过
Preferences或DataShare服务把进度值、任务状态写入共享存储。 - 桌面服务卡片(ArkTS 编写)监听共享存储数据变化,实时刷新进度条的
Text。 - 用户点击桌面卡片时,通过
Want拉起 App 的对应页面,利用route参数直接定位到队列卡片页面。
这套方案实现成本不高,但体验上比把整个 Flutter 页面嵌到桌面卡片里靠谱得多。如果后续 OpenHarmony 社区把flutter form的适配补齐了,那再考虑直接渲染 Flutter 到桌面卡片,目前不要硬上。
4. 避坑手册:Flutter for OpenHarmony 卡片开发常见问题与排查技巧
4.1 页面切换后卡片状态丢失,到底是哪一层的问题
现象:点击转换任务详情卡片,跳转到设置页面再返回,发现队列卡片的进度条回到了 0,或者文件选择卡片是空的。
排查顺序我建议从外到内:
- 先确认路由方式。是
Navigator.push还是IndexedStack切换?如果push之后旧页面在栈里,状态一般不会丢,丢掉说明你用错了路由 API。 - 再确认卡片组件是否被
dispose。可以在dispose里打个断点,切页后看有没有执行。 - 最后确认状态是放在全局还是放在 Widget 内部。放内部的,旧页面一旦销毁必然丢;放全局的,死活都在。
解决的关键是“状态归属设计”。批量队列的任务数据放全局 Store,文件列表可以放页面级State,进度动画值本质上是短暂瞬时值,丢失了也无所谓,恢复后重新 subscribe 即可。
4.2 EventChannel 和 MethodChannel 通信失败:首先要查通道名和注册时机
Listener 没有收到进度,invokeMethod直接抛PlatformException: channel not found,这类问题在 OpenHarmony 上非常常见。原因通常不是代码逻辑,而是原生侧的通道根本没有注册成功。
检查清单:
- 通道名两端必须完全一致,包括大小写和点号。我见过把
com.fileconverter/picker写错成com.fileconverter.picker的兄弟,排查了整整一天。 - 原生侧注册时机是否在 engine 加载完成后。不要放在
OnStart里太早执行,等 FlutterView 的attachToEngine回调触发后再注册。 - 多 engine 场景下选择错误。OpenHarmony 支持多 FlutterEngine,如果同一个通道被两个 engine 同时注册,后注册的会覆盖前面的。分析调度问题时注意确认当前绑定的是哪个 engine。
- 数据类型必须能序列化。Flutter 侧的
Map和原生侧的Record类型要兼容,如果原生侧返回了ArrayBuffer或Date这种无法跨通道的类型,通信会静默失败。
4.3 打包时报 AssertionError:could not close,其实是资源层问题
有段时间我们打包一直报:
java.lang.AssertionError: java.lang.exception: could not close ioutil...第一反应以为是 Flutter 编译缓存坏了,清掉.dart_tool和build目录后还是有。后来定位发现是原生侧ohos模块的资源文件里,某个图标文件被多模块重复引用,编译产物里出现同名资源冲突,输入输出流无法正常关闭。
解法很简单:检查ohos模块下resources/base/media和resources/rawfile有没有同名文件,确保模块内资源名唯一。另外,如果项目中同时引用了两个版本号接近的第三方依赖(比如两个都带common模块),也会出现类似问题,用hvigor编到assembleHap时查看冲突提示即可定位。
还有一个是 Gradle 层面的问题。OpenHarmony 的工程构建用的是hvigor,不是 Android 的 Gradle,但热词里“you are applying flutter's main gradle plugin imperatively”这种提示,通常是工程模板混用了两套构建工具导致。强烈建议 OpenHarmony 的工程不要照搬 Android 的settings.gradle配置,直接用 OpenHarmony 模板的hvigorfile.ts管构建。
4.4 Impeller 与渲染模糊:OpenHarmony 上默认用 Skia 更稳
Flutter 3.10 之后官方把 Impeller 作为 iOS 和 Android 的默认渲染引擎,但 OpenHarmony 的适配分支目前官方推荐依然以 Skia 为主。如果在 OpenHarmony 上启用 Impeller 后发现卡片渲染异常(比如阴影边缘发虚、圆角闪烁、文字出现残影),大概率是 Impeller 在当前分支上还不稳定。
解决方案是显式切换回 Skia,在flutter_engine启动参数里配置
--enable-impeller=false或者直接在你创建 FlutterView 的原生代码里设置FlutterShellArgs:
this.flutterEngine.getShellArgs().add('--enable-impeller=false')我实测下来 Skia 渲染虽然少了一些新特性,但稳定度远高于 Impeller,在 OpenHarmony 中低端设备上帧率反而更高。如果你在卡片里大量使用了ClipRRect、BoxShadow、Opacity这些组合,Skia 上的渲染性能依然在线。
另外提醒一下,OpenHarmony 上有部分设备屏显色彩空间不一致,会偶尔出现字体发虚的情况。这个和渲染引擎无关,多半是设备厂商没有正确上报物理像素密度。排查时先打印MediaQuery.DevicePixelRatio,如果超过 2.75,很多中低端设备会触发字体过小或者阴影过淡的问题。可以针对 DPR 做一个全局的字体缩放系数,把卡片标题和正文的字号微调一档,视觉效果会顺手很多。
4.5 平台插件适配鸿蒙流程:以 OKTA 为例说清第三方 SDK 接入
热词里有人问“flutter 平台插件 okta 适配鸿蒙流程”,这个问题其实非常有代表性。一个 Flutter 插件要在 OpenHarmony 上用,必须满足两个条件:第一,插件仓库里有ohos目录实现原生能力;第二,插件在pubspec.yaml里声明了 OpenHarmony 的平台支持。
以 OKTA 这样的登录认证 SDK 为例,标准适配步骤是:
- 检查插件仓库是否发布过 OpenHarmony 版本。很多老牌 Flutter 插件只维护了 Android/iOS,需要自己 fork。
- 在 fork 的仓库里新增
ohos目录,实现FlutterPlugin接口,把 OKTA 的 OpenHarmony SDK 包进来,并在onAttachToEngine里注册 MethodChannel。 - 项目工程里通过
pubspec.yaml的dependency_overrides指向你 fork 的仓库。 - 原生侧 OKTA SDK 的配置(如回调 scheme、登录页面参数)要放进
/ohos/module.json5的abilities配置里,否则登录回调拉起失败。
整体来说,Level 是“可用”,代价是每个插件都要过一遍原生适配流程。如果团队打算在 OpenHarmony 上认真落地 Flutter App,建议建一个“插件适配清单”,逐个确认核心插件(文件选择、相机、网络、存储、登录、推送)的 ohos 实现状态,而不是等做到某功能了再临时抱佛脚。
最后整理几句我的实际体会
功能卡片组件这套东西,做完以后回头看,真正的技术含量不在卡片 UI 有多好看,而在状态边界划得清不清楚、原生通道稳不稳、异常状态兜没兜住。我第一版把所有状态都怼在页面顶层,一个setState刷新全部卡片,低端设备上卡成 PPT;第二版组件化以后,进度卡和文件卡彻底解耦,代码量少了三分之一,问题排查速度快了不止一倍。
如果你们团队也准备在 OpenHarmony 上用 Flutter 做 App,我建议开工前先花两天时间做两件事:把需要原生能力的功能列成一张通道清单,把卡片组件按“状态归属”分好类。这两件事做完了,后续开发基本不需要返工。
一个小技巧送给大家:给所有 EventChannel 的监听增加一层“带记忆的恢复机制”。原生侧推上来的进度如果因为没有监听者而丢失,等 Flutter 侧重建页面重新订阅时,先主动向原生侧发送一次“拉起当前状态”的调用,把最后的状态再推一遍。这一条在花椒群里跟人聊,几乎没人一开始就想到,但卡状态丢失的问题百分之八十都是靠它解决的。