Как создать виджет для iOS 14 (и не удалить его у пользователей при обновлении)

в 8:10, , рубрики: iOS, баги, Блог компании Яндекс, виджеты, мобильная разработка, приложение яндекс, разработка мобильных приложений, разработка под iOS, яндекс

Как создать виджет для iOS 14 (и не удалить его у пользователей при обновлении) - 1

Одно из нововведений в iOS 14 — виджеты. Мы стали готовиться к этому событию задолго до официального релиза, чтобы они появились у пользователей приложения Яндекс уже на старте. В этом посте я расскажу об опыте разработки виджетов в условиях нестабильного бета-окружения, неполной документации и отсутствия готовых решений для возникающих проблем.

Помимо обзорной информации, под катом — истории о том, почему нельзя просто взять и добавить настройки в виджет (спойлер: можно случайно удалить виджет установившим его пользователям), и как даже новейшие Swift-only API иногда страдают от наследия Objective-C. Материал будет полезен как тем, кто привык разбираться во всём новом, находя готовые гайды в интернете, так и тем, кто предпочитает официальную документацию.

Первые шаги

Виджеты — новый тип элемента главного экрана в iOS SpringBoard. Он стал четвёртым по счёту, прервав консервативный период, который длился 10 мажорных релизов системы:

  1. изначально на рабочем пространстве находились только приложения;
  2. в версии 2.1 появилась возможность помещать на него закладки Safari;
  3. В iOS 4 добавилась группировка иконок (новым элементом экрана стала группа).

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

Виджеты бывают трёх размеров: маленький и большой — квадратные, занимают место 4 и 16 обычных иконок; средний — прямоугольный горизонтальный, 2х4 иконки. Мы сфокусировались на разработке маленького и среднего.

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

Как создать виджет для iOS 14 (и не удалить его у пользователей при обновлении) - 2

Что делаем для пользователя — разобрались, осталось выяснить, как это делать.

Изучаем особенности API

Apple добавили новый фреймворк для реализации виджетов WidgetKit, который работает вместе с дебютировавшим в iOS 13 SwiftUI:

public struct StaticConfiguration<Content> : WidgetConfiguration where Content : SwiftUI.View { ... }

public struct IntentConfiguration<Intent, Content> : WidgetConfiguration
where Intent : INIntent, Content : SwiftUI.View { ... }

При этом ограничение на уровне системы типов — не единственное:

  • запрещено использование UIKit (в обычном приложении интеграция UIKit в иерархию SwiftUI возможна с помощью UIViewRepresentable/UIViewControllerRepresentable);
  • недоступно анимирование контента;
  • отсутствует публичный API для добавления полупрозрачности тела виджета (чтобы через него просвечивал фон SpringBoard, как на виджете зарядов батареи);
  • влиять на взаимодействие с пользователем можно только задавая разные области нажатия, как следствие — запрещён любой скролл и не работает модификатор @State (присвоение значения не дает никакого эффекта).

Если добавить запрещённые элементы на UI, вместо них отобразится значок на жёлтом фоне.

На среднем и большом размере виджета с помощью SwiftUI.Link можно разметить области нажатия. При активации размеченной области откроется основное приложение, в котором вызовется метод application(_:open:options:) вашей реализации протокола UIApplicationDelegate. Для маленького размера виджета можно задать только один URL. Делается это с помощью нового модификатора widgetURL(_:) (его можно использовать и для других размеров, но поддерживается только одно использование на один виджет).

Стоит отметить, что взаимодействие с фоновыми загрузками тоже отличается от обычного приложения. UIApplicationDelegate отсутствует, поэтому невозможно реализовать его метод application:handleEventsForBackgroundURLSession:completionHandler:, который до этого был единственной точкой входа обработки произошедших в фоне событий. Вместо него добавлены два метода в обе доступные реализации протокола WidgetConfiguration. Обычные сетевые запросы работают так же, как и в приложении.

Загружаем контент

На виджетах мы чаще всего хотим отображать динамический контент, данные для которого загружаются асинхронно. Во время загрузки необходимо показывать легковесный UI (placeholder, заглушку), который создаётся за максимально короткое время и не вызывает задержек при первом добавлении виджета или изменении настроек. Подобный UI для системного виджета погоды выглядит так:

Как создать виджет для iOS 14 (и не удалить его у пользователей при обновлении) - 3

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

func placeholder(in context: Self.Context) -> Self.Entry

Здесь Self.Entry — заданный разработчиком тип. В нём можно явно сохранять признак того, что требуется именно заглушка, и использовать его при создании экземпляра View. Однако Apple не были бы Apple, если бы не добавили в этот механизм немного магии: к сформированному View неявно применяется модификатор .redacted(reason: .placeholder), который автоматически «упрощает» иерархию: превращает тексты в полупрозрачные прямоугольники со скругленными краями и убирает битмапы из изображений.

У нашего виджета с «картиной дня» в основном статический контент, и мы хотели сразу отображать его, показывая заглушки только для асинхронно загружающихся элементов, поэтому при создании View нужно было добавить модификатор .unredacted(). Возможно, создатели API делали упор на упрощение расчёта геометрии, чтобы не создавать угрозу плавности работы интерфейса при добавлении виджета, но на практике после того, как мы убрали заглушку, нам не удалось заметить задержек даже на стареньком iPhone 7.

Обновляем содержимое

Содержимое виджета обновляется по pull-модели. Система запрашивает контент, когда считает нужным. Примеры детерминированных моментов запроса контента: открытие галереи виджетов, добавление виджета и изменение настроек. На интервалы обновления можно повлиять с помощью Timeline, задающего массив состояний виджета:

public struct Timeline<EntryType> where EntryType : TimelineEntry {

    public let entries: [EntryType]

    public let policy: TimelineReloadPolicy
}

Каждое состояние привязано к определённой дате и может задавать параметр relevance, влияющий на поведение авторотации виджетов в стопке:

public protocol TimelineEntry {

    /// The date for WidgetKit to render a widget.
    var date: Date { get }

    /// The relevance of a widget’s content to the user.
    var relevance: TimelineEntryRelevance? { get }
}

Поле policy позволяет выбрать вариант перезапроса Timeline:

  • запросить новый Timeline сразу после отображения последнего состояния,
  • не запрашивать вообще,
  • запросить при наступлении определённой даты.

public struct TimelineReloadPolicy : Equatable {

    /// A policy that specifies that WidgetKit requests a new timeline after
    /// the last date in a timeline passes.
    public static let atEnd: TimelineReloadPolicy

    /// A policy that specifies that the app prompts WidgetKit when a new
    /// timeline is available.
    public static let never: TimelineReloadPolicy

    /// A policy that specifies a future date for WidgetKit to request a new
    /// timeline.
    public static func after(_ date: Date) -> TimelineReloadPolicy
}

Эта модель исключает быструю разрядку батареи из-за ошибки в пользовательском коде, ведь он запускается только на ограниченное время, нужное для асинхронного формирования контента. Есть, однако, и существенный минус. В случае, если при запросе обновления отсутствовала возможность получить данные (например, не было интернет-соединения), не получится оперативно сделать новую попытку в момент её восстановления (в случае с отсутствием интернета — с помощью реакции на нотификацию от SCNetworkReachability). На этот раз Apple не оставили даже традиционного приватного API для собственных приложений: системный погодный виджет надолго остаётся в состоянии заглушки, если добавить его, находясь в авиарежиме. Сгладить проблему можно, уменьшив интервал обновления данных при ошибке.

Стоит отметить, что существует механизм обновления содержимого виджетов из родительского приложения:

public class WidgetCenter {
    <...>

    /// Reloads the timelines for all widgets of a particular kind.
    /// - Parameter kind: A string that identifies the widget and matches the
    ///   value you used when you created the widget's configuration.
    public func reloadTimelines(ofKind kind: String)

    /// Reloads the timelines for all configured widgets belonging to the
    /// containing app.
    public func reloadAllTimelines()
}

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

Взаимодействуем с основным приложением

Как и в любом App Extension, коммуникация между виджетом и основным приложением возможна с помощью Keychain Sharing. Его дополняет WidgetCenter, который позволяет основному приложению получать информацию об установленных виджетах и обновлять их содержимое (выполнять новые запросы Timeline) вне очереди. Вот несколько идей, как их можно использовать:

  • аналитика (сбор статистики об установках виджетов пользователями);
  • интерактивный экран со списком установленных виджетов в приложении и конфигурирование с Keychain Sharing;
  • случаи, когда виджет отображает часть состояния приложения, и нужно реагировать на его изменение, вызывая обновление.

Добавляем настройки

История о добавлении настроек в виджет заслуживает отдельного внимания. На видео с WWDC всё выглядит легко, просто и красиво, но, как говорится, теоретически практика должна совпадать с теорией, а в реальности получается наоборот.

Как это выглядит

После добавления возможности конфигурирования виджета в меню действий, по долгому нажатию можно увидеть соответствующий пункт:

Как создать виджет для iOS 14 (и не удалить его у пользователей при обновлении) - 4

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

Как создать виджет для iOS 14 (и не удалить его у пользователей при обновлении) - 5

Как это работает

Настройки реализованы при помощи интентов. Изначально интенты были добавлены как часть SiriKit. Они классифицируют действия пользователя, которые система может распознать, в зависимости от окружающего контекста: например, подобрать ответ на обращение к Siri или предложить построить маршрут на работу при посадке в машину утром в будний день. Для обработки действия, описываемого интентом, создается специальный Intents Extension. Этот механизм натянули на глобус переиспользовали для реализации виджетов. Чтобы описать настройки виджета, нужно создать интент с типом View и отметить галочку Intent is eligible for widgets. В обучающем видео с WWDC 2020 выступающий скромно говорит, что «пока что» отключит галочки, отвечающие за использование интента в Siri и приложении Shortcuts, хотя не вполне очевидно, зачем описание настроек виджета может хотя бы теоретически там понадобиться. Также непонятно, почему тип интента не фиксируется на View, когда он отмечен для виджета, ведь остальные типы в этом случае неработоспособны.

Технически в качестве описания интентов и их вспомогательных типов выступает XML-файл с расширением intentdefinition, который особым образом интерпретируется Xcode-ом. Xcode предоставляет красивый UI для редактирования содержимого. Из файла при помощи утилиты intentbuilderc генерируется код на Swift с включённым экспортом в Objective-C. Этот код включается в сборку таргетов, использующих описанные в нём интенты. Экспорт в Objective-C здесь неспроста: iOS использует гибкость его рантайма для межпроцессного взаимодействия и хранения настроек. Чтобы это было возможно, в каждый бандл, использующий сгенерированные интенты, кладётся оригинал файла intentdefinition. Весь этот автомагичный механизм в духе Apple, как это часто бывает, не обошёлся без подводных камней.

Что с миграцией?

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

Допустим, вы сделали несколько разных виджетов, у которых изначально были одинаковые настройки, поэтому вы использовали один интент на всех. А если виджет один, но поддерживает несколько размерностей, API просто-таки подталкивает к тому, чтобы применить общий тип интента:

struct MultisizeWidget: Widget {
    var body: some WidgetConfiguration {
        IntentConfiguration(
            kind: "com.example.multisize-widget",
            intent: MultisizeWidgetIntent.self,
            provider: MultisizeWidgetTimelineProvider(),
            content: MultisizeWidgetEntryView.init
        )
        .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
        .configurationDisplayName("multisize.widget.title")
        .description("multisize.widget.description")
    }
}

Позже вам могут понадобиться уникальные настройки в одном из виджетов. Например, переключение между прогнозом погоды по дням и часам. Оно имеет смысл только для среднего и, возможно, большого размеров, ведь на маленький размер (2х2 иконки) ленту прогноза не уместить. Чтобы добавить настройку, нужно использовать отдельный тип интента для типов/размеров виджетов с уникальными пунктами в настройках. Здесь-то и кроется подвох.

На момент написания статьи (актуальный релиз iOS 14.3) не было механизма, который позволял бы изменить тип интента для установленного виджета. Тут бы пригодилась возможность скрыть виджет из галереи, чтобы у пользователей старой версии сохранялась её работоспособность, а новые виджеты добавлялись с раздельными классами настроек, но такой роскоши Apple нам не предоставили. Пришлось выбирать из двух зол:

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

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

Однако есть чуть более сложный, но действенный путь. При запуске основного приложения мы можем узнать, какие виджеты установлены у пользователя, и с помощью keychain sharing передать эту информацию в сам виджет. Так можно безопасно исключить из списка «старые» виджеты, если они у пользователя не стоят. Это не решает проблему полностью, но оставляет галерею «чистой» для новых пользователей и позволяет исключить новые установки нежелательных версий, чтобы со временем полностью от них избавиться.

Good old stringly typed way

Упоминая гибкость рантайма Objective-C, я подразумевал всеми любимый stringly typed подход: по информации из intentdefinition система генерирует описанные в нём классы непосредственно в рантайме, причём (предположительно) делает это также и в системном процессе, отвечающем за управление основным экраном iOS (Springboard). Если у интента есть подгружаемые с помощью Intents Extension данные, тот же подход используется для формирования имён методов, вызываемых системой. Для каждого описанного типа интента генерируется протокол *IntentHandling, Objective-C-селекторы которого формируются с учётом названий пользовательских типов:

@objc(FooIntentHandling)
public protocol FooIntentHandling: NSObjectProtocol {
    @objc(provideValueOptionsCollectionForFoo:searchTerm:withCompletion:)
    func provideValueOptionsCollection(
        for intent: FooIntent,
        searchTerm: String?,
        with completion: @escaping (INObjectCollection<Foo>?, Error?) -> Swift.Void
    )

    @objc(confirmFoo:completion:)
    optional func confirm(intent: FooIntent, completion: @escaping (FooIntentResponse) -> Swift.Void)

    @objc(handleFoo:completion:)
    optional func handle(intent: FooIntent, completion: @escaping (FooIntentResponse) -> Swift.Void)

    @objc(defaultValueForFoo:)
    optional func defaultValue(for intent: FooIntent) -> Foo?

    @objc(provideValueOptionsForFoo:withCompletion:)
    optional func provideValueOptions(
        for intent: FooIntent,
        with completion: @escaping ([Foo]?, Error?) -> Swift.Void
    )
}

В чём же здесь проблема, спросите вы? Ведь stringly typed подход издавна используется в экосистеме Apple, существуют даже API, которые невозможно использовать без KVO — одной из вершин мысли в сфере гибкости рантайма. О том, к чему всё это привело и почему мы решали возникшую проблему целый рабочий день, я и расскажу.

Мы решили добавить в настройки выбор города, чтобы пользователь мог получить актуальные данные без геолокации или создать несколько экземпляров виджета для разных городов. Казалось бы, задача тривиальная: создаём интент, называем его CitySearch, не подозревая, что потом не сможем переименовать его при добавлении в настройку новых пунктов. Создаём новый тип данных City, чтобы реализовать поиск по префиксу в Intents Extension. Проверяем: выбираем в новом пункте настроек город, подтверждаем выбор касанием по свободной области и видим размытый фон вместо экрана SpringBoard.

Как создать виджет для iOS 14 (и не удалить его у пользователей при обновлении) - 6

Кнопка Home возвращает нормальное состояние домашнего экрана, но выбранный город сбрасывается. Это же ранняя бета iOS, верно? Может, нужно заново добавить виджет, чтобы всё заработало, а к релизу баг починят? Сказано — сделано. Удаляем виджет, открываем список приложений, выбираем наше и… видим чёрный экран с индикатором загрузки по центру — признак упавшего процесса SpringBoard, отвечающего за отображение главного пользовательского экрана iOS. MacOS показывает, что, собственно, случилось (разработка в основном ведётся на симуляторе):

SpringBoard quit unexpectedly

*** Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[<City 0x7f9a1dc5b6d0> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key identifier.'

Возможно, это проблема симулятора. Проверяем на устройстве — картина та же. Не забываем, что это первый опыт добавления настройки в виджет, поэтому мы лихорадочно ищем, в какой момент свернули не туда: пересматриваем видео с WWDC, скрупулёзно изучаем код. Ведь не может же быть такого, что тривиальный happy case добавления настройки в виджет не работает! Спустя часы безуспешных расследований скачиваем пример от Apple — всё в порядке, настройки открываются, выбор сохраняется и применяется. Полностью воссоздаём ситуацию с динамической подгрузкой вариантов через Intents Extension, проблема не воспроизводится. Отличия два — названия интента и типа данных его единственного поля. А что, если… да ну, бред какой-то. Меняем название типа интента на наше — полёт нормальный. Переименовываем тип данных в City, бинго! Проблему воспроизвели, решение понятно: добавляем к City префикс и/или суффикс, и радуемся работающей настройке. А теперь несколько строк о том, что произошло.

Интерфейс настроек виджета запускается в отдельном процессе, поэтому исключение внутри него не приводит к падению всего интерфейса. А вот открытие галереи (или, по крайней мере, подготовка к её открытию) происходит в SpringBoard, что оказалось фатально. Насколько можно судить по косвенным признакам, в исполняемом файле SpringBoard уже есть Objective-C класс City, из-за которого произошёл конфликт имён в рантайме (скорее всего подсистема, конструирующая классы из intentdefinition в рантайме, увидела, что City существует и создавать его заново не нужно). К сожалению, в документации Apple ничего не сказано о необходимости добавлять префиксы к именам интентов или описанных для них типов данных. Пока для нас остались открытыми несколько вопросов:

  • Возможен ли конфликт типов данных из разных приложений?
  • Каким образом можно было быстро диагностировать проблему?
  • Насколько всё это влияет на безопасность?
  • Какого чёрта, Apple?

В последней на момент написания статьи версии iOS (14.3) проблема не исправлена.

Локализуем строки

Выдохнув после неожиданной заминки с поиском городов, переходим к локализации настроек. Делается это классическим способом через .strings-файлы для каждого из поддерживаемых языков. Не самым классическим при этом является формат файла. Его содержимое выглядит примерно так:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>zkSKoF</key>
  <string>Localized Foo</string>
  <key>nTpy85</key>
  <string>Localized Bar</string>
  <key>pMOT5M</key>
  <string>Localized Baz</string>
</dict>
</plist>

zkSKoF, nTpy85 и pMOT5M — идентификаторы строковых значений, автоматически сгенерированные при редактировании интента через Xcode. И здесь не обошлось без трудностей. Например, наши внутренние инструменты не умеют выгружать данные в таком формате, поэтому обновлять переводы настроек приходится вручную. Да и человекочитаемость формата оставляет желать лучшего, из-за чего страдают ревью пулл-реквестов.

Другие подводные камни

Кэширование UI

Система склонна довольно агрессивно кэшировать UI виджета. Это относится и к заглушкам, и к обычному состоянию: если изменить настройки, а потом вернуть их в прежний стейт, интерфейс восстановится из кэша без обращения к пользовательскому процессу. Такая особенность даёт неожиданный бонус в виде устойчивости к критическим ошибкам, приводящим к падению. Единственным последствием от краша процесса будет пропуск обновления контента, в то время как в старом Today-виджете появлялась надпись Unable to load.

Но кэширование, к сожалению, нельзя отключить. Настройки для разработчиков позволяют лишь убрать ограничение на количество показов в автоматически ротирующемся стеке виджетов. Из-за этого возникают неудобства при отладке. К счастью, кэш обычного состояния сбрасывается при переустановке приложения, даже если оно не изменилось (полезный пункт меню Xcode — Run Without Building). Это не относится к заглушке: будьте готовы перезагрузить симулятор или устройство, чтобы система показала изменения в дизайне.

Баги

Как это обычно бывает с новой функциональностью, механизм виджетов не обошёлся без багов. Особую остроту ситуации добавила разработка в условиях бета-релизов iOS. Мы не знали точно, какие из проблем Apple успеет исправить до релиза, а какие — нет.

Информация об установленных виджетах

Нам было важно знать, не пропали ли усилия с выпуском виджета к релизу iOS 14 даром, поэтому мы хотели получить данные о количестве пользователей новой функциональности. К нашему ужасу, вплоть до последнего выпуска беты сохранялся баг API, отдававшего информацию об установленных экземплярах: после удаления виджеты отображались как установленные до перезагрузки устройства. Главный сценарий был исправлен в GM-сборке, но в iOS 14.3 сохранилась проблема с менее популярной последовательностью действий. Если удалить основное приложение, не удаляя виджеты, а потом снова установить его, виджеты на SpringBoard не вернутся, но в WidgetCenter будут отображаться как установленные до перезагрузки устройства.

Анимации переходов

Я уже упоминал, что роль пользовательского кода в отображении виджетов максимально пассивна, и за многие вещи отвечает система. Одно из таких мест — отображение настроек и переход между ними и основным состоянием. К сожалению, в iOS 14.3 после изменения любой настройки до сих пор около секунды вместо плейсхолдера отображается фон. Он будет чёрным, если на телефоне установлена тёмная тема, или белым, если светлая:

Как создать виджет для iOS 14 (и не удалить его у пользователей при обновлении) - 7

Геолокация

В нашем случае от местоположения пользователя зависела информация, отображаемая в виджете. Оказалось, и тут всё непросто — виджет отправляет отдельный запрос на разрешение от пользователя, а в Xcode 12 beta 5 в CLLocationManager появилось новое поле:

open var isAuthorizedForWidgetUpdates: Bool { get }

По его значению можно понять, дал ли пользователь согласие на определение местоположения. Проблема заключалась в том, что запрос разрешения, которое влияет на значение этого флага, выполняется системой без участия пользовательского кода, и в первой версии этот механизм работал не так, как планировали разработчики API. Диалоговое окно и пункт меню в системных настройках, позволяющий изменить решение о выдаче доступа, не показывались при первом добавлении виджета. Была вторая половина августа, релиз намечался на первую половину сентября, поэтому ситуация получилась не из приятных: невозможно было полноценно разрабатывать и тестировать основной сценарий использования продукта. Исправленные Xcode 12/iOS 14 beta 6 появились только 25 августа.

Заключение

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

Автор: Alexander Skvortsov

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js