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

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

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

 


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

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

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

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


 


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

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

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

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

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

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

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

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

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

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

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

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

Другой вариант — перейти на язык 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)).

А вот при изменении 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 курильщика

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

  • Постарайтесь не использовать свойство 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.

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 и нажимаем красную круглую кнопку записи вверху экрана, останавливаем запись, когда нужно. Внизу экрана появится окно с отчетом.

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

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

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

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

 


Итоги

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

July 22, 2018
by @lizardsquad