На этой схеме не скелет древнего обитателя водных глубин и не схема метро какого-то мегаполиса, это карта переходов по экранам вполне реального Андроид приложения! Но, несмотря на сложность, нам удалось её удачно реализовать, а решение оформить в виде небольшой библиотеки, о которой и пойдет речь в статье.
Чтобы заранее избежать вопросов о названии, уточню: Cicerone ("чи-че-ро́-не") – устаревшее слово с итальянскими корнями, со значением «гид для иностранцев».
В наших проектах мы стараемся придерживаться архитектурных подходов, которые позволяют отделить логику от отображения.
Так как я в этом плане предпочитаю MVP, то далее по тексту будет часто встречаться слово «презентер», но хочу отметить, что представленное решение никак не ограничивает вас в выборе архитектуры (можно даже использовать в классическом подходе «все во Fragment’ах», и даже в этом случае Cicerone даст свой профит!).
Навигация – это скорее бизнес-логика, поэтому ответственность за переходы я предпочитаю возлагать на презентер. Но в Андроиде не все так гладко: для осуществления переходов между Activity, переключения Fragment’ов или смены View внутри контейнера
- не обойтись без зависимости от Context’a, который не хочется передавать в слой логики, связывая тем самым его с платформой, усложняя тестирование и рискуя получить утечки памяти (если забыть очистить ссылку);
- надо учитывать жизненный цикл контейнера (например, java.lang.IllegalStateException: Can not perform this action after onSaveInstanceState у Fragment’ов).
Поэтому и появилось решение, реализованное в Cicerone.
Начать, думаю, стоит со структуры.
Структура
На схеме есть четыре сущности:
- Command – это простейшая команда перехода, которую выполняет Navigator.
- Navigator – непосредственная реализация «переключения экранов» внутри контейнера.
- Router – это класс, который превращает высокоуровневые вызовы навигации презентера в набор Command.
- CommandBuffer – отвечает за сохранность вызванных команд навигации, если в момент их вызова нет возможности осуществить переход.
Теперь о каждой подробнее.
Команды переходов
Мы заметили, что любую карту переходов (даже достаточно сложную, как на первом изображении) можно реализовать, используя четыре базовых перехода, комбинируя которые, мы получим необходимое поведение.
Forward
Forward (String screenKey, Object transitionData) – команда, которая осуществляет переход на новый экран, добавляя его в текущую цепочку экранов.
screenKey – уникальный ключ, для каждого экрана.
transitionData – данные, необходимые новому экрану.
Буквой R обозначен корневой экран, его особенность только в том, что при выходе с этого экрана, мы выйдем из приложения.
Back
Back() – команда, удаляющая последний активный экран из цепочки, и возвращающая на предыдущий. При вызове на корневом экране ожидается выход из приложения.
BackTo
BackTo(String screenKey) – команда, позволяющая вернуться на любой из экранов в цепочке, достаточно указать его ключ. Если в цепочке два экрана с одинаковым ключом, то выбран будет последний (самый «правый»).
Стоит отметить, что если указанный экран не найден, либо в параметр ключа передать null, то будет осуществлен переход на корневой экран.
На практике эта команда очень удобна. Например, для авторизации: два экрана. Телефон -> СМС, а потом выход на тот, с которого была запущена авторизация.
Replace
Replace (String screenKey, Object transitionData) – команда, заменяющая активный экран на новый.
Кто-то может возразить, что этого результата удастся достичь, вызвав подряд команды Back и Forward, но тогда на корневом экране мы выйдем из приложения!
Вот и всё! Этих четырёх команд на практике достаточно для построения любых переходов. Но есть ещё одна команда, которая не относится к навигации, однако очень полезна на практике.
SystemMessage
SystemMessage (String message) – команда, отображающая системное сообщение (Alert, Toast, Snack и т. д.).
Иногда необходимо выйти с экрана и показать сообщение пользователю. Например, что мы сохранили сделанные изменения. Но экран, на который мы возвращаемся, не должен знать о чужой логике, и поэтому мы вынесли показ таких сообщений в отдельную команду. Это очень удобно!
Все команды отмечены интерфейсом-маркером Command. Если вам по какой-то причине понадобилась новая команда, просто создайте её, никаких ограничений!
Navigator
Команды сами по себе не реализуют переключение экранов, а только описывают эти переходы. За их выполнение отвечает Navigator.
public interface Navigator {
void applyCommand(Command command);
}
В зависимости от задачи, Navigator будет реализован по-разному, но он всегда будет там, где находится контейнер для переключаемых экранов.
- В Activity для переключения Fragment’ов.
- Во Fragment’е для переключения вложенных (child) Fragment’ов.
- … ваш вариант.
Так как в подавляющем большинстве Андроид приложений навигация опирается на переключение Fragment’ов внутри Activity, чтобы не писать однотипный код, в библиотеке уже есть готовый FragmentNavigator (и SupportFragmentNavigator для SupportFragment’ов), реализующий представленные команды.
Достаточно:
1) передать в конструктор ID контейнера и FragmentManager;
2) реализовать методы выхода из приложения и отображения системного сообщения;
3) реализовать создание Fragment’ов по screenKey.
За более подробным примером советую заглянуть в Sample-приложение.
В приложении необязательно должен быть один Navigator. Пример (тоже реальный, кстати): в Activity есть BottomBar, который доступен для пользователя ВСЕГДА. Но в каждом табе есть собственная навигация, которая сохраняется при переключении табов в BottomBar’е.
Решается это одним навигатором внутри Activity, который переключает табы, и локальными навигаторами внутри каждого Fragment’а-таба.
Таким образом, каждый отдельный презентер не завязан на то, где он находится: внутри цепочки одного из табов или в отдельном Activity. Достаточно предоставить ему правильный Router. Один Router связан только с одним Navigator’ом в любой момент времени. Об этом чуть дальше.
Router
Как было сказано выше, комбинируя команды, можно реализовать любой переход. Именно этой задачей и занимается Router.
Например, если стоит задача по некоторому событию в презентере:
1) скинуть всю цепочку до корневого экрана;
2) заменить корневой экран на новый;
3) и еще показать системное сообщение;
то в Router добавляется метод, который передает последовательность из трёх команд на выполнение в CommandBuffer:
public void navigateToNewRootWithMessage(String screenKey,
Object data,
String message) {
executeCommand(new BackTo(null));
executeCommand(new Replace(screenKey, data));
executeCommand(new SystemMessage(screenKey, data));
}
Если бы презентер сам вызывал эти методы, то после первой команды BackTo(), он был бы уничтожен (не совсем так, но суть передаёт) и не завершил работу корректно.
В библиотеке есть готовый Router, используемый по-умолчанию, с самыми необходимыми переходами, но как и с навигатором, никто не запрещает создать свою реализацию.
navigateTo() – переход на новый экран.
newScreenChain() – сброс цепочки до корневого экрана и открытие одного нового.
newRootScreen() – сброс цепочки и замена корневого экрана.
replaceScreen() – замена текущего экрана.
backTo() – возврат на любой экран в цепочке.
exit() – выход с экрана.
exitWithMessage() – выход с экрана + отображение сообщения.
showSystemMessage() – отображение системного сообщения.
CommandBuffer
CommandBuffer – класс, который отвечает за доставку команд навигации Navigator’у. Логично, что ссылка на экземпляр навигатора хранится в CommandBuffer’е. Она попадает туда через интерфейс NavigatorHolder:
public interface NavigatorHolder {
void setNavigator(Navigator navigator);
void removeNavigator();
}
Кроме того, если в CommandBuffer поступят команды, а в данный момент он не содержит Navigator’а, то они сохранятся в очереди, и будут выполнены сразу при установке нового Navigator’а. Именно благодаря CommandBuffer’у удалось решить все проблемы жизненного цикла.
Конкретный пример для Activity:
@Override
protected void onResume() {
super.onResume();
SampleApplication.INSTANCE.getNavigatorHolder().setNavigator(navigator);
}
@Override
protected void onPause() {
SampleApplication.INSTANCE.getNavigatorHolder().removeNavigator();
super.onPause();
}
Почему именно onResume и onPause? Для безопасной транзакции Fragment’ов и отображения системного сообщения в виде алерта.
От теории к практике. Как использовать Cicerone?
Предположим, мы хотим реализовать навигацию на Fragment’ах в MainActivity:
Добавляем зависимость в build.gradle
repositories {
maven {
url 'https://dl.bintray.com/terrakok/terramaven/'
}
}
dependencies {
//Cicerone
compile 'ru.terrakok.cicerone:cicerone:1.0'
}
В классе SampleApplication инициализируем готовый роутер
public class SampleApplication extends Application {
public static SampleApplication INSTANCE;
private Cicerone<Router> cicerone;
@Override
public void onCreate() {
super.onCreate();
INSTANCE = this;
cicerone = Cicerone.create();
}
public NavigatorHolder getNavigatorHolder() {
return cicerone.getNavigatorHolder();
}
public Router getRouter() {
return cicerone.getRouter();
}
}
В MainActivity создаем навигатор:
private Navigator navigator = new SupportFragmentNavigator(getSupportFragmentManager(),
R.id.main_container) {
@Override
protected Fragment createFragment(String screenKey, Object data) {
switch(screenKey) {
case LIST_SCREEN:
return ListFragment.getNewInstance(data);
case DETAILS_SCREEN:
return DetailsFragment.getNewInstance(data);
default:
throw new RuntimeException(“Unknown screen key!”);
}
}
@Override
protected void showSystemMessage(String message) {
Toast.makeText(MainActivity.this, message, Toast.LENGTH_SHORT).show();
}
@Override
protected void exit() {
finish();
}
};
@Override
protected void onResume() {
super.onResume();
SampleApplication.INSTANCE.getNavigatorHolder().setNavigator(navigator);
}
@Override
protected void onPause() {
super.onPause();
SampleApplication.INSTANCE.getNavigatorHolder().removeNavigator();
}
Теперь из любого места приложения (в идеале из презентера) можно вызывать методы роутера:
SampleApplication.INSTANCE.getRouter().backTo(...);
Частные случаи и их решение
Single Activity?
Нет! Но Activity я не рассматриваю как экраны, только как контейнеры. Смотрите: Router создан в классе Application, поэтому при переходе с одного Activity на другое, просто будет меняться активный навигатор, поэтому вполне можно делить приложение на независимые Activity, внутри которых будут уже переключения экранов. Конечно, стоит понимать, что цепочки экранов в таком случае будут привязаны к отдельным Activity, и команда BackTo() сработает только в контексте одного Activity.
Вложенная навигация
Я выше приводил пример, но повторюсь снова:
Есть Activity с табами. Стоит задача, чтобы внутри каждого таба была независимая цепочка экранов, сохраняющаяся при смене таба.
Решается это двумя типами навигации: глобальной и локальной.
GlobalRouter – роутер приложения, связанный с навигатором Activity.
Презентер, обрабатывающий клики по табам, вызывает команды у GlobalRouter.
LocalRouter – роутеры внутри каждого Fragment’а-контейнера. Навигатор для LocalRouter'а реализует сам Fragment-контейнер.
Презентеры, относящиеся к локальным цепочкам внутри табов, получают для навигации LocalRouter.
Где связь? Во Fragment’ах-контейнерах есть доступ и к глобальному навигатору! В момент, когда локальная цепочка внутри таба закончилась и вызвана команда Back(), то Fragment передает её в глобальный навигатор.
Совет: для настройки зависимостей между компонентами, используйте Dagger 2, а для управления их жизненным циклом – его CustomScopes.
А что с системной кнопкой Back?
Этот вопрос специально не решается в библиотеке. Нажатие на кнопку Back надо воспринимать как взаимодействие пользователя и передавать просто как событие в презентер.
Но есть ведь Flow или Conductor?
Мы смотрели другие решения, но отказались от них, так как одной из главных задач было использовать максимально стандартный подход и не создавать очередной фреймворк со своим FragmentManager’ом и BackStack’ом.
Во-первых, это позволит новым разработчикам быстро подключаться к проекту без необходимости изучения сторонних фреймворков.
Во-вторых, не придется всецело полагаться на сложные сторонние решения, что чревато затрудненной поддержкой.
Итог
Библиотека Cicerone:
- не завязана на Fragment’ы;
- не фреймворк;
- предоставляет короткие вызовы;
- легка в расширении;
- приспособлена для тестов;
- не зависит от жизненного цикла!
GitHub
Cicerone is a lightweight library that makes the navigation in an Android app easy.