Combine
概要
Combine は、WWDC 2019 で発表された非同期処理のための宣言的 API フレームワーク。Publisher が値を送出し、送出された値は様々な operator (map とか flatMap とか) によって加工、ハンドリングでき、最終的に Subscriber で結果を受け取る。
https://developer.apple.com/documentation/combine
下記の handbook 凄すぎる... 大体これを読めば良さそう。
https://heckj.github.io/swiftui-notes/#aboutthisbook
基本のき
Receiving and Handling Events with Combine に、テキストを編集したらその内容に応じて Table/Collection View を更新するようなケースのサンプルがある。大体の雰囲気はこれを一通り読むと掴めるはず。RxSwift を触ったことがあるとさらに把握しやすい。
既存の非同期な API を Combine の API 経由で扱えるようにする拡張されているケースがあって、NotificationCenter の API もその内の 1 つ。例えば、テキストを編集したことを NotificationCenter 経由で受け取るために、まずはイベントを送出する Publisher を用意する必要がある。このためのコードは、以下のようになる。
code:swift
let pub = NotificationCenter.default
.publisher(for: NSControl.textDidChangeNotification, object: filterField)
そして、この Publisher のイベントを購読するには、Subscriber を用意する必要がある。Publisher には Input, Subscriber には Output、さらに両者が各々 Failure という associated type を定義していて、イベントの購読の際には、Input と Output、そして Failure の具象型が合致している必要がある。
Combine には、アタッチされた Publisher の Output, Failure に自動的に適合するビルトインの Subscriber が用意されている。
sink(receiveCompletion:receiveValue:)
1つ目の closure は、Publisher が正常/異常終了した際に呼び出される
2つ目の closure は、Publisher から要素が送出された際に呼び出される
assign(to:on:)
オブジェクトとその KeyPath を引数にとる
受け取った要素を、即座に指定されたオブジェクトのプロパティに割り当てていく
ViewModel に値を即座に割り当てたい、みたいな時に便利
例えば、受け取ったイベントをシンプルにロギングするなら、以下のような感じで書ける。
code:swift
let sub = NotificationCenter.default
.publisher(for: NSControl.textDidChangeNotification, object: filterField)
.sink(receiveCompletion: { print ($0) },
receiveValue: { print ($0) })
sink の receiveValue closure のなかで全ての操作を行うのは場合によってはしんどい。ので、Publisher に様々な operator を chain できる。使い慣れた map, flatMap, reduce などがある。例えば、前述の Notification からテキストフィールドの文字列だけ取り出したいなら、以下のようにかける。
code:swift
let sub = NotificationCenter.default
.publisher(for: NSControl.textDidChangeNotification, object: filterField)
.map( { ($0.object as! NSTextField).stringValue } )
.sink(receiveCompletion: { print ($0) },
receiveValue: { print ($0) })
さらに、結果を直接オブジェクトにバインドしたいなら、以下のようにかける。
code:swift
let sub = NotificationCenter.default
.publisher(for: NSControl.textDidChangeNotification, object: filterField)
.map( { ($0.object as! NSTextField).stringValue } )
.assign(to: \MyViewModel.filterString, on: myViewModel)
他にも、filter でフィルタしたり、debounce でイベント送出を遅延させたり (ユーザの入力完了を待つなどの目的で)、receive で受け取るスレッドを変更したりできる。
code:swift
let sub = NotificationCenter.default
.publisher(for: NSControl.textDidChangeNotification, object: filterField)
.map( { ($0.object as! NSTextField).stringValue } )
.filter( { $0.unicodeScalars.allSatisfy({CharacterSet.alphanumerics.contains($0)}) } )
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.receive(on: RunLoop.main)
.assign(to:\MyViewModel.filterString, on: myViewModel)
キャンセルしたい場合は、Subscriber の cancel() メソッドを呼び出してキャンセルできる。
複数の Subscriber をつないでから publish させる
sink はつないだ瞬間に値が流れ出してしまうため、1 つめをつないだ後に 2 つ目を繋ぐと、2 つ目の Subscriber が値を受け取れない可能性がある。そのような場合には ConnectablePublisher を間に挟んで subscribe する。connect() を明示的に呼び出すため値が流れ始めなくなる。
明示的に呼び出すのが逆に手間な場合は autoconnect() を挟むと値が即座に流れ始める。
https://developer.apple.com/documentation/combine/controlling-publishing-with-connectable-publishers
Subject
外部から明示的に値を publish 可能な Publisher として Subject が用意されている。直近の値を保持するのが CurrentValueSubject、受け流すのが PassthroughSubject のようだ。
Operator
複数の Publisher の結果を合成する
RxSwift でいう zip のようなものが欲しいパターンがある。
Tips
型情報を削りたい
例えば、UserDefaults を protocol で隠蔽したい時。
code:swift
// 適当な実装クラス
class SettingStorage {
private let userDefaults = UserDefaults.standard
}
// KVOのCombine拡張を利用するために、UserDefaults自体にプロパティを拡張する
extension UserDefaults {
@objc
dynamic var isEnabled: Bool {
return self.bool(forKey: "myKey")
}
}
// 抽象化. ここでは UserDefaults は意識させたくない
protocol SettingStorageProtocol {
var isEnalbed: /* ここのPublisher型はどうする? */ { get }
}
extension SettingStorage: SettingStorageProtocol {
// MARK: - SettingStorageProtocol
var isEnabled: /* ここがNSObject.KeyValueObservingPublisher<UserDefaults, Bool>になる */ {
return self.userDefaults
.publisher(for: \.isEnabled)
}
}
Protocol には UserDefaults の情報を出したくない。つまり、型情報を削りたい。このような場合には eraseToAnyPublisher() を利用するのが良い。doc でも、抽象化の境界で利用するのが良いとの記述がある。
This form of type erasure preserves abstraction across API boundaries, such as different modules. When you expose your publishers as the AnyPublisher type, you can change the underlying implementation over time without affecting existing clients.
利用してみると以下のような感じ
code:swift
protocol SettingStorageProtocol {
var isEnabled: AnyPublisher<Bool, Never> { get }
}
extension SettingStorage: SettingStorageProtocol {
// MARK: - SettingStorageProtocol
var isEnabled: AnyPublisher<Bool, Never> {
return self.userDefaults
.publisher(for: \.isEnabled)
.eraseToAnyPublisher()
}
}
store(in:)
RxSwift における DisposableBag のように、複数の cancellable をストアしておきたい場合、Combine では cancellable の Set を保持しておき、その Set に対して store(in:) を呼び出すと良い。これで、型情報を削った AnyCancellable を保持できる。
A number of developers comfortable with RxSwift are using a "CancelBag" object to collect cancellable references, and cancel the pipelines on tear down. An example of this can be seen at https://github.com/tailec/CombineExamples/blob/master/CombineExamples/Shared/CancellableBag.swift. This is accommodated within Combine with the store function on AnyCancellable that easily allows you to put a reference to the subscriber into a collection, such as Set<AnyCancellable>.
https://heckj.github.io/swiftui-notes/#patterns-cascading-update-interface