Лицензия на вождение болида, или почему приложения должны быть Single-Activity
На AppsConf 2018, которая прошла 8-9 октября 2018 года, автор данной статьи – Константин Цховребов (см. источник) – выступил с докладом про создание андроид-приложений целиком в одном Activity. Хотя тема известная, существует много предубеждений относительно такого выбора — переполненный зал и количество вопросов после выступления тому подтверждение. Чтобы не ждать видеозаписи, он решил сделать статью с расшифровкой выступления (см. ниже).
О чем я расскажу
- Почему и зачем надо переходить на Single-Activity.
- Универсальный подход для решения задач, которые вы привыкли решать на нескольких Activity.
- Примеры стандартных бизнес задач.
- Узкие места, где обычно подпирают код, а не делают все честно.
Почему Single-Activity — это правильно?
Жизненный цикл
Все андроид-разработчики знают схему «холодного» запуска приложения. Сначала вызывается onCreate у класса Application, затем вступает в действие жизненный цикл первого Activity.
Если в нашем приложении несколько Activity (а таких приложений большинство), происходит следующее:
App.onCreate() ActivityA.onCreate() ActivityA.onStart() ActivityA.onResume() ActivityA.onPause() ActivityB.onCreate() ActivityB.onStart() ActivityB.onResume() ActivityA.onStop()
Это абстрактный лог запуска ActivityB из ActivityA. Пустая строка — момент, когда был вызван запуск нового экрана. На первый взгляд, все нормально. Но если мы обратимся к документации, станет понятно: гарантировать, что экран виден пользователю и он может с ним взаимодействовать, можно только после вызова onResume
у каждого экрана:
App.onCreate() ActivityA.onCreate() ActivityA.onStart() ActivityA.onResume() <-------- ActivityA.onPause() ActivityB.onCreate() ActivityB.onStart() ActivityB.onResume() <-------- ActivityA.onStop()
Проблема в том, что такой лог не помогает понять ЖЦ приложения. Когда пользователь еще внутри, а когда уже перешел в другое приложение или свернул наше, и так далее. А это необходимо, когда мы хотим привязать бизнес логику к ЖЦ приложения, например, держать сокет соединение, пока пользователь находится в приложении, и закрывать его при выходе.
В Single-Activity приложении все просто — ЖЦ Activity становится ЖЦ приложения. Все необходимое для любой логики легко привязать к состоянию приложения.
Запуск экранов
Как пользователь, я часто сталкивался с тем, что звонок из телефонной книги (а это явно запуск отдельной Activity) не происходит после клика на контакт. Непонятно, с чем это связано, но те, кому я безуспешно пытался дозвониться, говорили, что принимали звонок и слышали звук шагов. При этом мой смартфон уже давно лежал в кармане.
Проблема в том, что запуск Activity — это полностью асинхронный процесс! Нет гарантии мгновенного запуска, а еще хуже то, что мы не можем процесс контролировать. Совсем.
В Single-Activity приложении, работая с менеджером фрагментов, мы можем контролировать процесс.transaction.commit()
— выполнит переключение экранов асинхронно, что позволяет открывать или закрывать сразу несколько экранов подряд.transaction.commitNow()
— переключает экран синхронно, если не нужно добавлять его в стек.fragmentManager.executePendingTransactions()
позволяет выполнить все запущенные ранее транзакции прямо сейчас.
Анализ стека экранов
Представьте, что бизнес-логика вашего приложения зависит от текущей глубины стека экранов (например, ограничение вложенности). Или по завершении некоторого процесса нужно вернуться на определенный экран, а если есть несколько идентичных — на ближайший к корню (началу цепочки).
Как получить стек Activity? Какие параметры нужно указать при запуске экрана?
Кстати, о магии параметров запуска Activity:
- можно указывать флаги запуска в Intent (а еще смешивать их между собой, и менять из разных мест);
- можно добавить параметры запуска в манифест, ведь все Activity должны быть там описаны;
- добавьте сюда Intent фильтры для обработки внешнего запуска;
- и наконец вспомните про MultiTasks, когда Activity могут запускаться в разных «задачах».
Все вместе это создает путаницу и проблемы при поддержке-отладке. Никогда нельзя с уверенностью сказать, как именно был запущен экран и как он повлиял на стек.
В Single-Activity приложении все экраны переключаются только через транзакции фрагментов. Можно проанализировать текущий стек экранов и сохраненные транзакции.
В демонстрационном приложении библиотеки Cicerone можно увидеть, как в тулбаре отображается текущее состояние стека.
Замечание: в последних версиях саппорт библиотеки (на момент написания статьи) закрыли доступ к массиву фрагментов внутри менеджера фрагментов, но если очень хочется, эту проблему всегда можно решить.
Activity только одна на экране
В реальных приложениях нам обязательно понадобится совмещать «логические» экраны в одном Activity, то есть написать реальное приложение ТОЛЬКО на Activity нельзя. Двойственность подхода — это всегда плохо, так как одни и те же проблемы при этом могут решаться по-разному (где-то верстка прямо в Activity, а где-то Activity просто контейнер).
Don't keep activities
Этот флаг для тестирования действительно позволяет найти некоторые баги в приложении, но поведение, которое он воспроизводит, НИКОГДА не встречается в реальности! Не бывает, что процесс приложения остается и в этот момент Activity, пусть и не активное, умирает! Activity могут умирать только вместе с процессом приложения. Если же приложение отображается пользователю, а системе не хватает ресурсов, будет умирать все вокруг (другие неактивные приложения, сервисы и даже лаунчер), а ваше приложение будет жить до победного конца, и если уж ему придется умереть, то целиком.
Можете проверить.
Наследие
Исторически сложилось, что в Activity есть огромное количество лишней логики, которая скорее всего вам не пригодится. Например, все необходимое для работы с loaders
, actionBar
, action menu
и так далее. Это делает сам класс достаточно массивным и тяжеловесным.
Анимации
Сделать простую анимацию сдвига при переключении между Activity сможет, пожалуй, любой. Тут стоит уточнить, что надо сделать скидку на асинхронность запуска Activity, о чем мы говорили раньше.
Если потребуется что-то более интересное, можно вспомнить о таких примерах анимации перехода, которые сделаны на Activity:
Но есть большая проблема: кастомизировать эту анимацию практически невозможно. Дизайнеров и заказчика это вряд ли порадует.
С фрагментами все иначе. Мы можем опуститься прямо на уровень иерархии вью и сделать любую анимацию, какую только можно себе представить! Прямое доказательство вот:
Если вы посмотрите в исходный код, то обнаружите, что это сделано на обычной верстке. Да, кода там прилично, но анимации — это всегда достаточно сложно, а иметь такую возможность — это всегда плюс. Если же у вас переключаются два Activity, то в приложении нет общего контейнера, где можно производить такие переходы.
Изменение конфигурации налету
Этого пункта не было в моем выступлении, но он тоже очень важен. Если у вас есть фича с переключением языка внутри приложения, то при нескольких Activity реализовать ее будет достаточно проблематично, если, в том числе, надо не перезапустить приложение, а остаться там же, где пользователь был в момент вызова функциональности.
В Single-Activity приложении достаточно изменить установленную локаль в контексте приложения и вызвать recreate()
у Activity, остальное система сделает сама.
Напоследок
У Google появилось решение для навигации, в документации которого прямо сказано, что желательно писать Single-Activity приложения.
К этому моменту, надеюсь, у вас не осталось сомнений в том, что классический подход с несколькими Activity содержит ряд недостатков, на которые принято закрывать глаза, прикрываясь общей тенденцией недовольства Android.
Если все так, то почему Single-Activity еще не стандарт разработки?
Тут я приведу цитату моего хорошего знакомого:
Начиная новый серьезный проект, любой лид боится оплошать и избегает рискованных решений. Это правильно. Но я постараюсь предоставить исчерпывающий план по переходу на Single-Activity.
Переход на Single-Activity
Если изучить это приложение, по характерным анимациям и поведению можно определить, что оно написано на нескольких Activity. Я могу ошибаться, и все сделано даже на кастомных вьюхах, но на наших рассуждениях это никак не скажется.
А теперь внимание! Делаем вот так:
Мы сделали всего два изменения: добавили класс AppActivity и заменили все Activity на FlowFragment. Рассмотрим каждое изменение подробнее.
За что отвечает AppActivity:
- содержит только контейнер для фрагментов
- является точкой инициализации объектов UI скоупа (раньше приходилось делать это в Application, что неправильно, так как, например, Service'ам в нашем приложении точно не нужны такие объекты)
- является провайдером ЖЦ приложения
- привносит все плюсы Single-Activity.
Что такое FlowFragment:
- делает ровно то же, что и Activity, вместо которого создан.
Новая навигация
Основное отличие от старого подхода — это навигация.
Раньше перед разработчиком стоял выбор: запустить новое Activity или транзакцию фрагментов в текущем. Выбор не исчез, но изменились методы — теперь надо решить, запустить транзакцию фрагментов в AppActivity или внутри текущего FlowFragment.
Аналогично с обработкой кнопки Back. Раньше Activity передавала событие текущему фрагменту, а если тот не обработал, принимала решение сама. Теперь AppActivity передает событие текущему FlowFragment, а тот, в свою очередь, передает его текущему фрагменту.
Передача результата между экранами
Для неопытных разработчиков вопрос передачи данных между экранами — главная проблема нового подхода, ведь раньше можно было воспользоваться функционалом startActivityForResult()!
Не первый год обсуждаются различные архитектурные подходы к написанию приложений. Основной задачей при этом остается разделение UI и слоя данных и бизнес-логики. С этой точки зрения, startActivityForResult() ломает канон, так как данные между экранами одного приложения передаются на стороне сущностей UI слоя. Я подчеркиваю, что именно одного приложения, так как у нас есть общий слой данных, общие модели в глобальном скоупе, и так далее. Мы же не используем эти возможности и вгоняем себя в рамки одного Bundle (сериализация, размер и другое).
Мой совет: не используйте startActivityForResult() внутри приложения! Используйте его только по назначению — для запуска внешних приложений и получения результата от них.
Как тогда запускать экран с выбором для другого экрана? Есть три варианта:
- TargetFragment
- EventBus
- реактивная модель
TargetFragment — вариант «из коробки», но та же передача данных на стороне UI слоя. Плохой вариант.
EventBus — если вы сможете договориться в команде и — главное — контролировать договоренности, то на глобальной шине данных можно реализовать передачу данных между экранами. Но так как это опасный ход, то вывод — плохой вариант.
Реактивная модель — такой подход подразумевает наличие коллбеков и только. Как вы их реализуете, решает команда каждого проекта. Но именно этот подход оптимален, так как обеспечивает контроль над происходящим и не дает использовать код не по назначению. Наш выбор!
Итог
Я люблю новые подходы, когда они просты и несут явную пользу. Надеюсь, что в данном случае это именно так. Польза описана в первой части, а о сложности судить вам. Достаточно заменить все Activity на FlowFragment, сохранив всю логику без изменений. Немного поменять код навигации и подумать над работой с передачей данных между экранами, если это еще не сделано.
Чтобы показать простоту подхода, я сам перевел открытое приложение на Single-Activity, и на это ушло всего несколько часов (конечно, стоит учесть, что это не древнее легаси, и с архитектурой там все более или менее хорошо).
Что получилось?
Давайте посмотрим, как теперь решать стандартные задачи в новом подходе.
BottomNavigationBar и NavigationDrawer
Пользуясь простым правилом, что все Activity заменяем на FlowFragment, боковое меню теперь будет находиться в некотором фрагменте и переключать в нем же вложенные фрагменты:
Аналогично с BottomNavigationBar.
Гораздо интереснее то, что одни FlowFragment мы можем вкладывать в другие, поскольку это все еще обычные фрагменты!
Такой вариант можно найти в GitFox.
Именно возможность простого совмещения одних фрагментов внутри других позволяет без особых проблем делать динамичный UI для разных девайсов: планшеты + смартфоны.
DI-скоупы
Если у вас есть флоу покупки товара из нескольких экранов, и на каждом экране надо показывать имя товара, вы наверняка уже вынесли это в отдельное Activity, которое хранит товар и предоставляет его экранам.
Аналогично будет и с FlowFragment — он будет содержать DI-скоуп с моделями для всех вложенных экранов. Этот подход избавляет от сложного управления временем жизни скоупа, привязывая его к времени жизни FlowFragment.
Deep-links
Если вы использовали фильтры в манифесте для запуска по deep-link определенного экрана, могли возникнуть проблемы с запуском Activity, о чем я писал в первой части. В новом подходе все deep-link попадают в AppActivity.onNewIntent. Дальше по полученным данным происходит переход к необходимому экрану (или цепочке экранов. Предлагаю посмотреть на такую функциональность в Чичероне).
Смерть процесса
Если приложение написано на нескольких Activity, вы должны знать, что при смерти приложения, а затем при восстановлении процесса пользователь окажется на последнем Activity, а все предыдущие будут восстановлены только при возврате на них.
Если этого не учесть заранее, могут возникнуть проблемы. Например, если скоуп, необходимый на последнем Activity, открывался на предыдущем, его никто не пересоздаст. Что делать? Выносить это в класс Application? Делать несколько точек открытия скоупа?
Все проще с фрагментами, так как они находятся внутри Activity или другого FlowFragment, а любой контейнер будет восстановлен ДО пересоздания фрагмента.
Другие практические задачи можем обсудить в комментариях, так как иначе есть шанс, что статья получится слишком объемной.
А теперь самая интересная часть.
Узкие места (надо помнить и думать).
Здесь собраны важные вещи, о которых стоит задумываться в любом проекте, но все так привыкли их «подкостыливать» в проектах на нескольких Activity, что стоит напомнить об этом и рассказать, как правильно их решить в новом подходе. И первое в списке
Поворот экрана
Та самая страшная сказка для любителей ныть о том, что Android пересоздает Activity при повороте экрана. Самый популярный метод решения — фиксация портретной ориентации. Причем это предложение уже не разработчиков, а менеджеров, напуганных фразами типа "поддержать поворот очень сложно и стоит в несколько раз дороже".
Не будем спорить о правильности такого решения. Важно другое: фиксация поворота не освобождает от обработки смерти Activity! Так как те же процессы происходят при множестве других событий: сплит-режим, когда отображается несколько приложений на экране, подключение внешнего монитора, смена конфигурации приложения «на лету» и так далее.
Более того, поворот экрана позволяет проверить правильную «резиновость» верстки, поэтому в нашей питерской команде на всех дебажных сборках мы не отключаем поворот, даже если в релизной версии его не будет. Не говоря уже о типичных багах, которые все равно найдутся при проверке.
Для обработки поворота уже написано множество решений, начиная Moxy и заканчивая различными реализациями MVVM. Сделать это не сложнее, чем все остальное.
Рассмотрим другой интересный кейс.
Представим приложение каталога продуктов. Мы его делаем в Single-Activity. Везде зафиксирован портретный режим, но заказчик хочет фичу, когда при просмотре галереи фото пользователь может смотреть их в ландшафтной ориентации. Как это поддержать?
Кто-то предложит первый костыль:
<activity android:name=".AppActivity" android:configChanges="orientation" />
override fun onConfigurationChanged(newConfig: Configuration?) { if (newConfig?.orientation == Configuration.ORIENTATION_LANDSCAPE) { //ignore } else { super.onConfigurationChanged(newConfig) } }
Таким образом мы можем не вызвать super.onConfigurationChanged(newConfig)
, а обработать его сами и повернуть только необходимые вью на экране.
Но с API 23 проект будет падение с SuperNotCalledException
, поэтому плохой выбор.
В утверждениях выше допущена ошибка:
Меня резонно исправили в комментариях, что достаточно добавить android:configChanges=«orientation|screenSize», и тогда можно при повороте вызвать super и Activity пересоздана не будет. Это полезно использовать, когда на экране WebView или карта, которые долго инициализируются, и этого хочется избежать.
Это поможет решить описанный кейс с галереей, но основной посыл этого раздела: не игнорируйте пересоздание Activity, это может произойти во множестве других случаев.
Кто-то может предложить другое решение:
<activity android:name=".AppActivity" android:screenOrientation="portrait" /> <activity android:name=".RotateActivity" />
Но таким образом мы уходим от Single-Activity подхода для решения простой задачи и лишаем себя всей пользы подхода. Это костыль, а костыль всегда плохой выбор.
Вот верное решение:
<activity android:name=".AppActivity" android:configChanges="orientation" />
override fun onResume() { super.onResume() activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR } override fun onPause() { super.onPause() activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT }
То есть при открытии фрагмента приложение начинает «крутиться», а при возврате снова фиксируется. По моим наблюдениям именно так работает приложение AirBnB. Если открыть просмотр фотографий жилья, активируется обработка поворотов, но в ландшафтной ориентации можно потянуть фотографию вниз для выхода из галереи. Под ней будет виден предыдущий экран в ландшафтной ориентации, чего обычно не найдешь, так как сразу после выхода из галереи экран повернется в портрет и зафиксируется.
Вот здесь и поможет своевременная подготовка к поворотам экрана.
Transparent status bar
С системным баром работать может только Activity, а оно у нас теперь всего одно, поэтому надо всегда указывать
<item name="android:windowTranslucentStatus">true</item>
Но на каких-то экранах нет необходимости «подлезать» под него, и надо отобразить весь контент ниже. На помощь приходит флаг
android:fitsSystemWindows="true"
который указывает верстке, что не стоит отрисовываться под системным баром. Но если вы укажете его у верстки фрагмента, а затем попробуете отобразить фрагмент через транзакцию во фрагмент менеджере, то вас ждет разочарование… он не сработает!
Ответ быстро гуглится
Очень рекомендую ознакомиться, там дан действительно исчерпывающий ответ и много полезных ссылок.
Быстрое и рабочее (но не самое верное) решение — обернуть верстку в CoordinatorLayout
<android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true"> </android.support.design.widget.CoordinatorLayout>
Более правильное решение помогает обработать и клавиатуру.
Изменение верстки при появлении клавиатуры
Когда выезжает клавиатура, верстка должна меняться, чтобы важные элементы UI не оставались вне зоны досягаемости. И если раньше мы могли для разных Activity указать разные режимы реакции на клавиатуру, то теперь надо сделать это в Single-Activity. Поэтому необходимо использовать
android:windowSoftInputMode="adjustResize"
Если вы используете для обработки прозрачного статус-бара подход из прошлого раздела, то обнаружите досадную ошибку: если фрагмент успешно «подлезал» под статус бар, то при появлении клавиатуры он сожмется сверху и снизу, так как и статус бар и клавиатура внутри системы работают через SystemWindows
.
Обратите внимание на заголовок
Что делать? Изучать документацию! И обязательно посмотреть доклад Chris Banes про WindowInsets.
Использование WindowInsets позволит
- узнать правильную высоту статус бара (а не хардкодить 51dp)
- подготовить приложение к любым вырезам в экранах новых смартфонов
- узнать высоту клавиатуры (это реально!)
- получить события и среагировать на появление клавиатуры.
Всем изучать WindowInsets!
Splash screen
Если кто-то еще не в курсе, то каноничный Splash screen — это не первый экран в приложении, который грузит данные, а то, что видит пользователь при запуске, пока контент Activity не успел отрисоваться. Есть множество статей на эту тему.
Но я хочу отметить, что при Single-Activity, возможен только один Splash screen. Помните это и объясните дизайнерам, так как переходе по deep-link на темный экран и светлом Splash screen будет виден переход между цветами.
Запуск вашего приложения из других приложений
Это самое сложное для понимания место, так как таит хитрость, которую можно использовать как во благо, так и во вред.
Представьте, что вы создали приложение типичной социальной сети. Все сделано в Single-Activity. Пользователь перешел на какой-то далеко не первый экран и стал писать комментарий другу, но его отвлекли, и он свернул приложение.
На следующий день он читает новости в другом приложении и решил поделиться ими в вашей социальной сети...
Кидается Intent, открывается ваше приложение, а там недописанный комментарий на не первом экране...
Что дальше? Есть несколько кейсов:
- восстанавливаем экран с комментарием, а сверху открываем экран с функцией «поделиться». Тогда при нажатии «назад», пользователь увидит недописанный комментарий. Если так и надо, то все ок!
- сбрасываем сохраненный стек экранов и верим, что пользователь нас простит…
Оба варианта надо обсуждать с заказчиком, так как оба не идеальны. Но что если для разных внешних запусков надо выбирать разное поведение? И есть еще вариант — сохранить предыдущий стек для будущего запуска и после показа экрана «поделиться» по кнопке «назад» выйти из приложения.
Что делать? Ответ есть, но дочитайте до конца.
Надо создать отдельное Activity!
Вспомним, какую задачу нужно решить: дать возможность другим приложениям использовать функциональность нашего, а если — запускать некоторые экраны нашего приложения из других приложений.
Запускать для этого основное приложение полностью — ошибка, и стоит создать специальное приложение (то самое второе Activity), которое отобразит нужные экраны.
Второе Activity — это отдельное приложение для шаринга экранов основного. Его нельзя использовать из основного Activity, и для него надо отдельно настроить параметры в манифесте. Предлагаю обстоятельно изучить эту идею.
Заключение
Идея этой статьи (доклада) появилась потому что, когда речь заходит о приложениях внутри одного Activity, я часто сталкиваюсь с недоверием даже опытных Android-разработчиков. До сих пор я не встречал полноценного описания подхода, а тем более подробного разбора всех важных моментов и решил сделать это сам.
Хочу успокоить всех недоверчивых: после анонса архитектурных компонентов Google пофиксил все критичные баги у чайлд фрагментов. Что же касается перфоманса и большой вложенности — мы делаем уже не первый проект, придерживаясь данного подхода, и связанных с Activity проблем у нас не возникло.
Надеюсь, теперь вы можете считать, что получили лицензию на вождение болида, за рулем которого легко оставите всех соперников позади! Спасибо!