Делаю навигацию в приложении на Compose
О чем ты нам расскажешь и кто ты такой?
То, о чем пойдет речь ниже, назвать инновацией нельзя. Это, скорее, мое личное видение по навигации между фичевыми модулями, которое я определил для своего проекта базирующегося на Compose и Compose Navigation.
В моей предыдущей статьея писал, что проект в рамках которого я рассматриваю разные подходы — делается на всем, что связано с Compose и разрабатывается так, чтобы каждую его запчасть можно было максимально переиспользовать на других проектах и, в перспективе, доработать проект под кросс платформу на KMP.
Ну ок, а кто же будет вещать?
Меня зовут Виктор и я мобильный разработчик под Android с опытом в этой сфере 7+ лет и по совместительству основатель небольшой команды мобильной разработки Fill Team. Успел поработать как в маленьких стартапах, так и в крупных российских и зарубежных компаниях. Тем не менее на лютую экспертность не претендую, а лишь хочу поделиться своим опытом о том как я навигацию в своем приложении делал.
Для ориентира по статье
Для ориентира по серии статей
Фичи нужно переиспользовать
Так уж повелось, что если я что-то делаю, то всегда пытаюсь сделать так, чтобы результат работы можно было использовать в смежных областях (такой подход позволяет, количественно перекрывать многие потребности в будущем).
Аналогичного принципа я придерживался, когда начинал писать приложение для хранения паролей на Compose.
Дело в том, что в рамках моей команды Fill Team к нам часто приходят заказчики у которых сравнительно небольшой бюджет, но при этом это клиенты, которым нужно MVP для проверки гипотезы. А MVP, как мы знаем, очень часто состоит из нескольких типовых фичей: аутентификация; регистрация; настройки и т.д. Делать проект абсолютно с нуля: дорогая и невыгодная затея, а вот решение с повторным использованием готовых фичей — поистине оптимальное для таких ситуаций. Но не будем закапываться в бизнесовые детали, статья не об этом…
В общем, когда проектировал архитектуру приложения для хранения паролей, стояла задача переиспользовать фичи как полноценные модули из которых потом можно будет собирать новые приложения и при этом затрачивать минимум времени. Соответственно, навигация внутри фичей должна быть закрыта и при этом фичи не должны знать ничего о других фичах (а значит и навигация напрямую из одной фичи в другую должна подниматься на уровень выше этих N фичей).
А было ли что-то подходящее?
Как я писал выше, для Compose Navigation, на момент проектирования архитектуры приложения, подходящих решений под мои требования я не нашел.
Хотелось сделать удобное в использовании и не перегруженное лишними деталями решение, которое в перспективе можно будет расширять. Скажем, сделать модуль с перечнем навигационных контрактов, который можно будет подключать к модулям фичей и дружить с Compose Navigation. В итоге на ум пришла следующую схема подключения (довольно типовая, на самом деле):
В дальнейшем нам будут интересны Navigation Module и Feature Pack. Первый — это как раз таки модуль содержащий все необходимые контракты и базовые классы навигации. Второй — это абстракция над инкапсулированной логикой фичи (помним, у нас фича — это максимально замкнутая система со своей внутренней навигацией).
К делу
На этапе проектирования решил, что модуль определяющий контракты и базовые реализации связанные с навигацией должен быть отдельным, содержать все необходимое, что так или иначе связано с настройками навигации и переходов. При этом модуль, в перспективе, должен быть собран в отдельный подключаемый файл, который можно будет просто подключать к фичам в gradle.
Постановка сделана, го к решению. Пришлось набросать несколько блок схем, которые графически отобразили бы поток мыслей в связанную систему. Получилось что-то такое:
Navigation Module определяет парочку логических блоков:
Интерфейсы и классы, отвечающие за взаимодействие с навигацией как внутри фичи так и за ее пределами (на рисунке показаны основные интерфейсы в Navigation Module)
Упрощающие жизнь надстройки, такие как: системный backpressure, backpressure с условием, таймером, анимации и т.д. (см. Utils and Navigation tools)
Ничто так хорошо не расскажет о решении, как его применение на практике!
Подумал я и решил рассказать на примере одной фичи из проекта, как это все работает.
Какой функционал хочется? Чего делаем?
А делаем фичу для работы с паролями состоящую из двух экранов: отображение списка паролей; создание и редактирование пароля. В приложении они выглядят как на рисунке, соответственно.
Далее, вместо слова «пароль», часто буду использовать термин «токен», так как в будущем приложение будет работать не только с паролями, но и с другими данными, такими как: карты; pdf документы; текст; и т.д.
Сформулируем задачу предметно
Необходимо реализовать навигацию для модуля фичи токенов. Навигация между экранами фичи должна быть инкапсулирована и неизменна за пределами фичи. Навигация модуля должна давать ручки для входа и выхода из фичи (то есть, дать возможность встраивать ее в любые другие флоу имея лишь информацию о том, как фича может завершаться и инициализироваться).
Шагаем необходимые шаги
Весь процесс реализации навигации для модуля можно разделить на два последовательных этапа, каждый из которых состоит из нескольких шагов:
Настройка навигации на этапе реализации фичи
Реализация команд переходов (каждая команда определяет возможный переход между экранами или же переход соответствующий завершению фичи)
Подготовка данных, участвующих в инициализации экранов фичи
Реализация графа навигации (в нашем случае, на базе Compose Navigation)
Определение интерфейса обработчика терминальных состояний фичи
Реализация роутера для внутрифичевой навигации
Интеграция фичи в проектный флоу
Реализация интерфейса обработчика терминальных состояний фичи
Как можно заметить выше в иерархии, самый объемный этап — первый. Но важно понимать, что он выполняется только в процессе реализации самой фичи и в дальнейшем при использовании фичи в различных проектах уже не изменяется, а становится закрыт за занавеской инкапсуляции.
Про настройку навигации внутри фичи
Реализация команд переходов
На этапе проектирования (или даже на этапе прототипирования схемы экранов) мы первым делом можем составить блок-схему всех переходов фичи. Такой подход позволит нам сразу понять как будут взаимодействовать экраны фичи между собой и как фича будет взаимодействовать с остальными флоу приложения.
Пример схемы переходов для фичи токенов выглядит следующим образом.
На базе схемы полученной на этапе проектирования сразу видно какие переходы будут внутренними, а какие будут смотреть наружу для работы с ними на этапе интеграции фичи в проектный флоу. Имея такую информацию, мы можем сразу определить изолированную иерархию команд, которые будут использоваться для обработки переходов в коде приложения.
Иерархия команд выглядит таким образом
sealed interface TokensCommand : NavRouterCommand {
data class EditToken(val tokenId: Long) : TokensCommand
object CreateToken : TokensCommand
// Terminal commands between flows
object TerminalPincodeCreation : TokensCommand
object TerminalOpenSettings : TokensCommand
}
Те команды, которые помечены внутренними, реализуются только на этапе создания модуля фичи. Команды же, которые помечены терминальными, используются на этапе интеграции фичи в проектный флоу в соответствующий Binder (о нем пойдет речь дальше).
Инициализирующие данные
Мы знаем, что фича может состоять из нескольких отдельных экранов, каждый из которых реализует свою логику работы, но может быть завязан на соседях в рамках одной фичи. В нашем случае у нас два экрана (каждый из которых имеет несколько состояний визуализации): экран отображения списка токенов и экран для создания/редактирования токена.
На этапе перехода на экран нам важно иметь возможность дать ему понять, в каком из состояний нужно показываться. Разным состояниям могут понадобиться дополнительные данные, которые могут участвовать в первичной инициализации. Для этого я решил использовать, так называемый объект ноды для каждого отдельного экрана.
Нода — это логическое представление как состояния инициализации экрана, так и его навигационного состояния. В случае с фичей токенов у меня есть две ноды.
Нода для списка токенов (заголовок блока кода)
object TokensNode : NavRouteNode.Route, NavRouteNode.Parameters {
override val tag = "tokens_node"
override val route = tag
override val routeWithValues = tag
override val arguments = listOf()
}
Нода для создания/редактирования токена (заголовок блока кода)
sealed interface TokenNode : NavRouteNode.Parameters {
data class Edition(
val tokenId: Long,
override val routeWithValues: String = "$tag?$TOKEN_ID_KEY=$tokenId",
) : TokenNode {
companion object : NavRouteNode.Route {
const val TOKEN_ID_KEY = "token_id_key"
override val tag = "token_edition_node"
override val route = "$tag?$TOKEN_ID_KEY={$TOKEN_ID_KEY}"
override val arguments = listOf(
navArgument(TOKEN_ID_KEY) {
type = NavType.LongType
}
)
}
}
object Creation : TokenNode, NavRouteNode.Route {
override val arguments = listOf()
override val tag = "token_creation_node"
override val route = tag
override val routeWithValues: String = tag
}
}
Как вы могли заметить, каждая нода реализует два контракта: NavRouteNode.Route и NavRouteNode.Parameters.
Первый определяет интерфейс для работы с нодой на уровне графа навигации. Второй же определяет интерфейс для работы с нодой на уровне логики навигации: конфигурирование ноды; данных ноды; и т.д…
У NavRouteNode.Route есть три основных поля, которые используются на этапе реализации ноды для того, чтобы можно было взаимодействовать с навигацией уровня Compose Navigation:
val tag: String — поле, которое хранит строковое представление ноды, как правило эквивалентно названию ноды/экрана (используется как часть route и routeWithValues в отдельных случаях для построения линки перехода на экран)
val route: String — сырая линка, характеризующая экран. По ней Compose Navigation сможет понять, какой экран в графе навигации нам надо будет отрисовать (в линке помимо основной части хранятся заглушки данных, которые могут быть нужны экрану в формате ключ-значение)
val arguments: List
— список всех полей, которые могут быть в route. Они используются для того, чтобы Compose Navigation смог достать эти значения на этапе отрисовки экрана
В самом простом случае, когда нам нужно просто показать, что экрану не нужны никакие значения и он может быть только в одном единственном состоянии, нода экрана будет выглядеть так, как она выглядит для экрана списка токенов.
С нодами закончили, дальше у нас идет этап оформления графа навигации Compose Navigation.
Граф навигации
Итак, на текущем этапе мы подготовили ноды для того, чтобы построить граф навигации между экранами внутри фичи токенов. Так как фича довольно небольшая, то и граф навигации будет малюсеньким.
Код графа навигации фичи токенов
fun NavGraphBuilder.tokensGraph(start: NavRouteNode.Route) = navigation(
route = "start.route.tokens.graph",
startDestination = start.route
) {
composable(
route = TokensNode.route,
) {
TokensScreen()
}
composable(
route = TokenNode.Creation.route,
enterTransition = { slideUp },
exitTransition = { slideDown },
) {
TokenScreen(mode = TokenScreenMode.Create)
}
composable(
route = TokenNode.Edition.route,
arguments = TokenNode.Edition.arguments,
enterTransition = { slideUp },
exitTransition = { slideDown },
) { entry ->
entry.arguments?.getLong(TokenNode.Edition.TOKEN_ID_KEY)?.let { tokenId ->
TokenScreen(TokenScreenMode.Edit(tokenId))
}
}
}
Что же мы видим выше? Во-первых, если мы пользуемся Compose Navigation, то т.к. фича является отдельным подключаемым модулем и у нее должен быть свой граф навигации, мы создаем экстеншен для NavGraphBuilder. Он будет получать наш внутренний роутер для управления навигацией внутри модуля фичи. Этот экстеншен в дальнейшем вызывается в родительском графе навигации.
Во-вторых, мы размечаем каждый наш экран (в том числе каждое отдельное состояние экрана) с помощью composable, которая реализуется Compose Navigation для того, чтобы разметить отдельные экраны, которые в дальнейшем будут находиться при проходе по графу навигации для отрисовки.
Как мы видим, route поля нод используются, чтобы разметить экраны. А там, где на вход идут дополнительные поля с данными в route, мы пробрасываем arguments наших нод. Все довольно просто.
Интерфейс обработчика терминальных состояний
Тут все довольно просто. Нам нужно просто подготовить интерфейс для дальнейшей работы с терминальными состояниями на уровне приложения (т.е тогда, когда мы будем интегрировать наш модуль фичи в любой из проектов).
Код интерфейса Binder’а
interface TokensBinder : NavBinder
Выглядит супер просто, т.к. основной метод flowTo един для всех видов байндеров и определен на уровне интерфейса NavBinder. Здесь же мы указываем тип командной иерархии с которой наш байндер будет уметь работать.
Роутер внутрифичевой навигации
Ну и пожалуй последний муторный шаг реализации внутрифичевой навигации — это реализация роутера для обработки переходов на уровне фичи.
Код роутера для навигации внутри фичи
class TokensRouter(override val binder: TokensBinder) : NavRouter(binder) {
override fun navigateTo(navController: NavController?, command: NavRouterCommand): Boolean {
if (command !is TokensCommand) {
return false
}
return when (command) {
is TokensCommand.EditToken -> {
navController?.navigate(TokenNode.Edition(command.tokenId).routeWithValues)
true
}
TokensCommand.CreateToken -> {
navController?.navigate(TokenNode.Creation.routeWithValues)
true
}
else -> binder.flowTo(navController, command)
}
}
}
На самом деле, все довольно просто: если получили команду перехода на редактирование, значит нужно шагнуть с помощью navController«а на соответствующую ноду с настроенными данными состояния инициализации экрана.
На этом реализация навигации на уровне модуля фичи завершена. Самое сложное позади. Здесь у нас уже полностью готовый модуль, который мы можем упаковать. Дальше останется только взять нашу фичу, подключить ее к проекту (в нашем случае подключить в app модуль) и сделать последний рывок.
Про интеграцию фичи в проектный флоу
Итак, теперь остается только интегрировать нашу фичу в проектный флоу. Для этого достаточно на уровне модуля app создать реализацию определенного ранее интерфейса TokensBinder и описать переходы при получении соответствующих терминальных команд от нашей фичи.
В коде, наша реализация выглядит следующим образом (подпись для блока кода).
Реализация Binder’a для в месте интеграции
class TokensBinderImpl : TokensBinder {
override fun flowTo(navController: NavController?, command: TokensCommand) = when (command) {
TokensCommand.TerminalOpenSettings -> {
navController?.navigate(SettingsNode.routeWithValues)
true
}
TokensCommand.TerminalPincodeCreation -> {
navController?.navigate(PincodeNode.Creation.routeWithValues) {
popUpTo(0)
}
true
}
else -> false
}
}
Структурно реализация биндера очень похожа на роутер, но логически она полностью отделена от поведения роутера, т.к. задача роутера — это навигация внутри фичи, а задача биндера — это навигация между фичами.
Ну и так как мы ранее определили, что с фичи токенов мы можем уйти только в фичу настроек и на экран создания/изменения пинкода для входа в приложение, то и реализовывать мы будем всего два межмодульных перехода.
Вот и вся навигация на примере модуля фичи токенов. Дальше разберу несколько подробнее модуль навигации, который определяет все базовые контракты и реализации подхода навигации, который я использую в своем проекте для хранения паролей и планирую использовать в последующих.
Ну ок, сделали, а как использовать?
Да довольно просто. Если мы работаем с навигацией внутри фичи, то бросаем в конструктор TokensViewModel фичи наш Navigator и пользуемся.
Пример во ViewModel
class TokensViewModel(
private val navigator: Navigator,
...
) : ViewModel() { /* ... */
fun setAction(action: TokensAction) {
when (action) {
is TokensAction.EditToken -> navigator.apply(TokensCommand.EditToken(action.tokenId))
...
TokensAction.OpenSettings -> navigator.apply(TokensCommand.TerminalOpenSettings)
TokensAction.CreateToken -> navigator.apply(TokensCommand.CreateToken)
...
}
}
}
А если мы говорим про межфичевую навигацию, то для этого нам нужно лишь затолкать наш TokensGraph в основной граф приложения.
Вызов в Compose Navigation корневом графе
@Composable
fun MainGraph(navigator: NavigatorInitializer = getKoin().get()) {
navigator.initialize(rememberNavController())
NavHost(
modifier = Modifier.background(colorType = FTTheme.tokens.background.primary),
navController = navigator.navController,
startDestination = WelcomeNode.route,
enterTransition = { fadeIn },
exitTransition = { fadeOut },
popEnterTransition = { fadeIn },
popExitTransition = { fadeOut }
) {
// Starts authentication flow
...
// Settings flow
...
// Starts tokens flow
tokensGraph(start = TokensNode)
}
}
Дальше нужно будет сделать разовую инициализацию NavigatorImpl: Navigator (из модуля navigation), подсунуть в него наши роутеры, а в роутеры реализацию биндеров и все. В своем проекте я переложил эти обязательства на koin и выглядит это у меня примерно следующим образом:
Шаг 1. На уровне navigation модуля происходит инициализация навигатора:
single { NavigatorImpl(get(named(KOIN_ROUTERS_QUALIFIER))) }
Шаг 2. Не забываем, что нужно предоставить на уровне app модуля зависимость на binder«s:
single { TokensBinderImpl() }
Шаг 3. Binder«s будут пихаться DI в роутеры фичей по потребности, а значит не забываем про предоставление экземпляров роутеров на уровне фичей. Это делается на этапе реализации модуля фичи:
single { TokensRouter(binder = get()) }
Шаг 4. Далее на уровне app модуля я готовлю список роутеров всех фичей, что по сути является последним шагом:
single(named(KOIN_ROUTERS_QUALIFIER)) {
setOf>(
...,
get(),
)
}
На самом деле, шаги 1, 3, 4 реализуются в рамках проекта разово. Дальше только по мере добавления фичей, нужно будет возвращаться к шагу 2 и в шаг 4 дописывать get<РОУТЕР_НОВОЙ_ФИЧИ>().
Пожалуй, это все самое основное. Если у вас остались вопросы или было что-то непонятно, пишите в комментариях — буду рад дополнить и обсудить. Так как статья итак получилась большой, то пихать сюда описание модуля navigation не буду, если читателю будет интересно, тогда просто оформлю в отдельную нано статью — так как рассказывать там немного. Может даже к моменту написания выложу модуль в публичный Gitlab репозиторий (почему бы и да?! ).
Здесь наши полномочия все
Всем дошедшим до этих строк, огромное спасибо за ваше внимание. Надеюсь было интересно и, главное, понятно и полезно. А если заинтересовал проект, на котором я экспериментировал, то с ним можно ознакомиться в Google Play: https://play.google.com/store/apps/details? id=team.fill.keys (работает из под VPN или с телефона).