1. なぜ kotlinx.serialization なのか
- KMP 対応:JVM/Android/JS/Nativeで同じモデルを扱える
- ゼロリフレクション:Moshi/Gsonより軽量/高速になりやすい
-
ポリモーフィズムや
@SerialName等の言語統合的サポート
2. 依存関係(Gradle)
app/build.gradle.kts
plugins {
kotlin("android")
id("com.android.application")
kotlin("plugin.serialization") // ← 重要
}
android {
namespace = "com.example.app"
compileSdk = 35
defaultConfig {
minSdk = 24
targetSdk = 35
}
buildTypes {
release {
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
dependencies {
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.11.0")
// kotlinx.serialization (JSON)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
// Retrofit × kotlinx.serialization コンバータ
// Jake Wharton のコンバータ(実績大)
implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0")
// OkHttp(ログ/タイムアウト等)
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
}
バージョンはプロジェクトにあわせて更新してください。
kotlinx-serialization-jsonと Kotlin のメジャー互換に注意。
3. Retrofit × kotlinx.serialization の最小セットアップ
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.ExperimentalSerializationApi
import okhttp3.MediaType.Companion.toMediaType
@OptIn(ExperimentalSerializationApi::class)
fun provideRetrofit(baseUrl: String): Retrofit {
val json = Json {
ignoreUnknownKeys = true // 余分なフィールド無視(後方互換◎)
explicitNulls = false // null を省略したい場合に便利
isLenient = true // 非厳格 JSON 許容(APIによる)
coerceInputValues = true // 型違いを許容できるケースで便利
encodeDefaults = false
}
val client = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
})
.build()
val contentType = "application/json".toMediaType()
return Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(json.asConverterFactory(contentType))
.client(client)
.build()
}
4. API インターフェースと DTO
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
// --- DTO(APIワイヤ形式) ---
@Serializable
data class UserDto(
val id: String,
@SerialName("display_name") val displayName: String,
val age: Int? = null,
val email: String? = null
)
@Serializable
data class PagedUsersDto(
val items: List<UserDto>,
val nextCursor: String? = null
)
// --- Retrofit API ---
interface UserApi {
@GET("users/{id}")
suspend fun getUser(@Path("id") id: String): UserDto
@GET("users")
suspend fun listUsers(@Query("cursor") cursor: String? = null): PagedUsersDto
}
5. Domain モデルと Mapper(層の分離)
// Domain(UIやUseCaseが使う純粋モデル)
data class User(
val id: String,
val name: String,
val age: Int?, // 業務上null許容の意味があるならそのまま
val hasEmail: Boolean // DTOと少し異なる表現
)
// Mapper(Data層内部)
fun UserDto.toDomain(): User = User(
id = id,
name = displayName,
age = age,
hasEmail = !email.isNullOrBlank()
)
Domain は外部形式に依存しない。
@Serializableは DTO のみに付与するのが基本。
6. エラーハンドリング(成功/失敗の型安全化)
6.1 統一レスポンス型(Result 風)
sealed interface ApiResult<out T> {
data class Success<T>(val value: T) : ApiResult<T>
sealed interface Failure : ApiResult<Nothing> {
data class Http(val code: Int, val body: String?) : Failure
data class Network(val throwable: Throwable) : Failure
data class Unknown(val throwable: Throwable) : Failure
}
}
6.2 安全呼び出しヘルパ
import retrofit2.HttpException
import java.io.IOException
suspend inline fun <T> safeCall(crossinline block: suspend () -> T): ApiResult<T> =
try {
ApiResult.Success(block())
} catch (e: HttpException) {
ApiResult.Failure.Http(e.code(), e.response()?.errorBody()?.string())
} catch (e: IOException) { // ネットワーク切断やタイムアウト
ApiResult.Failure.Network(e)
} catch (e: Throwable) {
ApiResult.Failure.Unknown(e)
}
7. Repository 実装(Data 層)
interface UserRepository {
suspend fun getUser(id: String): ApiResult<User>
suspend fun listUsers(cursor: String? = null): ApiResult<Pair<List<User>, String?>>
}
class UserRepositoryImpl(
private val api: UserApi
) : UserRepository {
override suspend fun getUser(id: String): ApiResult<User> =
safeCall {
api.getUser(id).toDomain()
}
override suspend fun listUsers(cursor: String?): ApiResult<Pair<List<User>, String?>> =
safeCall {
val page = api.listUsers(cursor)
page.items.map { it.toDomain() } to page.nextCursor
}
}
8. Presentation からの利用(例:ViewModel)
class UserViewModel(
private val repo: UserRepository
) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state
sealed interface UiState {
object Loading : UiState
data class Data(val user: User) : UiState
data class Error(val message: String) : UiState
}
fun load(id: String) = viewModelScope.launch {
when (val r = repo.getUser(id)) {
is ApiResult.Success -> _state.value = UiState.Data(r.value)
is ApiResult.Failure.Http -> _state.value = UiState.Error("HTTP ${r.code}")
is ApiResult.Failure.Network -> _state.value = UiState.Error("ネットワークエラー")
is ApiResult.Failure.Unknown -> _state.value = UiState.Error("不明なエラー")
}
}
}
9. Polymorphic/Discriminator(高度なシリアライズ)
APIが「type」によってペイロード形を切り替える場合:
import kotlinx.serialization.*
import kotlinx.serialization.json.*
@Serializable
sealed class Item {
abstract val id: String
}
@Serializable
@SerialName("article")
data class Article(override val id: String, val title: String): Item()
@Serializable
@SerialName("video")
data class Video(override val id: String, val url: String): Item()
val json = Json {
ignoreUnknownKeys = true
classDiscriminator = "type" // ← "type" フィールドで分岐
}
10. テスト戦略(Unit / Integration)
- Mapper の単体テスト:DTO → Domain の境界を保証
- API の統合テスト:MockWebServer + 実コンバータで JSON ラウンドトリップ
test("UserDto toDomain") {
val dto = UserDto("1", "Anna", 25, "a@example.com")
val domain = dto.toDomain()
assert(domain.name == "Anna" && domain.hasEmail)
}
11. ProGuard(R8)設定のヒント
kotlinx.serialization は基本的にリフレクション不要ですが、万一の最適化でフィールドが消えるのを防ぐ場合:
# kotlinx.serialization (通常は不要だが保険で)
-keepattributes *Annotation*
-keep class kotlinx.serialization.** { *; }
-keep class **$$serializer { *; }
-keepclassmembers class ** {
@kotlinx.serialization.Serializable *;
}
12. よくある落とし穴(と対策)
-
未知フィールドで失敗:
ignoreUnknownKeys = trueを有効化 -
null地獄:
explicitNulls=false/encodeDefaults=falseの調整、Domainでは意味のある null のみにする -
エラー本文の解析:
errorBody()?.string()を JSON として別 DTO にパース → エラー画面に詳細表示 -
Date/Time:
@Contextual+kotlinx-datetimeのInstant/LocalDateTime用カスタムシリアライザを実装
13. 差し替え可能なコンバータ設計(拡張性)
同一 API でも、将来的に ProtoBuf / CBOR に切替えたい場合:
interface ConverterFactoryProvider {
fun provide(): retrofit2.Converter.Factory
}
class KotlinxJsonConverterProvider(
private val json: Json
) : ConverterFactoryProvider {
override fun provide() = json.asConverterFactory("application/json".toMediaType())
}
// Retrofit Builder では DI で差し替え
fun retrofit(baseUrl: String, provider: ConverterFactoryProvider) =
Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(provider.provide())
.build()
14. Clean Architecture での配置
domain/
model/User.kt
repository/UserRepository.kt
data/
dto/UserDto.kt
mapper/UserMapper.kt
network/UserApi.kt
repository/UserRepositoryImpl.kt
net/RetrofitModule.kt
app/ (presentation)
viewmodel/UserViewModel.kt
まとめ
-
kotlinx.serializationは 軽量・KMP対応・型安全。Retrofit とは JakeWharton のコンバータで連携 - DTO ↔ Domain を明確分離し、Mapperで境界を管理
- ApiResult で成功/失敗を型に閉じ込め、ViewModel で UI 状態へ変換
-
ignoreUnknownKeysなどの Json 設定で 互換性と堅牢性 を確保 - 将来のフォーマット変更に備え ConverterFactory を DI で差し替え可能に設計