Shadow对插件包管理的设计

8,786 阅读10分钟

在Shadow开源的代码中,首先分为core层和dynamic层。core层就完成了插件框架的全部功能,dynamic层又将插件框架动态化起来了。然后core层本身主要也分为两部分,一部分是loader相关的,一部分是manager相关的。其中loader就是解决插件框架核心功能的,比如将插件apk加载起来,将其中没有安装的Activity启动起来。而manager的功能就是管理插件包。这篇文章我们就梳理一下manager在管理插件包相关的设计。Manager对于启动插件的过程管理,在另外的文章中再谈。

InstalledApk

我们先假设没有Manager实现,单纯用core.loader,看看启动插件时需要提供什么参数。保证这个场景可用是我们设计SDK时的一个原则。因此有一个test-none-dynamic-host的半成品sample,目前只是验证这个场景是可以编译成功、启动成功的。未来还需要加强这个测试用例。

加载插件的入口方法是com.tencent.shadow.core.loader.ShadowPluginLoader#loadPlugin,这个方法接收的参数类型是com.tencent.shadow.core.common.InstalledApk。所以InstalledApk就是交给Loader的插件所有描述了。

InstalledApk是我们设计的始终不变的结构体,它处于打包在宿主的common模块。dynamic层加载loader实现、runtime实现时采用的也是这个结构体。我们可以看到InstalledApk只有4个变量:apkFilePathoDexPathlibraryPathparcelExtras。其中前3个是DexClassLoader加载apk所需的参数,确定了这3个参数就可以加载apk到DexClassLoader中了。第4个参数parcelExtras则是一个扩展字段,我们将另外一个Parcelable序列化后存储到这个变量中,达到动态扩展参数的目的。这个另外的Parcelable就是com.tencent.shadow.core.load_parameters.LoadParameters。这个LoadParameters可以任意修改,只需要manager和loader同时更新就可以了。

需要注意的是InstalledApk这个类的名字表明了这是一个已经安装了的插件。“免安装”的插件是免于安装到系统,但还是需要安装到我们的插件框架管理器(Manager)中的。Manager对于插件的安装,也是仿照系统安装正常app来设计的。主要工作就是将插件apk复制到Manager管理的特别目录中,就像系统安装apk是也将apk复制到data目录中一样。然后也像系统一样,将apk中的so解压复制出来。所以,构造InstalledApk传给loadPlugin之前,Manager要将插件apk置于宿主的data目录中,再将插件apk中所需ABI的so也解压到宿主的data目录中。必须将插件apk放在宿主的data目录中是因为我们的DexClassLoader只有权限加载宿主data目录中的文件。

LoadParameters

LoadParameters是插件的加载参数结构体。这是一个可以由动态实现的Manager和动态实现的Loader同时修改的类。这个结构体和宿主中的代码无关。目前LoadParameters中有4个参数:businessNamepartKeydependsOnhostWhiteList

businessName是业务名。Shadow允许一个Loader同时加载多个插件。只要这些插件没有so库冲突,这些插件可以是完全不相关的业务的。Shadow通过ClassLoader设计保证了多插件间Java类是隔离的,但是没法为Native的so库划出单独的内存空间。除了代码有可能冲突,对data目录的使用也是有可能冲突的。因为Shadow的原理是“插件是宿主代码的一部分”,所以所有插件都可以访问宿主的data目录。如果插件和宿主或者多插件之间有使用相同的data目录逻辑,比如MultiDex会向一个固定的SharePreference持久化数据,就会出现持久化数据的冲突。为了解决这个问题,引入的businessName。当businessName为空时,Shadow就认为这个插件跟宿主是同一个业务,这个插件直接使用宿主的data目录。当businessName设置了值时,Shadow就会在宿主的data目录中以businessName为参数新建一个子目录作为插件的data根目录使用。这样相同businessName的插件就会使用同一个data目录了,不同businessName的插件的data目录就相当于是隔离的了。

partKey是插件apk的别名。因为插件apk的文件名是有可能因为带了版本号或者什么参数而变化,所以有这样一个partKey作为一个插件apk的不变的别名。partKey可以用于在表示一个插件依赖另外一个插件时使用。Shadow内部在实现区分多插件逻辑时也会用partKey作为该apk的Key。在Loader等接口上的partKey参数指的也是这个partKey

dependsOn声明的是当前插件依赖哪些其他插件。指定依赖的插件,要填写插件的partKey。假设插件A依赖插件B,Shadow会将插件B的ClassLoader作为插件A的parent。这样插件A就可以访问插件B的类了。具有dependsOn声明的插件,它的ClassLoader是标准的双亲委派逻辑,不具备直接加载白名单中声明的宿主类的能力。代码体现在com.tencent.shadow.core.loader.blocs.LoadApkBloc#loadPlugin中构造PluginClassLoader时传入的specialClassLoadernull。因此,插件A如果依赖了插件B,在插件A的hostWhiteList中声明的宿主类是无效的。要将hostWhiteList声明在插件B中才有效。这个问题有人反馈过:github.com/Tencent/Sha… ,值得优化。插件A依赖插件B,还应该能使插件A中的资源依赖插件B中的资源,这应该表现在构造插件A的Resource对象时,将插件B作为插件A的android.content.pm.ApplicationInfo#sharedLibraryFiles。关于跨插件依赖资源,代码还没有上传,需要整理一下。请关注com.tencent.shadow.core.loader.blocs.CreateResourceBloc#create方法的实现变化。

hostWhiteList就是为了允许插件访问宿主的类而设计的参数。hostWhiteList中设置的是Java类的包名。没有设置dependsOn的插件会将宿主的ClassLoader作为parent,但是插件的ClassLoader不是正常的双亲委派逻辑。插件ClassLoader同时还将宿主的ClassLoader的parent作为名为specialClassLoader的变量持有。插件的ClassLoader加载类的主路径是先尝试自己加载,自己加载不到,再用specialClassLoader加载。当要加载的类处于hostWhiteList中则采用正常的双亲委派,用parent(也就是宿主的ClassLoader)加载。这样设计的目的就是为了让插件和宿主类隔离,又可以允许插件复用宿主的部分类。

关于so文件

处于apk中的so不能直接运行,要先解压到data目录中才能运行。这不是插件框架的限制,正常安装的App也是一样的过程。正常安装一个App,系统会在安装时根据当前手机的ABI自动确定一个合适的ABI,然后从apk中解压出指定ABI的so到data目录。所以插件框架在安装插件apk时也需要决定采用哪个ABI。Shadow目前的设计,没有自动化这一过程,需要在继承的PluginManager子类上Override com.tencent.shadow.core.manager.BasePluginManager#getAbi方法,返回需要解压的ABI。

插件的ABI不能像正常app一样任意决定,因为现在大部分手机都是64位的了,而Android系统不允许在一个进程中混用64位的so和32位的so。所以在64位还是32位这个选择上,插件要和宿主保持一致。一个特殊的情况,如果宿主没有任何so,安装在64位手机上时,系统会认为这是一个64位应用。进而导致插件不能加载32位的so。这个问题,除了让宿主先加载一个32位的so之外,我还没有找到合适的解决方法。

解压so的方法是com.tencent.shadow.core.manager.BasePluginManager#extractSo。可以在实现中看到so目录的确定是根据UUID确定的,跟partKey无关。这是因为我们没有技术手段能支持在同一个进程中将so隔离开。所以在同一个进程中加载的多个插件的so,相互之间没有隔离,都相当于是宿主加载的so。因此,插件A依赖插件B中的so,不需要特别声明。插件A同插件B有冲突so,又需要在同一个进程中工作,也需要so的设计方自行解决。

config.json的设计

config.json是一个可以插件包的描述。Manager通过com.tencent.shadow.core.manager.installplugin.PluginConfig#parseFromJson方法将json转换为com.tencent.shadow.core.manager.installplugin.PluginConfig对象,然后再通过com.tencent.shadow.core.manager.installplugin.InstalledDao#insert方法将插件包描述的所有信息写入到数据库中持久化存储。在这个过程中,将apk文件的相对路径转换成绝对路径。

config.json中有两部分内容,一部分是插件包的版本信息,另一部分是插件apk描述。

跟版本信息相关的字段有:versioncompact_versionUUIDUUID_NickName

version表示的是该config.json文件采用的格式版本;compact_version表示的是当前config.json文件跟哪些格式的旧版本是兼容的,可以被支持旧版本格式的Manager使用。

UUID表示的是插件包内容的版本,只有相同UUID的apk才能一同工作。apk有3中类型:Loader、Runtime、Plugin。所以在同一个config.json中描述的的所有apk都具有相同的UUID,所以能一同工作。UUID是要按照一般UUID生成算法生成的,以保证多次发布的插件版本不会重复。UUID_NickName则是一般业务使用的版本名,对插件更新逻辑没有实际作用。

需要注意的是,多个config.json是可以采用同一个UUID的。因为我们有时需要分段下载插件包,下载一部分先启动一部分。所以可以将插件apk分到多个config.json中,采用相同的UUID。并且,Loader和Runtime只需要存在于其中一个插件包中就可以了。

Loader、Runtime和Plugin的描述都有两个基本信息:apkNamehash。这个设计是为了便于未来实现即使UUID不同的config.json中也可能存在相同hash,没有发生变化的插件。那么根据hash相同,则可以决定跨UUID复用本地已经存在的插件了。

对于Plugin来说,还存在partKeybusinessNamehostWhiteList,作用如之前所说的,可以在这里设置这些参数。

插件包生成Gradle插件

为了方便直接生成插件包,无需手工填写config.json,我们实现了一个生成插件包的Gradle插件。也就是Sample中看到的shadow {packagePlugin {}}DSL。这样可以通过Gradle脚本动态填写config.json的一些参数。

执行packageDebugPlugin任务时会自动生成UUID。如果要复用之前UUID,可以在插件包生成的build目录放一个uuid.txt文件,将UUID指定在里面。这部分代码见com.tencent.shadow.core.gradle.extensions.PackagePluginExtension#toJson。需要注意的是,如果在源码依赖的sample中这样固定了UUID,会导致更新代码的sample-plugin不能正常更新安装,因为它的UUID 总是不变的。

生成的插件包zip的文件名,可以通过PluginSuffix环境变量添加后缀。代码见com.tencent.shadow.core.gradle.CreatePackagePluginTaskKt#createPackagePluginTask

关于这个Gradle插件,目前的实现主要还是满足我们业务的基本需求。看起来设计上是不够通用,也不是很健壮的。有几个简单的单元测试在projects/sdk/core/gradle-plugin/src/test中。欢迎大家贡献代码。

为什么我们的插件包是个zip包?

其实仔细分析一下前面所讲的所有设计,会发现我们不应该将config.json和所有apk一起打包在一个zip包中。这样做不到跨UUID复用apk。这是因为我们在开发Shadow时,新旧插件框架同时运行,而且没有人力修改插件的发布系统。插件的发布系统一直是发布zip包的。所以Shadow在前面所有涉及的基础上,封装了一层从zip安装插件包的实现。这一实现未来修改时应该不会影响底层设计。因此我们未来也会修改这一设计。