【Compose Multiplatform】Ktorを使用したHTTP通信とRepositoryの実装
みなさんこんにちは。今回はCompose Multiplatform記事の7回目になります。
HTTP通信処理を実装してきましょう。
これまでのCompose Multiplatform記事はこちら
Compose Multiplatform入門(環境構築編)
Compose Multiplatform入門(プロジェクト作成編)
Compose Multiplatform入門(画面遷移編)
Compose Multiplatform入門(ロギング編)
KMPAuthを使用したFirebaseソーシャルログイン(Android編)
KMPAuthを使用したFirebaseソーシャルログイン(iOS編)
Ktorを使用したHTTP通信
Ktorとは、KotlinとCompose Multiplatformの開発をしているJetBrainsによってサポートされている非同期クライアントおよびサーバ用ライブラリです。https://ktor.io/
公式がサポートしているだけあって安心感がある気がします。
環境構築
Ktorを使用するために、いつものようにlibs.versions.toml、build.gradle.ktsを更新しましょうlibs.versions.toml
Ktorはkotlinx.coroutinesとの組み合わせで使用するので、kotlinx.coroutinesも合わせて追加します。
[versions]
ktor = "2.3.9"
kotlinx-coroutine = "1.7.3"
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutine" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutine" }
ktor-client-content-negociation = { group = "ktor", module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { group = "ktor", module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-darwin = { group = "ktor", module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-logging = { group = "ktor", module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-okhttp = { group = "ktor", module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
build.gradle.kts
kotlin {
sourceSets {
androidMain.dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.ktor.client.okhttp)
}
commonMain.dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negociation)
implementation(libs.ktor.client.logging)
}
iosMain {
dependencies {
implementation(libs.ktor.client.darwin)
}
}
}
}
自己署名証明書を信頼させる方法
開発時はHTTPS通信時に自己署名証明書(オレオレ証明書)を利用する方も多いかと思いますが、その場合は自己署名証明書を信頼するための手続きがAndroid、iOS両方に必要になるので各プラットフォームごとにHttpClientを用意し、どんな証明書でも信頼する処理を追加する必要があります。
commonMain/kotlin/data/http/HttpClient.kt
以前のロギングの記事で使用した、Kermitを通信時のログに仕込んでいます。
package data.http
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
internal val HttpClient by lazy {
HttpClient {
configure()
}
}
internal fun HttpClientConfig<*>.configure() {
install(Logging) {
level = LogLevel.ALL
logger = object : Logger {
override fun log(message: String) {
co.touchlab.kermit.Logger.d(tag = "KtorClient", messageString = message)
}
}
}
configureForPlatform()
}
internal expect fun HttpClientConfig<*>.configureForPlatform()
androidMain/kotlin/data/http/HttpClient.android.kt
package data.http
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.okhttp.OkHttpConfig
import java.security.SecureRandom
import javax.net.ssl.SSLContext
internal actual fun HttpClientConfig<*>.configureForPlatform() {
engine {
this as OkHttpConfig
config {
val trustAllCert = AllCertsTrustManager()
val sslContext = SSLContext.getInstance("SSL")
sslContext.init(null, arrayOf(trustAllCert), SecureRandom())
sslSocketFactory(sslContext.socketFactory, trustAllCert)
}
}
}
androidMain/kotlin/data/http/AllCertsTrustManager.kt
package data.http
import java.security.cert.X509Certificate
import javax.net.ssl.X509TrustManager
internal class AllCertsTrustManager : X509TrustManager {
@Suppress("TrustAllX509TrustManager")
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
// no-op
}
@Suppress("TrustAllX509TrustManager")
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
// no-op
}
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
}
iosMain/kotlin/data/http/TrustAllChallengeHandler.kt
package data.http
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.darwin.DarwinClientEngineConfig
internal actual fun HttpClientConfig<*>.configureForPlatform() {
engine {
this as DarwinClientEngineConfig
handleChallenge(TrustAllChallengeHandler())
}
}
iosMain/kotlin/data/http/TrustAllChallengeHandler.kt
internal class TrustAllChallengeHandler : ChallengeHandler {
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
override fun invoke(
session: NSURLSession,
task: NSURLSessionTask,
challenge: NSURLAuthenticationChallenge,
completionHandler: (NSURLSessionAuthChallengeDisposition, NSURLCredential?) -> Unit
) {
// Check that we want to handle this kind of challenge
val protectionSpace = challenge.protectionSpace
if (protectionSpace.authenticationMethod != NSURLAuthenticationMethodServerTrust) {
// Not a 'NSURLAuthenticationMethodServerTrust', default handling...
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, null)
return
}
val serverTrust = challenge.protectionSpace.serverTrust
if (serverTrust == null) {
// Server trust is null, default handling...
completionHandler(NSURLSessionAuthChallengePerformDefaultHandling, null)
return
}
// Get the servers certs
val certChain = SecTrustCopyCertificateChain(serverTrust)
// Set those certs as trusted anchors
SecTrustSetAnchorCertificates(serverTrust, certChain)
if (serverTrust.trustIsValid()) {
// ✔ Server trust is valid, continue...
val credential = NSURLCredential.credentialForTrust(serverTrust)
completionHandler(NSURLSessionAuthChallengeUseCredential, credential)
} else {
// ✖ Server trust not valid, cancel challenge...
completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null)
}
}
}
/**
* Evaluates trust for the specified certificate and policies.
*/
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
private fun SecTrustRef.trustIsValid(): Boolean {
var isValid = false
val version = cValue<NSOperatingSystemVersion> {
majorVersion = 12
minorVersion = 0
patchVersion = 0
}
if (NSProcessInfo().isOperatingSystemAtLeastVersion(version)) {
memScoped {
val result = alloc<CFErrorRefVar>()
// https://developer.apple.com/documentation/security/2980705-sectrustevaluatewitherror
isValid = SecTrustEvaluateWithError(this@trustIsValid, result.ptr)
}
} else {
// https://developer.apple.com/documentation/security/1394363-sectrustevaluate
memScoped {
val result = alloc<SecTrustResultTypeVar>()
result.value = kSecTrustResultInvalid
val status = SecTrustEvaluate(this@trustIsValid, result.ptr)
if (status == errSecSuccess) {
isValid = result.value == kSecTrustResultUnspecified ||
result.value == kSecTrustResultProceed
}
}
}
return isValid
}
Info.plistの更新
<code>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>example.com</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
</code>
API, Repository, Moduleの追加
通信を受け取るための各種クラスを追加していきます。今回は自前でサーバを立ててJSONでレスポンスを受け取ってモデルに変換する...などということはせず、簡単のため、例示用のドメインのHTTPサーバのhttps://example.com/test
にアクセスしてステータスコードを返すだけのAPIクラスを作成します。成功レスポンスが返ってきたら無事に実装完了、ということにします。
commonMain/kotlin/domain/test/TestRepository.kt
package domain.test
import domain.Resource
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.flow.Flow
interface TestRepository {
fun streamTest(): Flow<Resource<HttpStatusCode>>
suspend fun testConnection()
}
commonMain/data/api/TestApi.kt
package data.api
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
class TestApi(
private val baseUrl: String,
private val client: HttpClient
) {
suspend fun get(): HttpStatusCode {
return client.get("$baseUrl/test").status
}
}
commonMain/kotlin/domain/test/TestRemoteDataSource.kt
package domain.test
import data.api.TestApi
import io.ktor.http.HttpStatusCode
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
class TestRemoteDataSource(
private val testApi: TestApi,
private val ioDispatcher: CoroutineDispatcher,
) {
suspend fun get(): HttpStatusCode {
val status: HttpStatusCode = withContext(ioDispatcher) {
try {
val res = testApi.get()
res.status
} catch (e: Exception) {
throw e
}
}
return status
}
}
commonMain/kotlin/domain/Resource.kt
package domain
enum class ErrorCode {
NETWORK_NOT_AVAILABLE,
NETWORK_CONNECTION_FAILED,
}
data class Error(
val code: ErrorCode,
)
data class Resource<out T>(val status: Status, val data: T?, val error: Error?) {
companion object {
fun <T> success(data: T): Resource<T> = Resource(
status = Status.SUCCESS,
data = data,
error = null,
)
fun <T> loading(data: T? = null): Resource<T> = Resource(
status = Status.LOADING,
data = data,
error = null,
)
fun <T> error(data: T? = null, error: Error? = null): Resource<T> = Resource(
status = Status.ERROR,
data = data,
error = error,
)
fun <T> idle(data: T?) = Resource(status = Status.INIT, data = data, error = null)
}
enum class Status {
INIT,
SUCCESS,
ERROR,
LOADING,
}
}
commonMain/kotkin/data/test/TestRepositoryImpl.kt
package data.test
import domain.Error
import domain.ErrorCode
import domain.Resource
import domain.test.TestRemoteDataSource
import domain.test.TestRepository
import io.ktor.http.HttpStatusCode
import io.ktor.http.isSuccess
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
class TestRepositoryImpl(
private val testRemoteDataSource: TestRemoteDataSource
) : TestRepository {
private val testFlowMap = mutableMapOf<Int, MutableStateFlow<Resource<HttpStatusCode>>>()
override fun streamTest(): Flow<Resource<HttpStatusCode>> = getOrCreateTestFlow().asSharedFlow()
override suspend fun testConnection() {
val testFlow = getOrCreateTestFlow()
testFlow.value = Resource.loading(testFlow.value.data)
try {
val test = testRemoteDataSource.get()
if(test.isSuccess()) {
testFlow.value = Resource.success(test)
}
else {
testFlow.value = Resource.error(testFlow.value.data, Error(code = ErrorCode.NETWORK_CONNECTION_FAILED))
}
} catch (ex: Exception) {
testFlow.value = Resource.error(testFlow.value.data, Error(code = ErrorCode.NETWORK_CONNECTION_FAILED))
}
}
private fun getOrCreateTestFlow(): MutableStateFlow<Resource<HttpStatusCode>> {
var testFlow = testFlowMap[0]
if(testFlow != null) {
return testFlow
}
testFlow = MutableStateFlow(Resource.idle(null))
testFlowMap[0] = testFlow
return testFlow
}
}
commonMain/kotlin/modules/ApiModule.kt
package modules
import data.api.TestApi
import data.http.HttpClient
import org.koin.dsl.module
val apiModule = module {
val baseUri = "https://example.com"
single {
TestApi(
baseUrl = baseUri,
client = HttpClient,
)
}
}
commonMain/kotlin/modules/IOModule.kt
package modules
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import org.koin.core.qualifier.named
import org.koin.dsl.module
val ioModule = module {
single(named("IODispatcher")) {
Dispatchers.IO
}
}
commonMain/kotlin/modules/TestModule.kt
package modules
import data.test.TestRepositoryImpl
import domain.test.TestRemoteDataSource
import domain.test.TestRepository
import org.koin.core.qualifier.named
import org.koin.dsl.module
val testModule = module {
single {
TestRemoteDataSource(
testApi = get(),
ioDispatcher = get(named("IODispatcher")),
)
}
single<TestRepository> {
TestRepositoryImpl(get())
}
}
commonMain/kotlin/modules/ViewModelModule.kt
package modules
import Greeting
import org.koin.dsl.module
import ui.screens.another.AnotherScreenViewModel
import ui.screens.home.HomeScreenViewModel
val viewModelModule = module {
factory {
HomeScreenViewModel(get())
}
factory {
Greeting()
}
factory {
params -> AnotherScreenViewModel(postId = params.get(), get())
}
}
commonMain/kotlin/modules/AppModule.kt
package modules
import org.koin.core.module.Module
fun appModules(): List<Module> = listOf(
apiModule,
ioModule,
testModule,
viewModelModule
)
Screen、ViewModelの更新
さて、ここまで書いてようやく準備ができました。あとはViewとViewModelを更新して実際に通信の処理を動かしてみましょう。画面遷移の実装の記事で追加したAnotherScreenに通信を開始するボタンを追加します。
AnotherScreen.kt
package ui.screens.another
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import cafe.adriel.voyager.core.lifecycle.LifecycleEffect
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import co.touchlab.kermit.Logger
import org.koin.core.parameter.parametersOf
class AnotherScreen(private val postId: String) : Screen {
@Composable
override fun Content() {
val viewModel = getScreenModel<AnotherScreenViewModel>{ parametersOf(postId) }
val navigator = LocalNavigator.currentOrThrow
val uiState = viewModel.uiState.collectAsState()
val uiStateValue = uiState.value
LifecycleEffect(
onStarted = { Logger.i { "Open Another Screen" } }
)
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
Button(onClick = { navigator.pop() }) {
Text("戻る")
}
Button(onClick = { viewModel.testConnection() }) {
Text("通信を開始")
}
when (uiStateValue) {
is AnotherScreenViewModel.State.Init -> Init()
is AnotherScreenViewModel.State.Loading -> Loading()
is AnotherScreenViewModel.State.Success -> Success(uiStateValue.statusCode)
is AnotherScreenViewModel.State.Error -> Error(uiStateValue.message)
}
}
}
@Composable
private fun Init() {
Text("初期状態")
}
@Composable
private fun Loading() {
Text("読み込み中...")
}
@Composable
private fun Success(statusCode: Int) {
Text("通信成功 StatusCode:$statusCode")
}
@Composable
private fun Error(message: String) {
Text("エラー: $message")
}
}
AnotherScreenViewModel.kt
package ui.screens.another
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import domain.Resource
import domain.test.TestRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class AnotherScreenViewModel(
private val postId: String,
private val testRepository: TestRepository
) : ScreenModel {
sealed class State {
data object Init: State()
data object Loading: State()
data class Success(val events: List<Event>): State()
data class Error(val message: String): State()
}
val uiState = testRepository.streamTest().map {
testResource ->
when(testResource.status) {
Resource.Status.INIT -> State.Init
Resource.Status.LOADING -> State.Loading
Resource.Status.SUCCESS -> when(testResource.data) {
null -> State.Error("読み込み失敗")
else -> State.Success(testResource.data.value)
}
Resource.Status.ERROR -> State.Error("読み込み失敗")
}
}.stateIn(screenModelScope, SharingStarted.WhileSubscribed(500L), State.Init)
fun testConnection() {
screenModelScope.launch {
testRepository.testConnection()
}
}
}
動作確認
実際に動かしてみましょう。「通信成功」と表示されればOKです!
さいごに
最後までご覧いただきありがとうございました。Compose Multiplatformの週刊連載ブログも、次回が最終回になります。