2 posts tagged

podlodka crew

Устройство многопоточности в iOS

В 2000 году Apple выпустила открытую unix-like ОС Darwin, которая уже в следующем году послужит базой для первой версии Mac OS X — 10.0, которая, в свою очередь, в будущем станет прародителем всех операционных систем Apple, начиная от современных macOS и iOS, заканчивая watchOS в часах и audioOS в «умных» колонках.

🎓

Эта статья написана по мотивам сессии «Устройство многопоточности в iOS» проходившей в рамках третьего сезона Podlodka iOS Crew, спикером которой был Александр Андрюхин. Я позволили себе изменить последовательность повествования, а так же более полно раскрыть некоторые темы, а некоторые вовсе убрать.

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


Darwin построен на XNU — гибридном ядре, включающим в себя микроядро Mach и некоторые части ОС семейства BSD. В контексте этой заметке нам важно что Darwin получил от BSD модель процессов unix и модель тредов POSIX, а от Mach — слегка переосмысленное предствление процессов как задач.

Для работы с потоками ОС разработчикам доступна С библиотека pthread — первый слой абстракции в наших операционных системах. Несмотря на то, что использовать её в своём коде можно и по сей день, Apple никогда не рекомендовала использовать pthread напрямую. Уже с первых версий версий Mac OS, разработчикам была доступна абстракция Apple поверх phread — NSThread.

NSThread — второй слой абстракции, которая нам заботливо предоставила Apple. Помимо более привычного Objective-C синтаксиса для создания и управления потоками, корпорация предоставила RunLoop — цикл обслуживания задач и событий. RunLoop использует инструменты микроядра Mach (порты, XPC, ивенты и задачи) для управления потоком POSIX и может перевести поток в режим сна, если ему нечего делать и пробудить, когда появиться работа.

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

Grand Central Dispatch

С выходом iOS 4, Apple подняла разработчиков выше еще на один уровень абстракции и представила Grand Central Dispatch и вводит понятие очередей и задач для организации асинхронного кода. GCD — это высокоуровневый API, позволяющее создавать пользовательские очереди, управлять задачами в них, решать вопросы синхронизации и делать это максимально эффективно.

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

Помимо создании новой очереди вручную, GCD предоставляет доступ к главной очереди, на которой работает UI и доступ к нескольким системным (глобальным) очередям.

Очереди GCD бывают двух типов:

  • serial — последовательные
  • concurrent — параллельные

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

let queue = DispatchQueue("com.company.name.app", attributes: .concurrent)

Как уже было сказано, GCD предоставляет уже созданные, глобальные очереди, которые отличаются приоритетом:

  • global(qos: .userInteractive) — Для заданий, которые взаимодействуют с пользователем в данный момент и занимают очень мало времени.
  • global(qos: .userInitiated) — Для заданий, которые инициируются пользователем и требуют обратной связи.
  • global(qos: .utility) — Для заданий, которые требуют некоторого времени для выполнения и не требуют немедленной обратной связи.
  • global(qos: .background) — Для заданий, не связанных с визуализацией и не критичных ко времени исполнения.

⚠️ Все глобальные очереди — очереди с параллельным выполнением задач.

Постановка задачи в очередь

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

...
DispatchQueue.global().async {
   processImage()
}
doRequest() // выполнется сразу, не дожидаясь processImage()

А в случае с синхронной постановкой, код, следующей за ней, не продолжит своё выполнение, пока не будет выполнена поставленная в очередь задача.

...
DispatchQueue.global().sync {
   processImage()
}
doRequest() // будет ждать processImage()

DispatchWorkItem

DispatchWorkItem — специальный класс GCD, более объектно-ориентированную альтернативу замыканию (блоку) для постановки задачи в очередь. В отличии от обычной постановки задачи в очередь, DispatchWorkItem имеет возможность:

  • указать приоритет задачи
  • получить уведомление о завершении задачи
  • отменить задачу

☝️

Важно понимать, что отмена задания работает до момента старта задачи, то есть пока она находиться в очереди. Если GCD начал исполнять код в блоке DispatchWorkItem отмена задания не приведет ни к какому результату, код продолжит своё выполнение.

NSOperation

GCD представляет удобную абстракцию для написания асинхронного кода, как с точки зрения идеи, так и с точки зрения синтаксиса. Но так было не всегда. В Objective-C (да и в первых версиях Swift) оперирование очередями и задачами было не таким удобным как в современном Swift да и вообще шло вразрез самому названию языка программирования. Apple необходимо было предоставить объектно ориентированную альтернативу GCD, и она это сделала представив NSOperation.

NSOperations — это по сути те же очереди, вместо DispatchQueue здесь OperationQueue, а вместо DispatchWorkItem — Operation. Но помимо ООПшного синтаксиса API Operations предоставляет два крутых перимущества:

  • Возможность указывать максимальное кол-во выполняемых одновременно задач в очереди.
  • Указывать зависимые операции, выстраивая таким образом иерархию операций. В таком случае операция пойдет на выполнение только тогда, когда все операции, на которых она зависит, завершатся.

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

class CustomOperation: Operation {
    var outputValue: Int?

    var inputValue: Int {
        return dependencies
            .filter({ $0 is CustomOperation })
            .first as? CustomOperation
            .outputValue ?? 0
    }
    ...
}

let operation1 = CustomOperation()
operation1.start()

let operation2 = CustomOperation()
operation2.addDependency(operation1)
operation2.start()

Ошибки и проблемы

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

Race Condition или состояние гонки

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

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

  1. считать значение переменной из общей памяти в регистр
  2. увеличить значение в регистре на 1
  3. записать значение из регистра обратно в общую память

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

💡

Самым серьёзным последствием Race Condition считается кейс ПО медицинсокого аппарата для лучевой терапии Therac-25. Состояние гонки приводило к неправильным значениям в переменой, используемой для определения режима работы лучевого механизма.

Deadlock

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

Разберем на примере. Допустим задача, в потоке А блокирует доступ к некому ресурсу А, пусть это будет некий SettingsStorage. Задача А блокирует доступ к стораджу, читает оттуда некоторые значения, и что-то вычисляет. В это время стартует задача B и блокирует доступ к ресурсу B, пусть это будет база данных. Чтобы выполнить некоторые вычисления задаче В тоже потребовался доступ к SettingsStorage и задача начинает ждать, когда задача А его освободит. В это время, задаче А понадобился доступ к БД, но он уже заблокирован задачей В. Происходит взаимна блокировка: задача А ожидает БД, которая заблокирована задачей В, которая ожидает сторадж, заблокированный задачей А.

Инверсия приоритетов

Инверсия приоритетов — ошибка, приводящая к смене приоритетов у потоков, которой не предполагался разработчиком.

Пусть у нас есть всего две задачи с разным приоритетом и всего 1 ресурс, пусть это снова будет БД. Первым в очередь помещается задача с низким приоритетом. Она выполняет свою работу и в момент времени Т1 ей понадобилась БД и она блокирует доступ к ней. Почти сразу же после этого, стартует высокоприоритетная задача и вытесняет низкоприоритетную. Все идет по плану до момента Т3, где высокоприоритетная задача пытается завладеть БД. Так как ресурс заблокирован, задача высокоприоритетная задача переводится в ожидание, а низкоприоритетная получает процессорное время. Временной промежуток T3-T4 называют ограниченной инверсией приоритетов. В этом промежутке наблюдается логическое несоответствие с правилами планирования — задача с более высоким приоритетом находится в ожидании в то время как низкоприоритетная задача выполняется.

Устройство UI в iOS

Как бы мы не любили UIKit, это всего лишь еще один UI фреймворк позволяющий облегчить для разработчиков процесс создания интерфейса для взаимодействия с пользователем. По сути, это I/O фреймворк для диалога с пользователем вашего приложения.

🎓

Эта заметка написана по мотивам сессии «Устройство UI в iOS» проходившей в рамках второго сезона Podlodka iOS Crew спикерами которой были @a_rychkov и @ antonsergeev88, которые в свою очередь вдохновлялись книжкой iOS Core Animation Advanced Techniques.

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

Ребята — топ, подлодка — топ, книжка — топ. Рекомендую!

Input

UIKit содержит в себе все необходимые компоненты для предоставления доступа к девайсам, через которые пользователь общается с вашим приложением. Это и акселероментры, и хардварные кнопки, внешние клавиатуры, специальные устройства ввода для людей с ограниченными способностями, мыши и карандаши (Apple Pencil).

Несмотря на то, что UIKit содержит в себе огромное кол-во функциональности, его размер исчисляется в десятках килобайт. Причиной тому является факт, что UIKit в современном iOS это по сути umbrella header, предоставляющий единую точку импорта.

Не стоит забывать, что помимо перечисленных выше устройстов ввода, UIKit получает и обрабатывает массу информации от системы, начиная с низкоуровневых событий жизненного цикла приложения и memory warnings, заканчивая Push-уведомлениями уровнем повыше.

Для того чтобы эффективно обслуживать такое большое количество входящих источников событий UIKit’у нужен Event Loop, который мы привыкли называть RunLoop. Тут UIKit вводит понятие главного потока, последовательного обслуживающего входящие источники и наш код. Принято считать, что главный поток это что-то неотъемлемое, что есть у приложения, но на самом деле — это абстракция, которую вводит и предоставляет именно UIKit.

Может показаться, что знание RunLoop’а — это что-то хардкорное и вовсе не нужное обычным разработчикам знание, но это не так. Понимание того, как UIKit обслуживает входящие события и открисовку UI важно для некоторых оптимизаций. Например, довольно частой задачей может быть добавление таймера для некоторых целей. Опытные разработчики могли встречаться таким эффектом, что таймер работает корректно и отсчитывает время до тех пор, пока пользователь не начинит скролить таблицу. В этот момент таймер просто перестаёт работать. Дело тут вовсе не в нехватке ресурсов девайса, а в том, что все таймеры обслуживаются RunLoop’ом, который в момент скрола переводится UIKit’ом в режим UI Tracking Mode. В этом режиме он отдает приоритет отрисовке UI, оставляя в очереди события из некоторых источников.

☝️

Чтобы таймер работал корректно, его нужно создавать не с помощью class-метода Timer.scheduledTimer, а обычным созданием объекта и добавлением его в RunLoop в специальном режиме .commonModes:

let timer = Timer(timeInterval: 1.0, ...) RunLoop.current.add(timer, forMode: .commonModes)

Output

С «выходом» у UIKit все чуть попроще. Нам доступен экран и Haptic. Следуя определению UI фреймворка можно было бы возразить, что пользователь может взаимодействовать с приложением и с помощью звука, и было бы логично отнести эту часть взаимодействия тоже в UIKit. Но в силу сложности работы с аудио, разработчики Apple выделили это в отдельный фреймворк Core Audio.

Основную часть времени работы над пользовательским интерфейсом разработчик, так или иначе тратит на графический интерфейс. Работая с графикой в iOS, как и на большинстве других платформ, мы имеем дело 2D пространством и прямоугольниками, которые как-то комбинируются и располагаются на экране. Сама абстракция прямоугольных областей очень удобна: с одной стороны это очень понятная схема для разработчиков, с другой стороны, очень понятная хардварной части и GPU. Работая с такими прямоугольниками перед разработчиком всегда стоит две задачи: расположить эти элементы на экране и нарисовать их.

Layout

Для решения первой задачи UIKit использует тривиальную и очень удобную структуру данных — дерево. Ни для кого не секрет, что view’шки можно организовывать в иерархию, бесконечно добавляя subview для subview. В конечном мы будем иметь дело с обычным деревом. Его легко и просто обходить, а использование такой структуры нам дает классную возможность верстать относительно, указывая значения координат родительского элемента, а не абсолютное значение на экране.

Здесь можно возразить и сказать, что мы уже давно не верстаем на фреймах, а используем autolayout или 3rd-party библиотеки для верстки. С этим трудно не согласиться, но все системы верстки для iOS сводятся к одному — в конечном итоге они проставляют фреймы, разница только в том когда они их считают и в какой момент присваивают элементам.

☝️

Помимо ручного расчета абсолютных величин для фреймов или использования autolayout для верстки существует еще и третий встроенный в iOS метод верстки. Если спуститься на уровень ниже от UIView касаемо отрисовки элементов, мы попадем на слой Core Animation, который позволяет c помощью свойства anchorPoint спозиционировать элементы используя относительные координаты и указывать позицию элемента в процентах от родительской.

Drawings

Расположить прямоугольники в нужном порядке и в нужном месте — только половина дела. Теперь в них нужно еще и что-то нарисовать. Для решения этой задачи Apple разработала целый фреймворк, первоначально назвав его LayerKit, а после переименовала в Core Animation.

☝️

Название Core Animation часто вводит в заблуждение — многие думают что этот фреймворк исключительно для создания анимаций, но это не так. Дело в том, что он ответственный вообще за любое отображение всего и вся на экране устройства, но спроектирован таким образом, что по-умолчанию любые изменения пытается анимировать. В отличии от большинства других, где нужно писать дополнительный код чтобы сделать что-то анимировано, в Core Animation нужно писать код, чтобы изменения прошли без анимации.


Core Animation предоставляет абстракцию, называемую слои. Почти любая UIView содержит в себе слой CALayer, который используется для отрисовки графического интерфейса. Слои, как и view, организуются в иерархию. Тут следует уточнить: несмотря на то, что принято считать именно UIView строительным кирпичиком UI, она по сути является фасадом для CALayer. При добавлении дочерней view, под капотом происходит добавление дочернего слоя на родительский. Все изменения frame, bounds, center, backgroundColor и многих прочих просто проксируются в CALayer.

Таким образом UIView разделяет отвественности: иерархия UIView ответственна за User Interaction, а иерархия CALayer за графическое представление.

☝️

Core Animation используется не только на iOS с UIKit для UIView, но и на macOS с AppKit с её NSView. В macOS система коодинат отличается от iOS: начало ее коодинат — нижний левый угол, против верхнего левого в iOS. Для кросплатформенной работы Core Animation Apple предоставляет свойство geometryFlipped у CALayer. Система коодинат macOS является системой по умолчанию, а UIKit проставляет geometryFlipped = true всем слоям при создании. Но возможны случае, когда созданому слою нужно будет указать значение этого свойства вручную, например, при добавлении слоёв на слой с видеоплеером.


Как уже говорилось ранее, Core Animation вводит понятие слоёв, из которых можно собрать визуальное представление программы. Самый базовый класс, CALayer позволяет только закрасить себя каким-то цветом или отобразить CoreGraphics контент. Для решения более сложных задач существуют специализированные слои, такие как CAShapeLayer, CATextLayer, CAGradientLayer и другие. Эти типы слоёв позволяют решить ту или иную задачу эффективным способом, проводя рисование на GPU.

💡

Тут стоит прояснить разницу между использованием специализированных слоёв и рисованием произвольной графики, используя метод UIView draw(in:). Как уже было сказано ранее, специализированные слои позволяют отрисовать контент оптимизированным способом на GPU, в то время как используя draw(in:) разработчик будет прибегать к рисованию с помощью CoreGraphics, который работает на CPU. Такой подход может приводить к фризам UI. Конечно, CoreGraphics можно пользоваться не из главного потока (не забывая то, что он не потокобезопасный), но стоит всегда помнить что он загружает CPU.

Animations

Какие бы задачи разработчики не решал с помощью CoreAnimation, он неизбежно касается вопроса анимаций: если задать какое-либо свойство CALayer (или его специализированных наследников) и это свойство окажется анимируемым, то изменения получиться увидеть только во времени.

Implicit Animations

Так происходит, потому что CoreAnimation запускает неявные анимации, делая это автоматически, без каких-либо усилий разработчика. Чтобы понять почему так происходит, нужно сначала рассказать про CATransaction. CATransaction — это контейнер, который инкапсулирует группу анимаций, управляет их длительностью и таймингом. UIKit создает корневой CATransaction в начале каждого вращения RunLoop’а, а в конце отправляет его на рендер. Именно по этому, любое изменение свойств слоёв «упаковано» в анимацию. Довольно часто стандартная анимация может не подходить разработчику, в том случае можно создать свой CATransaction, настроить скорость и указать тайминг функцию.

Описанная логика работы CALayer идет вразрез факту о том, что UIView является всего-лишь прокси для слоя. Ведь при изменении frame у UIView его положение и размер меняются мгновенно, не анимированно, а по логике должно перекинуться на слой и тот должен санимироваться. Тут дело в том, что корневой слой UIView ссылается на этот view как на делегата. И при любом изменении свойства, слой спрашивает нужно ли ему анимировать это свойство, вызывая метод делегата action(for:forKey:). View будет отвечать nil’ом на все изменения, выполняемые не в блоке анимации UIView.animate(...), таким образом блокируя анимации при простановки различных свойств.

💡

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

Мы можем из кода создать дочерней слой, добавить его к основному через addSublayer() и после санимировать UIView через

UIView.animate(withDuration:5)

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

⚠️ UIView может быть делегатом только у одного слоя. Несмотря на то, что мы можем из кода задать эту view делегатом у дочернего слоя, работать это не будет и очень скоро приведет к падению приложения.

Explicit

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

  • CABasicAnimation — обычная анимация, интерполирующая значение между fromPoint и toPoint
  • CAKeyFrameAnimation — анимация, интерполирующая значения между двумя ключевыми кадрами, заданные с помощью массивов values и keyTimes
  • CASpringAnimation — пружинная анимация

Анимация будет изменять presentationLayer анимируемого слоя. Это копия этого слоя, которая отражает его состояние в конкретный момент времени. Если вы хоть раз применяли анимации к слою, то вероятно знаете, что пока слой анимируется, значения анимируемых свойств у слоя не меняются. Дело в том, что «спрашивая» у слоя значения свойств мы «спрашиваем» значения свойств его модели. Использование же presentationLayer позволяет узнать эти значения в конкретный период, такие, какие они сейчас на экране. Это может быть полезно для нескольких кейсов:

  • Остановка анимации с сохранением текущего состояния (просто удалив анимацию слой вернется к значениям из его модельного представления)
  • Бесшовная смена анимации (для старта новой анимации нужны значения fromValue из presentation слоя)
  • Корректная обработка нажатий на анимируемый элемент (во время анимации hitTest(_:with:) (точнее point(inside:with:)) будет опираться на значения фрейма из модельного представления, и чтобы верно обрабатывать нажатия, необходимо будет переопределить point(inside:with:) для работы с презентационным слоем)

💡

Именно по этой причине после окончания анимации слой возвращается к исходным значениями, если не сменить у анимации значение свойства isRemovedOnCompletion. При установке этого свойства в false конечные значения анимируемых свойств сохранятся в модельном представлении слоя.

☝️

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

Управление анимациями

Анимациями в Core Animations можно управлять. Базовым кейсом является постановка анимации на паузу и возобновление, но таких методов «из коробки» CA не предоставляет. Взамен этого, разработчикам доступно управление временем и скоростью анимаций. Установив скорость слоя в 0, мы можем поставить анимацию на паузу, вернув исходное значение — продолжить её выполнение.

Скорость анимации и время в CA являются относительной величиной и зависят от скорости родителя. Время корневого слоя равно времени CACurrentMediaTime() а для всех его дочерних элементов будет вычисляться относительно иерархии скоростей. Таким образом можно замедлить или ускорить все анимации в приложении, изменив скорость у корневого слоя — слоя UIVindow.

Помимо скорости у анимации есть тайминг-функция. Эта функция определяет как прирастает виртуальное время относительно реального.

Разработчикам доступны несколько видов тайминг-функций «из коробки».

Тайминг-функции, можно создавать самостоятельно, описывая контрольные точки кривой Безье, но стоит помнить, что для тайминг-функции она может содержать только 2 перегиба, что не позволяет сделать сложные эффекты, типа пружинной анимации. И именно поэтому в CA существует отдельный класс для такой анимации.

 No comments   2020   iOS   podlodka crew   user interface   основы   статья