news 2026/9/16 11:34:01

用 flame_riverpod 打通 Flutter 组件与 Flame 游戏组件:基于 StreamProvider 的实时同步示例深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用 flame_riverpod 打通 Flutter 组件与 Flame 游戏组件:基于 StreamProvider 的实时同步示例深度解析

用 flame_riverpod 打通 Flutter 组件与 Flame 游戏组件:基于 StreamProvider 的实时同步示例深度解析

【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame

导读

Flame 游戏引擎中的Component并非 Flutter Widget,无法直接使用flutter_riverpodConsumerWidgetref.watch等机制来响应状态变化。flame_riverpod正是为解决这一鸿沟而生:本仓库packages/flame_riverpod/example中的示例用最简单的StreamProvider计数器,演示了如何让一个 FlutterTextWidget 与一个 FlameTextComponent同一数据源实时同步更新。读完本文,你将掌握RiverpodAwareGameWidgetRiverpodGameMixinRiverpodComponentMixin三件套的正确使用姿势,理解 Provider 订阅如何与 Flame Component 的生命周期自动绑定,并能独立复刻或扩展这个示例。

示例要解决的问题

官方 flame_riverpod 包说明 开宗明义:

  • flutter_riverpod中,Widget 可以在 Provider 状态变化时自动重建;
  • 但在 Flame 中,我们打交道的是Component,它不是 Widget,因此无法直接享受这套响应式机制;
  • flame_riverpod提供RiverpodAwareGameWidgetRiverpodGameMixinRiverpodComponentMixin,让 Provider 的状态管理能力渗透进 Flame 游戏世界。

示例 README(packages/flame_riverpod/example/README.md)将目标描述得非常清晰:

示例由一个非常简单的FlameGame与一个自定义Component组成,并与一个可比较的 Flutter Widget 同步更新。两者都依赖一个无限向上计数的StreamProvider。Flame Component 与 Flutter Text Widget 从同一数据源实时更新。

也就是说,这个示例的核心价值是证明同一份 Provider 状态可以被「Flutter 世界」和「Flame 世界」同时消费,且完全同步

示例整体结构

在动手看代码前,先梳理仓库中与示例相关的文件:

文件作用
示例入口 main.dart完整的示例实现
示例测试 widget_test.dart验证 Flutter 与 Flame 两侧计数文本一致
示例 pubspec.yaml依赖与运行环境约束
包源码 consumer.dartRiverpodComponentMixinRiverpodGameMixinComponentRef实现
包源码 widget.dartRiverpodAwareGameWidget与对应 State 实现
包级测试 widget_test.dart验证回调注册/注销与订阅生命周期

示例依赖版本(见 example/pubspec.yaml):flame ^1.38.0flame_riverpod ^5.5.5flutter_riverpod ^3.0.3,Dart SDK 要求>=3.12.0 <4.0.0。运行方式与常规 Flutter 项目一致:在packages/flame_riverpod/example目录下执行flutter pub getflutter run

数据源:一个无限计数的 StreamProvider

示例的状态源头定义在 main.dart,是全篇唯一的数据定义:

final countingStreamProvider = StreamProvider<int>((ref) { return Stream.periodic(const Duration(seconds: 1), (inc) => inc); });

Stream.periodic每秒发射一个递增整数(0、1、2、3……),StreamProvider<int>将其包装成异步状态。这个 Provider 既不被 Flutter 侧独占,也不被 Flame 侧独占——两侧都是通过ref.watch/ref.listen消费它,这正是「单一数据源」思想的体现。

入口处,应用必须被ProviderScope包裹(Riverpod 3.x 的硬性要求),同时创建一个RiverpodAwareGameWidgetState类型的GlobalKey

void main() { runApp(const ProviderScope(child: MyApp())); } final gameInstance = RefExampleGame(); final GlobalKey<RiverpodAwareGameWidgetState> gameWidgetKey = GlobalKey<RiverpodAwareGameWidgetState>();

注意gameInstance是顶层单例,GlobalKey的类型是RiverpodAwareGameWidgetState(而不是普通的GameWidgetState),这个 Key 会被注入游戏内部,作为Component访问ProviderContainer的通道(详见后文源码剖析)。

页面布局:Flutter 侧与 Flame 侧并排

MyApp用一行Row把两侧放在同一屏,方便肉眼对比实时同步效果(main.dart):

home: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Expanded(child: FlutterCountingComponent()), Expanded( child: RiverpodAwareGameWidget( key: gameWidgetKey, game: gameInstance, ), ), ], ),
  • 左侧FlutterCountingComponent:普通的ConsumerWidget,代表 Flutter Widget 世界;
  • 右侧RiverpodAwareGameWidget它是GameWidget的替代品,承载 Flame 游戏世界,同时负责把 Riverpod 的能力桥接进游戏。

Flutter 侧:ConsumerWidget 标准用法

FlutterCountingComponent(main.dart)是教科书式的ConsumerWidget

class FlutterCountingComponent extends ConsumerWidget { const FlutterCountingComponent({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final stream = ref.watch(countingStreamProvider); return Material( color: Colors.transparent, child: Column( children: [ Text('Flutter', style: textStyle), stream.when( data: (value) => Text('$value', style: textStyle), error: (error, stackTrace) => Text('$error', style: textStyle), loading: () => Text('Loading...', style: textStyle), ), ], ), ); } }

ref.watch(countingStreamProvider)建立订阅,配合stream.when分别渲染data/error/loading三种状态。当 Stream 每秒发出新值,这个 Widget 会自动重建并显示新计数。

Flame 侧:Game、Component 与 mixin 三件套

Flame 侧的接入遵循官方 README 的建议:游戏混入RiverpodGameMixin,组件混入RiverpodComponentMixin,并用RiverpodAwareGameWidget作为游戏宿主

游戏:RefExampleGame

class RefExampleGame extends FlameGame with RiverpodGameMixin { @override Future<void> onLoad() async { await super.onLoad(); add(TextComponent(text: 'Flame')); add(RiverpodAwareTextComponent()); } }

RiverpodGameMixin(实现于 consumer.dart)为游戏注入了以下能力:

  • widgetKey:与RiverpodAwareGameWidget关联的GlobalKey<RiverpodAwareGameWidgetState>,由 State 的initState自动写入(widget.dart);
  • 组件级的refComponentRef类型)与一组 build 回调管理 API:addToGameWidgetBuildonBuildhasBuildCallbacks
  • onLoad时把ref.game指向自身,onMount完成后强制刷新游戏 Widget 一次。

组件:RiverpodAwareTextComponent

class RiverpodAwareTextComponent extends PositionComponent with RiverpodComponentMixin { late TextComponent textComponent; int currentValue = 0; @override void onMount() { addToGameWidgetBuild(() { ref.listen(countingStreamProvider, (p0, p1) { if (p1.hasValue) { currentValue = p1.value!; textComponent.text = '$currentValue'; } }); }); super.onMount(); add(textComponent = TextComponent(position: position + Vector2(0, 27))); } }

这里有三个关键点:

  1. 订阅必须放在onMount而非onLoad:源码注释明确指出,onMount只在 Component 真正被挂载(mounted)时调用,只有在这种情况下框架才能可靠地在onRemove中帮你自动取消订阅;
  2. addToGameWidgetBuild必须在调用super.onMount()(即RiverpodComponentMixin.onMount)之前调用:因为 mixin 的onMount会把你注册的回调统一转发给游戏(见下文生命周期剖析);
  3. ref.listenWidgetRef风格的 APIflame_riverpod 5.0.0起,WidgetRef.watch等操作也可从 Component 内直接访问。

生命周期剖析:订阅如何自动挂接与清理

这是整个库最精妙的部分。RiverpodComponentMixin(consumer.dart)重写了onMountonRemove

onMount 时(订阅建立):

  1. ref.game = findGame()! as RiverpodGameMixin—— 找到所属游戏并建立ComponentRef关联;
  2. 把组件注册的 build 回调追加进游戏的_onBuildCallbacks
  3. rebuildOnMountWhen(ref)返回true(默认),调用rebuildGameWidget(),即通过widgetKey.currentState.forceBuild()强制重建RiverpodAwareGameWidget,让ref.watch/ref.listen在 build 阶段真正挂上订阅。

onRemove 时(订阅清理):

  1. 从游戏的_onBuildCallbacks中逐个移除本组件注册的回调,避免残留;
  2. 清空本地回调存储,防止组件重新挂载时重复注册;
  3. rebuildOnRemoveWhen(ref)返回true(默认),强制重建以刷新依赖;
  4. ref.game置空,切断对已卸载游戏的引用。

两个钩子rebuildOnMountWhen/rebuildOnRemoveWhen均可用ComponentRef参数做条件判断,默认行为是「挂载即重建、移除即重建」,恰好满足官方 README 的承诺:订阅随组件挂载而初始化、随组件移除而销毁,RiverpodAwareGameWidget默认会在 Riverpod 感知组件挂载与移除时重建

包级测试 widget_test.dart 验证了这套机制:添加EmptyComponentgame.hasBuildCallbacks变为true,移除后回到false,且component.ref.game被清空;对WatchingComponent则验证了 Provider 从「未被监听」到「被监听」再到「取消监听」的完整闭环(key.currentState?.exists(numberProvider)依次为false → true → false)。

RiverpodAwareGameWidgetState:游戏版 ConsumerStatefulElement

RiverpodAwareGameWidget(widget.dart)在构造参数上与原生GameWidget完全对齐(loadingBuildererrorBuilderoverlayBuilderMapfocusNode等均可透传),差别在于强制要求GlobalKey<RiverpodAwareGameWidgetState<T>>类型的 key。

其 State 类的职责,用源码注释的原话说是承担了flutter_riverpodConsumerStatefulElement的职责

  • forceBuild而非直接setState:由于组件挂载/移除可能发生在 Widget 正在 build 的过程中,直接setState会触发框架报错。forceBuild_isForceBuilding_hasQueuedBuild两个标志位做防重入,并把真正的新一轮重建推迟到下一帧回调执行(widget.dart);
  • 依赖管理与订阅去重build阶段通过game.onBuild()执行所有组件注册的回调,期间ref.watch调用被记录进_dependenciesputIfAbsent保证同一 Provider 只订阅一次,旧的、已无用的依赖在 build 的finally中被close(),与ConsumerStatefulElement的 diff 逻辑同构(widget.dart);
  • ProviderContainer 跟随依赖变化didChangeDependencies中若ProviderScope.containerOf(context)换成了新的 container(例如上层 ProviderScope 重建),会关闭并清空旧依赖;
  • 完整生命周期清理dispose时关闭所有watch依赖、listen订阅与listenManual手动订阅,避免内存泄漏;
  • listen的 build 期断言:与ConsumerWidget一致,ref.listen只能在 build 期间调用,否则断言失败(widget.dart)。

ComponentRef:组件的 WidgetRef

组件内拿到的ref并非 Riverpod 原生的WidgetRef,而是ComponentRef(consumer.dart),它把RiverpodAwareGameWidgetState上的能力包装成组件友好的接口:

方法对应 WidgetRef 语义说明
watchWidgetRef.watch在 build 阶段建立订阅并返回当前值
listenWidgetRef.listen监听值变化,回调在 build 阶段注册
readWidgetRef.read一次性读取,不建立订阅
refreshWidgetRef.refresh强制刷新 Provider
invalidateWidgetRef.invalidate使 Provider 失效
existsWidgetRef.exists查询 Provider 当前是否被监听
listenManualConsumerState.listenManual可在initState等非 build 阶段使用的手动订阅,需手动管理返回的ProviderSubscription
contextBuildContext通过game.buildContext透出

从源码结构看,ComponentRef实现了WidgetRef定义的绝大多数操作,这正是官方 README 所说「Riverpod 的WidgetRef定义的完整操作集都可从组件中访问」的实现基础。

运行结果与测试验证

运行示例后,屏幕左侧 Flutter 侧每秒递增的数字,与右侧 Flame 游戏内由TextComponent渲染的数字完全一致——两者消费的是同一个countingStreamProvider,从同一数据源实时更新。

这一点不是肉眼推测,而是被测试固化的:示例测试 example/test/widget_test.dart 中,先pump5 秒让流发射到 5,再分别取FlutterCountingComponent里的第二个Text的字符串与RiverpodAwareTextComponent.textComponent.text,断言两者相等。测试还顺带覆盖了RiverpodAwareGameWidget的存在性、isAttached/isLoaded/isMounted三态以及组件挂载顺序(第 3 个孩子即RiverpodAwareTextComponent)。

实战要点小结

  • 三件套缺一不可:宿主用RiverpodAwareGameWidget(并传入GlobalKey<RiverpodAwareGameWidgetState>),游戏with RiverpodGameMixin,需要访问 Provider 的组件with RiverpodComponentMixin
  • 订阅时机:优先在onMount中通过addToGameWidgetBuild注册ref.watch/ref.listen,并在回调注册完成后再调用super.onMount(),取消订阅由框架在onRemove自动完成;
  • 默认行为可定制:通过重写rebuildOnMountWhen(ref)/rebuildOnRemoveWhen(ref)控制组件挂载/移除时是否强制重建游戏 Widget;
  • 单一数据源:把共享状态定义为顶层 Provider,Flutter Widget 与 Flame Component 都能以响应式方式消费它,避免两套状态各维护一份;
  • 版本前提:本示例基于flame_riverpod 5.5.5flutter_riverpod 3.xWidgetRef.watch从组件访问的能力自5.0.0起提供。

如需进一步深入,建议继续阅读 flame_riverpod 包说明、消费者与 mixin 实现、GameWidget 桥接实现 以及两个层级的 示例测试 与 包测试,它们共同构成了从用法到原理的完整证据链。

【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 11:33:31

AI协同PCB设计:从自然语言到量产Gerber的工程实践

1. 这不是科幻预告片&#xff0c;是硬件工程师晨会的真实议题“GPT-6 都能自己画 PCB 了”——这句话最近在几个硬件工程师群和EDA工具论坛里反复刷屏&#xff0c;语气里混着调侃、焦虑&#xff0c;还有点将信将疑的试探。我上周在苏州一家做工业传感器的公司做技术交流&#x…

作者头像 李华
网站建设 2026/9/16 11:32:26

低压直流伺服驱动器怎么选?电压电流、编码器与通讯协议全解析

我做运动控制集成这些年&#xff0c;收到最多的咨询就是&#xff1a;低压直流伺服驱动器到底怎么选&#xff1f;电机功率、驱动器电流、通讯接口、编码器协议&#xff0c;每一关都有人踩坑。最常见的情况是&#xff0c;设备都装好了&#xff0c;上电一跑才发现&#xff0c;要么…

作者头像 李华
网站建设 2026/9/16 11:30:51

同余转化与桶优化:高效解决整除子数组计数问题

先声明一下&#xff0c;今天聊的MOD是模运算的mod&#xff0c;不是游戏模组那种MOD。这篇文章要解决的是算法题里非常高频的一类问题&#xff1a;给定数组&#xff0c;统计满足某种整除或取模条件的子数组。这类题拿到手如果直接双重循环去枚举左右端点&#xff0c;数据量一到1…

作者头像 李华
网站建设 2026/9/16 11:30:27

硬件电路分析实战:从公式到系统直觉的三级跃迁

1. 这不是题库&#xff0c;是电路分析能力的实战切片“硬件笔试面试2026年通关秘籍&#xff1a;电路分析核心问题深度剖析”——看到这个标题&#xff0c;别急着去翻《模拟电子技术基础》前五章&#xff0c;也别一上来就背戴维南定理公式。我带过三年校招面试&#xff0c;筛过两…

作者头像 李华
网站建设 2026/9/16 11:30:10

MOSFET小信号三结构实战解析:CS/CG/SF物理本质与工程避坑指南

1. 这不是教科书里的公式搬运&#xff0c;而是我在模拟电路实验室熬了72小时后画出的三张“小信号地图”你打开任何一本《模拟电子技术基础》&#xff0c;翻到MOSFET放大器章节&#xff0c;大概率会看到三张并排的电路图&#xff1a;共源极&#xff08;CS&#xff09;、共栅极&…

作者头像 李华
网站建设 2026/9/16 11:29:07

ILI9341驱动芯片深度解析:从接口时序到树莓派移植与性能优化

简介&#xff1a;这是一份面向嵌入式开发者的ILI9341 TFT液晶屏驱动源码&#xff0c;配套芯片详解与应用说明&#xff0c;适合使用Arduino、Raspberry Pi等平台需要点亮屏幕、快速上手的开发者。资源包为单个C语言源文件&#xff08;共1个文件&#xff09;&#xff0c;压缩体积…

作者头像 李华