Swift 運用協議泛型封裝網路層

swordjoy發表於2018-05-04

swift 版本: 4.1

Xcode 版本 9.3 (9E145)

基於 AlamofireMoya 再封裝

程式碼 Github 地址: MoyaDemo

一、前言

最近進入新公司開展新專案,我發現公司專案的網路層很 OC ,最讓人無法忍受的是資料解析是在網路層之外的,每一個資料模型都需要單獨寫解析程式碼。趁著專案才開始,我提議由我寫一個網路層小工具來代替以前的網路層,順便把載入菊花,快取也封裝到了裡面。

二、Moya工具和Codable協議簡介

這裡只是展示一下 Moya 的基本使用方法和 Codable協議 的基本知識,如果對這兩塊感興趣,讀者可以自行去搜尋研究。

2.1 Moya工具

使用 Moya 是因為筆者覺得它很方便,如果讀者不想使用 Moya,也不影響你閱讀這篇文章的內容。

Alamofire 這裡就不作介紹了,如果沒有接觸過,你可以把它當做是 Swift 版本的 AFNetworkingMoya 是一個對 Alamofire 進行了再次封裝的工具庫。如果只使用 Alamofire ,你的網路請求可能會是這樣:

let url = URL(string: "your url")!
Alamofire.request(url).response { (response) in
    // handle response
}
複製程式碼

當然讀者也會基於它進行二次封裝,不會僅僅是上面程式碼那麼簡單。

如果使用 Moya, 你首先做的不是直接請求,而是根據專案模組建立一個個檔案定義介面。例如我喜歡根據模組的功能取名 模組名 + API,然後再在其中定義我們需要使用的介面,例:

import Foundation
import Moya

enum YourModuleAPI {
    case yourAPI1
    case yourAPI2(parameter: String)
}

extension YourModuleAPI: TargetType {
    var baseURL : URL {
        return URL(string: "your base url")!
    }
    
    var headers : [String : String]? {
        return "your header"
    }
    
    var path: String {
        switch self {
            case .yourAPI1:
                return "yourAPI1 path"
            case .yourAPI2:
                return "yourAPI2 path"
        }
    }
    
    var method: Moya.Method {
        switch self {
            case .yourAPI1:
                return .post
            default:
                return .get
        }
    }
    
    // 這裡只是帶引數的網路請求
    var task: Task {
        var parameters: [String: Any] = [:]
        switch self {
            case let .yourAPI1:
                parameters = [:]
            case let .yourAPI2(parameter):
                parameters = ["欄位":parameter]
        }
        return .requestParameters(parameters: parameters,
                                    encoding: URLEncoding.default)
    }
    
    // 單元測試使用    
    var sampleData : Data {
        return Data()
    }
}
複製程式碼

定義如上的檔案後,你就可以使用如下方式進行網路請求:

MoyaProvider<YourModuleAPI>().request(YourModuleAPI.yourAPI1) { (result) in
    // handle result            
}
複製程式碼

2.2 Codable協議

Codable協議Swift4 才更新的,用來解析和編碼資料,它是由編碼協議和解碼協議組成。

public typealias Codable = Decodable & Encodable
複製程式碼

Swift 更新 Codable協議 之前,筆者一直用的 SwiftyJSON 來解析網路請求返回的資料。最近使用 Codable協議 後,發現還蠻好用的,就直接用上了。

不過 Codable協議 還是有一些坑點的,例如這篇文章所描述的:

When JSONDecoder meets the real world, things get ugly…

下面的 Person 模型類儲存了一個簡單的個人資訊,這裡只是使用瞭解碼,所以只遵守了 Decodable協議

struct Person: Decodable {
  var name: String
  var age: Int
}
複製程式碼

StringInt 是系統預設的可編解碼型別,所以我們無需再寫其他程式碼了,編譯器將預設為我們實現。

let jsonString = """
        {   "name": "swordjoy",
            "age": 99
        }
"""

if let data = jsonString.data(using: .utf8) {
    let decoder = JSONDecoder()
    if let person = try? decoder.decode(Person.self, from: data) {
        print(person.age)    // 99
        print(person.name)   // swordjoy
    }
}
複製程式碼

只需要將 Person 型別傳給 JSONDecoder 物件,它就能直接將 JSON 資料轉換成 Person 資料模型物件。實際使用中由於解析規則的各種嚴格的限制,遠遠沒有上面看著這麼簡單。

三、分析和解決方案

3.1.1 重複解析資料到模型

例如這裡有兩個介面,一個是請求商品列表,一個是請求商城首頁。筆者以前是這樣寫的:

enum MallAPI {
    case getMallHome
    case getGoodsList
}
extension MallAPI: TargetType {
    // 略   
}
複製程式碼
let mallProvider = MoyaProvider<MallAPI>()
mallProvider.request(MallAPI.getGoodsList) { (response) in
    // 將 response 解析成 Goods 模型陣列用 success 閉包傳出去
}

mallProvider.request(MallAPI.getMallHome) { (response) in
    // 將 response 解析成 Home 模型用 success 閉包傳出去
}
複製程式碼

以上是簡化的實用場景,每一個網路請求都會單獨的寫一次將返回的資料解析成資料模型或者資料模型陣列。就算是將資料解析的功能封裝成一個單例工具類,也僅僅是稍稍好了一些。

筆者想要的是指定資料模型型別後,網路層直接返回解析完成後的資料模型供我們使用。

3.1.2 運用泛型來解決

泛型就是用來解決上面這種問題的, 使用泛型建立一個網路工具類,並給定泛型的條件約束:遵守 Codable 協議。

struct NetworkManager<T> where T: Codable {
    
}
複製程式碼

這樣我們在使用時,就可以指定需要解析的資料模型型別了。

NetworkManager<Home>().reqest...
NetworkManager<Goods>().reqest...
複製程式碼

細心的讀者會發現這和 Moya 初始化 MoyaProvider 類的使用方式一樣。

3.2.1 使用Moya後,如何將載入控制器和快取封裝到網路層

由於使用了 Moya 進行再次封裝,每對程式碼進行一次封裝的代價就是自由度的犧牲。如何將載入控制器&快取功能和 Moya 契合起來呢?

一個很簡單的做法是在請求方法裡新增是否顯示控制器和是否快取布林值引數。看著我的請求方法引數已經5,6個,這個方案立馬被排除了。看著 MoyaTargetType 協議,給了我靈感。

3.2.2 運用協議來解決

既然 MallAPI 能遵守 TargetType 來實現配置網路請求資訊,那當然也能遵守我們自己的協議來進行一些配置。

自定義一個 Moya 的補充協議

protocol MoyaAddable {
    var cacheKey: String? { get }
    var isShowHud: Bool { get }
}
複製程式碼

這樣 MallAPI 就需要遵守兩個協議了

extension MallAPI: TargetType, MoyaAddable {
    // 略   
}
複製程式碼

四、部分程式碼展示和解析

完整的程式碼,讀者可以到 Github 上去下載。

4.1 封裝後的網路請求

通過給定需要返回的資料型別,返回的 response 可以直接調取 dataList 屬性獲取解析後的 Goods 資料模型陣列。錯誤閉包裡面也能直接通過 error.message 獲取報錯資訊,然後根據業務需求選擇是否使用彈出框提示使用者。

NetworkManager<Goods>().requestListModel(MallAPI.getOrderList, 
completion: { (response) in
    let list = response?.dataList
    let page = response?.page
}) { (error) in
    if let msg = error.message else {
        print(msg)
    }
}
複製程式碼

4.2 返回資料的封裝

筆者公司服務端返回的資料結構大致如下:

{
    "code": 0,
    "msg": "成功",
    "data": {
        "hasMore": false,
        "list": []
    }
}
複製程式碼

出於目前業務和解析資料的考慮,筆者將返回的資料型別封裝成了兩類,同時也將解析的操作放在了裡面。

後面的請求方法也分成了兩個,這不是必要的,讀者可以根據自己的業務和喜好選擇。

  • 請求列表介面返回的資料
  • 請求普通介面返回的資料
class BaseResponse {
    var code: Int { ... } // 解析
    var message: String? { ... } // 解析
    var jsonData: Any? { ... } // 解析
    
    let json: [String : Any]
    init?(data: Any) {
        guard let temp = data as? [String : Any] else {
            return nil
        }
        self.json = temp
    }
    
    func json2Data(_ object: Any) -> Data? {
        return try? JSONSerialization.data(
        withJSONObject: object,
        options: [])
    }
}

class ListResponse<T>: BaseResponse where T: Codable {
    var dataList: [T]? { ... } // 解析
    var page: PageModel? { ... } // 解析
}

class ModelResponse<T>: BaseResponse where T: Codable {
    var data: T? { ... } // 解析
}
複製程式碼

這樣我們直接返回相應的封裝類物件就能獲取解析後的資料了。

4.3 錯誤的封裝

網路請求過程中,肯定有各種各樣的錯誤,這裡使用了 Swift 語言的錯誤機制。

// 網路錯誤處理列舉
public enum NetworkError: Error  {
    // 略...
    // 伺服器返回的錯誤
    case serverResponse(message: String?, code: Int)
}

extension NetworkError {
    var message: String? {
        switch self {
            case let .serverResponse(msg, _): return msg
            default: return nil
        }
    }
    
    var code: Int {
        switch self {
            case let .serverResponse(_, code): return code
            default: return -1
        }
    }
}
複製程式碼

這裡的擴充套件很重要,它能幫我們在處理錯誤時獲取錯誤的 messagecode.

4.4 請求網路方法

最終請求的方法

private func request<R: TargetType & MoyaAddable>(
    _ type: R,
    test: Bool = false,
    progressBlock: ((Double) -> ())? = nil,
    modelCompletion: ((ModelResponse<T>?) -> ())? = nil,
    modelListCompletion: ((ListResponse<T>?) -> () )? = nil,
    error: @escaping (NetworkError) -> () )
    -> Cancellable?
{}
複製程式碼

這裡的 R 泛型是用來獲取 Moya 定義的介面,指定了必須同時遵守 TargetTypeMoyaAddable 協議,其餘的都是常規操作了。 和封裝的返回資料一樣,這裡也分了普通介面和列表介面。

@discardableResult
func requestModel<R: TargetType & MoyaAddable>(
    _ type: R,
    test: Bool = false,
    progressBlock: ((Double) -> ())? = nil,
    completion: @escaping ((ModelResponse<T>?) -> ()),
    error: @escaping (NetworkError) -> () )
    -> Cancellable?
{
    return request(type,
                    test: test,
                    progressBlock: progressBlock,
                    modelCompletion: completion,
                    error: error)
}

@discardableResult
func requestListModel<R: TargetType & MoyaAddable>(
    _ type: R,
    test: Bool = false,
    completion: @escaping ((ListResponse<T>?) -> ()),
    error: @escaping (NetworkError) -> () )
    -> Cancellable?
{
    return request(type,
                    test: test,
                    modelListCompletion: completion,
                    error: error)
}
複製程式碼

我綜合目前專案和 Codable 協議的坑點考慮,將這裡寫得有點死板,萬一來個既是列表又有其他資料的就不適用了。不過到時候可以新增一個類似這種方法,將資料傳出去處理。

// Demo裡沒有這個方法
func requestCustom<R: TargetType & MoyaAddable>(
    _ type: R,
    test: Bool = false,
    completion: (Response) -> ()) -> Cancellable? 
{
    // 略
}
複製程式碼

4.5 快取和載入控制器

想到新增 MoyaAddable 協議後,其他就沒什麼困難的了,直接根據 type 獲取介面定義檔案中的配置做出相應的操作就行了。

var cacheKey: String? {
    switch self {
        case .getGoodsList:
            return "cache goods key"
        default:
            return nil
    }
}

var isShowHud: Bool {
    switch self {
        case .getGoodsList:
            return true
        default:
            return false
    }
}
複製程式碼

這就新增了 getGoodsList 介面請求中的兩個功能

  • 請求返回資料後會通過給定的快取 Key 進行快取
  • 網路請求過程中自動顯示和隱藏載入控制器。

如果讀者的載入控制器有不同的樣式,還可以新增一個載入控制器樣式的屬性。甚至快取的方式是同步還是非同步,都可以通過這個 MoyaAddable 新增。

// 快取
private func cacheData<R: TargetType & MoyaAddable>(
    _ type: R,
    modelCompletion: ((Response<T>?) -> ())? = nil,
    modelListCompletion: ( (ListResponse<T>?) -> () )? = nil,
    model: (Response<T>?, ListResponse<T>?))
{
    guard let cacheKey = type.cacheKey else {
        return
    }
    if modelComletion != nil, let temp = model.0 {
        // 快取
    }
    if modelListComletion != nil, let temp = model.1 {
        // 快取
    }
}
複製程式碼

載入控制器的顯示和隱藏使用的是 Moya 自帶的外掛工具。

// 建立moya請求類
private func createProvider<T: TargetType & MoyaAddable>(
    type: T,
    test: Bool) 
    -> MoyaProvider<T> 
{
    let activityPlugin = NetworkActivityPlugin { (state, targetType) in
        switch state {
        case .began:
            DispatchQueue.main.async {
                if type.isShowHud {
                    SVProgressHUD.showLoading()
                }
                self.startStatusNetworkActivity()
            }
        case .ended:
            DispatchQueue.main.async {
                if type.isShowHud {
                    SVProgressHUD.dismiss()
                }
                self.stopStatusNetworkActivity()
            }
        }
    }
    let provider = MoyaProvider<T>(
        plugins: [activityPlugin,
        NetworkLoggerPlugin(verbose: false)])
    return provider
}
複製程式碼

4.6 避免重複請求

定義一個陣列來儲存網路請求的資訊,一個並行佇列使用 barrier 函式來保證陣列元素新增和移除執行緒安全。

// 用來處理只請求一次的柵欄佇列
private let barrierQueue = DispatchQueue(label: "cn.tsingho.qingyun.NetworkManager",
attributes: .concurrent)
// 用來處理只請求一次的陣列,儲存請求的資訊 唯一
private var fetchRequestKeys = [String]()
複製程式碼
private func isSameRequest<R: TargetType & MoyaAddable>(_ type: R) -> Bool {
    switch type.task {
        case let .requestParameters(parameters, _):
            let key = type.path + parameters.description
            var result: Bool!
            barrierQueue.sync(flags: .barrier) {
                result = fetchRequestKeys.contains(key)
                if !result {
                    fetchRequestKeys.append(key)
                }
            }
            return result
        default:
            // 不會呼叫
            return false
    }
}

private func cleanRequest<R: TargetType & MoyaAddable>(_ type: R) {
    switch type.task {
        case let .requestParameters(parameters, _):
            let key = type.path + parameters.description
            barrierQueue.sync(flags: .barrier) {
                fetchRequestKeys.remove(key)
            }
        default:
            // 不會呼叫
            ()
    }
}
複製程式碼

這種實現方式目前有一個小問題,多個介面使用同一介面,並且引數也相同的話,只會請求一次,不過這種情況還是極少的,暫時沒遇到就沒有處理。

五、後記

目前封裝的這個網路層程式碼有點強業務型別,畢竟我的初衷就是給自己公司專案重新寫一個網路層,因此可能不適用於某些情況。不過這裡使用泛型和協議的方法是通用的,讀者可以使用同樣的方式實現匹配自己專案的網路層。如果讀者有更好的建議,還希望評論出來一起討論。

轉載評論留轉載地址即可轉載。

相關文章