当了解了一些知识,应该用文字记录它,再抽个时间再看它,永远记住它
Android 换肤的理论知识和文章已经很多了,这里记录一下自己对这块的理解。本文效果如下:
工程:一键换肤的快乐
一、换肤的由来
首先,为什么要换肤呢?那肯定是一套UI不满足需求,无法面对多变的需求,从而需要有可以自由去更换UI 的手段,而这也是换肤想要达到的目的。
比如,一个imageview , 现在设置了一张图片,但是 618 来了, 我先更换成新的图片,怎么办?总不能让用户再更新一遍吧,虽然可以增量更新,但总不能每次都直接更新吧?
那我么一般怎么更新 imageview 的图片呢?
ImageView imageView = findViewById(R.id.image);
imageView.setImageResource(R.mipmap.bg);
可以通过 setImageResource() 设置更新图片。
1.1 应用内换肤分析
那如果我下发了换肤命令,怎么更新呢?如果是应用内更新,那图片的名字肯定是不能一样的,不能R文件找不到;这个时候,我们可以新建一个 res_skin ,skin_bg 改个名字,比如 skin_bg。
然后在换肤命令来的时候,换成如下代码:
imageView.setImageResource(R.mipmap.skin_bg);
那我换肤命令哪知道你有多少个 view 啊 ?怎么知道你要替换是 mipmap ,还是 color 啊?
别急,这个后面会讲。
1.2 插件换肤分析
上面是应用内换肤,如果是插件换肤呢。
插件换肤的话,就是把要替换的资源,比如上面的 bg 图片,放到一个apk 中,然后从这个apk 中取出这个资源,插件换肤不需要给资源名称,与原apk 保持一致即可。
怎么取呢,从上面 R.mipmap.bg 知道 ,所有得知道资源是从 mipmap 取,且名字叫做 bg 就可以取到这个 id 了。
幸运的是,Resource 有个方法:
public int getIdentifier(String name, String defType, String defPackage) {
return mResourcesImpl.getIdentifier(name, defType, defPackage);
}
参数解释如下:
- name:资源名称
- defType : 资源类型,比如 mipmap,color,string…
- defPackage : 目标包名
那这样的话,事实上,
imageView.setImageResource(R.mipmap.bg);
也可以写成:
int res = getResources().getIdentifier("bg","mipmap",getPackageName());
if (res != 0){
imageView.setImageResource(res);
}
可以看到,确实显示出来了:
咦,那我只要去加载皮肤的资源包,再通过 resource 的 getIdentifier 不就可以拿到资源文件了吗,然后同通过 view 去设置就可以了。
那怎么去解析这个 皮肤资源包呢?
我们知道 Android 的资源管理,除了 Resource ,还有 AssetManager;其中 Resource 类可以通过 ID 来查找资源,而 AssetManager 则可以根据文件名来查找资源。
那这里就好办了,就使用 AssetManager ,然后它有个方法:
/**
* @deprecated Use {@link #setApkAssets(ApkAssets[], boolean)}
* @hide
*/
@Deprecated
public int addAssetPath(String path) {
return addAssetPathInternal(path, false /*overlay*/, false /*appAsLib*/);
}
但这个是 hide 方法,且标注为 Deprecated,建议我们去使用setApkAssets,但 ApkAsset 又是 hide,难顶。
但笔者搜索了一下 setApkAssets 基本都是源码在使用,而主流的换肤,插件基本还是用 addAssetPath,且在 Android P 上试了一下,也没啥问题,所以这里也暂时用这个把。既然是 hide ,那肯定用反射了:
try {
//拿到资源加载器
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = AssetManager.class.getMethod("addAssetPath", String.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, skinPath);
} catch (Exception e) {
LggUtils.e("SkinManager - loadSkinPath error: " + e.getMessage());
e.printStackTrace();
}
最后,还是要用 Resource去加载 id 的,所以,这里创建的 Resource,使用 assetmanager 参数的,
Resources skinResources = new Resources(assetManager, mContext.getResources().getDisplayMetrics()
, mContext.getResources().getConfiguration());
后面咱们就可以使用 skinResource 和 getIdentifier 去加载资源了。
Ok,两种原理都分析完了。上面遗留的问题就是:
- 如何获取需要换肤的 View
- 如何知道这个view的换肤属性,比如是 bitmap,还是 color等
下面一起解决这个问题。
二、View 的生成过程
从 activity 下手,一般我们都是 setContentView(R.layout.main_activity) 去设置我们的 xml,但有没有想过,为啥设置了这个方法之后,就能拿到 View 呢?
再抛出一个问题,比如你在 xml,写个 textview 和 button 如下:
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="测试换肤"/>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="换肤"/>
然后打开 Tool - Layout Insepctor 查看:
额,怎么我的 textview 和 button 变成了 AppCompatTextView 和 AppCompatButton 了?
带着这个疑惑,我们从 setContentView 跟踪下去:
@Override
public void setContentView(@LayoutRes int layoutResID) {
getDelegate().setContentView(layoutResID);
}
首先,如果你的 activity 继承 AppCompatActivity,那么它会通过 一个 Delegate 代理类去设置 setContentView,它是个抽象方法,它的具体实现类是AppCompatDelegateImpl,但为了更好的看到整个过程,我建议你把targetSdkVersion改成26,然后去看 AppCompatDelegateImplV9,原理都是一样的,这是更加清晰。
好了,题外话过,去到实现类的 setContentView,可以看到:
除了我们熟悉的 R.id.content,最重要的就是 LayoutInflater 的 inflate 方法了,进入看看:
可以看到,拿到了 resource 之后,通过 res.getLayout(resource) 去解析 xml 布局,最后继续执行 inflate 方法,继续看下去:
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
synchronized (mConstructorArgs) {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "inflate");
final Context inflaterContext = mContext;
final AttributeSet attrs = Xml.asAttributeSet(parser);
Context lastContext = (Context) mConstructorArgs[0];
mConstructorArgs[0] = inflaterContext;
View result = root;
...
if (TAG_MERGE.equals(name)) {
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid "
+ "ViewGroup root and attachToRoot=true");
}
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// Temp is the root view that was found in the xml
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
.....
return result;
}
}
这个方法会先去解析是否有自定义属性,然后可以从 xml 文件根部去解析;最重要的是里面有个方法 createViewFromTag,它是 view 生成的关键点,进入看看:
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;
...
重点可以看到,View 的解析首先,会先判断 mFactory2 是否不为null,如果不是,则去通过 onCreateView 去创建这个 view,如果为 null,则判断 mFactory (其实如果你设置 mFactory ,到源码里面还是被替换成 mFactory2 的,具体自己跟踪),以此类推;
等等, 这个 mFactory2 哪来的?跟踪的时候没看到啊?
别急,当你继承 AppCompatActivity 的时候,我们进入看看
在 oncreate 方法的时候,有个 installViewFactory()方法,它的具体实现类是 AppCompatDelegateImpl ,可以看到:
恩恩,这个就好说了。
接着如果都找不到这个 view,则会通过 createView 这个方法去重新解析 View。去到 mFactory2 中的 onCreateView 方法,你是一个接口,具体实现类是 AppCompatDelegateImpl 或 AppCompatDelegateImplV9 (targetSdkVersion 26),看看里面的方法:
里面会把它再交给 mAppCompatViewInflater.createView(),然后可以看到:
public final View createView(View parent, final String name, @NonNull Context context,
@NonNull AttributeSet attrs, boolean inheritContext,
boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
final Context originalContext = context;
...
View view = null;
// We need to 'inject' our tint aware Views in place of the standard framework versions
switch (name) {
case "TextView":
view = new (context, attrs);
break;
case "ImageView":
view = new AppCompatImageView(context, attrs);
break;
case "Button":
view = new AppCompatButton(context, attrs);
break;
case "EditText":
view = new AppCompatEditText(context, attrs);
break;
case "Spinner":
view = new AppCompatSpinner(context, attrs);
break;
case "ImageButton":
view = new AppCompatImageButton(context, attrs);
break;
case "CheckBox":
view = new AppCompatCheckBox(context, attrs);
break;
case "RadioButton":
view = new AppCompatRadioButton(context, attrs);
break;
case "CheckedTextView":
view = new AppCompatCheckedTextView(context, attrs);
break;
case "AutoCompleteTextView":
view = new AppCompatAutoCompleteTextView(context, attrs);
break;
case "MultiAutoCompleteTextView":
view = new AppCompatMultiAutoCompleteTextView(context, attrs);
break;
case "RatingBar":
view = new AppCompatRatingBar(context, attrs);
break;
case "SeekBar":
view = new AppCompatSeekBar(context, attrs);
break;
}
if (view == null && originalContext != context) {
// If the original context does not equal our themed context, then we need to manually
// inflate it using the name so that android:theme takes effect.
view = createViewFromTag(context, name, attrs);
}
if (view != null) {
// If we have created a view, check its android:onClick
checkOnClickListener(view, attrs);
}
return view;
}
真香最终打败了,原来如果 activity 继承 AppCompatActivity,则在内部,会把 textView 替换成 AppCompatTextView,这也是我们在 xml 中写 TextView,在 layout inspector 却显示 AppCompatTextView 的问题了。
当然,不是每个 view 都替换,如果找不到这个 view,则通过 createViewFromTag(context, name, attrs); 去解析:
可以发现,还是用了 createView 去解析,createView方法时通过 类加载去加载的,这里不深入了解了。
2.1 简单替换 View
从上面知道了,View 的生成在 mFactory2 中的 onCreateView 中,那么,这里,我们做个小实验,比如检测到 textview ,把它改成 button 试试,由于 AppCompatActivity 在 onCreate 之前就设置了 mFactory2,所以,我们自己的 factory 要放到 super.oncreate() 之前,如下:
@Override
protected void onCreate(Bundle savedInstanceState) {
LayoutInflaterCompat.setFactory2(LayoutInflater.from(this), new LayoutInflater.Factory2() {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
if (name.equals("TextView") ){
Button button = new Button(context);
button.setText("我被替换了");
return button;
}
return null;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
//这个方法时 mFactory,因为 mFactory2 继承 mFactory ,所以可以不用管
return null;
}
});
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}
好了,看一下效果,换肤前:
换肤后:
可以看到,TextView 确实被替换了,不过我们看一下 layout insepctor:
咦,我的 Button 没有替换成 AppCompatButton了,为啥呢?
因为我们自己设置了 factory ,且在 onCreateView 回调的时候,直接返回 button了:
都没经过 系统的替换,那这里肯定没变了。那我想享受 AppCompat 带来的额外属性怎么办?
简单,我们自己不去创建 View,交还给系统去创建,把 name 改成 button 就可以了,如下:
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
if (name.equals("TextView") ){
name = "Button";
}
View view = getDelegate().createView(parent,name,context,attrs);
return view;
}
再看看 layout inspector:
三、实际应用
通过上面分析,你应该知道 factory 的作用,常见的实际应用有以下:
3.1 全局替换字体
有时候需要一键该字体,那我们检测到当前view 为 textview,全局替换即可,简单代码如下:
final Typeface typeface = Typeface.createFromAsset(getAssets(),"yahei.ttf");
LayoutInflaterCompat.setFactory2(LayoutInflater.from(this), new LayoutInflater.Factory2() {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
View view = getDelegate().createView(parent,name,context,attrs);
if (view instanceof TextView){
TextView textView = (TextView) view;
textView.setTypeface(typeface);
}
return view;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
//这个方法时 mFactory,因为 mFactory2 继承 mFactory ,所以可以不用管
return null;
}
});
3.2 换肤
这个网上都很成熟的方法了,但自己搞一个不香吗;可以应用上面的知识尝试一下;
很多网上的换肤框架都需要继承 baseActivity,baseFragment 的,又或者说什么需要传递 context 的,比如 skinManager.with(this)。
额,其实这里有小技巧,其实我们在自己的库里,编写一个 contentprovider,从 onCreate 拿到 context,检测到这个 context 是application,就可以通过 application 去拿到所有的 activity 了。比如:
然后在 onActivityCreated 的时候,添加我们的皮肤注入即可,如下:
感兴趣可以看看这个:https://github.com/LillteZheng/ZSkinPlugin
效果如下:
3.3无需编写shape、selector,直接在xml设置值
前段时间火到爆的,原理也是用到 factory,上面的 contentprovider 小技巧也是参考这个的哦;
地址: https://juejin.im/post/5b9682ebe51d450e543e3495
这样,这篇文章就写完了。
参考:https://mp.weixin.qq.com/s/1ua0geFnrbQbyHi8KG2VJQ