Основой любого приложения является его главный поток. На нем происходят все самые важные вещи: создаются другие потоки, меняется UI. Важнейшей его частью является цикл. Так как поток главный, то и его цикл тоже главный - в простонародье Main Loop.

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

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

По всем правилам приличия представлюсь — меня зовут Перевалов Данил, а теперь давайте перейдём к теме.

Как вообще работают программы

Но для начала, давайте разберемся как вообще работают простые программы в Java. 

С точки зрения системы - все что есть у программы это просто метод main который она вызовет при старте и завершит процесс после его выполнения.

В коде это выглядит примерно так - у нас есть класс и внутри него метод main, который и вызовется системой. В данном случае мы просто выведем Hello World в консоль.

package myjavacode;

public class MyClass {

    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

Вместо вывода в лог мы можем открыть экран, выполнить сложную операцию или отправить запрос в сеть. Суть не изменится: после выполнения метода main - программа закроется. Программа или, если говорить в контексте Android - приложение, живет пока что-то делает, затем просто завершается. 

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

В программах использующих UI и в частности в Android приложениях - все не так. Приложение не закрывается как только сделает все что ему было предписано на старте. Оно терпеливо ждет действий пользователя, кликов и прочего, и затем реагирует на них. Поэтому, приложения с UI должны жить и работать пока пользователь сам его не закроет (ну или пока приложение не упадет, или система сама его не закроет по причине нехватки памяти). Но вот проблема: как только последняя строчка метода main выполнится - приложение закроется само, так как посчитает, что оно сделало все что нужно.

Как не дать приложению закрыться?

Для начала давайте разберемся с тем, как же нам не дать приложению завершаться самостоятельно. Самый простой и самый действенный метод - (почти) бесконечный цикл. Проще всего его создать через обычный while.

public class MyClass {
    
    private static boolean isAlive = true;

    public static void main(String[] args) {
        while(isAlive) {
            doAction();
        }
    }

    private static void doAction() {
        
    }
}

По сути, мы просто добавили (почти) бесконечный цикл в котором вызывается метод doAction и теперь наше приложение уже не будет закрываться само, ведь цикл то бесконечный, а значит и приложению всегда есть что делать. Оно будет бесконечно выполнять метод doAction пока мы не попросим его наконец остановится переключив переменную isAlive в состояние false. Проблема только в том, что наше приложение пока ничего не делает. Метод doAction то пустой.

Заставляем приложение что-то делать

Теперь добавим возможность приложению выполнять какие-либо действия. Просто написать код всей программы в методе doAction не очень хороший вариант. В приложении могут быть сотни кнопок и текстовых полей на каждое из которых нужно написать свое действие. Если писать все в одном методе то он довольно быстро превратится в нечитаемое чудовище размером в несколько десятков тысяч строк. 

Поэтому воспользуемся функцией обратного вызова - в простонародье callback. Благо в Java уже есть интерфейс Runnable который хорошо для этого подходит. У него есть всего один метод run, который можно переопределить и написать туда свое действие. 

Для того, чтобы понимать какое действие надо выполнить следующим поместим их в очередь. Пока для нее сгодится обычный ArrayList.

public class MyClass {

    private static List<Runnable> availableActions = new ArrayList<>();
    private static boolean isAlive = true;

    public static void main(String[] args) {
        while(isAlive) {
            doAction();
        }
    }

    private static void doAction() {
        if (availableActions.size > 0) {
            Runnable currentAction = availableActions.get(0);
            currentAction.run();
            availableActions.remove(currentAction);
        }
    }
}

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

Для этого давайте представим, что у класса System есть возможность добавить слушатель на нажатия экрана вызвав метод - registerClickListener. В обратном вызове слушателя добавим какое-нибудь действие в очередь. Например, выведем в лог Click on screen.

public class MyClass {

    private static List<Runnable> availableActions = new ArrayList<>();
    private static boolean isAlive = true;

    public static void main(String[] args) {
        System.registerClickListener((clickEvent) -> {
            availableActions.add(() -> Log.d("Click on screen"));
        });
        while(isAlive) {
            doAction();
        }
    }

    private static void doAction() {
        if (availableActions.size > 0) {
            Runnable currentAction = availableActions.get(0);
            currentAction.run();
            availableActions.remove(currentAction);
        }
    }
}

Отлично! Теперь у нас есть приложение которое способно выводить сообщение в лог при нажатии на экран. Без цикла у нас бы ничего не вышло, ведь сразу после регистрации слушателя нажатий на экран, приложение закрылось бы и соответственно ни один клик не был бы обработан. 

Новые действия могут добавляться в нашу очередь как от вызовов системы, так и внутри самих действий. То есть какое-то действие добавляет новое действие, а то добавляет ещё 5 новых и так до бесконеч��ости. 

Вроде все хорошо, но есть один нюанс… Наше приложение теперь делает «что-то» постоянно. Даже когда у него нет доступных действий оно бесконечно проверяет не появились ли они. Тем самым оно постоянно загружает ядро процессора по максимуму, что явно не лучшим образом скажется на фоновых процессах, энергопотреблении и температуре самого процессора.

Заставляем приложение ничего не делать

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

Нам нужно указать, что если у нас пока нет доступных действий, то пора ничего не делать. Для этого воспользуемся стандартным методом wait() который заставляет текущий поток ждать и соответственно - бездействовать.

Так же, когда случится обратный вызов от нажатия на экран нам надо сказать нашему потоку, что пора поработать и проверить наличие доступных для выполнения действий. Для этого воспользуемся  стандартным методом notify() который «разбудит наш поток».

public class MyClass {

    private static List<Runnable> availableActions = new ArrayList<>();
    private static boolean isAlive = true;

    public static void main(String[] args) {
        System.registerClickLister((clickEvent) -> {
            availableActions.add(() -> Log.d("Click on screen"));
            availableActions.notify();
        });
        while(isAlive) {
            doAction();
        }
    }

    private static void doAction() {
        if (availableActions.size > 0) {
            Runnable currentAction = availableActions.get(0);
            currentAction.run();
            availableActions.remove(currentAction);
        } else {
            availableActions.wait();
        }
    }
}

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

Самый главный цикл в жизни программы

Сам по себе подход с использованием цикла называется Event Loop (если вам больше нравится на русском - Цикл событий). Если же Event Loop обеспечивает работу главного потока - то он уже «поднялся», он не какой-то простой Event Loop, он - Main Loop (Главный цикл). По сути он является ядром всего приложения, обеспечивая его работу. Весь код выполняемый на главном потоке (Main Thread) проходит через него. Практически все приложения в которых есть UI (и не только они) ис��ользуют его.

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

Распределяем ответственности по классам

В целом у нас написано что-то похожее на рабочий код. Приложение само не закрывается, ждет команд от пользователя и умеет их выполнять. Но как-то все не по принципам SOLID, особенно с буквой S (Single-Responsibility Principle) большие проблемы. Вся логика в одном классе и ни о каком разделении ответственности речи идти не может. Давайте попытаемся это исправить, да и в целом хочется накинуть новых возможностей.

Для начала давайте сделаем обертку над Runnable и назовем ее… к примеру Message.

Message

Для этого просто создадим новый класс Message. Одним из полей которого как раз и будет наш Runnable. Назовем его callback.

class Message {
   Runnable callback;
   public long when;
}

Дополнительно добавим еще одно поле when. Оно будет хранить значение времени в которое нужно выполнить действие. Ведь не всегда нужно выполнять что-то здесь и сейчас. Иногда, чтобы все было хорошо, нужно подождать 500 миллисекунд. Для реализации такого механизма в поле when будет записываться время с момента старта приложения плюс время через которое должно произойти действие записанное в callback. Тоесть when = время с момента старта приложения + задержка для сообщения. Допустим я добавляю действие и хочу, чтобы оно выполнилось через 500 миллисекунд, а с момента старта приложения прошло 2000 миллисекунд, тогда в when у нас будет 2000 + 500 = 2500. Если же мне важно выполнить действие как можно скорее, то тогда в поле when надо записать 0.

Теперь давайте разберемся с нашим ArrayList который содержит действия.

ArrayList

Тут сразу стоп!!! Мы ведь добавили поле when, тем самым позволяя создавать отложенные сообщения, а следовательно у нас появятся сообщения которые могут находится в очереди очень долго ожидая своего часа. 

Может сложиться следующая ситуация: у меня есть список из трёх сообщений, первые два должны выполняться как можно скорее, а третье… допустим через час. При этом первое сообщение добавляет ещё 10 сообщений в нашу очередь, каждое из которых должно выполняться как можно скорее. Это значит, что их надо добавить в очередь сразу после второго сообщения. Получается вставка в середину списка, а как все знают у ArrayList с операцией вставки есть проблемы. 

Вставка в ArrayList имеет сложность O(n), а значит, чем больше у нас будет сообщений в очереди, тем больше времени она будет занимать. 

Поэтому хорошим решением будет заменить ArrayList на связный список у которого сложность вставки O(1), а значит количество элементов не будет влиять на время операции. Правда у связного списка есть проблемы с временем доступа к произвольному элементу, но это не касается первого элемента. А нам как раз нужен доступ только к первому элементу. 

После того как сообщение выполнит свою работу оно станет ненужным. Поэтому двусвязный список тут не очень подходит, ведь придется каждый раз при удалении первого сообщения обращаться и ко второму, чтобы удалить ссылку на первое. А вот односвязный список - вполне подходит. Проблема только в том, что в Java нет стандартной реализации односвязного списка. Не беда! Сделаем сами. Для этого просто добавим поле next типа Message в сам Message.

class Message {
   Runnable callback;
   public long when;
   Message next;
}

Теперь у нас каждое сообщение содержит ссылку на следующее, таким образом формируя список. Если в поле next записан null, то это значит текущее сообщение является последним в списке.

Наш односвязный список готов и можно двигаться дальше.

Очередь сообщений

Теперь бы надо где-то прописать логику работы с сообщениями. Для этого создадим новый класс. Пусть будет MessageQueue. Это конечно не Queue в прямом понимании этого типа, так как у нас есть вставка в середину. С другой стороны - мы всегда берем для работы первое сообщение, так что называть класс MessageList еще более странная затея.

class MessageQueue {
   Message messages;
}

Пока в классе у нас будет единственное поле messages со ссылкой на начало списка сообщений, то есть ближайшее сообщение которое мы планируем обработать. Соответственно если поле messages null, то список пустой, а значит новых сообщений нет.

Возвращаем текущее сообщение

Теперь надо добавить метод который будет возвращать текущее сообщение. Для того, кто будет вызывать этот метод оно, по сути, будет следующим. Потому и назовем метод соответствующе - next.

class MessageQueue {
   Message messages;
  
   Message next() {
       Message current = messages;
       if (current == null) { 
          return null;
       } else {
          messages = current.next;
       }
       return current;
   }
}

В нем все просто: 

  1. берем текущее сообщение 

  2. у него забираем ссылку на следующее за ним сообщение 

  3. делаем его текущим.

Таким образом мы сделали текущим то сообщение, что было следующим, тем самым продвинув очередь. А то чтобы было текущим возвращаем тому, кто вызвал этот метод.

Теперь нужно учесть, что сообщение имеет поле when, которое позволяет выполнить сообщение в указанное время, а значит сообщение не всегда нужно отдавать, оно еще может быть не готово выполнится. Для этого добавим проверку сообщения по времени.

Message next() {
   Message current = messages;
  
   final long now = SystemClock.uptimeMillis();
   if (current == null || now < current.when) {
       return null;
   } else {
       messages = current.next;
       return current;
   }
}

Для этого берем время которое прошло с момента старта приложения и записываем в переменную now. Далее просто сравниваем now и время когда сообщение в переменной current должно выполниться. Если now меньше, то сообщаем тем кто вызвал метод, что следующего сообщения как будто бы и нет.

Новое сообщение

Получать следующее сообщение мы научились, теперь надо научится добавлять новое. Для это создадим метод enqueueMessage который добавляет новое сообщение.

boolean enqueueMessage(Message newMessage) {
   if (newMessage == null) {
     return false;
   }

   Message current = messages;
   if (current == null) {
       messages = newMessage;
   } else {
       Message previous;
       while(true) {
           previous = current;
           current = current.next;
           if (current == null) {
               break;
           }
       }
       previous.next = newMessage;
   }
   return true;
}

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

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

Просто засунем код нашего метода в блок synchronized.

boolean enqueueMessage(Message newMessage) {
   if (newMessage == null) {
     return false;
   }
   synchronized (this) {
       Message current = messages;
       if (current == null) {
           messages = newMessage;
       } else {
           Message previous;
           while(true) {
               previous = current;
               current = current.next;
               if (current == null) {
                   break;
               }
           }
           previous.next = newMessage;
       }
   }
   return true;
}

А так же добавим synchronized в метод next, так как он тоже обращается к messages и опять же может случится нечто нехорошее.

Message next() {
   synchronized (this) {
       Message current = messages;
       final long now = SystemClock.uptimeMillis();
       if (current == null || now < current.when) {
           return null;
       } else {
           messages = current.next;
           return current;
       }
   }
}

Теперь нужно учесть, что мы можем добавить новое сообщение в середину очереди. Для этого добавим сравнение сообщений по полю when когда ищем последнее сообщение. То есть теперь мы ищем не просто сообщение к которого next равен null, но так же и смотрим, чтобы у следующего сообщения значение when было меньше чем у нового. Ну и соответственно из-за вставки в середину нам нужно заполнить поле next у нового сообщения

С точки зрения кода это будет выглядеть следующим образом:

boolean enqueueMessage(Message newMessage) {
   if (newMessage == null) {
     return false;
   }
   synchronized (this) {
       Message current = messages;
       if (current == null) {
           messages = newMessage;
       } else {
           Message previous;
           while(true) {
               previous = current;
               current = current.next;
               if (current == null || newMessage.when < current.when) {
                   break;
               }
           }
           newMessage.next = previous.next;
           previous.next = newMessage;
       }
   }
   return true;
}

С очередью сообщений пока все. Теперь пора переработать сам цикл.

Запускаем цикл

Заводим новый класс и называем его Looper. В нем у нас содержится очередь сообщений (MessageQueue), переменная isAlive которая отвечает за то, продолжать ли приложению работать, а также два метода:

  • loop - в котором запускается и крутится наш цикл

  • shutdown - который переключает isAlive в false тем самым останавливая обработку сообщений и завершая приложение

Давайте присмотримся к нашему основному методу loop поближе.

class Looper {

   private static Looper instance = new Looper();
   final MessageQueue messageQueue;
   private static boolean isAlive = true;

   public Looper() {
       messageQueue = new MessageQueue();
   }

   public static void loop() {
       Looper currentInstance = instance;
       while (isAlive) {
           Message nextMessage = currentInstance.messageQueue.next();
           if (nextMessage != null ) {
               nextMessage.callback.run();
           } else {
               try { 
                  instance.messageQueue.wait(); 
               } catch (InterruptedException e) { 
               }
           }
       }
   }


   public static void shutdown() {
      isAlive = false;
   }
}

В нем мы получаем объект Looper и запускаем уже привычный нам бесконечный цикл, в котором запрашиваем новое сообщение у MessageQueue. Если сообщение есть, то выполняем действие, если же нет, то засыпаем.

Но уснуть то мы уснули, а когда же просыпаться? Нужно теперь куда-то добавить метод notify. По хорошему это надо делать там, где у нас добавляется новое сообщение, а происходит это внутри метода MessageQueue.enqueueMessage. Но вот засыпать в одном классе, а просыпаться в другом - идея так себе, ведь это будет сложно контролировать.

Также можно заметить, что мы не очень хорошо работаем с сообщениями которые собирались выполнить в определенное время и заполнили им поле when. Да, мы не выполним действие сообщения раньше времени за счет проверки внутри MessageQueue, но мы можем проспать его выполнение, если у нас не будут поступать новые сообщения. Ведь спим мы пока не придет новое сообщение и на самом деле маловероятно, что придет оно именно в тот момент когда надо будет выполнить сообщение по времени. Обе эти проблемы можно решить переносом ожидания нового сообщения внутрь MessageQueue.

Переносим ожидание в MessageQueue

Давайте сделаем так, чтобы мы гарантированно отдавали сообщение в методе next, а если сообщения нет, то дожидаемся его.

Message next() {
   int nextWaitTime = -1;
   while (true) {
       try {
           if (nextWaitTime >= 0) {
               wait(nextWaitTime);
           }
       } catch (InterruptedException e) {
       }
       synchronized (this) {
           Message current = messages;
           if (current != null) {
               final long now = SystemClock.uptimeMillis();
               if (now < current.when) {
                   nextWaitTime = (int) (current.when - now);
               } else {
                   messages = current.next;
                   return current;
               }
           } else {
               nextWaitTime = 0;
           }
       }
   }
}

Также внутри метода next появился цикл который отвечает за ожидание сообщения. В нем мы проверяем есть сообщения, если нет - то спим пока не вызовут notify. Ес��и есть, то смотрим нужно ли сейчас выполнять действие текущего сообщения. Если да, то возвращаем сообщение, если же нет, то засыпаем пока не придет время выполнить действие текущего сообщения.

В метод enqueueMessage же добавим вызов notify, чтобы пробудить наш цикл в методе next.

boolean enqueueMessage(Message newMessage) {
   if (newMessage == null) {
       return false;
   }
   synchronized (this) {
       Message current = messages;
       if (current == null) {
           messages = newMessage;
       } else {
           Message previous;
           while (true) {
               previous = current;
               current = current.next;
               if (current == null || newMessage.when < current.when) {
                   break;
               }
           }
           newMessage.next = previous.next;
           previous.next = newMessage;
       }
       notify();
   }
   return true;
}

Ну и напоследок в самом Looper уберем ожидание. Теперь он просто выполняет действия сообщений.

public static void loop() {
   Looper currentInstance = instance;
   while (isAlive) {
       Message nextMessage = currentInstance.messageQueue.next();
       nextMessage.callback.run();
   }
}

Общая схема

Общая логика работы получилась такой: 

  1. система запускает наш процесс, что в итоге ведет к вызову метода loop у Looper. 

  2. Looper внутри себя обращается к методу next у MessageQueue за новым сообщением 

  3. MessageQueue видя, что сообщений пока нет - останавливает текущий поток с помощью метода wait

  4. система кидает нам какое-нибудь событие, например клик на экран, что в итоге добавляет новое сообщение нашу в очередь сообщений через метод enqueueMessage у MessageQueue и будит текущий поток

  5. метод next у MessageQueue просыпается и видит что у него появилось новое сообщение

  6. это новое сообщение MessageQueue возвращается в Looper

  7. Looper просто выполняет callback из сообщения 

  8. обратно к пункту 2

Отлично! В итоге у нас вполне рабочий цикл событий. Даже что-то близкое к тому, как все устроено в Android, но в Android классах кода куда больше. Например, в нашем Looper 25 строк, а в Android 493, правда это с учетом JavaDoc. Все потому, что Looper, Message, MessageQueue обладают в Android SDK дополнительными возможностями.

В следующей статье мы поближе познакомимся с реализацией главного цикла в Android SDK. Будет интересно (ну или нет).

Main Loop (Главный цикл) в Android Часть 2. Android SDK

Main Loop (Главный цикл) в Android Часть 3. Другие главные циклы