01hr背景 移动互联网进入存量时代,随着人口红利减退,充分盘活、经营现有流量便成为了各行各业全新的机遇与挑战。各大公司都在内卷发力,对App包大小、启动速度、性能做持续优化。 App包体积和用户转换率成负相关,包体积越小、用户下载时长越短,用户转换率越高。而随着国内用户的增量见顶,越来越多的应用选择出海,开发对应的海外版,GooglePlay应用市场目前强制要求超过100MB的应用只能使用AAB扩展文件方式上传,GooglePlay会为我们的应用托管AAB扩展文件,进行自定义分发和动态交付。 (可以看到,排名靠前的App包大小基本是都100M,很多App都上架了极速版) 58同城App对包大小这块也非常关注,每次发布版本之前都会对包大小进行分析与监控,下图为Android32位包大小变化: 近期我们在对包大小进行新一轮的梳理,过程中发现:人脸认证库内置了4套框架,当自研框架认证异常时将切换到其他框架,这种策略下内置4套框架对包大小造成了较大的负重。经过分析与调研,达成了共识方案:内置腾讯认证,把自研认证和阿里认证动态化,预计收益可达4M,后期方案落地后逐步推进腾讯认证的动态化,预计可再减少1。66M。 包大小减少的常用手段非常多,主要分类还是技术手段和业务手段: 当前选择动态化作为技术选项,是因为我们在技术手段上对包大小做的努力已基本见顶,同时从认证模块的背景考虑,低频、低耦合正适合于插件化场景。动态化又分成了正规军AndroidAppBundle和国内的游击队插件化,至于58同城在插件化、AAB上的探索和实现上的技术选型,后面的章节中会进行讲解。最终效果: 插件化便是本文章的重点,插件化一般用来做两件事:减少基础包大小和动态更新。插件化是移动端模块化、插件化、组件化三剑客之一,历史也非常久了,网上公开的免费插件化文章很多都是纯概念型或纯方案型,本文章将会从01讲解插件化的知识,配合实战经验,让你有所收获。 工欲善其事,必先利其器,我们先来了解插件化中涉及到的相关知识点。 02hr插件化需要了解的知识在正式了解插件化之前,需要了解插件化会涉及到的相关概念,整个文章是一个循序渐进的过程。这样在后面讲到插件化需要解决的问题、现有插件化框架的对比、插件化的实现时可以做到知其然而知其所以然。2。1类加载过程和类加载器2。1。1Java 首先,我们来了解下类加载的过程和类加载器,以java文件为例,类加载干过程如下: 加载:这部分涉及到类加载器,将class文件加载到内存,创建对应的Class对象连接:包括验证、准备、解析三部分。验证阶段会检验被加载的类是否有正确的内部结构,并和其他类协调一致;准备阶段则负责为类的静态属性分配内存,并设置默认初始值;解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。初始化:JVM负责对类进行初始化,主要是对静态属性静态块进行初始化。 那么一个类什么时候会被触发加载过程呢?除去系统类、扩展类外,我们程序的类什么时候执行,主要包括以下几种情况:创建类的实例,如newXXX();调用某个类的静态方法;访问某个类或接口的静态属性,或为该静态属性赋值;通过反射方式来创建某个类或接口对应的java。lang。Class对象,如使用Class。forName(XXX)初始化某个类的子类。初始化子类时,所有的父类都会被初始化。 那么讲完了Java类的加载过程,我们再来看下它的类加载器: 类加载器加载类遵循双亲委派模式,这是基于安全和效率方面的考虑,实现委派模式是通过ClassLoader构造器中的parent来做的,我们看一下ClassLoader抽象类:publicabstractclassClassLoader{protectedClasslt;?loadClass(Stringname,booleanresolve)throwsClassNotFoundException{检测是否已装载过该ClassClasslt;?cfindLoadedClass(name);未装载过if(cnull){try{是否有父ClassLoader,有的话使用父ClassLoader尝试加载if(parent!null){cparent。loadClass(name,false);}else{没有父ClassLoader,使用BootstrapClassLoader尝试加载cfindBootstrapClassOrNull(name);}}catch(ClassNotFoundExceptione){ClassNotFoundExceptionthrownifclassnotfound}if(cnull){上述都没找到,使用自身ClassLoader加载cfindClass(name);}}returnc;}}2。1。2Android Android区别于Java的两个核心点:基于Dex文件格式,而非class文件格式,当然Dex里包含class虚拟机为DavilkART,而非JVM 其实dex和Class本质上都是一样的,都是二进制流文件格式,dex文件是从class文件演变而来的:class文件存在冗余信息,dex文件则去掉了冗余,并且整合了整个工程的类信息,在Android中做插件化和热修复都离不开dex。 classVSdex:内存占用大,不适合移动端:dex做了各种优化和去冗余堆栈的加载模式,加载速度慢文件IO操作多,类查找慢:Android虚拟机直接IOloaddex,再进行类加载 Android的类加载器包括: ClassLoader是一个抽象类,其中定义了ClassLoader的主要功能。SecureClassLoader拓展了ClassLoader类加入了权限方面的功能,加强了ClassLoader的安全性。URLClassLoader它继承自SecureClassLoader,用来通过URl路径从jar文件和文件夹中加载类和资源。BootClassLoader是ClassLoader的内部类,用于加载一些系统Framework层级需要的类。BaseDexClassLoader继承自ClassLoader,是抽象类ClassLoader的具体实现类,PathClassLoader和DexClassLoader都继承它。PathClassLoader加载系统类和应用程序的类,如果是加载非系统应用程序类,则会加载dataapp目录下的dex文件以及包含dex的apk文件或jar文件(已安装)DexClassLoader可以加载自定义的dex文件以及包含dex的apk文件或jar文件,也支持从SD卡进行加载InMemoryDexClassLoader是Android8。0新增的类加载器,继承自BaseDexClassLoader,用于加载内存中的dex文件。 这里需要注意一点,我们的应用程序的默认ClassLoader为PathClassLoader,而PathClassLoader的父ClassLoader为BootClassLoader,这也是为什么bugly上一些ClassNotFound堆栈顶部为BootClassLoader:ClassLoader。javaprivatestaticClassLoadercreateSystemClassLoader(){StringclassPathSystem。getProperty(java。class。path,。);StringlibrarySearchPathSystem。getProperty(java。library。path,);returnnewPathClassLoader(classPath,librarySearchPath,BootClassLoader。getInstance());} 在AndroidClassLoader中,Dex最终会被运行解析成DexElements,我们查看Android源码BaseDexClassLoader,可以看到:packagedalvik。system;publicclassBaseDexClassLoaderextendsClassLoader{privatefinalDexPathListpathList;publicBaseDexClassLoader(StringdexPath,FileoptimizedDirectory,StringlibraryPath,ClassLoaderparent){super(parent);this。pathListnewDexPathList(this,dexPath,libraryPath,optimizedDirectory);}OverrideprotectedClasslt;?findClass(Stringname)throwsClassNotFoundException{ClasscpathList。findClass(name,suppressedExceptions);if(cnull){throwerr;}returnc;}OverrideprotectedURLfindResource(Stringname){returnpathList。findResource(name);}OverrideprotectedEnumerationURLfindResources(Stringname){returnpathList。findResources(name);}OverridepublicStringfindLibrary(Stringname){returnpathList。findLibrary(name);}} 通过dexPath(dex路径)、libraryPath(so路径)、optimizedDirectory(oat优化存储目录)构建了DexPathList,而classloader的findClass、findLibrary、findResources都委托给了它去查找,也是AndroidClassLoader使用Dex加载的实现部分(区别于JavaClassLoader),最终loadClass会调用到DexFile的:privatestaticnativeClassdefineClassNative(Stringname,ClassLoaderloader,intcookie)throwsClassNotFoundException,NoClassDefFoundError; 2。2ClassLoader的findClass、findLibrary、findResource 上面我们了解了ClassLoader相关的知识,那么在Android插件化中,我们还需要了解相关的几个常用方法。2。2。1findClass 根据类完整名称去查找Class对象,如findClass(com。xx。Test),同时需要注意,在ClassLoader中关于class加载的有以下几个方法: 在打印App默认的PathClassLoader对象时,可以看到当前的类查找路径,路径一般以。apk、。jar结尾,ClassLoader变化在这些路径进行类查找,在插件化中,合并插件的dex路径也会出现在当中,如果是独立的ClassLoader,则里面只有插件本身的路径:DexPathList〔〔zipfiledataappcom。wubav5PkwKJhGUzCf2aDtJEQAQbase。apk,zipfiledatadatacom。wubalibraryhousehsgmainpluginall0。1。0hsgmainpluginrelease。jar〕 2。2。2findLibrary 查找、加载so库使用,我们在打印App默认的PathClassLoader对象时,可以看到当前可查找的so的路径,ClassLoader便会从这些路径进行so查找,在插件化中,合并插件的so路径也会出现在当中,如果是独立的ClassLoader,则里面只有插件so本身的路径:nativeLibraryDirectories〔dataappcom。wubav5PkwKJhGUzCf2aDtJEQAQlibarm,dataappcom。wubav5PkwKJhGUzCf2aDtJEQAQbase。apk!libarmeabiv7a,systemlib〕〕 关于ClassLoader中so查找有以下1个需要关注的方法:StringfindLibrary(Stringlibname) findLibrary是根据so名称去查找它所在的完整路径,当代码触发System。loadLibrary(libName)时触发,findLibrary实现publicStringfindLibrary(StringlibraryName){StringfileNameSystem。mapLibraryName(libraryName);for(Filedirectory:nativeLibraryDirectories){StringpathnewFile(directory,fileName)。getPath();if(IoUtils。canOpenReadOnly(path)){returnpath;}}returnnull;} Framework一般不会让用户直接通过dlopen去加载动态链接库,而是封装了以下两种方式:publicfinalclassSystem{方式一:通过so文件路径加载publicstaticvoidload(Stringfilename){Runtime。getRuntime()。load0(VMStack。getStackClass1(),filename);}方式二:通过so库名加载publicstaticvoidloadLibrary(Stringlibname){Runtime。getRuntime()。loadLibrary0(VMStack。getCallingClassLoader(),libname);}} System。loadLibrary()最终通过dlopen()来实现: SysytemloadLibrarySysytemloadRuntimenativeLoadJavaNativedvmLoadNativeCodedlopen打开一个so文件,创建一个handle2。2。3findResourceURLfindResource(Stringname)这个方法用于查找资源,如findResource(file:D:workspaces),在Android中基本无用,Android有属于自己的一套资源管理方案。2。3DexClassLoader的oat配置 如果需要使用多ClassLoader时,需要自己构造DexClassLoader:classPluginDexClassLoaderextendsBaseDexClassLoader{privatePluginDexClassLoader(ListStringdexPaths,FileoptimizedDirectory,StringlibrarySearchPath,ClassLoaderparent)throwsThrowable{super((dexPathsnull)?:TextUtils。join(File。pathSeparator,dexPaths),optimizedDirectory,librarySearchPath,parent);}} 需要传入dex路径集合、so库目录、dex优化目录、以及父ClassLoader。DexClassLoader提供了optimizedDirectory,而PathClassLoader则没有(系统会自动生成以后缓存目录,即datadalvikcache,不同厂商不一样),optimizedDirectory是用来存放odex文件的地方,所以可以利用DexClassLoader实现动态加载。 这边简单介绍下几个概念:dex:java程序编译成class后,dx工具将所有class文件合成dex文件。odex(Android5。0之前):OptimizedDEX,即优化过的dex。Android5。0之前APP在安装时会进行验证和优化,为了校验代码合法性及优化代码执行速度,验证和优化后,会产生odex文件,运行apk的时候,直接加载ODEX,避免重复验证和优化,加快了apk的响应时间。oat(Android5。0之后):oat是ART虚拟机运行的文件,是ELF格式二进制文件,包含DEX和编译的本地机器指令,oat文件包含dex文件,因此比odex文件占用空间更大。Android5。0dex2oat默认会把classes。dex翻译成本地机器指令,生成ELF格 式的oat文件。不过android5。0之后oat文件还是以。odex后缀结尾,但是已经不是android5。0之前的文件格式,而是ELF格式封装的本地机器码。vdex:Android8。0以后加入,包含apk的未压缩dex代码,另外还有一些旨在加快验证速度的元数据。2。4LoadedApk 在Android中,我们通过context。getClassLoader()即可获取到程序默认的类加载器,当然这个加载器在没有任何处理的时候为PathClassLoader,那么如果我们想对其进行替换扩展该如何处理呢?首先我们需要找到它具体的持有者ContextImpl。java:packageandroid。app;classContextImplextendsContext{finalNonNullLoadedApkmPackageInfo;} 再看看LoadedApk,LoadedApk对象是apk文件在内存中的表示,在启动我们的应用进程后,经过systemserver的层层调用,最终会创建LoadedApk,可以看到下面代码,它持有了很多重要的信息,如主线程、包信息、Resources、ClassLoader、Application等:packageandroid。app;publicfinalclassLoadedApk{privatefinalActivityThreadmActivityThread;finalStringmPackageName;privateApplicationInfomApplicationInfo;privateStringmAppDir;privateStringmResDir;privateStringmDataDir;privateStringmLibDir;privateFilemDataDirFile;privatefinalClassLoadermBaseClassLoader;ResourcesmResources;privateClassLoadermClassLoader;privateApplicationmApplication;privateString〔〕mSplitNames;privateString〔〕mSplitAppDirs;privateString〔〕mSplitResDirs;privateString〔〕mSplitClassLoaderNames; 在插件化中,对ClassLoader、Resources的处理都可以通过它,它在一个进程内是全局唯一的。VirtualApp处理资源采用的就是替换LoadedApk的Resource对象。而替换默认的ClassLoader也可以通过反射替换掉LoadedApk中的mClassLoader,这个api相对来说很稳定,各Android版本没有做变更。2。5AssetManager、Resources 插件化中,除了class、libs相关的加载,另一个重点就是资源,在Android中与资源加载相关的两个类便是AssetManager、Resources。Resources用来获取res目录下的各种与设备相关的资源,而AssetManager则用来获取assets目录下的资源。 AssetManager属于Resources的一个属性:packageandroid。content。res;publicclassResources{privateResourcesImplmResourcesImpl;publicResources(AssetManagerassets,DisplayMetricsmetrics,Configurationconfig){this(null);mResourcesImplnewResourcesImpl(assets,metrics,config,newDisplayAdjustments());}publicfinalAssetManagergetAssets(){returnmResourcesImpl。getAssets();}} 可以看到构造Resources对象时,需要传入AssetManager对象,我们再来看看AssetManager:packageandroid。content。res;publicfinalclassAssetManagerimplementsAutoCloseable{AssetManager构造器使用UnsupportedAppUsage注解UnsupportedAppUsagepublicAssetManager(){}AssetManageraddAssetPath使用UnsupportedAppUsage注解UnsupportedAppUsagepublicintaddAssetPath(Stringpath){returnaddAssetPathInternal(path,falseoverlay,falseappAsLib);}可获取已安装的资源路径,使用UnsupportedAppUsage注解UnsupportedAppUsagepublicNonNullApkAssets〔〕getApkAssets(){synchronized(this){if(mOpen){returnmApkAssets;}}returnsEmptyApkAssets;}} AssetManager不允许App代码直接对其进行构造,所以在插件化过程中,如果要使用独立资源模式构建插件AssetManager需要用到反射,同时AssetManager添加资源查找路径的方法addAssetPath也不允许App代码直接访问,插件化中添加插件资源路径需要对其进行反射。AssetManager的资源路径一般包含以下几类: 而通过AssetManager的getApkAssets的getAssetPath方法可以获取到该AssetManager的资源路径(数组),不过这些方法对于App层都无法直接调用,需要使用反射。 Android应用中,Application、Activity、Service都可以获取到AssetManager和Resources对象:publicclassAppextendsApplication{OverridepublicResourcesgetResources(){returnsuper。getResources();}OverridepublicAssetManagergetAssets(){returnsuper。getAssets();}}publicclassTestActivityextendsActivity{OverridepublicResourcesgetResources(){returnsuper。getResources();}OverridepublicAssetManagergetAssets(){returnsuper。getAssets();}}publicclassTestServiceextendsService{OverridepublicResourcesgetResources(){returnsuper。getResources();}OverridepublicAssetManagergetAssets(){returnsuper。getAssets();}} 其实它们都指向当前Context中的LoadedApk的Resources,可以说是当前应用唯一的,在插件化中,我们就可以针对上述这些资源相关的类和方法进行处理。 03hr插件化需要解决的核心问题了解了插件化需要掌握的知识点后,我们再来了解一下插件化需要解决的核心问题。 可以看到上图宿主与插件的结构图,插件化需要解决的核心问题也是从这几块入手。3。1插件化的安全性和稳定性为什么Android包括iOS,以及当前流行的跨端框架Flutter都不允许对已安装的应用做插件动态更新,主要是基于对安全性和性能的顾虑。如绕过应用市场检测,给用户App进行木马插件等风险性的动态更新,同时对于运行性能会有影响,缺少各种对宿主包的优化。当然AndroidiOS允许JS的动态更新,毕竟JS文件可明文查看,而GooglePlay近两年也推出了官方的插件化能力AndroidAppBundle。 抛开这些,我们看看国内的插件化,在安全性和稳定性上存在问题的原因主要如下:安全性: 插件Apk的安全性问题,如被劫持篡改所以插件化框架一般都需要对插件Apk做签名和摘要验证 稳定性: 主要涉及到私有api的反射,不同Android版本、不同厂商对class、so、resources涉及到的隐私api都会存在差异,特别是需要将插件合并到宿主运行环境的情况 同时一些插件化框架为了绕过四大组件未安装的校验,做了很多的hook和反射,稳定性和性能都需要做大量的适配工作3。2class和so加载 对于class和so的加载,有两种模式:合并式和独立式。合并式 优点: 宿主与插件可直接互相访问 缺点: 稳定差,需要做大量适配 宿主与插件相同库如果出现不兼容,会出现对应class加载异常 独立式 优点: 几乎无反射,稳定性强,只需要hook一处用于扩展默认的PathClassLoader 不用处理宿主和插件相同库版本不兼容问题,宿主和插件ClassLoader分离 缺点: 相互访问比较麻烦,主要在于宿主和插件之间的访问,不过都可以通过拦截各自 ClassLoader的findClass、findLibrary来处理 由此可见,如果是独立的模块,不使用宿主包的能力,其实用独立插件很合适,但如果涉及到大量宿主能力的调用(不推荐,这样插件Apk过于依赖宿主的相关库的向下兼容性),需要对ClassLoader做更多的处理。3。2。1合并式 顾名思义,就是将插件dex路径合并到宿主的dex路径中,so路径合并宿主的so路径中,到主要通过classloader实现。 dex路径合并: (1)Android6。0及其以上 反射classloader的pathList,扩展其dexElements,扩展时反射makeDexElements(List,File,List) (2)Android4。4。26。0 反射classloader的pathList,扩展其dexElements,扩展时反射makeDexElements(ArrayList,File,ArrayList) (3)Android4。04。4。2 反射classloader的pathList,扩展其dexElements,扩展时反射makeDexElements(ArrayList,File) 可以看到,classloader。pathList。dexElements是稳定的私有api,主要区别在于扩展DexElements用到的makeDexElements方法签名不同,不过目前大多数App的最低运行版本已经升到了Android5。0 对于合并式,会出现类版本兼容性问题:可以看下图,插件和宿主都引用了同一个库,但是当宿主升级此库后,由于它内部未做向下兼容,删除了某些类或者修改了对外方法,如果此时插件不进行同步更新打包,那么运行将会出现问题。 又或者宿主和插件打包分离,都引用了同一个库,但这个库版本不兼容时,也会出现这个问题 如何解决?方法1:参考AAB打包规则,插件参与宿主打包过程,将相同依赖库打包到宿主,同时可判断插件是否需要做对应的更新方法2:分开打包,但需要制定相同依赖库升级的规则,不过这种方式会使插件包体积变大,存在冗余 so路径合并: (1)Android7。1及其以上 反射classloader的pathList,扩展其nativeLibraryDirectories,需要使用pathList的systemNativeLibraryDirectories、makePathElements、nativeLibraryPathElements等几个函数去扩展 (2)Android6。07。1 与7。1及其以上一致,区别在于makePathElements的方法签名不同 (3)Android4。06。0 反射classloader的pathList,扩展其nativeLibraryDirectories,直接构建新的ArrayList替换nativeLibraryDirectories即可 与dex路径合并一致,在不同版本之前存在一些差别。3。2。2独立式 独立式就是插件中的class和so使用独立的ClassLoader加载:publicclassPluginDexClassLoaderextendsBaseDexClassLoader{publicPluginDexClassLoader(ListStringdexPaths,FileoptimizedDirectory,StringlibrarySearchPath,ClassLoaderparent){super((dexPathsnull)?:TextUtils。join(File。pathSeparator,dexPaths),optimizedDirectory,librarySearchPath,parent);}} 可以看到,独立ClassLoader无反射,一般,我们会扩展程序默认的ClassLoader(一处反射:替换掉loadedApk中的classloader对象),在默认ClassLoaderfindClass、findLibrary异常时,再使用独立的ClassLoader去加载。3。3资源加载和资源id冲突 上面讲完了插件Apk中class和so的加载,我们再来看下资源如何加载处理。同样,资源加载也分为合并式和独立式:合并式 优点: 宿主与插件可直接互相访问资源 缺点: 稳定差,需要做大量适配 需要解决资源id冲突问题 需要解决引用的相同第三库资源变更问题 独立式 优点: 反射少,仅需反射AssetManager创建、添加路径的方法 无需关心资源id冲突问题 无需关心第三方库资源变更问题 缺点: 资源相互访问比较麻烦 独立式意味着如果需要引用第三方库的资源,要将第三方库单独打包到插件中,而宿主如果也引用了此第三方库,势必会造成插件包体积增大,存在冗余 由此可见,如果是独立的模块,不使用宿主包的资源,其实用独立式很合适,但如果需要访问宿主资源,则需要考虑合并式,否则要做大量的处理,毕竟你无法轻松地控制那些第三方库。3。3。1合并式 合并式是将插件的路径合并到默认的Resouces中: Android5。0及其以上:获取到当前的Resources对象 获取该Resources的AssetManager,反射调用其addAssetPath()添加插件路径 Android5。0以下:获取到旧的Resources对象和当前的Context对象 构建新的Resource对象,将旧的Resources已有的资源路径、插件路径都合并进去 替换掉旧的Resources(这一步有大量的适配),主要是判断当前的Context类型: (1)Context为ContextThemeWrapper,做对应替换处理 (2)Context的baseContext为android。app。ContextImpl类型,做对应替换处理 (3)个别rom的定制,处理Context的baseContext的mResources和mTheme 除此之外,合并式还需要解决插件资源id和宿主资源id冲突问题,主要是Apk中的resources。arsc索引冲突,这个非常容易出现: 合并式资源id冲突问题: 我们知道可以通过R。id。xxxR。string来非常方便的访问应用程序的资源,在编译的时候,Android编译工具aapt会扫描你所定义的所有资源,然后给它们指定不同的资源ID。 资源ID是一个16进制的数字,格式是PPTTNNNN,如0x7f010001:PP代表资源所属的包(packageID),对于应用程序的资源来说,PP的取值是077TT代表资源的类型(typeID)NNNN代表这个类型下面的资源的名称 一旦资源被编译成二进制文件的时候,aapt会生成R。java文件和resources。arsc文件,R。java用于代码的编译,而resources。arsc则包含了全部的资源名称、资源ID和资源的内容(对于单独文件类型的资源,这个内容代表的是这个文件在其。apk文件中的路径信息),这样就把运行环境中的资源id和具体的资源对应起来了。 插件Apk和宿主Apk的资源id很容易发生重复,造成资源合并冲突,那么针对于这个问题,目前的插件化框架有以下几种解决方案: 3。3。2独立式 上述讲了合并式资源的方案和问题,而资源处理还有另一种方案,便是独立式,独立式资源不会有资源id冲突的问题,但是宿主和插件之间的资源访问比较麻烦,适用于业务比较独立的插件,插件只使用插件自身的资源,方法很简单:构建新的AssetManagerAssetManagerassetManagerAssetManager。class。newInstance();添加插件资源路径MethodaddAssetPathMethodHiddenApiReflection。findMethod(AssetManager。class,addAssetPath,String。class);addAssetPathMethod。invoke(assetManager,pluginApk。getAbsolutePath());构建新的ResoucesResourcesnewResourcesnewResources(assetManager,preResources。getDisplayMetrics(),preResources。getConfiguration()); 让插件使用这个新的Resources有两种方式: 3。4四大组件 Android的四大组件:Activity、Receiver、Service、ContentProvider需要在AndroidManifest。xml中进行注册。Android的四大组件其实有挺多的共通之处,比如它们都接受ActivityManagerService(AMS)的管理,它们的请求流程也是基本相通的。目前网上有很多关于四大组件启动流程和原理的分析,篇幅很长,我们这边就直接讲述插件化如何支持四大组件,通过系统服务的注册校验。 系统安装好宿主Apk后,会解析Apk中的AndroidManifest。xml,生成组大组件的信息,如果插件的四大组件未在宿主AndroidManifest。xml中注册,会出现启动组件崩溃问题。 方案总体可以分成以下3类:3。4。1动态替换方案 以Activity为例,主要是对Android底层代码进行Hook,使在App启动Activity中进行欺骗ActivityManagerService,以达到加载插件中组件的目的。 3。4。2静态代理方案 静态代理方案相对来说,会比较好理解一点。因为它不需要去Hook任何代码,主要在宿主中创建一个代理的Activity,叫ProxyActivity,ProxyActivity内部有一个对插件Activity的引用,让ProxyActivity的任何生命周期函数都调用插件中的Activity中同名的函数。这是dynamicloadapk插件化框架所创。 以上方案适用于Activity、Receiver、Service、ContentProvider,但ContentProvider需要注意一点:3。4。3提前预埋插件所用的四大组件 还有最后一种方案,是我认为最简单的,即提前预埋好四大组件,如预埋多个Activity(区分不同启动模式)、Service等,插件对这些预埋的组件进行实现。其实插件所用的组件一般比较固定,我们要做的是做好不同插件使用的组件管理。这种方案可以避免掉上述四大组件的各种问题,无需多余的hook、反射处理。如AAB机制就是会提前讲宿主和dynamicfeature的AndroidManifest文件进行合并,放置在宿主包中,不允许插件对这些四大组件配置进行动态更新。 这种方案需要注意一点就是ContentProvider: 应用程序在创建Application的过程中,会执行handleBindApplication(),将AndroidManifest中ContentProvider进行安装,所以ContentProvider的初始化时机是非常早的。这时如果插件Apk没有安装,则会导致这些ContentProvider找不到实现类,出现崩溃。我们可以用一个空的ContentProvider骗过App启动校验,插件安装完成后再对真实的ContentProvider进行初始化3。5现有插件化框架技术方案对比 讲完了插件化需要解决的几个核心问题,那么我们最后来看下目前市面上的插件化框架对这些问题分别是如何选型处理的: 总结: 这些插件化框架都很优秀,是行业的先驱,功能也很完备,但是实际落地过程中有一些问题,如:不再维护,部分框架还停留在15年,gradle插件、打包适配等比较陈旧功能庞杂,包含多种加载模式,以及大量衍生功能地适配处理,导致稳定性、接入成本剧增,后期维护成本高打包功能侵入宿主App,适配成本高只适合于特定的业务场景,如AAB,需要每次基于基础包重新构建发布使用了一些隐私API,有政府整改风险 04hr58App最小插件化实现在背景所有说的业务中,我们使用插件化作为技术方案的原因是为了减少包大小。不需要完整插件化框架这么多功能,如新增组件能力、多种加载模式的切换,以及一些其他的边缘能力,我们只需要最核心的插件安装、加载能力。其实Shadow、dynamicloadapk就是如此,适用于独立的业务模块,反射少,独立ClassLoader和Resources,四大组件使用静态代理模式进行生命周期分发,但即使这些框架,仍然具有代码复杂、接入成本高的问题,或者项目太老,很多环境和代码未做适配。如果有一个具备完全可运行的、接入成本低的、稳定高的插件化框架,其实更利于落地推广。 58同城Android端之前已使用基于AAB的动态化框架进行了落地:厂商包:包大小控制在50M以内市场包:基于版本级别的线上AB测剪包:招聘和房产剪包,用于外链投放 此框架之前在58App上线动态更新十余次,单次更新用户最高800w,那为什么信安人脸认证动态化不继续使用此框架呢?主要有以下两个原因: 以上就是关于信安人脸动态化的技术选型,对于插件化的几个痛点,解决方案如下: 接下来,我们来看下58App最小插件化框架的设计和实现。4。1框架设计 可以看到结构非常简单: 编译期:插件打包上传能力插件资源处理能力 运行期:插件管理:包含插件的版本、路径、apk、libs的管理插件安装:插件下载、校验,插件的apk拷贝、libs抽取存储,安装标记等插件加载:dexso加载,资源加载4。2插件打包 主要处理插件资源和插件打包上传,无需侵入宿主打包流程,执行:。。gradleuploadPluginRelease 成功后,在buildoutputsapkdebug(release)下会生成:buildoutputsapkdebug(release)pluginuploadinfos。json(上传的插件版本、md5、url)pluginmanifest。xml(清单文件,需要将内容拷贝合并到宿主接入SDK的清单文件)arm64v8a。apkarmeabiv7a。apkuniversal。apk pluginuploadinfos。json内容如下,eg:{version:1。0,infos:〔{abi:armeabiv7a,url:https:wos2。58cdn。com。cnFgHcBazYFgLicutpackagePluginApparmeabiv7adebug1653323406181。apk,md5:0804443b61a079262ff760f33f76c077},{abi:arm64v8a,url:https:wos2。58cdn。com。cnFgHcBazYFgLicutpackagePluginApparm64v8adebug1653323409379。apk,md5:34767a82a47a240c1bbd363ea6e615ea}〕} 资源处理这块,对插件Activity、Service资源获取方法做编译织入PluginResourcesManager。getResources(pluginName): PluginResourcesManager如下:publicfinalclassPluginResourcesManager{privatestaticfinalMapString,ResourcesresourcesMapnewHashMap();privatestaticfinalMapString,AssetManagerassetManagerMapnewHashMap();publicstaticResourcesgetResources(StringpluginName){if(resourcesMap。containsKey(pluginName)){returnresourcesMap。get(pluginName);}try{AssetManagerassetManagerAssetManager。class。newInstance();MethodaddAssetPathMethodHiddenApiReflection。findMethod(AssetManager。class,addAssetPath,String。class);ApkPluginapkPluginnewApkPlugin(pluginName,,);FilepluginApkPluginPathManager。getInstance()。getPluginApk(apkPlugin);addAssetPathMethod。invoke(assetManager,pluginApk。getAbsolutePath());ResourcespreResourcesWBPluginLoader。getContext()。getResources();ResourcesnewResourcesnewResources(assetManager,preResources。getDisplayMetrics(),preResources。getConfiguration());resourcesMap。put(pluginName,newResources);assetManagerMap。put(pluginName,assetManager);returnnewResources;}catch(Throwablee){returnWBPluginLoader。getContext()。getResources();}}publicstaticAssetManagergetAssetManager(StringpluginName){if(assetManagerMap。containsKey(pluginName)){returnassetManagerMap。get(pluginName);}ResourcesresourcesgetResources(pluginName);returnresources。getAssets();}} 4。3插件管理 datadata{packageName}appwbpluginspluginName1codecache(代码缓存)nativeLib(so目录)arm64v8aarmeabiv7aoat(oat优化目录)base。apk(插件apk)mark。json(安装标记,包含版本信息)pluginName2。。。4。4插件安装 插件安装这一块,流程如下: 安装标记如下,eg:{name:TestPlugin,version:1。0,abi:armeabiv7a} 4。5插件加载 加载dexso 插件ClassLoader:finalclassPluginDexClassLoaderextendsBaseDexClassLoader{privatePluginDexClassLoader(ListStringdexPaths,FileoptimizedDirectory,StringlibrarySearchPath,ClassLoaderparent)throwsThrowable{super((dexPathsnull)?:TextUtils。join(File。pathSeparator,dexPaths),optimizedDirectory,librarySearchPath,parent);UnKnownFileTypeDexLoader。loadDex(this,dexPaths,optimizedDirectory);}staticPluginDexClassLoadercreate(ListStringdexPaths,FileoptimizedDirectory,FilelibrarySearchFile)throwsThrowable{PluginDexClassLoaderclnewPluginDexClassLoader(dexPaths,optimizedDirectory,librarySearchFilenull?null:librarySearchFile。getAbsolutePath(),PluginDexClassLoader。class。getClassLoader());returncl;}} 重写App默认的PathClassLoader的双亲委派模式:第一步,获取App运行的默认ClassLoader扩展默认ClassLoader的class、library加载,优先使用原始默认的ClassLoader加载,加载失败则使用插件ClassLoader加载设置此新的ClassLoader为App运行的默认ClassLoader(替换context。mPackageInfo的ClassLoader,时机需要在Application的attachBaseContext())publicfinalclassPluginDelegateClassloaderextendsPathClassLoader{privatestaticBaseDexClassLoaderoriginClassLoader;PluginDelegateClassloader(ClassLoaderparent){super(,parent);originClassLoader(BaseDexClassLoader)parent;}privatestaticvoidreflectPackageInfoClassloader(ContextbaseContext,ClassLoaderreflectClassLoader)throwsException{ObjectpackageInfoHiddenApiReflection。findField(baseContext,mPackageInfo)。get(baseContext);if(packageInfo!null){HiddenApiReflection。findField(packageInfo,mClassLoader)。set(packageInfo,reflectClassLoader);}}publicstaticvoidinject(ClassLoaderoriginalClassloader,ContextbaseContext)throwsException{ContextctxbaseContext;while(ctxinstanceofContextWrapper){ctx((ContextWrapper)ctx)。getBaseContext();}PluginDelegateClassloaderclassloadernewPluginDelegateClassloader(originalClassloader);reflectPackageInfoClassloader(ctx,classloader);}OverrideprotectedClasslt;?findClass(Stringname)throwsClassNotFoundException{try{returnoriginClassLoader。loadClass(name);}catch(ClassNotFoundExceptionerror){SetPluginDexClassLoadersplitDexClassLoadersPluginClassLoaders。getInstance()。getClassLoaders();for(PluginDexClassLoaderloader:splitDexClassLoaders){Classlt;?clazzloader。loadClassItself(name);if(clazz!null){returnclazz;}}throwerror;}}OverridepublicClasslt;?loadClass(Stringname)throwsClassNotFoundException{returnfindClass(name);}OverridepublicStringfindLibrary(Stringname){StringlibNameoriginClassLoader。findLibrary(name);if(libNamenull){SetPluginDexClassLoadersplitDexClassLoadersPluginClassLoaders。getInstance()。getClassLoaders();for(PluginDexClassLoaderclassLoader:splitDexClassLoaders){libNameclassLoader。findLibraryItself(name);if(libName!null){break;}}}returnlibName;}} 资源加载 资源加载请见插件打包的PluginResourcesManager4。6遇到的问题1。启动阿里认证报Fatalsignal11(SIGSEGV),code1(SEGVMAPERR),faultaddr0x0intid28105(SGBackgroud) 这个问题前后排查1周多,阿里认证使用了安全套件,其本身so为apk插件格式: 反编译相关代码其使用的也是独立ClassLoader加载,最后经过二分、查看apk等方式,查找到原因,编译出的demoApk的METAINFO中没有签名信息,阿里安全套件对签名文件有检测。最后通过在demoapp中显示指定签名文件解决。2。独立资源appcompat库问题 androidx。appcompat:appcompat 阿里认证库已适配AndroidX,动态包由于使用的是独立的ClassLoader和Resources,如果插件包依赖appcompat会出现以下问题: 最终,选取了插件包不依赖appcompat,统一交由宿主进行依赖,目前大多数App均已适配AndroidX,这种方案无需维护插件appcompat和宿主appcompat的版本,同时也能对插件本身进行瘦身。 05hr总结插件化的原理其实不难,核心点就几个。各种插件化框架对于这些核心痛点也已经有了成熟的解决方案,目前插件化能在58App落地也是站在先驱的肩膀上,找到了最合适的方案进行微创新与落地。 作者:况众文 来源:微信公众号:58技术 出处:https:mp。weixin。qq。comszKpjaHPMjKYjqBd4co4Itg