В прошлой статье я описал, как стартует процесс нашего приложения, что такое ActivityStarter и как стартуют все Activity.
Во второй части расскажу, как показываем сплеш-скрин, что такое Window, что происходит перед первым показом Activity приложения, более подробно, как вызываются методы жизненных циклов Activity и что происходит с Activity при сворачивании и разворачивании.
Как показать сплеш-экран
Вернемся в момент, когда запущено создание нового процесса для нашего приложения. Параллельно этому пользователь видит сплеш-экран. Он нужен, чтобы показать пользователю что-то, пока стартует приложение, а стартовать оно может долго.
Даже если экран сплеша не настроен, он все равно появится. Чтобы понять, как работает сплеш, нужно знать про Window. Window — это прозрачный прямоугольник. У каждого Window есть свой Surface — набор пикселей, на котором происходит отрисовка всех View.
Window на экране может быть несколько, например статус-бар рисуется в своем Window, наши Activity — в другом, NavigationBar — в третьем.
Как отрисовать View: берем Activity, у которой есть Window. У Window есть корневая View, к которой прикрепляются уже все наши ViewGroup и View.Когда View нужно перерисоваться, она вызывает метод invalidate — по иерархии поднимается наверх.
Window вызывает метод lock у Surface, и тот в ответ выдает Canvas. Мы что-то рисуем на этом Canvas, затем Window вызывает метод unlockAndPost у Surface, и тот отправляет наш Canvas в буфер на прорисовку.
Вернемся к сплешу. За его показ отвечает специальный класс Starting Surface Controller, который запускает ActivityStarter из прошлой части.
Сплеш работает по такой схеме: у системы всегда есть доступ к теме конкретной Activity, которую мы сейчас запускаем. Тему берем из специального класса ActivityInfo. Система вытаскивает эту тему, затем создает Window, после создает FrameLayout и запихивает его в только что созданную Window. Ну а после того, как показывается первая Activity, Window сплеша удаляется — и мы видим уже Window первой Activity.
fun addSplashScreenStartingWindow(/* ... */) {
/* ... */
// replace with the default theme if the application didn't set
val theme: Int = getSplashScreenTheme(windowInfo.splashScreenThemeResId, activityInfo)
context.setTheme(theme)
/* ... */
val rootLayout = FrameLayout(mSplashscreenContentDrawer.createViewContextWrapper(context))
rootLayout.fitsSystemWindows = false
/* ... */
/* Вот тут и показывается Splash путем добавления нового окна */
addWindow(taskId, appToken, rootLayout, display, params, suggestType)
/* ... */
}
Сплеш показывается в отдельном Window, поэтому важно работать с ним именно через настройки темы первой Activity. Если просто показать картинку в первой Activity, это не будет сплешем. В таком случае это будет просто windowBackground, который, скорее всего, белый, и выглядит это так себе.
Правильный сплеш получается так:
1️⃣ создается специальная тема с атрибутом windowBackground под нужный цвет или drawable;
2️⃣ эта тема устанавливается в манифесте;
3️⃣ после старта Activity в методе onCreate тема меняется на основную.
И вуаля, все будет работать красиво.
Начиная с Android 12 все стало немного проще. Google зарелизил специальное API для показа сплеша, которое позволяет прикрутить разное кастомное поведение, сделать его анимированным, подержать подольше. Правда, именно анимированным сплеш получится сделать только с Android 12.
Если не брать в расчет анимацию, то Splash API не делает какой-то магии. Что раньше, что сейчас, нужно правильно сконфигурировать тему для сплеш-экрана. Splash API предоставляет свою тему с более очевидными параметрами. Помимо этого, Splash API переключает тему сплеша на тему нашего приложения самостоятельно. Раньше это переключение делали руками, сейчас немного удобнее.
Старт первой Activity приложения
Привычно, что у приложений, которые мы пишем, нет статической функции main. Когда я впервые встретил проект под Android, был слегка потерян: непонятно, как контролировать процесс запуска без main.
На самом деле функция main есть, только она скрыта от пользователя фреймворка, потому что запуском процесса управляет система и незачем клиентам фреймворка лезть туда самостоятельно.
А сейчас — внимание! — будет много кода и объяснения, как это все работает.
Метод main скрыт в классе ActivityThread — именно с него начинается любое приложение в системе. В этом методе инициализируется главный Looper, и потом вызывается метод attachApplication у ActivityManagerService. Этим мы сообщаем сервису, что стартанули приложения и нам сейчас нужна помощь с Activity.
class ActivityThread {
/* ... */
fun main(arg: Array<String>) {
/* ... */
Looper.prepare()
/* ... */
attachApplication(applicationThread)
}
}
Потом ActivityManagerService настраивает приложение. Например, вещи, связанные с debug. В настройках разработчика можно сделать так, чтобы приложение при запуске ждало дебагер и только потом двигалось дальше. За такое ожидание дебагера тоже отвечает ActivityManagerService.
class ActivityThread {
private val applicationThread = ApplicationThread()
inner class ApplicationThread : IApplicationThread.Stub {
/* Это метод дергает ActivityManagerService */
fun bindApplication(data: Data) {
val message = Message.obtain();
/* Отправляем сообщение в Looper главного потока */
Looper.getMainLooper().sendMessage(message)
}
}
fun main(arg: Array<String>) {
/* ... */
Looper.prepare()
/* ... */
attachApplication(applicationThread)
}
}
После настроек ActivityManagerService вызываем метод ApplicationThread, потому что при вызове метода attachApplication мы передали объект нашего ApplicationThread и его можно вызвать при помощи Binder.
После этого в методе main ничего не происходит, так как запустился Looper — бесконечный цикл.
/* Этот метод дергает ActivityManagerService */
fun bindApplication(data: Data) {
val message = Message.obtain();
/* init message */
Looper.getMainLooper().sendMessage(BIND_APPLICATION, message)
}
Looper главного потока выполняет сообщение BIND_APPLICATION, которое просто вызывает еще один метод для настройки. И на этом все, потому что в Looper главного потока больше нет сообщений.
На этом заканчивается конфигурация приложения. Но нужно запустить первую Activity. Чтобы понимать, как это происходит, нужно знать, как вообще работает жизненный цикл Activity, кто вызывает методы Activity и как они создаются.
Как система вызывает методы жизненного цикла
Система напрямую не вызывает методы ЖЦ Activity, потому что они обязаны происходить в главном потоке. Единственный вариант, как вызывать методы ЖЦ из главного потока, — отправлять специальные сообщения в Lopper самого потока. Сообщения, в которых описывалось бы, что за Activity и какой метод ЖЦ нужно вызвать.
Система создает специальный объект — транзакцию, в которой есть вся информация, перечисленная выше. Такая транзакция существует для каждого метода жизненного цикла Activity. Затем транзакция через IPC отправляется в TransactionExecutor, который работает на стороне нашего приложения.
/* Объект транзакции для перевода Activity в статус onStop */
class StopActivityItem : ActivityLifecycleItem {
override fun execute(/* ... */) {
/* ... */
client.handleStopActivity()
}
}
TransactionExecutor — обработчик транзакций. Он получает транзакцию, затем на основе информации из транзакции создает сообщение, которое отправляет в Lopper главного потока. После чего уже Looper вызывает нужный метод нужной Activity.
Для создания Activity существует специальная транзакция. Пока главный поток настраивался, ActivityManagerService подготавливал первую Activity к запуску. На основе информации, полученной из ApplicationThread, ActivityManagerService знает о том, какую именно Activity нужно запустить. И, сформировав нужную транзакцию, отправляет ее в приложение.
class ActivityThread {
/* Дергает методы конкретных Activity */
private val transactionExecutor = TransactionExecutor()
private val handler = ActivityThreadHandler(Looper.getMainLooper())
inner class ApplicationThread : IApplicationThread.Stub {
/* ... */
/* Этот метод дергают разные системные сервисы */
fun scheduleTransaction(data: Data) {
val message = Message.obtain();
/* Собираем сообщение с транзакцией */
Looper.getMainLooper().sendMessage(EXECUTE_TRANSACTION, message)
}
}
inner class ActivityThreadHandler : Handler {
override fun handleMessage(msg: Message) {
when (msg.what) {
BIND_APPLICATION -> bindApp(msg.obj)
EXECUTE_TRANSACTION -> transactionExecutor.execute(msg.obj)
}
}
}
}
И вот наше приложение получает первую транзакцию с информацией о том, какую Activity запустить. Если всегда было интересно, как именно создается объект Activity, — вот описание. Ничего сложного, из полученного Intent достается имя класса и через рефлексию создается объект.
Ну а дальше само приложение вызывает методы ЖЦ Activity в зависимости от статуса показа Activity. В большинстве случаев методы ЖЦ Activity берет само приложение, другими словами, они не вызываются системными сервисами. Например, когда приложение получает сигнал о том, что нужно свернуться, оно само должно вызвать методы onStop у тех Activity, что мы видим на экране.
Еще в системе есть куча различных сервисов, которые знают о наших Activity через ActivityRecord и решают, у какой Activity какой ЖЦ вызывать.
Activity во время сворачивания приложения
Есть специальный системный сервис, который слушает кнопку Home или жест смахивания. Когда это происходит, он анимирует сворачивание активного приложения и показывает Activity нашего лаунчера. Мы не выходим из приложения — просто наша Activity заменяется на Activity лаунчера.
С точки зрения вызовов ЖЦ ничего не меняется, вызываются они также через транзакции.
За показ старта приложения отвечает Starting Surface Controller. Интересен этот класс тем, что помимо показа сплеша при старте приложения он запоминает, как выглядит приложение при сворачивании, чтобы потом показать его в Recents.
У ActivityRecord для этого есть специальный метод. Работает это так: система понимает, что нужно свернуть приложение, достает ActivityRecord той Activity, которая сейчас есть на экране, и вызывает специальный метод, чтобы ActivityRecord при помощи Starting Surface Controller запомнил, как выглядела Activity до сворачивания.
Поэтому в Recents мы видим не текущее состояние Activity, а в некотором смысле только фото того, как она выглядела перед сворачиванием. Ну и при разворачивании приложения система рисует анимацию, основываясь на этой информации.
Вместо заключения
В двух частях мы разобрали основы того, как работают Activity, как стартует процесс приложения, как вызываются методы жизненного цикла и как работает сплеш-экран.
Концепции, которые используются в Android, не уникальны и взяты из других систем. Например, легко догадаться, откуда взяли Window. Идея аналогичная, у нас есть абстракция, которая позволяет управлять размером окна приложения и количеством этих окон, например, для такой фичи, как Split Screen.
Создание процесса реализовано при помощи идей из Unix. Концепции Looper MessageQueue и Handler перетекли из Java-фреймворка Swing, который работает по аналогичной схеме. Binder базируются на идеях System V IPC, который также идет из Unix.
Знание фундаментальных концепций помогает быстро разбираться в том, как устроен тот или иной фреймворк или библиотека. Фреймворки и библиотеки появляются, взлетают и также быстро деприкейтятся (привет, Google!), а концепции очень медленно устаревают и редко придумываются действительно уникальные.
Если вам понравилась статья и иллюстрации к ней, подписывайтесь на мой телеграм-канал. Я пишу про Android-разработку и Computer Science, на канале есть больше интересных постов про разработку под Android. Еще у нас есть ютуб-канал, где мы публикуем интересное про мобильную разработку.