Топливо для Андроида. Избавляем свое приложение от лагов, тормозов и долгих экранов загрузки

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

Как появляются лаги

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

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

Причины всех этих проблем просты: слишком долгое выполнение каких-либо операций, вычислительных или операций ввода-вывода, а вот способы оптимизации разные.

Холодный старт

Запуск приложения состоит из нескольких стадий: это инициализация нового процесса, подготовка окна для вывода интерфейса приложения, показ окна на экране и передача управления коду приложения. Далее приложение должно сформировать интерфейс на основе описания в XML-файле, подгрузить с «диска» или из интернета необходимые для корректного отображения интерфейса элементы (битмапы, данные для списков, графиков и прочее), инициализировать дополнительные элементы интерфейса, такие как выдвижное меню (Drawer), повесить на элементы интерфейса колбэки.

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

  1. отложенная инициализация;
  2. параллельный запуск задач.

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

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

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

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

В качестве заглушек можно использовать пустые картинки, пустые строки, пустые списки (например, RecyclerView можно инициализировать сразу, а при получении данных просто вызвать notifyDataSetChanged()). После получения данных с сервера их следует кешировать. При следующем запуске их можно будет использовать вместо заглушек.

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

Еще одно узкое место: формирование интерфейса из описания лайотов в XML-файле. Когда ты вызываешь метод setContentView() или inflate() объекта LayoutInflater (в коде фрагмента), Android находит нужный лайот в бинарном XML-файле (для эффективности Android Studio упаковывает XML в бинарный формат), читает его, проводит парсинг и на основе полученных данных формирует интерфейс, измеряя и подгоняя элементы интерфейса друг к другу.

Это действительно сложная и дорогая операция. Поэтому необходимо уделить особое внимание оптимизации лайотов: избегать излишней вложенности лайотов друг в друга (например, использовать RelativeLayout вместо вложенных друг в друга LinearLayout), а также разбить сложные описания интерфейса на множество более мелких и загружать их только тогда, когда в этом возникнет необходимость.

Смотри также:  10 фактов о Python

Другой вариант — перейти на язык Kotlin и использовать библиотеку Anko. Она позволяет описывать интерфейс прямо в коде, благодаря чему скорость отображения интерфейса возрастает в четыре раза, а ты получаешь большую гибкость в управлении логикой формирования интерфейса.

Фризы и проседания FPS

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

Есть, однако, намного более тонкий и неочевидный момент. Android обновляет экран со скоростью 60 FPS. Это значит, что во время показа анимации или промотки списков у него есть всего 16,6 мс на отображение каждого кадра. В большинстве случаев Android справляется с этой работой и не теряет кадров. Но некорректно написанное приложение может затормозить его.

Упс, мы пропустили кадр
Упс, мы пропустили кадр

Простой пример: RecyclerView — элемент интерфейса, позволяющий создавать чрезвычайно длинные проматываемые списки, которые занимают одинаковое количество памяти вне зависимости от длины самого списка. Такое возможно благодаря переиспользованию одних и тех же наборов элементов интерфейса (ViewHolder) для отображения разных элементов списка. Когда элемент списка скрывается с экрана, его ViewHolder перемещается в кеш и затем используется для отображения следующих элементов списка.

Когда RecyclerView извлекает ViewHolder из кеша, он запускает метод onBindViewHolder()твоего адаптера, чтобы наполнить его данными конкретного элемента списка. И тут происходит интересное: если метод onBindViewHolder() будет делать слишком много работы, RecyclerView не успеет вовремя сформировать следующий элемент для отображения и список начнет тормозить во время промотки.

Еще один пример. К RecyclerView можно подключить кастомный RecyclerView.OnScrollListener(), метод OnScrolled() которого будет вызван при промотке списка. Обычно его используют для динамического скрытия и показа круглой action-кнопки в углу экрана (FAB — Floating Action Button). Но если реализовать в этом методе более сложный код, список опять же будет притормаживать.

Ну и третий пример. Допустим, интерфейс твоего приложения состоит из множества фрагментов, между которыми можно переключаться с помощью бокового меню (Drawer). Кажется, что самый очевидный вариант решения этой задачи — поместить в обработчик нажатия на пункты меню примерно такой код:

// Переключаем фрагмент
getSupportFragmentManager
        .beginTransaction()
        .replace(R.id.container, fragment, "fragment")
        .commit()

// Закрываем Drawer
drawer.closeDrawer(GravityCompat.START)

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

Чтобы этого не происходило, переключать фрагмент нужно уже после того, как анимация закрытия меню закончилась. Сделать это можно, подключив к меню кастомный DrawerListener:

mDrawerLayout.addDrawerListener(new DrawerLayout.DrawerListener() {
    @Override public void onDrawerSlide(View drawerView, float slideOffset) {}
    @Override public void onDrawerOpened(View drawerView) {}
    @Override public void onDrawerStateChanged(int newState) {}

    @Override
    public void onDrawerClosed(View drawerView) {
      if (mFragmentToSet != null) {
        getSupportFragmentManager()
              .beginTransaction()
              .replace(R.id.container, mFragmentToSet)
              .commit();
        mFragmentToSet = null;
      }
    }
});

Еще один совсем не очевидный момент. Начиная с Android 3.0 рендеринг интерфейса приложений происходит на графическом процессоре. Это значит, что все битмапы, drawable и ресурсы, указанные в теме приложения, выгружаются в память GPU и поэтому доступ к ним происходит очень быстро.

Любой элемент интерфейса, показанный на экране, преобразуется в набор полигонов и GPU-инструкций и поэтому отображается в случае, например, промотки быстро. Так же быстро будет скрываться и показываться View с помощью изменения атрибута visibility (button.setVisibility(View.GONE) и button.setVisibility(View.VISIBLE)).

Смотри также:  Золотой фонд веб-разработчика. Самые полезные плагины для Atom и VS Code

А вот при изменении View, даже самом минимальном, системе вновь придется пересоздать View с нуля, загружая в GPU новые полигоны и инструкции. Более того, при изменении TextView эта операция станет еще более дорогой, так как Android придется сначала произвести растеризацию шрифта, то есть превратить текст в набор прямоугольных картинок, затем сделать все замеры и сформировать инструкции для GPU. А еще есть операция пересчета положения текущего элемента и других элементов лайота. Все это необходимо учитывать и изменять View только тогда, когда это действительно нужно.

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

Представь, что у тебя есть несколько вложенных друг в друга LinearLayout и некоторые из них к тому же со свойством background, то есть они не только вмещают в себя другие элементы интерфейса, но и имеют фон в виде рисунка или заливки цветом. В результате на этапе рендеринга интерфейса GPU будет делать так: заполнит пикселями нужного цвета область, занимаемую корневым LinearLayout, затем заполнит часть экрана, занимаемую вложенным лайотом, другим цветом (или тем же) и так далее. В результате во время рендеринга одного кадра многие пиксели на экране будут обновлены несколько раз. А это не имеет никакого смысла.

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

Поможет в этом инструмент отладки наложений. Он встроен в Android и находится здесь: Settings → Developer Options → Debug GPU overdraw → Show overdraw areas. После его включения экран перекрасится в разные цвета, которые означают следующее:

  • обычный цвет — одинарное наложение;
  • синий — двойное наложение;
  • зеленой — тройное;
  • красный — четверное и больше.

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

Overdraw здорового приложения и overdraw курильщика
Overdraw здорового приложения и overdraw курильщика
Overdraw здорового приложения и overdraw курильщика

Ну и несколько советов:

  • Постарайся не использовать свойство background в лайотах.
  • Сократи количество вложенных лайотов.
  • Вставь в начало кода Activity такую строку: getWindow().setBackgroundDrawable(null);.
  • Не используй прозрачность там, где можно обойтись без нее.
  • Используй инструмент Hierarchy Viewer для анализа иерархии своих лайотов, их отношений друг к другу, оценки скорости рендеринга и расчета размеров.

Systrace

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

Systrace — один из важнейших инструментов, которые ты должен освоить. Это трассировщик, который позволяет проследить, что происходит на устройстве во время работы приложения. В частности, он наглядно покажет, как происходит отрисовка каждого кадра, какие кадры были отрисованы вовремя, а какие системе пришлось выбросить.

Systrace можно запустить с помощью Android Device Monitor, который, в свою очередь, находится в меню Tools → Android в Android Studio. Открываем Android Device Monitor, дожидаемся, пока он обнаружит смартфон, выбираем приложение и нажимаем кнопку запуска трассировки.

В открывшимся окне запуска оставляем все настройки как есть и нажимаем Ok. Трассировка продолжится пять секунд, после чего будет сформирован HTML-файл с данными (в *nix это файл trace.html в домашнем каталоге). Его следует открыть в браузере.

Отчет о трассировке
Отчет о трассировке

При первом взгляде отчет Systrace вводит в замешательство — огромное количество непонятно что значащих данных. К счастью, большая часть этих данных тебе не понадобится, тебя интересуют только строки Frames, UI Thread и RenderThread.

Смотри также:  Трюки в поиске «Яндекса», не хуже Google

Frames показывает обновления экрана. Каждый кадр — это кружок одного из трех цветов: зеленый, желтый, красный. Зеленый означает, что кадр был отрисован за 16,6 мс, желтый и красный — отрисовка кадра заняла больше 16,6 мс, то есть фреймрейт падает. Сразу под строкой Frames находится строка UI Thread, с помощью которой можно проанализировать, какие шаги выполнила система для отображения фрейма.

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

Systrace позволяет оценить, на каком этапе произошла задержка отрисовки. Но он не скажет тебе, была ли проблема вызвана твоим кодом, и если да, то где конкретно узкое место. Чтобы найти проблему, вывод Systrace можно детализировать, добавив в код приложения маркеры, которые позволят оценить, сколько времени занимает выполнение твоего собственного кода. Пример трассировки метода onBindViewHolder:

@Override
public void onBindViewHolder(MyViewHolder holder, int position) {
    Trace.beginSection("MyAdapter.onBindViewHolder");
    try {
        try {
            Trace.beginSection("MyAdapter.queryDatabase");
            RowItem rowItem = queryDatabase(position);
            mDataset.add(rowItem);
        } finally {
            Trace.endSection();
        }
        holder.bind(mDataset.get(position));
    } finally {
        Trace.endSection();
    }
}

Есть более простой инструмент трассировки, встроенный в Android. Просто включи опцию Developer options → Profile GPU rendering → On screen as bars, и на экране появится график. По оси X — кадры, по оси Y — столбцы, отображающие длительность отрисовки каждого кадра. Если столбец выше зеленой черты — на отрисовку кадра ушло больше 16,6 мс.

Android Profiler

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

Запускаем Android Studio, кликаем на Android Profiler внизу экрана, затем на CPU и нажимаем красную круглую кнопку записи вверху экрана, останавливаем запись, когда нужно. Внизу экрана появится окно с отчетом.

CPU Profiler
CPU Profiler

По умолчанию отчет выводится в виде диаграммы, где по оси X отображается время, а по оси Y — вызываемые методы. Оранжевым помечены системные методы (API), зеленым — методы самого приложения, голубым — методы сторонних API, включая Java. На вкладке Flame chart — похожая диаграмма, в которой одинаковые методы объединены. Она удобна тем, что позволяет наглядно оценить, сколько всего времени работал тот или иной метод за весь период трейсинга.

Вкладки Top Down и Bottom Up показывают дерево вызовов методов, включая информацию о затраченном на их выполнение времени:

  • Self — время исполнения кода самого метода;
  • Children — время исполнения кода всех вызванных им методов;
  • Total — сумма Self и Children.

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

Выводы

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

 

Топливо для Андроида. Избавляем свое приложение от лагов, тормозов и долгих экранов загрузки

Всегда ваш, "Хай-теч вам в бок"