LoginSignup
36
28

More than 3 years have passed since last update.

【Swift】Custom Publisherを作成してCombineフレームワークの動きを学ぶ

Posted at

WWDC2019で発表されたApple製のCombineフレームワークは
非同期なデータの流れを簡単に扱え
多くの場面で活用できると期待されているフレームワークです。

詳しくはこちらに記載していますので
よろしければご参照ください。
https://qiita.com/shiz/items/5efac86479db77a52ccc

2019/7/5現在では
NotificationCenterURLSessionなどのextension
Publisherに適合する実装が用意されていますが
UIKitを拡張したものは用意されていません。

そこで
今回はUIKitのイベントが発生した際にデータを送る
Custom Publisherの作り方を見ていきたいと思います。

また
その中でCombineフレームワークが
どういう仕組みで動いているのかについても
改めて見ていきたいと思います。

主に下記の記事を参考にしています。
https://www.avanderlee.com/swift/custom-combine-publisher/

UIKitのイベントに応答するには?

UIControlのイベントが発生した時に
データをPublisherから送るようにします。

必要なもの

大きく分けて2つのものが必要になります。

  1. Custom Subscriptionを作成する
  2. Custom Publisherを作成する

Custom Subscriptionを作成する

まずは下記のようにUIControlを拡張します。


extension UIControl {

    final class Subscription<SubscriberType: Subscriber, Control: UIControl>: Combine.Subscription where SubscriberType.Input == Control {
        private var subscriber: SubscriberType?
        private let control: Control

        init(subscriber: SubscriberType, control: Control, event: UIControl.Event) {
            self.subscriber = subscriber
            self.control = control
            control.addTarget(self, action: #selector(eventHandler), for: event)
        }

        func request(_ demand: Subscribers.Demand) {
        }

        func cancel() {
            subscriber = nil
        }

        @objc private func eventHandler() {
            _ = subscriber?.receive(control)
        }

        deinit {
            print("UIControlTarget deinit")
        }
    }
}

上記のコードを見ていきます。

Protocolへの適合

まずSubscription Protocolに適合させる必要があります。

SubscriptionPublisherSubscriberを繋ぐ存在で
PublisherSubscriberの接続状態を表します。

Subscription ProtocolはCancellable Protocolを継承しており
cancelメソッドの実装が必須です。
Publisherからデータを受け取ることをキャンセルした時の処理を実装する必要があります。

さらにrequestメソッドも必須です。
https://developer.apple.com/documentation/combine/subscription/3213720-request

上記の例では何も行っていないため
来たデータをそのまま流しているだけですが
ここで流すデータに条件を追加したりすることもできます。

さらに引数で指定するDemandの値で
Publisherへ要求するデータ量をコントロールすることができます。

Demandは2つのcaseを持つenum

  • max(Int) 指定した回数を超えたらデータを受け取ることを止める
  • unlimited 無制限にデータを受け取る sinkメソッドはunlimitedになる

SubscriberのInputを制約する

次に注目する点としてはクラスの制約で


class Subscription<SubscriberType: Subscriber, Control: UIControl>
    : Combine.Subscription where SubscriberType.Input == Control

とすることで
Subscriberが受け取る値
つまりPublisherが出力する値をUIControlを継承したものに限定しています。

イベントを受け取る

SubscriptionSubscriberとイベントを発生させるUIControlを保持しており

  • init時にaddTargetでイベントハンドラーを登録
  • イベントハンドラーで保持するSubscriberへデータを流す

ことを行っています。

Subscriberは下記の3つのメソッドでデータを受け取ることができます。

receive(subscription: Subscription)

SubscriberPublisherと接続に成功し
データを要求することができるようになった通知を受け取ります。

receive(Self.Input) -> Subscribers.Demand

Publisherが出力した値を受け取ります。

receive(completion: Subscribers.Completion<Self.Failure>)

Publisherがデータを出力するのを完了したことを受け取ります。
正常に終了した場合もエラーが起きて終了した場合もあります。

cancelされた時

保持するSubscriberへの参照をnilにすることで
メモリから解放されるようにしています。

Custom Publisherを作成する

次にPublisherを作成します。


extension UIControl {

    struct Publisher<Control: UIControl>: Combine.Publisher {

        typealias Output = Control
        typealias Failure = Never

        let control: Control
        let controlEvents: UIControl.Event

        init(control: Control, events: UIControl.Event) {
            self.control = control
            self.controlEvents = events
        }

        func receive<S>(subscriber: S) where S : Subscriber, S.Failure == Failure, S.Input == Output {
            let subscription = UIControl.Subscription(subscriber: subscriber, control: control, event: controlEvents)
            subscriber.receive(subscription: subscription)
        }
    }    
}

先ほども少し触れましたが
PublisherOutputUIControlを継承したクラスになります。
init時にControlと出力するUIControl.Eventを保持します。

また
Publisher Protocolに適合させる必要があります。
そのためにはreceive<S>(subscriber: S)メソッドを実装します。

この中で
先ほど作成したCustom Subscriptionのインスタンスを生成し
引数のSubscriberreceive(subscription: Subscription)メソッドに渡します。

やることはこれだけです。

もっと簡単にPublisherを作成できるようにする

NotificationCenterなどと似た形でUIControl.Publisherを作成できるようにします。


protocol CombineCompatible { }
extension UIControl: CombineCompatible { }
extension CombineCompatible where Self: UIControl {
    func publisher(for events: UIControl.Event) -> UIControl.Publisher<Self> {
        return UIControl.Publisher(control: self, events: events)
    }
}

CombineCompatibleという空のProtocolを使用することで
UIControl以外にも拡張しやすくしたり
テスト時のモックを作りやすくしています。

これを使って実装例を見てみます。

実装例

参考にした記事に出てきた例で単純にボタンをタップしたときの挙動を見てみます。


let button = UIButton()
button.publisher(for: .touchUpInside).sink { button in
    print("Button is pressed!")
}
button.sendActions(for: .touchUpInside) // Button is pressed!

sendActionsを呼び出すとprintの内容が出力されます。

UISwitchのイベントに応答する

UISwitchisOnプロパティは
KVOに対応していないため
Publisherで変化をキャッチできるようにしてみます。


extension CombineCompatible where Self: UISwitch {
    var isOnPublisher: AnyPublisher<Bool, Never> {
        return publisher(for: [.allEditingEvents, .valueChanged]).map { $0.isOn }.eraseToAnyPublisher()
    }
}


これはUIのイベントには応答しますが
プログラム上でisOnプロパティを変更しても反応しません。

まとめ

Custom Publisherの作成を通して
Combineフレームワークの動作を確認してみました。

Combineフレームワークは
応用できる幅がたくさんあると思いますので
今後も色々な使い方を見ていきたいと思います。

Custom Publisherを活用しているライブラリには
下記のようなものもありますので
ぜひ参考にしてみてください😄

@noppefoxwolf さんのCombinative

iOS13より下のバージョンやLinux, WindowsでもCombineを使用可能にしようと試みているOpenCombine

間違いなどございましたらご指摘いただけると嬉しいです🙇🏻‍♂️

36
28
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
36
28