☰
Flutter for OpenHarmony 实战:体重趋势模块从零到一完整实现
2026/10/21 3:58:45 网站建设 项目流程

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 的所有功能都做完。我这次特意把项目边界划清楚,只做体重趋势相关部分:

  1. 体重数据的录入与编辑
  2. 体重数据的本地持久化
  3. 趋势图的展示与交互(时间范围切换、单位切换、数据点详情)
  4. 基础的统计信息(当前体重、变化量)

至于用户系统、云端同步、饮食记录、运动记录这些,一律不做。这样做的好处是:可以集中精力把一条数据链路彻底走通——录入、存储、读取、计算、渲染、交互,每一个环节都能抠细节。

2. 环境准备与依赖选型:先让 Flutter 跑在 OpenHarmony 上

2.1 5分钟搭好开发环境

如果你已经在用 Flutter 开发,那么做 Flutter for OpenHarmony 最核心的差异就是:你需要安装 OpenHarmony 专用的 Flutter SDK 分支,而不是官方主干。

我这里说下实测下来最稳的步骤:

  1. 从 OpenHarmony 社区仓库拉取 Flutter for OpenHarmony 的 SDK 分支。
  2. 把下载的 SDK 解压到你本地的 Flutter 目录,替代原来的 Flutter SDK。
  3. 配置环境变量FLUTTER_STORAGE_BASE_URL为国内镜像,不然下载依赖会非常慢,甚至超时失败。
  4. 执行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 数据加载与内存模型

首页打开时,流程是这样的:

  1. App 启动后,创建WeightRepository实例。
  2. 调用loadRecords()加载全部记录。
  3. 记录列表按日期排序后,交给图表组件和统计组件。
  4. 每次新增/修改/删除后,重新走一遍保存 + 加载流程。

这套逻辑是同步阻塞式的,好处是简单直观,代码好维护;代价是每次操作都读写一次本地存储。但前面说了,体重数据量级小,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 空状态与骨架屏

体重趋势页在没有数据时,不能显示一个空白框或者直接崩掉。我做了三套状态:

  1. 无数据状态:显示一个"还没有记录,点击下方按钮添加第一条"的空状态,配一个图标和 CTA 按钮。
  2. 单条数据状态:折线图只有一个点,没有线可以画。这个状态下隐藏图表,只显示统计卡片,并提示"再记录一条就能看到趋势了"。
  3. 正常状态:数据两条以上,展示完整图表。

单条数据这个边界情况特别容易忽略。很多开发者写完折线图后,自己测试时都有好几条数据,没意识到只有一条数据时 fl_chart 会怎么表现。实测下来可能会出现一条不可见的点或者异常空白,所以最好在数据层做一次判断,主动对单条数据做降级处理。

5.4 录入页面的交互设计

体重录入页面是模态弹出的,包含日期选择、体重输入框、备注输入框。

日期默认是今天,允许用户回填过去的记录(很多人是周末补记一周的)。体重输入框自动聚焦弹键盘,默认显示上次记录的值,省去用户重新输入的麻烦。备注字段是可选的,比如"运动后""早晨空腹"这类补充信息。

保存按钮在大键盘弹出时会被顶上去,我在布局里用了Scaffold的resizeToAvoidBottomInset默认配置,没有额外处理,效果正常。有一点值得注意的是,OpenHarmony 上输入法弹出时的避让行为与 Android 略有差异,如果发现按钮被遮挡,可以检查是否是SafeArea配置导致的,而不是立即去处理viewInsets。

5.5 列表页:最近记录的展示

趋势图下方我还加了一个"最近记录"列表,展示最近 10 条记录,每条显示日期、体重值和备注。列表项支持左滑删除,删除时弹出确认框,防止误操作。

这个列表的作用不只是展示,它也是编辑入口。点击某一条记录可以进入编辑页面,方便用户修改录错的数值。列表和图表共用同一份数据源,删掉一条记录后,图表会立刻刷新——这里同样用了_refreshSignal通知,不需要手动去同步两个组件。

6. 实战排错与避坑:OpenHarmony 上踩过的七八个坑

6.1 图表不显示?先检查数据是否为合法的 FlSpot 序列

最常见的问题:数据都传了,但折线图一片空白。排查下来,八成是spots里有非法值,比如double.nan或double.infinity。这些值通常来自空列表做聚合运算时的除零错误,活生生把数据源变成了非法数字。

我的排查步骤是:

  1. 在数据聚合函数后打日志,打印spots.length和首尾值。
  2. 检查聚合列表中是否存在 weight 小于 0 的值。
  3. 用 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 上的库都能直接使用。遇到依赖不兼容时,我的处理优先级是:

  1. 查 OpenHarmony 社区适配列表:很多常用库官方或社区已经有适配版本,直接用适配版的包名。
  2. 换纯 Dart 实现的替代库:不依赖原生平台通道的库,兼容性通常最好。
  3. 自己实现轻量替代:如果功能不复杂,比如一个本地存储,完全可以自己用文件读写封装,不必依赖第三方。

从经验来看,健康类 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 层面的适配工作量其实没那么大。

最后分享一个我自己养成的习惯:每个模块开发前,先用纸笔画一遍数据流——从用户输入开始,到数据存储,再到图表展示,每个环节的角色和数据格式都标出来。这个习惯帮我避免了很多"写到一半才发现数据格式不统一"的返工。你如果也在做类似的项目,可以试试这个方法,说不定能帮你省下半天调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询