前一阵子我接到一个活儿给某个健康管理项目做 OpenHarmony 端的应用。这个 App 的核心功能是把用户每天的身体数据——体重、心率、睡眠时长、血压、步数——记录下来然后在首页给用户一个一眼就能看懂的统计结果。我选了 Flutter for OpenHarmony 来做整个跨端实现其中一个重头戏就是“统计概览”页。这部分整体不算复杂但真正落地时牵扯到数据聚合、图表绘制、状态管理、页面性能一堆问题踩的坑比预想的多。这篇就把统计概览从设计到实现的完整过程复盘一遍给正准备用 Flutter 做 OpenHarmony 应用的开发者一份可以直接照着干的参考。1. 整体设计思路与方案选型1.1 为什么用 Flutter 做 OpenHarmony 应用先回答一个很多人会问的问题既然 OpenHarmony 有原生的 ArkUI 开发方式为什么还要用 Flutter最直接的原因是跨端复用。项目本身已经有人在 Android 和 iOS 上用 Flutter 写了业务层如果 OpenHarmony 端再用 ArkUI 从零写一套那么同样的数据模型、同样的聚合算法、同样的页面布局都要重写一遍。两个团队维护两套代码逻辑稍微有点不同步很容易出现“安卓上算出来的平均心率是 72OpenHarmony 上算出来 75”这种尴尬情况。用 Flutter for OpenHarmony业务层和 UI 层绝大部分代码可以共用只有极少数涉及平台能力的部分需要单独适配。另一个原因是 Flutter 自绘引擎的渲染机制。健康数据统计页面上大量使用卡片、圆角、渐变、阴影、图表这些用 Flutter 的 Widget 体系描述起来非常顺手。OpenHarmony 的 ArkUI 当然也能实现但对我这种从 Flutter 切过去的开发者来说基于已有组件的迁移成本显然更低。官方没有直接提供 Flutter 支持但社区维护的适配分支一直比较活跃我用的是 Flutter 3.x 的一个适配版本配合 OpenHarmony 的 SDK 使用整体稳定性完全可以接受。当然选择 Flutter 也要付出一些代价后面会详细说。最典型的就是插件生态很多在 Android/iOS 上直接能用的第三方包到了 OpenHarmony 上要么没有适配要么需要手动改。所以方案选型的核心思路是凡是纯 Dart 实现的、不依赖原生能力的东西优先用凡是依赖原生平台能力的东西能少用就少用尽量自己封装。1.2 统计概览页的产品需求拆解开工之前我先把这个页面向产品要了几个关键问题的答案。统计概览不是简单地把数据堆上去它要能快速回答用户的三个问题第一今天我的身体状态怎么样这对应“今日概览卡片”——显示今天的步数、心率、睡眠时长、血压并且和昨天比是升了还是降了。第二这段趋势是好是坏这对应“趋势图表”——按周显示体重变化折线按月显示平均心率波动按天显示睡眠时长柱状图。第三哪些指标需要提醒这对应“达标率”——比如目标每日 8000 步这周达标了几天用进度环展示一个百分比。基于这三个问题页面结构就非常清晰了顶部是一个今日总览卡片区中间是各指标的详细统计卡片底部是周/月趋势图表区域。每张卡片都有自己的数据来源和聚合逻辑但整体页面的数据状态又需要统一管理不能各自为政。设计上的核心原则是“数据驱动”。用户在这个 App 其他页面新增、修改、删除一条健康记录之后回到统计概览页所有卡片和图表必须自动刷新不需要手动下拉或者重启页面。这就要求状态管理方案一定要把“数据变化”和“UI更新”之间的链路打通。1.3 图表技术选型自绘比引用库更稳统计页面离不开图表而图表选型是我在 OpenHarmony 上遇到最大的一个坎。常用的 Flutter 图表库比如 fl_chart、syncfusion_flutter_charts在 Android 上是没问题但在 OpenHarmony 上就不好说了。这些库底层一般会依赖手势识别、Canvas 绘制以及一些平台通道适配分支并不保证这些依赖都能跑通。我在试跑过程中发现有些图表库的交互手势可以在模拟器上响应但到了真机上就出现触摸事件不生效、饼图中心旋转失灵之类的问题。最后我决定核心图表全部用 CustomPaint 自绘不用第三方图表库。这句话很多人听起来会觉得工作量很大其实对于健康数据统计这类固定形态的图表来说自绘反而更可控。我要画的类型很固定折线图体重趋势、柱状图每日步数/睡眠、环形进度图达标率。这些图形用 Canvas 的 Path、Line、Arc 就能画出来不需要图表库这层抽象。自绘的好处有三个不依赖插件适配跨平台行为一致。纯 Dart 代码OpenHarmony 上就是 Flutter 引擎自己渲染不存在平台通道丢失的问题。性能好控制。健康指标哪怕存了三年的数据聚合后也就是几十个点画起来毫无压力。UI 完全可控。图例、网格线、拐点标注、颜色渐变想怎么安排就怎么安排不会被库的默认样式限制住。当然自绘的代价是要自己处理触摸交互。比如用户想看某个日期的体重点一下折线图的拐点要能弹出对应的数值。这个后面在实现环节细说。所以整个技术选型清单如下模块方案理由UI 框架Flutter for OpenHarmony 适配分支跨端复用、自绘渲染状态管理Provider轻量、易上手、社区成熟图表CustomPaint 自绘避免三方库在 OpenHarmony 的兼容问题数据存储本地 JSON 内存缓存统计逻辑以读取为主实现简单数据聚合自写聚合器按日/周/月三种维度算法可控2. 环境构建与工程改造2.1 搭建 Flutter for OpenHarmony 开发环境这个部分网上资料比较少很多教程还在按老套路走我先把我验证过的流程说清楚。第一步拿到 Flutter for OpenHarmony 的适配版本。这跟普通 Flutter SDK 不一样不能直接从官方下载普通包然后强行用于 OpenHarmony否则会缺少 OpenHarmony 平台的打包工具链。需要从社区仓库单独拉取适配分支而且建议直接拉代码编译不要只下载 release 包因为后续可能要自己修补特定痛点。环境这块我整理一下我用到的组件OpenHarmony SDK包含 API 接口和工具链通过设备开发工具统一管理。Flutter for OpenHarmony 适配分支把原来的 flutter 命令行工具替换成适配分支里的版本。项目构建工具用于构建 OpenHarmony 的 hap 包它需要读取 OpenHarmony SDK 的路径配置。模拟器或真机模拟器方便调试 UI但真机测试是必需的下面会有专门一节讲差异。环境配置正确以后你的 Flutter 版本和目录结构其实和普通 Flutter 项目很接近只是在构建环节会多出一个 OpenHarmony 的 hap 包输出。一个小建议环境变量里把 OpenHarmony SDK 路径和 Flutter 适配分支的路径都固定下来写进备忘。否则换一台电脑或者换一个终端窗口经常会遇到“IDE 退出后找不到 SDK”的报错。第二步创建项目。先用适配分支的 flutter 命令创建标准工程执行完成后会在项目里生成标准的 pubspec.yaml 和 lib 目录。然后在这个工程基础上加入 OpenHarmony 平台目录一般会自动生成一个类似ohos的目录里面放着 OpenHarmony 工程的结构。如果你打不开这个目录大概率是环境变量没配对。第三步配置依赖。在pubspec.yaml里只需要添加纯 Dart 的第三方库。我用到的有provider状态管理、intl日期格式化这个也是纯 Dart。像shared_preferences这类涉及原生存储的包虽然社区可能有适配但我为了省事改成了自己写一个极简的文件存储工具后面会说明。2.2 目录结构与分层设计工程搭好之后我按功能模块做了分包避免所有页面和逻辑堆在 main.dart 里。一个典型的结构大概是这样的lib/ ├── main.dart # 入口配置 ProviderScope 等 ├── core/ │ ├── models/ │ │ ├── health_record.dart # 健康记录单条数据模型 │ │ └── health_metric.dart # 统计指标模型 │ ├── storage/ │ │ └── local_store.dart # 本地 JSON 存储工具 │ └── theme/ │ └── app_theme.dart # 主题、字号、间距 ├── features/ │ ├── overview/ │ │ ├── overview_page.dart # 统计概览页面 │ │ ├── model/ │ │ │ └── stats_state.dart # 统计数据状态 │ │ ├── provider/ │ │ │ └── stats_provider.dart # 页面状态管理 │ │ ├── service/ │ │ │ └── stats_aggregator.dart # 数据聚合逻辑 │ │ └── widgets/ │ │ ├── today_summary_card.dart # 今日概览卡片 │ │ ├── trend_chart.dart # 趋势折线图/柱状图 │ │ ├── progress_ring.dart # 达标率进度环 │ │ └── metric_chip.dart # 单指指标标签 │ └── record/ │ └── record_page.dart # 健康记录维护页面 └── shared/ └── utils/ └── date_utils.dart # 日期工具这个分层强调一点stats_provider.dart不直接操作 JSON 文件而是通过stats_aggregator.dart聚合数据聚合器再通过local_store.dart读取原始记录。各层职责明确后续加数据库也好替换。2.3 主题与字体适配的注意点健康类 App 的界面风格通常偏清爽我用的是白色底、圆角卡片、主色调蓝绿渐变。在 Flutter 里做这套主题设计的时候有两点专门为 OpenHarmony 做了适配。第一是字体。OpenHarmony 设备上的默认字体和安卓设备不完全一样部分中文字符在强调、数字等 FontWeight 下渲染效果有差异。我直接把 App 的主题字体设置为系统默认不开自定义字体文件省去字体打包和版权问题。同时对数字的展示用了fontFeatures或者干脆用TextStyle(fontFeatures: [FontFeature.tabularFigures()])强制等宽数字避免统计数字在刷新时宽度跳动。第二是安全区。OpenHarmony 的一些设备屏幕有挖孔或者圆角顶部状态栏高度和底部导航区域跟常见安卓机不一样。统计概览页是全屏滚动的必须用SafeArea包住不然首页卡片容易顶进状态栏。这个看起来是小细节在真机上非常影响第一印象。3. 统计数据的聚合与状态管理3.1 健康记录的数据模型设计统计概览页要展示的指标很多但底层数据结构其实很统一。我把每条健康记录设计成这样一个模型class HealthRecord { final String id; final DateTime date; // 记录归属日期 final HealthMetricType type; // 指标类型 final double value; // 数值 final double? secondaryValue; // 部分指标需要第二个值 final int? timestamp; // 毫秒时间戳 }HealthMetricType是一个枚举包含weight、heartRate、sleepDuration、bloodPressure、stepCount这几种。之所以用“枚举 通用字段”而不是“体重记录”和“心率记录”分开建表是因为统计概览页需要把这些指标统一放在一个列表里滚动展示统一模型会让聚合逻辑通用化很多。血压稍微特殊一点它有收缩压和舒张压两个值。我的处理是把收缩压放在value舒张压放在secondaryValue然后通过type HealthMetricType.bloodPressure判断。这样模型不需要多建字段聚合时也能拿到完整数据。3.2 按日/周/月维度聚合的算法细节统计的核心逻辑在StatsAggregator里。这个聚合器接收原始记录列表返回一个结构化的统计结果供 UI 层展示。我的实现思路是把聚合拆成三步过滤 - 分组 - 汇总。以“近 7 天步数柱状图”为例第一步过滤。从所有记录中筛选出type HealthMetricType.stepCount并且日期在最近 7 天内的记录。这里要注意记录中的date保存的是“自然日”维度一条记录对应一天。如果业务上有每天更新多条的就需要先把同一天的数据求和再进入分组。第二步分组。按date把记录分到对应的日期桶里。这个操作通常用groupBy实现但要注意键值必须是日期字符串而不是DateTime对象本身因为DateTime的比较包含时间部分可能导致同一天的数据被分到两个桶里。我自己封装了一个dateKey方法统一把日期格式化成yyyy-MM-dd。第三步汇总。每个日期桶里如果有多条步数就求和如果只有一条就直接取。对于心率、体重这类趋势指标建议取当天最后一次记录或者平均值具体看产品要求。平均值会平滑掉波动更像“趋势”而取最后一个值更真实反映“当前状态”。我在项目里对体重用了“最后一次记录值”对心率用了“全天平均”这样页面上两条曲线的语义不同用户不会混。环比数据的计算也有讲究。比如“今日步数比昨日增加 25%”分母如果为 0 会直接弹出无穷大或者界面显示异常。我的处理方式是统一判断分母小于等于 0 时返回nullUI 层发现null就显示“昨日无记录”而不是硬算一个百分比。3.3 Provider 状态管理的实现方式状态管理我用的是 Provider理由很简单这个页面没有特别复杂的异步流程也不需要多页面共享大量数据ChangeNotifier完全够用。在 OpenHarmony 上少依赖外部生态反而是优势因为 bug 排查范围更小。核心类是StatsProvider它继承ChangeNotifier内部持有当前统计数据状态。页面在initState时注册一个监听当用户从记录页修改数据返回后调用provider.refresh()它会重新读取存储、重新聚合、然后notifyListeners()让页面刷新。具体代码逻辑大致是class StatsProvider extends ChangeNotifier { StatsState _state StatsState.loading(); Futurevoid refresh() async { final records await localStore.fetchAll(); final stats statsAggregator.aggregate(records); _state StatsState.success(stats); notifyListeners(); } }StatsState我用了一个轻量的 sealed 类概念用枚举status 数据字段表示 loading/success/error。这样页面根据status渲染骨架屏、内容区或者错误重试按钮逻辑非常直接。关键是要注意 Provider 的刷新作用域。统计概览页如果是 tab 页不要用Consumer包整个页面而是在每个卡片组件外层单独包一层Consumer这样某个指标聚合结果变化时只重绘对应的卡片其他卡片不会无用重建。这个优化在数据量小的时候看不出来但记录量涨到几千条以后性能差距很明显。4. 统计卡片的实现细节4.1 今日概览卡片大数字与环比标记今日概览是整个页面的门面视觉上需要一眼抓住重点。我把步数、心率、睡眠、血压放在同一行卡片里每张卡片上半部分是一个大数字下半部分是一个趋势标记——和昨天相比是升了、降了、还是持平。在 Flutter 里每张卡片就是一个独立的 Widget内部接入ConsumerStatsProvider。为了避免四张卡片同时刷新我让每张卡片读取的指标互相独立步数卡只select今天的步数统计心率卡只select今天的心率统计。这里踩过的一个坑是刷新闪烁。最开始我在卡片滑动动画里使用了AnimatedSwitcher包裹数值导致每次从记录页返回时数字都先消失再出现视觉上很突兀。后来改成只在数值变化超过阈值时才触发动画比如体重变化超过 0.1 才播放过渡效果否则直接替换文本。这样日常“打开 App 看首页”的场景不会闪烁只有真实变数据时才给反馈。环比标记的颜色也做了语义区分上升、下降使用红色和绿色为了健康类 App 的谨慎我特意使用“比昨天多”的箭头加数值而不是简单套用股市涨跌的红色概念。这一点设计上要提前确认否则容易被产品挑战。4.2 趋势折线图与柱状图用 CustomPaint 自绘下面进入技术含量最高的部分用CustomPaint绘制趋势图。我以“近 7 天体重折线图”为例拆解实现过程。第一步定义CustomPainter的子类。它接收已经聚合好的数据点列表比如ListWeightPoint每个点包含日期和体重值。paint方法里第一件事就是计算绘图区域的宽高。这里切记要用size.width而不是直接使用逻辑像素常量因为在不同屏幕上可用宽度不同自绘必须基于实际尺寸计算。第二步计算 Y 轴范围。体重数据可能长期在 60kg 到 65kg 之间浮动如果 Y 轴从 0 开始折线会变成一条贴顶的直线毫无趋势感。我取数据里的最小值和最大值再上下各留 2 的 padding让折线在图表区域中占满纵向空间。同时把 Y 轴标签的格式化做好比如显示61.5kg而不是61.49kg。第三步绘制网格线和坐标标签。网格线用浅灰色每根线旁边标注一个刻度值让用户能大致读出纵轴的数量级。横轴日期标签则每两天显示一个避免 7 个标签挤在一起。绘制文字使用TextPainter它是纯 Dart 实现在 OpenHarmony 上没有问题但注意文字的方向和居中都要手动计算。第四步绘制折线。把每个数据点转换为Offset然后用Path连起来。如果是直线连接图形很生硬所以我用Path.cubicTo做平滑曲线连接。这里提供一个小技巧平滑系数取 0.2 效果最自然系数太大曲线会过冲出现“体重回去了”的假象系数太小就退化成直线。第五步绘制拐点标记。每个数据点上加一个小圆圈再给最后一个点加一块明显的强调色让用户一眼看到“最新体重”。如果用户触摸到了某个拐点附近我需要显示一个垂直线和浮层。触摸命中的计算方法是把触摸点Offset的横坐标和每个数据点的横坐标比较找差值最小的那个。这种精度在没有图表库的情况下已经足够。柱状图类似只是把 Path 换成Rect。绘制时要注意柱子的宽度 可用宽度 / 柱数 * 0.6左右留出空档看起来更像产品风格而不是 Excel 截图。4.3 环形进度图达标率的直观展示达标率这部分我做了“今日目标完成度”的环形进度图。例如目标 8000 步当前已完成 6000 步完成率 75%。UI 效果是一个圆环完成部分用主色渐变填充未完成部分用浅灰色背景中间显示百分比。原理上环形就是两个圆弧背景环和前景环。用Canvas.drawArc绘制注意 Flutter 的角度起点是 3 点钟方向且角度是顺时针增加。我需要把从 0 到 100% 映射为从-pi/2开始、顺时针2 * pi * progress的圆弧。这个细节经常有人搞错画出来进度环起点不在顶部。中间文字用TextPainter绘制在圆心的位置。这里有一个经验文字大小不要固定写死应随圆环尺寸变化。最简单的方式是取圆环半径的 0.4 作为文字字体大小这样在大屏上不显得小气、在紧凑卡片上不会溢出。如果环形还要展示“本周达标天数”我会改成用多个小圆点表示周一到周日达标的点亮色未达标的灰色而不是整个环。这种视觉模式对“日历式的达标”特别直观属于健康类 App 里的常见样式。5. 真机适配、性能优化与排查实录5.1 减少无谓重建的策略统计概览页的数据量虽然不算大但如果用户记录了半年每天 3 次体重那也有几百条数据再加上心率、睡眠、血压总共可能到几千条。每次刷新都重新计算全部聚合逻辑在低功耗设备上会有明显卡顿。我的优化策略是“分级刷新”。页面首次进入时refresh()全量读取一次生成统计数据存入状态后续如果只是新增一条记录采用增量更新的方式找到该记录对应的日期桶重算该桶的值然后重新跑一遍汇总。这个逻辑需要把聚合器设计成可复用的函数参数化日期范围。另外图表的CustomPainter都实现了shouldRepaint方法。这个方法返回false时Flutter 不会重绘画布这在列表滚动时非常关键。一定要比较数据列表是否真的有变化不要只比较引用地址因为有时聚合结果对象被替换了但内部数值没变这时也应该返回false。5.2 OpenHarmony 真机与模拟器的差异点这部分尤其重要项目里踩出来的教训都在这。首先是渲染性能差异。模拟器上图表滚动非常流畅但到了中低端 OpenHarmony 真机上如果圆角卡片使用了多层阴影滚动时会出现明显掉帧。原因是 Flutter 的阴影在部分 GPU 驱动上会触发额外的离屏渲染。我把卡片的阴影方案从默认的BoxShadow改成自定义的浅色描边 少量模糊视觉差距很小性能提升明显。第二是触摸事件的坐标系问题。在自绘图表中处理点击时GestureDetector回调里的localPosition在模拟器上是正确的但在部分真机上会偶尔偏移。我的处理是始终使用RawGestureDetector的onTapDown事件里的localPosition不依赖全局坐标转换。实测下来这个方案稳定很多。第三是中文字体渲染差异。同样的fontWeight: FontWeight.w500模拟器上显示很清晰真机上某些字符会发虚。我改成了FontWeight.w600并针对统计数字单独用fontFeatures开启等宽属性保证表格类的数字对得齐。5.3 常见问题与排查技巧速查表我整理了开发过程中真正遇到的高频问题按主题分类问题现象可能的根因处理建议首页卡片刷新但图表不变CustomPainter 的 shouldRepaint 返回 false比较内部数据列表返回真实比较结果数字变化时卡片宽度跳动数字字体不是等宽开启 FontFeature.tabularFigures触摸图表无响应GestureDetector 包了带背景色的 Container检查是否使用 RawGestureDetector确认命中的坐标点真机滚动掉帧多层 BoxShadow 触发离屏渲染减阴影层级改用浅色 border环形进度起点位置不对drawArc 起始角度写错从 -pi / 2 开始顺时针计算中文字体发虚OpenHarmony 对 FontWeight 处理与 Android 不同数字用等宽特性中文调高字重从记录页返回后数据没刷新Provider 的 ref 生命周期没管理好在页面重新可见的时机调用 refresh图表 Y 轴范围不合理直接用了 0 到最大值改为数据最小/最大值上下留 padding5.4 数据存储与记录页的联动设计统计概览页不是孤立存在的它依赖记录页写入的数据。我采用的极简存储方案是LocalStore维护一个 JSON 文件每次写入都序列化整个记录列表再追加到文件。这种方式在记录量不大时非常可靠而且便于调试打开文件就能直接看数据格式。但要注意并发问题。如果用户在记录页连续写入两条数据两次写入同时操作文件可能产生写冲突。我的做法是把所有写入操作放进一个内存队列串行执行。每次写入后通过回调通知StatsProvider执行刷新。这个联动逻辑写清楚之后统计页永远能拿到最新数据不需要手动刷新。联动还有一个细节当记录页删除一条数据时统计概览页里的“今日步数”这类 sum 指标会自动减少这是正确的行为但 UI 上如果有“小目标”动画可能会显得数据在往下掉。产品上我倾向于不在这类场景播放动画只做普通数值替换。6. 对整体实现的复盘与几个实操体会统计概览这个模块从前期设计到真机调通我最大的体会是在 OpenHarmony 上做 Flutter 开发控制第三方依赖比优化绘制代码更重要。很多看似高级的问题比如图表库不兼容、触摸事件失灵、状态管理困难和插件有关纯 Dart 的聚合逻辑和自绘 UI 基本一路顺利。所以建议所有准备入坑这个技术栈的人先把“能不用原生插件就不用”当作铁律凡是能自己用 Dart 解决的问题不要偷懒引库。几个可以直接带走的小技巧再重复一遍第一聚合逻辑一定要从 UI 里抽出来单独测试统计口径变了只会改一个函数第二自绘画图时起点角度和坐标轴范围这两处最容易出错先画最简数据比如 3 个点验证再扩展第三真机上的字体渲染和触摸坐标可能有问题项目里一定要备一台 OpenHarmony 真机不能只靠模拟器调完就提交。如果后续你要继续做这个健康记录 App统计概览页还可以扩展很多方向把每周健康报告生成成一张可分享的图片、为异常波动增加提醒文案、把多维度的数据综合成一个“健康评分”等等。这些功能的底层数据能力在这套模块里已经准备好了接下来要做的只是在聚合器上增加新的计算方法、在 UI 上增加新的卡片组件而已。我个人在这段开发经历里最大的收获其实是把“选型克制”这四个字刻在了脑子里。面对一个新技术平台先用最简单可靠的方式跑通核心流程再逐步迭代体验细节比一上来就铺开所有 fancy 的库和框架要靠谱得多。希望你在这个项目里也能少踩坑、多出活。