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也是不错的选择.

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 127,663评论 18 546
  • 内容抽屉菜单ListViewWebViewSwitchButton按钮点赞按钮进度条TabLayout图标下拉刷新...
    皇小弟阅读 30,838评论 21 577
  • 女人是个运动爱好者,这天在跑完10公里后,突然感觉右腿大腿根部非常疼痛,特别走路的时候,疼痛剧烈。于是,决定去医院...
    艾娃阅读 30评论 0 0
  • 我知道我喜欢你,就像一场莫名其妙的感冒,没有缘由的,就已经陷入了头疼、咳嗽、发热的表象里。我知道你不爱我啊,在从每...
    西顾AVIVI阅读 417评论 2 7
  • 其实有缘与无缘的即会不过 就隔了 那么不经意的 一个回眸 多情的长发穿过你的温柔 风也薄薄的醉了 虽然深深的知道 ...
    淡淡青莲阅读 188评论 27 19