Main Loop (Главный цикл) в Android Часть 1. Пишем свой цикл

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Основой любого приложения является его главный поток. На нем происходят все самые важные вещи: создаются другие потоки, меняется 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. Будет интересно (ну или нет).

Источник: https://habr.com/ru/company/cian/blog/588314/


Интересные статьи

Интересные статьи

На рубеже четвертого и третьего тысячелетия до нашей эры на Земле возникли две первые цивилизации. В долине Нила после объединения верхнего и нижнего Египта образовалось ...
Всем привет! В этой части хочу рассказать как мы использовали модель NomeroffNet предназначенного для распознавания автомобильных номеров, распознать рукописные записи. В...
История произошла в Telegram-канале БК00010, участником был я. Возник вопрос: как писать программы? Эмулятор не поддерживает запись дисков, поэтому использовать ассемблер в эму...
Аналитики полагают, что в настоящее время в мире существует порядка 10 миллиардов устройств из области «интернета вещей» (IoT). Иногда эти устройства завоевывают свое место на рынке, буквально вз...
Продолжение статьи Руководство по SQL: Как лучше писать запросы (Часть 1) От запроса к планам выполнения Зная, что антипаттерны не статичны и эволюционируют по мере того, как вы растете как ...