×Закрыть

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 — самое популярное на данный момент 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:

  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 может быть:

  • 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. В представленном 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. Буду рад ответить на ваши вопросы в комментариях.

LinkedIn

12 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.

«Пластмассовый мир победил....»
Андроїд котиться в сторону Ангулярів, Реактів та подібних Джаваскрипт фреймворків.

От не розумію я такого підходу до розробки «створити собі складнощі і успішно їх подолати»
В чому профіт?

По поводу less pain с AAC навигацией — я не согласен.
Инструмент очень интересный, но слишком сырой.
Начал имплементить навигацию 2 месяца назад.

1. Если у вас есть рекурсионный destination, который при этом является стартовым (категория->подкатегория), то будет проблема со стрелкой назад в экшенбаре. Чтобы её подлечить — нужно в графе подмазать говнеца.

2. Если нажать на две разные кнопки одновременно, которые куда-то переходят, то приложение падает. К пример, у нас список User, по клику переходящих в UserDetail, то кликнувши оба вылетет эксепшен, говорящий, что UserDetail не имеет экшена перехода на UserDetail.
По этому поводу я создавал ишью на гуглтрекере, её закрыли с Expected behavior и сказали, что это проблема во фрагментах, которую починили в androidx.fragment 1.1.0-alpha01

Pending input events (such as clicks) are now canceled in a Fragment’s onStop()

Т.е., грубо говоря, этот баг был всегда, просто без AAC у тебя бы просто открылись 2 UserDetail экрана.
Но нифига они не починили и оно всё-равно падает и ишью закрыта.
Это лечится, конечно, но это + немножно pain.

3. Сегодня очень популярен Bottom navigation. Навигация очень слабо работает с ним. Она не поднимает фрагменты, когда ты кликаешь по табам, а каждый раз создаёт новый. Бекстак работает ещё хуже, даже не по материалу. Уже не говоря о том, что материал бэкстак на деле хреновый, и все хотят делать такой, как на ютубе. Но инструмента пока нет. Даже расширить Навигатор пока не вариант, там в исходниках гуано написано и оно пока не готово к расширению.
Ишью по нижней навигации висит с конца весны/начала лета, не помню.
Lil пишеть, мол контрибьютье, лентяии сами, вот ссылка.

4. Вложенные навигации я пока глубоко не лез, но поверхностно, там тоже будет боль, когда я несколько графов разнесу по разным файлам и буду импортить их в один, чтобы не было xml-я на 800 строк.

Моё мнение:
Если хотите бежать за гуглом и готовы потерпеть немножко боли или
если приложение маленькое, с весьма прозрачной схемой навигации и хочется посмотреть новый инструмент, то пишите на AAC.

Если же нет, то приходите к осени, когда этот инструмент немного допилят.
А пока постартуйте активности и покоммитьте транзакции.
Штука интересная.

В моему случае, проблемы со стрелкой и гамбургером решились с помощью AppBarConfiguration.
Боюсь того момента когда ещё добавят нижнюю навигацию — уже есть связка — drawer + табы.

По поводу пункта 3. Вчера на гуглтрекере наконец-то появилось движение. Гугл пока не имеет возможности сделать нормальную работу нижней навигации из-за лимитов АПИ самого андроида.
Они вылили код, как «workaround» пока, не решат проблему по-человечески.
github.com/...​/NavigationAdvancedSample

Сам пока не смотрел, работает ли оно.

Минусы Cicerone:
...
Это open-source библиотека, и она в любой момент может перестать поддерживаться.

Как раз в ОС нет беды никакой — правь себе все что надо))

Мне кажется что navigation components — не лучшее решение, конечно, он добавляется очень быстро и приятно, но вносит очень много скрытой магии под капот приложения, которую потом будет крайне сложно кастомизировать, в случае потребности (пробовали дебажить фрагмент менеджер? :D)
С чичероне, ситуация немного другая, нужно реально потрудится чтобы правильно встроить его в архитектуру приложения (и это может быть не так легко как кажется), но на выхлопе мы получаем явно описаную навигацию, которую можно вдруг чего задебажить или кастомизировать

В чичероне версии 4 скрины это строго типизированные классы, что очень удобно кстати (не надо мапить key -> fragment)

Влад, спасибо за отличную статью! Кстати, хочу добавить, что ты хорошо пишешь, у тебя красивый слог. Как говорится, талантливый человек — талантлив во всем. Думаю, ты и в PR отделе смог бы работать, если бы захотел.

«Похвали мене моя губонька» ;)
Лайк від сусіднього відділу зараховано. !

Кстати, Влад, хочу еще добавить: сегодня ребята из соседнего отдела с проекта «MyLips» на кухне обсуждали твою статью, очень положительно отзывались. Оно и понятно, статья очень качественная!

Большое спасибо, Саша! Очень приятно получать такое количество поддержки, особенно из соседних отделов!

Подписаться на комментарии