1. 数独游戏撤销功能的核心价值
在数独游戏开发中,撤销功能绝不是简单的"回退一步"那么简单。作为一个深度参与过多个数独App开发的工程师,我可以明确地说:撤销功能的质量直接决定了游戏体验的上限。想象一下,当玩家在高级难度下苦思冥想半小时后不小心点错了格子,如果没有完善的撤销机制,这种挫败感足以让用户直接卸载应用。
好的撤销系统应该像时光机一样精准可靠。它不仅需要记录每一步操作的内容,还要保存操作时的完整上下文。在我们的Flutter for OpenHarmony实现中,一个完整的撤销系统包含以下核心要素:
- 操作记录的完整性:不只是记录数字变化,还包括笔记标记的变更
- 操作类型的全覆盖:填数、擦除、提示、笔记修改等所有操作类型
- 状态恢复的精确性:能够完美还原操作前的游戏状态
- 用户体验的流畅性:支持多种交互方式(按钮、手势、快捷键)
2. 数据结构设计与实现细节
2.1 GameMove类的深度解析
GameMove是我们撤销系统的基石,它的设计直接决定了撤销功能的可靠性和扩展性。让我们拆解这个核心数据结构:
class GameMove { final int row; final int col; final int? previousValue; final int? newValue; final Set<int>? previousNotes; final Set<int>? newNotes; final DateTime timestamp; GameMove({ required this.row, required this.col, this.previousValue, this.newValue, this.previousNotes, this.newNotes, DateTime? timestamp, }) : timestamp = timestamp ?? DateTime.now(); bool get isFill => newValue != null; bool get isErase => newValue == 0; bool get isNotesChange => newNotes != null; }几个关键设计决策值得特别说明:
可空类型的使用:不是所有操作都同时涉及数字和笔记变更。使用可空类型可以节省内存,同时保持类型安全。
时间戳的自动填充:默认使用当前时间,但允许外部传入特定时间,这在实现"回退到特定时间点"功能时非常有用。
计算属性的添加:isFill、isErase等属性让代码更易读,避免到处写null检查。
2.2 操作历史的存储策略
在GameController中,我们使用简单的List来存储操作历史:
class GameController extends GetxController { List<GameMove> moveHistory = []; List<GameMove> redoHistory = []; // 其他代码... }为什么不使用Stack?实际开发中我们发现List提供了更丰富的API,特别是当需要实现以下功能时:
- 查看历史记录条数(moveHistory.length)
- 遍历历史记录(for循环或map等操作)
- 实现多步撤销(sublist操作)
- 限制历史记录大小(removeAt等操作)
redoHistory的引入让重做功能成为可能。每次撤销时,我们将操作从moveHistory移到redoHistory;执行新操作时,清空redoHistory。
3. 各类操作的具体实现
3.1 数字填入操作
数字填入是最核心的操作,其撤销实现也最为典型:
void enterNumber(int number) { if (selectedRow < 0 || selectedCol < 0) return; if (isFixed[selectedRow][selectedCol]) return; int row = selectedRow; int col = selectedCol; if (notesMode) { addNote(number); } else { // 记录操作前的状态 int previousValue = board[row][col]; Set<int> previousNotes = Set.from(notes[row][col]); // 添加到历史记录 moveHistory.add(GameMove( row: row, col: col, previousValue: previousValue, newValue: number, previousNotes: previousNotes, newNotes: {}, )); // 清空redo历史 redoHistory.clear(); // 执行实际修改 board[row][col] = number; notes[row][col] = {}; update(); _checkCompletion(); } }关键点:
- 在修改游戏状态前先记录当前状态
- 填入数字会清空该格子的所有笔记
- 任何新操作都会清空redo历史
3.2 笔记操作的特殊处理
笔记操作与数字填入有所不同,它只影响笔记状态:
void addNote(int number) { if (selectedRow < 0 || selectedCol < 0) return; if (isFixed[selectedRow][selectedCol]) return; if (board[selectedRow][selectedCol] != 0) return; int row = selectedRow; int col = selectedCol; Set<int> currentNotes = notes[row][col]; Set<int> previousNotes = Set.from(currentNotes); // 切换笔记状态 if (currentNotes.contains(number)) { currentNotes.remove(number); } else { currentNotes.add(number); } // 记录笔记变更 moveHistory.add(GameMove( row: row, col: col, previousNotes: previousNotes, newNotes: Set.from(currentNotes), )); redoHistory.clear(); update(); }笔记操作的特殊性:
- 只在空白格子(值为0)允许笔记操作
- 笔记是切换(toggle)模式,而不是覆盖
- 使用Set.from创建副本,避免引用问题
3.3 擦除与提示操作
擦除操作本质上是将数字设为0:
void eraseCell() { if (selectedRow < 0 || selectedCol < 0) return; if (isFixed[selectedRow][selectedCol]) return; int row = selectedRow; int col = selectedCol; moveHistory.add(GameMove( row: row, col: col, previousValue: board[row][col], newValue: 0, previousNotes: Set.from(notes[row][col]), newNotes: {}, )); board[row][col] = 0; notes[row][col] = {}; redoHistory.clear(); update(); }提示操作则更为复杂,因为它需要访问解决方案:
void useHint() { if (selectedRow < 0 || selectedCol < 0) return; if (isFixed[selectedRow][selectedCol]) return; if (board[selectedRow][selectedCol] == solution[selectedRow][selectedCol]) return; int row = selectedRow; int col = selectedCol; int correctValue = solution[row][col]; moveHistory.add(GameMove( row: row, col: col, previousValue: board[row][col], newValue: correctValue, previousNotes: Set.from(notes[row][col]), newNotes: {}, )); board[row][col] = correctValue; notes[row][col] = {}; hintsUsed++; redoHistory.clear(); update(); _checkCompletion(); }提示操作的特殊考量:
- 不允许对已正确的格子使用提示
- 使用提示会减少可用提示数
- 同样需要记录完整的状态变更
4. 撤销与重做的核心逻辑
4.1 基础撤销实现
撤销操作的核心是从历史记录中恢复之前的状态:
void undoMove() { if (moveHistory.isEmpty) return; GameMove lastMove = moveHistory.removeLast(); redoHistory.add(lastMove); // 恢复数字 if (lastMove.previousValue != null) { board[lastMove.row][lastMove.col] = lastMove.previousValue!; } // 恢复笔记 if (lastMove.previousNotes != null) { notes[lastMove.row][lastMove.col] = lastMove.previousNotes!; } update(); }几个关键细节:
- 将操作从moveHistory移到redoHistory
- 分别检查并恢复数字和笔记状态
- 使用!操作符断言非空,因为我们在添加时已经确保了完整性
4.2 重做操作的对称实现
重做是撤销的逆过程:
void redoMove() { if (redoHistory.isEmpty) return; GameMove move = redoHistory.removeLast(); moveHistory.add(move); // 应用数字变更 if (move.newValue != null) { board[move.row][move.col] = move.newValue!; } // 应用笔记变更 if (move.newNotes != null) { notes[move.row][move.col] = move.newNotes!; } update(); }重做的特殊注意事项:
- 只有存在redo历史时才允许重做
- 新操作会清空redo历史(在enterNumber等方法中)
- 重做后操作会回到moveHistory,可以再次撤销
4.3 多步撤销与时间点撤销
对于高级玩家,单步撤销可能不够高效,我们实现了多步撤销:
void undoMultiple(int count) { for (int i = 0; i < count && moveHistory.isNotEmpty; i++) { GameMove lastMove = moveHistory.removeLast(); redoHistory.add(lastMove); if (lastMove.previousValue != null) { board[lastMove.row][lastMove.col] = lastMove.previousValue!; } if (lastMove.previousNotes != null) { notes[lastMove.row][lastMove.col] = lastMove.previousNotes!; } } update(); }更强大的时间点撤销:
void undoToTimestamp(DateTime timestamp) { while (moveHistory.isNotEmpty && moveHistory.last.timestamp.isAfter(timestamp)) { GameMove lastMove = moveHistory.removeLast(); redoHistory.add(lastMove); if (lastMove.previousValue != null) { board[lastMove.row][lastMove.col] = lastMove.previousValue!; } if (lastMove.previousNotes != null) { notes[lastMove.row][lastMove.col] = lastMove.previousNotes!; } } update(); }时间点撤销的使用场景:
- 玩家想要回退到特定时间前的状态
- 配合UI显示操作时间线
- 实现"撤销最近5分钟操作"这样的功能
5. 用户界面与交互设计
5.1 撤销按钮的完整实现
撤销按钮不仅要功能完整,还要提供良好的视觉反馈:
Widget _buildUndoButton(GameController controller) { bool canUndo = controller.moveHistory.isNotEmpty; int undoCount = controller.moveHistory.length; return GestureDetector( onTap: canUndo ? () { HapticFeedback.lightImpact(); // 触觉反馈 controller.undoMove(); } : null, child: Container( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), decoration: BoxDecoration( color: canUndo ? Theme.of(context).primaryColor.withOpacity(0.1) : Colors.grey.shade200, borderRadius: BorderRadius.circular(8.r), border: Border.all( color: canUndo ? Theme.of(context).primaryColor : Colors.transparent, width: 1.w, ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Stack( clipBehavior: Clip.none, children: [ Icon( Icons.undo, size: 24.sp, color: canUndo ? Theme.of(context).primaryColor : Colors.grey.shade400, ), if (canUndo && undoCount > 0) Positioned( right: -4, top: -4, child: Container( padding: EdgeInsets.all(4.w), decoration: BoxDecoration( color: Theme.of(context).primaryColor, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black12, blurRadius: 2.r, offset: Offset(0, 1.h), ), ], ), child: Text( undoCount > 99 ? '99+' : undoCount.toString(), style: TextStyle( fontSize: 8.sp, color: Colors.white, fontWeight: FontWeight.bold, ), ), ), ), ], ), SizedBox(height: 4.h), Text( '撤销', style: TextStyle( fontSize: 12.sp, color: canUndo ? Theme.of(context).primaryColor : Colors.grey.shade400, fontWeight: FontWeight.w500, ), ), ], ), ), ); }UI设计要点:
- 状态反馈:可撤销与不可撤销状态有明显视觉区分
- 操作计数:显示可撤销步数,超过99显示"99+"
- 触觉反馈:操作时提供轻微的震动反馈
- 主题适配:使用主题色保持应用一致性
5.2 手势操作的实现
除了按钮,我们还实现了滑动手势支持:
class UndoGestureDetector extends StatelessWidget { final Widget child; final VoidCallback onUndo; final VoidCallback onRedo; const UndoGestureDetector({ super.key, required this.child, required this.onUndo, required this.onRedo, }); @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onHorizontalDragEnd: (details) { if (details.primaryVelocity != null) { if (details.primaryVelocity! > 800) { // 快速右滑 - 撤销 onUndo(); } else if (details.primaryVelocity! < -800) { // 快速左滑 - 重做 onRedo(); } } }, child: child, ); } }手势实现细节:
- 设置较高的速度阈值(800)避免误操作
- 使用HitTestBehavior.opaque确保手势检测区域
- 只响应快速滑动,慢速拖动不会触发
- 右滑撤销,左滑重做,符合用户直觉
5.3 撤销动画与音效
为了提升操作体验,我们添加了动画和音效:
class UndoAnimation extends StatefulWidget { final VoidCallback onUndo; final bool canUndo; const UndoAnimation({ super.key, required this.onUndo, required this.canUndo, }); @override State<UndoAnimation> createState() => _UndoAnimationState(); } class _UndoAnimationState extends State<UndoAnimation> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation<double> _rotationAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _rotationAnimation = Tween<double>(begin: 0, end: -0.5).animate( CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ); } void _onTap() { if (!widget.canUndo) return; _controller.forward(from: 0).then((_) { _controller.reverse(); widget.onUndo(); }); } @override Widget build(BuildContext context) { return GestureDetector( onTap: _onTap, child: AnimatedBuilder( animation: _rotationAnimation, builder: (context, child) => Transform.rotate( angle: _rotationAnimation.value * pi, child: child, ), child: Icon( Icons.undo, size: 24.sp, color: widget.canUndo ? Theme.of(context).primaryColor : Colors.grey.shade400, ), ), ); } }音效服务的实现:
class UndoSoundService { static final AudioCache _audioCache = AudioCache(); static AudioPlayer? _player; static Future<void> playUndoSound() async { _player = await _audioCache.play('sounds/undo.wav'); } static Future<void> playRedoSound() async { _player = await _audioCache.play('sounds/redo.wav'); } static Future<void> playEmptyUndoSound() async { _player = await _audioCache.play('sounds/error.wav'); } static Future<void> dispose() async { await _player?.dispose(); } }体验优化要点:
- 旋转动画:图标逆时针旋转表示回退
- 双向动画:先向前再反向,形成完整动作
- 状态感知:不可操作时不播放动画
- 音效反馈:不同操作有不同音效提示
- 资源管理:及时释放音频资源
6. 高级功能与性能优化
6.1 撤销历史查看器
对于专业玩家,我们提供了完整的历史查看界面:
class UndoHistoryViewer extends StatelessWidget { final GameController controller; const UndoHistoryViewer({super.key, required this.controller}); @override Widget build(BuildContext context) { return Column( children: [ Padding( padding: EdgeInsets.all(16.w), child: Text( '操作历史 (${controller.moveHistory.length})', style: TextStyle( fontSize: 16.sp, fontWeight: FontWeight.bold, ), ), ), Expanded( child: ListView.builder( itemCount: controller.moveHistory.length, reverse: true, // 最新操作显示在最上面 itemBuilder: (context, index) { GameMove move = controller.moveHistory[index]; return ListTile( leading: CircleAvatar( backgroundColor: Theme.of(context).primaryColor.withOpacity(0.2), child: Text('${controller.moveHistory.length - index}'), ), title: Text( _getMoveDescription(move), style: TextStyle(fontSize: 14.sp), ), subtitle: Text( DateFormat('HH:mm:ss').format(move.timestamp), style: TextStyle(fontSize: 12.sp), ), trailing: IconButton( icon: Icon(Icons.undo, size: 20.sp), onPressed: () => _undoToIndex(context, index), ), onTap: () => _undoToIndex(context, index), ); }, ), ), ], ); } String _getMoveDescription(GameMove move) { String position = '(${move.row + 1}, ${move.col + 1})'; if (move.isFill) { return '在$position填入${move.newValue}'; } else if (move.isErase) { return '清除$position的数字'; } else if (move.isNotesChange) { return '修改$position的笔记'; } return '未知操作$position'; } void _undoToIndex(BuildContext context, int index) { int steps = controller.moveHistory.length - index; if (steps <= 0) return; if (steps > 5) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('确认撤销'), content: Text('确定要撤销最近$steps步操作吗?'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('取消'), ), TextButton( onPressed: () { Navigator.pop(context); controller.undoMultiple(steps); }, child: const Text('确定'), ), ], ), ); } else { controller.undoMultiple(steps); } } }历史查看器的关键功能:
- 倒序显示:最新操作在最上面
- 操作描述:清晰说明每个操作的内容
- 时间显示:记录每个操作的具体时间
- 批量撤销:支持直接回退到特定步骤
- 确认提示:大量撤销前要求确认
6.2 性能优化策略
随着游戏进行,操作历史可能变得很大,我们需要考虑性能优化:
- 历史记录限制:
class GameController extends GetxController { static const int maxHistoryLength = 200; void addToHistory(GameMove move) { moveHistory.add(move); redoHistory.clear(); // 限制历史记录大小 if (moveHistory.length > maxHistoryLength) { moveHistory.removeRange(0, moveHistory.length - maxHistoryLength); } } }- 内存优化:
class GameMove { // 使用更紧凑的数据表示 static const int _noteMask = 0x1FF; // 9位表示1-9的笔记 int? get compressedPreviousNotes => previousNotes != null ? _compressNotes(previousNotes!) : null; int? get compressedNewNotes => newNotes != null ? _compressNotes(newNotes!) : null; int _compressNotes(Set<int> notes) { int result = 0; for (int note in notes) { if (note >= 1 && note <= 9) { result |= 1 << (note - 1); } } return result & _noteMask; } }- 延迟加载:
对于非常长的历史记录,可以考虑只在需要时加载部分记录:
class LazyGameHistory { final List<GameMove> _loadedMoves = []; final int totalCount; final Future<List<GameMove>> Function(int, int) loader; Future<void> ensureLoaded(int index) async { if (index >= _loadedMoves.length) { int start = _loadedMoves.length; int end = min(start + 50, totalCount); var newMoves = await loader(start, end); _loadedMoves.addAll(newMoves); } } }6.3 撤销统计与用户行为分析
收集撤销相关数据可以帮助我们改进游戏设计:
class UndoAnalytics { final GameController controller; final Map<String, int> _undoCountsByType = {}; int _totalUndos = 0; int _totalRedos = 0; UndoAnalytics(this.controller) { controller.addListener(_recordUndos); } void _recordUndos() { if (controller.canUndo) { _totalUndos++; String type = controller.lastMoveType; _undoCountsByType[type] = (_undoCountsByType[type] ?? 0) + 1; } if (controller.canRedo) { _totalRedos++; } } double get undoRate { int totalMoves = controller.moveHistory.length; return totalMoves > 0 ? _totalUndos / totalMoves : 0; } Map<String, dynamic> toJson() { return { 'total_undos': _totalUndos, 'total_redos': _totalRedos, 'undo_rate': undoRate, 'undos_by_type': _undoCountsByType, }; } }数据分析的应用场景:
- 识别玩家容易出错的操作类型
- 评估游戏难度是否合理
- 发现可能的UI/UX问题
- 为不同玩家提供个性化提示
7. 跨平台适配与OpenHarmony优化
7.1 Flutter for OpenHarmony的特殊考量
在OpenHarmony平台上,我们需要特别注意以下几点:
性能特性:
- OpenHarmony的UI渲染管线与Android/iOS有所不同
- 动画和手势处理可能需要特别优化
- 内存管理策略需要调整
平台API差异:
- 系统音效API可能不同
- 触觉反馈的实现方式不同
- 后台任务处理有特殊限制
适配方案:
class OpenHarmonyUndoButton extends StatelessWidget { @override Widget build(BuildContext context) { if (Platform.isOpenHarmony) { return _buildOpenHarmonySpecificButton(); } else { return _buildDefaultButton(); } } Widget _buildOpenHarmonySpecificButton() { // OpenHarmony特有的按钮实现 return Container( // 使用OHOS设计规范 ); } }7.2 平台特定优化
针对OpenHarmony的优化措施:
- 渲染优化:
class OptimizedUndoAnimation extends StatefulWidget { @override _OptimizedUndoAnimationState createState() => _OptimizedUndoAnimationState(); } class _OptimizedUndoAnimationState extends State<OptimizedUndoAnimation> with SingleTickerProviderStateMixin { @override void initState() { super.initState(); if (Platform.isOpenHarmony) { // 使用更适合OHOS的动画参数 _controller = AnimationController( duration: const Duration(milliseconds: 250), vsync: this, ); } else { _controller = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); } } }- 手势识别优化:
class OpenHarmonyGestureDetector extends StatelessWidget { @override Widget build(BuildContext context) { return Listener( onPointerMove: (event) { if (Platform.isOpenHarmony) { // OHOS特有的手势处理逻辑 } else { // 标准处理逻辑 } }, child: child, ); } }- 内存管理:
class OpenHarmonyGameMove implements GameMove { @override void dispose() { // OHOS特有的资源释放逻辑 } }7.3 多平台兼容性测试
确保撤销功能在所有平台表现一致:
void testUndoFunctionality() { testWidgets('undo should restore previous state', (tester) async { // 初始化游戏 await tester.pumpWidget(MaterialApp(home: SudokuGame())); // 执行操作 await tester.tap(find.text('1')); await tester.pump(); // 验证状态 expect(find.text('1'), findsOneWidget); // 执行撤销 await tester.tap(find.byIcon(Icons.undo)); await tester.pump(); // 验证状态恢复 expect(find.text('1'), findsNothing); // 平台特定断言 if (Platform.isOpenHarmony) { // OHOS特有的验证 } else if (Platform.isAndroid) { // Android特有的验证 } }); }8. 实际开发中的经验与教训
在实现撤销功能的过程中,我们积累了一些宝贵的经验:
状态管理的陷阱:
- 必须深拷贝所有可变状态(特别是笔记集合)
- 操作记录应该保存原始值,而不是引用
- 时间戳应该在创建GameMove时立即记录
性能问题的发现:
- 最初实现时没有限制历史记录大小,导致内存暴涨
- 频繁的UI更新造成了卡顿
- 复杂的动画在低端设备上掉帧严重
解决方案的演进:
- 引入maxHistoryLength限制
- 批量更新时合并UI刷新
- 为动画添加复杂度检测和降级机制
测试中的发现:
- 边界条件测试:连续撤销所有步骤后再执行新操作
- 压力测试:快速连续执行大量操作和撤销
- 平台差异测试:不同设备上的表现一致性
一个典型的性能优化案例:
// 优化前的实现 - 每次操作都立即更新UI void enterNumber(int number) { // ...记录操作... board[row][col] = number; update(); // 立即更新 } // 优化后的实现 - 批量更新 void enterNumbers(List<int> numbers) { bool shouldUpdate = false; for (var number in numbers) { // ...记录操作... board[row][col] = number; shouldUpdate = true; } if (shouldUpdate) { update(); // 批量更新 } }另一个重要的教训是关于重做历史的处理:
// 错误实现 - 没有正确处理重做历史 void enterNumber(int number) { moveHistory.add(move); board[row][col] = number; } // 正确实现 - 清空重做历史 void enterNumber(int number) { moveHistory.add(move); redoHistory.clear(); // 关键行 board[row][col] = number; }