Objective-C runtime (libobjc) представляет собой библиотеку, которая хранит классы, методы и селекторы как данные во время работы приложения; [obj doThing] компилируется в objc_msgSend(obj, @selector(doThing)), и реализация ищется только в момент вызова.
**Селектор (SEL)**: уникальное имя метода, например setTitle:. Все классы с методом с таким именем используют один и тот же селектор.
**IMP**: обычный указатель на C-функцию (id self, SEL _cmd, ...). Метод хранится как пара «селектор + IMP» в списке методов класса.
Позднее связывание: класс получателя проверяется в момент вызова, поэтому метод можно добавить, подменить или переадресовать прямо во время работы, а заранее можно спросить respondsToSelector:.
Swift: свои методы вызывает напрямую; objc_msgSend использует только для классов Objective-C и членов @objc dynamic.
iOSSenior
Objective-C runtime и интероп
Отправка сообщений, swizzling, бриджинг, KVO изнутри, когда ObjC всё ещё нужен
Закрыто вопросов
0 / 18
- 0Повторить
- 7Часто спрашивают
Вопросы
Ничего не произойдёт и ничего не упадёт: objc_msgSend первым делом проверяет nil и возвращает ноль, то есть nil, 0, NO или 0.0. Swift делает этот случай явным через опционалы: obj?.run() пропускается и возвращает опционал; force unwrap от nil падает.
Чем удобно: цепочки вроде [[user profile] name] не требуют проверок, а [delegate didFinish] без назначенного делегата безвреден.
Чем опасно: забытый аутлет или так и не назначенный делегат ломаются молча: ни краша, ни лога, функция просто ничего не делает.
**nil как аргумент ведёт себя иначе**: [array addObject:nil] или @{ @"key": nil } бросают NSInvalidArgumentException. Безопасен только nil в роли получателя.
@objc публикует член в Objective-C runtime (селектор и запись в списке методов), @objcMembers делает это для всего класса, а dynamic заставляет и сам Swift вызывать член через objc_msgSend. Без dynamic вызовы из Swift идут в обход рантайма.
**@objc**: нужен для #selector, #keyPath, target-action, @objc-протоколов и вызовов из файлов Objective-C. Swift-код по-прежнему вызывает член напрямую или через vtable, поэтому подмену метода в рантайме вызовы из Swift не видят.
Вывод атрибута: начиная со Swift 4 (SE-0160) @objc выводится только для переопределений методов Objective-C, членов @objc-протокола, @IBAction, @IBOutlet и @NSManaged.
**@objcMembers**: @objc на всех членах, которые Objective-C способен представить, включая extension. Удобно для класса, который используют из Objective-C; за это платят размером бинарника.
**dynamic**: пишите @objc dynamic: каждый вызов, в том числе из Swift, становится отправкой сообщения, которую может перехватить сгенерированный подкласс KVO или swizzling. Все типы в сигнатуре должны быть представимы в Objective-C. Просто dynamic без @objc включает нативную dynamic replacement Swift, которую Objective-C runtime не видит.
Правило: @objc нужен, когда Objective-C должен видеть член, а @objc dynamic требуется, когда рантайм должен иметь возможность его подменить.
KVC (key-value coding) читает и записывает свойства по строковому ключу во время выполнения; KVO (key-value observing) уведомляет наблюдателей, когда такое свойство меняется. Из Swift вы вызываете observe(\.status) на свойстве @objc dynamic и храните токен; для собственных новых моделей вместо этого берут @Observable.
KVC: value(forKey:), setValue(_:forKey:) и пути вроде "address.city" находят свойство по имени во время выполнения. Неверный ключ бросает NSUnknownKeyException («not key value coding-compliant for the key»), классический крэш после переименования аутлета, который сториборд всё ещё подключает.
KVO из Swift: свойство должно быть @objc dynamic в наследнике NSObject. observe(_:options:changeHandler:) возвращает NSKeyValueObservation, и наблюдение живёт, только пока жив этот токен, поэтому храните его в свойстве.
Потоки: обработчик выполняется синхронно на том потоке, который изменил значение, а не на главном. Перед работой с UI переходите на main actor.
Где KVO всё ещё основной API: объекты самой Apple, например AVPlayer.timeControlStatus, WKWebView.estimatedProgress и Progress.fractionCompleted.
Замены: @Observable для собственных моделей, @Published в коде на Combine и Observations { model.total } (iOS 26) для AsyncSequence изменений.
Категория представляет собой именованное дополнение (@interface UIView (Badge)), которое добавляет методы любому классу при загрузке бинарника, даже без доступа к его исходникам; class extension (@interface Foo ()) является анонимной частью самого класса, компилируется с его @implementation и может добавлять ivar.
Категория: методы и объявления свойств, но без хранения: у @property в категории нет ivar и сгенерированных аксессоров. Их пишут вручную, часто через associated objects.
Class extension: живёт в .m-файле, добавляет приватные методы, ivar и свойства и может переобъявить публичное readonly-свойство как readwrite.
Конфликт имён: рантайм ставит методы категории перед собственными методами класса, поэтому метод категории молча перекрывает оригинал. Если категорий две, победитель зависит от порядка загрузки, то есть на практике не определён. Поэтому методам категорий дают префикс: xyz_setBadgeText:.
Статические библиотеки: линкер выбрасывает объектные файлы, в которых есть только категории, если не передать -ObjC, и вызов падает в рантайме с «unrecognized selector».
Swift: @objc-член в Swift-extension для класса Objective-C генерируется как метод категории и может конфликтовать так же; метод extension без @objc разрешается при компиляции и конфликтовать не может.
+load выполняется сразу, один раз для каждого класса и каждой категории, где он реализован, при загрузке бинарника и до main(); +initialize выполняется лениво, прямо перед первым сообщением классу, и наследуется. Swift запрещает переопределять оба метода ошибкой компиляции.
**+load**: рантайм вызывает его напрямую, не через objc_msgSend, поэтому он не наследуется, а +load категории не заменяет +load класса. Суперклассы идут раньше подклассов, а классы идут раньше своих категорий. Другие классы могут быть ещё не готовы, а каждый +load добавляет время к pre-main фазе запуска.
**+initialize**: отправляется через objc_msgSend и потокобезопасен: другие потоки ждут, пока он закончится. Подкласс без собственного +initialize запускает метод суперкласса повторно, отсюда проверка self == [Foo class]; +initialize категории заменяет метод класса.
Классическое применение: в +load делали swizzling, потому что он выполняется один раз и раньше всего остального; +initialize настраивает состояние класса при первом использовании.
Swift: override class func load() даёт ошибку «method 'load()' defines Objective-C class method 'load', which is not permitted by Swift», а initialize() даёт такую же. Вызывайте явную функцию настройки из application(_:didFinishLaunchingWithOptions:) или используйте static let, инициализатор которого выполняется ровно один раз.
Они говорят компилятору, как синтезировать аксессоры: strong удерживает объект, weak не удерживает и становится nil, copy хранит копию, assign хранит значение как есть, а atomic (по умолчанию) делает атомарным каждый отдельный get или set. Это не потокобезопасность: visits += 1 остаётся незащищённым.
**strong: по умолчанию для объектов, ссылка +1.
weak: без +1; рантайм обнуляет её, когда объект освобождается.
copy**: сеттер хранит [value copy]. Используется для NSString, NSArray и NSDictionary, чтобы переданный изменяемый наследник не мог потом поменять ваше значение, и для блоков.
**assign**: простая запись, для скаляров вроде NSInteger или CGFloat. Для указателя на объект это unsafe_unretained: ни удержания, ни обнуления, поэтому после освобождения объекта указатель висит.
**atomic и nonatomic**: атомарный геттер никогда не вернёт наполовину записанное значение, но платит за это локом при каждом обращении. Почти все свойства в iOS nonatomic: эта цена мало что даёт.
**Почему atomic недостаточно**: self.visits += 1 состоит из get и set, двух атомарных вызовов с промежутком между ними, поэтому параллельные инкременты затирают друг друга (десять очередей по 10 000 инкрементов дали в тесте около 20 000). Защищайте всю операцию через @synchronized, лок или serial-очередь; в Swift через Mutex или актор.
Extension и категории не умеют добавлять хранение, поэтому используют associated objects: objc_setAssociatedObject сохраняет значение для пары «объект + уникальный ключ» в отдельной таблице, а рантайм освобождает его при деаллокации объекта.
Ключ: любой уникальный стабильный адрес: приватная глобальная или статическая переменная, переданная как &key, или селектор в Objective-C. Значение по этому адресу никогда не читается.
Политика: .OBJC_ASSOCIATION_RETAIN_NONATOMIC для объектов, COPY_NONATOMIC для строк и блоков, атомарные варианты берут, если к значению обращаются несколько потоков. ASSIGN соответствует unsafe_unretained, а не weak: после смерти объекта вы читаете висячий указатель, поэтому слабую ссылку кладут в объект-обёртку.
Хранение: глобальная хеш-таблица внутри рантайма под локом с адресом объекта в роли ключа; в isa объекта ставится бит has_assoc, чтобы при деаллокации знать, что нужно прибраться.
Цена: лок и два поиска по хешу на каждое обращение, значения упаковываются в объекты (Swift-структура превращается в непрозрачный __SwiftValue), хранить их могут только экземпляры классов, а зависимость не видна тому, кто читает класс.
Лучше: подкласс, обёртка или своя side table в вашем коде. Associated objects оставьте для случаев, когда действительно нужно расширить класс фреймворка, например бейдж на любом UIView.
Блок удерживает каждый захваченный объект, поэтому блок, который хранится в self и упоминает self, создаёт retain cycle; захват __weak weakSelf разрывает цикл, а превращение его в strongSelf внутри блока держит объект живым до конца этого выполнения блока.
**Зачем strongSelf**: __weak-указатель может стать nil между двумя строками, если последний владелец отпустит объект на другом потоке, и блок применит обновление наполовину. Одна сильная локальная ссылка делает блок «всё или ничего», а if (!strongSelf) return; выходит сразу, если экран уже закрыт.
Скрытые захваты: обращение к ivar вроде _items означает self->_items, то есть сильный захват self, хотя слова self в коде нет; предупреждение -Wimplicit-retain-self это ловит.
**__block**: позволяет блоку изменять захваченную локальную переменную; она переезжает в общий для блока и стекового фрейма бокс в куче. Под ARC объектная __block-переменная сильная, поэтому, в отличие от ручного retain/release, цикл она не разрывает.
Копирование: блоки создаются на стеке, и ARC копирует их в кучу, когда их сохраняют или возвращают; поэтому свойства-блоки объявляют copy.
Swift: тот же приём выглядит как [weak self] плюс guard let self else { return }.
Аннотируйте заголовок, чтобы импортёр выдал нативные Swift-типы: nullability превращает T! в T или T?, lightweight generics превращают NSArray * в [String], а NS_SWIFT_NAME и NS_REFINED_FOR_SWIFT исправляют имена и формы, которые плохо переводятся.
Nullability: оберните заголовок в NS_ASSUME_NONNULL_BEGIN/END и пометьте исключения nullable; неаннотированные указатели приходят как T!. nonnull-метод, вернувший nil, роняет приложение далеко от причины, поэтому аннотации служат контрактом.
Дженерики: NSArray<NSString *> * импортируется как [String], а просто NSArray * импортируется как [Any].
Enum: NS_ENUM становится открытым Swift-enum, и switch по нему в Swift 6 требует @unknown default; NS_CLOSED_ENUM делает его frozen; NS_OPTIONS становится OptionSet; NS_ERROR_ENUM даёт тип ошибки, который можно ловить.
**NS_SWIFT_NAME: переименовывает тип, метод или C-функцию.
NS_REFINED_FOR_SWIFT**: импортирует член с префиксом __, чтобы обернуть его в более удобный Swift API в extension.
Конкурентность: NS_SWIFT_UI_ACTOR, NS_SWIFT_SENDABLE и NS_SWIFT_NONISOLATED сообщают строгой проверке Swift 6, что обещает класс. Метод с completion handler дополнительно импортируется как async-функция.
Прежде чем упасть с «unrecognized selector sent to instance», рантайм даёт объекту три шанса: добавить метод (resolveInstanceMethod:), назвать другой объект, который его обработает (forwardingTargetForSelector:), или принять весь вызов как NSInvocation (forwardInvocation:).
1. Динамическое разрешение: +resolveInstanceMethod: может тут же добавить реализацию через class_addMethod; она кешируется, и следующий вызов идёт обычным путём. Так работают @dynamic-свойства Core Data.
2. Быстрая переадресация: -forwardingTargetForSelector: возвращает другой объект, и рантайм отправляет ему то же сообщение. Дёшево; классический инструмент для прокси делегатов.
3. Полная переадресация: -methodSignatureForSelector: плюс -forwardInvocation: отдают вызов как объект, который можно изменить, записать или разослать нескольким получателям. Медленно, а NSInvocation недоступен в Swift, так что этот этап требует Objective-C.
4. Ошибка: -doesNotRecognizeSelector: бросает NSInvalidArgumentException, тот самый краш «unrecognized selector». Типичные причины: переименованный action, который всё ещё подключён в сториборде, категория, выброшенная линкером, объект не того класса после неверного приведения.
**respondsToSelector:**: в прокси его тоже нужно переопределить: UIKit проверяет опциональные методы делегата перед вызовом, и прокси должен сообщать о тех, что он переадресует.
Swizzling меняет местами реализации двух селекторов во время работы приложения, и каждый вызов, например, viewWillAppear: сначала выполняет ваш код. Работает только для методов, которые вызываются через objc_msgSend, и меняет поведение для всего процесса.
Как делать безопасно
Выполнять ровно один раз за процесс: dispatch_once внутри +load в Objective-C, инициализатор static let в Swift. Повторный обмен возвращает методы на место.
Подменять в классе, который объявляет метод, или сначала добавить оригинал в свой подкласс через class_addMethod; обмен унаследованного Method меняет суперкласс и все остальные подклассы.
Внутри замены вызывать собственный селектор замены: после обмена он указывает на оригинальный IMP. В Swift помечайте его @objc dynamic, иначе Swift может вызвать тело замены напрямую и уйти в бесконечную рекурсию.
Всегда вызывать оригинал, держать замену короткой и трогать только публичные, документированные методы.
Чем опасен
Оригинал выполняется с вашим селектором в _cmd, и код, который на _cmd опирается, ломается.
Несколько SDK, подменяющих один метод, наслаиваются в порядке загрузки, а тот, что подменит дважды, может сломать цепочку для всех.
Поведение больше не совпадает с исходником, и стектрейсы с отладкой вводят в заблуждение следующего разработчика.
Чистые Swift-методы, final-члены и SwiftUI-вью подменить нельзя, а внутренние пути вызовов, за которые вы цепляетесь, Apple может поменять в любом релизе iOS.
Лучше подкласс, делегирование, прокси или официальный хук; swizzling оставьте для аналитики, отладки и тестовых инструментов.
Каждый объект начинается с поля isa, которое указывает на его класс; objc_msgSend читает его, ищет селектор в кеше методов этого класса, а при промахе идёт по спискам методов вверх по цепочке суперклассов, кладёт найденный IMP в кеш и прыгает на него.
Поиск
Первым делом проверяются nil и tagged pointers. Tagged pointers (маленькие NSNumber, NSString, NSDate) хранят значение прямо в указателе, и настоящего isa у них нет.
Кеш устроен как небольшая хеш-таблица SEL → IMP у каждого класса. Попадание стоит горстку инструкций рукописного ассемблера, и это основной путь.
При промахе lookUpImpOrForward перебирает списки методов класса (вместе с категориями), затем каждого суперкласса, сохраняет результат в кеш класса получателя и, если ничего не нашлось, переходит к разрешению и переадресации.
objc_msgSend заканчивается прыжком, а не вызовом: аргументы остаются в регистрах, поэтому один трамплин подходит для любой сигнатуры. Поэтому в C его вызывают, приводя к точному типу функции, а Swift напрямую вызвать его не может.
Добавление методов, подключение категорий или swizzling сбрасывают затронутые кеши.
Классы и метаклассы
Класс тоже является объектом: его isa указывает на метакласс, а методы класса оказываются методами экземпляра метакласса.
isa любого метакласса указывает на корневой метакласс, суперклассом которого служит сам NSObject, поэтому поиск метода класса может закончиться методом экземпляра NSObject.
Non-pointer isa
На arm64 isa устроен как битовое поле: кроме указателя на класс в нём лежат встроенный счётчик ссылок (extra_rc) и флаги вроде weakly_referenced, has_assoc и deallocating. Большинство retain/release сводится к одной атомарной операции над isa, без side table.
Поэтому isa никогда не читают напрямую; для этого есть object_getClass.
Выведет progress 0.5, а затем status done. KVO подменяет класс объекта сгенерированным подклассом, сеттеры которого рассылают уведомления, а Swift отправляет сеттер через objc_msgSend, где подкласс может его перехватить, только для @objc dynamic.
Что делает KVO
При первом наблюдателе рантайм создаёт подкласс с именем NSKVONotifying_ плюс имя класса и переключает на него isa объекта. Это и есть isa-swizzling.
Подкласс переопределяет каждый наблюдаемый сеттер: willChangeValueForKey:, оригинальный сеттер, didChangeValueForKey:. Ещё он переопределяет class, чтобы тот возвращал Upload, поэтому type(of:) подмену скрывает, а object_getClassName показывает.
Почему каждая строка ведёт себя так
upload.progress = 0.5: свойство dynamic, поэтому Swift отправляет setProgress:, и переопределённый сеттер уведомляет.
upload.status = "running": @objc без dynamic; Swift вызывает свой сеттер через vtable и рантайм не спрашивает, поэтому ничего не срабатывает. Компилятор даже предупреждает: «passing reference to non-'@objc dynamic' property 'status' to KVO method».
setValue("done", forKey: "status"): KVC ищет setStatus: по имени через рантайм, попадает в переопределение, и наблюдатель срабатывает.
На практике
Помечайте каждое наблюдаемое свойство @objc dynamic и держите токены NSKeyValueObservation (a, b) живыми: освобождение токена снимает наблюдателя.
KVO остаётся способом следить за типами Apple вроде AVPlayer.timeControlStatus или WKWebView.estimatedProgress. Для своих моделей @Observable или @Published типобезопасны и не зависят от Objective-C runtime.
Swizzling выполняется при каждом подключении сцены, а не один раз за процесс: второе окно меняет реализации обратно, viewDidAppear снова оригинальный, и трекинг молча перестаёт работать, пока третье окно не включит его снова.
Почему: method_exchangeImplementations работает как переключатель, а не как установка. scene(_:willConnectTo:options:) вызывается для каждой новой сессии сцены: каждое дополнительное окно на iPad, сцена внешнего дисплея, сцена, переподключённая после того, как система её выгрузила.
Почему трудно заметить: ничего не падает. На iPhone сцена обычно одна, поэтому баг проявляется в основном на iPad и в многооконных сценариях (и после того, как сцену выгрузили и переподключили), а на дашборде просто меньше просмотров экранов.
Исправление: перенести обмен в инициализатор static let, который Swift выполняет ровно один раз и потокобезопасно (swift_once, аналог dispatch_once), и запускать его из application(_:didFinishLaunchingWithOptions:), который выполняется один раз за процесс.
Вызов, похожий на рекурсию, корректен: после обмена селектор tracked_viewDidAppear(_:) указывает на оригинальную реализацию UIKit. Метод должен оставаться @objc dynamic, чтобы Swift отправлял сообщение, а не вызывал тело напрямую.
Только базовый класс: UIViewController сам объявляет viewDidAppear(_:), поэтому обмен в нём корректен; для подкласса, который метод только наследует, сначала добавьте его через class_addMethod.
Toll-free пары вроде CFStringRef и NSString имеют одинаковое устройство в памяти, поэтому приведение использует тот же самый объект. ARC не управляет CF-объектами, поэтому в Objective-C bridge-приведение, а в Swift Unmanaged говорят, кто владеет ссылкой +1.
Пары: CFString/NSString, CFArray/NSArray, CFDictionary/NSDictionary, CFData/NSData, CFURL/NSURL, CFDate/NSDate и другие. Приведение бесплатное. Swift-String не toll-free: превращение в CFString идёт через бридж NSString.
**__bridge: приведение без смены владения, для CF-объекта, который вы только заимствуете.
__bridge_transfer / CFBridgingRelease**: CF-объект пришёл из функции Create или Copy (+1); ARC забирает его и освобождает.
**__bridge_retained / CFBridgingRetain**: отдаёт объект Objective-C в CF-код с +1; потом его нужно освободить через CFRelease.
Swift: CF API от Apple аннотированы, и их результаты управляются автоматически. Неаннотированные C API возвращают Unmanaged<T>: takeRetainedValue() для правила Create/Copy, takeUnretainedValue() для правила Get. Ошибка в выборе даёт утечку или краш от лишнего release.
Указатели контекста: self передают в C-колбэк как void * через Unmanaged.passUnretained(self).toOpaque(), и тогда отписаться нужно до deinit; с passRetained его нужно уравновесить вызовом release().
Swift-String хранит UTF-8 и считает Character (графемные кластеры), а NSString работает в кодовых единицах UTF-16, поэтому бридж или перевод индексов может означать перекодирование и проход O(n), а text.count возвращает не ту длину, которую ждёт NSRange.
String в NSString: as NSString или передача в API Objective-C оборачивает нативный буфер без копирования, но UTF-16-вызовам (length, characterAtIndex:) приходится пересчитывать смещения; Swift хранит breadcrumbs, чтобы после первого прохода это оставалось дешёвым на больших строках.
NSString в String: рантайм вызывает copy (бесплатно для неизменяемой строки), и String хранит её лениво; дальнейшие Swift-операции идут через API NSString и медленнее, чем на нативной строке. makeContiguousUTF8() конвертирует один раз перед тяжёлой работой.
Длины разные: в примере count равен 12, а utf16.count равен 16: разложенная é занимает две UTF-16-единицы, эмодзи с модификатором тона кожи занимает четыре, но каждая из них считается одним Character. NSRange длиной count обрывается раньше и до «rocks» не доходит.
Диапазоны: стройте их через NSRange(text.startIndex..., in: text) и переводите обратно через Range(match.range, in: text); никогда не подставляйте Int-смещения из одного мира в другой.
Горячие циклы: count и перевод индексов проходят строку при каждом вызове, а String, пришедшая из NSString, продолжает работать через API NSString; посчитайте длину или диапазон один раз или вызовите makeContiguousUTF8() перед циклом.
Новый код пишется на Swift, но Objective-C всё ещё оправдан для перехвата NSException, работы с рантаймом вроде forwardInvocation: и +load, обвязки на Objective-C++ для C++-кода, который C++-интероп Swift не умеет импортировать, и больших легаси-модулей и SDK, которые уже работают.
Где Objective-C всё ещё нужен
**NSException**: Swift не умеет его ловить. Маленький хелпер на Objective-C с @try/@catch оборачивает вызовы, которые бросают исключения, например NSPredicate(format:) с форматом от пользователя или removeObserver:forKeyPath: для наблюдателя, которого не добавляли.
Работа с рантаймом: NSInvocation, forwardInvocation: и +load в Swift недоступны.
C++: C++-интероп Swift (Swift 5.9+) импортирует многое, но не каждый шаблон и не каждую схему владения; адаптер на Objective-C++ остаётся надёжным мостом.
Существующий код: зрелый модуль, который работает и редко меняется; переписывание несёт риск без пользы для пользователя.
План миграции
Сначала аннотации: nullability, дженерики, NS_SWIFT_NAME, NS_SWIFT_UI_ACTOR и NS_SWIFT_SENDABLE, чтобы вызывающий Swift-код получил честные типы ещё до переписывания любого файла.
@objc @implementation extension Foo (Swift 6.1, SE-0436): переписывайте один класс или категорию Objective-C на Swift, пока .h-файл и все вызывающие Objective-C-классы остаются прежними; компилятор проверяет, что Swift-члены совпадают с заголовком.
Новые фичи писать на Swift в том же таргете и переводить сначала листовые классы. Objective-C не может наследоваться от Swift-класса, поэтому классы, от которых наследуется Objective-C-код, идут последними.
Держать API, видимый из Objective-C, под @objc, пока не уйдёт последний вызывающий Objective-C-код, а потом убрать @objc, чтобы вернуть статическую диспетчеризацию и меньший бинарник.
Переводить по одному классу на пулл-реквест, с тестами на поведение, и следить за временем запуска и размером бинарника; переписывание работающего модуля целиком обычно возвращает старые баги.