第一次使用Swift来做项目,第一步当然就是折腾架构,由于项目时间比较宽裕,使用了较多的时间来进行学习参照,最后使用了这样的架构来作为这个产品的网络层,满足了项目基本的需求,肯定有不少的缺陷,权当各位参考。

先来看看最终我们要达成的目标,下面是一段异步请求接口的代码,这段代码具有网络请求、序列化及基本的缓存功能

firstly { () -> Promise<[ArticleCategoryModel?]?> in
    return CallApi(DFAPI.navigations, isCached: true)
}.then { [weak self] (data) -> Void in
    self!.navigations = data!
}

<!-- more --> 这一段请求,由以下4个库实现:

  1. Moya:负责网络请求
  2. PromiseKit:负责干净优雅的链式异步调用
  3. HandyJSON:负责序列化、反序列化
  4. AwesomeCache:负责缓存请求结果及缓存过期

该项目Demo

接下来我们就从这个顺序开始,一步一步配置我们的网络层,首先从最基础的网络请求 Moya 开始。

1. 用 Moya 构建基础网络请求

基于Alamofire的抽象,通过更好的方式管理你的接口及其变量,当你的项目集成Moya之后,你的请求会变成这样(代码来源于官方文档):

provider = MoyaProvider<GitHub>()
provider.request(.zen) { result in
    switch result {
    case let .success(moyaResponse):
        let data = moyaResponse.data
        let statusCode = moyaResponse.statusCode
        // do something with the response data or statusCode
    case let .failure(error):
        // this means there was a network failure - either the request
        // wasn't sent (connectivity), or no response was received (server
        // timed out).  If the server responds with a 4xx or 5xx error, that
        // will be sent as a ".success"-ful response.
    }
}

接下来我们来对Moya进行配置。 这里用到了我们熟悉的知乎日报API。

import Moya

/// 接口
public enum ZhihuAPI {
    /// 最新文章
    case latest
    /// 文章内容
    case content(Int)
}

/// Moya 配置
extension ZhihuAPI: TargetType {
    /// 地址前缀
    public var baseURL: URL { return URL(string: "https://news-at.zhihu.com/api/4")! }
    /// 接口地址
    public var path: String {
        switch self {
        case .latest:
            return "/news/latest"
        case .content(let id):
            return "/news/\(id)"
        }
    }
    /// 请求方法
    public var method: Moya.Method {
        return .get
    }
    /// 参数
    public var task: Task {
        switch self {
        default:
            return .requestPlain
        }
    }
    public var validate: Bool {
        return true
    }
    public var sampleData: Data {
        return "".data(using: String.Encoding.utf8)!
    }
    public var headers: [String: String]? {
        return nil
    }
}

插件机制

请求接口时,我们往往会需要验签、请求前菊花跟请求后隐藏菊花等等功能,这些功能大范围的覆盖了一些接口,在Moya中,可以使用插件机制实现这一类的特性。 在Moya中,我们使用MoyaProvider对象来对接口进行调用,在初始化MoyaProvider时,就可以进行插件的配置。 在我的插件中,我配置了验签参数,请求菊花以及错误提示,他大概是这样的(并不用于该演示项目,仅作参考):

internal final class SGPreprocessingPlugin: PluginType {
    
    // 在每次请求前调用,获取并拼接验签参数
    func prepare(_ request: URLRequest, target: TargetType) -> URLRequest {
        // 声明并计算各个验签参数...
        ...
        
        // 获取 URL 并拼接
        var req = request
        if var url = req.url?.absoluteString {
            url.append("?nonce=\(nonce)")
            url.append("&timestamp=\(timestamp)")
            url.append("&signature=\(signature)")
            url.append("&key=\(key)")
            req.url = URL(string: url)
        }
        
        // 在 Header 中指定 Content-Type
        req.setValue(Constants.API.ContentType, forHTTPHeaderField: "Content-Type")

        return req
    }
    
    /// 发送请求之前
    func willSend(_ request: RequestType, target: TargetType) {
        UIViewController.topViewController()?.view.makeToastActivity(.center)
        UIApplication.shared.isNetworkActivityIndicatorVisible = true
    }
    
    /// 收到响应之后
    func didReceive(_ result: Result<Response, MoyaError>, target: TargetType) {
        UIViewController.topViewController()?.view.hideAllToasts()
        UIApplication.shared.isNetworkActivityIndicatorVisible = false
    }
    
    /// 对响应结果进行预处理
    public func process(_ result: Result<Response, MoyaError>, target: TargetType) -> Result<Response, MoyaError> {
        switch result {
        case .success(let response):
            // 序列化并进行一系列判断,主要判断接口返回是否成功,成功则返回结果,失败则直接弹出服务器给的错误提示。
        case .failure(let error):
            // 请求失败,弹出对应的错误提示。
        }
    }
}

当我这样配置完插件(至少基本我每次参与的接口开发中,都有类似的需求),就可以像Moya的官方文档那样,声明一个Provider并进行调用了,当然在这个演示项目中,并没有什么好配置插件的地方。

这样Moya的部分我们就配置完毕了,接下来开始配置PromiseKit

2. PromiseKit

PromiseKit通过链式语法,完美地解决了异步编程时Block对代码优雅的破坏,在认识PromiseKit之前,我对Block可以说是深恶痛绝。 PromiseKit长这样(代码来源于官方文档):

UIApplication.shared.isNetworkActivityIndicatorVisible = true

firstly {
    when(URLSession.dataTask(with: url).asImage(), CLLocationManager.promise())
}.then { image, location -> Void in
    self.imageView.image = image;
    self.label.text = "\(location)"
}.always {
    UIApplication.shared.isNetworkActivityIndicatorVisible = false
}.catch { error in
    UIAlertView(/*…*/).show()
}

干净优雅,多重请求时也不会增加Block层级。 那么接下来,如何让PromiseKitMoya配合使用?

结合 Moya 与 PromiseKit

先来个最基本的,调用接口并返回字典,同时为了方便,我们直接将其定义为全局方法。

import PromiseKit
import Moya

func CallApi(_ target: ZhihuAPI) -> Promise<[String : Any]> {
    
    let provider = MoyaProvider<ZhihuAPI>()
    
    return Promise<[String : Any]> { fulfill, reject in
        provider.request(target, completion: { (result) in
            switch result {
            case let .success(response):
                do {
                    let data = try JSONSerialization.jsonObject(with: response.data, options: []) as! [String: Any]
                    fulfill(data)
                } catch {
                    reject(error)
                }
            case let .failure(error):
                reject(error)
            }
        })
    }
}

这样就结束了,PromiseKit很简单,正确时调用fulfill,错误时调用reject。 我们来调用一下试试看。

@IBAction func buttonDidPress(_ sender: Any) {
    firstly { () -> Promise<[String: Any]> in
        return CallApi(ZhihuAPI.latest)
    }.then { [weak self] (result) -> Void in
        self?.textView.text = "\(result)"
    }
}

需要注意的是,在我们将来扩展这个方法时,Promise内部可能会出现很多个条件分支,但无论如何你必须要在Block结尾之前调用一次fullfill或者reject,否则PromiseKitBlock可能会被提前释放掉。

接下来就是拓展这个方法,使其支持反序列化的功能,在请求接口的同时,将我们需要的模型返回过来,这时候就要用到HandyJSON了。 当然序列化的工具很多,喜欢什么用什么。

3. 用HandyJSON让接口调用支持反序列化

这个不做介绍,直接上代码。

/// 调用接口,成功返回模型数组
///
/// - Parameter target:
/// - Returns:
func CallApi<T: HandyJSON>(_ target: ZhihuAPI) -> Promise<[T?]?> {
    
    let provider = MoyaProvider<ZhihuAPI>()
    
    return Promise<[T?]?> { fulfill, reject in
        provider.request(target, completion: { (result) in
            switch result {
            case let .success(response):
                do {
                    let data = try [T].deserialize(from: response.mapString())
                    fulfill(data)
                } catch {
                    reject(error)
                }
            case let .failure(error):
                reject(error)
            }
        })
    }
}


/// 调用接口,成功返回模型
///
/// - Parameter target:
/// - Returns:
func CallApi<T: HandyJSON>(_ target: ZhihuAPI) -> Promise<T> {
    
    let provider = MoyaProvider<ZhihuAPI>()
    
    return Promise<T> { fulfill, reject in
        provider.request(target, completion: { (result) in
            switch result {
            case let .success(response):
                do {
                    let data = try T.deserialize(from: response.mapString())
                    fulfill(data!)
                } catch {
                    reject(error)
                }
            case let .failure(error):
                reject(error)
            }
        })
    }
}

两个方法分别是用来返回模型跟模型数组的。

这样,我们有了三种返回类型(模型、字典、数组),基本涵盖了所有情况,接下来测试一下。

首先编写模型:

import HandyJSON

struct StoryModel: HandyJSON {
    var id: Int!
    var title: String!
    var images: [String]?
    var image: String?
}

struct LatestStoriesModel: HandyJSON {
    var date: String!
    var stories: [StoryModel]?
    var top_stories: [StoryModel]?
}

然后测试:

// 模型
firstly { () -> Promise<LatestStoriesModel> in
    return CallApi(ZhihuAPI.latest)
}.then { [weak self] (result) -> Void in
    self?.textView.text = "\(result.toJSONString()!)"
}

4. 最后一步,让请求支持缓存

关于缓存这一功能,我在演示项目中集成的只是最基本的需求,非常地简单粗暴,仅仅有缓存和定时过期两个功能,具体情形各位应根据需求自行调整,这里我采用了AwesomeCache这个库来实现。

首先,声明AwesomeCache对象(仅用于项目演示):

import AwesomeCache

private let globalCache = try! Cache<NSString>(name: "globalCache")
private let cacheExpireDate = CacheExpiry.seconds(60*60*24)

在我们其中一个CallApi()方法中实现缓存功能,并指定其过期时间:

/// 调用接口,成功返回字典
///
/// - Parameter target:
/// - Returns:
func CallApi(_ target: ZhihuAPI, isCached: Bool = false) -> Promise<[String : Any]> {
    
    let cacheKey = "dictionary:" + target.path
    
    // 优先获取缓存
    if isCached, let jsonString = globalCache[cacheKey] {
        return Promise<[String : Any]> { fulfill, reject in
            do {
                let data = try JSONSerialization.jsonObject(with: jsonString.data(using: String.Encoding.utf8.rawValue)!, options: []) as? [String: Any]
                
                print("fetch from cache")
                fulfill(data!)
            } catch {
                reject(error)
            }
        }
    }
    
    let provider = MoyaProvider<ZhihuAPI>()
    
    return Promise<[String : Any]> { fulfill, reject in
        provider.request(target, completion: { (result) in
            switch result {
            case let .success(response):
                do {
                    // 缓存
                    try globalCache.setObject(response.mapString() as NSString, forKey: cacheKey, expires: cacheExpireDate)
                    
                    print("fetch from request")
                    let data = try JSONSerialization.jsonObject(with: response.data, options: []) as! [String: Any]
                    fulfill(data)
                } catch {
                    reject(error)
                }
            case let .failure(error):
                reject(error)
            }
        })
    }
}

好,测试一下。

// 缓存
firstly { () -> Promise<[String: Any]> in
    return CallApi(ZhihuAPI.latest, isCached: true)
}.then { [weak self] (result) -> Void in
    self?.textView.text = "\(result)"
}

如日志所示,分别是第一次请求和第二次请求。 其他CallApi()方法的缓存功能按类似的方式实现即可。