☰
Flutter迁移OpenHarmony:健康统计概览模块的工程化实践
2026/10/21 7:43:01 网站建设 项目流程

这两年我一直在用 Flutter 做健康类应用的跨平台开发,去年开始把一个原本跑在 Android 和 iOS 上的身体健康记录 App 往 OpenHarmony 生态迁移。项目本身不算特别复杂,核心就是让用户记录体重、血压、心率和步数,再通过首页的统计概览把健康趋势可视化出来。整个迁移过程中,最有嚼头、也最值得复盘的就是统计概览模块——它不只是把数据画成图表那么简单,数据模型设计、多粒度聚合计算、图表选型、渲染性能优化,每个环节都有讲究。

接下来我按实际开发的顺序,把设计思路、数据层方案、页面实现和踩坑记录完整梳理一遍。如果你也在做健康类应用,或者正准备用 Flutter 切到 OpenHarmony 开发,可以直接照着这条思路走。我不会从头讲 Flutter 基础语法,但每个关键决策背后的理由都尽量掰开揉碎。这个模块看着不起眼,真正做起来以后你会发现,它其实是整个 App 里最考验工程能力的地方。

1. 项目背景、架构选型与数据模型设计

1.1 为什么选择 Flutter for OpenHarmony 这套组合

先说一下项目来历。团队原先的健康记录 App 是一套标准的 Flutter 工程,状态管理用 Provider,本地存储用 sqflite,业务代码量加上测试接近十万行。去年接到适配 OpenHarmony 设备的需求时,我们认真对比过三条路:用 ArkUI 重写界面、用游戏引擎做跨平台、用 Flutter for OpenHarmony 做引擎适配。

当时组里有人倾向于 ArkUI,说既然目标系统是 OpenHarmony,不如干脆原生重写,以后少一层依赖。这个想法不是没道理,但现实是健康 App 的页面数量不算少,统计、图表、表单、设置,每个页面都重写,视觉效果还要保持一致,两个月排期根本打不住。游戏引擎那条路直接出局,因为我们的 UI 是典型的应用界面,不是高频交互的动画场景,用游戏引擎代价高收益低。

剩下就是 Flutter for OpenHarmony。这个方案本质上是由多家厂商社区联合推动的 Flutter 引擎移植成果,把 Flutter 的渲染管线、Dart 运行时、组件树体系整体迁移到 OpenHarmony 的图形栈之上。对业务开发者来说,最实在的一点是:Dart 代码几乎原样跑,Widget 树不用重写,手势、动画、路由这些 Flutter 框架能力都能复用。我们当时做了一个快速验证:把原工程的 main.dart 直接编译跑在一个测试板子上,页面出来那一刻,整个团队心里就有底了。

当然,选它也有代价。最明显的是插件生态还没跟上,Flutter 丰富的三方库在 OpenHarmony 上不一定有对应实现,遇到 sqflite、path_provider 这种基础库缺失或者行为不一致的情况,需要自己写平台适配或者换方案。还有一个问题是引擎启动耗时比 Android 多几百毫秒。这些坑我们后面都遇到了,解决方案也会单独拿出来说。

1.2 统计概览模块在健康 App 里的定位

健康记录 App 的首页就是统计概览,它相当于银行 App 的资产总览页:用户一打开 App,你不能让他自己去翻报表,而是要在两三秒内回答三个问题——今天身体的主要指标是多少?最近的趋势是在变好还是变差?哪些指标需要关注?

基于这个目标,统计概览被拆成四个核心任务:

  • 今日核心指标展示:体重、血压、心率的当日最新值或者设定时段内的均值,步数的当日累计值。
  • 周期趋势图:以折线图形式展示近 7 天、近 30 天的体重变化、收缩压变化、心率变化。
  • 指标分布统计:用柱状图或饼图展示一段时间内血压测量值属于正常、偏高、偏低的区间占比。
  • 最近记录列表:倒序展示最近录入的健康记录,点击后可以直接跳到详情或编辑。

四块任务里,趋势图和分布统计最难,因为它们的正确性完全取决于聚合逻辑。举个例子:用户某天量了三次体重,趋势图这一天到底画哪个值?画所有点吧,图会变得很密,如果用户一天量十次,七天就是七十个点,还没算上周末加密测量的场景。只画最后一次吧,可能丢掉早晨空腹体重和晚上睡前体重对比的参考价值。所以从数据模型到聚合 SQL,都要围绕“每个粒度只产出一个有意义的汇总值”来设计。这也是为什么我在开发初期宁可在数据层多花时间,也不急着写 UI。

1.3 健康记录表设计的来龙去脉

统计概览开发初期,我们吃过数据表设计不当的亏。最开始的表结构非常偷懒,就是一张通用大宽表,字段是type、value、measured_at,当时觉得够用了。结果写统计 SQL 的时候怎么都别扭:一会儿要按天分组,一会儿要按周分组,一会儿还要算均值、最大值、最小值,SQL 越来越长,索引效率也撑不住。

后来重构数据层,把健康记录表细化了。核心字段如下:

字段类型说明
idINTEGER 主键自增主键
typeTEXT指标类型,枚举:weight、pressure_systolic、pressure_diastolic、heart_rate、step
valueREAL记录数值
unitTEXT计量单位,如 kg、mmHg、次/分、步
measured_atINTEGER测量时间戳,毫秒级
noteTEXT用户备注
is_deletedINTEGER软删除标记,默认 0

为什么用type而不是五个独立字段?因为后期加新指标(比如血糖、体温)只需要加枚举值,不用改表结构。为什么用毫秒级时间戳而不是字符串时间?因为数据库的日期函数可以直接对整数做 range 查询,性能更好,也避免时区转换造成的字符串解析开销。

建表 SQL 大致是这样:

CREATE TABLE health_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, value REAL NOT NULL, unit TEXT DEFAULT '', measured_at INTEGER NOT NULL, note TEXT DEFAULT '', is_deleted INTEGER DEFAULT 0 ); CREATE INDEX idx_health_records_type_time ON health_records(type, measured_at DESC);

这个复合索引非常关键。统计概览的查询基本都是“取某个类型在某个时间段内的数据”,索引顺序type在前、measured_at在后,可以让数据库直接走索引过滤,不用全表扫描。我们在三千条测试数据上对比过,加索引和不加索引的聚合查询耗时能差出七八倍。

在 Dart 侧,对应的模型类是这样的:

class HealthRecord { final int? id; final String type; final double value; final String unit; final int measuredAt; final String note; HealthRecord({ this.id, required this.type, required this.value, required this.unit, required this.measuredAt, this.note = '', }); factory HealthRecord.fromMap(Map<String, dynamic> map) { return HealthRecord( id: map['id'] as int?, type: map['type'] as String, value: (map['value'] as num).toDouble(), unit: map['unit'] as String? ?? '', measuredAt: map['measured_at'] as int, note: map['note'] as String? ?? '', ); } }

这里有一个很实际的提醒:sqflite 在 OpenHarmony 上如果找不到原生的平台实现,可以换用 drift 的自定义后端,或者直接走 OpenHarmony 的 RDB 关系型数据库封装。我们在项目中给 sqflite 写了一个轻量适配层,把openDatabase、query、insert这几个核心方法转到 RDB 上,上层业务代码改动很小。这个封装思路对所有平台差异都适用,后面还会再强调。

2. 功能拆解与图表方案选型

2.1 统计概览需要哪些子能力

动手写代码之前,我习惯先列能力清单,把 UI 元素和数据来源一一对应起来。统计概览页的 UI 元素有这么几块:顶部是日期范围切换器,下方是今日汇总卡片,再往下是趋势图卡片、分布统计卡片、最近记录列表,以及右下角悬浮的新增记录按钮。

对应到能力层面,至少需要六项:

  1. 日期范围切换:支持快速切换近 7 天、近 30 天,展示开始和结束日期,且统计卡片、趋势图、分布图联动刷新。
  2. 今日指标计算:从今天的记录里按指标类型分别输出当日汇总值。
  3. 周期数据聚合:根据日期范围拉取记录,按天分桶,配合统计口径计算每个桶的数值。
  4. 图表渲染:折线图、柱状图、环形图,包含简单的动画与 tooltip。
  5. 记录列表加载:分页倒序加载最近 50 条记录,支持下拉刷新。
  6. 空态与异常态处理:没有任何记录时展示引导,避免白屏和 0 值误导。

如果把这六项能力彻底做完,统计概览页基本就能稳定工作了。实际开发中,第 3 项周期数据聚合是核心中的核心,它既是图表的直接数据源,也是最容易被低估复杂度的地方。我们最早就是在这里想过用一条 SQL 解决所有问题,结果走了不少弯路。

2.2 图表方案:调研一圈后发现还是混合策略最稳

图表是统计概览的门面,选型不能拍脑袋。我们当时认真调研了三个方向:成熟图表库、轻量图表库、完全自绘。

在 Android 和 iOS 的 Flutter 业务里,我们一直用的是 fl_chart,上手简单,折线图、柱状图都够用,动画效果也不错。fl_chart 本身是纯 Dart 实现,绘制走的是 Flutter Canvas,不依赖原生控件,理论上在 OpenHarmony 上也能跑。这一点比很多平台通道类插件要放心得多,它不会因为某个原生接口不存在就直接挂掉。

不过在兼容性验证时,我们遇到了一个奇怪的现象:OpenHarmony 的 Canvas 文本绘制在某个引擎版本上表现异常,坐标轴文字偶尔会漏掉,重启应用又好了。排查到最后发现是 Flutter for OpenHarmony 的文本布局缓存更新时机和 Android 不同。这个问题后面在常见问题章节详说。

最终我们用了一个混合策略:普通趋势图用 fl_chart,特殊的区间分布图用 CustomPainter 自绘。这样既控制了开发量,又把最不可控的核心图形掌握在自己手里。三种方案的对比:

方案优点缺点我们的判断
fl_chartAPI 成熟、折线和柱状图覆盖好、动画完善包体较大,文本绘制偶发兼容问题可用,但需要适配补丁
syncfusion_flutter_charts图表类型最全、文档丰富体积大,商业授权有额外成本不采用,对轻量场景太重
CustomPainter 自绘包体最小,绘制逻辑完全可控开发量大,边界 case 多用于自研分布图,最稳

这里也给一个选型建议:如果只是做简单折线趋势,优先用成熟库;如果涉及非常规的图表样式,或者你发现第三方库在目标平台上出现了无法绕过的渲染 bug,不要犹豫,直接自绘。自绘并没有想象中那么可怕,后面我会贴一个环形图的绘制核心逻辑。

2.3 统计口径:先定规矩再写代码

统计口径是整个模块的灵魂,口径不定清楚,图表画出来也毫无意义。我们当时和产品对了几轮,定了下面这些硬规矩:

  • 体重日值:取当天所有测量值的算术平均值。理由是体重一天内波动明显,均值更能代表整体水平,末次值容易受饮食、喝水等短期因素影响。
  • 血压日值:舒张压和收缩压分别取当天测量值的均值,但要求收缩压和舒张压必须来自同一条记录,不允许单独统计。
  • 心率日值:展示当天最大值、最小值和平均心率,趋势图默认展示平均心率,用户可切换。
  • 步数日值:求和。
  • 周统计:周一至周日作为一个自然周,周平均值等于本周每天日值的平均值,周总量等于本周步数总和。
  • 月统计:自然月,口径同周。

这些规矩落到代码之后,聚合 SQL 的复杂度立刻降了一半,因为每个指标都明确了“我是要对原始记录做一次聚合,还是先做日聚合再做二次聚合”。一个容易忽略的问题是:OpenHarmony 生态里可能存在平板、手机、电视盒子等多个设备共享数据的场景,统计周期的定义必须全局统一,否则不同设备对“周”的起始日定义不一致,数据就会漂移。我们统一按 ISO-8601 的周定义,周一为一周的第一天。

提示:统计口径必须是文档级别的明确约束。如果你在代码里临时发挥,比如“今天先画最后一个值吧”,后期数据一多,图表前后口径不一致,用户只会觉得这是 Bug。

3. 统计概览页面实现全流程

3.1 页面骨架与滚动结构

统计概览页面用CustomScrollView来搭建,主要原因是页面内容较多,需要支持整页下拉刷新和局部滚动。结构大概是:

CustomScrollView( slivers: [ SliverAppBar( title: Text('统计概览'), actions: [/* 设置入口、分享入口 */], pinned: true, ), SliverToBoxAdapter(child: _buildDateRangeSwitcher()), SliverToBoxAdapter(child: _buildTodayCards()), SliverToBoxAdapter(child: _buildTrendCard()), SliverToBoxAdapter(child: _buildDistributionCard()), SliverToBoxAdapter(child: _buildRecentHeader()), SliverList( delegate: SliverChildBuilderDelegate( (context, index) => _buildRecordItem(_records[index]), childCount: _records.length, ), ), ], )

用 Sliver 而不是普通的 ListView 嵌套 Column,有两个原因。第一是避免“无限高度”问题,如果页面里既有图表又有列表,直接套 Column 再塞 ListView,滚动时会冲突;第二是扩展性,后面要加今日提醒、快捷入口等模块,直接往 slivers 数组里插元素就行,不用改布局层级。

这段结构看着简单,但它是整页稳定性的基础。我们在 OpenHarmony 真机上验证过,使用 Sliver 的页面滚动流畅度明显好于嵌套滚动,尤其在图表区域有复杂绘制时。如果你的页面有多个自定义区域,建议一开始就用 slivers,不要图省事用 Column。

3.2 今日指标卡片的实现

今日指标卡片的布局是一行三张卡片:体重、血压、心率。每个卡片的内容来自一个TodaySummary对象。

class TodaySummary { final double? weight; final double? systolic; final double? diastolic; final int? avgHeartRate; final int? maxHeartRate; final int? minHeartRate; final int stepCount; }

加载逻辑分两步:先按measured_at >= 当天零点 && measured_at < 明天零点查记录,再按指标类型分组,最后调用各自的口径函数计算。这里要注意,“当天零点”不能按本地时间的零点硬编码,应该根据系统时区动态计算:

DateTime now = DateTime.now(); DateTime startOfDay = DateTime(now.year, now.month, now.day); DateTime startOfNextDay = startOfDay.add(const Duration(days: 1)); int startTs = startOfDay.millisecondsSinceEpoch; int endTs = startOfNextDay.millisecondsSinceEpoch;

如果直接拿当前时刻的 hour 和 minute 做字符串比较,很容易在跨天、夏令时场景踩坑。OpenHarmony 上大部分设备默认是北京时间,但我们也支持用户手动选择时区,所以这一块必须统一走时间戳,而不是本地格式化后的字符串。

卡片上还会显示一个“与昨天对比”的箭头,比如今天的体重比昨天涨了 0.5kg。这个对比逻辑就是用两天的日聚合值相减,逻辑不复杂,但日期边界必须用同一个“日键”生成函数,不能一边用本地日期、一边用 UTC 日期。我们最初就在这里出过 bug,用户发截图说箭头方向错了,排查半天发现是时区偏移导致的。

3.3 趋势图:以体重趋势为例

趋势图的数据准备是整个页面里最核心的函数。先按日期范围查原始记录,再在 Dart 内存里按天分桶,最后计算每天的平均值、最大值、最小值:

Future<List<DailySummary>> fetchDailySummaries( String type, DateTime startDate, DateTime endDate, ) async { final startTs = DateTime(startDate.year, startDate.month, startDate.day).millisecondsSinceEpoch; final endTs = DateTime(endDate.year, endDate.month, endDate.day, 23, 59, 59).millisecondsSinceEpoch; final rows = await db.query( 'health_records', where: 'type = ? AND measured_at >= ? AND measured_at <= ?', whereArgs: [type, startTs, endTs], ); // 按天分组 final Map<String, List<HealthRecord>> grouped = {}; for (final row in rows) { final r = HealthRecord.fromMap(row); final dateKey = _dateKeyFromTs(r.measuredAt); grouped.putIfAbsent(dateKey, () => []).add(r); } // 每天聚合 final results = <DailySummary>[]; grouped.forEach((dateKey, records) { final values = records.map((r) => r.value).toList(); final avg = values.reduce((a, b) => a + b) / values.length; results.add(DailySummary( dateKey: dateKey, avgValue: avg, maxValue: values.reduce(max), minValue: values.reduce(min), )); }); results.sort((a, b) => a.dateKey.compareTo(b.dateKey)); return results; }

这段代码有两个容易出错的点。第一,_dateKeyFromTs要把时间戳转成本地时间再生成yyyy-MM-dd,不能直接用 UTC 时间格式化,否则非 UTC 时区下所有日期都会偏移一天。第二,如果某一天没有任何记录,grouped里就没有这个 key,图表上这一天会是空点。为了让折线连续,生成完集合后还要补全缺失日期,补的值为 null,图表库会把它当作断点处理。

// 补全日历日期,让时间轴不缺失 final allKeys = _generateDateKeys(startDate, endDate); for (final key in allKeys) { if (!summaryMap.containsKey(key)) { summaryMap[key] = DailySummary(dateKey: key, avgValue: null, maxValue: null, minValue: null); } }

补全空日期这个动作,能让日均值折线图在用户某天漏记录时依然显示完整时间轴,而不是跳过一个点导致横轴刻度不连续。实测这个细节对用户感知影响很大。

画折线图用 fl_chart 的LineChart,核心参数是 spots 和 titles:

LineChart( LineChartData( minY: minY * 0.9, maxY: maxY * 1.1, lineBarsData: [ LineChartBarData( spots: spots, isCurved: true, dotData: FlDotData(show: false), barWidth: 3, ), ], titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) { // 按索引显示短日期,如 03/01 return Text(_shortDate(value.toInt())); }, ), ), ), ), )

minY * 0.9和maxY * 1.1这个边界 padding 很关键。如果直接用数据的最大最小值作为 Y 轴范围,折线会顶到图表边缘,视觉上十分局促。留出 5% 到 10% 的上下边距,整个图表看起来就自然得多。这个细节属于“不做不知道,做了就回不去”的类型。

3.4 分布统计与最近记录列表

分布统计展示的是血压区间占比。我们按医疗参考范围把收缩压分为三档:低于 90 为偏低、90 到 139 为正常、140 及以上为偏高。这个分档本身不是医疗建议,只是产品展示用的参考区间,所以页面上也写了“仅供参考”。

实现方式是加载周期内所有血压记录,把收缩压和舒张压作为成对数据读取,然后逐个分类,统计三个桶的数量,最后用自绘环形图展示:

class DistributionChartPainter extends CustomPainter { final Map<String, int> bucketCounts; @override void paint(Canvas canvas, Size size) { final total = bucketCounts.values.fold(0, (a, b) => a + b); if (total == 0) return; final rect = Rect.fromLTWH(0, 0, size.width, size.height); final stroke = Paint() ..style = PaintingStyle.stroke ..strokeWidth = 18 ..strokeCap = StrokeCap.butt; double currentAngle = -pi / 2; for (final entry in bucketCounts.entries) { final sweepAngle = entry.value / total * 2 * pi; stroke.color = _colorForBucket(entry.key); canvas.drawArc(rect, currentAngle, sweepAngle, false, stroke); currentAngle += sweepAngle; } } @override bool shouldRepaint(covariant DistributionChartPainter oldDelegate) { return oldDelegate.bucketCounts != bucketCounts; } }

自绘环形图的要点是算圆弧:起始角度是上一段的结束角,扫过角度是当前段占比乘以 2π。用粗描边代替实心圆环,再留出 18 左右的 strokeWidth,就能画出环形效果。颜色方面,正常段用绿色系、偏高段用橙色系、偏低段用蓝色系,方便用户一眼看清分布。

最近记录列表就是一个SliverList,每条记录展示类型图标、数值、测量时间、备注。需要倒序加载,并做分页。在 OpenHarmony 上要注意 item 数量不要一次性铺太多,否则滚动会顿。我们用每页 30 条,滚动到底时自动加载更多。列表项本身很轻,没有复杂嵌套,所以性能问题主要集中在图表区,而不是列表区。

4. 统计聚合逻辑的工程化实践

4.1 大胆简化聚合 SQL,换来可维护性

早期为了追求效率,我们试图用一个超长 SQL 同时完成“每天均值 + 趋势图数据”的输出,大概长这样:

SELECT date(measured_at / 1000, 'unixepoch', 'localtime') as day, AVG(case when type='weight' then value end) as avg_weight, MAX(case when type='heart_rate' then value end) as max_heart_rate FROM health_records WHERE measured_at BETWEEN ? AND ? GROUP BY day;

这个 SQL 看似聪明,SELECT 里做 case 聚合,但写起来越来越复杂。尤其是血压记录需要成对判断,一旦出现单条记录只有收缩压没有舒张压的情况,case 条件就要额外加 EXISTS 判断,维护成本很高。到了后期,基本只有写这条 SQL 的人自己能看懂,别人接手根本不敢改。

后来我换了个思路:SQL 只负责按类型和时间的粗粒度条件筛选,把“每条记录属于哪一天”的映射交给一个纯 Dart 函数去算,然后分组聚合。这样 SQL 变得非常简单,Dart 侧逻辑也清晰可控。

final rows = await db.query( 'health_records', columns: ['type', 'value', 'measured_at'], where: 'type IN (?) AND measured_at >= ? AND measured_at <= ?', whereArgs: [_selectedTypes.join(','), startTs, endTs], orderBy: 'measured_at ASC', );

拿到原始记录后,在 Dart 内存里完成分桶。有人会说这样内存占用高,但健康记录 App 单用户的数据量级一般就在几万条以内,加载 7 到 30 天的数据最多也就几千条,内存占用完全可接受。换来的是逻辑透明:你能在 Dart 里单步调试每天的聚合值,这在复杂 SQL 里根本无法做到。

注意:这里有一个前提,就是查询条件必须走索引。如果type和measured_at没有联合索引,全表扫描加载几十个月的记录,内存和耗时都会激增。索引是这种“SQL 取数 + Dart 聚合”模式能成立的基础。

4.2 多粒度统计的缓存策略

统计概览页面每次切换周期、切换指标,都要重新跑聚合。如果数据量不大还好,但随着使用时间拉长,用户半年、一年的数据量会显著增长,每次都现算,耗时就会从几十毫秒涨到几百毫秒。这个差距在屏幕上的表现就是“切日历时卡片先空白一秒,再蹦出来”,非常掉好感。

我们的工程化做法是引入一张“日汇总缓存表”:

CREATE TABLE daily_summary_cache ( type TEXT NOT NULL, day_key TEXT NOT NULL, avg_value REAL, max_value REAL, min_value REAL, sum_value REAL, count_value INTEGER, updated_at INTEGER, PRIMARY KEY (type, day_key) );

每次写入新记录时,增量更新对应day_key的汇总,而不是全量重算。例如用户新插入一条体重记录,我们只需要重新计算今天这一天的体重汇总,再更新缓存表里type='weight' AND day_key=当天那一行。

这里要非常小心并发和幂等。我们用的是事务包裹:先查出该用户当天该类型的所有原始记录,再在事务里计算汇总并 upsert 到缓存表。这样即使同一个事务里插入了两条记录,也能保证最终缓存正确。实测下来,统计概览首屏的耗时从原来的 300ms 左右降到了 100ms 以内,切换日期的表现也丝滑了很多。

缓存表带来的另一个好处是:图表的数据源可以直接查缓存表,而不是每次都去扫原始表。比如近 30 天的趋势图,只需要查 30 行缓存数据,按日期排个序,就足够绘制了。这一步对 OpenHarmony 上性能相对有限的低端设备作用尤其明显。

4.3 时间切换与边界条件处理

时间切换的边界条件,比大多数人想象的多得多。举几个我们实际踩过的坑:

  • 用户刚下载 App,今天还一条记录都没有。此时趋势图要显示空状态图,而不是白屏或 0 值折线。
  • 用户选择“近 7 天”,但其中有好几天的数据是 null。这里不能把 null 当 0 显示,否则折线会突然掉到 0,看起来像体重暴跌。
  • 切换周期时,如果当前选中的指标类型没有数据,卡片要显示“--”,不能显示 0。
  • 如果用户手滑切到了未来月份,日期范围的结束日期不能超过今天,否则一个空数据的未来周期会展示成异常状态。

处理这些边界条件时,我们统一封装了一个小工具类:

class StatDataChecker { static bool hasData(List<DailySummary> summaries) { return summaries.any((s) => s.avgValue != null); } static DateTime effectiveEndDate(DateTime rawEnd) { final now = DateTime.now(); return rawEnd.isAfter(now) ? now : rawEnd; } }

这些小函数看着简单,但能避免九成以上的空数据崩溃。我们在代码评审时有一条硬性要求:所有 UI 取值前必须走判空,不允许出现对可空值裸调toString()的情况。这条规矩救了我们很多次,尤其是新同学接手代码时,不太容易踩到空安全之外的逻辑空值坑。

5. OpenHarmony 真机适配与性能调优

5.1 从 Android 迁移到 OpenHarmony 的适配清单

把统计概览从 Android 迁移到 OpenHarmony,核心适配点可以整理成一份清单,方便其他团队直接对照:

适配项AndroidOpenHarmony处理方式
存储路径getDatabasesPath()RDB 沙箱路径写适配层封装
权限声明AndroidManifest.xmlmodule.json5替换为 ohos.permission 声明
包结构APKHAP使用对应的构建工具链出包
平台通道FlutterMethodChannelFlutter 通道底层不变部分插件需要重新实现原生端
日志输出Logcathilog建立日志封装,按 TAG 过滤

sqflite 是典型的插件适配案例。在 OpenHarmony 上,如果社区还没有完整的 sqflite 实现,我们直接改用了系统 RDB 服务,但为了让上层业务代码不受影响,我把所有数据库访问都封装在了一个AppDatabase接口后面,上层只依赖接口。这样替换底层实现时,改动被限制在一个文件里,其他页面完全无感。

这个封装思路对所有平台差异都适用。如果你正在做类似的迁移,强烈建议先梳理项目中所有直接依赖平台通道的调用点,提前抽象接口。我们当时预估迁移工作量的误差,有一半是因为没有算上这些隐藏的平台耦合点。

权限声明是另一个容易忽略的点。健康记录 App 如果要在后台记录步数,OpenHarmony 上需要在module.json5里声明对应的权限,而不能沿用 Android 的权限模型。我们在真机上第一次跑步数统计时,发现数据一直为零,排查半天才发现是权限没配,而不是业务逻辑出错。

5.2 引擎启动与首屏渲染优化

Flutter for OpenHarmony 刚编译出来的应用,启动首帧时间比 Android 慢一些。第一次在测试机上跑,首帧大概用了 1.2 秒,Android 上是 0.7 秒。这个差距会让用户明显感知到“卡”,尤其是健康记录类 App 的用户,往往每天早上打开 App 第一件事就是看一眼昨天的数据和趋势。

我们做了三件事来优化:

  1. 减少首帧前的同步工作量。在main()里只初始化导航框架和数据库配置,把健康记录的预加载、汇总缓存预热全部放到首帧之后的Future.microtask里。
  2. 开启 Flutter 引擎预启动模式。在 OpenHarmony 的容器层做一个单例引擎,下一个页面打开时复用已有引擎实例,避免重复创建线程和渲染环境。
  3. 配置启动占位图。启动页先展示品牌图标,Flutter 首帧起来后无缝切换,体感提升非常明显。

这里有必要解释一下预启动的原理。Flutter 引擎的创建是一个重操作,涉及 Dart 虚拟机初始化、渲染线程启动、GPU 上下文建立等。传统 Flutter 应用每次冷启动都会完整做一遍。预启动就是让容器一进入前台就开始初始化引擎,用户真正跳进 Flutter 页面时,引擎已经就绪,可以少掉几百毫秒的等待。

5.3 图表渲染性能的逐层优化

图表渲染性能是统计概览的另一个瓶颈。fl_chart 在数据点很多、且每个数据点都带 tooltip 和动画时,重绘开销不小。我们在真机上遇到过滚动页面时图表区域掉帧的问题,现象是手指滑动时折线区域边缘有明显的撕裂感。

优化经过了三层:

  • 数据层:控制传给图表的点数。近 30 天最多 30 个点,拉长到近 90 天也不会超过 90 个。数字上没有压力,但要在加载数据时检查重复点,同一个时间戳的记录只保留一个聚合值,避免冗余点。
  • 绘制层:关闭图表的默认动画和 tooltip 悬停效果。LineChart用duration参数控制动画时长,我们直接设为 0,因为数据切换时用户关注的是结果,不是动画过程。关闭渐变填充和阴影也能省掉不少绘制压力。
  • 刷新层:统计卡片和趋势图使用同一个ValueNotifier触发刷新。如果数据源没有变化,就不重建图表组件。这个优化听起来简单,但真正确认“数据是否变化”需要给DailySummary实现好==和hashCode,否则比较永远是 false,等于没有优化。

另外一个小坑:在 OpenHarmony 上,RepaintBoundary的使用比 Android 上更敏感。我们给图表卡片外层包了RepaintBoundary,让图表单独成为一个可重绘区域,滚动页面时不会导致整页重绘。但注意不要滥用,如果每个小图标都包一层,反而增加内存和层级复杂度。正确的用法是只给高频更新的复杂绘制区域加。

6. 常见问题排查实录与避坑总结

6.1 图表在 OpenHarmony 上白屏

现象:统计概览页其他内容都正常,只有折线图区域空白,但左上角的文字隐约能看到。

排查:先怀疑数据为空,打印趋势图数据发现完全正常。然后把折线图单独抽到一个测试页面跑,依然白屏。后来发现是文本绘制与 Canvas 图层合成的兼容问题,坐标轴文字渲染异常时,整个绘制图层被跳过。

解法:升级到修复该问题的引擎版本,同时给趋势图坐标轴文字加了显式的 TextStyle,指定字体族为默认字体。升级后问题消失,此后再没有复现。

这个案例提醒我:OpenHarmony 上的 Flutter 引擎迭代速度很快,遇到奇怪的渲染问题,第一件事要查引擎版本的更新日志,而不是在业务代码里死磕。我们当时浪费了半天时间检查布局和颜色,结果一个版本升级就解决了。

6.2 切换日期后统计值不刷新

现象:在概览页从“近 7 天”切到“近 30 天”,顶部日期范围变了,但图表数据还是旧的。

排查:状态管理里缓存了查询结果,切换周期时查询函数确实被调用了,但页面用的数据源没有切换到新的 Future 上。这是典型的异步竞态:旧请求返回慢,晚于新请求返回,于是覆盖了新数据。

解法:引入请求序号。每次切换周期时_requestSeq加一,查询回调里只有当前序号等于最新序号时才允许写状态。这样即使旧请求晚返回,也不会覆盖新数据。

int _requestSeq = 0; Future<void> _loadTrendData() async { final seq = ++_requestSeq; final data = await _repository.fetchTrendData(...); if (seq == _requestSeq && mounted) { setState(() => _trendData = data); } }

这个写法在 Flutter 社区很常见,属于异步 UI 更新的标准防御姿势。如果你接入了状态管理框架,也可以在框架层面处理竞态,但请求序号的方式最轻量,也最容易理解,建议先在业务层做掉。

6.3 周统计的口径不一致

现象:手机端和开发板端显示的周统计结果不一样。

排查:手机端用的是标准周计算,开发板端的周定义是从周日开始。双方生成的周键不一致,导致同一批数据被分到了不同的周桶里。

解法:统一使用 ISO-8601 周定义,在生成周键时用周一作为每周第一天,并把firstDayOfWeek作为一个常量固化在数据层,禁止各端自行处理。这也是我在统计口径设计里反复强调那一点的直接原因。如果你要支持多个 OpenHarmony 设备,这类跨端不一致问题非常隐蔽,不查数据根本发现不了。

6.4 常见问题速查表

问题可能原因解决方向
图表白屏引擎文本绘制兼容问题升级引擎版本,显式设置字体样式
统计值不刷新异步竞态覆盖使用请求序号防御
周统计不一致周起始日不同统一为 ISO 周口径
首屏慢引擎冷启动预启动引擎,首帧后加载数据
滚动掉帧图表重复重建关闭动画,酌情加 RepaintBoundary
数据重复同一天多次记录聚合层统一口径,不对原始记录做修改
权限导致步数为零未声明对应权限检查 module.json5 声明

最后说两句

我个人做这个项目最大的体会是:统计概览这种“看起来只是一个图表页”的功能,真正写起来最难的不是 UI,也不是图表组件,而是数据口径和聚合逻辑。口径定清楚,聚合做正确,整个页面就稳了。另一条经验是,在 OpenHarmony 这类新生态里做 Flutter 开发,一定要对平台差异有预期,凡是涉及文件路径、数据库、权限、引擎版本的部分,都比在 Android 上多留一些排查时间。

最后再分享一个小技巧:把统计概览的数据聚合逻辑拆成纯 Dart 函数,不依赖任何 Flutter Widget 和平台通道。这样你就可以在桌面环境写测试用例,覆盖各种边界情况,再拿到 OpenHarmony 真机上验证。这一步帮我省了大量在真机上反复安装调试的时间,也让统计口径的改动变得非常安全。如果你的项目也在做健康类应用或者类似的统计模块,建议尽早把聚合逻辑和页面渲染解耦,后面你会感谢这个决定的。

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

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

立即咨询