一、换肤原理
首先需要明白以下几点:
- setContentView()原理
- 布局加载工程Factory2
- onCreateView()方法
- super.onCreate()原理
- 如何拦截Factory2
下面就以上几点进行源码分析
1、 setContentView()
(1)都知道通过setContentView()能够加载布局,那么布局是xml文件,它是如何加载到内存中的呢?首先跟踪源码,最终会跟到AppCompatDelegateImpl类中的setContentView()方法,源码如下:
public void setContentView(int resId) {
//初始化父容器
this.ensureSubDecor();
//拿到id为R.id.content的父布局,我们自己的布局view将会添加在它之上
ViewGroup contentParent = (ViewGroup)this.mSubDecor.findViewById(16908290);
contentParent.removeAllViews();
//解析我们自己的布局
LayoutInflater.from(this.mContext).inflate(resId, contentParent);
//通知界面显示
this.mOriginalWindowCallback.onContentChanged();
}
(2)重点分析解析布局过程,继续跟踪inflate()方法,最终跟到LayoutInflater类的inflate()方法,主要代码如下:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
if (TAG_MERGE.equals(name)) {
....
} else {
//-------------1、解析根布局,创建根view
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
...
//-------------2、根据根view解析子view,创建子view, 最总创建子view还是调用createViewFromTag()方法
rInflateChildren(parser, temp, attrs, true);
...
//-------------3 赋值返回view
if (root == null || !attachToRoot) {
result = temp;
}
}
...
return result;
}
这里主要进行解析xml,一开始先调用createViewFromTag()并且返回view。然后将view作为参数传递给rInflateChildren()方法,处理完成后最后将view返回。看一下rInflateChildren()方法做了什么处理,该方法最终调用rInflate()方法源码如下:
void rInflate(XmlPullParser parser, View parent, Context context,
AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
.........................
//-----------------------------------1、while循环进行xmlPullParser解析
while (((type = parser.next()) != XmlPullParser.END_TAG ||
parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
...........................//标签判断
//--------------------------------2、最总调用createViewFromTag()返回view
final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
rInflateChildren(parser, view, attrs, true);
viewGroup.addView(view, params);
}
.......................
}
可以发现还是会调用createViewFromTag()方法。
(2)看一下createViewFromTag()方法,在LayoutInflater类中,重点来了,前方高能,源码如下:
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
...
View view;
//---------------1、如果mFactory2不为空,会调用onCreateView()方法创建view,参数中name为控件名称,attrs为控件属性
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
//-------------2、上面没有创建view、这这里初始化view
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
//-------------3、判断是不是自定义控件
if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs);
} else {
//-------------4、非自定义控件
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
}
注释1告诉我们如果mFactory2不为空,就会调用它的onCreateView()方法创建view,并且将控件的名称和属性作为参数传递进去。看到这里难免会有疑问mFactory2是什么?其实它就是一个接口,并且实现了Factory接口,两个接口的区别就是回调方法参数不一样。注释4处通过createView()方法返回系统控件。源码如下:
public final View createView(String name, String prefix, AttributeSet attrs)
throws ClassNotFoundException, InflateException {
//--------------1、拿到当前view的构造方法
Constructor<? extends View> constructor = sConstructorMap.get(name);
......
//------------参数之类的初始化
......
//-------------2、通过反射创建view
final View view = constructor.newInstance(args);
....
//---------3、返回
return view;
}
没错,你没有看错,系统通过反射创建了这个view,并返回。回到上面的createViewFromTag()方法,既然mFactory2会调用onCreateView()方法创建一个view,那么我们可不可以在这个方法上面做点文章呢?比如返回我们自己的控件view!!!先放这里,去看一下Factory2.
2、Factory2
它是一个接口,实现了Factory,在LayoutInflater类中,源码如下:
public interface Factory {
public View onCreateView(String name, Context context, AttributeSet attrs);
}
public interface Factory2 extends Factory {
public View onCreateView(View parent, String name, Context context, AttributeSet attrs);
}
既然是接口,那么它的实现类是哪些呢?那就不得不看看 super.onCreate()方法了,这里只需要知道Factory2 接口提供了一个onCreateView()方法,并且方法中参数包含控件名称和属性等。
3、onCreateView()
(1)在上面代码分析中会调用mFactory2.onCreateView()方法,看一下是如何创建view的,跟踪源码会进入AppCompatDelegateImpl类的onCreateView()方法,源码如下:
public View createView(View parent, String name, @NonNull Context context, @NonNull AttributeSet attrs) {
if (this.mAppCompatViewInflater == null) {
...........
//--------------------------------1、创建mAppCompatViewInflater
this.mAppCompatViewInflater = new AppCompatViewInflater();
..........
}
//--------------------------------2、调用createView()方法
return this.mAppCompatViewInflater.createView(parent, name, context, attrs, inheritContext, IS_PRE_LOLLIPOP, true, VectorEnabledTintResources.shouldBeUsed());
}
(2)注释2处createView(),AppCompatViewInflater类的createView()方法,代码如下:
final View createView(View parent, String name, @NonNull Context context, @NonNull AttributeSet attrs, boolean inheritContext, boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
...
View view = null;
byte var12 = -1;
//--------------1、匹配基础控件,比如TextView,ImageView,这里没有匹配RelativeLayout这样的父容器控件
switch(name.hashCode()) {
...
case -938935918:
if (name.equals("TextView")) {
var12 = 0;
}
break;
...
case 1125864064:
if (name.equals("ImageView")) {
var12 = 1;
}
break;
...
}
//`------------2、创建兼容的view
switch(var12) {
case 0:
view = this.createTextView(context, attrs);
this.verifyNotNull((View)view, name);
break;
case 1:
view = this.createImageView(context, attrs);
this.verifyNotNull((View)view, name);
break;
...
}
return (View)view;
}
这里为了兼容V7包做了控件的兼容处理,通过调用createTextView()、createImageView()等方法,截图如下:
4、super.onCreate()
(1)跟踪源码会进入父类AppCompatActivity的onCreate()方法,源码如下:
protected void onCreate(@Nullable Bundle savedInstanceState) {
......
delegate.installViewFactory();
.....
//-------------------主要为了调用其父类Activity的onCreate方法来实现对界面的图画绘制工作
super.onCreate(savedInstanceState);
}
(2)绘制我们不做分析,重点看installViewFactory()方法,最后进入AppCompatDelegateImpl类的installViewFactory()方法,源码如下:
public void installViewFactory() {
LayoutInflater layoutInflater = LayoutInflater.from(this.mContext);
//----------这句话的意思是如过Factory不为空了,就不能再次进行创建,一开始界面初始化肯定为空的,就会进行赋值操作
if (layoutInflater.getFactory() == null) {
LayoutInflaterCompat.setFactory2(layoutInflater, this);
} else if (!(layoutInflater.getFactory2() instanceof AppCompatDelegateImpl)) {
Log.i("AppCompatDelegate", "The Activity's LayoutInflater already has a Factory installed so we can not install AppCompat's");
}
}
以上会判断factory是否为空,系统为了避免重复设置Factory
public void setFactory2(Factory2 factory) {
//--------------------1、这个属性为true就会抛出异常
if (mFactorySet) {
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
....
mFactorySet = true;
if (mFactory == null) {
mFactory = mFactory2 = factory;
} else {
mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
}
}
对mFactory 和mFactory2 进行赋值,并且进行工厂合并。注意注释1处,mFactorySet为true将会抛出异常,这个属性将会在后面介绍到
总结一下:1、setContentView()进行了xml的解析,并且将view填充到父view之上
2、创建view的过程中,通过布局加载工程Factory2交给实现类创建view并返回
3、创建view的是通过Factory2的onCreateView()方法,最终实现在AppCompatViewInflater类的createView()方法中
4、super.onCreate()初始化布局加载工厂Factory2
通过以上几点分析,发现我们可以通过拦截Factory2的onCreateView()方法可以进行控件信息的拦截,可以将这些控件收集起来,当需要换肤的时候可以拿到当前控件,并将它的控件属性值(比如background,textColor等)进行修改即可。
5、如何拦截Factory2
拦截的方法很简单,只需要将系统的mFactory2替换成自己的即可,在哪里替换呢?我们先看一下我们平常写的Acctivity中是否有onCreatView()方法。我去!!!还真有。这是为什么呢?其实我们activity的父类Activity类是实现了Factory2接口的,截图如下:
之前分析在super.onCreate()方法种说到,当factory不为空的时候就会抛出异常,说明我们初始化factory要在super.onCreate()方法之前进行,这样mFactory2就会被初始化了,之后就可以通过当前activity的onCreateView()方法收集控件信息了。
二、代码实现
1、创建依赖module,命名为skin
2、在module中,创建父类SkinActivity.java
/**
* 供子类继承
*/
public class SkinActivity extends AppCompatActivity {
private SkinAppCompatViewInflater mAppCompatViewInflater;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
//拦截新系统factory2
LayoutInflater layoutInflater = LayoutInflater.from(this);
LayoutInflaterCompat.setFactory2(layoutInflater, this);
super.onCreate(savedInstanceState);
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
//判断是否打开换肤
if (openChangeSkin()) {
if (this.mAppCompatViewInflater == null) {
mAppCompatViewInflater = new SkinAppCompatViewInflater(context);
}
//控件名称和属性传递过去
mAppCompatViewInflater.setName(name);
mAppCompatViewInflater.setAttrs(attrs);
//返回自定义的view 匹配不到自定义view,返回null,交给系统反射创建
return mAppCompatViewInflater.matchView();
}
/*
1、没有打开换肤,调用系统方法
2、打开换肤、但是没有匹配到view就会通过系统方法反射创建view
*/
return super.onCreateView(name, context, attrs);
}
/**
* 提供给子类activity,是否需要开启换肤
*
* @return 默认为false
*/
protected boolean openChangeSkin() {
return false;
}
/**
* 开始换肤
*
* @param nightMode 当前白天还是黑夜模式
*/
protected void applySkin(@AppCompatDelegate.NightMode int nightMode) {
//判断版本号,适配5.0以上,5.0以下不适合
final boolean isPost21 = Build.VERSION.SDK_INT >= 21;
getDelegate().setLocalNightMode(nightMode);
//修改状态栏、标题栏和导航栏颜色
if (isPost21) {
// 换状态栏
StatusBarUtils.forStatusBar(this);
// 换标题栏
ActionBarUtils.forActionBar(this);
// 换底部导航栏
NavigationUtils.forNavigation(this);
}
//拿到顶层布局
View decorView = getWindow().getDecorView();
startChangeSkin(decorView);
}
/**
* 执行开始换肤
*/
protected void startChangeSkin(View view) {
//判断当前view是否实现了该接口,如果是的话就需要到类中更新
if (view instanceof ViewChange) {
//子view进行换肤
((ViewChange) view).viewChange();
}
//遍历子控件,递归调用
if (view instanceof ViewGroup) {
ViewGroup vg = (ViewGroup) view;
for (int i = 0; i < vg.getChildCount(); i++) {
View childView = vg.getChildAt(i);
startChangeSkin(childView);
}
}
}
}
在super.oncreate()方法之前调用了设置当前activity的factory2。在onCreateView()方法中调用mAppCompatViewInflater.matchView();并将控件的name和attrs传递过去。看一下mAppCompatViewInflater的代码:
3、在module中,创建父类SkinAppCompatViewInflater.java
/**
* 可以不继承 ,当matchView()返回空的时候,系统在setContentView()中会判断view是否为空,为空的话会通过反射创建出系统的view
*/
public class SkinAppCompatViewInflater /*extends AppCompatViewInflater*/ {
private Context mContext;
public SkinAppCompatViewInflater(Context context) {
this.mContext = context;
}
private String name;
private AttributeSet attrs;
public void setName(String name) {
this.name = name;
}
public void setAttrs(AttributeSet attrs) {
this.attrs = attrs;
}
public View matchView(){
View view = null;
switch (name){
case "LinearLayout":
view = new SkinLinearLayout(mContext,attrs);
this.verifyNotNull(view, name);
break;
case "RelativeLayout":
view = new SkinRelativeLayout(mContext,attrs);
this.verifyNotNull(view, name);
break;
case "TextView":
view = new SkinTextView(mContext,attrs);
this.verifyNotNull(view, name);
break;
case "Button":
view = new SkinButton(mContext,attrs);
this.verifyNotNull(view, name);
break;
}
return view;
}
/**
* 校验控件不为空(源码方法,由于private修饰,只能复制过来了。为了代码健壮,可有可无)
*
* @param view 被校验控件,如:AppCompatTextView extends TextView(v7兼容包,兼容是重点!!!)
* @param name 控件名,如:"ImageView"
*/
private void verifyNotNull(View view, String name) {
if (view == null) {
throw new IllegalStateException(this.getClass().getName() + " asked to inflate view for <" + name + ">, but returned null");
}
}
}
显而易见,它是一个匹配view的过程,匹配到以后就会创建我们自定义的view并返回。
回到上面SkinActivity代码中,StatusBarUtils,ActionBarUtils和NavigationUtils工具类处理了状态栏、标题栏和导航栏颜色变化,后面源码会提供。在startChangeSkin()方法中调用了((ViewChange) view).viewChange()进行换肤。看一下ViewChange代码
4、在module中,创建ViewChange.java
public interface ViewChange {
void viewChange();
}
它是一个接口,供我们的自定义view实现,这样调用上面的方法就可以在viewChange()方法中各自执行换肤代码。这里仅仅参考自定义的TextView控件,命名为SkinTextView
5、在module中,创建SkinTextView.java
public class SkinTextView extends AppCompatTextView implements ViewChange {
private SkinBean skinBean;
public SkinTextView(Context context) {
this(context, null);
}
public SkinTextView(Context context, AttributeSet attrs) {
this(context, attrs, android.R.attr.textViewStyle);
}
public SkinTextView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
skinBean = new SkinBean();
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.SkinTextView,
defStyleAttr, 0);
//临时存储相关属性
skinBean.setResource(typedArray, R.styleable.SkinTextView);
typedArray.recycle();
}
@Override
public void viewChange() {
//改变控件背景颜色
int bgKey = R.styleable.SkinTextView[R.styleable.SkinTextView_android_background];
int bgColor = skinBean.getResource(bgKey);
//动态改变背景颜色
if (bgColor > 0) {
// 兼容包转换
Drawable drawable = ContextCompat.getDrawable(getContext(), bgColor);
// 控件自带api,这里不用setBackgroundColor()因为在9.0测试不通过
// setBackgroundDrawable本来过时了,但是兼容包重写了方法
setBackgroundDrawable(drawable);
}
int textColorKey = R.styleable.SkinTextView[R.styleable.SkinTextView_android_textColor];
int textColor = skinBean.getResource(textColorKey);
if (textColor > 0) {
ColorStateList color = ContextCompat.getColorStateList(getContext(), textColor);
this.setTextColor(color);
}
}
}
在自定义控件中会临时存储当前控件的属性值,保存在skinBean中,注意对于TextView来说,换肤只需要更改字体颜色和背景颜色,所以这里仅仅存储这两个属性,大大的节省了资源,属性配置在attrs.xml中,截图如下:
6、在module中,创建SkinBean.java
/**
* 存放控件的相关信息
*/
public class SkinBean {
private SparseIntArray sourceArray;
public SkinBean() {
sourceArray = new SparseIntArray();
}
public void setResource(TypedArray typedArray, int[] skinLinearLayout) {
for (int i = 0; i < typedArray.length(); i++) {
int key = skinLinearLayout[i];
int value = typedArray.getResourceId(i,-1);
sourceArray.put(key,value);
}
}
public int getResource(int key){
return sourceArray.get(key);
}
}
以上就完成了SkinModule的配置,现在在需要换肤的Activity继承SkinActivity即可,比如MainActivity
7、在app中,创建MainActivity.java
public class MainActivity extends SkinActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
@Override
protected boolean openChangeSkin() {
return true;
}
/**
* 换肤切换
*/
public void changeColor(View view) {
int uiMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
switch (uiMode) {
case Configuration.UI_MODE_NIGHT_NO:
applySkin(AppCompatDelegate.MODE_NIGHT_YES);
PreferencesUtils.putBoolean(this, "isNight", true);
break;
case Configuration.UI_MODE_NIGHT_YES:
applySkin(AppCompatDelegate.MODE_NIGHT_NO);
PreferencesUtils.putBoolean(this, "isNight", false);
break;
default:
break;
}
}
}
有以下几点需要注意:
- 内置换肤需要在app的res目录下配置values和values-night两个文件夹,并且对于color属性来说,白天和黑夜的颜色值名称要一致。
- 如果换肤过程中出现闪烁或者导航栏不能换肤的情况,请在AndroidManifest.xml中加如下配置:
最后上一波效果图
三、性能对比
为什么要拓展一下,针对目前市面上的换肤,也是通过拦截Factory2的onCreateView()方法实现。但是他们是在super.onCreate()方法之后,setContentView()之前处理。大致步骤如下:
1、注册ActivityLifecycleCallbacks,因为这个可以看成是系统的拦截所有Activity生命周期的方式(AOP的实现),在重载方法onActivityCreated()中通过反射修改属性mFactorySet值为false,对的这个属性之前有提到过,再看一次
public void setFactory2(Factory2 factory) {
//--------------------1、这个属性为true就会抛出异常
if (mFactorySet) {
throw new IllegalStateException("A factory has already been set on this LayoutInflater");
}
....
mFactorySet = true;
if (mFactory == null) {
mFactory = mFactory2 = factory;
} else {
mFactory = mFactory2 = new FactoryMerger(factory, factory, mFactory, mFactory2);
}
}
为什么设置成false?看源码就明白了,当系统在调用super.onCreate()方法的时候就已经把mFactorySet 设置为true了,只有设置为false,才能将Factory2重新赋值,进行拦截。
大致浏览下onActivityCreated()方法的代码
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
Log.d(TAG, "onActivityCreated");
LayoutInflater layoutInflater = LayoutInflater.from(activity);
// 利用反射去修改mFactorySet的值为false,防止抛出 A factory has already been set on this...
// 反正就是为了提高健壮性
try {
// 尽量使用LayoutInflater.class,不要使用layoutInflater.getClass()
Field mFactorySet = LayoutInflater.class.getDeclaredField("mFactorySet");
// 源码312行
mFactorySet.setAccessible(true);
mFactorySet.set(layoutInflater, false);
} catch (Exception e) {
e.printStackTrace();
Log.e(TAG, "reflect failed e: " + e.toString());
}
skinFactory = new SkinFactory(activity);
// mFactorySet = true是无法设置成功的(源码312行)
LayoutInflaterCompat.setFactory2(layoutInflater, skinFactory);
}
2、注册观察者(监听用户操作,点击了换肤,通知观察者更新)
3、写个类如上面的SkinFactory继承Factory2,并实现了观察者接口
4、在SkinFactory的onCreateView()方法中收集所有的控件的所有属性。
5、用户点击换肤了,通知观察者更新,比如先找到Textview,再找到Textview的textColor和backgroundColor等属性,进行改变。这样就会导致两层的for循环,第一层遍历所有的控件,第二处遍历所有控件的所有属性名称
这样做的缺点:
1、临时集合收集所有控件,换肤时需要双层遍历。如果布局里面有几十个控件,那么会导致性能消耗明显增加。
2、置换setContentView()方法,容易不兼容。。
3、对自定义控件兼容很难。
四、实现过程的疑惑点
不拦截系统的onCreatView()方法,即没有设置Factory2,发现在activity的onCreateView()方法同样可以收集控件,但是收集到的只是viewGroup控件和一些系统默认添加的节点控件,而没有我们布局中的TextView,ImageView等这些子控件。这是为什么?
答:debug源码你会发现在FragmentActivity中的onCreateView()方法,截图如下:
这里的View变量v就会去creatView()方法中找有没有这个控件(之前知道creatView()方法只会返回兼容的控件比如TextView,ImageView)。而LinerLayout,RelativeLayout是不在里面的。所以返回v = null,这样就会返回。反之找到了就不会返回。
最后,高效的换肤代码地址如下:
代码地址app内置换肤