最近在把一款数独游戏App往OpenHarmony平台上迁移,顺手把首页那组统计卡片组件重写了一遍。之前这堆卡片其实是临时拼的,Container套着几行Text,数据直接从全局变量里读,看起来能用,但页面一切换就掉状态,数据一多就挤在一起。这次我专门把它抽成独立组件,也把数据层、事件通道、平台适配一并理清了。如果你也在用Flutter开发数独或者类似的益智类App,尤其是目标平台带OpenHarmony,这篇的内容可以直接当参考。
1. 统计卡片组件:为什么值得单独做成独立模块
1.1 不是花架子,是留存和成就感的直接入口
很多单机游戏做完核心玩法就觉得项目完工了,其实玩家第一次通关之后最想看到的不是“再开一局”的按钮,而是“我这局用了多久”“一共完成了多少题”“困难模式推到什么进度了”。这些数据得在最能触发成就感的节点出现,统计卡片干的就是这件事。
在数独App里,统计卡片一般放在主菜单首页顶部,或者游戏结算页的下方。它承担的不是冷冰冰的信息罗列,而是给玩家一个继续玩下去的理由。玩家看到自己的最佳时间被刷新,或者某个难度完成率超过80%,大概率会再点一次“新游戏”。所以我从一开始就没把它当成简单的UI组件,而是和游戏状态、数据持久化、平台事件通道绑在一起设计。
1.2 统计字段怎么定:先想清楚卡片上放什么
我最初想放一堆数字,总局数、胜率、平均时间、最佳时间、当前难度分布,结果预览图一出来,整个卡片区域密密麻麻,用户根本抓不住重点。后来我按三层来划分:
| 层级 | 统计项 | 展示形式 |
|---|---|---|
| 核心指标 | 总完成数、最佳时间、最近7天完成数 | 大数字卡片 |
| 次要指标 | 平均耗时、总耗时 | 小数字卡片 |
| 进度类 | 简单/中等/困难/专家完成率 | 进度条或圆环 |
这样设计以后,“最佳时间”这种强成就感数据就能放大字号,放在左上角第一眼看到的位置。字段层面,我用一个SudokuStats对象统一承载,通过JSON序列化落到本地。
1.3 组件边界:展示归展示,计算归计算
统计卡片组件最容易犯的毛病是把“统计计算逻辑”也塞进Widget里。比如在build方法里循环遍历游戏记录算平均值,这就是典型的坏味道。我这次明确拆了三层:
- 数据层负责从存储里读取记录,计算出统计结果;
- 状态层用Cubit持有统计结果,并对外暴露状态变化;
- UI层只负责把状态里的数据渲染成卡片。
这样做的好处很直接:以后想增加“导出战绩”或者“云同步”,只需要动数据层,卡片组件一行不用改。而且组件可以在游戏结算页和首页重复使用,不用复制代码。
2. 数据层与状态管理:用Cubit加part拆分文件
2.1 模型先行:SudokuStats和DifficultyStats
我先把统计模型定下来,字段一旦确定,后面的计算和UI都不会跑偏。项目里lib/models/stats_models.dart写的是最基础的几个类:
class SudokuStats { final int totalGames; final int completedGames; final int bestSeconds; final double avgSeconds; final Map<Difficulty, int> completedByDifficulty; SudokuStats({ required this.totalGames, required this.completedGames, required this.bestSeconds, required this.avgSeconds, required this.completedByDifficulty, }); factory SudokuStats.fromJson(Map<String, dynamic> json) => SudokuStats( totalGames: json['totalGames'] as int, completedGames: json['completedGames'] as int, bestSeconds: json['bestSeconds'] as int, avgSeconds: (json['avgSeconds'] as num?)?.toDouble() ?? 0, completedByDifficulty: (json['completedByDifficulty'] as Map<String, dynamic>? ?? {}) .map((key, value) => MapEntry(Difficulty.values.byName(key), value as int)), ); Map<String, dynamic> toJson() => { 'totalGames': totalGames, 'completedGames': completedGames, 'bestSeconds': bestSeconds, 'avgSeconds': avgSeconds, 'completedByDifficulty': completedByDifficulty.map((key, value) => MapEntry(key.name, value)), }; }Difficulty是枚举,简单、中等、困难、专家四档。这里要提醒一点:枚举序列化时不要用index,因为以后一旦调整枚举顺序,旧数据就全乱了。数字格式化我放到UI层处理,模型层永远保持“秒”和“局”这种原始单位。
2.2 为什么选Cubit而不是Bloc
项目里正在用状态管理,我这次没有上完整的Bloc,而是选Cubit。原因很实际:统计卡片的状态变更逻辑很简单,无非就是“收到新数据→更新状态→UI重建”。用Bloc需要写StatsEvent、StatsState、StatsBloc三个文件,还要考虑事件分发,对于这个场景属于过度设计。
Cubit只需要一个类和一个状态文件,逻辑清晰,也方便单测。我在stats_cubit.dart里这样写:
class StatsCubit extends Cubit<StatsState> { StatsCubit(this._repository) : super(StatsLoading()); final StatsRepository _repository; Future<void> load() async { emit(StatsLoading()); try { final stats = await _repository.fetchStats(); emit(StatsLoaded(stats)); } catch (_) { emit(StatsError()); } } void forceReload(SudokuStats stats) { emit(StatsLoaded(stats)); } }forceReload是给结果页使用的,当玩家完成一局数独,原生侧通过EventChannel把最新记录推过来,Flutter侧收到后不需要重新读数据库,直接刷新状态即可。
2.3 part和part of怎么用在多文件库中
随着模型、状态、Cubit、repository文件越来越多,我发现stats.dart这个库文件下挂了太多import。有的开发者会把每个类都单独建import,然后互相import,结果出现循环依赖。其实Dart提供了一个很顺手的关键字:part。
我最终的目录长这样:
lib/stats/ stats.dart stats_models.dart stats_state.dart stats_cubit.dart stats_repository.dart其中stats.dart只是入口,内容非常少:
library stats; part 'stats_models.dart'; part 'stats_state.dart'; part 'stats_cubit.dart'; part 'stats_repository.dart';而每个part文件顶部不需要再写import 'dart:async'这类公共依赖,因为part文件会共享主库的import作用域。需要注意,part文件里不能出现library声明,所有私有类成员在part之间是互相可见的,这既是方便也是坑。我曾经把一个_buildChartData函数写在主库文件里,结果part文件访问不了,排查了半天才发现作用域规则。
用part最大的好处是:模块对外只暴露stats.dart一个入口,外部调用方只需写import 'stats/stats.dart',不需要关心内部文件拆分。这对组件化设计非常友好。
2.4 持久化与数据来源
统计数据的来源有两个:一是历史战绩存储,二是实时游戏事件。历史战绩我存在本地JSON文件里,统计卡片每次加载时读取一次,然后由Cubit转成StatsLoaded。实时事件则通过OpenHarmony原生侧监听游戏引擎回调,再用EventChannel推给Flutter侧。
本地存储我原本想用shared_preferences,但考虑到统计数据结构比较复杂、字段会持续增加,最终还是用path_provider拿到应用文件目录,手动写入stats.json。这样模型层加字段不用做繁琐的key迁移,天然支持以后扩展。
3. 统计卡片UI实现:从布局到数字滚动动画
3.1 自适应卡片网格
统计卡片的UI布局我用的是GridView嵌套在CustomScrollView里,而不是写死的Row。原因是OpenHarmony设备有手机模式,也可能以折叠屏或平板的窗口形态运行,宽度一旦超过600dp,SliverGridDelegateWithFixedCrossAxisCount里的crossAxisCount可以动态切换。
我封装了一个自适应卡片:
Widget _buildStatCard({ required String label, required String value, required IconData icon, VoidCallback? onTap, }) { return Material( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.circular(20), elevation: 1, child: InkWell( borderRadius: BorderRadius.circular(20), onTap: onTap, child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Icon(icon, size: 22, color: Theme.of(context).colorScheme.primary), const Spacer(), Text( value, style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 4), Text(label, style: Theme.of(context).textTheme.bodyMedium), ], ), ), ), ); }这里我加了一整层Material+InkWell,不只是为了点击水波纹,更重要的是让卡片在无障碍阅读和焦点移动时有明确的实体边界。OpenHarmony上一些设备的无障碍服务对无Material容器包裹的语义区域识别很差,这算是我吃了一次亏后的补丁。
3.2 数字改变时的滚动动画
统计卡片如果只是瞬间从“0”跳到“89”,用户根本注意不到数据在变化,成就感会少一大截。我做了个数字滚动动画,用TweenAnimationBuilder把整数从旧值过渡到新值。
class AnimatedCounter extends StatelessWidget { final int value; final Duration duration; const AnimatedCounter({super.key, required this.value, this.duration = const Duration(milliseconds: 800)}); @override Widget build(BuildContext context) { return TweenAnimationBuilder<double>( tween: Tween(begin: 0, end: value.toDouble()), duration: duration, curve: Curves.easeOutCubic, builder: (context, animatedValue, _) { return Text( animatedValue.round().toString(), style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.bold), ); }, ); } }这个组件有个细节:当value从外部更新时,因为TweenAnimationBuilder内部会自动以当前动画值作为新的begin,所以连续更新时不会出现“闪回0”的跳变。比如先显示25,再更新成89,动画会从25滚到89,看起来很自然。
性能方面,像这种几百毫秒的补间动画对GPU压力很小,但如果卡片数量超过8个,同时播放就容易掉帧。我的处理方式是给每张卡片的动画加上不同的delay,用Future.delayed控制启动时间,造成依次滚动的视觉效果。
3.3 难度分布进度条
数独的难度分布不能只用数字表达,因为玩家对“简单完成70%”和“专家完成5%”没有线性感知。我用的是一个自绘进度条,每档难度显示在卡片底部:
class DifficultyBar extends StatelessWidget { final String label; final double progress; final Color color; const DifficultyBar({ super.key, required this.label, required this.progress, required this.color, }); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label), Text('${(progress * 100).toStringAsFixed(0)}%'), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: progress.clamp(0.0, 1.0), minHeight: 8, backgroundColor: Colors.black12, valueColor: AlwaysStoppedAnimation<Color>(color), ), ), ], ); } }progress我按照“该难度已完成题目数 / 该难度总生成数”计算,而不是所有难度混在一起算。这样玩家可以直观看到自己在某一档的推进情况,比单一总进度更符合数独游戏的分级挑战心理。
3.4 空数据、加载态和深色模式
统计卡片页面刚打开时会有一小段时间读取文件,我给了骨架屏,而不是转圈loading。骨架屏用灰底占位块模拟卡片结构,视觉上比Spin指示器更平滑。
空数据状态也容易被忽略。新玩家还没有任何游戏记录,如果直接显示“最佳时间:0秒”“平均耗时:0秒”会非常奇怪。我在StatsLoaded里判断totalGames == 0,直接渲染一张引导卡:“完成第一局数独,解锁个人统计”。
深色模式这块,我没用固定的Colors.white,全部取自Theme.of(context).colorScheme,这样OpenHarmony系统切换深色模式时卡片会自动跟随。实际测试发现,LinearProgressIndicator的backgroundColor在深色模式下如果用Colors.black12几乎不可见,我改成Theme.of(context).colorScheme.surfaceContainerHighest后好很多。
4. Flutter for OpenHarmony平台适配:EventChannel与构建细节
4.1 Flutter for OpenHarmony环境怎么搭
Flutter官方SDK默认不支持OpenHarmony,需要拉取社区维护的OpenHarmony版本Flutter SDK。我这里的做法是单独克隆一个分支,不和Android/iOS的Flutter混用,然后通过FVM管理不同项目依赖的SDK版本。
环境配置上有一个容易踩的坑:OpenHarmony SDK路径和Android SDK路径不能混在一个local.properties里。OpenHarmony侧需要配置ohosSdkDir,否则构建时找不到ohos平台。我的local.properties长这样:
sdk.dir=/Users/me/Library/Android/sdk ohos.sdk.dir=/Users/me/ohos-sdk如果一开始只配了sdk.dir,构建时它会尝试用Android SDK去解析OpenHarmony,报错信息又长又绕,最后基本都指向“找不到ohos工具链”。我花了半天才意识到是环境配置指向问题。
4.2 EventChannel推送游戏统计变更
一开始统计卡片的数据是页面打开时主动拉取的,但结果页需要刷新首页数据,就得用EventChannel。Flutter侧声明一个通道,监听原生侧推送的消息:
class StatsEventChannel { static const EventChannel _channel = EventChannel('com.example.sudoku/stats_channel'); static void startListening({required ValueChanged<SudokuStats> onStatsChanged}) { _channel.receiveBroadcastStream().listen( (event) { final stats = SudokuStats.fromJson(Map<String, dynamic>.from(event as Map)); onStatsChanged(stats); }, onError: (Object e, StackTrace st) { debugPrint('StatsEventChannel error: $e'); }, ); } }原生侧在OpenHarmony应用里用ArkTS API实现同样的通道名称。这里最需要注意的是:通道名称必须完全一致,且两侧的JSON字段名要能对得上。我一开始用驼峰命名,后来原生侧返回的是下划线命名,Flutter侧解析出来全是空数据,排查半天。
EventChannel适合单向持续推送,如果要双向调用,比如Flutter主动让原生计算某段统计数据,建议改用MethodChannel。我这次是因为原生游戏引擎会实时产生成绩数据,所以用EventChannel更合适。
4.3 第三方插件怎么适配OpenHarmony
统计卡片本身没有用到太重的外部插件,但项目里还依赖了其他第三方库。很多Flutter插件默认只实现了Android/iOS平台代码,在OpenHarmony上跑起来会报MissingPluginException。我参考了社区里比较典型的适配流程:先检查插件是否已经有OpenHarmony实现,没有的话就在自己的项目中通过plugin方式补一份。
说起来其实不复杂:在Flutter插件工程下新建ohos目录,用ArkTS实现与Android相同的方法名和通道名,然后注册到PluginRegistry。以某些登录SDK适配为例,适配的核心就是“让原生侧的代码响应Flutter侧发过来的MethodCall”,业务逻辑不复杂,复杂的是各种原生SDK的初始化时序。
我给项目组定了一个原则:凡是统计卡片相关的功能,不依赖任何没有OpenHarmony实现的第三方插件。能自己写的就用Dart实现,数据持久化只用path_provider这类已经适配好的插件。这个原则在后面排查兼容性问题时帮了大忙。
4.4 两个高频Gradle/SDK报错
构建OpenHarmony版本时,我先后遇到了两个经典报错,网上讨论热度也很高。
第一个是:
You are applying Flutter's main Gradle plugin imperatively using the apply method...这通常是因为项目里还在用旧式Gradle写法,在模块的build.gradle里直接apply plugin: 'com.android.application'。现在OpenHarmony适配版的Flutter Gradle插件改用plugins DSL,需要在settings.gradle里声明插件版本并统一管理。修改方式大致是:
plugins { id 'com.android.application' version '8.x.x' apply false id 'dev.flutter.flutter-gradle-plugin' apply false }然后在app模块里:
plugins { id 'com.android.application' id 'dev.flutter.flutter-gradle-plugin' }第二个高频报错是:
The current configured Flutter SDK is not known to be fully supported.这个反而好解决,一般是OpenHarmony分支Flutter SDK和Android Gradle Plugin版本组合过新,或者全局Flutter SDK版本和项目里FVM锁定的版本不一致。我最后固定用了一份经过验证的SDK版本组合,不轻易升级。玩Flutter for OpenHarmony,版本稳定比版本新重要得多。
5. 常见问题排查:状态丢失、掉帧与消息漏收
5.1 Navigator切换后统计卡片还是旧数据
这个坑其实和OpenHarmony关系不大,是Flutter状态管理本身的问题。游戏结算页跳转用Navigator.push,在结算页更新了统计,但回到首页时发现卡片还是旧数据。
原因是我一开始把StatsCubit放在了首页Widget的State里创建,页面销毁时Cubit也跟着销毁。解决办法是把Cubit提升到组件根部,或者在路由跳转时用同一个Cubit实例。我最终用了一个简单做法:给首页的统计卡片区域传入一个全局的StatsRepository,页面每次从后台恢复时,在RouteAware回调里重新调用load()。
但这里要注意一个性能陷阱:重新load()会先emitStatsLoading,导致卡片闪一下骨架屏。如果玩家只是从结算页返回,最好用EventChannel推送的新数据直接forceReload,而不是全量重新加载。只有App冷启动或从后台切回时才走完整load()流程。
5.2 Impeller渲染引擎下的动画掉帧
Flutter从3.7开始逐渐切换Impeller渲染器,OpenHarmony分支也跟进了。Impeller在大部分场景下性能比Skia好,但我在低内存设备上发现统计卡片的阴影动画偶尔掉帧,尤其是连续快速切换数据时。
排查下来问题出在Material卡片的elevation阴影叠加太多,Impeller对模糊阴影的绘制开销比较大。我的优化方式是:在统计卡片区域用clipRRect+纯色边框代替大面积阴影,层级变浅后帧率明显稳定。
如果你也想保留阴影质感,可以考虑把多张卡片的阴影合并到父容器上,避免每张卡片单独渲染长阴影。还有个技巧是给动画中的Material设置elevation: 0,等动画结束再恢复,交互视觉损失很小。
5.3 EventChannel收不到数据
EventChannel在页面initState里注册,结果打开统计卡片时总是收不到原生侧的数据。排查步骤有三个:
第一,确认通道名是否一致,我错在下划线命名和驼峰命名各写一半。第二,确认注册时机是否晚于原生事件发送时间——EventChannel和MethodChannel不一样,它的事件是广播式的,在Flutter侧开始监听之前的消息会直接丢弃。解决方法是原生侧在客户端注册成功后再发送“历史统计”,类似补发机制。第三,检查是否在错误线程调用setStreamHandler,ArkTS侧必须在主线程绑定。
我还遇到了一个更隐蔽的问题:统计卡片组件被PageView预加载时,initState在不可见页面也执行了,但组件被回收后监听没有移除,导致内存泄漏。解决方式是在dispose里调用_channel.receiveBroadcastStream()返回的订阅对象的cancel()。
5.4 打包时遇到断言错误
构建OpenHarmony发行包时,有次打包直接抛了类似这样的错误:
java.lang.AssertionError: java.lang.Exception: Could not close i...当时还以为是Flutter打包脚本的问题,后来发现是代码里某个资源文件被占用,Windows上文件句柄没释放。清理掉进程里残留的Gradle守护进程,把build目录删掉重新打包就恢复了。
常见的打包断言错误也不全是环境问题,有时候是Dart代码里有未处理的未续期StreamSubscription,在树摇优化时触发了断言。所以打包前先跑一遍flutter analyze和flutter test,比翻编译日志更省时间。
6. 这个项目里的几个实操心得
做完这套统计卡片组件,我最明显的感觉是:Flutter开发OpenHarmony应用,难点不在Dart层,而在平台通道和工具链的适配。统计数据如何持久化、卡片怎么画,这些都有成熟模板,真正让人头疼的是SDK版本、EventChannel注册时机、ArkTS线程模型这些细节。
给其他想尝试的人几个实用建议:
- 统计组件的数据模型一定要提前定好,而且JSON字段要加版本号,方便以后迁移;
- 卡片展示的动画不要全满铺,给每张卡片错峰启动,体验反而更高级;
- 依赖插件越少越好,第三方插件OpenHarmony适配参差不齐,能自己封装功能就自己封装;
- 构建环境单独准备一套,不要和Android工程共用全局Flutter SDK,版本组合固定下来能省掉大量来回折腾的时间。
我最后还要分享一个小技巧:统计卡片组件里的颜色不要写死,统一从Theme.of(context).colorScheme取。这样不仅适配深色模式,OpenHarmony上不同设备厂商的主题定制也能直接兼容,不用为某款设备单独写样式。这个组件做完之后,我又把它复用到了另一款扫雷小游戏的战绩页,基本只改了字段名,UI和数据模型完全复用。如果你想给自己的App加一个类似的数据反馈模块,这个拆分思路可以直接抄作业。