1. 项目整体设计:一个"简单"的体重记录功能,藏着哪些门道
先说个背景。我自己一直在做 Flutter 跨端应用,前阵子接到一个基于 Flutter for OpenHarmony 的健康类 App 需求,其中有个模块叫"体重趋势"。乍一看,这不就是个折线图加几个输入框吗?真做起来才发现,这里面的坑比想象中多得多——首先是 OpenHarmony 上的 Flutter 环境就和 Android/iOS 不完全一样,其次是"体重趋势"四个字背后牵扯到数据存储、图表选型、单位换算、时间轴聚合、状态刷新一系列问题。
我决定把这个项目的完整实现过程写出来,重点聚焦体重趋势这个模块。这篇文章不是泛泛讲 Flutter 基础,而是直接带你从零到一把这个功能在 OpenHarmony 设备上跑起来,包括图表怎么选、数据怎么存、交互怎么优化、哪些地方容易翻车。适合已经会一点 Flutter、想在 OpenHarmony 上做实际项目的开发者,也适合对健康类 App 开发感兴趣、想了解一个功能模块完整落地过程的朋友。
1.1 为什么"体重趋势"是健康 App 的核心模块
在健康类应用里,体重记录是最基础也最刚需的功能之一。用户记录体重的深层需求并不是"记一个数字",而是想看到变化趋势:最近一周是涨了还是跌了?这个月整体走势如何?减肥计划有没有效果?
这就决定了体重趋势模块不能只是简单地把数据点画出来,它至少要满足几个隐性需求:
- 趋势可视化:要能直观看到上升、下降、平台期。
- 时间维度的切换:用户会想看一周、一个月、三个月的不同粒度。
- 单位切换:国内用户习惯斤,部分用户习惯公斤,国际场景还涉及磅,这个不能忽略。
- 数据录入的便捷性:如果记录一次体重要操作五步,用户很快就会放弃。
1.2 技术选型思路:为什么用 Flutter for OpenHarmony
先说我为什么要在 OpenHarmony 上做 Flutter,而不是直接用 ArkUI 原生写。根本原因一句话:一套代码,多端复用。团队里已经有现成的 Flutter 业务代码,如果为了 OpenHarmony 单独用 ArkUI 重写一套,维护成本直接翻倍。Flutter for OpenHarmony 是 OpenHarmony 生态对 Flutter 框架的适配方案,通过它可以把 Flutter 应用跑在 OpenHarmony 设备上,同时保留 Flutter 的渲染能力和组件模型。
在具体模块设计上,我的思路是:
- UI 层:Flutter Widget 实现,保证与 Android/iOS 端一致。
- 图表层:选一个 Flutter 生态里成熟的图表库,不自己造轮子。我自己用的是某个开源图表库(后面详细讲为什么选它,以及替代方案)。
- 数据层:用轻量级本地存储方案,因为体重数据量不大,单机存储足够,不需要上重量级数据库。
- 状态管理:最轻量级的方案,用 Flutter 自带的 setState 配合 ValueNotifier 就能满足需求,不需要引入重量级状态管理框架。体重趋势这个模块的数据流非常简单,没必要杀鸡用牛刀。
1.3 模块边界拆解:不贪多,先把一条链路走通
很多开发者拿到需求后容易犯一个错误:一上来就想把整个 App 的所有功能都做完。我这次特意把项目边界划清楚,只做体重趋势相关部分:
- 体重数据的录入与编辑
- 体重数据的本地持久化
- 趋势图的展示与交互(时间范围切换、单位切换、数据点详情)
- 基础的统计信息(当前体重、变化量)
至于用户系统、云端同步、饮食记录、运动记录这些,一律不做。这样做的好处是:可以集中精力把一条数据链路彻底走通——录入、存储、读取、计算、渲染、交互,每一个环节都能抠细节。
2. 环境准备与依赖选型:先让 Flutter 跑在 OpenHarmony 上
2.1 5分钟搭好开发环境
如果你已经在用 Flutter 开发,那么做 Flutter for OpenHarmony 最核心的差异就是:你需要安装 OpenHarmony 专用的 Flutter SDK 分支,而不是官方主干。
我这里说下实测下来最稳的步骤:
- 从 OpenHarmony 社区仓库拉取 Flutter for OpenHarmony 的 SDK 分支。
- 把下载的 SDK 解压到你本地的 Flutter 目录,替代原来的 Flutter SDK。
- 配置环境变量
FLUTTER_STORAGE_BASE_URL为国内镜像,不然下载依赖会非常慢,甚至超时失败。 - 执行
flutter doctor,确认 OpenHarmony 相关的工具链都已识别。
一个必须强调的点:OpenHarmony 的 Flutter SDK 版本和 Dart 版本是对齐的,你在pubspec.yaml里最好不要随意指定过新的 Dart SDK 约束,否则解析依赖时会直接报版本冲突。我一开始就踩过这个坑,项目里写了sdk: ">=3.0.0 <4.0.0",结果 OpenHarmony 分支的 Flutter SDK 还是旧版本的 Dart,解析依赖直接失败,排查了半天才发现是约束写得过于激进。后来改成项目对应的版本区间之后,一切正常。
2.2 项目初始化与依赖清单
项目创建之后,最要紧的就是把pubspec.yaml配好。下面是我这个项目的依赖清单,每一条我都写了用途:
dependencies: flutter: sdk: flutter # 图表库:用于绘制体重趋势折线图 fl_chart: ^0.66.0 # 本地存储:用于持久化体重记录 shared_preferences: ^2.2.2 # 日期格式化与计算 intl: ^0.18.0 # 唯一 ID 生成:用于记录主键 uuid: ^3.0.7选这几个库的原因,不在"看起来流行",而在于它们在 OpenHarmony 上的兼容性经过了验证。shared_preferences 有 OpenHarmony 社区的适配版本,而 fl_chart 纯 Dart 绘制、不依赖平台通道,跨端渲染没有问题。相比之下,有些图表库底层会用到平台视图(PlatformView),在 OpenHarmony 上就可能出现白屏或者无法交互的问题,这个后面再展开。
2.3 编译配置里的三个关键点
跑通编译之前,我在配置上卡了几个小时,这里直接把结论写出来,帮你少走弯路:
- 包名设置:在
pubspec.yaml里配置openharmony平台选项,bundle名称(即应用包名)要提前规划好,后面涉及到权限申请和签名打包,包名改起来非常麻烦。 - 权限配置:如果你的设备需要访问网络、读取健康数据等能力,需要在
entry/src/main/module.json文件里声明权限。体重趋势模块本身不涉及敏感权限,但要提前留好扩展位。 - 签名配置:直接用自动签名方案最省事,在开发调试阶段不需要配置额外证书,构建产物签名时它自己会处理。真机验证时如果遇到安装失败的错误,十有八九是签名问题。
2.4 验证环境跑通再往下写
新技术栈上手的时候,我给自己定了个规矩:先跑通一个最小 Demo,再开始写业务代码。所以第一步不是撸业务逻辑,而是新建一个空白 Flutter 项目,里面只放一个 Text 控件,分别在模拟器和真机上跑一遍,确认渲染、触摸事件、热重载都正常。
这一步花的时间不长,但价值极高。因为如果连最小 Demo 都跑不通,后面写再多代码也没意义。实测下来,OpenHarmony 上的 Flutter 热重载功能是支持的,但是比 Android 端偶尔慢一些,大文件改完我基本都是直接热重启而不是热重载,避免一些状态错乱的假象。
3. 数据模型与持久化:体重记录怎么存,才能既简单又不怕后面改
3.1 体重记录的数据模型设计
在写任何 UI 之前,先把数据模型想清楚。我设计的体重记录模型长这样:
class WeightRecord { final String id; // 唯一标识,用 UUID final DateTime date; // 记录日期 final double weightKg; // 体重,统一以公斤存储 final String note; // 备注信息,可选 WeightRecord({ required this.id, required this.date, required this.weightKg, this.note = '', }); Map<String, dynamic> toJson() => { 'id': id, 'date': date.millisecondsSinceEpoch ~/ 1000, // 存储秒级时间戳 'weightKg': weightKg, 'note': note, }; factory WeightRecord.fromJson(Map<String, dynamic> json) => WeightRecord( id: json['id'] as String, date: DateTime.fromMillisecondsSinceEpoch((json['date'] as int) * 1000), weightKg: (json['weightKg'] as num).toDouble(), note: json['note'] as String? ?? '', ); }这里有两个细节值得说道说道:
第一个细节:体重统一用公斤存储。用户界面可以显示斤、公斤、磅,但数据层永远只存公斤。这样做的原因很直接:换算逻辑只写一次,放在 UI 层做,而不是散落在各个地方。不然用户昨天用斤记录、今天切到公斤,你的数据就是一团乱麻。
第二个细节:为什么用秒级时间戳?很多人在设计模型时顺手就用millisecondsSinceEpoch,这样存的是毫秒级。但跨端场景下,不同平台对时间戳的精度处理偶尔会有差异,统一用秒级可以避免边界情况下的偏差。显示的时候再乘 1000 转成毫秒,完全不耽误用。
3.2 本地存储:为什么没有用数据库
健康类数据按理说用数据库更正规,但在这个项目里,我选用了 shared_preferences 而不是 SQLite 数据库。原因很简单:
- 体重数据的核心量级很小,一个人一天最多记录一两次,一年也就几百条。
- 体重趋势的查询模式非常固定:一次性取出全部,按日期排序,在内存里做聚合计算。
- 用数据库会引入额外的原生依赖和异步复杂度,而这个体量下完全没有必要。
class WeightRepository { static const _storageKey = 'weight_records_v1'; final SharedPreferences _prefs; WeightRepository(this._prefs); Future<List<WeightRecord>> loadRecords() async { final jsonString = _prefs.getString(_storageKey); if (jsonString == null || jsonString.isEmpty) { return []; } final list = jsonDecode(jsonString) as List<dynamic>; return list .map((e) => WeightRecord.fromJson(e as Map<String, dynamic>)) .toList() ..sort((a, b) => a.date.compareTo(b.date)); } Future<void> saveRecord(WeightRecord record) async { final records = await loadRecords(); records.removeWhere((r) => r.id == record.id); records.add(record); records.sort((a, b) => a.date.compareTo(b.date)); await _prefs.setString(_storageKey, jsonEncode(records.map((r) => r.toJson()).toList())); } Future<void> deleteRecord(String id) async { final records = await loadRecords(); records.removeWhere((r) => r.id == id); await _prefs.setString(_storageKey, jsonEncode(records.map((r) => r.toJson()).toList())); } }存储键名我特地加了一个_v1后缀,这是为将来数据迁移留的口子。如果哪天数据量真的上来了,需要迁移到 SQLite 或者云端,直接写一个迁移方法,把_v1的数据读出来写入新存储就行,不需要动业务代码。这个习惯是从一次痛苦的数据结构变更里学来的:当时没设计版本号,用户升级后老数据直接解析失败,只能让用户清掉重录,体验极差。
3.3 新增/编辑记录时的数据校验
录入是数据链路的第一步,这里校验不做好,后面图表全是脏数据。我的校验逻辑如下:
String? validateWeight(String value) { final weight = double.tryParse(value); if (weight == null) { return '请输入有效的数字'; } if (weight <= 20 || weight >= 300) { return '体重范围应在 20 - 300 kg 之间'; } return null; }范围不是拍脑袋定的,是按照健康管理场景的合理区间来设置的。20kg 以下、300kg 以上在普通成年人健康记录里都不太可能出现,设这个区间既不会误伤正常用户,又能拦住明显的手误输入(比如把 65.5 输成了 655)。另外,录入控件的键盘类型我设成了数字键盘,并在输入框里做了FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')),从源头拦掉字母和特殊字符。
3.4 数据加载与内存模型
首页打开时,流程是这样的:
- App 启动后,创建
WeightRepository实例。 - 调用
loadRecords()加载全部记录。 - 记录列表按日期排序后,交给图表组件和统计组件。
- 每次新增/修改/删除后,重新走一遍保存 + 加载流程。
这套逻辑是同步阻塞式的,好处是简单直观,代码好维护;代价是每次操作都读写一次本地存储。但前面说了,体重数据量级小,JSON 序列化和反序列化的开销完全可以忽略。实测下来,1000 条记录的读写耗时在几毫秒级别,用户完全无感知。
4. 趋势图核心实现:数据聚合、图表渲染、单位切换一个都不能少
4.1 先算后画:趋势图的数据聚合逻辑
趋势图不是简单地把所有记录点连起来。原始数据是散点,一天可能有 1 条记录,也可能有 3 条(早上、晚上各一次),如果不做聚合,折线图会显得非常杂乱。我做了按天聚合的逻辑:
List<WeightPoint> aggregateByDay(List<WeightRecord> records) { final Map<String, List<double>> grouped = {}; for (final record in records) { final key = '${record.date.year}-${record.date.month}-${record.date.day}'; grouped.putIfAbsent(key, () => []).add(record.weightKg); } final points = grouped.entries.map((entry) { final parts = entry.key.split('-'); final date = DateTime(int.parse(parts[0]), int.parse(parts[1]), int.parse(parts[2])); final avg = entry.value.reduce((a, b) => a + b) / entry.value.length; return WeightPoint(date: date, weightKg: avg); }).toList() ..sort((a, b) => a.date.compareTo(b.date)); return points; }聚合规则是取平均值。为什么不用第一条或者最后一条?因为健康数据里,同一天早晚称重会有波动,取平均值能更好地代表当天的真实水平,趋势线也会更平滑,不容易出现"昨天 65.2、今天早上 64.8、今天中午 65.0"这种锯齿状的噪音。
如果你希望趋势更"平滑",还可以在上面的基础上再加几层处理:比如取 3 日移动平均、7 日移动平均。但这个项目我选择了保留原始聚合数据,让用户看到真实的波动。只有等记录足够多以后,才考虑加一个"平滑曲线"的开关。
4.2 图表库选型:为什么最终选了 fl_chart
Flutter 社区里能画折线图的库有好几个,我实际对比过的有:
- fl_chart:纯 Dart 实现,高度可定制,支持折线图、柱状图、饼图等。社区活跃,文档相对完善。
- syncfusion_flutter_charts:功能强大,有专业的时序图组件,交互很丰富,但体积较大,很多高级特性需要商业授权。
- graphic:基于
gram的数据可视化库,功能强大,但上手成本偏高,文档对新手不够友好。
最终我选 fl_chart 的原因有三个:一是纯 Dart 实现,不依赖平台视图,在 OpenHarmony 上不会出现渲染白屏问题;二是 API 设计直白,画折线图只需要配置LineChartBarData,代码量少;三是体积和性能在可接受范围,启动耗时没有明显增加。
4.3 折线图组件的核心代码
下面这个代码是从项目里提取出来的核心图表组件,我做了一些精简,保留最主要的配置项:
class WeightTrendChart extends StatelessWidget { final List<WeightPoint> points; final WeightUnit unit; final VoidCallback onTouch; const WeightTrendChart({ super.key, required this.points, required this.unit, required this.onTouch, }); @override Widget build(BuildContext context) { final spots = points .map((p) => FlSpot( p.date.millisecondsSinceEpoch.toDouble(), _convertWeight(p.weightKg, unit), )) .toList(); return LineChart( LineChartData( minX: spots.isNotEmpty ? spots.first.x - 86400000 : 0, maxX: spots.isNotEmpty ? spots.last.x + 86400000 : 1, minY: _computeMinY(spots, unit), maxY: _computeMaxY(spots, unit), lineBarsData: [ LineChartBarData( spots: spots, isCurved: true, color: const Color(0xFF4CAF50), barWidth: 3, dotData: const FlDotData(show: true), belowBarData: BarAreaData( show: true, color: const Color(0x334CAF50), ), ), ], titlesData: FlTitlesData( leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 46, getTitlesWidget: (value, meta) { return Text( _formatWeight(value, unit), style: const TextStyle(fontSize: 11), ); }, ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 28, interval: 86400000 * 5, // 5天显示一个刻度 getTitlesWidget: (value, meta) { final date = DateTime.fromMillisecondsSinceEpoch(value.toInt()); return Text( '${date.month}/${date.day}', style: const TextStyle(fontSize: 11), ); }, ), ), ), gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: _gridInterval(unit), ), borderData: FlBorderData(show: false), lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( getTooltipItems: (touchedSpots) { return touchedSpots.map((spot) { return LineTooltipItem( '${_formatWeight(spot.y, unit)}', const TextStyle( color: Colors.white, fontWeight: FontWeight.bold, ), ); }).toList(); }, ), ), ), ); } }有几个配置点值得展开讲:
X 轴采用时间戳映射。FlSpot.x直接传毫秒时间戳,这样 X 轴天然就是一个连续的时间线,日期刻度可以按任意间隔显示,不会有"数据点 1、2、3 等距"这种失真问题。注意在 fl_chart 里,X 轴的数据类型是 double,时间戳转过来完全没有精度问题。
Y 轴的范围不是直接取最大最小值。我用的是_computeMinY和_computeMaxY这两个自定义方法,它们会在数据最大最小值的上下各留 10% 的余量:
double _computeMinY(List<FlSpot> spots, WeightUnit unit) { if (spots.isEmpty) return 0; final minVal = spots.map((s) => s.y).reduce((a, b) => a < b ? a : b); final range = spots.map((s) => s.y).reduce((a, b) => a > b ? a : b) - minVal; return minVal - range * 0.1; }如果不加余量,折线会紧贴图表的上下边缘,视觉上非常局促,而且数据点的小圆点容易被裁剪掉。
tooltip 配置让用户手指按上去时能看到具体数值。fl_chart 的默认 tooltip 样式其实挺好看的,但我把文字加粗了,并且在单位切换时动态格式化数值,保证当前显示单位下看到的就是最直接可读的数字。
4.4 单位切换:一套数据,三种显示
前面提过,数据层统一存公斤,显示层做换算。我的单位枚举和换算方法如下:
enum WeightUnit { kg, jin, lb } double convertWeight(double kgValue, WeightUnit unit) { switch (unit) { case WeightUnit.kg: return kgValue; case WeightUnit.jin: return kgValue * 2; case WeightUnit.lb: return kgValue * 2.20462; } } String formatWeight(double value, WeightUnit unit) { switch (unit) { case WeightUnit.kg: return '${value.toStringAsFixed(1)} kg'; case WeightUnit.jin: return '${value.toStringAsFixed(1)} 斤'; case WeightUnit.lb: return '${value.toStringAsFixed(1)} lb'; } }单位切换在 UI 上的实现就是一个SegmentedButton(分段按钮),切换后调用setState更新图表。这里要注意一个细节:切换单位不需要重新读数据、不需要重新聚合,因为数据一直没有变,变的只是显示层的换算函数。这个设计很符合数据与表现分离的原则,也让界面切换瞬间完成,没有任何卡顿感。
如果用户希望切换单位后趋势的"形状"也发生变化,那就要小心了。比如同一个数据,公斤显示的范围是 60-65,斤显示的范围是 120-130,如果 Y 轴上下限没有按换算后的数值重新计算,图表的波动幅度观感会有细微差别。我在_computeMinY/_computeMaxY里传入的单位做了换算,所以切换后整个图表的 Y 轴范围会自适应,视觉上保持一致的波动比例。
4.5 时间范围切换:周/月/三个月
时间范围切换的逻辑不算复杂,但特别容易写乱。我在实现时把过滤逻辑集中到一个函数里:
List<WeightPoint> filterByRange(List<WeightPoint> points, TimeRange range) { final now = DateTime.now(); final startDate = switch (range) { TimeRange.week => now.subtract(const Duration(days: 7)), TimeRange.month => now.subtract(const Duration(days: 30)), TimeRange.threeMonths => now.subtract(const Duration(days: 90)), }; return points.where((p) => !p.date.isBefore(startDate)).toList(); }注意我没有用now.subtract(Duration(days: 30))来代表"一个月",因为实际月份长度不固定(28 天到 31 天),用 30 天近似是大多数 App 的通用做法,用户观感上不会有问题。从产品语义上这也不是 bug,用户选择"近一个月"时预期到的是差不多 30 天,这个偏差是可以接受的。
时间范围切换组件就是一个简单的ChoiceChip行,放在图表上方,与单位切换组件平行排列。状态存在页面的 State 里,切换时重新 filter + setState。
4.6 补充统计卡片:当前体重与变化量
图表上方我放了三张统计卡片:当前体重、近一个月变化量、记录天数。
- 当前体重:取最新一条记录,按当前显示单位格式化。
- 近一个月变化量:一个月范围内的最后一条减去第一条,正数显示红色(涨了),负数显示绿色(降了)。这个颜色逻辑在健康管理场景里算是常识——涨不是好事,降才是。
- 记录天数:聚合后的点数,代表实际有记录的天数,不是记录条数,因为同一天可能录了多次。
统计卡片虽然只是几行文字的展示,但它们承担了"趋势之外快速给反馈"的功能。用户不用每次都去读图表里的细节,扫一眼数字就能知道当前状态。
5. 交互与体验优化:趋势页不只是画个折线图
5.1 数据点的点击反馈
fl_chart 自带的 tooltip 已经能应付多数场景:用户手指滑过折线时,会显示对应数值。我在此基础上加了两个增强:
一是给数据点加了showingTooltipIndicators的默认显示逻辑,当图表只有三个点以内的时候,默认把 tooltip 显示出来,让用户一眼看到数值,不需要再手动触摸。
二是触摸反馈的防抖处理。fl_chart 的 touch 回调触发频率很高,如果每次 touch 都去触发 setState 或者调用上层回调,页面会明显卡顿。我的处理方式是在回调里做一个节流,只在上一个位置 300 毫秒后或位移超过一定阈值时才触发:
DateTime _lastTouchTime = DateTime.fromMillisecondsSinceEpoch(0); void _handleTouch(FlTouchEvent event, LineTouchResponse? response) { final now = DateTime.now(); if (now.difference(_lastTouchTime) < const Duration(milliseconds: 300)) { return; } _lastTouchTime = now; // 处理选中点逻辑 }5.2 刷新机制:新增记录后图表秒更新
新增/编辑体重记录后,页面需要立即刷新。我的做法是页面用一个ListenableBuilder包住整个趋势区域,监听一个ValueNotifier<int> refreshSignal:
final ValueNotifier<int> _refreshSignal = ValueNotifier(0); Future<void> _handleSave(WeightRecord record) async { await _repository.saveRecord(record); _refreshSignal.value++; }ListenableBuilder的优点是只重建依赖它的子树,其他不相关的区域(比如底部导航栏)不会被动重建,性能上有保证。实测下来,每秒钟连续刷新几十次也感觉不到任何 UI 卡顿。
如果你用的是全页面 setState 的方式,每次录入保存后整页重建,在数据量还小的时候看不出问题,但一旦图表上的点多了,全页重建会导致图表闪烁或者出现短暂的绘制空白。所以这里推荐ListenableBuilder或者其他局部刷新的方案。
5.3 空状态与骨架屏
体重趋势页在没有数据时,不能显示一个空白框或者直接崩掉。我做了三套状态:
- 无数据状态:显示一个"还没有记录,点击下方按钮添加第一条"的空状态,配一个图标和 CTA 按钮。
- 单条数据状态:折线图只有一个点,没有线可以画。这个状态下隐藏图表,只显示统计卡片,并提示"再记录一条就能看到趋势了"。
- 正常状态:数据两条以上,展示完整图表。
单条数据这个边界情况特别容易忽略。很多开发者写完折线图后,自己测试时都有好几条数据,没意识到只有一条数据时 fl_chart 会怎么表现。实测下来可能会出现一条不可见的点或者异常空白,所以最好在数据层做一次判断,主动对单条数据做降级处理。
5.4 录入页面的交互设计
体重录入页面是模态弹出的,包含日期选择、体重输入框、备注输入框。
日期默认是今天,允许用户回填过去的记录(很多人是周末补记一周的)。体重输入框自动聚焦弹键盘,默认显示上次记录的值,省去用户重新输入的麻烦。备注字段是可选的,比如"运动后""早晨空腹"这类补充信息。
保存按钮在大键盘弹出时会被顶上去,我在布局里用了Scaffold的resizeToAvoidBottomInset默认配置,没有额外处理,效果正常。有一点值得注意的是,OpenHarmony 上输入法弹出时的避让行为与 Android 略有差异,如果发现按钮被遮挡,可以检查是否是SafeArea配置导致的,而不是立即去处理viewInsets。
5.5 列表页:最近记录的展示
趋势图下方我还加了一个"最近记录"列表,展示最近 10 条记录,每条显示日期、体重值和备注。列表项支持左滑删除,删除时弹出确认框,防止误操作。
这个列表的作用不只是展示,它也是编辑入口。点击某一条记录可以进入编辑页面,方便用户修改录错的数值。列表和图表共用同一份数据源,删掉一条记录后,图表会立刻刷新——这里同样用了_refreshSignal通知,不需要手动去同步两个组件。
6. 实战排错与避坑:OpenHarmony 上踩过的七八个坑
6.1 图表不显示?先检查数据是否为合法的 FlSpot 序列
最常见的问题:数据都传了,但折线图一片空白。排查下来,八成是spots里有非法值,比如double.nan或double.infinity。这些值通常来自空列表做聚合运算时的除零错误,活生生把数据源变成了非法数字。
我的排查步骤是:
- 在数据聚合函数后打日志,打印
spots.length和首尾值。 - 检查聚合列表中是否存在 weight 小于 0 的值。
- 用 Dart 的
debugPrint直接输出spots数据,肉眼过一遍。
如果想在源头上杜绝,可以在数据层做一个防御性过滤:
List<FlSpot> _safeSpots(List<WeightPoint> points, WeightUnit unit) { return points .where((p) => p.weightKg.isFinite && p.weightKg > 0) .map((p) => FlSpot( p.date.millisecondsSinceEpoch.toDouble(), _convertWeight(p.weightKg, unit), )) .toList(); }6.2 X 轴时间显示错乱?多半是毫秒和秒混了
我在模型里用了秒级时间戳存储,但 fl_chart 的 X 轴需要毫秒级。如果你存的时候是秒、画的时候忘了乘 1000,X 轴的时间会全部对不上,显示的日期会变成 1970 年的某一天。
这个问题排查起来特别隐蔽,因为大部分情况下图表依然能画出来,只是底部日期看起来很不合理。解决方案很简单:在fromJson和聚合逻辑里统一乘 1000,并在代码注释里写清楚。
6.3 OpenHarmony 上依赖库不兼容的处理思路
OpenHarmony 的 Flutter 生态还在快速演进中,并不是所有 pub.dev 上的库都能直接使用。遇到依赖不兼容时,我的处理优先级是:
- 查 OpenHarmony 社区适配列表:很多常用库官方或社区已经有适配版本,直接用适配版的包名。
- 换纯 Dart 实现的替代库:不依赖原生平台通道的库,兼容性通常最好。
- 自己实现轻量替代:如果功能不复杂,比如一个本地存储,完全可以自己用文件读写封装,不必依赖第三方。
从经验来看,健康类 App 里重度依赖原生能力的功能(比如传感器读取、系统通知)在 OpenHarmony 上要特别谨慎评估,最好是先做技术验证再排期。
6.4 fl_chart 在长列表滚动的性能问题
如果你把图表直接放在ListView里,并且列表里还有其他元素,滚动时可能会有掉帧。原因是图表组件在ListView里默认不会懒加载,每次构建都会完整绘制。
两个优化方向:一是把图表单独封装,利用RepaintBoundary隔离图表的绘制区域,避免它触发列表里其他组件的重绘;二是数据点特别多时(比如一年以上的日记录),可以对LineChartBarData开启preventCurveOverShooting和isCurved: false,减少曲线平滑计算的开销。
我实测过 OpenHarmony 设备上,500 个数据点以内的 fl_chart 性能完全没问题,超过之后下拉列表会有轻微掉帧。如果未来数据积累到几千条,我会改成时间聚合后再显示的策略,把数据点控制在 200 个以内,这也是很多成熟健康类 App 的做法。
6.5 中文字体在 OpenHarmony 上的渲染
体重记录里可能包含备注信息,界面上也会显示"斤""公斤"等中文单位。Flutter for OpenHarmony 默认会使用系统字体渲染中文,正常情况下没有问题,但如果你在 Widget 里指定了 fontFamily 到某个 OpenHarmony 上没有的字体,中文就可能显示为方框(豆腐块)。
最稳妥的做法是不要显式指定中文字体,让 Flutter 走系统字体回退机制。如果一定要自定义字体,需要把字体文件打包进 assets,并在pubspec.yaml里配置好,确保所有平台都有这个字体文件。
6.6 热重载失效?用热重启解决
OpenHarmony 上的 Flutter 热重载稳定性暂时还不如 Android。我遇到的典型情况是改了模型类字段后热重载,页面看起来没变化,但代码逻辑已经更新了,导致状态错乱。
解决办法很简单:改动数据类、状态管理这类和 State 生命周期强相关的代码时,直接按R热重启,不要依赖热重载。改动纯 UI 样式时可以热重载,实测下来大多数场景没问题。
6.7 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 图表空白无绘制 | spots 数据为空或含 NaN/Infinity | 打印 spots 列表检查,添加 isFinite 过滤 |
| 底部日期显示 1970 年 | 时间戳毫秒/秒单位混淆 | 统一在 fl_chart 使用毫秒时间戳 |
| 切换单位不生效 | 图表未刷新 Y 轴范围 | 在 setState 中同时更新换算后的 minY/maxY |
| 删除记录后图表不更新 | 数据源刷新了但 UI 没监听 | 用 ValueNotifier/ListenableBuilder 驱动刷新 |
| 中文显示方框 | 指定了 OpenHarmony 没有的字体 | 去掉 fontFamily 或打包字体资源 |
| 热重载后状态错乱 | 数据类变更导致 State 未重置 | 改用热重启 R |
| 模拟器上图表卡顿 | 模拟器图形性能不足 | 用真机验证,并考虑减少数据点 |
| 首次安装启动闪退 | 签名未配置或依赖不兼容 | 检查签名配置,逐个排除依赖 |
结尾:一些实操中的体会
这次把 Flutter for OpenHarmony 的体重趋势模块走完一遍之后,我最大的感触是:跨端开发的核心挑战从来不是"写界面",而是"数据设计和处理逻辑的一致性"。同样的数据模型,在 Android 上怎么存、在 OpenHarmony 上就怎么存;同样的聚合逻辑,不会因为换了个平台就重写一套。把数据层做好之后,UI 层面的适配工作量其实没那么大。
最后分享一个我自己养成的习惯:每个模块开发前,先用纸笔画一遍数据流——从用户输入开始,到数据存储,再到图表展示,每个环节的角色和数据格式都标出来。这个习惯帮我避免了很多"写到一半才发现数据格式不统一"的返工。你如果也在做类似的项目,可以试试这个方法,说不定能帮你省下半天调试时间。