Shadow的全动态设计原理解析

10,624 阅读12分钟

我们在宣传Shadow的时候说了Shadow具有两大特性,其中一个叫做“全动态插件框架”。这篇文章就讲这个特性。我们很早之前用过一款基于数百反射私有API实现的插件框架,在前面的文章也提过,在这种插件框架里要不停的兼容新版本的Android系统,OEM系统。尤其是Activity的attach方法,经常需要兼容。还有壳子Activity上也偶尔要补充覆盖方法。但实际上,这些需求都满足不了。因为最早我们也是像市面上能见到的其他插件框架一样,将插件框架打包在宿主里的。所以,这些修复和更新代码只有在宿主的下一个版本才能生效。改Bug就还好,下一个版本就没问题了,但是新特性就麻烦了,需要保证使用了新特性的插件不会被老版本的宿主启动。

动态化的基本原理

我们先直接回顾动态化的基本原理,再说明Shadow是如何应用这个基本原理的。动态化的基本原理非常简单,是Java的基本知识。但是确实有很多人没能彻底理解,也不会灵活运用。

Java代码编译的时候是没有链接过程的。链接过程指的是传统的C语言在编译的时候分为两个步骤,一是将源码编译成机器码,对于其中引用了其他文件的符号(比如文件中的全局变量),暂时用符号名代替。然后第二个步骤是链接步骤,在这个步骤中将前一个步骤中暂时使用的符号名真正替换为实际内存地址。C语言的这个编译过程在Java角度来看,就相当于Java源码先编译成了字节码,对于源码中引用的其他类,先暂时在字节码中以名称代替。然后在一个链接过程中将字节码中的名称替换为其他类的真正实现代码。不过,实际情况是,Java就没有这个链接过程。Java编译的字节码中保存的就是其他类的名称。其他类的实现是在运行时才去查找的。因此这一过程又相当于是C语言的动态链接过程。我们前面提到的C语言编译过程被称为静态链接。所以,有一些学过C语言的人评论Java说,Java这门语言是完全动态链接的语言,是一门动态语言。这里说“动态语言”指的是链接过程是动态的。我们平时如果说“动态语言”还有一种可能性指的是类型是否动态,Java是一门静态类型语言,这两个静态、动态不要搞混了。

除了一些优化成Native实现的特殊系统类,Java的类都是在运行时由ClassLoader动态加载的。如果类A引用了类B,在类A的代码执行到要用B时,就会向加载了自己的ClassLoader查找类B的实现。找到了类B的实现,才能new出来B的实例,才能继续执行,或者是才能调用B的静态方法。而且同一个ClassLoader加载的同一个名字的类才是运行时实际上的同一个类。一个类A有public static final int a静态域,想问有没有可能类B和类C中相同的代码System.out.println(A.a);会打印出来不同的值?答案应该是“有可能的”。因为在精心构造的ClassLoader结构下,类B和类C可能分别由不同的ClassLoader加载的,那么它们向各自的ClassLoader请求到的类A的实现可能是不同的。就算是类A的实现只有一份,类B和类C加载到的类A也是两个不同的类。一旦用反射修改其中一个的a静态域,另一个的a静态域是不会跟着变化的。

Java还有两个和动态化相关的特性,一个是接口,另一个是向上转型。

Class<?> implClass = classLoader.loadClass("com.xxx.AImpl");
Object implObject = implClass.newInstance();
A a = (A) implObject;

这里假设classLoader动态加载了一些Java类,其中就有一个类叫做com.xxx.AImpl,AImpl继承自A,或者AImpl实现了A接口。注意这里用了强制类型转换,是因为代码层面是将Object类型向下转型成了A。但实际上我们知道implObject的类型是AImpl,AImpl转换成A是一个向上转型。向上转型总是安全的。所以用这种方法总是可以先定义出接口,精心设计接口,让接口足够通用和稳定。只要接口不变,它的实现总是可以修改的。我们将接口打包在宿主中,接口就轻易不能更新了。但是它的实现总是可以更新的。

所有的插件框架中,Activity的加载都是这样的,new一个DexClassLoader加载插件apk。然后从插件ClassLoader中load指定的插件Activity名字,newInstance之后强转为Activity类型使用。实际上Android系统自身在启动Activity时也是这样做的。所以这就是插件机制能动态更新Activity的基本原理。

所以,所有的插件框架在解决的问题都不是如何动态加载类,而是动态加载的Activity没有在AndroidManifest中注册,该如何能正常运行。如果Android系统没有AndroidManifest的限制,那么所有插件框架都没有存在的必要了。因为Java语言本身就支持动态更新实现的能力。

Manager的动态化

Shadow的Manager的功能就是管理插件,包括插件的下载逻辑、入口逻辑,预加载逻辑等。反正就是一切还没有进入到Loader之前的所有事情。

由于Manager就是一个普通类,不是Android系统规定要在Manifest中注册才能使用的类,所以Manager的动态化就是一般性的动态加载实现。

为了让宿主中的固定代码足够的少,我们给Manager定义的接口就是一个类似传统Main函数的接口。

void enter(Context context, long formId, Bundle bundle, EnterCallback callback);

这就是Manager的唯一方法,宿主中只会调用这个方法。传入当前界面的Context以便打开下一个插件Activity。将所有插件中可能用到的参数通过Bundle传给插件。定义一些fromId,用来让Manager的实现逻辑分辨这一次enter是从哪里来的。实际上在宿主中的每一处enter调用都可以设置不同的fromId,就相当于让Manager知道调用来自宿主中的哪一行代码了。再传入一个EnterCallback供Manager可以返回一个动态加载的View作为插件的Loading View。

Loader的动态化

Loader就是负责加载插件Activity,然后实现插件Activity的生命周期等功能的那部分核心逻辑了。很多插件框架就只有Loader这部分功能,或者说只开源了Loader这部分功能。一般来说,Loader是宿主到插件的桥梁。比如说我们要在宿主中执行Loader的代码,才能Hack一些系统类,让它们加载插件Activity。或者在宿主中的代理壳子Activity中,也要使用Loader去加载插件Activity完成转调功能。所以通常宿主代码就直接依赖了Loader的代码。这就是为什么其他插件框架都需要将插件框架本身的代码打包在宿主中。

稍复杂一点的问题就是代理壳子ContainerActivity需要和PluginActivity通过Loader相互调用。所以Shadow应用前面提到的动态化原理时,做了双向的接口,可以看到代码中的HostActivityDelegateHostActivityDelegator。通过定义出这两个接口,可以避免ContainerActivity和Loader相互加载对方时还需要加载对方所依赖的其他类。定义成接口,就只需要加载这个接口就行了。

通过这个设计,插件框架的绝大部分需要修改或修复的代码就都可以动态发布了。并且也使得在同一个宿主中可以有多个不同实现的Loader,这样业务就可以针对业务自身的bug修改Loader的代码,不会影响其他业务了。紧急情况下Loader也可以耦合业务逻辑。

Container的动态化

Container就是那些注册在宿主AndroidManifest中的代理壳子。由于Activity的创建是系统根据Activity的名字直接通过宿主的PathClassLoader构造的,所以这些Activity必须打包在宿主中才能处于PathClassLoader,才能被系统找到。所以Container是不能放到Loader中,通过动态加载的一般方法加载的。因为前面提到的一般方法都是要new一个新的ClassLoader加载动态实现的。

但是我们业务的宿主对合入代码的增量要求极其严格,是要求0增量合入的。也就是我们合入代码的同时还要优化原有代码,使整体0增量。增量既包含安装包体积增量,也包含方法数增量。

所以做了Loader的动态化还是不够的,因为代理壳子Activity上需要提前Override非常多的方法。同时由于定义了Delegate和Delegator接口,还在Delegator接口上又添加了superOnCreate等方法,导致Activity上每有一个需要Override的方法,就要增加4个方法数,而Activity上大概有350个方法。

Container的实现由于前面Loader的动态化已经变得非常简单了,无论是什么方法,都是转调给Delegate接口,自己不实现任何逻辑。按理说可以认为不会有什么Bug了,至少将方法全部覆盖实现,Container即使不动态化也是可以长期使用的。

Android系统的虚拟机和一般的JVM有一点不太一样,就是可以通过反射修改private final域。这在一般的JVM上是不能成功的,读过《Java编程思想》的同学可能还记得专门有这段讲解。而ClassLoader类的parent域,恰恰就是private final域。ClassLoader的parent指向的是ClassLoader的“双亲”,就是“双亲委派”中的那个“双亲”(现在去学习这个概念的同学注意这里的“双”是没有意义的,不存在两个“亲”)。宿主的PathClassLoader就是一个有正常“双亲委派”逻辑的ClassLoader,它加载任何类之前都会委托自己的parent先去加载这个类。如果parent能够加载到,自己就不会加载了。因此,我们可以通过修改ClassLoader的parent,为ClassLoader新增一个parent。将原本的BootClassLoader <- PathClassLoader结构变为BootClassLoader <- DexClassLoader <- PathClassLoader,插入的DexClassLoader加载了ContainerActivity就可以使得系统在向PathClassLoader查找ContainerActivity时能够正确找到实现。

所以我们就迫于无奈做了Container的动态化,也在这个动态化中使用了唯一一次反射修改私有变量。这里要承认,Shadow开源的全部代码中确实有这一处反射。跟Shadow宣传的零反射是有点冲突的。这里值得辩驳一点的是,零反射是和传统插件框架解决动态加载Activity等组件时是否使用反射来对比的。Container的动态化,乃至Shadow的dynamic层对于解决其他插件框架相同的问题来说都不是必要的部分。特别是Container的动态化是可选的。

ClassLoader的parent域不属于非公开API,甚至不是Android的代码,而是JDK的代码。而且这个反射的实现不需要硬编码“parent”这个单词,因为有getParent这个方法可以使我们通过运行时对比确定parent域。所以,这一处反射实现还是比较安全的,实际上我们线上运行了3年了,也从来没见过失败的情况。

Container的动态化虽然可以说是不必要的,但确实还是有好处的。有了Container的动态化,我们就没必要一次性实现Container上的所有需要Override的方法了。可以在业务需要时再添加。

关于Container的动态化,可以具体看com.tencent.shadow.dynamic.host.DynamicRuntime这个类的实现。

另外Runtime虽然包含了Container,但是实际上只有Container需要这样动态化。Runtime中的其他类是因为简化实现的关系放在了一起,其他类是可以按传统做法加载的,只需要在PluginClassLoader上方即可。

关于动态加载接口实现的实践经验

最后再分享一点关于加载接口实现的经验。我们在Shadow里专门写了一个ApkClassLoader类,封装了com.tencent.shadow.dynamic.host.ApkClassLoader#getInterface泛型方法,可以直接获得接口类型实例。可以注意到,这个方法是不支持有参数的构造器的。看我们代码历史,可以找到有参数版本的实现,但是最终删掉了。因为我发现,传一组参数类型class令牌,并不能在编译期跟实现类的构造器关联起来。就是说实现类的构造器如果参数列表变化了,这边调用getInterface的参数没有修改是不能在编译期发现的。会等到运行时才会抛出找不到那种参数列表的构造器的异常。所以我改成了定义工厂接口的形式,也就是getInterface总是取出来一个工厂接口,然后再通过工厂接口build有参数的对象。这样实现类的构造参数列表变化,就能在编译期检查出来了。

可以查看Shadow代码中的com.tencent.shadow.dynamic.host.LoaderFactorycom.tencent.shadow.dynamic.host.ManagerFactory来分析我讲的区别。

总结

Shadow将我们定义的插件框架的所有部分全部实现了动态化加载,使得插件框架自身的问题可以动态修复,也使得插件框架成为了插件包的一部分,避免了插件需要适配不同版本插件框架的问题。

这个特性在实践中比无Hack实现更为重要。因为它甚至使得我们在不跟宿主版本的情况下,不改宿主一行代码,就把数百反射实现的旧框架替换为了无Hack实现的Shadow框架。使得我们在做这个切换时,可以完全不考虑旧框架的维护了。