Долгое время виджеты были отличительной чертой Android, пока на айфонах царили стройные ряды одинаковых иконок. Но в 2020-м они масштабно пришли и на iOS. Обычно мобильные разработчики знают одну из этих платформ, но у Анны Жарковой (@anioutka) есть опыт работы с обеими — и она выступила у нас с докладом о виджетах на обеих.

Сейчас мы готовим конференцию Mobius 2023 Spring, где также освещаются обе платформы, а Анна выступит с новым докладом. И в ожидании этого решили сделать для Хабра текстовую версию доклада о виджетах (видеозапись также прилагаем). Далее — текст от лица спикера.

Содержание

Вступление

Что будет в докладе? Сначала мы рассмотрим, что такое виджеты в целом. Казалось бы, тема известная, но далеко не все с ними успели поработать. Многих смущает сама терминология, потому что в Flutter тоже есть понятие «виджеты», и там это совершенно другое. 

Поговорим про то, какими были классические виджеты до Android 12, с которых всё и начиналось. Затем про Glance — специальный Compose-фреймворк для создания виджетов, его возможности и ограничения. Так как мы хотим рассмотрим, как сделать что-то полноценное и интересное, то затронем темы архитектуры, управления состояниями и обновление данных. 

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

Виджеты: концепция

Итак, что же такое виджеты? Не будем путать это с флаттеровскими контролами. Это специальные компоненты приложения, которые выводится на домашнем экране. Многие видели встроенные виджеты — календарь, погода, новости, часы и так далее.

Что же они нам дают: 

  • Быстрый доступ к определённой функциональности: например, посмотреть время, узнать текущую погоду.

  • Быстрый доступ к основному приложению: по тапу на виджет вы можете быстро перейти в него.

  • Ну и, разумеется, это привлекает внимание к основному приложению. Поэтому важно их сделать красивыми и интересными. 

Если говорить про историю появления виджетов, то они были представлены на платформе Android ещё в 2009-м, а затем пошло развитие данной технологии:

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

Затем в 2018-м про эту фичу снова вспомнили, решили реанимировать и стали прорабатывать возможность конфигурации функциональности виджетов и их вида.

В 2020 году появляются современные iOS-виджеты. На самом деле, первые виджеты iOS появились  ещё в 2017 году, но в 2020-м пережили новый рывок, о чём мы ещё поговорим. 

И, наконец, в 2021-м с выходом Android 12 произошло глобальное изменение концепции виджетов, снова вернувшее популярность этой фиче и открывшее нам довольно много интересных возможностей.

Начнём с Android

Давайте посмотрим, как же всё развивалось на Android, начиная с ранних версий.

Изначально виджет состоял из следующего:

  • RemoteView — обертка над классическим View, которая использовала XML layout, потому что тогда не было Compose. 

  • XML для описания конфигураций самого виджета.

  • Специальный AppWidgetProvider, который определял поведение виджета.

  • Менеджер для обновления виджета, чтобы он брал новую информацию в зависимости от событий. 

  • События декларировались в манифесте и для их оповещения использовался механизм broadcast receiver. 

Весь UI реализовывался в XML. На скриншоте представлен пример виджета, который я писала еще в 2015 году. Это было приложение календаря с поддержкой интерактивных виджетов с изменяемой темой. 

Всё это сделано на XML. Нет никаких дополнительных анимаций.

Также, чтобы виджет корректно работал, необходимо было в XML прописать провайдер виджета с указанием нужной для настройки информации.

Всё это сделано на XML. Нет никаких дополнительных анимаций.

Также, чтобы виджет корректно работал, необходимо было в XML прописать провайдер виджета с указанием нужной для настройки информации.

<?xml version="1.0" encoding="utf-8"?>

<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"

android: initialLayout=t/activity_calendar"

android: minHeight="220dip"

android: minWidth="220dip"

android: previewImage=e/preview"

android: updatePeriodMillis="1800000"

>

</appwidget-provider>

Это и сведения о начальном layout, и ссылка на previewImage в ресурсах (изначально @drawable, а потом XML). Также указывались размеры виджета. Была целая шкала для измерения размера виджета. По ней экран делился на доли в 72dp, и нужно было рассчитывать ширину и высоту относительно кратности этой величине. Также можно было указать период обновления виджета, либо поставить 0, если хотелось использовать свой собственный механизм для оповещения об изменениях.

Также, чтобы виджет реагировал на изменения, нужно было создать выделенный receiver и в <intent-filter> указать action для обновления конфигурации виджета.

Архитектура  сложного решения с виджетами включала следующие компоненты:

Помимо AppWidget и AppWidget Receiver, в неё могли входить специальные сервисы, если вам нужна была дополнительная логика (например, отложенные или долгие задачи). При необходимости сервис мог «стучаться» в репозиторий. Для передачи информации из сервиса ресиверу использовался броадкаст. Его отправка могла осуществляться из AlarmManager. Затем при получении информации в менеджере виджета нужно было вызвать метод updateAppWidget для обновления и отрисовки.

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

Рассмотрим для примера код AppWidgetProvider календаря, внутри которого логика создания и обновления виджета:

public class SmallCalendarWidget extends AppwWidgetProvider {
  private PendingIntent timePendIntent;
  private AlarmManager timeManager;

  @0verride
  public void onUpdate(Context context, AppWidgetManager appWidgetManager,int[] appWidgetIds) {
    Intent serviceIntent = new Intent(context, TimeUpdateService.class);
    timePendIntent PendingIntent.getService(context, 0, serviceIntent, PendingIntent.FLAG_UPDATE_CURRENT);
    Calendar calendar = Calendar.getInstance();
    timeManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
    timeManager.setRepeating(AlarmManager.RTC, calendar.getTimeInMillis(), 1000, timePendIntent);
    for (int widgetId : appWidgetIds) {
      updateView(context, appWwidgetManager, widgetId);
    }


У него был специальный метод Update, который вызывался при обновлении виджета. Именно здесь и производилось обновление View. Также можно было поставить pending intent для запуска какого-то сервиса (в том числе и по AlarmManager) или, например, открыть какой-то экран по тапу на этот View.

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

Чтобы всё работало правильно и красиво, нужно было правильно сверстать и сконфигурировать.

Тот визуал, который не поддерживался из коробки, можно было сделать с помощью последовательности картинок из View. Например, если вы хотели анимированные часы, то нужно было сделать имитацию анимации: раз в секунду создавать view из кода или XML и устанавливать в специальный RemoteView. Скажем, популярные в 2015 году экранные часы делались именно так, потому что никакой анимации быть не могло.

Вот пример кода, где мы сначала рендерим изображение из заготовки View с указанием параметров и устанавливаем в RemoteView. А затем этим RemoteView мы обновляем через manager наш виджет.

val remoteView = RemoteViews(context.getPackageName(), R.layout.widget_layout)
val bitmap = Utility.createDrawableFromView(context, createCurrentClockView(context))
remoteView.setImageViewBitmap(bitmap)
manager.updateAppwWidget(widgetId, remoteView)

Теперь у нас Compose

С появлением Jetpack Compose несколько изменилась архитектура решения, которую мы использовали. Например, представили фреймворк Glance, который позволяет легко создавать виджеты с помощью Сompose-синтаксиса. Он оперирует специальными WidgetProvider и WidgetReceiver, и работа идет как с помощью RemoteView, так и через рендеринг в XML. Точнее, у вас есть возможность зарендерить в XML как Remote и потом интеропнуть специальным AndroidRemoteView. Под капотом Сomposable также транслируется в тот же RemoteView, который мы использовали раньше. То есть, по сути, это обертка над прежним решением (все DSL так или иначе являются такими обёртками). 

Но есть некоторые отличия по сравнению с классическим Compose. Костяк решения представляют специальный GlanceAppWidget и GlanceAppWidgetReceiver. Терминология, казалось бы, похожа на классический виджет, и мы видим все признаки Compose, но на деле это именно Glance с полным своим инструментарием. В AppWidgetReceiver мы напрямую создаём виджет. Рендеринг Composable в View происходит в специальном методе content нашего виджета.

class TimeWidget : GlanceAppWidget() {
  @Composable
  override fun Content() {
    TimeView(...)
  }
}

class TimeWidgetReceiver : GlanceAppWidgetReceiver() {
  override val glanceAppWidget = TimeWidget()
}

Эти Composable не те же самые, что в Jetpack Compose. Здесь есть и Box, Row, Column, Text, Button, LazyColumn, Image, Space — все элементы для создания статического изображения. Также специальный Modifier. Но это контролы и Modifier Glance, а не Material. Потому что здесь не поддерживается free canvas. В Glance мы оперируем статичными View, которые не слушают сами изменения состояния — по сути, они stateless. Для их обновления есть специальный компонент, которые отвечает как за хранение состояний, так и их обработку, и либо сам, либо по триггеру вызывает перерисовку виджета с новыми вводными. То есть сам виджет автоматически не обновляется.  

Итак, Free canvas недоступен, поэтому использовать тот же composable, что в приложении на Compose, вы не можете. В таком случае вы получаете такую некрасивую ошибку:

Поэтому не рекомендуется использовать Material theme: она не будет работать так, как нужно. И не смешивайте Jetpack Composable и Glance, чтобы избежать конфликта имён. Где-то у вас зарендерится Glance, где-то неподдерживаемый контрол, но в итоге весь виджет не отрисуется.

Что ещё интересного: Glance поддерживает довольно простую схему добавления действия по нажатию на элемент.

class SomeWidget : GlanceAppWidget() {
  @Composable
  override fun Content() {
    Column (
      modifier = GlanceModifier.fillMaxSize().Clickable(actionRunCallback<SomeAction>()),
  )
  
class SomeAction : ActionCallback {
  override suspend fun onAction(
    context: Context,
    glanceId: Glanceld,
    parameters: ActionParameters
  ) {
  /**Магия*/
  }
}


Никаких pending intent теперь нет. Теперь мы можем добавить в любой Composable через Modifier специальный clickable для поддержки действия, определяемого actionRunCallback, в который передается ссылка на id нажимаемого элемента виджета и параметры действия для конфигурации.


Подобный механизм есть для логики перехода на какую-то activity или запуска сервиса. Для этого мы можем использовать специальные команды, которые нам доступны в Glance — actionStartActivity, actionStartService.

Как теперь обновлять и хранить состояние

Теперь давайте посмотрим на самое интересное. У нас виджеты stateless, но интерактивные. Мы можем обновлять их, заполняя данными, рендерить их. Как же мы это будем правильно делать, и как это состояние, по сути, мы можем хранить?

Если мы говорим про обычный Compose, там есть такая классная штука, как remember, которая позволяет отследить mutable state какой-то переменной. 

@Composable
fun ClockText() {
  val currentTimeMillis = remember {
    mutableState0f (System.currentTimeMillis())      
  }
  
  LaunchedEffect(key1 = currentTimeMillis) {
    while (true) {
      delay(250)
      currentTimeMillis.value = System.currentTimeMillis()
    }
  }
  
  Box() {
    Text(
      text = DateUtils.formatDateTime(LocalContext.current, currentTimeMillis.value, DateUtils.FORMAT_SHOW_TIME),
      /**…*/
    )
  }
}

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

Но с Glance такого не получится, remember с ним не работает. И мы в итоге увидим опять же ошибку нашего виджета и пугающий output. 

Чтобы решить проблему обновления состояния, у нас есть специальный GlanceStateDefiniton, завязанный на Preferences. 

Под капотом это те же Preferences DataStore, которые могут использоваться не только для того, чтобы автоматически представлять настройки приложению, но и расшариваться с другими виджетами. Для работы с ними потребуется создать свой кастомный GlanceStateDefiniton на Preferences и задать путь, куда у нас будут сохраняться данные, и откуда считываться.

object CustomGlanceStateDefinition : GlanceStateDefinition<Preferences> {
  override suspend fun getDataStore(context: Context, fileKey: String): DataStore<Preferences> {
    return context.dataStore
  }
  
  override fun getLocation(context: Context, fileKey: String): File {
    return File(context.applicationContext.filesDir, "datastore/$fileName")
  }
  
  private const val fileName = "widget_store"
  private val Context.dataStore: DataStore<Preferences>
    by preferencesDataStore(name = fileName)
}

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

То есть для каждого виджета мы должны указать свой stateDefinition на основе  GlanceStateDefinition, и в методе content мы можем воспользоваться currentState, это как раз и есть переменная состояния текущего виджета. currentState<Preferences> позволит нам получить доступ к тем или иным Preferences, на которые завязан наш виджет, взять данные по некоторому ключу и положить их в наш composable.

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

  • Использовать CoroutineScope для многопоточности

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

  • Также для шедулирования отложенных запросов можем использовать WorkManager API как замену фоновым сервисам

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

В целом у нас получается такая архитектура, что мы берём наш AppWidgetReceiver, в нем в каком-то методе вызываем UseCase, который стучится, например, в репозиторий или вызывает другую логику. Затем мы в UseCase после получения ответа кладём некоторые данные в Preferences, после чего вызываем обновление нашего виджета в AppWidgetReceiver, в самом виджете считываем значения из Preferences и при необходимости что-то туда сохраняем для дальнейшей работы.

Выглядеть это будет примерно так:

//GlanceAppWidgetReceiver
coroutineScope.launch {
  getDateFlowUseCase.execute().collect { date ->
    GlanceAppWidgetManager(context)
      .getGlancelIds(TimeWidget::class.java)
      .forEach { glanceId ->
        /**Обновление*/
        }
      }
  }

Эту логику вызываем в onReceive, нашем ресивере. Сам же ресивер мы регистрируем в переопределении метода onEnabled виджета. Итак, после получения новых данных мы проходимся по всем айдишникам наших виджетов, и затем уже для всех, либо для специального id вызываем updateAppWidgetState:

GlanceAppWidgetManager(context)
  .getGlanceIds(TimeWidget:: class. java)
  .forEach { glanceId ->
    updateAppWidgetState(
      context,
      PreferencesGlanceStateDefinition,
      glanceld
  
    ) { preferences ->
        preferences.toMutablePreferences().apply {
          this[timeKey] = date.time
        }
    }
    glanceAppWidget.update(context, glanceld)
  }


Как и в другом случае, автоматического триггера не будет, нужно вызывать вручную. . Поэтому мы сохраняем новое значение в preferences в нашей бизнес-логике. После чего вызываем обновление виджета, далее уже сам виджет считывает из preferences новые данные и перерисовывает View.

То есть мы делаем следующее:

  • Заменяем remember() на PreferenceGlanceState 

  • Оборачиваем запросы в UseCase (GlanceAppWidgetReceiver), чтобы очистить наш код

  • Затем обновляем состояние виджетов: updateAppWidgetState

Теперь посмотрим, что у нас с интервалами обновления данных.

Самыми популярными виджетами всегда были календари и часы. Данные в них требуется запрашивать по факту или после изменения.

Для корректного обновления по времени нам потребуется добавить ещё один ресивер, который будет как раз реагировать на те или иные события. 

class TimeWidgetReceiver : GlanceAppWidgetReceiver() {

  @Inject
  lateinit var timeTickReceiverManager: TimeTickReceiverManager
  
  override fun onEnabled(context: Context) {
    super.onEnabled(context)
    observeData(context)
    timeTickReceiverManager. registerReceiver()
  }
  
  override fun onDisabled(context: Context) {
    super.onDisabled(context)
    timeTickReceiverManager.unregisterReceiver()
    coroutineScope.cancel()
  }
}

Зарегистрируем мы его также в самом AppWidgetReceiver.

Например, для часов нам потребуется time tick. Это даст нам возможность отслеживать время каждую минуту, к сожалению, не чаще. С учетом того, сколько всего уже было оптимизировано в Android, и насколько обрезалось API виджетов, скажем спасибо и на том, что хотя бы с такой частотой оно у нас будет работать.

class TimeTickReceiverManager @Inject constructor(
  @ApplicationContext private val context: Context,
  private val updateDateUseCase: UpdateDateUseCase
) {
  
  private val broadcastReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent?) {
      if (intent?.action == Intent.ACTION_TIME_TICK) {
        updateDateUseCase.execute()
        }
      }
  
  fun registerReceiver() {/***/}
  
  fun unregisterReceiver() {/***/}
}

И затем уже, когда в самом ресивере получаем флаг, что у нас произошло то или иное событие, мы вызываем наш UseCase, вызываем логику, получаем результат, затем вызываем обновление нашего виджета.

Также возможен и другой кейс, такой как периодический опрос для всего нашего контента. Например, у нас есть список дел, нам не нужно обновлять его каждую минуту, достаточно это делать раз в несколько часов, либо по запросу. Или же данные о погоде. Если мы будем слушать какой-то сервис нон-стоп, у нас просто сядет батарея и наше приложение попадёт в плохой priority bucket в плане поддержания фоновой работы и разрешений.

В таком случае мы используем WorkManager API для шедулирования. Можно его использовать прямо из ресивера, либо использовать некий промежуточный менеджер, а в самом WorkManager будут создаваться периодические запросы на вызов того же самого UseCase.

Например, если у нас виджет погоды, то в WorkManager мы создаём соответствующий запрос, обязательно указываем все наши констрейнты, интервал обновления, и затем уже в этом реквесте мы вызываем задачу воркера, под капотом которой обращаемся к UseCase (если, конечно, используем его). 

class WeatherReadManager @Inject constructor(
  private val workManager: WorkManager
) {
    fun execute() = enqueueWorker(
    
    private fun enqueueWorker() {
      workManager.enqueueUniquePeriodicWork(
        WeatherReadTask.TAG,
        ExistingPeriodicWorkPolicy.KEEP,
        buildRequest()
      )
    }
    
    private fun buildRequest(): PeriodicWorkRequest {
      return PeriodicWorkRequestBuilder<WeatherReadTask>(1, TimeUnit.HOURS)
        .addTag(WeatherReadTask.TAG)
        .setConstraints(updateConstraints)
        .build()
    }
    )
  }

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

Ну и, разумеется, чтобы всё правильно триггерилось, необходимо запускать те же самые события через BroadcastReceiver. Например, если нам важно отслеживать местоположение, то нам достаточно делать это по факту, а не дёргать запросы каждые 5 секунд. Просто добавляем ресиверы на отслеживание событий и запускаем нашу логику, когда получаем уведомление. 

А если нам нужно вывести то, что не поддерживается? Например, карту-сэмпл с  виджетом PeopleInSpace:

К сожалению, у меня не получилось обратиться к нему напрямую, меня отрезал VPN. Но в самом виджете, по коду, логика работает так: при изменении локации мы показываем карту, а контрол карты у нас не является разрешённым для отображения. Поэтому в этом случае мы превращаем View в Bitmap, как и раньше:

val bitmap = withContext(Dispatchers.Main) {
  suspendCoroutine<Bitmap> { cont ->
    val mapSnapshot = MapSnapshot(
      {
        if (it.status == MapSnapshot.Status.CANVAS_OK) {
        val bitmap = Bitmap.createBitmap(it.bitmap)
        cont.resume(bitmap)
        }
      },
    
      MapSnapshot.INCLUDE_FLAG_UPTODATE or MapSnapshot.INCLUDE_FLAG_SCALED, 
      MapTileProviderBasic(context, source, null),
      listOf(stationMarker),
      projection
      )
      
      launch(Dispatchers.IO) {
      mapSnapshot. run()
    }
  }
}

Некоторые библиотеки сами умеют делать какие-то Bitmap-снэпшоты, либо просто снимок экрана. Например, авторы PeopleInSpace используют osmdroid, который у меня как раз из-за VPN не запустился. И в нём есть специальный MapSnapshot для создания Bitmap, и этот Bitmap мы уже потом кладём в переменную состояния и затем вызываем триггер для использования.

Резюмируем по Android-виджетам:

  • Они интерактивные, но без анимации. Как и раньше, анимацию мы имитируем, но сейчас с этим сложнее.

  • Поддерживают современный архитектурный подход, который нам нужно учитывать. 

  • Не все Composable сейчас имеют аналог в Glance, но он развивается, ситуация может измениться.

  • Glance-виджеты совместимы с RemoteView

  • Сложности с интервалами обновления, не гарантированный результат

Теперь посмотрим, что с iOS

Виджеты в iOS появились в 2020-м. Но на самом деле, в этом году появился новый WidgetKit — новый фреймворк для iOS 14, который позволяет просто реализовывать те же самые виджеты в концепции SwiftUI. Если говорить откровенно, то самые первые виджеты появились еще в iOS 11, но сейчас этот паттерн устарел. 

Что собой представляет виджет в iOS? Это, как и в Android, не мини-приложение, а его часть для отображения на экране. Вы можете минимизировать логику — это ваше право. 

В iOS 11 появились NCWidgetProvidingAPI — они позволяли реагировать на действия пользователя, хотя и имели ограниченный инструментарий. С новым фреймворком инструментарий расширили, но урезали и возможности. 

Виджеты под iOS 14 поддерживают Shared код. Вы можете с помощью XCode сделать специальное Multiplatform приложение и выделить там часть кода как общую, поддерживаемую в разных таргетах. То, что поддерживается в семантике WidgetKit (то есть всё, кроме классических UIKit View), вы можете вызывать из вашего виджета. И разумеется, весь код будет на SwiftUI. Например, вот код, в котором в зависимости от размера виджета мы вызываем тот или иной View на SwiftUI, куда передает entry, то есть информацию:

struct TodoWidgetView : View {
  @Environment(\.widgetFamily) var family
  var entry: Provider.Entry
  
  var body: some View {
    switch family {
    case .systemSmall:
    
    TodoWidgetSmallView(entry: entry)
    case .systemLarge:
    
    TodoWidgetLargeView(entry: entry)
    default:
    
    Text("Not supported")
    }
  }
}

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

Сам виджет состоит из специального статического View Content, который меняется в зависимости от TimelineProvider, который оперирует Snapshot’ами в виде цепочки  Timeline entry. То есть вы создаете массив состояний виджета, который через заданные промежутки времени отрисовываются в вашем View. Сам Snapshot подразумевает, что все статичное. Что подсказывает нам, что мы можем создать эффект динамики из цепочки отрисовываемых с определенной скоростью Timeline entry.

Timeline провайдер работает так: при старте виджета в Timeline загружается определенная последовательность entry с заданными промежутками времени и политиками обновления виджета. Например, если задать atEnd, то после конца обновления текущей стадии виджета берется следующий слепок через определенный промежуток времени и отображается. Чем больше промежуток, тем точнее выполнение, потому что система может регулировать старт того или иного события. Когда все Timeline закончились, вызывается последний Timeline, говорящий, что больше обновлять нечего, и виджет выходит из круга обновлений.

У вас есть возможность передать данные с основным приложением через Deeplink, в который вы можете обернуть View.

struct SimpleWidgetEntryView: View {
  var entry: SimpleProvider.Entry
  var body: some View {
  
    Link(destination: URL(string: "widget://link1")!) {
    Text ("Link 1")
    }
  }
}

@main
struct WidgetTestApp: App {
  var body: some Scene {
    WindowGroup {
      Text ("Test")
        .onOpenURL { url in
          print("Received deep link: \(url)")
        }
    }
  }
}

Рассмотрим несколько кейсов. 

Что с UI

Виджеты не поддерживают ничего, что связано с анимацией: ни карты, ни прокрутку, ни видео. Что самое неприятное — никакого UIView и UIViewRepresentable. Поэтому мы можем использовать либо только SwiftUI, либо, по традиции, превратить все в картинку и выводить в UIImage. То есть мы создаем расширение, которое превращает View с помощью GraphicsImageRenderer и CGContext в UIImage. А затем UIImage показывается через специальный инициализатор с помощью задание границ для Image. 

Например, мы можем зарендерить карту как контрол UIKit промежуточно, снять слепок изображения и поставить в наш View.

Также мы можем использовать готовые библиотеки, например, MapView. MKMapSnapshotter будет делать скрины, которые вы положите в View. 

//MKMapSnapshot from MKMapView

var _snapShotOptions: MKMapSnapshotOptions
var _snapShot: MKMapSnapshotter

_snapShotOptions.region = mapRegion
//…
_snapShot.start { (snapshot, error) —> Void in

  if error == nil {
    let image = snapshot?.image
    //…
  } else {
    print("error")
  }
}
//…

Резюмируем: либо мы используем только SwiftUI, либо используем картинки для имитации интерактивного UI.

Интервалы обновления

Мы рассмотрели, что может быть массив entry, но массивы могут быть конечными. А если нам нужно запрашивать время каждую секунду? Конечно, это спорно в плане памяти и сразу отрубает возможность создания чего-то на UIKit. Хочется сделать оптимальнее, с помощью SwiftUI. Воспользуемся старым добрым таймером, который засунем в TimelineProvider  в специальный метод getTimeline и будем вызывать раз в полсекунды наш виджет с новым Timeline, добавляя туда новый entry с указанной датой и данными.

Если мы хотим подключить сложную логику, например, получая что-то асинхронно по сети, либо из хранилища, то нам потребуется всю логику положить в Timeline Provider и в Completion-блоке вызвать Timeline с конкретными данными по факту обновления.

В современной версии мы можем использовать таски и async-await. Таски у нас не detached, поэтому мы находимся в главном потоке UI и на другом потоке вызывается только метод loadData. Мы получаем данные из сервиса, создаем на их основе entry. Затем, например, создаем refreshData и в Timeline инициализируем entry, указываем, когда будет следующее событие, и передаем в Completion-блок

func getTimeline(for configuration: RideDataIntent, in context: Context,
    completion: @escaping (Timeline<SimpleEntry>) -> ()) {
  var entries: [SimpleEntry] = []

  Task {
    let currentDate = Date()
    let data = await someService.loadData()
    entries.append(SimpleEntry(date: currentDate, data: data))
    let refreshDate = Calendar.current.date(byAdding: .minutes, value: 15, to: currentDate)!
    let timeline = Timeline(entries: entries, policy: .after(refreshDate))
    completion(timeline)
  }
}

Асинхронными могут быть и картинки. Например, если данные содержат URL картинки, а не саму картинку. Казалось бы, все классно, но ничего подобного: API, конечно, асинхронное, но Callback для виджета нет, поэтому виджет не понимает, нужно ли ему перезагружаться, чтобы показать эту картинку. Поэтому нам потребуется собственное API для загрузки изображений и в его Completion-блоке можем, например, дернуть виджет на перерисовку. Команду WidgetCenter.shared.reloadTimelines можно дернуть в любой точке приложения, но правильнее будет не засовывать его в бизнес-логику, чтобы не нарушать целостность функциональных слоев.

В итоге мы можем получить такое изображение, которое точно покажет данные с карты. Например, если мы взяли готовый URL, который мы создали через API static на основе координат и зарендерить нашу картинку в виджете. Даже сами Apple снэпшотят свою карту и показывают в виджете, то есть это тоже не интерактив.  

Подведем итоги

Чтобы на iOS-виджете корректно работали запросы и обновления, мы можем использовать либо async-await, либо completionHandler. Вся логика запросов будет в TimelineProvider. Конечно, вы выносите саму логику в какой-нибудь менеджер или релоадер, но его функцию вы дергаете в TimelineProvider и callback приходит в нем же. Также мы используем в качестве решения Timer, если нам необходимо точное время выполнения. 

Суммируем по iOS-виджетам:

  • Виджеты статичные, stateless и не интерактивные

  • Можно использовать общий код приложения, чтобы сократить количество работы

  • Используем таймеры для событий

  • Архитектура решений может быть мудреной, потому что нет единого подхода, рекомендованного Apple, как работать со сложной логикой.

Сравним с Android:

  • Android-виджеты тоже без анимации, но они интерактивные. 

  • Для них есть специальный архитектурный подход 

  • Не все Composable имеют аналог в Glance

  • Можем использовать RemoteView, но из-за того, что не все имеет аналог в Glance, могут быть трудности

  • Есть сложности с интервалами обновления, не гарантированный результат

Какая есть проблема с виджетами в целом? И на iOS, и на Android есть проблема управления состоянием и обновлений и правильная архитектура решения. Также в обеих ОС есть проблема с имитацией сложных UI. Причем если в iOS мы можем сделать анимацию за счет смены изображений раз в секунду, то на Android из-за ограничений это сделать не получится. И поэтому не все можно превратить в картинку. 

Я собрала для вас список полезных источников:

Спасибо за внимание!

Напоследок напомним, что в мае на нашей конференции Mobius Анна представит новый доклад «Упрощаем и укрощаем UI для Android с помощью аннотаций». Также там будет множество другого контента для мобильных разработчиков, мы уже публиковали на Хабре программу. Конференция пройдёт 12–13 мая (онлайн) и 19–20 мая (Москва + возможность онлайн-участия).

Комментарии (1)