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();
Почему этот подход является не самым удачным? По нескольким причинам:
- Этот подход создает много boilerplate кода, который вам приходится повторять раз за разом для каждого перехода от одного экрана к другому. Сюда входит написание FragmentTransaction минимум в 4 строки, а также создание новых Fragment или Intent с аргументами, которое требует создания Bundle-ов.
- Оба метода привязаны к тому, что в Android традиционно считается View. Как по мне, навигация должна быть отделена от представления и вынесена в какие-то отдельные классы. Даже если оставить ее во View, вызовы навигации должны быть упрощены до максимума.
Возможно, вы могли бы назвать еще какие-нибудь причины, но, мне кажется, этого достаточно для того, чтобы попытаться как-то упростить этот подход.
В статье мы рассмотрим способы, которые позволяют решать задачу навигации намного проще и должны позволить вам в будущем тратить время на какие-то более сложные задачи. Мы рассмотрим:
- Cicerone — самое популярное на данный момент open-source решение.
- Navigation Architecture Component — решение от Google из Android Jetpack.
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 разбиваются на набор базовых команд:
- Forward — добавление экрана в конец цепи.
- Back — переход на предыдущий экран в цепи с удалением текущего (по аналогии с popBackStack() во FragmentManager-е).
- BackTo — переход на некоторый заданный экран из цепи с удалением всех экранов, которые стояли перед ним.
- Replace — замена текущего экрана другим с сохранением состояния цепи (по аналогии с replace() во FragmentManager-е).
Комбинируя эти команды, вы можете представить любой переход в навигации, какой только может прийти вам в голову. Но если вы все-таки придумаете что-то настолько нестандартное, что этих команд вам окажется мало, то вы всегда сможете добавить еще пару команд для себя и научить 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:
- Легко встраивается в проект. Если у вас уже есть готовый проект, в котором вам хотелось бы отрефакторить навигацию, то вы можете установить Cicerone и переходить на нее постепенно по мере необходимости.
- Lifecycle-safe. Возможно, библиотека для навигации не должна заниматься подобными вопросами, но в любом случае для Cicerone это просто приятный бонус.
- Легко переопределяется. Вся Cicerone построена на интерфейсах, все стандартные классы имеют protected методы, которые позволят вам добиться любого поведения, которые выходит за рамки стандартного подхода.
Минусы Cicerone:
- Cicerone позволяет выносить информацию о создании экранов в отдельные классы, но перемещения между экранами все еще остаются раскиданными по коду.
- Это open-source библиотека, и она в любой момент может перестать поддерживаться.
Navigation Architecture Component
Navigation Architecture Component — решение, которое Google представил на Google I/O 2018. Пока что библиотека находится в альфе, но, по моему мнению, уже пригодна для использования. Сейчас я лично использую эту библиотеку в своем проекте и могу сказать, что ее плюсы однозначно перевешивают все возможные минусы.
Как все устроено внутри
Первое и самое важное, что нужно знать о Navigation Architecture Component, — это то, каким образом в нем представлен ваш набор экранов. А представлен он в виде вот такого симпатичного графа:
Когда кто-нибудь знакомится с вашим проектом и в нем будет использован Navigation Architecture Component, такой граф поможет ускорить процесс ознакомления, так как изначально понятно, как связаны между собой экраны приложения.
Весь граф состоит из destination (экранов) и action (то, что соединяет экраны):
Destination может быть:
- Fragment;
- Activity;
- Custom-ный тип;
- навигационный граф.
Action хранит в себе информацию о перемещении между destination-ами. Action может включать в себя:
- Destination, в который он ведет;
- опция PopUpTo, то есть до какого destination нужно откатиться и добавить нужный;
- анимации для FragmentTransaction;
- флаги Activity.
Все графы хранятся в 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 содержит:
- id;
- name — класс, соответствующий данному destination;
- label — используется в editor-е, а также в навигационных UI компонентах;
- layout — layout, который нужно отобразить в editor-е.
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. В представленном
Библиотека рассчитана на то, что все фрагменты вашего графа будут помещаться в 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:
- Все метаданные собраны в одном месте, а именно в xml графах, что позволяет легко получить информацию о том, как связаны между собой экраны в приложении.
- Safe args plugin решает проблему создания Bundle-ов и этим убирает много boilerplate кода.
- Поддержка Google дает библиотеке большое пространство для развития, например, в виде специального Editor-а для графов.
Минусы Android Navigation Component:
- Единственным минусом является только то, что библиотека все еще находится в альфе, хотя уже пригодна для использования.
Когда какое решение использовать
Если перед вами стоит задача рефакторинга навигации в уже существующем приложении, то я бы рекомендовал использовать Cicerone, так как ее API позволяет сделать это постепенно и безопасно. Android Navigation Component здесь может не подойти, так как за счет привязки к NavHost придется переписывать полностью всю навигацию вместо того, чтобы сделать этот переход постепенным.
Если же вы стартуете новый проект, то вам однозначно следует выбрать Android Navigation Component, так как эта библиотека еще полностью не раскрыла свой потенциал и будет становиться только лучше по мере того, как вы разрабатываете новое приложение.
P. S. Буду рад ответить на ваши вопросы в комментариях.