Runtime перекраска приложения

в 14:23, , рубрики: android development, Разработка под android

Привет!

Недавно мне выпала интересная задача перекрасить приложение по JSON объекту, стянутому с сервера. Google диктует идею, что все цвета/темы прописаны в xml. Из-за чего легким движением руки не выйдет везде заменить какой-нибудь R.color.primary_button с синего на зеленый.

Если вам интересен небольшой пересказ недельного приключения по Resources, то добро пожаловать под кат.

Небольшая предыстория

Наше приложение имеет несколько вариаций, каждая из которых прописана с использованием productFlavors. Любое изменение какой-либо мелочи (например, цвета текста) требует вмешательства разработчика, поэтому был принят ряд мер по разделению приложения и его ресурсов. В рамках этой задачи так же обратили внимание, что любое изменение цветовой схемы влечёт за собой обновление приложения в PlayMarket/AppStore. Потому один из разработчиков выдвинул идею: «А давайте стягивать цветовую схему с сервера и перекрашивать приложение в runtime».

Итак, что представляет собой поле действий:

  • 47 различных экранов;
  • ~50 shapes и selectors;
  • ~70 разных цветов (одни элементы могут иметь градиент и рамку, другие – специфичны для конкретного экрана).

По существующему опыту были выделены следующие решения:

  1. В каждой Activity написать код, который будет перекрашивать UI (решение в лоб, всем Views назначаются id и в каждой Activity программно задаются цвета).
  2. Наследование от всех используемых UI элементов (развитие первого решения, исключающее внесение изменений в Activity, за место этого переписываются xml).
  3. Обертка над Resources или над чем-нибудь еще, что позволило бы реализовать требуемую задачу во время создания View или Shape.

Далее пойду изыскания по третьему решению.

Эксперимент номер один. Попытка завернуть Resources

В Android есть монополист на все ресурсы – это Resources. Любое создание View или Shape получает экземпляр этого класса из переданного в конструкторе контекста. И единственный способ вмешаться в работу конструктора – подменить Context.

Google не имеет ничего против такого и даёт нам доступ к ContextWrapper, который представляет собой полноценную обертку. Подмена контекста происходит с помощью перегрузки attachBaseContext. Тут ничего сложного.

Теперь о класс Resources

При изучении этого класса обнаружилось, что многие методы, которые хотелось бы перегрузить – пакетные. Никто не мешает перегрузить, например, getColor, но он не используется ни при построении View, ни в TypedArray (нужен для извлечения набора значений ресурсов соответствующего переданному набору атрибутов). А то, что используется – скрыто. Таким образом, провалилась первая, наивная, идея.

Но при этом было отмечено обильное использование TypedValue и TypedArray. В целом, Resources и работа с ним построены на активной работе через эти два класса.

С первым нет никаких проблем, в Resources существует метод getValue. Перегрузив этот метод, сразу получаешь правильно работающий getColor (в случае цвета) и getDrawable (в случае ColoredDraawble).

А с TypedArray всё куда хуже. Этот класс не обернуть, потому что его конструкторы private. Его поля закрыты и он не обладает методами для их изменения. Вмешаться в его заполнение тоже не получится, потому что это происходит через final класс AssetManager. Единственное, что у меня вышло с ним сделать, это получить доступ к нужному полю через рефлексию.

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

Уже во время второго эксперимента встретил еще одну проблему с оберткой Resources. Оказалось, что в Android уже существует android.support.v7.widget.ResourcesWrapper. Его реализации могут для какого-нибудь компонента обернуть твой класс и выдать совсем другой результат. Кстати, ResourcesWrapper – пакетный и скрыт для простых смертных.

Эксперимент номер два

По причине неспособности сделать всё централизованно, задача была разбита на две части:

  1. Замена ресурсов в View.
  2. Замена ресурсов в Shape и Selector.

O View. Подмена LayoutInflater

Наверное, многие знакомы с github.com/chrisjenx/Calligraphy. Для второго эксперимента была выбрана идея, используемая в этой библиотеке, а именно подмена LayoutInflater. Подмена LayoutInflater происходит так же через ContextWrapper. Внутри LayoutInflater переопределяются фабрики, обрабатывающие View (одна из них, к сожалению, через рефлексию). А внутри фабрики реализован код, который в зависимости от View и атрибутов занимается подменой нужных ресурсов.

О Shape

Тут сложнее. Фабрики для них нет. Cоздание происходит внутри Resources через статический метод createFromXml, который парсит переданный xml файл, а далее используется TypedArray. Аналогично происходит и с ColorStateList.

Вмешаться в работу создания не выйдет (за исключением способа, описанного в первом эксперименте). А созданный объект не хранит в себе Id ресурса, из-за чего перекрасить его после создания так же не получится. Но можно пойти в обход. В Resources существует метод getXml. Он позволяет получить любой xml и распарсить его самостоятельно. Таким образом, имея Id и Resources можно получить любой Drawable и внести в него требуемые изменения.

ColorStateList (В отличии от любой реализации Drawble) не дает изменять свой контент. Тут либо использовать рефлексию, либо создавать новый экземпляр и реализовывать кеширование на своей стороне.

Еще немного о кэше ресурсов

Первоначально была надежда использовать кэш Resources просто изменив в нем нужные Drawable и ColorStateList. Но от этого пришлось отказаться по двум причинам.

Первая описана выше и затрагивает ColorStateList. Без рефлексии свойства его экземпляров изменить нельзя, а значит закешированные в Resources экземпляры использовать не выйдет.

Вторая связана с кэшированием ColorDrawable и единичных ColorStateList (это когда запрашивается ColorStateList для цвета, а не selector). Их кэширование оптимизировано и происходит не по id ресурса, а по цвету, на который ссылается ресурс.

Результат

В итоге в приложении есть:

  1. Свой собственный LayoutInflater, который вносит изменения в View.
  2. Великий Singletone с набором методов вида getDrawable(int resId, Resources baseResource), который занимается хранением цветовой схемы, Drawables и ColorStateLists.
  3. Базовая активность, содержащая перекраску статус бара и оборачивание контекста.

Задача решилась с незначительным изменением существующего кода (например, где программно меняется цвет текста в зависимости от результата вычислений). И на дальнейшую разработку повлиять особо не должна.

Плата за это: как минимум увеличенная нагрузка при создании View, в случае Shapes и Selectors – двойная. А так же возможные проблемы при переходе на следующую версию API (сейчас мы используем 24) и device specific баги.

Я верю, что среди вас есть те, кто сталкивался с подобными проблемами. И было бы интересно увидеть ваши мысли на тему runtime перекраски в комментариях.

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

Автор: DeFract

Источник

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


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