0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

jetpackでKtorを使う

Last updated at Posted at 2025-10-18

Ktorとは

Ktorは、JetBrainsがKotlinを使用して開発したHTTPクライアントおよびサーバーライブラリである。
Ktorの特徴として、軽量・モジュール式・Corutineベースで作られていることが挙げられる。
Ktorをクライアントで使用する場合の注意点として、サスペンド関数内もしくはコルーチン内での実行が必要となる。

従来までのAndroidのデファクトスタンダードなHTTPライブラリはRetrofitであったが、Kotlinで言語を統一したい場合非常に相性が良いライブラリとして使用できる。

Ktorの公式情報に関しては以下参照。
https://ktor.io/docs/welcome.html

KtorをAndroidで使用する

以下、AndroidアプリでのKtorの実装方法例

build.gradle.kts
// 主要なクライアント機能を追加
implementation("io.ktor:ktor-client-core:3.3.1")
// CIOエンジンの追加
implementation("io.ktor:ktor-client-cio:3.3.1")

※CIOとはCoroutine-based I/Oの略。KotlinのコルーチンとノンブロッキングI/O(NIO)で実装されたHTTPエンジンをCIOと呼ぶ。

// Kotlinx Coroutines + 非同期I/OのCIOエンジンを使ったHTTPクライアント
private val client = HttpClient(CIO)

// 天気情報を取得するサスペンド関数
public suspend fun fetchWeather(params): String =
    // BASE_URL に対して GET リクエストを送る
    client.get(BASE_URL) {
        url {
            // クエリパラメータを追加する。
            // 結果は `...?params=<paramsの値>` のように付与される。
            parameter("params", params)
        }
        // 必要に応じてヘッダやタイムアウト等の設定もここで可能
        // headers { append(HttpHeaders.Accept, "application/json") }
        // timeout { requestTimeoutMillis = 10_000 }
    }
    .body()

Ktorシリアライゼーション

Ktorシリアライゼーションを設定すると、KtorはJSON文字列を自動的にでシリアライズするように構成できる

Jsonレスポンス解析ライブラリとして、GsonやMoshiがデファクトスタンダードではあったが、Ktorシリアライゼーションを使用することでHTTPクライアントとシリアル機構を共にKtorに一任することができる。

Ktorシリアライゼーションを使用する場合、コンテントネゴシエーションプラグインをインストールして、HttpClientのインスタンス化を更新することが必要。

以下、AndroidアプリでのKtorシリアライゼーションの実装方法

build.gradle.kts
plugins {
    kotlin("plugin.serialization") version "kotlin version"
}
build.gradle.kts
// KtorClientのContentNegotiationプラグイン本体
implementation("io.ktor:ktor-client-content-negotiation:3.3.1")
// kotlinx.serialization(JSON)をKtorに接続するコンバータ
implementation("io.ktor:ktor-serialization-kotlinx-json:3.3.1")

Ktorを使ってHTTPリクエストを行う

上記のライブラリをアプリに組み込んで、OpenWeatherMapにHTTPリクエストを行う。

// 現在の天気APIのエンドポイント
private const val BASE_URL = "https://api.openweathermap.org/data/2.5/weather"

// 東京タワー付近の緯度経度(デフォルト位置)
private const val DEFAULT_LATITUDE = "35.658584"
private const val DEFAULT_LONGITUDE = "139.7454316"

// 摂氏表示 / 日本語
private const val UNITS = "metric"
private const val LANG = "ja"

// UIで使う最小限の天気データ
private data class UiWeather(
    val iconId: String = "",
    val main: String = "",
    val description: String = "",
    val name: String = "",
    val dtEpochSeconds: Long? = null
)

// 画面状態(読み込み中/成功/失敗)
private sealed interface UiState {
    data object Loading : UiState
    data class Success(val data: UiWeather) : UiState
    data class Error(val message: String) : UiState
}

class MainActivity : ComponentActivity() {

    // Ktorクライアント(CIO + JSONデシリアライズ)
    private val client = HttpClient(CIO) {
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true }) // 未知フィールドは無視する
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            TestApplicationTheme {
                var state by remember { mutableStateOf<UiState>(UiState.Loading) }

                // 初回起動時に天気取得を実行
                LaunchedEffect(Unit) {
                    state = withContext(Dispatchers.IO) {
                        runCatching { fetchWeather() }
                            .map { it.toUiWeather() } // サーバーモデルからUIモデルへ
                            .fold(
                                onSuccess = { UiState.Success(it) },
                                onFailure = { UiState.Error(it.message ?: "不明なエラーが発生しました") }
                            )
                    }
                }

                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Card(
                        modifier = Modifier
                            .padding(innerPadding)
                            .padding(16.dp)
                            .fillMaxSize()
                    ) {
                        // 状態に応じて表示を切り替え
                        when (val s = state) {
                            UiState.Loading -> LoadingView()
                            is UiState.Error -> ErrorView(s.message)
                            is UiState.Success -> WeatherScreen(
                                iconUrl = iconUrl(s.data.iconId),
                                main = s.data.main,
                                description = s.data.description,
                                name = s.data.name,
                                dtText = s.data.dtEpochSeconds?.let { formatEpochJp(it) } ?: "-"
                            )
                        }
                    }
                }
            }
        }
    }

    // 天気APIを叩いてサーバーレスポンスを取得
    private suspend fun fetchWeather(): ServerResponseData =
        client.get(BASE_URL) {
            url {
                parameter("appid", OPEN_WEATHER_TOKEN)
                parameter("lat", DEFAULT_LATITUDE)
                parameter("lon", DEFAULT_LONGITUDE)
                parameter("units", UNITS)
                parameter("lang", LANG)
            }
        }.body()
}

// サーバーレスポンス → 表示用モデルへ変換
private fun ServerResponseData.toUiWeather(): UiWeather {
    val w: WeatherData? = weather.firstOrNull()
    return UiWeather(
        iconId = w?.iconId.orEmpty(),
        main = w?.shortDescription.orEmpty(),
        description = w?.longDescription.orEmpty(),
        name = name.orEmpty(),
        dtEpochSeconds = dt
    )
}

// 天気アイコンの表示URLを生成(空IDなら空文字)
private fun iconUrl(iconId: String): String =
    if (iconId.isEmpty()) "" else "https://openweathermap.org/img/wn/$iconId@4x.png"

// Epoch秒→日本語日時(ローカルタイムゾーン)に整形
private fun formatEpochJp(epochSeconds: Long): String {
    val zdt = Instant.ofEpochSecond(epochSeconds).atZone(ZoneId.systemDefault())
    val fmt = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss", Locale.JAPAN)
    return fmt.format(zdt)
}

@Composable
private fun LoadingView() {
    // 中央に「読み込み中…」を表示
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("読み込み中…", style = MaterialTheme.typography.titleMedium)
    }
}

@Composable
private fun ErrorView(message: String) {
    // エラーメッセージを中央表示
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("エラー", fontSize = 22.sp, fontWeight = FontWeight.Bold)
        Text(message, modifier = Modifier.padding(top = 8.dp))
    }
}

@Composable
fun WeatherScreen(
    iconUrl: String,
    main: String,
    description: String,
    name: String,
    dtText: String,
    modifier: Modifier = Modifier
) {
    // 取得した天気情報を縦並びで表示
    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        if (iconUrl.isNotEmpty()) {
            LoadedImage( // 画像ローダは別実装想定
                imageUrl = iconUrl,
                modifier = Modifier
                    .fillMaxWidth(0.8f)
                    .aspectRatio(1f)
            )
        }
        Text(text = main.ifEmpty { "-" }, fontSize = 28.sp, fontWeight = FontWeight.Bold)
        Text(text = description.ifEmpty { "-" }, fontSize = 20.sp)
        Text(text = name.ifEmpty { "-" }, fontSize = 22.sp)
        Text(text = dtText.ifEmpty { "-" }, fontSize = 18.sp)
    }
}

Screenshot

ソースコード全文は以下
https://github.com/motojimay/ktor-test-android

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?