ARTICLE DETAIL

资讯详情

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

OpenHarmony上的Flutter健康仪表盘:EventChannel与CustomPaint实践

OpenHarmony上的Flutter健康仪表盘:EventChannel与CustomPaint实践 去年有一个健康类 Flutter 应用要迁移到 OpenHarmony 设备上核心页面是一块每天要看很多次的健康仪表盘。Flutter OpenHarmony 这个组合目前还没有特别完善的官方文档社区里维护的 flutter_flutter 分支配合 DevEco Studio 的链路我踩了不少坑也理顺了。这篇就把生活助手 App 里健康仪表盘的完整实现思路拆开写清楚涵盖原生健康数据的 EventChannel 推送、Cubit 状态管理、CustomPaint 自绘仪表盘以及渲染性能调优给正在做 Flutter for OpenHarmony 落地、尤其是仪表盘这类复杂 UI 的朋友一份可以照着做的实战笔记。1. 项目定位与前期方案选型1.1 为什么在 OpenHarmony 上用 Flutter 做健康仪表盘先回答一个很多人会问的问题OpenHarmony 官方推荐应用开发用 ArkTS 和 ArkUI为什么还要折腾 Flutter我在项目里做技术选型时主要看中了三点。第一是跨端业务复用。健康类 App 往往同时有 Android、iOS 版本如果 OpenHarmony 版本也用 Flutter那么 Dart 层的 UI、状态管理、数据模型、工具函数可以直接复用。仪表盘的 Canvas 绘制代码写一次多个平台都能跑这是原生 ArkUI 做不到的。第二是自定义 UI 能力强。健康仪表盘需要画环形进度、折线趋势图、渐变区域Flutter 的 CustomPaint 在自绘方面足够灵活不用被组件库的既有能力绑住。第三是团队技能栈。团队本身有 Flutter 基础与其重新学一套 ArkTS 开发范式不如把成本花在打通 OpenHarmony 的 Flutter 适配链路上。当然也要泼一盆冷水OpenHarmony 的 Flutter 适配目前不是官方主推路线而是社区 SIG 维护的分支。版本更新有一定滞后三方插件生态也不如 Android 成熟。如果项目的核心诉求是快速做一个 OpenHarmony 原生体验标杆ArkUI 更稳如果追求跨端复用和 UI 自绘效率Flutter 值得考虑。下面这张表是我选型时给自己列的对比。对比维度Flutter for OpenHarmonyArkUI 原生方案跨端代码复用高Dart 层通用低需要单独维护自定义仪表盘 UICustomPaint 灵活自绘Canvas 组件也可实现但生态成熟度一般团队学习成本需补充 OpenHarmony 平台侧知识需要完整掌握 ArkTS 范型范式三方插件支持逐步完善中官方组件相对齐全版本跟进速度社区分支滞后于 Flutter 主线跟随系统版本节奏1.2 健康仪表盘的功能切分仪表盘不是一张静态图它是一个信息聚合页。我在项目里把它拆成了四块今日概览、目标进度、趋势图、状态反馈。每一块的数据来源、刷新频率和交互方式都不一样所以不能只用一个页面状态硬扛。今日概览卡片展示步数、卡路里、活动时长和最近心率数据来自原生侧的传感器或健康服务刷新频率要求最高步数几乎是实时的。目标进度用环形进度条展示比如每日 8000 步目标完成了多少这里适合用动画过渡数据更新时不需要整页刷新。趋势图展示最近 24 小时或最近 7 天的心率变化需要把离散的采样点映射成坐标再画成平滑曲线。状态反馈包含加载中、错误、无数据三种情况比如设备未连接时心率区域要显示“暂无可展示数据”。我把这些功能点整理成了一个表格后续开发就按这个列表逐项实现。功能模块数据来源刷新策略UI 形态今日概览原生健康服务聚合进入页面拉取 定时刷新数字卡片目标进度原生步数传感器事件驱动实时推送环形进度 CustomPaint心率趋势原生健康记录页面可见期间订阅流折线图 CustomPaint状态反馈数据链路状态通信异常时触发占位图 重试按钮1.3 整体分层架构设计健康仪表盘看起来只是一个页面背后却有完整的链路OpenHarmony 原生侧负责采集健康数据通过 Channel 与 Flutter 通信Dart 侧分为数据仓库层和状态管理层UI 层只负责把状态渲染成仪表盘。我在项目里严格分了四层原生壳层、通信层、状态层、UI 层。原生壳层是 DevEco 工程里的 ohos 模块负责传感器注册、权限申请、数据聚合不直接参与 UI 渲染。通信层用 EventChannel 做实时数据推送用 MethodChannel 做按需查询数据格式统一成 JSON 兼容的基本类型。状态层用 Cubit 管理把原生数据转换为 UI 需要的状态比如把步数原始值转换为进度百分比。UI 层只接收状态对象通过 CustomPaint 把数据画出来。这样的分层带来的直接好处是后续如果要从 OpenHarmony 扩展到 Android或者反过来只需要替换原生壳层和通信实现Dart 层的状态管理和 UI 绘制代码都不用动。我在实际开发中深刻体会到分层在跨平台项目里不是过度设计而是避免后期失控的保命设计。2. 环境搭建与 OpenHarmony 工程初始化2.1 准备 OpenHarmony 专用的 Flutter SDK第一步是拿到正确的 Flutter SDK。官方 flutter 仓库默认只支持 Android、iOS、Web、Windows、macOS、LinuxOpenHarmony 需要社区维护的 flutter_flutter 分支。项目里用的是 openharmony-sig 下的 flutter_flutter仓库地址在 gitee 上不要用系统里已经装好的官方 Flutter否则flutter create生成的工程里不会有 ohos 平台目录。我当时的安装步骤很简单先 clone 分支然后把 SDK 的 bin 目录加入 PATH并且确认flutter --version输出的是 OpenHarmony 分支的版本号。git clone -b master https://gitee.com/openharmony-sig/flutter_flutter.git cd flutter_flutter export PATH$PWD/bin:$PATH flutter --version这里有一个必须注意的细节flutter_flutter 分支的版本需要和 OpenHarmony SDK 版本匹配。早期我随便拉了一个新分支结果 DevEco Studio 里同步工程时报了一堆编译错误后来才发现是 API Level 对不上。建议动手之前先看仓库 README 里标注的版本矩阵确认 flutter_flutter 分支、OpenHarmony SDK、DevEco Studio 三者之间的兼容关系。这个版本的坑比写 Dart 代码的坑更难排查因为报错信息通常指向底层编译产物而不是版本。另外OpenHarmony 侧依赖下载源和 pub 源最好在开始时就配好。国内网络环境下pub 源不切换会非常慢我一般配置一个可用的镜像环境变量这样flutter pub get拉取依赖时能省下大量时间。2.2 生成 ohos 平台壳工程并接入 DevEco StudioSDK 准备好后我在已有 Flutter 工程根目录执行了下面的命令让 Flutter 脚手架生成 OpenHarmony 平台壳工程。flutter create --platforms ohos --org com.example --project-name health_dashboard .如果当前 flutter_flutter 分支支持 ohos 平台会在工程目录下多出一个ohos文件夹。这个文件夹本质上是 OpenHarmony 的 HAP 工程骨架里面包含 entry 模块、hvigor 构建配置、module.json5 等文件。如果命令执行后提示不支持 ohos不要慌多半是分支版本问题或平台参数不一致可以先执行flutter create --help查看当前 SDK 支持哪些平台避免盲目拼参数。接下来用 DevEco Studio 打开工程。官方推荐的做法是直接打开整个 Flutter 工程根目录IDE 能识别到 Flutter 插件和 ohos 工程但我实际项目里为了减少噪音更喜欢只打开ohos目录做原生侧调试。第一次打开时 hvigor 会自动同步依赖这一步耗时较长而且经常因为网络问题失败建议先在 DevEco 的设置里确认 SDK 路径和 hvigor 版本是正确的。签名配置也是一定要做的。OpenHarmony 应用在真机上运行需要签名调试阶段用 DevEco 的自动签名即可具体操作是在 File Project Structure 里勾选自动签名并登录华为账号。这一步不做的话构建出的 HAP 无法安装到真机报错信息通常是 install 失败或签名校验失败。整个初始化过程里我踩得最重的坑是 Flutter 侧代码改动后没有重新构建。OpenHarmony 工程里的 Flutter 产物是要单独生成的并不是 DevEco 每次编译都会自动拉取最新 Dart 代码。所以在调 UI 的时候正确的流程是改完 Dart 代码回到命令行执行 Flutter 构建命令再在 DevEco 里打包或者直接通过调试模式运行。3. 健康数据从原生侧到 Flutter 侧的通信链路3.1 用 EventChannel 做实时数据推送健康仪表盘的数据多数是实时变化的步数在走心率在跳这种场景适合 EventChannel。EventChannel 是 Flutter 提供的一种通信方式方向固定为原生到 Flutter原生侧可以持续不断地向 Dart 侧发送数据流Dart 侧通过监听流来接收。它的语义比 MethodChannel 更适合“事件流”MethodChannel 是请求-响应模式每次都要主动调用。我的原生侧实现思路是这样的OpenHarmony 壳工程里有一个专门负责健康数据的模块它注册传感器监听当步数变化时把数据包装成 JSON通过 EventChannel 推给 Flutter。下面是一段简化后的 ArkTS 示意代码实际类名以当前 flutter_flutter 分支的 SDK 导出为准。// ArkTS 原生侧示意代码具体 API 以当前 SDK 版本为准 import { EventChannel } from ohos/flutter_ohos; const channel new EventChannel(engine, com.example.health/step_count); channel.setStreamHandler({ onListen: (arguments, events) { // 注册传感器监听每次步数变化时 // events.success({ step: 1024, target: 8000 }); }, onCancel: (arguments) { // 注销传感器监听避免后台空耗电 } });Flutter 侧只需要在初始化时监听同一个 channel name。这里最需要注意的是 channel name 两端必须完全一致大小写、分隔符都不能错。我在项目里把这些 name 集中放到一个常量类里原生侧和 Flutter 侧共同引用同一份协议文件从源头上杜绝了拼写错误。const _stepEventChannel EventChannel(com.example.health/step_count);接收数据时我从 event 中取出步数值交给数据仓库层做一次格式转换再推给状态层。这里不建议直接在监听回调里做 UI 更新因为流回调可能非常频繁一秒钟几十次直接 setState 会很快把页面卡死。3.2 用 MethodChannel 做按需查询EventChannel 适合高频推送但像“进入页面拉取今日汇总”这种一次性查询用 MethodChannel 更合适。MethodChannel 是 Flutter 和原生之间的双向调用通道Dart 侧发起 invokeMethod原生侧处理后返回结果。它适合低频、需要明确回复的场景。我在项目里定义了一个summary通道专门用来查询当天健康数据汇总。final summary await MethodChannel(com.example.health/summary) .invokeMethodMap(getTodaySummary);原生侧对应的处理逻辑会在setMethodCallHandler里对 method name 做分发。这里有一个经验返回的数据尽量用 Map、List、String、num 这些可序列化基本类型不要传自定义对象。跨 Channel 通信本质上是在做序列化和反序列化自定义对象会增加协议复杂度而且不利于 Debug 时抓包查看数据内容。MethodChannel 还有一个隐蔽的坑如果调用期间原生侧抛了异常Dart 侧会收到 PlatformException。我在封装 Repository 方法时统一把 PlatformException catch 住转换成业务层错误对象避免异常直接冒泡到 UI 层导致 RedScreen。做健康仪表盘时设备断开、权限拒绝属于正常状态UI 必须能响应这类异常而不是直接崩溃。3.3 用 Cubit 管理页面状态状态管理层我选了 Cubit而不是 Bloc。健康仪表盘的状态流程并不复杂无非是加载中、有数据、无数据这三种Cubit 的模板代码更少团队上手成本低。Cubit 本质上是一个可观察的状态容器通过emit向监听者推送新状态UI 通过 BlocBuilder 或 BlocSelector 响应变化。我定义了一个HealthState作为页面状态对象它聚合了四块数据概览数据、步数进度、心率趋势、加载状态。对应的 Cubit 提供refresh、subscribe、retry三个主要方法。refresh负责从 Repository 拉取今日汇总成功后 emit 新状态subscribe负责在页面可见时订阅 EventChannel 数据流retry是在异常后重新建立通信链路。class HealthCubit extends CubitHealthState { HealthCubit(this._repository) : super(HealthState.initial()); final HealthRepository _repository; Futurevoid refresh() async { emit(state.copyWith(loading: true)); try { final summary await _repository.fetchTodaySummary(); emit(state.copyWith(summary: summary, loading: false)); } catch (e) { emit(state.copyWith(error: e.toString(), loading: false)); } } }这里要提醒一点不要整个页面只包一个 BlocBuilder。我一开始就是这么做结果任何一块数据更新整页都跟着重建步数卡片动一下心率趋势图也要重新走一遍 build。后来把页面的几个区域拆开各用各的 BlocSelector只监听自己关心的字段。这样步数推送只触发步数卡片更新完全不影响趋势图的绘制性能。3.4 组件通信方式的选择在做健康仪表盘的过程中我接触到的 Flutter 组件通信不只是 Channel还有 BasicMessageChannel 和 Pigeon。简单总结一下我的选择标准MethodChannel 适合请求-响应式通信EventChannel 适合原生到 Flutter 的流式数据比如传感器事件BasicMessageChannel 适合高频双向消息但协议要自己定义Pigeon 适合需要类型安全的场景它可以在写代码前生成双方的通信代码减少手写 channel 的出错概率。健康仪表盘的数据链路不复杂EventChannel 加 MethodChannel 已经够用所以我没引入 Pigeon。如果你的项目里通信协议很多建议一开始就上 Pigeon把协议定义成类型安全的接口比后期手工维护 channel name 要靠谱得多。这里的核心原则是通信方式越早确定后期改造成本越低不要想着临时用 MethodChannel 顶一下后面再换。4. 健康仪表盘 UI 的核心实现4.1 CustomPaint 自绘环形进度圈环形进度圈是健康仪表盘最显眼的部分。它本质上是一个圆弧绘制任务用 CustomPaint 可以完全控制样式。绘画原理并不复杂先画一个背景圆环再根据进度值画一个覆盖在前面的彩色圆弧。关键在于角度计算和绘制细节。我先定义了一个RingPainter它继承CustomPainter接收一个progress值取值范围是 0 到 1。绘制时背景圆弧从 0 开始扫过整个 360 度进度圆弧从 -90 度开始扫过2 * pi * progress度。这里用-pi / 2作为起始角是因为 Flutter 的 canvas 默认 0 度在三点钟方向而仪表盘习惯从十二点钟方向开始。class RingPainter extends CustomPainter { RingPainter({required this.progress, required this.color}); final double progress; final Color color; override void paint(Canvas canvas, Size size) { final strokeWidth 14.0; final rect Offset(strokeWidth / 2, strokeWidth / 2) Size(size.width - strokeWidth, size.height - strokeWidth); final bgPaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..color Colors.grey.withAlpha(60); canvas.drawArc(rect, 0, 2 * pi, false, bgPaint); final progressPaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round ..shader SweepGradient( colors: [color.withAlpha(180), color], ).createShader(rect); final startAngle -pi / 2; final sweepAngle 2 * pi * progress; canvas.drawArc(rect, startAngle, sweepAngle, false, progressPaint); } override bool shouldRepaint(RingPainter oldDelegate) oldDelegate.progress ! progress || oldDelegate.color ! color; }这里的三个细节值得展开说明。第一strokeCap StrokeCap.round会让圆弧端点变成圆头视觉上更柔和但当 progress 非常接近 0 的时候它会在起始位置画出一个圆点看起来像进度条坏了我在代码里加了判断progress 小于 0.02 时不绘制进度弧。第二SweepGradient的 shader 需要传入矩形区域区域必须是圆环的外接矩形否则渐变方向会乱。第三shouldRepaint必须实现并且只在 progress 或 color 变化时返回 true否则每次父组件 rebuild 都会让画布重绘这是性能隐患。4.2 折线趋势图绘制心率趋势图是健康仪表盘里另一个核心自绘区域。绘制折线图的难点不在于画线而在于把数据值转换成画布坐标以及如何画出一条平滑的曲线。坐标映射的公式我在项目里固定下来了假设有 n 个数据点画布宽度为 width高度为 height那么第 i 个点的 x 坐标是i * width / (n - 1)y 坐标需要根据数据最小值和最大值做线性映射。为了不让曲线顶到画布边缘我在上下各留了 20 像素的 padding。Offset _mapPoint(int index, Listnum values, Size size) { final min values.reduce((a, b) a b ? a : b); final max values.reduce((a, b) a b ? a : b); final range max - min 0 ? 1.0 : (max - min).toDouble(); final x size.width * index / (values.length - 1); final y size.height - 20 - (values[index].toDouble() - min) / range * (size.height - 40); return Offset(x, y); }折线图如果用直线连接采样点在高频心率数据下会很生硬我改成了三次贝塞尔曲线。遍历坐标点时以相邻两个点作为端点用它们中间的两个控制点生成一条平滑连接路径这样曲线会更接近真实心率波动的形态。需要注意控制点的计算要基于前后点的水平间距不要让曲线出现突兀的尖角。实际调参时我发现控制点偏移量取水平间距的三分之一到四分之一视觉效果最好。曲线下方还可以加一个渐变填充区域让趋势图更有层次。填充区域就是把曲线路径延伸到左下角和右下角形成一个闭合路径。这里我用了Path的lineTo把曲线终点连接到画布底部再连线到起点然后使用ui.Gradient.linear生成一个从半透明到透明的渐变填充。需要注意的是填充路径和曲线路径是两条路径不能复用同一个 Paint否则渐变色会把曲线也盖住。4.3 布局、动效与细节体验画好了两个核心组件后真正让仪表盘好用的是整体布局和动效细节。我的页面结构是一个纵向滚动视图从上到下依次是今日概览卡片、环形目标进度、心率趋势卡片。概览卡片里用 Row 排列四个指标数字每个数字单独一个小组件这样数据刷新时互不干扰。动画方面环形进度的 progress 变化我希望有一个缓动效果而不是瞬间跳变。我用AnimationController包了一层数据更新时 controller 从当前值动画到新值动画时长控制在 500 毫秒左右。这里有一个容易踩的坑如果数据更新频率很快动画会被频繁打断看起来就像在疯狂抽搐。我的处理方式是做了数据节流只让最后稳定的值触发动画并且在动画进行中收到新数据时先平滑衔接而不是直接重置动画起点。Tab 切换也有一个体验优化。生活助手主界面大概率是 TabBar 结构点击其他 Tab 再回到健康仪表盘时默认的 TabBar 会有一个横向滑动动画。对于仪表盘这种强数据场景我不希望每次切回来都看到一段滑动画我把 TabBar 的animationDuration设成了Duration.zero让点击 Tab 立即切换视觉上更干脆。这个小细节在稳定版本上可用能让整个应用显得更利落。关于 PlatformView我在项目里也踩了一脚。后续版本想在仪表盘里嵌入原生地图组件当时考虑了直接用 PlatformView 接入 OpenHarmony 原生组件但很快发现滚动容器里放 PlatformView 的合成开销比想象中大卡片滑动时明显掉帧。最终方案是仪表盘页面尽量纯 Flutter 自绘只有确实需要原生能力的场景才引入 PlatformView并且单独做性能测试不能无脑堆。5. 渲染性能与典型问题排查实录5.1 高频刷新导致的帧率瓶颈健康仪表盘的性能问题主要来自高频数据刷新。真机上传感器事件一多页面帧率就会肉眼可见地下滑。我排查掉帧问题时先用 DevEco 和 Flutter 的 performance overlay 确认瓶颈在 UI 重建然后逐个排查原因。第一个原因是整个页面用一个 BlocBuilder 包住任何状态变化都触发整页 build。解决方式是拆成多个 BlocSelector让步数卡片只关心步数字段趋势图只关心趋势数组。第二个原因是 CustomPaint 没有隔离重绘区域。我给两个自绘组件都包上了RepaintBoundary这样数据更新时只有对应画布会重绘页面其他区域不会受影响。第三个原因是 paint 方法里做了过多计算。折线图的坐标映射、路径构造都是耗时操作我把计算做了一层缓存数据变化时才重新计算坐标列表shader 也尽量复用避免每次都创建新的渐变对象。还有一个很容易忽视的点原生侧推送频率。传感器步数可能每秒钟推好几次但实际上 UI 并不需要每秒都变。我在原生侧做了合并推送间隔 500 毫秒推送一次最新值Dart 侧也做了防抖。这样既保留了实时感又把绘制频率控制在合理范围。健康仪表盘追求的不是极限帧率而是在稳定帧率下提供足够的实时反馈。5.2 Impeller 引擎与 Skia 后端的取舍Flutter 的渲染引擎在 Android 和 iOS 上已经逐步转向 ImpellerOpenHarmony 的 flutter_flutter 分支在这方面还在追赶。我的项目用的是分支默认的 Skia 后端没有强行开启 Impeller。原因是健康仪表盘的主要绘制内容是弧线、渐变、贝塞尔曲线这些在 Skia 下表现稳定没必要为了新特性去承担不兼容风险。如果你使用的 flutter_flutter 分支支持开启 Impeller可以在运行时或构建参数里尝试验证一下。开启后如果能显著减少复杂路径下的漂移和卡顿再决定是否全量启用。经验是无论用哪个渲染后端先把画布重绘频率降下来才是正经事。我在测试中发现当温度折线图的数据点超过几百个时Skia 和 Impeller 的压力都不小最有效的优化还是减少不必要的重绘以及把绘制逻辑尽量简化。这里分享一个实际调优技巧环形进度和折线图的渐变色尽量不要在paint方法里每次新建 shader。shader 的创建成本并不低我会在数据变化时缓存 shader 实例或者把渐变预设到一个ui.Image里用 Canvas 绘制图片来替代动态创建 shader。这样画布重绘时,GPU 的工作量能下降一大截。5.3 常见问题速查表开发过程中我遇到的典型问题整理成了一张速查表方便以后直接查。现象可能原因解决思路ohos 目录 sync 失败flutter_flutter 分支与 OpenHarmony SDK 版本不匹配核对 README 版本矩阵切换匹配分支EventChannel 收不到数据两端 channel name 不一致或原生侧未正确调用 onListen统一协议常量名在原生侧打日志确认注册成功页面从后台恢复后数据空白EventChannel 生命周期未重新订阅在页面可见回调里重新创建流订阅环形进度动画频繁抖动数据更新频率过高动画被反复打断原生侧合并推送Dart 侧做数据节流折线图每次刷新都跳动Painter 的 shouldRepaint 逻辑未正确判断数据变化用数据列表的引用或版本号判断是否需要重绘只改 Dart 代码打包后无变化Flutter 产物未重新构建先执行 Flutter 构建再进入 DevEco 打包真机安装 HAP 失败签名未配置或签名失效在 DevEco 里重新配置自动签名Tab 点击动画太拖沓TabBar 默认带动画切换设置 animationDuration 为 Duration.zero5.4 真机调试中的额外建议健康仪表盘最怕在模拟器上调因为 OpenHarmony 模拟器通常没有真实的传感器数据我一般会在原生侧写一个模拟数据源按照真实步频和心率变化曲线生成事件流方便在真机 UI 上一次跑通。等到 UI 侧流程完全不依赖模拟数据后再切回真实传感器采集这样排查问题时可以快速区分是原生数据问题还是 Flutter 绘制问题。另外要关注功耗。长时间保持 EventChannel 接收流会不断唤醒 Flutter 引擎导致手机发烫。我在页面不可见时会取消订阅只在页面真正展示时才接收健康数据。原生侧也做了对应的处理页面退到后台时注销传感器监听由原生缓存最新数据等页面重新可见时再补推一条快照这样既省电又不丢状态。最后聊一个我最想分享的调整。最初我把步数、心率、睡眠数据全部放在同一个 Cubit 里UI 层也只有一个大的 BlocBuilder结果每次心率刷新整页重建帧率很难看。后来我把状态按领域拆成三个 Cubit并且把静态的卡片背景、装饰元素和动态数据层拆成不同组件问题立刻缓解。健康仪表盘这类页面的难点不在画一个圆环而在怎么不掉帧地把实时数据准确地画出来。如果你也正在用 Flutter 适配 OpenHarmony建议先用模拟数据把 UI 跑起来再决定通信和状态管理的细节这样能少走很多弯路。
返回列表