Navigaton with less pain. Рішення для Android

Всім привіт! Мене звуть Недомовный Влад, я Android Engineer в мобільної студії компанії Provectus. Під час роботи над проектами я постійно стикався з проблемою реалізації навігації в Android. Я провів аналіз існуючих рішень, структурував їх і вирішив поділитися своїми новими знаннями, які успішно застосовую на практиці.

Як виглядає рішення цієї задачі засобами Android Framework?

Для Activity:

Intent intent = new Intent(context, MainActivity.class);
startActivity(intent);

Для Fragment:

getSupportFragmentManager()
.beginTransaction()
 .replace(R. id.content_frame, CommonFragment.newIntance())
.addToBackStack(null)
 .commit();

Чому цей підхід є не самим вдалим? З кількох причин:

  1. Цей підхід створює багато boilerplate коду, який вам доводиться повторювати раз за разом для кожного переходу від одного екрана до іншого. Сюди входить написання FragmentTransaction мінімум в 4 рядки, а також створення нових Fragment або Intent з аргументами, яке вимагає створення Bundle-ів.
  2. Обидва методу прив'язані до того, що в Android традиційно вважається View. Як на мене, навігація повинна бути відокремлена від подання та винесена в якісь окремі класи. Навіть якщо залишити її під View, виклики навігації повинні бути спрощено до максимуму.

Можливо, ви могли б назвати ще якісь причини, але, мені здається, цього достатньо для того, щоб спробувати якось спростити цей підхід.

У статті ми розглянемо способи, які дозволяють вирішувати завдання навігації набагато простіше і повинні дозволити вам в майбутньому витрачати час на якісь більш складні завдання. Ми розглянемо:

Cicerone

Cicerone — open-source бібліотека, яка існує вже більше 2 років, але все одно продовжує активно розвиватися. Вона розроблялася як бібліотека для застосунків з MVP архітектурою, але виявилася настільки вдалою, що її можна застосовувати, навіть якщо у вас використовується якийсь інший архітектурний підхід або, може, не використовується зовсім.

Як все влаштовано всередині

Всі екрани, між якими відбувається навігація, представляються у виді об'єктів класу Screen:

public abstract class Screen {

 protected String screenKey = getClass().getCanonicalName();

 public String getScreenKey() {
 return screenKey;
}

}

Все, що він в собі зберігає, — це ключ screenKey, який дозволяє відрізняти його від інших об'єктів. За замовчуванням це canonical name класу, але ви можете це змінити. Якщо ваша навігація складається з Activity та Fragment-ів, то для їх подання є клас SupportAppScreen:

public abstract class SupportAppScreen extends Screen {

 public Fragment getFragment() {
 return null;
}

 public Intent getActivityIntent(Context context) {
 return null;
}

}

Він має префікс «Support», так як працює з елементами support бібліотеки. Якщо у вас в додатку використовуються non-support елементи, то існує аналогічний клас без префікса.

Відповідно, якщо ви хочете представити Activity, то слід перевизначити метод getActivityIntent():

public static final class MainScreen extends SupportAppScreen {

@Override
 public Intent getActivityIntent(Context context) {
 return new Intent(context, MainActivity.class);
}

}

А в разі Fragment-а змінюється getFragment():

public static final class NumberScreen extends SupportAppScreen {

 private final int number;

 public NumberScreen(int number) {
 this.number = number;
}

@Override
 public Fragment getFragment() {
 return NumberFragment.newInstance(number);
}

}

Будь-які переходи між екранами в Cicerone розбиваються на набір базових команд:

Комбінуючи ці команди, ви можете представити будь-який перехід в навігації, який тільки може прийти вам в голову. Але якщо ви все-таки придумаєте щось настільки нестандартне, що цих команд вам виявиться мало, то ви завжди зможете додати ще пару команд для себе і навчити Cicerone з ними працювати.

Загальна схема роботи з Cicerone виглядає так:

Все починається з Router. Router — високорівневий об'єкт, з яким ви безпосередньо спілкуєтеся в коді, викликаючи його методи.

Будь-який з методів Router перетворюється в деякий набір базових команд, про які написано вище, і передається в CommandBuffer. CommandBuffer — це сутність, яка робить Cicerone lifecycle-safe. Якщо ваш додаток не готовий в даний момент зробити перехід між екранами, то CommandBuffer буде накопичувати в собі всі команди, які до нього надійшли. В момент, коли програма знову увійде в активний стан, він миттєво застосує їх, і для користувача все буде виглядати максимально природно.

Всі команди з CommandBuffer передаються в Navigator. Navigator — це ніщо інше, як обгортка над усім знайомими Context і FragmentManager, але на відміну від стандартного підходу робота з ними відбувається десь в окремому від View місці.

Таким чином, все, що від вас вимагається, — це отримати об'єкт Router де-небудь, де вам було б зручно з ним працювати, і викликати його метод з потрібними аргументами. Всю іншу роботу Cicerone робить за вас.

Як це виглядає на практиці

Все починається зі створення об'єкта класу Cicerone, типізованого під Router, який ви збираєтеся використовувати (в коді представлена стандартна реалізація). Неважливо, де ви його створюєте. Що нас дійсно цікавить, так це його getter-и. Саме з нього ми отримуємо Router, а також NavigationHolder.

public class SampleApplication extends Application {

 private Cicerone<Router> cicerone;

@Override
 public void onCreate() {
super.onCreate();
 cicerone = Cicerone.create();
}

 public NavigatorHolder getNavigatorHolder() {
 return cicerone.getNavigatorHolder();
}

 public Router getRouter() {
 return cicerone.getRouter();
}

}

NavigationHolder нам потрібен для того, щоб передавати і видаляти з Cicerone наш поточний навігатор, який описується інтерфейсом Navigator. У стандартному підході це буде відбуватися в callback-ах onPause() і onResume() вашої Activity або Fragment-а — все залежить від того, що ви вибираєте як контейнер. Саме видалення Navigator-a дозволяє CommandBuffer зрозуміти, коли додаток знаходиться в background-е.

@Override
protected void onResume() {
super.onResume();
getNavigatorHolder().setNavigator(navigator);
}

@Override
protected void onPause() {
super.onPause();
getNavigatorHolder().removeNavigator();
}

private Navigator navigator = new Navigator() {

@Override
 public void applyCommands(Command[] commands) {
 //implement commands logic
}

};

Navigator — це інтерфейс з єдиним методом applyCommands, що приймає в якості аргументу масив об'єктів Command (наші базові команди). Для навігації між Activity та Fragment-ами існує готовий клас SupportAppNavigator. За аналогією зі Screen існує такий же клас для non-support елементів.

public class SupportAppNavigator implements Navigator {

 public SupportAppNavigator(FragmentActivity activity, int containerId) {
 // initializing
}

 public SupportAppNavigator(FragmentActivity activity,
 FragmentManager fragmentManager,
 int containerId) {
 // initializing
}

}

Потрібно передати йому ваш контейнер для навігації, а він перетворює об'єкти Command до викликів методів Activity та FragmentManager-а.

Далі вам потрібно де-небудь в коді викликати один з методів стандартного Router.

public class Router {

 void navigateTo(Screen screen) {}

 void newRootScreen(Screen screen) {}

 void replaceScreen(Screen screen) {}

 void backTo(Screen screen) {}

 void newChain(Screen... screens) {}

 void newRootChain(Screen... screens) {}

 void finishChain() {}

 void exit() {}

}

Це буде виглядати приблизно так:

public void navigateToNumberScreen(int number) {
 router.navigateTo(new Screens.NumberScreen(number));
}

Проробивши всі ці кроки, ви отримуєте простий і зручний для роботи інструмент.

Висновки

Плюси Cicerone:

  1. Легко вбудовується в проект. Якщо у вас вже є готовий проект, в якому вам хотілося б отрефакторить навігацію, то ви можете встановити Cicerone і переходити на неї поступово по мірі необхідності.
  2. Lifecycle-safe. Можливо, бібліотека для навігації не повинна займатися подібними питаннями, але в будь-якому випадку для Cicerone це просто приємний бонус.
  3. Легко змінюється. Вся Cicerone побудована на інтерфейсах, всі стандартні класи мають protected методи, які дозволять вам добитися будь-якого поведінки, що виходить за рамки стандартного підходу.

Мінуси Cicerone:

  1. Cicerone дозволяє виносити інформацію про створення екранів в окремі класи, але переміщення між екранами все ще залишаються розкиданими по коду.
  2. Це open-source бібліотека, і вона в будь-який момент може перестати підтримуватися.

Navigation Architecture Component

Navigation Architecture Component — рішення, що Google представив на Google I/O 2018. Поки що бібліотека знаходиться в альфі, але, на мою думку, вже придатна для використання. Зараз я особисто використовую цю бібліотеку в своєму проекті і можу сказати, що її плюси однозначно переважують всі можливі мінуси.

Як все влаштовано всередині

Перше і найважливіше, що потрібно знати про Navigation Architecture Component, — це те, яким чином у ньому представлений ваш набір екранів. А він представлений у вигляді ось такого симпатичного графа:

Коли хто-небудь знайомиться з вашим проектом і в ньому буде використаний Navigation Architecture Component, такий граф допоможе прискорити процес ознайомлення, так як спочатку зрозуміло, як пов'язані між собою екрани програми.

Весь граф складається з destination (екранів) і action (те, що з'єднує екрани):

Destination може бути:

Action зберігає в собі інформацію про переміщення між destination-ами. Action може включати в себе:

Всі графи зберігаються в xml файли в папці res/navigation. Кожен граф містить у собі тег navigation.

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_example"
app:startDestination="@id/firstFragment">

...

</navigation>

В теге navigation обов'язково потрібно визначити id графа, а також startDestination — вхідні крапку в даний граф. При цьому сам граф повинен містити усередині себе destination (в даному випадку fragment), id якого буде відповідати тому, що вказаний в startDestination:

<?xml version="1.0" encoding="utf-8"?>
<navigation 
...
app:startDestination="@id/firstFragment">

<fragment
android:id="@+id/firstFragment"
android:name="com.example.navigation.FirstFragment"
android:label="FirstFragment"
tools:layout="@layout/fragment_first">

...

</fragment>

</navigation>

Destination містить:

Action-и додаються всередину тега для destination (в нашому випадку fragment):

<?xml version="1.0" encoding="utf-8"?>
<navigation ... >

<fragment
android:id="@+id/firstFragment"
 ... >

<action
android:id="@+id/action_firstFragment_to_secondFragment"
 app:destination="@id/secondFragment" />

</fragment>

<fragment
android:id="@+id/secondFragment"
 ... />

</navigation>

Загальна схема роботи виглядає так:

Для роботи з бібліотекою необхідний об'єкт NavController, який прив'язаний до певного NavHost-у. Після того, як ви викликаєте якийсь із методів NavController-а, він знаходить у графі потрібну інформацію і передає її в Navigator, який так само, як в Cicerone, служить обгорткою для роботи з FragmentManager і Context.

Як це виглядає на практиці

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout ...
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">

<fragment
android:id="@+id/my_nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true"
 app:navGraph="@navigation/nav_example" />

</FrameLayout>

Для початку, якщо ви реалізуєте навігацію в традиційному підході, а саме з допомогою Activity та Fragment, то Android Navigation Component надає спеціальний клас — NavHostFragment. У представленому xml-е у нього є 2 додаткові опції: defaultNavHost — прив'язує до нього кнопку Back і navGraph — присвоює йому деякий навігаційний граф.

Бібліотека розрахована на те, що всі фрагменти вашого графа будуть поміщатися в child fragment manager NavHostFragment-а, а якщо ви переходите на іншу Activity, то у неї повинен бути свій NavHostFragment з іншим графом.

Потім необхідно отримати об'єкт NavController з допомогою одного з запропонованих методів:

NavHostFragment.findNavController(currentFragment);

Navigation.findNavController(activity, R. id.view);

Navigation.findNavController(view);

Після цього вся навігація зводиться до таких викликів:

getNavController().navigate(R. id.secondFragment, argsBundle);

getNavController()
.navigate(
R. id.action_firstFragment_to_secondFragment,
argsBundle
 );

Також бібліотека містить в собі класи для прив'язки до компонентів UI, роботи з deeplinking-му і shared element transitions.

SafeArgs plugin

Плюс до всього, Navigation Architecture Component вирішує проблему зі створенням Bundle-ів для Fragment і Intent. Для цього використовується спеціальний gradle плагін. Цей плагін, орієнтуючись на xml графів у вашому проекті, генерує набір класів, які створюють Bundle-и за вас.

Для того, щоб змусити плагін працювати, вам необхідно додати теги argument до destination-ам у вашому графі.

<?xml version="1.0" encoding="utf-8"?>
<navigation ... >
...
<fragment
android:id="@+id/secondFragment"
 ... >

<argument
android:name="number"
android:defaultValue="0"
app:nullable="false"
 app:argType="integer" />

</fragment>

</navigation>

Після запуску плагіна для вас будуть згенеровані спеціальні класи Args:

public void navigateToSecondFragment() {
 Bundle args = new SecondFragmentArgs.Builder()
.setNumber(1)
.build()
.toBundle();

 getNavController().navigate(R. id.secondFragment, args);
}

Або ж, якщо ви використовуєте action-и, то плагін згенерує клас Directions з усіма доступними action-ами для конкретного destination:

public void navigateToSecondFragmentWithAction() {
 NavDirections navDirections = new FirstFragmentDirections
.ActionFirstFragmentToSecondFragment()
.setNumber(1);

getNavController().navigate(
navDirections.getActionId(),
navDirections.getArguments()
);
}

У самому ж фрагменті ви можете з getArguments() назад отримати клас Args і використовувати його getter-и:

public int getNumber() {
 return SecondFragmentArgs.fromBundle(getArguments()).getNumber();
}

Висновки

Плюси Android Navigation Component:

  1. Всі метадані зібрані в одному місці, а саме в xml графах, що дозволяє легко отримати інформацію про те, як пов'язані між собою екрани в додатку.
  2. Safe args plugin вирішує проблему створення Bundle-ів і цим прибирає багато boilerplate коду.
  3. Підтримка Google дає бібліотеці великий простір для розвитку, наприклад, у вигляді спеціального Editor-а для графів.

Мінуси Android Navigation Component:

  1. Єдиним мінусом є тільки те, що бібліотека все ще знаходиться в альфі, хоча вже придатна для використання.

Коли яке рішення використовувати

Якщо перед вами стоїть завдання рефакторінгу навігації у вже існуючому додатку, то я б рекомендував використовувати Cicerone, так як її API дозволяє зробити це поступово і безпечно. Android Navigation Component тут може не підійти, так як за рахунок прив'язки до NavHost доведеться переписувати повністю всю навігацію замість того, щоб зробити цей перехід поступовим.

Якщо ж ви стартуєте новий проект, то вам однозначно варто вибрати Android Navigation Component, так як ця бібліотека ще повністю не розкрила свій потенціал і буде ставати тільки краще по мірі того, як ви розробляєте новий додаток.

P.s. Буду радий відповісти на ваші питання в коментарях.

Опубліковано: 11/01/19 @ 11:15
Розділ Різне

Рекомендуємо:

Як налаштувати Jira для управління бэклогом: покрокова інструкція
Рейтинг роботодавців 2018: аналізуємо оцінки
C++ дайджест #11: підсумки року, реліз Visual studio 2019
Як українські IT-компанії святкували Новий рік 2019
MHT vs MAFF