[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 を実装する方法を載せておきます。

  • 自前で実装する場合

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