🌊

iOSのCombine学習メモ

2021/05/31に公開

これはなに

iOSのCombineについてオフィシャルのドキュメントを読み解いたときの私的なメモです。
https://developer.apple.com/documentation/combine

Combine

The Combine framework provides a declarative Swift API for processing values over time. These values can represent many kinds of asynchronous events. Combine declares publishers to expose values that can change over time, and subscribers to receive those values from the publishers.

要約:
Combineフレームワークは時系列データを処理するための宣言的なSwift APIを提供します。多くの種類の非同期イベントもこれらを使って表現することができます。Combineは時系列に沿って変化する値を公開するPublisherと、それらの値を受け取るSubscriberを宣言します。

The Publisher protocol declares a type that can deliver a sequence of values over time. Publishers have operators to act on the values received from upstream publishers and republish them.

Publisherプロトコルは時系列に沿って連続した値をデリバリーする型です。Publisherは上流のPublisherから受け取った値を操作して再発行するためのオペレーターを持っています。

At the end of a chain of publishers, a Subscriber acts on elements as it receives them. Publishers only emit values when explicitly requested to do so by subscribers. This puts your subscriber code in control of how fast it receives events from the publishers it’s connected to.

SubscriberはPublisherの(メソッド)チェーンの終端でエレメント(データなど)を受信するとそれに応じて処理をおこないます。PublisherはSubscriberから明示的に要求された場合にのみ値を発行します。これによりSubscriberのコードは接続されているPublisherからイベントを受け取る速度をコントロールすることができます。

Essentials

Receiving and Handling Events with Combine

Apple Developer Documentation

EssentialsではNotificationCenterの例を使ってCombineの基本動作を説明しています。

Publisher

Publisherの主要な登場人物について

Subject

https://developer.apple.com/documentation/combine/subject

protocol Subject : AnyObject, Publisher

SubjectはPublisherを継承したプロトコルです。send(_:)メソッドを呼び出してストリームに値を注入することができます。既存の命令的なコードをCombineの宣言的なモデルへ適応させるのに適しています。

代表的なSubjectとしてCurrentValueSubjectPassthroughSubjectが用意されています。

CurrentValueSubject

https://developer.apple.com/documentation/combine/currentvaluesubject

final class CurrentValueSubject<Output, Failure> where Failure : Error

CurrentValueSubjectは1つの値をラップしてその値が変更されるたびに新しい要素を公開するSubjectです。Combine側で値を保持する時に利用します。変数の初期値はinit(Output)で与えます。

コードの例はこちらです。

let subject = CurrentValueSubject<[Int], Never>([])
let cancellable = subject.sink{ print($0) }
subject.value.append(1)
subject.value.append(2)
subject.value.append(3)
subject.send(completion: .finished)

/* Prints:
[]
[1]
[1, 2]
[1, 2, 3]
*/

PassthroughSubject

https://developer.apple.com/documentation/combine/passthroughsubject

final class PassthroughSubject<Output, Failure> where Failure : Error

PassthroughSubjectCurrentValueSubjectとは異なり値を保持しません。値を保持する必要なく毎回データを更新(上書き)するような場合はPassthroughSubjectを使うと良いです。

次のようなケースではPassthroughSubjectが適しています。

  • Delegateから時系列に沿って最新の値が提供される
  • サーバーから定期的に最新の値が送られてくる
let subject = PassthroughSubject<Int, Never>()
var cancellable = subject.sink{ print($0) }
subject.send(1)
subject.send(completion: .finished)

/* Prints:
1
*/

Tips: downstream, upstream

ドキュメントを読んでいるとdownstream, upstreamというキーワードが出てきます。

  • downstream
    • Subscriberに値を送ること
  • upstream
    • PublisherをSubscribeすること

(違うかも?)

Instance Method

Publisherのインスタンスメソッドの代表的なものとしてsinkassignがあります。

sink(receiveValue:)

https://developer.apple.com/documentation/combine/publisher/sink(receivevalue:)

func sink(receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable

sink(receiveValue:) はストリームが失敗しない場合、つまりPublisherのFailureタイプがNeverの場合にのみ使用できます。

使い方

let integers = (0...3)
let cancelable = integers.publisher.sink { print("Received \($0)") }

// Prints:
//  Received 0
//  Received 1
//  Received 2
//  Received 3

注意点として、戻り値にAnyCancellable型のオブジェクト返却されるので、破棄されないように自前で保持する必要があります。

sink(receiveCompletion:receiveValue:)

https://developer.apple.com/documentation/combine/publisher/sink(receivecompletion:receivevalue:)

func sink(receiveCompletion: @escaping ((Subscribers.Completion<Self.Failure>) -> Void), receiveValue: @escaping ((Self.Output) -> Void)) -> AnyCancellable

sink(receiveCompletion:receiveValue:)は2つのクロージャを受け取ります。最初のクロージャはCompletionを受け取った時に実行されます。これは、Publisherが正常に終了したか、エラーで失敗した場合に受け取ります。2つ目のクロージャはPublisherから要素を受け取った時に実行されます。

let myRange = (0...3)
let cancellable = myRange.publisher
    .sink(receiveCompletion: { print ("completion: \($0)") },
          receiveValue: { print ("value: \($0)") })

// Prints:
//  value: 0
//  value: 1
//  value: 2
//  value: 3
//  completion: finished

PublisherのFailureNeverではなく、エラーハンドリングが必要な場合はこちらを使用します。こちらのケースも戻り値のAnyCancellable型のオブジェクトを保持する必要があります。

assign(to:on:)

https://developer.apple.com/documentation/combine/publisher/assign(to:on:)

func assign<Root>(to keyPath: ReferenceWritableKeyPath<Root, Self.Output>, on object: Root) -> AnyCancellable

Publisherから流れてくるデータをオブジェクトにバインディング(代入)する時に使います。

コード例はこちら

class MyClass {
    var anInt: Int = 0 {
        didSet {
            print("anInt was set to: \(anInt)", terminator: "; ")
        }
    }
}

var myObject = MyClass()
let myRange = (0...2)
let cancellable = myRange.publisher.assign(to: \.anInt, on: myObject)

// Prints: "anInt was set to: 0; anInt was set to: 1; anInt was set to: 2"

assign(to:)

https://developer.apple.com/documentation/combine/publisher/assign(to:)

func assign(to published: inout Published<Self.Output>.Publisher)

@Publishedで宣言した変数にPublisherから流れてきた値をバインディング(注入)することができます。AnyCancellabletoに指定したPublishserのオブジェクトが破棄される時に自動で破棄されます。

使用例はこちら

class MyModel: ObservableObject {
    @Published var lastUpdated: Date = Date()
    init() {
         Timer.publish(every: 1.0, on: .main, in: .common)
             .autoconnect()
             .assign(to: &$lastUpdated)
    }
}
class MyModel2: ObservableObject {
	 @Published var id: Int = 0
}
let model2 = MyModel2()
Just(100).assign(to: &model2.$id) // model2.id == 100

AnyPublisher

https://developer.apple.com/documentation/combine/anypublisher

@frozen struct AnyPublisher<Output, Failure> where Failure : Error

AnyPublisherPublisherの基本となる型です。

eraseToAnyPublisher

https://developer.apple.com/documentation/combine/publisher/erasetoanypublisher()

Subject(Publisher)の型を消去して(型をwrapして)AnyPublisherに変換します。

func eraseToAnyPublisher() -> AnyPublisher<Self.Output, Self.Failure>

eraseToAnyPublisherを使って型消去する理由

eraseToAnyPublisherの公式リファレンスより

原文

Use eraseToAnyPublisher() to expose an instance of AnyPublisher to the downstream subscriber, rather than this publisher’s actual type. 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.

要約

下流の購読者に AnyPublisher のインスタンスを公開するには、このパブリッシャーの実際の型ではなく、 eraseToAnyPublisher() を使用します。この形式の型の消去は、異なるモジュールなどの API の境界を越えて抽象化を維持します。パブリッシャーを AnyPublisher タイプとして公開すると、既存のクライアントに影響を与えることなく、基本的な実装を変更することができます。

原文

The following example shows two types that each have a publisher property. TypeWithSubject exposes this property as its actual type, PassthroughSubject, while TypeWithErasedSubject uses eraseToAnyPublisher() to expose it as an AnyPublisher. As seen in the output, a caller from another module can access TypeWithSubject.publisher as its native type. This means you can’t change your publisher to a different type without breaking the caller. By comparison, TypeWithErasedSubject.publisher appears to callers as an AnyPublisher, so you can change the underlying publisher type at will.

要約

次の例では、それぞれがパブリッシャーのプロパティを持つ2つのタイプを示しています。TypeWithSubject はこのプロパティを実際の型である PassthroughSubject として公開し、TypeWithErasedSubject は eraseToAnyPublisher() を使用して AnyPublisher として公開しています。出力に見られるように、他のモジュールからの呼び出し元は、TypeWithSubject.publisherをそのネイティブな型としてアクセスすることができます。つまり、呼び出し元を壊すことなくパブリッシャーを別の型に変更することはできません。それに比べて、TypeWithErasedSubject.publisher は呼び出し元には AnyPublisher として表示されるので、根本的なパブリッシャーのタイプを自由に変更することができます。

AnyPublisherの公式リファレンスより

原文

Use AnyPublisher to wrap a publisher whose type has details you don’t want to expose across API boundaries, such as different modules. Wrapping a Subject with AnyPublisher also prevents callers from accessing its send(_:) method. When you use type erasure this way, you can change the underlying publisher implementation over time without affecting existing clients.

要約

AnyPublisher は、異なるモジュールなど、API の境界を越えて公開したくない詳細情報を持つパブリッシャーをラップするために使用します。AnyPublisher で Subject をラップすると、呼び出し元が send(_:) メソッドにアクセスできなくなります。このようにして型の消去を行うと、既存のクライアントに影響を与えることなく、 徐々にパブリッシャーの実装を変更することができます。

原文

You can use Combine’s eraseToAnyPublisher() operator to wrap a publisher with AnyPublisher.

要約

Combine の eraseToAnyPublisher() 演算子を使って、パブリッシャーを AnyPublisher でラップすることができます。

つまり、

  • Publisherのインスタンスを公開するには実際の型ではなくeraseToAnyPublisher() を使用して抽象化した型を提供する
    • 抽象化されているので利用する側に影響を与えることなくPublisherの実装(具象)を変更することが可能になる
  • AnyPublisherはモジュール境界などを越えて公開したくない詳細情報を持つパブリッシャーをラップするために使用する(実装詳細を隠蔽する)
    • 例えばAnyPublisherでSubjectをラップするとsend(_:) メソッドにアクセスできなくすることができる

以下のコードを使って説明すると、TypeWithErasedSubjectは型が消去されPassthroughSubjectにキャストできないのでsendできません。

public class TypeWithSubject {
    public let publisher: some Publisher = PassthroughSubject<Int,Never>()
}
public class TypeWithErasedSubject {
    public let publisher: some Publisher = PassthroughSubject<Int,Never>()
        .eraseToAnyPublisher()
}

// In another module:
let nonErased = TypeWithSubject()
if let subject = nonErased.publisher as? PassthroughSubject<Int,Never> {
    print("Successfully cast nonErased.publisher.")
}
let erased = TypeWithErasedSubject()
if let subject = erased.publisher as? PassthroughSubject<Int,Never> {
    print("Successfully cast erased.publisher.")
}

// Prints "Successfully cast nonErased.publisher."

handleEvents(receiveSubscription:receiveOutput:receiveCompletion:receiveCancel:receiveRequest:)

Publisherに関するイベントを補足したい時に使う

https://developer.apple.com/documentation/combine/publisher/handleevents(receivesubscription:receiveoutput:receivecompletion:receivecancel:receiverequest:)

let integers = (0...2)
cancellable = integers.publisher
    .handleEvents(receiveSubscription: { subs in
        print("Subscription: \(subs.combineIdentifier)")
    }, receiveOutput: { anInt in
        print("in output handler, received \(anInt)")
    }, receiveCompletion: { _ in
        print("in completion handler")
    }, receiveCancel: {
        print("received cancel")
    }, receiveRequest: { (demand) in
        print("received demand: \(demand.description)")
    })
    .sink { _ in return }

// Prints:
//   received demand: unlimited
//   Subscription: 0x7f81284734c0
//   in output handler, received 0
//   in output handler, received 1
//   in output handler, received 2
//   in completion handler
  • 途中でcancel()した場合
let subject = PassthroughSubject<Int, Never>()
var cancellable = subject.handleEvents(receiveSubscription: { subs in
    print("Subscription: \(subs.combineIdentifier)")
}, receiveOutput: { anInt in
    print("in output handler, received \(anInt)")
}, receiveCompletion: { _ in
    print("in completion handler")
}, receiveCancel: {
    print("received cancel")
}, receiveRequest: { (demand) in
    print("received demand: \(demand.description)")
}).sink{ print($0) }
subject.send(1)
subject.send(2)
cancellable.cancel()
subject.send(3)

/*
---- PassthroughSubject
received demand: unlimited
in output handler, received 1
1
in output handler, received 2
2
received cancel
*/

print

https://developer.apple.com/documentation/combine/publisher/print(_:to:)

publisherのイベントをdebug-printしたい時に使う

以下のようなコードを書くと

model.locationPublisher().print("dump:location").sink { [weak self] locations in
		guard let self = self else { return }
    if let last = locations.last {
		    self.location = last
    }
}.store(in: &cancellables)

こんな感じでアウトプットされる(locationsにはCore Locationで取得したCLLocationの配列が入っていると仮定)

dump:location: receive subscription: (PassthroughSubject)
dump:location: request unlimited
dump:location: receive value: ([<+37.33442613,-122.06864035> +/- 5.00m (speed 33.67 mps / course 266.13) @ 5/23/21, 3:34:00 PM Japan Standard Time])
dump:location: receive value: ([<+37.33442613,-122.06864035> +/- 5.00m (speed 33.67 mps / course 266.13) @ 5/23/21, 3:34:02 PM Japan Standard Time])

Convenience Publisher

Just

1回限りのPublisher

Just(100).sink(...)

みたいな感じでなんでもPublisherにできる

func twice(_ value: Int) -> Int { return value * 2 }
Just(2).map{ twice($0) }.sink { print($0) }

/* Prints:
4
*/

Future

非同期処理をCombine化する時に使う

let f = Future<String, Never> { promise in
    print("in Future")
    promise(.success("Future success"))
}.eraseToAnyPublisher()
print("subscribe f")
f.sink { print ("Future value: \($0)") }

/* Prints:
in Future
subscribe f
Future value: Future success
*/

Deferred

let d = Deferred {
    return Future<String, Never> { promise in
        print("in Deferred")
        promise(.success("Future success"))
    }
}.eraseToAnyPublisher()
print("subscribe d")
d.sink { print ("Deferred value: \($0)") }

/* Prints:
subscribe d
in Deferred
Deferred value: Future success
*/

Operator

PublisherのInstance Method

https://developer.apple.com/documentation/combine/currentvaluesubject-publisher-operators

map

https://developer.apple.com/documentation/combine/publisher/map(_:)-99evh

func map<T>(_ transform: @escaping (Self.Output) -> T) -> Publishers.Map<Self, T>
let numbers = [5, 4, 3, 2, 1, 0]
let romanNumeralDict: [Int : String] =
   [1:"I", 2:"II", 3:"III", 4:"IV", 5:"V"]
cancellable = numbers.publisher
    .map { romanNumeralDict[$0] ?? "(unknown)" }
    .sink { print("\($0)", terminator: " ") }

// Prints: "V IV III II I (unknown)"

compactMap

let numbers = (0...5)
let romanNumeralDict: [Int : String] =
    [1: "I", 2: "II", 3: "III", 5: "V"]

cancellable = numbers.publisher
    .compactMap { romanNumeralDict[$0] }
    .sink { print("\($0)", terminator: " ") }

last

let numbers = (-10...10)
cancellable = numbers.publisher
    .last()
    .sink { print("\($0)") }

// Prints: "10"

Subscriber

https://developer.apple.com/documentation/combine/processing-published-elements-with-subscribers

A protocol that declares a type that can receive input from a publisher.
// Publisher: Uses a timer to emit the date once per second.
let timerPub = Timer.publish(every: 1, on: .main, in: .default)
    .autoconnect()

// Subscriber: Waits 5 seconds after subscription, then requests a
// maximum of 3 values.
class MySubscriber: Subscriber {
    typealias Input = Date
    typealias Failure = Never
    var subscription: Subscription?

    func receive(subscription: Subscription) {
        print("published                             received")
        self.subscription = subscription
        DispatchQueue.main.asyncAfter(deadline: .now() + 5) {
            subscription.request(.max(3))
        }
    }

    func receive(_ input: Date) -> Subscribers.Demand {
        print("\(input)             \(Date())")
        return Subscribers.Demand.none
    }

    func receive(completion: Subscribers.Completion<Never>) {
        print ("--done--")
    }
}

// Subscribe to timerPub.
let mySub = MySubscriber()
print ("Subscribing at \(Date())")
timerPub.subscribe(mySub)
Subscribing at 2019-12-09 18:57:06 +0000
published                             received
2019-12-09 18:57:11 +0000             2019-12-09 18:57:11 +0000
2019-12-09 18:57:12 +0000             2019-12-09 18:57:12 +0000
2019-12-09 18:57:13 +0000             2019-12-09 18:57:13 +0000

Other

AnyCancelable

https://developer.apple.com/documentation/combine/anycancellable

store

  • storeはAnyCancellableのインスタンスメソッド

https://developer.apple.com/documentation/combine/anycancellable/store(in:)-3hyxs

final func store(in set: inout Set<AnyCancellable>)
  • assign/sinkとstoreの違い
    • assign/sinkはPublisherのインスタンスメソッド
      • 戻り値はAnyCancellable
    • storeはAnyCancelableのインスタンスメソッド
    • cancelable = subject.sink()
    • subject.sink().store()
    • subject.assign().store()
import Combine

let subject = PassthroughSubject<Int, Never>()
var cancellable = subject.sink{ print($0) }
subject.send(1)
subject.send(2)
cancellable.cancel()
subject.send(3)

/* Prints:
1
2
*/

@Published

https://developer.apple.com/documentation/combine/published

class Weather {
    @Published var temperature: Double
    init(temperature: Double) {
        self.temperature = temperature
    }
}

let weather = Weather(temperature: 20)
cancellable = weather.$temperature
    .sink() {
        print ("Temperature now: \($0)")
}
weather.temperature = 25

// Prints:
// Temperature now: 20.0
// Temperature now: 25.0
import Combine

final class CityViewModel {
    @Published var city: String = ""
    private var disposables = Set<AnyCancellable>()
    init() {
        $city.dropFirst().sink(receiveValue: { print("\($0), 2020") }).store(in: &disposables)
    }
    deinit {
        disposables.removeAll()
    }
}

let viewModel = CityViewModel()
viewModel.city = "Tokyo"

/* Prints:
 Tokyo, 2020
 */
import Combine

final class ViewModel {
    @Published var city: String = ""
}

let viewModel = ViewModel()
var disposables = Set<AnyCancellable>()
viewModel.$city.dropFirst().sink(receiveValue: { print("\($0), 2020") }).store(in: &disposables)

viewModel.city = "Tokyo"

disposables.removeAll()

/* Prints:
 Tokyo, 2020
 */

未整理

非同期処理にCombineを活用する

https://developer.apple.com/documentation/combine/using-combine-for-your-app-s-asynchronous-code

よくあるパターン

値を更新する側でPublisherを生成する
値を購読する側でPublisherをSubscribeする
値が更新されたらsendする

  • 準備
    • subject(publisher)としてCurrnetValueSubjectPassthroughtSubjectを用意する
    • 戻り値がAnyPublisherの関数を用意する
      • eraseToAnyPublisher()してsubjectをreturnする
  • 購読
    • 利用する側で購読する
      • sink
    • 終了時はAnyCancellablesを使ってremoveする
  • 更新
    • subjectに対してsend() or valueを使って値を更新する

Combineの何が嬉しいのか

  • 非同期処理でデータを処理するケースでは事前に値が決まらない
  • CombineはPublisherが用意されていてPublisherに対して手続き的な(宣言的な?)処理が書ける
  • 汎用インタフェース
    • メソッドチェーンができる
    • mapなどの処理ができる
    • 型を途中で変換できる

もうちょっと具体的に

  • DelegateパターンのようなケースでConsumer?(データを処理する側)が同期的な処理をかける
  • Provider?側に依存関係を持たせることなく非同期処理できる

@Stateや@Bindingの理解

$が付いた同名の変数(Publisher)が内部的に生成される
@State var foo: String$fooでPublisherにアクセスできる
_ = $foo.sink()すると変数の初期値が飛んでくる
値を更新foo = "hoge"するとイベントが飛んでくる

Discussion