Cicerone — проста навігація в android-додатку


На цій схемі не скелет стародавнього мешканця водних глибин і не схема метро якогось мегаполісу, це карта переходів по екранах цілком реального Андроїд програми! Але, незважаючи на складність, нам вдалося її вдало реалізувати, а рішення оформити у вигляді невеликої бібліотеки, про яку і піде мова в статті.

Щоб заздалегідь уникнути питань про назву, уточню: Cicerone ("чі-че-ро-не") – застаріле слово з італійськими коренями, зі значенням «гід для іноземців».
В наших проектах ми намагаємося дотримуватися архітектурних підходів, які дозволяють відокремити логіку від відображення.
Так як я в цьому плані волію MVP, то далі по тексту буде часто зустрічатися слово «презентер», але хочу зазначити, що представлене рішення ніяк не обмежує вас у виборі архітектури (можна навіть використовувати в класичному підході «все під Fragment'ах», і навіть у цьому випадку Cicerone дасть свій профіт!).
Навігація – це скоріше бізнес-логіка, тому відповідальність за переходи я віддаю перевагу покладати на презентер. Але в Андроїді не все так гладко: для здійснення переходів між Activity, перемикання Fragment'ів або зміни View всередині контейнера
  1. не обійтися без залежності від Context'a, який не хочеться передавати в шар логіки, пов'язуючи тим самим його з платформою, ускладнюючи тестування і ризикуючи отримати витоку пам'яті (якщо забути очистити посилання);
  2. треба враховувати життєвий цикл контейнера (наприклад, 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.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.