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


image


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

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


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


image


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


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


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


Don't keep activities


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


Наследие


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


Анимации


Сделать простую анимацию сдвига при переключении между 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, вместо которого создан.

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


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



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



Аналогично с обработкой кнопки Back. Раньше Activity передавала событие текущему фрагменту, а, если тот не обработал, принимала решение сама. Теперь 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-скоупы


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




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


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


<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 проблем у нас не возникло.


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

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (73):

  1. advance
    /#19254455

    Спасибо. Статья интересная. Особенно по поводу статусбара.
    Однако, утверждение, что все приложения должны быть Single Activity- спорное. Этот подход хорош, когда у Вас типичное «конвейерное приложение» а-ля веб-клиента. Если писать что-то по-сложнее или добавить к существующему Single Activity-приложению действительно тяжелый, с точки зрения ресурсов устройства, функционал- с этого момента могут возникнуть проблемы.
    Надо ли говорить, что Single Activity-приложение, чаще всего, представляет собой изобилие рукотворных утечек. Потому работа с таким подходом, если всё делать по совести, вызывает не меньше сложностей в сравнении с multiple activity.
    Потому важно иметь права как на болид F1, так и на старый добрый вездеход. И понимать, на чем ехать в конкретном случае. Иногда комбинировать.
    Иначе это- просто мода. По типу «asynctask- зло» или «dagger нужен везде». Каждому инструменту- своё применение.

    • alaershov
      /#19254549

      Надо ли говорить, что Single Activity-приложение, чаще всего, представляет собой изобилие рукотворных утечек

      Надо :) Почему риск утечек памяти (если вы о них) в Single Activity выше, чем в любом другом подходе?

      • advance
        /#19254847 / -1

        Согласен, надо. Не хотел приводить пример потому, что коммент был и без того слишком развернутый.
        Если просто- activity, которые есть в backstack, android может выгружать из памяти при ее нехватке, оставляя в памяти только ту, на которою Вы смотрите (и ближайшие к ним), а при возврате восстановить из saved state. Таким образом ресурсы, которые выделены на Ваше приложение, со стороны UI (ну или view/presentation-слоя, смотря что используете) не залочены.
        В случае подхода с single activity в backstack все фрагменты держатся в памяти. И то, на что Вы ссылаетесь жесткими ссылками из фрагментов- тоже. Да, в простых случаях это- спички. Но когда в стэке лежит что-то тяжелое, да еще и много инстансов (например, карты, или другие тяжелые элементы/api)- то потребление памяти сильно подскакивает, а вместе с ним падает оперативность и отзывчивость приложения из-за частых вызовов GC и нагрузки в принципе.
        Да, Вы можете сказать, что «вот на моем флагмане (или мощном B-бренде) всё хорошо». Однако, часто, бОльшая часть ЦА не располагает достаточно мощными устройствами. И современные low-end устройства с 2-3гб оперативы всё равно испытывают нехватку, и там этот вопрос стоит острее.
        Да, конечно, это можно решить\обойти в рамках Single Activity- это вопрос навыков. Однако, по моему опыту, такие решения весьма не тривиальны и многие просто на это забивают.

        • terrakok
          /#19255039

          Если просто- activity, которые есть в backstack, android может выгружать из памяти при ее нехватке, оставляя в памяти только ту, на которою Вы смотрите

          Не может. Я об этом специально написал дальше в статье.
          Только процесс может быть выгружен из памяти при нехватке ресурсов.

          • advance
            /#19255135

            В стоковом варианте- да. Есть возможность выгнать их в отдельный процесс. Так же зависит от вендора прошивки

            • terrakok
              /#19255199

              Это будет полноценное отдельное приложение. Там ничего не останется от общего стека и общих расшаренных моделей. Поэтому и разработка этого Activity будет не разработкой экрана, но полноценного приложения. Если мне не изменят память, то там и новый инстанс Application создастся. Я могу ошибаться, такой подход совсем не рядом с обычным стеком экранов, и применяется исключительно для специальных кейсов (когда надо выделить больше памяти на процесс).


              А про вендоров — это еще одна из причин делать все в одном Activity, меньше полагаешься на систему, больше работаешь внутри со своими классами.

              • advance
                /#19255343

                Не совсем так, всё зависит от того, где у Вас data source. Да, процесс отдельный, но взаимодействовать с ним проще, чем кажется.

                Это будет полноценное отдельное приложение.
                Когда будет отдельный проект- то тогда и будет отдельное полноценное приложение. До тех пор у Вас есть логика, которую можно переиспользовать

          • Bringoff
            /#19258775

            А откуда эта информация, что отдельное activity из бекстека не может быть выгружено? Этот контракт где-то описан? Я, конечно, глубоко не копал, но при использовании sgs3 даже на глаз было, что activity в различных приложениях при возврате к ним из бекстека иногда пересоздаются.

    • terrakok
      /#19254565

      вызывает не меньше сложностей в сравнении с multiple activity.

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

      • advance
        /#19254999 / -1

        Согласен. Но, разработка в принципе состоит из проблем и их решений.
        Если покопаться, такие проблемы есть и с single activity. И потому, что оба этих подхода имеют проблемы разного характера, они оба имеют право на существование в продакшене. Что из этого выбирать- вопрос опыта разработчика.
        Однако, согласитесь, что заголовок и некоторые доводы из статьи весьма провокационны. Вы сравниваете умение применять повсеместно single activity с правами F1, что, всё-таки, расходится с реальностью. Я прочитал статью с удовольствием, но такие моменты смущают.
        Лично я (как, думаю, и многие) вижу это так- «Либо ты пишешь только single activity, либо ты- плохой разраб»
        И ладно, если в это верят отдельные разрабы- это вопрос доверчивости и опыта каждого. Но, читая подобные статьи на хабре, в это начинает верить техническое руководство. А это- уже проблема…

        • terrakok
          /#19255085

          такие проблемы есть и с single activity

          Так я прошу их привести и обсудить! Прошу конструктивный диалог) Я же хочу о них узнать, чтобы подстелить себе соломку.

    • terrakok
      /#19254859

      Если писать что-то по-сложнее...

      Пока это просто неподкрепленное фактами утверждение. Пример: приложение Uber с 1700+ градл модулями в проде написано целиком в одном Activity. Как вы считаете это тоже "типичное «конвейерное приложение»"?


      Приведите пример, который на нескольких Activity решается бесспорно лучше, и мы детально обсудим)

      • advance
        /#19255117

        приложение Uber с 1700+ градл модулями
        На мой скромный взгляд, Uber не является хорошим примером приложения. Как и многие от крупных брендов.
        Примеры- сервисы синхронизации, аутентикаторы, камеры различной насыщенности с кучей точек входа, приложения с широким функционалом шаринга, чатики с быстрым ответом, навороченные радио и плееры… В общем примеров много, и перечислил далеко не все. В основном те приложения, которые часто контактируют с пользователем посредством точек входа ОС, нежели напрямую

        • terrakok
          /#19255207

          Для этого вы должны были обратить особое внимание на самую последнюю часть в статье!

          • advance
            /#19255309

            Всё верно, там приведен пример отдельного запуска activity и дальнейшего переиспользования кода. А когда таких активити будет много? А когда там будет работа с различными payload? А если другую (уже имеющуюся) активити проще вызвать сразу, а не городить новый стек? А если захочется еще и в recents сохранять?
            Какой же это тогда single activity?
            Поймите правильно, подавляющее большинство задач можно реализовать обоими путями, но не надо позиционировать свой подход как «истинно верный»…
            На мой взгляд, надо брать то, что дает меньше сопротивления в каждом конкретном случае

            • terrakok
              /#19255345

              Какой же это тогда single activity?

              Вот поэтому я очень просил в статье внимательно читать последний абзац.
              Это надо уметь сохранить single-activity.
              "дает меньше сопротивления" — и перечитав недостатки нескольких Activity в приложении надо все писать в одном. И принять подход как "«истинно верный»" :-)

              • advance
                /#19255361 / -1

                Вы, похоже, половины из моего комментария не прочитали…

  2. upwardteam
    /#19254551

    Вы написали:

    узнать высоту клавиатуры (это реально!)

    а в рамках вашей статьи не показали пример

    • terrakok
      /#19254559

      Потому что статья не об этом, но там специально выделено: Всем изучать WindowInsets!
      Когда появляется клавиатура, то система сообщает ее размер через применение WindowInsets к Activity, именно так и можно узнать ее высоту.

      • HotIceCream
        /#19254981

        Как узнать высоту клавиатуры, если приложение запущено в split screen?)

        • terrakok
          /#19255095

          А если клавиатура не налезет на наше Activity, то ее знать и не надо)
          И в таком случае система и не кинет нам WindowInsets

  3. alaershov
    /#19254677

    Спасибо за статью!
    Пара вопросов:


    1) Темы фрагментам задаёте в onCreateView, создавая ContextThemeWrapper? Что-то вроде:


    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        val contextThemeWrapper = ContextThemeWrapper(activity, R.style.Theme_MyApp_Dark)
        val themedInflater = inflater.cloneInContext(contextThemeWrapper)
        return themedInflater.inflate(R.layout.fragment_smoething_dark, container, false)
    }

    ?


    2) Передача данных с экрана на экран. Допустим, у нас в приложении есть экран, который умеет сканировать паспорт, и возвращать объект с паспортными данными. Если это Activity, то всё понятно, у нас одна точка выхода: onActivityResult(). Перегрузили, распарсили, передали куда надо.
    Как правильно сделать это через фрагмент? Можете чуть подробнее рассказать о подходе к реализации "реактивного подхода", который использует ваша команда?

    • terrakok
      /#19255131

      1) Нет, просто в разметке
      2) два варианта:


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

      • agent10
        /#19255537

        если этот сканер нужен только нам, то в него будет инжектиться модель

        А как можно обобщить когда множество мелких/и не очень объектов передаются между разными фрагментами? Сделать некий SharedFragmentExchangeData модель и везде её инжектить?

    • vyndor
      /#19255275

      Отвечу на первый вопрос.
      Можно повесить тему непосредственно, на root элемент в layout'е. И это будет даже лучше, чем указывать это в манифесте, т.к. всё что касается отображения конкретного окна будет в одном месте.
      Более интересные вопрос что делать с штуками типа windowBackground. Тут видимо придётся либо использовать один background на всё приложение, либо в каждом фрагменте указывать фон явно.

      • terrakok
        /#19255279

        про windowBackground я отдельно сказал в разделе про Splash Screen

        • vyndor
          /#19255383

          Ну вот, получается, что если мы хотим Single-Activity, то получаем overdraw на каждом экране, т.к. нам нужно нарисовать фон ещё и поверх splash'а. Не смертельно, но всё же.

          • terrakok
            /#19255573

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

            • vyndor
              /#19255873

              Может я вас не понял, но вы сами говорите, что у вас есть splash, который задаётся как windowBackground. У вас splash и является фоном для всех фрагментов? Или вы его в своей активити/фрагменте в рантайме подменяете, как только всё загрузилось?

              • terrakok
                /#19255921

                да, подменяем сразу после старта Activity

      • landarskiy
        /#19258363

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

        • terrakok
          /#19258367

          внутри диалога есть возможность указать любую тему

    • agent10
      /#19255503

      Тоже интересно посмотреть на грамотные примеры такой передачи данных между экранами. Просто, если говорить о всяких MVP/MVVP, то я вижу это как создание временного объекта при запуске нового фрагмента с которым будет работать Rx. Одно дело сделать один раз, другое дело как-то обобщить на всё приложение.

  4. Semper-Viventem
    /#19254827

    узнать правильную высоту статус бара (а не хардкодить 51dp)

    А разве не 24dp все хардкодят? 51 — как-то много для статусбара :)


    Вообще, кейс очень даже реальный. Успели с ним столкнуться. Несмотря на то, что на material.io написано, что высота statusbar должна быть 24 dp, по-факту, она может быть какой угодно. Особенно это заметно на девайсах с "челкой".

    • terrakok
      /#19254903

      Наверное, вы правы, очень давно не делал так, потому и ошибся)

      • Prototik
        /#19256901

        Больше похоже на высоту navigation bar.

  5. Xanderblinov
    /#19254907

    Don't keep activities

    Этот флаг для тестирования действительно позволяет найти некоторые баги в приложении, но поведение, которое он воспроизводит, НИКОГДА не встречается в реальности!


    У нас последнее время стали встречаться все чаще, так как вендоры все больше перепиливают систему. Возможно у вас не получилось это воспроизвести, но это не значит, что проблемы нет)

    Don't keep Activities воспроизводит следующее поведение:
    • Свернули приложение
    • Запустили другое ресурсоемкое и/или дали телефону уйти в сон
    • Развернули свое приложение


    Поднимется верхнее Activity стэка, при этом нижние будут задестроены.

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

    • terrakok
      /#19255067

      Именно! Это то, о чем я и говорю) Процесс в вашем кейсе тоже будет пересоздан!


      Только вместе с процессом могут умирать Activity, никогда не может быть такого, что вы находитесь в приложении на некотором экране, а в этот момент другие экраны (Activity) выгружаются. И именно такое поведение создает Don't keep Activities. Посмотрите внимательно в статью.


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


      Другой случай — это поворот приложения. Там Activity будут лениво пересоздаваться при возврате по стеку назад.

    • terrakok
      /#19255167

      для инициализации нативных библиотек, камеры, VR и т.д.

      Это еще одна вариация самого последней части в статье. Я для этого пишу отдельное "приложение", то есть Activity никак не связанное с основным приложением. Там нет общего слоя данных, общих стилей, общих моделек — ничего. И общаюсь с ним только через Intent.
      Но этот кейс редкий и поэтому такой особенный.

  6. upwardteam
    /#19255249

    terrakok
    android:windowSoftInputMode=«adjustResize» — установили

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

    т.е на анимации, что вы показали при сворачивании клавиатуры заголовок не переместился в первоначальное положение:
    для решения проблемы надо переопределить onApplyWindowInsets и вызывать dispatchApplyWindowInsets у child?

    • terrakok
      /#19255271

      да, через onApplyWindowInsets реализуется любое поведение. То есть вы можете просто не передавать инсеты сверху, и фрагмент останется под статус баром

  7. Axrorxoja
    /#19255263

    Спасибо за супер статью :)

  8. ilyamodder
    /#19255551

    Перевести-то вы GitFox на single-activity перевели, вот только приложение лишилось анимаций и появились баги с бэкстеком вроде белого экрана при нажатии кнопки назад на первом экране. И это как бы намекает, что подход не идеален.

    • terrakok
      /#19255587

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

      • ilyamodder
        /#19255693

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

        • terrakok
          /#19255791

          То что приложение без анимаций переходов между экранами работает хуже чем с анимациями — это лично ваше представление о качестве работы приложения.


          GitFox — это не иллюстрация подхода, это мой личный проект "для души". Просто там я это использую.

  9. k0ber
    /#19255807

    У меня была проблема, которую не получилось нормально решить в рамках одной активити: по нажатию на превью картинки, должна проиграться анимация перехода картинки на фулскрин, а по свайпу картинка улетает обратно. Такое поведение есть в настройках телеграмма, когда нажимаешь на свою аватарку. Дело в том, что транзишн на фрагментах не работает, если вызывать метод add, только replace или remove, а поскольку нам нужно, чтобы под полноэкранной картинкой оставался прошлый фрагмент, нам такой вариант не подходит. Есть вариант сделать превьюху в чайлд фрагменте, внутри основного и реплейсить на фуллскрин франмент, тогда анимация работать будет, но если превьюха находится внутри CollapsingToolbarLayout, то контейнер с превьюхой не получиться сделать во весь экран и реплейс будет происходить не так, как мы задумали. В итоге не придумал ничего лучше кроме как использовать отдельную активити для фулскрин галереи. Думаю, в данном случае это нормально, но интересно, можно ли эту задачу решить без дополнительной активити и без создания анимаций вручную.

    • terrakok
      /#19255821

      Как вы правильно заметили: проблема из-за "транзишн на фрагментах не работает, если вызывать метод add". Это косяк стандартных анимаций.
      У себя мы просто сделали сами эту анимацию, а галерея открывается в фулскрин диалоге, так как отдельное Activity несет все проблемы из статьи.

  10. petrovichtim
    /#19255903

    А без фрагментов трудно сделать?

    • terrakok
      /#19255927

      Это не относится к статье, тут каждый решает сам.
      Нам фрагменты на данный момент всем нравятся, никуда уходить не планируем.

  11. dmdev
    /#19255969

    Удивительно, что кто-то еще не пишет приложения в Single-Activity. В любой момент может потребоваться поместить экран в NavigationDrawer, ViewPager или BottomBar. В этом случае без кучи фрагментов в одной Activity не обойтись.

  12. ilitaexperta
    /#19256331

    Насколько всё-таки андроид херово спроектирован. Уже 10 лет прошло с первого релиза системы, а разработчики все еще спорят как делать базовые вещи!

    • terrakok
      /#19256663 / +1

      Заметьте, что это не "андроид херово спроектирован", а все это случилось из-за двух факторов:
      1) открытость системы
      2) отсутствие документации


      Именно из-за этого армия джава разработчиков, за неимением совета в документации "как надо", заглянула внутрь, и каждый стал писать статьи, как он понял надо что-то делать. и понеслась…
      Это обусловлено гонкой за рынок и так далее, но я считаю, что андроид внутри не так уж плохо сделан. Да, проблемы есть, но не такие, как о них постоянно ноют. А такие, которые есть везде. Поверьте, на иОС проблем тоже море, просто сразу объяснено, как делать правильно и не встречать граблей на своем пути.

      • ilitaexperta
        /#19258443

        Нет, это именно что плохо спроектирован.

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

        Системе уже больше 10 лет, а вы до сих пор пишете статьи разного уровня костыльности для решения проблем, которых вообще не должно существовать.

        Если бы проблемы были только в документации, то за 10 лет выработали бы уже best practice, и статьи вроде вашей не было бы необходимости писать. А официального объяснения как сделать правильно нет в первую очередь потому что нормального способа сделать правильно тоже нет, есть только костыли.

        В iOS конечно есть свои проблемы, но поворот экрана там изкоробки, жизненный цикл приоожения прозрачен, а отбработка серевых запросов от сервера не вызываеет вопросов совсем.

  13. QtRoS
    /#19256937

    Интересно получается, что мейнстрим пошел по пути кроссплатформенных фреймворков (например Qt), когда все внутренние страницы приложения живут внутри одной системной Activity. Чем меньше зависимость от поведения системы, тем меньше головной боли, однако каждый пилит свои реализации и user experience в одинаковых задачах отличается. И столько библиотечных возможностей пылится…

  14. kemsky
    /#19257943 / +1

    С фрагентами конечно проще, но в целом писать одно мучение.


    Будем реалистами, зайдем на гитлаб GitFox и посмотрим статистику, сотни комитов, несколько авторов. Установим приложение (на первом же экране кнопка back ведет на пустое место, ошибка в токене никак не сообщается юзеру, просто слетает форма и все), по сути это клиент для части гитлабовского рест апи. На каком-нибудь реакте или ионике или том же новом флаттере это все заняло бы максимум неделю времени: сделать апи клиент, закодить n экранов/списков, без лишних размышлений о потоках, активити, лайфсайклах, листвью, темах, стилях и тп.

    • terrakok
      /#19257965

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

      • kemsky
        /#19258003 / +2

        Вы неправильно меня поняли, я не критикую ваш труд, я говорю о том, что разработка под андроид нативным способом требует слишком много времени.

  15. faq700
    /#19257967

    Замечательная статья, спасибо!

    Немного не понятен один вопрос. Предположим мы берем приложуху с не залоченным экраном, а нормальное полностью адаптивное приложене, обрабатывающие повороты и разные размеры и все что только нужно. Вы предлагаете использовать SinglActivity и увязывать ее жизненный цикл с жизненным циклом приложения. Если я правильно Вас понял, то такой подход возможен только при android:configChanges=«orientation», в противном случае наша активити при повороте будет пересоздаваться.
    Резюмируя — для адаптивных приложении Вы советуете использовать android:configChanges=«orientation»?

    • terrakok
      /#19257977

      Нет, вы неправильно поняли. Прошу, перечитайте еще раз. Там прямо про поворот экрана отмечено "Вот верное решение" и так далее

      • faq700
        /#19258029 / +1

        override fun onResume() {
           super.onResume()
           activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR
        }
        
        override fun onPause() {
           super.onPause()
           activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
        }
        


        Это предложение справедливо для приложения которое в зависимости от экрана либо обрабатывает либо нет поворот.
        А я все же говорю, что мы априори обрабатываем повороты всех экранов всегда.
        т.е. мы все же берем как описано у Вас в манифесте

        <activity android:name=".AppActivity"
        android:configChanges=«orientation» />

        нам же не нужно устанавливать в моем случае флаги SCREEN_ORIENTATION_PORTRAIT и SCREEN_ORIENTATION_SENSOR

        • terrakok
          /#19258063

          Зачем тогда вообще блокировать поворот? Обрабатываете и все.
          Не понимаю..

          • faq700
            /#19258135

            Я так и говорю. Обрабатываем повороты, но если Вы предлагаете увязать жизненный цикл активити к циклу приложения то не обойтись без android:configChanges=«orientation» (я так понял другого варианта нет?), что нам не мешает обрабатывать повороты, но придется в сложных случаях обрабатывать колбэк onConfigurationChanged()

            Просто сейчас реализую Single Activity App со всеми поворотами, но в моем проекте она все же пересоздается при поворотах т.е. параметра android:configChanges в манифесте нет.

            • terrakok
              /#19258169

              А, я понял, хороший вопрос. Ответ такой: в Activity есть системный флаг isFinishing(), который говорит о том срабатывают коллбеки ЖЦ из-за завершения экрана или это просто поворот (пересоздание).

              • faq700
                /#19258179

                Да, спасибо!
                Буду разбираться.

              • vyndor
                /#19258295

                а как насчет retained фрагмента? Будет работать «из коробки» и без магических флагов типа isFinishing.

                • terrakok
                  /#19258361

                  retained фрагменты как работали, так и работают. мы их правда не используем, достаточно Toothpick с простым управлением DI-скоупами

                • faq700
                  /#19258787

                  Наверное retain фрагменты не подойдут, если у нас clear arcitecture or mvvm.
                  По сути retain позволяет сохранять данные во фрагменте но все равно пересоздает UI. А если мы храним данные в другом слое необходимости в этом нет.

                  Если только мы не привязываем один хостовой фрвгмент к ЖЦ приложения для обработки чего либо ранее бывшего в application))(но я так не делал)

                  Вообще странно, что по умолчанию GOOGL не сделал активити android:configChanges=«orientation» а фрагмент retain, в противном случае это похоже уже на костыль против порочной логики пересоздания. Хотя я могу заблуждаться

                  • vyndor
                    /#19259185

                    А если вам не надо сохранять данные или запускать/останавливать сервисы etc., то зачем привязывать ЖЦ activity к ЖЦ приложения?

                    Вообще странно, что по умолчанию GOOGL не сделал активити android:configChanges=«orientation» а фрагмент retain
                    Да нет в этом ничего странного. configChanges=«orientation» не стоит по дефолту, т.к. могут быть разные layout'ы для разных ориентаций. А фрагменты не retained, потому что содержат UI и должны пересоздаваться по такими же правилам, как и activity.

                    • terrakok
                      /#19262085

                      Не понимаю, как при configChanges=«orientation» вы хотите не пересоздать Activity, а только повернуть верстку? Мы обязаны вызвать super метод, а он пересоздаст все.

                      • vyndor
                        /#19262445

                        У нас тут какое-то недопонимание, я не понял что вы сейчас хотели сказать. При повороте окна + configChanges=«orientation» как раз ничего не пересоздаётся.
                        А отвечал я комментатору выше, почему configChanges=«orientation» не стоит по-умолчанию.