1. 需求拆解健康记录App的统计概览页到底要展示什么1.1 统计概览页的功能定位与用户诉求统计概览页是我在Flutter for OpenHarmony健康记录App项目里最后啃下来的一块硬骨头。开发到这一步时App已经能正常录入体重、血压、心率、睡眠这些数据了但用户打开App的第一眼看到什么直接决定他愿不愿意把记录这件事坚持下去。统计概览页承担的就是这个“第一眼”的职责——它要在两三秒内回答四个问题今天状态怎么样最近一段时间趋势如何设定目标完成到什么程度连续坚持记录了多少天这四个问题看起来直白真落到界面上却需要不少取舍。比如“今天状态”并不是指把所有最新记录堆在一起而是要把不同量纲的指标统一成“正常/偏高/偏低”的状态卡否则体重80kg和一个心率70bpm放在一起用户根本看不出重点。我在设计时把今日指标卡放在页面最顶端只显示最近一条记录的值、记录时间和一个状态色标点击卡片可以跳转到详细录入页补充数据。这样既完成了“概览”的使命也没有把信息密度做得过大。另一个要点是趋势图。健康数据的价值不在单次测量而在长期变化。我在统计概览页中部放了一张最近7天的多指标折线图默认展示体重和收缩压两条曲线因为这两类数据在绝大多数健康场景里最受关注。折线图下方是目标达成进度环用来展示按周或按月统计的目标完成情况比如“本月运动目标完成86%”。再往下是一个按日分布的记录点阵图用来直观呈现“连续打卡”的状态。1.2 统计口径必须先于UI确定这个模块最容易踩的坑是把UI排得漂漂亮亮之后才发现统计数据算得不对。实际开发中指标口径的确定必须走在界面之前而且最好用文档固定下来。我整理过一份口径清单例如体重取“当日最后一次录入值”还是“当日所有记录的平均值”血压的收缩压和舒张压该怎么配对展示心率是展示全天最高值、最低值还是静息心率睡眠时长按自然日还是24小时滑动窗口统计这些口径如果不在开发初期拍板后期每改一次就要同步修改数据层查询、统计层算法、UI层展示三个地方工作量很大。最终我采用的方案是体重和血压取当日最后一次有效值心率取当日的静息心率凌晨5点到8点的最低值睡眠时长按自然日0点到24点统计并把深睡时长单列。这个口径相对符合日常理解也方便用户对比和验证。整份口径清单我放到项目文档里作为统计模块的基准定义。1.3 页面结构划分与数据刷新策略统计概览页整体结构划分为四个区域顶部是今日概览卡片中间是7天趋势折线图下方是目标进度环底部是连续记录点阵图。这种从“瞬时状态”到“长期趋势”再到“行为激励”的排列顺序符合用户查看数据的直觉开发时也能把渲染职责划分得很清楚。刷新策略是另一个需要提前想清楚的事情。用户可能在录入页新增了一条记录返回统计概览页后页面必须立刻反映最新数据。我在项目里引入了一个简单的数据变更通知机制录入页写入成功后通过全局事件总线抛出一个DataChanged事件统计概览页监听该事件后重新拉取汇总数据。页面自身的下拉刷新也保留用于处理极端情况下的数据同步。这套机制在OpenHarmony上的表现很稳定后面我会详细讲到生命周期相关的坑。2. 数据地基健康记录的模型设计、存储与聚合查询2.1 健康记录的数据模型用动态字段兼容多种指标统计概览页的底层支撑是通用且扩展性好的健康记录数据模型。我最初的设计是把体重、血压、心率、睡眠各建一张表每个表字段各不相同。后来发现这种做法在统计层非常痛苦每接入一个新指标就要重新写一套查询和聚合逻辑而且统计概览页需要同时读多类指标SQL拼接和结果映射都很繁琐。后来我重构为统一的HealthRecord模型核心结构是id、记录类型weight/bloodPressure/heartRate/sleep、时间戳、动态数值字典values、备注文本。values使用Map键名在不同类型下有不同含义。例如体重记录里是{value: 72.5, unit: kg}血压是{systolic: 118, diastolic: 76}睡眠是{duration: 432, deep: 96}单位固定为分钟。用动态字段的精髓在于新增记录类型时不需要改表结构只需要在统计层增加对应的键名解析逻辑。虽然存储上牺牲了一点规范性但对这个体量的App来说非常划算。Dart侧对应模型比较简洁我当时是这样定义的class HealthRecord { final String id; final String type; final int timestamp; final MapString, double values; final String note; HealthRecord({ required this.id, required this.type, required this.timestamp, required this.values, this.note , }); factory HealthRecord.fromJson(MapString, dynamic json) { return HealthRecord( id: json[id] as String, type: json[type] as String, timestamp: json[timestamp] as int, values: (json[values] as MapString, dynamic).map( (key, value) MapEntry(key, (value as num).toDouble()), ), ); } MapString, dynamic toJson() { return { id: id, type: type, timestamp: timestamp, values: values, note: note, }; } }2.2 OpenHarmony下的本地存储选型Flutter标准生态里做本地存储大概率会选sqflite或shared_preferences。但Flutter for OpenHarmony的插件兼容性在项目启动时还没完全对齐我不能把核心数据存储押在一个可能没有OpenHarmony原生实现的插件上。权衡之后我采用了“按天分文件的JSON存储 内存索引”的方案。具体做法是在应用私有目录下建立一个records目录按天生成Y-MM-DD.json文件当天所有的健康记录都追加写入这个文件。写入时做一次内存索引更新索引结构是“日期字符串 - 记录列表”。这样统计概览页需要展示哪一天的数据直接按日期取索引读取对应文件并解析成HealthRecord列表。这个方案牺牲了一部分查询灵活性换来了极高的稳定性和可控性。文件I/O在OpenHarmony上表现稳定即使未来要迁移到数据库也只需要把JSON文件逐条导入。还有一点很重要健康数据敏感所谓“上云”在这个阶段完全不在考虑范围内所有记录默认只存在本地文件级别的隐私隔离非常简单直接。2.3 聚合查询汇总表是统计页的命根子统计概览页真正需要的数据并不是“某天有哪些记录”而是“某天的体重均值是多少”“最近7天血压趋势如何”。如果每次都全量扫描文件再在Dart侧计算数据量小的时候没问题数据量增长到几个月后页面会明显卡顿。我在录入层做了关键设计每次写入新记录时同步更新一个dailySummary.json汇总文件。汇总文件的结构是“日期 - 各指标的当日汇总结果”例如体重当日的最后值和均值、血压的最值和最后一次值、心率的静息值、睡眠的总时长和深睡时长。统计概览页的所有图表基础数据都优先读这份汇总文件只有用户点击具体某天才去读原始明细。这个“写时汇总”的设计把统计概览的首屏查询成本从一开始的“遍历所有记录”降为“读一个小JSON文件”代价是录入操作稍稍变重。考虑到健康记录的写入频率本来就很低一天最多十几次这个取舍非常划算。聚合结果在内存里也被缓存为不可变对象任何UI刷新都不会触发重复计算。3. 图表渲染的选型实录自绘Canvas、图表库与混合方案3.1 第一轮纯CustomPaint自绘能画但要处理的细节很多图表渲染是统计概览页技术含量最高的部分。我第一轮选择用Flutter的CustomPaint全自绘理由很直白在OpenHarmony上运行时我不能确定社区图表库对这套底层环境的适配程度。自绘折线图、环形进度图核心路径其实不复杂折线图就是画坐标轴、网格线、数据点连接环形图就是画两个圆弧。这一轮做出来的效果视觉上完全可控渐变色、圆角、阴影都能精细调整。但代价也很快暴露出来坐标轴刻度计算、辅助线文字位置、tooltip交互、手势滑动查看历史数据这些和图表无强关联但用户很在意的细节工作量远超预期。尤其是刻度标签我需要在Canvas上计算文本宽度来防止重叠这部分代码又绕又容易出Bug。更现实的问题在性能。统计概览页是一个滚动的长页面整个页面如果只有一个大的CustomPaint任何局部状态变化例如进度环动画都会触发整页重绘。首次实现时页面滑动明显掉帧后面我花了很大力气做分层隔离这个问题才解决。3.2 第二轮轻量图表库带来的意外麻烦自绘到一半我尝试引入某个社区图表库来验证另一条路。在标准Flutter环境里这个库跑得很好折线图、环形图配置API很顺手。但移植到OpenHarmony的真机上问题接踵而至图表库内部大量使用了文本绘制和手势判定而OpenHarmony对Flutter文本布局的底层实现与标准平台存在差异图表内的坐标轴标签间距、tooltip跟随位置都出现了肉眼可见的偏移。我花了一个下午排查最后定位到是TextPainter在OpenHarmony上的layout结果与测量结果不一致导致的。按理说可以修但这个图表库的源码结构很复杂改动面大而且我不能保证改完不会引出新的兼容性问题。最终我放弃了在核心图表上继续依赖图表库的路线。这次尝试也不是全无收获它帮我明确了一个边界凡是只画形状和颜色的图形折线、圆环、柱条自绘成本低且最可控凡是涉及大量文本排版、滚动交互、多指手势的复杂图表依赖成熟组件反而更安全。可惜我当时手上没有找到专门针对OpenHarmony做过适配的Flutter图表库所以只能自己扛。3.3 第三轮混合渲染各取所需最终方案是混合渲染。折线趋势图和目标达成进度环用CustomPaint自绘因为这两类图形的结构相对简单我完全能控制它们的绘制细节和性能特征。今日概览卡片、记录点阵图则用普通Widget组合点阵图直接使用GridView.builder利用Flutter成熟的布局和复用机制不碰Canvas。混合方案还有一个隐藏优势调试定位问题更快。普通的Widget树可以用Flutter的检查器直接看布局自绘图形则可以通过隔离图层快速判断是绘制逻辑问题还是数据问题。两类问题互不干扰开发效率明显提升。自绘折线图时我封装了一个简洁的趋势图Widget对外只暴露数据和样式参数内部维护一个RepaintBoundary。核心绘制代码其实不长关键部分是坐标映射和路径生成class TrendChartPainter extends CustomPainter { final Listdouble values; final int targetCount; TrendChartPainter({required this.values, required this.targetCount}); override void paint(Canvas canvas, Size size) { final paint Paint() ..style PaintingStyle.stroke ..strokeWidth 2.5 ..isAntiAlias true; final minValue values.reduce((a, b) a b ? a : b) - 1; final maxValue values.reduce((a, b) a b ? a : b) 1; final stepX size.width / (targetCount - 1); final stepY size.height / (maxValue - minValue); final path Path(); for (var i 0; i values.length; i) { final x i * stepX; final y size.height - (values[i] - minValue) * stepY; if (i 0) { path.moveTo(x, y); } else { path.lineTo(x, y); } } canvas.drawPath(path, paint); } override bool shouldRepaint(covariant TrendChartPainter oldDelegate) { return oldDelegate.values ! values; } }4. 统计核心算法的工程细节连续天数、趋势拟合与目标达成率4.1 连续记录天数的计算边界问题比想象中多连续记录天数看起来是最简单的统计项实际实现时边界情况非常多。先说定义我最终采用“截止到昨天的连续记录天数”因为用户通常是在当天某个时候查看首页如果当天还没记录就立即显示中断会制造不必要的焦虑。算法上的关键点有两处。第一必须先把时间戳归一化到日期年月日再用日期对象做差值计算否则直接用DateTime差会受时区、夏令时影响第二要处理间隔判断如果某天没有记录连续计数要重置。我写了一个纯函数来算连续天数方便单测覆盖int calculateContinuousDays(ListDateTime recordDates, {DateTime? benchmark}) { if (recordDates.isEmpty) return 0; final targetDay benchmark ?? DateTime.now(); final today DateTime(targetDay.year, targetDay.month, targetDay.day); final uniqueDays recordDates .map((d) DateTime(d.year, d.month, d.day)) .toSet() .toList() ..sort((a, b) b.compareTo(a)); // 如果今天还没记录从昨天开始往前找 if (uniqueDays.isNotEmpty uniqueDays.first.isBefore(today)) { var cursor today.subtract(const Duration(days: 1)); var count 0; for (final day in uniqueDays) { if (day.isAtSameMomentAs(cursor)) { count; cursor cursor.subtract(const Duration(days: 1)); } else if (day.isBefore(cursor)) { break; } } return count; } var cursor today; var count 0; for (final day in uniqueDays) { if (day.isAtSameMomentAs(cursor)) { count; cursor cursor.subtract(const Duration(days: 1)); } else if (day.isBefore(cursor)) { break; } } return count; }为什么要用“去重排序”而不是遍历所有记录因为同一天可能有多次录入如果不去重会在同一天的记录之间反复横跳。日期比较也不要直接比字符串Dart的DateTime在day维度上比较在覆盖了跨年场景的同时代码也更易读。4.2 7日均值与趋势计算滑动窗口和异常值剔除趋势折线图需要把最近7天的当日值作为数据点。为了让曲线更有参考价值我对原始数据做了两层处理。第一层是异常值剔除健康数据里时常出现手误录入的极端值例如体重突然变成200kg血压收缩压变成260。我用了一个简单的规则——超过历史均值正负3个标准差的值直接丢弃不参与均值和曲线绘制。实现上先算一轮全局均值和标准差第二次遍历时剔除异常值再算有效均值。这个规则在健康场景下比任何复杂算法都直观有效。第二层是滑动窗口平滑。7天的数据量其实很少直接绘制原始点即可。但如果用户切换查看30天趋势原始曲线会过于锐利我做了简单的移动平均处理。窗口大小设为3对每天的值取前后相邻三天的均值这样曲线更平滑也不至于失真。移动平均的代码很轻量Listdouble movingAverage(Listdouble values, int window) { if (values.length window) return List.of(values); final result double[]; for (var i 0; i values.length; i) { final start (i - window 1) 0 ? 0 : (i - window 1); final subset values.sublist(start, i 1); final avg subset.reduce((a, b) a b) / subset.length; result.add(avg); } return result; }值得提醒的是这一步算出来的“展示值”不应该直接覆盖“真实值”。我在数据层把原始汇总、有效值、平滑展示值分了三层统计图只消费平滑展示值原始数据和有效值用于详细的页面展示这样后续加明细功能不用重写计算逻辑。4.3 目标达成率的计算口径与进度环动画目标达成率这个模块最大的坑是指标方向不一致。体重、血压这类指标是“在某个区间内算达标”下限超标和上限超标都算未达成心率也是区间指标睡眠时长则是“不低于某小时算达标”步数则是“越高越好”。如果统一用一个“目标值/实际值”的公式方向相反的指标会得出完全错误的结果。我的做法是给每种记录类型定义GoalRule包含目标下限、目标上限、优先级、方向。计算达成率时先判断该指标的当日值是低于下限、在上限内还是高于上限再映射到0%到100%区间。区间内达标记100%在区间外则根据超出比例线性递减但不为负数。这样整个统计概览页的所有指标都能复用同一套进度环组件。达成率算完之后的动画也值得讲究。进度环从0%转到最终值我用了AnimationController配合Curves.easeOutCubic时长300到500毫秒。动画只在首次进入统计概览页或下拉刷新后播放一次切换tab时则不重播避免动画太过频繁导致用户视觉疲劳。进度环自绘部分读取animation.value由Controller驱动重绘配合RepaintBoundary隔离整个动画过程非常流畅。5. OpenHarmony真机适配排查重绘、字体、生命周期与内存5.1 真机Canvas重绘风暴RepaintBoundary与shouldRepaint把统计概览页第一次跑上OpenHarmony真机时印象最深的问题就是重绘风暴。最初版本的页面只有一个大的CustomPaint包裹整块图表区域进度环动画每帧变化都会导致整个图表区域重绘折线图和点阵图也跟着受影响页面掉帧非常严重。排查过程不复杂我打开Flutter的Performance Overlay看到单帧在图表区域重建的图层数量异常高。解决办法是给进度环单独包一层RepaintBoundary让它的重绘只影响自己折线图也单独包一层RepaintBoundary并在CustomPainter里认真实现shouldRepaint。只有当传入的数据引用发生变化时才返回true否则直接return false。这个改动很小效果却立竿见影。滑动页面和播放动画时的帧率恢复到了正常水平。现在回头看自绘图形越多越应该先规划好渲染边界而不是把所有东西都塞进一个大Canvas。另一点是数据源的一致性传入ChartPainter的列表必须是不可变引用否则外部改动会触发意外重绘排查起来非常隐蔽。5.2 字体、单位符号与文本测量的坑统计概览页在OpenHarmony上遇到的第二个实际问题在字体上。有些单位符号如“µg”“³”在系统字体里渲染异常尤其是自绘Canvas里的文本OpenHarmony的字体回退链和标准Flutter的整体表现不完全一致。我遇到的情况是文本在标准Flutter模拟器上正常到了OpenHarmony真机上变成方框或字距错乱。解决思路是尽量不要在Canvas里绘制会导致字体测量差异的复杂文本所有单位、图例、刻度标签统一用Text Widget铺设只有曲线、圆环这类纯图形留在Canvas里。这样既回避了文本测量差异又让文本样式能直接复用App的Theme不需要在Painter里重复定义TextStyle。在墨菲定律的指引下我最后连表盘中心的“86%”这种短字符串都改成了Stack叠放Text Widget彻底绕开Canvas文本绘制。TextPainter的layout开销在OpenHarmony上也要特别留心。一次文本测量在标准平台可能只是几百微秒但在真机上实测有几千微秒。我在自绘图形上严格限制文本测量次数图表内所有标签的位置在布局阶段就算好并缓存绘制时直接取偏移量。5.3 生命周期差异与后台数据刷新OpenHarmony的页面生命周期与Flutter应用生命周期的映射在部分版本上并不完全及时。最直接的体验是用户切到后台再切回来AppLifecycleState有时不会触发resumed回调统计概览页上的数据就还停留在切出前的状态。我在项目里没有继续依赖生命周期而是改成三重刷新机制。第一重是录入成功后的事件通知这是最主要的数据更新途径第二重是页面内下拉刷新用于手动兜底第三重才是生命周期回调只在捕获到resumed时做一次无感知的静默刷新。这三重机制叠加后统计页的数据新鲜度问题彻底解决。内存方面也有个细节。点阵图如果要铺一整年的记录会有365个子组件如果一次性全部构建会导致明显的帧率抖动。我改成GridView.builder懒加载并且设置较短的cacheExtent。统计概览页的卡顿在真机上明显改善内存在长时使用时的增长曲线也平稳了很多。6. 统计页性能优化清单与实测心得6.1 一页纸的优化清单整个模块打磨下来有价值的优化项基本集中在渲染边界和数据缓存上。我整理了一个清单适合同类统计概览页直接套用优化项具体操作说明图层隔离进度环、折线图分别包RepaintBoundary避免单图动画拖累整页条件重绘CustomPainter的shouldRepaint严格比较数据引用防止无谓的Canvas重绘聚合缓存写时更新dailySummary.json内存缓存聚合结果统计页首屏不扫明细Widget复用点阵图用GridView.builder卡片用const构造滚动时减少Widget重建文本量最小化单位、图例、刻度尽量用Text Widget而非Canvas文本规避OpenHarmony文本测量开销动画节制进度环动画仅首屏和刷新时播放避免动画过频导致视觉疲劳异常值过滤统计前剔除超出均值3倍标准差的脏数据曲线不受手误录入干扰这七项没有一项是复杂的技术但它们组合起来决定了统计概览页在真机上的整体体验。先做优化方向再回头调整代码结构比闷头把功能写完再改要高效得多。6.2 一次完整的实测表现优化完成后我在手头那台OpenHarmony测试设备上做了两轮对比测试一轮是优化前的基线版本一轮是优化后的版本。由于测试条件有限我没有跑严格的基准脚本只记录了同一段时间内的观察数值。基线版本从冷启动进入到统计概览页首帧大约需要1.8秒页面滑动时帧率在30到40fps之间波动进度环动画期间掉帧明显。优化后版本首帧时间降到1.2秒左右滑动稳定在60fps动画播放期间也没有再出现掉帧。内存占用方面经过半小时的反复切换和滚动操作基线版本内存稳定在180MB左右优化后版本在120MB上下浮动。这些数字不一定有多高的普适性但在我这台设备上的趋势非常清晰性能问题绝大多数不是OpenHarmony本身造成的而是Flutter侧的重绘边界和数据量控制没做好。把这个方向把握好真机体验就不会差。6.3 后续扩展的方向与一点提醒统计概览模块稳定后后续值得扩展的方向还有不少。比如把汇总数据导出为JSON或CSV文件方便用户自己留存或者根据历史记录做一个简单的趋势预测提醒用户关注体重的季节性波动。另一个方向是OpenHarmony的多设备协同能力可以把统计数据从一个设备迁移到另一个设备。不过做这些之前建议先把统计口径和性能基线固化下来特别是聚合缓存结构一旦扩展大概率要动它提前做好模块边界划分能省很多事。最后再分享一个我在这个项目里的实际操作习惯每次调整统计口径或者图表渲染逻辑之前我都会先把对应的测试用例或者手工验证步骤写出来。连续天数算法、异常值过滤、达成率方向这几个逻辑尤其容易在改代码时被悄悄破坏光靠“点开页面看一眼”很难全部覆盖。健康记录App的特殊性在于用户会对自己的数据非常较真一个统计口径的错误相比一个界面Bug更容易破坏信任感。花一点时间把边界和验证步骤理清楚是这个模块里我觉得最值回票价的投入。