Help us understand the problem. What is going on with this article?

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

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になる

https://developer.apple.com/documentation/combine/subscribers/demand

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

https://github.com/noppefoxwolf/Combinative

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

https://github.com/broadwaylamb/OpenCombine

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

shiz
iOSエンジニア 受託開発会社でモバイルアプリからインフラの構築まで行う雑多なエンジニアでしたが、 Swiftへの関心が特に強く、ご縁をいただきiOSエンジニアへと転身。日々勉強中。 開発言語経験: Swift, kotlin, Javascript(Angular, Node.js), C#, PHP, Java
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
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  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
ユーザーは見つかりませんでした