Pull to refresh

Тёмная сторона Android App widgets

Reading time14 min
Views6.7K

Всем привет! Меня зовут Егор Карташов, и я Android-разработчик в команде мобильного оператора Yota. 

Виджет домашнего экрана (App widget) – один из компонентов ОС Android, который появился в одной из первых версий системы (Android 1.5) и сохранил свою концепцию до наших дней. Однако почти 9 лет про него не вспоминали – выпустив обновление виджетов в Android 4.2, Google отложил их в долгий ящик.

Всё изменилось, когда Apple выпустила iOS 14, в которой обновила свои виджеты и добавила возможность их размещения на рабочем столе. Google отреагировал почти сразу и в следующей версии Android получил масштабную переработку виджетов – дизайн обновили в соответствии с Material You, расширили возможности API, освежили порядочно устаревшую документацию.

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

Я не буду расписывать, как инициализировать виджет в приложении и добавить простую верстку. Всё это достаточно подробно описано в документации. Кроме того, с выходом Android 12 она была обновлена с учетом нововведений API 31 – большинство белых пятен закрыто.
Вместо этого речь пойдет о двух вещах: обновлении необходимых данных и их последующем корректном рендеринге.

Требования

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

  • отображение актуальных данных о счете пользователя;

  • автоматическое обновление данных по истечении определенного времени;

  • возможность ручного обновления данных по нажатию соответствующей кнопки;

  • поддержка двух размеров виджета – 2x2 и 4x1;

  • корректное отображение виджета на телефонах и планшетах с самыми разными диагоналями и сетками launcher’ов.

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

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

  • реализация логики обновления данных;

  • верстка, её отладка и адаптация.

На каждом из этапов порядочно нюансов и подводных камней. Давайте пойдем по порядку и рассмотрим каждый из них.

Обновление данных

В целом, для автоматического и ручного обновления алгоритм одинаков:

  1. стартуем фоновую работу, в рамках которой идём в сеть за свежими данными;

  2. получив данные, сохраняем текущий timestamp как время последней актуализации и запускаем таймер следующего обновления;

  3. по завершении таймера или по нажатию на кнопку ручного обновления возвращаемся в п. 1.

Время работы таймера = serverTimestamp - lastUpdateTimestamp. Предполагаемый интервал обновления виджета – 3-20 минут. Величина зависит от значения, пришедшего с сервера. Возникает вопрос – зачем такое усложнение, если можно зашить какое-то фиксированное значение в приложении? Всё это нужно для того, чтобы сервер мог распределять нагрузку в зависимости от частоты запросов.

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

Work Manager

Самый очевидный вариант – Work Manager. Он успешно используется в приложении для выполнения различных фоновых задач. Кроме того, в документации рекомендуется использовать этот вариант, поэтому логично рассмотреть возможности Work Manager для реализации обновления данных виджета. Однако после написания и тестирования worker’а на поверхность всплывают несколько критичных недостатков, из-за которых от Work Manager стоит отказаться.

Ограничение периодических работ

Для выполнения работ с определенным интервалом в Work Manager предлагается использовать PeriodicWorkRequest.

val workRequest = PeriodicWorkRequestBuilder<SomeWorker>(repeatInterval = 5, TimeUnit.MINUTES).build()
WorkManager.getInstance(context).enqueue(workRequest)

В билдер в качестве параметра передается значение таймаута повторного старта работы. Минимальное допустимое значение – 15 минут. Значения меньше игнорируются. 

// PeriodicWorkRequest.java

/**
* The minimum interval duration for {@link PeriodicWorkRequest} (in milliseconds).
*/
@SuppressLint("MinMaxConstant")
public static final long MIN_PERIODIC_INTERVAL_MILLIS = 15 * 60 * 1000L; // 15 minutes.


// WorkSpec.java

/**
* Sets the periodic interval for this unit of work.
*
* @param intervalDuration The interval in milliseconds
*/
public void setPeriodic(long intervalDuration) {
    if (intervalDuration < MIN_PERIODIC_INTERVAL_MILLIS) {
        Logger.get().warning(TAG, String.format(
            "Interval duration lesser than minimum allowed value; Changed to %s",
            MIN_PERIODIC_INTERVAL_MILLIS));
        intervalDuration = MIN_PERIODIC_INTERVAL_MILLIS;
    }
    setPeriodic(intervalDuration, intervalDuration);
}

Таким образом, если от сервера придет таймаут равный 5 минутам, то оно будет пропущено. Это нас совсем не устраивало.

Worker и таймер

Но что, если не использовать предлагаемый PeriodicWorkRequest и сымитировать периодическое выполнение работы? После выполнения сетевого запроса не завершать worker, а ждать указанное сервером время, после которого повторно планировать работу?

Согласно документации, максимальное время работы worker’а – 10 минут, по истечении которых система принудительно остановит его выполнение.

// CommandHandler.java   

// constants
static final long WORK_PROCESSING_TIME_IN_MS = 10 * 60 * 1000L;


// DelayMetCommandHandler.java

@Override
public void onAllConstraintsMet(@NonNull List<String> workSpecIds) {
    ...
    if (isEnqueued) {
        // setup timers to enforce quotas on workers that have
        // been enqueued
        mDispatcher.getWorkTimer().startTimer(mWorkSpecId, WORK_PROCESSING_TIME_IN_MS, this);
        ...
    }
    ...
}

@Override
public void onTimeLimitExceeded(@NonNull String workSpecId) {
    Logger.get().debug(
        TAG,
        String.format("Exceeded time limits on execution for %s", workSpecId));
    stopWork();
}

Следовательно, интервалы обновления виджета в 10+ минут будут проигнорированы.

Из этого затруднительного положения можно было бы выйти написанием костылей, отслеживающих отмену работы и повторяющих планирование работы с учетом оставшегося время таймаута. Но от этого нас избавила очень занятная особенность Work Manager’а.

Work Manager и rescheduling

Допустим, с помощью Work Manager мы запланировали определенное количество фоновых работ. Что произойдет, если после этого устройство будет перезапущено? Все запланированные работы потеряются?

Если копнуть вглубь Work Manager, то можно заметить два интересных момента. Сначала запланированная работа сохраняется во внутренней БД Work Manager’а.

// EnqueueRunnable.java

@Override
public void run() {
    ...
    boolean needsScheduling = addToDatabase();
    ...
}

/**
* Adds the {@link WorkSpec}'s to the datastore, parent first.
* Schedules work on the background scheduler, if transaction is successful.
*/
@VisibleForTesting
public boolean addToDatabase() {
    WorkManagerImpl workManagerImpl = mWorkContinuation.getWorkManagerImpl();
    WorkDatabase workDatabase = workManagerImpl.getWorkDatabase();
    workDatabase.beginTransaction();
    try {
        boolean needsScheduling = processContinuation(mWorkContinuation);
        workDatabase.setTransactionSuccessful();
        return needsScheduling;
    } finally {
        workDatabase.endTransaction();
    }
}

Затем включается компонент RescheduleReceiver.

// EnqueueRunnable.java

@Override
public void run() {
    ...
    boolean needsScheduling = addToDatabase();
    if (needsScheduling) {
        // Enable RescheduleReceiver, only when there are Worker's that need scheduling.
        final Context context = mWorkContinuation.getWorkManagerImpl().getApplicationContext();
        PackageManagerHelper.setComponentEnabled(context, RescheduleReceiver.class, true);
        scheduleWorkInBackground();
    }
    ...
}

Его задача – отслеживание броадкастов со следующими action’ами:

  • ACTION_BOOT_COMPLETED;

  • ACTION_TIME_CHANGED;

  • ACTION_TIMEZONE_CHANGED.

После получения одного из них проверяется наличие в БД необработанных задач и  при необходимости производится их повторное планирование.

// WorkManagerImpl.java

/**
* Reschedules all the eligible work. Useful for cases like, app was force stopped or
* BOOT_COMPLETED, TIMEZONE_CHANGED and TIME_SET for AlarmManager.
*
* @hide
*/
public void rescheduleEligibleWork() {
    if (Build.VERSION.SDK_INT >= WorkManagerImpl.MIN_JOB_SCHEDULER_API_LEVEL) {
        SystemJobScheduler.cancelAll(getApplicationContext());
    }

    // Reset scheduled state.
    getWorkDatabase().workSpecDao().resetScheduledState();

    // Delegate to the WorkManager's schedulers.
    // Using getters here so we can use from a mocked instance
    // of WorkManagerImpl.
    Schedulers.schedule(getConfiguration(), getWorkDatabase(), getSchedulers());
}

По завершении всех работ RescheduleReceiver отключается, чтобы не тратить ресурсы системы впустую.

// WorkerWrapper.java

private void resolve(final boolean needsReschedule) {
    ...
    // Check to see if there is more work to be done. If there is no more work, then
    // disable RescheduleReceiver. Using a transaction here, as there could be more than
    // one thread looking at the list of eligible WorkSpecs.
    boolean hasUnfinishedWork = mWorkDatabase.workSpecDao().hasUnfinishedWork();
    if (!hasUnfinishedWork) {
        PackageManagerHelper.setComponentEnabled(
            mAppContext, RescheduleReceiver.class, false);
    }
    ...
}

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

Вроде бы полезная и правильная фича, но есть один нюанс. При включении/выключении RescheduleReceiver отправляется броадкаст с ACTION_PACKAGE_CHANGED. На него реагируют разные компоненты системы, в том числе виджеты. После каждого такого броадкаста у виджета будет вызван метод onUpdate(). Согласно документации, именно здесь стоит планировать обновление виджета с помощью Work Manager. В итоге мы получаем бесконечный цикл:

Кроме того, обновление виджета будет триггериться при использовании Work Manager в других юзкейсах приложения.

Решения этой проблемы пока нет, но, возможно, оно появится – Google решили доработать поддержку виджетов в Work Manager. Как скоро это будет сделано – неизвестно, но связанный issue переехал в статус Assigned. Пока что возможный костыль – планирование задачи-заглушки, ожидающей выполнение через, например, 10 000 лет. RescheduleReceiver будет зарегистрирован единожды и никогда не выключится, тем самым предотвращая зацикливание.

Плодить костыли в приложении – сомнительное решение, поэтому рассмотрим другие варианты.

Service, JobIntentService и updatePeriodMillis

Для обновления виджета средствами системы предусмотрен атрибут updatePeriodMillis в <appwidget-provider>. Может быть, можно использовать его в связке с каким-нибудь сервисом?

К сожалению, это невозможно по нескольким причинам:

  1. Минимальное значение атрибута – 30 минут. Значения меньше игнорируются системой и приводятся к нижней границе.

  2. Значение атрибута нельзя задать программно.

  3. Система может не стриггерить обновление виджета по истечении заданного периода.

Сервисы тоже использовать не получится – вспоминаем ограничения на их запуск и фоновую работу. JobIntentService – вроде неплохой вариант, лишенный недостатков обычных сервисов. Но в настоящий момент он задепрекейчен в пользу Work Manager. Кроме того, перед подготовкой приложения к Android 12 мы избавились от оставшихся JobIntentService’ов – возвращать устаревшее решение в проект нет никакого желания.

Из вариантов остается явным образом использовать JobScheduler, что мы в итоге и сделали. Рассмотрим подробнее, что у нас получилось.

Обновление данных с помощью JobScheduler

Любое обновление виджета – ручное или автоматическое – начинается с вызова метода onUpdate() в AppWidgetProvider:

// YotaAppWidgetProvider.kt

override fun onReceive(context: Context, intent: Intent) {
  when (intent.action) {
    ACTION_APPWIDGET_UPDATE -> onUpdate(context, intent)
    else -> super.onReceive(context, intent)
  }
}

private fun onUpdate(context: Context, intent: Intent) {
  val updateType =
    intent.getSerializableExtra(EXTRA_APP_WIDGET_UPDATE_TYPE) as? AppWidgetUpdateType ?: SYSTEM
  context.jobScheduler?.enqueueAppWidgetUpdate(context, updateType)
}

В нём мы обращаемся к JobScheduler и с его помощью планируем задачу обновления данных виджета:

// JobSchedulerUtil.kt

fun JobScheduler.enqueueAppWidgetUpdate(context: Context, updateType: AppWidgetUpdateType) {
  val componentName = getComponentName(context, AppWidgetUpdateService::class)
  val jobInfo = JobInfo.Builder(APP_WIDGET_UPDATE_JOB_ID, componentName)
      .setExtras(persistableBundleOf(EXTRA_APP_WIDGET_UPDATE_TYPE to updateType.name))
      .setOverrideDeadline(DEFAULT_OVERRIDE_DEADLINE)
      .setPersisted(true)
      .build()

  schedule(jobInfo)
}

updateType – тип инициированного обновления виджета. Может принимать следующие значения:

  • PLANNED – обновление по истечении таймера;

  • MANUAL – обновление по нажатию на соответствующую кнопку на виджете;

  • SYSTEM – обновление виджета, инициированное системой.

Система может инициировать обновление в основном в трех случаях – при установке виджета на рабочий стол, после перезапуска системы и в результате реакции на интент с ACTION_PACKAGE_CHANGED, о котором шла речь при рассмотрении нюансов Work Manager. Чтобы лишний раз не ходить на сервер, эти кейсы обрабатываются отдельно – учитывается наличие кэшированного значения и дата последнего обновления.

После планирования задачи начинает свою работу JobService. Сначала получаем набор информации о состоянии виджета – из кэша или с сервера. После этого конструируем новую RemoteView для всех установленных на рабочем столе виджетов и обновляем их через AppWidgetManager. Затем либо стартуем таймер следующего обновления, либо заканчиваем работу сервиса, если пользователь не авторизован в приложении.

// AppWidgetUpdateService.kt

override fun onStartJob(params: JobParameters?): Boolean {
  val appWidgetUpdateType = params?.getAppWidgetUpdateTypeExtra() ?: SYSTEM

  // Получаем набор информации о состоянии виджета
  updateAppWidgetDataScenario(appWidgetUpdateType)
    .applySchedulers()
    .flatMapCompletable { appWidgetState ->
      // Рендерим полученный стейт для каждого установленного виджета
      AppWidgetType.values.forEach { updateWidget(appWidgetState, it) }

      if (appWidgetState !is Authorized) {
        Completable.fromAction { jobFinished(params, false) }
      } else {
        // Если пользователь авторизован - запускаем таймер следующего обновления
        getAppWidgetNextUpdateTimeUseCase()
          .flatMapCompletable { updateTimeSeconds ->
            Completable.timer(updateTimeSeconds, SECONDS)
          }
          .andThen {
            // После того, как таймер отработал, завершаем текущий Job и отсылаем броадкаст
            // для инициации повторного обновления виджетов
            jobFinished(params, false)
            appWidgetManager.requestEnabledWidgetsUpdate(applicationContext, PLANNED)
          }
      }
    }
    .subscribeWith(defaultCompleteObserver { })
    .addTo(disposables)

  return true
}

По истечении таймера (или по нажатию пользователем соответствующей кнопки) рассылаются броадкасты с ACTION_APPWIDGET_UPDATE. AppWidgetProvider реагирует на них, вызывает метод onUpdate(), и всё начинается сначала.

В целом, с логикой обновления закончили. По большому счету она довольно проста – достаточно только выбрать правильный инструмент для её реализации. На текущий момент наиболее оптимальное решение – JobScheduler. Также не стоит упускать из виду Work Manager – возможно, в ближайшее время нас ждут обновления, улучшающие интеграцию с AppWidget.

Переходим к разбору следующего этапа и набора проблем – верстке и её корректному отображению.

Виджеты и RemoteViews

По большому счёту верстка виджета ничем не отличается от того, что мы привыкли верстать для наших экранов. Тот же XML, те же TextView, Button, LinearLayout и т. д. Однако есть одно важное отличие, о котором всегда нужно помнить. Виджет рендерится в launcher’е с помощью RemoteViews. Это класс, представляющий иерархию вьюх, отображающихся в другом процессе. Чаще всего применяется для отображения уведомлений в notification drawer и виджетов на рабочем столе.

Возможности RemoteViews ограничены – поддерживается только небольшой набор компонентов верстки и операций с ними. А это значит, что кастомные компоненты нашего приложения использовать не получится. Если засунуть в XML неподдерживаемый компонент, то после добавления виджета на рабочий стол на нем будет красоваться такая надпись:

А в логах – ошибка:

2022-04-15 18:33_47.829 2447-2447/com.google.android.apps.nexuslauncher W/AppWidgetHostView: Error inflating AppWidget AppWidgetProviderInfo(UserHandle{0}/ComponentInfo{ru.yota.android/ru.yota/android.appWidgetModule.presentation.provider.RectangleYotaAppWidgetProvider}): android.content.res.Resources$NotFoundException: Resource ID #0x7f0e00de

Но, несмотря на ограничения, большинство кейсов RemoteViews вполне покрывает.

Размеры и масштабирование

После того, как верстка готова, нас ждет другая проблема – никто не гарантирует, что виджет будет выглядеть так, как задумывалось изначально. На великое многообразие экранов устройств накладывается особенность рендеринга экрана launcher’а. В большинстве случаев свободное пространство разбивается на клетки так, что получается сетка 4x4, 5x4, 6x5 и т. д. Контент, который пользователь добавляет на рабочий стол, распределяется по этим ячейкам и не вылезает за их границы.

Ячейка сетки далеко не всегда квадратная. Размер может быть самым разным – например, 30x60dp. Поэтому не стоит удивляться тому, что вы дизайнили и верстали квадратный виджет, а на тестовом девайсе получили прямоугольник.

Также нужно помнить о размерах экрана и их разрешениях, о возможности настройки размеров шрифтов в настройках приложения. Всё это тоже сильно влияет на финальное отображение виджета на рабочем столе.

Поддержка кастомных шрифтов

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

После непродолжительных поисков по stackoverflow выяснилось, что так и должно быть.

Позже выяснилось, что шрифты игнорируются и на некоторых китайфонах с более старыми версиями Android.

Что же делать, если хочется писать текст на виджете своим кастомным шрифтом? Очень просто – рисовать текст с помощью Canvas и запихивать в ImageView.

Нам очень хотелось использовать наш кастомный шрифт, поэтому для этого мы написали экстеншн RemoteViews:

fun RemoteViews.setImageViewText()
/**
 * Отрисовка заданного [text] в ImageView, расположенную в виджете.
 *
 * Необходимо использовать вместо RemoteViews.setTextViewText() и TextView,
 * если тект должен быть отрисован кастомным шрифтом - с помощью TextView это сделать невозможно.
 *
 * @param viewId id ImageView, в которой будет отрисован текст.
 * @param textAppearanceRes стиль, используемый для отрисовки текста.
 * @param textColorRes цвет текста. Используется для переопределения цвета текста стиля программно.
 * @param text
 */
@SuppressLint("ResourceType")
internal fun RemoteViews.setImageViewText(
  context: Context,
  @IdRes viewId: Int,
  text: CharSequence,
  @StyleRes textAppearanceRes: Int,
  @ColorRes textColorRes: Int? = null,
  textContainerWidthPx: Int? = null,
  shouldEllipsize: Boolean = false,
  maxLines: Int = Int.MAX_VALUE
) {
  // Если текст пустой - устанавливаем 1x1 px заглушку.
  if (text.isEmpty()) {
    val emptyBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
    setImageViewBitmap(viewId, emptyBitmap)
    return
  }

  // Необходимые для отрисовки атрибуты, которые достаем из textAppearanceRes.
  val attrs = intArrayOf(
    android.R.attr.textSize,
    android.R.attr.textColor,
    android.R.attr.lineSpacingExtra,
    android.R.attr.fontFamily
  )

  val textPaint = TextPaint().apply {
    isAntiAlias = true
    isSubpixelText = true
    style = Paint.Style.FILL
  }

  var lineSpacingExtra = 0f
  context.withStyledAttributes(textAppearanceRes, attrs) {
    textPaint.color = textColorRes?.let(context::color) ?: getColor(1, Color.BLACK)
    textPaint.textSize = getDimensionPixelSize(0, 0).toFloat()
    textPaint.typeface = ResourcesCompat.getFont(context, getResourceId(3, -1))
    lineSpacingExtra = getDimensionPixelSize(2, 0).toFloat()
  }

  // Определяем ширину текста, который нужно отрисовать.
  // Если указан параметр textContainerWidthPx - используем его.
  // Иначе считаем длину каждой строки и выбираем наибольшую.
  val coercedGivenTextWidth = textContainerWidthPx?.coerceAtLeast(0)
  val measuredTextWidth = text.split("\n")
    .map { paragraph -> textPaint.measureText(paragraph, 0, paragraph.length).roundToInt() }
    .maxOf { it }
    .coerceAtLeast(0)
  val textWidth = coercedGivenTextWidth?.let { min(it, measuredTextWidth) } ?: measuredTextWidth
  val ellipsize = when {
    shouldEllipsize && coercedGivenTextWidth != null && coercedGivenTextWidth < measuredTextWidth ->
      TruncateAt.END
    else ->
      null
  }

  val staticLayout = if (isAtLeastMarshmallow()) {
    StaticLayout.Builder.obtain(text, 0, text.length, textPaint, textWidth)
      .setLineSpacing(lineSpacingExtra, 1f)
      .setMaxLines(maxLines)
      .setEllipsize(ellipsize)
      .build()
  } else {
    StaticLayout(
      text,
      0,
      text.length,
      textPaint,
      textWidth,
      Layout.Alignment.ALIGN_NORMAL,
      1f,
      lineSpacingExtra,
      true,
      ellipsize,
      textWidth
    )
  }

  val bitmapWidth = staticLayout.width
  val bitmapHeight = staticLayout.height

  val textBitmap = Bitmap.createBitmap(bitmapWidth, bitmapHeight, Bitmap.Config.ARGB_8888)
  val canvas = Canvas(textBitmap)

  staticLayout.draw(canvas)

  setImageViewBitmap(viewId, textBitmap)
}

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

Итоги

Несмотря на проделанный путь, наши виджеты пока не идеальны – например, кое-где вылезают косяки масштабирования. С ними мы планируем при первой возможности разобраться и надеемся, что нам в этом поможет Jetpack Glance – находящаяся в настоящий момент в альфе библиотека для создания виджетов с помощью инструментов Jetpack Compose.

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

Tags:
Hubs:
Total votes 8: ↑8 and ↓0+8
Comments2

Articles