WWDC2019で発表されたApple製のCombineフレームワークは
非同期なデータの流れを簡単に扱え
多くの場面で活用できると期待されているフレームワークです。
詳しくはこちらに記載していますので
よろしければご参照ください。
https://qiita.com/shiz/items/5efac86479db77a52ccc
2019/7/5現在では
NotificationCenter
やURLSession
などのextension
で
Publisher
に適合する実装が用意されていますが
UIKitを拡張したものは用意されていません。
そこで
今回はUIKitのイベントが発生した際にデータを送る
Custom Publisherの作り方を見ていきたいと思います。
また
その中でCombineフレームワークが
どういう仕組みで動いているのかについても
改めて見ていきたいと思います。
主に下記の記事を参考にしています。
https://www.avanderlee.com/swift/custom-combine-publisher/
UIKitのイベントに応答するには?
UIControl
のイベントが発生した時に
データをPublisher
から送るようにします。
必要なもの
大きく分けて2つのものが必要になります。
- Custom Subscriptionを作成する
- 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に適合させる必要があります。
Subscription
はPublisher
とSubscriber
を繋ぐ存在で
Publisher
とSubscriber
の接続状態を表します。
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
を継承したものに限定しています。
イベントを受け取る
Subscription
はSubscriber
とイベントを発生させるUIControl
を保持しており
-
init
時にaddTarget
でイベントハンドラーを登録 - イベントハンドラーで保持する
Subscriber
へデータを流す
ことを行っています。
Subscriber
は下記の3つのメソッドでデータを受け取ることができます。
receive(subscription: Subscription)
Subscriber
がPublisher
と接続に成功し
データを要求することができるようになった通知を受け取ります。
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)
}
}
}
先ほども少し触れましたが
Publisher
のOutput
はUIControl
を継承したクラスになります。
init
時にControl
と出力するUIControl.Event
を保持します。
また
Publisher
Protocolに適合させる必要があります。
そのためにはreceive<S>(subscriber: S)
メソッドを実装します。
この中で
先ほど作成したCustom Subscriptionのインスタンスを生成し
引数のSubscriber
のreceive(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のイベントに応答する
UISwitch
のisOn
プロパティは
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
間違いなどございましたらご指摘いただけると嬉しいです🙇🏻♂️