Flutter数独App统计卡片组件设计:从Cubit状态管理到OpenHarmony适配实践

发布时间:2026/9/28 5:13:50
Flutter数独App统计卡片组件设计:从Cubit状态管理到OpenHarmony适配实践 最近在把一款数独游戏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 MapDifficulty, int completedByDifficulty; SudokuStats({ required this.totalGames, required this.completedGames, required this.bestSeconds, required this.avgSeconds, required this.completedByDifficulty, }); factory SudokuStats.fromJson(MapString, 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 MapString, dynamic? ?? {}) .map((key, value) MapEntry(Difficulty.values.byName(key), value as int)), ); MapString, 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 CubitStatsState { StatsCubit(this._repository) : super(StatsLoading()); final StatsRepository _repository; Futurevoid 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设备有手机模式也可能以折叠屏或平板的窗口形态运行宽度一旦超过600dpSliverGridDelegateWithFixedCrossAxisCount里的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), ], ), ), ), ); }这里我加了一整层MaterialInkWell不只是为了点击水波纹更重要的是让卡片在无障碍阅读和焦点移动时有明确的实体边界。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 TweenAnimationBuilderdouble( 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: AlwaysStoppedAnimationColor(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 ValueChangedSudokuStats onStatsChanged}) { _channel.receiveBroadcastStream().listen( (event) { final stats SudokuStats.fromJson(MapString, 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 Flutters 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侧开始监听之前的消息会直接丢弃。解决方法是原生侧在客户端注册成功后再发送“历史统计”类似补发机制。第三检查是否在错误线程调用setStreamHandlerArkTS侧必须在主线程绑定。我还遇到了一个更隐蔽的问题统计卡片组件被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加一个类似的数据反馈模块这个拆分思路可以直接抄作业。