Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Android Navigation Compose で画面遷移時の引数を簡略化させる試み

こんにちは。Android アプリ開発担当の nagayama(@nagayan_dev)です。

今回私が担当している「auサービスToday」で Navigation Compose で画面遷移時の引数を簡略化させる試みをしてみました。こちらの内容をお伝えします。

背景

以前のブログで Navigation Compose の導入方法についてお伝えしました。まだご覧になっていない方がいましたら、下記からお願いします。

tech.gunosy.io

Navigation Compose を導入すると「画面遷移時に引数を渡したい」という場面が出てきます。Navigation Compose ではもちろん画面遷移時に引数を渡す方法は用意されています。ですが、遷移する画面間で情報を統一させる必要があったり、加工しないとクラッシュするパターンがあったりと、画面遷移の実装がスムーズに進まない場面が多々ありました。


基本的な引数の指定方法

まずは Navigation Compose で画面遷移時に引数を指定する基本的な実装方法について簡単に説明します。

developer.android.com

遷移先画面の対応

Navigation Compose の画面遷移で必要な引数は、 URL のパラメータのように route の文字列の後ろに設定します。

composable(
    route = "second_screen?pageId={pageId}"
    ~省略~

続けて composable メソッドの引数である arguments を指定します。arguments はリスト型で、引数に入れたいオブジェクトの「型」情報を入れます。情報は navArgument メソッドで作成し、name には key の文字列を、type には引数の型を設定します。設定できる型は Int や Boolean といったプリミティブ型を始め、文字列や Parcelable 型の指定が可能です。今回は Long 型を指定してみたいと思います。

composable(
  route = "second_screen?pageId={pageId}",
  arguments = listOf(
    navArgument(name = "pageId") {
      type = NavType.LongType
    }
  )
) { ~省略~ }

渡ってきた引数の受け取り方法は、通知される NavBackStackEntry の中の arguments に格納されるため、設定した type ごとの getter から値を取得します。

composable(
  route = "second_screen?pageId={pageId}",
  ~省略~
) { navBackStackEntry: NavBackStackEntry ->
  val pageId = navBackStackEntry.arguments?.getLong("pageId")
  SecondScreen(pageId)
}

遷移元画面の対応

表示する画面の route に合わせて、文字列とパラメータを設定します。

navController.navigate(route = "second_screen?pageId=" + 100)

これで画面遷移時に pageId = 100 を渡すことができるようになりました。


問題点

実際のアプリケーションに落とし込むと様々な問題が発生してきます。

問題1. 引数が多くなると、route に複雑な文字列指定が必要となる

画面遷移の情報を一つの「文字列」として表現する関係で、引数の数が増えてくると下記のような設定になります。

composable(
  route = "second_screen?pageId={pageId}&userId={userId}&url={url}",
  arguments = listOf(
    navArgument(name = "pageId") { type = NavType.LongType },
    navArgument(name = "userId") { type = NavType.StringType },
    navArgument(name = "url") { type = NavType.StringType }
  )
) { ~省略~ }
navController.navigate(route = "second_screen?pageId=" + 100 + "&userId=user_A&url=https://tech.gunosy.io/")

こうなってくると、1 つの画面遷移するために文字列を組み立てるのが面倒になってきます。

問題2. Nullable の設定

引数を Nullable にする場合は NavArgumentBuildernullable を true に設定します。

composable(
  route = "second_screen?userId={userId}",
  arguments = listOf(
    navArgument(name = "userId") {
      type = NavType.StringType
      nullable = true
    }
  )
) { ~省略~ }
val userId: String? = null
navController?.testNavController?.navigate("second_screen?userId="+ userId)

しかし、Nullable がサポートされているのは String 型と Array 型のみで、Parcelable 型は Nullable にできず、下記のようなエラーが発生します。

java.lang.IllegalArgumentException: long does not allow nullable values
  at androidx.navigation.NavArgument.<init>(NavArgument.kt:174)
  at androidx.navigation.NavArgument$Builder.build(NavArgument.kt:169)
  at androidx.navigation.NavArgumentBuilder.build(NavDestinationBuilder.kt:257)
  at androidx.navigation.NamedNavArgumentKt.navArgument(NamedNavArgument.kt:25)

問題3. 引数に URL があると、そのままの場合はエラーになってしまう

引数に URL を入れる場合があると思います。

navController.navigate(route = "second_screen?url=https://tech.gunosy.io/")
~~~
composable(
  route = "second_screen?url={url}",
  arguments = listOf(
    navArgument(name = "url") {
      type = NavType.StringType
    }
  )
) { ~省略~ }

しかし URL をそのまま入れてしまうと、下記のようなエラーが発生します。

java.lang.IllegalArgumentException: Navigation destination that matches request NavDeepLinkRequest{ uri=android-app://androidx.navigation/second_screen?url=https://tech.gunosy.io/ } cannot be found in the navigation graph NavGraph(0x0) startDestination={Destination(0x13a578e8) route=first_screen}
  at androidx.navigation.NavController.navigate(NavController.kt:1664)
  at androidx.navigation.NavController.navigate(NavController.kt:1984)
  at androidx.navigation.NavController.navigate$default(NavController.kt:1979)
  ~省略~

この事象を解消する方法の 1 つとして、URL を Json 文字列にエンコードして渡し、受け取り側でデコードをする。といったものがあります。そうなると

  • 渡す側は URL があるかどうかをチェックして、Json にエンコードする
  • 受け取る側はその文字列が URL かどうかを判別して、Json にデコードする

が必要となり、画面遷移の実装の足枷になってしまいます。

複雑な引数を渡すことはアンチパターン

そもそも公式からは 複雑な引数を渡すことはアンチパターン と言われています。Navigation Compose で Nullable の引数を制限したり、URL のような複雑な情報を設定しにくくなっているのは、そういう意図から来ているのではないかと思われます。



アプリ共通部分で情報を管理し、そこを遷移後の画面が参照する。のような「引数にしない」方法が適切かもしれません・・が、大規模なアプリケーションになるとどうしても引数で渡したい場面があり、できれば全画面統一した仕組みにしておきたいと思いました。

そこで、Navigation Compose の引数指定をシンプルにできないかチャレンジしてみました。


対応方針

問題点を受け、引数の指定方法は下記の方針で実現していきたいと思います。

  • 引数を 1 つのデータクラスにする
  • Nullable や URL の考慮をしなくてよくする
  • 全画面共通の仕組み

画面遷移の実装は下記のようになることを目指します。

val data = SecondScreenData(
  pageId = 100,
  userId = null,
  url = "https://tech.gunosy.io/"
)
navController.navigate(screen = "second_screen", data = data)


対応内容

引数のデータクラスの作成

引数を 1 つのデータクラスにする ため、sealed クラスを用意します。これを継承したクラスが画面遷移時に渡せるようにしたいと思います。また引数として設定できるように、sealed クラスは Parcelable 型に設定します。

sealed class NavData : Parcelable {}

渡したいデータを格納したデータクラスを作成し、先ほどの sealed クラスを継承します。

data class SecondScreenData(
  val pageId: Long,
  val userId: String?,
  val url: String,
) : NavData()

全画面共通の仕組み とするため、NavController#navigate の拡張関数を作成します。引数には画面遷移先の route に設定さたの文字列と、先ほど作成したデータクラスを設定します。NavData は Nullable にし、引数が無い場合でも画面遷移として用いることができるようにします。

渡すデータクラスは nav_data という文字列をキーにして受け取りができるようにします。

データクラスは Json 文字列に変換して、route に設定します。これによりパラメータに URL があったとしても問題なく route の指定ができます。Json 文字列に変換する処理は NavData クラスにメソッドを用意します。

fun NavController.navigate(
  route: String,
  data: NavData? = null
) {
  if (data != null) {
    navigate(route = route + "?nav_data=${data.toNavRouteParam()}")
  } else {
    navigate(route = route)
  }
}
sealed class NavData : Parcelable {
  fun toNavRouteParam(): String? {
    val moshi = Moshi.Builder()
      .add(
        PolymorphicJsonAdapterFactory
          .of(NavData::class.java, "nav_data")
          .withSubtype(this::class.java, this::class.java.toString())
        )
      .add(KotlinJsonAdapterFactory())
      .build()
    val adapter = moshi.adapter(NavData::class.java)
    return Uri.encode(adapter.toJson(this))
  }
}

遷移先画面の対応

遷移先画面の実装です。route は画面固有の文字列の後ろに ?nav_data={nav_data} を追加します。

渡されたデータは Parcelable 型であるため、getParcelable で値を取り出します。

composable(
  route = "second_screen?nav_data={nav_data}",
  arguments = ~省略~
) { backstackEntry ->
  val navScreenData: SecondScreenData? = backstackEntry.arguments?.getParcelable("nav_data")
  SecondScreen(navScreenData)
}

続けて arguments に型を指定します。他の画面でも使用するため createNavArguments とメソッドを作成します。Parcelable 型を指定する場合は、NavType のオブジェクトを作成して get / put します。parseValue では画面遷移前に Json に変換したものを元のデータクラスに戻す処理を行います。

composable(
  route = "second_screen?nav_data={nav_data}",
  arguments = createNavArguments<SecondScreenData>()
) { ~省略~ }

~~~~

inline fun <reified T: Parcelable> createNavArguments(isNullableAllowed: Boolean): List<NamedNavArgument> {
  return listOf(
    navArgument("nav_data") {
      type = object : NavType<T?>(isNullableAllowed = isNullableAllowed) {
        override fun get(bundle: Bundle, key: String): T? {
          return bundle.getParcelable(key) as? T
        }
        
        override fun parseValue(value: String): T? {
          val moshi = Moshi
                    .Builder()
                    .add(KotlinJsonAdapterFactory())
                    .build()
            val jsonAdapter: JsonAdapter<T> = moshi
                    .adapter(T::class.java)

            return try {
                jsonAdapter.fromJson(Uri.decode(value))
            } catch (e: Exception) {
                null
            }
        }
        
        override fun put(bundle: Bundle, key: String, value: T?) {
          if (value != null) {
            bundle.putParcelable(key, value)
          }
        }
      }
    }
  )
}

遷移元画面の対応

以上の実装で当初予定していた画面遷移処理を実行すると、データクラスが遷移先の画面に渡せられるようになりました。

val data = SecondScreenData(
  pageId = 100,
  userId = null,
  url = "https://tech.gunosy.io/"
)
navController.navigate(screen = "second_screen", data = data)

やってみて改善されたこと

やってみて改善されたこととして、シンプルで分かりやすい画面遷移になったと思います。Nullable が可能かどうかや URL かどうかを考慮する必要がなくなり、ストレスなく画面遷移時にデータを渡せるようになりました。

また、データクラスの型があるため、複数の画面から画面遷移する場合でも遷移時に何が必要かが分かりやすくなりました。


まとめ

Navigation Compose で画面遷移時の引数を簡略化させる試みをしてみました。route の文字列作成や渡すデータの考慮が手間でなかなかスムーズに画面遷移の実装をすることができていませんでしたが、引数を 1 つのデータクラスにして内部で Json 変換をすることで、画面遷移の実装をシンプルで分かりやすいものにできました。ただし公式からは複雑な引数を渡すことはアンチパターンと言われているため、参考にされる場合はご注意をお願いします。


最後に

Gunosy では Android エンジニアを募集しております。ご興味をお持ちの方はぜひご連絡ください。

gunosy.co.jp