swift学习专题

iOS 基于 RxSwift + Moya 搭建易测试的网络请求

2019-11-20  本文已影响0人  FicowShen

 

内容概览

 


Moya

 

/// The protocol used to define the specifications necessary for a `MoyaProvider`.
public protocol TargetType {

    /// The target's base `URL`.
    var baseURL: URL { get }

    /// The path to be appended to `baseURL` to form the full `URL`.
    var path: String { get }

    /// The HTTP method used in the request.
    var method: Moya.Method { get }

    /// Provides stub data for use in testing.
    var sampleData: Data { get }

    /// The type of HTTP task to be performed.
    var task: Task { get }

    /// The type of validation to perform on the request. Default is `.none`.
    var validationType: ValidationType { get }

    /// The headers to be used in the request.
    var headers: [String: String]? { get }
}

在遵循这个协议的类型中提供这些属性,可以很方便地定制API请求的各种参数。
而且,由于是属性,所以非常容易进行测试。

 

/// Request provider class. Requests should be made through this class only.
open class MoyaProvider<Target: TargetType>: MoyaProviderType {

    /// Closure that defines the endpoints for the provider.
    public typealias EndpointClosure = (Target) -> Endpoint

    /// Closure that decides if and what request should be performed.
    public typealias RequestResultClosure = (Result<URLRequest, MoyaError>) -> Void

    /// Closure that resolves an `Endpoint` into a `RequestResult`.
    public typealias RequestClosure = (Endpoint, @escaping RequestResultClosure) -> Void

    /// Closure that decides if/how a request should be stubbed.
    public typealias StubClosure = (Target) -> Moya.StubBehavior

    /// A closure responsible for mapping a `TargetType` to an `EndPoint`.
    public let endpointClosure: EndpointClosure

    /// A closure deciding if and what request should be performed.
    public let requestClosure: RequestClosure

    /// A closure responsible for determining the stubbing behavior
    /// of a request for a given `TargetType`.
    public let stubClosure: StubClosure

    ...

    /// Initializes a provider.
    public init(endpointClosure: @escaping EndpointClosure = MoyaProvider.defaultEndpointMapping,
                requestClosure: @escaping RequestClosure = MoyaProvider.defaultRequestMapping,
                stubClosure: @escaping StubClosure = MoyaProvider.neverStub,
                callbackQueue: DispatchQueue? = nil,
                manager: Manager = MoyaProvider<Target>.defaultAlamofireManager(),
                plugins: [PluginType] = [],
                trackInflights: Bool = false) {

        self.endpointClosure = endpointClosure
        self.requestClosure = requestClosure
        self.stubClosure = stubClosure
        self.manager = manager
        self.plugins = plugins
        self.trackInflights = trackInflights
        self.callbackQueue = callbackQueue
    }

    ...
}

实际的请求由MoyaProvider发起,而实例化一个MoyaProvider需要提供一个泛型参数Target
所以MoyaProvider需要相应的TargetType,然后才能完成请求。

在进行测试的时候,我们可以使用这些Closure去深度定制MoyaProvider实例以实现我们想要的效果。

 


RxSwift

基于RxSwift去实现网络请求层的优点:

 

初次接触 RxSwift 的朋友可以简单了解一下:
Learn & Master ⚔️ the Basics of RxSwift in 10 Minutes

 


实例讲解

 

Demo 地址:MoyaWithRxSwiftDemo

 

/// 定义首页模块需要用到的API信息
struct HomeAPI {
    let baseURL: URL
    let endpoint: HomeAPIEndpoint
}

/// 多个API请求
enum HomeAPIEndpoint {
    // 如果需要传递参数,可以使用枚举的关联值,比如:case basicInfo(userID: String)
    case basicInfo 
    case hobbies
}

/// 遵循 TargetType 协议,并定制请求参数
extension HomeAPI: TargetType {

    var path: String {
        switch endpoint {
        case .basicInfo:
            return "basic_info.json"
        case .hobbies:
            return "hobbies.json"
        }
    }

    var method: Moya.Method {
        // 如果有必要,可以根据 endpoint 来改变
        return .get
    }

    /// 每个请求需要用到的参数
    var task: Task {
        // 如果endpoint中的枚举有关联值,可以使用 switch 中的 case let 取出关联值
        return .requestParameters(parameters: [:], encoding: URLEncoding.queryString)
    }

    var headers: [String : String]? { nil }

    /// 不建议将测试数据放到App所在的Target
    var sampleData: Data { Data() }

    /// 测试时,如果需要测试状态码,就需要重写这个属性。
    /// TargetType 协议中的默认实现是返回 .none,也就是不校验 statusCode
    var validationType: ValidationType { .successAndRedirectCodes }
}

 

final class HomeNetworkHelper {

    private let baseURL: URL
    private let moyaProvider: MoyaProvider<HomeAPI>

    // moyaProvider 采用依赖注入的方式进行初始化,便于测试
    init(baseURL: URL,
         moyaProvider: MoyaProvider<HomeAPI> = MoyaProvider<HomeAPI>()) {
        self.baseURL = baseURL
        self.moyaProvider = moyaProvider
    }

    func fetchBasicInfo() -> Observable<UserBasicInfo> {
        return Observable.create { (observer) -> Disposable in
            let api = HomeAPI(baseURL: self.baseURL, endpoint: .basicInfo)
            self.requestAPI(api: api, observer: observer)
            return Disposables.create()
        }.observeOn(SerialDispatchQueueScheduler(qos: .default)) // 切换到后台线程
    }

    func fetchHobbies() -> Observable<UserHobbies> {
        return Observable.create { (observer) -> Disposable in
            let api = HomeAPI(baseURL: self.baseURL, endpoint: .hobbies)
            self.requestAPI(api: api, observer: observer)
            return Disposables.create()
        }.observeOn(SerialDispatchQueueScheduler(qos: .default))
    }

    func requestAPI<T: Decodable>(api: HomeAPI, observer: AnyObserver<T>) {
        // moyaProvider 发起网络请求
        self.moyaProvider.request(api) { (response) in
            switch response {
            case .success(let value):
                do {
                    let result = try value.map(T.self, atKeyPath: nil, using: JSONDecoder(), failsOnEmptyData: false)
                    observer.onNext(result)
                    observer.onCompleted()
                } catch {
                    observer.onError(error)
                }
            case .failure(let error):
                observer.onError(error)
            }
        }
    }
}

 

class ViewController: UIViewController {


    ...


    private lazy var networkHelper = HomeNetworkHelper(baseURL: baseURL)

    private let baseURL = URL(string: "https://raw.githubusercontent.com/FicowShen/MoyaWithRxSwiftDemo/master/MoyaWithRxSwiftDemo/json/")!
    private let disposeBag = DisposeBag()
    

    ...


    func loadFirstRow() {
        networkHelper
            .fetchBasicInfo()
            .delay(DispatchTimeInterval.milliseconds(500),
                   scheduler: MainScheduler.instance)
            .observeOn(MainScheduler.instance) // 切换到主线程。如果引入了RxCocoa,可以将Observable转换为Driver
            .subscribe(onNext: { [unowned self] (model) in
                self.models.insert(model, at: 0)
                self.myTableView.reloadData()
            }, onError: { (error) in
                logError(error)
            }).disposed(by: disposeBag)
    }

}

 

import XCTest
import Moya
import RxBlocking
@testable import MoyaWithRxSwiftDemo

class MoyaWithRxSwiftDemoTests: XCTestCase {

    func testHomeAPI() {
        guard let url = URL(string: "https://apple.com") else {
            XCTFail()
            return
        }
        var api = HomeAPI(baseURL: url, endpoint: .basicInfo)
        XCTAssertEqual(api.path, "basic_info.json")
        api = HomeAPI(baseURL: url, endpoint: .hobbies)
        XCTAssertEqual(api.path, "hobbies.json")
    }
    
    /// 测试请求成功的情况
    func testSuccessfulHomeAPIRequest() {
        // 从项目中的JSON里读取数据用来模拟请求成功后返回的model数据
        guard let url = URL(string: "https://apple.com"),
            let basicInfoData = loadDataInJSONFile(fileName: "basic_info") else {
            XCTFail()
            return
        }

        // endpointClosure 可以用来修改
        // 如果 MoyaProvider 的 stubClosure 不为空,则 MoyaProvider 不会发起真的网络请求
        let provider = MoyaProvider<HomeAPI>(endpointClosure: { self.mockEndpointForAPI(api: $0, response: .networkResponse(200, basicInfoData)) },
                                             stubClosure: { _ in .immediate })
        let apiHelper = HomeNetworkHelper(baseURL: url, moyaProvider: provider)

        // RxBlocking 提供了便捷的方法,可以阻塞住当前线程,然后收集 Observable 中的事件
        guard let basicInfo = try? apiHelper.fetchBasicInfo().toBlocking().first() else {
            XCTFail()
            return
        }
        XCTAssertEqual(basicInfo.name, "John")
        XCTAssertEqual(basicInfo.age, 10)
    }

    /// 测试发生网络故障(请求超时等情况)的情况
    func testNetworkErrorForHomeAPIRequest() {
        guard let url = URL(string: "https://apple.com") else {
            XCTFail()
            return
        }
        let expectedError = NSError(domain: "expectedError", code: -1, userInfo: nil)
        let provider = MoyaProvider<HomeAPI>(endpointClosure: { self.mockEndpointForAPI(api: $0, response: .networkError(expectedError)) },
                                             stubClosure: { _ in .immediate })
        let apiHelper = HomeNetworkHelper(baseURL: url, moyaProvider: provider)
        expectError(expectedError) {
            _ = try apiHelper.fetchBasicInfo().toBlocking().first()
        }
    }

    /// 测试请求成功后,响应为错误的情况
    func testResponseErrorForHomeAPIRequest() {
        guard let url = URL(string: "https://apple.com") else {
            XCTFail()
            return
        }
        let expectedError = NSError(domain: "", code: 404, userInfo: nil)
        let provider = MoyaProvider<HomeAPI>(endpointClosure: {
            self.mockEndpointForAPI(api: $0, response: .networkResponse(404, Data())) },
        stubClosure: { _ in .immediate })
        let apiHelper = HomeNetworkHelper(baseURL: url, moyaProvider: provider)
        expectError(expectedError) {
            _ = try apiHelper.fetchBasicInfo().toBlocking().first()
        }
    }
    
    func mockEndpointForAPI(api: TargetType, response: EndpointSampleResponse) -> Endpoint {
        return Endpoint(url: api.baseURL.absoluteString,
                        sampleResponseClosure: { response },
                        method: api.method,
                        task: api.task,
                        httpHeaderFields: api.headers)
    }
    
    func loadDataInJSONFile(fileName: String) -> Data? {
        let bundle = Bundle(for: type(of: self))
        guard let filePath = bundle.path(forResource: fileName, ofType: "json"),
            let data = try? Data(contentsOf: URL(fileURLWithPath: filePath)) else {
            return nil
        }
        return data
    }

    func expectError(_ expectedError: NSError, inFailedRequest requestOperation: (() throws -> ())) {
        do {
            try requestOperation()
            XCTFail()
        } catch let error as Moya.MoyaError {
            switch error {
            case let .underlying(error as NSError, response):
                if let response = response {
                    XCTAssertEqual(expectedError.code, response.statusCode)
                } else {
                    XCTAssertEqual(error.code, expectedError.code)
                }
            default:
                XCTFail()
            }
        } catch {
            XCTFail()
        }
    }

}

总结

 

基于 RxSwift + Moya 搭建的网络请求层,具有以下优势:

劣势:

 

 


参考文章:
Moya Tutorial for iOS: Getting Started
Getting Started With RxSwift and RxCocoa
RxSwift + MVVM: how to feed ViewModels
Testing Your RxSwift Code

 

转载请注明出处,谢谢~

上一篇下一篇

猜你喜欢

热点阅读