MagicaSakura解析

MagicaSakura

bilibili的又一Android开源作品, 可以无闪屏地对程序中的控件更换主题色, 其采用的遍历View树的方式对每一个控件进行操作(区别于保存集合). 在控件变色上使用的是对Drawable进行tint(区别于只对Drawable或者ImageView设置ColorFilter), 其中使用到了V4包的DrawableCompat, 还对特别的View进行了特殊处理. 使用TintDrawable的方式不会影响原来的属性和使用方式. 要说明的是这种方式要对所有要变色的View进行自定义, 以后项目中就不能够好好写换件了...更多的介绍可以看原作者的介绍.

MagicaSakura使用

原作者有在博客中说明使用方法: 实现切换颜色的SwitchColor, 重写其两个方法. 再有要自己确定各个主题色, 然后切换主题色时使用的方法是ThemeUtils的一个全局方法refreshUI, 它最终会使用到SwitchColor来得到色值.

MagicaSakura分析

下面先分析换扶主要流程, 再去分析每一个View进行换肤的流程, 最后再说一些特殊的View进行换肤的细节

流程分析

首先要去自己实现SwitchColor, 并通过ThemeUtils将其注册成为全局变量, 在以后的换肤中方便使用.

//将切换颜色的对象作为全局变量存储起来
ThemeUtils.setSwitchColor(this);

其中的this实现了SwitchColor接口, 负责给出皮肤的颜色, 通过两个接口方法给出.

public interface switchColor {
    //通过指定ID来更换颜色
    @ColorInt int replaceColorById(Context context, @ColorRes int colorId);

    @ColorInt int replaceColor(Context context, @ColorInt int color);
}

下面分析在我们点换肤的时候程序运程的流程
上面说过每一次换肤都要对View树进行遍历, 封闭遍历的方法在ThemeUtils.refreshUI(Context context, ExtraRefreshable extraRefreshable)中.

//在这里对整个view树进行遍历
public static void refreshUI(Context context, ExtraRefreshable extraRefreshable) {
    TintManager.clearTintCache();
    Activity activity = getWrapperActivity(context);
    if (activity != null) {
        if (extraRefreshable != null) {
            extraRefreshable.refreshGlobal(activity);
        }
        //对contentView进行遍历
        View rootView = activity.getWindow().getDecorView().findViewById(android.R.id.content);
        refreshView(rootView, extraRefreshable);
    }
}

两个参数, ctx不用说, 第二个ExtraRefreshable接口有两个方法, void refreshGlobal(Activity activity);是每次换肤是调用一次的方法, void refreshSpecificView(View view)是对特殊的View进行染色时都要调用的方法.
我们可以看到他是通过对Activity去拿到contentView去进行遍历的. refreshView(rootView, extraRefreshable);是对View树进行递归遍历的方法.

private static void refreshView(View view, ExtraRefreshable extraRefreshable) {
    if (view == null) return;
    //下面进行递归遍历
    view.destroyDrawingCache();
    if (view instanceof Tintable) {
        //最关键的部分, 拿到每个view后tint一下
        ((Tintable) view).tint();
        if (view instanceof ViewGroup) {
            for (int i = 0; i < ((ViewGroup) view).getChildCount(); i++) {
                refreshView(((ViewGroup) view).getChildAt(i), extraRefreshable);
            }
        }
    } else {
        if (extraRefreshable != null) {
            extraRefreshable.refreshSpecificView(view);
        }
        //ListView和GridView之类
        if (view instanceof AbsListView) {
            ListAdapter adapter = ((AbsListView) view).getAdapter();
            //拿到根本的Adapter
            while (adapter instanceof WrapperListAdapter) {
                adapter = ((WrapperListAdapter) adapter).getWrappedAdapter();
            }
            if (adapter instanceof BaseAdapter) {
                ((BaseAdapter) adapter).notifyDataSetChanged();
            }
        }
        if (view instanceof RecyclerView) {
            try {
                if (mRecycler == null) {
                    mRecycler = RecyclerView.class.getDeclaredField("mRecycler");
                    mRecycler.setAccessible(true);
                }
                if (mClearMethod == null) {
                    mClearMethod = Class.forName("android.support.v7.widget.RecyclerView$Recycler")
                            .getDeclaredMethod("clear");
                    mClearMethod.setAccessible(true);
                }
                mClearMethod.invoke(mRecycler.get(view));
            } catch (NoSuchMethodException e) {
                ...
            }
            ((RecyclerView) view).getRecycledViewPool().clear();
            ((RecyclerView) view).invalidateItemDecorations();
        }
        //不是tintabale, 遍历孩子
        if (view instanceof ViewGroup) {
            for (int i = 0; i < ((ViewGroup) view).getChildCount(); i++) {
                refreshView(((ViewGroup) view).getChildAt(i), extraRefreshable);
            }
        }
    }
}

整个过程就是递归遍历, Tintable的实现就直接渲染, 是ViewGroup就递归, 是ListView(GridView)或者RecylerView就notify一下, 就这么完了, 这就是一个简单的流程了.
其中的tint方法就是对每个具体的View进行渲染, 达到自定义颜色的效果. 具体tint的过程下面会讲一下, 几乎对所有常用的View进行了重写, 工作量很大, 但一个控件的流程走通了, 其他的控件原理都是类似的, 也很快就了解了. 此时配上MagicaSakura的包结果图, 可以看到widgets包下的所有常用View都被重写了.

MagicaSakura的包结构

View进行渲染过程

对View的渲染都是通过在View中保存的几个Helper实现的, 每个要换肤的View在构造的时候会根据跟随皮肤变化的属性构建对应的Helper, 比如说TextView在换肤的时候要变换自己的TextColor, BackGround以及drawableLeft, drawableRight之类的属性所以在TintTextView中会保存对应的三个Helper, 如图:

TintTextView中的Helper

这么做不仅能将换肤功能的代码解耦出来, 最重要的是可以在不同的控件上复用这个Helper, 比如TintImageView也要在换肤时对Background进行变换, 直接重用AppCompatBackgroundHelper就可以了.
下面以TextView为例, 分析一下作者是怎样让一个View能显示任意一种颜色, 并且还能动态地切换View的色值.
先看其构造方法:

public TintTextView(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    if (isInEditMode()) {
        return;
    }
    //TintManager负责管理Drawable资源, 后面会讲到
    TintManager tintManager = TintManager.get(getContext());
    //控制TextColor之类的属性
    mTextHelper = new AppCompatTextHelper(this, tintManager);
    mTextHelper.loadFromAttribute(attrs, defStyleAttr);
    //控制Background属性
    mBackgroundHelper = new AppCompatBackgroundHelper(this, tintManager);
    mBackgroundHelper.loadFromAttribute(attrs, defStyleAttr);
    //控制DrawableLeft, DrawableRight之类的属性
    mCompoundDrawableHelper = new AppCompatCompoundDrawableHelper(this, tintManager);
    mCompoundDrawableHelper.loadFromAttribute(attrs, defStyleAttr);
}

TintTextView使用了三个Helper,都作为成员保存起来, 构造出来之后直接调用其void loadFromAttribute(AttributeSet attrs, int defStyleAttr)方法, 其它Helper也是类似. 这三个Helper就在View的tint方法中类似于下面方式使用. 另外, 涉及这些属性变化的方法都要进行重写, 都要使用这些Helper进行变化属性值.

if (mTextHelper != null) {
    mTextHelper.tint();
}

下面以AppCompatTextHelper为例分析他们的工作原理.
其构造方法只是将当前的viewtintManager保存为成员. 其loadFromAttribute方法要对View的几个属性进行处理, 代码如下:

void loadFromAttribute(AttributeSet attrs, int defStyleAttr) {
    TypedArray array = mView.getContext().obtainStyledAttributes(attrs, ATTRS, defStyleAttr, 0);

    int textColorId = array.getResourceId(0, 0);
    if (textColorId == 0) {//如果没有设定TextColor就使用TextAppearance
        setTextAppearanceForTextColor(array.getResourceId(2, 0), false);
    } else {
        setTextColor(textColorId);//此方法去会去找到真正的颜色并且设置给这个View
    }

    if (array.hasValue(1)) {
        setLinkTextColor(array.getResourceId(1, 0));
    }
    array.recycle();
}

插一句, 当时看这一点的时候犯了个迷糊...这里使用了0, 2什么的是是因为上面对ATTRS的定义:

private static final int[] ATTRS = {
        android.R.attr.textColor,
        android.R.attr.textColorLink,
        android.R.attr.textAppearance,
};
//这里就要处理三个属性, 所在先组成一个数组
//拿到的TypeArray里面就应该只有三个值, 这也是后面使用0, 1, 2的原因

回到正题上来, 看setTextColor方法

private void setTextColor(@ColorRes int resId) {
    if (mTextColorId != resId) {
        //记录色值, 清除染色信息, 放心, 在下面一句又将这个信息给加上了
        resetTextColorTintResource(resId);
        if (resId != 0) {
            setSupportTextColorTint(resId);
        }
    }
}
//设置染色信息
private void setSupportTextColorTint(int resId) {
    if (resId != 0) {
        if (mTextColorTintInfo == null) {
            mTextColorTintInfo = new TintInfo();
        }
        mTextColorTintInfo.mHasTintList = true;
        //这个过程会在后面解释, 就是能拿到要渲染的ColorStateList
        mTextColorTintInfo.mTintList =  mTintManager.getColorStateList(resId);
    }
    applySupportTextColorTint();
}

applySupportTextColorTint中直接使用了上面的mTextColorTintInfo.mTintList, 直接将其设置给TextView. Helper也会有tint方法, 此方法会对View进行渲染, 类似于

if (mTextColorId != 0) {
    setSupportTextColorTint(mTextColorId);
}

TintManager分析

还剩下最后一部分, TintManager是怎么找到Drawable并给他设置了皮肤包的颜色的, 下面进行简单分析

@Nullable
public ColorStateList getColorStateList(@ColorRes int resId) {
    if (resId == 0) return null;
    //对Ctx进行弱引用处理
    final Context context = mContextRef.get();
    if (context == null) return null;
    //对colorStateList进行了LRU缓存处理
    ColorStateList colorStateList = mCacheTintList != null ? mCacheTintList.get(resId) : null;
    if (colorStateList == null) {
        colorStateList = ColorStateListUtils.createColorStateList(context, resId);//创建tintcolorStateList
        if (colorStateList != null) {
            if (mCacheTintList == null) {
                mCacheTintList = new SparseArray<>();
            }
            mCacheTintList.append(resId, colorStateList);
        }
    }
    return colorStateList;
}

这段代码主要是处理异常和缓存问题, 真正拿到ColorStateList是在ColorStateListUtils.createColorStateList(context, resId);的方法中.

static ColorStateList createColorStateList(Context context, int resId) {
    if (resId <= 0) return null;

    TypedValue value = new TypedValue();
    context.getResources().getValue(resId, value, true);
    ColorStateList cl = null;
    if (value.type >= TypedValue.TYPE_FIRST_COLOR_INT
            && value.type <= TypedValue.TYPE_LAST_COLOR_INT) {
        cl = ColorStateList.valueOf(ThemeUtils.replaceColorById(context, value.resourceId));
    } else {
        final String file = value.string.toString();
        try {
            if (file.endsWith("xml")) {
                final XmlResourceParser rp = context.getResources().getAssets().openXmlResourceParser(
                        value.assetCookie, file);
                final AttributeSet attrs = Xml.asAttributeSet(rp);
                int type;

                while ((type = rp.next()) != XmlPullParser.START_TAG
                        && type != XmlPullParser.END_DOCUMENT) {
                    // Seek parser to start tag.
                }

                if (type != XmlPullParser.START_TAG) {
                    throw new XmlPullParserException("No start tag found");
                }

                cl = createFromXmlInner(context, rp, attrs);
                rp.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } catch (XmlPullParserException e) {
            e.printStackTrace();
        }
    }
    return cl;
}

木有错, 作者选择了直接去解析XML, 这是我当时做换肤时根本不考虑的方式, 想不到就这样被实现了...其中使用了Android对XML文件进行解析的方法, 非常值得我们去学习. 通过资源的ID去取资源的信息, 如果只是颜色值就创建ColorStateList, 再如果资源是XML文件的话就开始解析这个文件.
createFromXmlInner中判断了文件是不是一个selector, 是的话才继续执行, 否则不处理. 继续执行会调用到static ColorStateList inflateColorStateList(Context context, XmlPullParser parser, AttributeSet attrs) throws IOException, XmlPullParserException的方法, 来看看真正的实现

static ColorStateList inflateColorStateList(Context context, XmlPullParser parser, AttributeSet attrs) throws IOException, XmlPullParserException {
    final int innerDepth = parser.getDepth() + 1;
    int depth;
    int type;
    LinkedList<int[]> stateList = new LinkedList<>();
    LinkedList<Integer> colorList = new LinkedList<>();

    while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
            && ((depth = parser.getDepth()) >= innerDepth || type != XmlPullParser.END_TAG)) {
        if (type != XmlPullParser.START_TAG || depth > innerDepth
                || !parser.getName().equals("item")) {
            continue;
        }
        TypedArray a1 = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.color});
        //这里面会使用到最开始的SwitchColor, 拿到真正的Color
        final int baseColor = com.bilibili.magicasakura.utils.ThemeUtils.replaceColorById(context, a1.getResourceId(0, Color.MAGENTA));
        a1.recycle();
        TypedArray a2 = context.obtainStyledAttributes(attrs, new int[]{android.R.attr.alpha});
        final float alphaMod = a2.getFloat(0, 1.0f);
        a2.recycle();
        colorList.add(alphaMod != 1.0f
                ? ColorUtils.setAlphaComponent(baseColor, Math.round(Color.alpha(baseColor) * alphaMod))
                : baseColor);

        stateList.add(extractStateSet(attrs));
    }

    if (stateList.size() > 0 && stateList.size() == colorList.size()) {
        int[] colors = new int[colorList.size()];
        for (int i = 0; i < colorList.size(); i++) {
            colors[i] = colorList.get(i);
        }
        return new ColorStateList(stateList.toArray(new int[stateList.size()][]), colors);
    }
    return null;
}

上面的所有就是对换肤的流程进行了一个简单的分析, 是否能在自己的项目中使用这个库已经可以做出部分判断, 还有很多的细节没有讲到, 下面会无规则地介绍一些细节的问题.

部分细节问题

  • TintMManager中使用了LruCache, 对于解析等到的Drawable要进行缓存, 下次再取用的时候可以不去解析XML这么复杂
  • 解析资源时主要支持下面三种Drawable, 对于不同的Drawable解析的方式也不全一样
    支持的Drawable类型
  • 程序中使用的ColorFilter都是PorterDuffColorFilter
  • 上面的例子中使用的是AppCompatTextHelper, 还有另一种使用更多的方式渲染(比如在AppCompatBackgroundHelper中)
private boolean applySupportBackgroundTint() {
    Drawable backgroundDrawable = mView.getBackground();
    if (backgroundDrawable != null && mBackgroundTintInfo != null && mBackgroundTintInfo.mHasTintList) {
        backgroundDrawable = DrawableCompat.wrap(backgroundDrawable);
        backgroundDrawable = backgroundDrawable.mutate();
        if (mBackgroundTintInfo.mHasTintList) {
            DrawableCompat.setTintList(backgroundDrawable, mBackgroundTintInfo.mTintList);
        }
        if (mBackgroundTintInfo.mHasTintMode) {
            DrawableCompat.setTintMode(backgroundDrawable, mBackgroundTintInfo.mTintMode);
        }
        if (backgroundDrawable.isStateful()) {
            backgroundDrawable.setState(mView.getDrawableState());
        }
        setBackgroundDrawable(backgroundDrawable);
        return true;
    }
    return false;
}

其中使用了V4包的DrawableCompat, 才能使用setTintList.

  • 如果DrawableColorDrawable的话是不能设置ColorFilter的, 在API21以下都是不起效果的, 使用的是ColorDrawablesetColor方法.

结语

MagicaSakura中将解析XML, 属性渲染, 控件功能分离得很好, 结构非常清晰利于扩展, 可以学到很多.
他使用系统解析XML方法去自己解析, 值得学习.
如果项目中要实现换肤功能的话可以考虑使用, 就是项目如果已经比较大的话, 工作量可能也会很大, 也可以考虑一下Android_Skin_Loader也是不错的选择.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,186评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,858评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,620评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,888评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,009评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,149评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,204评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,956评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,385评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,698评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,863评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,544评论 4 335
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,185评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,899评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,141评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,684评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,750评论 2 351

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,900评论 25 707
  • 内容抽屉菜单ListViewWebViewSwitchButton按钮点赞按钮进度条TabLayout图标下拉刷新...
    皇小弟阅读 46,740评论 22 665
  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,068评论 4 62
  • 女人是个运动爱好者,这天在跑完10公里后,突然感觉右腿大腿根部非常疼痛,特别走路的时候,疼痛剧烈。于是,决定去医院...
    艾娃阅读 394评论 0 0
  • 我知道我喜欢你,就像一场莫名其妙的感冒,没有缘由的,就已经陷入了头疼、咳嗽、发热的表象里。我知道你不爱我啊,在从每...
    西顾AVIVI阅读 814评论 2 7