KMM開発においてSwift側からKotlinのFlowを扱う方法
- #KMM
- #Android
- #iOS
- #Kotlin
- #Swift
はじめに
KMMを用いたクロスプラットフォーム開発においてデータバインディング等を前提としたMVVMなどのアーキテクチャを採用しようとすると、Kotlin CoroutinesのFlowを使うことになると思います。
この共有モジュールのFlowをSwift側から購読しようとした場合に乗り越えなければいけない課題として
- Flowの購読処理をSwift側からキャンセルできるようにする
- 購読処理のオペレータをSwift側でも使えるようにする
というものがあります。この課題を解決するための方法を調べたのでここでまとめておこうと思います。
以下、↓のコードから引用させていただきます。
// https://johnoreilly.dev/posts/kotlinmultiplatform-swift-combine_publisher-flow/
import Combine
import Foundation
import shared
typealias OnEach<Output> = (Output) -> Void
typealias OnCompletion<Failure> = (Failure?) -> Void
typealias OnCollect<Output, Failure> = (@escaping OnEach<Output>, @escaping OnCompletion<Failure>) -> shared.Cancellable
/**
Creates a `Publisher` that collects output from a flow wrapper function emitting values from an underlying
instance of `Flow<T>`.
*/
func collect<Output, Failure>(_ onCollect: @escaping OnCollect<Output, Failure>) -> Publishers.Flow<Output, Failure> {
return Publishers.Flow(onCollect: onCollect)
}
typealias OnCollect1<T1, Output, Failure> = (T1, @escaping OnEach<Output>, @escaping OnCompletion<Failure>) -> shared.Cancellable
/**
Creates a `Publisher` that collects output from a flow wrapper function emitting values from an underlying
instance of `Flow<T>`.
*/
func collect<T1, Output, Failure>(_ onCollect: @escaping OnCollect1<T1, Output, Failure>, with arg1: T1) -> Publishers.Flow<Output, Failure> {
return Publishers.Flow { onCollect(arg1, $0, $1) }
}
/**
Wraps a KMM `Cancellable` in a Combine `Subscription`
*/
class SharedCancellableSubscription: Subscription {
private var isCancelled: Bool = false
var cancellable: shared.Cancellable? {
didSet {
if isCancelled {
cancellable?.cancel()
}
}
}
func request(_ demand: Subscribers.Demand) {
// Not supported
}
func cancel() {
isCancelled = true
cancellable?.cancel()
}
}
extension Publishers {
class Flow<Output, Failure: Error>: Publisher {
private let onCollect: OnCollect<Output, Failure>
init(onCollect: @escaping OnCollect<Output, Failure>) {
self.onCollect = onCollect
}
func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input {
let subscription = SharedCancellableSubscription()
subscriber.receive(subscription: subscription)
let cancellable = onCollect({ input in _ = subscriber.receive(input) }) { failure in
if let failure = failure {
subscriber.receive(completion: .failure(failure))
} else {
subscriber.receive(completion: .finished)
}
}
subscription.cancellable = cancellable
}
}
}
Flowの購読処理をSwift側からキャンセル
ドキュメントにあるようにKotlinのsuspend functionはObjective-Cにコールバック形式でマッピングされます。またSwift側でCoroutineを起動したりキャンセルしたりといったことができません。
そのためSwift側でFlowを直接使用することができません。この問題を解決するために、Kotlin側でinterfaceを作ります。
interface Cancellable {
fun cancel()
}
fun <T> Flow<T>.collect(onEach: (T) -> Unit, onCompletion: (cause: Throwable?) -> Unit): Cancellable {
val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
scope.launch {
try {
collect {
onEach(it)
}
onCompletion(null)
} catch (e: Throwable) {
onCompletion(e)
}
}
return object : Cancellable {
override fun cancel() {
scope.cancel()
}
}
}
このようにKotlin側でCoroutineを起動してFlowを購読して、かつそれをキャンセルすることができるCancellableというinterfaceの実装を返す関数を作ります。そうするとSwift側からはcancel()を呼ぶだけで購読処理をキャンセルすることができるようになります。この時、onCompletionの引数にThrowable?を渡すことでExceptionをキャッチしたときにそれをSwift側に渡すことができます。
map等のオペレータをSwift側から使用
結論から言うと、FlowをCombineのPublisherに変換することでオペレータを使うことができるようにします。
typealias OnEach<Output> = (Output) -> Void
typealias OnCompletion<Failure> = (Failure?) -> Void
typealias OnCollect<Output, Failure> = (@escaping OnEach<Output>, @escaping OnCompletion<Failure>) -> shared.Cancellable
func collect<Output, Failure>(_ onCollect: @escaping OnCollect<Output, Failure>) -> Publishers.Flow<Output, Failure> {
return Publishers.Flow(onCollect: onCollect)
}
上のように、OnCollectというクロージャのtypealiasを定義します。これは、
- 引数を受け取ってVoidを返すOnEach
- Optionalの引数をとってVoidを返すOnCompletion
という2つのクロージャを引数に取り、先ほど共有モジュール側でかいたCancellableというinterface(Swiftのprotocolに変換されている)の実装を返す形になっています。これがまさに一つ前のセクションでFlowから拡張させて作ったcollect関数を引数として受け取れる形になっています。
class SharedCancellableSubscription: Subscription {
private var isCancelled: Bool = false
var cancellable: shared.Cancellable? {
didSet {
if isCancelled {
cancellable?.cancel()
}
}
}
func request(_ demand: Subscribers.Demand) {
// Not supported
}
func cancel() {
isCancelled = true
cancellable?.cancel()
}
}
extension Publishers {
class Flow<Output, Failure: Error>: Publisher {
private let onCollect: OnCollect<Output, Failure>
init(onCollect: @escaping OnCollect<Output, Failure>) {
self.onCollect = onCollect
}
func receive<S>(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input {
let subscription = SharedCancellableSubscription()
subscriber.receive(subscription: subscription)
let cancellable = onCollect({ input in _ = subscriber.receive(input) }) { failure in
if let failure = failure {
subscriber.receive(completion: .failure(failure))
} else {
subscriber.receive(completion: .finished)
}
}
subscription.cancellable = cancellable
}
}
}
次にKotlin側のCancellableをCombineのSubscriptionでラップするSharedCancellableSubscriptionクラスを作ります。最後にそれをSubscriptionとするPublisherの拡張クラスであるFlowを作成して、Kotlin側のFlowを購読できるようになりました。これでFlowをPublisherに変換することができたので、Combineのオペレータを使うことができるようになります。
補足
SharedCancellableSubscriptionのcancellableにおいてdidSetでcancellableをキャンセルしている理由がわからなかったのですが、おそらく一度キャンセルされたSubscriptionを再び購読することができないようにするためだと思います。ここが一番自信ないので、もし間違っていたら教えていただきたいです🙏
終わりに
普段Android開発をメインにしていてSwift自体に不慣れなので、何か間違いがあったら優しくご教授いただきたいです(マサカリが怖すぎて保険をかけました)。個人的にKotlinとSwiftをかき分けるKMMの開発スタイルがめちゃくちゃかっこいいなと思っているので、今後どんどん深くまで理解できるようになりたいです。
KMM開発のアーキテクチャも色々調べて考えたのでいつか記事にしたいです。