Flutter复杂业务流程的状态管理实践

avatar
SugarTurboS Club @SugarTurboS

背景

近期项目内增加了许多游戏相关的需求。因为目前接手开发的游戏仅是2D平面的小游戏,作为一名应用工程师,并没有使用高大上的游戏引擎,而是采用了原生动画框架来开发。但是在开发过程中,明显感受到这种游戏化的功能开发与普通功能开发的诸多差异点。主要在于:

  1. 游戏页面较为单一,通常游戏中所有元素都处于同一个 Page 中;
  2. 控件状态复杂,且基本含有动画、音频等效果;
  3. 游戏通常具有一个线性流程,需要控制各个组件动画和音频的时序;

痛点

目前我所在的大前端组内,Flutter 项目都是用 Bloc 框架来管理 Widget 状态。即在 BlocState 中定义变量作为一个状态,再由 Cubit 控制和协调状态的分发,Widget中使用 BlocBuilderBlocListener 监听状态变化;常规页面的状态数量较少,这种模式一般情况下并没有明显的问题;但在开发游戏时,会发现随着游戏流程的拉长、以及各种游戏细节的扩充, BlocState 中的状态数量将会飞速膨胀。每增加一个细微的游戏细节,基本无法复用原有的状态变量,不得不再增加一个 state,再往 Cubit 里面塞几个控制该 state 的方法。因此用这种模式开发完整个游戏后,会发现 Cubit 中的 GameCubitState 类中的代码是这样的:

class GameCubitState {

  bool isStartGame = false;
  bool isBlocking = false;
  bool isCharacterWaking = false;
  bool isSelectionHiding = false;
  bool selectionAnimateStart = false;
  double mapOffset = 0.0;
  int currGameProgress = -1;
  GamePuzzle? currPuzzle;
  ......

  GameCubitState clone() {
    return GameCubitState()
      ..isStartGame = isStartGame
      ..isBlocking = isBlocking
      ..isCharacterWaking = isCharacterWaking
      ..isSelectionHiding = isSelectionHiding
      .......
  }
  ......
}  

除了状态变量难以管理外,发现该模式还存在几个问题:一是一旦整个游戏开发完毕后,会发现很难再往其中增加或修改一部分游戏流程。此时 Cubit 模块里面的各个状态成员耦合,牵一发而动全身;二是阅读成本很高;Flutter 的声明式代码在可读性方面本就不如传统的程序式代码(个人见解),特别是这类含有大量状态变量的页面,在后期维护、迭代时会感觉力不从心,改起来无从下手。

因此问题主要有以下几点:

  • 如何更有效率地进行对整个复杂游戏流程做状态管理;
  • 如何改善页面代码的扩展性、可读性;
  • 后续如何建立此类复杂流程模块的开发规范;

不仅限于游戏开发,所有复杂流程类的功能开发时,都会遇到上述问题。

改善思路

初步构想

首先想到的一个解决方案就是Muti-Cubit方案。

既然一个Cubit解决不了,那就再怼一个

将游戏的各个部分抽离,例如将游戏人物视为一个单独的模块,使用 CharacterCubitCharacterState 管理和控制人物的位置、动作等状态;背景地图则创建 MapCubitMapState 来负责地图位置的更新。这样就将一个复杂的 Cubit 状态系统划出了若干子模块,状态变量也得以分门别,易于管理。

但仔细想想似乎有哪里不对劲:如果要实现当地图移动时,人物的动画同步修改为跑步的姿势,要如何实现呢?因为地图、人物两个模块已经完全剥离,也切断了两者间的通信。于是一拍脑门,哒哒哒写了一个MainCubit作为中台,负责各个子Cubit的通信。这样当地图移动时,MapCubit会通知 MainCubitMainCubit 再通知 CharacterCubit 修改人物状态,大功告成!

classDiagram
MainCubit *-- CharacterCubit
MainCubit *-- MapCubit
MainCubit *-- SelectionCubit
CharacterCubit *-- CharacterWidget
MapCubit *-- MapWidget
SelectionCubit *-- SelectionWidget

class CharacterWidget{
- build()
}
class MapWidget{
- build()
}
class SelectionWidget {
- build()
}

class MainCubit{
- MainState state
+ notifyCharacter()
+ notifyMap()
}
class CharacterCubit{
- CharacterState state
+ emit()
+ report()
}
class MapCubit{
- MapState state
+ emit()
+ report()
}
class SelectionCubit {
- SelectionState state
+ emit()
+ report()
}

但事与愿违,后来发现用了这种写法后,游戏中处处需要重复上述的逻辑。例如游戏开场动画模块执行完动画后,调用音频模块开始播放BGM以及游戏介绍音频、音频播放完后紧接着调用人物模块去移动人物 offset ......我开始意识到这个 MainCubit 的中台属性已经慢慢退化,开始朝着God Class发展,其内部的混乱程度甚至不亚于一开始的单 Cubit 模式。而且这种强相关的通信逻辑与Muti-Cubit 的设计初衷背道而驰,开发效率也没有得到显著提升。

思考

我没敢提交代码,怕 Code Review 时出事,于是痛定思痛,开始思考。其实出现上述问题的根因在于文章开头提到的游戏与普通功能开发差异点的第三点:

游戏通常具有一个线性流程,需要控制各个组件动画和音频的时序。

游戏的各个模块看似互不相关,实际上又密切相关,因为大多数线性流程游戏的流程本质上是一个组件状态依次执行的过程,如A模块中某一个状态的结束紧接着就是B模块中的另一个状态的开始。看来解决问题的重点不在于如何隔离各个模块,而是如何统一管理各个状态的时序。

那么可不可以写一份类似剧本一样的描述文档,将游戏全流程的各个事件按照类似于时间线的方式描述出来,再由某个调度器去执行这个剧本?

重构一下 Cubit 模块,创建一个剧本调度器负责按顺序下发游戏剧本,以 Cubit 作为通信媒介,下发剧本中的事件节点;UI层作为剧本的消费者,接收到一个事件后作出相应的处理。如地图控件会响应地图移动事件,执行地图移动动画。因为各个事件的消费时间并不是固定的,因此控件结束事件后需要通过 Cubit 告知调度器该事件已结束,调度器则会根据剧本继续下发下一个事件,形成游戏的事件分发循环,一直持续到游戏结束。

实现

按照这种设计模式,可以得出如下的大致框架:

1、状态组

既然是“游戏剧本”模式,因此首先考虑剧本的实现。这里定义两个概念:

  • 状态节点是Widget在一个特定时间点的取值,例如当前人物控件处于跑步状态,每一个状态都可以用一个 int 值表示;

  • 状态组是若干个描述同一个控件的状态集合,例如站立、跑步、欢呼都是状态节点,属于“人物动作”状态组的成员。状态组具有保存当前的状态节点,以及指向下一个状态的功能,可以抽成一个基类:

// 状态组
abstract class BaseState {
  final int tag;
  int currState;
  BaseState? nextState;
  BaseState(this.currState, this.tag);

  @override
  bool operator ==(other) => return other is BaseState && currState == other.currState;
}

因为游戏运行时会按列表顺序依次执行每一个状态节点,这种运行方式很适合使用链表来管理,因此可以在 BaseState 中加入一个 nextState 指针。

由于对交互稿已经倒背如流,提前将页面内各个控件所有状态节点枚举出来,分类整理成状态组:

/// 人物动作状态组
class CharacterActionState extends BaseState {
  static const TAG = 1001;
  static const INIT_STATE = standing;
  static const standing = 10;
  static const moving = 11;
  static const cheering = 12;
  CharacterActionState(int currState) : super(currState, TAG);
}

/// 地图位置状态组
class MapSiteState extends BaseState {
  static const TAG = 1002;
  static const INIT_STATE = atStart;
  static const atStart = 20;
  static const atPuzzle1 = 21;
  static const atPuzzle2 = 22;
  static const atPuzzle3 = 23;
  static const atEnd = 24;
  MapSiteState(int currState) : super(currState, TAG);
}
......

状态节点即可以充当游戏剧本中的一个单独的事件,因此游戏剧本实际上就是由各个状态节点构成的一个 list ,因此游戏剧本可以有多种实现方式。

// 游戏剧本
static final List<BaseState> _gameScript = [
  // start, to puzzle1
  CharacterActionState(CharacterActionState.moving),
  CharacterSiteState(CharacterSiteState.middle),
  MapSiteState(MapSiteState.atPuzzle1),
  // solving puzzle1
  PuzzleResolveState(PuzzleResolveState.puzzle1Resolved),
  SelectionDisplayState(SelectionDisplayState.hiding),
  SelectionDisplayState(SelectionDisplayState.none),
  // puzzle1 resolved, to puzzle2
  ......
  // puzzle3 resolved, to end
  CharacterActionState(CharacterActionState.moving),
  MapSiteState(MapSiteState.atEnd),
  CharacterSiteState(CharacterSiteState.end),
  CharacterActionState(CharacterActionState.cheering),
];

2、GameController

创建 GameController 作为调度器,持有 gameScript 并负责控制游戏剧本的走向。

class GameController {
  static final List<BaseState> _gameScript = [...]

  BaseState? _currState;
  
  /// 将状态变更事件通知给Cubit
  final Function(BaseState?) onNextState;

  RecognitionGameController({required this.onNextState});

  /// 开始游戏时将_gameScript中的各个节点串成链表,并回调第一个状态
  void startGame() {
    _currState = _gameScript[0];
    for (int i = 0; i < _gameScript.length - 1; i++) {
      _gameScript[i].nextState = _gameScript[i + 1];
    }
    onNextState.call(_currState);
  }

  /// 回调下一个状态
  /// [checkState] Widget当前持有的状态,保证剧本状态的上下流动正确,防止误传
  void enterNextState(int checkState) {
    if (checkState == _currState?.currState) {
      _currState = _currState?.nextState;
      onNextState.call(_currState);
    }
  }
}

3、Cubit层

Cubit 在该设计模式中,只充当UI层和控制层的通信工具,主要做两件事:

  • GameController 下发的下一个状态通知到相关的 Widget 中;
  • Widget 上报的状态结束事件通知给 GameController
class GameCubit extends Cubit<GameCubitState> {
  late RecognitionGameController _controller;

  GameCubit(): super(GameCubitState()) {
    _controller = GameController(onNextState: _onNextState);
  }
 
  void _onNextState(BaseState? baseState) {
    if (baseState != null) {
      emit(state.clone()..states[baseState.tag] = baseState);
    }
  }

  void startGame() {
    _controller.startGame();
  }

  void notifyStateFinish(int checkState) {
    _controller.enterNextState(checkState);
  }
}

class GameCubitState {
  Map<int, BaseState> states = {
    GameStageState.TAG: GameStageState(GameStageState.INIT_STATE),
    CharacterActionState.TAG: CharacterActionState(CharacterActionState.INIT_STATE),
    MapSiteState.TAG: MapSiteState(MapSiteState.INIT_STATE),
    ......
  };

  int getState(int tag) => states[tag]?.currState ?? -1000;

  GameCubitState clone() => GameCubitState()..states = Map.of(states);
}

可以看到Cubit层的代码干净利落,与游戏状态控制相关的方法只有三个:

  • startGame:通知 Controller 启动游戏循环。游戏初始化后调用并开始执行游戏循环;
  • notifyStateFinish:UI层执行完某个状态节点后,通知 Controller 分发下一个状态节点;
  • _onNextState: 将 Controller 的状态下发到UI层。

另外,GameCubitState 是 Cubit 持有的状态类,与 Controller 无关,主要用于状态组的初始化和记录各个状态组的当前状态。

4、UI层

将游戏的各个 Widget 从 Page 中抽成子控件;通过 BlocBuilderBlocListener 自行监听自己关心的状态,当监听游戏切换到自己的状态时,开始执行动画、播放音频等操作,具体实现则取决于业务逻辑。只是需要在结束后统一回调 notifyStateFinish 通知 Controller。

特殊情况

这个框架运用到实际开发中时,发现几个特殊情况需要处理:

  1. 如何兼容两个状态同时执行的情况? 实际上 notifyStateFinish 回调时机是比较灵活的,合理安排回调时机即可解决该问题。例如在地图开始移动的同时,需要同步将人物动作切换为跑步状态,那么可以在地图控件的 BlocBuilder 收到移动命令的同时,回调 notifyStateFinishGameController 会马上下发下一个状态,也就实现两个状态同时执行的效果了。

  2. 等待用户输入时的阻塞效果如何实现? 当需要等待用户输入时,游戏需要进入到类似暂停的状态。为避免特殊处理,可以将它也视为一个状态,定义 BlockingState ,所有 Widget 都不监听该状态即可。当 BlockingState 被 Controller 下发后,该状态不会被任何 Widget 接收,UI层不会刷新,也就实现了游戏的阻塞效果;直到用户结束输入,再通过与用户直接交互的 Widget(如 Checkbox、TextField 等)调用 notifyStateFinish(BlockingState.blocking),主动结束阻塞状态,Controller 继续执行游戏循环。

class BlockingState extends BaseState {
  static const TAG = 1000;
  static const blocking = -1;
  BlockingState(int currState) : super(currState, TAG);
}

扩展

游戏分支

目前我正在开发的游戏属于单线流程的游戏,即游戏的流程固定只有一条,不会受到用户的操作影响而改变游戏的走向。

例如前段时间很火的《隐形守护者》、《底特律:变人》等游戏,结构上看也是属于线性流程,但区别在于游戏走向、结局等完全取决于用户的选择,即多线流程。开发这种类型的游戏时,单个游戏剧本已经无法满足需求,可以对其做进一步扩展,例如将游戏剧本视为树状链表,创建多个子剧本,Controller 调度各个子剧本,在下发状态节点时根据游戏的具体走向,连接到下一个子剧本中,实现切换分支的功能。

graph LR
MainScript --> SubScript1
MainScript --> SubScript2
SubScript2 --> SubScript3
SubScript2 --> SubScript4
SubScript2 --> SubScript5

游戏回溯

许多游戏都有各种类似回溯的功能,例如在游戏第二步闯关失败时,需要回到第二步的开始状态。仔细思考下会发现,实际上流程的回溯也相当于一种特殊的多线流程游戏。即把回溯的回溯节点作为子分支的起点,连接到之前的某一个节点,构成一个子剧本。如下图,StartState--EndState 是子剧本A,State4--State2 也可以构成一个子剧本。那么用上述的多线流程的实现方式也可以实现类似的效果。

graph LR
StartState --> State2
State2 --> State3
State3 --> State4
State4 --> State2
State4 --> EndState