西柚9102
读完需要
14
分钟
速读仅需8分钟
作者:西柚9102 链接:https://juejin.im/post/5d25f09bf265da1bb565217f
1
引子
产品大佬又提需求啦,要求app里面的图表要实现白天黑夜模式的切换,以满足不同光线下都能保证足够的图表清晰度. 怎么办?可能解决的办法很多,你可以给图表view增加一个toggle方法,参数String,day/night,然后切换之后postInvalidate 刷新重绘. OK,可行,但是这种方式切换白天黑夜,只是单个View中有效,那么如果哪天产品又要另一个View换肤,难道我要一个一个去写toggle么?未免太low了.
那么能不能要实现一个全app内的一键换肤,一劳永逸~~~
2
正文大纲
2.1
什么是一键换肤
2.2
界面上哪些东西是可以换肤的
2.3
利用HOOK技术实现优雅的“一键换肤"
2.4
相关android源码一览
- Activity 的 setContentView(R.layout.XXX) 到底在做什么?
- LayoutInflater这个类是怎么把 layout.xml 的变成TextView对象的?
- app中资源文件大管家 Resources / AssetManager 是怎么工作的
2.5
"全app一键换肤" Demo源码详解
- 关键类 SkinEngine SkinFactory
- 关键类的调用方式,联系之前的android源码,解释hook起作用的原理
- 效果展示
- 注意事项
3
正文
3.1
什么是一键换肤
所谓"一键",就是通过"一个"**接口的调用,就能实现全app范围内的所有资源文件的替换.包括 文本,颜色,图片等.
一些换肤实现方式的对比
方案1:自定义View中,要换肤,那如同引言中所述,toggle方法,invalidate重绘。弊端:换肤范围仅限于这个View.
方案2:给静态变量赋值,然后重启Activity. 如果一个Activity内用静态变量定义了两种色系,那么确实是可以通过关闭Activity,再启动的方式,实现 貌似换肤的效果(其实是重新启动了Activity)弊端:太low,而且很浪费资源
也许还有其他方案吧,View重绘,重启Activity,都能实现,但是仍然不是最优雅的方案,那么,有没有一种方案,能够实现全app内的换肤效果,又不会像重启 Activity 这样浪费资源呢?请看下图:
这个动态图中,首先看到的是Activity1,点击换肤,可直接更换界面上的background,图片的src,还有textView的textColor,跳转Activity2之后的textView颜色,在我换肤之前,和换肤之后,是不同的。换肤的过程我并没有启动另外的Activity,界面也没有闪烁。我在Activity1里面换肤,直接影响了Activity2的textView字体颜色。
既然给出了效果,那么肯定要给出Demo,不然太没诚意,嘿嘿嘿github地址奉上:
https://github.com/18598925736/HookSkinDemoFromHank
3.2
界面上哪些东西是可以换肤的
上面的换肤动态图,我换了ImageView,换了background,换了TextView的字体颜色,那么到底哪些东西可以换?
答案其实就一句话:我们项目代码里面 res目录下的所有东西,几乎都可以被替换。(为什么说几乎?因为一些犄角旮旯的东西我没有时间一个一个去试验....囧)
具体而言就是如下这些
- 动画
- 背景图片
- 字体
- 字体颜色
- 字体大小
- 音频
- 视频
3.3
利用HOOK技术实现优雅的“一键换肤"
什么是hook
如题,我是用hook实现一键换肤。那么什么是hook?hook,钩子. 安卓中的hook技术,其实是一个抽象概念:对系统源码的代码逻辑进行"劫持",插入自己的逻辑,然后放行。注意:hook可能频繁使用java反射机制...
"一键换肤"中的hook思路
- "劫持"系统创建View的过程,我们自己来创建View系统原本自己存在创建View的逻辑,我们要了解这部分代码,以便为我所用.
- 收集我们需要换肤的View(用自定义view属性来标记一个view是否支持一键换肤),保存到变量中劫持了 系统创建view的逻辑之后,我们要把支持换肤的这些view保存起来
- 加载外部资源包,调用接口进行换肤外部资源包,是.apk后缀的一个文件,是通过gradle打包形成的。里面包含需要换肤的资源文件,但是必须保证,要换的资源文件,和原工程里面的文件名完全相同.
3.4
相关android源码一览
Activity 的 setContentView(R.layout.XXX) 到底在做什么?
回顾我们写app的习惯,创建Activity,写xxx.xml,在Activity里面setContentView(R.layout.xxx). 我们写的是xml,最终呈现出来的是一个一个的界面上的UI控件,那么setContentView到底做了什么事,使得XML里面的内容,变成了UI控件呢?
如果不先来点干货,估计有些人就看不下去了,各位客官请看下图:
源码索引:
setContentView(R.layout.activity_main);
--->
getDelegate().setContentView(layoutResID);
OK,这里暴露出了两个方法,getDelegate()和setContentView()
先看getDelegate:
这里返回了一个AppCompatDelegate对象,跟踪AppCompatDelegate内部,阅读源码,可以得出一个结论:AppCompatDelegate是替Activity生成View对象的委托类,它提供了一系列setContentView方法,在Activity中加入UI控件。
那它的AppCompatDelegate的setContentView方法又做了什么?
插曲:关于如何阅读源码?这里漏了一个细节:那就是,当你在源码中看到一个接口或者抽象类,你想知道接口的实现类在哪?很简单…如果你没有更改androidStudio的快捷键设置的话,Ctrl T可以帮你直接定位 接口和抽象类的实现类.
用上面的方法,找到setContentView的具体过程
那么就进入下一个环节:LayoutInflater又做了什么?
LayoutInflater这个类是怎么把layout.xml的变成TextView对象的?
我们知道,我们传入的是int,是xxx.xml这个布局文件,在R文件里面的对应int值。LayoutInflater拿到了这个int之后,又干了什么事呢?
一路索引进去:会发现这个方法:
发现一个关键方法:CreateViewFromTag,tag是指的什么?其实就是 xml里面 的标签头:<TextView ....> 里的
TextView.跟踪进去:
代码语言:javascript复制
代码语言:javascript复制 1 View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
2 boolean ignoreThemeAttr) {
3 if (name.equals("view")) {
4 name = attrs.getAttributeValue(null, "class");
5 }
6
7 // Apply a theme wrapper, if allowed and one is specified.
8 if (!ignoreThemeAttr) {
9 final TypedArray ta = context.obtainStyledAttributes(attrs, ATTRS_THEME);
10 final int themeResId = ta.getResourceId(0, 0);
11 if (themeResId != 0) {
12 context = new ContextThemeWrapper(context, themeResId);
13 }
14 ta.recycle();
15 }
16
17 if (name.equals(TAG_1995)) {
18 // Let's party like it's 1995!
19 return new BlinkLayout(context, attrs);
20 }
21
22 try {
23 View view;
24 if (mFactory2 != null) {
25 view = mFactory2.onCreateView(parent, name, context, attrs);
26 } else if (mFactory != null) {
27 view = mFactory.onCreateView(name, context, attrs);
28 } else {
29 view = null;
30 }
31
32 if (view == null && mPrivateFactory != null) {
33 view = mPrivateFactory.onCreateView(parent, name, context, attrs);
34 }
35
36 if (view == null) {
37 final Object lastContext = mConstructorArgs[0];
38 mConstructorArgs[0] = context;
39 try {
40 if (-1 == name.indexOf('.')) {
41 view = onCreateView(parent, name, attrs);
42 } else {
43 view = createView(name, null, attrs);
44 }
45 } finally {
46 mConstructorArgs[0] = lastContext;
47 }
48 }
49
50 return view;
51 } catch (InflateException e) {
52 throw e;
53
54 } catch (ClassNotFoundException e) {
55 final InflateException ie = new InflateException(attrs.getPositionDescription()
56 ": Error inflating class " name, e);
57 ie.setStackTrace(EMPTY_STACK_TRACE);
58 throw ie;
59
60 } catch (Exception e) {
61 final InflateException ie = new InflateException(attrs.getPositionDescription()
62 ": Error inflating class " name, e);
63 ie.setStackTrace(EMPTY_STACK_TRACE);
64 throw ie;
65 }
66 }
这个方法有4个参数,意义分别是:
- View parent 父组件
- String name xml标签名
- Context context 上下文
- AttributeSet attrs view属性
- boolean ignoreThemeAttr 是否忽略theme属性
并且在这里,发现一段关键代码:
代码语言:javascript复制
代码语言:javascript复制1 if (mFactory2 != null) {
2 view = mFactory2.onCreateView(parent, name, context, attrs);
3 } else if (mFactory != null) {
4 view = mFactory.onCreateView(name, context, attrs);
5 } else {
6 view = null;
7 }
实际上,可能有人要问了,你怎么知道这边是走的哪一个if分支呢?
方法:新创建一个Project,跟踪MainActivity onCreate里面setContentView()一路找到这段代码debug:你会发现:
答案很明确了,系统在默认情况下就会走Factory2的onCreateView(),
应该有人好奇:这个mFactory2对象是哪来的?是什么时候set进去的*答案如下:
如果细心Debug,就会发现
《标记标记,因为后面有一段代码会跳回到这里,这里非常重要...》
当时,getDelegate()得到的对象,和 LayoutInflater里面mFactory2其实是同一个对象
那么继续跟踪,一直到:AppCompatViewInflater类
代码语言:javascript复制
代码语言:javascript复制 1final View createView(View parent, final String name, @NonNull Context context,
2 @NonNull AttributeSet attrs, boolean inheritContext,
3 boolean readAndroidTheme, boolean readAppTheme, boolean wrapContext) {
4 final Context originalContext = context;
5
6 // We can emulate Lollipop's android:theme attribute propagating down the view hierarchy
7 // by using the parent's context
8 if (inheritContext && parent != null) {
9 context = parent.getContext();
10 }
11 if (readAndroidTheme || readAppTheme) {
12 // We then apply the theme on the context, if specified
13 context = themifyContext(context, attrs, readAndroidTheme, readAppTheme);
14 }
15 if (wrapContext) {
16 context = TintContextWrapper.wrap(context);
17 }
18
19 View view = null;
20
21 // We need to 'inject' our tint aware Views in place of the standard framework versions
22 switch (name) {
23 case "TextView":
24 view = createTextView(context, attrs);
25 verifyNotNull(view, name);
26 break;
27 case "ImageView":
28 view = createImageView(context, attrs);
29 verifyNotNull(view, name);
30 break;
31 case "Button":
32 view = createButton(context, attrs);
33 verifyNotNull(view, name);
34 break;
35 case "EditText":
36 view = createEditText(context, attrs);
37 verifyNotNull(view, name);
38 break;
39 case "Spinner":
40 view = createSpinner(context, attrs);
41 verifyNotNull(view, name);
42 break;
43 case "ImageButton":
44 view = createImageButton(context, attrs);
45 verifyNotNull(view, name);
46 break;
47 case "CheckBox":
48 view = createCheckBox(context, attrs);
49 verifyNotNull(view, name);
50 break;
51 case "RadioButton":
52 view = createRadioButton(context, attrs);
53 verifyNotNull(view, name);
54 break;
55 case "CheckedTextView":
56 view = createCheckedTextView(context, attrs);
57 verifyNotNull(view, name);
58 break;
59 case "AutoCompleteTextView":
60 view = createAutoCompleteTextView(context, attrs);
61 verifyNotNull(view, name);
62 break;
63 case "MultiAutoCompleteTextView":
64 view = createMultiAutoCompleteTextView(context, attrs);
65 verifyNotNull(view, name);
66 break;
67 case "RatingBar":
68 view = createRatingBar(context, attrs);
69 verifyNotNull(view, name);
70 break;
71 case "SeekBar":
72 view = createSeekBar(context, attrs);
73 verifyNotNull(view, name);
74 break;
75 default:
76 // The fallback that allows extending class to take over view inflation
77 // for other tags. Note that we don't check that the result is not-null.
78 // That allows the custom inflater path to fall back on the default one
79 // later in this method.
80 view = createView(context, name, attrs);
81 }
82
83 if (view == null && originalContext != context) {
84 // If the original context does not equal our themed context, then we need to manually
85 // inflate it using the name so that android:theme takes effect.
86 view = createViewFromTag(context, name, attrs);
87 }
88
89 if (view != null) {
90 // If we have created a view, check its android:onClick
91 checkOnClickListener(view, attrs);
92 }
93
94 return view;
95 }
这边利用了大量的switch case来进行系统控件的创建,例如:TextView
代码语言:javascript复制
代码语言:javascript复制1@NonNull
2 protected AppCompatTextView createTextView(Context context, AttributeSet attrs) {
3 return new AppCompatTextView(context, attrs);
4 }
都是new 出来一个具有兼容特性的TextView,返回出去。但是,使用过switch 的人都知道,这种case形式的分支,无法涵盖所有的类型怎么办呢?这里switch之后,view仍然可能是null.
所以,switch之后,谷歌大佬加了一个if,但是很诡异,这段代码并未进入if,因为 originalContext != context并不满足....具体原因我也没查出来,(;´д`)ゞ
代码语言:javascript复制
代码语言:javascript复制1 if (view == null && originalContext != context) {
2 // If the original context does not equal our themed context, then we need to manually
3 // inflate it using the name so that android:theme takes effect.
4 view = createViewFromTag(context, name, attrs);
5 }
然而,这里的补救措施没有执行,那自然有地方有另外的补救措施:回到之前的LayoutInflater的下面这段代码:
代码语言:javascript复制
代码语言:javascript复制1 if (mFactory2 != null) {
2 view = mFactory2.onCreateView(parent, name, context, attrs);
3 } else if (mFactory != null) {
4 view = mFactory.onCreateView(name, context, attrs);
5 } else {
6 view = null;
7 }
这段代码的下面,如果view是空,补救措施如下:
代码语言:javascript复制
代码语言:javascript复制 1 if (view == null) {
2 final Object lastContext = mConstructorArgs[0];
3 mConstructorArgs[0] = context;
4 try {
5 if (-1 == name.indexOf('.')) {//包含.说明这不是权限定名的类名
6 view = onCreateView(parent, name, attrs);
7 } else {//权限定名走这里
8 view = createView(name, null, attrs);
9 }
10 } finally {
11 mConstructorArgs[0] = lastContext;
12 }
13 }
这里的两个方法onCreateView(parent, name, attrs)和createView(name, null, attrs);都最终索引到:
代码语言:javascript复制
代码语言:javascript复制 1public final View createView(String name, String prefix, AttributeSet attrs)
2 throws ClassNotFoundException, InflateException {
3 Constructor<? extends View> constructor = sConstructorMap.get(name);
4 if (constructor != null && !verifyClassLoader(constructor)) {
5 constructor = null;
6 sConstructorMap.remove(name);
7 }
8 Class<? extends View> clazz = null;
9
10 try {
11 Trace.traceBegin(Trace.TRACE_TAG_VIEW, name);
12
13 if (constructor == null) {
14 // Class not found in the cache, see if it's real, and try to add it
15 clazz = mContext.getClassLoader().loadClass(
16 prefix != null ? (prefix name) : name).asSubclass(View.class);
17
18 if (mFilter != null && clazz != null) {
19 boolean allowed = mFilter.onLoadClass(clazz);
20 if (!allowed) {
21 failNotAllowed(name, prefix, attrs);
22 }
23 }
24 constructor = clazz.getConstructor(mConstructorSignature);
25 constructor.setAccessible(true);
26 sConstructorMap.put(name, constructor);
27 } else {
28 // If we have a filter, apply it to cached constructor
29 if (mFilter != null) {
30 // Have we seen this name before?
31 Boolean allowedState = mFilterMap.get(name);
32 if (allowedState == null) {
33 // New class -- remember whether it is allowed
34 clazz = mContext.getClassLoader().loadClass(
35 prefix != null ? (prefix name) : name).asSubclass(View.class);
36
37 boolean allowed = clazz != null && mFilter.onLoadClass(clazz);
38 mFilterMap.put(name, allowed);
39 if (!allowed) {
40 failNotAllowed(name, prefix, attrs);
41 }
42 } else if (allowedState.equals(Boolean.FALSE)) {
43 failNotAllowed(name, prefix, attrs);
44 }
45 }
46 }
47
48 Object lastContext = mConstructorArgs[0];
49 if (mConstructorArgs[0] == null) {
50 // Fill in the context if not already within inflation.
51 mConstructorArgs[0] = mContext;
52 }
53 Object[] args = mConstructorArgs;
54 args[1] = attrs;
55
56 final View view = constructor.newInstance(args); // 真正需要关注的关键代码,就是这一行,执行了构造函数,返回了一个View对象
57 if (view instanceof ViewStub) {
58 // Use the same context when inflating ViewStub later.
59 final ViewStub viewStub = (ViewStub) view;
60 viewStub.setLayoutInflater(cloneInContext((Context) args[0]));
61 }
62 mConstructorArgs[0] = lastContext;
63 return view;
64
65 } catch (NoSuchMethodException e) {
66 ·····
67 }
68 }
这么一大段好像有点让人害怕。其实真正需要关注的,就是反射的代码,最后的 newInstance().
OK,Activity上那些丰富多彩的View的来源,就说到这里, 如果有看不懂的,欢迎留言探讨. ( ̄▽ ̄) !
app中资源文件大管家 Resources / AssetManager 是怎么工作的
从我们的终极目的出发:我们要做的是“换肤”,如果我们拿到了要换肤的View,可以对他们进行setXXX属性来改变UI,那么属性值从哪里来?界面元素丰富多彩,但是这些View,都是用资源文件来进行 "装扮"出来的,资源文件大致可以分为:
图片,文字,颜色,声音视频,字体等。如果我们控制了资源文件,那么是不是有能力对界面元素进行set某某属性来进行“再装扮”呢?当然,这是可行的。因为,我们平时拿到一个TextView,就能对它进行setTextColor,这种操作,在view还存活的时候,都可以进行操作,并且这种操作,并不会造成Activity的重启。
这些资源文件,有一个统一的大管家。可能有人说是R.java文件,它里面统筹了所有的资源文件int值.没错,但是这个R文件是如何产生作用的呢?答案:Resources.*
本来这里应该写上源码追踪记录的,但是由于 源码无法追踪,原因暂时还没找到,之前追查setContentView(R.layout.xxxx)的时候还可以debug,现在居然不行了,很诡异!
答案找到了:因为我使用的是 真机,一般手机厂商都会对原生系统进行修改,然后将系统写到到真机里面。
而,我们debug,用的是原生SDK。用实例来说,我本地是SDK 27的源码,真机也是27的系统,但是真机的运行起来的系统的代码,是被厂家修改了的,和我本地的必然有所差别,所以,有些代码报红,就很正常了,无法debug也很正常。
既然如此,那我就直接写结论了,一张图说明一切:
3.5
"全app一键换肤" Demo源码详解
项目工程结构:
关键类 SkinFactory
SkinFactory类, 继承LayoutInflater.Factory2 ,它的实例,会负责创建View,收集 支持换肤的view
关键类的调用方式
初始化"换肤引擎"
代码语言:javascript复制
代码语言:javascript复制1public class MyApp extends Application {
2
3 @Override
4 public void onCreate() {
5 super.onCreate();
6 //初始化换肤引擎
7 SkinEngine.getInstance().init(this);
8 }
9}
劫持 系统创建view的过程
代码语言:javascript复制
代码语言:javascript复制 1public class BaseActivity extends AppCompatActivity {
2
3 ...
4
5 @Override
6 protected void onCreate(Bundle savedInstanceState) {
7 // TODO: 关键点1:hook(劫持)系统创建view的过程
8 if (ifAllowChangeSkin) {
9 mSkinFactory = new SkinFactory();
10 mSkinFactory.setDelegate(getDelegate());
11 LayoutInflater layoutInflater = LayoutInflater.from(this);
12 layoutInflater.setFactory2(mSkinFactory);//劫持系统源码逻辑
13 }
14 super.onCreate(savedInstanceState);
15 }
执行换肤操作
代码语言:javascript复制
代码语言:javascript复制1protected void changeSkin(String path) {
2 if (ifAllowChangeSkin) {
3 File skinFile = new File(Environment.getExternalStorageDirectory(), path);
4 SkinEngine.getInstance().load(skinFile.getAbsolutePath());//加载外部资源包
5 mSkinFactory.changeSkin();//执行换肤操作
6 mCurrentSkin = path;
7 }
8 }
注意事项
- 皮肤包skin_plugin module,里面,只提供需要换肤的资源即可,不需要换肤的资源,还有src目录下的源码(只是删掉java源码文件,不要删目录结构啊....(●´∀`●)),不要放在这里,无端增大皮肤包的体积.
- 皮肤包 skin_plugin module的gradle sdk版本最好和app module的保持完全一致,否则无法保证不会出现奇葩问题.
- 皮肤包 skin_plugin module的gradle sdk版本最好和app module的保持完全一致,否则无法保证不会出现奇葩问题. 1File skinFile = new File(Environment.getExternalStorageDirectory(), "skin.apk");
- 上图中,打了两个皮肤包,要注意:打两个皮肤包运行demo,打之前,一定要记得替换drawable图片资源为同名文件,以及
不然切换没有效果.
4
结语
hook技术是安卓高级层次的技能,学起来并不简单,demo里面的注释我自认为写的很清楚了,如果还有不懂的,欢迎留言评论。读源码也并不是这么轻松的事,可是还是那句话,太简单的东西,不值钱,有高难度才有高回报。