Каждый Android-разработчик знает, что навигация в Jetpack Compose  —  не самая лучшая его сторона. Для ее осуществления приходится передавать слишком много callbacks и navControllers. А что если нужно выполнить бизнес-логику перед отправкой аргументов? В этом случае код начинает усложняться. 

Вопрос о способах реализации навигации активно обсуждается. Кроме того, появилась одна отличная библиотека. Рекомендую с ней познакомиться и заодно поблагодарить ее создателя Rafael Costa. 

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

Описание проблемы

В настоящее время наиболее распространенная реализация навигации выглядит так: 

val navController = rememberNavController()

NavHost(
navController = navController,
startDestination = "home"
) {
composable(route = "home") {
HomeScreen(
navigatToUsersScreen = { navController.navigate("users") },
navigatToMessagesScreen = { navController.navigate("messages") },
navigatToDetailsScreen = { navController.navigate("details") }
)
}
composable(route = "users") {
UsersScreen(
navigateBack = { navController.navigateUp() },
navigatToUserDetailsScreen = { navController.navigate("user_details") }
)
}
composable(route = "user_details") {
UserDetailsScreen(
navigateBack = { navController.navigateUp() }
)
}
composable(route = "messages") {
MessagesScreen(
navigateBack = { navController.navigateUp() }
)
}
composable(route = "details") {
DetailsScreen(
navigateBack = { navController.navigateUp() }
)
}
}

Первый вариант предполагает передачу лямбда-функций, а второй  —  navController. И то, и другое решение имеет свои недостатки. Чрезмерная сложность экрана может быть сопряжена с большим количеством обратных вызовов, что прилично утяжеляет код. Возможно, ваша логика вокруг маршрутов запрограммирована не так жестко, но суть проблемы понятна. 

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

Попробуем найти оптимальное решение для этой проблемы. 

Чистая навигация Jetpack Compose

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

Сначала создаем специальный класс для маршрутов: 

sealed class Destination(protected val route: String, vararg params: String) {
val fullRoute: String = if (params.isEmpty()) route else {
val builder = StringBuilder(route)
params.forEach { builder.append("/{${it}}") }
builder.toString()
}

sealed class NoArgumentsDestination(route: String) : Destination(route) {
operator fun invoke(): String = route
}

object HomeScreen : NoArgumentsDestination("home")

object UsersScreen : NoArgumentsDestination("users")

object MessagesScreen : NoArgumentsDestination("messages")

object DetailsScreen : NoArgumentsDestination("details")

object UserDetailsScreen : Destination("user_details", "firstName", "lastName") {
const val FIST_NAME_KEY = "firstName"
const val LAST_NAME_KEY = "lastName"

operator fun invoke(fistName: String, lastName: String): String = route.appendParams(
FIST_NAME_KEY to fistName,
LAST_NAME_KEY to lastName
)
}
}

internal fun String.appendParams(vararg params: Pair<String, Any?>): String {
val builder = StringBuilder(this)

params.forEach {
it.second?.toString()?.let { arg ->
builder.append("/$arg")
}
}

return builder.toString()
}

Destination имеет конструктор с 2 аргументами. Первый из них представляет собой базовый маршрут, а второй  —  параметры для этого маршрута. У каждого Destination есть route и fullRoute. route, базовый маршрут без параметров, используется для создания fullRoute с именами параметров или со значениями параметров. 

Вызов Destination возвращает его путь. Функция appendParams добавляет параметры к маршруту и возвращает fullRoute со значениями параметров. 

Далее добавляем требуемые составные элементы навигации (англ. Composables): 

@Composable
fun NavHost(
navController: NavHostController,
startDestination: Destination,
modifier: Modifier = Modifier,
route: String? = null,
builder: NavGraphBuilder.() -> Unit
) {
NavHost(
navController = navController,
startDestination = startDestination.fullRoute,
modifier = modifier,
route = route,
builder = builder
)
}

fun NavGraphBuilder.composable(
destination: Destination,
arguments: List<NamedNavArgument> = emptyList(),
deepLinks: List<NavDeepLink> = emptyList(),
content: @Composable (NavBackStackEntry) -> Unit
) {
composable(
route = destination.fullRoute,
arguments = arguments,
deepLinks = deepLinks,
content = content
)
}

NavHost аналогичен элементу из androidx.navigation.compose с той лишь разницей, что аргумент startDestination имеет тип Destination.

Тоже самое и с composable: вместо route: String применяется destination: Destination.

Реализуем этот пользовательский навигатор посредством следующего кода: 

interface AppNavigator {
val navigationChannel: Channel<NavigationIntent>

suspend fun navigateBack(
route: String? = null,
inclusive: Boolean = false,
)

fun tryNavigateBack(
route: String? = null,
inclusive: Boolean = false,
)

suspend fun navigateTo(
route: String,
popUpToRoute: String? = null,
inclusive: Boolean = false,
isSingleTop: Boolean = false,
)

fun tryNavigateTo(
route: String,
popUpToRoute: String? = null,
inclusive: Boolean = false,
isSingleTop: Boolean = false,
)
}

sealed class NavigationIntent {
data class NavigateBack(
val route: String? = null,
val inclusive: Boolean = false,
) : NavigationIntent()

data class NavigateTo(
val route: String,
val popUpToRoute: String? = null,
val inclusive: Boolean = false,
val isSingleTop: Boolean = false,
) : NavigationIntent()
}

У AppNavigator есть канал navigationChannel, который поставляет сведения в MainScreen и располагает 4 функциями для навигации. NavigationIntent содержит все возможные намерения по навигации. Вы можете дополнить их список, например намерением перейти по ссылке на конкретный ресурс или что-нибудь в этом роде. Аргументы каждого NavigationIntent необходимы для функций navController

Реализация AppNavigator не представляет сложности: просто отправляем NavigationIntents в navigationChannel.

class AppNavigatorImpl @Inject constructor() : AppNavigator {
override val navigationChannel = Channel<NavigationIntent>(
capacity = Int.MAX_VALUE,
onBufferOverflow = BufferOverflow.DROP_LATEST,
)

override suspend fun navigateBack(route: String?, inclusive: Boolean) {
navigationChannel.send(
NavigationIntent.NavigateBack(
route = route,
inclusive = inclusive
)
)
}

override fun tryNavigateBack(route: String?, inclusive: Boolean) {
navigationChannel.trySend(
NavigationIntent.NavigateBack(
route = route,
inclusive = inclusive
)
)
}

override suspend fun navigateTo(
route: String,
popUpToRoute: String?,
inclusive: Boolean,
isSingleTop: Boolean
) {
navigationChannel.send(
NavigationIntent.NavigateTo(
route = route,
popUpToRoute = popUpToRoute,
inclusive = inclusive,
isSingleTop = isSingleTop,
)
)
}

override fun tryNavigateTo(
route: String,
popUpToRoute: String?,
inclusive: Boolean,
isSingleTop: Boolean
) {
navigationChannel.trySend(
NavigationIntent.NavigateTo(
route = route,
popUpToRoute = popUpToRoute,
inclusive = inclusive,
isSingleTop = isSingleTop,
)
)
}
}

Обратите внимание, что в примере в качестве паттерна DI используется Dagger-Hilt, но вы вольны выбрать любой другой. 

Теперь реализуем MainScreen:

@Composable
fun MainScreen(
mainViewModel: MainViewModel = hiltViewModel()
) {
val navController = rememberNavController()

NavigationEffects(
navigationChannel = mainViewModel.navigationChannel,
navHostController = navController
)
MediumReposTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
NavHost(
navController = navController,
startDestination = Destination.HomeScreen
) {
composable(destination = Destination.HomeScreen) {
HomeScreen()
}
composable(destination = Destination.UsersScreen) {
UsersScreen()
}
composable(destination = Destination.UserDetailsScreen) {
UserDetailsScreen()
}
composable(destination = Destination.MessagesScreen) {
MessagesScreen()
}
composable(destination = Destination.DetailsScreen) {
DetailsScreen()
}
}
}
}
}

@Composable
fun NavigationEffects(
navigationChannel: Channel<NavigationIntent>,
navHostController: NavHostController
) {
val activity = (LocalContext.current as? Activity)
LaunchedEffect(activity, navHostController, navigationChannel) {
navigationChannel.receiveAsFlow().collect { intent ->
if (activity?.isFinishing == true) {
return@collect
}
when (intent) {
is NavigationIntent.NavigateBack -> {
if (intent.route != null) {
navHostController.popBackStack(intent.route, intent.inclusive)
} else {
navHostController.popBackStack()
}
}
is NavigationIntent.NavigateTo -> {
navHostController.navigate(intent.route) {
launchSingleTop = intent.isSingleTop
intent.popUpToRoute?.let { popUpToRoute ->
popUpTo(popUpToRoute) { inclusive = intent.inclusive }
}
}
}
}
}
}
}

В MainScreen применяем пользовательские элементы NavHost и composable. Запоминаем navController, который передаем в NavigationEffects наряду с navigationChannel из MainViewModel. NavigationEffects получает сведения из navigationChannel и переходит к нужному экрану. Как видно, данный способ более оптимальный и не требует передачи обратных вызовов и navController.

С MainViewModel все просто: получаем navigationChannel из AppNavigator.

@HiltViewModel
class MainViewModel @Inject constructor(
appNavigator: AppNavigator
) : ViewModel() {

val navigationChannel = appNavigator.navigationChannel
}

Осталось только показать, как вызываются функции навигатора. Обратимся к примеру HomeViewModel:

@HiltViewModel
class HomeViewModel @Inject constructor(
private val appNavigator: AppNavigator
) : ViewModel() {

fun onNavigateToUsersButtonClicked() {
appNavigator.tryNavigateTo(Destination.UsersScreen())
}

fun onNavigateToMessagesButtonClicked() {
appNavigator.tryNavigateTo(Destination.MessagesScreen())
}

fun onNavigateToDetailsButtonClicked() {
appNavigator.tryNavigateTo(Destination.DetailsScreen())
}
}

HomeScreen вызывает соответствующие функции. В HomeViewModel мы вызываем функции AppNavigator и задействуем Destination в качестве аргумента для маршрутов.

UsersViewModel демонстрирует примеры обратной навигации и передачи параметров маршруту: 

@HiltViewModel
class UsersViewModel @Inject constructor(
private val appNavigator: AppNavigator
) : ViewModel() {

private val _viewState = MutableStateFlow(UsersViewState())
val viewState = _viewState.asStateFlow()

fun onBackButtonClicked() {
appNavigator.tryNavigateBack()
}

fun onUserRowClicked(user: User) {
appNavigator.tryNavigateTo(
Destination.UserDetailsScreen(
fistName = user.firstName,
lastName = user.lastName
)
)
}
}

На этом все! 

Заключение

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

Можно оспорить необходимость выполнения навигации из ViewModel, но я на этом настаиваю. View должен быть “глупеньким” и просто показывать данные. А все, что мы делаем при нажатии, должно попадать в зону ответственности ViewModel.

Полный вариант исходного кода предоставлен в репозитории GitHub

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Igor Stevanovic: Jetpack Compose Clean Navigation

Предыдущая статьяКак использовать React в приложениях Angular
Следующая статьяТоп-7 онлайн-редакторов кода и IDE