用面向协议的思想打造菜单按钮

1.10 在北京参加了中国首届 Swift 开发者大会,其中很多人都提到一个观点:POP (protocol oriented programming)面向协议编程。结合之前翻译组一些关于 POP 的译文,觉得 POP 将势不可挡。

环境信息:

Mac OS X 10.11.3

Xcode 7.2.1

iOS 9.2

Swift 2

正文

在看 《Mixins 比继承更好》 一文中,其中提到了 OOP 和 POP 的一些优劣势。其中 OOP 的 God object 是最让框架设计者头疼的。文中列举了一个 Burger Menu 的例子,在此我再描述一下,因为本文会细化他的实现。

完整 Demo 下载地址:

https://github.com/saitjr/STBurgerButtonManager.git

需求

假设现在正在为一款 App 设计框架,所有的一级页面左上角都有一个菜单按钮,点击以后触发同一事件:弹出警告框。

OOP

按照以前的思想,首先,我们会去给一级页面写一个公共父类:MainBaseViewController (当然 MainBaseViewController 很有可能还会有个父类叫 BaseViewController),然后在 MainBaseViewController 去添加菜单按钮与相应的事件。UML 图大致如下:

问题

但是在实际项目中,不是所有界面都会继承 MainBaseViewController ,可能还会去继承 UITableViewController 或者 UICollectionViewController

方法一:当然,也可以选择放弃使用 UITableViewController ,选择使用 UIViewController + UITableView 来替代,然后开心的继承 MainBaseViewController

方法二 :试试面向协议编程吧。

解决

在这个例子中,POP 的更有一种模块化的感觉,需要什么样的功能(行为),只需要遵循协议即可。关于为什么要将行为封装为协议的案例,可以查看文章 《Mixins 比继承更好》

首先创建一个 BurgerMenuManager.swift 的文件,添加一个名为 BurgerMenuManager 的协议,并为此协议添加一个初始化按钮的方法 needBurgerButton

protocol BurgerMenuManager {
  func needBurgerManager();
}

在新的 Swift 2.0 的语法中,允许协议有默认实现,这也为协议的设计提供了新思路。因为每一个菜单按钮的样式与事件都相同,所以直接给出默认实现就行,而没必要每个遵循协议的类都去实现。在当前文件下,添加以下代码:

extension BurgerMenuManager {
  func needBurgerManager() {
    let burgerButton = UIBarButtonItem(title: "菜单", style: .Plain, target: nil, action: nil)
    // 添加按钮到 navigation 上
  }
}

暂时不给按钮添加事件,先来考虑如何将 burgerButton 添加到 navigation 上。

在当前的 extension 中,没有办法使用 self ,是因为编译器并不知道会有哪些类型来遵循 BurgerMenuManager 协议,所以,只需要对遵循协议的类型进行限制即可。根据当前需求,所有遵循协议的至少都是 UIViewController ,那么,对刚才的 extension 做以下修改:

extension BurgerMenuManager where Self: UIViewController {
  func needBurgerManager() {
    ...
    // 现在就可以添加按钮了
    self.navigationItem.leftBarButtonItem = burgerButton
  }
}

接着,需要给按钮添加事件。想想,这还不简单,直接给 target 和 action 不就行了吗。但是这是在 extension 中,要实现起来,还真不是很方便(或许是我没找到优雅的解决方案,希望大家能参与讨论)。

首先试试直接添加 target 和 action 的方式吧:

extension BurgerMenuManager {
  func needBurgerManager() {
    let burgerButton = UIBarButtonItem(title: "菜单", style: .Plain, target: self, action: Selector("burgerItemTapped"))
    self.navigationItem.leftBarButtonItem = burgerButton
  }

  func burgerItemTapped {
    print("tapped")
  }
}

实验之前,先在 Main.Storyboard 中,给 ViewController Embed In 一个 Navigation View Controller 。然后让 ViewController 遵循 BurgerMenuManager 协议,并在 viewDidLoad 中初始化菜单按钮:

class ViewController: UIViewController, BurgerMenuManager {
  override func viewDidLoad() {
    super.viewDidLoad()
    needBurgerManager()
  }
}

运行程序,点击菜单按钮,程序崩溃。原因:ViewController 中找不到 burgerItemTapped 方法。因为 action 不能写在 extension 中。

解决方案有两种,但核心都是给 item 绑定 block:

第一种:使用 runtime 给绑定 UIBarButtonItem 绑定 block,然后通过 block 来添加按钮的 action;

第二种:继承 UIBarButtonItem ,添加 block 属性,然后通过 block 来添加按钮的 action;

这几种解决方案在 Objective-C 中并不陌生,即使是在 Swift 中,使用方式也大同小异。根据项目需求,可任意选择其中一种,并没有一定的写法。

如果项目中有很多 UIBarButtonItem 需要使用到 runtime 绑定的方式,那么这里也直接用这种方式就行。当然,也有可能会觉得,Swift 在有意的弱化 runtime,再大量使用或许不是很优雅,那么选择第二种方案也是不错的。

第一种(runtime):

首先给 UIBarButtonItem 添加 block。

typealias STBarButtonItemBlock = () -> ()

class STBarButtonItemWrapper {
    var block: STBarButtonItemBlock?
    init(block: STBarButtonItemBlock) {
        self.block = block
    }
}

struct STConst {
    static var STBarButtonItemWrapperKey = "STBarButtonItemWrapperKey"
}

extension UIBarButtonItem {
    convenience init(title: String?, style: UIBarButtonItemStyle, block: STBarButtonItemBlock) {
        self.init()
        self.title = title
        self.style = style
        target = self
        action = "buttonTapped"
        let wrapper = STBarButtonItemWrapper(block: block)
        objc_setAssociatedObject(self, &STConst.STBarButtonItemWrapperKey, wrapper, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
    }
    
    func buttonTapped() {
        guard let wrapper = objc_getAssociatedObject(self, &STConst.STBarButtonItemWrapperKey) as? STBarButtonItemWrapper else {
            return
        }
        guard let block = wrapper.block else {
            return
        }
        block()
    }
}

需要注意的是,objc_setAssociatedObject 的第三个参数要是对象。所以需要将 block 放入对象中,然后使用runtime 将该对象添加为 UIBarButtonItem 成员变量。

然后,在 protocol 的默认实现中,将 needBurgerButton() 方法实现改为:

extension BurgerButtonManager where Self: UIViewController {
    func needBurgerButton() {
        let burgerButton = UIBarButtonItem(title: "菜单", style: .Plain) {
            print("菜单显示")
        }
        self.navigationItem.leftBarButtonItem = burgerButton
    }
}

第二种(继承):

相比 runtime,继承的实现就简单多了。首先给继承类添加便利构造方法,用于绑定 block:

typealias STBarButtonItemBlock = ()->()

class BlockBarButtonItem: UIBarButtonItem {
    var block: STBarButtonItemBlock?
    convenience init(title: String, style: UIBarButtonItemStyle, block: STBarButtonItemBlock) {
        self.init(title: title, style: style, target: nil, action: "buttonTapped")
        self.target = self
        self.block = block
    }
    
    convenience init(image: UIImage, style: UIBarButtonItemStyle, block: STBarButtonItemBlock) {
        self.init(image: image, style: style, target: nil, action: "buttonTapped")
        self.target = self
        self.block = block
    }
    
    func buttonTapped() {
        guard let block = block else {
            return
        }
        block()
    }
}

然后,将默认实现的 needBurgerButton() 方法实现改为:

extension BurgerButtonManager where Self: UIViewController {
    func needBurgerButton() {
        let item = BlockBarButtonItem(title: "菜单", style: .Plain) {
            print("菜单显示")
        }
        self.navigationItem.leftBarButtonItem = item
    }
}

最后

完整的 Demo 已上传 Github。在完成过程中,也和翻译组的小伙伴进行了讨论。感谢 @ray16897188@小锅@靛青

One More Thing

在选择绑定 action 的过程中,我也尝试过在 extension 中绑定,有解决方案说在 func 前面加 @objc,但是这并不凑效。详细描述可参见 Apple Thread 16773

当然,欢迎评论。

参考

Mixins 比继承更好

Github : Swift2-Protocol-Extension-Example

iOS 9 Tutorial Series: Protocol-Oriented Programming with UIKit

如果你还在用子类(Subclassing),那就不对了

《用面向协议的思想打造菜单按钮》有3个想法

发表评论

电子邮件地址不会被公开。