Темы оформления. С блэк-джеком и WeakReference

в 7:56, , рубрики: android, Блог компании Mail.Ru Group, интерфейсы, Разработка под android, метки:

Как-то раз передо мной встала задача сделать в приложении на Android поддержку тем оформления. Что хотелось получить:

  1. Возможность переключить оформление — изменить некоторые цвета и графику
  2. Смена должна происходить “на лету”, для пользователя должно измениться только оформление, все остальное (содержание полей ввода, позиция элементов в списке и т.п.) меняться не должно
  3. В перспективе хотелось бы, чтобы тема могла изменяться и без участия пользователя, например по времени суток
  4. Не хотелось бы в значительной степени изменять уже существующий код или разметку. В идеале хотелось бы просто как-то пометить элементы в разметке
  5. Было бы здорово уметь подгружать новые темы без обновления приложения.

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

Самый очевидный способ, который наперебой предлагают нам Stack Overflow и документация Android — Context.setTheme. Одна загвоздка — установить тему нужно до создания всех наших View. Сразу понятно, что “на лету” переключить тему не получится, пользователь обязательно заметит полное пересоздание всего содержимого Activity. Да и с кодом каждого Activity так или иначе придется повозиться. Каких-то других рекомендаций в интернетах я так и не нашел (если у кого-то есть информация, буду благодарен за ссылку).

Что ж, напишем свою реализацию. С блэк-джеком и WeakReference.

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

В этом нам поможет свойство tag. Если где-то еще в приложении используются теги, например, в качестве Holder’ов для оптимизации адаптеров в ListView, не страшно. В коде можно использовать setTag/getTag с параметром id. Если таких мест много, поиск и замена нам помогут.

Теперь придумаем какой-то простенький формат для тегов. Во-первых, отделим зерна от плевел и сделаем простейшую проверку на то, что данный тег действительно является указанием к применению наших тем: наш тег всегда будет начинаться с символа “!”. Дальше последует имя ресурса, например “background”. Затем какой-нибудь разделитель, что-то вроде “|”, и тип ресурса — цвет текста, фоновая картинка или фоновый цвет и т.п. Например, фон для окна чата с использованием тайлинга: “!chat|tiled_bg”. Может быть, не слишком эстетично, зато быстро парсить. Чтобы делать минимум ошибок при написании таких тегов, лучше вынести их в строковые ресурсы и переиспользовать — в нашем приложении ресурс !primary|text_fg используется 77 раз.

Самое сложное позади, осталось только как-то эти теги обработать… Элементы с такими тегами надо обрабатывать сразу при “надувании” (inflate) View, а затем при каждой смене темы. “Надувание” происходит фактически двумя способами — setContentView в Activity и с помощью LayoutInflater. Начнем с setContentView.

В нашем приложении все Activity наследуются от небольшого числа базовых Activity. Достаточно переопределить метод setContentView:

    public void setContentView(int id) {
        super.setContentView(id);
        HotTheme.manage(mActivity.getWindow().getDecorView());
    }

Метод getDecorView вернет нам “корень” иерархии View.
Чтобы сделать то же самое при создании View с помощью LayoutInflater, обернем его:

public class HotLayoutInflater {

    private LayoutInflater inflater;

    private HotLayoutInflater(LayoutInflater inflater) {
        this.inflater = inflater;
    }

    public View inflate(int resource, ViewGroup root, boolean attachToRoot) {
        View v = inflater.inflate(resource, root, attachToRoot);
        HotTheme.manage(v);
        return v;
    }

    public View inflate(int resource, ViewGroup root) {
        View v = inflater.inflate(resource, root);
        HotTheme.manage(v);
        return v;
    }

    public static HotLayoutInflater wrap(LayoutInflater layoutInflater) {
        return new HotLayoutInflater(layoutInflater);
    }

    public static HotLayoutInflater from(Context context) {
        return new HotLayoutInflater(LayoutInflater.from(context));
    }

}

Теперь — разбор иерархии View:

HotTheme.java:

    public static void manage(View... views) {
        for (View v : views) {
            simpleManage(v);
            if (v instanceof ViewGroup) {
                ViewGroup vg = (ViewGroup) v;
                for (int i = 0; i < vg.getChildCount(); i++) {
                    manage(vg.getChildAt(i));
                }
            }
        }
    }

    public static void simpleManage(View view) {
        Object t = view.getTag();
        if (t instanceof String) {
            String tag = (String) t;
            if (tag.startsWith("!")) {
                tag = tag.substring(1);
                String[] elements = tag.split("\|");
                String base = elements[0];
                for (int i = elements.length - 1; i >= 1; i--) {
                    ThemedView tv = createThemedView(view, base, elements[i]);
                    tv.notifyChange();
                    HotTheme.sViews.add(tv);
                }
            }
        }
    }

Как видно, из иерархии выдергиваются те View, что содержат наш тег. На всякий случай считаем, что разделителей “|” может быть несколько — тогда ресурс будет применен к каждому типу (это может оказаться полезным).

Далее эти элементы оборачиваются в некий ThemedView, который и отвечает за всю магию. Метод notifyChange применит текущую тему к данному View. Ну и сохраним ThemedView на будущее для нотификации о смене тем — ничего сложного.
Сам класс ThemedView представляет собой простейшую обертку над View, предотвращающую утечку контекста:

private static abstract class ThemedView {

    private WeakReference<View> view;

    ThemedView(View v) {
        view = new WeakReference<View>(v);
    }

    boolean notifyChange() {
        View v = view.get();
        if (v == null) {
            return false;
        }
        onChange(v);
        return true;
    }

    abstract void onChange(View v);

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }

        ThemedView view1 = (ThemedView) o;

        View v1 = view.get();
        View v2 = view1.view.get();

        return (v1 != null ? v1.equals(v2) : v2 == null);

    }

    @Override
    public int hashCode() {
        if (view == null) {
            return 0;
        }
        View v = view.get();
        return v != null ? v.hashCode() : 0;
    }
}

Теперь при смене темы для ее применения ко всем заинтересованным View достаточно вызвать:

    for (Iterator<ThemedView> it = views.iterator(); it.hasNext(); ) {
        if (!it.next().notifyChange()) {
            it.remove();
        }
    }

Небольшое лирическое отступление

Я люблю Java. Да, Java немного медлительнее C. Да, она менее гибка, чем Python. Но она (да, для меня Java — “она”) умеет поразительные вещи:

  1. Делать за меня. Серьезно, я просто говорю ей, чего я хочу. Если я хочу пива — она сходит за бутылкой, откроет ее и любезно подождет, пока я наслаждаюсь чудесным напитком, после чего сама выбросит бутылку. Спасибо, GC!
  2. Думать за меня. Мне не нужно держать в голове типы данных, как в языках со слабой типизацией. Мне не нужно думать о том, выделилась ли память на стеке или в куче. Когда у тебя есть Java, вообще редко приходится думать — часто достаточно объяснить ей, чего ты хочешь.
  3. Писать код за меня. Javaassist, CGLib, java.lang.reflect.Proxy, JSR-269, аннотации, reflections… Метапрограммирование с помощью Java — это прекрасно!
  4. Приводить (cast) за меня. Причем безопасно! Ну почти. Как минимум, пока не накричишь на нее при помощи @SuppressWarning(“unchecked,rawtypes”). Спасибо, Generics!
  5. Java не гордая. Она умеет делать Unsafe, несмотря на то что это противно ее природе.

Да, у нее есть недостатки. Она любит поболтать — я не встречал языка многословнее Java (Pascal за язык, разумеется, не считаем). Но обычно IDE позволяет это побороть при помощи различных автоподстановок и шаблонов (templates).

Android использует Java. Да вот только ни одного из ее достоинств в нем не осталось. Он больше похож на пьяного небритого мужика, чем на прекрасную и покорную женщину. Я говорю ему — хочу пива, а он мне — заведи константу, создай Intent, сериализуй данные, открой Activity, получи результат… Если все хорошо, десериализуй его и приведи его к типу “Пиво”. И да, учти, что в любой момент твоя операция по получению пива может быть прервана. Даже если ты за него уже заплатил. Особенно радует, когда ты при этом находишься в контексте одного физического процесса.

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

Генерация кода на Android — то еще занятие. Про Javaassit/CGLib можно практически забыть (есть некоторые реализации чего-то схожего, но скорость их работы оставляет желать лучшего). С остальным (Proxy, JSR-269, аннотации и reflections) я периодически грешу, но приходится производить множество телодвижений, чтобы заставить работать с более или менее приемлемой скоростью.

Android гордый. Он умеет Unsafe. И это не противно его природе (с учетом NDK, RenderScript и т.п.). Да вот только доступен он исключительно через reflections, чем уничтожает большую часть преимуществ Unsafe.

Так вот, к чему это я. Благодаря покорности Java такой инструмент как WeakReference используется довольно редко, лишь в самых смелых эротических фантазиях (например, поддержка консистентности данных в различных ORM). С Android вместо романтики WeakReference приходится использовать для доминирования в стиле BDSM. Приходится мириться с тем, что объекты живут своей жизнью, подчиняясь неведомому life-cycle. Приходится “цепляться” за них с помощью WeakReference, чтобы не вызывать утечек контекста (Context). Возможно, стоило бы “прогнуться” под Android, и в каждом активити при выходе “разрегистрировать” иерархию View, да вот беда — она может измениться, и некоторых View там уже не будет (особенно характерно для ListView, чьи элементы могут постоянно появляться/исчезать с экрана). Во многом поэтому я применяю WeakReference практически всегда, когда какой-то из модулей приложения затрагивает визуальную часть — все View хранятся только на WeakReference, что, разумеется, в значительной степени усложняет логику работы.

Вернемся к нашему ThemedView, в наследниках которого в методе onChange мы определяем, что именно происходит с View:

    private static ThemedView createThemedView(View v, final String base, String element) {
        ThemeType type = types.get(element);

        switch (type) {
        case TILED_BG:
            return new ThemedView(v) {
                @Override
                public void onChange(View v) {
                    Bitmap bmp = decodeBitmap(base + "_bg");
                    BitmapDrawable bd = new BitmapDrawable(app().getResources(), bmp);
                    bd.setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT);
                    v.setBackgroundDrawable(bd);
                }
            };
        case VIEW_COLOR_BG:
            return new ThemedView(v) {
                @Override
                public void onChange(View v) {
                    int color = getColor(base + "_bg");
                    v.setBackgroundColor(color);
                    if (v instanceof ListView) {
                        // There is an android bug in setCacheColorHint
                        // That caused the IndexOutOfBoundsException
                        // look here:
                        // http://code.google.com/p/android/issues/detail?id=12840
                        //
                        // Moreover, that bug doesn't allow us to setDrawableCacheColor
                        // for recycled views. That's why we need to perform cleaning up
                        // via reflections
                        //
                        // Fixed in android 4.1.1_r1
                        try {
                            ((ListView) v).setCacheColorHint(color);
                        } catch (IndexOutOfBoundsException ex) {
                            try {
                                Field mRecycler = AbsListView.class.getDeclaredField("mRecycler");
                                mRecycler.setAccessible(true);
                                Object recycler = mRecycler.get(v);
                                Method m = recycler.getClass().getDeclaredMethod("clear");
                                m.setAccessible(true);
                                m.invoke(recycler);
                            } catch (Throwable t) {
                                // No need to report this

                            }
                        }
                    }
                }
            };
        case VIEW_IMAGE_BG:
            return new ThemedView(v) {
                @Override
                public void onChange(View v) {
                    v.setBackgroundDrawable(decodeDrawable(base + "_bg"));
                }
            };
        case IMAGE_FG:
            return new ThemedView(v) {
                @Override
                public void onChange(View v) {
                    ((ImageView) v).setImageDrawable(decodeDrawable(base + "_bg"));
                }
            };
        case TEXT_COLOR:
            return new ThemedView(v) {
                @Override
                public void onChange(View v) {
                    final int color = getColor(base + "_fg");
                    if (v instanceof TextView) {
                        ((TextView) v).setTextColor(color);
                    }
                }
            };
        case TEXT_HINT:
            return new ThemedView(v) {
                @Override
                public void onChange(View v) {
                    final int color = getColor(base + "_hint_fg");
                    if (v instanceof TextView) {
                        ((TextView) v).setHintTextColor(color);
                    }
                }
            };
        case PAGER:
            return new ThemedView(v) {
                @Override
                public void onChange(View v) {
                    int active = getColor(base + "_active_fg");
                    int inactive = getColor(base + "_inactive_fg");
                    int footer = getColor(base + "_footer_bg");

                    TitlePageIndicator pager = (TitlePageIndicator) v;
                    pager.setSelectedColor(active);
                    pager.setTextColor(inactive);
                    pager.setFooterColor(footer);
                }
            };
        case DIVIDER:
            return new ThemedView(v) {
                @Override
                public void onChange(View v) {
                    int color = getColor(base + "_divider");
                    ListView lv = (ListView) v;
                    int h = lv.getDividerHeight();
                    lv.setDivider(new ColorDrawable(color));
                    lv.setDividerHeight(h);
                }
            };
        case TABBUTTON_BG:
            return new ThemedView(v) {
                @Override
                void onChange(View v) {
                    StateListDrawable stateDrawable = new StateListDrawable();

                    Drawable selectedBd = decodeDrawable(base + "_selected");

                    stateDrawable.addState(new int[]{android.R.attr.state_selected}, selectedBd);
                    stateDrawable.addState(new int[]{android.R.attr.state_pressed}, selectedBd);
                    stateDrawable.addState(new int[]{}, decodeDrawable(base + "_unselected"));
                    v.setBackgroundDrawable(stateDrawable);
                }
            };
        case EDITTEXT_COLOR:
            return new ThemedView(v) {
                @Override
                void onChange(View v) {
                    int color = getColor(base + "_fg");
                    EditText edit = (EditText) v;
                    edit.setTextColor(color);
                    int hintColor = getColor(base + "_disabled_fg");
                    edit.setHintTextColor(hintColor);
                }
            };
        case GROUP_TINT:
            return new ThemedView(v) {
                @Override
                void onChange(View v) {
                    int tintColor = getColor(base + "_fg");
                    ImageView imageView = (ImageView) v;
                    imageView.setColorFilter(tintColor, PorterDuff.Mode.SRC_ATOP);
                }
            };
        default:
            throw new IllegalArgumentException("Error in layout: no such type "" + element + "" (" + base + ")");
        }
    }

Код types.get(element) просто возвращает enum по строке в нижнем регистре.

Из интересного остались методы decodeBitmap, decodeDrawable и getColor:

    private static ResourceInfo findResource(String base, ResourceType type) {
        return sCurrentProvider.findResource(base, type);
    }

    public static Drawable decodeDrawable(String base) {
        ResourceInfo info = findResource(base, ResourceType.Drawable);
        return info.getResources().getDrawable(info.getResId());
    }

    public static Bitmap decodeBitmap(String base) {
        ResourceInfo info = findResource(base, ResourceType.Drawable);
        return BitmapFactory.decodeResource(info.getResources(), info.getResId(), Util.newPurgeableBitmapOptions());
    }

    public static int getColor(String base) {
        ResourceInfo info = findResource(base, ResourceType.Color);
        return info.getResources().getColor(info.getResId());
    }

В качестве sCurrentProvider выступает объект класса ThemeProvider, единственной задачей которого является получение информации ресурса по его имени и типу.
Простейшая реализация будет добавлять в качестве префикса к имени ресурса некоторый ID темы:

    @Override
    public ResourceInfo findResource(String name, ResourceType type) {
        int id = IdUtils.getResId(app().getResources(),
                mPrefix + "_" + name, type.getType(), PACKAGE_NAME);
        if (id == 0 && mNext != null) {
            return mNext.findResource(name, type);
        }
        return new ResourceInfo(app().getResources(), id);
    }

Метод getResId является небольшой оберткой над методом Resources.getIdentifier.
Поле mNext также является объектом ThemeProvider. Он нужен для того, чтобы производить поиск по цепочке, если ресурс не был найден (в конечном итоге будет взят дефолтный).

В итоге для того, чтобы сделать очередную тему, нужно просто добавить набор необходимых ресурсов, добавив какой-нибудь префикс. Например, названия ресурсов для фона окна чата:
def_chat_bg
night_chat_bg
pink_chat_bg
wood_chat_bg

Итого

Как было сказано в начале, было бы здорово уметь подгружать ресурсы не только из самого приложения, но и извне. Источником может служить, например, другое приложение. В этом случае все будет работать так же, за исключением имени пакета и объекта Resources. Его, в свою очередь, можно получить через PackageManager.getResourcesForApplication.

Вспомним еще раз, чего мы хотели добиться:

  1. Возможность переключить оформление — готово
  2. Смена должна происходить “на лету” — готово
  3. В перспективе хотелось бы, чтобы тема могла изменяться и без участия пользователя — перспектива проглядывается, препятствий нет
  4. Не хотелось бы в значительной степени изменять уже существующий код или разметку — кода, на самом деле, поменялось довольно много, но, в основном, с помощью поиска и замены, так что здесь тоже можно поставить плюс
  5. Подгружать новые темы без обновления приложения — готово, ресурсы могут подгружаться из любых apk

Похоже, все получилось. Спасибо всем, кто осилил статью до конца. Надеюсь, кто-то воспользуется описанным приемом для того, чтобы придать своему приложению возможностью подстраиваться под настроение пользователей — поверьте, они скажут вам спасибо!

P.S.: ну и, как полагается, минутка рекламы — приложение Агента с темами здесь.

Автор: Artyomcool

Источник

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


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