Основой любого приложения является его главный поток. На нем происходят все самые важные вещи: создаются другие потоки, меняется UI. Важнейшей его частью является цикл. Так как поток главный, то и его цикл тоже главный - в простонародье Main Loop.
Тонкости работы главного цикла уже описаны в Android SDK, а разработчики лишь взаимодействуют с ним. Поэтому, хотелось бы разобраться подробней, как работает главный цикл, для чего нужен, какие проблемы решает и какие у него есть особенности.
Это третья и финальная часть разбора главного цикла в Android. В первой части мы разобрались с тем, что такое главный цикл и как он работает. Во второй, как это работает в Android SDK в Java слое. В этой части мы посмотрим на особенности Looper в C++, поверхностно пробежимся по Flutter, Chrome и React Native. А еще есть игры — в них вообще все с ног на голову.
Тут мы начинаем заходить на окраину территории моих знаний, поэтому если где-то будут неточности, то обязательно поправляйте меня в комментариях. Также из-за этого в статье будет много ссылок, чтобы было что почитать на досуге.
По всем правилам приличия представлюсь — меня зовут Перевалов Данил, а теперь давайте перейдём к теме.
Looper в C++ слое
Общая логика
В предыдущей части я упомянул, что Looper из Java вызывает Looper из C++. Как можно догадаться, в слое C++ есть свой Looper который отвечает за сам цикл и его работу, своя очередь сообщений и свой Message. Далее в рассуждениях я буду отталкиваться от того, что вы прочли предыдущие части статьи и/или понимаете общую логику работы главного цикла и его особенности в Java слое Android SDK.
В итоге у нас есть два Looper, а значит, нужно как-то передавать управление C++ слою. Для этого в Java слое вызывается метод nativePollOnce. Каждый раз когда в MessageQueue мы пытаемся найти следующее сообщение, сначала вызывается nativePollOnce. В этот момент наступает очередь Looper из C++ обрабатывать сообщения, и он вернет управление в Java слой лишь тогда, когда он закончит обрабатывать все свои текущие сообщения. Важно понимать, что оба этих Looper работают в одном потоке — MainThread, то есть у нас имеется две очереди основных событий на один поток. Следовательно, если «заспамить» очередь из C++, то очередь из Java вообще не будет продвигаться. Получается следующая схема:
Интересно, что с точки зрения кода Looper из C++ сочетает в себе и логику Looper и логику MessageQueue. Да и в целом написан не так аккуратно, как его собрат из Java слоя. При желании можете удостовериться в этом сами, посмотрев его исходный код с заголовочным файлом. По этой причине, код я прикладывать особо не буду, но все же рассмотрим избранные куски.
Для начала начнём с самого сообщения — класса Message. Оно здесь сделано настолько просто, насколько это вообще было возможно. По сути у нас есть только одна переменная what и два конструктора.
struct Message {
Message() : what(0) { }
Message(int w) : what(w) { }
int what;
};
А как же тогда указать в сообщении, что оно должно делать? Для этого в переменную what надо записать идентификатор действия, которое мы хотим выполнить. Затем обработчик считает этот идентификатор и выполнит соответствующее действие. В качестве обработчика выступают наследники MessageHandler.
class MessageHandler : public virtual RefBase {
protected:
virtual ~MessageHandler();
public:
virtual void handleMessage(const Message& message) = 0;
};
У MessageHandler есть всего один виртуальный (считай абстрактный) метод, который мы и должны переопределить. Внутри него мы можем считать идентификатор и выполнить действие, которое к нему предписано. В целом если делать по отдельному MessageHandler на каждый Message, то всеми премудростями с идентификатором можно и не заниматься. Есть сообщение и есть его обработчик, который всегда выполняет одно и тоже действие.
Но как нам теперь связать сообщение с его обработчиком? Для этого существует MessageEnvelope. В нем и находятся:
экземпляр Message с которым познакомились выше;
StrongPointer на MessageHandler, являющийся указателем с встроенным счетчиком ссылок, чтобы мы могли, без лишних проблем, переиспользовать один экземпляр MessageHandler для нескольких сообщений;
uptime для хранения времени со старта процесса. Она несет в себе такой же смысл как и when в Message из Java, то есть это время в которое надо выполнить сообщение. Очень полезно для отложенных операций.
struct MessageEnvelope {
MessageEnvelope() : uptime(0) { }
MessageEnvelope(nsecs_t u, sp<MessageHandler> h, const Message& m)
: uptime(u), handler(std::move(h)), message(m) {}
nsecs_t uptime;
sp<MessageHandler> handler;
Message message;
};
По сути, создав объект MessageEnvelope мы свяжем наше сообщение с его обработчиком. Такой объект уже можно, без зазрения совести, класть в очередь сообщений. Кстати о ней.
Она представлена обычным классом Vector, который по сути является улучшенным одномерным массивом. Как я упоминал выше - хранится эта очередь в самом Looper в переменной mMessageEnvelopes.
Vector<MessageEnvelope> mMessageEnvelopes;
В основной логике Looper из C++ похож на своего собрата из Java слоя, но у него есть один нюанс, который мы еще не рассмотрели. Если Looper из Java вместо ожидания передает управление в Looper из C++, то что же делает последний, когда сообщения в очереди закончились и надо действительно ждать? Передавать управление более нижнему слою уже нельзя, мы на самом дне. Надо действительно ждать.
Ожидание
Для начала давайте посмотрим чуть подробнее на wait и notify из Java. Каждый объект в Java имеет свой монитор. При вызове wait у монитора произойдет перемещение текущего потока в wait set монитора. Пока поток находится в wait set он «спит». При вызове notify или notifyAll пробуждается один или все потоки, которые сейчас находятся в wait set монитора.
В С++ похожее поведение реализуют condition_variable, но в Looper используется другой подход - связка epoll + eventfd. В отличии от wait-notify/condition_variable которые предоставляют синхронизацию только внутри процесса, epoll + eventfd предоставляет синхронизацию как внутри процесса, так и между процессами. Взглянем на этот подход подробнее. Начнем с eventfd.
eventfd
Прежде чем разбираться с eventfd, нужно сначала разобраться с тем, что такое файловый дескриптор, так как логика eventfd основана на нем.
Мне наиболее простым и понятным кажется определение файлового дескриптора как - уникальный идентификатор для ресурса ввода-вывода. Им может быть файл, каталог, сокет, stdin, stdout и т.д. Сам файловый дескриптор управляется на уровне операционной системы. Когда вы хотите открыть файл или сокет, или что-то еще, это делает ядро. Оно знает много чего еще про открытый файл, но отдает только этот идентификатор (который, кстати, уникален только в рамках одного процесса). Так же нельзя открыть файл с дескрипторами 0, 1 и 2 — при создании процесса ядро автоматически открывает их для ввода, вывода и сообщений об ошибках. Подробнее можно почитать тут.
eventfd это kernel system call при вызове возвращающий нам файловый дескриптор, который будет использоваться для уведомления о новых событиях. Может использоваться как внутри приложения, так и для уведомления приложения ядром системы.
Для начала получим наш файловый дескриптор eventfd и поместим его в хранилище в переменной mWakeEventFd:
mWakeEventFd.reset(eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC));
Если проводить аналогию с привычным нам подходом wait/notify с монитором, то файловый дескриптор, который нам отдаст eventfd, будет аналогом монитора.
Теперь нужны аналоги для wait и notify. Начнем с wait, в качестве него выступит epoll.
(e)poll
poll это kernel system call, который позволяет отслеживать файловый дескриптор. epoll это улучшенная версия poll, которая работает оптимальнее. Что важно, при создании экземпляра epoll нам возвращается файловый дескриптор, указывающий на него, так что в теории мы можем с помощью epoll отслеживать другой экземпляр epoll.
Из этой утилиты нам понадобятся следующий методы - epoll_wait, epoll_ctl и epoll_create1.
Для начала создадим экземпляр epoll, а связанный с ним файловый дескриптор запишем в хранилище в переменной mEpollFd.
mEpollFd.reset(epoll_create1(EPOLL_CLOEXEC));
Затем с помощью epoll_ctl мы указываем, что epoll должен отслеживать дескриптор полученный от eventfd.
epoll_ctl(mEpollFd.get(), EPOLL_CTL_ADD, mWakeEventFd.get(), &wakeEvent);
Ну и в конце концов в том месте, где мы будем ждать новых событий, вызовем метод epoll_wait. Он будет ждать появления новых данных об объектах за которыми, с помощью файловых дескрипторов, следит epoll. В итоге он вернет нам количество событий, произошедших с объектами с момента последнего отслеживания.
int eventCount = epoll_wait(
mEpollFd.get(),
eventItems,
EPOLL_MAX_EVENTS,
timeoutMillis);
При вызове epoll_wait мы либо сразу получим количество событий с последнего отслеживания, либо, если событий нет, будем их дожидаться. Механизм epoll также поддерживает ожидание по времени - для этого достаточно передать в метод нужное нам время. В нашем случае это делается с помощью переменной timeoutMillis. Это позволяет потоку ждать, пока не поступят новые данные, или не истечет заданное в timeoutMillis время, в зависимости от того, что произойдет раньше. Это нужно для поддержки отложенных сообщений.
То, как Looper начинает ждать мы разобрались. Теперь пора бы перейти к последнему - пробуждению. Тут к счастью все достаточно просто. Для этого используется write.
write
write - простой метод, который записывает байты в ресурс связанный с файловым дескриптором. В нашем случае в качестве байтов выступит просто единица формата uint64_t. Нам ведь просто надо уведомить epoll, а не передать какие-либо данные.
uint64_t inc = 1;
write(mWakeEventFd.get(), &inc, sizeof(uint64_t))
По итогу логика получается следующей:
создаем объект eventfd и получаем файловый дескриптор указывающий на него;
создаем экземпляр epoll;
указываем экземпляру epoll следить за файловым дескриптором полученным от eventfd;
когда задачи заканчиваются, с помощью epoll начинаем ждать вызвав epoll_wait;
при появлении нового сообщения пишем в ресурс привязанный к файловому дескриптору eventfd, тем самым уведомляя epoll.
Логика не сложная, но достаточно тяжело воспринимаемая из-за непривычных для Android разработчика файловых дескрипторов.
Общий путь ожиданий и пробуждений, включая Java слой, получается довольно длинным:
С Android SDK покончено, давайте перейдем к поверхностному рассмотрению альтернативных фреймворков для разработки под Android. Рассмотрим конечно же не все. Только то, что является достаточно популярным или обладает своими особенностями.
Flutter
Начнем мы с Flutter. Штука эта мультиплатформенная, так что в зависимости от платформы конечная реализация может быть немного разной. Поэтому рассмотрим все достаточно абстрактно.
Две очереди
Основной особенностью главного цикла во Flutter является наличие двух очередей: очередь Event и очередь MicroTask.
Первая очередь нам уже привычна и понятна - это очередь основных событий, а вот вторая очередь уже интереснее. Она используется для очень коротких событий, которые желательно выполнить как можно скорее.
С точки зрения кода это выглядит примерно так:
void loop() {
var isAlive = true;
while (isAlive) {
........................................
while (_microTaskQueue.isNotEmpty) {
final currentMicroTask = _microTaskQueue.first;
currentMicroTask.call();
_microTaskQueue.remove(currentMicroTask);
}
if (_eventQueue.isNotEmpty) {
final currentEvent = _eventQueue.first;
currentEvent.call();
_eventQueue.remove(currentEvent);
}
........................................
}
}
В каждом проходе цикла мы проверяем, есть ли у нас сообщение в очереди событий MicroTask, если там есть сообщение, то оно выполняется и удаляется из очереди. Если нет - то тоже самое происходит с очередью Event. По сути очередь MicroTask это та же очередь, просто имеющая наивысший приоритет, и пока эта очередь не опустеет, события из очереди Event не будут выполняться.
Isolate
Также во Flutter нет привычной системы потоков, но есть Isolate. Он похож на обычные потоки в других языках, но при этом Isolate не делят память между собой, то есть нельзя по привычной для нас схеме менять одну переменную из двух разных Isolate, а значит и проблем с синхронизацией по большей части нет. Интересующей же нас особенностью является то, что каждый из Isolate имеет свой цикл событий с собственными очередями Event и MicroTask. Общаются же Isolate c помощью сообщений, которые они могут посылать друг другу. Таким образом вообще все действия во Flutter выполняются в циклах событий.
Подробнее про это можно прочитать в этой статье (перевод).
Chrome
Многие фреймворки для кроссплатформенной разработки, например Cordova, используют системный WebView, также не стоит забывать про PWA (хотя, кажется, все уже забыли). Начиная с Android 7, при использовании стандартного WebView под капотом используется Chrome. Следовательно, довольно много Android приложений используют Chrome. Так что стоит присмотреться и к его главному циклу.
Он основан на open-source библиотеке libevent. Эта кроссплатформенная библиотека, специально созданная для реализации циклов событий как UI, так и серверных приложений. Она сразу предоставляет все самые необходимые вещи как: отложенные сообщения, множественные очереди, приоритеты и прочие вещи которые могут понадобиться при работе с циклами событий.
(Кстати, в Android SDK эта библиотека тоже есть, но по какой-то причине для главного цикла не используется, правда и добавили ее туда только в 2015 году.)
В зависимости от платформы libevent использует разные способы ожидания/пробуждения. Конкретно в Android используется старый добрый epoll + eventfd. В других платформах реализация может отличаться.
Поверх этой библиотеки написана обёртка. В целом логика похожа на цикл Flutter (что не удивительно, учитывая “корни” языка Dart) - отдельная очередь Task и отдельная очередь Microtask. Но есть особенность, которая сильно выделяется - часть задач, связанных с рендерингом, находится в отдельной очереди.
Подробнее о главном цикле Chrome можно прочитать в этой статье. Я же просто приведу диаграмму оттуда.
То есть мы уже имеем целых три очереди на один цикл событий.
Если открыть инструменты разработчика в Chrome, то мы можем увидеть прекрасную картину работы главного (и не только) потока.
Сообщения из очереди Render здесь обозначены сиреневым цветом.
Если вас заинтересовала тема рендеринга, то вот статья про рендеринг во Flutter в которой есть сравнение с Chromium и Android SDK.
React Native
Особенности главного цикла в React Native уходят корнями в логику потоков. Давайте взглянем на потоки main и JsThread:
main - старый, добрый, привычный. Именно в нем приложение начинает свою работу. Сначала он загружает пакеты JS, и после этого вся работа с JS ведется в отдельном потоке. Main продолжает разве что просто обновлять View.
JsThread - поток в котором выполняется весь Javascript. В нем по сути происходит все самое важное, что может случится в React Native приложении. Обновления нативных view группируются, и отправляются в главный поток каждый оборот главного цикла.
В итоге получается, что MainThread здесь используется только для отрисовки и чтобы загрузить JS. Вся логика происходит в JsThread, у которого есть собственный цикл. Так что на вопрос “Кто тут главный?” в рамках ReactNative ответить сложнее.
KMM
Сказать тут особо нечего, так как код Kotlin Native по сути вызывается из Android SDK, то логично, что главный цикл используется оттуда же. Тем не менее в KMM есть собственный EventLoop для корутин, но главным циклом он, увы, не является.
Игры
В играх главный цикл отличается больше всего. Основной его особенностью является то, что он работает всегда, загружая вашу систему насколько этого позволяет движок. Ведь листики на деревьях должны двигаться в соответствии с ветром, даже если игрок никак в данный момент не взаимодействует с игрой.
Ну тут, хотя бы не надо так заморачиваться с ожиданием и пробуждением цикла. Все должно быть проще, правда? Нет, не правда, на деле тут все даже сложнее.
Итак, задача следующая - за один проход цикла нам надо:
Считать ввод пользователя с экрана, геймпада или клавиатуры. Для этого сделаем метод processInput.
Просчитать физику, геометрию, поворот камеры, искусственный интеллект и пр. То есть в итоге мы должны получить позиции всех точек для полигонов. Пусть это делается в методе updatePhysics
На основе точек отрисовать наши полигоны, наложить на них текстуры, шейдеры, отражения и пр., чтобы в конце выполнить растеризацию. Пусть это делается в методе updateRender.
В нашем примере processInput, updatePhysics, updateRender это просто методы, но в реальности используются привычные нам схемы с очередями и сообщениями. Так как мы уже достаточно разобрались как работают очереди, то в дальнейшем мы будем ими пренебрегать.
Подход в лобовую
Первое что приходит в голову - просто взять и запустить наши методы в бесконечном цикле, выглядеть это будет примерно следующим образом:
class GameLooper {
private var isRunning = true
fun loop() {
while (isRunning) {
processInput()
updatePhysics()
updateRender()
}
}
private fun processInput() {
..................
}
private fun updatePhysics() {
..................
}
private fun updateRender() {
..................
}
}
Ииииии... Это работает, но плохо. Главная проблема такого цикла в том, что он не привязан ко времени. По сути, чем мощнее железо телефона, тем быстрее будет выполняться наша игра, но если железо медленное, то игра будет идти наоборот очень медленно.
Чтобы понять почему это происходит давайте представим что в нашей игре есть персонаж, и пока нажата кнопка «вперед», он идет вперед (да да!!!). Делается это с помощью метода walk, который двигает нашего персонажа ровно на метр вперед. Обработка физики для этого метода занимает на слабом железе — допустим 1 секунду, а на мощном железе — 0,5 секунды.
Получается, что если пользователь жмет на кнопку 3 секунды, то на медленном железе метод успеет выполниться 3 раза, а на мощном — 6. Соответственно, на мощном железе персонаж пройдет в два раза большее расстояние.
В реальной же игре вообще все объекты будут двигаться в два раза быстрее. Согласитесь — такой геймплей нам не нужен.
Как же от этого избавиться?
Постоянное количество кадров
Проще всего взять и сделать количество кадров константным. Для этого выберем константу например — 25 кадров в секунду. Если же наша игра работает быстрее, чем 25 кадров в секунду, то вызывается старый добрый метод Thread.sleep, и наш цикл уснет до тех пор, пока не придет время нового кадра.
private const val FRAMES_PER_SECOND = 25
private const val SKIP_TICKS = 1000 / FRAMES_PER_SECOND
class GameLooper {
private var isRunning = true
fun loop() {
var nextGameTick = SystemClock.uptimeMillis()
while (isRunning) {
processInput()
updatePhysics()
updateRender()
nextGameTick += SKIP_TICKS
val sleepTime = nextGameTick - SystemClock.uptimeMillis()
if (sleepTime > 0) {
Thread.sleep(sleepTime)
}
}
}
}
Такое решение лучше предыдущего, на среднем и мощном железе оно будет работать с одинаковой скоростью. И на этом плюсы этого решения заканчиваются.
Проблема с тем, что на слабом железе наша игра работает медленно, никуда не ушла. Получается эдакое лагающее slo-mo, такое можно заметить например в Dark Souls.
С мощным железом всё тоже не очень хорошо. По факту, оно простаивает большую часть времени, просто находясь в ожидании времени наступления следующего кадра. Хотя и у этого решения есть плюсы - меньше расходуется батарея на телефоне. Но все же, чем мощнее железо, тем больше оно спит.
Это не очень хорошо, ведь пользователь покупал мощный телефон со 120 Гц экраном не для того, чтобы быть ограниченным 25 кадрами. Поэтому займемся проблемами скорости игры.
Реальное время
Если задуматься, то станет очевидно, что такие вещи, как звуки и анимации, завязаны на реальном времени. Звук должен проигрываться определенное количество времени независимо от мощности железа. Почему бы и в случае с графикой тогда не смотреть на реальное время и уже подстраиваться под него.
Правда придется переписать все расчеты физики, чтобы они учитывали время, так что хорошо, что наш проект гипотетический. Мы просто будем прокидывать время, которое прошло с предыдущего кадра, прямо в метод updatePhysics и уже в самих расчетах отталкиваться от количества времени которое прошло.
class GameLooper {
private var isRunning = true
fun loop() {
var currentFrameTick = SystemClock.uptimeMillis()
while (isRunning) {
val prevFrameTick = currentFrameTick
currentFrameTick = SystemClock.uptimeMillis()
processInput()
updatePhysics(currentFrameTick - prevFrameTick)
updateRender()
}
}
}
В итоге наша игра идёт с одинаковой скоростью независимо от мощности железа (не считая совсем уж низкий FPS). Первая победа!
Этот вариант кажется практически идеальным, но у него есть и минусы.
На слабом железе все достаточно хорошо. Конечно если FPS будет совсем уж низким - 10-15 кадров в секунду, то управлять игрой станет практически невозможно, так как в отличии от предыдущих вариантов игра не замедляется давая игроку возможность сориентироваться. Но откровенно говоря играть на таком FPS практически невозможно при любом цикле событий.
На мощном железе может происходить магия. В чем она заключается? Ну, возможно, некоторые объекты будут летать (а не должны), что-то двигаться слишком быстро, а что-то слишком медленно. В чем же причина такой магии? В дробных числах.
Дробные числа, вроде Float или Double, имеют фиксированное количество цифр после запятой и вообще не являются идеально точными. Например, 0.1 может оказаться как 0,10000000000000001, так и 0,99999999999999976. Для примера давайте возьмем простой код:
println("%f".format(1f * 1_000_000_000_000))
Мы просто взяли единицу формата Float и умножили на очень большое число. В итоге нам выведется 999999995904.000000. Погрешность стала уже достаточно большой.
Обычно это не вызывает проблем. Но допустим у нас есть гусеница, которая проползает 0.0001 метра в секунду. При 30 кадров в секунду мы должны будем подвинуть гусеницу на 1/30 от этого расстояния - 0,0000033, при 120 уже на 1/120 - 0,000000833. Чем выше FPS тем меньшими дробями нам приходится оперировать и тем сильнее может сказываться погрешность. Очень наглядным примером является Skyrim, который при высокой частоте кадров начинает запускать телеги в воздух.
Не факт, что конкретно в вашей игре погрешность Float на что-то повлияет, но это одна из тех вещей, которая может с вами случиться. Избавиться от этого можно просто стараясь не использовать очень малые дроби. Но, так как у нас тут статья про главный цикл, давайте попробуем исправить это за счет изменения цикла.
Да и в целом, физические движки любят постоянство.
Постоянство Шрёдингера
Раз проблема с погрешностью Float проявляет себя в первую очередь в расчётах физики, то давайте зададим ей постоянную частоту обновления, а отрисовка пусть происходит так часто, как это вообще возможно. Получится одновременно и постоянная частота и не постоянная.
Условимся, что наша физика должна рассчитываться 50 раз в секунду. Путем несложных вычислений мы понимаем, что рассчитывать физику мы должны каждые 20 миллисекунд. Поэтому в цикле мы просто проверяем, что если с момента последнего обновления физики прошло более чем 20 миллисекунд, то ее нужно обновить.
private const val TICKS_PER_SECOND = 50
private const val SKIP_TICKS = 1000 / TICKS_PER_SECOND
class GameLooper {
private var isRunning = true
fun loop() {
var nextGameTick = SystemClock.uptimeMillis()
while (isRunning) {
val needUpdate = SystemClock.uptimeMillis() > nextGameTick
if (needUpdate) {
processInput()
updatePhysics()
nextGameTick += SKIP_TICKS
}
updateRender()
}
}
}
Вывод изображения на экран с помощью updateRender мы делаем так часто, как можем. Теперь игра должна ощущаться плавнее, так как многие эффекты, шейдеры, частицы и пр. будут обрабатываться настолько быстро, насколько это возможно.
Но в играх время отрисовки кадров неодинаково, и может быть ситуация, когда какой-то кадр обрабатывался в updateRender сильно дольше обычного - к примеру 65 мс. Тогда обработка физики вместо того, чтобы выполниться 3 раза, выполнится всего один раз. В целом таких кадров может быть много. Из-за этого и так не слабенькое подтормаживание ощущается еще сильнее.
Поэтому надо научить физику “догонять”. Для этого мы вставим еще один цикл while, вместо if.
private const val TICKS_PER_SECOND = 50
private const val SKIP_TICKS = 1000 / TICKS_PER_SECOND
class GameLooper {
private var isRunning = true
fun loop() {
var nextGameTick = SystemClock.uptimeMillis()
while (isRunning) {
while (SystemClock.uptimeMillis() > nextGameTick) {
processInput()
updatePhysics()
nextGameTick += SKIP_TICKS
}
updateRender()
}
}
}
Теперь новый кадр не будет отрисовываться, пока физика не догонит текущее время.
Пришло время разобраться с updateRender. Так как между вызовами updateRender физика может и не обновиться, то получится ситуация когда большая часть объектов в кадре не изменится. С этим нужно что-то сделать. Решение есть - интерполяция!
Допустим, с последнего обновления физики прошло 10 мс, время между обновлениями физики 20 мс. По сути мы просто делим 10 на 20 тем самым получив ½. То есть с последнего обновления физики прошла примерно половина времени кадра. ½ и будет нашим значением интерполяции. При отрисовке кадра мы можем например умножить вектора движений на это значение. Таким образом наша игра станет гораздо плавнее.
private const val TICKS_PER_SECOND = 50
private const val SKIP_TICKS = 1000 / TICKS_PER_SECOND
class GameLooper {
private var isRunning = true
fun loop() {
var nextGameTick = SystemClock.uptimeMillis()
while (isRunning) {
while (SystemClock.uptimeMillis() > nextGameTick) {
processInput()
updatePhysics()
nextGameTick += SKIP_TICKS
}
val time = SystemClock.uptimeMillis() + SKIP_TICKS - nextGameTick
val interpolation = time.toFloat() / SKIP_TICKS
updateRender(interpolation)
}
}
}
Так, а разве у нас опять не возникнет проблема с погрешностью Float? Мы ведь опять что-то делим.
Вообще, так как минимальным шагом в наших вычислениях является миллисекунда, то в наихудшем случае мы будем делить 1 на 20. А 1/20 не очень страшная дробь. К тому же, updateRender лишь выводит изображение, и небольшая погрешность в нем навредит не так сильно, как в расчётах физики или положения объектов.
Последний вариант не является «серебрянной пулей» хоть и достаточно оптимален. В разных ситуациях могут пригодиться разные варианты циклов. Все очень зависит от конкретной игры. Для соревновательных игр типа CS:GO или Valorant хочется обновлять мир и физику как можно чаще, на это и стоит делать упор. А также есть VR, в котором надо отрендерить не одну картинку, а две, причем не меньше 90 фпс, а лучше 120, иначе будет тошнить. А если уж в игре есть перемотка времени… В реальных игровых движках в таком цикле гораздо больше действий и итераций. Можете оценить как это выглядит в Unity. Если хотите поглубже погрузиться в океан главных циклов в играх, то вот хорошая статья и вот еще тоже.
Также мы не рассмотрели такой важный фактор, как постоянство кадровой частоты. Она тоже очень сильно влияет на ощущение плавности, но это отдельная и очень глубокая тема, для тех кто хочет в нее погрузиться, я тоже нашел хорошую статью (она еще и с gif’ками!!!).
Заключение
Ну вот мы и рассмотрели самое важное, что может быть связано с главным циклом в Android. Конечно я мог о чем-то не знать или о чем-то забыть, но кажется что все самое интересное я рассказал. Удивительно, насколько разнообразным может быть по сути обычный бесконечный цикл где-то в недрах приложения.