iOSのCombine学習メモ

23 min read読了の目安(約20700字

これはなに

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.

  • 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.
  • 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.

Combineとは

  • Publisher
    • 時系列に沿って値を送信(デリバリー)する
  • Subscriber
    • チェーンの最後で値を受け取る
    • サブスクライバーが受信を要求して初めてパブリッシャーは値を出力する
    • パブリッシャーからイベントを受け取る速度を制御する

Essentials

Receiving and Handling Events with Combine

Apple Developer Documentation

Publisher

Subject

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

A subject is a publisher that you can use to ”inject” values into a stream, by calling its send(_:) method. This can be useful for adapting existing imperative code to the Combine model.

protocol Subject : AnyObject, Publisher

Subjectはsend()を使って値を送信することができる

代表的なのSubjectとしてCurrentValueSubjectPassthroughSubjectが用意されている

CurrentValueSubject

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

final class CurrentValueSubject<Output, Failure> where Failure : Error
A subject that wraps a single value and publishes a new element whenever the value changes.

Publisherで値を保持したい時に使う、init(Output)で初期値を与える

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

// dropFirst()を外すと初期値が表示される

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

PassthroughSubject

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

final class PassthroughSubject<Output, Failure> where Failure : Error
A subject that broadcasts elements to downstream subscribers.

PassthroughSubjectは値を保持しない、そのためinit()にパラメータがない

値の保持が必要なく、毎回データを更新(上書き)するような場合はPassthroughSubjectを使うとよい

let subject = PassthroughSubject<Int, Never>()
var cancellable = subject.sink{ print($0) }
subject.send(1)
subject.send(completion: .finished)

/* Prints:
1
*/

Tips: downstream, upstream

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

違うかも??

Instance Method

Publisherのインスタンスメソッド、代表的なものはsinkassign

Combine provides two built-in subscribers, which automatically match the output and failure types of their attached publisher:

  • sink(receiveCompletion:receiveValue:) takes two closures. The first closure executes when it receives Subscribers.Completion, which is an enumeration that indicates whether the publisher finished normally or failed with an error. The second closure executes when it receives an element from the publisher.
  • assign(to:on:) immediately assigns every element it receives to a property of a given object, using a key path to indicate the property.

sink(receiveValue:)

built-in subscribers

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

Attaches a subscriber with closure-based behavior to a publisher that never fails.

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

使い方

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:)

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

PublisherのFailureNeverじゃないときはこちらを使う(エラーハンドリングする)

assign(to:on:)

built-in subscribers

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なオブジェクトの$変数にバインディングすることができる

assing(to: )の戻り値はVoid、toに指定したPublshedな変数とともに管理されてそのオブジェクトが破棄される時に自動で破棄される

Use this operator when you want to receive elements from a publisher and republish them through a property marked with the @Published attribute. The assign(to:) operator manages the life cycle of the subscription, canceling the subscription automatically when the Published instance deinitializes. Because of this, the assign(to:) operator doesn’t return an AnyCancellable that you’re responsible for like assign(to:on:) does.

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

A publisher that performs type erasure by wrapping another publisher.

Publisherの基本となる型

eraseToAnyPublisher

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

Subjectの型を消去して(型を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.

DeepL

下流の購読者に 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.

DeepL

次の例では、それぞれがパブリッシャーのプロパティを持つ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.

DeepL

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

原文

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

DeepL

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"するとイベントが飛んでくる