Irbisa · Шпаргалка

Расширения и WidgetKit

Таймлайны WidgetKit, Live Activities, App Intents, share- и notification-расширения, App Groups

Middle-разработчик · 20 вопросов

  1. 01

    Что такое app extension, какие виды встречаются чаще всего и чем расширение отличается от основного приложения по процессу, жизненному циклу и памяти?

    App extension представляет собой отдельный таргет со своим bundle id и своим процессом, который система запускает, когда он нужен хосту, например домашнему экрану или share sheet.

  2. 02

    Из каких частей состоит виджет WidgetKit и что на самом деле где выполняется?

    Виджет состоит из Widget с конфигурацией, timeline provider, который выдаёт записи с датами, и SwiftUI-view, рисующей каждую запись.

    struct StepsEntry: TimelineEntry {  let date: Date  let steps: Int} struct StepsProvider: TimelineProvider {…
  3. 03

    Что делают placeholder, snapshot и timeline в timeline provider виджета и когда WidgetKit вызывает каждый из них?

    placeholder мгновенно возвращает заглушку-скелет, которую рисуют серыми плашками; snapshot возвращает одну запись для галереи виджетов и других временных показов; timeline возвращает записи с датами, которые виджет реально покажет, и указывает, когда спросить снова.

    struct SalesProvider: AppIntentTimelineProvider {  // Synchronous and instant: the redacted skeleton.  func placeholder(in context: Context) -> SalesEntry {    SalesEntry(date: .now, total: 1234, store: .sample)  }…
  4. 04

    Как сделать переход из виджета на конкретный экран приложения и чем widgetURL отличается от Link?

    .widgetURL(_:) делает весь виджет одной зоной тапа с одним URL; Link даёт отдельные адреса частям виджета, но только в семействах medium, large и extra-large; у маленьких виджетов и аксессуаров на экране блокировки есть только widgetURL.

    struct OrdersWidgetView: View {  let entry: OrdersEntry  @Environment(\.widgetFamily) private var family   var body: some View {    VStack(alignment: .leading) {…
  5. 05

    Что такое Live Activity и чем она отличается от виджета и от push-уведомления?

    Live Activity представляет собой карточку одного идущего события (доставка, поездка, матч): приложение запускает её и обновляет на месте на экране блокировки и в Dynamic Island.

  6. 06

    Эта view виджета должна отсчитывать время каждую секунду. Почему этот отсчёт никогда не тикает на домашнем экране и как это исправить?

    struct CountdownView: View {  let entry: CountdownEntry  @State private var secondsLeft = 0   var body: some View {    Text("\(secondsLeft) s left")      .onAppear {        secondsLeft = Int(entry.deadline.timeIntervalSinceNow)      }      .onReceive(Timer.publish(every: 1, on: .main, in: .common).autoconnect()) { _ in        secondsLeft = Int(entry.deadline.timeIntervalSinceNow)      }  }}

    Отсчёт не пойдёт: WidgetKit рендерит body один раз и показывает заархивированный результат, поэтому ни onAppear, ни таймер не могут изменить @State на экране, и число никогда не меняется.

  7. 07

    Как работают политики перезагрузки .atEnd, .after(_:) и .never и как приложение обновляет виджет сразу после изменения данных?

    Политика перезагрузки сообщает WidgetKit самое раннее время, когда можно попросить у provider новый таймлайн; это просьба, а не расписание.

    func timeline(for configuration: SalesIntent,              in context: Context) async -> Timeline<SalesEntry> {  let hours = await SalesStore.forecast(for: configuration.store)  let entries = hours.map { SalesEntry(date: $0.start, total: $0.total) }  // Ask again only when the numbers can actually differ.  return Timeline(entries: entries, policy: .after(SalesStore.nextOpeningBell))…
  8. 08

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

    // Compiled into both the app and the widget extension.enum StepsStore {  private static var defaults: UserDefaults { .standard }  static func save(_ steps: Int) { defaults.set(steps, forKey: "todaySteps") }  static func todaySteps() -> Int { defaults.integer(forKey: "todaySteps") }} // App: called when HealthKit delivers new samples.func didUpdateSteps(_ steps: Int) {  StepsStore.save(steps)  WidgetCenter.shared.reloadTimelines(ofKind: "StepsWidget")} func makeEntry() -> StepsEntry {   // widget extension, in getTimeline  StepsEntry(date: .now, steps: StepsStore.todaySteps())}

    UserDefaults.standard у каждого процесса свой, внутри его sandbox, поэтому приложение сохраняет в свой контейнер, а widget extension читает свой, пустой, и integer(forKey:) возвращает 0 для отсутствующего ключа.

  9. 09

    Этот provider должен показывать каждый из четырёх почасовых прогнозов, когда наступает его час. Что происходит вместо этого и как исправить?

    func timeline(for configuration: CityIntent,              in context: Context) async -> Timeline<WeatherEntry> {  let forecast = await WeatherCache.hourly(for: configuration.city)   let entries = forecast.prefix(4).map { hour in    WeatherEntry(date: .now, temperature: hour.temperature)  }  return Timeline(entries: entries, policy: .atEnd)}

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

  10. 10

    Как часто виджет может обновляться и что расходует его бюджет обновлений?

    Каждый добавленный виджет получает скользящий бюджет (обычно 40–70 перезагрузок в сутки, примерно раз в 15–60 минут), и его размер зависит от того, как часто пользователь реально смотрит на виджет.

  11. 11

    Как сделать настраиваемый пользователем виджет на App Intents и откуда берётся список вариантов для выбора?

    Используйте AppIntentConfiguration с WidgetConfigurationIntent, у которого @Parameter имеет тип AppEntity; варианты выдаёт EntityQuery этой сущности, и выполняется он внутри widget extension, пока пользователь редактирует виджет.

    struct ProjectEntity: AppEntity {  let id: String  let name: String   static let typeDisplayRepresentation: TypeDisplayRepresentation = "Project"  var displayRepresentation: DisplayRepresentation { DisplayRepresentation(title: "\(name)") }…
  12. 12

    Как работают интерактивные виджеты начиная с iOS 17 и почему onTapGesture в виджете ничего не делает?

    Интерактивны только Button(intent:) и Toggle(isOn:intent:): тап выполняет AppIntent в фоне, интент обновляет общие данные, а затем WidgetKit перезагружает виджет.

    struct CompleteTaskIntent: AppIntent {  static let title: LocalizedStringResource = "Complete task"   @Parameter(title: "Task") var taskID: String   init() {}…
  13. 13

    Что такое режимы рендеринга виджета и почему цвета виджета пропадают на экране блокировки или на тонированном домашнем экране?

    Виджет рендерится в режиме .fullColor, .accented или .vibrant, и два последних отбрасывают ваши цвета, поэтому всё, что передаёт смысл только оттенком, исчезает.

    struct BuildStatusView: View {  @Environment(\.widgetRenderingMode) private var renderingMode  let build: Build   var body: some View {    VStack(alignment: .leading, spacing: 6) {…
  14. 14

    Что в ActivityKit относится к ActivityAttributes, а что к его ContentState, и как запустить, обновить и завершить Live Activity?

    В ActivityAttributes лежит то, что не меняется за всю жизнь activity, например id заказа и ресторан; во вложенном ContentState лежит то, что меняется: этап и ETA.

    struct DeliveryAttributes: ActivityAttributes {  // Fixed at request time.  let orderID: String  let restaurantName: String   // Replaced on every update; at most 4 KB.…
  15. 15

    Какие представления есть у Live Activity в Dynamic Island и когда система использует каждое из них?

    Compact (левая и правая view) показывается, когда ваша activity единственная запущенная, minimal показывается, когда activity есть у двух и более приложений, expanded открывается по долгому нажатию; плюс view для экрана блокировки, которая используется на устройствах без Island и для баннеров при обновлении.

    struct DeliveryLiveActivity: Widget {  var body: some WidgetConfiguration {    ActivityConfiguration(for: DeliveryAttributes.self) { context in      // Lock Screen, devices without an Island, and update banners.      LockScreenView(context: context)    } dynamicIsland: { context in…
  16. 16

    Что такое App Intent и что нужно добавить, чтобы действие работало из Siri и Spotlight без настройки шортката пользователем?

    AppIntent представляет собой Swift-тип, описывающий одно действие (заголовок, @Parameter и perform()), которое система может выполнить без вашего UI.

    struct LogDrinkIntent: AppIntent {  static let title: LocalizedStringResource = "Log a drink"   @Parameter(title: "Drink", requestValueDialog: "Which drink?")  var drink: DrinkEntity…
  17. 17

    Найдите баг: текстовые push-уведомления, которые обрабатывает этот notification service extension, приходят примерно на 30 секунд позже, а push с картинкой приходит сразу.

    final class NotificationService: UNNotificationServiceExtension {  var handler: ((UNNotificationContent) -> Void)?  var best: UNMutableNotificationContent?  override func didReceive(_ request: UNNotificationRequest,                           withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {    handler = contentHandler    best = request.content.mutableCopy() as? UNMutableNotificationContent    guard let best, let link = best.userInfo["image"] as? String,          let url = URL(string: link) else { return }    Task {      if let attachment = try? await ImageAttachment.download(url) { best.attachments = [attachment] }      contentHandler(best)    }  }  override func serviceExtensionTimeWillExpire() { if let handler, let best { handler(best) } }}

    Когда в payload нет URL image, guard выходит, не вызвав contentHandler, поэтому система ждёт, пока истечёт лимит времени расширения (около 30 секунд), вызывает serviceExtensionTimeWillExpire() и только тогда показывает push.

  18. 18

    Как share-расширение получает файл, которым поделились, и как надёжно загрузить большой файл на сервер?

    Прочитайте вложения NSItemProvider из extensionContext.inputItems, скопируйте файл в контейнер App Group и передайте загрузку фоновой URLSession.

    final class ShareViewController: UIViewController {  static let groupID = "group.com.example.clips"  override func viewDidLoad() {    super.viewDidLoad()    guard let item = extensionContext?.inputItems.first as? NSExtensionItem,          let provider = item.attachments?.first(where: {…
  19. 19

    Как запускать и обновлять Live Activity с сервера через push-уведомления и какие токены нужны бэкенду?

    Бэкенду нужен push-to-start токен, который принадлежит типу activity и существует до запуска любой activity, и update-токен для каждой работающей activity.

    func observeLiveActivityTokens() {  // 1. One token per activity type, before anything runs.  Task {    for await token in Activity<DeliveryAttributes>.pushToStartTokenUpdates {      await api.register(startToken: token.hex)    }…
  20. 20

    На устройстве виджет пустой, хотя приложение работает нормально, а вывод print нигде не появляется. Как отлаживать widget extension?

    Отлаживайте его как отдельный процесс, которым он и является: запустите схему widget extension или подключите Xcode к расширению по имени, логируйте через Logger вместо print и читайте краш-репорты самого расширения; пустой виджет чаще всего означает убитое расширение.