欢迎大家下载我个人开发的app安琪花园
前言
我总结的这个并不是说从网络下载皮肤包 。而是通过换肤的原理来实现白天模式和夜间模式的切换。
因为在我当前开发的项目,夜间模式的切换实现逻辑是通过recreateActivity来实现的。
但是这个有一个缺点,就是每次切换明显能看到闪烁,且卡顿一下。
当我研究过了换肤的原理,并接入到了项目里面就能解决上面的问题, 夜间模式的切换就比较自然。
看一下修改过后的效果图
tuhaokuai_1580118181.gif
实现原理
- 首先在res文件夹下面建立白天模式 和夜间模式的文件夹,分别对应白天和夜间模式的资源
- 在渲染Activity的时候,将要换肤的控件实现某一个特定的接口,并把控件里面的属性保存到实体类里面
- 当点击夜间模式切换的按钮时, 调用集合里面的控件的接口方法,达到换肤的目的
具体是如何实现的
从setContentView开始分析,这个方法最终会执行到PhonwWindow里面的setContentView方法
@Override
public void setContentView(int layoutResID) {
// Note: FEATURE_CONTENT_TRANSITIONS may be set in the process of installing the window
// decor, when theme attributes and the like are crystalized. Do not check the feature
// before this happens.
if (mContentParent == null) {
installDecor();
} else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
mContentParent.removeAllViews();
}
if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
getContext());
transitionTo(newScene);
} else {
mLayoutInflater.inflate(layoutResID, mContentParent);
}
mContentParent.requestApplyInsets();
final Callback cb = getCallback();
if (cb != null && !isDestroyed()) {
cb.onContentChanged();
}
mContentParentExplicitlySet = true;
}
从上面的的代码里面可以看到这样的一句代码: mLayoutInflater.inflate(layoutResID, mContentParent);
所以着重看一下LayoutInflater.inflate方法
这个方法就是去解析布局资源里面的控件。从源码分析最终会执行到如下的代码:
void rInflate(XmlPullParser parser, View parent, Context context,
AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
final int depth = parser.getDepth();
int type;
boolean pendingRequestFocus = false;
while (((type = parser.next()) != XmlPullParser.END_TAG ||
parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
if (type != XmlPullParser.START_TAG) {
continue;
}
final String name = parser.getName();
if (TAG_REQUEST_FOCUS.equals(name)) {
pendingRequestFocus = true;
consumeChildElements(parser);
} else if (TAG_TAG.equals(name)) {
parseViewTag(parser, parent, attrs);
} else if (TAG_INCLUDE.equals(name)) {
if (parser.getDepth() == 0) {
throw new InflateException("<include /> cannot be the root element");
}
parseInclude(parser, context, parent, attrs);
} else if (TAG_MERGE.equals(name)) {
throw new InflateException("<merge /> must be the root element");
} else {
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);
}
}
if (pendingRequestFocus) {
parent.restoreDefaultFocus();
}
if (finishInflate) {
parent.onFinishInflate();
}
}
从上面的源码里面我们可以看到如下的一句代码: final View view = final View view = createViewFromTag(parent, name, context, attrs);; 这句代码的作用就是创建出xml文件中的控件的实例
接下来分析一下 final View view = createViewFromTag(parent, name, context, attrs);
是如何创建出控件的, 继续从源码分析
View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
boolean ignoreThemeAttr) {
*** 省略 ***
try {
View view;
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
if (view == null && mPrivateFactory != null) {
view = mPrivateFactory.onCreateView(parent, name, context, attrs);
}
if (view == null) {
final Object lastContext = mConstructorArgs[0];
mConstructorArgs[0] = context;
try {
if (-1 == name.indexOf('.')) {
view = onCreateView(parent, name, attrs);
} else {
view = createView(name, null, attrs);
}
} finally {
mConstructorArgs[0] = lastContext;
}
}
return view;
} catch (InflateException e) {
throw e;
}
*** 省略了部分代码 ***
}
上面的源码省略了一部分不相关的的代码,保留了核心的代码
最核心的代码如下:
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
**分析到了这里,我可以先诉你,让createViewFromTag方法里面执行mFactory2.oncreateView分支 就是本节换肤的突破口, 得到View实例不希望走系统默认的onCreateView方法,而是调用mFactory2.onCreateView方法,这样我们就能在mFactory2的onCreateView方法里面添加自己的逻辑**
在默认的情况下LayoutInflater里面的mFactory2属性是为null,所以根本就不会执行到mFactory2.onCreateView分支
如何让mFactory2不为空呢? 这就需要从Activity的源码开始分析
1580121669020.jpg
从上面的截图可知, Activity实现了LayoutInflater.Factory2接口, 如何把activity实例赋值给LayoutInflater 的mFactory2属性, 那么mFactory2的实例就不为空,代码就能按照上面我们的设想去执行。
接下来就是让LayoutInflater里面的mFactory2的值 不为null
首先我们分析一下, 对于 一个项目里面的Activity一般情况下都是有一个基类的Activity, 而关于一些换肤的逻辑,当然就可以在基类里面处理了。对于我当前的项目而言,我的基类Activity为:
image.png
从上面的的onCreate方法分析知:LayoutInflaterCompat.setFactory2(layoutInflater, this); LayoutInflaterCompate通过反射将layoutinflater的mFactory2实例设置为当前的activity. 这样createViewFromTag方法得到的View实例就是调用 mFactory2.onCreateView方法。 也就是调用了上面的截图方法
从给的截图知,控件的创建是委托给CustomAppCompatViewInflater来创建的。接下来看一下
CustomAppCompatViewInflater源码:
public final class CustomAppCompatViewInflater {
private String name; // 控件名
private Context context; // 上下文
private AttributeSet attrs; // 某控件对应所有属性
public CustomAppCompatViewInflater(Context context) {
this.context = context;
}
public void setName(String name) {
this.name = name;
}
public void setAttrs(AttributeSet attrs) {
this.attrs = attrs;
}
/**
* @return 自动匹配控件名,并初始化控件对象
*/
public View autoMatch() {
View view = null;
switch (name) {
case "LinearLayout":
view = new SkinnableLinearLayout(context, attrs);
this.verifyNotNull(view, name);
break;
case "View":
view = new SkinnableView(context, attrs);
this.verifyNotNull(view, name);
break;
}
return view;
}
}
具体分析一下autoMatch方法, 说得直白点,就是一种偷天换日的做法, LayoutInflater解析到需要一个LinearLayout控件,但是并不是直接new一个LinearLayout返回, 而是new了一个SkinnableLinearLayout控件。 这个控件是继承自LinearLayout,只是加入了一些换肤需要的逻辑。主要分析一下LinearLayout,对于其它控件的逻辑是类似的
具体分析一下SkinnableLinearLayout做了什么处理
public class SkinnableLinearLayout extends LinearLayout implements ViewsMatch {
private AttrsBean attrsBean;
public SkinnableLinearLayout(Context context) {
this(context, null);
}
public SkinnableLinearLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SkinnableLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
attrsBean = new AttrsBean();
// 根据自定义属性,匹配控件属性的类型集合,如:background
TypedArray typedArray = context.obtainStyledAttributes(attrs,
R.styleable.SkinnableLinearLayout,
defStyleAttr, 0);
// 存储到临时JavaBean对象
attrsBean.saveViewResource(typedArray, R.styleable.SkinnableLinearLayout);
// 这一句回收非常重要!obtainStyledAttributes()有语法提示!!
typedArray.recycle();
}
@Override
public void skinnableView() {
// 根据自定义属性,获取styleable中的background属性
int key = R.styleable.SkinnableLinearLayout[R.styleable.SkinnableLinearLayout_android_background];
// 根据styleable获取控件某属性的resourceId
int backgroundResourceId = attrsBean.getViewResource(key);
if (backgroundResourceId > 0) {
// 兼容包转换
Drawable drawable = ContextCompat.getDrawable(getContext(), backgroundResourceId);
setBackground(drawable);
}
}
}
从上面的源码可以提取到两个重要信息:
1. attrsBean.saveViewResource(typedArray, R.styleable.SkinnableLinearLayout);
2. 实现了ViewsMatch接口,并重写了skinnableView方法。
第一个信息,是将控件里面的所有的属性值保存到AttrsBean里面。并提供了getViewResource方法
public class AttrsBean {
private SparseIntArray resourcesMap;
private static final int DEFAULT_VALUE = -1;
public AttrsBean() {
resourcesMap = new SparseIntArray();
}
public void saveViewResource(TypedArray typedArray, int[] styleable) {
for (int i = 0; i < typedArray.length(); i++) {
int key = styleable[i];
int resourceId = typedArray.getResourceId(i, DEFAULT_VALUE);
resourcesMap.put(key, resourceId);
}
}
public int getViewResource(int styleable) {
return resourcesMap.get(styleable);
}
}
第二信息,当点击夜间模式切换的时候肯定会调用skinnableView,来达到换肤的目的
public void skinnableView() {
// 根据自定义属性,获取styleable中的background属性
int key = R.styleable.SkinnableLinearLayout[R.styleable.SkinnableLinearLayout_android_background];
// 根据styleable获取控件某属性的resourceId
int backgroundResourceId = attrsBean.getViewResource(key);
if (backgroundResourceId > 0) {
// 兼容包转换
Drawable drawable = ContextCompat.getDrawable(getContext(), backgroundResourceId);
setBackground(drawable);
}
}
对于LinearLayout,换肤的话主要是针对背景, 那如果是TextView的话,
换肤可能不仅有背景 还有字体颜色值 。
上面的代码就是改变背景的一段代码。如果改变字体颜色 逻辑也是类似的。
Drawable drawable = ContextCompat.getDrawable(getContext(), backgroundResourceId);
如果是白天模式: 则取白天模式的资源,
如果是夜间模式: 则取夜间模式的资源
在点击夜间模式切换的时候,应该先将系统的夜间模式给切换, 切换后才调用对应的skinnableView换肤
public void toggle() {
if (sUiNightMode == Configuration.UI_MODE_NIGHT_YES) {
notNight();
} else {
night();
}
}
public static boolean isNightMode() {
return sUiNightMode == Configuration.UI_MODE_NIGHT_YES;
}
public void notNight() {
updateConfig(Configuration.UI_MODE_NIGHT_NO);
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
SharedPrefsUtil.save("config", "isNight", false);
AppManager.getAppManager().changeSkin();
}
public void night() {
updateConfig(Configuration.UI_MODE_NIGHT_YES);
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
SharedPrefsUtil.save("config", "isNight", true);
AppManager.getAppManager().changeSkin();
}
private void updateConfig(int uiNightMode) {
AppCompatActivity activity = mActivity.get();
if (activity == null) {
throw new IllegalStateException("Activity went away?");
}
Resources resources = activity.getResources();
DisplayMetrics dm = resources.getDisplayMetrics();
Configuration newConfig = new Configuration(resources.getConfiguration());
newConfig.uiMode &= ~Configuration.UI_MODE_NIGHT_MASK;
newConfig.setLocale(getLanguageMode());
newConfig.uiMode |= uiNightMode;
resources.updateConfiguration(newConfig, dm);
sUiNightMode = uiNightMode;
if (mPrefs != null) {
mPrefs.edit().putInt(PREF_KEY, sUiNightMode).apply();
}
}
调用上面的toggle方法就可以将系统的白天模式和夜间模式进行切换,虽然模式切换了,但是还得调用 skinnableView方法才能将控件换肤, 所以在notNight方法和night方法最后 有这样一句代码, AppManager.getAppManager().changeSkin(); 就是将项目里面启动的每一个activity进行换肤
public void changeSkin() {
for (Activity activity : activityStack) {
if(activity instanceof BaseActivity)
((BaseActivity) activity).changeSkin();
}
}
上面一直说到的换肤只是针对某一个控件进行换肤。但是一个Activity页面肯定不止一个控件
所以这里面肯定会用到递归的逻辑。
public void changeSkin() {
if (needCheckLightStatusBar()) {
checkLightStatusBar();
forNavigation(ContextCompat.getColor(this, R.color.day_mode_background_color));
}
applyViews(getWindow().getDecorView());
}
protected void applyViews(View view) {
if (view instanceof ViewsMatch) {
ViewsMatch viewsMatch = (ViewsMatch) view;
viewsMatch.skinnableView();
}
if (view instanceof ViewGroup) {
ViewGroup parent = (ViewGroup) view;
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
applyViews(parent.getChildAt(i));
}
}
}
从上面的代码看到了 applyViews(View view) 方法,这个方法会递归的调用viewsMatch.skinnableView()
来达到换肤的目的。
以上就是一个整个换肤的逻辑。 有什么不对的地方 欢迎大家共同探讨。
公众号:
网友评论