用 flame_riverpod 打通 Flutter 组件与 Flame 游戏组件:基于 StreamProvider 的实时同步示例深度解析
【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame
导读
Flame 游戏引擎中的Component并非 Flutter Widget,无法直接使用flutter_riverpod的ConsumerWidget、ref.watch等机制来响应状态变化。flame_riverpod正是为解决这一鸿沟而生:本仓库packages/flame_riverpod/example中的示例用最简单的StreamProvider计数器,演示了如何让一个 FlutterTextWidget 与一个 FlameTextComponent从同一数据源实时同步更新。读完本文,你将掌握RiverpodAwareGameWidget、RiverpodGameMixin、RiverpodComponentMixin三件套的正确使用姿势,理解 Provider 订阅如何与 Flame Component 的生命周期自动绑定,并能独立复刻或扩展这个示例。
示例要解决的问题
官方 flame_riverpod 包说明 开宗明义:
- 在
flutter_riverpod中,Widget 可以在 Provider 状态变化时自动重建; - 但在 Flame 中,我们打交道的是
Component,它不是 Widget,因此无法直接享受这套响应式机制; flame_riverpod提供RiverpodAwareGameWidget、RiverpodGameMixin与RiverpodComponentMixin,让 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.dart | RiverpodComponentMixin、RiverpodGameMixin、ComponentRef实现 |
| 包源码 widget.dart | RiverpodAwareGameWidget与对应 State 实现 |
| 包级测试 widget_test.dart | 验证回调注册/注销与订阅生命周期 |
示例依赖版本(见 example/pubspec.yaml):flame ^1.38.0、flame_riverpod ^5.5.5、flutter_riverpod ^3.0.3,Dart SDK 要求>=3.12.0 <4.0.0。运行方式与常规 Flutter 项目一致:在packages/flame_riverpod/example目录下执行flutter pub get后flutter 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);- 组件级的
ref(ComponentRef类型)与一组 build 回调管理 API:addToGameWidgetBuild、onBuild、hasBuildCallbacks; 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))); } }这里有三个关键点:
- 订阅必须放在
onMount而非onLoad:源码注释明确指出,onMount只在 Component 真正被挂载(mounted)时调用,只有在这种情况下框架才能可靠地在onRemove中帮你自动取消订阅; addToGameWidgetBuild必须在调用super.onMount()(即RiverpodComponentMixin.onMount)之前调用:因为 mixin 的onMount会把你注册的回调统一转发给游戏(见下文生命周期剖析);ref.listen是WidgetRef风格的 API:flame_riverpod 5.0.0起,WidgetRef.watch等操作也可从 Component 内直接访问。
生命周期剖析:订阅如何自动挂接与清理
这是整个库最精妙的部分。RiverpodComponentMixin(consumer.dart)重写了onMount与onRemove:
onMount 时(订阅建立):
ref.game = findGame()! as RiverpodGameMixin—— 找到所属游戏并建立ComponentRef关联;- 把组件注册的 build 回调追加进游戏的
_onBuildCallbacks; - 若
rebuildOnMountWhen(ref)返回true(默认),调用rebuildGameWidget(),即通过widgetKey.currentState.forceBuild()强制重建RiverpodAwareGameWidget,让ref.watch/ref.listen在 build 阶段真正挂上订阅。
onRemove 时(订阅清理):
- 从游戏的
_onBuildCallbacks中逐个移除本组件注册的回调,避免残留; - 清空本地回调存储,防止组件重新挂载时重复注册;
- 若
rebuildOnRemoveWhen(ref)返回true(默认),强制重建以刷新依赖; - 将
ref.game置空,切断对已卸载游戏的引用。
两个钩子rebuildOnMountWhen/rebuildOnRemoveWhen均可用ComponentRef参数做条件判断,默认行为是「挂载即重建、移除即重建」,恰好满足官方 README 的承诺:订阅随组件挂载而初始化、随组件移除而销毁,RiverpodAwareGameWidget默认会在 Riverpod 感知组件挂载与移除时重建。
包级测试 widget_test.dart 验证了这套机制:添加EmptyComponent后game.hasBuildCallbacks变为true,移除后回到false,且component.ref.game被清空;对WatchingComponent则验证了 Provider 从「未被监听」到「被监听」再到「取消监听」的完整闭环(key.currentState?.exists(numberProvider)依次为false → true → false)。
RiverpodAwareGameWidgetState:游戏版 ConsumerStatefulElement
RiverpodAwareGameWidget(widget.dart)在构造参数上与原生GameWidget完全对齐(loadingBuilder、errorBuilder、overlayBuilderMap、focusNode等均可透传),差别在于强制要求GlobalKey<RiverpodAwareGameWidgetState<T>>类型的 key。
其 State 类的职责,用源码注释的原话说是承担了flutter_riverpod中ConsumerStatefulElement的职责:
forceBuild而非直接setState:由于组件挂载/移除可能发生在 Widget 正在 build 的过程中,直接setState会触发框架报错。forceBuild用_isForceBuilding与_hasQueuedBuild两个标志位做防重入,并把真正的新一轮重建推迟到下一帧回调执行(widget.dart);- 依赖管理与订阅去重:
build阶段通过game.onBuild()执行所有组件注册的回调,期间ref.watch调用被记录进_dependencies,putIfAbsent保证同一 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 语义 | 说明 |
|---|---|---|
watch | WidgetRef.watch | 在 build 阶段建立订阅并返回当前值 |
listen | WidgetRef.listen | 监听值变化,回调在 build 阶段注册 |
read | WidgetRef.read | 一次性读取,不建立订阅 |
refresh | WidgetRef.refresh | 强制刷新 Provider |
invalidate | WidgetRef.invalidate | 使 Provider 失效 |
exists | WidgetRef.exists | 查询 Provider 当前是否被监听 |
listenManual | ConsumerState.listenManual | 可在initState等非 build 阶段使用的手动订阅,需手动管理返回的ProviderSubscription |
context | BuildContext | 通过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.5与flutter_riverpod 3.x,WidgetRef.watch从组件访问的能力自5.0.0起提供。
如需进一步深入,建议继续阅读 flame_riverpod 包说明、消费者与 mixin 实现、GameWidget 桥接实现 以及两个层级的 示例测试 与 包测试,它们共同构成了从用法到原理的完整证据链。
【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考