Меня зовут Алексей Чернякович, я занимаюсь разработкой и поддержкой Android App Widgets в мобильном банке Тинькофф. Сейчас у нас работает три виджета. Несмотря на относительную простоту, они довольно популярны у пользователей — более 50 тысяч использований в месяц. Расскажу, как мы разрабатывали наши виджеты, с какими проблемами столкнулись и как искали решение.

Виджет и трудности с его UI

App Widget в Android появились еще в самых первых версиях для улучшения User Experience и быстрого доступа к важным функциям приложения. Цель была достигнута, как показали исследования 2022 года, почти 60% пользователей используют хотя бы один виджет или больше.

В разработке виджетов есть ряд трудностей.

Шрифты. Помещенный в виджет текст определяет его размеры, если размеры виджета выставлены как match_parent или wrap_content. Такое поведение встречается у всех View и ViewGroup.

Но в случае с виджетами появляется побочный эффект — занимаемое виджетом место. Например, стоит задача сделать виджет размером 2 × 2 ячейки. Создаем layout, смотрим на экран — виджет 2 × 2.

Виджет 2 × 2, виджет, который занимает три ячейки вместо двух и виджет, где много пустого места между рамкой и контентом
Виджет 2 × 2, виджет, который занимает три ячейки вместо двух и виджет, где много пустого места между рамкой и контентом

Меняем размер сетки экрана с 5 × 6 на 4 × 6 или меняем в настройках устройства размер шрифта на больший — и теперь наш виджет занимает по горизонтали три ячейки вместо двух. В итоге получаем много пустого места между рамкой и контентом виджета. Такое поведение ожидаемо, но есть много виджетов, где этот нюанс пропущен.

Покопавшись в исходниках виджетов, я выяснил, что избежать проблем с текстом можно, если выбрать вместо sp — dp. Получим красивый текст без регулирования размера на виджетах в системных настройках.

Масштабирование элементов. Размеры App Widget определяются двумя способами: явным значением в dp или размерами внутреннего содержимого виджета, если указать виджету размеры wrap_content или match_parent.

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

А — определенный тип виджета, в котором явно прописали значение

Б — виджет А, в котором увеличили масштаб
А — определенный тип виджета, в котором явно прописали значение Б — виджет А, в котором увеличили масштаб

И тут начинаются трудности: можно применять в App Widgets только некоторые layout из Android SDK, без возможностей ConstraintLayout для динамического управления отступами между вьюшками внутри виджета. Мы не можем контролировать это из кода, потому что виджеты основаны на RemoteView, который не имеет ничего общего с Android View.

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

С определением размера виджета как match_parent или wrap_content есть свои проблемы. Если укажем ширину и высоту виджета как match_parent, то получим идеально заполненные бэкграундом виджета ячейки сетки экрана.

Выглядит здорово до момента, пока пользователь не выберет сетку экрана 3 × 3. Получится растянутый на пол-экрана бэкграунд. Такой кейс подходит для случаев, когда внутренний контент по размеру приближен к размеру самого виджета.

А — сетка экрана 4 × 5, Б — сетка экрана 3 × 3, В — сетка экрана 6 × 5
А — сетка экрана 4 × 5, Б — сетка экрана 3 × 3, В — сетка экрана 6 × 5

С параметром wrap_content ждет еще одно приключение, которое схоже по своей природе с проблемой из пункта «Шрифты». Виджет может раздуться и занять непредвиденное количество ячеек. В итоге получим что-то вроде рисунка В, когда система выделит для бэкграунда на несколько пикселей больше места, чем необходимо для одной ячейки. Это значит, что для виджета выделяется не одна, а две ячейки.

Размер виджета можно определить двумя способами: указать точные величины в XML и определить размер внутренним контентом. В каждом подходе мы сталкиваемся с тем, что контент может либо не влезть в виджет, либо распределиться внутри него неверно.

Если указать строгое значение размера — рискуем тем, что внутренний контент виджета не влезет. А еще Android 12+ принудительно скругляет углы, и это тоже может обрезать часть контента. При увеличении масштаба в системных настройках снова может возникнуть проблема — перенос текста или урезание.

Если использовать параметр wrap_content/match_parent для layout, можно встретить другую проблему. Не получится строго определить количество ячеек, которые будут выделяться для нашего виджета на рабочем столе. Такой нюанс можно обойти с помощью параметров max_width и max_height в файле конфигурации. Параметры будут контролировать минимально допустимые размеры виджета и по умолчанию выделять для них минимально допустимое количество ячеек.

Получается две оптимальные конфигурации для виджетов: 

  • Если виджет должен строго соответствовать дизайну — нужно использовать точное указание размеров в dp и постараться избежать текста внутри виджета.

  • Если можно отступить от заданных в дизайне размеров, лучше использовать параметры wrap_content или match_parent. Тогда получим виджет, background которого полностью зальет те ячейки, что были выделены для виджета на рабочем столе.

Количество занимаемых виджетом ячеек. Начиная с Android 12 разработчики получили два важных параметра конфигурации виджета: targetCellWidth и targetCellHeight. Эти параметры определяют, сколько ячеек будет выделено для виджета в момент его установки на экран. Остальное нужно рассчитывать самим.

В документации к разработке виджетов есть сравнительная таблица, которая дает общее понимание того, как посчитать количество ячеек для виджета. Отправной точкой для расчетов становится параметр в 57 × 102 dp. Именно такого размера будет виджет, занимающий клетку 1 × 1, но с оговорками, вынесенными в отдельную сноску:

Говоря проще, руководствуйтесь в своих расчетах этими параметрами, но мы не гарантируем, что все будет гладко!
Говоря проще, руководствуйтесь в своих расчетах этими параметрами, но мы не гарантируем, что все будет гладко!

В ходе экспериментов выяснилось, что 1 dp съедается внутренним отступом самой рамки виджета и в расчетах нужно это учитывать. Поэтому размеры будут 56 × 101 dp вместо 57 × 102 dp для виджета размером 1 × 1.

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

На практике получается дополнительный отступ в рамке, который не дает поместить виджет в одну ячейку и выделяет для него две 2 × 2, а layout подставляет от виджета размером 1 × 1. В плане UX такой виджет никуда не годится, так как при выставлении размера сетки по горизонтали меньше чем 4 мы рискуем не уместить на большом экране два маленьких виджета рядом друг с другом

Изменения виджетов в Android 12

В Android 12 виджеты значительно изменились — внутренняя реализация и внешний вид стали другими. Коротко расскажу самые важные пункты.

Изменение layout виджета при изменении его размера на экране. Раньше для того, чтобы изменить layout виджета, при изменении его размера необходимо было учитывать предполагаемые размеры ячейки. Это трудно и выдает проблемы на нестандартных лаунчерах. Теперь нужно создать Map, положить в нее все layouts и указать размеры, при которых каждый из них будет применяться.

В превью виджета появилась возможность подставить layout. В ранних версиях Android есть возможность подставлять в превью только картинку. Из-за этого ограничения приходилось создавать и хранить PNG/WEBP разных тем и локализаций, что увеличивало размер APK. Сейчас такой необходимости нет — превью будет меняться в соответствии с локалью и темой устройства.

Принудительное скругление углов виджета. Говоря проще, теперь все виджеты будут выглядеть единообразно в плане своей формы. Если в более старых версиях верстали layout виджета и гарантированно получали желаемый вид на рабочем столе, то теперь система будет принудительно пытаться обрезать острые углы.

Android 11          и           Android 12
Android 11 и Android 12

Минимальный радиус скругления фона бэкграунда виджета, контролируемый параметром system_app_widget_background_radius, не будет превышать 28 dp, а радиус внутреннего контента виджета всегда будет на 8 dp меньше system_app_widget_background_radius. Говоря о скруглении углов бэкграунда, нужно помнить, что если мы его не определим явно как @android:id/background, то углы будут скруглены принудительно. Чтобы контролировать эту ситуацию, рекомендуется создать кастомную тему и явно переопределить параметр appWidgetRadius для каждой версии Android.

```xml
<resources>
    <style name="Theme.AppWidgetIllustrationProj.AppWidgetContainerParent" parent="@android:style/Theme.DeviceDefault.DayNight">
        <item name="appWidgetRadius">@android:dimen/system_app_widget_background_radius</item>
        <item name="appWidgetInnerRadius">@android:dimen/system_app_widget_inner_radius</item>
    </style>
</resources>
```

Что касается атрибута system_app_widget_inner_radius, то он станет определять то, с каким радиусом будут скруглены углы для контента, который находится внутри бэкграунда. Этот параметр также можно (нужно) переопределить в своей теме для того, чтобы не случилось неожиданностей с версионностью устройств.

appInnerRadius = 0 dp и appInnerRadius = 12 dp
appInnerRadius = 0 dp и appInnerRadius = 12 dp

Функция системного скругления привнесла в Android 12 преимущество в плане дизайна, но теперь мы рискуем получить обрезанный углами внутренний контент.

Один из вариантов, как скругление может обрезать контент
Один из вариантов, как скругление может обрезать контент

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

Поддержка темы приложения из коробки. Для этого нужно создать разные темы и присвоить их виджету. Больше ничего делать не потребуется — тема будет меняться по аналогии с обычными View.

Увеличился список допустимых View для RemoteView. Добавились CheckBox, Switch и RadioButton.

Smoother transitions. Теперь приложение разворачивается на весь экран более плавно, когда мы открываем его через виджет.

Больше не нужно использовать RemoteViewsService при работе с коллекциями в виджетах. Для этих нужд добавили метод setRemoteAdapter, который принимает в качестве аргумента RemoteViews.RemoteCollectionItems.

Динамическое управление RemoteViews. Появились методы setColorStateList и setViewLayoutMargin, которые помогут изменять цвет и отступы виджета соответственно.

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

Еще несколько важных моментов

Задержки в процессе изменения размера — Resizable App Widgets. Начиная с Android 12 мы получили возможность четко указать свой layout для любого количества ячеек, а точнее, для любых размеров. Это значительно облегчает разработку, но проблемы старых версий остаются.

AppWidgetProvider — доработанный BroadcastReceiver, но со своими недостатками. Задержка между моментом изменения размера виджета на рабочем столе и вызовом метода onReceive прямо пропорционально зависит от времени выполнения метода onCreate класса Application.

Если в приложении в Application.onCreate() инициализируется Dagger-граф или выполняются другие тяжелые операции, то будет задержка интерактивности с виджетом. Это неочевидное обстоятельство, и решения, кроме как вынести тяжелые операции из onCreate, пока не нашлось. Такой же эффект при использовании экрана настройки виджета. Если Application.onCreate() выполняется достаточно долго, то мы получим фриз экрана настроек при добавлении виджета на рабочий стол.

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

Превью виджетов. Если не принимать во внимание нововведения в Android 12, то все виджеты поддерживают функцию превью в общем списке виджетов. До 12-й версии это только картинки PNG или WEBP, на более новых версиях — layout.xml. Для экономии размера приложения рекомендуется конвертировать картинки в формат WEBP.

Glance. Сейчас идет активная разработка, чтобы создавать виджеты с помощью Jetpack Compouse. Glance — альтернатива нынешнему подходу проектирования виджетов, но пока она в стадии alpha. Какие новинки можно ждать:

  • Добавится в коллекцию допустимых View компонент LazyColumn — возможно, в будущем делать виджеты-списки станет проще.

  • Будет GlanceStateDefinition, чтобы отвечать за состояние виджета. Мы сможем в любой момент получить актуальное состояние виджета, который отображается на экране. С помощью него можно будет избавиться от проблемы, когда виджеты меняют свой вид после перезагрузки устройства, а также проблему с изменением размера виджета.

  • SizeMode для борьбы с багами изменения размеров. Этот объект позволит разработчику контролировать политику вызова метода onUpdate() в ходе изменения пользователем размера виджета и при добавлении его на рабочий стол.

  • Переработана система взаимодействия с виджетом. Теперь у нас появляется Action. С точки зрения кода подход с Actions похож на обычный listener, и напрямую обращения к Intent и PendingIntent теперь происходить не будет. Можно будет более гибко подходить к обработке событий от пользователя в сложных виджетах, где необходимо обрабатывать не одно событие.

Glance — надежда на то, что разрабатывать и поддерживать виджеты станет намного проще.

Заключение

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

В Android 12 разработчики Google внесли изменения как в механизм, так и во внешний вид виджетов. Возможно, к такому шагу их подтолкнул успех коллег из Apple, презентовавших свои виджеты за год до выхода Android 12.

Реализовать красивые виджеты, как на презентации Google, можно только для идеальных условий: Google Pixel 7 и без кастомных лаунчеров. В случае адаптации под все девайсы нас ждет целый скоуп трудноразрешимых неочевидных сложностей
Реализовать красивые виджеты, как на презентации Google, можно только для идеальных условий: Google Pixel 7 и без кастомных лаунчеров. В случае адаптации под все девайсы нас ждет целый скоуп трудноразрешимых неочевидных сложностей

Если вы сталкивались с теми же проблемами, что и мы, или нашли более оптимальные пути их решения — буду рад обсудить в комментариях!

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


  1. Rusrst
    02.02.2023 11:40
    +1

    Спасибо!


  1. LAVElek
    02.02.2023 13:20
    +1

    В свое время много поработал с виджетами. Если придерживаться нескольких правил то будет все отлично:
    1. Размеры виджета всегда match_parent и делать гибкую верстку.
    2. Никогда не завязываться на точное кол-во ячеек, это просто стартовый размер. Большинство сторонних лаунчеров позволяют играть с размерами виджета как хочется(для теста верстки даже есть спец приложение).
    3. Нет точного размера ячейки в сетке и все расчеты гугла только для их образцового лаунчера.
    4. Ну и если прям хочется впихнуть что-то нестандартное, то можно рассмотреть вариант с преобразованием вьюшки в картинку.


    1. alexcas13 Автор
      02.02.2023 14:40

      По сути да, самым оптимальным является вариант с match_parent, если не надо прям точно соответствовать дизайну. Вариант с преобразованием в картинку - да, иногда действительно приемлем. Но тут тоже нашел много нюансов (то же переключение тем, локализация и т.д..)


    1. GMaksym
      02.02.2023 18:59
      +1

      Согласен со всем. Долго не мог понять зачем гугл приводит какие-то свои коэффициенты для расчётов размера, ведь на практике они не работают. Это только путало меня в начале. Но в итоге я пришёл к размеру в dp для разных размеров экрана. match_parent мне не подошёл.

      Ещё болью стало обновление виджета каждые пол часа. Гугл сейчас не любит когда само что-то происходит :)