ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OpenHarmony上Flutter数独统计卡片开发:自绘环图与事件通道

OpenHarmony上Flutter数独统计卡片开发:自绘环图与事件通道 把数独游戏从老项目迁移到 OpenHarmony 设备上的时候我心里最没底的其实不是数独终盘生成算法也不是手势填数的交互而是角落那张不起眼的统计卡片。以前在别的平台上跑统计卡片组件只是把盘面用时和错误次数列出来没什么技术含量换到 Flutter for OpenHarmony 数独游戏App 这套组合后它反而成了最折腾人的一块。如果你也在做类似的迁移或者刚接触 OpenHarmony 生态下的 Flutter 开发这篇文章把统计卡片组件从数据模型、自绘方案、状态管理到平台通道适配的完整落地过程拆开讲一遍。1. 数据指标先行统计卡片到底要展示什么1.1 数独玩家真正关心的是“变化”不是“数字”做统计卡片最容易犯的错是把所有指标一股脑塞进一张卡片里。我第一版就是照着 Dashboard 做的总用时、平均用时、最快速度、连击天数、Hint 使用次数、每格错误率…… 塞了七八个数字结果卡片特别挤用户盯着三秒也不知道自己该看什么。先回到使用场景想。一个数独玩家打完一局之后想知道的其实就三件事我这次用了多久、难度越高越有什么变化、和我之前比是进步还是退步。所以统计卡片组件的指标不要追求全要追求“一个视图里能感受到变化”。最终我保留的指标是总对局数、胜局数、当前连胜天数、最近7天完成局数柱状小块、最佳用时、失误次数。Hint 使用次数这种次要数据点进详情再给。1.2 统计对象要做成“记录 快照”而不是临时算统计卡片组件最忌讳每次显示时去遍历所有历史对局记录现算总和。对局量少没关系一旦用户玩了两三百局每开一次页面就 O(n) 扫一遍 JSON哪怕在开发机上不卡在 OpenHarmony 设备上也会感觉到延迟。所以数据结构要分成两层原始记录SudokuSessionRecord一条代表一局结束后的快照事件聚合快照StatsSnapshot由所有记录派生的统计结果持久化缓存每次对局结束更新一次。两层结构的好处是卡片只管读StatsSnapshot逻辑干净。下面是记录模型的大致样子。enum Difficulty { easy, medium, hard, } class SudokuSessionRecord { final String gameId; final Difficulty difficulty; final DateTime startTime; final DateTime finishTime; final int durationInSeconds; final int mistakes; final int hintsUsed; final bool isWin; const SudokuSessionRecord({ required this.gameId, required this.difficulty, required this.startTime, required this.finishTime, required this.durationInSeconds, required this.mistakes, required this.hintsUsed, required this.isWin, }); MapString, dynamic toJson() { gameId: gameId, difficulty: difficulty.name, startTime: startTime.toIso8601String(), finishTime: finishTime.toIso8601String(), durationInSeconds: durationInSeconds, mistakes: mistakes, hintsUsed: hintsUsed, isWin: isWin, }; factory SudokuSessionRecord.fromJson(MapString, dynamic json) { return SudokuSessionRecord( gameId: json[gameId] as String, difficulty: Difficulty.values.byName(json[difficulty] as String), startTime: DateTime.parse(json[startTime] as String), finishTime: DateTime.parse(json[finishTime] as String), durationInSeconds: json[durationInSeconds] as int, mistakes: json[mistakes] as int, hintsUsed: json[hintsUsed] as int, isWin: json[isWin] as bool, ); } }1.3 为什么把统计做成“卡片”而不是“页面”卡片的天然特点是信息密度高、可嵌入可折叠。在数独游戏里统计不需要长期占据一个页面它在对局结束弹窗、主菜单侧边、设置页顶部都能出现。统计卡片组件只要接收一个StatsSnapshot参数就能渲染出同一套视觉这种复用价值是单独做页面给不了的。外观上我把它做成一张带圆角和浅底色渐变的容器左侧是“主数字区”右侧是环形进度和迷你柱状图。主数字区显示本局耗时右侧环显示难度完成度底部一排小点显示最近7天活跃度。点击卡片可以轮流切换“今日 / 累计 / 最佳”三种视图这个后面会聊。2. 环形进度条的自绘实现CustomPainter 的数学细节与性能习惯2.1 为什么不自接图表库统计卡片只需要一个环形图和几个小柱状条实在没必要引 fl_chart 这类重型图表库。引入一个通用图表库包体积上去组件 API 还要迁就对方的数据结构每次想改动画效果都像在反向拆解人家的 Canvas 封装。自己用 CustomPainter 画二三十行代码就搞定而且性能可控。关键是 CustomPainter 这套 API 在 Flutter for OpenHarmony 上也是共享的。它是 Flutter 渲染树的绘制回调只要引擎的 Canvas 能力对齐同一段 painter 代码可以直接复用不需要按平台写两套。2.2 drawArc 与 Paint 的配合环形图的核心是Canvas.drawArc。先说公式一个整圆对应的弧度是2 * π我们要画的进度比例是value0.0 到 1.0那扇形扫过的角度就是2 * π * value。为了让进度从正上方开始起始角要设置成-π / 2。class RingPainter extends CustomPainter { final double value; final Color color; RingPainter({required this.value, required this.color}); override void paint(Canvas canvas, Size size) { final strokeWidth size.shortestSide * 0.08; // 让线条不至于贴到组件边缘 final rect Rect.fromLTWH( strokeWidth / 2, strokeWidth / 2, size.width - strokeWidth, size.height - strokeWidth, ); final trackPaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round ..color color.withValues(alpha: 0.15); final fillPaint Paint() ..style PaintingStyle.stroke ..strokeWidth strokeWidth ..strokeCap StrokeCap.round ..color color ..shader SweepGradient( startAngle: -3.1415927 / 2, endAngle: 3 * 3.1415927 / 2, colors: [color.withValues(alpha: 0.6), color], ).createShader(rect); canvas.drawArc(rect, -3.1415927 / 2, 3.1415927 * 2, false, trackPaint); canvas.drawArc(rect, -3.1415927 / 2, 3.1415927 * 2 * value, false, fillPaint); _drawCenterText(canvas, size); } }四个细节提醒一下。Rect一定要往内缩strokeWidth / 2不缩的话半条线会画到组件外面导致视觉上环被切边。这个问题我头一次画就踩了。useCenter参数要传false我们要的是圆环不是实心扇形。StrokeCap.round会让开头结尾多出半圆头画 100% 进度时会出现一小段重叠所以进度为 1.0 时最好单独画一个完整圆。SweepGradient的startAngle/endAngle要和drawArc的起止角保持一致否则明明只画了 70% 的弧渐变却从另一头开始转特别难看。中心数字用 TextPainter 画就行宽度用textPainter.width取再通过Offset((size.width - textPainter.width) / 2, (size.height - textPainter.height) / 2)定位。2.3 动画曲线和局部重绘进度环必须有动画否则统计卡片看起来死气沉沉。我用一个AnimationController控制时长 800ms曲线用Curves.easeOutCubic。开始动画前先controller.forward(from: 0)让每次切换到新卡时都有从低到高的生长感。性能上有个反直觉的点动画每一帧都在触发 build但不要让 build 里每次都创建 Paint 对象。Painter 内部的 Paint 如果属性不变应该做成临时变量缓存否则每帧都在走对象创建和 GC。RepaintBoundary也要包在环形图外面这样卡片其他部分的文字变化不会拖着重绘整个 Canvas。2.4 Impeller 渲染引擎下的绘制注意事项新版 Flutter 默认跑 Impeller 渲染器OpenHarmony 移植版也在逐步跟进。我遇到过一个现象使用SweepGradientStrokeCap.round时部分 OpenHarmony 设备上出现弧线边缘发虚、渐变色带明显看起来跟锯齿一样。排查一圈后发现是 Impeller 对该组合的 shader 编译路径和 Skia 不完全一致。调试手段是在启动时临时关闭 Impellerflutter run --no-enable-impeller关掉后如果画面恢复基本可以确认是渲染器兼容问题。我当时没有立刻改代码而是先把渐变改成两段纯色过渡效果虽然不如渐变细腻但胜在稳。后来升级 Flutter 版本后渐变恢复正常再换回来的。遇到这种情况别慌先确认是不是渲染器差异再决定是改代码还是依赖升级。3. 数据流转与状态管理一局结束后的统计更新链路3.1 对局结束那一瞬间发生了什么统计卡片的数据更新入口不在卡片内部而在对局结束的回调里。用户提交最后一格、提示“完成”时游戏逻辑会拼装一条SudokuSessionRecord包含难度、耗时、失误、Hint 使用次数和是否胜利然后交给一个统计控制器。整个过程像流水线对局逻辑 → 记录落盘 → 聚合快照 → 通知卡片刷新。落盘我用的最简单方案本地 JSON 文件 shared_preferences存路径。OpenHarmony 上path_provider和shared_preferences都有对应的 ohos 适配实现只要 pubspec 依赖包选对Dart 层代码不用动。真正要注意的是写入频率不要每局都写全量快照建议先写记录再异步聚合快照避免卡在对局结束的转场动画上。3.2 用 ChangeNotifier 描述统计快照统计卡片的状态树很小我不建议上来就上 Riverpod 或者 Bloc。ChangeNotifier自带监听机制轻量直接对单卡片场景足够。class StatsController extends ChangeNotifier { StatsSnapshot _snapshot StatsSnapshot.empty(); StatsSnapshot get snapshot _snapshot; void addRecord(SudokuSessionRecord record) { // 读取旧快照更新总数、最佳时间、连胜天数 _snapshot _snapshot.copyWith( totalGames: _snapshot.totalGames 1, totalWins: _snapshot.totalWins (record.isWin ? 1 : 0), bestSeconds: _snapshot.bestSeconds record.durationInSeconds ? record.durationInSeconds : _snapshot.bestSeconds, ); // 持久化新快照 _persistSnapshot(_snapshot); // 通知统计卡片重建 notifyListeners(); } }卡片侧只需要在initState里加监听在dispose里取消监听。如果你用的是flutter_cubit这种方案本质也一样把 Cubit 放在根级不要放在页面 State 里。3.3 组件通信和页面切换不丢状态的关键很多人在 Flutter 里遇到“Navigator 切换页面后统计卡片的状态丢了”的问题。原因多数是状态对象绑定在了页面 Widget 的 State 生命周期上——页面 A push 页面 BB 返回时 A 的 State 可能被重建如果 State 里临时统计的数据自然就没了。我的做法是把StatsController挂在 App 根级的InheritedWidget或直接构造参数下发它的生命周期和根组件一样长。统计卡片无论在哪个页面打开读到的都是同一个控制器实例。这样还有一个好处对局中切到后台、再切回来统计快照不会因为页面被回收而重置。组件通信方面卡片本身不要持有修改数据的能力。它只负责读快照、显示数值点击切换维度时调用控制器暴露的switchView()更新的是卡片内部的显示模式。写和读分离后续加新指标时不容易牵连 UI。4. OpenHarmony 平台接入EventChannel 与 Flutter 插件适配的边界4.1 在 OpenHarmony 上跑 Flutter 的第一课构建目标变了普通的 Flutter 工程默认创建 android/ios 目录在 OpenHarmony 上跑需要换成支持 ohos 目标的 Flutter SDK/工具链工程里会多出ohos目录构建产物从apk变成hap。命令类似flutter build hap第一次接触这个流程时最容易遇到的是版本不匹配警告。比如热词里那个报错“The current configured Flutter SDK is not known to be fully supported”。我那时的处理方法是先不看别的直接检查三样东西Flutter SDK 版本、ohos 插件版本、原生侧oh-package.json里的依赖版本。三者对齐后警告一般就消失。不要混用不同版本的 flutter tool 和 OpenHarmony SDK排查成本极高。4.2 EventChannel 处理持续事件流统计卡片本身不直接使用 EventChannel但它后面接的“对局事件流”会用到。比如原生侧在检测到设备休眠、应用退到后台、或者系统时间跨天时需要通知 Flutter 侧刷新统计日期的边界。这类持续事件用MethodChannel不合适它是请求响应模式原生侧没法主动把数据推给 Dart 层所以要用EventChannel。Flutter 侧平时我们这样收流class StatsDataSource { static const EventChannel _statsEventChannel EventChannel(sudoku/stats_stream); StreamObject?? _stream; StreamObject? get statsStream { _stream ?? _statsEventChannel.receiveBroadcastStream(); return _stream!; } }拿到流之后在卡片或页面里监听事件跨天时重新从本地缓存读取新一天的快照并notifyListeners()。注意一个很容易犯的错EventChannel 的流是广播流页面销毁时一定要取消订阅否则卡片已经被回收事件还在往里塞数据轻则泄漏重则崩溃。一次性流我记得也有实现差异所以在 OpenHarmony 上我更推荐用广播流 手动管理订阅生命周期。原生侧实现EventChannel.StreamHandler时要在onListen里把事件源注册进去onCancel里释放监听。这块在 OpenHarmony 的插件模板里已经写得很清楚照着把平台事件翻译成标准消息结构发回来即可。4.3 插件报错的定位思路OpenHarmony 生态下 Flutter 插件数量有限统计卡片用到的依赖不一定都有适配版本。排查插件问题时我的顺序是固定的先确认插件包名后面有没有ohos的适配。pub 上有些插件只有 android/ios 实现直接拿过来编译会报 “Unable to load plugin” 之类的运行时错误。再确认插件原生侧有没有在FlutterPlugin注册表里登记。有些自研插件只写了 Dart 端忘了在 ohos 原生入口onAttachedToEngine里注册事件通道就会一直安静地收不到任何数据。最后检查ohos目录下的权限配置。比如要访问剪贴板用于粘贴数字或者要常驻后台保持对局计时manifest 里缺权限会表现为“功能偶尔生效”。我在这个阶段碰到最大的坑是插件拿不到宿主上下文。OpenHarmony 插件模型里要拿到Context必须等onAttachedToEngine之后不能在插件构造方法里提前取否则一直是空。这和 Android 插件的行为有差异照着 Android 习惯写很容易埋雷。5. 从能跑到好看交互细节、避坑清单与验收方法5.1 点击切换统计维度的最小刷新策略统计卡片支持“今日 / 累计 / 最佳”三个维度切换。第一版我是整个卡片setState切换时从数据到圆环到柱状图全部重建肉眼可见地闪一下。后来拆成三个独立区域中心大数字用AnimatedSwitcher做上下滑动切换环形进度只有数值变化交给 Painter 自己去动画柱状小图是Row里的若干AnimatedContainer单独更新高度。这样点击切换时只有对应区域在动卡片底色和边框保持const部分不变低端设备上也流畅。卡片类组件想要好看核心不是加多少动画而是把“变化区域”和“不变区域”彻底切开。5.2 调试统计卡片时我会开的三个开关开发阶段推荐打开以下三个东西能省很多肉眼排查时间debugPaintSizeEnabled看卡片内边距和圆环位置。环形图一旦贴边开启后立刻能发现 padding 是不是被某个布局吃掉了。debugRepaintRainbowEnabled看页面哪些区域在频繁重绘。统计卡片如果一闪一闪像彩虹灯说明 setState 范围太大该用 RepaintBoundary 隔离。PerformanceOverlay或 DevTools 的 Flutter Performance看绘制帧耗时。进度环动画期间如果每帧都在 16ms 边缘挣扎优先查 Painter 内部是否每帧新建 Paint、文字是否每帧重新 layout。5.3 常见问题对照表把踩过的坑整理成了一张参考表遇到问题时可以直接对着查。现象可能原因解决方案进度环切边Rect没内缩 strokeWidth/2绘制矩形用deflate或手动内缩圆环 100% 时出现缺口重叠StrokeCap.round起止端重叠progress 为 1 时改用完整圆绘制切换统计维度很卡整卡 setState动画范围过大拆区域用 AnimatedSwitcher 最小化刷新翻页回来统计数字丢失状态绑在页面 State 上把 Controller 挂在根级OpenHarmony 上 EventChannel 收不到事件原生侧没注册 StreamHandler检查插件onAttachedToEngine注册逻辑渐变环出现色带/锯齿Impeller 与 Skia 差异临时用--no-enable-impeller验证或换纯色分段shared_preferences 报插件不存在依赖包里没有 ohos 适配换用带 ohos 的适配包或自写数据持久化最后说一个特别容易被忽略的细节统计卡片的时间边界。数独对局可能在 23:59 开始、00:01 结束如果按“结束时间”归档跨天会导致两个日期的统计都不准确。我在StatsSnapshot里专门加了一个字段记录归档基准日跨天时把前一天的局归入开始时间所在日期。这个逻辑不写出来平时根本发现不了等用户跨天连续玩了两局才会露馅。统计卡片组件看似只是画个圆环加几个数字但它横跨了数据模型设计、自绘渲染、状态管理、平台通道和交互优化。在我个人看来这类“小组件”反而是最能考验 Flutter 基本功的地方。如果以后要把三消、拼图类游戏也迁移到 OpenHarmony 上这套统计卡片的思路可以直接复用只是指标和图表形态换一下而已。
返回列表