IOS框架使用:Moya

原創(chuàng):知識(shí)點(diǎn)總結(jié)性文章
創(chuàng)作不易,請(qǐng)珍惜,之后會(huì)持續(xù)更新,不斷完善
個(gè)人比較喜歡做筆記和寫總結(jié),畢竟好記性不如爛筆頭哈哈,這些文章記錄了我的IOS成長(zhǎng)歷程,希望能與大家一起進(jìn)步
溫馨提示:由于簡(jiǎn)書不支持目錄跳轉(zhuǎn),大家可通過(guò)command + F 輸入目錄標(biāo)題后迅速尋找到你所需要的內(nèi)容

目錄

  • 一、POP面向協(xié)議編程
    • 1、POP 面向協(xié)議編程相比面向?qū)ο缶幊痰膬?yōu)勢(shì)
    • 2、使用POP進(jìn)行網(wǎng)絡(luò)請(qǐng)求
    • 3、總結(jié)與解惑
  • 二、初識(shí)Moya
    • 1、Moya的簡(jiǎn)介
    • 2、豆瓣范例
    • 3、訂單范例
    • 4、登錄范例
  • Demo
  • 參考文獻(xiàn)

一、POP面向協(xié)議編程

1、POP 面向協(xié)議編程相比面向?qū)ο缶幊痰膬?yōu)勢(shì)

a、橫切關(guān)注點(diǎn)問(wèn)題

指的是我們很難在不同繼承關(guān)系的類里共用代碼。想要解決這個(gè)問(wèn)題,我們有幾個(gè)方案。

Copy & Paste

這是一個(gè)比較糟糕的解決方案,但是演講現(xiàn)場(chǎng)還是有不少朋友選擇了這個(gè)方案,特別是在工期很緊,無(wú)暇優(yōu)化的情況下。這誠(chéng)然可以理解,但是這也是壞代碼的開(kāi)頭。我們應(yīng)該盡量避免這種做法。

引入 BaseViewController

在一個(gè)繼承自 UIViewControllerBaseViewController 上添加需要共享的代碼,或者干脆在 UIViewController 上添加 extension??雌饋?lái)這是一個(gè)稍微靠譜的做法,但是如果不斷這么做,會(huì)讓所謂的 Base 很快變成垃圾堆。職責(zé)不明確,任何東西都能扔進(jìn) Base,你完全不知道哪些類走了 Base,而這個(gè)“超級(jí)類”對(duì)代碼的影響也會(huì)不可預(yù)估。

依賴注入

通過(guò)外界傳入一個(gè)帶有 myMethod 的對(duì)象,用新的類型來(lái)提供這個(gè)功能。這是一個(gè)稍好的方式,但是引入額外的依賴關(guān)系,可能也是我們不太愿意看到的。

面向協(xié)議

現(xiàn)在通過(guò)面向協(xié)議的方式,任何遵循協(xié)議的對(duì)象都可以使用協(xié)議中的方法和屬性,比如只有對(duì)象遵守了下面代碼中的PersonProtocl協(xié)議就可以使用 name 屬性以及sayHello()方法。


b、POP 解決橫切關(guān)注點(diǎn)
? 提供聲明
protocol PersonProtocl
{
    // 協(xié)議屬性
    var name: String {get}
    
    // 協(xié)議方法
    func sayHello()
}

通過(guò)結(jié)構(gòu)體來(lái)實(shí)現(xiàn)協(xié)議

struct Teacher: PersonProtocl
{
    var name: String
    
    func sayHello()
    {
        print("同學(xué)們好,請(qǐng)把周末的作業(yè)交上來(lái)")
    }
}

struct Student: PersonProtocl
{
    var name: String
    
    func sayHello()
    {
        print("老師你好,我作業(yè)放在家里忘帶了")
    }
}

進(jìn)行調(diào)用

override func viewDidLoad()
{
    super.viewDidLoad()
    
    let teacher = Teacher(name: "蔣紅")
    let student = Student(name: "謝佳培")
    teacher.sayHello()
    student.sayHello()
}

輸出結(jié)果為

同學(xué)們好,請(qǐng)把周末的作業(yè)交上來(lái)
老師你好,我作業(yè)放在家里忘帶了
? 擴(kuò)展實(shí)現(xiàn)

但是仍然存在一個(gè)很大的問(wèn)題,那就是協(xié)議里的方法和屬性缺乏具體的實(shí)現(xiàn)。如果只是提供聲明,那意味著我們還需要在每一個(gè)類里面都實(shí)現(xiàn)一遍,那協(xié)議就顯得比較雞肋了,而且有很多時(shí)候這些方法是共有的,不需要太多的特定實(shí)現(xiàn)。這時(shí)候就需要對(duì)協(xié)議提供默認(rèn)實(shí)現(xiàn)的協(xié)議擴(kuò)展閃亮登場(chǎng)了。

extension PersonProtocl
{
    func sayHello()
    {
        print("hello! boy")
    }
}

對(duì)其進(jìn)行調(diào)用

class UsePop: UIViewController, PersonProtocl
{
    var name: String = ""

    override func viewDidLoad()
    {
        super.viewDidLoad()
        sayHello()
    }
}

輸出結(jié)果為

hello! boy

c、POP 解決動(dòng)態(tài)派發(fā)安全性

Objective-C 恰如其名,是一門典型的 OOP 語(yǔ)言,同時(shí)它繼承了 Small Talk 的消息發(fā)送機(jī)制。這套機(jī)制十分靈活,是 OC 的基礎(chǔ)思想,但是有時(shí)候相對(duì)危險(xiǎn)??紤]下面的代碼:

ViewController *v1 = ...
[v1 myMethod];

AnotherViewController *v2 = ...
[v2 myMethod];

NSArray *array = @[v1, v2];
for (id obj in array)
{
    [obj myMethod];
}

我們?nèi)绻?ViewControllerAnotherViewController 中都實(shí)現(xiàn)了 myMethod 的話,這段代碼是沒(méi)有問(wèn)題的。myMethod 將會(huì)被動(dòng)態(tài)發(fā)送給 array 中的 v1v2。但是,要是我們有一個(gè)沒(méi)有實(shí)現(xiàn) myMethod 的類型,會(huì)如何呢?

NSObject *v3 = [NSObject new]
// v3 沒(méi)有實(shí)現(xiàn) `myMethod`

NSArray *array = @[v1, v2, v3];
for (id obj in array)
{
    [obj myMethod];
}

編譯依然可以通過(guò),但是顯然,程序?qū)⒃谶\(yùn)行時(shí)崩潰。Objective-C 是不安全的,編譯器默認(rèn)你知道某個(gè)方法確實(shí)有實(shí)現(xiàn),這是消息發(fā)送的靈活性所必須付出的代價(jià)。而在 app 開(kāi)發(fā)看來(lái),用可能的崩潰來(lái)?yè)Q取靈活性,顯然這個(gè)代價(jià)太大了。雖然這不是OOP 范式的問(wèn)題,但它確實(shí)在 Objective-C 時(shí)代給我們帶來(lái)了切膚之痛。

Runtime error: unrecognized selector sent to instance blabla

與之相對(duì),對(duì)于沒(méi)有實(shí)現(xiàn) Protocl 提供的屬性和方法的對(duì)象,編譯器將進(jìn)行錯(cuò)誤提示,因此更加安全。

Type 'Teacher' does not conform to protocol 'PersonProtocl' Do you want to add protocol stubs?

d、POP 解決菱形缺陷

繼承中存在的一個(gè)重要問(wèn)題是菱形缺陷,也就是子類無(wú)法確定使用哪個(gè)父類的方法。在協(xié)議的對(duì)應(yīng)方面,這個(gè)問(wèn)題依然存在,因?yàn)槎鄠€(gè)協(xié)議可能存在相同的協(xié)議屬性、協(xié)議方法,遵循者也是無(wú)法確定使用的是哪個(gè)協(xié)議中的方法,所以我們?cè)陂_(kāi)發(fā)中一定要盡量規(guī)避多個(gè)協(xié)議中的同名問(wèn)題。

protocol AnimalProtocl
{
    // 協(xié)議屬性
    var name: String {get}
    
    // 協(xié)議方法
    func sayHello()
    
    func canNotThink()
}

遵守協(xié)議

struct Teacher: PersonProtocl, AnimalProtocl
{
    var name: String

    func sayHello()
    {
        print("同學(xué)們好,請(qǐng)把周末的作業(yè)交上來(lái)")
    }
    
    func canNotThink()
    {
        print("動(dòng)物無(wú)法思考,僅僅憑借生存本能行動(dòng)")
    }
}

進(jìn)行調(diào)用

func solveProblem()
{
    let teacher = Teacher(name: "蔣紅")
    let student = Student(name: "謝佳培")
    teacher.sayHello()
    student.sayHello()
    teacher.canNotThink()
}

輸出結(jié)果

同學(xué)們好,請(qǐng)把周末的作業(yè)交上來(lái)
老師你好,我作業(yè)放在家里忘帶了
動(dòng)物無(wú)法思考,僅僅憑借生存本能行動(dòng)

如果我們?yōu)槠渲械哪硞€(gè)協(xié)議進(jìn)行了擴(kuò)展,在其中提供了默認(rèn)的 name 實(shí)現(xiàn),這樣的編譯是可以通過(guò)的,雖然 Teacher 中沒(méi)有定義 name,但是通過(guò) AnimalProtoclname,Teacher 依然可以遵守 PersonProtocl。

extension AnimalProtocl
{
    var name: String { return "another default name" }
}

struct Teacher: PersonProtocl, AnimalProtocl
{
    // let name: String 
}

不過(guò),當(dāng) PersonProtoclAnimalProtocl 都有 name 的協(xié)議擴(kuò)展的話,就無(wú)法編譯了。這種情況下,Teacher 無(wú)法確定要使用哪個(gè)協(xié)議擴(kuò)展中 name 的定義。在同時(shí)實(shí)現(xiàn)兩個(gè)含有同名元素的協(xié)議,并且它們都提供了默認(rèn)擴(kuò)展時(shí),我們需要在具體的類型中明確地提供實(shí)現(xiàn)。這里我們將 Teacher 中的 name 進(jìn)行實(shí)現(xiàn)就可以了。

extension PersonProtocl
{
    var name: String { return "default name" }
}

extension AnimalProtocl
{
    var name: String { return "another default name" }
}

struct Teacher: PersonProtocl, AnimalProtocl
{
    let name: String 
}

let teacher = Teacher(name: "蔣紅")

2、使用POP進(jìn)行網(wǎng)絡(luò)請(qǐng)求

a、直接在ViewController (代表應(yīng)用層) 進(jìn)行網(wǎng)絡(luò)請(qǐng)求
  • 應(yīng)用層與網(wǎng)絡(luò)層耦合在一起,但應(yīng)用層其實(shí)根本不應(yīng)該關(guān)心網(wǎng)絡(luò)請(qǐng)求的方法、接口、參數(shù)
  • 到處嵌套,可復(fù)用性特別低
class StudentAndTeacher: UIViewController
{
    AF.request("http://127.0.0.1:5000/pythonJson/")
        .validate(statusCode: 200..<300)
        .validate(contentType: ["application/json"])
        .responseData
        { response in
            switch response.result
            {
            case .success:
                print(response)
                //let _ = LoginClient.json(data: response.data)
            case .failure(let error):
                print(error)
            }
        }
}

b、提供信息能力者
? 通過(guò)面向協(xié)議的方式給 PersonRequest 賦予網(wǎng)絡(luò)請(qǐng)求的能力(能夠提供網(wǎng)絡(luò)請(qǐng)求需要的各種屬性)
// 請(qǐng)求協(xié)議
protocol Requestable
{
    // 請(qǐng)求路徑
    var path: String { get }
    // 請(qǐng)求方法
    var method: HTTPMethod { get }
    // 請(qǐng)求參數(shù)
    var parameter: [String: Any] { get }
    
    // 遵守解碼協(xié)議的關(guān)聯(lián)類型
    // 通過(guò)在 Requestable 協(xié)議中添加一個(gè)關(guān)聯(lián)類型,我們可以將回調(diào)參數(shù)進(jìn)行抽象
    associatedtype Response: DecodableProtocol
}
? 遵守請(qǐng)求協(xié)議
struct PersonRequest: Requestable
{
    // 相應(yīng)地添加類型定義,以滿足協(xié)議,默認(rèn)使用的數(shù)據(jù)模型是 Person
    typealias Response = Person
    
    // 未定義初始值的 name 屬性
    let name: String
    // 將 host 和 path 拼接起來(lái)可以得到我們需要請(qǐng)求的 API 地址
    var path: String
    {
        return "/users/\(name)"
    }
    // 在我們的例子中只會(huì)使用到 GET 請(qǐng)求
    let method: HTTPMethod = .GET
    // 因?yàn)檎?qǐng)求的參數(shù)用戶名 name 會(huì)通過(guò) URL 進(jìn)行傳遞,所以 parameter 是一個(gè)空字典就足夠了
    let parameter: [String: Any] = [:]
}

c、網(wǎng)絡(luò)請(qǐng)求能力者
? HTTPMethod 提供本模塊 PersonRequest 需要的請(qǐng)求方法枚舉
enum HTTPMethod: String
{
    case GET
    case POST
}
? 客戶端協(xié)議:提供基地址屬性和發(fā)送請(qǐng)求方法
  • T是遵守請(qǐng)求協(xié)議的范型,request是請(qǐng)求,handler是請(qǐng)求完成后的回調(diào)閉包,Response是遵守解碼協(xié)議的關(guān)聯(lián)類型
  • 定義了可逃逸的閉包 (T.Response?) -> Void。在請(qǐng)求完成后,我們調(diào)用這個(gè) handler 方法來(lái)通知調(diào)用者請(qǐng)求是否完成,如果一切正常,則將一個(gè)數(shù)據(jù)模型 Person 實(shí)例傳回,否則傳回 nil
  • 我們想要發(fā)送請(qǐng)求的 send 方法對(duì)于所有的 Request 都通用,所以顯然回調(diào)的參數(shù)類型不能是數(shù)據(jù)模型 Person
  • 因?yàn)?Requestable 是含有關(guān)聯(lián)類型的協(xié)議,所以它并不能作為獨(dú)立的類型來(lái)使用,我們只能夠?qū)⑺鳛轭愋图s束,來(lái)限制輸入?yún)?shù) request
  • 除了使用 <T: Request> 這個(gè)泛型方式以外,我們還將 hostRequestable 移動(dòng)到了 Client 里,這是更適合它的地方
protocol ClientProtocol
{
    // 基地址屬性
    var host: String { get }
    
    // 發(fā)送請(qǐng)求方法
    func send<T: Requestable>(_ request: T, handler: @escaping (T.Response?) -> Void)
}
? 客戶端遵守客戶端協(xié)議

除了 URLSessionClient 以外,我們還可以使用任意的類型來(lái)滿足這個(gè)協(xié)議,并發(fā)送請(qǐng)求。這樣網(wǎng)絡(luò)層的具體實(shí)現(xiàn)和請(qǐng)求本身就不再相關(guān)了,我們之后在測(cè)試的時(shí)候會(huì)進(jìn)一步看到這么做所帶來(lái)的好處。

class URLSessionClient: ClientProtocol
{
    // 創(chuàng)建客戶端管理者
    static let manager = URLSessionClient()
    
    // 給基地址賦值
    let host: String = "http://127.0.0.1:5000"
    
    // 實(shí)現(xiàn)發(fā)送請(qǐng)求方法
    func send<T>(_ request: T, handler: @escaping (T.Response?) -> Void) where T : Requestable
    {
        ...
    }
}
? 實(shí)現(xiàn)發(fā)送請(qǐng)求方法
func send<T>(_ request: T, handler: @escaping (T.Response?) -> Void) where T : Requestable
{
    // 請(qǐng)求地址 = 基地址 + request的傳入路徑
    let url = URL(string: host.appending(request.path))!
    
    // 根據(jù)url創(chuàng)建URLRequest
    var urlRequest = URLRequest(url: url)
   
    // 設(shè)置請(qǐng)求方法
    urlRequest.httpMethod = request.method.rawValue
    
    // 根據(jù)request創(chuàng)建dataTask并將請(qǐng)求發(fā)送
    let task = URLSession.shared.dataTask(with: urlRequest)
    // 使用 Response 中的 parse 方法將回調(diào)中的 data 轉(zhuǎn)換為合適的對(duì)象類型,并調(diào)用 handler 通知外部調(diào)用者
    { (data, response, error) in
        // 調(diào)用Response里面的解碼方法將請(qǐng)求到的數(shù)據(jù)解碼成model后從主線程傳遞出去
        if let data = data, let model = T.Response.parse(data: data)
        {
            DispatchQueue.main.async { handler(model) }
        }
        else
        {
            DispatchQueue.main.async { handler(nil) }
        }
    }
    task.resume()
}

d、序列化能力者
? 解碼協(xié)議提供解碼方法

請(qǐng)求不應(yīng)該也不需要知道如何解析得到的數(shù)據(jù),這項(xiàng)工作應(yīng)該交給 Response 來(lái)做,而現(xiàn)在我們沒(méi)有對(duì) Response 進(jìn)行任何限定。接下來(lái)我們將新增一個(gè)協(xié)議,滿足這個(gè)協(xié)議的類型將知道如何將一個(gè) data 轉(zhuǎn)換為實(shí)際的數(shù)據(jù)類型。

對(duì)于 Person 我們知道可以使用 Person.init(data:)json數(shù)據(jù)進(jìn)行轉(zhuǎn)化成數(shù)據(jù)模型,但是對(duì)于一般的 Response,我們還不知道要如何將數(shù)據(jù)轉(zhuǎn)為模型。DecodableProtocol要求滿足該協(xié)議的具體類型提供parse(data:) 方法合適的實(shí)現(xiàn),這樣提供轉(zhuǎn)換方法的任務(wù)就被“下放”到了各數(shù)據(jù)模型中。

protocol DecodableProtocol
{
    static func parse(data: Data) -> Self?
}

DecodableProtocol 定義了一個(gè)靜態(tài)的 parse 方法,接下去我們需要在 RequestableResponse 關(guān)聯(lián)類型中為它加上這個(gè)限制,這樣我們可以保證所有的 Response 都可以對(duì)數(shù)據(jù)進(jìn)行解析。

protocol Requestable
{
    var path: String { get }
    var method: HTTPMethod { get }
    var parameter: [String: Any] { get }

    associatedtype Response: DecodableProtocol
}
? 遵守解碼協(xié)議實(shí)現(xiàn)解碼方法
extension Person: DecodableProtocol
{
    static func parse(data: Data) -> Person?
    {
        // 傳入data獲取到Person,調(diào)用Person的初始化方法
        return Person(data: data)
    }
}
? 數(shù)據(jù)模型類Person
struct Person
{
    // 屬性
    let name: String
    let age: String
    let hobby: String
    let petPhrase: String

    // 初始化方法
    init?(data: Data)
    {
        // [String: Any] 表示是字典類型
        guard let person = try? JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] else { return nil }
        
        // 獲取person中的數(shù)據(jù)
        guard let name = person["name"] as? String else { return nil }
        guard let age = person["age"] as? String else { return nil }
        guard let hobby = person["hobby"] as? String else { return nil }
        guard let petPhrase = person["petPhrase"] as? String else { return nil }

        // 給Person結(jié)構(gòu)體的屬性賦值
        self.name = name
        self.age = age
        self.hobby = hobby
        self.petPhrase = petPhrase
    }
}

e、外界調(diào)用

當(dāng)然,你也可以為 URLSessionClient 添加一個(gè)單例來(lái)減少請(qǐng)求時(shí)的創(chuàng)建開(kāi)銷,或者為請(qǐng)求添加 Promise 的調(diào)用方式等等。在 POP 的組織下,這些改動(dòng)都很自然,也不會(huì)牽扯到請(qǐng)求的其他部分。你可以用和 UserRequest 類型相似的方式,為網(wǎng)絡(luò)層添加其他的 API 請(qǐng)求,只需要定義請(qǐng)求所必要的內(nèi)容,而不用擔(dān)心會(huì)觸及網(wǎng)絡(luò)方面的具體實(shí)現(xiàn)。

// 根據(jù)傳入的name創(chuàng)建request
let request = PersonRequest(name: "Xiejiapei")

// 客戶端發(fā)送request
URLSessionClient().send(request)
{ [weak self](person) in
    // 根據(jù)服務(wù)端返回的數(shù)據(jù)更新UI
    if let person = person
    {
        // 更新UI
        print("\(person.hobby) from \(person.name)")
        self?.updataUI(person: person)
    }
}

f、進(jìn)行模塊劃分而不是全部堆砌在Request協(xié)議中的原因

倘若不進(jìn)行功能模塊劃分,而是將全部功能都放在Request協(xié)議中,就會(huì)變成下面這樣。這里最大的問(wèn)題在于,Request 協(xié)議管理了太多的東西。一個(gè) Request 協(xié)議應(yīng)該做的事情應(yīng)該僅僅是定義請(qǐng)求入口和期望的響應(yīng)類型,而現(xiàn)在 Request 協(xié)議不光定義了 host 的值,還對(duì)如何解析數(shù)據(jù)了如指掌。最后 send 方法被綁死在了 URLSession 的實(shí)現(xiàn)上,而且是作為 Request 協(xié)議的一部分存在,這是很不合理的,因?yàn)檫@意味著我們無(wú)法在不更改請(qǐng)求的情況下更新發(fā)送請(qǐng)求的方式,它們被耦合在了一起。這樣的結(jié)構(gòu)讓測(cè)試變得異常困難,我們可能需要通過(guò) stubmock 的方式對(duì)請(qǐng)求攔截,然后返回構(gòu)造的數(shù)據(jù),這會(huì)用到 NSURLProtocol 的內(nèi)容,或者是引入一些第三方的測(cè)試框架,大大增加了項(xiàng)目的復(fù)雜度。

protocol Request
{
    var host: String { get }
    var path: String { get }
    
    var method: HTTPMethod { get }
    var parameter: [String: Any] { get }
    
    associatedtype Response
    func parse(data: Data) -> Response?
}

extension Request
{
    func send(handler: @escaping (Response?) -> Void)
    {
        ...
    }
}

g、網(wǎng)絡(luò)層測(cè)試

Client 聲明為協(xié)議給我們帶來(lái)了額外的好處,那就是我們不再局限于使用某種特定的技術(shù) (比如這里的 URLSession) 來(lái)實(shí)現(xiàn)網(wǎng)絡(luò)請(qǐng)求。利用 POP,你只是定義了一個(gè)發(fā)送請(qǐng)求的協(xié)議,你可以很容易地使用像是 AFNetworking 或者 Alamofire 這樣的成熟的第三方框架來(lái)構(gòu)建具體的數(shù)據(jù)并處理請(qǐng)求的底層實(shí)現(xiàn)。我們甚至可以提供一組“虛假”的對(duì)請(qǐng)求的響應(yīng),用來(lái)進(jìn)行測(cè)試。這和傳統(tǒng)的 stub & mock 的方式在概念上是接近的,但是實(shí)現(xiàn)起來(lái)要簡(jiǎn)單得多,也明確得多。我們現(xiàn)在來(lái)看一看具體應(yīng)該怎么做。

我們先準(zhǔn)備一個(gè)文本文件,將它添加到項(xiàng)目的測(cè)試 target 中,作為網(wǎng)絡(luò)請(qǐng)求返回的內(nèi)容

// 文件名:usersXiejiapei
{"name":"姓名:謝佳培", "age": "年齡:23", "hobby": "愛(ài)好:讀書", "petPhrase": "格言:求知若渴,虛心若愚"}

接下來(lái),可以創(chuàng)建一個(gè)新的類型,讓它滿足 ClientProtocol 協(xié)議。但是與 URLSessionClient 不同,這個(gè)新類型的 send 方法并不會(huì)實(shí)際去創(chuàng)建請(qǐng)求,并發(fā)送給服務(wù)器。我們?cè)跍y(cè)試時(shí)需要驗(yàn)證的是一個(gè)請(qǐng)求發(fā)出后如果服務(wù)器正確響應(yīng),那么我們應(yīng)該也可以得到正確的模型實(shí)例。所以這個(gè)新的 LocalFileClient 需要做的事情就是從本地文件中加載定義好的結(jié)果,然后驗(yàn)證模型實(shí)例是否正確。

struct LocalFileClient: ClientProtocol
{
    // 為了滿足 ClientProtocol 的要求,實(shí)際上我們不會(huì)發(fā)送請(qǐng)求
    let host = ""
    
    func send<T : Requestable>(_ request: T, handler: @escaping (T.Response?) -> Void)
    {
        switch request.path
        {
        case "/users/xiejiapei":
            // 獲取fileURL
            guard let fileURL = Bundle.main.url(forResource: "usersXiejiapei", withExtension: "")  else { fatalError() }
            // 根據(jù)fileURL獲取data
            guard let data = try? Data(contentsOf: fileURL) else { fatalError() }
            // 將data傳遞出去
            handler(T.Response.parse(data: data))
        default:
            fatalError("Unknown path")
        }
    }
}

LocalFileClient 做的事情很簡(jiǎn)單,它先檢查輸入請(qǐng)求的 path 屬性,如果是 /users/Xiejiapei (也就是我們需要測(cè)試的請(qǐng)求),那么就從測(cè)試的 bundle 中讀取預(yù)先定義的文件,將其作為返回結(jié)果進(jìn)行 parse,然后調(diào)用 handler。如果我們需要增加其他請(qǐng)求的測(cè)試,可以添加新的 case 項(xiàng)。

LocalFileClient 的幫助下,現(xiàn)在可以很容易地對(duì) UserRequest 進(jìn)行測(cè)試了。通過(guò)這種方法,我們沒(méi)有依賴任何第三方測(cè)試庫(kù),也沒(méi)有使用 url 代理或者運(yùn)行時(shí)消息轉(zhuǎn)發(fā)等等這些復(fù)雜的技術(shù),就可以進(jìn)行請(qǐng)求測(cè)試了。保持簡(jiǎn)單的代碼和邏輯,對(duì)于項(xiàng)目維護(hù)和發(fā)展是至關(guān)重要的。

let client = LocalFileClient()
client.send(PersonRequest(name: "xiejiapei"))
{ [weak self](person) in
    if let person = person
    {
        print("\(person.hobby) from \(person.name)")
        self?.updataUI(person: person)
    }
}

3、總結(jié)與解惑

a、總結(jié)

因?yàn)楦叨冉怦?,這種基于 POP 的實(shí)現(xiàn)為代碼的擴(kuò)展提供了相對(duì)寬松的可能性。我們剛才已經(jīng)說(shuō)過(guò),你不必自行去實(shí)現(xiàn)一個(gè)完整的 Client,而可以依賴于現(xiàn)有的網(wǎng)絡(luò)請(qǐng)求框架,實(shí)現(xiàn)請(qǐng)求發(fā)送的方法即可。也就是說(shuō),你也可以很容易地將某個(gè)正在使用的請(qǐng)求方式替換為另外的方式,而不會(huì)影響到請(qǐng)求的定義和使用。類似地,在 Response 的處理上,現(xiàn)在我們定義了 Decodable,用自己手寫的方式在解析模型。我們完全也可以使用任意的第三方 JSON 解析庫(kù),來(lái)幫助我們迅速構(gòu)建模型類型,這僅僅只需要實(shí)現(xiàn)一個(gè)將 Data 轉(zhuǎn)換為對(duì)應(yīng)模型類型的方法即可。

通過(guò)面向協(xié)議的編程,我們可以從傳統(tǒng)的繼承上解放出來(lái),用一種更靈活的方式,搭積木一樣對(duì)程序進(jìn)行組裝。每個(gè)協(xié)議專注于自己的功能,特別得益于協(xié)議擴(kuò)展,我們可以減少類和繼承帶來(lái)的共享狀態(tài)的風(fēng)險(xiǎn),讓代碼更加清晰。高度的協(xié)議化有助于解耦、測(cè)試以及擴(kuò)展,而結(jié)合泛型來(lái)使用協(xié)議,更可以讓我們免于動(dòng)態(tài)調(diào)用和類型轉(zhuǎn)換的苦惱,保證了代碼的安全性。


b、解惑
? 范例都是直接先寫 protocol,而不是 struct 或者 class,是不是我們?cè)趯?shí)踐 POP 的時(shí)候都應(yīng)該直接先定義協(xié)議?

我直接寫 protocol 是因?yàn)槲乙呀?jīng)對(duì)我要做什么有充分的了解。但是實(shí)際開(kāi)發(fā)的時(shí)候你可能會(huì)無(wú)法一開(kāi)始就寫出合適的協(xié)議定義。建議可以像我在 demo 中做的那樣,先“粗略”地進(jìn)行定義,然后通過(guò)不斷重構(gòu)來(lái)得到一個(gè)最終的版本。當(dāng)然,你也可以先用紙筆勾勒一個(gè)輪廓,然后再去定義和實(shí)現(xiàn)協(xié)議。當(dāng)然了,也沒(méi)人規(guī)定一定需要先定義協(xié)議,你完全也可以從普通類型開(kāi)始寫起,然后等發(fā)現(xiàn)共通點(diǎn)或者遇到我們之前提到的困境時(shí),再回頭看看是不是面向協(xié)議更加合適,這需要一定的 POP 經(jīng)驗(yàn)。

? 既然 POP 有這么多好處,那我們是不是不再需要面向?qū)ο?,可以全面轉(zhuǎn)向面向協(xié)議了?

答案可能讓你失望。在我們的日常項(xiàng)目中,每天打交道的 Cocoa 其實(shí)還是一個(gè)帶有濃厚 OOP 色彩的框架。也就是說(shuō),可能一段時(shí)期內(nèi)我們不可能拋棄 OOP。不過(guò) POP 其實(shí)可以和 OOP “和諧共處”,我們也已經(jīng)看到了不少使用 POP 改善代碼設(shè)計(jì)的例子。另外需要補(bǔ)充的是,POP 其實(shí)也并不是銀彈,它有不好的一面。最大的問(wèn)題是協(xié)議會(huì)增加代碼的抽象層級(jí) (這點(diǎn)上和類繼承是一樣的),特別是當(dāng)你的協(xié)議又繼承了其他協(xié)議的時(shí)候,這個(gè)問(wèn)題尤為嚴(yán)重。在經(jīng)過(guò)若干層的繼承后,滿足末端的協(xié)議會(huì)變得困難,你也難以確定某個(gè)方法究竟?jié)M足的是哪個(gè)協(xié)議的要求。這會(huì)讓代碼迅速變得復(fù)雜。如果一個(gè)協(xié)議并沒(méi)有能描述很多共通點(diǎn),或者說(shuō)能讓人很快理解的話,可能使用基本的類型還會(huì)更簡(jiǎn)單一些。

? 想問(wèn)一下你們?cè)陧?xiàng)目中使用 POP 的情況

我們?cè)陧?xiàng)目里用了很多 POP 的概念。上面 demo 里的網(wǎng)絡(luò)請(qǐng)求的例子就是從實(shí)際項(xiàng)目中抽出來(lái)的,我們覺(jué)得這樣的請(qǐng)求寫起來(lái)非常輕松,因?yàn)榇a很簡(jiǎn)單,新人進(jìn)來(lái)交接也十分愜意。除了模型層之外,我們?cè)?viewview controller 層也用了一些 POP 的代碼,比如支持分頁(yè)請(qǐng)求 tableview controllerNextPageLoadable,空列表時(shí)顯示頁(yè)面的 EmptyPage 等等。因?yàn)闀r(shí)間有限,不可能展開(kāi)一一說(shuō)明,所以這里我只挑選了一個(gè)具有代表性,又不是很復(fù)雜的網(wǎng)絡(luò)的例子。其實(shí)每個(gè)協(xié)議都讓我們的代碼,特別是 View Controller 變短,而且使測(cè)試變?yōu)榭赡?。可以說(shuō),我們的項(xiàng)目從 POP 受益良多,而且我們應(yīng)該會(huì)繼續(xù)使用下去。


二、初識(shí)Moya

1、Moya的簡(jiǎn)介

我們知道在 iOS 開(kāi)發(fā)中,可以使用 URLSession 進(jìn)行網(wǎng)絡(luò)請(qǐng)求。但為了方便起見(jiàn),我通常會(huì)選擇使用 Alamofire 這樣的第三方庫(kù)。這些庫(kù)本質(zhì)上也是基于 URLSession 的,但其封裝了許多細(xì)節(jié),可以讓我們網(wǎng)絡(luò)請(qǐng)求相關(guān)代碼(如獲取數(shù)據(jù),提交數(shù)據(jù),上傳文件,下載文件等)更加簡(jiǎn)潔易用。Moya 又是一個(gè)基于 Alamofire 的更高層網(wǎng)絡(luò)請(qǐng)求封裝抽象層。Moya 也就可以看做我們的網(wǎng)絡(luò)管理層,用來(lái)封裝 URL、參數(shù)等請(qǐng)求所需要的一些基本信息。使用后我們的客戶端代碼會(huì)直接操作 Moya,然后 Moya 去管理請(qǐng)求,而不用跟 Alamofire 進(jìn)行直接接觸。

在我們項(xiàng)目的 Service、View、或者 Model 文件中可能都會(huì)出現(xiàn)請(qǐng)求網(wǎng)絡(luò)數(shù)據(jù)的情況,如果直接使用 Alamofire,不僅很繁瑣,而且還會(huì)使代碼變得很混亂。過(guò)去我們通常的做法是在項(xiàng)目中添加一個(gè)網(wǎng)絡(luò)請(qǐng)求層ViewModel用來(lái)管理網(wǎng)絡(luò)請(qǐng)求,但這樣做可能會(huì)遇到一些問(wèn)題。


2、豆瓣范例

a、創(chuàng)建provider:如果要發(fā)起網(wǎng)絡(luò)請(qǐng)求就使用這個(gè) provider
// 初始化豆瓣FM請(qǐng)求的provider
let DouBanProvider = MoyaProvider<DouBan>()
b、請(qǐng)求類型
public enum DouBan
{
    case channels //獲取頻道列表
    case playlist(String) //獲取歌曲信息
}

c、配置請(qǐng)求信息
extension DouBan: TargetType
{
    ...
}
? 服務(wù)器地址
public var baseURL: URL
{
    switch self
    {
    case .channels:
        return URL(string: "https://www.douban.com")!
    case .playlist(_):
        return URL(string: "https://douban.fm")!
    }
}
? 各個(gè)請(qǐng)求的具體路徑
public var path: String
{
    switch self
    {
    case .channels:
        return "/j/app/radio/channels"
    case .playlist(_):
        return "/j/mine/playlist"
    }
}
? 請(qǐng)求方法類型
public var method: Moya.Method
{
    return .get
}
? 請(qǐng)求任務(wù)事件(這里附帶上參數(shù))
public var task: Task
{
    switch self
    {
    case .playlist(let channel):
        var params: [String: Any] = [:]
        params["channel"] = channel
        params["type"] = "n"
        params["from"] = "mainsite"
        return .requestParameters(parameters: params, encoding: URLEncoding.default)
    default:
        return .requestPlain
    }
}
? 是否執(zhí)行Alamofire驗(yàn)證
public var validate: Bool
{
    return false
}
? 下面這個(gè)是做單元測(cè)試模擬的數(shù)據(jù),只會(huì)在單元測(cè)試文件中有作用
public var sampleData: Data
{
    return "{}".data(using: String.Encoding.utf8)!
}
? 設(shè)置請(qǐng)求頭
public var headers: [String: String]?
{
    return nil
}

d、使用我們的provider進(jìn)行網(wǎng)絡(luò)請(qǐng)求(獲取頻道列表數(shù)據(jù))
// 頻道列表數(shù)據(jù)
var channels:Array<JSON> = []

DouBanProvider.request(.channels)
{ result in
    if case let .success(response) = result
    {
        // 解析數(shù)據(jù)
        let data = try? response.mapJSON()
        let json = JSON(data!)
        self.channels = json["channels"].arrayValue
         
        // 刷新表格數(shù)據(jù)
        DispatchQueue.main.async
        {
            self.tableView.reloadData()
        }
    }
}

e、使用我們的provider進(jìn)行網(wǎng)絡(luò)請(qǐng)求(根據(jù)頻道ID獲取下面的歌曲)
DouBanProvider.request(.playlist(channelId))
{ result in
    if case let .success(response) = result
    {
        // 解析數(shù)據(jù),獲取歌曲信息
        let data = try? response.mapJSON()
        let json = JSON(data!)
        let music = json["song"].arrayValue[0]
        let artist = music["artist"].stringValue
        let title = music["title"].stringValue
        let message = "歌手:\(artist)\n歌曲:\(title)"
         
        // 將歌曲信息彈出顯示
        self.showAlert(title: channelName, message: message)
    }
}

3、訂單范例

a、準(zhǔn)備工作
  • 請(qǐng)求地址:http://127.0.0.1:8080
  • 公共請(qǐng)求頭:devtype:iOS,devid
  • 公共請(qǐng)求參數(shù):token:"Gz1qYLXeBW8MZuUfDlr9wsAYuVS1cZFMJY9BbaF842L2gRps747o4w=="
API 參數(shù) 說(shuō)明
order/list pageNO:訂單列表開(kāi)始頁(yè)碼,默認(rèn)從1開(kāi)始。 pageSize:每頁(yè)記錄數(shù) 訂單列表
order/findById sn:訂單id 根據(jù)id查詢訂單

b、配置請(qǐng)求信息
? 生成請(qǐng)求封裝類
let orderProvider = MoyaProvider<OrderApi>()
? 訂單相關(guān)api
enum OrderApi 
{
    case list(pageNO: Int = 1, pageSize: Int = 10) //很好的利用了枚舉綁定值這個(gè)特性
    case findOne(sn: String)
}

c、實(shí)現(xiàn)TargetType協(xié)議
extension OrderApi: TargetType
{
    ...
}
? baseURL
var baseURL: URL
{
    return URL(string: "http://127.0.0.1:8080/order")!
}
? 請(qǐng)求路徑
var path: String
{
    switch self
    {
    case .list:
        return "list"
    case .findOne(_):
        return "findById"
    }
}
? 請(qǐng)求方式
var method: Moya.Method
{
    return .post
}
? 解析格式
var sampleData: Data
{
    return "{}".data(using: String.Encoding.utf8)!
}
? 創(chuàng)建請(qǐng)求任務(wù)
var task: Task
{
    // 公共參數(shù)
    var params: [String: Any] = ["token": "Gz1qYLXeBW8MZuUfDlr9wsAYuVS1cZFMJY9BbaF842L2gRps747o4w=="]
    
    // 收集參數(shù)
    switch self
    {
    case let .list(pageNO, pageSize):
        params["pageNO"] = pageNO
        params["pageSize"] = pageSize
    case .findOne(let sn):
        params["sn"] = sn
    }
    
    // 發(fā)起請(qǐng)求
    return .requestParameters(parameters: params, encoding: URLEncoding.default)
}
? 公共請(qǐng)求頭
var headers: [String : String]?
{
    return ["devtype": "iOS", "devid": UIDevice().identifierForVendor?.uuidString ?? "unknow"]
}

d、調(diào)用發(fā)送請(qǐng)求
orderProvider.request(OrderApi.findOne(sn: "DJKRE3248DFHJEW23"))
{ (result) in
    print(result)
}

4、登錄范例

a、LoginAPI
類型
public enum LoginAPI
{
    case login(String, String, String)  // 登錄接口
    case smscode(String)                // 登錄,發(fā)送驗(yàn)證碼
    case otherRequest                   // 其他接口,沒(méi)有參數(shù)
}
服務(wù)器地址
public var baseURL: URL
{
    return URL(string:"http://127.0.0.1:5000/")!
}
各個(gè)請(qǐng)求的具體路徑
public var path: String
{
    switch self
    {
    case .login:
        return "login/"
    case .smscode:
        return "login/smscode/"
    case .otherRequest:
        return "login/otherRequest/"
    }
}
請(qǐng)求方式
public var method: Moya.Method
{
    switch self
    {
    case .login:
        return .post
    case .smscode:
        return .post
    default:
        return .get
    }
}
請(qǐng)求任務(wù)事件(這里附帶上參數(shù))
public var task: Task
{
    var param:[String:Any] = [:]

    switch self
    {
    case .login(let username,let password,let smscode):
        param["username"] = username
        param["password"] = password
        param["smscode"] = smscode
    case .smscode(let username):
        param["username"] = username
    default:
        return .requestPlain
    }
    return .requestParameters(parameters: param, encoding: URLEncoding.default)
}
設(shè)置請(qǐng)求頭
public var headers: [String: String]?
{
    return nil
}

b、LoginClient
static let manager = LoginClient()
發(fā)送驗(yàn)證碼
func smscode(username:String,complete:@escaping ((String) -> Void))
{
    let provide = MoyaProvider<LoginAPI>()
    provide.request(.smscode(username))
    { (result) in
        switch result
        {
        case let .success(response):
            let dict = LoginClient.myJson(data: response.data)
            complete(dict["smscode"] as! String)
        case let .failure(error):
            print(error)
            complete("")
        }
    }
}
進(jìn)行登錄
func login(username:String,password:String,smscode:String)
{
    let provide = MoyaProvider<LoginAPI>()
    provide.request(.login(username, password, smscode))
    { (result) in
        switch result
        {
        case let .success(response):
            let _ = LoginClient.myJson(data: response.data)
        case let .failure(error):
            print(error)
        }
    }
}
其他事件 - 比如注冊(cè)
func otherRequest()
{
    let provide = MoyaProvider<LoginAPI>()
    provide.request(.otherRequest)
    { (result) in
        switch result
        {
        case let .success(response):
            let _ = LoginClient.myJson(data: response.data)
        case let .failure(error):
            print(error)
        }
    }
}
序列化
static func myJson(data:Data?)->([String: Any])
{
     guard let data = data else
     {
         print("data 為空")
         return [:]
     }
     do
     {
         let dict = try JSONSerialization.jsonObject(with: data, options: .allowFragments)
         print("序列化字典: \(dict)")
         return dict as! ([String : Any])
     }
     catch
     {
         print("序列化失敗")
         return [:]
     }
 }

c、點(diǎn)擊登錄或者注冊(cè)
點(diǎn)擊發(fā)送驗(yàn)證碼
@objc func didClickCodeButton()
{
    guard let username = usernameTF.text else
    {
        print("賬戶不可為空")
        return
    }
    
    LoginClient.manager.smscode(username: username)
    { [weak self](smscode) in
        self?.smscodeTF.text = smscode
    }
}
點(diǎn)擊登錄
@objc func didClickLoginButton()
{
    LoginClient.manager.login(username:usernameTF.text!, password: passwordTF.text!, smscode: smscodeTF.text!)
}

Demo

Demo在我的Github上,歡迎下載。
UseFrameworkDemo

參考文獻(xiàn)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
禁止轉(zhuǎn)載,如需轉(zhuǎn)載請(qǐng)通過(guò)簡(jiǎn)信或評(píng)論聯(lián)系作者。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容