
1. 项目概述数独游戏作为经典的逻辑解谜游戏撤销功能是其核心交互体验的重要组成部分。在Flutter for OpenHarmony平台上实现这一功能需要考虑跨平台兼容性、状态管理效率以及用户体验的流畅性。撤销功能不仅仅是简单的回退一步它需要完整记录每一步操作的前后状态支持多种操作类型填数、笔记、擦除等并提供直观的交互反馈。从实际开发经验来看一个完善的撤销系统应该包含以下几个关键要素操作历史记录的数据结构设计、撤销/重做逻辑的实现、用户界面反馈机制以及性能优化策略。这些要素共同决定了撤销功能的可靠性和用户体验。2. 核心数据结构设计2.1 GameMove数据模型实现撤销功能的基础是设计一个能够完整记录操作信息的数据结构。GameMove类是这个功能的核心class GameMove { final int row; final int col; final int? previousValue; final int? newValue; final Setint? previousNotes; final Setint? 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 previousValue ! null newValue 0; bool get isNotesChange (previousNotes ! null || newNotes ! null) !(isFill || isErase); }这个设计有几个关键考虑点使用可空类型?区分不同类型的操作填数、笔记修改等同时记录操作前后的状态确保可以完全恢复添加时间戳用于实现按时间点撤销计算属性(isFill/isErase/isNotesChange)方便后续逻辑处理2.2 操作历史管理GameController负责管理操作历史和游戏状态class GameController extends GetxController { ListGameMove moveHistory []; ListGameMove redoHistory []; UndoSettings undoSettings UndoSettings(); UndoStats undoStats UndoStats(); void addToHistory(GameMove move) { // 限制历史记录数量 while (moveHistory.length undoSettings.maxUndoSteps) { moveHistory.removeAt(0); } moveHistory.add(move); redoHistory.clear(); // 新操作会清空重做历史 update(); } // 其他游戏逻辑... }这里有几个值得注意的实现细节使用两个列表分别存储撤销历史和重做历史可配置的最大撤销步数限制防止内存无限增长新操作会清空重做历史这是大多数编辑软件的通用做法使用GetX的update()方法通知UI更新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]; Setint previousNotes Set.from(notes[row][col]); addToHistory(GameMove( row: row, col: col, previousValue: previousValue, newValue: number, previousNotes: previousNotes, newNotes: {}, )); board[row][col] number; notes[row][col] {}; _checkCompletion(); } }关键点在修改棋盘状态前先记录当前状态填数操作会清空该单元格的所有笔记使用Set.from()创建集合的副本避免引用问题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; Setint currentNotes notes[row][col]; Setint previousNotes Set.from(currentNotes); if (currentNotes.contains(number)) { currentNotes.remove(number); } else { currentNotes.add(number); } addToHistory(GameMove( row: row, col: col, previousNotes: previousNotes, newNotes: Set.from(currentNotes), )); }注意事项笔记只能在空白单元格(值为0)上操作笔记是切换(toggle)模式已有则删除无则添加同样需要注意集合的深拷贝问题3.3 擦除操作记录擦除操作需要处理数值和笔记的清除void eraseCell() { if (selectedRow 0 || selectedCol 0) return; if (isFixed[selectedRow][selectedCol]) return; int row selectedRow; int col selectedCol; addToHistory(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] {}; }实现要点擦除操作将数值设为0笔记清空即使单元格本来就是空的也记录操作保持历史完整固定单元格(isFixed)不能被擦除4. 撤销与重做实现4.1 基本撤销功能void undoMove() { if (moveHistory.isEmpty) { UndoSoundService.playEmptyUndoSound(); 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!; } UndoSoundService.playUndoSound(); undoStats.recordUndo(lastMove); update(); }关键细节检查历史是否为空避免异常从moveHistory移除操作并添加到redoHistory分别恢复数值和笔记状态提供声音反馈和统计记录4.2 多步撤销实现void undoMultiple(int count) { if (moveHistory.isEmpty) return; count min(count, moveHistory.length); if (undoSettings.confirmMultipleUndo count undoSettings.confirmThreshold) { _showUndoConfirmDialog(count); return; } for (int i 0; i count; i) { GameMove move moveHistory.removeLast(); redoHistory.add(move); if (move.previousValue ! null) { board[move.row][move.col] move.previousValue!; } if (move.previousNotes ! null) { notes[move.row][move.col] move.previousNotes!; } undoStats.recordUndo(move); } UndoSoundService.playUndoSound(); update(); }实现考虑处理撤销步数超过历史记录的情况大数量撤销前显示确认对话框批量操作后只调用一次update()提高性能4.3 重做功能实现void redoMove() { if (redoHistory.isEmpty) { UndoSoundService.playEmptyUndoSound(); 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!; } UndoSoundService.playRedoSound(); undoStats.recordRedo(); update(); }注意事项重做是撤销的逆操作逻辑对称同样需要考虑状态恢复的完整性提供与撤销类似的声音反馈5. 用户界面实现5.1 撤销按钮组件Widget _buildUndoButton(GameController controller) { bool canUndo controller.moveHistory.isNotEmpty; int undoCount controller.moveHistory.length; return GestureDetector( onTap: canUndo ? () { if (controller.moveHistory.length 5) { _showUndoConfirmDialog(1); } else { controller.undoMove(); } } : null, child: Container( padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 8.h), decoration: BoxDecoration( color: canUndo ? Colors.grey.shade100 : Colors.grey.shade50, borderRadius: BorderRadius.circular(8.r), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ Stack( children: [ Icon( Icons.undo, size: 24.sp, color: canUndo ? Colors.grey.shade700 : Colors.grey.shade400, ), if (canUndo undoCount 0) Positioned( right: -4, top: -4, child: Container( padding: EdgeInsets.all(4.w), decoration: const BoxDecoration( color: Colors.blue, shape: BoxShape.circle, ), 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 ? Colors.grey.shade700 : Colors.grey.shade400, ), ), ], ), ), ); }UI设计要点根据可撤销状态改变按钮外观显示可撤销步数徽章步数过多时显示99避免布局问题大量操作时弹出确认对话框5.2 撤销动画效果class UndoAnimation extends StatefulWidget { final VoidCallback onUndo; const UndoAnimation({super.key, required this.onUndo}); override StateUndoAnimation createState() _UndoAnimationState(); } class _UndoAnimationState extends StateUndoAnimation with SingleTickerProviderStateMixin { late AnimationController _controller; late Animationdouble _rotationAnimation; override void initState() { super.initState(); _controller AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _rotationAnimation Tweendouble(begin: 0, end: -0.5).animate( CurvedAnimation(parent: _controller, curve: Curves.easeInOut), ); } void _onTap() { _controller.forward(from: 0).then((_) { widget.onUndo(); _controller.reverse(); }); } override Widget build(BuildContext context) { return GestureDetector( onTap: _onTap, child: AnimatedBuilder( animation: _rotationAnimation, builder: (context, child) Transform.rotate( angle: _rotationAnimation.value * 3.14159, child: child, ), child: Icon(Icons.undo, size: 24.sp), ), ); } }动画实现细节使用AnimationController控制动画过程图标逆时针旋转半圈表示回退动画完成后执行实际撤销操作操作完成后反向播放动画恢复原状5.3 手势支持实现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! 300) { // 向右滑动 - 撤销 HapticFeedback.lightImpact(); onUndo(); } else if (details.primaryVelocity! -300) { // 向左滑动 - 重做 HapticFeedback.lightImpact(); onRedo(); } } }, child: child, ); } }手势交互要点向右滑动触发撤销向左滑动触发重做速度阈值300避免误操作添加触觉反馈提升操作确认感HitTestBehavior.opaque确保手势检测区域完整6. 高级功能实现6.1 按时间点撤销void undoToTimestamp(DateTime timestamp) { bool changed false; 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!; } undoStats.recordUndo(lastMove); changed true; } if (changed) { UndoSoundService.playUndoSound(); update(); } }实现细节撤销指定时间点之后的所有操作检查是否有实际变化再触发更新记录到重做历史以便恢复适用于回到5分钟前这类场景6.2 撤销历史查看器Widget _buildUndoHistoryViewer() { return Container( height: 200.h, decoration: BoxDecoration( color: Colors.grey.shade50, borderRadius: BorderRadius.circular(8.r), ), child: Column( children: [ Padding( padding: EdgeInsets.all(8.w), child: Text( 操作历史 (${controller.moveHistory.length}), style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.bold, ), ), ), Expanded( child: ListView.builder( itemCount: controller.moveHistory.length, itemBuilder: (context, index) { int reverseIndex controller.moveHistory.length - 1 - index; GameMove move controller.moveHistory[reverseIndex]; return ListTile( leading: CircleAvatar( backgroundColor: Colors.grey.shade200, child: Text(${reverseIndex 1}), ), title: Text(_getMoveDescription(move)), subtitle: Text(_formatTimestamp(move.timestamp)), trailing: IconButton( icon: const Icon(Icons.undo), onPressed: () _undoToIndex(reverseIndex), ), onTap: () _undoToIndex(reverseIndex), ); }, ), ), ], ), ); } 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; } String _formatTimestamp(DateTime timestamp) { return DateFormat(HH:mm:ss).format(timestamp); } void _undoToIndex(int index) { int steps controller.moveHistory.length - index; controller.undoMultiple(steps); }功能特点倒序显示操作历史最新操作在最上面每个操作显示描述、位置和时间点击项目或撤销按钮可回退到指定步骤支持操作类型分类显示6.3 撤销统计功能class UndoStats { int totalUndos 0; int totalRedos 0; MapString, int undosByType { fill: 0, erase: 0, note: 0, }; void recordUndo(GameMove move) { totalUndos; if (move.isFill) { undosByType[fill] undosByType[fill]! 1; } else if (move.isErase) { undosByType[erase] undosByType[erase]! 1; } else if (move.isNotesChange) { undosByType[note] undosByType[note]! 1; } } void recordRedo() { totalRedos; } double get undoRate { int totalOps totalUndos totalRedos; return totalOps 0 ? totalUndos / totalOps : 0; } MapString, dynamic toJson() { return { totalUndos: totalUndos, totalRedos: totalRedos, undosByType: undosByType, undoRate: undoRate, }; } }统计功能用途分析玩家行为模式评估游戏难度设计发现可能的UI/UX问题为游戏平衡性调整提供数据支持7. 性能优化与调试7.1 内存管理策略class UndoSettings { int maxUndoSteps 100; bool compressHistory true; static const int _compressThreshold 50; ListGameMove compressHistory(ListGameMove history) { if (!compressHistory || history.length _compressThreshold) { return history; } // 简单压缩策略保留最近的30步之前的每5步保留1步 ListGameMove compressed []; compressed.addAll(history.sublist(history.length - 30)); for (int i 0; i history.length - 30; i 5) { compressed.add(history[i]); } return compressed; } }优化策略限制最大撤销步数历史记录压缩策略定期清理过旧的操作记录针对移动设备的内存优化7.2 状态序列化方案class GameMove { // ...其他代码 MapString, dynamic toJson() { return { row: row, col: col, previousValue: previousValue, newValue: newValue, previousNotes: previousNotes?.toList(), newNotes: newNotes?.toList(), timestamp: timestamp.toIso8601String(), }; } factory GameMove.fromJson(MapString, dynamic json) { return GameMove( row: json[row], col: json[col], previousValue: json[previousValue], newValue: json[newValue], previousNotes: json[previousNotes] ! null ? Setint.from(json[previousNotes]) : null, newNotes: json[newNotes] ! null ? Setint.from(json[newNotes]) : null, timestamp: DateTime.parse(json[timestamp]), ); } }序列化考虑支持游戏状态保存/恢复处理Set类型的序列化转换时间戳的ISO格式存储可空字段的序列化处理7.3 常见问题排查撤销后状态不一致检查是否所有操作类型都被正确记录验证GameMove是否包含恢复所需的全部信息确保撤销逻辑正确处理可空字段内存占用过高检查maxUndoSteps设置是否合理考虑实现历史记录压缩分析GameMove对象的内存占用重做功能异常确认新操作是否清空了redoHistory检查重做逻辑是否与撤销逻辑对称验证状态恢复是否完整跨平台兼容性问题测试不同设备上的手势识别灵敏度验证序列化在不同平台的兼容性检查声音反馈在各平台的可用性8. 项目总结与扩展思考在Flutter for OpenHarmony平台上实现数独游戏的撤销功能需要考虑跨平台特性与性能优化的平衡。通过本项目我们实现了一个完整的撤销系统具有以下特点完整的状态记录能够捕获所有类型的游戏操作灵活的撤销策略支持单步、多步、按时间点撤销直观的用户反馈包含视觉动画、触觉和声音反馈可扩展的设计方便添加新的操作类型和撤销策略对于类似的项目可以考虑以下扩展方向操作合并将连续的相同操作合并为一步撤销分支历史支持创建保存点并分支发展云同步将操作历史同步到云端实现跨设备继续AI分析基于撤销数据提供游戏难度自适应调整撤销功能作为游戏交互的重要组成部分其实现质量直接影响用户体验。一个设计良好的撤销系统能够让玩家更自信地探索游戏内容提升整体游戏体验。