Производительность в iOS или как разгрузить main thread. Часть 1



    Есть разные приёмы и хитрости, которые помогают оптимизировать работу iOS-приложений, когда одна задача должна выполняться за 16,67 миллисекунд. Рассказываем, как разгрузить main thread и какие инструменты лучше подходят для отслеживания стека вызовов в нём.


    «Ребята, давайте представим, что вы сможете сократить время запуска на 10 секунд. Умножив это на 5 миллионов пользователей, ежедневно у нас будет 50 миллионов секунд. За год это составит порядка десяти человеческих жизней. Поэтому, если вы сделаете первичную загрузку на 10 секунд быстрее, вы спасёте несколько десятков жизней. Это действительно стоит того, не правда ли?»

    Стив Джобс о производительности (времени запуска компьютера Apple II).


    Статья основана на докладе iOS-разрабочика из Fyusion Люка Пархема, с которым он выступил на Международной конференции мобильных разработчиков MBLT DEV в прошлом году.


    MBLT DEV 2018 состоится в Москве 28 сентября. Билеты дешевле всего сейчас. По доброй традиции, пока Программный комитет отбирает доклады, вы можете купить early bird билеты на конфу. Воспользуйтесь этой возможностью сейчас. С 29 июня билеты будут стоит дороже.


    Потеря кадров


    На main thread выполняется код, который отвечает за ивенты типа касания и работу с UI. Он же рендерит экран. В большинстве современных смартфонов рендеринг происходит с частотой 60 кадров в секунду. Это значит, что задачи должны выполняться за 16,67 миллисекунд (1000 миллисекунд/ 60 кадров). Поэтому ускорение работы в Main Thread — важно.


    Если какая-то операция занимает больше 16,67 миллисекунд, автоматически происходит потеря кадров, и пользователи приложения заметят это при воспроизведении анимаций. На некоторых устройствах рендеринг происходит ещё быстрее, например, на iPad Pro 2017 частота обновления экрана составляет 120 Гц, поэтому на выполнение операций за один кадр есть всего 8 миллисекунд.


    Правило #1


    CADisplayLink — специальный таймер, который запускается во время вертикальной синхронизации (Vsync). Вертикальная синхронизация гарантирует, что на рендеринг кадра отводится не более 16,67 миллисекунд. В качестве проверки в AppDelegate можно зарегистрировать CADisplayLink в main run loop, и тогда у вас появится дополнительная функция, которая будет выполнять вычисления. Можно отследить длительность работы приложения и узнать, сколько времени прошло с момента последнего запуска этой функции.


    .


    Запуск происходит, когда появляется необходимость в рендере. Если выполнялось множество различных операций, которые перегружали main thread, то эта функция запускается с задержкой в 100 миллисекунд. Это значит, что выполнялось слишком много работы, и в этот момент произошла потеря кадров.


    Перед вами приложение Catstagram. Во время загрузки картинок приложение начинает тормозить. Мы видим, что частота кадров снизилась в определённый момент, и время загрузки длилось порядка 200 миллисекунд. Похоже, что-то занимает слишком много времени.


    .


    Пользователи не будут в восторге от подобного, особенно если приложение запускается на более старых устройствах, например iPhone 5 или устаревших моделях iPod и т.д.


    Time Profiler


    Полезный инструмент для отслеживания таких проблем — Time Profiler. Другие инструменты также полезны, но в конечном итоге в компании Fyusion в 90% случаев мы используем Time Profiler. Обычно проблемы в приложении связаны со ScrollView, участками с текстом и изображениями.


    Изображения важны. Мы декодируем JPEG-формат с помощью UIImage. Они делают это медленно, и мы не можем отследить их производительность напрямую. Это происходит не сразу после установки изображения в UIImageView, но можно увидеть этот момент через трассировку в Time Profiler.


    Форматирование текста – ещё один важный момент. Оно имеет значение, когда в приложении большое количество «сложного» текста, например на японском или китайском языках. Может потребоваться много времени, чтобы вычислить правильные размеры для строчек с текстом.


    Разметка интерфейса также замедляет рендеринг в приложении. Особенно это касается инструмента AutoLayout. AutoLayout удобен в использовании, однако он сильно замедляет работу приложения в сравнении с ручной разметкой. Приходится идти на уступки. Если AutoLayout замедляет работу приложения, возможно, настало время отказаться от него и попробовать другие виды разметок.


    Образец трассировки




    В данном примере дерева вызовов можно увидеть, какую работу выполняет CPU. Можно поменять вид трассировки, взглянуть на неё с точки зрения потоков, процессоров. Обычно самое интересное — разделение трассировки на потоки и наблюдение за главным потоком.


    Первичный анализ трассировки может показаться сложным. Не всегда получается разобраться сразу, что означает СFRunLoopDoSource0.


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


    Дерево вызовов


    Предположим, у нас есть очень простое приложение. В нём находится основная функция, которая вызывает несколько других функций. Суть работы Time Profiler заключается в том, что он делает снимки текущего состояния трассировки стека с периодичностью в одну миллисекунду (по умолчанию). Спустя еще одну миллисекунду он делает снимок трассировки. На ней вызывается основная функция, которая вызывает функцию “foo”, которая вызвала функцию “bar”. Первичная трассировка стека изображена на скриншоте ниже. Эти данные собираются воедино. Напротив каждой функции указывается число: 1, 1, 1.




    Это значит, что каждая из этих функций вызывалась один раз. Затем, через одну миллисекунду мы получаем ещё один снимок стека. В этот раз он выглядит точно также, поэтому все цифры увеличиваются на 1, и мы получаем 2, 2, 2.




    Во время третьей миллисекунды наш стек вызовов выглядит немного иначе. Основная функция вызывает “bar” напрямую. Поэтому к главной функции и функции “bar” добавляется ещё по единице, и их значение становится 3. Далее происходит разделение. Иногда главная функция напрямую вызывает “foo”, иногда “bar” вызывается напрямую. Такое происходило один раз. Одна функция вызывалась через другую.


    Далее, одна функция вызывала другую, которая вызывала третью функцию. Мы видим, что функция “baz” вызывалась дважды. Но эта функция настолько незначительная, что она вызывается быстрее одной миллисекунды.


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




    При переключении вызовов на консольный режим можно увидеть и сравнить все моменты снижения частоты кадров. В примере несколько раз происходила потеря кадров и выполнялись различные процессы.




    Если в macOS нажать alt-click, то это развернет секцию и вложенные секции, а не только выбранную. Они будут упорядочены по величине выполняемой работы. В 90% случаев на первом месте будет CFRunLoopRun, а после — функции обратного вызова.


    Это приложение целиком основывается на едином цикле исполнения задач Run Loop. Существует бесконечно повторяющийся цикл, и на каждой его итерации запускаются коллбэки. Если посмотреть на эти коллбэки, то можно выделить топ узких мест.


    Взглянув на эти вызовы подробнее, вы, скорее всего, не поймёте, что они делают. Это могут быть renders, image provider, IO.




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


    Есть измерители, которые в процентном соотношении показывают, какой объём работы выполняет конкретная функция или операция. Если мы посмотрим на данный пример, то увидим здесь значение — 34%. Это процесс Apple jpeg_decode_image_all. После изучения становится понятно, что декодинг JPEG-изображений происходит в main thread, и в большинстве случаев это является причиной потери кадров.




    Правило #2


    В общем случае, декодирование jpeg-изображений стоит делать в фоне. Большинство сторонних библиотек (AsyncDisplayKit, SDWebImage и т.д.) могут делать это по умолчанию. Если вы не хотите использовать фреймворки, то можно сделать декодирование вручную. Для этого вы можете написать расширение над UIImage, в котором создадите контекст и вручную отрисуете изображение.




    При выполнении этой операции можно вызвать функцию decodeImage не из main thread. Она всегда будет возвращать декодированное изображение. Не существует способа проверить, прошло ли декодинг конкретное UIImage-изображение, поэтому всегда придётся пропускать их через этот метод. Но если вы кешируете данные правильным образом, в системе не возникнет ненужных процессов.


    С технической точки зрения это менее эффективно. Применение класса UIImageView представляется оптимизированным и эффективным. Но он также выполняет аппаратное декодирование, поэтому здесь тоже есть свои минусы. При таком методе ваши изображения будут декодироваться медленнее. Но есть и хорошая новость — можно декодировать изображение вышеописанным способом не на main thread, а затем вернуться на main thread и настроить интерфейс.




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


    Предупреждения о нехватке памяти


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


    Такая проблема произошла в приложении Fyuse. Если бы я провёл декодинг всех своих JPEG-изображений на стороннем потоке, то в ряде случаев, например, на более старых моделях телефонов, эта система моментально бы сломала приложение. Это случилось бы из-за того, что сторонние потоки задач не реагируют на предупреждение о нехватке памяти от системы, вроде «Эй, удалите ненужные данные!». Происходит следующая ситуация: сначала вы размещаете все эти изображения на сторонних потоках, а затем приложение постоянно даёт сбой. Если сторонние потоки посылают сигналы main thread о происходящем в системе, то такая проблема не возникнет.


    Работа без сбоев




    По сути, main thread — это очередь, состоящая из процессов. Когда происходит работа со сторонними потоками, в Objective-C можно прописать команду performSelectorOnMainThread:withObject:waitUntilDone:. Благодаря ей, задачи встанут в конец очереди на main thread. Поэтому, если main thread занят обработкой уведомлений о нехватке памяти, вызов этой команды позволит дождаться момента, когда все уведомления будут обработаны, и только затем начать выполнение сложного процесса загрузки и размещения данных. В Swift это выглядит несколько проще. DispatchQueue.main.sync освобождает место на main thread.


    Вот ещё один пример. Мы освободили память и проводим декодинг изображений на сторонних потоках. Визуально пролистывание ленты стало гораздо лучше. У нас всё ещё происходит потеря кадров из-за того, что мы тестируем iPod 5g. Это одна из самых худших моделей для тестирования из тех, которые до сих пор поддерживают версии iOS 10 и 11.




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


    Конечно, не всегда можно с лёгкостью оптимизировать работу приложения. Но если у вас есть задачи, которые выполняются сравнительно долго, следует выносить их в фоновые потоки. Убедитесь, что эти задачи не связаны с UI, так как многие классы UIKit не потокобезопасны, то есть вы не можете создавать их в бэкгрануде.


    Используйте Core Graphics, если вам нужно обрабатывать изображения на стороннем потоке. Не скрывайте отображение системных библиотек. Помните о предупреждениях о нехватке памяти.


    Приглашаем на MBLT DEV 2018


    Приходи 28 сентября на 5-ю Международную конференцию мобильных разработчиков MBLT DEV 2018 в Москве. Первые спикеры уже на сайте, а последние early bird ещё в продаже. Цена на билеты вырастет 29 июня. Купи билеты сейчас по самой низкой цене.



    Читай о реализации пользовательского интерфейса в iOS, применении кривых Безье и других полезных инструментах во второй части статьи, которую мы опубликуем 28 июня.

    • +22
    • 4,6k
    • 3
    e-Legion 116,11
    Лидер мобильной разработки в России
    Поделиться публикацией
    Комментарии 3
      –1

      Видимо я что-то не понимаю, но отличить 30 Гц от 60 Гц и уж тем более 120 я как-то совсем не могу. Неужели кто-то это замечает?

        +1
        30 от 60 невооружённым глазом видно. 60 и 120 вот тут уже вопрос
          +1

          Да

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое