3
Help us understand the problem. What are the problem?
Organization

[Swift] Combine の flatMap で failure になると、以降は値が流れなくなる問題に対処する

初めに

タイトル通りですが、CombineflatMapで行う処理が、一度でもfailureになると、それ以降は値が流れなくなるという困った挙動があります。

かなり前からSwift Forumsとかでも上がっていますが、現在もなお直っていません。

ということで、色々と対応方法を調べたので掲載しておきます。

前提

実装に先立ち、まずは問題の発生したコード例で紹介します。

1. Combine を使ったネットワークコード例

Combineを用いて、以下のような簡単な API クライアントとレスポンス例を用意しました。

final class Repository: NSObject {

    static func fetch() -> AnyPublisher<Response, Error> {
        URLSession.shared
            .dataTaskPublisher(for: URL(string: "https://xxx/yyy/zzz")!)
            .map(\.data)
            .decode(type: Response.self, decoder: JSONDecoder())
            .eraseToAnyPublisher()
    }
}
struct Response: Decodable {}

2. flatMap を使った呼び出しのコード

先のネットワークコードを呼び出す時の例を用意しました。
(例えばボタンを押したときにAPIが呼び出されるようなコード)

var cancellable = Set<AnyCancellable>()
var didTapButton = PassthroughSubject<(), Never>()

func bind() {
    didTapButton
        .flatMap { // ここで呼び出す
            Repository.fetch() 
        }
        .sink(receiveCompletion: { result in
            switch result {
            case .failure(let error): break
            case .finished: break
            }
        }, receiveValue: { response in
            // do something
        })
        .store(in: &cancellable)
}
// 実行
didTapButton.send(())

3. 実装の問題

このとき、API が失敗して一度でも failure に来ると、、、

以後 didTapButton.send(()) をいくら呼んでも値が流れてこない問題が発生します。
これがタイトルでも述べている CombineflatMap の問題です。

対応方法

今回はこのコード例を修正する形で対応方法を記載したいと思います。
また、方法を2つ掲載しておきます。

replaceError で回避する

didTapButton
    .flatMap {
        Repository.fetch()
            .replaceError(with: Response())
    }
    .sink { response in
        // do something
    }
    .store(in: &cancellable)

replaceError を経由することで、暗黙的に AnyPublisher<Response, Never> になるため、failure に入ってくることは無くなります。ただし、成功でも失敗でも sink に一括で値が入ってくるため、何かしらの判定が必要になっています。

Result で返す

① の sink では帰ってきた値を判定する方法がなかったので、Result 型にすることでこれを実現します。

didTapButton
    .flatMap {
        Repository.fetch()
            .map { Result.success($0) }
            .catch { Just(Result.failure($0)) }
    }
    .sink { result in
        switch result {
        case .success(let response): break
        case .failure(let error): break
        }
    }
    .store(in: &cancellable)

mapResult 型にして、失敗時は catch でハンドリングしています。こちらも先と同様に、暗黙的に AnyPublisher<Result<Response, Error>, Never> になるため、failure に入ってくることは無くなります。

この書き方は、せっかく CombineResponseError でハンドリングできるものを1つに流してしまうため、Combine の良さは失われてしまいます。

ただし、RxSwift のような書き方ができるため、人によっても見やすいかもしれません。さらに RxSwiftmaterialize のようなものがあれば、さらに分割できたりもしますが、 Combine にはないのは残念です。

終わりに

Apple が直してくれ!!!

コメントになぜそうなるかのやりとりがあり、わかりやすかったので読んでみてください!

余談

Combinematerialize を実装する方法を載せておきます。

  • 自前で実装する場合

  • ライブラリで実装する場合

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
3
Help us understand the problem. What are the problem?