Log

【Swift】 Combine 基礎

はじめに

Combineは2019年に発表されたフレームワークですが「アプリに組み込めるようになったのは最近」という人は多いのではないでしょうか.
私もそのうちの一人なのですが,備忘録としてまとめておきます.
間違っている箇所ありましたらご指摘いただけると助かります.

Combineとは

CombineはApple WWDC19で発表されたフレームワークです.
UIKitは処理の結果を元に各オブジェクト内の状態を変化させていくような手続き型プログラミングが主流でした.
SwiftUIを始めとした,オブジェクトが状態の変化を検知して適切な処理へ更新するような宣言型プログラミングスタイルを実現させるためのフレームワークとして登場しました.
iOS 13.0.macOS 10.15から利用可能です.

登場人物

Combineのコアとなるのは以下の3つのプロトコルです.
- Publisher
- Subscriber
- Operator

Publisherとは

Publisherは"発行者"と訳されます.
次に説明するSubscriberに値/エラーを通知することができます.
1対1の関係のDelegateパターンとは異なり,1つのPublisherに複数のSubscriberが登録(subscribe)することが可能です.この特徴が宣言型プログラミングスタイルを実現可能としています.

Subscriberとは

Subscriberは"登録者"と訳され,Publisherからの通知を受け取る役割があります.

Operatorとは

OperatorはPublisherとSubscriberの間に加入し,Subscriberが通知した値を加工しSubscriberへと渡す役割があります.
値の加工とはIntからLongへのキャストや,不要な値の破棄なども行うことができます.

Publisher

WWDC19よりPublisherとはプロトコルで以下のように定義されています.

protocol Publisher {
    associatedtype Output
    associatedtype Failure: Error

    func subscribe<S: Subscriber>(_ subsctiber: S)
        where S.Input == Output, S.Failure == Failure
}

通知を行うために出力するための型,異常を通知するError型を指定するassociatedtype
Subscriberが通知の送付先として登録するためのsubscribe(_:Subscriber)があります.

Subscriber

WWDC19よりSubscriberもプロトコルで以下のように定義されています.

protocol Subscriber {
    associatedtype Input
    associatedtype Failure: Error

    func receive(subscription: Subscription)
    func receive(_ input: Input) -> Subscribers.Demand
    func receive(completion: Subscribers.Completion<Failure>)
}

通知の入力として受け取る型と異常時のError型を指定します.
Publisherに登録する場合はPublisherのOutputとSubscriberのInputの型は一致している必要があります.
静的型付けを行うことによって実行時のキャストエラーを防ぎ,外部APIとして公開する場合も扱いやすくなります.

Combine組込みSubscriberとしてAssignSinkが用意されています. AssignはPublisherから受け取った値を,指定した変数に代入するSubscriber.
SinkはPublisherから値を受け取ったら,初期化時に指定したクロージャを呼び出すSubscriberです.

Operator

Operatorはプロトコルではありません.Publisherからの出力を受け取り,値のキャスト等を行いSubscriberへと出力するものです. Combineの組込みOperatorだけでもかなりの数のものが用意されており,様々なことが実現可能です.
Operatorの一つとして提供されているMapは以下のような定義です.

extension Publishers {
    struct Map<Upstream: Publisher, Output>: Publisher {
        typealias Failure = Upstream.Failure

        let upstream: Upstream
        let transform: (Upstream.Output) -> Output
    }
}

組込みOperatorはPublishersという名前空間に定義されています.詳しく知りたい方はドキュメントを参照してください.

Subject

SubjectはPublisherを継承したプロトコルです.
Publisherの実装に加えてsend()メソッドの実装を要求します. 組込みSubjectとしてCurrentValueSubjectとPassthroughSubjectが提供されており,既存の命令型コードと共存させる場合に力を発揮します.

ライフサイクル

PublisherとSubscriberは以下の流れで処理を行います.
Combineを利用するにあたってOperatorは必須ではありませんのでここでは割愛します.

  1. Subscriberは通知を受け取りたいPublisherのsubscribe(_:Subscriber)に自身を引数として渡す.
  2. Publisherは登録要求で渡されたSubscriberのreceive(_:Subscription)を呼び出して接続を確率します.
  3. Subscriberはreceive(_:Subscription)で渡されたSubscriptionオブジェクトのrequest(_:Demand)メソッドを用いてN回もしくは無限回の通知を受け取ることをPublisherへと伝える.
  4. Publisherは通知イベントをSubscriberへ送るためにSubscriber.receive(_:Input)を呼び出します.このイベントは接続が確率されている間は複数回送ることが可能です.
  5. Subscriberは.cancel()メソッドを用いてPublisherとの接続をキャンセルすることが可能です..cancel()呼び出し以降はPublisherからの通知はされなくなります.
  6. PublisherはSubscriber.receive(_:Completion)を呼び出し,以降の通知は発生しないことを伝えることができます.

ライフサイクル PublisherとSubscriber

実践 Publisher Subscriber

Foundationの拡張でCombineを利用することができます.
今回はKVO(KeyValueObserve)をCombineで利用してみます.

import Foundation
import Combine


class User: NSObject {
    @objc dynamic var age: Int = 0
}

let user = User()

// ① User.ageの値の変更を通知するPublisher
let agePublisher = user.publisher(for: \.age, options: .new)

// ② 通知を受け取り,都度クロージャを呼び出す組込みSubscriber
let ageSubscriber = Subscribers.Sink<Int, Never>(receiveCompletion: { _ in
    // 完了/失敗時に呼ばれる
}, receiveValue: { value in
    // 値が更新されたときに呼ばれる
    print(value)
})

// ③ Publisherと接続する
agePublisher.subscribe(ageSubscriber)

user.age = 40
user.age = 30

// ④ キャンセル
ageSubscriber.cancel()

// ⑤ この変更は通知されない
user.age = 10

上記のコードを実行してみるとコンソールには

40  
30

が出力されます.
④ キャンセル以降のイベントは通知されないことが見て取れます.

また,④の.cancel()を実行した場合は②のreceiveCompletionクロージャは実行されません.
強引ですが,以下のように完了通知を呼び出してあげるとreceiveCompletionが呼び出されて.cancel()と同じ動きをすることが確認できました.

// ③ Publisherと接続する
agePublisher.subscribe(ageSubscriber)

user.age = 40
user.age = 30

ageSubscriber.receive(completion: .finished)

Chained Publisher

Combineの例としてよく見る書き方.
Chained Publisherと呼ばれており以下のような書き方で,先程の例と同じ動きをします.

class User: NSObject {
    @objc dynamic var age: Int = 0
}

let user = User()

let cancellable = user.publisher(
    for: \.age, options: .new).sink(receiveCompletion: { _ in
        // 完了/失敗時に呼ばれる
    }, receiveValue: { value in
        // 値が更新されたときに呼ばれる
        print(value)
    })

user.age = 40
user.age = 30

cancellable.cancel()

user.age = 10

Publisher.sinkメソッドで先程の例と同じ動きをしますが,返り値として渡されるのはAnyCancellableという型のオブジェクトです.
AnyCancellableはSubscriberをラップし.cancel()メソッドのみを呼び出し可能としたものです. ラップされたSubscriberは他のメソッドを呼び出される考慮をしなくて良くなる(先程の強引にreceive(_:Completion)を呼び出す等)ので外部APIとして公開する場合に有用です.

Publisher.sinkで返ってくるオブジェクトを保持しない場合はラップされているSubscriberも破棄されるため意図した動作をしませんので注意が必要です.

実践 Subject

組込みSubjectを用いて,Subjectをsubscribeする例を示します.
組込みSubjectはCurrentValueSubjectとPassthroughSubjectの2つがあります.2つの違いは
CurrentValueSubjectはSubscriberからのsubscribe時に,Subjectが最後に受け取った(保持している)値を渡します. PassthroughSubjectはsubscribeが完了した後に,send()にて受け取った値をSubscriberに渡すという動作になります.

let subject = CurrentValueSubject<String, Never>("hello")

let cancellable = subject.sink(receiveValue: { str in
    print(str)
})

subject.send("world")

コンソールの出力は

hello
world

となります.

今までの例ではsubscribeをするときにエラー時の処理も指定していましたが,Publisher/SubjectのFailureがNever(エラーが発生しないことを表す)の場合は,subscribe処理の(receiveCompletion:)を省略することが可能です.

おわりに

Combineというフレームワークを調べてみると,基本的なコンセプトとしては小さなものでした.
CombineはSwiftUIを支えている技術の一つでもあるため,その基礎を抑えておいて損はないかと思います.
使いこなせれば良い道具となりそうなので,これからも活用方法を探っていきたいと思いました.

参考

Combine | Apple Developer Documentation
Introducing Combine - WWDC19 - Videos - Apple Developer
Combine in Practice - WWDC19 - Videos - Apple Developer
Using Combine