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

西柚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.跟踪进去:

 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属性

并且在这里,发现一段关键代码:

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类

 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

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并不满足....具体原因我也没查出来,(;´д`)ゞ

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的下面这段代码:

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是空,补救措施如下:

 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);都最终索引到:

 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

关键类的调用方式

初始化"换肤引擎"

1public class MyApp extends Application {
2
3    @Override
4    public void onCreate() {
5        super.onCreate();
6        //初始化换肤引擎
7        SkinEngine.getInstance().init(this);
8    }
9}

劫持 系统创建view的过程

 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    }

执行换肤操作

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里面的注释我自认为写的很清楚了,如果还有不懂的,欢迎留言评论。读源码也并不是这么轻松的事,可是还是那句话,太简单的东西,不值钱,有高难度才有高回报。

原文发布于微信公众号 - Android补给站(PayneDev)

原文发表时间:2019-07-15

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券