iOSのCombine学習メモ
これはなに
iOSの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
EssentialsではNotificationCenter
の例を使ってCombineの基本動作を説明しています。
Publisher
Publisherの主要な登場人物について
Subject
https://developer.apple.com/documentation/combine/subject
protocol Subject : AnyObject, Publisher
Subject
はPublisherを継承したプロトコルです。send(_:)
メソッドを呼び出してストリームに値を注入
することができます。既存の命令的なコードをCombineの宣言的なモデルへ適応させるのに適しています。
代表的なSubjectとしてCurrentValueSubjectとPassthroughSubjectが用意されています。
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
PassthroughSubject
はCurrentValueSubject
とは異なり値を保持しません。値を保持する必要なく毎回データを更新(上書き)するような場合は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のインスタンスメソッドの代表的なものとしてsink
とassign
があります。
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:)
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のFailure
がNever
ではなく、エラーハンドリングが必要な場合はこちらを使用します。こちらのケースも戻り値の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
から流れてきた値をバインディング(注入)することができます。AnyCancellable
はto
に指定した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
AnyPublisher
はPublisher
の基本となる型です。
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(_:)
メソッドにアクセスできなくすることができる
- 例えばAnyPublisherでSubjectをラップすると
以下のコードを使って説明すると、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に関するイベントを補足したい時に使う
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
*/
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のインスタンスメソッド
- assign/sinkはPublisherのインスタンスメソッド
- 例
- 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)として
CurrnetValueSubject
やPassthroughtSubject
を用意する - 戻り値が
AnyPublisher
の関数を用意する- eraseToAnyPublisher()してsubjectをreturnする
- subject(publisher)として
- 購読
- 利用する側で購読する
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