App extension представляет собой отдельный таргет со своим bundle id и своим процессом, который система запускает, когда он нужен хосту, например домашнему экрану или share sheet. Он делит с приложением исходный код, но не его состояние в памяти.
Основные виды
Widget extension: виджеты WidgetKit, Live Activities и (с iOS 18) контролы Пункта управления.
Share и Action: получают NSExtensionItem в share sheet.
Notification Service и Content: переписывают push перед показом или рисуют собственный UI уведомления.
App Intents: выполняют действия Siri, Shortcuts и Spotlight без запуска приложения.
Чем отличается от приложения
Процесс: собственный процесс и sandbox. Синглтонов, настроенных в App.init или AppDelegate, там нет; данные переходят только через контейнер App Group, общую access group Keychain или сеть.
Жизненный цикл: нет UIApplication (UIApplication.shared недоступен) и колбэков запуска. Система стартует расширение по требованию и завершает, как только работа сделана.
Память и время: лимиты намного жёстче, чем у приложения: widget extension ограничен десятками МБ (обычно измеряют около 30 МБ; Apple это не документирует) и несколькими секундами на колбэк. Превышение означает убитый процесс, а не замедление.
iOSMiddle
Расширения и WidgetKit
Таймлайны WidgetKit, Live Activities, App Intents, share- и notification-расширения, App Groups
Закрыто вопросов
0 / 20
- 0Повторить
Вопросы
Виджет состоит из Widget с конфигурацией, timeline provider, который выдаёт записи с датами, и SwiftUI-view, рисующей каждую запись. WidgetKit рендерит эти view заранее и показывает сохранённый результат, поэтому, пока виджет виден, в view ничего не выполняется.
**Widget**: объявляет строку kind (по ней перезагружают виджет), конфигурацию и supportedFamilies.
Конфигурация: StaticConfiguration или AppIntentConfiguration, если виджет можно редактировать.
**TimelineEntry**: date, с которой запись становится текущей, плюс данные для view.
**TimelineProvider: возвращает записи и политику перезагрузки. Это код, который получает данные внутри расширения, когда попросит система.
WidgetBundle** с @main служит точкой входа, если в расширении несколько виджетов.
Отрендеренная view архивируется, поэтому onAppear, таймеры и жесты на экране ничего не делают; всё живое дают view, которые анимирует система, например Text(_:style:).
placeholder мгновенно возвращает заглушку-скелет, которую рисуют серыми плашками; snapshot возвращает одну запись для галереи виджетов и других временных показов; timeline возвращает записи с датами, которые виджет реально покажет, и указывает, когда спросить снова.
**placeholder(in:)**: синхронный, без I/O. WidgetKit рисует его с маскировкой (redacted), серыми плашками вместо текста, пока готовится настоящая view, поэтому значения стоит захардкодить.
**snapshot(for:in:)**: вызывается для галереи виджетов и первого показа после того, как пользователь добавил виджет. Когда context.isPreview равен true, сразу верните тестовые данные: ожидание хранилища или сети здесь и есть причина, по которой виджет в галерее висит пустым или показывает состояние «не залогинен».
**timeline(for:in:)**: единственный колбэк, где стоит читать хранилище или ходить в сеть, и даже у него всего несколько секунд.
context.family и context.displaySize говорят, какой размер рендерится; один provider обслуживает все семейства.
Настраиваемые виджеты используют AppIntentTimelineProvider (iOS 17+), чьи async-методы получают интент конфигурации; TimelineProvider с completion handler остаётся для статических виджетов.
.widgetURL(_:) делает весь виджет одной зоной тапа с одним URL; Link даёт отдельные адреса частям виджета, но только в семействах medium, large и extra-large; у маленьких виджетов и аксессуаров на экране блокировки есть только widgetURL.
Ставьте один widgetURL, в корне. Несколько в одном дереве дают неопределённое поведение, и строка со своим собственным widgetURL часто оказывается причиной того, что любая строка открывает один и тот же экран.
Используйте оба вместе: Link на каждую строку и widgetURL как запасной вариант для тапов мимо ссылок.
Приложение получает URL в onOpenURL. Кодируйте стабильный идентификатор, а не индекс в списке: запись, которая нарисовала строку, могла устареть на несколько часов.
Button(intent:) не ссылка: он выполняет App Intent в фоне вместо открытия приложения.
Live Activity представляет собой карточку одного идущего события (доставка, поездка, матч): приложение запускает её и обновляет на месте на экране блокировки и в Dynamic Island. В отличие от виджета она привязана к одному событию и обновляется сразу и явно; в отличие от пуша это одна меняющаяся карточка, а не россыпь сообщений.
vs виджет: виджет добавляет пользователь, и система обновляет его в рамках бюджета. Live Activity запускает приложение, у неё есть явный конец, и обновления (activity.update или push от ActivityKit) появляются сразу.
Из чего сделана: ActivityKit отвечает за данные и жизненный цикл, а SwiftUI-view в widget extension отвечает за UI.
Ограничения: активна до 8 часов, потом система её завершает; карточка может провисеть на экране блокировки ещё до 4 часов. Пользователь может отключить её для приложения.
Отсчёт не пойдёт: WidgetKit рендерит body один раз и показывает заархивированный результат, поэтому ни onAppear, ни таймер не могут изменить @State на экране, и число никогда не меняется. Обратный отсчёт в виджете должен быть view, которую анимирует сама система, например Text(timerInterval:countsDown:).
Пока виджет виден, расширение не работает: нет run loop, на котором сработал бы Timer.publish, и нет перерисовки после изменения стейта.
Text(timerInterval:countsDown:) и Text(_:style: .timer) рисует и обновляет система, без единой перезагрузки; ProgressView(timerInterval:countsDown:) делает то же для полосы прогресса.
Text(date, style: .timer) после наступления даты снова начинает считать вверх, поэтому добавьте запись таймлайна с датой дедлайна, которая переключит view в завершённое состояние.
ClosedRange, у которого конец раньше начала, падает в рантайме, поэтому проверяйте дедлайн до того, как строить start...end.
Политика перезагрузки сообщает WidgetKit самое раннее время, когда можно попросить у provider новый таймлайн; это просьба, а не расписание. Чтобы обновить виджет сразу, приложение записывает новые данные туда, откуда их читает виджет, и вызывает WidgetCenter.shared.reloadTimelines(ofKind:).
**.atEnd: спросить снова после даты последней записи. Одна запись с датой «сейчас» означает «спроси немедленно», и это сжигает дневной бюджет.
.after(date): спросить не раньше этой даты. Подходит, когда известно, когда данные могут измениться: полночь, открытие биржи, следующая смена.
.never**: система сама не спрашивает никогда; перезагрузку может вызвать только приложение или App Intent.
reloadTimelines(ofKind:) и reloadAllTimelines() работают только пока выполняется приложение или одно из его расширений. Приложение, которое не запускалось со вчерашнего дня, ничего не перезагрузит, и это обычная причина устаревшего виджета. Будите его фоновым push или BGAppRefreshTask.
Сначала сохранить, потом перезагрузить: provider читает хранилище App Group, и перезагрузка до окончания записи нарисует старое значение.
WidgetCenter.shared.currentConfigurations() показывает, какие виджеты добавлены, так что синхронизацию можно пропустить, если их нет.
UserDefaults.standard у каждого процесса свой, внутри его sandbox, поэтому приложение сохраняет в свой контейнер, а widget extension читает свой, пустой, и integer(forKey:) возвращает 0 для отсутствующего ключа. Обоим таргетам нужен suite App Group.
Почему: приложение и расширение живут в разных бандлах с разными контейнерами; .standard указывает на plist в Library/Preferences контейнера того процесса, который его вызывает. Общий исходный файл делит код, но не данные. Перезагрузка работает исправно: она просто заново читает пустое хранилище.
Исправление: включите capability App Groups с одним и тем же id группы на обоих таргетах и используйте UserDefaults(suiteName:) с этим id. Для файлов и баз данных правило то же: кладите их в FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:).
Тихий отказ: если у одного таргета нет entitlement, suite в этом процессе ведёт себя как приватное хранилище: данные не общие, и ничего не падает. containerURL(forSecurityApplicationGroupIdentifier:) в этом случае возвращает nil, поэтому assert на него при запуске в обоих таргетах ловит неправильно настроенный таргет при первом же отладочном запуске.
Миграция: значения, которые уже есть у пользователей, лежат в старом .standard; один раз скопируйте их в suite при первом запуске после обновления, иначе у существующих пользователей виджет начнёт с нуля.
У каждой записи дата .now, поэтому все четыре становятся актуальными в один и тот же момент, и виджет никогда не проходит по часам. А раз дата последней записи уже прошла, .atEnd сразу же просит новый таймлайн, снова и снова.
date записи означает момент, когда WidgetKit её подставит, а не время получения данных. Ставьте каждой записи дату hour.start.
С правильными датами .atEnd спросит снова, когда текущей станет последняя запись: одна перезагрузка покрывает четыре записи и примерно три часа.
Provider, который постоянно просит перезагрузку немедленно, тратит дневной бюджет за первый час; дальше система перестаёт его перезагружать, и виджет выглядит замёрзшим, хотя код «обновляется».
Запись с датой в прошлом для первого слота допустима: WidgetKit показывает последнюю запись, чья дата уже наступила.
Каждый добавленный виджет получает скользящий бюджет (обычно 40–70 перезагрузок в сутки, примерно раз в 15–60 минут), и его размер зависит от того, как часто пользователь реально смотрит на виджет. Когда бюджет исчерпан, система молча перестаёт выполнять вашу политику перезагрузки, пока он не восстановится.
Что расходует бюджет
Перезагрузки, которые WidgetKit делает потому, что их попросила политика .atEnd или .after.
Перезагрузки, запрошенные через WidgetCenter.shared.reloadTimelines(ofKind:), пока приложение в фоне.
Что не расходует
Перезагрузки, пока приложение на переднем плане или у него активна аудио- или навигационная сессия.
Системные изменения вроде смены локали или Dynamic Type, а также изменение пользователем настроек виджета.
Всё, что происходит при отладке из Xcode: там бюджет, по распространённым сообщениям, не применяется, поэтому виджет, который в разработке обновляется каждую минуту, в продакшене замирает.
Как в него укладываться
Возвращайте много будущих записей за одну перезагрузку вместо опроса: предсказуемые данные обходятся в несколько перезагрузок в день.
Для непредсказуемых изменений шлите фоновый push, который будит приложение, синхронизируйтесь там и перезагружайте виджет, а не уменьшайте интервал.
Виджеты, которые пользователь редко видит, и устройства в режиме энергосбережения получают меньше перезагрузок.
Используйте AppIntentConfiguration с WidgetConfigurationIntent, у которого @Parameter имеет тип AppEntity; варианты выдаёт EntityQuery этой сущности, и выполняется он внутри widget extension, пока пользователь редактирует виджет.
AppIntentConfiguration (iOS 17+) заменил IntentConfiguration с файлом .intentdefinition и отдельным Intents-расширением. Интент теперь стал обычной Swift-структурой.
EntityQuery.suggestedEntities() заполняет список выбора; entities(for:) восстанавливает идентификаторы, выбранные раньше. Для фиксированного набора значений проще AppEnum или DynamicOptionsProvider.
Provider становится AppIntentTimelineProvider, и каждый колбэк получает настроенный интент, так что timeline(for:in:) читает configuration.project напрямую.
Query работает с лимитами памяти и времени расширения, возможно ещё до первого запуска приложения. Читайте закэшированный список из App Group; холодный запрос к API оставляет экран редактирования с вечным спиннером.
entities(for:) должен переживать id, которого больше нет: верните пустой массив и дайте таймлайну откатиться к значению по умолчанию, а не бросайте ошибку.
Миграция виджета с iOS 16: Xcode умеет конвертировать intent definition в App Intent. Сохраните сгенерированные имена типов и параметров, иначе виджеты, уже стоящие у пользователей, потеряют настройки.
Интерактивны только Button(intent:) и Toggle(isOn:intent:): тап выполняет AppIntent в фоне, интент обновляет общие данные, а затем WidgetKit перезагружает виджет. Жесты и замыкания кнопок не выполняются никогда: view на экране остаётся заархивированным снимком.
Тап мимо интент-контролов уходит в widgetURL и открывает приложение.
perform() выполняется вне view, и у него всего несколько секунд. Пишите результат в хранилище App Group: следующий рендер читает оттуда, а не из какого-то состояния в памяти.
.invalidatableContent() помечает view, которые тап делает устаревшими; система показывает их в «ожидающем» стиле до перезагрузки, и тап ощущается мгновенным.
Toggle(isOn:intent:) сразу показывает новое положение, но правдой считается рендер после perform(): если provider читает хранилище, которое perform() не обновил, переключатель откатится.
Виджет, по которому тапнули, перезагружается сам; WidgetCenter.shared.reloadTimelines(ofKind:) вызывайте только для других kind, которых касается изменение.
Если действию нужен UI, реализуйте OpenIntent или задайте openAppWhenRun; это запускает приложение, поэтому пусть остаётся исключением.
Виджет рендерится в режиме .fullColor, .accented или .vibrant, и два последних отбрасывают ваши цвета, поэтому всё, что передаёт смысл только оттенком, исчезает. Читайте режим из environment, дополняйте цвет иконкой или словом и объявляйте фон через containerBackground.
**.fullColor: обычный домашний экран и StandBy днём.
.vibrant: виджеты на экране блокировки и StandBy ночью. Контент обесцвечивается и по яркости накладывается на системный материал, поэтому бледно-серое на белом пропадает.
.accented**: тонированный и прозрачный стили домашнего экрана и watchOS. View с .widgetAccentable() получают акцентный оттенок, всё остальное обесцвечивается; .widgetAccentedRenderingMode(_:) (iOS 18+) управляет отдельным Image.
**.containerBackground(for: .widget)**: обязателен с iOS 17. Система убирает этот фон там, где он неуместен (StandBy, экран блокировки iPad, тонированные стили); фон, нарисованный через .background, остаётся и выглядит неправильно.
.containerBackgroundRemovable(false) сохраняет фон в духе фотовиджета, но тогда виджет не предлагается в этих местах.
Перед релизом проверьте все поверхности на устройстве: домашний экран в каждом стиле, экран блокировки и StandBy.
В ActivityAttributes лежит то, что не меняется за всю жизнь activity, например id заказа и ресторан; во вложенном ContentState лежит то, что меняется: этап и ETA. Запускают через Activity.request, состояние заменяют через update, завершают через end.
Атрибуты нельзя изменить после request. Исправить неверное значение можно, только завершив activity и запустив новую, и пользователь увидит, как карточка исчезла и появилась другая.
ContentState обязан быть Codable & Hashable. Каждое обновление заменяет его целиком и ограничено 4 КБ, поэтому передавайте id и короткие значения, а картинки держите в бандле расширения или в App Group.
Activity.request(attributes:content:pushType:) работает только пока приложение на переднем плане (исключение составляет push-to-start) и только если ActivityAuthorizationInfo().areActivitiesEnabled. В Info.plist приложения нужен NSSupportsLiveActivities.
ActivityContent(state:staleDate:relevanceScore:): после staleDate view видит context.isStale и может сообщить, что данные, возможно, устарели.
end(_:dismissalPolicy:): .default оставляет финальное состояние на экране блокировки на какое-то время, .immediate убирает сразу, .after(_:) задаёт момент.
После перезапуска приложения ищите работающие activity в Activity<DeliveryAttributes>.activities, а не запускайте дубликат.
Compact (левая и правая view) показывается, когда ваша activity единственная запущенная, minimal показывается, когда activity есть у двух и более приложений, expanded открывается по долгому нажатию; плюс view для экрана блокировки, которая используется на устройствах без Island и для баннеров при обновлении.
Compact: compactLeading и compactTrailing по обе стороны от камеры. Их получает только одно приложение за раз.
Minimal: маленький кружок. При двух activity одна прикреплена к Island, а вторая отображается отдельно рядом; при большем количестве выбирает система. Он должен сам по себе узнаваться как ваше приложение: иконка, а не голое кольцо прогресса.
Expanded: собирается из DynamicIslandExpandedRegion с .leading, .trailing, .center и .bottom.
View для экрана блокировки: первое замыкание ActivityConfiguration, и оно обязательно. Держите её ниже примерно 160 поинтов, иначе система её обрежет.
.widgetURL обрабатывает тапы по compact и minimal; внутри expanded-регионов работает Link. .keylineTint(_:) красит обводку, которую система рисует вокруг содержимого Island.
Тексту таймера в compact-view нужны .monospacedDigit() и фиксированный frame(width:), иначе Island дёргается при смене цифр.
AppIntent представляет собой Swift-тип, описывающий одно действие (заголовок, @Parameter и perform()), которое система может выполнить без вашего UI. Сам по себе он виден только в приложении Shortcuts; если добавить его в AppShortcutsProvider с голосовыми фразами, он сразу после установки доступен в Siri и Spotlight.
**AppShortcutsProvider**: до 10 AppShortcut на приложение, регистрируются автоматически при установке. Каждая фраза обязана содержать \(.applicationName), потому что по нему Siri отличает ваше приложение от других.
**parameterSummary: предложение, которое показывает редактор Shortcuts. Параметры, не попавшие в него, спрятаны за раскрывающимся блоком, и обязательный параметр выглядит незаполненным.
Модельные объекты как параметры**: сделайте их AppEntity с EntityQuery (EntityStringQuery сопоставляет произнесённое название). Вызывайте updateAppShortcutParameters(), когда список меняется; IndexedEntity (iOS 18+) ещё и добавляет сущности в Spotlight.
Выполнение: perform() по умолчанию работает в фоне. requestValueDialog позволяет Siri спросить недостающее значение вашими словами, а OpenIntent или openAppWhenRun нужны, когда действию требуется UI приложения.
Те же интенты управляют интерактивными виджетами, контролами и кнопкой Action, так что один хорошо спроектированный интент обслуживает все точки входа системы.
Когда в payload нет URL image, guard выходит, не вызвав contentHandler, поэтому система ждёт, пока истечёт лимит времени расширения (около 30 секунд), вызывает serviceExtensionTimeWillExpire() и только тогда показывает push.
Почему: если push содержит "mutable-content": 1, система держит его, пока расширение не вернёт контент. Она не отличает «менять нечего» от «ещё работаю», поэтому путь, который молча выходит, выглядит как долгая работа, и push показывается, когда лимит заканчивается.
Исправление: каждый выход из didReceive должен заканчиваться ровно одним вызовом contentHandler: на раннем выходе отдайте request.content без изменений, а на обычном пути отдайте отредактированную копию.
Почему это прячется при тестировании: serviceExtensionTimeWillExpire() спасает push: ничего не падает и ничего не теряется, уведомление просто опаздывает. Отправьте на устройство push без необязательного поля и сравните время доставки с push, где оно есть, и задержка видна сразу.
Как ловить на ревью: читайте каждый return в didReceive как вопрос «кто здесь вызывает handler?». Ещё лучше, если сервер ставит mutable-content только тем push, которым нужно расширение: текстовые тогда вообще его не запускают.
Оговорка: путь истечения и асинхронный путь могут сработать оба, поэтому в боевом коде храните Task и отменяйте его в serviceExtensionTimeWillExpire() (или защитите вызов флагом), чтобы contentHandler вызывался только один раз.
Прочитайте вложения NSItemProvider из extensionContext.inputItems, скопируйте файл в контейнер App Group и передайте загрузку фоновой URLSession. Процесс расширения завершается сразу после completeRequest и уносит с собой любую загрузку, идущую внутри процесса.
Как достать файл
В inputItems лежат NSExtensionItem; содержимое лежит в attachments каждого элемента, массиве NSItemProvider.
loadFileRepresentation(for:) отдаёт временный URL, который удаляется после выхода из замыкания, поэтому сначала скопируйте файл. loadDataRepresentation грузит всё в память, и видео на 200 МБ убивает расширение: его лимит памяти в разы меньше, чем у приложения.
NSExtensionActivationRule в Info.plist расширения определяет, для каких типов контента оно предлагается. TRUEPREDICATE годится только для разработки, App Review его отклоняет.
Как передать загрузку
Создайте сессию через URLSessionConfiguration.background(withIdentifier:) и задайте sharedContainerIdentifier равным App Group: внутри расширения без этого передача не работает.
Фоновые сессии принимают только загрузку из файла: uploadTask(with:fromFile:).
Система продолжает передачу и после того, как расширения уже нет. Когда она заканчивается, система запускает основное приложение в фоне и вызывает application(_:handleEventsForBackgroundURLSession:completionHandler:); там пересоздайте сессию с тем же идентификатором, чтобы получить результат.
Бэкенду нужен push-to-start токен, который принадлежит типу activity и существует до запуска любой activity, и update-токен для каждой работающей activity. Оба приходят как async-последовательности и могут меняться, поэтому приложение должно отправлять на сервер каждое новое значение, а не только первое.
Токены
Activity<DeliveryAttributes>.pushToStartTokenUpdates (iOS 17.2+): подпишитесь при запуске. Позволяет серверу запустить activity, когда приложение не работает.
activity.pushTokenUpdates появляется только после старта activity, будь то request(..., pushType: .token) или start-push; подпишитесь на Activity<…>.activityUpdates, чтобы поймать activity, запущенные сервером. Регистрация только первого токена и есть классическая причина, почему обновления перестают приходить посреди доставки.
Broadcast-обновления (iOS 18+): pushType: .channel(_:) подписывает activity на канал, и один push обновляет всех зрителей одного матча без учёта токенов каждого устройства.
Запрос к APNs
Заголовки apns-push-type: liveactivity и apns-topic: <bundle-id>.push-type.liveactivity, аутентификация по токену (.p8).
В aps передаются timestamp, event (start, update, end) и content-state, ключи которого должны совпадать с Codable-структурой ContentState; start-push дополнительно требует alert, attributes-type и attributes. С JSONDecoder по умолчанию Date читается как секунды с 2001 года (timeIntervalSinceReferenceDate), поэтому проверьте на устройстве или шлите обычные числа и декодируйте их сами.
Обновления с приоритетом 10 лимитируются; рядовые шлите с apns-priority: 5. Для очень частых обновлений нужен NSSupportsLiveActivitiesFrequentUpdates, а пользователь может их отключить.
Ограничения
Payload до 4 КБ. Система завершает activity через 8 часов, и её update-токен умирает вместе с ней.
Отлаживайте его как отдельный процесс, которым он и является: запустите схему widget extension или подключите Xcode к расширению по имени, логируйте через Logger вместо print и читайте краш-репорты самого расширения; пустой виджет чаще всего означает убитое расширение.
Подключение отладчика
Выберите схему widget extension и нажмите Run: Xcode установит приложение и подключится, когда система запустит расширение. Переменные окружения схемы _XCWidgetKind и _XCWidgetFamily выбирают, какой виджет показать.
Или запустите приложение, выберите Debug > Attach to Process by PID or Name с именем исполняемого файла расширения и вызовите из приложения WidgetCenter.shared.reloadAllTimelines().
#Preview(as: .systemSmall) с блоком timeline: даёт быстрый цикл для вёрстки, но он работает в процессе превью Xcode, без реального лимита памяти и без entitlements.
Логирование
print пишет в stdout, который для процесса, запущенного системой, никто не собирает. Logger(subsystem:category:) виден в Console.app и в Xcode после подключения; интерполированные строки и объекты скрываются в Console.app как <private>, если не указать privacy: .public.
Типичные причины
Убит по памяти: в краш-логе EXC_RESOURCE с типом memory. Обычно виноваты картинки в полном разрешении; уменьшайте их до размера отрисовки.
Общие данные не читаются: у таргета расширения нет capability App Group или отличается id группы, либо файл защищён, пока устройство заблокировано.
Provider не завершается: ветка кода, где не вызывается completion handler, или медленный сетевой запрос выглядят так же, как краш.
Краш-репорты сохраняются под именем исполняемого файла расширения: Xcode Organizer > Crashes или Analytics Data на устройстве.