ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter在OpenHarmony上实现工具卡片组件:事件通信与状态管理实战

Flutter在OpenHarmony上实现工具卡片组件:事件通信与状态管理实战 Flutter 在 OpenHarmony 上跑起来之后我第一个正经做的模块就是这个文件转换助手里的“工具卡片组件”。一开始以为只是个带图标和名称的宫格入口结果做着做着发现它几乎串起了整个项目的架构——从跨端通信、状态管理、原生视图嵌入到列表性能优化全都在这个组件里过了一遍。这篇文章不聊虚的直接说实操我为什么要用 Flutter 做 OpenHarmony 应用工具卡片组件的信息架构怎么理卡片上的转换进度怎么实时刷新不丢状态Flutter 和 OpenHarmony 原生侧怎么用 EventChannel 打通以及我在这个过程中踩过的坑和最后怎么解的。1. 项目逻辑与工具卡片的定位分析1.1 为什么用 Flutter 做 OpenHarmony 应用这个项目的核心目标用户同时拥有不同的终端设备考虑到不想为 OpenHarmony 单独维护一套原生业务代码Flutter 是当前稳妥的跨端方案。OpenHarmony 的官方开发语言是 ArkTS / ArkUI但社区已有 Flutter 对 OpenHarmony 的适配分支这就意味着我可以复用大量 Dart 业务代码只在涉及系统能力的地方写原生桥接。比起完全重写成 ArkUIFlutter 方案的 UI 一致性更好而且热重载对 UI 迭代效率有明显提升。实际上 Flutter 在 OpenHarmony 上并不是直接用 Google 官方主分支的 SDK而是需要使用适配了 OpenHarmony 能力的 Flutter 引擎版本。项目配置上要改pubspec.yaml里对 SDK 的依赖同时 native 侧要把 flutter 的 engine 库替换成支持 OHOS 的预编译产物。刚开始不熟悉这套流程的话会觉得有点绕但跑通一次之后后面的开发体验和 Android/iOS 上基本没有区别。1.2 文件转换助手到底做什么这个 App 的基本功能是文件格式转换图片格式互转、音频格式转码、视频格式压缩转码、PDF 提取文字或转图片。核心流程是用户选择文件选择目标格式然后等待转换完成。转换过程可能在 App 内完成也可能交给系统原生能力处理这里就要考虑 Flutter 侧与 OpenHarmony 系统引擎的协作。功能拆开后整体模块大致分成三类文件选择与存储权限模块负责访问本地媒体库和文件路径映射转换引擎模块承载图片、音视频、文档三类转换逻辑任务管理模块负责展示转换列表、进度更新、结果通知和工具入口。工具卡片组件属于任务管理模块的一部分但它比一个普通列表复杂很多它既是入口也是状态展示既要展示图标和说明文字还要承载当前任务的实时进度和操作反馈。所以我把它单独抽出来设计本质上是一个自带状态机、通信能力和 UI 展示的组合组件。1.3 工具卡片组件在项目里承担了哪些责任工具卡片组件不是简单的按钮网格。它要同时做这几件事作为一组工具的展示入口用户一眼能看出这个工具有什么用作为当前转换任务的状态节点卡片需要呈现等待、执行中、已完成、失败几种状态并实时展示进度百分比和文件体积变化作为用户操作后的反馈区域比如点击转换、取消任务、打开转换结果都需要在卡片内完成交互闭环。这意味着卡片不能只做静态 UI它必须能够感知到转换任务的进度变化并且把这些变化反映到界面上。在 Flutter 里实现这个效果并不复杂但如果设计得不好后面接了原生事件流之后就会出现各种状态不同步的问题比如进度跳变、卡片刷新频繁导致列表卡顿、动画和真实进度互相打架等。1.4 为什么要把卡片组件化而不是写死布局我最开始其实是把工具卡片写死的每个卡片一个独立 Widget里面堆了各自的逻辑。结果第一版联调的时候就发现三个卡片对应三类转换任务其实是同一个模型的三种形态代码里有大量重复的状态判断和布局代码。后来痛定思痛把卡片抽成了统一的ToolCardWidget用外部传入的数据模型驱动渲染。组件化的价值在于当一个新的转换任务被创建时我只需要往任务列表里插一条模型数据列表对应位置的卡片就自动进入转换状态不需要单独去操作某个卡片实例。卡片内部再按状态拆出不同的子组件比如CardIdleView、CardProgressView、CardResultView用统一的容器承载公共样式。后期如果要新增一个 OCR 工具只要定义好它的模型字段和转换动作然后把数据和列表绑定起来UI 侧几乎不用动。2. 工具卡片组件的信息架构与 UI 设计2.1 卡片上到底放哪些信息工具卡片的信息量要控制在“扫一眼就能获取”的程度。第一版我在卡片上放了工具名称、描述、图标、转换进度、当前阶段、耗时、原文件体积、目标体积、操作按钮结果整个卡片高度超过 200 像素在列表里显得特别笨重。后来砍掉了一些非关键字段卡片最终只保留六个核心信息区域工具图标用于快速识别工具类型工具名称和目标格式说明比如“PDF 转 Word”当前状态标识比如“待转换”“转换中”“已完成”转换进度条和百分比数字关键操作按钮不同状态下按钮文案不同文件体积变化提示两张卡片布局时节省空间。布局上用单列卡片左区域放图标和信息右区域放按钮和进度。进度条的容器高度固定这样即使在状态切换过程中卡片高度也不会突然变化列表整体的滚动位置就不会跳动。这个细节对用户体验影响很大强烈建议做的时候注意。卡片还会根据不同状态改变背景色和描边色。待转换状态我用的是浅灰背景 主题色描边转换中状态会加上一条薄薄的渐变进度条背景完成状态则整体换成浅绿底。状态的视觉区分很重要尤其是当一张屏幕上同时有好几个卡片的时候用户必须靠颜色和文案快速锁定处于转换中的那一张。2.2 转换任务的状态机设计卡片能不能稳定展示核心取决于任务状态机的设计。我一开始是直接在代码里根据模型字段瞎判断的比如progress 0 progress 100就认为是转换中后来发现这套判断在失败、暂停、完成这些边界场景下到处都是 bug。最后我梳理出一套适合文件转换场景的状态枚举总共七种状态含义卡片表现idle初始待机显示工具说明和开始按钮preparing正在准备文件转圈动画 “正在准备…”converting正在转换进度条 百分比 取消按钮paused暂时停止进度条冻结 “继续”按钮completed转换完成绿色底 “查看结果”按钮failed转换失败红底 错误提示 “重试”按钮canceled用户取消回到 idle 文案这七个状态其实可以归类成三大类空闲状态、进行中状态、终态。其中preparing和converting都属于进行中但用户看到的表现不同。转换中要展示实时进度准备文件阶段没有准确进度所以用循环动画而不是线性进度条。这个设计很重要因为如果做转换的引擎初始化要好几秒在那一小段时间里强制给一个假的线性进度反而会显得特别不真实。到终态之后卡片的操作按钮会变成“查看结果”或“重试”。为了让用户有一个清晰的反馈闭环卡片在进入终态后还会做一次轻微的回弹动画同时状态文字颜色从主题色切换成明确的完成或错误色。这套状态机在后来接入 EventChannel 的时候帮了大忙因为原生侧发过来的事件很容易映射到这七个状态里。2.3 状态管理和数据流是怎么设计的状态管理我直接用了一套轻量的编码习惯方案没有引入特别重的状态管理框架。原因是这个项目的卡片列表规模不大大多数状态其实集中在单张卡片内部用一个全局 Store 反而要多写很多模板代码。这里并不是不推荐用 Provider / Riverpod而是在文件转换这个场景下任务数据本身由原生引擎持有Flutter 侧只是渲染原生层反馈过来的状态变化所以用一个可监听的任务模型列表就够了。实际的代码结构是这样一个ConversionTask数据模型保存文件路径、目标格式、状态枚举、进度值、耗时等字段一个TaskListModel继承ChangeNotifier持有ListConversionTask提供增删改查卡片列表页面通过AnimatedBuilder监听TaskListModel的变化每个卡片自行根据ConversionTask的状态渲染不同的 UI 子组件。这套设计下卡片组件本身完全无状态化它只负责把传入的task渲染成界面。原生侧事件过来后第一步是更新对应的ConversionTask字段第二步是调用notifyListeners()这样所有绑定了这个任务的卡片都会自动刷新。这个模式是我在多个项目里反复验证过简单且不容易出错。有一个容易被忽略的点卡片上的动画不要直接依赖状态值变化来隐式驱动比如进度条动画如果每次从原生通道收到进度就立刻跳到新的进度值视觉上会非常生硬。我在卡片里用TweenAnimationBuilder或AnimationController对进度做了一次平滑过渡一般过渡时间是 300ms 左右。原生一秒钟推好几次进度的情况下动画也不会显得特别跳。2.4 卡片里的动画细节值得认真打磨卡片在所有状态切换的时候我统一用了 200ms 的淡入淡出加 8dp 的位移动画。这样处理的原因很简单OpenHarmony 上的 Flutter 动画引擎目前跑得没问题但过度复杂的动画如果同时出现在多张卡片上帧率很容易掉。所以在设计动画时就要克制只对状态切换和进度条做动画常规的组件出现和消失不做额外的缩放弹性动画。进度条动画选择了TweenAnimationBuilder因为它不需要手动管理AnimationController的生命周期——卡片销毁时控制器可能会泄漏但TweenAnimationBuilder是随组件生命周期的天然安全。进度条的颜色分三段0% 到 30% 是浅色进度30% 到 80% 是主题色80% 以上用高亮色视觉暗示转换进入收尾阶段。这个细节是后来看用户反馈加上的测试中发现低于 60% 的时候用户普遍觉得转换太慢加了颜色分段之后至少能让人感知“快了”。3. Flutter 与 OpenHarmony 原生侧的事件通信实战3.1 为什么要用 EventChannel 而不是 MethodChannel工具卡片上的转换进度需要实时更新如果用 MethodChannel 由 Flutter 侧主动轮询原生侧每隔几百毫秒发起一次方法调用去问“进度多少了”实现上可行但有两个问题一是会造成不必要的 IPC 开销二是进度到达的实时性取决于轮询频率轮询快了费性能慢了又觉得卡顿。EventChannel 是推送模式原生侧主动把事件流发给 Flutter 侧Flutter 侧只需要订阅一次之后所有的事件更新都由原生侧在合适的时机推过来。两边的模型是完全异步的不会阻塞 UI 线程。这个模式用来做转换进度事件流再合适不过。在文件转换这个场景里原生侧可能同时跑着多个转换任务每个事件都会带上任务 IDFlutter 侧收到事件后按任务 ID 找对应的ConversionTask再更新卡片状态。事件流的定义主要做了三种事件进度事件、状态切换事件、结果事件。进度事件里包含当前任务 id、当前状态、已完成百分比。状态切换事件其实可以并入进度事件我分开原因是为了少传重复数据。注意注册 EventChannel 时channel name 必须是唯一的不要和别的插件重名。我当时有个 channel 名字写得太泛叫flutter/engine结果和 SDK 内部 channel 冲突调试了半天才发现问题。3.2 Dart 侧的订阅链路是怎么搭的Dart 侧的事件接收很简单核心就是用EventChannel.receiveBroadcastStream()返回的Stream去侦听。具体代码片段在项目里是这样的static const _eventChannel EventChannel(com.example.fileconverter/events); Streamdynamic? _stream; void subscribeTaskEvents() { _stream ?? _eventChannel.receiveBroadcastStream(); _stream?.listen((event) { final data MapString, dynamic.from(event as Map); final taskId data[taskId] as String; final task taskModel.findTask(taskId); if (task null) return; if (data[type] progress) { task.progress (data[value] as num).toDouble(); task.status ConversionStatus.converting; taskModel.notifyListeners(); } else if (data[type] statusChange) { _handleStatusChange(task, data[status] as String); } else if (data[type] result) { _handleResult(task, data); } }); }订阅代码写在任务管理页面的初始化方法里。因为 EventChannel 一般是全局唯一的所以页面销毁时不需要取消订阅但如果你的页面做了反复的初始化就要防止重复订阅。我加了一个_stream空判断只订阅一次后续复用同一个流。数据解析那一行的强制类型转换是需要注意的地方因为 OpenHarmony 原生侧传过来的 Map 的 key 默认是 String但 value 的类型在不同系统版本上可能不一样比如 int 和 double 的混用。为了稳妥拿到数据后统一做一次num到double的转换避免空安全和类型不匹配导致崩溃。3.3 OpenHarmony 原生侧的实现OpenHarmony 侧的代码需要基于 OpenHarmony Flutter SDK 的能力来注册 EventChannel。这个过程和 Android 原生插件开发类似但语言是用 ArkTS。原生侧需要继承 FlutterPlugin 基类然后在onAttachToEngine方法里创建EventChannel同时实现StreamHandler接口。当转换任务开始执行时原生侧会启动一个任务管理器去跟踪转换过程的每个阶段。每当进度更新时调用sink.success()将事件发往 Flutter 侧。需要注意sink只能在成功注册并且当前有听众的情况下使用如果在 Flutter 侧还没有订阅事件的时候就调用 success可能不会产生任何效果甚至会有异常提示。这里要用到EventSink的回调机制原生侧在onListen回调里拿到EventSink对象保存为成员变量在onCancel回调里把成员变量置空。每次转换任务更新时判断 sink 是否为空不为空才发送事件。大致逻辑如下用伪代码表示class ConversionStreamHandler implements EventStreamHandler { private eventSink: EventSink? null; onListen(arguments: any, sink: EventSink): void { this.eventSink sink; // 如果有当前正在执行中的任务可以先把一次快照状态推给 Flutter 侧 } onCancel(arguments: any): void { this.eventSink null; } sendProgress(taskId: string, progress: number) { if (this.eventSink ! null) { this.eventSink.success({ type: progress, taskId: taskId, value: progress }); } } }一个比较关键的细节是EventChannel 的sink.success()不能保证事件一定按顺序到达 Flutter 侧。实际测试中如果进度事件发送太频繁少数低端设备上会出现后发先至的情况表现在 UI 上就是进度条倒着走。解决方法是原生侧做一次简单的事件节流比如同一任务的进度更新至少间隔 80ms 再发一次同时在事件里带上timestampFlutter 侧解析到更大的时间戳之后才更新 UI小的时间戳直接忽略。3.4 工具卡片唤起原生转换引擎的完整链路整个链路在代码里是这样的用户点击工具卡片上的开始转换按钮Flutter 侧用 MethodChannel 向原生侧发起“启动转换任务”的调用。原生侧拿到文件路径和目标格式参数后创建任务立即返回一个 task id。然后原生侧开始执行实际的转换工作。转换工作可能涉及系统的媒体处理库或文档处理服务这个执行过程需要单独启动线程不能放在主线程。任务线程每完成一步就通过 EventChannel 推到 Flutter 侧。Flutter 侧收到事件后更新ConversionTask卡片随之切换进度。当转换结束后原生侧除了推送完成状态还会把输出文件路径保存进result事件Flutter 侧拿到后把输出路径绑定到卡片上用户点击“查看结果”时直接可以用系统文件预览能力打开。这个链路结构稳定因为整个过程是单向数据流用户交互通过 MethodChannel 原生侧原生状态更新通过 EventChannel 回传 Flutter。回调通路一定要有超时和异常兜底。我在原生侧对跨线程任务做了失败监听一旦转换引擎返回错误码立刻通过事件通道推送一个failed状态和错误描述。Flutter 侧不再额外判断而是完全依赖事件来驱动 UI。这套设计让状态展示的职责完全归到 UI 侧逻辑不纠缠在一起。4. 工具卡片组件在 OpenHarmony 上的渲染与性能问题4.1 Impeller 渲染引擎在卡片动画上的表现OpenHarmony 适配的 Flutter 版本已经支持 Impeller 渲染引擎这个在我的实际体验中是一个很大的加分项。之前用 Skia 时卡片上的进度条动画在快速滚动时偶尔会有撕裂感换成 Impeller 后整体渲染明显更平滑特别是在带有半透明背景和多重圆角阴影效果的卡片上。不过 Impeller 对部分 API 的支持度还在完善中。我在卡片上用到了AnimatedContainer的渐变背景切换在切换过程中发现颜色过渡的中间态比 Skia 下略显生硬。排查后确认不是代码问题而是 Impeller 对 shader 编译做了缓存优化初次动画会有一次短暂的编译卡顿。处理方式是尽量减少动画过程中新建 shader动画用的渐变背景提前定义好不要边动画边创建新的渐变对象。卡片数量超过六张且每张都带阴影时Impeller 的 shadow 渲染开销会比预想高一些。优化方案是给卡片设置了固定的 elevation 和 shape 属性而不是直接在外层套一整个Container的boxShadow。固定形状的 elevation 在渲染时可以被引擎缓存和复用不定高度的 boxShadow 则很难复用。4.2 列表滚动卡顿与重建问题工具卡片最终放在一个ListView里之前会出现滚动时卡片状态闪一下的情况原因是列表在滚动过程中重新 build 了卡片组件。最有效的解决办法是保证列表项具备不可变属性和稳定的 key。每个ConversionTask都有唯一 task id列表项的 key 直接设为 ValueKey(taskId)这样 Flutter 就能准确复用 element不会因为 index 变化而重建整个卡片状态。这里注意不要在 build 方法里频繁创建新的TextStyle或BoxDecoration对象。把这些样式定义成卡片类的静态常量或顶层级常量每次 build 时复用同一个对象。Dart 的对象创建是有开销的高频 build 时尤其明显把这些地方抠一抠滚动帧率会有实质提升。卡片内的RepaintBoundary也很关键。我把每张卡片包在RepaintBoundary里这样卡片的内容变化时只会触发该卡片自身的重绘层重建不会波及整个列表。结合AnimatedBuilder局部监听列表滚动时只有正在更新的卡片会重绘其他卡片的渲染图层完全不动。4.3 大文件转换下的内存把控文件转换本身发生在原生侧但 Flutter 侧如果有预览功能比如转换前展示一张很大的原图或者转换后预览一个几百 MB 的视频文件内存会直接飙升。我在卡片的结果页里加了缩略图而不是原图加载图片预览用ResizeImage重新采样限制解码后的图片尺寸。视频预览则取首帧作为封面展示实际点击才进入播放不提前加载完整视频文件。在 OpenHarmony 设备上内存限制相对严格Flutter 侧如果不注意图片加载策略OOM 的风险比 Android 上更高。转换任务列表一旦超过十个卡片全部展开的话内存占用相当可观。我的策略是列表中只保留当前可展示区域的卡片是活跃构建的离开可视区域的卡片自动卸载进度数据继续存在于模型模型中重新滑入时再重新渲染不丢失状态。这个能力得益于ListView.builder的懒加载机制。连续转换多个文件时当前展示的进度由模型数据驱动卡片偶发离开 viewport 再回来进度条会更新到最终值而不是重新从 0 开始这一点在设计状态机时就保证了。4.4 卡片状态丢失问题的排查实际测试中遇到过一个诡异问题转换过程中锁屏再解锁回来发现卡片上的进度条变成初始状态但任务还在后台跑着。排查下来原因是页面在锁屏期间被系统回收再次进入时重新创建了页面状态而原生侧的事件通道在页面重建期间没有重新订阅导致后续进度事件全部丢失。这个问题绕了一点远路最后在两个层面解决页面恢复时重新执行一次原生侧的“获取当前任务快照”方法通过 MethodChannel 拉取所有转换中任务的进度和状态一次补齐卡片EventChannel 的订阅时机提前到应用启动时放在全局单例里而不是放在页面 destroy 再重建的流程里。这两个叠加基本可以保证事件通道永远在线锁屏、切后台、切换页面都不断流。状态恢复快照方法要注意导出的数据格式必须和事件格式保持一致这样 Flutter 侧在处理时不用区分数据来源统一走同一个模型更新方法。5. 实战常见问题与排查技巧实录5.1 转换进度卡在 99% 不动这是我在联调测试时遇到的第一个实际问题。文件转换明明已经完成了原生侧的线程也已经返回了 success但 UI 上的进度条一直停在 99%。排查后确认是原生侧发送进度事件的顺序问题最后一条真实进度是 99%完成后又发送了一个完成事件但完成事件里的进度值没带上 100Flutter 侧的状态机因为progress 100就认为任务还在转换中。解决方案是状态机里加了一个强制规则一旦收到completed状态进度值强制设置为 100无论事件里带的值是多少。原生侧的事件格式也做了规范状态切换事件必须携带最终进度缺省的情况下 Flutter 侧完成状态默认填 100%。这条规则后续也适用于失败情况失败状态强制进度值保留在失败前的实际值而不是回跳。5.2 原生侧事件推得太频繁导致 UI 卡顿转换引擎在快速处理阶段可能每秒推送三四十次进度事件。这些事件如果都直接触发 Flutter 重建 UI性能损耗会直接体现在帧率上。我在 EventChannel 的对外接口里加了一个统一的节流函数同一任务 id 的事件距上次发送少于 60ms 的直接合并丢弃。这样 UI 侧最多每秒收到十五六个进度事件人眼看起来依然是顺滑的但渲染开销已经大幅降低。同时 Flutter 侧的进度更新也做合并处理模型只记录收到的最新值进度条动画用 300ms 的缓动来过渡。即使事件源本身有节流双端双重保护任何一端出现节奏不稳定的情况另一端都能兜住。5.3 多任务并发时事件顺序混乱出现过两个转换任务同时完成时卡片 A 的状态显示到了卡片 B 上。原因是事件通道的原生侧实现里sink 在多个任务线程同时触发时没有做线程同步两个任务的完成事件交错输出Flutter 侧在极端时序下读到了错位的任务 id。修复办法是给原生侧的事件发送统一加了一个串行队列所有任务的进度更新和状态更新都进入队列按顺序发送。这不是多此一举跨线程共享资源如果不加锁迟早会出现偶发问题。5.4 文件路径映射问题OpenHarmony 上媒体文件的路径规则和 Android 不完全一样原生侧拿到的可能是基于沙箱的相对路径而 Flutter 侧如果用这个路径去读取文件就会失败。我在卡片打开文件结果之前会把原生返回的路径再做一次合法化校验先判断文件是否存在不存在则尝试通过 MediaLibrary 接口解析真实路径。这个步骤多一次 IO 判断但在兼容不同设备上效果差异明显。5.5 一个冷门但重要的组件复用技巧工具卡片组件需要支持不同宽度下的自适应布局。在手机上是单列卡片在平板或折叠屏上可能是双列或三列。我并不想为不同尺寸单独写几套布局所以在卡片的宽高比上做了一个约束卡片之间用 GridView 布局时每个卡片的 childAspectRatio 固定为 2.4 左右这样卡片高度会随列宽自动收缩文字和按钮在限高内自动换行并压缩间距。实测下来在横屏和折叠屏场景下表现都很自然不用维护多套 UI。这个技巧来自一个教训一开始我用的是固定高度容器平板上一列三个卡片高度就变得很怪。改用 childAspectRatio 之后卡片看起来就像长在列表容器里一样自然。卡片内部用了LayoutBuilder根据宽度动态调整字体大小宽度小于 280dp 时简略掉描述文案只保留图标和名称大于 360dp 时展示全部信息。组件本身完全响应式放到任何容器里都能自适应。6. 后续可以扩展的方向与个人心得卡片组件现在最稳定的一套方案是“模型状态驱动 EventChannel 事件流 响应式布局”。但我实际做下来最耗时间的地方反而不是代码部分而是调试设备上的真机效果。OpenHarmony 的设备种类不多但不同屏幕比例下的表现差异很大尤其是横屏和折叠屏卡片上文字的间距和图标大小在不同 dp 下会显得很不协调。建议做的时候多拿几个不同尺寸的设备去验证布局断点。后面如果再迭代我想给卡片增加一个拖拽排序和自定义工具显隐的功能。这样用户在主页面上就能管理自己常用的转换工具不用的卡片隐藏起来列表会更精简。这个功能在模型设计上并不困难只要在工具的模型上加一个 enable 字段列表里过滤掉 disabled 的工具即可。难的地方在于拖拽排序手势要处理多指冲突需要给列表整体加上手势仲裁让横向滚动和纵向拖拽能同时生效。还有一个想做的点是用卡片记录用户历史转换配置比如上次把 PDF 转成 Word 时选的是高质量模式下次点同一张卡片就直接用上次的配置启动用户省一次点击。这类细节看似无用但对日活留存其实影响很大。工具卡片组件在功能上已经稳定了但离“好用”还有很长的路要走。这套代码从最初的写死布局到最终组件化调试过程中积累的这些状态管理、事件通道、渲染优化经验对任何想在 OpenHarmony 上用 Flutter 做项目的开发者来说都值得提前了解。
返回列表