Flutter中如何基于ChangeNotifier实现关卡初始状态不可变
问题描述
背景
我正在用Flutter开发一款小型棋盘游戏,已经定义好了关卡的初始状态。Level类包含Board类,Board类又包含多个带坐标的Tile对象,示例代码如下:
const levels = [ Level( number: 1, board: Board( cols: 3, rows: 1, tiles: [ Tile( id: 1, isExists: true, isFilled: true, coordinates: Coordinates(x: 0, y: 0), ), Tile( id: 2, isExists: true, isFilled: true, coordinates: Coordinates(x: 0, y: 1), ), Tile( id: 3, isExists: true, isFilled: false, coordinates: Coordinates(x: 0, y: 2), ), ], ), ), ]
核心问题
玩家在游戏区域操作后,关卡的初始状态会被修改(尤其是isFilled字段),返回主菜单重新选择该关卡时,显示的是修改后的状态。我需要基于ChangeNotifier实现不可变的关卡数组,避免初始状态被篡改。
我试过用ChangeNotifierProvider+Consumer,也尝试复制Level对象,但都没效果,是不是必须用Bloc这类其他状态管理方案?
已尝试的代码及问题
我写了一个简单的addCol功能,代码如下:
PlaySessionScreen组件
final Board board = Board( cols: widget.level.board.cols, rows: widget.level.board.rows, tiles: widget.level.board.tiles, winCondition: widget.level.board.winCondition, ); return MultiProvider( providers: [ ChangeNotifierProvider( create: (context) => BoardState( board: board, ), ), ], child: Scaffold( Consumer<BoardState>( builder: (context, boardState, child) => Container( child: Column( children: [ Text(boardState.board.cols.toString()), InkWell( onTap: () => Provider.of<BoardState>(context, listen: false) .addCol(), child: Text('Add col'), ), ], ), ), ), ), );
BoardState类
class BoardState extends ChangeNotifier { final Board board; BoardState({ required this.board, }); int get cols => board.cols; void addCol() { board.cols++; notifyListeners(); } }
最初的Board类
class Board { final int cols; final int rows; final List<Tile> tiles; final int winCondition; const Board({ required this.cols, required this.rows, required this.tiles, required this.winCondition, }); }
直接修改board.cols会报错,于是我把Board类改成了:
class Board { late int cols; final int rows; final List<Tile> tiles; final int winCondition; Board( this.cols, { required this.rows, required this.tiles, required this.winCondition, }); }
但这样定义levels常量时又出现了新错误。
解决方案
不需要切换到Bloc,用ChangeNotifier完全可以实现需求,核心是保持原始关卡数据不可变,操作时使用副本。
步骤1:定义不可变的数据类
所有实体类(Level、Board、Tile、Coordinates)都保持不可变,用final字段,并且提供copyWith方法来生成修改后的副本:
// Coordinates类 class Coordinates { final int x; final int y; const Coordinates({required this.x, required this.y}); Coordinates copyWith({int? x, int? y}) { return Coordinates( x: x ?? this.x, y: y ?? this.y, ); } } // Tile类 class Tile { final int id; final bool isExists; final bool isFilled; final Coordinates coordinates; const Tile({ required this.id, required this.isExists, required this.isFilled, required this.coordinates, }); Tile copyWith({ int? id, bool? isExists, bool? isFilled, Coordinates? coordinates, }) { return Tile( id: id ?? this.id, isExists: isExists ?? this.isExists, isFilled: isFilled ?? this.isFilled, coordinates: coordinates ?? this.coordinates, ); } } // Board类 class Board { final int cols; final int rows; final List<Tile> tiles; final int winCondition; const Board({ required this.cols, required this.rows, required this.tiles, required this.winCondition, }); Board copyWith({ int? cols, int? rows, List<Tile>? tiles, int? winCondition, }) { return Board( cols: cols ?? this.cols, rows: rows ?? this.rows, tiles: tiles ?? this.tiles, winCondition: winCondition ?? this.winCondition, ); } } // Level类 class Level { final int number; final Board board; const Level({ required this.number, required this.board, }); Level copyWith({ int? number, Board? board, }) { return Level( number: number ?? this.number, board: board ?? this.board, ); } }
步骤2:正确实现BoardState
不再直接修改原始Board对象,而是生成新的副本,替换掉原来的状态:
class BoardState extends ChangeNotifier { late Board _currentBoard; // 保存原始关卡的Board,用于重置 final Board _originalBoard; BoardState({required Board originalBoard}) : _originalBoard = originalBoard, _currentBoard = originalBoard; Board get currentBoard => _currentBoard; // 添加列的方法:生成新的Board副本 void addCol() { _currentBoard = _currentBoard.copyWith(cols: _currentBoard.cols + 1); notifyListeners(); } // 修改Tile的isFilled状态:生成新的Tile列表副本 void toggleTileFilled(int tileId) { final updatedTiles = _currentBoard.tiles.map((tile) { if (tile.id == tileId) { return tile.copyWith(isFilled: !tile.isFilled); } return tile; }).toList(); _currentBoard = _currentBoard.copyWith(tiles: updatedTiles); notifyListeners(); } // 重置为原始关卡状态 void resetLevel() { _currentBoard = _originalBoard; notifyListeners(); } }
步骤3:在PlaySessionScreen中正确使用
从传入的Level中获取原始Board,初始化BoardState,确保不会修改原始的levels数组:
class PlaySessionScreen extends StatelessWidget { final Level level; const PlaySessionScreen({super.key, required this.level}); @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (context) => BoardState(originalBoard: level.board), child: Scaffold( appBar: AppBar( title: Text('关卡 ${level.number}'), actions: [ IconButton( icon: const Icon(Icons.refresh), onPressed: () { Provider.of<BoardState>(context, listen: false).resetLevel(); }, ), ], ), body: Consumer<BoardState>( builder: (context, boardState, child) { final board = boardState.currentBoard; return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text('当前列数:${board.cols}'), const SizedBox(height: 20), InkWell( onTap: () { Provider.of<BoardState>(context, listen: false).addCol(); }, child: const Text('添加一列'), ), const SizedBox(height: 20), // 示例:展示Tile状态 Wrap( children: board.tiles.map((tile) { return Container( width: 50, height: 50, margin: const EdgeInsets.all(4), color: tile.isFilled ? Colors.blue : Colors.grey, child: Center(child: Text(tile.id.toString())), ); }).toList(), ), const SizedBox(height: 20), // 示例:点击Tile切换isFilled InkWell( onTap: () { Provider.of<BoardState>(context, listen: false) .toggleTileFilled(3); }, child: const Text('切换Tile 3的填充状态'), ), ], ); }, ), ), ); } }
关键说明
- 不可变数据:所有实体类用
final字段,确保原始的levels常量不会被修改,每次操作都生成新的副本。 - 副本更新:BoardState中保存原始Board和当前Board,修改时通过
copyWith生成新对象,而不是直接修改原对象。 - 状态通知:每次生成新副本后调用
notifyListeners(),触发UI更新。
这样,当玩家返回主菜单重新选择关卡时,传入的还是原始的Level对象,BoardState会重新初始化,显示初始状态,不会受到之前操作的影响。
内容的提问来源于stack exchange,提问作者pawaebawy
相关产品推荐
相关产品推荐

