Особенности виджетов Android и iOS

НачалоОбщая концепция виджетовОбновление и состоянияТриггер и логикаИнтерактивностьКросс-платформенные виджеты

Анна Жаркова, руководитель группы разработки Usetech, подробно разобрала разницу в работе с виджетами Android и iOS: обновление виджетов, UI и ограничения, работу с состоянием. Рассмотрим, как реализовать сложную архитектуру, поговорим об интерактивности и кросс-платформенных виджетах.

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

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

Android

Самыми первыми были виджеты на Android, основными их компонентами до Android 12 были:

  • AppWidgetProvider, который определял поведение виджета
  • RemoteView — обёртка над классическим View на XML layout
  • AppWidgetManager — менеджер для обновления виджета
  • Broadcast receivers для подписки на различные события

До 2012 года в Android UI и AppWidgetProvider описывались в XML, остальное — кодом. Следующим поколением виджетов стали Android Widget 12. Реализуются новые виджеты на специальном фреймворке Glance, который продолжает развиваться.

Glance-виджеты имеют UI на Composable, но под капотом у них всё тот же RemoteViews на XML. Для создания такого виджета нужно создать свой класс-наследник GlanceAppWidget, в качестве контента которого предоставить Composable. Для управления виджетом используется кастомный GlanceAppWidgetReceiver, предоставляющий инстанс виджета.

С помощью AndroidRemoteView можно подключать UI для старых виджетов в Glance.

Для виджетов на Android существуют строгие требования:

  • UI в стилистике приложения или системы
  • Адаптивность к тёмной теме, изменению шрифта или размера
  • Поддержка изменения настроек
  • Понятность и ясность описания на Preview, чтобы заинтересовать пользователя
  • Полезность
  • Атомарность — каждый виджет должен решать одну задачу
  • Взаимосвязь с системой
  • Доступ к функционалу основного приложения
  • Отсутствие перегрузки — система не должна быть перегружена фоновой работой

Glance-виджеты поддерживают различные Composable: Button, Column, Raw и другие. Если какой-то контрол отсутствует, его можно сконвертировать в Bitmap и вывести как Image.

Composable, которые поддерживает Glance, отличаются от обычных Jetpack Composable, которые не поддерживаются. Если вы попытаетесь использовать Matherial, Jetpack Compose, Free Canvas контрол или анимацию, то вместо виджета получите заглушечный UI об ошибке.

iOS

В iOS виджеты появились ещё в iOS 11. C версии iOS 14 виджеты функционируют на WidgetKit и SwiftUI. Одной из их особенностей является возможность расшарить код основного приложения.

Чтобы задать виджет в iOS, нужны:

  • Структура, имплементирующая протокол Widget, где в body указывается UI виджета, передаётся возможная конфигурация модели данных entry и другие настройки
  • WidgetBundle, в котором в body представлен инстанс созданного виджета структуры

Виджеты iOS поддерживают различные размеры, которые считываются как переменные среды. Поддерживаемые четыре размера прописываются в конфигурации.

Есть ряд ограничений при реализации UI:

  • Анимация и видео не поддерживаются
  • Нет интерактивных контролов (до iOS 17), не поддерживаются контролы из UIView и UIViewRepresentable
  • Не поддерживаются UI с прокруткой (View со скроллом, например List, Map)

У виджета нет изменяемого состояния, но есть снепшот для отрисовки и отображения данных. Управляет ими специальный провайдер состояний виджета. Таймлайн провайдера состоит из массива снепшотов, которые отрисовываются в заданный в провайдере момент времени.

Для решения проблемы со сложным UI также можно применить конвертацию View в изображение и вывод через Image. Либо стоит полностью переписать на поддерживаемые контролы SwiftUI.

С iOS 17 появилась интерактивность, встроенные стили и марджины. Вы можете столкнуться с внезапными встроенными отступами по краям, которые могут испортить UI вашего виджета. Проблема решается с помощью специального ContentBackgroundView.

Android

Android-виджет считывает снепшоты, которые предоставляются через preferences. Состояние виджета задаётся через CurrentState<Preference>. Для управления настройками виджета создаётся собственный GlancePreferenceDefinitionState.

Для запроса данных используется следующая архитектура: GlanceAppWidgetReceiver отправляет запрос в UseCase. UseCase запрашивает у репозитория. При получении ответа UseCase сохраняет данные в PreferenceGlanceState.

Чтобы виджет обновился, в ресивере необходимо вызвать UpdateAppWidgetState с вызовом в коллбэке ID того виджета, для которого был запрос.

Внутри блока UpdateAppWidgetState нужно сохранить данные в prefs. Далее, когда сработает триггер на обновление виджета, внутри него считываем prefs, после чего данные уже смогут отрендериться.

iOS

В iOS используются различные TimelineProvider. IntentTimelineProvider работает на Completion Handler. Если вы хотите стабильной и гарантированной работы, используйте его. AppIntentTimelineProvider работает на async/await. В нём можно сразу вызвать асинхронную логику, после чего полученные данные сразу отрисуются.

Для создания читаемой модели данных используем структуру, которая имплементирует протокол Timeline Entry, с указанием всех нужных полей, включая конфигурацию.

Для получения данных (репозиторий, data storage) вызываем бизнес-логику сервиса или менеджера из TimelineProvider. Полученные данные сохраняем в хранилище, а затем вызываем их считывание в TimelineProvider и загружаем в виджет.

Вызов происходит в методе предоставления конкретного снепшота, в нём же считываются данные. Он может быть с Completion Handler или асинхронный.

Расшариваемая бизнес-логика — это особенность мультиплатформенных приложений на SwiftUI. Мультиплатформенность значит, что код написан на SwiftUI, но может исполняться под iOS, virtual OS, iPadOS и macOS в зависимости от поддержки таргета. Эта логика может использоваться и для основного приложения.

Для обмена данными между основным приложением и виджетом можно использовать различные prefs и хранилища. Для их совместимости нужно прописать одинаковую AppGroup в настройки виджета и приложения. Далее следует использовать UserDefault, запрошенный с указанием AppGroup. Для сохранения и считывания данных используем их сериализацию и десериализацию.

Кроме того, можно использовать хранилища SwiftData либо CoreData. Для синхронизации с приложением в этих случаях нужно указывать AppGroup.

Android

В таких приложениях, как часы и календарь, нужно запрашивать обновление раз в секунду. Можно использовать BroadcastReceiver, который подключается в WidgetReceiver. Но событие TIME_TICK, на которое настраивается такой ресивер, срабатывает раз в минуту. Поэтому стоит использовать решение с таймером CountDownTimer и его перезапуском.

Для периодического обновления виджета есть специальное API WorkManager, которое позволяет зашедулировать любую задачу.

Для запроса UseCase вызывается задача из WorkManager. В его работе есть ограничения:

  • Минимальный интервал работы 15 минут
  • Время старта не гарантировано
  • Есть всего 10 минут на запрос
  • Не рекомендуется перезагружать фон запросами

Можно подключить WorkManager напрямую или создать менеджер для его настройки на работу и запуск задачи. Для создания задачи наследуем CoroutineWorker. У него есть метод doWork, в котором можно асинхронно запросить UseCase. После получения коллбэка нужно сохранить его в prefs или Room в зависимости от архитектуры. Виджет считает новое состояние и обновится.

iOS

Для решения аналогичной задачи с часами и календарём нужно также запускать обновление раз в секунду. В случае с IntentTimelineProvider нужно взять простой таймер и в нём шедулировать выполнение задачи. После обновления состояния и вызова Completion Handler следует обновить конкретный виджет или всё одновременно через инструкцию WidgetCenter.shared.reloadTimelines(of:kind)/WidgetCenter.shared.reloadAllTimelines().

В AppIntentTimelineProvider c поддержкой async/await можно делать запуск обновления раз в минуту. Вызвать шедулирование таймером через async/await не получится, потому что TimelineEntry асинхронно отдастся раньше, чем вызовется отложенное действие.

Android

По умолчанию виджеты интерактивные. В качестве примера можно привести плеер или менеджер задач. При нажатии на контрол или весь виджет запустится одно из действий: actionRunCallback, actionStartActivity, actionStartService, actionSendBroadcast. Эти действия имеют разное назначение и поддерживают разные режимы работы.

iOS

Для виджетов на SwiftUI c iOS 14 из интерактивных элементов были только диплинки.

Все диплинки обрабатываются в едином App, при их большом количестве он будет перегружен. Также при наложении кликов не будет гарантировано выполнение конкретного линка.

С iOS 17 появилась полноценная интерактивность. Можно выполнять действия, не переключаясь в foreground приложения, и управлять бизнес-логикой прямо из виджета. Для синхронизации между приложением и виджетом используется AppGroup. Для поддержки действий используется специальный AppIntent. Для этого нужно создать класс-наследник AppIntent, в котором необходимо указать передаваемые параметры и вызвать нужную логику.

Если нужно, например, чтобы менеджер задач обновился в зависимости от индекса, используйте специальный PropertyWrapper @Parameter с указанием названия переменной. Нужно создать специальный инициализатор для передачи нужного параметра.

Далее в методе Perform необходимо прописать логику. Если она синхронизирована между приложением и виджетом, произойдёт изменение менеджера задач. При открытии приложение подтянет новое состояние, если оно на это настроено.

С iOS 17 появилась возможность сделать полноценный плеер, как у Apple. Для того чтобы связать плеер с виджетом, используются специальные AudioStartingIntent и AudioPlaybackIntent. Эти интенты настроены на поддержку всех разрешений и состояний для воспроизведения музыки. При этом есть баги — например, ReloadAllTimelines не всегда срабатывает, если его вызвать из AppIntent.

Рассмотрим архитектуру классического приложения Kotlin Multiplatform. UI не общий и не shared, то есть мы имеем отдельные iOS View и Android View. Они взаимодействуют с кросс-платформенной логикой через делегат.

Если используем Compose Multiplatform, то у нас есть специальный ComposeView, который подключается в нативное приложение iOS и Android. Но если для Android Compose есть Glance, то для iOS нет портирования SwiftUI на Glance Compose, поэтому виджеты только нативные, подключаемые к основному приложению.

Если нужны полноценные кросс-платформенные виджеты, стоит использовать Flutter. Хотя есть необходимость подготовки и дополнительной настройки. Это, по сути, и есть кросс-платформенный виджет.

Поделитесь увиденным

Скопировать ссылку
ТелеграмВКонтакте