【Swift/Combine】combineLatestで複数条件を監視する方法!mergeやzipとの違い

この記事からわかること
- SwiftのCombineフレームワークの使い方
- combineLatestメソッドとは?
- 複数のpublisherを1つにまとめて監視する方法
- mergeやzipとの違いや使い分け
index
[open]
\ アプリをリリースしました /
環境
- Xcode:15.0.1
- iOS:17.1
- Swift:5.9
- macOS:Sonoma 14.1
combineLatestメソッドの使い方
func combineLatest<P>(_ other: P) -> Publishers.CombineLatest<Self, P> where P : Publisher, Self.Failure == P.Failure
combineLatest
は複数のpublisherをまとめて1つのpublisherにして管理することができるメソッドです。例としてバリデーションロジックを実装する時など「全てがtrueの場合のみ処理を実装」する場合で考えてみます。
combineLatest
メソッドは1つのpublisherから呼び出し、引数に一緒に観測させたいpublisherを渡します。まとめた後のオペレーターからはタプルで流れてくるそれぞれの値を参照することが可能ですが、map
などを使用して1つの値を流すように変換すると見通しが良くなります。
let subjectA: PassthroughSubject<Bool, Never> = .init()
let subjectB: PassthroughSubject<Bool, Never> = .init()
let subjectC: PassthroughSubject<Bool, Never> = .init()
let cancellable = subjectA
.combineLatest(subjectB, subjectC)
.map { valueA, valueB, valueC in
return valueA && valueB && valueC
}
.sink { value in
if value {
print("All subjects are true.")
}
}
subjectA.send(true)
subjectB.send(false)
subjectC.send(true)
combineLatest
メソッドでまとめると全てのpublisherから値が流れてこないと処理が走りません。以下のようにsubjectC
だけsend
メソッドが送られていない状態の場合はその後のオペレーターなどは動作しなくなります。
subjectA.send(true)
subjectB.send(false)
全ての値が一度でも流れていれば、それ以降はいずれかの値が流れてくるたびに動作します。
subjectA.send(true)
subjectB.send(false)
subjectC.send(true)
sleep(3)
subjectB.send(true) // このタイミングで All subjects are true. が出力される
publisherのデータ型はバラバラでもOK
publisher
が保持するデータ型が異なっていても正常に動作します。
let subjectA: PassthroughSubject<Bool, Never> = .init()
let subjectB: PassthroughSubject<Int, Never> = .init()
let subjectC: PassthroughSubject<Bool, Never> = .init()
let cancellable = subjectA
.combineLatest(subjectB, subjectC)
.sink { valueA, valueB, valueC in
print("\(valueA)/\(valueB)/\(valueC)")
}
subjectA.send(true)
subjectB.send(0)
subjectC.send(true)
まとめられる最大個数
public func combineLatest<P, Q, R>(_ publisher1: P, _ publisher2: Q, _ publisher3: R) -> Publishers.CombineLatest4<Self, P, Q, R> where P : Publisher, Q : Publisher, R : Publisher, Self.Failure == P.Failure, P.Failure == Q.Failure, Q.Failure == R.Failure
combineLatest
は引数の個数違いで複数定義されていますが、用意されているのは最大3個までのようです。
let subjectA: PassthroughSubject<Bool, Never> = .init()
let subjectB: PassthroughSubject<Int, Never> = .init()
let subjectC: PassthroughSubject<Bool, Never> = .init()
let subjectD: PassthroughSubject<Int, Never> = .init()
let cancellable = subjectA
.combineLatest(subjectB, subjectC, subjectD)
.sink { valueA, valueB, valueC, valueD in
print("\(valueA)/\(valueB)/\(valueC)/\(valueD)")
}
subjectA.send(true)
subjectB.send(0)
subjectC.send(true)
subjectD.send(1)
3個以上をまとめたい場合はcombineLatest
を再度呼び出せばsink
の引数ではタプルになってしまいますが、結合させることができます。
let subjectA: PassthroughSubject<Bool, Never> = .init()
let subjectB: PassthroughSubject<Bool, Never> = .init()
let subjectC: PassthroughSubject<Bool, Never> = .init()
let subjectD: PassthroughSubject<Bool, Never> = .init()
let subjectE: PassthroughSubject<Bool, Never> = .init()
let subjectF: PassthroughSubject<Bool, Never> = .init()
let subjectG: PassthroughSubject<Bool, Never> = .init()
let cancellable = subjectA
.combineLatest(subjectB, subjectC, subjectD)
.combineLatest(subjectE, subjectF, subjectG)
.sink { valueAtoD, valueE, valueF, valueG in
print("\(valueAtoD)/\(valueE)/\(valueF)/\(valueG)")
// (true, true, true, true)/true/true/true
}
subjectA.send(true)
subjectB.send(true)
subjectC.send(true)
subjectD.send(true)
subjectE.send(true)
subjectF.send(true)
subjectG.send(true)
エラー型が異なる場合は連結できない
連結したいpublisher
のエラー型が異なる場合はコンパイルエラーが発生します。
class MyError: Error { }
class TestError: Error { }
let subjectA: PassthroughSubject<Bool, MyError> = .init()
let subjectB: AnyPublisher<Int, Never> = Just(0).eraseToAnyPublisher()
let subjectC: AnyPublisher<Bool, TestError> = Just(false).setFailureType(to: TestError.self).eraseToAnyPublisher()
// Instance method 'combineLatest' requires the types 'MyError' and 'Never' be equivalent
let cancellable = subjectA
.combineLatest(subjectB2, subjectC2)
.sink { error in
print(error)
} receiveValue: { valueA, valueB, valueC in
print("\(valueA)/\(valueB)/\(valueC)")
}
そのためsetFailureType
やmapError
などを使用して適切にエラーの型を合わせてあげる必要があります。
let subjectB2 = subjectB.setFailureType(to: MyError.self)
let subjectC2 = subjectC.mapError { _ in MyError() }
let cancellable = subjectA
.combineLatest(subjectB2, subjectC2)
.sink { error in
print(error)
} receiveValue: { valueA, valueB, valueC in
print("\(valueA)/\(valueB)/\(valueC)")
}
エラー型の統一は後述するmerge
やzip
、flatMap
などでも同じです。
並列実行
時間のかかるpublisher
だと把握しやすいですがcombineLatest
で連結させると並列処理でpublisherを実行することが可能です。
print("START")
func slowPublisher(_ delay: Double) -> AnyPublisher<String, Never> {
return Future { promise in
DispatchQueue.global().asyncAfter(deadline: .now() + delay) {
print("delay", delay)
promise(.success("Result from Publisher \(delay)"))
}
}.eraseToAnyPublisher()
}
let cancellable = slowPublisher(3)
.combineLatest(slowPublisher(5))
.sink { result1, result2 in
print("Both publishers have completed!")
}
そのためslowPublisher(3)
とslowPublisher(5)
は同時にカウントが始まるので出力は以下のようになります。
0秒:START
3秒:delay 3.0
5秒:delay 5.0
5秒:Both publishers have completed!
mergeやzipとの違いと使い分け
似たようなpublisherをまとめるメソッドにmerge
やzip
があります。それぞれの違いは以下の通りです。
- combineLatest:最新の値を使って結合。最低でも1回は値が送信されるまで結合されない。
- merge:複数のpublisherからのイベントを単一のpublisherに順次結合
- zip:対応する位置の値をペアにして出力。最小のpublisherが完了すると結合
mergeメソッド
func merge<P>(with other: P) -> Publishers.Merge<Self, P> where P : Publisher, Self.Failure == P.Failure, Self.Output == P.Output
mergeメソッドは複数のpublisherからのイベントを単一のpublisherに順次結合するメソッドです。実行してみるとわかりますが、複数のpublisherをmergeしても流れてくる値は1つのみです。つまり両方のイベントが発火するタイミングで同じpublisherから値が観測することができるので全てのpublisherの値が揃うのを待つわけではありません。
let publisher1 = PassthroughSubject<Int, Never>()
let publisher2 = PassthroughSubject<Int, Never>()
let cancellable = publisher1
.merge(with: publisher2)
.sink { value in
print(value)
}
publisher1.send(1) // 出力: 1
print("-----")
publisher2.send(2) // 出力: 2
そのため出力は以下のようになります。
出力: 1
-----
出力: 2
Output型は統一する
merge
メソッドはOutput
が1つにまとめられてしまうのでOutput
型を統一する必要があります。異なるOutput
型でmerge
しようとするとNo exact matches in call to instance method 'merge'
というコンパイルエラーが発生するのでmap
などを使用してOutput
型を変換する必要があります。
let publisher1 = PassthroughSubject<Int, Never>()
let publisher2 = PassthroughSubject<String, Never>()
let publisher3 = publisher2.map { _ in 0 }
let cancellable = publisher1
.merge(with: publisher3)
.sink { value in
print(value)
}
zipメソッド
func zip<P>(_ other: P) -> Publishers.Zip<Self, P> where P : Publisher, Self.Failure == P.Failure
zip
メソッドは複数のpublisherに対応する位置の値をペアにして出力します。値が流れるのはzip
でまとめている値が全て揃った時です。
let publisher1 = PassthroughSubject<Int, Never>()
let publisher2 = PassthroughSubject<String, Never>()
let publisher3 = PassthroughSubject<Bool, Never>()
let cancellable = publisher1
.zip(publisher2, publisher3)
.sink { value in
print(value)
}
publisher1.send(1)
publisher2.send("A")
publisher3.send(false) // 出力: (1, "A", false)
値が一度流れると再度全ての値が揃うまで動作することはありません。例えば以下のようなイベントの流れ方をした場合は「11行目の3つの値が揃ったタイミング」で値が流れます。
publisher1.send(1)
publisher2.send("A")
publisher3.send(false) // 出力: (1, "A", false)
sleep(2)
publisher1.send(2)
sleep(2)
publisher2.send("B")
publisher3.send(true) // 出力: (2, "B", true)
まだまだ勉強中ですので間違っている点や至らぬ点がありましたら教えていただけると助かります。
ご覧いただきありがとうございました。