🍑

ちょっとしたことにCombineを使う -Combine初心者向け-

2021/03/07に公開

プログラミング初心者ではないけど、Combineは
「Publisherっていうのをsinkすると値が飛んでくる。mapとかもできる」
くらいの感覚で、実際に自分のソースコードにCombineを組み込んだことはない方向けに

  • いつものコードの一部をCombineにするには?
  • 引っ掛かりポイントは?

といった疑問を解消するための記事です。
MVVMだったりReduxだったり、大きな枠組みの話はしません。

普通の関数をPublisherにする

以下をPublisherにする必要性はあまりないですが、簡単な例ということでご了承ください。

combine_playground.swift
// 1を足す関数
func addOne(_ n: Int) -> Int { n + 1 }
// 2を足す関数
func addTwo(_ n: Int) -> Int { n + 2 }

// 0に1を足して2を足す
let result = addTwo(addOne(0))
print(result)

これをCombineにします(AnyPublisherにする)。

combine_playground.swift
// 1を足すPublisherを返す関数
func addOnePublisher(_ n: Int) -> AnyPublisher<Int, Never> {
    Deferred {
        Future { (promise) in
            promise(.success(addOne(n)))
        }
    }
    .eraseToAnyPublisher()
}
// 2を足すPublisherを返す関数
func addTwoPublisher(_ n: Int) -> AnyPublisher<Int, Never> {
    Deferred {
        Future { (promise) in
            promise(.success(addTwo(n)))
        }
    }
    .eraseToAnyPublisher()
}

// 0に1を足して2を足す(map版)
let cancellable1 = addOnePublisher(0)
    .map {
        addTwo($0)
    }
    .sink { (completion) in
        print(completion)
    } receiveValue: { (n) in
        print(n)
    }

// 0に1を足して2を足す(flatMap版)
let cancellable2 = addOnePublisher(0)
    .flatMap {
        addTwoPublisher($0)
    }
    .sink { (completion) in
        print(completion)
    } receiveValue: { (n) in
        print(n)
    }

普通の関数をPublisherにする書き方は、まずは丸暗記して良いと思います。

  • AnyPublisherのOutputは、関数の戻り値の型
  • AnyPublisherのFailureの型は、エラーをthrowしない関数なのでNever型
  • DeferredしてFutureする流れは丸暗記する(Deferredがない場合の動作はこちらの記事がとてもわかりやすいです)
  • 処理の結果はpromise(.success(結果))として渡す
  • eraseToAnyPublisher()でAnyPublisherにする(大抵そのほうが扱いやすい)

Publisherと普通の関数を繋げる場合はmap、PublisherとPublisherを繋げる場合はflatMapを使います。

receiveValue部分は計算結果を受け取ります。
上記例では普通の関数の戻り値に相当しますが、Combineの場合は何個も値が飛んでくることもあります。

sink部分は処理完了を受け取ります。
Combineは普通の関数と違い1個値が飛んでくれば終わりとは限りませんが、ここで処理完了を判断できます。

この方法を覚えておけば、ファイルコピーや削除、その他好きな機能をCombineの流儀で扱うことが可能になります。

補足: Publisherの組み立てと実行を分ける

combine_playground.swift
// まだ計算されていない
let addOneTwoPublisher = addOnePublisher(0)
    .flatMap {
        addTwoPublisher($0)
    }

// ここで計算される
let cancellable3 = addOneTwoPublisher
    .sink { (completion) in
        print(completion)
    } receiveValue: { (n) in
        print(n)
    }

throwsの関数をPublisherにする

次はErrorをthrowする関数をPublisherにします。
まずは普通の関数バージョン。

combine_playground.swift
// 1を足す関数のError
enum AddOneError: Error {
    case error
}
// 1を足す関数
func addOne(_ n: Int) throws -> Int {
    guard n < 10 else { throw AddOneError.error }
    return n + 1
}
// 2を足す関数のエラー
enum AddTwoError: Error {
    case error
}
// 2を足す関数
func addTwo(_ n: Int) throws -> Int {
    guard n < 9 else { throw AddTwoError.error }
    return n + 2
}

// 0に1を足して2を足す
do {
    let result = try addTwo(addOne(0))
    print(result)
} catch let error as AddOneError {
    print(error)
} catch let error as AddTwoError {
    print(error)
} catch {
    print(error)
}

Publisherにすると以下のようになります。
Error時の型をを明示できるのがCombineの利点の一つです。
(Resultでもできますが、利点であることに変わりないと言うことで)

まずPublisherを返す関数を作ります。

combine_playground.swift
// 1を足すPublisherを返す関数
func addOnePublisher(_ n: Int) -> AnyPublisher<Int, AddOneError> {
    Deferred {
        Future { (promise) in
            guard n < 10 else {
                promise(.failure(AddOneError.error))
                return
            }
            promise(.success(n + 1))
        }
    }
    .eraseToAnyPublisher()
}
// 2を足すPublisherを返す関数
func addTwoPublisher(_ n: Int) -> AnyPublisher<Int, AddTwoError> {
    Deferred {
        Future { (promise) in
            guard n < 9 else {
                promise(.failure(AddTwoError.error))
                return
            }
            promise(.success(n + 2))
        }
    }
    .eraseToAnyPublisher()
}

throwsの関数をPublisherにする場合の書き方も、丸ごと暗記して良いと思います。

  • AnyPublisherのOutputは、関数の戻り値の型
  • AnyPublisherのFailureの型は、throwする型。Errorでもいいですし、独自のError型でもOK
  • 処理の結果はpromise(.success(結果))として渡す
  • エラーはpromise(.failure(エラー))として渡す

次の呼び出し方がポイントです。
以下のコード、実はコンパイルできません。

// 0に1を足して2を足す🤔
let cancellable = addOnePublisher(0)
    .flatMap {
        addTwoPublisher($0)
    }
    // 以下省略
  1. addOnePublisherを呼ぶ。OutputはInt
  2. flatMapの$0はIntになる
  3. IntをaddTwoPublisherの引数にする

一見問題ないように見えます、なぜコンパイルできないのでしょうか?

その理由は Failureの型が合っていないから です。
flatMapの中にはエラー時の処理は出てきません。しかしFailureの型は合っている必要があります。

// 0に1を足して2を足す🤔
let cancellable = addOnePublisher(0) // <Int, AddOneError>
    .flatMap {
        addTwoPublisher($0) // <Int, AddTwoError>
    }
    // この時点の型は <Int, AddOneError>? <Int, AddTwoError>?

コメントの <Int, AddOneError>AnyPublisher<Int, AddOneError> と考えてください。
上記のようにFailureの型が確定できずコンパイルエラーになるようです。
この場合は mapError の出番です。

combine_playground.swift
// 0に1を足して2を足す☺️
let cancellable = addOnePublisher(0) // <Int, AddOneError>
    .mapError { $0 as Error } // <Int, Error>
    .flatMap {
        addTwoPublisher($0) // <Int, AddTwoError>
            .mapError { $0 as Error } // <Int, Error>
    }
    // <Int, Error>
    .sink { (completion) in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            switch error { // Error
            case let error as AddOneError:
                print(error)
            case let error as AddTwoError:
                print(error)
            default:
                break
            }
        }
        print(completion)
    } receiveValue: { (n) in
        print(n)
    }

mapErrorを使うと、errorが発生した場合に別のerrorに変換できます。
今回はError型にキャストして統一することでコンパイルを通るようにしました。

場合によってはErrorではなく具体的なError型の方が嬉しい場合もあります。
その場合は別途Errorを定義してmapErrorしましょう。

combine_playground.swift
// 0に1を足して2を足すPublisherのエラー
enum AddError: Error {
    case one(AddOneError)
    case two(AddTwoError)
}
// 0に1を足して2を足す☺️
let cancellable2 = addOnePublisher(0) // <Int, AddOneError>
    .mapError { AddError.one($0) } // <Int, AddError>
    .flatMap {
        addTwoPublisher($0) // <Int, AddTwoError>
            .mapError { AddError.two($0) } // <Int, AddError>
    }
    .sink { (completion) in
        switch completion {
        case .finished:
            break
        case .failure(let error):
            switch error { // AddError
            case .one(let error):
                print(error)
            case .two(let error):
                print(error)
            }
        }
        print(completion)
    } receiveValue: { (n) in
        print(n)
    }

APIを呼び出す例

個人的にAPI呼び出しにCombineを使うことが多かったので、API呼び出しの例をご紹介します。

import UIKit
import Combine

public struct API {
    public typealias Input = Int
    public typealias Output = String
    public enum Failure: Error {
        case urlError(error: URLError)
        case httpStatus(status: Int)
        case decodeError(error: Error)
        case apiError(message: String)
        case other(error: Error)
        case unknown
    }

    // APIのレスポンス。成功ならresultに文字列が入る、失敗ならerrorMessageに文字列が入る想定
    struct Response: Codable {
        var result: String?
        var errorMessage: String?
    }
    
    let url: URL = URL(string: "")! // 適当なURLが設定される想定
    
    public func publisher(_ input: Input) -> AnyPublisher<Output, Failure> {
        let session = URLSession(configuration: .default)
        let urlRequest = URLRequest(url: url)
        // inputをなんかする想定
        return session.dataTaskPublisher(for: urlRequest)
            .mapError(Failure.urlError(error:)) // dataTaskPublisherのerrorをFailure.urlError(error:)で包み込む
            .tryMap { (data, response) -> Data in
                // tryMapはthrowできるmap
                // Data型を返しているので <Data, Error> になる。
                guard let response = response as? HTTPURLResponse else {
                    throw Failure.unknown
                }
                guard response.statusCode == 200 else {
                    throw Failure.httpStatus(status: response.statusCode)
                }
                return data
            }
            .flatMap {
                Just($0) // `$0: Data` を流すPublisherにする
                    .decode(type: Response.self, decoder: JSONDecoder()) // JSONのデコードをする
                    .mapError(Failure.decodeError(error:)) // デコードのエラーをFailure.decodeError(error:)で包み込む
            }
            .tryMap { response -> Output in
                // tryMapはthrowできるmap
                // Output型を返しているので <Output, Error> になる。
                if let result = response.result {
                    return result
                } else {
                    throw Failure.apiError(message: response.errorMessage!)
                }
            }
            .mapError {
                // tryMapのFailureはError型なのでFailure型に変換する。
                // 実装上Failure型以外のエラーが来ることはないので `as!` 可能。
                // 一応 `as!` を避けて、Failure.other(error:)に包み込む
                $0 as? Failure ?? Failure.other(error: $0)
            }
            .eraseToAnyPublisher()
    }
}

Combineの便利なところ

無理して使う理由はないのですが、便利に使えそうな例をいくつかご紹介します。

retry

Errorだった場合にn回再実行するのが簡単

API().publisher(1)
    .retry(3)
    .sink { ... }

flatMapでn個まで並列実行する

flatMapは最大n個並行して実行するといった指定が可能です。

combine_playground.swift
// 時間がかかる処理
func sleepPublisher() -> AnyPublisher<Int, Never> {
    return Deferred<Future<Int, Never>> {
        Swift.print("🏁")
        return Future<Int, Never> { (promise) in
            // 時間のかかる処理
            DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                promise(.success(1))
            }
        }
    }
    .eraseToAnyPublisher()
}

// 最大2個並行して動作する
let c = [sleepPublisher(), sleepPublisher(), sleepPublisher()] // Publisherの配列
    .publisher // 配列をPublisherにする
    .flatMap(maxPublishers: .max(2)) { sleepPublisher in // 今回は最大2個同時実行する
        return sleepPublisher
    }
    .sink { (completion) in
        print(completion)
    } receiveValue: { (n) in
        print("🏎", n)
    }

さまざまな便利機能

retryやflatMap(並行動作)は、ハマると効果が特に大きいですが、それ以外にも

  • combineLatest 複数の処理が全てが終わったら動き出す
  • prepend イベントが起きると動く処理に対して、最初の一回を手動で設定する
  • merge Timer由来とボタンタップ由来の起点をまとめる

など便利なことがいろいろできます。(combineLatestは、1回しか動作しないわけではないので注意)

処理の起点の違いを(あまり)意識しない

  • Timer
  • NotificationCenter
  • @Published var name

などどれもPublisherとして扱えるため違いを意識しなくてすみます。
(Output/Failureの型の違いはあるけど)

同期処理と非同期処理の書き方が統一される

機能を全部Publisherにしておけば、とりあえず繋げれば大半はうまくいく(かも)

おまけ: その他注意点

sinkした時の戻り値(AnyCancellable)を保持しておかないと、動いたり動かなかったりするので注意しましょう。
playgroundで実験してるとよく保持し忘れます……

まとめ

  • Combineを使ってると便利なことがある
  • Failureの型は要注意
  • AnyCancellableも要注意

Discussion