Themes of design. With blackjack and WeakReference

    Once, I had the task of making themes for the Android application. What I wanted to get:

    1. Ability to switch appearance - change some colors and graphics
    2. The change should take place on the fly, for the user only the design should change, everything else (the contents of the input fields, the position of the elements in the list, etc.) should not change
    3. In the future, I would like the topic to be able to change without the participation of the user, for example, by time
    4. I would not like to significantly modify existing code or markup. Ideally, I would just like to somehow mark the elements in the markup
    5. It would be great to be able to upload new topics without updating the application.


    About what was achieved and how it was implemented - under the cut.



    The most obvious way that Stack Overflow and the Android documentation are vying to offer us is Context.setTheme. One catch - you need to install the theme before creating all of our View. It’s immediately clear that you won’t be able to switch the topic on the fly, the user will definitely notice a complete re-creation of the entire contents of the Activity. Yes, and with the code of each Activity one way or another will have to tinker. I did not find any other recommendations on the Internet (if anyone has information, I will be grateful for the link).

    Well, let's write our implementation. With blackjack and WeakReference.

    We will proceed from point 4. I consider myself to be developers who prefer not to write code. I do not like to write code so much that I am ready to write a lot of code, just to not write it in the future. I can not help myself: I do not want to think over the logic of interaction when the next window appears in such a way as to take into account the susceptibility to dynamic changes in design. I just want to indicate in the markup next to the element that, for example, its color will be equal to the background color.

    The tag property will help us with this. If somewhere else in the application tags are used, for example, as Holder'ov for optimizing adapters in ListView, it’s okay. In the code, you can use setTag / getTag with the id parameter. If there are many such places, the search and replacement will help us.

    Now we’ll come up with some simple format for tags. First, we separate the grains from the chaff and make a simple check that this tag is really an indication of the use of our themes: our tag will always start with a “!” Symbol. This is followed by the name of the resource, for example “background”. Then some separator, something like “|”, and the type of resource - text color, background image or background color, etc. For example, the background for the chat window using tiling: “! Chat | tiled_bg”. Maybe not too aesthetically pleasing, but quickly parsing. To make a minimum of mistakes when writing such tags, it is better to put them in string resources and reuse them - in our application the resource! Primary | text_fg is used 77 times.

    The hardest part is behind, it remains only to somehow process these tags ... Elements with such tags must be processed immediately during the “inflate” View, and then each time the theme is changed. “Inflating” occurs in virtually two ways - setContentView in Activity and using LayoutInflater. Let's start with setContentView.

    In our application, all Activities are inherited from a small number of base Activities. Just override the setContentView method:
        public void setContentView(int id) {
            super.setContentView(id);
            HotTheme.manage(mActivity.getWindow().getDecorView());
        }
    

    The getDecorView method will return us the “root” of the View hierarchy.
    To do the same when creating a View using LayoutInflater, wrap it:
    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));
        }
    }
    

    Now - parsing the View:

    HotTheme.java hierarchy:
        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);
                    }
                }
            }
        }
    

    As you can see, those View that contain our tag are pulled out of the hierarchy. Just in case, we consider the delimiters “|” maybe several - then the resource will be applied to each type (this may turn out to be useful).

    Further, these elements turn into a certain ThemedView, which is responsible for all the magic. The notifyChange method will apply the current theme to this View. Well, save ThemedView for the future for notification of a change of topics - nothing complicated.
    ThemedView class itself is a simple wrapper over View that prevents context leakage:
    private static abstract class ThemedView {
        private WeakReference view;
        ThemedView(View v) {
            view = new WeakReference(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;
        }
    }
    

    Now, when changing the theme, to apply it to all interested View, just call:
        for (Iterator it = views.iterator(); it.hasNext(); ) {
            if (!it.next().notifyChange()) {
                it.remove();
            }
        }
    

    A slight digression
    I love Java. Yes, Java is a little slower than C. Yes, it is less flexible than Python. But she (yes, for me Java is “she”) can do amazing things:
    1. Do for me. Seriously, I'm just telling her what I want. If I want a beer, she goes to get a bottle, opens it and kindly waits until I enjoy a wonderful drink, after which she throws the bottle away. Thank gc
    2. To think for me. I don’t need to keep data types in mind, as in weakly typed languages. I do not need to think about whether memory was allocated on the stack or on the heap. When you have Java, you rarely have to think at all - often it’s enough to explain to her what you want.
    3. Write the code for me. Javaassist, CGLib, java.lang.reflect.Proxy, JSR-269, annotations, reflections ... Metaprogramming with Java is great!
    4. Cast for me. And it’s safe! Almost. At least until you scream at her with @SuppressWarning (“unchecked, rawtypes”). Thank Generics
    5. Java is not proud. She knows how to make Unsafe, despite the fact that it is contrary to her nature.

    Yes, she has flaws. She loves to chat - I have not seen a language more verbose than Java (Pascal for the language, of course, we do not consider). But usually the IDE allows you to overcome this with the help of various auto-substitutions and templates.

    Android uses Java. Yes, that's just not one of its virtues was left in it. He looks more like a drunk unshaven man than a beautiful and submissive woman. I tell him - I want beer, and he told me - get a constant, create an Intent, serialize the data, open the Activity, get the result ... If all goes well, deserialize it and convert it to the “Beer” type. And yes, keep in mind that at any time your operation to get a beer may be interrupted. Even if you already paid for it. It is especially pleasing when you are in the context of one physical process.



    I constantly have to keep in mind what type of Message.obj to lead to depends on Message.what. And do a huge switch. Very comfortably.

    Android code generation is something else to do. You can almost forget about Javaassit / CGLib (there are some implementations of something similar, but their speed leaves much to be desired). With the rest (Proxy, JSR-269, annotations and reflections) I periodically sin, but I have to make a lot of gestures to make it work at a more or less acceptable speed.

    Android is proud. He knows Unsafe. And this is not contrary to its nature (taking into account NDK, RenderScript, etc.). Yes, it’s only available exclusively through reflections, which destroys most of the benefits of Unsafe.

    So, what am I doing. Thanks to the submissiveness of Java, a tool like WeakReference is used quite rarely, only in the most daring erotic fantasies (for example, maintaining data consistency in various ORMs). With Android, instead of romance, WeakReference has to be used to dominate the BDSM style. We have to put up with the fact that objects live their own lives, subject to an unknown life-cycle. We have to “cling” to them using WeakReference, so as not to cause context leaks (Context). Perhaps it would be worthwhile to “bend” under Android, and in each activity, when “exiting”, unregister the View hierarchy, but the trouble is that it can change and there will not be some View there (especially typical for ListView, whose elements can constantly appear / disappear from the screen). This is largely why I use WeakReference almost always,

    Let's get back to our ThemedView, in the descendants of which in the onChange method we determine what exactly happens with the 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 + ")");
            }
        }
    

    The types.get (element) code simply returns enum in a lowercase string.

    Of the interesting methods decodeBitmap, decodeDrawable and getColor remained:

        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());
        }
    

    An object of the ThemeProvider class acts as sCurrentProvider, the only task of which is to obtain resource information by its name and type.
    The simplest implementation will add a topic ID as a prefix to the resource name:
        @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);
        }
    

    The getResId method is a small wrapper over the Resources.getIdentifier method.
    The mNext field is also a ThemeProvider object. It is needed in order to search the chain if the resource was not found (in the end, the default will be taken).

    As a result, in order to make the next topic, you just need to add the set of necessary resources, adding some kind of prefix. For example, resource names for the chat window background:
    def_chat_bg
    night_chat_bg
    pink_chat_bg
    wood_chat_bg

    Total

    As mentioned at the beginning, it would be great to be able to load resources not only from the application itself, but also from the outside. A source can be, for example, another application. In this case, everything will work the same, except for the package name and the Resources object. It, in turn, can be obtained through PackageManager.getResourcesForApplication.

    Recall again what we wanted to achieve:
    1. The ability to switch the design - done
    2. The change should take place "on the fly" - you're done
    3. In the future, I would like the topic to change without the participation of the user - the perspective is visible, there are no obstacles
    4. I would not like to significantly change the existing code or markup - the code, in fact, has changed quite a lot, but mainly by searching and replacing, so here you can put a plus
    5. Upload new topics without updating the application - ready, resources can be loaded from any apk


    Everything seems to have worked out. Thanks to everyone who mastered the article to the end. I hope someone will use the described technique in order to give their application the ability to adapt to the mood of users - believe me, they will thank you!

    PS: well, and, as expected, a minute of advertising - Agent's application with the themes here .

    Also popular now: