Привет!
Недавно мне выпала интересная задача перекрасить приложение по JSON объекту, стянутому с сервера. Google диктует идею, что все цвета/темы прописаны в xml. Из-за чего легким движением руки не выйдет везде заменить какой-нибудь R.color.primary_button с синего на зеленый.
Если вам интересен небольшой пересказ недельного приключения по Resources, то добро пожаловать под кат.
Небольшая предыстория
Наше приложение имеет несколько вариаций, каждая из которых прописана с использованием productFlavors. Любое изменение какой-либо мелочи (например, цвета текста) требует вмешательства разработчика, поэтому был принят ряд мер по разделению приложения и его ресурсов. В рамках этой задачи так же обратили внимание, что любое изменение цветовой схемы влечёт за собой обновление приложения в PlayMarket/AppStore. Потому один из разработчиков выдвинул идею: «А давайте стягивать цветовую схему с сервера и перекрашивать приложение в runtime».
Итак, что представляет собой поле действий:
- 47 различных экранов;
- ~50 shapes и selectors;
- ~70 разных цветов (одни элементы могут иметь градиент и рамку, другие – специфичны для конкретного экрана).
По существующему опыту были выделены следующие решения:
- В каждой Activity написать код, который будет перекрашивать UI (решение в лоб, всем Views назначаются id и в каждой Activity программно задаются цвета).
- Наследование от всех используемых UI элементов (развитие первого решения, исключающее внесение изменений в Activity, за место этого переписываются xml).
- Обертка над 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 – пакетный и скрыт для простых смертных.
Эксперимент номер два
По причине неспособности сделать всё централизованно, задача была разбита на две части:
- Замена ресурсов в View.
- Замена ресурсов в 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 ресурса, а по цвету, на который ссылается ресурс.
Результат
В итоге в приложении есть:
- Свой собственный LayoutInflater, который вносит изменения в View.
- Великий Singletone с набором методов вида getDrawable(int resId, Resources baseResource), который занимается хранением цветовой схемы, Drawables и ColorStateLists.
- Базовая активность, содержащая перекраску статус бара и оборачивание контекста.
Задача решилась с незначительным изменением существующего кода (например, где программно меняется цвет текста в зависимости от результата вычислений). И на дальнейшую разработку повлиять особо не должна.
Плата за это: как минимум увеличенная нагрузка при создании View, в случае Shapes и Selectors – двойная. А так же возможные проблемы при переходе на следующую версию API (сейчас мы используем 24) и device specific баги.
Я верю, что среди вас есть те, кто сталкивался с подобными проблемами. И было бы интересно увидеть ваши мысли на тему runtime перекраски в комментариях.
Спасибо за внимание!
Автор: DeFract