WWDC2019で紹介されたCombineはSwiftで使えるasync frameworkです。
iOS13以上でしか使えないのでプロダクツに導入するのをためらっていましたが、そろそろ導入できそうですね。
まだCombineを導入していない方向けに、Combineの簡単な使い方を書いてみました。
(この記事はXcode v12.0 beta 3 (12A8169g)で確認しています。今後のバージョンアップで動作が変わるかもしれません。)
PublisherとSubscriber
Publisher
はCombineの中核となるもので、目的のデータを時系列順に送る output stream みたいなものですね。
そして、 Subscriber
がそのstreamを受け取る側になります。
まずは一番単純な Publisher
を動かしてみましょう。
素数の配列からPublisher
を作り、Subscriber
はそれをprintするコードです。
let sosu = [2,3,5,7,11] let cancellable = sosu.publisher .sink(receiveCompletion: { print ("completion: \($0)") }, receiveValue: { print ("value: \($0)") }) // (結果) // value: 2 // value: 3 // value: 5 // value: 7 // value: 11 // completion: finished
動かしてみると、配列の要素を順番に送り、すべての要素を送り終わったらCompletionが送られています。
ちょっとこの書き方だとどれがPublisher
とSubscriber
なのかわかりにくいので、書き直してみましょう。
let sosu = [2,3,5,7,11] let sosuPublisher = sosu.publisher let sosuSubscriber = Subscribers.Sink<Int,Never>( receiveCompletion: {comp in print("completion: \(comp)")}, receiveValue: { val in print ("value: \(val)")}) sosuPublisher.subscribe(sosuSubscriber) // (結果) // value: 2 // value: 3 // value: 5 // value: 7 // value: 11 // completion: finished
sosuPublisher
がPublisher
で、sosuSubscriber
がSubscriber
です。
これでPublisher
とSubscriber
がわかりやすくなりました。
sosuPublisher
はIntのArrayを先頭から順に送り、 sosuSubscriber
はそれを順にプリントアウトしていきます。
最初の書き方はChained Publisher
とよばれ、これを使うと、下記のようにArray要素の追加も可能です。
let sosu = [2,3,5,7,11] let cancellable = sosu.publisher .append([17,19]) .prepend([0,1]) .sink(receiveCompletion: { print ("completion: \($0)") }, receiveValue: { print ("value: \($0)") }) // (結果) // value: 0 // value: 1 // value: 2 // value: 3 // value: 5 // value: 7 // value: 11 // value: 17 // value: 19 // completion: finished
(念のためですが、0と1は素数ではありません。)
さて、ここまでは固定要素のArray のPublisherであまりasync要素が感じられないので、逐次的なPublisherを作ってみましょう。
Passthrough な Publisher
PassthroughSubject
を使って、入力されたものを逐次的に送るPublisher
を作ってみます。
(Subject
はPublisher
の一種だと考えてください。)
さきほどと同じようにsink subscriber
を使ってプリントアウトするようにセットアップします。
ここでは文字列をどんどん流していくPublisher
を作りましたが、作成したPublisher
にsend
コマンドで任意のデータを流すことができます。
ボタンアクションなどで、このsend
アクションを呼ぶようにしておくと、ボタンをおすたびにSubscriber
がよばれるのがわかると思います。
var stringPublisher = PassthroughSubject<String?, Never>() var cancellables = [AnyCancellable]() func setupStringSubscriber(){ stringPublisher .sink(receiveCompletion: { print ("completion: \($0)") }, receiveValue: { print ("value: \($0)") }) .store(in: &cancellables) } // Hello ButtonのAction func pressHelloButton(){ stringPublisher.send("hello,") } // World ButtonのAction func pressWorldButton(){ stringPublisher.send("world!") } // Hello Buttonを押すと // value: Optional("hello,") // World Buttonを押すと // value: Optional("world!")
ちょっとストリームっぽくなってきましたね。
Publisher はいつとまるのか。
さて、Publisher
のストリームをとめたい場合、どうすればいいでしょうか。
大きく分けて3つの方法があります、
まず、一つ目はPublisher
側から Completion
を送る方法です。
このcompletion
が送られると、 Subscriber
側でもCompletion
ハンドラがよばれます。
stringPublisher.send("hello,") stringPublisher.send("world!") stringPublisher.send(completion: .finished) // Completionを送ります。 stringPublisher.send("again") // 上の行でfinishedを送ったので、これは送られません。 // (結果) // value: Optional("hello,") // value: Optional("world!") // completion: finished
二つ目はSubscriber
側からキャンセルする方法。
キャンセルのために必要なtoken
がAnyCancellable
になっており、subscribe
したときに取得できるので、下記のようにstoreしておきましょう。
var stringPublisher = PassthroughSubject<String?, Never>() var cancellables = [AnyCancellable]() func setupStringSubscriber(){ stringPublisher .sink(receiveCompletion: { print ("completion: \($0)") }, receiveValue: { print ("value: \($0)") }) .store(in: &cancellables) }
それを使ってキャンセルします。
cancellables.first?.cancel()
三つ目は、ちょっと消極的な方法ですが、このAnyCancellable
のオブジェクトの消滅です。
下記のように、 AnyCancellable
を保持していない場合、setupStringSubscriber
のスコープをぬけた瞬間にstreamは停止します。
(streamが予想外に停止してしまう場合、このAnyCancellable
がきちんと保持できているかどうかを確認しましょう。)
var stringPublisher = PassthroughSubject<String?, Never>() func setupStringSubscriber(){ stringPublisher .sink(receiveCompletion: { print ("completion: \($0)") }, receiveValue: { print ("value: \($0)") }) // 保持されていないので、この瞬間にstringPublisherは停止。 }
Assign Subscriber
Subscriber
には、いままで使っていたsink subscriber
の他にもう一つassign subscriber
というものがあります。
このassign subscriber
は、指定したKeyの値を変更することができるので、実行時にかなり便利です。
下記では、UILabel
に表示されるテキストをPublisher
から送られたものにしています。
@IBOutlet weak var resultLabel: UILabel! var stringPublisher = PassthroughSubject<String?, Never>() var cancellables = [AnyCancellable]() func setupStringSubscriber(){ stringPublisher .assign(to: \.resultLabel.text, on: self) .store(in: &cancellables) }
Notification
さて、ここからは、既存のコードをCombineでかきかえたらどうなるかをみていきましょう。
まずはNotification
。UITextField
で文字を入力した時の.textDidChangeNotification
で見てみましょう。
今まではaddObserver
をしていましたね。
NotificationCenter.default.addObserver( forName: UITextField.textDidChangeNotification, object: nil, queue: OperationQueue.main) { (note) in if let txf = note.object as? UITextField{ let currentText = txf.text print("old way: \(currentText)") } }) // (結果) // old way: Optional("a") // old way: Optional("ab") // old way: Optional("abc")
Combineでsink subscriber
を使うとこうなります。
NotificationCenter .default .publisher(for: UITextField.textDidChangeNotification, object: inputText) .map { ($0.object as! UITextField).text } .sink(receiveCompletion: { print (" comp:\($0)") }, receiveValue: { print (" new way:\($0)") }) .store(in: &cancellables) // (結果) // new way: Optional("a") // new way: Optional("ab") // new way: Optional("abc")
assign subscriber
ではこうなります。
NotificationCenter .default .publisher(for: UITextField.textDidChangeNotification, object: inputText) .map { ($0.object as! UITextField).text } .assign(to: \.resultLabel.text, on: self) .store(in: &cancellables)
KVO
次は key value observing です。
UIScrollView
のcontentOffset
をKVOで確認するコードで見てみましょう。
Swift3ではこうでした。
@IBOutlet weak var scrollView: UIScrollView! func setupKVO(){ scrollView.addObserver(self, forKeyPath: "contentOffset", options: .new, context: nil) } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { print(" key: \(keyPath)") }
Swift4からはこうなりました。
@IBOutlet weak var scrollView: UIScrollView! private var observations = [NSKeyValueObservation]() func setupKVO(){ observations.append(scrollView.observe(\.contentOffset, options: .new){_,change in print("contentOffset : \(change.newValue)") }) }
そして、Combineにするとこうなります。
@IBOutlet weak var scrollView: UIScrollView! var cancellables = [AnyCancellable]() func setupKVO(){ KeyValueObservingPublisher(object: scrollView, keyPath: \.contentOffset, options: .new) .receive(on: DispatchQueue.main) .sink(receiveCompletion: { completion in print("finished") }, receiveValue: { response in print(response) }).store(in: &cancellables) }
Timer
Timer
の Publisher
はConnectablePublisher
といって、他の Publisher
と少し違い、connect
しないと使えません。
すぐ発火する場合には、autoconnect
を使うと便利です。
1秒おきに時間を確認するコードで見てみましょう。
sink subscriber
の場合はこうなります。
Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() .sink() { print ("timer fired: \($0)") } .store(in: &cancellables)
assign subscriber
の場合はこうなります。(画面上のラベルに時間を表示するようになります。)
let timer = Timer.publish(every: 1.0, on: .main, in: .default) .autoconnect() .map { String(describing: $0) } .assign(to: \.resultLabel.text, on: self)
Network
URLSessionをつかったネットワーク処理もCombineで書くことができます。
こちらはGithubのリポジトリを取得するコードです。
var cancellableNetwork: AnyCancellable? = nil struct Repository: Codable { let name: String let html_url: String } func fetch(_ sender: Any) { let url = URL(string: "https://api.github.com/repositories")! let session = URLSession(configuration: .default) cancellableNetwork = session .dataTaskPublisher(for: url) .tryMap() { element -> Data in guard let httpResponse = element.response as? HTTPURLResponse, httpResponse.statusCode == 200 else { throw URLError(.badServerResponse) } return element.data } .decode(type: [Repository].self, decoder: JSONDecoder()) .sink(receiveCompletion: { print ("Received completion: \($0).") }, receiveValue: { value in print ("Received user: \(value).")}) }
Apple のCombineによるネットワークアクセスのガイドもあるので、詳しくはそちらをみてください。 Apple Developer Documentation
まとめ
Combine、まだまだいろいろな機能がありますが、とりあえず最初の一歩としてはこのくらいわかっていればいいかなと思います。 まずは始めてみましょう!