做出健康记录类App第一版的时候,我内心最没底的模块就是运动详情。跑步、健走、骑行结束后,用户点进来看什么?总时长、总距离、配速曲线、心率分布、步频区间、分段数据,外加一条轨迹图。这个页面看起来只是展示,实际上从数据模型到绘图性能,每一层都有讲究。这篇文章我打算把做Flutter for OpenHarmony版本运动详情模块的完整过程记录下来,包括选型理由、UI拆解、图表实现、状态管理、性能优化,以及OpenHarmony平台上独特的适配问题,给想做类似功能的朋友省点时间。我默认你会一点Flutter,但即使你是刚入门的状态,跟着思路走也能大致明白整个模块是怎么搭起来的。
1. 为什么偏偏用Flutter做OpenHarmony应用
1.1 原生ArkTS方案的顾虑
拿到需求的时候,团队内部其实有过一轮讨论:是不是直接用ArkTS和ArkUI写?毕竟OpenHarmony应用的主流开发方式就是那套声明式UI框架,官方文档和示例代码也多。但我们的实际情况是:项目里已经有一套成熟的Flutter健康管理界面和业务逻辑,是从其他平台迁移过来的,几百个页面和组件不可能推倒重来。如果改用ArkTS重写,等于把UI层、状态管理、路由全部重做一遍,工期不是翻倍,是翻三倍——运动详情、历史记录、周报月报、图表组件这些全是重活。
另一个顾虑是跨端一致性。同一套Flutter代码,在Android、Windows、Web上都能跑,多一个OpenHarmony目标平台,只是多一个编译产物而已。运动相关页面里大量的曲线图、环形图、雷达图,用同一套绘制代码可以保证视觉和交互完全一致。健康类App对数字的准确性要求极高,两套代码库一旦出现数据展示不一致,排查起来非常痛苦,而Flutter可以把这个风险降到最低。
所以从工程角度,Flutter不是“最炫”的方案,却是“最稳”的选择。这个决定的前提是OpenHarmony上已经能够比较稳定地跑起Flutter引擎,我们验证之后才敢放开手脚做。项目启动时我做的第一件事,就是跑一个带列表、导航、Canvas绘制的Demo,确认基本能力可以工作。
1.2 Flutter在OpenHarmony上的适配现状
客观说,Flutter官方对OpenHarmony不是一等公民,但这几年社区推进速度比想象中快。我当时跑Demo的结论是:常见Widget基本可用,官方插件生态有明显缺口,需要自己兜底。Dart层的绝大多数代码不用改,也就是说业务UI、状态管理、数据库逻辑在跨平台层是通的。真正要花时间的是OpenHarmony专属的平台通道实现——有的第三方插件只有Android/iOS实现,在OpenHarmony上要么找替代,要么自己写MethodChannel实现。
比如运动详情页用到的实时心率读取,原生的健康数据接口在OpenHarmony上有自己的权限申请流程和API风格,不能直接复用Android那套。这块后面单独说,总之你心里要有个预期:Flutter代码能跑通是大概率事件,但插件生态的缺口一定会让你在某个角落多花几天时间。
1.3 基础设施选型:能用的与不能用的
先盘点一下运动详情模块需要哪些基础设施:
- 本地持久化,存运动记录:用Hive,纯Dart实现,不依赖平台通道,OpenHarmony上不会有兼容问题。
- 图表绘制:优先自绘CustomPainter,不依赖第三方图表库,避免平台差异。
- 图片加载:轨迹缩略图第一版只显示静态占位图,后续再考虑接入真实地图。
- 权限管理:读取传感器、步数、心率等需要平台通道,自己封装。
- 网络请求:数据同步是另一个模块的事,运动详情先把本地数据展示做扎实,暂时不引入网络层。
选型原则只有一个:尽量选纯Dart实现的库,把平台相关的代码量降到最低。这个原则在OpenHarmony上尤其重要,因为你的编译目标和传统安卓不完全一致,每引入一个带原生代码的插件,都可能要在OpenHarmony目录底下补实现。
2. 运动详情的数据层:模型、存储与数据来源
2.1 一次运动记录的完整数据模型
运动详情页展示的数据从哪里来?主要来自运动采集模块落库的一条记录。我定义的核心模型大致是这样:
class WorkoutRecord { final String id; // 全局唯一id final String type; // running / walking / cycling final int startTime; // 开始时间,毫秒时间戳 final int durationSec; // 总时长,单位秒 final double distanceKm; // 总距离,单位公里 final double avgPace; // 平均配速,分钟/公里;跑步用 final double calories; // 估算消耗,单位千卡 final int avgHeartRate; // 平均心率,单位bpm final int maxHeartRate; final int minHeartRate; final double avgCadence; // 平均步频 final List<PaceSegment> paceSegments; // 每公里分段 final List<HeartRateSample> hrSamples; // 心率采样序列 final List<GpsPoint> trackPoints; // 轨迹点序列 final String note; final int createTime; }有些字段产品手册上没写,但实际做详情页时非常关键:比如hrSamples的采样间隔,直接决定曲线图横坐标的粒度;paceSegments的长度决定了“每公里配速”卡片有几行。这些细节要在采集入库的时候就想清楚,否则到了详情页只能将就。
为什么要存trackPoints而不是存一张渲染好的地图缩略图?因为详情页可能需要支持横竖屏切换、不同比例展示轨迹,存原始经纬度序列更灵活。代价是体积,一条一小时跑步记录的GPS点大概几百KB,Hive完全扛得住。
2.2 本地存储方案:Hive落地细节
选Hive有几层考虑。健康类App数据比较敏感,我不想引入SQLite在OpenHarmony端还要处理原生依赖;Hive是纯Dart的,底层基于二进制文件,读写速度也很好。运动详情的读写模式是:一次写入、多次读取、按时间倒序列出,Hive的Box模型非常契合这个场景。
实际落地时有个值得注意的点:不要把整个WorkoutRecord直接塞进一个Box当作单条记录,因为List字段会让Box的可读性下降,也不利于以后做数据迁移。我的做法是拆成两个Box:
Box<WorkoutRecord> recordBox; // 主记录,字段少,列表页用 Box<WorkoutDetail> detailBox; // 详情字段,按recordId关联recordBox存概要字段(时长、距离、卡路里),detailBox存曲线、分段、GPS点这些重数据。列表页只读recordBox,滚动性能会好很多;详情页进入时再用recordId去detailBox取全量详情。如果所有字段都塞一起,每次列表刷新都要反序列化几千个采样点,很快就卡了。
提示:拆Box看起来多写两行代码,但对列表页的性能影响是数量级的,这个设计在数据量大时收益非常明显。
这里还涉及到运动详情页进入时的加载策略。我的做法是先读recordBox秒开页面骨架,再异步加载detailBox里的曲线数据,加载完setState刷新图表区域。用户体感上几乎没有白屏,也不会因为几十毫秒的IO等待阻塞首帧。
2.3 健康数据读取与权限申请
运动详情页本身不采集数据,但有一个入口:点击“实时心率变化”区域时,会试着从系统健康数据接口同步一次最新的心率记录。这个功能需要平台权限,在OpenHarmony上有自己的权限申请流程,不能在AndroidManifest里照抄,需要在module.json5里声明,并通过系统弹窗请求。
我封了一个统一的通道:
class HealthDataChannel { static const MethodChannel _channel = MethodChannel('health_data'); static Future<int?> fetchLatestHeartRate() async { try { return await _channel.invokeMethod<int>('getLatestHeartRate'); } on MissingPluginException { return null; } } }真机上会遇到一种情况:权限弹窗出现一次,但用户拒绝后,再次调用直接返回失败,没有二次引导。所以我在详情页里做了降级逻辑——拿不到实时心率时,只展示入库的历史采样曲线,并且用一个小图标提示“当前未授权实时数据”,不阻塞正常浏览。健康类App里这种降级体验一定要做,否则用户拒一次权限,整个页面就崩了,产品体验会非常差。
3. 详情页UI拆解:卡片、图表与轨迹
3.1 页面骨架:滚动容器与信息层级
运动详情页的信息层级我按“结论先行”的思路排:最顶部是一张总览卡片,显示运动类型、日期、时长、距离、消耗;往下是配速/心率/步频曲线区;再往下是每公里分段的表格卡片;最后一屏是轨迹缩略图与备注区。用户80%的需求在第一个屏幕内解决,这是健康App详情的通用逻辑。
页面骨架用SingleChildScrollView包Column,内部每个卡片都是一个独立的Widget,用Container加圆角和阴影统一风格。这里我没有用ListView,因为页面内容总量固定,不存在懒加载收益,SingleChildScrollView配合Column可以让卡片间的间距计算更灵活。
平台差异点:在OpenHarmony设备上,SingleChildScrollView的EdgeEffect默认是平台默认效果——安卓上可能看到波纹,开源鸿蒙设备上可能看到类似边缘光晕的效果。如果产品想要统一,可以在MaterialApp的theme里设置scrollBehavior,或者直接包一层自定义ScrollBehavior。我在这个项目里统一压掉EdgeEffect,因为详情页是卡片信息流,不需要弹跳反馈,视觉上更干净,不会让用户觉得突兀。
3.2 可复用的指标卡片组件
总览卡片里最常用的是一行三列或一行四列的指标格子:距离、时长、配速、千卡。这类格子组件很值得抽象成可复用Widget,因为历史记录页、周报月报页都会用到:
class MetricItem extends StatelessWidget { final String label; final String value; final String unit; const MetricItem({ super.key, required this.label, required this.value, required this.unit, }); @override Widget build(BuildContext context) { return Column( mainAxisSize: MainAxisSize.min, children: [ Text(label, style: TextStyle(fontSize: 12, color: Colors.grey)), const SizedBox(height: 4), Text( value, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600), ), Text(unit, style: TextStyle(fontSize: 12, color: Colors.grey)), ], ); } }这个组件的“坑”在于数字越界:当距离大于100公里,或者千卡达到四位数时,需要省略单位、缩小字号,否则会挤破卡片。我后来加了自适应逻辑,value和unit用RichText拼一起,空间不够时单位缩到10号字。还有一点是语义色:健康App里,心率超区间时数字要用警示色,配速不用,所以组件要暴露一个color参数,而不是写死黑色。
3.3 自绘曲线图:CustomPainter实现配速与心率曲线
曲线图是运动详情页最重的部分。一开始我想过直接用第三方图表库,后来放弃,理由很简单:单个曲线图也就几百行CustomPainter,第三方图表库在OpenHarmony上有没有兼容问题不确定,而且图表交互需求(点击取点、缩放手势)不复杂,自己做反而好调。
核心思路是用CustomPainter画折线,数据源是hrSamples或paceSegments的序列。
class LineChartPainter extends CustomPainter { final List<double> values; final Color lineColor; final Color fillColor; LineChartPainter({ required this.values, required this.lineColor, required this.fillColor, }); @override void paint(Canvas canvas, Size size) { if (values.isEmpty) return; final paint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 2.0 ..strokeCap = StrokeCap.round ..color = lineColor; final path = Path(); const padding = 8.0; final maxV = values.reduce((a, b) => a > b ? a : b); final minV = values.reduce((a, b) => a < b ? a : b); final range = maxV - minV == 0 ? 1.0 : maxV - minV; for (int i = 0; i < values.length; i++) { final dx = padding + i * (size.width - padding * 2) / (values.length - 1); final dy = padding + (1 - (values[i] - minV) / range) * (size.height - padding * 2); if (i == 0) { path.moveTo(dx, dy); } else { path.lineTo(dx, dy); } } canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant LineChartPainter oldDelegate) { return oldDelegate.values != values; } }这里有个数据量性能问题:一条记录的心率采样点可能有几千个,逐个画点不仅慢,曲线还显得毛糙。更稳的做法是抽稀——先按显示宽度算出目标点数(比如400点),再对原始序列做分桶平均。这样每个像素区间只取一个代表值,曲线既平滑又不会因为局部噪声产生锯齿。抽稀逻辑要写在数据预处理阶段,不是放在每帧painter里。
曲线下方的渐变填充也是健康App常见的视觉风格,代码上可以用LinearGradient加Paint.shader实现,注意不要对渐变区域重新遍历数据,直接复用已经算好的path路径闭合到底部就行。
3.4 分段数据与轨迹地图的取舍
每公里分段我一开始想做成表格,后来发现体验一般——用户在跑步时会特别关注“第几公里慢了”,表格的形式找起来不方便。最终改成横向滚动的条形图卡片:每一根柱条代表一公里,柱高代表配速,点击柱条弹出该公里平均心率。这个交互方式用了很多人觉得顺。
轨迹地图是详情页里最容易踩性能坑的部分。OpenHarmony端没有内建地图SDK,如果强行接入在线地图服务,又要处理原生依赖、Key申请、授权这些事,运动详情页的复杂度会明显上升。我的取舍是:第一版只展示轨迹静态图,把trackPoints投影到画布上,用CustomPainter描路线,在地图底色上画折线,不加真实地理底图。这样既能直观看出运动轨迹形状,又绕开了平台依赖。后续如果产品有强需求,再在平台通道里接入地图引擎。
4. 状态管理、路由与页面交互细节
4.1 状态管理到底选了什么
这个模块的状态不算特别复杂,但也不简单:进入详情页要加载记录概要、异步加载详情数据、切换心率/配速/步频三个Tab、以及处理权限回调。我最后选的是Provider加ChangeNotifier,没有上更重的状态库。理由很直接:一个是团队已经熟悉,二是这个页面的状态树是单页面的、临时的,没必要引入全局方案。如果有人用Riverpod或GetX,核心思路一样,不必非要统一。
运动详情页的状态模型我拆成三类:
LoadingState:无/加载中/成功/失败,控制骨架屏与错误重试。DataState:持有WorkoutRecord和WorkoutDetail,也就是页面的所有展示数据。ViewState:当前选中Tab、图表缩放系数、是否展示轨迹详情。
三类状态分属不同的ChangeNotifier,页面根Widget用MultiProvider组装,卡片组件各自监听自己关心的部分。这样切Tab时只有图表区和卡片区重建,不会造成整页刷新。
4.2 数据刷新与页面参数传递
从列表页点进详情页,我用的不是把一个完整的记录对象塞进路由参数,而是传recordId,然后详情页自己去Hive取数据。这是个隐蔽但很重要的设计。如果直接把整个WorkoutRecord对象通过路由参数传进详情页,会带来两个问题:一是大字段(GPS点、心率序列)在路由序列化时要整体拷贝,增加耗时;二是列表页数据如果后期更新,详情页进来拿到的还是旧对象。
用recordId取数还有个额外好处:详情页的异步加载逻辑可以复用“从Hive读取加模型转换加状态更新”的流程,以后如果接入服务端数据,只需要把数据源替换成网络请求,UI层完全不用动。我把这个取数逻辑放在一个Repository类里,详情页只依赖接口,不直接依赖Hive。
class WorkoutRepository { Future<WorkoutDetail> loadDetail(String recordId) async { final box = Hive.box<WorkoutDetail>('workout_detail'); return box.get(recordId) ?? WorkoutDetail.empty(recordId); } }4.3 横向滑动与竖向滚动手势冲突的处理
运动详情页的下半部分有横向滚动的公里柱状图,上半部分的曲线卡有时也要支持横向缩放,而整页是一个竖着的SingleChildScrollView——这就必然遇到一个经典问题:竖滑和横滑的手势竞争。
Flutter默认的GestureArena机制通常会按“子部件优先”的原则把水平方向的手势交给子Widget,但实际测试下来,在OpenHarmony设备上偶发横滑被竖滑抢走的情况,尤其是系统级的边缘返回手势和横向水平滚动冲突时更明显。
我的解决办法是给横向滚动区包一层GestureDetector,在onHorizontalDragUpdate里手动判断dx和dy的绝对值:
GestureDetector( onHorizontalDragStart: (_) => _allowHorizontalScroll = true, onVerticalDragStart: (_) => _allowHorizontalScroll = false, child: SingleChildScrollView( scrollDirection: Axis.horizontal, physics: ClampingScrollPhysics(), child: barChart, ), )原理其实很简单:通过GestureDetector先把竖滑和横滑的手势在起点就分好类,不等到GestureArena去猜。竖屏设备上用户做竖滑时dx基本很小,做横滑时dy基本接近0,用初始位移方向判断就够了。后来我把这个逻辑抽成了一个小通用组件,其他页面遇到同样的手势冲突也能用。
5. 性能优化与真机上的坑
5.1 数据量不大但列表却卡:找原因的过程
详情页刚写完时,在真机上滚动明显掉帧,尤其从轨迹区滑到备注区时能感觉到一顿一顿。一开始我以为是图表太重,后来逐项排查才发现问题根本不在图表。
一次排查过程大概是这样的:先在详情页里把所有卡片换成占位灰块,滚动依旧卡,说明和内容渲染无关;再检查页面根Widget,发现整个SingleChildScrollView的child每次setState都会重建。问题出在详情页加载过程里,我把LoadingState、DataState、ViewState都放进了同一个ChangeNotifier,而每个卡片都监听这个ChangeNotifier,于是任何局部状态变化,比如切Tab,都会触发整棵树重建。
定位到原因后把状态拆成三个ChangeNotifier,只让对应的卡片做自己范围内的监听;再把卡片组件都加上const构造,让Flutter可以复用已有Element。压测结果从明显掉帧降到60fps稳定。这套排查思路对任何Flutter页面都适用:先替换内容定位是不是数据层,再检查状态监听范围,最后看重建面积。
5.2 图表重绘开销与刷新频率
图表自绘还有个隐藏的坑:shouldRepaint的返回值决定painter是否会重绘。我用的是:只要values引用相等就直接返回false。但实际开发里,如果状态管理中不小心对values做了copy,每次setState都会触发整张图重绘,哪怕数据根本没变。
这里有一个性能测试小技巧:在paint()方法里打印计数,观察一次页面滚动到底触发几次paint。滚动本身不触发RepaintBoundary外的重绘,但如果你给图表区域包了不合适的RepaintBoundary,或者父组件在滚动过程中一直setState,paint会被反复调用。我最终给三个图表Card都包了RepaintBoundary,并把shouldRepaint的比较逻辑明确为“比较数据list长度和首尾值”,这样即使引用变了,只要数据实质未变也不会重画。
5.3 内存检查:详情页退出后的隐患
健康类App容易长时间挂在后台,内存问题必须提前防范。我在压力测试时发现,连续打开10次详情页再退出,内存曲线明显爬升,最后的回落却很少。
Bug根因是一次自绘图表里持有实时心率采样的StreamSubscription,没有在dispose里取消,导致页面虽然被路由弹出,但订阅仍然存活,每次从平台通道推来新数据都会触发回调,还持有旧页面的context。这在普通页面上很难察觉,但详情页如果经常进出,占用会持续累积。
修复方式很简单:
@override void dispose() { _subscription?.cancel(); _controller.dispose(); super.dispose(); }但要警惕同类问题。后来我全局扫了一遍,凡是页面里用到的StreamController、AnimationController、Timer、TextEditingController都在dispose里处理干净。内存这块查一次就能避免很多线上隐患。
6. 在OpenHarmony上跑Flutter的专属适配清单
6.1 插件缺失时的替代方案
前面说过,很多第三方Flutter插件在OpenHarmony上还没有实现,细分生态缺口大于主流平台。运动详情模块遇到过的典型场景有三个:
- 读取实时心率的插件只在Android/iOS实现了,在OpenHarmony上调用直接
MissingPluginException。我的做法是自己在OpenHarmony目录里补一份平台实现,通过MethodChannel对接开源鸿蒙的生命周期与传感器接口。 - 地图SDK插件在OpenHarmony上不可用,替代方案就是前文说的自绘轨迹静态图。
- 本地通知插件在OpenHarmony上行为不一致,详情页里的“运动完成提醒”功能先砍掉,改在页面内展示横幅提示。
选择替代方案的判断标准就一条:这个能力是否核心。健康记录App里,曲线数据、轨迹展示是核心,实时推送是附加,砍掉附加功能不影响主流程,就果断砍。等插件适配好了再补回来,不要在第一版就被非核心功能拖住。
6.2 字体与布局渲染差异
OpenHarmony设备上的字体渲染和标准安卓不完全一样。实际踩到的表现是:中文行高偏高,个别数字在小字号下间距异常。我后来统一在MaterialApp的theme里设置了TextTheme,把数字部分用等宽数字特性固定住,时间、心率读数这类数字就稳定了。
Card的圆角阴影在某些OpenHarmony低端设备上性能很差,会出现一截一截的掉帧。排查后把复杂卡片阴影换成了细边框加浅色背景,视觉差异不大,性能提升明显。这类问题在模拟器上根本测不出来,必须真机多跑几轮。
6.3 页面路由与返回手势
路由这块,OpenHarmony下的Flutter页面是从原生容器进入的,详情页如果使用Navigator.push压栈,返回手势在部分设备上会和系统返回手势冲突。我的做法是不用自定义动画,直接用MaterialPageRoute,让系统返回键和侧滑返回能同时起作用。
另外,从列表页跳详情时用到了Hero动画,某类字体和缩放换算在OpenHarmony上会闪烁,后来直接去掉,页面切换反而更平滑。运动详情页的进入动画不需要花哨,稳定比好看重要。
6.4 真机回归清单
跨平台开发的通病就是“模拟器上一切正常,真机上一堆问题”。我整理了一份运动详情模块的真机回归清单,每次改完代码都会过一遍:
| 检查项 | 关注点 | 预期表现 |
|---|---|---|
| 首帧展示 | 从列表点进详情 | 1秒内出骨架,数据异步补齐 |
| 大数据量刷新 | 1万+心率采样点 | 滚动不卡,图表不重复绘制 |
| 横竖屏切换 | 切换时轨迹图重算 | 卡片不溢出,曲线不变形 |
| 权限拒绝 | 拒绝实时心率授权 | 页面降级显示,无报错弹窗 |
| 返回手势 | 系统侧滑返回 | 响应及时,无双重返回 |
| 内存退出 | 连续进出详情页20次 | 内存曲线回落,无持续增长 |
| 深色模式 | 切换深色背景 | 卡片和曲线颜色校正正确 |
这份清单看起来简单,但几乎每一条都对应一个真实踩过的坑。建议你在自己的项目里也维护一份,平台差异会越来越明显,回归测试不能省。
最后说点心里话。整个运动详情模块做完,我最大的感受是:跨平台开发的真正成本不在于写UI,而在于你对每个平台边界情况的掌握程度。Flutter让代码跑起来很快,但OpenHarmony的适配工作会提醒你,一个应用要走得远,必须得认真对待每一层平台差异。如果你也正在做类似的功能,欢迎按这个思路试试看——先把数据模型定牢,再把图表自绘做扎实,最后慢慢磨平台适配,顺序千万别反。