【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

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