Jetpack Compose NavigationでSerializableされていない型の値を渡す方法

に公開

Navigation Compose 2.8以降のType Safe Navigationでは、Serializableされていない型やカスタムシリアライザーが必要な型を引数として渡すことができます。この記事では、その実装方法を3つのアプローチで解説します。

概要

Type Safe NavigationではルートクラスをSerializableにすることで型安全な画面遷移が実現できますが、以下のような型は標準では対応していません:

  • LocalDate、UUIDなどのJava標準型
  • カスタムdata class
  • 既存ライブラリの型

これらを安全に渡すための手法を実例とともに紹介します。

基本実装

まず、共通で使用するデータクラスを定義します:

// 書籍情報(既存のSerializable型)
@Serializable
data class SearchedBookItem(
  val id: String,
  val title: String,
  val author: String?,
  val publisher: String?,
  // ...
)

// 書籍詳細情報(Serializableされていない型を含む)
data class BookDetailInfo(
  val searchedBookItem: SearchedBookItem,
  val viewedDate: LocalDate, // Serializableされていない
  val sessionId: UUID,       // Serializableされていない
  val rating: Double?,
  val notes: String?,
)

アプローチ1: toString()とparse()を使った方法

最もシンプルなアプローチです。LocalDateやUUIDのような標準型のtoString()は安定しており、parse可能です。

@Serializable
data class BookDetailRoute(
  val bookId: String,
  val viewedDate: String, // LocalDate.toString()
  val sessionId: String,  // UUID.toString()
  val rating: Double? = null,
  val notes: String? = null,
)

// Navigation関数
fun NavController.navigateToBookDetail(bookDetailInfo: BookDetailInfo) {
  navigate(
      BookDetailRoute(
          bookId = bookDetailInfo.searchedBookItem.id,
          viewedDate = bookDetailInfo.viewedDate.toString(),
          sessionId = bookDetailInfo.sessionId.toString(),
          rating = bookDetailInfo.rating,
          notes = bookDetailInfo.notes,
      )
  )
}

// 画面側での復元
fun NavGraphBuilder.bookDetailScreen() {
  composable<BookDetailRoute> { backStackEntry ->
      val route = backStackEntry.toRoute<BookDetailRoute>()

      val bookDetailInfo = BookDetailInfo(
          searchedBookItem = getSearchedBookItem(route.bookId), // 何らかの方法で取得
          viewedDate = LocalDate.parse(route.viewedDate),
          sessionId = UUID.fromString(route.sessionId),
          rating = route.rating,
          notes = route.notes,
      )

      BookDetailScreen(
          bookDetailInfo = bookDetailInfo,
      )
  }
}

// ViewModel側で復元する場合
@HiltViewModel
class BookDetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
) : ViewModel() {
    private val route = savedStateHandle.toRoute<BookDetailRoute>()
    private val simpleReadingProgress = route.readingProgressJson?.let { jsonString ->
        runCatching {
            Json.decodeFromString(SimpleReadingProgressSerializer, jsonString)
        }.getOrNull()
    }
}

toString()の結果例

val date = LocalDate.now()
println(date.toString()) // "2025-09-02"

val uuid = UUID.randomUUID()
println(uuid.toString()) // "550e8400-e29b-41d4-a716-446655440000"

アプローチ2: JSON文字列 + カスタムシリアライザー

複雑なdata classや既存ライブラリの型に対応する方法です。

カスタムシリアライザーの実装

// Serializableでないカスタム型
data class ReadingProgress(
  val currentPage: Int,
  val totalPages: Int,
  val lastReadDate: String,
  val hasBookmarks: Boolean = false,
)

// カスタムシリアライザー
object ReadingProgressSerializer : KSerializer<ReadingProgress> {

  @Serializable
  @SerialName("ReadingProgress")
  private data class ReadingProgressSurrogate(
      val currentPage: Int,
      val totalPages: Int,
      val lastReadDate: String,
      val hasBookmarks: Boolean,
  )

  override val descriptor: SerialDescriptor = ReadingProgressSurrogate.serializer().descriptor

  override fun serialize(encoder: Encoder, value: ReadingProgress) {
      val surrogate = ReadingProgressSurrogate(
          currentPage = value.currentPage,
          totalPages = value.totalPages,
          lastReadDate = value.lastReadDate,
          hasBookmarks = value.hasBookmarks,
      )
      encoder.encodeSerializableValue(ReadingProgressSurrogate.serializer(), surrogate)
  }

  override fun deserialize(decoder: Decoder): ReadingProgress {
      val surrogate = decoder.decodeSerializableValue(ReadingProgressSurrogate.serializer())
      return ReadingProgress(
          currentPage = surrogate.currentPage,
          totalPages = surrogate.totalPages,
          lastReadDate = surrogate.lastReadDate,
          hasBookmarks = surrogate.hasBookmarks,
      )
  }
}

Navigationでの使用

@Serializable
data class BookDetailRoute(
  val bookId: String,
  val viewedDate: String,
  val sessionId: String,
  val rating: Double? = null,
  val notes: String? = null,
  val readingProgressJson: String? = null, // JSON文字列として渡す
)

// 送信側
fun NavController.navigateToBookDetail(bookDetailInfo: BookDetailInfo) {
  val readingProgress = ReadingProgress(
      currentPage = 150,
      totalPages = 300,
      lastReadDate = bookDetailInfo.viewedDate.toString(),
      hasBookmarks = true,
  )

  val readingProgressJson = Json.encodeToString(ReadingProgressSerializer, readingProgress)

  navigate(
      BookDetailRoute(
          bookId = bookDetailInfo.searchedBookItem.id,
          viewedDate = bookDetailInfo.viewedDate.toString(),
          sessionId = bookDetailInfo.sessionId.toString(),
          rating = bookDetailInfo.rating,
          notes = bookDetailInfo.notes,
          readingProgressJson = readingProgressJson,
      )
  )
}

// 受信側
fun NavGraphBuilder.bookDetailScreen(goBack: () -> Unit) {
  composable<BookDetailRoute> { backStackEntry ->
      val route = backStackEntry.toRoute<BookDetailRoute>()

      val readingProgress = route.readingProgressJson?.let { jsonString ->
          Json.decodeFromString(ReadingProgressSerializer, jsonString)
      }

      BookDetailScreen(
          // ...
          readingProgress = readingProgress,
      )
  }
}

アプローチ3: typeMapを使った方法

より高度なアプローチとして、カスタムNavTypeを作成してtypeMapに登録する方法もあります。ただし、この方法はNavigation Safe Argsの制限により、プロパティレベルでの@Serializable(with = ...)は使用できません。そこで直接プロパティとして持たずにもう1つSerializeなdata classを用意します。

@Serializable
data class BookDetailRoute(
    val param: BookDetailRouteParameter,
)

@Serializable
data class BookDetailRouteParameter(
    val bookId: String,

    @Serializable(with = LocalDateSerializer::class)
    val viewedDate: LocalDate,

    @Serializable(with = UUIDSerializer::class)
    val sessionId: UUID,

    val rating: Double? = null,
    val notes: String? = null,

    @Serializable(with = SimpleReadingProgressSerializer::class)
    val readingProgress: SimpleReadingProgress? = null,
)

必要なカスタムシリアライザーを用意する。

object SimpleReadingProgressSerializer : KSerializer<SimpleReadingProgress> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
        "SimpleReadingProgress",
        kotlinx.serialization.descriptors.PrimitiveKind.STRING,
    )

    override fun serialize(encoder: Encoder, value: SimpleReadingProgress) {
        encoder.encodeString(
            "SimpleReadingProgress(" +
                "currentPage=${value.currentPage}, " +
                "totalPages=${value.totalPages}, " +
                "lastReadDate=${value.lastReadDate}, " +
                "hasBookmarks=${value.hasBookmarks}" +
                ")",
        )
    }

    override fun deserialize(decoder: Decoder): SimpleReadingProgress {
        val string = decoder.decodeString()
        val regex = """SimpleReadingProgress\(currentPage=(\d+), totalPages=(\d+), lastReadDate=([^,]+), hasBookmarks=(true|false)\)""".toRegex()
        val matchResult = regex.matchEntire(string) ?: throw IllegalArgumentException("Invalid format for SimpleReadingProgress: $string")

        val (currentPageStr, totalPagesStr, lastReadDate, hasBookmarksStr) = matchResult.destructured

        return SimpleReadingProgress(
            currentPage = currentPageStr.toInt(),
            totalPages = totalPagesStr.toInt(),
            lastReadDate = lastReadDate,
            hasBookmarks = hasBookmarksStr.toBoolean(),
        )
    }
}

object LocalDateSerializer : KSerializer<LocalDate> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
        "LocalDate",
        kotlinx.serialization.descriptors.PrimitiveKind.STRING,
    )

    override fun serialize(encoder: Encoder, value: LocalDate) {
        encoder.encodeString(value.toString())
    }

    override fun deserialize(decoder: Decoder): LocalDate {
        val dateString = decoder.decodeString()
        return LocalDate.parse(dateString)
    }
}

object UUIDSerializer : KSerializer<UUID> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor(
        "UUID",
        kotlinx.serialization.descriptors.PrimitiveKind.STRING,
    )

    override fun serialize(encoder: Encoder, value: UUID) {
        encoder.encodeString(value.toString())
    }

    override fun deserialize(decoder: Decoder): UUID {
        val uuidString = decoder.decodeString()
        return UUID.fromString(uuidString)
    }
}

間に用意したBookDetailRouteParameterのNavTypeを用意する。

object BookDetailRouteNavType : NavType<BookDetailRouteParameter>(false) {
    override fun get(bundle: Bundle, key: String): BookDetailRouteParameter? {
        return bundle.getString(key)?.let { jsonString ->
            parseValue(jsonString)
        }
    }

    override fun parseValue(value: String): BookDetailRouteParameter {
        return runCatching {
            Json.decodeFromString(BookDetailRouteParameter.serializer(), value)
        }.getOrNull() ?: throw IllegalArgumentException("Failed to parse BookDetailRouteParameter from: $value")
    }

    override fun put(bundle: Bundle, key: String, value: BookDetailRouteParameter) {
        val jsonString = value.let { param ->
            Json.encodeToString(BookDetailRouteParameter.serializer(), param)
        }
        bundle.putString(key, jsonString)
    }

    override fun serializeAsValue(value: BookDetailRouteParameter): String {
        return value.let { param ->
            Json.encodeToString(BookDetailRouteParameter.serializer(), param)
        }
    }
}

そしてこのBookDetailRouteNavTypeをTypeMapで指定する必要がある。

fun NavGraphBuilder.bookDetailScreen() {
    composable<BookDetailRoute>(
        typeMap = mapOf<KType, NavType<*>>(
            typeOf<BookDetailRouteParameter>() to BookDetailRouteNavType,
        ),
    ) { backStackEntry ->
        val bookDetailViewModel = hiltViewModel<BookDetailViewModel>(backStackEntry)

        BookDetailRoute(
            viewModel = bookDetailViewModel,
        )
    }
}

// ..

@HiltViewModel
class BookDetailViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    // 本来はRepositoryなどから書籍情報を取得するが、デモ用に省略
) : ViewModel() {
    private val route = savedStateHandle.toRoute<BookDetailRoute>(
        typeMap = mapOf(
            typeOf<BookDetailRouteParameter>() to BookDetailRouteNavType,
        ),
    )
}

実装時の注意点

現在のNavigation Safe Argsでは以下の制限があります:

// ❌ これはサポートされていない
@Serializable
data class BookDetailRoute(
  @Serializable(with = CustomSerializer::class)
  val customData: CustomType
)

// ✅ これは動作する
@Serializable
data class BookDetailRoute(
  val customDataJson: String // JSON文字列として渡す
)

// ✅ これも動作する
@Serializable
data class BookDetailRoute(
  val param: BookDetailParam
)

@Serializable
data class BookDetailParam(
  @Serializable(with = CustomSerializer::class)
  val customData: CustomType
)

SerializeなRouteのプロパティとしてカスタムシリアライザーを用いると以下のエラーが発生します。

java.lang.IllegalStateException: Custom serializers declared directly on a class field via @Serializable(with = ...) is currently not supported by safe args for both custom types and third-party types. Please use @Serializable or @Serializable(with = ...) on the class or object declaration.

しかし、BookDetailParamのようなdata classを挟むとBookDetailParamのプロパティとしてはカスタムシリアライザーは利用できます。つまり直接プロパティとして持ってしまうとサポートしていないがNavTypeとして用いれば問題ないということのようです(そういう理解)。

toString()使用時の注意点

安全な例:

LocalDate.now().toString()    // "2025-09-02"
UUID.randomUUID().toString()  // "550e8400-e29b-41d4-a716-446655440000"

注意が必要な例:

// カスタムtoString()や特殊文字を含む場合
data class ProblematicClass(val value: String) {
  override fun toString() = "Custom: $value with chars: ()=&"
}
// → URLエンコーディングが必要な文字が含まれる可能性

まとめ

アプローチ 適用場面 複雑度 推奨度
toString/parse LocalDate、UUIDなど標準型 ⭐⭐⭐
JSON + カスタムシリアライザー カスタム型、複雑な構造 ⭐⭐⭐
typeMap + NavType 特殊要件、高度なカスタマイズ ⭐⭐

推奨アプローチ:

  1. 標準型(LocalDate、UUID等)はtoString/parse
  2. カスタム型はJSON + カスタムシリアライザー
  3. 特殊要件がある場合のみtypeMap

基本的にはシリアライズさせれば良く、例えばSDKのレスポンスをRouteパラメータにセットしたい場合などはこちらからシリアライズ化はできないのでtypeMapを用いる方法が必要になりそうです。

Discussion