ARC освобождает экземпляр класса в тот момент, когда счётчик сильных ссылок на него падает до нуля; вызовы retain и release вставляет компилятор. Сборщик мусора, наоборот, ищет недостижимые объекты во время работы: это стоит пауз или фоновой работы, зато он освобождает и циклы ссылок, чего ARC не делает никогда.
Сильная ссылка (по умолчанию) держит объект живым и увеличивает его счётчик на единицу.
**weak**: счётчик не увеличивает и автоматически становится nil, когда объект освобождён, поэтому это всегда опциональный var. Подходит для делегатов и обратных ссылок.
**unowned: счётчик не увеличивает и не опционален; чтение после освобождения объекта приводит к крэшу. Используйте, только когда второй объект гарантированно живёт дольше владельца ссылки.
Детерминированность**: deinit срабатывает сразу после ухода последней ссылки, поэтому там надёжно останавливать таймеры и отменять работу, а print в нём служит самой дешёвой проверкой на утечку.
Цена: каждый retain и release выполняется как атомарная операция, а два объекта, сильно держащие друг друга, не освободятся никогда. Разрывать циклы предстоит вам.
Структуры и перечисления копируются, а не считают ссылки; ARC касается их только через ссылки на классы внутри них.
iOSSenior
Производительность iOS и память
ARC, retain cycles, Instruments, главный поток, оптимизация изображений и таблиц
Закрыто вопросов
0 / 24
- 0Повторить
- 6Часто спрашивают
Вопросы
Стек служит памятью конкретного потока: выделение в нём сводится к простому сдвигу указателя, а всё освобождается при возврате из функции. Куча представляет собой общую память, выделяемую через malloc: медленнее, а для экземпляров классов ещё и со счётчиком ссылок.
Обычно в стеке: локальные структуры, перечисления и кортежи известного размера, которые не переживают функцию.
В куче: экземпляры классов и акторов, контексты убегающих замыканий и хранилище элементов Array, Dictionary, Data и длинных значений String. Сама структура маленькая, а её буфер лежит в куче.
Значимые типы переезжают в кучу, когда их захватывает убегающее замыкание, когда они упакованы в any Protocol и не помещаются во встроенный буфер или когда хранятся внутри класса.
Почему это важно: выделение в куче требует потокобезопасного аллокатора и атомарного подсчёта ссылок, поэтому тысячи мелких объектов в горячем цикле стоят намного дороже тех же данных в структурах.
Стек маленький (около 1 МБ у главного потока iOS и 512 КБ у остальных), поэтому неконтролируемая рекурсия заканчивается крэшем из-за переполнения стека.
Retain cycle возникает, когда два или больше объекта держат друг друга сильными ссылками, поэтому ни один счётчик не доходит до нуля и все они утекают. Разрывают его, делая одну из связей weak или unowned, обычно обратную.
Замыкания: объект хранит замыкание, которое сильно захватывает self: completion handler в свойстве, Combine-sink, сохранённый в cancellables самого self, блочный observer NotificationCenter. Захватывайте [weak self].
Действия на своих вью: button.addAction(UIAction { _ in self.reload() }, for: .touchUpInside) на кнопке, которой владеет контроллер, замыкает петлю self → кнопка → UIAction → self.
Ссылки на методы: viewModel.onUpdate = render сильно захватывает self точно так же, как замыкание, и на ревью это легко пропустить.
Делегаты и связи родитель–потомок: объявляйте weak var delegate: SomeDelegate?; чтобы weak скомпилировался, протокол должен быть ограничен классами (AnyObject).
Таймеры: run loop удерживает запланированный Timer, а таймер удерживает свою цель или блок, поэтому повторяющийся таймер держит владельца живым до вызова invalidate().
Проверка: поставьте print в deinit и закройте экран. Если ничего не напечаталось, Memory Graph Debugger покажет, кто ещё держит объект.
Не каждому замыканию нужен [weak self]: замыкание, которое выполнилось и освободилось, как блок UIView.animate или неубегающий map, цикл образовать не может.
C и D создают циклы, A и B не создают. Все четыре сильно захватывают self, но A и B освобождаются после выполнения и могут разве что отложить deinit, а C и D хранит объект, которым владеет сам self, и петля замыкается.
A: UIKit держит блок анимации, только пока настраивает анимацию, и затем отпускает его. Цикла нет, [weak self] не нужен.
B: сессия держит completion handler, пока запрос не завершится. Если пользователь закроет экран раньше, контроллер освободится только с приходом ответа, после того как reload() отработает для экрана, которого никто не видит. Это не утечка, но [weak self] стоит поставить, когда такая запоздалая работа нежелательна, например обновление UI или отправка события аналитики.
C: self → onSave → замыкание → self. Классический цикл.
D: self → view → saveButton → UIAction → обработчик → self. Цикл идёт через иерархию вью, поэтому его легко пропустить на ревью.
Исправление: { [weak self] in self?.reload() } для C и D. Про любое замыкание стоит спрашивать: кто его хранит и владеет ли self этим хранителем, напрямую или через другие объекты.
Run loop удерживает запланированный таймер, а таймер удерживает свою цель self, поэтому после закрытия экрана счётчик контроллера не падает до нуля. deinit, единственное место с invalidate(), не выполняется: каждый заход оставляет утёкший контроллер, который продолжает тикать.
Почему: Timer с target–selector сильно держит цель, пока его не инвалидируют, а главный run loop держит повторяющийся таймер живым. Цепочка run loop → таймер → контроллер не проходит через свойство timer, поэтому weak у этого свойства ничего не меняет.
Цена: каждый утёкший экран держит в памяти иерархию вью и будит CPU каждую секунду.
Исправление: блочный scheduledTimer(withTimeInterval:repeats:block:) с [weak self]: контроллер освобождается при закрытии, а его deinit инвалидирует таймер (в Swift 6 в блоке нужен ещё MainActor.assumeIsolated). Либо запускать таймер в viewWillAppear и инвалидировать в viewDidDisappear.
Как поймать: юнит-тест, который вызывает loadViewIfNeeded(), отпускает контроллер и проверяет, что weak-ссылка на него стала nil. Инструмент Leaks здесь молчит: run loop всё ещё может дотянуться до контроллера, поэтому утечку видно только по растущему числу экземпляров в Memory Graph.
Утечкой называют память, которая приложению больше не нужна, но так и не освобождается. Инструмент Leaks показывает только объекты, до которых ничто не может дотянуться; на практике это retain cycle, а основной реальный рост памяти по-прежнему достижим из кэша, массива или синглтона и виден только в Allocations и Memory Graph.
Два вида: утёкшая память (недостижимая, цикл) и брошенная память (достижимая, но больше не используемая). В Swift-приложениях чаще встречается вторая.
Memory Graph Debugger: поставьте приложение на паузу в Xcode, ищите фиолетовые значки, которыми отмечены циклы, и смотрите число экземпляров по классам: три ProfileViewController после одного захода на экран и есть баг. Включите Malloc Stack Logging в схеме, чтобы видеть, где был создан каждый объект.
Allocations с поколениями: отметьте поколение, откройте и закройте экран, отметьте снова. Всё, что держит новое поколение, пережило проход, после которого должно было освободиться.
Типичные источники: замыкания и таймеры, захватившие self, Task, крутящий бесконечный стрим, неудалённые observers, кэши на обычном Dictionary, синглтоны, держащие вью-контроллеры, и неограниченные буферы: AsyncStream по умолчанию .unbounded, а Combine-sink запрашивает неограниченный demand.
Закрепите в тестах: addTeardownBlock { [weak sut] in XCTAssertNil(sut) } роняет тест, если объект его пережил.
Освободить всё, что можно пересоздать (кэши картинок, декодированные данные, тяжёлое состояние невидимых экранов), потому что система может убить процесс следующим шагом. NSCache делает значительную часть этого сам: он вытесняет объекты при нехватке памяти, а Dictionary только растёт.
Где приходит предупреждение: didReceiveMemoryWarning() во вью-контроллере, applicationDidReceiveMemoryWarning(_:) или UIApplication.didReceiveMemoryWarningNotification из любого места. DispatchSource.makeMemoryPressureSource сообщает уровни warning и critical.
Не полагайтесь на него: предупреждение может прийти слишком поздно или не прийти вовсе перед убийством процесса. Держите потребление памяти ограниченным всегда, а предупреждение считайте последним шансом.
В фоне выживание решает размер: при нехватке памяти система сначала убивает фоновые приложения, и чем меньше ваш footprint, тем меньше шанс, что выберут ваше. Сбрасывайте кэши при уходе приложения в фон.
**NSCache**: потокобезопасен, вытесняет объекты сам и не копирует ключи. countLimit и totalCostLimit задают мягкие лимиты, порядок вытеснения не документирован, а ключи и значения должны быть объектами (NSURL, NSString или обёртка-класс).
Нужна точная политика, например настоящий LRU с жёстким лимитом? Пишите сами: словарь плюс двусвязный список дают поиск и вытеснение за O(1).
Цикл заполняет autorelease pool, который очищается только в конце текущего оборота run loop или рабочего блока, поэтому временные картинки и данные каждой итерации живут до завершения всего цикла. Если обернуть тело цикла в autoreleasepool { }, пул будет очищаться на каждой итерации.
Кто делает autorelease: чистые Swift-объекты освобождаются ARC при последнем использовании и пул не трогают. А Objective-C-код под капотом, здесь UIKit, ImageIO и бриджинг NSData, по-прежнему возвращает autoreleased-временные объекты.
Когда пул очищается: главный поток очищает его в конце каждой итерации run loop, а фоновый поток очищает не раньше конца своего рабочего блока. Долгий цикл внутри одного из них до этого момента не доходит.
Исправление: поместите тело в try autoreleasepool { ... }. Функция дженерик и rethrows, поэтому try внутри работает. continue внутри замыкания не компилируется, поэтому в guard вместо него пишут return.
Что ещё стоит исправить: UIImage(contentsOfFile:) декодирует фото в полном размере, прежде чем уменьшить его. CGImageSourceCreateThumbnailAtIndex из ImageIO уменьшает картинку прямо при декодировании, и пиковая память намного ниже.
В современном Swift autoreleasepool почти больше нигде не нужен: только в долгих циклах по API на Objective-C в одном потоке.
Декодированное изображение занимает ширина × высота × 4 байта, каким бы маленьким ни был файл, поэтому 12-мегапиксельное фото занимает около 48 МБ в памяти, даже если JPEG весит 2 МБ. Даунсемплинг через ImageIO декодирует сразу в размер отображения, и полный bitmap вообще не появляется.
Декодирование происходит поздно: UIImage(data:) и UIImage(contentsOfFile:) хранят сжатые байты и декодируют картинку в полном размере при первой отрисовке, на главном потоке. Это одновременно и всплеск памяти, и хитч при скролле.
Даунсемплинг через ImageIO: CGImageSourceCreateThumbnailAtIndex с kCGImageSourceThumbnailMaxPixelSize, равным размеру в поинтах × масштаб экрана, kCGImageSourceShouldCacheImmediately, чтобы декодировать сразу в фоновом потоке, и kCGImageSourceShouldCache: false у источника, чтобы не кэшировать полное изображение.
Короткий путь в UIKit: preparingThumbnail(of:) и асинхронный byPreparingThumbnail(ofSize:) (iOS 15+) делают то же для уже имеющегося изображения, но исходник всё равно читается целиком.
**UIImage(named:) кладёт картинки в системный кэш, рассчитанный на маленькие повторяющиеся ассеты. Большие разовые изображения загружайте по-другому.
Кэшируйте маленький результат**, а не оригинал, в NSCache со стоимостью bytesPerRow × height.
У расширений лимит памяти намного меньше, чем у приложения, поэтому одно полноразмерное фото может убить share extension или виджет.
Task сильно держит self, пока выполняется его тело, а цикл for await не заканчивается, потому что стрим никогда не завершается. Задачу никто не хранит и не отменяет, поэтому она держит модель живой вечно.
Это не классический цикл: модель не держит задачу. Задача просто владеет self, пока её замыкание не вернётся, а здесь это не происходит никогда.
**Что наследует Task { }: приоритет, изоляцию актора и task-local значения, но не время жизни создавшего его объекта. Он не является дочерним ни для какой внешней задачи, поэтому никто не отменит его автоматически.
Одного [weak self] мало**, если перед циклом написать guard let self: тогда self становится сильным на весь цикл. Разворачивайте его внутри каждой итерации.
Управляйте временем жизни сами: сохраните Task и вызывайте cancel(), когда экран уходит. В SwiftUI лучше .task { }, который отменяется при исчезновении вью.
Отмена кооперативная: она лишь ставит флаг. for await, Task.sleep и URLSession на него реагируют, а вычислительный цикл должен сам вызывать Task.checkCancellation().
Воспроизведите медленный сценарий на реальном устройстве в Release-сборке, запишите его шаблоном, подходящим под симптом, и меняйте по одной вещи за раз, сравнивая один и тот же трейс до и после. Симулятор работает на CPU вашего Mac, поэтому его тайминги ничего не значат.
Time Profiler: сэмплы стеков вызовов. Отфильтруйте главный поток, инвертируйте дерево вызовов и скройте системные библиотеки, чтобы найти, какой из ваших методов съедает время.
Hangs и Animation Hitches: зависания главного потока со стеками и кадры, не успевшие к дедлайну, с разбивкой на время commit и render.
Allocations и Leaks: память во времени с отметками поколений и недостижимые циклы.
SwiftUI: вычисления body и изменения состояния, которые их вызвали; инструмент переработали в Xcode 26.
App Launch, Network и Power Profiler: фазы запуска, тайминги запросов и энергопотребление.
Собственные интервалы: OSSignposter размечает фазы вашего кода, чтобы они вставали рядом с системными треками, а сообщения Logger появляются на той же шкале.
Сначала данные с поля: MetricKit присылает время запуска, hang rate и память от реальных пользователей и подсказывает, какую проблему профилировать первой.
Hitch означает кадр, показанный с опозданием: заметное подёргивание при скролле или анимации. При hang главный поток заблокирован на 250 мс и дольше, так что тапы не получают ответа. Оба обычно означают слишком много работы на главном потоке; лечится это переносом работы с него и укладыванием каждого кадра в бюджет.
Бюджеты: на кадр есть около 16,7 мс при 60 Гц и 8,3 мс при 120 Гц на ProMotion. Hitch бывает и со стороны рендера (тени, маски, блюр), даже когда ваш код быстрый.
Уносите работу с главного актора: декодирование JSON и изображений, сортировку и диффинг больших коллекций. В UI возвращайте только готовое значение.
Никакого синхронного I/O на главном: Data(contentsOf:), крупные выборки Core Data, DispatchQueue.main.sync или ожидание семафора.
Откладывайте то, что не нужно первому кадру: запускайте это в .task или после viewDidAppear, а не в init или viewDidLoad.
Изоляция по умолчанию в Swift 6.2: в модуле, где по умолчанию главный актор, код без аннотаций выполняется на главном. Работу, которая должна уйти с него, помечайте @concurrent.
Как найти: инструменты Hangs и Animation Hitches, Thread Performance Checker в Xcode и hang rate из MetricKit от реальных пользователей.
Укладывайте работу каждой ячейки в бюджет кадра (около 16 мс при 60 Гц и 8 мс при 120 Гц): переиспользуйте ячейки, загружайте и декодируйте картинки вне главного потока в размере отображения и избегайте работы с layout и рендерингом, которая повторяется каждый кадр.
Переиспользование: всегда используйте dequeue и отменяйте загрузку картинки в prepareForReuse, чтобы картинка старой строки не попала в новую.
Prefetching: UICollectionViewDataSourcePrefetching начинает загрузку до появления строки; отменяйте эти загрузки в cancelPrefetchingForItemsAt.
Изображения: декодируйте и уменьшайте вне главного потока. Полноразмерный UIImage декодируется при первой отрисовке, на главном потоке.
Обновления: применяйте снапшоты diffable data source вместо reloadData(), а для обновления содержимого без пересоздания ячеек используйте reconfigureItems.
Layout: плоские дешёвые ячейки, self-sizing с разумными оценками размера и никакого layoutIfNeeded() внутри cellForRowAt.
Рендеринг: тени с явным shadowPath, непрозрачные фоны, никаких масок на каждой ячейке.
Измеряйте через Animation Hitches и Time Profiler: обычно виноват один дорогой вызов при конфигурации ячейки.
Offscreen rendering означает, что GPU рисует поддерево слоёв в отдельный буфер и потом композитит его обратно, потому что не может нарисовать его за один проход. Его вызывают маски, тени без shadowPath, групповая прозрачность и shouldRasterize, и цена повторяется каждый кадр для каждого такого слоя.
Кадр проходит через ваш коммит (layout и draw), композитинг дерева слоёв в render server и GPU. Offscreen-проходы стоят времени на двух последних этапах, поэтому Time Profiler может показывать простаивающий главный поток, пока кадры продолжают теряться.
Что его вызывает
**layer.mask и тени без shadowPath: GPU должен сначала отрисовать содержимое, чтобы узнать его форму. Десять карточек с мягкой тенью дают десять лишних проходов на кадр.
cornerRadius с masksToBounds поверх поддерева: скругление одной картинки или фонового цвета идёт по быстрому пути, а обрезка нескольких подслоёв по нему не идёт. Скругляйте bitmap заранее.
Групповая прозрачность**: alpha меньше 1 у вью с сабвью, потому что allowsGroupOpacity по умолчанию включён.
**shouldRasterize: кэширует плоский bitmap, что выгодно только для никогда не меняющегося контента. Всё анимируемое он растеризует заново каждый кадр.
Смежные затраты GPU
Смешивание**: каждый не полностью непрозрачный слой смешивается с тем, что под ним. Ставьте isOpaque = true и настоящий backgroundColor.
Блюр: UIVisualEffectView стоит пропорционально своей площади, поэтому полноэкранный блюр поверх скроллящегося списка обходится дорого.
Как найти
В симуляторе Debug → Color Off-screen Rendered красит виновные слои жёлтым, а Color Blended Layers красит смешиваемые пиксели красным; на устройстве те же переключатели есть в Xcode в Debug → View Debugging → Rendering.
Animation Hitches делит время хитча на commit и render; длинные фазы render при спокойном главном потоке указывают именно сюда.
Символизируйте репорт dSYM-файлом именно этой сборки, а затем прочитайте тип исключения и стек упавшего потока. Тип исключения подсказывает, что это за баг, ещё до того, как вы откроете код.
Откуда берутся репорты: Xcode Organizer (от пользователей, которые делятся аналитикой), TestFlight, MXCrashDiagnostic из MetricKit или крэш-SDK вроде Crashlytics.
dSYM: отладочные символы, вырезанные из релизного бинарника. У каждой сборки свой UUID, и символизировать её может только dSYM с тем же UUID, поэтому храните все архивы и выгружайте dSYM в крэш-сервис из CI.
**EXC_BREAKPOINT (SIGTRAP)**: ловушка рантайма Swift: force unwrap nil, выход за границы массива, fatalError, арифметическое переполнение, чтение освобождённой unowned-ссылки.
**EXC_BAD_ACCESS (SIGSEGV/SIGBUS): обращение к недопустимой памяти: use-after-free, небезопасные указатели, гонки данных.
EXC_CRASH (SIGABRT)**: непойманное исключение Objective-C или явный abort(). Секция Last Exception Backtrace показывает, где его бросили.
**SIGKILL с termination reason**: приложение убила система (watchdog, память, перегрев). Верхний фрейм багом не является.
Дальше ранжируйте крэши по числу затронутых пользователей, воспроизводите на той же версии ОС и классе устройств и закрепляйте исправление тестом.
Приложение обратилось к памяти, к которой нельзя: к освобождённому объекту, висячему указателю или неотображённому адресу. Строка крэша часто лишь место, где повреждение заметили, поэтому нужны инструменты, которые останавливают приложение в момент самого неверного обращения.
В безопасном Swift это редкость: он вместо этого падает с EXC_BREAKPOINT. Подозревайте Unmanaged, unowned(unsafe), UnsafePointer, использованный за пределами своего замыкания withUnsafe…, интероп с C и Objective-C и гонки данных, портящие общее состояние.
Address Sanitizer (схема → Diagnostics) останавливается ровно на use-after-free или переполнении буфера и печатает, где память выделили и где освободили. Замедляет приложение примерно в 2–5 раз, поэтому запускайте его в debug и в отдельном прогоне тестов на CI.
Thread Sanitizer: сообщает о гонках данных с обоими стеками. Вместе с Address Sanitizer запускаться не может.
Zombie Objects: освобождённые Objective-C-объекты становятся зомби и пишут «message sent to deallocated instance» вместо того, чтобы падать в случайном месте.
Malloc Scribble и Guard Malloc: заполняют или защищают освобождённую память, чтобы случайный крэш стал воспроизводимым.
Exception breakpoint: при SIGABRT от исключения Objective-C останавливает на месте броска, а не на abort где-то в глубине main.
Его убил watchdog: приложение не завершило переход жизненного цикла (запуск или возврат на передний план) за отведённое время, поэтому система прислала SIGKILL и пометила отчёт кодом 0x8badf00d («ate bad food»).
Лимит: около 20 секунд от запуска до первого кадра и меньше на возобновление или приостановку сцены. В Termination Reason указан FRONTBOARD и исчерпанный лимит времени, так что видно, какой переход не уложился.
Читайте Thread 0, а не верхний кадр: ищите semaphore_wait, __psynch_mutexwait, dispatch_sync или чтение из сокета. Это заблокированный главный поток, а не падение в этом кадре.
Типичные причины: синхронный сетевой вызов, DispatchSemaphore, которым async-код делают похожим на синхронный, DispatchQueue.main.sync с очереди, которую главный поток уже ждёт, миграция Core Data или SwiftData во время запуска или SDK, делающий что-то из этого в didFinishLaunchingWithOptions.
Соседние коды: 0xdead10cc означает, что приложение держало файловую или SQLite-блокировку в момент приостановки; 0xc00010ff означает убийство из-за перегрева.
Ловите раньше пользователей: Thread Performance Checker во время работы подсвечивает блокирующие вызовы на главном потоке, инструмент Hangs показывает каждое залипание со стеком, а MetricKit считает watchdog-завершения в MXAppExitMetric.
Воспроизводите так, как это получают пользователи: холодный запуск, пустые кэши и сеть, которая отвечает за три секунды, а не за тридцать миллисекунд.
Выберите несколько заметных пользователю показателей и измеряйте их в двух местах: тесты производительности в CI на одном и том же устройстве ловят регрессию до релиза, а MetricKit и Xcode Organizer показывают, что видят реальные устройства после него.
Несколько бюджетов: холодный запуск до первого кадра, доля зависаний, доля хитчей на главном скролле, пиковая память на самом тяжёлом экране, размер загрузки. Измерять всё означает не следить ни за чем.
Метрики XCTest: measure(metrics:) с XCTApplicationLaunchMetric, XCTMemoryMetric, XCTOSSignpostMetric или XCTClockMetric записывает baseline и роняет прогон, когда сборка уходит за допустимое отклонение.
Стабильные цифры: запускайте эти тесты на одной модели физического устройства с Release-сборкой. Симулятор или загруженная общая CI-машина дают шум, а нестабильный тест производительности отключают уже через неделю.
Xcode Organizer: время запуска, доля зависаний, память, запись на диск, энергопотребление и завершения по каждой версии приложения от пользователей, которые делятся аналитикой.
MetricKit: MXMetricPayload приходит примерно раз в сутки с гистограммами; MXDiagnosticPayload несёт стеки зависаний, крэшей и CPU-исключений. Отправляйте оба к себе, чтобы резать данные по устройству, версии ОС и фиче-флагу.
Перцентили, а не средние: медианный пользователь никогда не видит того, с чем каждый день живут самые медленные 10% устройств, поэтому алерты стоит ставить на p90 и p95.
Приложение только описывает кадр: в конце итерации run loop оно коммитит изменённое дерево слоёв в render server, отдельный процесс, который рисует его на GPU и сам проигрывает анимации. Уже переданная анимация продолжает идти, даже когда ваш главный поток заблокирован.
Почему спиннер выживает
UIActivityIndicatorView и UIView.animate создают CAAnimation, которые render server интерполирует сам. Замерзает всё, чем владеет главный поток: обработка касаний и новые коммиты. Колбэк CADisplayLink работает на вашем run loop, поэтому останавливается вместе с приложением.
Поэтому зависание (hang, 250 мс и больше без обработки ввода) выглядит как мёртвые кнопки, а не как застывшая картинка. Оценивайте его инструментом Hangs, а не на глаз.
Цикл отрисовки
Event: касания, таймеры и колбэки меняют состояние вью на главном потоке.
Commit: в конце итерации run loop коммитится неявная CATransaction: layout (layoutSubviews), display (draw(_:)), декодирование изображений, затем дерево слоёв кодируется и отправляется в render server. Все изменения за одну итерацию run loop коммитятся вместе: присвойте frame трижды, и нарисуется только последнее значение.
Render: render server декодирует дерево и рисует его на GPU, включая offscreen-проходы.
Display: готовый кадр появляется на одном из следующих vsync. Каждая стадия должна уложиться во время одного кадра: 16,7 мс на 60 Гц, 8,3 мс на 120 Гц.
Где теряются кадры
Медленный commit (главный поток) или медленный render (GPU) одинаково дают хитч; два вида и способы измерения разобраны в вопросе про анимации.
Цифра индикатора. Лимит памяти система применяет к phys_footprint (всей грязной и сжатой памяти процесса), а Allocations показывает в основном то, что прошло через malloc и рантаймы объектов, и пропускает крупные регионы памяти вне malloc-кучи.
Грязная память включает всё, куда приложение писало: куча, декодированные битмапы, стеки; она считается целиком. Чистая память привязана к файлу и может быть перечитана, поэтому ядро может её вытеснить: бинарник приложения, замапленные файлы. Она не считается, пока вы в неё не пишете.
Сжатая память состоит из грязной памяти, которую ядро сжало под давлением. Она всё равно считается, в сжатом размере, а обращение к ней стоит процессорного времени на распаковку. Footprint, который падает без единого освобождения, обычно говорит просто о сжатии.
Где обычно недостающие 390 МБ: память IOSurface и IOKit, общая с GPU: backing store слоёв, декодированные изображения на экране, CVPixelBuffer, текстуры Metal. В сетке фотографий это почти вся разница. Инструмент VM Tracker, vmmap --summary и утилита footprint раскладывают процесс по регионам.
Измеряйте цифру, которая убивает: os_proc_available_memory() возвращает запас до лимита, а task_info с TASK_VM_INFO отдаёт phys_footprint изнутри приложения.
Отображайте файл в память вместо чтения: Data(contentsOf: url, options: .mappedIfSafe) даёт чистые вытесняемые страницы, а обычный Data(contentsOf:) копирует файл в грязную память.
Берите цифру из Release-сборки на устройстве: Malloc Stack Logging и санитайзеры раздувают отладочную сессию. Затем сверяйте её с пиковой памятью из MetricKit.
Измерьте холодный запуск до первого кадра на реальных устройствах и сокращайте две фазы по отдельности: работу dyld и рантайма до main и ваш собственный код от main до отрисовки первого экрана. Apple советует ориентироваться на первый кадр примерно за 400 мс.
До main
Динамические фреймворки: каждый dyld загружает, связывает и проверяет подпись при каждом холодном запуске. Линкуйте статические библиотеки или используйте mergeable libraries (Xcode 15+), чтобы в Debug фреймворки оставались динамическими, а в Release сливались в один бинарник.
Статические инициализаторы: методы +load в Objective-C и статические конструкторы C++ выполняются до main. Глобальные переменные и static let в Swift ленивые и инициализируются при первом обращении, поэтому здесь они безопасны.
От main до первого кадра
В didFinishLaunchingWithOptions, настройке сцены и body или viewDidLoad первого экрана прячется большая часть лишнего времени: инициализация SDK, синхронные чтения из Keychain или базы, разбор большого конфига, прогрев кэшей.
Откладывайте каждый SDK, не нужный первому экрану, делайте сервисы ленивыми, а остальное запускайте в .task или фоновой задаче после первого кадра.
Показывайте закэшированный контент, а не ждите сеть.
Измерение
Шаблон App Launch в Instruments показывает обе фазы на одной временной шкале, а интервалы OSSignposter отмечают ваши собственные шаги.
MXAppLaunchMetric из MetricKit и Xcode Organizer дают время запуска у реальных пользователей; XCTApplicationLaunchMetric ловит регрессии в CI.
Измеряйте холодные запуски после перезагрузки: тёплый запуск переиспользует страницы, которые уже в памяти, и приукрашивает результат. Система также может заранее прогреть процесс (prewarming) до того, как пользователь нажмёт на иконку, поэтому время от старта процесса не равно тому, сколько ждал пользователь.
Энергия уходит в основном на пробуждение железа (сотового модуля, GPS, GPU и CPU), а не на выполнение кода, поэтому исправления сводятся к тому, чтобы делать работу реже и пачками. Сначала измерьте на устройстве: обычно причиной оказывается одна подсистема, которую забыли остановить.
Измерение: инструмент Power Profiler (Xcode 26) раскладывает потребление по CPU, GPU, дисплею и сети, Xcode Organizer показывает отчёты об энергии от пользователей, а MetricKit присылает время CPU и GPU, активность геолокации и условия сотовой связи.
Сетевой трафик пачками: сотовый модуль остаётся включённым несколько секунд после каждой передачи, поэтому пятьдесят мелких запросов стоят намного дороже одного. Буферизуйте аналитику и отправляйте её пачками или при уходе приложения в фон.
Никакого поллинга: таймер, который каждые тридцать секунд ходит на сервер, становится классическим пожирателем батареи. Используйте push-уведомления или одно долгоживущее соединение.
Пусть момент выбирает система: фоновая URLSession с isDiscretionary = true для больших передач и BGAppRefreshTask или BGProcessingTask для обслуживающей работы.
Геолокация: обычно самый крупный потребитель. Запрашивайте самую грубую точность, которой достаточно, предпочитайте significant-change или мониторинг регионов непрерывным обновлениям и останавливайте обновления, когда экран уходит.
Не рисуйте, когда ничего не меняется: бесконечные анимации, CADisplayLink, забытый на 120 Гц, или видео, которое крутится за статичным экраном, держат GPU и дисплей занятыми.
Учитывайте Low Power Mode и нагрев: приостанавливайте префетчинг и необязательные анимации, когда ProcessInfo.processInfo.isLowPowerModeEnabled равно true, и сокращайте работу, когда растёт thermalState.
Измерьте то, что пользователи реально скачивают (размеры по устройствам в App Store Connect или App Thinning Size Report из Xcode), и атакуйте самую большую часть: обычно это ресурсы, затем сторонний код. .ipa на вашем Mac содержит варианты для всех устройств, и это не та цифра.
Ресурсы: держите изображения в asset catalog, чтобы app thinning доставлял только нужный устройству масштаб, предпочитайте SF Symbols и векторные изображения наборам битмапов, пережимайте слишком большие картинки и ищите встроенные видео, шрифты и дубликаты изображений.
Скачивайте контент позже: уводите туториалы, наборы уровней и редко используемые медиа из установки с помощью Background Assets. С iOS 26 Apple может сама хостить asset pack'и и представляет Background Assets как замену On-Demand Resources.
Код: держите включённым dead code stripping, вырезайте символы в Release и собирайте модули, где скорость не важна, с -Osize. Обильная специализация дженериков и большие @inlinable API тоже добавляют код.
Зависимости: linker map (LD_GENERATE_MAP_FILE = YES) показывает байты по модулям. Один SDK со своим сетевым стеком, аналитикой или ML-моделью может стоить десятки мегабайт, а каждый динамический фреймворк несёт свои метаданные; статическая линковка или mergeable libraries уменьшают и размер, и время запуска.
Следите за размером в CI: записывайте размер после thinning для каждой сборки и роняйте её при превышении бюджета, потому что размер растёт по одной зависимости за раз. Это важно: по сотовой сети App Store по умолчанию просит подтверждение для загрузок больше 200 МБ, а конверсия в установку падает с ростом размера.
Взрыв потоков (thread explosion) случается, когда работа, отправленная на конкурентную очередь, блокируется, и GCD запускает ещё один поток для следующего элемента, потом ещё один. Десятки почти всё время заблокированных потоков тратят память и CPU на переключение контекста вместо работы.
Классический источник: DispatchQueue.global().async в цикле, где каждый элемент блокируется на вводе-выводе, блокировке или семафоре.
Кооперативный пул: Swift Concurrency выполняет задачи на пуле размером примерно с число ядер CPU. Это работает только потому, что задачи никогда не блокируются: на await они приостанавливаются, и поток берёт другую задачу. Блокировка внутри задачи нарушает этот контракт и может остановить пул; см. вопрос по Swift Concurrency о блокировке кооперативного пула.
Ограничивайте параллелизм: task group, которая добавляет новые задачи только по мере завершения старых, держит в работе, скажем, шесть загрузок, а не пятьсот.
Диагностика: инструмент Swift Concurrency показывает задачи и акторы во времени; список потоков в Time Profiler показывает кучу потоков GCD.