Меня зовут Митя Куркин, я руковожу разработкой iOS мессенджеров Mail.Ru Group. Сегодня я расскажу о нашем опыте ускорения приложений на iOS. Высокая скорость работы очень важна для 99% приложений. Особенно это актуально на мобильных платформах, где вычислительные мощности и, соответственно, заряд аккумулятора весьма ограничены. Поэтому каждый уважающий себя разработчик стремится оптимизировать работу своего приложения с целью устранения различных задержек, из которых складывается общее время реакции.
Измерение
Прежде чем производить какие-либо манипуляции, нужно зафиксировать текущее положение дел. То есть замерить, сколько времени сейчас теряется в проблемных местах. Метод замера должен быть воспроизводим, иначе эти данные будет бессмысленно сравнивать с последующими достижениями. Как замерять? Ситуации могут быть разные, но секундомер у нас всегда есть. Правда, это наименее точный вариант.
Можно замерять с помощью профайлера. Если есть характерные участки на графике (спад или пик нагрузки), то можно мерить по ним. Такой вариант дает более точный результат. Кроме того, на графике будет видно влияние дополнительных факторов. Например, если замерять скорость работы всех процессов устройства, то можно выяснить, что делают остальные приложения и сказывается ли это на результат нашего замера. Если в профайлере зацепиться не за что, то можно мерить своими логами. Это может дать еще более точный результат, но для этого потребуется изменить приложение, что может некоторым образом сказаться на его работе. Также в этом случае не будет видно влияние дополнительных факторов.
Идеальный результат
Чтобы понимать, можно ли ускориться в конкретной ситуации, желательно заранее понять, каким может быть минимальное время. Наиболее быстрое решение — это рассмотреть работу аналогичной функции у приложения-конкурента. Это может дать ориентир, насколько быстро может выполняться такая операция. Необходимо оценить, позволяют ли выбранные технологии достичь желаемой скорости. Нужно убрать все, что только возможно, чтобы получить самый минимум выполняемой функции:
- отключить параллельно работающие процессы;
- заменить переменные на константы;
- вместо полноценной загрузки экрана показать только заглушку;
- оставить от сетевой операции только последовательность константных сетевых запросов;
- можно даже создать чистое приложение, выполняющее только эту константную функцию.
Если в этом случае мы получаем желаемую скорость работы, то дальше можно потихоньку возвращать отключенные элементы и смотреть, как это влияет на производительность. Если же даже после выполнения всех описанных процедур результат неудовлетворительный, значит нужны более радикальные действия: смена используемых библиотек, уменьшение объема трафика за счет его качества, смена используемого протокола и т.д.
Профилировщик
При оптимизации простое чтение кода может легко повести по ложному пути. Возможно, вам попадется некоторая «тяжёлая» операция, которую с трудом, но можно немного оптимизировать. И вот, потратив уйму времени, применив новейшие и моднейшие алгоритмы, вам это удается. Но при этом до идеала по-прежнему как до Китая. А может быть, даже стало хуже. Хотя на самом деле проблема может крыться в самых неожиданных местах, совершенно не вызывающих подозрения. Где-то могут выполняться совершенно не нужные действия, или это просто ошибка, приводящая к зависанию. Поэтому сначала нужно замерить, на что уходит время. Тем более что у нас есть такая возможность благодаря инструментарию от Apple.
Для ускорения работы приложения в первую очередь требуется Time Profiler. Его интерфейс довольно понятен: сверху график нагрузки на процессор, внизу дерево вызовов, показывающее какой метод сколько съел. Есть разбиение на потоки, фильтры, выделение фрагмента, различные сортировки и еще много всего.
Чтобы наиболее эффективно работать с этими данными, нужно понимать, как они вычисляются. Возьмем вот такой график:
При большом увеличении он выглядит так:
Профайлер измеряет расход времени за счет периодического опроса состояния приложения. Если во время такого замера оно использует процессор, то значит все методы из стека вызовов используют процессорное время. По общей сумме таких замеров мы получаем дерево вызовов с указанием затраченного времени:
Инструменты позволяют настраивать частоту таких замеров:
Получается, что чем чаще в замерах отмечается использование процессора, тем выше уровень на исходном графике. Но тогда, если процессор не используется, это время не сказывается на общем результате. Как тогда искать те случаи, когда приложение находится в состоянии ожидания какого-либо события, — например ответа на http-запрос? В этом случае может помочь настройка «Record Waiting Threads». Тогда при замерах будут записываться и те состояния, когда процессор не используется. В нижней таблице автоматически включится колонка с количеством замеров на функцию вместо затраченного времени. Отображение этих колонок можно настраивать, но по умолчанию показывается или время, или количество замеров.
Рассмотрим такой пример:
- (void)someMethod
{
[self performSelector:@selector(nothing:) onThread:[self backThread] withObject:nil waitUntilDone:YES];
}
- (void)nothing:(id)object
{
for (int i=0; i<10000000; ++i)
{
[NSString stringWithFormat:@"get%@", @"Some"];
}
}
Замер приложения с запуском такого кода даст примерно такой результат:
На рисунке видно, что по времени главный поток занимает 94 мс и 2,3%, а в замерах (samples) — 9276 и 27%. Однако разница не всегда может быть так заметна. Как искать такие случаи в реальных приложениях? Здесь помогает режим отображения графика в виде потоков:
В этом режиме видно, когда запускаются потоки, когда они выполняют какие-то действия и когда «спят». Кроме просмотра графика в верхней части можно также включить режим отображения списка замеров (Sample List) в нижней таблице. Просматривая участки «сна» главного потока, можно найти виновника подвисания интерфейса.
Не останавливайтесь на системных вызовах
Проводя замеры, очень легко можно упереться в системные вызовы. Получается, что все время уходит на работу системного кода. Что тут поделать? На самом деле, главное не останавливаться на этом. Пока есть возможность, нужно углубляться в эти вызовы. Если покопаться, то может запросто оказаться колбек или делегат, который вызывает ваш код, и ощутимый расход времени оказывается именно из-за него.
Отключайте
Итак, подозреваемый найден. Прежде чем переделывать, нужно проверить, как будет работать приложение совсем без него.
Бывает так, что потенциальных виновников торможения много, и замерять все это профилировщиком проблематично. Например, проблема хорошо воспроизводится только у некоторых пользователей и не всегда, а только в определенной ситуации. Чтобы быстро понять, в правильном ли направлении мы движемся, те ли места замеряем и оптимизируем, очень хорошо помогает отключение этих модулей.
Если уже все выключено, а до идеала далеко, то нужно попробовать двигаться с другой стороны. Создать пустое приложение и наращивать его функционал.
Учитывайте технические особенности устройств
Технические характеристики устройств меняются, и это также стоит учитывать. Например, начиная с iPhone 4S начали использовать многоядерные процессоры. Поэтому там использование многопоточности более эффективно за счет использования нескольких ядер. Однако на одноядерном процессоре это может замедлить получение конечного результата, поскольку мы все равно можем использовать только одно ядро, но при этом дополнительно тратим ресурсы на переключение контекста потока.
Будьте осторожны при подключении больших фреймворков
Чем больше и мощней механизм вы подключаете, тем больше он берет в свои руки. Тем меньше вы контролируете ситуацию. И, соответственно, приложение становится менее гибким. В нашем случае мы крепко сели на CoreData. Прекрасная технология. Всяческая поддержка миграций, FetchResultController, кеширование — очень соблазнительно. Но возьмем запуск приложения. Дли инициализации стека CoreData нужно как минимум загрузить базу и загрузить модель. Если использовать sqlite без CoreData — загрузки модели не требуется. В нашем случае модель содержит 26 сущностей. Ее загрузка занимает ощутимое время, особенно на старых устройствах, где скорость запуска ощущается наиболее остро.
Наши приложения активно развиваются, поэтому постоянно есть потребность в добавлении сущностей в базу. Благодаря удобному механизму миграции это не вызывает проблемы. Но вот их уже почти 40. В первую очередь это сильно сказывается на размере приложения. В сумме все миграции добавляют порядка 30%. Кроме того миграции работают последовательно. Значит чем их больше — тем дольше происходит миграция. И это опять сказывается на скорости запуска.
Мы также столкнулись с проблемой удаления. При нашей модели и достаточной большой базе удаление, затрагивающее все сущности, занимало порядка 10 минут. Включив волшебную опцию отладки CoreData SQLDebug, мы увидели огромное количество SELECT-ов, UPDATE-ов и чуть-чуть DELETE-ов. Основная проблема тут в том, что в NSManagedObjectContact нет метода типа deleteObjects. То есть объекты можно удалять только по одному, хотя SQL сам по себе умеет удалять через DELETE … WHERE someValue IN … Кроме того для удаления каждого объекта делается SELECT его ключа и только потом удаление. Аналогично происходит удаление зависимых объектов.
В нашей ситуации положение усугубляется еще и тем, что пользователи мобильных устройств как правило не дожидаются такого огромного срока и «убивают» приложение. В результате получается битая база.
Выводы
Как видите, путей по оптимизации скорости работы мобильных приложений довольно много. Но, обложившись цифрами и графиками, нужно не отрываться от реальности. Желательно сохранять работоспособность приложения, чтобы эффект от оптимизации можно было пощупать в боевых условиях. К сожалению, зачастую разработчики либо уделяют оптимизации недостаточно внимания, либо чересчур увлекаются этим занятием. Главное — помнить, что оптимизация должна давать ощутимые пользователем результаты. Оптимизация ради самой оптимизации, когда эффект получается гомеопатическим, является пустой тратой времени и сил. Всего должно быть в меру.
Автор: SClown