Help us understand the problem. What is going on with this article?

[Swift] URLSession+Combine+CodableでAPIクライアントを作る

※注意
本記事は Xcode 11.0 GM Seed の環境で動作確認したものです。正式版では動作が変わる可能性もあります。
(うまく動作しない場合は、シュミレータやXcodeを再起動したりするとうまくいきます。)

前書き

過去に以下のような記事を上げていましたが、そのCombine版になります。

そのまんま置き換えというのは、厳しい部分がありましたがなんとか同じような書き方に集約しております。

Combineについては【iOS】Combineフレームワークまとめを見ていただくのが早いかと思います。

導入

1.環境

- MacOS Catalina 10.15 Beta 8
- Xcode 11.0 GM Seed (Swift 5)
- Alamofire (手軽に試すならPodがおすすめ)

をインストールしてください。

余談(Alamofireをインストールしていることに関して)

URLSession使うのにAlamofireを入れるんかい?という疑問が来そうなので、記載しておきます。

①当初はAlamofireをラップしたものでやろうとしていたが、URLSessionに用意されているDataTaskPublisherを用いたほうが綺麗だと気づいた
②基底クラスの作成において、Alamofireで用意されているtypealias、メソッド、エンコード部分が自前で作るより優れていたので採用したかった

(そのうちAlamofire本家が対応してくれるのではと思っている笑)

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

こちらに関しては、上記でもあげた以下
- [Swift]ObjectMapper+RxSwiftを実装した備忘録
- [Swift]Alamofire+RxSwift+CodableでAPIクライアントを作る

全く同じ記載があるので省略します。

3.リクエスト・レスポンスの作成

こちらも先の記事内に同様の記載あるので、必要ない方は読み飛ばして大丈夫です。レスポンスモデルが違うので一応記述しています。

①レスポンスの作成

例として、APIを叩くと以下のレスポンス(Json)が返ってくるとする。

Users.json
{
  "data": [
    {
      "name": "Bob",
      "id": 0
    },
    {
      "name": "Sam",
      "id": 1
    }
  ]
}

上記のJsonからレスポンスを作成。

UserResponse.swift
// MARK: - Response

struct UserResponse: Codable {
    var data: [UserModel]
}

struct UserModel: Codable {
    let name: String
    let id: Int
}

②リクエストの作成

[2]で作成したプロトコルに適応するリクエストになるよう作成。

UserRequest.swift
import Alamofire

// MARK: - Request

struct UserRequest: BaseRequestProtocol {
    typealias ResponseType = UserResponse

    var method: HTTPMethod {
        return .get
    }

    var path: String {
        return "/xxx/yyy/zzz" // ※必要に応じて各自でセットしてください
    }

    var parameters: Parameters? {
        return nil
    }
}

余談

こちらのMyjsonというとサービスを使うと、指定したjsonhttpsで返してくれるURLを発行してくれるので、サクッと試したい方はお使いください。

4.ネットワーククライアントを作成

①DataTaskPublisherに関して

URLSessionに用意されたDataTaskPublisherを用いることで比較的きれいに書くことができます。

URLSession.shared.dataTaskPublisher(for: /* URLRequest or URL */)

以下の記事を見ていただけるとわかるかと思いますが、

自前で作るとなると

- Subscriber
- Subscription

とかを意識する必要があり、結構めんどくさいです。

DataTaskPublisherでその辺をまるっと考えなくて済むのはありがたい感じです。
(細かいところ処理わけしたいとかある場合は、結局自前でやることになるかと思います)

②コード

実際に作成したコードは以下になります。

NetworkPublisher.swift
import Alamofire
import Combine

// MARK: - NetworkPublisher

struct NetworkPublisher {

    // MARK: Variables

    private static let successRange = 200..<300
    private static let retryCount: Int = 1
    private static let decorder: JSONDecoder = {
        let jsonDecoder = JSONDecoder()
        jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
        return jsonDecoder
    }()


    // MARK: Methods

    static func publish<T, V>(_ request: T) -> AnyPublisher<V, Error>
        where T: BaseRequestProtocol, V: Codable, T.ResponseType == V {

            return URLSession.shared
                .dataTaskPublisher(for: try! request.asURLRequest()) // 絶対に落ちない保証があるので`try!`
                .validateNetwork() // ※[5.オプション]で後述
                .validate(statusCode: successRange) // ※[5.オプション]で後述
                .retry(retryCount)
                .map { $0.data }
                .decode(type: V.self, decoder: decorder)
                .eraseToAnyPublisher()
    }
}

備考

メソッドの説明を記載しておきます。
- .validateNetwork() : 通信モードの精査。独自関数につき[5.オプション]で後述
- .validate(statusCode: Range) : ステータスコードの判定。独自関数につき[5.オプション]で後述
- .retry() : 名前の通り失敗したときのリトライ回数を指定
- .map : decodeできるように必要なデータを取り出し
- .decode(type: Class, decoder: JSONDecoder) : 指定した型にデコード
- .eraseToAnyPublisher() : 型消し(Combineではお作法みたいなもの)

③使用例

実際に使用した際の記述例になります。

var cancellables: Set<AnyCancellable> = []

func publish() {
    let request = UserRequest()

    NetworkPublisher.publish(request)
        .receive(on: DispatchQueue.main)
        .sink(receiveCompletion: { result in
            switch result {
            case .finished: print("finished")
            case .failure(let e): print("failure", e) // failure
            }
        }, receiveValue: { value in
            print("receiveValue", value) // success
        }).store(in: &cancellables)
}

deinit {
    cancellables.forEach { $0.cancel() }
}

実行結果(成功時)

receiveValue [UserModel(name: "Bob", id: 0), UserModel(name: "Sam", id: 1)]
finished

備考

sinkに関して
成功時でもreceiveValueで値を受け取った後に、receiveCompletionfinishedに入ってくる点に注意。
流れが逆になってる感じが若干違和感ですが、プログレスを消す処理などは成功・失敗共にここで一元管理できそう。

② RxSwiftで考えた時
- .receive(on: ).observeOn()
- .sink().subscribe()
- .store(in: ).disposed(by: )
- Set<AnyCancellable>Disposebag
だと思ってみると上記のコードもスーッと入ってこないでしょうか?
やっていることは同じです。(内部的には違うでしょうが笑)

④拡張(RxSwiftぽく使うために)

上記でも記載したが、値を受け取った後に、receiveCompletionfinishedに入ってくるのが気持ち悪いので、
RxSwiftっぽく使えるようにもう一段階ラップしたものも用意してみた

NetworkPublisher.swift
static func sink<T, V>(_ request: T, _ scheduler: DispatchQueue = DispatchQueue.main,
                       success: @escaping (V) -> Void,
                       failure: @escaping (Error) -> Void,
                       completion: @escaping () -> Void = {}) -> AnyCancellable

    where T : BaseRequestProtocol, V == T.ResponseType, T.ResponseType : Codable {

        publish(request)
            .receive(on: scheduler)
            .sink(receiveCompletion: { result in
                switch result {
                case .finished: break
                case .failure(let e): failure(e)
                }
                completion()
            }, receiveValue: success)
}

使用例

~省略~

let request = UserRequest()

NetworkPublisher.sink(request, success: { value in
    print("success", value)
}, failure: { error in
    print("failure", error)
}).store(in: &cancellables)

~省略~

こちらのほうがより直感的で、かつ必要に応じてcompletionのクロージャをセットすれば良い仕組みになっている。
(あくまでも個人的な好み)

5.オプション

[4]のネットワーククライアント内で説明していなかったものになります。
実際はなくても動作しますが、あったほうが良いと思い作成。

①DataTaskPublisherを拡張

⑴ Low Data Modeに対応

iOS13から追加されたLow Data Modeに対応するためにDataTaskPublisher拡張します。
extensionで書いてますが普通にtryCatchを繋げれば使用できます。)

DataTaskPublisher+Extensions.swift
extension URLSession.DataTaskPublisher {

    func validateNetwork() -> Self {
        tryCatch { error -> URLSession.DataTaskPublisher in
            guard error.networkUnavailableReason == .constrained else { throw error }
            return self
        }.upstream
    }
}

上記の.constrained = Low Data Mode ということになります。
現状はreturn selfしているので通常モードの時と何もかわりませんが、本来であればここでLow Data Modeように処理を記載します。

わかりやすい例はWWDCの動画(後述の参考にあります)にある画像の読み込みURLを

通常モード → 普通のサイズのURL
LowDataモード → 小さいサイズのURL

という形で処理を分けていました。

上記は省略形であり、実際にちゃんとした処理分岐をすべて書くならば、以下になり

DataTaskPublisher+Extensions.swift
extension URLSession.DataTaskPublisher {

    func validateNetwork() -> Self {
        tryCatch { error -> URLSession.DataTaskPublisher in
            guard let reasonState = error.networkUnavailableReason else { throw error }

            switch reasonState {
            case .cellular: // Cellular
                throw error

            case .expensive: // Cellular + Personal Hotspot
                throw error

            case .constrained: // Low Data Mode
                return self

            @unknown default:
                throw error
            }
        }.upstream
    }
}

各モードごとに分岐が可能です。
(細かくチューニングできるので、高速化とかUX改善には一役買いそうなイメージ)

参考

⑵ StatusCodeのバリデーションに対応

Alamofireに用意されているValidateのようなことがしたかったので作成。

DataTaskPublisher+Extensions.swift
extension URLSession.DataTaskPublisher {

    func validate<S: Sequence>(statusCode range: S) -> Self where S.Iterator.Element == Int {
        tryMap { data, response -> Data in
            switch (response as? HTTPURLResponse)?.statusCode {
            case .some(let code) where range.contains(code):
                return data
            case .some(let code) where !range.contains(code):
                throw NSError(domain: "out of statusCode range", code: code)
            default:
                throw NSError(domain: String(data: data, encoding: .utf8) ?? "Network Error", code: 0)  // ※ 仮のエラーコード
            }
        }.upstream
    }
}

※ defaultの場合にエラーコードが0ですが、各自アプリで決めたエラーコードに置き換えるか、Errorを継承した独自クラスを作成してください

また、特定のエラーコードの場合は特定の処理をしたい

ex
- 強制アプデのアラートが出る
- 再ログインのために、ルート画面をスプラッシュやログイン画面に入れ替える

みたいなことは、こちらで一元管理することもできます。

後書き

RxSwift導入した頃は、なんやこれ、、、と思っていたが、、、
いざCombineに移行しようとしたときに、その便利さにあらために気づかされたりしました笑

とはいえ、公式でサポートしてくれるのはありがたいことであり、
RxSwiftはライブラリとしても地味に大きかったりするので(セットアップとかアプデ時間かかるよね問題)
その辺は移行して解消できたらなぁとは思いつつ。

コードの指摘等あればお願いしますmm

動作するリポジトリを置いておきます

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした