Как стать автором
Обновить

Как работает UI в Android. Не все так сложно

Время на прочтение6 мин
Количество просмотров17K

Одна из фундаментальных тем в разработке под Android это работа с UI. Понимание того, как работает UI не даст многого в практическом плане, зато уменьшит вероятность того, что вы натворите полную дичь.

В интернете уже существует куча статей по этой теме и чего-то существенно нового я тут не расскажу. Основная проблема в том, что большая часть этих материалов неоправданно усложнена и новичку довольно сложно их понять. Хотя на самом деле концепции на которых построен UI довольно просты.

Это подтолкнуло сделать эту статью. Это статья должна дать хоть и не исчерпывающее представление о том как работает UI в Android, но простым языком объяснит основные концепции и на каких сущностях он построен.

Looper

Представьте себе бесконечный цикл, старый добрый while(true). Далее представим, что в этом цикле мы читаем из некоторой очереди значения и выполняем их, получается что-то вроде:

val queue: Queue<Runnable> = ArrayDeque()
while (true) {
    val runnable = queue.poll()
    runnable?.run()
}

Это и есть вся суть Looper. Просто бесконечный цикл который получает из очереди сообщения и их выполняет. В реальности Looper делает примерно то же самое только немного усложнённое для оптимизации и поддержки отложенных сообщений.

Чтобы создать Looper, нужно вызвать метод Looper.prepare(). После этого метод Looper.prepare() сохраняет созданный объект Looper в ThreadLocal.

ThreadLocal в упрощении просто ConcurrentHashMap<Thread, T>, другими словами коллекция, в которой ключом является объект потока, а значением любой объект который нам нужен. Этот Map позволяет внутри потока достать объект предназначенный для него в любой момент времени. 

Таким образом можно из любого потока, через статический метод Looper.myLooper() получить лупер, связанный с текущим потоком. При условии, что этот Looper конечно есть в этом потоке.

Далее мы запускаем Looper при помощи метода loop(), он запускает бесконечный цикл, который мы обсудили выше. Отсюда вытекает условие, что Looper у потока может быть только один. Это довольно очевидно, так как метод loop() это бесконечный цикл. Если Looper для потока уже был создан, то метод Looper.prepare() просто упадет.

MessageQueue

Теперь поговорим про следующую сущность MessageQueue. В целом MessageQueue похож на Queue<Runnable> который использовали в примере выше, однако: 

☝️– это не просто очередь из Collection, это отдельный класс, который в коде так и называется MessageQueue

✌️– внутри очереди не просто Runnable, а специальные объекты, которые называются Message

Начнем с класса Message. В классе есть много полей, но нас сейчас интересует только 3: callback, next и when.

Message можно создать через обычный конструктор, однако так лучше не делать. Для создания лучше использовать метод Message.obtain(). Message.obtain() возвращает объект Message из пула. Сделано это для оптимизации, чтобы не тратить память каждый раз когда там нужен Message. В этом пуле может быть максимум 50 сообщений. Если все сообщения пула используются, то Message.obtain() создает и возвращает новый объект Message. 

MessageQueue — простой односвязный список. Если заглянуть в MessageQueue, то увидим, что там просто одно поле mMessages типа Message. У каждого Message есть ссылка на следующее сообщение Message.next. Другими словами, MessageQueue хранит только ссылку на первое сообщение. 

Сообщения в MessageQueue отсортированы по возрастанию значения поля when. Looper в своем цикле получает уже отсортированное сообщение, которое нужно обработать. Если очередь пуста, метод MessageQueue.next() блокирует цикл до тех пор, пока не появится какое-нибудь сообщение. Может быть ситуация, что очередь не пуста, но сообщения не обрабатываются, так как еще не пришло их время. 

Я думаю сейчас уже понемногу должна складываться картина того, каким образом все это работает. Мы разобрали что такое Looper, что такое Message и MessageQueue. Возникает вопрос, как именно послать задачу в Looper, так как прямого доступа к MessageQueue у нас нет. В этот момент на сцену выходит Handler.

Handler

Handler это прослойка которая позволяет позволяет отправить сообщения через MessageQueue в Looper. Другими словами Handler предоставляет удобный API для отправки Message в Looper. Фактически это единственный способ как это сделать.

Для того чтобы создать Handler нужно получить Looper потока. Как это сделать уже обсуждали выше. Дальше просто создаем Handler через обычный конструктор:

val looper = requireNotNull(Looper.myLooper())
val handler = Handler(looper)
handler.post { doSmth() }

В этом примере мы создаем Runnable с вызовом метод doSmth, далее Handler обернет этот Runnable в Message и отправит в Looper который его выполнит.

 Прежде чем начнем разбирать Handler глубже введем 2 понятия: 

☝️– поток Consumer: ждет сообщения и выполняет их, другими словами тот который вызывал loop(). 

✌️– поток Producer: создает сообщения и посылает их потоку Consumer через Handler. 

Когда мы говорим про Handler главного потока, чаще всего Consumer и Producer это один и тот же поток. Если Looper у потока только один, то объектов Handler может быть сколько угодно. Сам Handler ничего не знает про потоки, его единственная задача отправлять сообщения в Looper. Handler просто на всего лодочник. Именно этот факт позволяет нам отправлять сообщения и в Looper текущего потока и в Looper другого потока.

В статьях для начинающих есть пример того, что из фонового потока можно изменить View через вызов метода runOnUiThread у Activity. Думаю, вы уже догадались, что этот метод делает под капотом. Да, просто навсего отправляет Runnable через Handler в Looper главного потока. 

Интересный факт, у View есть свой собственный Handler. Можете сами проверить взяв любую View, и вызывав у нее метод post(). У каждого Android разработчика в карьере есть история про то, как он поправил баг просто отложив выполнение какого-то метода View. Происходит это примерно так:

view.postDelayed({ view.doSmth() }, 100)

Это конечно утрировано, и лучше в продакшен коде так не делать, но часть правды в этом есть.

Теперь немного практики, давайте посмотрим на следующий кусок кода:

fun onCreate() {
    Handler().post {
        Log.d("tag", "hello from handler")
    }
    Log.d("tag", "hello from onCreate")
}

Попробуйте ответить сами, что будет выведено в лог? В логах мы увидим следующую последовательность:

  1. “hello from onCreate thread main”

  2. “hello from handler thread main”

Когда вызываем метод post, переданный Runnable оборачивается в Message и отправляется на Looper потока. Следовательно, задача которую мы отправляем в Handler выполнится после того, как завершится метод onCreate().

Теперь следите за руками. Все методы жизненного цикла активности, фрагмента, view, методы Broadcast Receiver, всех компонентов приложения система вызывает через Looper потока Main. Метод onCreate() который мы использовали для примера вызывался примерно так:

val activity = getCurrentActivity()
val handler = Handler(Looper.getMainLooper())
handler.post {
    activity.onCreate()
}

Этот пример ахренеть какое сильное упрощение того, что происходит на самом деле, но суть остается та же. Именно этим фактом, обуславливается асинхронность в UI. 

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

Заключение

После прочтения может возникнуть вопрос, а зачем все это нужно знать и как это используется на практике? В статье я уже приводил пример с транзакциями и отложенным вызовом метода на View. 

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

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

В чем крутость этого подхода? Модель построенная на очереди и сообщениях позволяет откладывать выполнение задачи, отменять не нужные задачи и самое важное: эта модель позволяет делать GUI в одном потоке.

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

Было очень много попыток крутых инженеров сделать GUI систему, которая бы работала в разных потоках. Спойлер, ни у кого не получилось, потому как сложность такой системы просто запредельная и сделать dead lock в такой системе как два байта…

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

Теги:
Хабы:
Всего голосов 8: ↑7 и ↓1+6
Комментарии2

Публикации

Истории

Работа

Ближайшие события

2 – 18 декабря
Yandex DataLens Festival 2024
МоскваОнлайн
11 – 13 декабря
Международная конференция по AI/ML «AI Journey»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань