Design Pattern:命令模式

1,495 阅读6分钟

命令模式是行为型的设计模式,其核心思想很简单:将一个请求封装成一个对象,并且这个对象包含请求的所有信息(turns a request into a stand-alone object that contains all information about the request) 怎么理解呢?command 命令,这个单词的英文解释是 an authoritative direction or instruction to do something,而请求 request 可以简单理解成方法调用 to do something,因此,命令模式的核心就是将动词 to do something 转成了名词 command,封装命令类。

看一下命令模式的类图:

  • Client:命令的发起人
  • Invoker:命令的执行者(responsible for initiating request),并持有命令,一般由 Client 传入一个命令
  • Command:抽象类,描述了命令执行的接口(declares just a single method for executing the command)
  • Concrete Command:实现请求(implement various kinds of requests),调用 Receiver 执行
  • Receiver:命令真正的执行者,处理命令执行的相关业务逻辑(contains some business logic)

总之,可以将命令模式看成一个客人在餐厅点餐的过程:

  1. 你告诉服务员要点的菜。
  2. 服务员将你点的订单交给厨师。
  3. 厨师做好菜之后将菜交给服务员。
  4. 最后服务员把菜递给你。 你的命令(订单)通过调用者(服务员)交给了命令的执行者(厨师),至于这道菜是怎么做的,谁做的,你并不知道也不用关心,只需要发出命令。而对于餐厅,厨师只需要将菜做好,不用关心是谁点的菜,如果某个厨师请假也可以换一个厨师做菜。

直接调用方法不就行了?为什么要将 request 封装成 command 呢?因为直接调用 request 时需要知道 request 所有细节,当 request 较多时也难以管理,而抽象成 command 就可以:

  • 可以使用不同的请求把客户端参数化(parameterize methods with different requests)
  • 可以将请求排队或者延时(delay or queue a request’s execution)
  • 可以提供命令的撤销和恢复功能(support undoable operations)

真实的例子

NSInvocation

An NSInvocation object contains all the elements of an Objective-C message: a target, a selector, arguments, and the return value. Each of these elements can be set directly, and the return value is set automatically when the NSInvocation object is dispatched.

NSInvocation 是 iOS 中的系统类,基于命令模式,将 Objective-C 消息的所有信息封装到此类中,下面是使用 NSInvocation 的例子:

NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
[invocation setArgument:&parameters atIndex:2];
[invocation setSelector:action];
[invocation setTarget:target];
[invocation invoke];
NSInteger result = 0;
[invocation getReturnValue:&result];

通过使用 NSInvocation,可以将方法的调用者与执行者隔离开,进行解耦(例如 CTMediator 和 JSPatch 都使用 NSInvocation 调用方法)。除了解耦,由于消息的信息都被封装到 NSInvocation 中,因此可以进行消息分发,例如可以修改 target 从而将消息转发给其他 target 或者修改 selector 从而将消息转发给其他 implemention。

YTKNetwork

我司的 YTKNetwork 网络库使用的也是命令模式:

  • Client:ViewController
  • Invoker:YTKNetworkAgent
  • Command:YTKBaseRequest
  • Concrete Command:Custom Request
  • Receiver:AFNetworking

将 API 请求封装成 YTKBaseRequest 的命令之后,Client 并不关心真正执行的是谁,目前是 AFNetworking,如果进行更换或者大版本升级,对 Client 没有影响。另外,可以对 Request 的 URL 进行 URL Filter,统一修改 URL 的某些值(例如 Common Arguments 或 Device),也可以对多个 Request 进行管理(无论是 ViewController 还是 YTKNetworkAgent)。

应用场景

绘画模块(Undo+Redo )

应用内报 Bug 支持当前屏幕截图后进行绘制,并且绘制可以 Undo 和 Redo,那就非常适合命令模式:将每次绘制的动作抽象成 Action,Action 中包含了此次绘制的 Path 和 Color(其实是 CAShapeLayer),用两个队列分别存储 Undo 和 Redo 的 Action:

  • 每当 Undo 时,从 Undo 队尾移除一项,Action 对应的 CAShapeLayer 从当前 Layer 中移除,并将此 Action 放入到 Redo 队列中。
  • 每当 Redo 时,从 Redo 队尾移除一项,将 Action 对应的 CAShapeLayer 加入到当前 Layer 中,并将 Action 放入到 Undo 队列中。
  • 每当有新的绘制动作时,新建 Action,放入 Undo 队列中,并将 Redo 队列清空 。

如何让弹框按顺序弹出?

PM 曾提出需求,要求在启动时的弹窗能够按照顺序弹出,当一个弹窗关闭后再弹出一个,而不是一起弹出。

[AlertUtils showAlertWithTitle:title message:message buttonCallback:^{
	// Do Something
}];

上面的代码就是一般弹窗的使用方式,分析一下这个需求,问题核心是弹窗的结束是基于 UI 操作,是种异步操作,如何将异步的操作能够按照序列执行呢?注意,“方法 + 序列”是不是听起来很熟悉?所以这个需求就可以用命令模式来处理,将弹窗封装成命令后在串行队列中管理就行了。

具体做法是从 NSOperation 继承一个 AlertOperation,在 runInMain 函数中执行的 AlertUtils 的 showAlert 操作,并在其 buttonCallback 中调用 NSOperation 的 finishOperation。而所有的 AlertOperation 都是在 Serial Operation Queue 中,当前一个 Operation 没有变成 finished 时,后一个 Operation 是不会执行的,因此实现了 Alert 的按顺序弹出。

重构 WebViewController

最早的 WebViewController 在处理 JS 回调的方法是用一堆 if/else if/else 语句:

- (void)jsCallback:(NSString *)name arguments:(NSDictionary *)arguments {
	if ([name isEqualToString:@“command1”]) {
		[self handleCommand1:name arguments:arguments];
	} else if ([name isEqualToString:@“command2”]) {
		[self handleCommand2:name arguments:arguments];
	} else if ([name isEqualToString:@“command3”]) {
		[self handleCommand3:name arguments:arguments];
	} else if ([name isEqualToString:@“command4”]) {
		[self handleCommand4:name arguments:arguments];
	}
}

这样写的问题是导致 WebViewController 越来越庞大,一堆业务逻辑耦合到 WebViewController 中(例如登录通知,语音跟读的回调等),维护性变差。另外,如果想配置 WebViewController 只支持某些或者不支持某些 JS 特定的回调的话,甚至根据页面 URL 进行动态调整,也不是很干净。于是趁着 UIWebView 升级 WKWebView,做了一次重构:基于命令模式,将 JS 回调的处理抽离到一个个 Handler 中,JS 回调的名称和参数也在 Handler 中维护,WebViewController 中不再含有任何与 WebView 无关的业务逻辑,当 WebView 触发了 JS 回调后,调用 Command Manager 这个 Invoker 去调用 Command。

- (void)registerCommands {
	[self.commandManager registerCommand:[Command1Handler new]];
	[self.commandManager registerCommand:[Command2Handler new]];
	[self.commandManager registerCommand:[Command3Handler new]];
	[self.commandManager registerCommand:[Command4Handler new]];
}

- (void)jsCallback:(NSString *)name arguments:(NSDictionary *)arguments {
	JSCommand *command = [JSCommand commandWithName:name arguments:arguments];
	[self.commandManager handleCommand:command];
}

总结

命令模式的核心在于将一个 to do something 的动作抽象成 command 对象,而不要太纠结于 Invoker 是谁,Client 在哪?一旦 command 接口抽象完,Client、Invoker、Receiver 自然而然的能找到。使用命令模式的优点是:

  • 解耦:Client 与 Receiver 之间没有任何依赖关系,调用者实现功能时只需要调用 Command 抽象类的 execute 方法即可,不需要知道到底是哪个接收者执行。
  • 可扩展性:Command 子类可以非常容易的扩展,符合 SRP 和 OCP。

参考:

Article by Joe Shang