You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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的填充状态'),
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}

关键说明

  1. 不可变数据:所有实体类用final字段,确保原始的levels常量不会被修改,每次操作都生成新的副本。
  2. 副本更新:BoardState中保存原始Board和当前Board,修改时通过copyWith生成新对象,而不是直接修改原对象。
  3. 状态通知:每次生成新副本后调用notifyListeners(),触发UI更新。

这样,当玩家返回主菜单重新选择关卡时,传入的还是原始的Level对象,BoardState会重新初始化,显示初始状态,不会受到之前操作的影响。


内容的提问来源于stack exchange,提问作者pawaebawy

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.09 10:35:25