[Перевод] Масштабируем Jetpack Compose Navigation

image-loader.svg

Привет, меня зовут Антон Шилов, я Android-разработчик в Badoo. Недавно мой коллега Lachlan McKee написал статью о работе с библиотекой Jetpack Compose Navigation и том, как он решал проблему с масштабированием навигации. Далее — перевод его текста.

В одном из моих личных проектов я решил использовать Jetpack Compose в качестве основной технологии. Это означало, что моё приложение будет иметь одну Activity, а вся навигация будет выполняться с помощью Compose. Когда я начал планировать проект, библиотеки Compose Navigation ещё не было, как не было и способа внедрить ViewModel в Composable без использования компонентов Activity, Fragment или View. 

Но примерно за полгода до публикации этой статьи появилась библиотека Jetpack Compose Navigation, и Dagger-Hilt стал поддерживать Compose. Сегодня я расскажу о моём пути: поделюсь видением проблемы масштабируемости навигации на примерах Google и предложу её возможное решение. 

Что представляет собой Jetpack Compose Navigation?

Jetpack Compose Navigation — это Compose-эквивалент Jetpack Navigation. Разработчики могут задавать граф навигации, в котором для переходов между экранами используются URI. Граф можно применять для перехода между экранами без знания подробностей их имплементации/реализации — для этого достаточно URI. 

Подход Google к Jetpack Compose Navigation

На момент публикации этой статьи в документации Google была представлена следующая структура Compose-навигации:

NavHost(navController, startDestination = "profile") {
    composable("profile") { Profile(...) }
    composable("friendslist") { FriendsList(...) }
    ...
}

О чём нам говорит этот сниппет:

  • NavHost должен определяться внутри Сomposable функции, которая является хостом для навигации;

  • каждый маршрут внутри NavHost мы определяем с помощью Сomposable функции;

  • Composable функция принимает строковый параметр, определяющий маршрут, а также любой аргумент в этой строке. Она может изменять поведение навигации с помощью знакомого нам режима singleTop и управлять стеком в ходе навигации (например, операцией popTo).

Последствия подхода Google к Compose-навигации

Как вы могли заметить, для области видимости NavHost нужно определять каждый маршрут. Хотя для децентрализации маршрутизации можно использовать вложенную навигацию, в области видимости NavHost всё же нужно объявить Composable.

На мой взгляд, такую реализацию трудно поддерживать. NavHost должен знать, как создавать экземпляр каждой функции «composable», — и в результате может превратиться в God object. Эта схема поможет понять суть подхода Google:

image-loader.svg

Но если у нас сотни маршрутов Composable, как нам всё это масштабировать? Поскольку Google не предлагала никакого решения, я придумал собственное.

Масштабируем Jetpack Compose Navigation с помощью Dagger-Hilt

Поскольку степень модульности приложений растёт, не целесообразно ли сделать один NavHost (в зависимости от вложенности графов) ответственным за создание всех Composable? Я пришёл к следующему решению: все маршруты создаются внутри NavHost с помощью фабрик, которые можно определять в функциональном модуле (feature module), сохраняя механизм определения маршрутов Composable функций в одном модуле.

Хотя это решение может добавить сложности и объёмности коду, с помощью Dagger-Hilt можно значительно уменьшить количество шаблонного кода и связанных с ним проблем.

Немного поясню. Dagger-Hilt больше не требует от разработчиков явно создавать Dagger-компоненты: для определения области видимости модулей (Singleton, ActivityScoped, FragmentScoped, ViewModelScoped и т. д.) применяется InstallInAnnotation. Недавно я узнал, что Dagger-Hilt обнаруживает Hilt-модули в Gradle модуле без каких-либо явных ссылок в коде. Это означает, что фабрика навигации, которую мы хотим создать, может быть привязана внутри модуля Gradle к области видимости Singleton и доступна откуда угодно.

Другим важным изменением было появление в Dagger-Hilt функции-расширения из Kotlin под названием hiltViewModel (). Это стало для меня зацепкой, положившей начало моему исследованию. hiltViewModel () позволяет из Composable функции получить заданную ViewModel без явной ссылки на Activity, Fragment или View. Делает она это с помощью извлечения Activity, внутри которого находится Composable с небольшим количеством рефлексии (на момент написания статьи).

Что у нас получается

Как я упоминал выше, это решение я разработал для использования в личном проекте. Оно оказалось крайне полезно для разработки, да к тому же выяснилось, что все мои функциональные модули могут использовать модификатор доступа internal в Kotlin. Благодарить за это нужно размещение Composable внутри фабричного класса, определённого в функциональном модуле Composable.

Этот подход продемонстрирован в моём проекте на GitHub. Вот некоторые фрагменты кода, которые иллюстрируют мою идею:

// An interface is created to allow a feature module to add their Composable to the NavGraph.
// Defined within a library module
interface ComposeNavigationFactory {
  fun create(builder: NavGraphBuilder, navController: NavHostController)
}
// An implementation of the interface, as well as a Dagger 2 module installed via hilt.
// Defined within a feature module
internal class Feature1ComposeNavigationFactory @Inject constructor() : ComposeNavigationFactory {
  override fun create(builder: NavGraphBuilder, navController: NavHostController) {
    builder.composable(
      route = "feature1",
      content = {
        Feature1(
          navController = navController
        )
      }
    )
  }
}

// Defined within the 'feature 1' module
@Module
@InstallIn(SingletonComponent::class)
internal interface ComposeNavigationFactoryModule {
  @Singleton
  @Binds
  @IntoSet
  fun bindComposeNavigationFactory(factory: Feature1ComposeNavigationFactory): ComposeNavigationFactory
}
// An example of a set of factories being used to construct a NavHost.
// Potentially defined within the app module
@AndroidEntryPoint
class ExampleActivity: AppCompatActivity {
  @Inject
  lateinit var composeNavigationFactories: @JvmSuppressWildcards Set

  @Composable
  fun JetpackNavigationHiltApp() {
    val navController = rememberNavController()

    NavHost(navController, startDestination = "feature1") {
      composeNavigationFactories.forEach { factory ->
        factory.create(this, navController)
      }
    }
  }
}

Как видите, ExampleActivity нет нужды знать, как конструировать Сomposable функции, так как эта задача делегирована ComposeNavigationFactory. Созданием Feature1 занимается Feature1ComposeNavigationFactory, которая находится в том же модуле.

Эта схема поможет лучше понять идею:

image-loader.svg

Двигаемся дальше

Чтобы моё решение было ещё полезнее, я создал библиотеку, которая упрощает некоторые аспекты и уменьшает количество шаблонного кода. Hilt Compose Navigation Factory содержит интерфейс ComposeNavigationFactory, а также компилятор Dagger 2, в котором применён подход Google в отношении функции hiltViewModel (). Новый подход выглядит так:

// Defined within the feature module
@HiltComposeNavigationFactory
internal class Feature1ComposeNavigationFactory @Inject constructor() : ComposeNavigationFactory {
  override fun create(builder: NavGraphBuilder, navController: NavHostController) {
    builder.composable(
      route = "feature1",
      content = {
        Feature1(
          navController = navController
        )
      }
    )
  }
}

HiltComposeNavigationFactory действует аналогично аннотации Dagger-Hilt HiltViewModel. Она генерирует модуль Dagger 2, который в предыдущем примере мы создавали вручную, тем самым снижая количество шаблонного кода. Пример набора фабрик, используемых для конструирования NavHost:

// Potentially defined within the app module
@Composable
fun JetpackNavigationHiltApp() {
  val navController = rememberNavController()
  val context = LocalContext.current
  
  // The start destination would still need to be known at this point.
  NavHost(navController, startDestination = "feature1") {
    hiltNavGraphNavigationFactories(context).addNavigation(this, navController)
  }
}

Обратите внимание, что для Composable нам больше не нужна область видимости Activity, поскольку функция hiltNavGraphNavigationFactories может обращаться через контекст к набору ComposeNavigationFactory. Схема нового подхода с использованием библиотеки:

image-loader.svg

Заключение

С помощью нового паттерна мы можем масштабировать NavHost, возлагая ответственность за определение маршрутов на сами функции. Это далеко не единственный доступный вариант. Есть и другие библиотеки, которые иначе решают задачу навигации с помощью Jetpack Compose Navigation, например Compose Router. Но если вы хотите придерживаться подхода к навигации, предложенного Google, то, на мой взгляд, это отличный способ масштабируемого применения паттерна.

Буду рад предложениям по улучшению библиотеки, смело присылайте вопросы и даже пул-реквесты!

© Habrahabr.ru