Edited at

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

※注意

本記事は Xcode 11.0 beta 5 の環境で動作確認したものです。正式版では動作が変わる可能性もあります。


前書き

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

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

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


導入


1.環境

- Xcode 11.0 beta 5 (Swift 5)

- Alamofire (XcodeがBetaなので、手軽に試すなら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,
finished: @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: finished()
case .failure(let e): failure(e)
}
}, receiveValue: success)
}


使用例

~省略~

let request = UserRequest()

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

~省略~

こちらのほうがより直感的で、かつ必要に応じてfinishedのクロージャをセットすれば良い仕組みになっている。

(あくまでも個人的な好み)


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 sealf

@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

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