前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Hook源码实现阿里无闪烁换肤

Hook源码实现阿里无闪烁换肤

作者头像
Rouse
发布2019-07-17 17:47:02
1.4K0
发布2019-07-17 17:47:02
举报
文章被收录于专栏:Android补给站

西柚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思路

  1. "劫持"系统创建View的过程,我们自己来创建View系统原本自己存在创建View的逻辑,我们要了解这部分代码,以便为我所用.
  2. 收集我们需要换肤的View(用自定义view属性来标记一个view是否支持一键换肤),保存到变量中劫持了 系统创建view的逻辑之后,我们要把支持换肤的这些view保存起来
  3. 加载外部资源包,调用接口进行换肤外部资源包,是.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    }
注意事项

  1. 皮肤包skin_plugin module,里面,只提供需要换肤的资源即可,不需要换肤的资源,还有src目录下的源码(只是删掉java源码文件,不要删目录结构啊....(●´∀`●)),不要放在这里,无端增大皮肤包的体积.
  2. 皮肤包 skin_plugin module的gradle sdk版本最好和app module的保持完全一致,否则无法保证不会出现奇葩问题.
  3. 皮肤包 skin_plugin module的gradle sdk版本最好和app module的保持完全一致,否则无法保证不会出现奇葩问题. 1File skinFile = new File(Environment.getExternalStorageDirectory(), "skin.apk");
  1. 上图中,打了两个皮肤包,要注意:打两个皮肤包运行demo,打之前,一定要记得替换drawable图片资源为同名文件,以及

不然切换没有效果.

4

结语

hook技术是安卓高级层次的技能,学起来并不简单,demo里面的注释我自认为写的很清楚了,如果还有不懂的,欢迎留言评论。读源码也并不是这么轻松的事,可是还是那句话,太简单的东西,不值钱,有高难度才有高回报。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2019-07-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Android补给站 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Activity 的 setContentView(R.layout.XXX) 到底在做什么?
  • 那么就进入下一个环节:LayoutInflater又做了什么?
  • app中资源文件大管家 Resources / AssetManager 是怎么工作的
  • 项目工程结构:
  • 关键类 SkinFactory
  • 关键类的调用方式
  • 注意事项
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档