25
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【KMM】suspend funで定義された通信処理をSwift側でキャンセルできるようにするためにどう実装するか

Last updated at Posted at 2021-01-23

まずはじめに

Kotlin Multiplatformを利用する機会があったのですが、Swiftから利用できるようにする上で「どのように導入するか」や「試してみた」という参考文献はいくつか存在するかと思います。
しかし、Swiftから利用できるようにするためにもう一歩踏み込んだ実装詳細が記載された参考文献はあまり見つからなかったので、備忘録として投稿していきます。
本投稿では、suspend funで定義された通信処理をSwift側でキャンセルできるようにするためにどう実装するかに絞って記載しています。
※本投稿で利用しているKotlinはv1.4.xで、通信処理にはKtor v1.5.xを利用しています。

suspend funで定義された関数をSwiftで利用した場合に

以下のようなGitHubのApiからユーザーIDをもとにユーザー情報を取得するInterfaceを定義したとします。

GitHubApi.kt
interface GitHubApi {
    @Throws(GitHubApiException::class, CancellationException::class)
    suspend fun getUser(userId: String): User
}

上記の定義をSwiftで利用した場合に以下のような実装ができます。

let gitHubApi: GitHubApi = GitHubApiImpl()
gitHubApi.getUser(
    userId: "marty-suzuki",
    completionHandler: { (user: User?, error: Error?) in
        //userとerrorを利用して後続の処理を実施
    }
)

Kotlin/Native interoperability with Swift/Objective-C Mappingsにあるように、関数の定義はgetUser(userId: String, completionHandler: (User?, Error?) -> Void)となり、suspend funの返り値@Throwsで指定されているExceptionがcompletionHandlerのクロージャの引数として取り得る値となります。

suspend funの処理をSwift側でキャンセルするには

上記の実装を利用した場合に、ユーザー情報を取得中に画面が破棄された際の処理はどのよう実装すれば良いでしょうか。
Swiftから利用できるのはgetUser(userId: String, completionHandler: (User?, Error?) -> Void)なので、実装できたとしても画面が破棄さているため描画はされないが通信処理は実行されているという状態になってしまうかと思います。

KtorのIosClientEngineの実装

そもそも通信処理をキャンセルできるのかを確認するために、通信処理に利用しているKtorの実装を見ていきます。
iOSの通信処理に利用されるのはIosClientEngineとなり、以下のような実装となっています。

IosClientEngine.kt
internal class IosClientEngine(override val config: IosClientEngineConfig) : HttpClientEngineBase("ktor-ios") {
   ...
   override suspend fun execute(data: HttpRequestData): HttpResponseData {
        val callContext = callContext()
        val responseReader = IosResponseReader(callContext, data, config)
        ...
        return try {
            responseReader.awaitResponse()
        } catch (cause: CancellationException) {
            if (task.state == NSURLSessionTaskStateRunning) {
                task.cancel()
            }
            throw cause
        }
   }
}

responseReader.awaitResponse()で通信処理の完了を待っており、キャンセルのExceptionが投げられてURLSessionTaskが実行中だった場合はキャンセルする処理となっています。
つまり内部実装的には通信処理をキャンセルできるようになっているので、Swiftから利用する場合の定義にキャンセルが可能なインターフェースがないことが課題であることがわかります。

suspend funをラップする

上記でも述べたようにKotlinでsuspend funとして定義した場合は、Swiftからは返り値がVoidで引数にcompletionHandlerを取る定義しか呼び出せません。
キャンセル可能なインターフェースにするためにはどのような実装にすれば良いのでしょうか。
参考になる記事を探していたところ、Working with Kotlin Coroutines and RxSwiftに記載されている内容が実現したい処理だったので、こちらの記事をベースに必要な部分を実装していきます。

前提として、CoroutineScopeのlaunch関数を利用してsuspend funを実行すると、launch関数の返り値のJobを利用してキャンセルをすることができます。

val job = GlobalScope.launch {
    gitHubApi.getUser("marty-suzuki")
}
job.cancel()

つまり、Jobから処理をキャンセルできることを利用してsuspend funをラップするクラスを実装することで、Swiftからでも処理をキャンセルする定義を呼び出すことができるようになります。
suspend funをラップした実装(以降SuspendWrapper)は以下になります。

SuspendWrapper.kt
class SuspendWrapper<T>(private val suspender: suspend () -> T) {
    // Kotlinからsuspend funとして呼び出せるようにするための関数
    suspend fun suspend() = suspender()

    // Swiftから呼び出す処理をキャンセルできるようにする関数
    fun subscribe(
        onSuccess: (item: T) -> Unit,
        onThrow: (error: Throwable) -> Unit
    ): Cancelable {
        val job = CoroutineScope(Dispatchers.Main).launch {
            try {
                onSuccess(suspender())
            } catch (error: Throwable) {
                onThrow(error)
            }
        }

        return object: Cancelable {
            override fun cancel() {
                job.cancel()
            }
        }
    }
}

interface Cancelable {
    fun cancel()
}

SuspendWrapperのconstructorにsuspend funのlambdaを引数とします。
関数の定義としては

  • Swiftからsuccessとexceptionをcallbackとしてハンドリングできるようにしつつ、キャンセル可能なインターフェースを返り値とするfun
  • Kotlinからそのまま利用できるようにするためのsuspend fun

の2つとなります。

GitHubApiにSuspendWrapperを適用すると以下のようなInterfaceとなります。

GitHubApi.kt
interface GitHubApi {
    fun getUser(userId: String): SuspendWrapper<User>
}

そして、Swift側から上記を呼び出すと、以下のようにsuccessとexceptionをハンドリングしつつ、キャンセルを実行することができるようになります。

let cancelable = gitHubApi.getUser(userId: "marty-suzuki")
    .subscribe(
        onSuccess: { (user: User?) in
            ...
        },
        onThrow: { (throwable: KotlinThrowable) in
            ...
        }
    )

cancelable.cancel()

余談

本投稿の本筋とは若干それるのですが、備忘録として余談も記載していきます。

テストでSuspendWrapperをどう注入するか

Swiftから呼び出し場合にSuspendWrapperのInitializerの定義はSuspendWrapper(suspender: KotlinSuspendFunction0)となっています。
KotlinSuspendFunction0はObjective-Cで定義されたprotocolであるため、associatedTypeを持っていません。
よってテストなどでSuspendWrapperを介してFakeを渡す場合に、型が違っていたとしてもランタイムでわかるという形になってしまいます。
そこで、KotlinSuspendFunction0を採用した型パラメータを利用できるSuspendFunctionのFakeを実装することで、Swift側でもコンパイル時に代入ミスなどがエラーで気づけるようにできます。

FakeSuspendFunction.swift
final class FakeSuspendFunction<T: AnyObject>: KotlinSuspendFunction0 {
    private let result: Result<T, Error>

    init(_ result: Result<T, Error>) {
        self.result = result
    }

    func invoke(completionHandler: @escaping (Any?, Error?) -> Void) {
        switch result {
        case let .success(value):
            completionHandler(value, nil)
        case let .failure(error):
            completionHandler(nil, error)
        }
    }

    func asSuspendWrapper() -> SuspendWrapper<T> {
        SuspendWrapper(suspender: self)
    }
}
FakeGitHubApi.kt
class FakeGitHubApi: GitHubApi {
    var user = SuspendWrapper<User> { User("") } 
    
    override fun getUser(userId: String) = user
}
let fakeApi = FakeGitHubApi()
let fakeUser = FakeSuspendFunction(.success(User(userId: "marty-suzuki")))
fakeApi.user = fakeUser.asSuspendWrapper()

RxSwiftで使いやすくするために

現在Kotlin Multiplatformを導入しようとしているプロジェクトではRxSwiftを利用しています。
SuspendWrapper(suspend fun自体も)は処理を1度だけ実行して結果を返します。
その性質をRxSwiftのSingleと同様なため、以下のように変換することができます。

enum KotlinError: Error {
    case invalidResponse
    case throwable(KotlinThrowable)
}

extension Single where Element: AnyObject {
    static func create(_ suspendWrapper: SuspendWrapper<Element>) -> Single<Element> {
        Single.create { observer in
            let cancalable = suspendWrapper.subscribe(
                onSuccess: {
                    observer($0.map(SingleEvent.success) ?? .failure(KotlinError.invalidResponse))
                },
                onThrow: {
                    observer(.failure(KotlinError.throwable($0)))
                }
            )
            return Disposables.create {
                cancalable.cancel()
            }
        }
    }
}

Kotlin Multiplatformで生成された実装はObjective-Cとなるため、Objective-CでGenericに定義されたクラスの型パラメータをSwift側のextensionから呼び出すことはできません。
そのため、SuspendWrapper<T>.asSingle()のような実装が容易にできないため、苦肉の策としてカスタムオペレータとしてpostfixなものを実装することで、SuspendWrapperからSingleへの変換が呼び出しやすくなります。

postfix operator ~
postfix func ~ <T>(lhs: SuspendWrapper<T>) -> Single<T> {
    Single.create(lhs)
}
let disposable = gitHubApi.getUser(userId: "marty-suzuki")~
    .subscribe(
        onSuccess: { (user: User) in
            ...
        },
        onError: { (error: Error) in
            ...
        }
    )

disposable.dispose()

SuspendWrapper以外の解決方法

本投稿では関数ごとに通信のリクエストを定義する形で実装していますが、APIKitのようにRequestオブジェクトベースで通信のリクエストを定義し、リクエストの実行はsend関数に一本化するという方法も試してみました。
リクエストの実行をsend関数に一本化することで、Kotlin向けのsend関数とSwift向けのSend関数に分けるだけで済むようになります。

ApiClient.kt
interface ApiClient {
    suspend fun <RES> send(request: ApiRequest<RES>): RES

    fun <RES> send(request: ApiRequest<RES>, onSuccess: (RES) -> Unit, onThrow: (Throwable) -> Unit): Cancelable {
        val job = CoroutineScope(Dispatchers.Main).launch {
            try {
                onSuccess(send(request))
            } catch (exception: Throwable) {
                onThrow(exception)
            }
        }
        return object: Cancelable {
            override fun cancel() {
                job.cancel()
            }
        }
    }
}

SuspendWrapperの場合と同様に、Singleへの変換ができます。

ApiClientType.swift
enum KotlinError: Error {
    case castError(Any?)
    case throwable(KotlinThrowable)
}

extension ApiClient {
    func send<T>(_ request: ApiRequest<T>) -> Single<T> {
        Single.create { observer in
            let cancelable = self.send(
                request: request as! ApiRequest<AnyObject>,
                onSuccess: { res in
                    guard let response = res as? T else {
                        observer(.failure(KotlinError.castError(res)))
                        return
                    }
                    observer(.success(response))
                },
                onThrow: {
                    observer(.failure(KotlinError.throwable($0)))
                }
            )
            return Disposables.create {
                cancelable.cancel()
            }
        }
    }
}

最後に

Swiftからsuspend funをキャンセルすることができるようになりました。
しかし、Kotlinからはsuspend funとして定義すれば良いInterfaceを一旦SuspendWrapperを挟んでsuspend()を呼び出す形になってしまうというデメリットもあります。
両プラットフォームにとって最善な落とし所を見つけていくことが重要になってくると思うので、引き続きいろいろと試していきたいと思います。

2021/01/27追記

iOS向けにSuspendWrapperを利用した関数を自動生成してくれるライブラリが公開されていました。
https://github.com/FutureMind/kmm-ios-suspendwrapper

25
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?