iOS-Swift 独孤九剑:十三、面向协议编程

3,431 阅读14分钟

面向协议编程(Protocol Oriented Programming,简称 POP)是 Swift 的一种编程范式,Apple 于 2015 年 WWDC 提出,在 Swift 的标准库中,能见到大量 POP 的影子。

同时,Swift 是一门面向对象的编程语言(Object Oriented Programming,简称 OOP),在 Swift 开发中,OOP 和 POP 是相辅相成的,任何一方并不能取代另一方。POP 能弥补 OOP 一些设计上的不足。

一、POP 和 OOP

OOP 的三大特性:封装、继承、多态。

举一个继承的使用场合的例子:当多个类(比如 A、B、C 类)具有很多共性时,可以将这些共性抽取到一个父类中(比如 D 类),最后 A、B、C 类继承 D 类。

面向对象结构图.png

但是有些问题使用 OOP 并不能很好的解决,举个例子:将 BVC 和 DVC 的公共方法 run 抽取出来。

class BVC: UIViewController {
    func run() {
        print("run")
    }
}

class DVC: UITableViewController {
    func run() {
        print("run")
    }
}

我们来看一下 BVC 和 DVC 的关系。

面向对象继承关系.png

基于 OOP 我们可能想到这么一些解决方案:

  • 将 run 方法放到另一个对象 A 中,然后 BVC、DVC 拥有对象A属性,但是这样子多了一些额外的依赖关系。

  • 将 run 方法增加到 UIViewController 分类中,但是 UIViewController 会越来越臃肿,而且有可能会影响它的其他所有子类。

很显然,采用 OOP 的方式去解决多多少少都会有些缺点,并不是那么完美。我们来看一下 POP 是怎么去解决的。

protocol Runnable {
    func run()
}

extension Runnable {
    func run() {
        print("run")
    }
}

class BVC: UIViewController, Runnable {

}

class DVC: UITableViewController, Runnable {

}

通过协议将 run 方法抽取出来,然后利用协议的 extension 去实现 run 方法。当某个控制器需要用到 run 方法的时候,直接遵守相关的协议就拥有 run 方法了,这样就能够弥补 OOP 实现的一些不足。

更复杂的继承关系.png

如果我们遇到更复杂的继承关系,用 POP 去实现会更加明显的感觉到 POP 实现的好处。

使用 POP 的注意点

  • 优先考虑创建协议,而不是父类(基类)。
  • 优先考虑值类型(struct、enum),而不是引用类型(class)。
  • 巧用协议的扩展功能。
  • 不要为了面向协议而使用协议。

二、利用协议实现前缀效果

1. OC 给方法添加前缀

在 OC 中给某个类扩展一些方法时我们通常会给这个类的分类中添加扩展的方法,而且为了防止与原生的方法或者第三方库的一些方法产生冲突,通常会在方法的前面加上自己的前缀。

@interface NSObject (SHRun)
- (void)sh_run;
@end

@implementation NSObject (SHRun)
- (void)sh_run {
    NSLog(@"run");
}
@end
NSObject *objc = [NSObject new];
[objc sh_run];

前缀的书写格式通常为:<前缀>_<方法>。但是在 Swift 中采用这种方式的话不太好,这么去写感觉和 OC 并没有什么区别,不能体现出 Swift 的特性。那怎么给 Swift 添加前缀呢?

2. Swift 给方法添加前缀

假设我要给 String 添加一个 numberCount 方法用来获取字符串中包含数字的个数,numberCount 的实现如下:

func numberCount(string: String) -> Int {
    var count = 0
    for c in string where ("0"..."9").contains(c) {
        count += 1
    }
    return count
}

这个方法的缺点很明显,他是一个全局的方法,并且需要传一个 String 类型。既然他是 String 独有的方法,我们可以把 numberCount 抽取到 String 的 extension 中。

extension String {
func numberCount() -> Int {
    var count = 0
    for c in self where ("0"..."9").contains(c) {
        count += 1
    }
    return count
    }
}

这么写还是有问题,既然是给系统的类扩展方法,很有可能扩展的方法名会有冲突。解决冲突的方法有两种,像 OC 一样,给 String 加一个前缀,前缀的书写格式和 OC的一样:<前缀>_<方法>

还有一种方法就是给 String 添加一个前缀类型,以 <前缀>.<方法> 的形式去调用方法。既然是以 <前缀>.<方法> 的形式去调用,很显然,这个前缀是一个属性。

struct SHBase {
    var string: String

    func numberCount() -> Int {
        var count = 0
        for c in string where ("0"..."9").contains(c) {
            count += 1
        }
        return count
    }
}

extension String {
    var sh: SHBase { SH(string: self) }
}
let count = "123abc456".sh.numberCount()

这样子就可以实现以 <前缀>.<方法> 的形式去调用方法了。如果其他的类型也想以:<前缀>.<方法> 这种方式去调用方法,难道要给 SH 类型添加一个要扩展的类型的属性吗?这样子会产生很多问题,此时可以用泛型来解决这个问题。

struct SHBase<Base> {
    var base: Base
    init(_ base: Base) {
        self.base = base
    }
}

extension String {
    var sh: SHBase<String> { SHBase(self) }
}
"123abc456".sh

此时 String 类型的实例就可以以 .sh 的形式调用了,这个时候再给 Person 添加一个 sh 的前缀属性也是一样的,代码如下:

class Person {}

extension Person {
    var sh: SHBase<Person> { SHBase(self) }
}
let person = Person()
person.sh

此时,怎么去扩展东西呢。注意!我们最终以 <前缀>.<方法> 方式调用的方法是谁的,是前缀类型的方法吧,所以最终是给 SHBase 扩展方法。比如给 String 类型扩展一个 numberCount 方法。

首先,一个前缀类型属性是要有的,所以给 String 的 extension 添加一个前缀类型属性:

extension String {
    var sh: SHBase<String> { SHBase(self) }
}

然后,通过 extension 给前缀类型添加一个 numberCount 方法,在 extension 中判断前缀类型的泛型是否是 String。

extension SHBase where Base == String {
    func numberCount() -> Int {
        var count = 0
        for c in base where ("0"..."9").contains(c) {
            count += 1
        }
        return count
    }
}
let count = "123abc456".sh.numberCount()

此时我们就可以通过 <前缀>.<方法> 的方式调用方法了,而且这种方式不会调用出其他类型的方法,比如 Person 的 run 方法。如果给 Person 也添加一个扩展的方法,我们可以照猫画虎,代码如下:

extension Person {
    var sh: SHBase<Person> { SHBase(self) }
}

extension SHBase where Base: Person {
    func run() {
        print("run")
    }
}
let person = Person()
person.sh.run()

到这里,基本可以实现以 <前缀>.<方法> 的方式调用方法了,就三个步骤:

  • 声明一个泛型类型前缀类型。

  • 给需要添加扩展方法的类型以 extension 的方式添加前缀类型属性。

  • 最后给前缀类型扩展方法,在前缀类型的 extension 中判断泛型是否是需要扩展的类型并实现扩展的内容。

但此时还是不够完善,因为每次想给某个类型添加前缀的时候都要去 extension 中添加前缀类型属性。此时我们可以通过协议来解决这个问题。 代码如下:

protocol SHCompatible {}
extension SHCompatible {
    var sh: SHBase<Self> { SHBase(self) }
}

这里也是利用了协议的两个特性:一个是协议的 extension 可以添加计算属性和方法;一个是协议的 Self 可以还原出真实类型。此时只需要给需要添加扩展方法的类型遵守 SHCompatible 协议就拥有 sh 的前缀类型属性了。

extension String: SHCompatible {}
extension Person: SHCompatible {}
let count = "123abc456".sh.numberCount()

let person = Person()
person.sh.run()

这样就不需要给每个需要添加前缀类型的类型添加前缀类型属性了,只需要遵守 SHCompatible 就能拥有前缀类型属性。上面添加的 sh 属性是实例的计算属性,所以只能通过 sh 调用实例方法。此时想调用类方法也很简单,给 SHCompatible 协议添加一个 static 的 sh。

代码如下:

extension SHCompatible {
    var sh: SHBase<Self> { SHBase(self) }
    static var sh: SHBase<Self>.Type { SHBase<Self>.self }
}

extension SHBase where Base: Person {
    static func run() {
        print("run")
    }
}
Person.sh.run()

还有最后一个细节,遵守 SHCompatible 协议的类型有可能是一个值类型,当在 extension 实现的方法中需要对值类型的内存进行操作的时候,需要添加 mutating,此时我们定义的 sh 属性就不能调用值类型的异变方法。此时我们把 sh 属性修改成可读可写的属性就行了。

代码如下:

extension SHCompatible {
    var sh: SHBase<Self> {
        get { SHBase(self) }
        set {}
    }

    static var sh: SHBase<Self>.Type {
        get{ SHBase<Self>.self }
        set {}
    }
}
struct Person {
    var age = 10
}

extension Person: SHCompatible { }
extension SHBase where Base == Person {
    mutating func setAge() {
        base.age = base.age + 8
    }
}
var person = Person()
person.sh.setAge()

3. 总结

最后我们来做一个总结,在 Swift 中给类型添加前缀调用的方式分四个步骤:

  • 声明一个前缀类型,这个前缀类型通常是一个 struct。
  • 声明前缀属性的协议,在协议中实现实例的前缀属性和静态类型的前缀属性。
  • 需要用到前缀调用自定义扩展内容的类型遵守前缀属性的协议。
  • 通过前缀类型的 extension 来实现类型的扩展内容。

完整的例子如下:

// 前缀类型
struct SHBase<Base> {
    var base: Base
    init(_ base: Base) {
        self.base = base
    }
}

// 拥有前缀属性的协议
protocol SHCompatible {}
extension SHCompatible {
    var sh: SHBase<Self> {
        get { SHBase(self) }
        set {}
    }

    static var sh: SHBase<Self>.Type {
        get{ SHBase<Self>.self }
        set {}
    }
}

// 遵守拥有前缀属性的协议的类型
extension String: SHCompatible {}

// 通过前缀类型 extension 实现 String 的扩展内容
extension SHBase where Base == String {
    func numberCount() -> Int {
        var count = 0
        for c in base where ("0"..."9").contains(c) {
            count += 1
        }
        return count
    }
}

// 调用
let count = "123abc456".sh.numberCount()
print(count) // 6

三、Moya

在平时的开发中我们都会和网络打交道,而 iOS 中绝大多数都是用的第三方库:AFNetworking 或者 Alamofire。这两者都是对官方的 URLSession 进行封装,避免开发中使用官方那繁琐的 API。

但是在使用 AFNetworking 或者 Alamofire 久了就会发现,App 中到处散落着 AFNetworking 或者 Alamofire 相关的代码,这个时候就导致不便与统一管理。并且会发现,很多代码都是重复的,这个时候我们就会新建一个 Network Manager 来管理网络请求相关的代码。

在和网络打交道的时候,我们只要通过封装好的 Network Manager 打交道就可以了,Network Manager 的目的是隔离网络请求的第三方库,当需要替换第三方网络请求库的时候,只需要在 Network Manager 的内部进行替换,这样子就对我们上层的业务逻辑毫无影响。

如果封装的 Network Manager 不是那么理想的话,可能会导致直接越过 Network Manager,进而与底层的网络请求库打交道,我们来看 Moya 提供的一张图:

Network Layer 和 Moya.png

所以,其实 Moya 的作用其实就是我们提到的 Network Manager,它是对是对网络业务逻辑的抽象,我们只需要遵循相关协议,就可以发起网络请求,而不用关系底层细节。

Moya文档地址:中文文档英文文档

1. Moya 的基本使用

Moya 既然是一个网络请求的中间层,那它的使用和我们平时自己写的 Network Manager 还是有区别的,Moya 更加的 Swift 化,并且 Moya 是采用面向协议编程的范式去构建的。

Moya 有一个协议:TargetType;这个协议定义的都是平时需要的基础网络请求数据,而且它的定义也比简单。来看一下 TargetType 长什么样。

TargetType 的定义.png

可以看到,TargetType 中定义的有 baseURL、path、method、headers 等,在使用的时候就可以为我们的 API 新建一个文件:MyService.swift,文件内定义一个枚举:MyService。

enum MyService {
    case zen
    case showUser(id: Int)
    case createUser(firstName: String, lastName: String)
    case updateUser(id: Int, firstName: String, lastName: String)
    case showAccounts
}

这个枚举中定义都是一些 API 入口,接下来只需要这个枚举遵守 TargetType 协议,就可以填充这些 API 的 baseURL 和 path。

// MARK: - TargetType Protocol Implementation
extension MyService: TargetType {
    var baseURL: URL { return URL(string: "https://api.myservice.com")! }
        var path: String {
        switch self {
        case .zen:
            return "/zen"
        case .showUser(let id), .updateUser(let id, _, _):
            return "/users/\(id)"
        case .createUser(_, _):
            return "/users"
        case .showAccounts:
            return "/accounts"
        }
    }
    
    var method: Moya.Method {
        switch self {
        case .zen, .showUser, .showAccounts:
            return .get
        case .createUser, .updateUser:
            return .post
        }
    }
    
    var task: Task {
        switch self {
        case .zen, .showUser, .showAccounts: // Send no parameters
            return .requestPlain
        case let .updateUser(_, firstName, lastName):  // Always sends parameters in URL, regardless of which HTTP method is used
            return .requestParameters(parameters: ["first_name": firstName, "last_name": lastName], encoding: URLEncoding.queryString)
        case let .createUser(firstName, lastName): // Always send parameters as JSON in request body
            return .requestParameters(parameters: ["first_name": firstName, "last_name": lastName], encoding: JSONEncoding.default)
        }
    }
    
    var sampleData: Data {
        switch self {
        case .zen:
            return "Half measures are as bad as nothing at all.".utf8Encoded
        case .showUser(let id):
            return "{\"id\": \(id), \"first_name\": \"Harry\", \"last_name\": \"Potter\"}".utf8Encoded
        case .createUser(let firstName, let lastName):
            return "{\"id\": 100, \"first_name\": \"\(firstName)\", \"last_name\": \"\(lastName)\"}".utf8Encoded
        case .updateUser(let id, let firstName, let lastName):
            return "{\"id\": \(id), \"first_name\": \"\(firstName)\", \"last_name\": \"\(lastName)\"}".utf8Encoded
        case .showAccounts:
            // Provided you have a file named accounts.json in your bundle.
            guard let url = Bundle.main.url(forResource: "accounts", withExtension: "json"),
                let data = try? Data(contentsOf: url) else {
                return Data()
            }
                return data
        }
    }
    
    var headers: [String: String]? {
        return ["Content-type": "application/json"]
    }
}

// MARK: - Helpers
private extension String {
    var urlEscaped: String {
        addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
    }

    var utf8Encoded: Data {
        Data(self.utf8)
    }
}

通过 TargetType 就可以把请求需要的 urlString、参数、请求的方式定义好了。这个时候你会发现,我们所有请求需要做的准备已经通过 TargetType 写好了,下一步就是发起网络请求。

在外部使用 Moya 进行网络请求的时候,只需要通过 MoyaProvider 对象来发送请求即可:

let provider = MoyaProvider<MyService>()
provider.request(.createUser(firstName: "James", lastName: "Potter")) { result in
    // do something with the result (read on for more details)
}

provider.request(.updateUser(id: 123, firstName: "Harry", lastName: "Potter")) { result in
    // do something with the result (read on for more details)
}

我们还可以自定义 Endpoint,做一些处理:

let endpointClosure = { (target: MyService) -> Endpoint in
    return Endpoint(url: URL(target: target).absoluteString, sampleResponseClosure: {.networkResponse(200, target.sampleData)}, method: target.method, task: target.task)
}

Endpoint 对象在下面会讲,这里只需要有个印象就可以了,最后一个点,当我们发起网络请求之后,如何拿到网络请求中的数据呢。

provider.request(.zen) { result in
    // do something with `result`
}

request 方法被传递了一个 MyService 值 (.zen), 它包含了用来创建 Endpoint的所有必须的信息,Endpoint 实例对象被用来创建一个 URLRequest (繁重的工作已通过 Alamofire 完成的), 并且 request 被发送 (也是被 - Alamofire). 一旦 Alamofire 得到了一个响应 (或者没有得到响应), Moya 将用 enumResult 类型包裹成功或者失败. result 要么是 .success(Moya.Response) 要么是 .failure(MoyaError)。

所以,我们可以从 Moya.Response 中拿到我们需要的数据:

provider.request(.zen) { result in
    switch result {
    case let .success(moyaResponse):
        let data = moyaResponse.data // Data, your JSON response is probably in here!
        let statusCode = moyaResponse.statusCode // Int - 200, 401, 500, etc

        // do something in your app
    case let .failure(error):
        // TODO: handle the error == best. comment. ever.
    }
}

2. Moya 的构建

在看了 Moya 的源码之后总结出来的模块大致可以分成五个模块:

Moya 模块.png

而 Moya 主要的数据处理流程可以用下面这张图来表示:

Moya的数据处理流程.png

MoyaProvider

Moya 首先通过 TargetType 来定义网络请求的基本配置信息,之后由 MoyaProvider 对象来发起真正的网络请求。MoyaProvider 遵守一个协议:MoyaProviderType;MoyaProviderType 的定义如下:

MoyaProviderType 的定义.png

可以看到,MoyaProviderType 其实就是定义了一个 request 的请求方法,通过关联类型来要求第一个参数必须遵守 TargetType 协议类型的泛型参数。来看一下 MoyaProvider 是如何实现协议的请求方法的:

MoyaProvider 中 request 的实现.png

可以看到其内部调用了一个 requestNormal 方法,在 requestNormal 的内部,会先把遵守 TargetType 协议的类型生成一个 Endpoint 的类型。

生成 Endpoint.png

Endpoint 是一个 class,其内部包含的是 TargetType 中的部分内容,例如 url,method 等。

Endpoint 的定义.png

在 requestNormal 方法的内部,最终会调用 requestClosure,这个 requestClosure 其实是一个闭包表达式,调用 requestClosure 的代码如下:

requestClosure(endpoint, performNetworking)

可以看到,requestClosure 接收了 endpoint 和一个 performNetworking,这个 performNetworking 很显然就是网络请求操作。在最后 requestNormal 会返回一个 CancellableWrapper 的类型,CancellableWrapper 用于管理是否取消当前的网络请求任务。

这个 requestClosure 在内部做了什么呢,来看一下这个闭包表达式是在哪里赋值的:

MoyaProvider 的初始化方法.png

在 MoyaProvider 进行初始化的时候,requestClosure 默认就有一个初始值,并且 endpointClosure 也有一个默认的初始值(endpointClosure 在下面会讲)。

可以看到 requestClosure 本质上是 MoyaProvider.defaultRequestMapping,我们来看看这个函数在内部都做了些什么:

defaultRequestMapping.png

可以看到,defaultRequestMapping 方法就只做一件事,通过 Endpoint 来生成 URLRequest。我们再来回顾 requestNormal 方法中是如何生成 Endpoint 的,在 requestNormal 方法中通过调用 endpoint 方法来生成 Endpoint,其内部的实现也非常简单,就是调用了刚刚提到的闭包表达式:endpointClosure。

endpoint 方法.png

endpointClosure 本质上是 MoyaProvider.defaultEndpointMapping,这个方法就是用来生成 Endpoint 的,我们来看一下:

defaultEndpointMapping.png

前面也提到了,Endpoint 是用来生成 URLRequest,那么 Endpoint 是如何生成 URLRequest 的呢。其实也很简单,Endpoint 会根据当前的 task 生成不同类型的 URLRequest。

生成 URLRequest 的方法.png

生成 URLRequest 之后就开始发起网络请求了,在 requestNormal 的闭包:performNetworking 中调用 performRequest 方法,这个方法就是 Moya 发起网络请求的入口。

performRequest 的实现.png

在 performRequest 中会根据 Endpoint 的 task 来处理不同的请求,但其实这些方法的内部最终调用的都是 sendAlamofireRequest 方法。sendAlamofireRequest 通过 Moya 对 Alamofire 封装好的方法进行发起网络请求。

progressAlamoRequest.png

Moya+Alamofire

Moya 对 Alamofire 的封装非常简单,通过 Requestable 协议对 Alamofire 的 Request 进行扩展,Requestable 中只声明了一个 response 方法,这个方法返回的是一个 Self 类型。通过 Self 可以拿到 Alamofire 中 Request 的真实类型,进而调用 resume 方法来开始当前的请求。

Requestable协议.png

我们最后看一下 DataRequest 和 DownloadRequest 是什么:

DataRequest 和 DownloadRequest.png

Moya 主要的数据处理流程到这里就结束了,在发起网络请求之后的回调处理都是在 sendAlamofireRequest 方法中。