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

Привет, Хабр!

Недавно мне выпала интересная задача перекрасить приложение по 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 перекраски в комментариях.

Спасибо за внимание!
Поделиться публикацией
Похожие публикации
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 15
  • 0
    70 разных цветов — это точно хороший дизайн?
    • +1
      Статья не об этом ведь
      • 0
        Под «70 цветов» подразумевается количество констант в colors.xml. Самих цветов обычно 15-20. Например, для текстовых полей используется 7 констант:
        • 2 для фона (градиент);
        • 3 для рамки (есть фокус, нет фокуса, введено неверное значение);
        • 2 для текста (обычный и подсказки).

        Но цвет текста обычно соответствует цвету заголовков, цвет рамки в фокусе – основному цвету темы, фон – белый без градиента.

        Это сделано для гибкой настройки. Так как один из заказчиков может захотеть выделить кнопки в диалоге отличным от основной темы цветом. Другой – увидеть градиент в Action Bar, но только там, чтобы остальные элементы были без.
        • 0
          Спасибо за пояснение. Я бы, наверное, в таком случае генерировал этот XML c 70-ю цветами из базовых 5-10 цветов, чтобы и поменять нюансы можно было, и поменять всю палитру на другой базовый цвет было попроще.
          • 0
            Так и делалось. Есть скрипт, который парсит данные, предоставленные заказчиком, и создает нужные xml (там не только цвета).
            Но за последнее время были случаи, когда заказчик хотел изменить, например, цвет заголовков в списках или логотип в ActoinBar. Ради этих изменений приходится перевыпускать приложение. Для Android это не особо критично, а вот с iOS беда.
            Отсюда всё и пошло. Хотя пока этот функционал ещё не внедрен и мне не особо хочется нагружать им приложение.
      • 0
        Насколько мне известно, нормальная практика смены темы — вынос всех ресурсов в стили для последующего переключения между ними
        • 0
          Обычно да.
          Если в рамках одного приложения, то через стили.
          Если твое приложение имеет несколько вариаций (код один, но приложения разные, собраны для разных заказчиков, различаются содержанием и цветовой схемой), то можно использовать productFlavors.

          Но в данном случае суть в том, что заказчик может изменить цветовую схему на сервере (вдруг решил, что черный шрифт — плохо, хочет темно-синий) и она поменяется у тебя в приложении без перевыпуска его в Market
          • 0
            Решал похожую задачу просто перекрашиваем вью активити на уровне java кода. Shape drawable можно перекрашивать после создания. Был интерфейс IAppConfiguration, поставляющий все цвета, иконки и размеры. А все экраны перекрашивали свои вью используя его. Получалось достаточно гибки. Мне кажется, что это меньший велосипед, чем пытаться переопределить цвета в resources, ведь ресурсы — для загрузки ресурсов с учетом текущей конфигурации.

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

            Если писать на dls вроде anko, так вообще будет выгладить однородно.
            • 0
              По сути, сейчас сделано похожим образом. Просто перекраска происходит внутри LayoutInflater, в нем есть доступ к создаваемым View и их аттрибутами из xml. Как следствие, внутри самих Activity надо дописать только обертку надо контекстом.

              С shape drawable были проблемы в плане получения id цветов для них, так как сам shape их не хранит. Была идея, что каждый id будет ссылаться на уникальный цвет, а потом программно изменять его на нужный. Но в итоге для перекраски shape тянется его xml и id цветов берётся оттуда.

              От велосипеда в Resources я отказался поностью.

              Касаемо второго вашего способа. Спасибо. Я отстал от жизни и databinding не использовал вообще. Метод гибкий и его можно поставить в альтернативу LayoutInflater. На этой неделе посмотрю.
            • 0
              Так-же можете попробовать использовать databinding.
              В том-же xml вы передаете объект со всеми стилями, необходимыми для текущего экрана. На уровне java для каждого экрана вы пишите маппинг из конфига в локальные стили. И в итоге при изменениях конфига будет пересоздаваться объект локальных стилей и сетиться с помощью databinding во вью. Почти react-redux.

              В xml можете дефолтные значения задавать, которые будут ссылаться уже на статичные ресурсы. Кода чуть больше, но это еще более гибко и более android-way (учитывая движение в сторону mvvm в android architecture components)
              • 0
                Делаю у себя в проекте именно так как вы описали (включая дефолтные значения), работает прекрасно. Используются дополнительные адаптеры, но в сумме выглядит, на мой взгляд, аккуратно и без лишней неявности. В xml задается примерно так (Color — enum c доступными для изменения цветами, colorScheme — map со значениями цветов):
                Пример
                <!--suppress AndroidUnknownAttribute -->
                <data class="SomeFragmentBinding">
                    <import type="package.Color"/>
                    <variable name="colorScheme" type="package.ColorScheme"/>
                </data>
                
                <FrameLayout
                    ...
                    android:background="@{colorScheme[Color.CONTENT_BACKGROUND]}">
                
                    <GridView
                        ...
                        app:colorScheme="@{colorScheme}"/>
                
                    <TextView
                        ...
                        android:textColor="@{colorScheme[Color.DEFAULT_TEXT]}"/>
                
                </FrameLayout>
                

                • 0
                  Собирался задействовать такой способ, но у него оказался один минус. Databinding работает только в рамках layout, в стилях его не применить. Например, в приложении более 100 textview, размер, тип и цвет стандартизирован и вынесен в стили. Чтобы задействовать databinding, надо выносить цвет из стилей.

                  Оставил эту идею, как резервную, на случай, если текущая реализация подведет.
                  • 0
                    Это может звучать как перебор, но можно использовать include и передавать изменяемые параметры через те же databinding. Хотя стили для ViewGroup так не задать, конечно.
                    • 0
                      Я рассматривал аналог стилей, реализованный на databinding. Т.е. делаем свой адаптер и внутри прописываем нужные параметры. Но решил, что пока это перебор.
                      • 0
                        Да, я об этом думал, но пока идей, как сделать это аккуратно, не было.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.