Pull to refresh
332.1
TINKOFF
IT’s Tinkoff — просто о сложном

Как работает Activity. Часть 1

Reading time12 min
Views19K

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

Вся эта магия происходит благодаря тому, что Android предоставляет фреймворк с базовыми компонентами. Мы не управляем этими компонентами, а лишь можем реагировать на колбэки, которые есть во фреймворке. Процессом приложения управляет система. 

В первой части совсем немного расскажу про Binder, про то, как происходит запуск Activity, как стартует процесс приложения и как на вызов Activity влияют флаги и launch mode. Во второй части будет про то, как вызываются методы жизненного цикла Activity, что происходит при сворачивании приложения, и более подробно расскажу про старт первой Activity.

Все начинается с Binder

Binder — фреймворк для построения межпроцессной коммуникации. Сам Binder работает на уровне ядра системы. Он использует набор низкоуровневых функций ОС, позволяющих обмениваться данными между процессами так, будто у них есть общая память.

Фишка в том, что он позволяет описать интерфейсы на специальном языке AIDL, Android Interface Definition Language, — что-то вроде суперурезанной Java. А потом при помощи кодогенерации сгенерировать классы Java. Дальше вызываются сгенеренные методы Java-классов, как будто все происходит в одном процессе. На самом деле данные будут ходить между процессами
Фишка в том, что он позволяет описать интерфейсы на специальном языке AIDL, Android Interface Definition Language, — что-то вроде суперурезанной Java. А потом при помощи кодогенерации сгенерировать классы Java. Дальше вызываются сгенеренные методы Java-классов, как будто все происходит в одном процессе. На самом деле данные будут ходить между процессами

Знать про Binder нужно, чтобы разобраться во всем, что будет дальше. Про саму технологию много есть в телеграм-канале Android Easy Notes и на Хабре:

Система Android работает на базе ядра Linux, пусть и сильно изменившегося. Это значит, что так или иначе приложения работают в разных процессах. Мы не контролируем создание процесса приложения и управление им. Вся сложность скрывается за фреймворком, который любезно предоставляет команда Google. 

Работа проходит в разных адресных пространствах, нельзя влезть в другой процесс и дернуть методы какого-то класса. Задача сложная, когда появляется много межпроцессных вызовов. 

Нужен был инструмент, который позволил бы разработчикам фреймворка легко реализовывать межпроцессное взаимодействие. И разработчики Android создали Binder.

Как стартует первая Activity

Все начинается с лаунчера. Лаунчер — первое приложение, которое запускается системой. Он отличается от обычного приложения тремя вещами:

1️⃣ Главной Activity лаунчера устанавливается категория HOME, благодаря чему система будет вызывать эту Activity при нажатии на кнопку Home.

<category android:name="android.intent.category.HOME" />

2️⃣ Всем Activity лаунчера нужно выставить флаг excludeFromRecents, чтобы они не мелькали в recents.

<activity
   android:name=".MainActivity"
   android:excludeFromRecents="true"

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

Основная задача лаунчера — вывести красивый список других приложений. Получить список приложений можно несколькими путями, но самый простой — через сервис LauncherApps. 

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

Можно запустить Activity приложения через обычный Intent, но хороший лаунчер должен учитывать рабочий профиль. Поэтому запуск первой Activity желательно делать при помощи все того же системного сервиса LauncherApps. 

launcherApps.startMainActivity(
   сomponentName, // название пакета приложения
   userHandle, // пользователь, из которого нужно запустить приложение
   null, // указание координат иконки нашего приложения
   options // bundle для всяких допнастроек типа варианта анимации
)

У сервиса есть специальный метод для запуска первой Activity. Он заставляет указать пользователя, с которого нужно запустить приложение, а еще передать данные для красивой анимации запуска приложения от иконки до сплеша. 

val options = ActivityOptions.makeScaleUpAnimation(
    view, // изображение иконки приложения 
    0, // старовая позиция X анимации относительно переданной иконки
    0, // старовая позиция Y анимации относительно переданной иконки
    view.measuredWidth, // изначальная ширина будущей Activity
    view.measuredHeight // изначальная высота будущей Activity
)

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

Все дороги ведут в ActivityStarter

После запуска специального метода через ActivityManagerService приходим в ActivityStarter. По названию очевидно, что класс отвечает за старт Activity. Каждый раз, запуская Activity, мы будем приходить сюда.

ActivityStarter — интересный класс, его API отдаленно напоминает запрос из OkHttp. Очень большой билдер, через который задаются настройки, и все запускается через метод execute.

В ActivityStarter передается куча параметров, и один из них — сам Intent запускаемой Activity. ActivityStarter из этого Intent вытаскивает объект ActivityInfo — класс, который описывает все, что мы прописываем про Activity в Manifest.

class ActivityInfo(
   val theme: Int,
   val launchMode: Int,
   val taskAffinity: Int
   /*...*/
)

На основании ActivityInfo создается ActivityRecord — класс, который является репрезентацией Activity. Он описывает состояние Activity и позволяет системе принимать решения о том, какой метод жизненного цикла нужно вызвать, исходя из этого состояния. 

class ActivityRecord(
   val packageName: String,
   val activityInfo: ActivityInfo,
   val task: Task, //Task, в котором запущена (будет запущена) Activity
   /*...*/
)

В классе больше информации, чем в ActivityInfo: например, в каком Task запущена Activity, в каком статусе жизненного цикла она находится и запущен ли вообще процесс этой Activity.

Про ActivityRecord обычно знают, если на собеседовании прилетал вопрос: «А где же хранятся ViewModel?» Это не те ActivityRecord. ViewModel хранятся в ActivityClientRecord, которые создаются только в рамках приложения, что-то вроде локальной версии ActivityRecord, о которых мы сейчас говорим.

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

После того как ActivityStarter создал ActivityRecord, он пытается запустить его. Тут два варианта:

  1. Процесс уже создан, и нужно лишь отправить Activity в этот процесс.

  2. Процесса нет, и для начала нужно создать этот процесс.

Сниппет кода, как это происходит:

fun startSpecificActivity(r: ActivityRecord, andResume: Boolean, checkConfig: Boolean) {
   // Is this activity's application already running?
   val wpc = activityTaskManagerService
       .getProcessController(r.processName, r.info.applicationInfo.uid)
   /*..*/
   if (wpc != null && wpc.hasThread()) {
        realStartActivityLocked(r, wpc, andResume, checkConfig)
        /*..*/
   } else {
        /*..*/
        activityTaskManagerService.startProcessAsync(r)
   }
}

Через много вложенных методов, с проверками и настройками приходим в статический метод из класса ZygoteProcess — startViaZygote. Используем этот метод, чтобы подать сигнал Zygote о том, что нужно запустить новый процесс.

Краткое описание Zygote и fork

ZygoteInit каждый видел в стеке «вызов функций». Независимо от того, что у вас за приложение, стартовать оно всегда будет отсюда. 

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

Если погрузиться в череду вызовов системных функций, то при старте системы запускается файл init.rc. В нем описано, как запускать все процессы-демоны, сервисы и остальные элементы системы.

Разработчики Android сделали систему (Android Init Language), которая при помощи специальных инструкций описывает, как именно и в каком порядке нужно запустить все системные сервисы. Этот файл различается для разных версий ОС и напоминает работу с yml-файлами при настройке CI. Там мы тоже описываем команды, а также триггеры, когда эти команды вызывать. Вот конфигурация старта zygote:

service zygote /system/bin/app_process -Xzygote /system/bin --zygote --start-system-server
   class main
   priority -20
   …

Service — специальная команда (Android Init Language), которая запускает некоторый сервис. Только это не тот сервис, который в Android Framework, а просто программа на низкоуровневом языке. В нашем примере запускается app_process.

int main(int argc, char* const argv[]){
   /*...*/
   AppRuntime runtime(argv[0], computeArgBlockSize(argc, argv));
   /*...*/
   if (zygote) {
       runtime.start("com.android.internal.os.ZygoteInit", args, zygote);
   } else if (!className.isEmpty()) {
       runtime.start("com.android.internal.os.RuntimeInit", args, zygote);
   } else {
       fprintf(stderr, "Error: no class name or --zygote supplied.\n");
       /*...*/
   }
}

app_process — небольшая программа на C++, основная задача которой — запустить JVM и в частности main-функцию ZygoteInit.

С этого начинается работа Zygote. Сначала подгружаются все классы для работы JVM и все Android-зависимости, необходимые для работы
С этого начинается работа Zygote. Сначала подгружаются все классы для работы JVM и все Android-зависимости, необходимые для работы

После загрузки классов запускается ZygoteServer, который запускает бесконечный цикл, и начинается самое интересное.

class ZygoteInit {

   fun main(argv: Array<String>) {
       var zygoteServer: ZygoteServer? = null;
       /* Загрузка всех необходимых классов */
       var caller: Runnable? = null
       /* ... */
       zygoteServer = ZygoteServer(isPrimaryZygote)
       // Запускаем бесконечный цикл, родительский процесс дальше не двигается
       // Созданные процессы будут выходить из этого цикла и идти дальше
       caller = zygoteServer.runSelectLoop(abiList)
       /* ... */
       // Тут мы уже находимся в новом созданном процессе и выполняем переданную команду
       caller.run()
   }
}

Эта магия системного программирования, которую очень редко можно встретить в обычных проектах. Объясню на примере кода на низкоуровневом языке. C++, на мой взгляд, должен отправиться на полку истории, поэтому модно-молодежно черканем пару строк на Rust:

fn main() {
   let fork_result = unsafe { fork() };
   match fork_result {
       Ok(ForkResult::Parent { child }) => {
           println!("I'm a parent process, pid: {}, child pid {}", process::id(), child);
       }
       Ok(ForkResult::Child) => println!("I'm a new child process, pid: {}", process::id()),
       Err(_) => println!("Fork failed"),
   };
}

В Unix есть системный вызов, который называется fork. Он берет ваш процесс, копирует все состояния переменных, потоки, стеки, хип, переносит в отдельное место в оперативной памяти и запускается как отдельный процесс.

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

/*...*/
val command = Os.poll()
/*...*/
val caller = Zygote.forkUsap(mUsapPoolSocket, sessionSocketRawFDs, isPriorityRefill)
/*...*/

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

Android и так не всегда быстрый, а если бы при каждом старте еще нужно было каждый раз подгружать все нужные классы, было бы совсем грустно. Поэтому разработчики придумали копировать инстанс JVM с уже загруженными классами. Отсюда и смесь кода на Java и системных вызовов. Zygote можно воспринимать как кэш с загруженными классами для любого нового процесса приложения.

Зачем нужно вообще на каждый старт приложения запускать новый процесс, почему на одной JVM все приложения не запускать? 

Основная причина — безопасность. Когда приложение работает, хочется быть уверенным, что в его память никто не сможет залезть. И если мы работаем в рамках одного процесса, такой безопасности никто гарантировать не может.

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

Для решения этой проблемы каждое приложение запускается в отдельном процессе, и эта система называется sandbox.

Как подать сигнал Zygote для старта нового процесса ​​

ZygoteServer называется сервером не просто так, он буквально слушает сокет. Тот самый джавовский сокет, только не совсем обычный.

В Unix-системах есть Unix Domain Socket (UDS) — специальная реализация сокета, которая не использует сетевую карту и предназначена именно для передачи данных между двумя процессами на одной машине

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

Запуск последующих Activity

Activity запускаются в Task — это стек, в который складываются Activity при запуске. Стек фрагментов делался как копия Task, поэтому работают они примерно одинаково.

Таких Task у приложения может быть несколько. По дефолту все Activity запускаются в одном Task. Но у Activity есть специальный атрибут, который позволяет указать, в каком Task должна запускаться Activity. Этот атрибут называется taskAffinity.

<activity
   android:name="..."
   android:taskAffinity="{applicationId}”/>

Прописываем какую-то уникальную строку, и Activity будет запускаться не в стандартном Task, а в другом. Activity с одинаковыми taskAffinity запускаются в одном Task. В лаунчере со списком запущенных приложений эти Task будут разными, в recent Task будут отображаться как два отдельных приложения.

Работа с определением, в какой Task нужно отправлять Activity, происходит в ActivityStarter. В Android есть класс, который называется Task, а в ActivityStarter есть метод, который определяет, в какой именно Task будет отправлена Activity, исходя из флага или настройки taskAffinity.

На практике этот флаг красиво использует LeakCanary. Библиотека подсовывает свои Activity таким образом, что кажется, будто вам внедрился целый кусок другого приложения. Подробнее об этом я писал в телеграм-канале.

Еще из интересного, есть атака через этот атрибут, которая называется Android Task Hijacking. Если кратко, можно сделать так, чтобы Activity нашего приложения запускалась в Task другого приложения, ну и, соответственно, можно, например, встроить рекламу ставок на спорт туда, где этого ну вообще не ожидали. Будьте аккуратнее с флагом singleTask! 

Теперь обсудим launch mode. Есть четыре основных launch mode и куча различных флагов, которые мы можем передать в Intent. Разбирать, какие есть флаги, нет смысла, потому как их прямо много, а про launch mode можно отдельно прочитать тут.

<activity
   android:name="..."
   android:launchMode="singleInstance|singleTop|singleTask|default"/>

Работа с ними осуществляется все в том же ActivityStarter, для этого есть отдельный метод. По факту вся работа дальше происходит только с флагами, все launch mode, которые мы указываем в манифесте, дальше превращаются во флаги. В зависимости от флага ActivityStarter настраивает, в какой Task нужно отправить Activity, нужно ли удалять предыдущие и прочее.

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

Когда мы хотим показать новую Activity, мы вызываем startActivity. Но он не сразу идет в системный сервис, а сначала вызывает метод mInstrumentation.execStartActivity. 

Класс Instrumentation в основном используется в тестах, по сути своей это hook, который позволяет перехватить методы показа Activity и еще много чего.

В обычной реализации execStartActivity передает вызов в ActivityTaskManagerService, который отправляет вызов в уже известный нам ActivityStarter. Как видите, нет особой разницы, запускаем ли мы сами Activity или ее запускает система.

Однажды я встретил вопрос: «Для чего нужно обозначать наши Activity в манифесте? Скажем, вот пример с BroadcastReceiver, мы можем его назначить динамически, можно ли так сделать с Activity?»

Ответ на этот вопрос кроется в логике работы ActivityStarter. Как он может узнать о том, как ему запускать Activity, если приложение еще ни разу не было запущено? ActivityStarter получает данные о том, какую Activity ему нужно запустить и как именно ее нужно запустить, из PackageManager.

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

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

Вместо заключения

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

Очень многое сделано на базе клиент-серверной архитектуры, в которой запрос отправляется при помощи Binder. Вы наверняка часто встречали, что при помощи метода getSystemService мы почему-то получаем какой-то Manager. Самый банальный пример:

val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager

И вот, если вдуматься в синтаксис, абсурд же происходит! Вроде достаем сервис, а получаем менеджер. Просто нужно понимать, что этот Manager — это клиент для работы с каким-то системным сервисом.

Системный сервис чем-то похож на привычный нам сервис, только работает он всегда и в другом отдельном процессе. 

Manager — это клиент, Service — сервер. Такая архитектура позволяет как угодно менять Service, делать его вложенным классом, затаскивать в другое место — не имеет никакого значения. Главное, что, если не меняется интерфейс AIDL, клиент также не будет меняться.

Эта же концепция действует и при работе с Activity. Мы просто отправляем запрос в приложение посредством Binder, а приложение уже само решает, как именно ему нужно показать Activity.

Если вам понравилась статья и иллюстрации к ней, подписывайтесь на мой телеграм-канал. Я пишу про Android-разработку и Computer Science, на канале есть больше интересных постов про разработку под Android. А еще залетайте на наш ютуб с подборкой про Android и слушайте подкаст «Кем ты стал» о том, как превратить работу в личный бренд.

Tags:
Hubs:
+11
Comments0

Articles

Information

Website
www.tinkoff.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия