Connect with us

Разработка

Масштабирование навигации в Jetpack Compose: от простых приложений до реальной архитектуры

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

Опубликовано

/

     
     

Некоторое время назад я написал статью о навигации в Jetpack Compose, где подробно рассмотрел маршруты, аргументы, вложенные графы и реальные сценарии работы. Эта статья, как и мои предыдущие работы по SwiftUI, была написана на основе реального опыта. Я пересмотрел старый код, заметил шаблоны, которые плохо масштабировались, и захотел задокументировать то, что наконец-то стало для меня понятным.

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

Но после работы над более крупными приложениями я понял кое-что важное.

Этого подхода недостаточно, когда ваше приложение разрастается.

Он не дает полного ответа на вопрос о том, как структурировать навигацию, когда у вас 20, 30 или 50 экранов. Он также не рассматривает, как навигация взаимодействует с состоянием, а именно здесь и возникает большая часть реальной сложности.

Поэтому эта статья — продолжение, которое я хотел бы написать раньше.

Речь идёт не об изучении API. Речь идёт о том, как структурировать навигацию таким образом, чтобы она не разваливалась по мере роста вашего приложения.

Когда навигация перестаёт быть простой

В демонстрационных примерах навигация выглядит простой.

Вы создаёте NavHost, определяете несколько компонуемых пунктов назначения и вызываете navigate(). Всё работает. Вы чувствуете, что понимаете это.

Затем приложение начинает становиться реальным.

Вы добавляете потоки авторизации. Вы добавляете онбординг. Вы вводите вкладки внизу. Вам нужно осуществлять навигацию после успешного вызова API. Вам нужно очистить стек возврата после аутентификации. Вам нужны глубокие ссылки. У вас есть несколько функций, связанных друг с другом.

И внезапно навигация перестаёт быть просто перемещением между экранами. Она становится архитектурой.

Именно в этот момент большинство приложений начинают сыпаться.

Психологическая модель остаётся неизменной

Даже для больших приложений основная ментальная модель не меняется.

  • Маршрут определяет пункт назначения
  • NavHost определяет карту
  • NavController перемещается по этой карте

Изменяется лишь способ организации этой карты.

В небольших приложениях достаточно одного NavHost с несколькими маршрутами.

В больших приложениях такой же подход превращается в гигантский файл, в котором сложно разобраться, который сложно поддерживать и легко сломать.

Ошибка, которую я совершил на раннем этапе

Самая большая ошибка, которую я совершил, когда впервые использовал Compose Navigation в реальном проекте, заключалась в следующем:

Я держал всё в одном месте.

Один NavHost. Десятки блоков composable(). Маршруты, определенные непосредственно в коде. Логика навигации, разбросанная по экранам.

Это работало, но не масштабировалось.

Со временем:

  • Файл стал слишком большим, чтобы в нём можно было ориентироваться мысленно.
  • Разные потоки начали мешать друг другу.
  • Поведение стека возврата стало сложнее понять.
  • Рефакторинг стал рискованным.

Решение заключалось не в изучении новых API. Решение заключалось в изменении структуры навигации.

Как структурировать навигацию в реальном приложении

Если в вашем приложении много экранов, цель состоит не в том, чтобы централизовать всё в одном файле.

Цель — централизовать управление, разделив структуру.

Хорошо зарекомендовавшая себя схема выглядит так:

  • Один корневой NavHost
  • Отдельные графы навигации для каждой функции или потока
  • Централизованные определения маршрутов
  • Навигация обрабатывается на уровне маршрута или контейнера

На практике это выглядит так.

Корневая навигация

@Composable
fun App() {
    val navController = rememberNavController()
    AppNavHost(navController)
}

@Composable
fun AppNavHost(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = Graph.AUTH,
        route = Graph.ROOT
    ) {
        authNavGraph(navController)
        mainNavGraph(navController)
    }
}

Корневой граф отвечает только на один вопрос: какие основные потоки выполняются в приложении?

Не на каждом экране.

Графы функций

Каждый поток получает свой собственный граф навигации.

Например, аутентификация:

fun NavGraphBuilder.authNavGraph(navController: NavHostController) {
    navigation(
        startDestination = "login",
        route = Graph.AUTH
    ) {
        composable("login") {
            LoginScreen(
                onLoginSuccess = {
                    navController.navigate(Graph.MAIN) {
                        popUpTo(Graph.AUTH) { inclusive = true }
                    }
                }
            )
        }
composable("signup") {
            SignupScreen()
        }
    }
}

И поток вашего приложения:

fun NavGraphBuilder.mainNavGraph(navController: NavHostController) {
    navigation(
        startDestination = "home",
        route = Graph.MAIN
    ) {
        composable("home") {
            HomeRoute(navController)
        }
composable("settings") {
            SettingsScreen()
        }
    }
}

Это позволяет объединять связанные экраны и предотвращает превращение вашей навигации в один большой плоский граф.

Маршруты по-прежнему должны быть структурированы.

Даже в больших приложениях правило из предыдущей статьи остается в силе.

Не разбрасывайте строки маршрутов повсюду.

Используйте хелперы.

sealed class AppScreen(val route: String) {
    data object Home : AppScreen("home")
    data object UserDetails : AppScreen("user/{userId}") {
      fun createRoute(userId: Long) = "user/$userId"
    }
}

Эта небольшая привычка убережет вас от удивительно большого количества ошибок в будущем.

Где на самом деле хранится состояние (недостающий элемент)

Это та часть, которую я недостаточно подробно осветил в своей предыдущей статье. Навигация не владеет вашими данными. Она лишь перемещает вас между экранами. В реальных приложениях состояние должно храниться в одном из трех мест.

1. Аргументы навигации

Передавайте только небольшие, стабильные значения:

  • ID n- фильтры
  • простые флаги

Пример:

navController.navigate(UserDetails.createRoute(userId))

2. ViewModel (состояние экрана)

Каждый экран должен иметь ViewModel, которая загружает и хранит бизнес-данные.

class UserDetailsViewModel(
    savedStateHandle: SavedStateHandle
) : ViewModel() {

private val userId: Long =
        checkNotNull(savedStateHandle["userId"])
    val uiState = MutableStateFlow(UserDetailsUiState())
}

Здесь происходят:

  • Вызовы API
  • Состояния загрузки и ошибок отображаются в реальном времени
  • Обрабатывается бизнес-логика

3. Состояние Composable

Состояние, доступное только для пользовательского интерфейса, остается в компонуемом объекте.

var text by rememberSaveable { mutableStateOf("") }

Это включает в себя:

  • текстовые поля
  • переключатели
  • временные элементы пользовательского интерфейса

Почему это разделение важно

Если вы попытаетесь передать состояние через навигацию, всё быстро сломается.

  • Объекты становится сложно сериализовать
  • Данные устаревают
  • Навигация становится ненадежной

Передача идентификаторов и загрузка данных в целевом элементе обеспечивает предсказуемость.

Чистое связывание множества экранов

Когда несколько экранов связаны друг с другом, возникает соблазн вызывать NavController везде.

Это работает, но распределяет логику навигации по всему пользовательскому интерфейсу.

Более чистый подход — хранить навигацию на уровне маршрута.

@Composable
fun HomeRoute(navController: NavHostController) {
    HomeScreen(
        onOpenSettings = {
            navController.navigate("settings")
        },
        onOpenUser = { id ->
            navController.navigate("user/$id")
        }
    )
}

@Composable
fun HomeScreen(
    onOpenSettings: () -> Unit,
    onOpenUser: (Long) -> Unit
) {
    Column {
        Button(onClick = onOpenSettings) {
            Text("Settings")
        }
        Button(onClick = { onOpenUser(42) }) {
            Text("Open User")
        }
    }
}

Теперь ваш пользовательский интерфейс остаётся переиспользуемым и его проще тестировать.

Нижняя навигация и несколько сценариев использования

Для приложений с вкладками навигация должна корректно обрабатывать несколько разделов.

Распространённый шаблон:

navController.navigate(route) {
    popUpTo(navController.graph.startDestinationId) {
        saveState = true
    }
    launchSingleTop = true
    restoreState = true
}

Это:

  • позволяет избежать дублирования экранов
  • сохраняет состояние вкладок
  • восстанавливает состояние при возвращении к вкладке

Это одна из тех мелких деталей, которые существенно влияют на качество вашего приложения.

Структура, которая масштабируется

Если бы мне нужно было кратко описать подход, работающий для больших приложений, он бы звучал так:

  • Один корневой NavHost
  • Вложенные графы для каждой функции
  • Централизованные хелперы маршрутизации
  • ViewModel для состояния экрана
  • Composables , ориентированные только на UI

Это сохраняет понятную навигацию даже спустя месяцы разработки.

Собираем всё вместе

Чтобы сделать это более наглядным, вот минимальная, но реалистичная схема подключения всего: тонкая MainActivity, корневой App, AppNavHost, графы функций, вспомогательные функции маршрутизации и экран с ViewModel.

MainActivity

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            App()
        }
    }
}

App

@Composable
fun App() {
    val navController = rememberNavController()
    AppNavHost(navController = navController)
}

Графы

object Graph {
    const val ROOT = "root"
    const val AUTH = "auth"
    const val MAIN = "main"
}

Хелперы для маршрутов

sealed interface Destination { val route: String }

sealed interface AuthDest : Destination {
    data object Login : AuthDest { override val route = "login" }
    data object Signup : AuthDest { override val route = "signup" }
}
sealed interface MainDest : Destination {
    data object Home : MainDest { override val route = "home" }
    data object Settings : MainDest { override val route = "settings" }
    data object UserDetails : MainDest {
        override val route = "user/{userId}"
        const val ARG = "userId"
        fun create(userId: Long) = "user/$userId"
    }
}

Корневой NavHost

@Composable
fun AppNavHost(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = Graph.AUTH,
        route = Graph.ROOT
    ) {
        authNavGraph(navController)
        mainNavGraph(navController)
    }
}

Граф аутентификации

fun NavGraphBuilder.authNavGraph(navController: NavHostController) {
    navigation(startDestination = AuthDest.Login.route, route = Graph.AUTH) {
        composable(AuthDest.Login.route) {
            LoginRoute(
                onLoginSuccess = {
                    navController.navigate(Graph.MAIN) {
                        popUpTo(Graph.AUTH) { inclusive = true }
                    }
                },
                onSignup = { navController.navigate(AuthDest.Signup.route) }
            )
        }

composable(AuthDest.Signup.route) {
            SignupRoute(onDone = { navController.popBackStack() })
        }
    }
}

Основной граф (с аргументами)

fun NavGraphBuilder.mainNavGraph(navController: NavHostController) {
    navigation(startDestination = MainDest.Home.route, route = Graph.MAIN) {
        composable(MainDest.Home.route) {
            HomeRoute(
                onOpenSettings = { navController.navigate(MainDest.Settings.route) },
                onOpenUser = { id -> navController.navigate(MainDest.UserDetails.create(id)) }
            )
        }
composable(MainDest.Settings.route) {
            SettingsRoute()
        }
        composable(
            route = MainDest.UserDetails.route,
            arguments = listOf(navArgument(MainDest.UserDetails.ARG) {
                type = NavType.LongType
            })
        ) { entry ->
            val userId = entry.arguments?.getLong(MainDest.UserDetails.ARG)
                ?: return@composable
            UserDetailsRoute(userId = userId)
        }
    }
}

Маршрутизация против UI (не допускаяющая NavController в конечный UI)

@Composable
fun HomeRoute(
    onOpenSettings: () -> Unit,
    onOpenUser: (Long) -> Unit
) {
    HomeScreen(
        onOpenSettings = onOpenSettings,
        onOpenUser = onOpenUser
    )
}

@Composable
fun HomeScreen(
    onOpenSettings: () -> Unit,
    onOpenUser: (Long) -> Unit
) {
    Column {
        Button(onClick = onOpenSettings) { Text("Settings") }
        Button(onClick = { onOpenUser(42L) }) { Text("Open User") }
    }
}

Состояние в целевом экране на основе ViewModel

data class UserDetailsUiState(
    val isLoading: Boolean = true,
    val name: String = "",
    val error: String? = null
)

class UserDetailsViewModel(
    savedStateHandle: SavedStateHandle
) : ViewModel() {
    private val userId: Long = checkNotNull(savedStateHandle[MainDest.UserDetails.ARG])
    private val _uiState = MutableStateFlow(UserDetailsUiState())
    val uiState: StateFlow<UserDetailsUiState> = _uiState
    init {
        // fake load
        viewModelScope.launch {
            delay(300)
            _uiState.value = UserDetailsUiState(isLoading = false, name = "User #$userId")
        }
    }
}
@Composable
fun UserDetailsRoute(
    userId: Long,
    viewModel: UserDetailsViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    UserDetailsScreen(uiState = uiState)
}
@Composable
fun UserDetailsScreen(uiState: UserDetailsUiState) {
    when {
        uiState.isLoading -> Text("Loading...")
        uiState.error != null -> Text("Error: ${uiState.error}")
        else -> Text("Hello ${uiState.name}")
    }
}

Вот полный цикл:

  • MainActivity хостит приложение
  • App владеет NavController
  • AppNavHost определяет корневой граф
  • Графы функций группируют связанные потоки
  • Хелперы маршрутов определяют пункты назначения и аргументы
  • Композируемые объекты маршрутов обрабатывают привязку навигации
  • ViewModel владеют состоянием экрана
  • UI composable отображают состояние и генерируют события

Если вы будете следовать этой структуре, вы сможете добавлять экраны, потоки и функции, не превращая вашу навигацию в огромный, хрупкий файл.

Заключение

Моя предыдущая статья была посвящена тому, как сделать навигацию Compose более удобной. Эта статья о масштабируемости.

Для небольших приложений достаточно одного NavHost и нескольких маршрутов. Но как только ваше приложение разрастется, навигация перестанет быть просто перемещением между экранами. Она станет частью вашей архитектуры. Самым большим изменением для меня стало осознание следующего:

Навигация должна оставаться простой. Структура делает её мощной.

Как только вы правильно разделите графы, маршруты и состояние, всё станет проще для понимания.

И что ещё важнее, ее станет проще поддерживать, когда ваше приложение неизбежно будет расти.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: