Combine 最初の一歩

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が送られています。

ちょっとこの書き方だとどれがPublisherSubscriberなのかわかりにくいので、書き直してみましょう。

  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)")})
        
  let cancellable = sosuPublisher.subscribe(sosuSubscriber)

// (結果)
// value: 2
// value: 3
// value: 5
// value: 7
// value: 11
// completion: finished

sosuPublisherPublisherで、sosuSubscriberSubscriberです。 これでPublisherSubscriberがわかりやすくなりました。

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を作ってみます。 (SubjectPublisherの一種だと考えてください。)

さきほどと同じようにsink subscriberを使ってプリントアウトするようにセットアップします。

ここでは文字列をどんどん流していくPublisherを作りましたが、作成したPublishersendコマンドで任意のデータを流すことができます。

ボタンアクションなどで、この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側からキャンセルする方法。 キャンセルのために必要なtokenAnyCancellableになっており、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でかきかえたらどうなるかをみていきましょう。

まずはNotificationUITextFieldで文字を入力した時の.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 です。

UIScrollViewcontentOffsetを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

TimerPublisherConnectablePublisherといって、他の 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によるネットワークアクセスのガイドもあるので、詳しくはそちらをみてください。 https://developer.apple.com/documentation/foundation/urlsession/processing_url_session_data_task_results_with_combine

まとめ

Combine、まだまだいろいろな機能がありますが、とりあえず最初の一歩としてはこのくらいわかっていればいいかなと思います。 まずは始めてみましょう!