过去半年我一直在折腾一个三国杀攻略类小应用,不是单纯堆图文攻略,而是把玩家的对局记录跑成数据看板——武将胜率、身份表现、锦囊牌倾向、回合数分布这类统计。这个项目最有意思的部分不是页面排版,而是用 Flutter 跑在 OpenHarmony 环境里做数据图表。今天专门把数据统计与图表实现这块拆开聊聊,从数据模型设计、状态管理选型,到用 CustomPainter 自绘柱状图和饼图,再到真机调试时踩过的坑,一次性说清楚。
这篇文章适合两类人:一类是刚准备用 Flutter 做 OpenHarmony 应用,想知道怎么搭数据展示的;另一类是手上有游戏攻略类产品,想给用户做对局复盘和胜率分析,但纠结图表库兼容性的。我会尽量把每一步的原理和取舍讲透,代码可以直接抄部分。
1. 项目背景与数据看板设计
1.1 为什么用 Flutter 啃 OpenHarmony 这块硬骨头
先交代一下背景。这个三国杀攻略 App 不是从 OpenHarmony 起步的,最早它跑在 Android 上,页面主要就是武将图鉴、攻略文章和用户对局记录。后来团队想覆盖更多终端,尤其是各类搭载 OpenHarmony 的设备,这时候问题就来了:不可能为每个系统单独写一套 UI,两个人维护两套渲染代码的成本太高,这在小团队里是致命的。
选 Flutter 的原因其实很朴素。Flutter 不走系统原生控件,而是自己用 Skia 引擎画 UI,这就意味着同一个页面在不同系统上长得很一致。对游戏攻略类应用来说,武将卡牌的颜色、图标的质感、图表的配色都需要稳定还原,原生控件很难在不同平台上保持一致,Flutter 的“自绘”刚好解决这个问题。可以这么理解:Flutter 就像一辆拥有自己动力系统的新能源车,不管换什么底盘(Android、OpenHarmony),驾驶舱体验都是一样的。
OpenHarmony 那个分支最关键的一点是 Flutter 引擎层被完整移植了过来,Dart 代码可以复用大部分逻辑。但也不是所有插件都能直接用,有些依赖系统通道的插件需要单独适配。我当时的策略是:App 的核心业务逻辑尽量不依赖原生,数据统一走抽象层,UI 全用 Flutter 绘制,这样就算个别插件不兼容,也能用自研方案快速顶上。
如果你也准备在一个新系统上跑 Flutter,记住这个原则:先跑通一个最少的 Demo,再往上加功能。我见过太多人在环境没验证好的时候就直接铺业务页面,最后发现某个插件在 OpenHarmony 上直接挂,整个项目重来。一个能显示的“Hello World + 一个自定义绘图”Demo,比十页架构文档都管用。
1.2 攻略 App 到底该统计哪些数据
三国杀这类卡牌桌游的攻略说到底是概率和经验,玩家的对局记录天然是数据源。但“统计”不能胡乱堆,得围绕玩家的真实诉求来拆解。我当时把统计需求分成了三个维度:武将维度、身份维度、对局宏观维度。
武将维度要回答的问题是“我用谁胜率高”。这里面不能只看总胜率,还要区分主公、反贼、内奸、忠臣等身份下的表现,因为同一个武将在不同身份里的发挥往往差很多。身份维度是这套玩法的核心结构,用户最想知道的永远是“我当内奸到底该选爆发流还是防御流”,用数据说话最有说服力。对局宏观维度则关注平均回合数、场均锦囊使用数、武器牌替换频率等,这些指标能体现实战的节奏压制能力。
我把具体指标整理成了一张表,方便后续建数据模型:
| 统计维度 | 核心指标 | 说明 |
|---|---|---|
| 武将胜率 | 出场率、胜率、场均伤害 | 反映武将强度与玩家熟练度 |
| 身份胜率 | 各身份胜率、身份出场比例 | 帮助玩家优化身份武将选择 |
| 对局节奏 | 平均回合数、首轮行动分布 | 判断对局长短与节奏风格 |
| 手牌资源 | 锦囊使用数、装备替换数 | 反映牌局运营能力 |
| 阵营平衡 | 反贼胜率 vs 主公胜率 | 验证选将策略是否正确 |
看到这张表其实就能明白,图表部分不是装饰品,而是这些统计指标的出口。数据算出来了,最终要让人一眼看明白,就得靠柱状图、饼图、雷达图这类可视化。换句话说,这个 App 的“攻略”不是文章写出来的,是统计图帮着玩家自己读出来的。
2. 数据模型与状态管理设计
2.1 对局记录的数据结构怎么搭不后悔
任何统计功能,第一步都是把原始数据存成“可计算”的结构。对局记录字段设计得不好,后面聚合代码会越写越痛苦。我踩过的坑是:一开始图省事,把一局关键信息存成一坨 JSON 字符串,结果要算“武将-身份-胜负”组合胜率的时候,到处解析字符串,又慢又容易出 bug。
后来我把对局记录拆成了结构化字段,核心结构大概是这样的:
class BattleRecord { final String heroId; // 使用的武将 ID final String roleType; // 身份:lord / loyalist / rebel / spy final bool win; // 是否获胜 final int rounds; // 对局回合数 final int trickCards; // 使用过的锦囊牌数 final int equipChanges; // 更换装备次数 final int damageDealt; // 输出伤害总量 const BattleRecord({ required this.heroId, required this.roleType, required this.win, required this.rounds, required this.trickCards, required this.equipChanges, required this.damageDealt, }); }字段并不复杂,但每一条字段都能接上前面那张统计表。heroId 决定武将维度,roleType 决定身份维度,win 是胜率计算的关键,rounds、trickCards、equipChanges 则对应对局节奏和手牌资源指标。写模型的时候有个原则:能存数字就不要存显示文本,比如胜平负用 bool 或枚举,而不是存“赢了/输了”这种字符串。数字可计算,文本只能看。
另外我强烈建议把持久化层抽象一下。这个项目在 OpenHarmony 上没法直接复用 Android 的 SQLite 插件,所以我用一个接口封装了所有存取操作,底层先放本地 JSON 文件,等插件适配稳定了再切数据库。代码大致长这样:
abstract class RecordRepository { Future<List<BattleRecord>> loadRecords(); Future<void> saveRecord(BattleRecord record); }接入页面的时候只依赖这个接口,底层是文件还是数据库对上层完全透明。后来我在某次测试中把底层从 JSON 文件换成了一款轻量数据库,UI 层一行代码都没改,验证了这套抽象的价值。
2.2 状态管理:Provider 与 Riverpod 的一次对比选型
做统计页最头疼的问题是多个页面共享“对局记录集合”这份数据。你可能有武将胜率页、身份胜率页、最近对局页,它们都要基于同一份数据源,如果每个页面自己加载、自己维护状态,一旦数据刷新,全乱套。
我当时在 Provider 和 Riverpod 之间犹豫了一阵。Riverpod 功能确实更强,编译期安全、更方便测试、依赖覆盖,但现在这个小项目体量不大,核心状态翻来覆去就三份:当前用户、原始对局列表、统计结果映射。Riverpod 的样板代码在这份量下反而显得重。反倒 Provider 简单直接,用 ChangeNotifier 就能把“加载对局 — 触发更新 — 所有图表刷新”这条链路串起来,学过的开发者都能快速接手。
我做了一张主观对比表,不是黑谁,仅供参考:
| 对比项 | Provider | Riverpod |
|---|---|---|
| 上手难度 | 低,只要理解 InheritedWidget | 中高,需要理解 Provider 容器体系 |
| 测试友好度 | 中,需要额外包裹 | 高,编译期即注入 |
| 包体积影响 | 小 | 略大 |
| 在 OpenHarmony 兼容性 | 好,纯 Dart | 好,纯 Dart |
| 适合项目体量 | 中小型 | 中型以上或复杂依赖场景 |
选型这块我的建议是,别为了“新”而选,为了“解决问题”而选。Provider 虽然没有花哨能力,但它能把我想要的“数据共享 + 自动重绘”办得妥妥当当。核心代码就是一个继承 ChangeNotifier 的模型:
class StatsProvider extends ChangeNotifier { List<BattleRecord> _records = []; void load(List<BattleRecord> records) { _records = records; notifyListeners(); } List<BattleRecord> get records => _records; }页面再用 Provider 的 watch 方法监听变化,一旦数据更新,图表自动重绘。这个模式的好处是,你完全不用手动去调“刷新图表”的函数,所有组件都跟着数据走,状态变更和界面渲染的耦合被降到了最低。
3. 图表渲染:从零开始自绘
3.1 不直接用图表库的四个理由
最开始我也想过直接上现成的图表库,毕竟网上 Flutter 图表库一抓一大把。但实际调研一圈,发现在 OpenHarmony 环境里有四个现实问题。
第一,兼容性悬空。大多数图表库都重度依赖 Canvas 能力和布局系统,虽然 Flutter 的引擎层被移植了,但部分库内部用了原生通道或者特定平台的字体测量逻辑,在 OpenHarmony 上会静默失败,图表区域画出来是白的,报错也没有。第二,包体积问题。攻略 App 本身图标、图片素材已经不少,再塞一个完整图表库,安装包会明显变大,对于中小型应用来说不划算。第三,定制灵活性不足。我要的不是通用图表,而是带三国杀配色的身份胜率饼图、带武将头像缩略图的排行榜柱状图,改库的样式往往比从零画还费劲。第四,这是最重要的——自绘技术不难,还能确保可控。Flutter 提供了 CustomPainter 这个强大的绘制接口,自己画一个统计图的核心代码并不复杂。
有人觉得自绘图表高不可攀,其实可以反过来想:图表就是几个几何形状的排列组合。柱状图就是一堆矩形加坐标轴;饼图就是几个扇形拼成一个圆;雷达图就是多边形连线。理解了这一点,你会觉得上图表库反而不划算。
3.2 柱状图:用 CustomPainter 画出胜率排行
柱状图用来展示“武将胜率排行”,是数据看板里最直观的元素。我实现它的思路很简单:把排行榜数据喂给 CustomPainter,在 paint 方法里按条数均分宽度,每一根柱子高度按胜率归一化。核心代码我来拆解一下。
class BarChartPainter extends CustomPainter { BarChartPainter({required this.items, required this.barColor}); final List<StatItem> items; final Color barColor; @override void paint(Canvas canvas, Size size) { final width = size.width; final height = size.height; final top = 24.0; final bottom = height - 40; final chartHeight = bottom - top; final gap = 12.0; final barWidth = (width - gap * (items.length - 1)) / items.length; final maxValue = items .map((e) => e.value) .reduce((a, b) => a > b ? a : b); for (var i = 0; i < items.length; i++) { final item = items[i]; final barHeight = chartHeight * (item.value / maxValue); final left = i * (barWidth + gap); final rect = Rect.fromLTWH(left, bottom - barHeight, barWidth, barHeight); final paint = Paint() ..style = PaintingStyle.fill ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ barColor.withOpacity(0.9), barColor.withOpacity(0.4), ], ).createShader(rect); canvas.drawRect(rect, paint); // 绘制数值文本 final textPainter = TextPainter( text: TextSpan( text: item.value.toStringAsFixed(1), style: const TextStyle(color: Colors.white, fontSize: 11), ), textDirection: TextDirection.ltr, )..layout(); textPainter.paint( canvas, Offset(left + barWidth / 2 - textPainter.width / 2, rect.top - 16), ); } } @override bool shouldRepaint(covariant BarChartPainter old) => old.items != items; }这段代码里有几个地方值得注意。一个是TextPainter绘制数值,Flutter 里没有“顺手画文字”的方法,必须先用 TextPainter 布局再绘制,所以我把这段拎出来提醒你。另一个是渐变着色器createShader(rect),它的作用范围必须和柱体本身绑定,否则渐变方向不对。我在初版代码里忘了传 rect,结果所有柱子颜色都糊成一片,排查了好久。
还有性能问题。shouldRepaint里我现在用old.items != items做判断,如果 items 是重新构建的 List,那每次都会被判定为需要重绘。更精细的做法是引入一个版本号,只有版本号变化才重绘。这个优化在数据量大、滚动频繁的时候特别有用。我把 50 根柱子的测试样本从每秒重绘 20 次降到了 2 次,肉眼完全没区别。
3.3 饼图:身份胜率分布绘制的数学细节
饼图我用来展示“主公 / 忠臣 / 反贼 / 内奸”四个身份的胜率占比。相比柱状图,饼图的难点在扇形角度计算。Flutter 的 drawArc 方法接收的是弧度制,起始角度从 x 轴正方向开始,并且是顺时针方向。但一般人脑里想的角度是从 12 点方向开始。所以我在代码里加了一个偏移量,把所有扇区的起点推后 90 度,让第一块从正上方开始画。
class PieChartPainter extends CustomPainter { PieChartPainter({required this.slices}); final List<Slice> slices; @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = size.shortestSide / 2 - 12; final total = slices.fold(0.0, (sum, s) => sum + s.value); const startOffset = -3.1415927 / 2; // 从12点方向开始 var currentAngle = startOffset; for (final slice in slices) { final sweep = 2 * 3.1415927 * (slice.value / total); canvas.drawArc( Rect.fromCircle(center: center, radius: radius), currentAngle, sweep, true, Paint() ..isAntiAlias = true ..color = slice.color, ); currentAngle += sweep; } } @override bool shouldRepaint(covariant PieChartPainter old) => old.slices != slices; }你发现没有,这个版本的paint里每次循环都新建了一个 Paint 对象。对 4 个扇区来说无所谓,但如果你要画 200 个数据点组成的行为热力图、扇形细分图,这就成了性能瓶颈。正确做法是把 Paint 对象提到循环外面复用,只切换 color 属性。这是我在后面对图表做批量渲染优化时总结出来的一个通用经验。
饼图的另一个细节是占比太小时文字根本没地方放。我最初想给每个扇区都标注“身份名 + 百分比”,后来发现内奸胜率如果只有 8%,扇形窄到连一个“奸”字都塞不进去。我的解决办法是:占比小于等于 10% 的扇区不画内部文字,而是把标签统一画在图例区,用颜色小方块对应。这个处理方式在视觉上干净很多,也避免了文字重叠的尴尬。
4. 实操过程与核心环节实现
4.1 项目结构与 OpenHarmony 构建流程
先展示一下项目目录结构,从小型应用角度看,这已经算清晰:
lib/ ├── main.dart ├── models/ │ └── battle_record.dart ├── repositories/ │ └── record_repository.dart ├── providers/ │ └── stats_provider.dart ├── screens/ │ ├── overview_screen.dart │ └── hero_detail_screen.dart └── widgets/ ├── bar_chart.dart └── pie_chart.dartmodels 层放数据模型,repositories 做数据访问抽象,providers 管理状态,screens 放页面,widgets 放自定义图表组件。这个分层不复杂,但足够支撑我前面描述的所有功能,而且每个人接手都很清楚东西该放哪。
构建 OpenHarmony 版本时,其实 Flutter 命令的变动不大。你先把适配工具链的 Flutter SDK 下载下来并切到对应分支,然后在项目根目录执行和标准流程差不多的构建命令,它会生成 OpenHarmony 需要的工程结构,接着用对应的 IDE 打开工程就能编译安装到设备上。整个链路里最容易出问题的步骤是 SDK 版本不一致,比如 Flutter 框架切到了新版本,但引擎分支还停留在旧版本,这时候编译会报一堆奇怪的符号错误。我后来固定了一组经过验证的版本组合,不再随意升级。
真正和普通 Flutter 工程不一样的地方是插件机制。OpenHarmony 的插件不是 Android 的 Gradle 依赖,而是需要进入适配后的原生工程手动添加。比如我需要用设备存储能力读取 JSON 文件,官方插件不支持时就得先写一个最小原生插件,再把方法通道注册到 Dart 侧。这一块比较繁琐,但它的好处是你对底层控制力更强,遇到问题可以一步步回溯排查。
4.2 从原始对局到屏幕图表的数据管道
整个统计页的数据流其实是一条很清晰的管道:原始对局记录 -> 聚合计算 -> 排序截断 -> 图表组件 -> 屏幕渲染。我强烈建议不要把聚合计算塞进 build 方法里,因为 build 可能被系统频繁触发,每次做全量统计会白白浪费性能。更合理的做法是把它放到数据变化的时机去做,比如加载完成时,或者切换到统计页时。
下面这段代码是“武将胜率排行”的核心聚合逻辑:
List<StatItem> computeHeroWinRates(List<BattleRecord> records) { final resultMap = <String, StatItem>{}; for (final r in records) { final stat = resultMap.putIfAbsent( r.heroId, () => StatItem(name: r.heroId, total: 0, wins: 0), ); stat.total++; if (r.win) stat.wins++; } final list = resultMap.values .where((s) => s.total >= 5) // 至少5局,避免小样本误导 .map((s) => StatItem( name: s.name, total: s.total, wins: s.wins, )) .toList(); list.sort((a, b) => (b.wins / b.total).compareTo(a.wins / a.total)); return list; }你注意看那个where((s) => s.total >= 5),这是我从实际使用反馈里学到的。如果不设最小样本量,一个只打了一局但赢了的武将,胜率就是 100%,会永远出现在排行榜顶部。这个阈值可以按业务调整,但样本量门槛必须要有,否则数据分析就没有参考价值。
聚合之后,把 StatItem 列表和颜色参数一起传给 BarChartWidget,在 build 方法里用 CustomPaint 画出柱状图。因为我已经在 State 里缓存了聚合结果,所以即使页面被翻来覆去地重建,也不会重复计算,只是复用已经算好的那份数据。这种“缓存 + 显式刷新”的策略,在对局记录上千上万条时尤其关键。
4.3 图表与列表同屏时的性能优化手段
三国杀攻略 App 的统计页不是单张图表一屏显示,而是“排行榜柱状图 + 身份饼图 + 趋势折线”等多个图表纵向排列。这时候 Flutter 的页面性能就会面临一个问题:所有图表都在同一个 ListView 里滚动,每次滚动都可能触发上层 rebuild,导致图表层也跟着重绘,出现肉眼可见的掉帧。
我的第一个优化手段是给每个图表组件包上RepaintBoundary。它是个非常实用的组件,作用是把子组件的绘制结果缓存成图层,父级重绘时不会连带着子组件一起重绘。举个例子,整个页面滚动时,ListView 在动,但 RepaintBoundary 内部的图表内容根本没变,直接复用旧画面,省下了大量 Canvas 绘制开销。
第二个优化点是防止“数据未变但列表项重新 build”。我用了自定义的SliverChildBuilderDelegate,给每个图表项配上稳定的 key,这样滚动时 Flutter 会复用已有的元素,不会因为父级刷新就把整棵树丢掉重建。这个技巧很多人容易忽略,但实测下来滚动流畅度提升非常明显。
第三个优化点是把进度样式的动画拆出去。我之前在柱状图上加了“入场生长动画”,每次数据变动都从 0 开始生长。这个动画本身很漂亮,但在页面加载时多个图表同时生长,CPU 直接拉满。后来只保留一个主推的柱状图动画,其他图表进场就直接静态展示。在一个看板类页面上,动画不是越多越好,重点突出一个视觉焦点就够了。
5. 常见问题与排查技巧实录
5.1 图表白屏、不刷新、坐标错乱,逐个排查
这节内容全部来自真实调试经历。我把常见问题做一个速查表,方便你以后直接对着翻:
| 现象 | 常见原因 | 处理方法 |
|---|---|---|
| 图表区域完全空白 | CustomPainter 抛错被吞 | 在 paint 里临时加 print,或 try-catch 定位 |
| 数据变了但图表不变 | shouldRepaint 返回 false | 确认是否用旧列表引用,或加版本号强制刷新 |
| 柱状图坐标轴不在预期位置 | 未考虑控件尺寸是一整块画布 | 画坐标轴时以 size 为基准,不能假设固定像素 |
| 饼图扇形方向不对 | 忘记角度从 x 轴正方向开始 | 起始角度统一减去 90 度并确认方向 |
| 刷新页面时闪一下白 | 无 RepaintBoundary 且父级重建 | 给图表包 RepaintBoundary 隔离重绘 |
最容易被忽视的是第一种:不是图表画不出来,而是 paint 里抛了异常但没有被 Flutter 打印出来。我在一次调试里,因为 item.value 里混入了 NaN,所有柱子算出来都是 NaN,画面直接空白,但控制台居然没有报错日志。后来我在 paint 方法开头加了一个assert(values.every((e) => e.isFinite)),才把问题逼出来。这个建议也推荐给你:自绘代码里能加断言就加断言,越早暴露问题越好。
“数据变了但图表不动”也很典型。CustomPainter 并不会自动感知你“觉得”数据变了,它依赖 shouldRepaint 的返回值。如果你把新数据赋值给了同一个 List 引用,Flutter 比较不出差异,就会认为不需要重绘。我的经验是给 StatItem 增加一个int version,每次数据重新聚合就自增,shouldRepaint 直接判断版本号是否相等,简单粗暴且绝对可靠。
5.2 中文字体渲染与画笔复用那些坑
OpenHarmony 设备对中文字体的渲染跟预期有差异,这是我在做武将名标签时踩到的。Flutter 默认字体在部分OpenHarmony 设备上,中文可能回退成默认字形,表现为字体发虚、字重不对。我试过直接用工程内打包中文字体文件,在 MaterialApp 主题里设置 fontFamily,效果稳定很多。这里提醒一下,中文字体文件往往十几 MB 起步,会显著增大安装包,如果只是统计页几个标签需要特殊字体,可以考虑只对这几处做局部 TextStyle,而不是全局替换所有字体。
另一个典型的坑是画笔复用。Flutter 的 Paint 对象持有 shader、color、style 等状态,每次新建会有小开销,但真正严重的是在循环里创建大量 Paint 并各自关联 shader,内存和 GC 压力会明显上升。我在画“牌堆类型分布”的 20 段条形图时,初始版本在循环里 new 了 20 个 Paint,结果页面反复切换之后,内存曲线一路向上。把 Paint 创建提到循环外,只修改 color 或者 shader 字段,问题就消失了。
关于文字布局,我要单独强调一下TextPainter的测量时机。绘制文字之前必须调用 layout,否则宽度高度都是默认值,文本位置、居中对齐全都会跑偏。我见过不少初学者在 paint 里直接 new TextPainter 就 paint,最后图形全挤在一角。正确姿势是先 textPainter.layout(),再用 textPainter.width 和 height 参与坐标计算。
5.3 三维/多维统计扩展:雷达图与热力图思路
当基础图表稳定后,自然就想做更多维度的分析。我后续加了一个“武将六维能力”雷达图,用来展示某个武将在输出、防御、续航、控制、辅助和节奏这六个维度上的倾向。雷达图的实现本质上就是算多边形顶点坐标:
Offset pointOnCircle(Offset center, double radius, double angle) { return Offset( center.dx + radius * math.cos(angle), center.dy + radius * math.sin(angle), ); }然后按六个维度均分圆周,再用 Path 连接这些顶点形成雷达轮廓。这个代码量不大,但视觉效果非常“专业”,用户一眼就能看懂武将定位。唯一要注意的是,如果某个维度数值为 0,多边形会变形甚至退化,我的处理是给所有维度加了一个最小基准值 0.1,保证图形完整。
热力图则是用来展示“不同身份 vs 胜率区间”的交叉分布。Flutter 里做热力图其实就是一个个彩色小方格拼接,颜色根据数值档位映射。我封装了一个mapValueToColor(double value)函数,用线性插值在深红和深蓝之间过渡。热力图的配色要谨慎,色盲用户对红绿对比不敏感,可以改用蓝橙渐变。如果你做到这一步,说明你的统计功能已经从“能看”变成了“能分析”,这个 App 的攻略价值会再上一个台阶。
写在最后的经验
这个项目的核心难点其实不在图表绘制本身,而在把一个真实业务问题拆成数据、状态、绘制三层,每一层都做得干净可控。Flutter for OpenHarmony 这个组合还远谈不上成熟,但正因如此,自己手写一遍图表才能对整个渲染链路的理解深入很多。如果你也在搞类似的多端统计应用,建议先别急着上重型图表库,把 CustomPainter 用熟,它会成为你手里最灵活的底牌。数据模型上的最小样本过滤、状态管理里的缓存思想、绘制上的画笔复用,这些经验拿到别的项目里也完全通用。