Как мы внедряли навигацию из Jetpack в боевое приложение. Доклад Яндекс.Еды
В мобильных приложениях всё чаще используются deep links. Это ссылки, которые позволяют не просто перейти в приложение извне, а попасть на конкретный экран. Android-разработчик из Яндекс.Еды Владислав Кожушко объяснил, почему мы для реализации deep links внедрили навигацию из Jetpack, с какими проблемами столкнулись, как их решили и что получилось в итоге.
— Всем привет! Меня зовут Влад. Android-разработкой я интересуюсь с 2013 года, в Яндекс.Еде работаю с лета прошлого года. Я расскажу про наш путь внедрения библиотеки Navigation Components в боевое приложение.
Все началось с того, что у нас была техническая задача по рефакторингу навигации. Потом к нам пришли продакт-менеджеры и сказали, что будем делать диплинки, их будет много, они будут вести на разные экраны.
И тут мы подумали — навигация, представленная на Google I/O 2018 году, очень хорошо подходит для реализации задач по диплинкам. Мы решить посмотреть, что из этого получится. У наших коллег с iOS в Xcode есть удобный графический редактор, в котором они могут с помощью мышки натыкать всю верстку экранов, а также задать переходы между экранами. Теперь у нас тоже есть такая возможность, мы можем с помощью мышки задать переходы, диплинки на экраны и связать их с фрагментами. Помимо этого, мы можем задать аргументы на экран.
То есть аргументы необходимо либо натыкать в UI-редакторе, либо прописать в XML. Кроме перехода между экранами, появился тег action, в котором указываем его id и экран, на который нам нужно перейти.
Также мы указали диплинк, которым хотим открывать экран. В данном случае есть параметр itemId, если будем передавать параметр такого вида, и он будет вести на фрагмент, то значение этого параметра будет передаваться в аргументы фрагмента, и по ключу itemId мы сможем его достать и пользоваться. Еще библиотека поддерживает аплинки. Если мы задаем аплинк, например, navdemo.ru/start/{itemId}, то нам больше не нужно будет заботиться о том, чтобы прописать схемы http/https для открытия таких диплинков. Библиотека сделает все за нас.
Теперь поговорим про аргументы. Мы добавили два аргумента, integer и boolean, а также задали им дефолтные значения.
После этого при сборке фрагмента у нас появится класс NextFragmentArgs. В нем есть Builder, с помощью которого можно собрать и задать нужные нам аргументы. Также в самом классе NextFragmentArgs есть геттеры для получения наших аргументов. Можно собрать NextFragmentArgs из bundle и преобразовать bundle, что очень удобно.
Об основных фичах библиотеки. У нее удобный UI-редактор, в котором мы можем сделать всю навигацию. У нас получается читаемая XML-разметка, которая инфлейтится так же, как и Views. И у нас нет таких болей, как у iOS-разработчиков, когда они что-то поправили в графическом редакторе, и очень много всего поменялось.
Диплинки могут работать как с фрагментами, так и с Activity, и для этого не нужно прописывать большое количество IntentFilter в манифесте. Также у нас поддерживаются аплинки, и с Android 6 можно включить autoverify для проверки. Помимо этого, при сборке проектов происходит кодогенерация с аргументами на нужный экран. Навигация поддерживает вложенные графы и вложенную навигацию, что позволяет логически декомпозировать всю навигацию на отдельные сабкомпоненты.
Теперь мы поговорим про наш путь, который мы прошли, внедряя библиотеку. Все началось с альфа-версии 3. Мы все внедрили, мы заменили всю навигацию на Navigation components, все супер, все работает, диплинки открываются, но появились проблемы.
Первая проблема — IllegalArgumentException. Появлялось оно в двух случаях: неконсистентность графа, потому что происходила рассинхронизация представления графа и фрагментов в стеке, из-за этого возникало это исключение. Вторая проблема — двойные клики. Когда мы сделали первый клик, у нас происходит навигация. Происходит переход на следующий экран, и состояние графа меняется. Когда мы совершаем второй клик, граф уже находится в новом состоянии, и он пытается совершить старый переход, которого уже больше не существует, поэтому мы получаем такое исключение. В этой версии не открывались диплинки, схема которых содержит точку, например, project.company. Это было решено в следующих версиях библиотеки, и в стабильной версии все хорошо работает.
Также не поддерживаются shared elements. Вы наверное видели, как работает Google Play: есть список приложений, вы нажимаете на приложение, у вас открывается экран, и происходит красивая анимация перемещения иконки. У нас тоже такое в приложении есть на списке ресторанов, но нам нужна была поддержка shared elements. Также SafeArgs у нас не заработали, поэтому мы жили без них.
Поправить диплинк было просто. Требовалось заменить схему, которая была в библиотеке, на свою схему, которая поддерживает точку. С помощью рефлексии мы стучимся в класс, меняем значение regex, и все работает.
Чтобы поправить двойной клик, мы воспользовались следующим методом. У нас есть extension функции, чтобы задавать клики в навигацию. После нажатия на кнопку или другой элемент, мы обновляем ClickListener и делаем навигацию, чтобы избежать двойного перехода. Либо если у вас в проекте есть RxJava, я рекомендую воспользоваться библиотекой RxBindingsgs от Джейка Вортона, и с помощью нее можно обрабатывать события от View в реактивном стиле, использовать доступные нам операторы.
Поговорим про shared elements. Поскольку они появились чуть позже, мы решили допилить навигатор, добавить в него навигацию. Мы же программисты, почему бы нет?
Доработка заключалась в следующем: мы наследуем наш навигатор от навигатора, который есть в библиотеке. Здесь не весь код представлен, но это основная часть, которую мы доработали. Хочу отметить, прежде чем делать навигацию, проверяется состояние FragmentManager. Если оно было сохранено, то мы теряем наши команды. На мой взгляд, Это недостаток.
Также, Когда мы начинаем транзакцию фрагмента, мы создаем транзакцию и задаем все наши Views, которые необходимо пошарить. Но возникает вопрос, что это за класс такой TransitionDestination? Это наш кастомный класс, в котором есть возможность задать Views. Мы его наследуем от Destination и расширяем функциональность. Задаем Views и наш Destination готов, чтобы ими поделиться.
Следующая часть — нам необходимо сделать навигацию. Мы ищем при нажатии на кнопку по id destination, вытаскиваем вершину графа, на которую нужно перейти. После этого преобразуем ее к нашему TransitionDestination, в котором у нас есть Views. Дальше мы задаем все наши Views для анимации транзиций, и делаем навигацию. Все работает, все супер. Но потом появилась alpha06.
Это не значит, что мы делали прыжки между версиями. Мы пытались обновлять библиотеки по мере необходимости, но пожалуй, это самые основные изменения, с которыми мы столкнулись.
В alpha06 возникли проблемы. Поскольку это была альфа-версия библиотеки, происходили постоянные изменения, связанные с переименованием методов, колбэков, интерфейсов, не говоря уже о том, что добавлялись и убирались параметры в методах. Поскольку мы написали свой собственный навигатор, нам пришлось синхронизировать код библиотечного навигатора с нашим, чтобы также заливать багфиксы и новые возможности.
Также в самой библиотеке по мере перехода от ранних альфа-версий к стабильным версиям, менялось поведение, убирались какие-то возможности. Раньше был флажок launchDocument, но он так и не использовался, потом его убрали.
Например, было такое изменение, в котором разработчики сказали, что метод navigateUp (), который работает с DrawerLayout, устарел, используйте другой, в котором параметры просто поменяли местами.
Следующая наша большая миграция была на alpha11. Здесь исправили основные проблемы с навигацией при работе с графом. Мы наконец-то убрали наш допиленный контроллер и использовали все, что было из коробки. У нас по-прежнему не работали safe args, и мы расстроились.
Потом вышла версия beta01, и в этой версии фактически ничего не изменилось, при поведении навигации, но появилась следующая проблема: если в приложении открыто какое-то количество экранов, то у нас происходит очистка стека перед открытием нашего диплинка. Нас это поведение не устраивало. Safe args по-прежнему не работали.
Мы написали issue в Google, на что нам ответили, что все норм, так было изначально задумано, и в самом коде так происходило потому, что перед переходом на диплинк происходил возврат к корневому фрагменту с помощью id графа, который лежит в корне. А также в методе setPopUpTo () передается флаг true, говорящий, что вернувшись к этому экрану, нам также надо дропнуть его из стека.
Мы решили вернуть наш допиленный навигатор и поправить то, что считаем неправильным.
Вот она, исходная проблема, из-за которой происходила очистка стека. Мы решили ее следующим образом. Мы проверяем, если startDestination равен нулю, начальный экран, то мы будем использовать его, брать в качестве айдишника айдишник графа. Если у нас айдишник startDestination не равен нулю, то мы будем у графа брать этот айдишник, благодаря которому мы сможем не чистить стек и открыть диплинк поверх контента, который у нас есть. Либо, как вариант, можно просто убрать pop-up true в navigation options. По идее, тоже все должно заработать.
И вот наконец-то выходит стабильная версия. Мы обрадовались, думали, что все хорошо, но в стабильной версии поведение, по большому счету, не изменилось. Просто ее сделали финализированной. У нас наконец-то заработали safe args, поэтому мы активно начали добавлять аргументы на наших экранах и повсюду пользоваться ими в коде. Еще мы обнаружили, что навигация не работает с DialogFragments. Поскольку у нас были DialogFragments, мы хотели все их перенести в граф, в XML, и описать переходы между ними. Но у нас не вышло.
Двойное открытие. Также у нас была проблема, которая нас преследовала с самой первой версии, — двойное открытие Activity при холодном старте приложения.
Происходит это следующим образом. Есть прекрасный метод handle deeplink, который мы можем вызывать из нашего кода, например, когда в активити получаем onNewIntent () для того, чтобы перехватить диплинк. Здесь в Intent приходит флаг ACTIVITY_NEW_TASK при запуске приложения по диплинку, поэтому происходит следующее: стартуется новое Activity, и если есть текущее Activity, оно убивается. Поэтому если мы запускаем приложение, сначала запускается белый экран, потом он исчезает, появляется еще один экран, и выглядят они очень красиво.
В итоге, внедрив эту библиотеку, мы получили следующие преимущества.
У нас есть документация на нашу навигацию, а также графическое представление о переходах между экранами, и если к нам приходит человек в проект, он быстро в этом разбирается, посмотрев на граф, открыв в Студии его представление.
У нас SingleActivity. Все экраны сделаны на фрагментах, все диплинки ведут на фрагменты, и я считаю, это удобно.
Получилось простое связывание диплинка с фрагментами, просто добавляем тег deeplink к фрагменту, и библиотека все за нас делает. Также мы разбили нашу навигацию на вложенные подграфы, сделали вложенную навигацию. Это разные вещи. Просто вложенный граф, по сути, это include внутри графа, а вложенная навигация — это когда отдельный навигатор используется, чтобы ходить по экранам.
Также мы меняем граф динамически в коде, мы можем добавлять вершины, можем удалять вершины, можем менять стартовый экран — все это работает.
Мы практически забыли, как работать с FragmentManager, поскольку вся логика работы с ним инкапсулирована внутри библиотеки, и библиотека делает всю магию за нас. Также библиотека работает с DrawerLayout, если у вас задан корневой фрагмент, библиотека сама будет рисовать гамбургер на нем, и при переходе к следующим экранам, будет рисовать стрелочку и делать это анимированно при возврате с предпоследнего фрагмента.
Также мы перенесли все аргументы, их большую часть, в SafeArgs, и у нас все создается при сборке проекта. Помимо этого мы справились со всеми проблемами, которые нас преследовали, и доработали библиотеку под свои нужды.
SafeArgs может генерировать код на Kotlin, для этого используется отдельный плагин.
Помимо этого у библиотеки есть недостатки. Первое — в стабильную версию завезли очистку стека. Не знаю, зачем это было сделано, может, кому-то так удобно, но в случае нашего приложения мы бы хотели открывать диплинки поверх того контента, который у нас есть.
Сами фрагменты создаются фрагмент навигатором, и создаются через рефлексию. Я не считаю, что это плюс в реализации. Условие для диплинков не поддерживается. У вас в приложении могут быть секретные экраны, которые доступны только авторизованным пользователям. И чтобы открывать диплинки по условию, нужно писать для этого костыли, поскольку нельзя задать обработчик диплинков, в котором бы мы получили управление и сказали, что делать, либо открывать экран по диплинку, либо открывать другой экран.
Также команды на переход теряются, все потому что у фрагмент навигатора проверяется состояние, сохранен он или нет, и если сохранен, то мы просто ничего не делаем.
Также нам библиотеку пришлось дорабатывать напильником, это не является плюсом. И еще один существенный недостаток — у нас нет возможности открыть цепочку экранов перед диплинком. В случае нашего приложения мы бы хотели сделать диплинк на корзину, перед которым открыть все предыдущие экраны: рестораны, конкретный ресторан, и только после этого корзину.
Также для работы с навигацией необходимо иметь экземпляр View. Чтобы получить навигатор, нужно обратиться ко View — сама библиотека навигации будет обращаться к родителям этой View — и попытаться найти в нем навигатор. Если она его находит, то происходит переход на нужный нам экран.
Но возникает вопрос: стоит ли использовать библиотеку в бою? Я скажу да, если:
Если нам необходимо получить быстрый результат. Библиотека внедряется очень быстро, ее можно вставить в проект минут за 20, все переходы тыкаются мышкой, все это удобно. Так же быстро это пишется в XML, быстро собирается, быстро работает. Все супер.
Если вам необходимо иметь в приложении много экранов, которые работают с диплинками, и нет условий, по которым они должны открываться.
No single Activity. Здесь я подразумеваю not only single Activity. Мы можем делать навигацию как на фрагментах с одной Activity, так и на смеси фрагментов и Activity, просто в графе также можно будет описывать Activity. Для этого используется провайдер внутри навигатора, в котором есть две реализации навигации. Одна на Activity, которая создает Intents для перехода в нужные Activities, вторая реализация — фрагмент-навигатор, который работает внутри себя с фрагмент-менеджером.
Если у вас нет сложной логики для перехода между экранами. Каждое приложение по-своему уникально, и если есть сложная логика по открытию экранов, эта библиотека не для вас.
Вы готовы лезть в исходники и ее дорабатывать, как мы. На самом деле это очень интересно.
Я скажу нет, если в приложении сложная логика навигации, если вам необходимо открывать перед диплинком цепочку экранов, если вам нужны сложные условия для открытия экранов через диплинк. В принципе, на примере авторизации это можно сделать, просто открывается нужный вам экран, а поверх него — экран авторизации. Если пользователь у вас не авторизовывается, то вы сбрасываете в стек до предыдущего экрана, перед которым был открыт секретный экран. Такое вот костыльное решение.
Потеря команд для вас критична. Тот же самый Cicerone умеет сохранять команды в буфер, и когда навигатор становится доступен, он их исполняет.
Если у вас нет желания допиливать код, это не значит, что вы как разработчик не хотите что-то делать. Допустим, у вас есть сроки, вам продакт-менеджеры говорят, что нужно пилить фичи, бизнес хочет фичи выкатывать. Вам нужно готовое решение, которое работает из коробки. Navigation Components не про этот случай.
Еще одна критичная вещь — не поддерживаются DialogFragments. Навигатор, который бы с ними работал, можно было добавить, но почему-то не добавили. Проблема в том, что сами по себе они открываются как обычные фрагменты. Не выставляется флажок на isShowing и, соответственно, не выполняется жизненный цикл DialogFragments по созданию диалога. По факту DialogFragment открывается как обычный фрагмент, весь экран без создания окна.
На этом у меня всё. Копайте исходники, это действительно интересно и увлекательно. Вот полезные материалы для тех, кому интересно поработать с библиотекой навигации. Спасибо за внимание.