通过 WPF 应用程序中的无状态命令堆栈实现撤销/重做(第一阶段/第四阶段)





5.00/5 (11投票s)
使用扫雷游戏示例实现的撤销/重做命令
引言
大多数商业应用程序都支持撤销/重做,但许多行业应用和自定义应用程序仍然不支持。我将演示如何实现此功能,并强调规划行业应用程序时的注意事项。虽然实现起来可能需要更多工作,但从质量、可重复性、可支持性和可维护性方面来看,这是值得的。
此应用程序实现了随 Windows 3.1 一起发布的扫雷游戏应用程序。它支持声音、主题和命令,这些命令被放入撤销/重做命令堆栈中。它使用标准的 MVVM/WPF 应用程序编写,代码隐藏最少,并且不包含任何附加库。尽管对如何构建和绘制应用程序进行了简要讨论,但重点将放在添加撤销/重做服务上。选择此平台进行演示是任意的——唯一的要求是开发和实现撤销/重做功能要有趣。对于从未玩过的人来说,扫雷是一款猜测游戏;您尽量不要选中地雷(左键单击),如果选中了,您就输了,游戏结束;如果您正确地标记了(右键单击)所有地雷,您就赢了。
关于状态的一些话
应用程序命令分为两类:无状态和有状态。无状态操作在撤销时不需要了解先前状态。在此阶段,我们将仅处理无状态命令;放置一个棋子,切换棋子上的标记,显示和隐藏日志窗口。您将看到大多数相关命令都是无状态的。而且撤销相对容易实现。在下一阶段,我们将实现支持有状态撤销/重做的命令。有状态操作包括设置主题、撤销超出当前游戏边界的游戏玩法操作、选择自定义游戏的参数以及设置声音级别。所有这些都需要了解先前状态。在下表中,请注意无状态列。您会发现我们领域中的大多数命令都是无状态的。同样有趣的是,大多数撤销功能尚未编写。但已实现的足够功能足以撤销/重播整个游戏。`UnExecute()` 的占位符明确指示了如果以后有必要或期望,可以在何处实现剩余项目。这可以极大地提高代码库的可维护性。
![]() |
表 1 - 我们领域中的大多数命令都是无状态的。同样有趣的是,大多数撤销功能尚未编写。但已实现的足够功能足以撤销/重播整个游戏。`UnExecute()` 的占位符明确指示了如果以后有必要或期望,可以在何处实现剩余项目。这可以极大地提高代码库的可维护性。
|
首先 [Ctrl-Z][Ctrl-Y]
除了 [Ctrl-Z][Ctrl-Y] 撤销/重做,我们还将支持通过 [Ctrl-U] 撤销所有命令,并通过 [Ctrl-A] 重做所有命令。我还添加了 [ Ctrl-S] 来显示当前命令堆栈。让我们将这些添加到主窗口
![]() | ![]() |
图 1 - MainWindow.xaml 键盘命令绑定到 ViewModel 以用于菜单命令
| 图 2 - MainWindow.xaml 菜单命令绑定到 ViewModel 以用于菜单命令
|
在创建了用于 Execute 和 Unexecute 方法的依赖属性之后,本文的其余部分将遵循它们的执行路径,直到我们清楚地理解第一个命令之一——Play(Row,Col) 的路径。所有其他命令都遵循相同的路径,并且可以以相同的方式理解。Play(Row, Col) 是无状态的,除了参数 (Row,Col)(在正向和反向命令堆栈中压入和弹出)之外,几乎不需要携带先前状态。但是,Play 和 UnPlay 并非完全对称;因为它们需要唯一的实现。如果您查看“Flag”命令,您会发现“Flag”是一个对称的切换,因此它们对于 Execute 和 UnExecute 共享相同的代码路径。
Ctrl-S 快捷键可以查看我们的命令堆栈
![]() |
图 3 – Ctrl-S 显示撤销/重做命令堆栈的当前状态
|
插曲——ViewModel/View 绘制面板和绑定命令
附加行为
![]() |
图 4 - 显示应用了“Key West”主题和隐藏了 9 个地雷的游戏面板。其中一个地雷通过右键单击被“标记”,游戏处于“失败”状态。游戏失败是因为在游戏时间达到 1000 秒阈值时触发了 Play。
|
像我刚才演示的那样,将命令附加到菜单和窗口键盘绑定非常容易。这完全受其类实现的完全支持。左边的面板是我们的完成的应用程序。“笑脸”按钮和所有游戏棋子都是 Rectangle 类的实例。然而,Rectangle 类本身不支持 Command 属性。在这里,我们将附加一个行为,从而允许 Rectangle 在 ViewModel 中拥有可分配的 Command/CommandParameter 属性。本节后续图中的 XAML 将展示如何将命令绑定到 Rectangle 元素。
所有支持的命令集都来自我们的基类 RelayCommand 的实例,该类位于两个文件中——SweeperViewModel.CMDS.cs 和 GamePiece.CMDS.cs
- SweeperViewModel.CMDS.cs 是主 ViewModel (SweeperViewModel.cs) 的一个部分类,支持与游戏逻辑相关的命令。
- GamePiece.CMDS.cs 也是 GamePiece.cs 的一个部分类,实现了面板上每个游戏棋子的逻辑。
为了实现这一点,请附加一个可绑定的“Command”作为依赖属性。让我们先看一些使用附加行为将命令作为属性附加的 XAML。附加此项后,我将向您展示如何创建附加行为类 MouseBehavior.cs
![]() |
图 5 - 绘制笑脸元素——一个矩形,用画笔填充,画笔通过转换器(faceselector)填充,该转换器根据 ViewModel 提供的 GameState 值进行选择。MouseBehaviors 用于设置命令以用于上述鼠标事件。我们将 MouseBehavior 类编译到应用程序中。所有面板上的 Rectangle (MainWindow.xaml) 也都这样做。
|
让那些矩形像个行为体!
为了将 Command 附加到 ViewModel,我们注册了一个 DependencyProperty
或行为(在 WPF 中称为行为)。上面的 MouseLeftButtonUpCommand
通过新的 DependencyProperty
"MouseUpCommandPropery
" 以及对 DependencyProperty.RegisterAttached
的调用而变得可附加和可绑定。
![]() |
面板布局
现在您已经知道如何将命令附加到 ViewModel 并将它们绑定到任何 FrameworkElement 或 Rectangle,您就可以布局游戏面板了。我选择了无形式的 ItemsControl,它只是一个用于在 ViewModel 中创建 ObservableCollections 和其他集合的视图容器。有趣的是,我们的 ViewModel 不使用二维数组或集合。它采用线性的 Observable Collection,并在构造时进行转置,保存 [Row,Column] 信息。我实现了 UniformGrid 作为 ItemTemplate,它采用绑定的 Rows 和 Columns 来确定您要显示的面板大小。我还留下了一个 <!—StackPanel --> 注释在 XAML 中。您可能会对在这里使用 StackPanel 而不是 UniformGrid 感到好奇。您猜对了——您将看到一个长长的线性游戏棋子数组!这个观察结果将使其成为重构为用户控件的候选。
![]() |
图 7 - 布置面板 – 一个绑定到面板(ViewModel 中的 Observable Collection)的 Items Control。我们采用线性集合,并将布局包装成 UniformGrid 的形式,该 UniformGrid 也绑定到 ViewModel 提供的 Rows/Columns。GamePieceTemplate 附加鼠标行为,并将集合的每个项目与每个 Rectangle 相关联。(MainWindow.xaml)
|
上面引用的 ItemTemplate 描述了面板对象上的每个元素。GamePieceTemplate 再次引用 MouseBehavior
,并插入一个 Brush Converter,该转换器来自 GamePiece.Value
的 BoardCollection 中的 Value 属性。稍微发挥一下想象力,您就可以看到如何使用这种策略和布局来实现各种面板游戏,例如 Words With Friends、Checkers、Monopoly 等。发挥更多您自己的想象力,您就可以发明下一个热门应用。
![]() |
图 8 – 一个数据模板,它为每个鼠标手势关联一个命令 (MainWindow.xaml)
|
支持撤销/重做的命令类
命令已附加后,现在是时候查看 ICommand 接口并添加一些字段和方法来支持撤销/重做和命令过滤了。在我们的示例中,我们将通过 Categories 类型 ["MOUSE", "OPTIONS", "DIALOG", "STACK", "GAME"] 执行命令。这些命令通过相同的 Command 类和基类 Execute() 方法运行,但仅对“GAME”类别的命令执行撤销/重做。实际上,此方法应用了撤销/重做执行的过滤器。您需要做的就是添加到 ICommand 接口,该接口需要实现 Execute()
和 CanExecute()
方法,以另外支持您的新的 UnExecute()
和 CanUnExecute
方法。我添加了 DisplayText 字段以显示命令名称。我还添加了一个 Category 字段来帮助您对命令进行分类,因此您可以使用它来补充 CanUnExecute()
方法。这将决定您是否可以在应用程序中撤销它们。此外,还有一个替代构造函数,它不需要创建 UnExecute()
方法。这为您的应用程序开发工作提供了一个良好的起点。稍后您可以添加 UnExecute
功能。
这是您的新 ICommand 实现构造函数
![]() |
图 9 – 此构造函数显示了我们新的 RelayCommand 实现 (RelayCommand.cs)。还有一个替代构造函数,它不需要 UnExecute 方法。您可以稍后返回并实现 UnExecute() 功能。
|
遵循 Play.Execute 和 Play.UnExecute 命令的路径
既然您已经将命令附加到了 Rectangle,剩下要做的就是实现 PlayCommand 的 Execute/Unexecute 路径。最终,您将回到起点,看到图 1 中出现的 [Ctrl-Z][Ctrl-Y] 的工作原理。
![]() |
图 10 - 显示了与 PlayCommand 相关的所有元素。Point 参数包含与被按下 Rectangle 相关的给定 GamePiece 元素的行和列信息。此命令是通过 GamePiece Command MouseLeftButton Up 调用的,该命令在图 8 – 一个数据模板,它为每个鼠标手势关联一个命令中通过 XAML 附加的。
|
PlayCommand 仅执行一个操作。换句话说,在 Rectangle/GamePiece 元素上调用 MouseButtonUpCommand 后,它会在面板上播放一个元素。您在图 8 中附加了 mouseup。我抓住一切机会表明,您可以创建符合 Action 的委托,形式多种多样;如 ExecutePlay;或者您可以创建符合 Predicate 的匿名委托,如 CanExecute();
;或者直接调用 ViewModel 上附近的中间方法,例如 UnPlay。真的,这是一种非常灵活的应用程序开发策略。它避免了代码隐藏,这对可测试性是不利的。诚然,为不支持设置“Command”属性的元素设置可附加属性存在一些开销,而这正是此策略的关键所在。如果您尚未准备好编写 UnExecute,只需调用不带 unexecuting 函数的构造函数,就会分配默认值,您可以在以后添加。是的,您可以编写正向代码路径,稍后返回到同一点并添加反向路径。
添加可重复性堆栈(撤销重做和 BusTub)
到目前为止,您已经通过单个 Execute(object)
方法执行了所有 RelayCommands;但是,您还没有存储每个命令的执行以供重做/撤销。在 Execute 方法中,在执行期间会引发一个事件,以通知 ViewModel 将命令推入命令堆栈。
![]() |
图 11 – 所有命令都通过这个单一的类方法执行/撤销。一个事件被引发。ViewModel 正在监听该事件,并将命令存储在撤销/重做堆栈中以供重播
|
为以后撤销/重做执行存储命令
![]() |
图 12 – ViewModel 的新命令项执行元素的事件处理程序在队列中。某些命令被丢弃,因为它们不在“GAME”的活动过滤类别中,或者因为某些原因它们的 `CanUnExecute()` 评估为 false(参考图 9 和图 10 中 `CanUnExecute()` 的实现)。
|
ViewModel 的 UndoLast 方法
![]() | ![]() |
图 13 – ViewModel 的 `UndoLast()` 方法,
| 图 14 – ViewModel 的 `RedoLast()` 方法。无需将数据推送到 RedoStack,RedoStack 在 NewCommandItem 事件处理程序中已填充。
|
收尾——回归本源
我们开始这段旅程,在 MainWindow.xaml(图 1)中添加了撤销/重做命令项。然后,我们将命令注入并将 Rectangle 绑定到 ViewModel 中的命令(图 5 到图 8)。现在我们已经完整地展示了在“Ctrl-Z”和“Ctrl-Y”上调用的简单方法。实现起来非常容易。在下一次迭代中,我们可能需要将面板控件变成一个用户控件来真正封装该对象,但这里的重点是命令堆栈,以及如何使用它来实现撤销和重做服务。
结论和下一步
阅读完本文后,您现在可以无所畏惧地将行为附加到 ViewModel 并将命令属性注入其中。另外,如果我成功了,您应该清楚如何从应用程序开发的开始阶段就开始策略性地实现撤销/重做。您还应该意识到,您不会立即承担反向代码路径实现的负担,但如果您继承具有默认未执行方法的基类,则可以在以后实现。
在下一步中,我们将使用同一个应用程序,仅通过“MOUSE”类别中的命令,或者可能是命名的一组类别/命令来重演游戏。在这个版本的 Code 中——我称之为“Version 0.25”——我们过滤并重新执行了仅无状态的“GAME”类别命令。在未来的阶段,我们将添加一个命令模型并保存命令以供将来重播。可能还会为当前版本的 Rectangle 对象实现一个控件。可能我们还会看看如何进入堆栈窗口并重新执行任何未按“堆栈”顺序选择的命令。
我希望您能看到这种应用程序开发的强大之处。毫不奇怪,您的用户和技术支持人员会欣赏这样的功能。此外,如果您能从您的用户社区获得足够的数据,这可能有助于识别您的应用程序的哪些功能实际上正在被使用以及在何种程度上,这将是编写下一代应用程序的极其宝贵的信息。
其他想法
这种模型的输出能否成为为您的应用程序构建领域特定脚本语言的起点?可能性很多。
规划撤销/重做命令堆栈应用程序的注意事项
- 请实现一个基类 Relay Command,所有命令都在其中执行。如果您最初没有指定 UnExecute 方法,以后也可以添加 Unexecute 方法。
- 请尽早实现撤销/重做。在您的开发周期中。
- 请尽早实现命令堆栈视图,以帮助您的开发周期。这将有助于调试。
- 请勿假设通过修改 ViewModel 并稍后转换为命令模式来实现起来会很容易。
- 请勿假设状态对于实现命令的 `UnExecute` 是必需的。我曾以为 UnPlay 命令需要它。进一步检查问题表明事实并非如此。
- 请勿假设所有命令都需要取消执行才能为用户提供有用的撤销/重做功能。请注意,我将大量命令分配给 BusTub(通过未实现的 UnExecutes),但仍然能够提供有用的功能。
致谢
感谢我的顾问和编辑 Sam Gilcrist、Athea Davis、Linda Hagood 和 Ken Clement
历史
2015 年 5 月 14 日 - 首次发布文章
2015 年 5 月 17 日 添加了致谢