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都被重写了.
View进行渲染过程
对View的渲染都是通过在View中保存的几个Helper实现的, 每个要换肤的View
在构造的时候会根据跟随皮肤变化的属性构建对应的Helper, 比如说TextView
在换肤的时候要变换自己的TextColor
, BackGround
以及drawableLeft
, drawableRight
之类的属性所以在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
为例分析他们的工作原理.
其构造方法只是将当前的view
和tintManager
保存为成员. 其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 stateList = new LinkedList<>();
LinkedList 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
解析的方式也不全一样
- 程序中使用的
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
.
- 如果
Drawable
是ColorDrawable
的话是不能设置ColorFilter
的, 在API21以下都是不起效果的, 使用的是ColorDrawable
的setColor
方法.
结语
MagicaSakura
中将解析XML, 属性渲染, 控件功能分离得很好, 结构非常清晰利于扩展, 可以学到很多.
他使用系统解析XML方法去自己解析, 值得学习.
如果项目中要实现换肤功能的话可以考虑使用, 就是项目如果已经比较大的话, 工作量可能也会很大, 也可以考虑一下Android_Skin_Loader也是不错的选择.