封装一个 Swift-Style 的网络模块

Swift 跟 OC 有着完全不同的设计哲学,它鼓励你使用 protocol 而不是 super class,使用 enum 和 struct 而不是 class,它支持函数式特性、范型和类型推导,让你可以轻松封装异步过程,用链式调用避免 callback hell。如果你还是用 OC 的思维写着 Swift 代码,那可以说是一种极大的资源浪费,你可能还会因为 Swift 弱鸡的反射而对它感到不满,毕竟 Swift 在强类型和安全性方面下足了功夫,如果不使用 OC 的 runtime,在动态性方面是远不如 OC 的。

OOP 和消息传递非常适合 UI 编程,在这方面来说 OC 是非常称职的,整个 Cocoa Touch 框架也都是面向对象的,所以对于 iOS 开发来说,不管你使用什么语言,都必须熟悉 OOP。在 UI 构建方面,无论是 Swift 还是 OC,无非都是调用 API 罢了,在有自动提示的情况下,其实编码体验都差不多。那 Swift 相比于 OC 的优势到底体现在什么地方呢,我认为是 UI 以外的地方,跟 UI 关系越小,Swift 能一展拳脚的余地就越大,譬如网络层。

讲到网络层就绕不开 Alamofire,Alamofire 几乎是现在用 Swift 开发 iOS App 的标配,它是个很棒的库,几乎能满足所有网络方面的日常需求,但如果对它再封装一下的话,不仅使用起来更得心应手,而且能将第三方库与业务代码解耦,以后万一要更换方案会更加方便。

Alamofire 使用 Result 来表示请求返回的结果,它是个 enum,长这样:

public enum Result<Value, Error : ErrorType> {
    case Success(Value)
    case Failure(Error)
    /// Returns `true` if the result is a success, `false` otherwise.
    public var isSuccess: Bool { get }
    /// Returns `true` if the result is a failure, `false` otherwise.
    public var isFailure: Bool { get }
    /// Returns the associated value if the result is a success, `nil` otherwise.
    public var value: Value? { get }
    /// Returns the associated error value if the result is a failure, `nil` otherwise.
    public var error: Error? { get }
}

我们可以对它进行扩展,让它支持链式调用:

import Foundation
import Alamofire

extension Result {

    // Note: rethrows 用于参数是一个会抛出异常的闭包的情况,该闭包的异常不会被捕获,会被再次抛出,所以可以直接使用 try,而不用 do-try-catch

    // U 可能为 Optional
    func map<U>(@noescape transform: Value throws -> U) rethrows -> Result<U, Error> {
        switch self {
        case .Failure(let error):
            return .Failure(error)
        case .Success(let value):
            return .Success(try transform(value))
        }
    }

    // 若 transform 的返回值为 nil 则作为异常处理
    func flatMap<U>(@noescape transform: Value throws -> U?) rethrows -> Result<U, Error> {
        switch self {
        case .Failure(let error):
            return .Failure(error)
        case .Success(let value):
            guard let transformedValue = try transform(value) else {
                return .Failure(SYError.errorWithCode(.TransformFailed) as! Error)
            }
            return .Success(transformedValue)
        }
    }

    // 适用于 transform(value) 之后可能产生 error 的情况
    func flatMap<U>(@noescape transform: Value throws -> Result<U, Error>) rethrows -> Result<U, Error> {
        switch self {
        case .Failure(let error):
            return .Failure(error)
        case .Success(let value):
            return try transform(value)
        }
    }

    // 处理错误,并向下传递
    func mapError(@noescape transform: Error throws -> NSError) rethrows -> Result<Value, NSError> {
        switch self {
        case .Failure(let error):
            return .Failure(try transform(error))
        case .Success(let value):
            return .Success(value)
        }
    }

    // 处理数据(不再向下传递数据,作为数据流的终点)
    func handleValue(@noescape handler: Value -> Void) {
        switch self {
        case .Failure(_):
            break
        case .Success(let value):
            handler(value)
        }
    }

    // 处理错误(终点)
    func handleError(@noescape handler: Error -> Void) {
        switch self {
        case .Failure(let error):
            handler(error)
        case .Success(_):
            break
        }
    }
}

有了这个扩展我们就可以定义一个parseResult的方法,对返回结果进行处理,像这样:

func parseResult(result: Result<AnyObject, NSError>, responseKey: String) -> Result<AnyObject, NSError> {
    return result
        .flatMap { $0 as? [String: AnyObject] }
        .flatMap(self.checkJSONDict) // 解析错误信息并进行打印,然后继续向下传递,之后业务方可自由选择是否进一步处理错误
        .flatMap { $0.valueForKey(responseKey) }
}

checkJSONDict用来处理服务器返回的错误信息,具体的处理逻辑不同项目都不一样,主要看跟服务器的约定,我就不细说了。valueForKey是对Dictionary的扩展,可以通过字符串拿到返回的 JSON 数据中需要的部分(先转换成[String: AnyObject]),支持用"."分隔 key,从而取得嵌套对象。譬如这样一个东西:

{
  key1: value1,
  key2: { nest: value2 }
  key3: { nest1: { nest2: value3 } }
}

你可以用"key2.nest"拿到value2,用"key3.nest1.nest2"拿到value3。我用reduce实现了这个功能:

extension Dictionary {
    var dictObject: AnyObject? { return self as? AnyObject }

    func valueForKey(key: Key) -> Value? {
        guard let stringKey = key as? String 
            where stringKey.containsString(".") else { return self[key] }

        let keys = stringKey.componentsSeparatedByString(".")
        guard !keys.isEmpty else { return nil }

        let results: AnyObject? = keys.reduce(dictObject, combine: fetchValueInObject)
        return results as? Value
    }
}

func fetchValueInObject(object: AnyObject?, forKey key: String) -> AnyObject? {
    return (object as? [String: AnyObject])?[key]
}

有了parseResult之后,我们就可以轻松封装请求过程了:

/**
 Fetch raw object

 - parameter api:              API address
 - parameter method:           HTTP method, default = POST
 - parameter parameters:       Request parameters, default = nil
 - parameter responseKey:      Key of target value, use '.' to get nested objects, e.g. "data.vehicle_list"
 - parameter jsonArrayHandler: Handle result with raw object

 - returns: Optional request object which is cancellable.
 */
func fetchDataWithAPI(api: API,
                   method: Alamofire.Method = .POST,
               parameters: [String: AnyObject]? = nil,
              responseKey: String,
 networkCompletionHandler: NetworkCompletionHandler) -> Cancellable? {

    guard let url = api.url else {
        printLog("URL Invalid: \(api.rawValue)")
        return nil
    }

    let params = configParameters(parameters)

    return Alamofire.request(method, url, parameters: params).responseJSON {
        networkCompletionHandler(self.parseResult($0.result, responseKey: responseKey))
    }
}

API是一个枚举,有一个url的计算属性,用来返回 API 地址,configParameters用来配置请求参数,也跟具体项目有关,就不展开了,method可以设置一个项目中常用的 HTTP Method 作为默认参数。这个方法会返回一个Cancellable,长这样:

protocol Cancellable {
    func cancel()
}

extension Request: Cancellable {}

Request本来就实现了cancel方法,所以只要显式地声明一下它遵守Cancellable协议就行了,使用的时候像这样:

let task = NetworkManager.defaultManager
    .fetchDataWithAPI(.ModelList, responseKey: "data.model_list") {
        // ...
}

在请求完成之前,随时可以调用task?.cancel() 来取消这个网络任务。

当然如果你想在网络模块中把 JSON 直接转化成 Model 也是可以的,我个人倾向于使用 ObjectMapper 来构建网络 Model 层,于是就可以对外提供两个直接取得 Model 和 Model 数组的方法:

/**
 Fetch JSON model

 - parameter api:              API address
 - parameter method:           HTTP method, default = POST
 - parameter parameters:       Request parameters, default = nil
 - parameter responseKey:      Key of target value, use '.' to get nested objects, e.g. "data.vehicle_list"
 - parameter jsonArrayHandler: Handle result with model

 - returns: Optional request object which is cancellable.
 */
func fetchJSONWithAPI<T: Mappable>(api: API,
                                method: Alamofire.Method = .POST,
                            parameters: [String: AnyObject]? = nil,
                           responseKey: String,
                           jsonHandler: Result<T, NSError> -> Void) -> Cancellable? {

    return fetchDataWithAPI(api, method: method, parameters: parameters, responseKey: responseKey) {
        jsonHandler($0.flatMap(=>))
    }
}

/**
 Fetch JSON array

 - parameter api:              API address
 - parameter method:           HTTP method, default = POST
 - parameter parameters:       Request parameters, default = nil
 - parameter responseKey:      Key of target value, use '.' to get nested objects, e.g. "data.vehicle_list"
 - parameter jsonArrayHandler: Handle result with model array

 - returns: Optional request object which is cancellable.
 */
func fetchJSONArrayWithAPI<T: Mappable>(api: API,
                                     method: Alamofire.Method = .POST,
                                 parameters: [String: AnyObject]? = nil,
                                responseKey: String,
                           jsonArrayHandler: Result<[T], NSError> -> Void) -> Cancellable? {

    return fetchDataWithAPI(api, method: method, parameters: parameters, responseKey: responseKey) {
        jsonArrayHandler($0.flatMap(=>))
    }
}

=>是我自定义的操作符,它有两个重载版本,都满足flatMap的参数要求:


postfix operator => {}

postfix func =><T: Mappable>(object: AnyObject) -> T? {
    return Mapper().map(object)
}

postfix func =><T: Mappable>(object: AnyObject) -> [T]? {
    return Mapper().mapArray(object)
}

于是就可以在业务代码中直接这样:

class TableViewController: UITableViewController {
    // ...
    var results: [Demo]? {
        didSet {
            tableView.reloadData()
        }
    }

    func fetchData() {
        let task = NetworkManager.defaultManager
            .fetchJSONArrayWithAPI(.Demo, responseKey: "data.demo_list") { 
                self.results = $0.value
        }
    }
}

到此一个简洁方便的网络模块就差不多成型了,别忘了为你的模块添加单元测试,这会让模块的使用者对你的代码更有信心,而且在测试过程中会让你发现一些开发过程中的思维盲区,还能帮你优化设计,毕竟良好的可测试性在某种程度上就意味着良好的可读性和可维护性。

有什么建议欢迎在评论中指出 ^ ^

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,312评论 5 473
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,578评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,337评论 0 333
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,134评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,161评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,303评论 1 280
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,761评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,421评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,609评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,450评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,504评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,194评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,760评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,836评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,066评论 1 257
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,612评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,178评论 2 341

推荐阅读更多精彩内容