Хабрахабр

Лицензия на вождение болида, или почему приложения должны быть Single-Activity

Хотя тема известная, существует много предубеждений относительно такого выбора — переполненный зал и количество вопросов после выступления тому подтверждение. На AppsConf 2018, которая прошла 8-9 октября, я выступил с докладом про создание андроид-приложений целиком в одном Activity. Чтобы не ждать видеозаписи, я решил сделать статью с расшифровкой выступления.

О чем я расскажу

  1. Почему и зачем надо переходить на Single-Activity
  2. Универсальный подход для решения задач, которые вы привыкли решать на нескольких Activity
  3. Примеры стандартных бизнес задач
  4. Узкие места, где обычно подпирают код, а не делают все честно

Почему 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 могут запускаться в разных «задачах».

Никогда нельзя с уверенностью сказать, как именно был запущен экран, и как он повлиял на стек. Все вместе это создает путаницу и проблемы при поддержке-отладке.

Можно проанализировать текущий стек экранов и сохраненные транзакции.
В демонстрационном приложении библиотеки Cicerone можно увидеть, как в тулбаре отображается текущее состояние стека. В Single-Activity приложении все экраны переключаются только через транзакции фрагментов.

image

Замечание: в последних версия саппорт библиотеки закрыли доступ к массиву фрагментов внутри менеджера фрагментов, но если очень хочется, эту проблему всегда можно решить.

Activity только одна на экране

Двойственность подхода — это всегда плохо, так как одни и та же проблема при этом могут решаться по-разному (где-то верстка прямо в Activity, а где-то Activity просто контейнер). В реальных приложениях нам обязательно понадобится совмещать «логические» экраны в одном Activity, то написать реальное приложение ТОЛЬКО на Activity нельзя.

Don't keep activities

Не бывает, что процесс приложения остается, и в этот момент Activity, пусть и не активное, умирает! Этот флаг для тестирования действительно позволяет найти некоторые баги в приложении, но поведение, которое он воспроизводит, НИКОГДА не встречается в реальности! Если же приложение отображается пользователю, а системе не хватает ресурсов, будет умирать все вокруг (другие неактивные приложения, сервисы и даже лаунчер), а ваше приложение будет жить до победного конца, и если уж ему придется умереть, то целиком.
Можете проверить. Activity могут умирать только вместе с процессом приложения.

Наследие

Например, все необходимое для работы с loaders, actionBar, action menu и так далее. Исторически сложилось, что в Activity есть огромное количество лишней логики, которая скорее всего вам не пригодится. Это делает сам класс достаточно массивным и тяжеловесным.

Анимации

Тут стоит уточнить, что надо сделать скидку на асинхронность запуска Activity, о чем мы говорили раньше.
Если потребуется что-то более интересное, можно вспомнить о таких примерах анимации перехода, которые сделаны на Activity: Сделать простую анимацию сдвига при переключении между Activity сможет, пожалуй, любой.

image

Дизайнеров и заказчика это вряд ли порадует. Но есть большая проблема: кастомизировать эту анимацию практически невозможно.

Мы можем опуститься прямо на уровень иерархии вью и сделать любую анимацию, какую только можно себе представить! С фрагментами все иначе. Прямое доказательство вот:

image

Да, кода там прилично, но анимации — это всегда достаточно сложно, а иметь такую возможность — это всегда плюс. Если вы посмотрите в исходный код, то обнаружите, что это сделано на обычной верстке. Если же у вас переключаются два 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, вместо которого создан.

Новая навигация

Основное отличие от старого подхода — это навигация.

Выбор не исчез, но изменились методы — теперь надо решить, запустить траназакцию фрагментов в AppActivity или внутри текущего FlowFragment. Раньше перед разработчиком стоял выбор: запустить новое Activity или транзакцию фрагментов в текущем.

Раньше Activity передавала событие текущему фрагменту, а, если тот не обработал, принимала решение сама. Аналогично с обработкой кнопки Back. Теперь AppActivity передает событие текущему FlowFragment, а тот, в свою очередь, передает его текущему фрагменту.

Передача результата между экранами

Для неопытных разработчиков вопрос передачи данных между экранами — главная проблема нового подхода, ведь раньше можно было воспользоваться функционалом startActivityForResult()!

Основной задачей при этом остается разделение UI и слоя данных и бизнес-логики. Не первый год обсуждаются различные архитектурные подходы к написанию приложений. Я подчеркиваю, что именно одного приложения, так как у нас есть общий слой данных, общие модели в глобальном скоупе и так далее. С этой точки зрения, startActivityForResult() ломает канон, так как данные между экранами одного приложения передаются на стороне сущностей UI слоя. Используйте его только по назначению — для запуска внешних приложений и получения результата от них. Мы же не используем эти возможности и вгоняем себя в рамки одного Bundle (сериализация, размер и другое).
Мой совет: не используйте startActivityForResult() внутри приложения!

Есть три варианта: Как тогда запускать экран с выбором для другого экрана?

  1. TargetFragment
  2. EventBus
  3. реактивная модель

Плохой вариант. TargetFragment — вариант «из коробки», но та же передача данных на стороне UI слоя.

Но так как это опасный ход, то вывод — плохой вариант. EventBus — если вы сможете договориться в команде и — главное — контролировать договоренности, то на глобальной шине данных можно реализовать передачу данных между экранами.

Как вы их реализуете, решает команда каждого проекта. Реактивная модель — такой подход подразумевает наличие коллбеков и только. Наш выбор! Но именно этот подход оптимален, так как обеспечивает контроль над происходящим и не дает использовать код не по назначению.

Итог

Надеюсь, что в данном случае это именно так. Я люблю новые подходы, когда они просты и несут явную пользу. Достаточно заменить все Activity на FlowFragment, сохранив всю логику без изменений. Польза описана в первой части, а о сложности судить вам. Немного поменять код навигации и подумать над работой с передачей данных между экранами, если это еще не сделано.

Чтобы показать простоту подхода, я сам перевел открытое приложение на Single-Activity, и на это ушло всего несколько часов (конечно стоит учесть, что это не древнее легаси, и с архитектурой там все более или менее хорошо).

Что получилось?

Давайте посмотрим, как теперь решать стандартные задачи в новом подходе.

BottomNavigationBar и NavigationDrawer

Пользуясь простым правилом, что все Activity заменяем на FlowFragment, боковое меню теперь будет находиться в некотором фрагменте и переключать в нем же вложенные фрагменты:

Аналогично с BottomNavigationBar.
Гораздо интереснее то, что одни FlowFragment мы можем вкладывать в другие, поскольку это все еще обычные фрагменты!

Такой вариант можно найти в GitFox.

Именно возможность простого совмещения одних фрагментов внутри других позволяет без особых проблем делать динамичный UI для разных девайсов: планшеты + смартфоны.

DI-скоупы

Этот подход избавляет от сложного управления временем жизни скоупа, привязывая его к времени жизни FlowFragment. Если у вас есть флоу покупки товара из нескольких экранов, и на каждом экране надо показывать имя товара, вы наверняка уже вынесли это в отдельное Activity, которое хранит товар и предоставляет его экранам.
Аналогично будет и с FlowFragment — он будет содержать DI-скоуп с моделями для всех вложенных экранов.

В новом подходе все deep-link попадают в AppActivity.onNewIntent. Если вы использовали фильтры в манифесте для запуска по deep-link определенного экрана, могли возникнуть проблемы с запуском Activity, о чем я писал в первой части. Предлагаю посмотреть на такую функциональность в Чичероне). Дальше по полученным данным происходит переход к необходимому экрану (или цепочке экранов.

Смерть процесса

Если приложение написано на нескольких 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?) else { super.onConfigurationChanged(newConfig) }
}

Таким образом мы можем не вызвать super.onConfigurationChanged(newConfig), а обработать его сами и повернуть только необходимые вью на экране.
Но с API 23 проект будет падение с SuperNotCalledException, поэтому плохой выбор.

Кто-то может предложить другое решение:

<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>

Более правильное решение помогает обработать и клавиатуру.

Изменение верстки при появлении клавиатуры

И если раньше мы могли для разных Activity указать разные режимы реакции на клавиатуру, то теперь надо сделать это в Single-Activity. Когда выезжает клавиатура, верстка должна меняться, чтобы важные элементы UI не оставались вне зоны досягаемости. Поэтому необходимо использовать

android:windowSoftInputMode="adjustResize"

Если вы используете для обработки прозрачного статус-бара подход из прошлого раздела, то обнаружите досадную ошибку: если фрагмент успешно «подлезал» под статус бар, то при появлении клавиатуры он сожмется сверху и снизу, так как и статус бар и клавиатура внутри системы работают через SystemWindows.

Обратите внимание на заголовок

Изучать документацию! Что делать? И обязательно посмотреть доклад Chris Banes про WindowInsets.

Использование WindowInsets позволит

  • узнать правильную высоту статус бара (а не хардкодить 51dp)
  • подготовить приложение к любым вырезам в экранах новых смартфонов
  • узнать высоту клавиатуры (это реально!)
  • получить события и среагировать на появление клавиатуры.

Всем изучать WindowInsets!

Splash screen

Есть множество статей на эту тему. Если кто-то еще не в курсе, то каноничный Splash screen — это не первый экран в приложении, который грузит данные, а то, что видит пользователь при запуске, пока контент Activity не успел отрисоваться.

Помните это и объясните дизайнерам, так как переходе по deep-link на темный экран и светлом Splash screen будет виден переход между цветами. Но я хочу отметить, что при Single-Activity, возможен только один Splash screen.

Запуск вашего приложения из других приложений

Это самое сложное для понимания место, так как таит хитрость, которую можно использовать как во благо, так и во вред.

Все сделано в Single-Activity. Представьте, что вы создали приложение типичной социальной сети. Есть несколько кейсов: Пользователь перешел на какой-то далеко не первый экран и стал писать комментарий другу, но его отвлекли, и он свернул приложение.
На следующий день он читает новости в другом приложении и решил поделиться ими в вашей социальной сети...
Кидается Intent, открывается ваше приложение, а там недописанный комментарий на не первом экране...
Что дальше?

  • восстанавливаем экран с комментарием, а сверху открываем экран с функцией «поделиться». Тогда при нажатии «назад», пользователь увидит недописанный комментарий. Если так и надо, то все ок!
  • сбрасываем сохраненный стек экранов и верим, что пользователь нас простит…

Но что если для разных внешних запусков надо выбирать разное поведение? Оба варианта надо обсуждать с заказчиком, так как оба не идеальны. И есть еще вариант — сохранить предыдущий стек для будущего запуска и после показа экрана «поделиться» по кнопке «назад» выйти из приложения.

Ответ есть, но дочитайте до конца. Что делать?

Надо создать отдельное Activity!
Вспомним, какую задачу нужно решить: дать возможность другим приложениям использовать функциональность нашего, а если — запускать некоторые экраны нашего приложения из других приложений.
Запускать для этого основное приложение полностью — ошибка, и стоит создать специальное приложение (то самое второе Activity), которое отобразит нужные экраны.

Его нельзя использовать из основного Activity, и для него надо отдельно настроить параметры в манифесте. Второе Activity — это отдельное приложение для шаринга экранов основного. Предлагаю обстоятельно изучить эту идею.

Заключение

До сих пор я не встречал полноценного описания подхода, а тем более подробного разбора всех важных моментов и решил сделать это сам. Идея этой статьи (доклада) появилась потому что, когда речь заходит о приложениях внутри одного Activity, я часто сталкиваюсь с недоверием даже опытных Android-разработчиков.

Что же касается перфоманса и большой вложенности — мы делаем уже не первый проект, придерживаясь данного подхода, и связанных с Activity проблем у нас не возникло. Хочу успокоить всех недоверчивых: после анонса архитектурных компонентов Google пофиксил все критичные баги у чайлд фрагментов.

Спасибо! Надеюсь, теперь вы можете считать, что получили лицензию на вождение болида, за рулем которого легко оставите всех соперников позади!

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть