Меня зовут Максим, и я
На волне про соискателей скажу, что регулярно провожу собеседования мобильных разработчиков для компаний.
Среди кандидатов попадаются кадры, которые курят кальян прямо на Skype собеседовании, пытаются гуглить вопросы на ходу, хотят ЗП 180к за 3 месяца опыта, ведут себя так, как будто гоп-стопнули меня на улице (с соответствующей терминологией) и так далее.
Но в большинстве случаев, даже у адекватных middle специалистов, есть общий пробел: непонимание принципов асинхронного выполнения задач и работы аппаратного ускорения в iOS.
В этой статье я решил простыми словами рассказать про применение многопоточности в iOS, чтобы уже после первого прочтения можно было легко и с полным пониманием использовать полученные знания на практике.
(Если лень читать, то прилагается видео)
Материала будет два, один посвященный многопоточности (вот этот), а второй по аппаратному ускорению: как равномерно распределять нагрузку между CPU и GPU, чтобы получить идеально-плавный интерфейс.
Для тех, кому интересно не только научиться применять методики, но еще и постичь дзен, есть отличная статья. Она, правда, еще для Swift 3, но суть за это время не изменилась.
Так что, физика, бессердечная ты сволочь. Тайна раскрыта, расходимся.
Краткая практическая теория
Практическая теория — это такая теория, без которой ты не нацеленный на результат практик, а просто необразованный дикарь.
И перед тем как начать фантазировать на тему асинхронности, потоков и прочих премудростей, надо ответить на вопрос: зачем нужно что-то параллелить? Вот есть главный поток, почему бы все на нем не крутить? Надеюсь, для многих очевидный ответ: потому что все будет тормозить, але.
И чем главный поток такой особенный тогда? Его исключительность в том, что на нем происходит все взаимодействие с приложением извне: обработка касаний, уведомления, системные сообщения и прочее.
А основное в нашем случае это то, что на main thread висит весь responder chain:
UIApplication -> UIWindow -> UIViewController -> UIView.
Все нажатия на экран, все взаимодействие с пользователем, приходят именно туда.
Но ладно, пускай нажатия обрабатывается на главном потоке, но, черт побери, Apple, почему я не могу, как Клинт Иствуд, рисовать с двух рук-то?
Да потому что для общения нескольких потоков придется обмазаться толстым слоем штуковин для синхронизации, а это все ненужное барахло и давление на без того куцые ресурсы. Apple даже ввел Main Thread Checker, чтобы помочь избежать всяких экзотичных багов, вызванных негуманным обращением с главным потоком.
В общем, первое правило — это оставьте main thread для UI, а UI для main thread.
Ладно, а где же тогда делать все остальное?
В iOS есть полно инструментов для этих целей: треды, posix треды, gcd, operation queue.
У каждого есть свое применение, но в повседневной жизни, состоящей из банальных задач по типу: сходи на сервер, принеси, сохрани и выведи на экран, достаточно gcd и operation queue.
GCD — это библиотека Apple для параллельного выполнения задач. Состоит из выполняемых операций (задач) и очередей, которые содержат эти самые операции. Самая банальная FIFO коллекция с тасками. Конечно, там есть еще куча опций, но они нам пока не нужны.
NSOperationQueue — та же очередь, только высокоуровневая и ООП ориентированная. А по сути, просто красивая обертка над gcd, никаких функциональных преимуществ не имеет, хотя раньше и давала.
Выбор между тем и другим, в большинстве случаев зависит от вкуса, за редким исключением. Работайте с тем, с чем вам удобнее. Лично я предпочитаю gcd из-за лучшей управляемости и отсутствия дополнительного оверхеда.
Кстати, среди разработчиков зачастило такое мракобесие, что якобы NSOperationQueue больше не базируется на GCD, а специально переработан для iOS и потому быстрее/выше/сильнее. Но это отнюдь не так, процитирую яблоко:Apple:
Так что специальных преимуществ у NSOperationQueue перед GCD нет.
Приоритеты GCD & NSOperationQueue
Пробежимся по основным компонентам.
У каждой очереди есть такое понятие как приоритет, с которой она получает ресурсы. Называется это — quality of service, чаще всего употребляется аббревиатурой 'qos' или качество услуг.
Чем выше приоритет, тем быстрее и больше процессорного времени выделяется под задачи на этой очереди. Да-да, именно еще и быстрее, вы не ослышались. Система может оптимизировать пробуждение процессора, тем самым экономя энергию. Это полезно помнить, если работаете с low power mode, когда у пользователя садится батарейка.
Так хочется, чтобы это узнали авторы Яндекс.Такси. Ведь можно экономить батарейку таким простым способом, а не устраивать «майнинг биткоинов» у меня на айфоне.
Какие же есть приоритеты? Их несколько и надо все запомнить, чтобы не было бесконечно больно. А то многие говорят, что это якобы нигде не освящается, и типа вовсе не нужно.
И так приоритеты: userInteractive, userInitiated, default, utility и background.
Main не считаем, потому что это не приоритет, а отдельная очередь для главного потока. У нее, кстати, тоже есть приоритет: userInteractive. Так что, например, запустив шаманство с картинками в отдельной queue с приоритетом userInteractive вы получите неиллюзорные лаги, потому что начнется гонка за ресурсы. Меньшие проблемы, чем если просто запустить в main, но зато сложнее отлаживаемые, потому как лаги будут непостоянны.
(есть еще unspecified, но это вообще дикость, с которой Вы вряд ли когда-нибудь столкнетесь)
Если хочется понять, как именно происходит перекидывания операций между очередями — выше приводил ссылку на статью.
Так когда какую задействовать?
- userInteractive — не надо применять вообще. Она негласно зарезервирована главным потоком, как я уже писал выше. Apple определяет спектр ее применения как: операции, критические для взаимодействия с пользователем, занимающие не более доли секунды. Звучит как штучка для UI, не правда ли? На практике у меня была только одна задача, которая должна была конкурировать с интерфейсом по скорости и требующая хирургической точности, но она решалась не через gcd. Короче, userInteractive — это для богов из Apple, а не простых работяг как мы.
- userInitiated — локальные операции, требующие мгновенного результата. Например, сохранение чего-либо в базу перед переходом на следующий экран. Но, при этом, не блокирующее UI. Особо акцентируюсь на том, что именно локальные операции. Сеть сюда не входит.
Допустим, крутите вы индикатор загрузки посреди экрана и надо срочно что-то сделать, чтобы показать контент. На главном потоке, очевидно, это делать нельзя, потому что начнет сбоить gui, но и слишком в глубоко в фон закидывать нет смысла, потому что из всего интерфейса одна единственная крутилочка крутится. В этом случае используется userInitiated. - default — дефолтный приоритет. Apple расходится во мнениях сама с собой, в документации говоря, что не надо его юзать, а на WWDC наоборот, заявляет, что это наилучший приоритет для загрузки картинок и прочих сетевых коммуникаций. Поигравшийся с разными QoS могу сказать, что default лучше всего подходит для загрузки изображений или небольших файлов, которые влияют на восприятие приложения пользователем. Разница между utlity (следующий уровень) и default реально ощущается при работе с изображениями, особенно при пре-рендеринге. Default отрабатывает значительно быстрее, но при этом не конкурирует с интерфейсом за ресурсы. Моя рекомендация — всю сетевую бизнес логику и изображения оставлять в дефолт.
- utility — что-то не слишком приоритетное, но все таки нужное в ближайшее время. Например, обработка громоздких файлов или сложные манипуляции с базой, конвертация медиа и так далее. Проще говоря, когда надо сделать насущную задачу для приложения, но где пара лишних секунд ожидания роли не сыграют. Кстати, такие операции — первый кандидат на перенос в background режим при работе с lower power mode.
- background — самый овощной режим из всех. Как говорится, для тех, кто познал жизнь и никуда не торопится. Применять стоит при экономии энергии, либо для сверх-тяжелых операций. Типа загрузки толстенных файлов, бэкапы и прочее. А если вдруг пользователь включил lower power mode, а ваша операция и так была в background приоритете, то может ну ее нафиг сразу, а?
Практика в реальном мире
Говоря о применении, если вы используете для какой-то задачи third-party фреймворк, то большинство инструментов делают работу на той очереди с которой были вызваны, либо поддерживают явное ее упоминание. Если не получилось за 5 минут найти способа явно указать приоритет, то проще значит просто оборачивать операцию в dispatch_async и не переживать.
Главное, обратите внимание, что часто callback-и вызываются на главном потоке по неким исторически сложившимся причинам. Бывает делаешь запрос с default qos, а потом дернул в completion блоке сохранение в базу, забыв, что ты уже дома. И чешешь репу, почему это приложение еле едет.
Так что если нет уверенности, то ставим брейкпоинт в блоке и смотрим по стеку вызовов. В таких моментах лучше перепроверить, чем потом искать лаги через профайлер. Обожаю на собеседованиях спрашивать про профайлер.
Главный поток:
Любой другой:
В общем, обязательно надо обращать внимание на каком потоке идет действие. Сохранит много нервов и времени потом.
Еще один нюанс, который возникает, когда упарываешься по асинхронности: а как много надо выносить в отдельные операции? Где граница? Каковы последствия?
Философски, если что-то асинхронится, то это можно асинхронить. Но будем подходить более прагматично: если ваше приложение слагается из множества долесекундных операций, то сначала следует подумать, можно ли эти мелочи заведомо объединить в некой более крупной задаче? Если плодить отдельную операцию на каждый чих, то лагов станет только больше.
For example: есть у нас некая таблица с продуктами в магазине. Каждая ячейка — это цены, аватар, многострочное описание. Цена локализованная (символ рубля + форматирование), описание тоже (имеет некий префикс словесный). Как правило, составление локализованной строки делается прямо в момент установки значений в соответствующие лейблы.
Но ведь можно асинхронно это сделать? Сначала локализуем в фоне, а потом ставим в лейбл.
Так вот, хреновое это решение. Наилучшим вариантом будет для каждого объекта товара составить локализованные значения сразу после запроса на сервер, записав данные в соответствующие поля у сущности.
Особенно полезно будет еще и размеры этих самых полей заранее посчитать, записав в модель. Да, это нормально, несмотря на то, что выглядит непривычно.
В нашей команде уже давно принята такая практика — подсчитывать высоты ячеек заведомо при получении данных с сервера, сохраняя их в базу. Или в массив, если не используете БД, лишь бы это происходило предварительно и в фоне. Лучше пусть ваш пользователь будет лишнюю долю секунды лицезреть крутящуюся крутилочку, чем потом любоваться фризами.
И не нужно беспокоиться за объем хранилища. В текущей действительности любая память на айфоне — это дешевый ресурс, а процессорное время — дорогой. Стоит это помнить.
Вывод: подготавливайте данные для интерфейса заранее. Так дешевле и красивее.
И так как вы уже половину забыли, то вот вопросы, которые стоит себе задавать, чтобы перестать писать ерунду:
- Можно ли операцию сделать заранее в фоне и закешировать результат?
- Какой приоритет лучше подойдет для задачи?
- userInitiated: мелкие и срочные действия
- utility или default: сетевые задачи, рендеринг
- background: длительные процессы
- На каком потоке вызываются коллбэки? Нет ли лишней нагрузки на главный поток? (можно легко проверить через стек вызовов на брейкпоинте)
В следующей серии обсудим аппаратное ускорение. Звучит страшно, но будет легко.
P.S. Буду благодарен любому фидбеку по видео. Первый опыт, на каждую минуту уходило по часу буквально.
Комментарии (24)
alexwillrock
02.04.2018 12:10как вы рассчитываете ячейки? вы уже в базе храните параметры ячеек, что то вроде UserInfoCellModel, которая включает width, height, UserModel (другая модель, которая отобразиться) и height рассчитывается при создании модели?
по большей части, это имеет смысл при довольно объемном лайауте с обновлением ячеек или tableView.beginUpdates() / tableView.endUpdates(), в 99% случаем будет оверхедMehdzor Автор
02.04.2018 12:23В базе хранятся height, тип ячейки, нужные поля пред-обработанные. В общем, все что требуется для корректного отображения. Ширину можно не сохранять, если она фиксированная (кэп).
Даже без обновления это полезно, потому что меньше нагружается главный поток при скроллинге. Кроме банального быстродействия и экономии энергии, у вас появляется возможность накосячить с главным потоком где-нибудь в другом месте без последствий. Звучит немного странно, но на деле это ощутимо экономит время разработчика и снижает порог скилла для плавного интерфейса.
midday
02.04.2018 13:47А как решается вопрос с разными разрешениями, соотношениями… планшет/телефон там и прочее?
Mehdzor Автор
02.04.2018 13:56Достаточно считать все пропорционально экрану. А так как ваш iPhone 5 внезапно не превратится в X, то вычисленные значения будут актуальны между использованиями.
Если ваше приложение поддерживает ротацию, то сохраняйте два комплекта значений.
С точки зрения БД это можно организовать как угодно: сразу записывать в модель или сделать массив вспомогательных сущностей, которые дополнительно хранят информацию о критерии их применения, определяя в рантайме задействованный объект.
Разумеется, если приложение крайне динамическое, туда-сюда растягивается, то пред-расчет сделать сложнее или вообще не нужно. Так или иначе, каждая задача уникальна, и перед использованием того или иного подхода стоит проанализировать ситуацию.
alexwillrock
03.04.2018 06:07но это легко поломать, добавив Accessibility изменяемый размер шрифта. И простое изменение дизайна — отступы прибавить / убавить, добавить множитель для констрейнтов или приоритеты, а если там будет коллекция с интересным лайаутом… ИМХО, такое решение хорошо для построения ленты с предрасчетом высоты ячейки какой — либо ленты, ячейки которой надо уметь сворачивать / разворачивать и сохранять их стейт, но расчет самого АЛ никто не отменял.
Mehdzor Автор
03.04.2018 09:22Если что-то изменилось, то перерасчет в любом случае понадобится. В случае с Accessibility надо на уведомление всего лишь подписать, где и дергать нужный рычажок.
Говоря о сложном интерфейсе, то чем интереснее правила для лайаута, тем сильнее виден импакт на производительности. Это первый кандидат на автоматизацию.
У меня как-то был случай, когда ячейка и размер зависел от соседних ячеек. Сценарий распространялся на ситуацию, когда при удалении ячейки мог изменяться размер соседних. Уверен, вы представляете себе глубину норы. Все решилось тем же вызовом одной функции для перерасчета в нужный момент.
Хочу добавить, что у меня нет цели форсировать такое решение. Если вы считаете, что к вам это неприменимо — пожалуйста. Но я должен сказать, что оно имеет положительное влияние на скорость работы, а подавляющее большинство трудностей с ним несложно решаются.
Это мой практический опыт.
alexwillrock
03.04.2018 09:44не было цели спорить, но просто хотел узнать примеры и мотивы — где это хорошо работает) иногда для реализации какой либо фичи приходится делать страшные «костыли», но в тот момент и с тем багажом знаний — они были наиболее эффективны.
Встречался такой же сценарий за созависимость соседний ячеек от текущего размера и состояния, но тогда было удачное решение — производить некоторый расчет размеров внутри ViewModel, кэшировать эти размеры и отдавать в Collection View, и зачастую это зависит даже не от насыщенности UI эффектами, а бизнес логики работы общего контрола.
iStaZzzz
02.04.2018 17:10Спасибо за статью, расписано интересно, но есть несколько вопросов:
1. Вас не смущает что модель жестко привязана к UI?
2. Если дизайн выдаст экран с табличкой чуть уже остальных?Mehdzor Автор
02.04.2018 17:21- Модель и хранилище — разные вещи, пусть и тесно связанные обычно. Мы не заморачиваемся разделением данных модели и данных UI, потому что они в любом случае будут вместе использоваться. Но если принципиально иметь разделенную модель и UI, то можно сделать отдельный инстанс базы для UI компонентов и оттуда уже запрашивать инфу.
- Разницы нет. Станет уже — значит это будет учтено в расчетах. А если вы имеете в виду, что делать, если уже есть посчитанные размеры из прошлой версии, то варианта два:
- В миграции учитывать этот момент.
- Просто очищать базу.
А дальше как сами сочтете нужным.
iStaZzzz
02.04.2018 17:441. Заводить базу под UI звучит уже лучше
2. Вопрос в переиспользовании. Если ячейка используется в таблицах с разной шириной, или заголовок с разными шрифтами. Заводить разные записи в БД?
Не пробовали кстати замерять разницу между вычислением размеров по необходимости или запросы как базе? Неужели расчеты в tableView:heightForRowAtIndexPath и layoutSubviews настолько тяжеловесны что их лучше заменять базой данных?Mehdzor Автор
02.04.2018 18:01- Тут есть drawback в виде быстродействия. Я бы не стал так заморачиваться, потому как выигрыша это никакого не дает. Да, разделено, идеологически красиво. А смысл? Вход в код не облегчает, масштабирование только усложняет. Хоть на первый взгляд так и кашернее, но на деле — нет.
- Разная ширина с разными шрифтами — это разные ячейки. Если это одна сущность, то делается массив UI компонентов модели, где у каждой есть критерий применения.
Как более простой для управления вариант — это на индивидуальные экраны создать отдельный инстанс базы. - Так как обычно обычно вычисляемые данные хранятся вместе с моделью, то фетчинг пары полей UI-ных вообще несущественен. Да даже если разделять, то все равно мелочь.
Представьте, что вам нужно посчитать высоту текста. Вы для этого создаете атрибутивную строку, что уже не мало так, считаете ее размер через CoreText/TextKit/UIKit, что вообще ахтунг, потому как работа с текстом на CPU в iOS — одна из тяжелейших операций. И делаете это на каждую ячейку, каждый раз, когда дергается heightForRow. Считайте сами.
Slams
03.04.2018 16:25А согли бы вы отдельный материал написать про подсчет высоты ячеек и последующее ранение/использование этих данных? Либо отправить на какой-то источник почитать.
Сейчас в процессе изучения swift и параллельного написания приложения рецептов для сайта.
И как паз столкнулся с тем, что когда в экране рецепта прокручиваешь пошаговый рецепт, то приложение скроллится с небольшим дерганием, как раз при обчислении высоты для attributed текста.iStaZzzz
03.04.2018 16:46Метод делегата таблицы
tableView:heightForRowAtIndexPath
в нем просчитывается высота. Как пример можно гуглить любой код связанный с таблицами без автоматического подсчета высоты. Автоматический подсчет советую не использовать от слова совсем, самый простой и эффективный способ получить лагающий списокMehdzor Автор
03.04.2018 17:45На правах борца за истину — автоматический подсчет высот не всегда плох. Зависит от сложности ячейки и ее статичности.
Стоит всегда начинать с самого простого варианта, а усложнять по мере нарастания необходимости в оптимизации. Проще говоря, преждевременная оптимизация — это такое же зло, как и отсутствие оптимизации вовсе.
Mehdzor Автор
03.04.2018 17:43Материала такого не видел. Возможно, позже напишу.
Самый простой вариант — считаете высоту в момент получения данных о модели и сохраняете вместе с ней.
GDXRepo
03.04.2018 14:01Статья любопытная, спасибо. Все знакомое, но есть пара интересных подходов, в частности, к пред-расчету высот ячеек. Я предпочитаю выполнять расчет и размещение элементов в общем updateConstraints через SnapKit/Masonry, пользовались ли вы этим подходом, и если да, то чем он вам понравился либо не понравился? Расчет на скалярных абсолютных величинах я перестал использовать именно после перехода на полноценный autolayout в коде.
Mehdzor Автор
03.04.2018 18:30Autolayout не всегда достаточно производителен, особенно на сложных коллекциях и слабых устройствах. Поэтому приходится все делать ручками. Иногда достаточно снять с него ответственность за высоту, а иногда и целиком возвращаться к истокам с версткой на фреймах.
GDXRepo
04.04.2018 10:58Хм, я пока встречал лишь одно приложение с огромным количеством таблиц с большой вложенностью ячеек, где «автослои» существенно замедляли работу, в остальных случаях его более, чем достаточно, без лишнего усложнения серверной работы и пред-расчетов. Но я понял вашу точку зрения, спасибо.
Mehdzor Автор
04.04.2018 11:42Порог допустимой производительности везде разный. Где-то даже один потерянный кадр бросится в глаза, а бывает, что и 30 фпс всех устраивает. Это нормальный сценарий, целиком зависящий от ключевой аудитории и бизнес модели.
Но если мы обсуждаем идеальную производительность без швов, когда приложение в любой момент времени выдает 60 фпс, то нужно проводить более объективный и структурированный анализ.
Что это значит? Когда речь заходит о лагах, каждый полагается на свое субъективное восприятие производительности. Если мы хотим убедиться, что приложение действительно способно выдерживать нагрузку, то следует подойти более научно к процессу:
- Ввести критерий 'лага'. Им является потеря кадра. Если нет возможности замерить объективно фпс, то альтернативной шкалой может являться нагрузка на главный поток: когда в некий момент времени она приближается к 100% (от 90% и выше). Это альтернативный критерий. Замечу, что нагрузка может вызвана не только вычислениями и графикой, а так же блокировкой и ожиданием.
- Определить пользовательские сценарии. Как правило, принятой формой проверки служит скроллинг в динамических коллекциях, имеющих постраничную загрузку (которая на швах не должна вызывать пробуксовывания) и динамический контент в ячейках (изображения или что-то требующее дополнительной подгрузки после появления).
Темп скроллинга должен быть замерен в нескольких ритмах:
- Плавный. Когда за один жест контент смещается не более, чем на экран. Естественно, при проведении жеста нужно отпускать экран для инерционного перемещения.
- Резкий. Например, несколько жестов подряд, когда данные смещаются на несколько экранов.
- Ищущий. Максимальная возможная скорость перемещения, можно без остановки. Такой сценарий случается, когда пользователь что-то пытается найти в контенте.
Каждый сценарий должен быть проведен на разных устройствах: нельзя замерять производительность только на десятом айфоне, например. Если во всех случаях приложение уверенно показывает 60 фпс и/или отсутствие предельной нагрузки на главный поток, а так же визуально движение ощущается плавным, то можно считать стресс-тест пройденным.
Последовательный анализ исключает все заявления типа: 'У меня аутолейат никогда не лагает, что я делаю не так'
GDXRepo
04.04.2018 15:57Безусловно, все так. Я говорил о приемлемых сценариях работы на приложениях разного уровня сложности. Разумеется, все тестируется на разных поддерживаемых устройствах. Я лишь имею в виду, что гонка за производительностью не должна быть самоцелью (вы тоже это упоминали), оптимизация делается лишь после того, как все уже работает, а не наоборот. И необходимость этой оптимизации тоже для разных приложений и круга пользователей разная.
NightSilf
Спасибо, интересная статья.
Особенно про расчет высоты ячеек.
Mehdzor Автор
Забыл упомянуть еще один плюс пред-расчета высоты: экономия батарейки. Чем меньше вычислений происходит в хаотичном порядке (а тем более с высочайшим приоритетом), тем лучше.