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

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

Спасибо за внимание!
Поделиться с друзьями
-->

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


  1. VioletGiraffe
    24.07.2017 17:28

    70 разных цветов — это точно хороший дизайн?


    1. FluffyMan
      24.07.2017 18:31
      +1

      Статья не об этом ведь


    1. DeFract
      25.07.2017 12:23

      Под «70 цветов» подразумевается количество констант в colors.xml. Самих цветов обычно 15-20. Например, для текстовых полей используется 7 констант:

      • 2 для фона (градиент);
      • 3 для рамки (есть фокус, нет фокуса, введено неверное значение);
      • 2 для текста (обычный и подсказки).

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

      Это сделано для гибкой настройки. Так как один из заказчиков может захотеть выделить кнопки в диалоге отличным от основной темы цветом. Другой – увидеть градиент в Action Bar, но только там, чтобы остальные элементы были без.


      1. VioletGiraffe
        25.07.2017 12:26

        Спасибо за пояснение. Я бы, наверное, в таком случае генерировал этот XML c 70-ю цветами из базовых 5-10 цветов, чтобы и поменять нюансы можно было, и поменять всю палитру на другой базовый цвет было попроще.


        1. DeFract
          25.07.2017 12:46

          Так и делалось. Есть скрипт, который парсит данные, предоставленные заказчиком, и создает нужные xml (там не только цвета).
          Но за последнее время были случаи, когда заказчик хотел изменить, например, цвет заголовков в списках или логотип в ActoinBar. Ради этих изменений приходится перевыпускать приложение. Для Android это не особо критично, а вот с iOS беда.
          Отсюда всё и пошло. Хотя пока этот функционал ещё не внедрен и мне не особо хочется нагружать им приложение.


  1. FlashLight13
    25.07.2017 12:53

    Насколько мне известно, нормальная практика смены темы — вынос всех ресурсов в стили для последующего переключения между ними


    1. DeFract
      25.07.2017 12:57

      Обычно да.
      Если в рамках одного приложения, то через стили.
      Если твое приложение имеет несколько вариаций (код один, но приложения разные, собраны для разных заказчиков, различаются содержанием и цветовой схемой), то можно использовать productFlavors.

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


      1. punksta
        30.07.2017 20:14

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

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

        Если писать на dls вроде anko, так вообще будет выгладить однородно.


        1. DeFract
          31.07.2017 14:22

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

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

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

          Касаемо второго вашего способа. Спасибо. Я отстал от жизни и databinding не использовал вообще. Метод гибкий и его можно поставить в альтернативу LayoutInflater. На этой неделе посмотрю.


      1. punksta
        30.07.2017 20:24

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

        В xml можете дефолтные значения задавать, которые будут ссылаться уже на статичные ресурсы. Кода чуть больше, но это еще более гибко и более android-way (учитывая движение в сторону mvvm в android architecture components)