iOS
Swift
Carthage
RxSwift
ObjectMapper

[Swift]ObjectMapper+RxSwiftを実装した備忘録

前書き

objective-c+swift3 という混在するプロジェクトでコーディングを行っている。
近い将来アプリのフルリニューアルを行うことが決定しており、レガシーコードの書き換える中で、通信系がブラックボックス化されており依存度が高いものであるため、新しく作り直すことになった。

swift4に移行したかったが、objective-cのコードをすべてswift化してから行いたかったので、一旦現状のまま実装できるものとしてObjectMapperを採用した(Realmとの相性を考えて)。また、それに伴いRxの導入も行なった。

この手の記事は多いので、個人的な備忘録としてここに記録する。

現状

Alamofire + Curry + Argo

予定

Alamofire + RxSwift + ObjectMapper(AlamofireObjectMapper)

導入

実際に、プロジェクトに導入する手順で行っていく。

1. インストール

ライブラリの導入はCarthageを使用。(Cocoapods,SPMなど導入できるのであれば何でもおk)

github "Alamofire/Alamofire" ~> 4.4
github "ReactiveX/RxSwift"
github "Hearst-DD/ObjectMapper" ~> 2.2
github "tristanhimmelman/AlamofireObjectMapper" ~> 4.0

※ObjectMapperは最新のものだとエラーになるかもしれないので、あえて低いものを指定

2. ネットワークプロトコルの作成

コメントアウトで説明してあります。

import Alamofire
import ObjectMapper
import AlamofireObjectMapper

// MARK: - Base API Protocol
protocol BaseAPIProtocol {
    associatedtype ResponseType

    var method: HTTPMethod { get }
    var baseURL: URL { get }
    var path: String { get }
    var headers: HTTPHeaders? { get }

}

extension BaseAPIProtocol {
    var baseURL: URL {
        return try! "https://~~~".asURL()
    }
    var headers: HTTPHeaders? {
        return nil // 必要であれば個々に設定
    }
}

// MARK: - BaseRequestProtocol
protocol BaseRequestProtocol: BaseAPIProtocol, URLRequestConvertible {
    var parameters: Parameters? { get }
    var encoding: URLEncoding { get }
}

extension BaseRequestProtocol {
    var encoding: URLEncoding {
        // parameter の変換の仕方を設定 
        // defaultの場合、get→quertString、post→httpBodyとよしなに行ってくれる
        return URLEncoding.default
    }

    func asURLRequest() throws -> URLRequest {
        // requestごとの pathを設定
        var urlRequest = URLRequest(url: baseURL.appendingPathComponent(path))

        // requestごとの methodを設定(get/post/delete etc...)
        urlRequest.httpMethod = method.rawValue

        // headersを設定
        urlRequest.allHTTPHeaderFields = headers

        // timeout時間を設定
        urlRequest.timeoutInterval = TimeInterval(30)

        // requestごとの parameterを設定
        if let params = parameters {
            urlRequest = try encoding.encode(urlRequest, with: params)
        }

        return urlRequest
    }

}

upload用のプロトコルを作成できるようにBaseAPIProtocolで切り出しています。

3. ネットワークマネージャーを作成

AlamofireRxSwiftを盛り込む。
また、シングルトン(もどき、厳密には違う)で作成しています。

長いので解説はちょっとあれですが、、、

import UIKit
import Alamofire
import ObjectMapper
import AlamofireObjectMapper
import RxSwift

// MARK: - API Manager
struct APIManager {

    // MARK: - Static Variables    
    private static let successRange = 200..<300
    private static let contentType = ["application/json"]

    // MARK: - APICallProtocol Methods

    /// API呼び出しメソッド
    static func call<T, V>(_ request: T, _ disposeBag: DisposeBag,
                           onNext: @escaping (V) -> Void, onError: @escaping (Error) -> Void)
        where T : BaseRequestProtocol, V == T.ResponseType, T.ResponseType : Mappable {

            _ = observe(request)
                .observeOn(MainScheduler.instance)
                .subscribe(onNext: { onNext($0) },
                           onError: { onError($0) })
                .disposed(by: disposeBag)
    }

    /// RxでAPIを呼び出すメソッド ※下部に追記あり
    static func observe<T, V>(_ request: T) -> Observable<V>
        where T: BaseRequestProtocol, V: Mappable, T.ResponseType == V {

            return Observable<V>.create { observer in
                let calling = callForJson(request) { response in
                    switch response {
                    case .success(let result): observer.onNext(result as! V)
                    case .failure(let error): observer.onError(error)
                    }
                    observer.onCompleted()
                }

                return Disposables.create() { calling.cancel() }
            }
    }

    /// Json 形式で取得するメソッド
    static func callForJson<T, V>(_ request: T, completion: @escaping (APIResult) -> Void) -> DataRequest
        where T: BaseRequestProtocol, V: Mappable, T.ResponseType == V {

            return customAlamofire(request).responseJSON { response in
                    switch response.result {
                    case .success(let result): completion(.success(mappingJson(request, result as! Parameters)))
                    case .failure(let error): completion(.failure(error))
                    }

            }
    }

    /// Alamofireカスタマイズメソッド
    static func customAlamofire<T>(_ request: T) -> DataRequest
        where T: BaseRequestProtocol {

            return Alamofire
                .request(request)
                .validate(statusCode: successRange)
                .validate(contentType: contentType)
    }

    /// Jsonをレスポンスモデルに mappingするメソッド
    static func mappingJson<T, V>(_ request: T, _ result: Parameters) -> V
        where T: BaseRequestProtocol, V: Mappable, T.ResponseType == V {

        // 必ずmappingされるので「!」を使用
        return Mapper<V>().map(JSON: result)!
    }

}

/* APIレスポンスの結果分岐

 success: ObjectMapperに対応したレスポンスモデルを返す
 failure: サーバーからのエラーログを返す

 */

// MARK: - ResultType
enum APIResult {
    case success(Mappable)
    case failure(Error)
}

4. レスポンスモデルの作成

サーバーから取得できる値をswiftで扱える形にする必要がある。そのためのモデルを作成する。
前提として、サーバーからはJson形式で取得されるものとする。
以下、レスポンスのJson。今回は階層構造のものを用意した。

{
    "data": [
        {
            "title": "タイトル", 
            "description": "説明文", 
            "icon": "https://xxx.jpg",
            "date": "2017-01-01 00:00:00",  
            "id": "00000", 
        },
    ], 
    "result": true
}

上記をObjectMapperに対応したレスポンスモデルにすると

import ObjectMapper

struct xxxResponse: Mappable {

    var data: [xxxDataModel]?
    var result: Bool?

    init?(map: Map) { }

    mutating func mapping(map: Map) {
        data <- map["data"]
        result <- map["result"]
    }

}

struct xxxDataModel: Mappable {

    var title: String?
    var description: String?
    var icon: URL?
    var date: Date?
    var id: Int?

    init?(map: Map) { }

    mutating func mapping(map: Map) {
        title <- map["title"]
        description <- map["description"]  
        icon <- (map["icon"], URLTransform()) // String -> URL 変換
        date <- (map["date"], ISO8601DateTransform()) // String -> Data 変換
        id <- (map["id"], TransformOf<Int, String> // String -> Int 変換
                          (fromJSON: { Int($0!) },toJSON: { $0.map { String($0) } }))
    }

}

階層構造のものは別のstructで定義されている。
map["~~"]でセットする際に、必要な型に変換できる。(ObjectMapperが用意してくれている)

5. リクエストの作成

必要なリクエストパラメータが

[
  "keyword": String,
  "ids": [Int]
]

の場合、

import Alamofire
import ObjectMapper

// MARK: - Request
enum xxxRequest: BaseRequestProtocol {
    typealias ResponseType = xxxResponse    

    case post(keyword: String, ids: [Int])

    var method: HTTPMethod {
        switch self {
        case .post: return .post
        }
    }

    var path: String {
        return "xxx/yyy/zzz" /// 個々に設定する
    }

    var parameters: Parameters? {
        switch self {
        case .post(let keyword, let ids):
            return [
                "keyword": keyword,
                "ids": ids
            ]
        }
    }

}

実用例

class ViewController: UIViewController {

   private let disposeBag = DisposeBag()

   private func callForXXX() {
      let request = xxxRequest.post(keyword: "キーワード", ids: [00000, 11111, 10101])
      APIManager.call(request, disposeBag, onNext: onNext, onError: onError)
   }  

   private func onNext(with result: Mappable) {
      // do something
   }

   private func onError() {
      // error handling
   }

}

通信結果を一元管理できるようにしようか迷ったが、同じ通信でも処理をわけたいことも考えられたので、引数として成功時・失敗時のメソッドを渡す仕組みにした。

追記
上記のネットワークマネジャーに関して
onCompletedで特に処理を行っていないので、Success/Failureいずれかだけをかえす、
RxSingleに書き換えました。以下になります。

static func observe<T, V>(_ request: T) -> Single<V>
   where T: BaseRequestProtocol, V: Mappable, T.ResponseType == V {

      return Single<V>.create { observer in
            let calling = callForJson(request) { response in
                  switch response {
                     case .success(let result): observer(.success(result as! V))
                     case .failure(let error): observer(.error(error))
                  }
            }

            return Disposables.create() { calling.cancel() }
   }
}

後書き

結局自分でブラックボックスのようなコードを作ってしまった気がする笑

レスポンスのJSON形式がJSONObjectの場合しか対応していないので(関わっているプロジェクトがその形式のため)、他で使用する場合を考えて少しだけ汎用的にすべきだったかと。

参考

[2017年版]RxSwift + Alamofire + ObjectMapper + RealmのSwift実装について
Swiftを使ったAPIクライアントの実装方法
HTTP通信部分にRxSwiftを使ってみる