4 posts tagged

статья

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

Копаем внутрь SpringBoard

Я, наверное как и многие iOS и macOS разработчики, каждый год жду WWDC чтобы увидеть новые API, новые инструменты и улучшения существующих. Но помимо всего, связанного с разработкой, я жду саму ОС — хочу увидеть что для меня, как для обычного пользователя ОС, изменилось.

В этом году произошло два больших изменения домашнего экрана которые у всех на слуху: добавили возможность размещать виджеты и представили библиотеку приложений. В первый же день я удалил весь хлам, бережливо расфасованный по папочкам «Other», «Utility» с домашнего экрана, оставив только то, чем пользуюсь каждый несколько раз в день на регулярной основе. Я уже давно не ищу глазами иконку нужного приложения, а пользуюсь поиском Spotlight, поэтому Apps Library стала для меня фишкой номер один в этом релизе.

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

Немного про блюр в iOS

Немного отвлечемся и поговорим про блюр. Несмотря на то, что Apple предоставляет разработчикам богатое API, позволяющее сделать блюр и другие эффекты для изображения, возможность делать это с живым UI существенно ограничены. Конечно всегда можно отрендерить слой, наложить на изображение эффекты и фильтры и показать это в ImageView, но как несложно догадаться, производительность такого решения будет оставлять лучшего. Всё, что остаётся — использовать дарованный с барского плеча UIVisualEffectView, который предоставляет возможность сделать эффект матового стекла с помощью блюра и поиграться с Vibrancy эффектом.

☝️

Несмотря на то, что Apple предоставляет разработчикам богатое API, позволяющее сделать блюр и другие эффекты для изображения, возможность делать это такое с живым UI существенно ограничены.

Начинаем раскопки

Итак мы поняли, что из коробки сделать ничего не получиться. Что делать, куда копать?

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

Первым делом определимся где будем искать. Библиотека App Library находится на домашнем экране, значит нам нужно найти что-то связанное с процессом SpringBoard — приложением, которое ответственное за домашний экран, запуск приложений и всё с этим связанное. Попробуем его найти:

Удача! Попробуем загрузить бинарник в Hopper Disassembler чтобы посмотреть что там внутри. Надежды на то, что внутри что-то полезное мало, т.к. размер бинарника всего 140 КБайт. Дизассемблер встречает нас диалоговым окном с просьбой выбрать архитектуру, т.к. бинарник жирный.

☝️

Ага! Значит Apple, начиная с Xcode 12 поставляет «потроха» скомпилированные под классический x86_64 и под новый ARM (Apple Silicon). Сразу становится понятным почему Xcode 12 занимает так много места!

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

Приватные фреймворки

Много анализировать не приходится, «кишки» SpringBoard’а находятся в одноименном приватном фреймворке. Вернемся вверх по дереву файлов до iOS.simruntime и поищем его относительно этой директории. А вот и папка с приватными библиотеками и нужный нам фреймворк! Грузим в дизассемблер.

Сразу попытаемся найти какие-нибудь символы, связанные с AppLibrary. Что-то есть, но не жирно. Поиграемся с поиском попробуем найти вью контроллер этой библиотеки. Есть зацепка! По строке LibraryViewController находится сеттеры и геттеры этого контроллера для корневого SBIconController’а (не трудно догадаться его назначение), но реализацией тут не пахнет. Если её тут нет, значит она есть где-то в другом фреймворке, другого не дано. Попробуем посмотреть с otool.

Ого! Этот фреймворк использует 170 внешних зависимостей! Методом перебора не справимся, нужен другой способ. В сегменте Mach-O файлов (коим является бинарник фреймворка) есть сегмент External Symbols, который содержит список внешних символов, которые содержаться во внешних библиотеках. Возвращаемся в Hopper и попробуем найти символ используя список отфильтрованный по libraryviewcontroller.

Вот и символ _OBJC_CLASS_$_SBHLibraryViewController, но нажатию на который мы можем увидеть, что объявлен он в соседнем фреймворке SpringBoardHome. Грузим его в Hopper! Теперь найдем и покопаемся в классе SBHLibraryViewController.

Ноете что у вас MassiveViewController? Взгляните на заголовок этого класса: 147 методов вам готовы насмеять в лицо вашему вьюконтроллеру:)

☝️

Сдампить все все методы всех Objective-C классов внутри Mach-O файла поможет class-dump.


Ладно, первым делом глянем метод init, переключившись в режим псевдокода — Hopper настолько хорош, что может попытаться сделать человекопонятный псевдокод из машинного.

Первая зацепка

Метод init дергает initWithCategoryMapProvider: который по сути инициализирует родительский контроллер SBNestingViewController через initWithNib:bundle: и определяет очередь неких событий. В initWithNib:bundle: передаются два nil’a что говорит о том, что инициализация вью происходит в коде. Вспоминаем жизней цикл вью контроллера (наканец-то пригодилось!) — для загрузки вью обычно используется loadView, посмотрим что там.

Ага, метод переопределен и тут вызывается _setupIconTableViewController.

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

Находится три потенциальных класса SBHIconLibraryTableViewController, SBHLibraryPodFolderController, SBHLibrarySearchController. Самым перспективным кажется последний, т.к. строка в топ баре, который я хочу повторить есть поиска. Увы, никаких зацепок найти не удаётся. Следующим на очереди был SBHLibraryPodFolderController, но и там ничего интересного найти не удалось. Наверное я бы искал еще долго, если бы по счастливой случайности/запарке я не стал стирать название целиком, а стер бекспейсом только слово Controller. Оказывается, есть еще SBHLibraryPodFolderView у которого есть метод-геттер navigationBar! Да! Это то, что нам нужно; похоже мы нашли нужный нам класс — SBHFeatherBlurNavigationBar!

И почему я раньше просто не поискал по строке Blur?! Ладно, не время себя винить, глянем что внутри. Первым делом видим initWithFrame, где создается SBHFeatherBlurView методом initWithRecipe: с параметром 0x2.

Hoper в сторону! Настало время экспериментов. Создадим новый проект в Xcode предварительно указав язык ObjectiveC. Накинем UIScrollView и какой нибудь контент для эксперимента. Не будем напрямую линковать фреймворк, загрузим его в рантайме.

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


#import "FeatherBlurView.h"
#import 

#define SBH_PATH @"/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/SpringBoardHome.framework/SpringBoardHome"

// Объявим интерфейс класса из SpribngBoardHome для удобной инициализации
@interface SBHFeatherBlurView : UIView
- (instancetype) initWithRecipe:(int)arg1;
@end

@implementation FeatherBlurView

+ (void)load {
    const char *path = [SBH_PATH cStringUsingEncoding:NSUTF8StringEncoding];
    dlopen(path, RTLD_NOW);
}

- (instancetype) init {
    self = [super init];
    if (self) {
        SBHFeatherBlurView *view = [NSClassFromString(@"SBHFeatherBlurView") alloc];
        view = [view initWithRecipe:0x2];
        view.translatesAutoresizingMaskIntoConstraints = NO;
        [self addSubview:view];
        [[view.topAnchor constraintEqualToAnchor:self.topAnchor] setActive:YES];
        [[view.bottomAnchor constraintEqualToAnchor:self.bottomAnchor] setActive:YES];
        [[view.leftAnchor constraintEqualToAnchor:self.leftAnchor] setActive:YES];
        [[view.rightAnchor constraintEqualToAnchor:self.rightAnchor] setActive:YES];
    }
    return self;
}

@end

Первая победа!

📦 Тестовый проект доступен на GitHub

Йоу! Всё получилось и работает как я и хотел! Можете скачать тестовый проект и поиграться самостоятельно.

🖖

Время подвести некоторые выводы и подчеркнуть интересные факты

  • Изучать внутренности ОС не так сложно и очень интересно
  • Знания Objective-C и жизненного цикла вью контроллера могут пригодиться в 2k20
  • Все фреймворки и утилиты внутри Xcode поставляются в виде жирных бинарников, собранных под ARM и x86_64, это частично объясняет увеличенный почти вдвое размер Xcode
  • Несмотря на 2020 год и актуальный Swift версии 5.4, Apple активно использует Objective-C в системных фреймворках. Даже код для управления виджетами (которые, на секунду SwiftUI-only) написан на Objective-C
  • В Apple не бояться Massive View Controller и глубокого наследования


Но неужели так всё просто и такой блюр можно использовать в своих проектах?! К сожалению, нет. Дело даже не в использовании приватных библиотек, что запрещает Apple, а дело в том, что у нас в принципе нет возможности использовать внешние фреймворки находясь в песочнице. То есть даже сейчас запустить на девайсе это не получиться. Но это не повод опускать руки! Уже в следующей статье мы поковыряемся во внутренностях этой вью и попытаемся воссоздать её так, чтобы код можно было запустить и на девайсе! Stay tuned!

iOS 15

SBHFeatherBlurView был перемещен в новый фреймворк SpringBoardFoundation и переименован в SBFFeatherBlurView.

Устройство 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   основы   статья

Продвинутая отладка в Xcode

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

По данным некоторых исследований, мы в среднем тратим до 60% на отладку — и это именно усредненное значение, для кого-то, особенно для начинающих разработчиков, оно может быть ещё больше. Эта статья призвана уменьшить это время и сделать процесс отладки эффективнее и приятнее.

🎓

Эта статья является условным пересказом одноименного доклада для любимой iOS команды Noveo. Факт того, что это пересказ накладывает определённый след на статью и стилистику изложения. Если что-то останется непонятным, welcome в комментарии, постараюсь ответить на все вопросы.

Пример

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

Первым под поздозрение попадает флага showTitle и мы хотим знать его значение в момент вычисления. Что первым приходит на ум? Правильно, поместить print для отладки.

Запускаем проект — он у нас большой, монолитный, да и Xcode не идеальный. Чаще всего происходит всем знакомая ситуация: добавили одну строчку и ждём несколько минут компиляцию. А тут и твиттер манит и тик-ток (у кого что, в зависимости знаете ли вы ObjC 😁 ) и вот уже пара минут компиляции превращается в 20 с выпадом из задачи. А ведь кроме сборки и запуска нужно ещё и восстановить условия, добраться до нужного экрана, воспроизвести проблему и только после этого посмотреть вывод свежедобавленного print’a.

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

И снова нас ждёт сборка, воспроизведение условий и анализ. Сразу оговорюсь — отладка через print’ы сама по себе не является чем-то плохим. Порой это единственный способ отладить что-то в сложных ситуациях, например когда баг наблюдается только в релизной сборке с включенными оптимизациями. Но сегодня разговор пойдёт о простых, рутинных сценариях, которые чаще всего происходят во время разработки.

Breakpoints

Мы уже выяснили, что отладка через принты не совсем эффективна и нам нужен какой-то инструмент, который облегчит жизнь. Таким инструментом являются брейкпоинты! Этот механизм представлен практически во всех средах разработки, на множестве платформ и языков. Где-то он реализован лучше, где-то хуже, но Apple нам предоставила мощный и гибкий механизм точек останова. Однако, работая с разными разработчиками, я заметил, что пользуются брейкпоинтами в большинстве случаев только для остановки программы — просто чтобы убедиться, что её выполнение пошло по запланированному сценарию. Иногда люди пользуются консолью отладки и командой po, но лишь в тех случаях, когда нужно разок выяснить состояние переменной. Я предлагаю рассмотреть дополнительные возможности отладчика, встроенного в нашу IDE, и привести примеры ситуаций, в которых они могут пригодиться.

Условные брейкпоинты

Начнём с очевидного: conditional breakpoints. Как ни странно, строка, позволяющая указать условия срабатывания брейкпоинта, всегда у нас под носом, но почему-то люди удивляются такой возможности. Для удивлённых самим наличием такого диалога — его можно увидеть, если дважды нажать на сам брейкпоинт. В поле Condition можно записать любое выражение, которое может вернуть булево значение, будь то сравнение переменной из текущей области видимости или вовсе значение какого-то синглтона. Но будьте внимательны — медленно вычисляемое выражение способно существенно снизить производительность вашей программы.

Skipping

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

Actions

Но самое интересное дня нас кроется за кнопкой Add Action. Эта кнопка позволяет добавить дополнительное действие, которое будет вызвано в момент срабатывания брейкпоинта. Как вы видите, есть 6 типов действий, которыми можно дополнить брейкпоинт:

  1. Apple script. Позволяет запустить скрипт на одноименном языке.
  2. Capture GPU Frame. Для отладки приложений, использующих движок Metal, может потребоваться эта опция.
  3. Debugger command. Позволяет выполнить команду отладчика. О ней мы поговорим позже.
  4. Log-message. Позволяет вывести текстовое сообщение в лог.
  5. Shell command. Позволяет выполнить произвольную команду в среде, дефолтной для системы командной оболочки, sh/bash/zsh.
  6. Sound. Позволяет проиграть звук из динамиков компьютера, на котором запущен Xcode.

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

Log messages

Рассмотрим чуть более подробно тип дополнительного действия «Log message». Если мы его выберем, к нашим услугам окажется строка ввода формата сообщения. Обратите внимание, что в строке можно указывать полезные плейсхолдеры, два из которых позволяют подставить информацию о брейкпоинте и одно, самое полезное, позволяет подставить результат вычисления произвольного выражения. Таким выражением может быть переменная или любая другая конструкция используемого вами языка программирования. Но это не имеет никакого смысла, если не поставить галочку «Automatically continue after evaluating actions». Именно она в паре с любым из действий позволит нам экономить время на дебаге. Больше не нужно писать print(), пересобирать проект и ждать вечность. В любой момент времени, без перезапуска проекта вам доступен вывод в консоль отладки любой информации о ходе выполнения программы. А для знающих толк в извращениях дебаге Apple предусмотрела возможность воспроизвести выражения, используя встроенный синтезатор речи.

Shell command

Нетрудно догадаться, что этот экшен позволяет запустить произвольную команду в стандартной оболочке терминала ОС. Как и «Log message», она позволяет вычислить результат выражения в текущем контексте и дополнить им аргументы вызова команды. Для чего это может быть полезно? Примеров использования можно придумать массу. Из реальной жизни: запуск троттлинга через Charles. Необходимо было замедлять запросы из определённой точки, при этом в остальное время соединение должно было быть полноценным. Я не успевал включать-выключать троттлинг вручную и ещё совершать действия в симуляторе. Такой трюк с брейкпоинтом и «Shell command» отлично меня выручил. В другой раз мне понадобилось изменять информацию на сервере прямо параллельно с запросом, чтобы отловить довольно странный баг. Тут тоже был кстати этот вид брейкпоинта. Особые извращенцы могут собрать конструкцию на Arduino с электрошокером и бить себя током при каждом срабатывании нежелательного кода. Шучу. Не пытайтесь это воспроизвести в реальной жизни.

Debugger command

Одним из самых интересных видов экшенов я считаю «Debugger command». Этот экшен позволяет действительно безгранично влиять на отлаживаемую программу. Debugger command — это команды отладчика LLDB, а LLDB — это отладчик для проекта LLVM, который сейчас используется Apple и Xcode для сборки программ. Отладчик LLDB позволяет подключаться к процессу, прерывать выполнение программы и воздействовать на её память. Для этого отладчик имеет множество команд, некоторые из которых станут героями сегодняшнего повествования. Именно благодаря отладчику LLDB у нас в принципе есть такая замечательная возможность отлаживать программу, в частности устанавливать брейкпоинты.

Начнём мы с самой известной команды — po. Наверняка многие из вас уже не раз использовали эту команду при отладке, но для меня в своё время это стало открытием, хотя я уже имел некоторый опыт в разработке под iOS на тот момент. po — это сокращение от print object. Команда позволяет вычислить выражение из правой части от команды и распечатать в консоли результат выполнения. При этом у объекта запросится его debugDescription, если он определён, или просто description, если нет. У po существует команда-прародитель — print, или p, которая точно так же вычислит выражение и распечатает результат, но только в этом случае вам будет доступна сырая информация об объекте или скалярном типе. Обе эти команды будут компилировать введенное выражение в текущем контексте, что неминуемо замедлит выполнение кода при срабатывании брейкпоинта. К счастью, в Xcode 10.2 Apple добавили ещё одну команду отладчика — v, которая работает значительно быстрее. Она позволяет вывести в консоль значение переменной из текущей области видимости, но, в отличии от p и po, без компиляции выражения. Естественное ограничение, накладываемое этой особенностью, — вывод в консоль возможен только для хранимых свойств.

Влияние на ход программы

Такая комбинация (брейкпоинт + debugger command po + автоматическое продолжение) заменит нам описанную ранее Log message. Что же ещё мы можем сделать с помощью такой комбинации? Например, с помощью дебаггера мы можем пропустить выполнение нескольких строчек кода, будто они закомментированы. При этом вам не нужно пересобирать программу и заново воспроизводить условия. Для этого достаточно ввести

thread jump --by 1

для скачка вперёд на одну строчку или же

thread jump --line 44

для перехода, как вы уже могли догадаться, к 44 строчке.

☝️

Но будьте осторожны — вы не можете на 100% безопасно перепрыгивать по строчкам. Дело в том, что вы можете перепрыгнуть через инициализацию некоторой переменной, и это вызовет краш. Дело осложняется тем, что Swift «ленив» по своей природе, и инициализация может происходить не там, где вам кажется. Плюс компилятор при сборке вашей программы вставляет дополнительные инструкции, например для управления памятью, пропуская которые вы рискуете получить в лучшем случае утечку, в худшем — краш.

Влияние на дебаггер

Кроме влияния на вашу программу, с помощью отладчика вы можете влиять на сам отладчик. Например, мы можем поставить брейкпоинт из брейкпоинта. Вы спросите, зачем это нужно? Бывают методы общего назначения, которые срабатывают по ряду триггеров. Например функция по отправке сообщения в аналитику может вызываться сотню раз в секунду, а нам нужно отловить именно ту отправку, которую породит нажатие на кнопку. В этом случае мы можем поставить брейкпоинт на метод нажатия кнопки и добавить команду установки брейкпоинта на произвольной строке программы в произвольном файле. Команда bp s -o -f Calc.swift -l 44 расшифровывается как breakpoint set one-shot на файл Calc.swift на строку 44. Модификатор -o или --one-shot создаст специальный тип брейкпоинта, который «живёт» ровно до момента своего срабатывания, а после исчезает. Таким нехитрым способом мы можем создавать интересные алгоритмы установки брейкпоинтов для отладки нетривиальных багов.

Other breakpoints types

А есть ли ещё виды брейкпоинтов, о которых мы можем не знать? Конечно, есть. Xcode позволяет добавить некоторые виды брейкпоинтов, которые не относятся к какому-то конкретному файлу и строке. В Xcode есть вкладка Breakpoint Navigator, которая позволяет управлять уже созданными брейкпоинтами сквозь все файлы проекта, а также создавать новые. Внизу окна нашего IDE есть кнопка со значком плюса.

Это позволяет использовать 6 дополнительных типов брейкпоинтов:

  • Swift Exception брейкпоинт — брейкпоинт, останавливающий программу при срабатывании не перехваченного throw для Swift кода.
  • Exception брейкпоинт — то же самое, но для мира ObjC. Может показаться, что это не актуальный в современном мире брейкпоинт, но это не так. Стоит помнить, что нам пока всё ещё нужен UIKit, написанный на ObjC, ошибки которого мы можем отловить с помощью такого вот брейкпоинта.
  • Symbolic breakpoint — позволяет останавливать процесс выполнения программы при выполнении кода, ассоциированного с некоторым идентификатором, который Apple называет символом. О символах я расскажу чуть позже.
  • OpenGL ES Error брейкпоинт — брейкпоинт, останавливающий программу при возникновении ошибки OpenGL при разработке соответствующих приложений.
  • Constraint Error breakpoint — очевидно, остановит вашу программу при возникновении ошибки автолейаута.
  • Test Failure breakpoint может вам помочь при отладке тестов.

Так как уместить в этой сессии обзор всех типов точек останова не представляется возможным, я остановлюсь только на самых часто используемых. По своему опыту — я всегда использую Exception breakpoint. Довольно часто при разработке программ я сталкиваюсь с перехваченными системными исключениями, отладить которые порой проблематично из-за крайне неинформативного call stack’а. Думаю, вы сталкивались хоть раз с такой или подобной ошибкой:

Exception breakpoint

Для того, чтобы сделать стек вызова более информативным, мы можем добавить Exception breakpoint. Он позволит остановить программу прямо на моменте выброса исключения и отследить цепочку событий, которые привели к такому результату. По умолчанию неперехваченное исключение вызовет аварийную остановку приложения, и в стеке вызова мы ничего полезного не увидим, т.к. исключение будет пробрасываться вверх по стеку вызова и вся информация о месте выброса будет утеряна. Exception breakpoint позволяет остановить программу в момент выброса исключения и уже привычными нами методами получить гораздо больше информации о проблеме, пройдясь по стеку вызова и просмотрев значения переменных, если это необходимо. Я считаю этот тип брейкпоинта очень полезным и использую его на всех проектах по умолчанию. Для этого в Xcode есть удобный механизм, который позволяет указать брейкпоинту уровень и хранить его на трёх уровнях:

  1. Проект.
  2. Воркспейс.
  3. Пользователь.

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

Symbolic Breakpoint

Вторым часто используемым типом брейкпоинтов является Symbolic Breakpoint. Ранее я уже писал, что этот брейкпоинт позволяет останавливать программу при выполнении кода, ассоциированного с каким-то символом, и обещал рассказать подробнее про символы. Так вот, символы — это человекопонятные идентификаторы, которые ассоциируются с тем или иным адресом в памяти. LLDB умеет маппить известные ей символы в адреса функций и наоборот. При каждой сборке проекта система создаёт особый бандл из специальных файлов в формате dSYM, которые расшифровываются как Debug Symbols. Эти файлы хранят что-то вроде таблицы, содержащей в себе некоторые адреса методов и некоторые идентификаторы, среди которых сигнатуры методов, имена файлов, смещения и номера строк. Именно благодаря этим файлам мы можем поставить брейкпоинт на строку файла, получить читаемый стек вызова или расшифровать crashlog приложения из AppStore.

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

Для расследования необходимо понять, кто и почему инициирует смену фокуса. Можно долго читать код, искать логику в неочевидных расширениях классов, а как надоест — сделать наследника UITextField’a, переопределив там метод becomeFirstResponder(), поменять реализации и уже там поставить брейкпоинт. А можно за 10 секунд создать символьный брейкпоинт -[UITextField becomeFirstResponder], и программа остановится в момент смены фокуса. По цепочке бэктрейса мы сможем легко восстановить последовательность событий, которые приводят к нежелательным результатам.

У тех, кто пользуется таким видом брейкпоинта в первый раз, наверняка возник вопрос: а что это за символ
-[UITextField becomeFirstResponder]? Это ObjectiveC-сигнатура метода установки текста для лейбла. Использование ObjectiveC обусловлено тем, что UIKit написан именно на этом языке.

Пара слов для тех, кто имел мало опыта с ObjectiveC. Знак минуса обозначает, что нас интересует инстанс-метод, а не метод класса, далее в квадратных скобках записывается название класса и через пробел метод, двоеточие указывает на то, что этот метод принимает параметр.

Тут можно возразить, что пример притянут за уши. Я согласен — в хорошем коде не будет десятка мест с установкой текста лейбла, но моя цель — показать, как это может работать. Давайте рассмотрим более реальный пример. Допустим, для целей отладки нам может понадобиться распечатать последовательность показа вью контроллеров. Добавляем брейкпоинт с символом -[UIViewController viewDidAppear:], указываем дополнительное действие po NSStringFromClass([instance class]) и, конечно же, не забываем поставить галочку «Automatically continue after evaluating actions».

Мы снова вынуждены использовать ObjC, даже в дополнительной команде, так как находимся в его контексте. Что касается Swift, то символы записываются как название ClassName.methodName(param:). Прописывать параметры не обязательно, LLDB попытается разрешить неоднозначность, если есть методы с одинаковым названием, но разными параметрами.

Поиск символов

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

image lookup -r -n

и найти интересующие вас символы в вашей программе и во всех загруженных библиотеках. Это действительно делает вас чуть ли не богом дебага, потому как вы властны искать символы везде, скажем в UIKit’e, искать приватные методы, останавливать и изучать внутреннее устройство системных библиотек. Надеюсь, я убедил в вас в силе этого метода и он не раз поможет вам сэкономить время.

Watchpoints

Вотчпоинты позволяют останавливать программу, когда изменяется значение переменной. Корректнее будет сказать, что этот механизм позволяет следить за изменениями памяти по заданному адресу с заданным размером, но благодаря LLDB и Xcode разработчику достаточно сделать несколько кликов. Использование вотчпоинтов будет удобным, когда за изменением переменной не следует никакого сайд-эффекта прямо после изменения, но её состояние важно для отложенных вычислений. В ряде случаев может быть непонятно, что инициирует это изменение, и вотчпоинты позволят быстро узнать это. Достаточно приостановить выполнение программы в контексте нужного класса и воспользоваться окном Variables View.

Тут будут перечислены переменные в текущем фрейме, доступные к отлаживанию. В крупных проектах вычисление доступных переменных и их типов может занимать некоторое время, поэтому иногда нужно подождать несколько (десятков?) секунд перед тем, как переменные будут доступны к манипуляциям над ними. Приятным бонусом является возможность «заглянуть» внутрь объектов Objective-C: функциональность Variables View позволяет увидеть приватные переменные этих объектов. По клику правой кнопки мыши по переменной нам доступно не так много опций — мы можем изменять значение переменных скалярных типов и, собственно, добавлять вотчпоинты.

Конечно же, вотчпоинт можно установить и командой LLDB: watchpoint set variable variable_name, или, пользуясь функцией сокращения команд LLDB, просто: w s v variable_name, но помните, что переменная должна быть видна отладчику, то есть находиться в текущем фрейме. Помимо установки брейкпоинта на изменение переменной, нам доступна установка вотчпоинта на область памяти:

watchpoint set expression — 0x0d78ab5ea8

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

watchpoint list

или в Debugger navigator. Так как любые вотчпоинты в итоге следят за адресом памяти, они становятся неактуальны после перезапуска и не сохраняются между перезапусками приложения. Даже если вы установили брейкпоинт на изменение переменной, под капотом механизм lldb вычислил её адрес и поставил вотчпоинт по этому адресу.

Влияем на состояние

Будем закругляться. Последнее, о чем я хотел поведать в рамках этой статьи, — влияние на состояние приложения из LLDB. До этого я говорил только об изменении состояния какого-либо объекта системы при остановке по брейкпоинту. Но что, если нам требуется приостановить программу в произвольный момент времени? Нажатие на значок паузы приводит к приостановке программы, но вот вместо привычного нам кода мы увидим код ассемблера. Так как же добраться до произвольного объекта и выполнить с ним хитрые манипуляции?

Memory graph

Большинство iOS-разработчиков уже с первых месяцев своей работы используют этот инструмент. Для тех, кто ни разу им не пользовался, поясню. Memory graph позволяет сделать дамп памяти программы и отобразить в виде списка и графа все экземпляры объектов, которые сейчас находятся в памяти. Зачастую этот инструмент используется для выявления утечек объектов и анализа связей, которые привели к такому результату. Но сегодня от этого инструмента нам нужна только возможность остановить программу в произвольное время, найти нужный объект и узнать его адрес. Но что мы можем сделать с этой, казалось бы, бесполезной информацией?

На самом деле — всё, что угодно. Тут нам на помощь приходит мощь ObjC. Мы можем написать

[0x7fafffa54a5 setValue:[UIColor redColor] forKey:@"switchedOffColor"]

— и мы уже поменяли значение цвета выключенной лампы на красный, используя стандартные методы NSObject, доступные нам из коробки. Но что, если нам недостаточно этих методов, а нужно «дёрнуть» за свои рычаги? Всё просто — мы можем использовать кастинг:

[(MyLamp *)0x7fafffa54a5 powerOff]

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

Мы можем сохранить значение этого адреса в переменную для удобства:

(MyLamp *)$lamp = 0x7fafffa54a5

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

ObjectiveС предоставляет поистине широкие возможности для того, чтобы похакать текущее состояние и обойти многие ограничения, но что делать с классами, доступными только в Swift? Конечно же, при попытке кастинга Swift-класса в ObjC-контексте ничего не произойдёт. К счастью, в Swift есть подобный механизм. Точнее, функция, имя которой — unsafeBitCast(_:to:). Мы вправе использовать его с адресом:

unsafeBitCast(0x7fafffa54a5, to: MySwiftLamp.self)

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

View Hierarchy

Рядом со инструментом Debug Memory Graph соседствует другой, не менее полезный инструмент, — View Hierarchy. Он позволяет быстро найти нужную View, посмотреть её параметры и лейаут, посмотреть активные и неактивные констрейнты. С iOS 11 этот инструмент ещё научился отображать ViewController’ы в иерархии, таким образом находить нужную View стало легче. Неочевидным тут является возможность фильтрации по имени и возможность отключить/включить отображение View, скрытых за экраном. Также я обратил внимание, что редко кто пользуется панелью управления внизу окна визуального отображения View.

Кроме того, что она может регулировать глубину просмотра иерархии, она позволяет указать «включить отображение обрезанного контента» и «включать отображение констрейнтов». Обязательно поиграйтесь со всеми инструментами, я уверен — вы найдете полезное для себя применение для некоторых из них.

Но в рамках этого рассказа нам нужна только возможность найти нужную View и узнать её адрес. Далее действуем по накатанной:

po unsafeBitCast(0x7fafffa54a5, to: UIView.self)

но в таком случае мы получим ошибку, т.к. сейчас находимся в контексте ObjectiveC и не можем использовать po со Swift-кодом. Мы вынуждены использовать команду expession, или просто e с указанием языка:

e -l Swift -- unsafeBitCast(0x7fafffa54a5, to: UIView.self)

Но и тут наши попытки не увенчаются успехом, мы получим ошибку error: :3:35: error: use of unresolved identifier ‘UIView’.

Это произойдет из-за модульной природы Swift’а. Для успешного выполнения операции нам потребуется сделать импорт модуля UiKit:

e -l Swift -- import UIKit

, и после этого мы наконец добьёмся результата:

e -l Swift -- unsafeBitCast(0x7fafffa54a5, to: UIView.self)

.
Ура! Мы получили описание в консоли. Теперь давайте попробуем поменять, скажем, цвет её бэкграунда. Для начала сохраним View в переменную, чтобы облегчить процесс доступа к ней. Как и в случае с ObjectiveC, при создании переменной в LLDB контексте её название должно начинаться со знака доллара:

e -l Swift -- let $view = unsafeBitCast(0x7fafffa54a5, to: UIView.self)

далее мы можем применить необходимые изменения:

e -l Swift -- $view.backgroundColor = .red

Чтобы увидеть изменения, необходимо продолжить выполнение программы. Но есть способ увидеть изменения и без этого, находясь в режиме «паузы».

Дело в том, что мы не видим изменения не потому, что приложение приостановлено, а потому, что все изменения UIView копятся в транзакцию CALayer и применяются только в конце «вращения» текущего RunLoop’а с помощью вызова CATrasaction.flush(). Когда приложение приостановлено для отладки, операционная система всё ещё живёт своей жизнью, вы можете свернуть это приложение и открыть другое. Операционная система всё ещё опрашивает состояние UI вашего приложения и отрисовывает ваше приложение несколько десятков раз в секунду, только RunLoop приостановлен, CATrasaction.flush не вызывается, изменения не применяются.

Так что, достаточно самостоятельно сделать вызов

e -l Swift -- CATrasaction.flush()

, и мы увидим изменения.

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

 No comments   2020   iOS   Xcode   отладка   статья