怠慢プログラマーの備忘録

怠慢でナマケモノなプログラマーの備忘録です。

【Kotlin Multiplatform Mobile】 Ktorを使ったApiClientをSwiftのCombineで使う(備忘録)

Kotlin: 1.7.0.

KMM Plugin: 0.3.4.

Ktor: 2.0.3.

SharedModule/build.gradle.kts

sourceSets {
        val commonMain by getting {
            dependencies {
                //Network
                implementation("io.ktor:ktor-client-core:${findProperty("version.ktor")}")
                implementation("io.ktor:ktor-client-logging:${findProperty("version.ktor")}")
                implementation("io.ktor:ktor-client-serialization:${findProperty("version.ktor")}")
                implementation("io.ktor:ktor-client-json:${findProperty("version.ktor")}")
                implementation("io.ktor:ktor-serialization-kotlinx-json:${findProperty("version.ktor")}")
                implementation("io.ktor:ktor-client-content-negotiation:${findProperty("version.ktor")}")
                implementation("io.ktor:ktor-client-auth:${findProperty("version.ktor")}")
                implementation("org.jetbrains.kotlinx:kotlinx-datetime:${findProperty("version.kotlin_datatime")}")
            }
        }

        val androidMain by getting {
            dependencies {
                //Network
                implementation("io.ktor:ktor-client-okhttp:${findProperty("version.ktor")}")
                implementation("io.ktor:ktor-client-android:${findProperty("version.ktor")}")
            }
        }
        val androidTest by getting

        val iosArm64Main by getting
        val iosSimulatorArm64Main by getting
        val iosX64Main by getting
        val iosMain by creating {
            dependsOn(commonMain)
            iosArm64Main.dependsOn(this)
            iosSimulatorArm64Main.dependsOn(this)
            iosX64Main.dependsOn(this)
            dependencies {
                //Network
                implementation("io.ktor:ktor-client-ios:${findProperty("version.ktor")}")
                implementation("io.ktor:ktor-client-darwin:${findProperty("version.ktor")}")
            }
        }
    }

commonMain/ApiClient.kt

expect class SharedApiClient(bearerToken: String?) {
    val client: HttpClient
    val dispatcher: CoroutineDispatcher
}

androidMain/ApiClient.kt

actual class SharedApiClient actual constructor(private val bearerToken: String?) {
    actual val client: HttpClient = HttpClient {
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
                ignoreUnknownKeys = true
            })
        }
        install(DefaultRequest) {
            header(HttpHeaders.ContentType, ContentType.Application.Json)
        }
        bearerToken?.let {
            install(Auth) {
                bearer {
                    loadTokens {
                        // Load tokens from a local storage and return them as the 'BearerTokens' instance
                        BearerTokens(bearerToken, "")
                    }
                }
            }
        }
    }
    actual val dispatcher: CoroutineDispatcher = Dispatchers.Default
}

iosMain/ApiClient.kt

actual class SharedApiClient actual constructor(private val bearerToken: String?) {
    actual val client: HttpClient = HttpClient(Darwin) {
        install(ContentNegotiation) {
            json(Json {
                prettyPrint = true
                isLenient = true
                ignoreUnknownKeys = true
                allowSpecialFloatingPointValues = true
                useArrayPolymorphism = false
            })
        }
        install(Auth) {
            bearer {
                loadTokens {
                    // Load tokens from a local storage and return them as the 'BearerTokens' instance
                    bearerToken?.let {
                        BearerTokens(bearerToken, "")
                    }
                }
            }
        }
    }

    actual val dispatcher: CoroutineDispatcher = Dispatcher(dispatch_get_main_queue())
}

class Dispatcher(private val dispatchQueue: dispatch_queue_t) : CoroutineDispatcher() {
    override fun dispatch(context: CoroutineContext, block: Runnable) {
        dispatch_async(dispatchQueue) {
            block.run()
        }
    }
}

commonMain/SharedNoteRequest.kt

class SharedNoteRequest(private val token: String?,
                                              private val apiClient: SharedApiClient = SharedApiClient(token),
                                              private val url: String = "https://test.com/v1/notes",
)  {

    suspend fun getNotes(): SharedNoteEntity {
        return apiClient.client.get(url).body()
    }
}

gradle.properties

kotlin.native.binary.memoryModel=experimental

↑これがないとiOS側でクラッシュします。

以上のSharedModuleをxcframework化してSwiftPackageManagerで配布しています(この辺りの詳細は割愛)。

NoteRepository.swift

import Combine
import Foundation
import FractalSharedModule

public protocol NoteRepositoryProtocol {
    func fetchNotes() -> AnyPublisher<Note, Error>
}

public final class NoteRepository: NoteRepositoryProtocol {
    public init() {}

    public func fetchNotes() -> AnyPublisher<Note, Error> {
        return Future<Note, Error> { promise in
            let apiClient = SharedApiClient()
            SharedNoteRequest(apiClient: apiClient,
                                                url: "https://test.com/api/v1/notes")
            .getNotes(completionHandler: { entity, error in
                if let error {
                    debugPrint(error.localizedDescription)
                    promise(.failure(error))
                }
                if let entity {
                    // SharedModuleのEntityをiOS側で使うmodelに変換してるだけ
                    promise(.success(SharedTranslator.translator(shared: entity)))
                }
            })
        }
        .eraseToAnyPublisher()
    }
}

後は使いたいところで、

        repository.fetchNotes(path: "magazines/m7a867df0f98f/notes")
            .subscribe(on: DispatchQueue.global())
            .receive(on: DispatchQueue.main)
            .sink(
                receiveCompletion: { completion in
                    if case .failure(let error) = completion {
                        fatalError(error)
                    }
                },
                receiveValue: { note in
                    // successfully
                }
            )
            .store(in: &cancellables)

みたいな感じになります。