1. 项目概述与目标价值
这套“Flutter for OpenHarmony 身体健康状况记录App”的实战项目,最近在开发者圈子里讨论热度一直不低。尤其是运动详情模块,属于整个健康记录应用里信息密度最高、交互最复杂、也是用户打开频率最高的页面之一。很多人问我说,在 OpenHarmony 设备上跑 Flutter 到底顺不顺手,做健康类应用有没有什么坑,我干脆把这阵子把一个运动详情页从零到一的完整实现过程整理出来,给大家一份可以直接跟着落地的参考。
先说清楚这个内容是什么。健康记录App的核心场景就是让用户能够直观看到自己某一次运动的状态:今天跑了多远、用时多久、心率如何变化、消耗了多少热量、配速稳不稳定。而“运动详情”就是把一次运动记录从数据库里拿出来,经过格式化计算之后,在 Flutter 页面上以数字、进度条和图表的形式呈现。这件事的基础业务逻辑不复杂,复杂的是数据采集端和设备端的兼容处理,以及 Flutter 跨端渲染在 OpenHarmony 设备上的适配细节。
这篇文章适合谁看?第一类是打算在 OpenHarmony 上做 Flutter 应用开发、但又担心生态不成熟的团队或个人开发者;第二类是已经在做健康类 App、想参考运动详情页的架构设计和 UI 组织方式的工程师;第三类是 Flutter 都还不太熟、想通过一个真实业务场景快速上手的初学者。我会从环境搭建讲到页面实现,再讲到问题排查,尽量把每一步都掰开揉碎,让你看完之后能直接照抄。
2. 项目背景与工程初始化
2.1 技术选型:为什么是 Flutter + OpenHarmony
在开始写运动详情页之前,先说一下技术路线选择背后的逻辑,这决定了你后续整个开发过程中的工作方式。
OpenHarmony 目前支持两种主流应用开发方式,一种是基于 ArkUI 的原生声明式开发,另一种就是本文采用的 Flutter 跨端方案。Flutter 的优势在于一次编写、多端运行,不仅能在 OpenHarmony 上跑,还能同步覆盖 Android 和 iOS,这对需要发布多平台健康应用的产品来说,UI 一致性和维护成本都是明显占优的。但与此同时,OpenHarmony 对 Flutter 的支持还在持续完善中,一些原生平台的传感器接口和系统能力调用需要借助“平台通道”来打通,不能像在 Android 上那样直接依赖成熟的 Flutter 插件生态。
我选择的路线是:界面层完全用 Flutter 实现,数据采集走 OpenHarmony 端的能力封装,再通过自定义的 MethodChannel 桥接到 Flutter 层。这样既能享受 Flutter 的开发效率,又能保证设备端的健康数据源可信、可控。
2.2 创建 Flutter 工程并接入 OpenHarmony 平台
开始之前先把基础环境说透。你机器上需要准备 OpenHarmony SDK、Node.js 环境和 Flutter 工具链,版本尽量保持较新。OpenHarmony 侧的 Flutter 适配 SDK 目前主要维护在 Gitee 仓库里,需要手动下载后配置到 Flutter 的 custom 模式下,不能用官方 Flutter 直接编译。
创建一个 Flutter 工程,我用的是命令行标准操作:
flutter create --org com.example.healthapp health_app然后在工程根目录执行 OpenHarmony 平台代码的生成命令:
flutter create --platforms ohos .注意这个命令执行的前提是 Flutter 已经正确切换到了支持 OpenHarmony 的 SDK 版本。生成完成之后,工程目录下会多出ohos目录,里面就是 OpenHarmony 的 ACE 工程结构。
接下来配置权限。健康类应用需要访问传感器和用户的身体数据,在ohos目录下的module.json5文件里,需要声明对应权限:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.HEALTH_DATA", "reason": "用于读取用户的健康运动数据", "usedScene": { "abilities": ["MainAbility"] } } ] } }这个声明不能漏,否则后面调用系统健康数据接口时会被直接拒绝。实际调试中我遇到过几次因为没有加权限导致数据读取返回空的情况,排查半天才发现是权限声明的问题,白白浪费了时间。
2.3 工程目录结构与职责划分
我习惯把健康记录 App 的工程按业务模块划分,而不是全部堆在lib/pages下面。运动详情页相关的文件结构大致是这样的:
lib/ ├── main.dart // 应用入口与路由配置 ├── models/ │ └── workout_record.dart // 运动记录数据模型 ├── services/ │ ├── health_api.dart // OpenHarmony 健康数据平台通道封装 │ └── workout_service.dart // 运动记录业务逻辑层 ├── providers/ │ └── workout_provider.dart // 状态管理 ├── utils/ │ ├── formatter.dart // 日期、时长、配速格式化 │ └── calc_helper.dart // 卡路里与配速计算 └── pages/ └── detail/ ├── workout_detail_page.dart // 页面主体 ├── widgets/ ├── status_card.dart // 核心数据卡片 ├── heart_rate_chart.dart // 心率图表组件 └── split_table.dart // 分段配速表格把 UI 组件单独拆到widgets子目录下,是我个人比较推荐的实践方式。因为运动详情页面的组成部分很多,如果全部写在同一个 Dart 文件里,光布局代码可能就要上千行,后面想改任何一个细节都非常痛苦。拆开之后,每个组件只负责自己的一块渲染逻辑,调试效率高很多。
3. 运动详情页的信息架构与视觉布局设计
3.1 页面内容层级拆解
在真正动手写 UI 之前,先站在用户的角度把“运动详情”这个页面应该展示什么梳理清楚。一个跑步记录详情页,用户打开之后最先想看的是什么?总距离、总时长、平均配速这三项是一眼就要看到的。然后会想看心率变化曲线、卡路里消耗、步频数据,再往下翻才是分段配速、路线轨迹这类更详细的内容。
基于这个认知,我把页面信息的视觉权重排成三级:
- 一级信息:运动类型图标、总距离、总时长、平均配速,放在页面顶部,以大号数字和醒目的卡片呈现
- 二级信息:心率曲线图、卡路里消耗、平均心率、步频,放在中间区域,通过图表和辅助数值展示
- 三级信息:分段配速表格、备注、运动时间等辅助记录,放在底部,用简洁的列表承载
这个层级设计遵循的规律就是“信息从粗到细、从核心到边缘”。用户如果只是想快速知道今天的运动情况,只看顶部就够了;如果想做训练分析,可以继续往下翻心率图和分段数据。这种信息排布也符合大多数人阅读时的注意力习惯,避免第一屏信息过载。
3.2 配色方案与视觉风格
健康类应用和普通的工具类应用在视觉上有个明显差异,就是用户对可信度和舒适感的要求更高。纯黑色的底配高饱和度的绿色,看起来很有科技感,但长时间盯着看会累。我最终选择的是浅色背景搭配品牌蓝色系作为主色调,运动数据用深色数字突出显示,心率曲线用红色系,卡路里用橙色系,整体观感干净清爽。
在 Flutter 里我通过主题配置来保证整套界面风格统一:
ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF2B7AFF), brightness: Brightness.light, ), cardTheme: CardThemeData( elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide(color: Colors.grey.shade200), ), ), fontFamily: 'HarmonyOS Sans', ),这里有个小细节,我把fontFamily设置成了HarmonyOS Sans,这是 OpenHarmony 系统内置的中文字体。如果不指定字体,Flutter 默认使用的字体在部分 OpenHarmony 设备上中文渲染会发虚或者显示成默认等宽字体,观感很差。后面我会专门讲字体适配这块的坑。
4. UI 实现细节与关键组件编码
4.1 状态卡片组件
页面顶部的状态卡片是整个运动详情页里视觉最重要的部分。我用一个StatusCard的 StatelessWidget 来实现,接收一个WorkoutRecord对象作为入参,内部负责展示总距离、总时长和平均配速。
实现代码如下:
class StatusCard extends StatelessWidget { final WorkoutRecord record; const StatusCard({super.key, required this.record}); @override Widget build(BuildContext context) { return Card( margin: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ _buildMetricItem( label: '总距离', value: record.totalDistance.toStringAsFixed(2), unit: '公里', ), _buildDivider(), _buildMetricItem( label: '总时长', value: formatDuration(record.durationSeconds), unit: '', ), _buildDivider(), _buildMetricItem( label: '平均配速', value: formatPace(record.durationSeconds, record.totalDistance), unit: 'min/km', ), ], ), ), ); } Widget _buildMetricItem({ required String label, required String value, required String unit, }) { return Column( mainAxisSize: MainAxisSize.min, children: [ Text( label, style: const TextStyle( fontSize: 13, color: Colors.grey, ), ), const SizedBox(height: 6), Text( value, style: const TextStyle( fontSize: 26, fontWeight: FontWeight.w700, ), ), if (unit.isNotEmpty) Text( unit, style: const TextStyle( fontSize: 12, color: Colors.grey, ), ), ], ); } Widget _buildDivider() { return Container( width: 1, height: 40, color: Colors.grey.shade200, ); } }注意分隔线的实现,我用了一个高度固定的Container,宽度 1 像素,颜色是灰色 200 色阶。很多初学者习惯直接用Divider组件,但Divider在水平布局中默认会撑满最大宽度,表现并不受控,需要额外包一层SizedBox。直接用 Container 反而更简单可控。
4.2 心率曲线图实现
心率曲线是运动详情页的核心交互组件。方案选型上有两条路,一条是引入第三方图表库,另一条是用 Flutter 自带的CustomPainter自己画。我最终选择了 CustomPainter 方案,原因有两个:第一,当前 OpenHarmony 的 Flutter 环境下,部分第三方图表库的底层渲染依赖尚未完全验证,存在兼容风险;第二,心率曲线本身只需要折线加渐变填充,自绘的代码量并不大,可控性反而更好。
自绘心率图的完整实现如下:
class HeartRateChart extends StatelessWidget { final List<double> heartRates; final List<double> timestamps; const HeartRateChart({super.key, required this.heartRates, required this.timestamps}); @override Widget build(BuildContext context) { return SizedBox( height: 160, width: double.infinity, child: CustomPaint( painter: HeartRatePainter( heartRates: heartRates, timestamps: timestamps, ), ), ); } } class HeartRatePainter extends CustomPainter { final List<double> heartRates; final List<double> timestamps; HeartRatePainter({required this.heartRates, required this.timestamps}); @override void paint(Canvas canvas, Size size) { if (heartRates.isEmpty) return; final minRate = heartRates.reduce((a, b) => a < b ? a : b).toDouble() - 10; final maxRate = heartRates.reduce((a, b) => a > b ? a : b).toDouble() + 10; final range = maxRate - minRate; final path = Path(); final linePaint = Paint() ..color = const Color(0xFFFF4D6A) ..strokeWidth = 2.5 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..strokeJoin = StrokeJoin.round; final fillPaint = Paint() ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ const Color(0xFFFF4D6A).withOpacity(0.35), const Color(0xFFFF4D6A).withOpacity(0.0), ], ).createShader(Offset.zero & size); for (int i = 0; i < heartRates.length; i++) { final x = i / (heartRates.length - 1) * size.width; final y = size.height - (heartRates[i] - minRate) / range * size.height; if (i == 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } } canvas.drawPath(path, linePaint); // 绘制渐变填充区域 final fillPath = Path.from(path) ..lineTo(size.width, size.height) ..lineTo(0, size.height) ..close(); canvas.drawPath(fillPath, fillPaint); } @override bool shouldRepaint(covariant HeartRatePainter oldDelegate) { return oldDelegate.heartRates != heartRates; } }这段代码的核心逻辑是把心率数值映射为 Canvas 上的坐标点:横轴按时间均匀分布,纵轴按心率值的最大最小值范围做线性映射。绘制折线图和渐变填充区域用的是两条 Path,一条负责描边,一条负责在底部闭合后填充渐变。
你可能注意到我在上下各留了 10 的缓冲区间(minRate - 10、maxRate + 10),这个设计是为了避免心率贴近边界时曲线顶到画布边缘,显得很拥挤。这个细节在实际渲染效果上差异还是很明显的。
心跳数据的平滑处理也是一块值得说的地方。如果直接把传感器采集到的原始值画出来,图形会因为个别异常点产生尖锐的毛刺,观感很差。我在传给图表之前做了一层简单的中值滤波,对连续的三个点取中间值,效果立竿见影。处理逻辑放在数据解析层,图表组件本身不关心数据是否经过处理,这样可以保持组件的单一职责。
4.3 分段配速表格
运动详情页下半部分的分段配速表用途是让用户了解每一公里的速度波动情况。跑过步的人都知道,配速忽快忽慢很常见,通过这张表格可以快速定位到哪段掉速了。
实现上我用的就是 Flutter 的Table组件,配合DataRow构建行数据:
Table( border: TableBorder( horizontalInside: BorderSide( width: 0.5, color: Colors.grey.shade200, ), ), columnWidths: const { 0: FixedColumnWidth(60), 1: FlexColumnWidth(), 2: FlexColumnWidth(), 3: FixedColumnWidth(90), }, children: [ const TableRow( children: [ _HeaderCell('分段'), _HeaderCell('距离'), _HeaderCell('用时'), _HeaderCell('配速'), ], ), ...splits.map( (split) => TableRow( children: [ _DataCell('第 ${split.index} 公里'), _DataCell('${split.distance} km'), _DataCell(split.durationLabel), _DataCell(split.paceLabel), ], ), ), ], )注意分段数据如果很多,我会在外层套一个SingleChildScrollView,确保在窄屏设备上可以横向滑动查看。这个表格的视觉样式比较朴素,但胜在信息密度高、阅读效率好。我没有给每行加斑马纹背景,因为经过实际测试,在运动详情这种数据密集的页面上,斑马纹反而会干扰数字的快速阅读。
5. 数据层设计与业务逻辑封装
5.1 运动记录数据模型
运动详情的所有 UI 都依赖一份结构化的数据模型。我在workout_record.dart里定义了这样的实体类:
class WorkoutRecord { final String id; final String type; // 运动类型:running/cycling/walking final DateTime startTime; final int durationSeconds; final double totalDistanceKm; final double avgHeartRate; final double maxHeartRate; final double caloriesBurned; final int avgCadence; final List<HeartRateSample> heartRateSamples; final List<SplitRecord> splits; WorkoutRecord({ required this.id, required this.type, required this.startTime, required this.durationSeconds, required this.totalDistanceKm, required this.avgHeartRate, required this.maxHeartRate, required this.caloriesBurned, required this.avgCadence, required this.heartRateSamples, required this.splits, }); }这里有一点值得注意,WorkoutRecord里我同时存放了聚合统计值(平均心率、最大心率)和采样明细(heartRateSamples)。为什么不只存明细、需要的时候再计算平均值?因为一次运动可能有上千个心率采样点,每次打开详情页都去遍历计算虽然不至于卡顿,但在数据量增大之后会引入不必要的计算开销。更好的做法是运动结束时在采集侧完成聚合计算,详情页直接读取聚合结果,明细数据按需加载。这个思路在数据密集型应用里是非常重要的设计原则。
SplitRecord表示分段数据,通常按公里划分,里面记录每段的用时、距离、平均心率和配速,代码结构比较直观,就不展开了。
5.2 平台通道封装与数据读取
数据来源是 OpenHarmony 侧的健康数据服务。Flutter 层不可能直接访问系统的传感器数据库,必须通过 MethodChannel 桥接。我在health_api.dart里做了统一的调用入口:
class HealthApi { static const MethodChannel _channel = MethodChannel( 'com.example.healthapp/health', ); static Future<List<WorkoutRecord>> fetchWorkoutRecords({ required DateTime start, required DateTime end, }) async { final result = await _channel.invokeMethod('fetchWorkoutRecords', { 'startTime': start.millisecondsSinceEpoch, 'endTime': end.millisecondsSinceEpoch, }); return (result as List) .map((e) => WorkoutRecord.fromJson(Map<String, dynamic>.from(e))) .toList(); } static Future<List<HeartRateSample>> fetchHeartRateSamples(String workoutId) async { final result = await _channel.invokeMethod('fetchHeartRateSamples', { 'workoutId': workoutId, }); return (result as List) .map((e) => HeartRateSample.fromJson(Map<String, dynamic>.from(e))) .toList(); } }需要注意,传给平台层的密码必须要是 JSON 兼容的简单类型,我在方法参数里用时间戳而不是 DateTime 对象,就是为了避免序列化问题。这里有个大坑:Flutter 层发送的数据类型如果不符合平台接口预期,平台端接收时可能解析不正确甚至直接崩溃,所以封一层HealthApi专门处理序列化和反序列化,业务代码不要直接去碰 channel,这是保持项目整洁的关键。
5.3 卡路里计算与配速格式化
卡路里消耗常用公式是 MET 值法。跑步的 MET 值大约在 8 到 12 之间,根据速度变化,为了简化,我取速度对应的 MET 值做近似计算:速度在 8 km/h 以下取 8,8到12之间取 10,12 以上取 12。然后套用公式:
热量消耗(kcal) = MET × 体重(kg) × 运动时长(小时)代码实现:
double calcCalories({ required double speedKmh, required double weightKg, required Duration duration, }) { double met; if (speedKmh < 8) { met = 8; } else if (speedKmh < 12) { met = 10; } else { met = 12; } return met * weightKg * duration.inMinutes / 60; }配速格式化函数则是把“秒数”转成常见的“X分X秒每公里”表示法:
String formatPace(int totalSeconds, double distanceKm) { if (distanceKm <= 0) return '--'; final paceSeconds = (totalSeconds / distanceKm).round(); final min = paceSeconds ~/ 60; final sec = paceSeconds % 60; return '$min\'$sec"'; }这两个函数虽然简短,但很容易写错边界条件。特别是formatPace里对distanceKm <= 0的保护判断,如果漏掉,极端情况下会出现除以零异常,整个页面直接崩溃。类似这种“数据驱动页面”的逻辑,所有可能为 0 的分母都要提前做防范。
6. 页面状态管理与路由传参
6.1 Provider 状态管理方案
运动详情页需要的数据由多个接口拼接而成,页面内部还有“加载中”“加载失败”“数据为空”三种状态需要管理。我用的是provider库,没有上更重的bloc,原因很简单:页面状态复杂度不高,用ChangeNotifier的粒度就够了。如果后面业务膨胀到需要跨页面共享状态,再考虑升级方案也不迟。
WorkoutProvider的核心代码大致这样:
class WorkoutProvider extends ChangeNotifier { WorkoutRecord? _currentRecord; bool _isLoading = false; String? _errorMessage; WorkoutRecord? get currentRecord => _currentRecord; bool get isLoading => _isLoading; String? get errorMessage => _errorMessage; Future<void> loadWorkoutDetail(String workoutId) async { _isLoading = true; _errorMessage = null; notifyListeners(); try { final record = await WorkoutService.fetchDetail(workoutId); _currentRecord = record; } catch (e) { _errorMessage = '数据加载失败,请重试'; } finally { _isLoading = false; notifyListeners(); } } }页面侧通过Consumer监听 Provider 的状态变化,页面主体根据isLoading、errorMessage、currentRecord三个状态决定渲染加载骨架屏、错误提示页面还是正式内容。这种状态驱动 UI 的模式比较成熟,调试时逻辑清晰,新手也很容易上手。
6.2 路由跳转与参数传递
列表页跳转详情页,我用的是命名路由加参数的方式:
Navigator.pushNamed( context, '/workout/detail', arguments: {'workoutId': workoutId, 'userId': userId}, );在详情页接收参数:
final args = ModalRoute.of(context)!.settings.arguments as Map; final workoutId = args['workoutId'] as String;这里有个体验问题值得注意:传入userId是因为健康数据模块在 OpenHarmony 上支持多用户隔离,如果不带上用户标识,某些设备上可能默认读取到错误用户的数据。这也是我在实机测试时踩过的坑。
6.3 加载骨架屏与异常兜底
网络或设备数据读取可能有延迟,直接白屏会让人误以为应用卡住了。我做了一个很简单的骨架屏组件,用灰色的圆角矩形模拟卡片结构,数据到达后自然过渡到真实内容。实现上不需要引入第三方 shimmer 库,一个AnimatedOpacity搭配定时改变透明度的AnimationController就够用,这里我采用了简化方案,只做静态灰块展示,避免代码过度设计。
异常兜底页面则是一个居中的图标加文案加“重试”按钮。重试按钮绑定到 Provider 的loadWorkoutDetail方法,逻辑闭环,体验完整。
7. 性能优化与 OpenHarmony 实机适配注意事项
7.1 列表加载与图片资源优化
运动详情页面虽然只有一个主要数据页面,但底部如果展示运动轨迹缩略图、用户头像等资源文件时,内存占用会快速上升。我在加载轨迹图时统一走了异步解码,避免阻塞 UI 线程:
FutureBuilder<ui.Image>( future: _loadTrackImage(), builder: (context, snapshot) { if (snapshot.hasData) { return RawImage(image: snapshot.data); } return const SizedBox.shrink(); }, )同时,HeartRatePainter里的折线点数如果超过一定阈值,我会做抽稀处理。比如记录了一条 2 小时的跑步,心率采样频率是每秒一次,那就是 7200 个点。所有点全部画到 Canvas 上,绘制的开销会明显增大。我的做法是先把点数压缩到不超过 360 个点,在压缩过程中取窗口内的最大值和最小值,这样既能保留波峰波谷的形态,又能把绘制负担降到一个很低的水平。
抽稀逻辑放在calc_helper.dart中,是一个独立的纯函数,方便单测。
7.2 OpenHarmony 设备上的字体渲染问题
前文提到过字体问题,这里详细说明。OpenHarmony 系统默认字体在中英文混排场景下,如果不主动指定fontFamily,Flutter 默认走的是运行时自带的Roboto字体,对中文的 fallback 支持并不总会正确匹配,表现就是中文字体发虚、发细,甚至出现部分字形缺失。
我的解决方式是双保险:
- 第一层:全局 Theme 中设置
fontFamily: 'HarmonyOS Sans' - 第二层:在
pubspec.yaml里不打包自定义字体文件,依赖系统字体
但要注意,HarmonyOS Sans 这个字体并不是所有 OpenHarmony 设备都预置。如果设备上确实没有,Flutter 会 fallback 到系统默认字体。考虑到绝大多数设备都内置了这款字体,这个方案在覆盖率和观感上达到了可接受的平衡。如果你的产品对品牌字体有严格要求,那就得在工程包里打包 ttf 文件,这是唯一的确定做法,但会增加包体积约 5 到 10 MB,需要产品层面权衡。
7.3 状态栏适配与刘海屏
OpenHarmony 设备的屏幕形态五花八门,有刘海屏、挖孔屏、也有传统的平面屏。运动详情页的顶部状态卡片如果不做安全区适配,在刘海屏上会直接被刘海遮挡。Flutter 提供了MediaQuery.of(context).padding.top来获取顶部安全区高度,我把它应用到了页面根容器上:
SafeArea( child: Scaffold( ... ), )SafeArea组件默认会同时适配顶部和底部的安全区域,一句代码解决刘海屏适配问题。一个小细节是,如果你给页面设置过自定义背景色,要确保Scaffold的backgroundColor和SafeArea的外层容器颜色一致,否则安全区区域会出现一圈明显的色差。
7.4 真机调试的常见问题与解决实录
在项目开发过程中,我整理了三个对开发效率影响最大的问题,写在这里希望能帮你少走弯路。
第一个问题是 MethodChannel 调用持续超时。症状表现是页面一直停在加载状态,也不报错,也不弹异常。排查后发现原因在 OpenHarmony 侧处理健康数据的接口没有在主线程执行,而是放到了子线程,导致 Executor 的返回接口没有正确回调到 Flutter 侧。解决思路是确保所有平台方法调用都在聚道封装内部正确切换到主线程再返回结果,OpenHarmony 的MethodChannel实现和 Android 在这块的调度机制略有差异,不能完全照搬经验。
第二个问题是滚动列表跟手卡顿。原因定位在分段配速表格的构建方式上:我原本用Table包在ListView里,而且每次 build 都重新创建了所有 TableRow,导致滚动时重复同步布局,性能开销很大。优化方案是用CustomScrollView的SliverList替代,并且将分段数据单个 ListTile 化。这个改动让滚动帧率明显上升,卡顿消失。
第三个问题是数据为空时页面崩溃。运动记录有时没有心率采样数据,但是构建HeartRatePainter时我又直接访问了timestamps[0]之类的索引,空列表直接抛异常。解决方式是所有图表数据渲染前都做空集合检测,数据量为 0 时直接渲染占位文案“本次运动未采集到心率数据”。这个兜底逻辑虽然简单,但能避免线上闪退事故。
8.后续可以扩展的方向
运动详情页作为健康记录 App 的一个核心页面,第一版能做好“看得清、点得动、不卡顿”这三件事,就已经完成了它的核心使命。我在这篇文章里提到的所有设计决策,从信息层级划分、状态管理方案选型到图表自绘、字体适配,都是在保证稳定性和开发效率的前提下做出来的取舍,不是最优解,但一定是经过实机验证的可靠方案。
我个人在实际项目中的体会是:在 OpenHarmony 设备上用 Flutter 做健康类应用,最大的精力消耗往往不在 Flutter 本身,而是在跨端数据打通和设备适配这两个环节。Flutter 的 UI 开发效率依然很高,但千万别指望它屏蔽所有平台差异,该做防御性代码的地方一定不能偷懒。特别是数据模型这一层,一定要想清楚空值、零值、异常值出现时页面各自的呈现状态,把防御式编程做到位,后续的实际测试会轻松非常多。
最后分享一个实战小技巧:在开发运动详情页的图表功能时,可以提前准备几份不同量级的模拟数据,比如 10 个采样点、500 个采样点、8000 个采样点,用来反复验证图表绘制的性能表现。一份好的模拟数据比什么工具都好用,尤其是当你需要判断是否要做抽稀处理的时候。这个小习惯我到现在都还在用,希望对做健康类应用的你也有帮助。