58同城Android端-最小插件化框架实战和原理分析

目录

  1. 背景
  2. 插件化需要了解的知识
    2.1 类加载过程和类加载器
    2.2 ClassLoader 的 findClass、findLibrary、findResource
    2.3 DexClassLoader 的 oat 配置
    2.4 LoadedApk
    2.5 AssetManager、Resources
  3. 插件化需要解决的核心问题
    3.1 插件化的安全性和稳定性
    3.2 class 和 so 加载
    3.3 资源加载和资源 id 冲突
    3.4 四大组件
    3.5 现有插件化框架技术方案对比
  4. 58App 最小插件化实现
    4.1 框架设计
    4.2 插件打包
    4.3 插件管理
    4.4 插件安装
    4.5 插件加载
    4.6 遇到的问题
  5. 总结

1. 背景

移动互联网进入存量时代,随着人口红利减退,充分盘活、经营现有流量便成为了各行各业全新的机遇与挑战。各大公司都在内卷发力,对 App 包大小、启动速度、性能做持续优化。

App 包体积和用户转换率成负相关,包体积越小、用户下载时长越短,用户转换率越高。而随着国内用户的增量见顶,越来越多的应用选择出海,开发对应的海外版,Google Play 应用市场目前强制要求超过 100MB 的应用只能使用 AAB 扩展文件方式上传,Google Play 会为我们的应用托管 AAB 扩展文件,进行自定义分发和动态交付。

(可以看到,排名靠前的 App 包大小基本是都 >100M,很多 App 都上架了极速版)

58同城Android端-最小插件化框架实战和原理分析_第1张图片

58同城 App 对包大小这块也非常关注,每次发布版本之前都会对包大小进行分析与监控,下图为 Android 32位包大小变化:

58同城Android端-最小插件化框架实战和原理分析_第2张图片 58同城Android端-最小插件化框架实战和原理分析_第3张图片

近期我们在对包大小进行新一轮的梳理,过程中发现:人脸认证库内置了 4 套框架,当自研框架认证异常时将切换到其他框架,这种策略下内置 4 套框架对包大小造成了较大的负重。经过分析与调研,达成了共识方案:内置腾讯认证,把自研认证和阿里认证动态化,预计收益可达 4M,后期方案落地后逐步推进腾讯认证的动态化,预计可再减少 1.66 M。

包大小减少的常用手段非常多,主要分类还是技术手段和业务手段:

#
技术手段 对资源、代码、so这些编译文件做处理
业务手段 业务动态化

当前选择动态化作为技术选项,是因为我们在技术手段上对包大小做的努力已基本见顶,同时从认证模块的背景考虑,低频、低耦合正适合于插件化场景。动态化又分成了正规军 Android App Bundle 和国内的游击队插件化,至于58同城在插件化、AAB 上的探索和实现上的技术选型,后面的章节中会进行讲解。最终效果:

插件化便是本文章的重点,插件化一般用来做两件事:减少基础包大小和动态更新。插件化是移动端模块化、插件化、组件化三剑客之一,历史也非常久了,网上公开的免费插件化文章很多都是纯概念型或纯方案型,本文章将会从 0-1 讲解插件化的知识,配合实战经验,让你有所收获。

工欲善其事,必先利其器,我们先来了解插件化中涉及到的相关知识点。

2. 插件化需要了解的知识

在正式了解插件化之前,需要了解插件化会涉及到的相关概念,整个文章是一个循序渐进的过程。这样在后面讲到插件化需要解决的问题、现有插件化框架的对比、插件化的实现时可以做到知其然而知其所以然。

2.1 类加载过程和类加载器

2.1.1 Java

首先,我们来了解下类加载的过程和类加载器,以 java 文件为例,类加载干过程如下:

58同城Android端-最小插件化框架实战和原理分析_第4张图片
  • 加载:这部分涉及到类加载器,将 class 文件加载到内存,创建对应的 Class 对象
  • 连接:包括验证、准备、解析三部分。验证阶段会检验被加载的类是否有正确的内部结构,并和其他类协调一致;准备阶段则负责为类的静态属性分配内存,并设置默认初始值;解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
  • 初始化:JVM 负责对类进行初始化,主要是对静态属性/静态块进行初始化。

那么一个类什么时候会被触发加载过程呢?除去系统类、扩展类外,我们程序的类什么时候执行,主要包括以下几种情况:

  • 创建类的实例,如 new XXX();
  • 调用某个类的静态方法;
  • 访问某个类或接口的静态属性,或为该静态属性赋值;
  • 通过反射方式来创建某个类或接口对应的 java.lang.Class 对象,如使用Class.forName(“XXX”)
  • 初始化某个类的子类。初始化子类时,所有的父类都会被初始化。

那么讲完了 Java 类的加载过程,我们再来看下它的类加载器:

58同城Android端-最小插件化框架实战和原理分析_第5张图片

类加载器加载类遵循双亲委派模式,这是基于安全和效率方面的考虑,实现委派模式是通过 ClassLoader 构造器中的 parent 来做的,我们看一下 ClassLoader 抽象类:

public abstract class ClassLoader {

    protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // 检测是否已装载过该 Class
        Class c = findLoadedClass(name);
        // 未装载过
        if (c == null) {
            try {
                // 是否有父 ClassLoader,有的话使用父 ClassLoader 尝试加载
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 没有父 ClassLoader,使用 BootstrapClassLoader 尝试加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
            }

            if (c == null) {
                // 上述都没找到,使用自身 ClassLoader 加载
                c = findClass(name);
            }
       }
       return c;
    }
}

2.1.2 Android

Android 区别于 Java 的两个核心点:

  • 基于 Dex 文件格式,而非 class 文件格式,当然 Dex 里包含 class
  • 虚拟机为 Davilk/ART,而非 JVM

其实 dex 和 Class 本质上都是一样的,都是二进制流文件格式,dex 文件是从 class 文件演变而来的:class 文件存在冗余信息,dex 文件则去掉了冗余,并且整合了整个工程的类信息,在 Android 中做插件化和热修复都离不开 dex.

58同城Android端-最小插件化框架实战和原理分析_第6张图片

class VS dex:

  • 内存占用大,不适合移动端:dex 做了各种优化和去冗余
  • 堆栈的加载模式,加载速度慢
  • 文件 IO 操作多,类查找慢:Android 虚拟机直接 IO load dex,再进行类加载

Android 的类加载器包括:

58同城Android端-最小插件化框架实战和原理分析_第7张图片
  • ClassLoader 是一个抽象类,其中定义了 ClassLoader 的主要功能。
  • SecureClassLoader 拓展了 ClassLoader 类加入了权限方面的功能,加强了 ClassLoader 的安全性。
  • URLClassLoader 它继承自 SecureClassLoader,用来通过 URl 路径从jar 文件和文件夹中加载类和资源。
  • BootClassLoader 是 ClassLoader 的内部类,用于加载一些系统 Framework 层级需要的类。
  • BaseDexClassLoader 继承自 ClassLoader,是抽象类 ClassLoader的具体实现类,PathClassLoader 和 DexClassLoader 都继承它。
  • PathClassLoader 加载系统类和应用程序的类,如果是加载非系统应用程序类,则会加载 data/app/ 目录下的 dex 文件以及包含 dex 的 apk 文件或 jar 文件(已安装)
  • DexClassLoader 可以加载自定义的 dex 文件以及包含 dex 的 apk 文件或jar文件,也支持从 SD 卡进行加载
  • InMemoryDexClassLoader 是 Android8.0 新增的类加载器,继承自BaseDexClassLoader,用于加载内存中的 dex 文件。

这里需要注意一点,我们的应用程序的默认 ClassLoader 为 PathClassLoader,而 PathClassLoader 的父 ClassLoader 为 BootClassLoader,这也是为什么 bugly 上一些 ClassNotFound 堆栈顶部为 BootClassLoader

ClassLoader.java

    private static ClassLoader createSystemClassLoader() {
        String classPath = System.getProperty("java.class.path", ".");
        String librarySearchPath = System.getProperty("java.library.path", "");
        return new PathClassLoader(classPath, librarySearchPath, BootClassLoader.getInstance());
    }

在 Android ClassLoader 中,Dex 最终会被运行解析成 DexElements,我们查看 Android 源码 BaseDexClassLoader,可以看到:

package dalvik.system;

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
    }

    @Override
    protected Class findClass(String name) throws ClassNotFoundException {
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            throw err;
        }
        return c;
    }

    @Override
    protected URL findResource(String name) {
        return pathList.findResource(name);
    }

    @Override
    protected Enumeration findResources(String name) {
        return pathList.findResources(name);
    }

    @Override
    public String findLibrary(String name) {
        return pathList.findLibrary(name);
    }
}

通过 dexPath(dex路径)、libraryPath(so路径)、optimizedDirectory(oat优化存储目录) 构建了 DexPathList,而 classloader 的 findClass、findLibrary、findResources 都委托给了它去查找,也是 Android ClassLoader 使用 Dex 加载的实现部分(区别于 Java ClassLoader),最终 loadClass 会调用到 DexFile 的:

private static native Class defineClassNative(String name, ClassLoader loader, int cookie)
throws ClassNotFoundException, NoClassDefFoundError;

2.2 ClassLoader 的 findClass、findLibrary、findResource

上面我们了解了 ClassLoader 相关的知识,那么在 Android 插件化中,我们还需要了解相关的几个常用方法。

2.2.1 findClass

根据类完整名称去查找 Class 对象,如 findClass(“com.xx.Test”),同时需要注意,在 ClassLoader 中关于 class 加载的有以下几个方法:

方法 描述
Class loadClass(String name) 定义加载模型的框架,即双亲委派模型,可参考 ClassLoader 类中的实现,如果要打破默认的双亲委派模式,可以重新此方法
Class findClass(String name) 查找 Class 类,插件化框架一般重写此方法
Class defineClass(byte[] b, int off, int len) IO 把类字节数组变成内存对象

在打印 App 默认的 PathClassLoader 对象时,可以看到当前的类查找路径,路径一般以 .apk、.jar 结尾,ClassLoader 变化在这些路径进行类查找,在插件化中,合并插件的 dex 路径也会出现在当中,如果是独立的 ClassLoader,则里面只有插件本身的路径:

> DexPathList[[zip file "/data/app/com.wuba-v5PkwKJhGUzCf2aDtJEQAQ==/base.apk", zip file "/data/data/com.wuba/library/house/hsgmainplugin-all-0.1.0/hsgmainplugin-release.jar"]

2.2.2 findLibrary

查找、加载 so 库使用,我们在打印 App 默认的 PathClassLoader 对象时,可以看到当前可查找的 so 的路径,ClassLoader 便会从这些路径进行 so 查找,在插件化中,合并插件的 so 路径也会出现在当中,如果是独立的 ClassLoader,则里面只有插件 so 本身的路径:

> nativeLibraryDirectories=[/data/app/com.wuba-v5PkwKJhGUzCf2aDtJEQAQ==/lib/arm, /data/app/com.wuba-v5PkwKJhGUzCf2aDtJEQAQ==/base.apk!/lib/armeabi-v7a, /system/lib]] 

关于 ClassLoader 中 so 查找有以下1个需要关注的方法:

String findLibrary(String libname)

findLibrary 是根据 so 名称去查找它所在的完整路径,当代码触发 System.loadLibrary(libName) 时触发,findLibrary 实现

public String findLibrary(String libraryName) {
    String fileName = System.mapLibraryName(libraryName);
    for (File directory : nativeLibraryDirectories) {
         String path = new File(directory, fileName).getPath();
         if (IoUtils.canOpenReadOnly(path)) {
                return path;
         }
    }        
    return null;    
}

Framework 一般不会让用户直接通过 dlopen 去加载动态链接库,而是封装了以下两种方式:

public final class System { 
     // 方式一:通过 so 文件路径加载 
    public static void load(String filename) { 
       Runtime.getRuntime().load0(VMStack.getStackClass1(), filename);
    } 

     // 方式二:通过 so 库名加载 
    public static void loadLibrary(String libname) {              
       Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname); 
    }
 }

System.loadLibrary() 最终通过 dlopen() 来实现:

Sysytem#loadLibrary --> Sysytem#load --> Runtime#nativeLoad Java + | Native dvmLoadNativeCode --> dlopen -> 打开一个 so 文件,创建一个 handle

2.2.3 findResource
URL findResource(String name)

这个方法用于查找资源,如 findResource(“file:D:\workspaces\”),在 Android 中基本无用,Android 有属于自己的一套资源管理方案。

2.3 DexClassLoader 的 oat 配置

如果需要使用多 ClassLoader 时,需要自己构造 DexClassLoader:

class PluginDexClassLoader extends BaseDexClassLoader {
    private PluginDexClassLoader(List dexPaths,
                                File optimizedDirectory,
                                String librarySearchPath,
                                ClassLoader parent) throws Throwable {
        super((dexPaths == null) ? "" : TextUtils.join(File.pathSeparator, dexPaths), optimizedDirectory, librarySearchPath, parent);
    }
}

需要传入 dex 路径集合、so 库目录、dex 优化目录、以及父 ClassLoader. DexClassLoader 提供了 optimizedDirectory,而 PathClassLoader 则没有(系统会自动生成以后缓存目录,即 /data/dalvik-cache,不同厂商不一样),optimizedDirectory 是用来存放 odex 文件的地方,所以可以利用 DexClassLoader实现动态加载。

58同城Android端-最小插件化框架实战和原理分析_第8张图片

这边简单介绍下几个概念:

  • dex: java 程序编译成 class 后,dx 工具将所有 class 文件合成 dex 文件。
  • odex (Android5.0 之前): Optimized DEX,即优化过的 dex. Android5.0 之前 APP 在安装时会进行验证和优化,为了校验代码合法性及优化代码执行速度,验证和优化后,会产生 odex 文件,运行 apk 的时候,直接加载 ODEX,避免重复验证和优化,加快了 apk 的响应时间。
  • oat (Android5.0 之后): oat 是 ART 虚拟机运行的文件,是 ELF 格式二进制文件,包含 DEX 和编译的本地机器指令,oat 文件包含 dex 文件,因此比 odex 文件占用空间更大。Android5.0 dex2oat 默认会把 classes.dex 翻译成本地机器指令,生成 ELF 格
    式的 oat 文件。不过 android 5.0 之后 oat 文件还是以 .odex 后缀结尾,但是已经不是 android5.0 之前的文件格式,而是 ELF 格式封装的本地机器码。
  • vdex: Android8.0 以后加入,包含 apk 的未压缩 dex 代码,另外还有一些旨在加快验证速度的元数据。

2.4 LoadedApk

在 Android 中,我们通过 context.getClassLoader() 即可获取到程序默认的类加载器,当然这个加载器在没有任何处理的时候为 PathClassLoader,那么如果我们想对其进行替换/扩展该如何处理呢?首先我们需要找到它具体的持有者 ContextImpl.java:

package android.app;

class ContextImpl extends Context {    
    final @NonNull LoadedApk mPackageInfo;
}

再看看 LoadedApk, LoadedApk 对象是 apk 文件在内存中的表示,在启动我们的应用进程后,经过 system_server 的层层调用,最终会创建 LoadedApk,可以看到下面代码,它持有了很多重要的信息,如主线程、包信息、Resources、ClassLoader、Application 等:

package android.app;

public final class LoadedApk {
    private final ActivityThread mActivityThread;

    final String mPackageName;
    private ApplicationInfo mApplicationInfo;
    private String mAppDir;
    private String mResDir;
    private String mDataDir;
    private String mLibDir;
    private File mDataDirFile;

    private final ClassLoader mBaseClassLoader;
    Resources mResources;
    private ClassLoader mClassLoader;
    private Application mApplication;

    private String[] mSplitNames;
    private String[] mSplitAppDirs;
    private String[] mSplitResDirs;
    private String[] mSplitClassLoaderNames;

在插件化中,对 ClassLoader、Resources 的处理都可以通过它,它在一个进程内是全局唯一的。VirtualApp 处理资源采用的就是替换 LoadedApk 的 Resource 对象。而替换默认的 ClassLoader 也可以通过反射替换掉 LoadedApk 中的 mClassLoader,这个 api 相对来说很稳定,各 Android 版本没有做变更。

2.5 AssetManager、Resources

插件化中,除了 class、libs 相关的加载,另一个重点就是资源,在 Android 中与资源加载相关的两个类便是 AssetManager、Resources. Resources 用来获取 res 目录下的各种与设备相关的资源,而 AssetManager 则用来获取 assets 目录下的资源。

AssetManager 属于 Resources 的一个属性:

package android.content.res;

public class Resources {
	private ResourcesImpl mResourcesImpl;
	
    public Resources(AssetManager assets, DisplayMetrics metrics, Configuration config) {
        this(null);
        mResourcesImpl = new ResourcesImpl(assets, metrics, config, new DisplayAdjustments());
    }
    
    public final AssetManager getAssets() {
        return mResourcesImpl.getAssets();
    }
}

可以看到构造 Resources 对象时,需要传入 AssetManager 对象,我们再来看看 AssetManager:

package android.content.res;

public final class AssetManager implements AutoCloseable {

	// AssetManager 构造器使用 @UnsupportedAppUsage 注解
	@UnsupportedAppUsage
	public AssetManager() {
	}

	// AssetManager  addAssetPath 使用 @UnsupportedAppUsage 注解
	@UnsupportedAppUsage
	public int addAssetPath(String path) {
	    return addAssetPathInternal(path, false /*overlay*/, false /*appAsLib*/);
	}
	
	// 可获取已安装的资源路径,使用 @UnsupportedAppUsage 注解
	@UnsupportedAppUsage
	public @NonNull ApkAssets[] getApkAssets() {
	    synchronized (this) {
              if (mOpen) {
                  return mApkAssets;
               }
           }
        return sEmptyApkAssets;
      }
}

AssetManager 不允许 App 代码直接对其进行构造,所以在插件化过程中,如果要使用独立资源模式构建插件 AssetManager 需要用到反射,同时 AssetManager 添加资源查找路径的方法 addAssetPath 也不允许 App 代码直接访问,插件化中添加插件资源路径需要对其进行反射。AssetManager 的资源路径一般包含以下几类:

#
1 系统资源路径
2 Apk 资源路径,包含业务自身资源和第三库资源
3 插件资源路径

而通过 AssetManagergetApkAssetsgetAssetPath 方法可以获取到该 AssetManager 的资源路径(数组),不过这些方法对于 App 层都无法直接调用,需要使用反射。

Android 应用中,Application、Activity、Service 都可以获取到 AssetManager Resources 对象:

public class App extends Application {
    @Override
    public Resources getResources() {
        return super.getResources();
    }

    @Override
    public AssetManager getAssets() {
        return super.getAssets();
    }
}

public class TestActivity extends Activity {
    @Override
    public Resources getResources() {
        return super.getResources();
    }

    @Override
    public AssetManager getAssets() {
        return super.getAssets();
    }
}

public class TestService extends Service {
    @Override
    public Resources getResources() {
        return super.getResources();
    }

    @Override
    public AssetManager getAssets() {
        return super.getAssets();
    }
}

其实它们都指向当前 Context 中的 LoadedApk 的 Resources,可以说是当前应用唯一的,在插件化中,我们就可以针对上述这些资源相关的类和方法进行处理。

3. 插件化需要解决的核心问题

了解了插件化需要掌握的知识点后,我们再来了解一下插件化需要解决的核心问题。

58同城Android端-最小插件化框架实战和原理分析_第9张图片

可以看到上图宿主与插件的结构图,插件化需要解决的核心问题也是从这几块入手。

3.1 插件化的安全性和稳定性

为什么 Android 包括 iOS,以及当前流行的跨端框架 Flutter 都不允许对已安装的应用做插件动态更新,主要是基于对安全性和性能的顾虑。如绕过应用市场检测,给用户 App 进行木马插件等风险性的动态更新,同时对于运行性能会有影响,缺少各种对宿主包的优化。当然 Android & iOS 允许 JS 的动态更新,毕竟 JS 文件可明文查看,而 Google Play 近两年也推出了官方的“插件化”能力 - Android App Bundle.

抛开这些,我们看看国内的插件化,在安全性和稳定性上存在问题的原因主要如下:

安全性:
插件 Apk 的安全性问题,如被劫持篡改 所以插件化框架一般都需要对插件 Apk 做签名和摘要验证


稳定性:

  1. 主要涉及到私有 api 的反射,不同 Android 版本、 不同厂商对 class、so、resources 涉及到的隐私 api 都会存在差异,特别是需要将插件合并到宿主运行环境的情况
  2. 同时一些插件化框架为了绕过四大组件未安装的校验,做了很多的 hook 和反射,稳定性和性能都需要做大量的适配工作

3.2 class 和 so 加载

对于 class 和 so 的加载,有两种模式:合并式和独立式。

合并式
优点:

  1. 宿主与插件可直接互相访问

缺点:

  1. 稳定差,需要做大量适配
  2. 宿主与插件相同库如果出现不兼容,会出现对应 class 加载异常

独立式
优点:

  1. 几乎无反射,稳定性强,只需要 hook 一处用于扩展默认的 PathClassLoader
  2. 不用处理宿主和插件相同库版本不兼容问题, 宿主和插件 ClassLoader 分离

缺点:

  1. 相互访问比较麻烦,主要在于宿主和插件之间的访问,不过都可以通过拦截各自
    ClassLoader 的 findClass、findLibrary 来处理

由此可见,如果是独立的模块,不使用宿主包的能力,其实用独立插件很合适,但如果涉及到大量宿主能力的调用(不推荐,这样插件 Apk 过于依赖宿主的相关库的向下兼容性),需要对 ClassLoader 做更多的处理。

3.2.1 合并式

顾名思义,就是将插件 dex 路径合并到宿主的 dex 路径中,so 路径合并宿主的 so 路径中,到主要通过 classloader 实现。

dex 路径合并:

(1) Android 6.0 及其以上

反射 classloader 的 pathList,扩展其 dexElements,扩展时反射 makeDexElements(List, File, List)

(2) Android 4.4.2 - 6.0

反射 classloader 的 pathList,扩展其 dexElements,扩展时反射 makeDexElements(ArrayList, File, ArrayList)

(3) Android 4.0 - 4.4.2

反射 classloader 的 pathList,扩展其 dexElements,扩展时反射 makeDexElements(ArrayList, File)

可以看到,classloader.pathList.dexElements 是稳定的私有 api,主要区别在于扩展 DexElements 用到的 makeDexElements 方法签名不同,不过目前大多数 App 的最低运行版本已经升到了 Android 5.0

对于合并式,会出现类版本兼容性问题:

可以看下图,插件和宿主都引用了同一个库,但是当宿主升级此库后,由于它内部未做向下兼容,删除了某些类或者修改了对外方法,如果此时插件不进行同步更新打包,那么运行将会出现问题。

又或者宿主和插件打包分离,都引用了同一个库,但这个库版本不兼容时,也会出现这个问题

58同城Android端-最小插件化框架实战和原理分析_第10张图片

如何解决?

  • 方法1:参考 AAB 打包规则,插件参与宿主打包过程,将相同依赖库打包到宿主,同时可判断插件是否需要做对应的更新
  • 方法2:分开打包,但需要制定相同依赖库升级的规则,不过这种方式会使插件包体积变大,存在冗余

so 路径合并:

(1) Android 7.1 及其以上

反射 classloader 的 pathList,扩展其 nativeLibraryDirectories,需要使用 pathList 的 systemNativeLibraryDirectories、makePathElements、nativeLibraryPathElements 等几个函数去扩展

(2) Android 6.0 - 7.1

与 7.1 及其以上一致,区别在于 makePathElements 的方法签名不同

(3) Android 4.0 - 6.0

反射 classloader 的 pathList,扩展其 nativeLibraryDirectories,直接构建新的 ArrayList 替换 nativeLibraryDirectories 即可

与 dex 路径合并一致,在不同版本之前存在一些差别。

3.2.2 独立式

独立式就是插件中的 class 和 so 使用独立的 ClassLoader 加载:

public class PluginDexClassLoader extends BaseDexClassLoader {
    public PluginDexClassLoader(
        List dexPaths, File optimizedDirectory, String librarySearchPath, ClassLoader parent)  {
        super((dexPaths == null) ? "" : TextUtils.join(File.pathSeparator, dexPaths), optimizedDirectory, librarySearchPath, parent);
    }
 }

可以看到,独立 ClassLoader 无反射,一般,我们会扩展程序默认的 ClassLoader(一处反射:替换掉 loadedApk 中的 classloader 对象),在默认 ClassLoader findClass、findLibrary 异常时,再使用独立的 ClassLoader 去加载。

3.3 资源加载和资源 id 冲突

上面讲完了插件 Apk 中 class 和 so 的加载,我们再来看下资源如何加载处理。同样,资源加载也分为合并式和独立式:

合并式
优点:

  1. 宿主与插件可直接互相访问资源

缺点:

  1. 稳定差,需要做大量适配
  2. 需要解决资源 id 冲突问题
  3. 需要解决引用的相同第三库资源变更问题

独立式
优点:

  1. 反射少,仅需反射 AssetManager 创建、添加路径的方法
  2. 无需关心资源 id 冲突问题
  3. 无需关心第三方库资源变更问题

缺点:

  1. 资源相互访问比较麻烦
  2. 独立式意味着如果需要引用第三方库的资源,要将第三方库单独打包到插件中,而宿主如果也引用了此第三方库,势必会造成插件包体积增大,存在冗余

由此可见,如果是独立的模块,不使用宿主包的资源,其实用独立式很合适,但如果需要访问宿主资源,则需要考虑合并式,否则要做大量的处理,毕竟你无法轻松地控制那些第三方库。

3.3.1 合并式

合并式是将插件的路径合并到默认的 Resouces 中:

Android 5.0 及其以上:

  1. 获取到当前的 Resources 对象
  2. 获取该 Resources 的 AssetManager,反射调用其 addAssetPath() 添加插件路径

Android 5.0 以下:

  1. 获取到旧的 Resources 对象和当前的 Context 对象
  2. 构建新的 Resource 对象,将旧的 Resources 已有的资源路径、插件路径都合并进去
  3. 替换掉旧的 Resources (这一步有大量的适配),主要是判断当前的 Context 类型:
    (1) Context 为 ContextThemeWrapper,做对应替换处理
    (2) Context 的 baseContext 为 android.app.ContextImpl 类型,做对应替换处理
    (3) 个别 rom 的定制,处理 Context 的 baseContext 的 mResources 和 mTheme

除此之外,合并式还需要解决插件资源 id 和宿主资源 id 冲突问题,主要是 Apk 中的 resources.arsc 索引冲突,这个非常容易出现:

58同城Android端-最小插件化框架实战和原理分析_第11张图片

合并式资源 id 冲突问题:

我们知道可以通过 R.id.xxx/R.string 来非常方便的访问应用程序的资源,在编译的时候,Android 编译工具 aapt 会扫描你所定义的所有资源,然后给它们指定不同的资源 ID。

资源 ID 是一个16进制的数字,格式是 PPTTNNNN,如 0x7f010001

  • PP 代表资源所属的包 (packageID),对于应用程序的资源来说,PP 的取值是 0×77
  • TT 代表资源的类型(typeID)
  • NNNN 代表这个类型下面的资源的名称

一旦资源被编译成二进制文件的时候, aapt 会生成 R.java 文件和 resources.arsc 文件,R.java 用于代码的编译,而 resources.arsc 则包含了全部的资源名称、资源 ID 和资源的内容 (对于单独文件类型的资源,这个内容代表的是这个文件在其 .apk 文件中的路径信息),这样就把运行环境中的资源 id 和具体的资源对应起来了。

插件 Apk 和宿主 Apk 的资源 id 很容易发生重复,造成资源合并冲突,那么针对于这个问题,目前的插件化框架有以下几种解决方案:

#
1 修改 aapt 源码,定制 aapt ⼯具,编译期间修改 PP 段,替换掉 Gradle 默认的 aapt
2 固定插件资源 id,在 public.xml 中指定 apk 中所有资源的id值,成本很高,扩展性不足
3 利用 AAB 模式打包,但插件强依赖于宿主包编译
3.3.2 独立式

上述讲了合并式资源的方案和问题,而资源处理还有另一种方案,便是独立式,独立式资源不会有资源 id 冲突的问题,但是宿主和插件之间的资源访问比较麻烦,适用于业务比较独立的插件,插件只使用插件自身的资源,方法很简单:

// 构建新的 AssetManager
AssetManager assetManager = AssetManager.class.newInstance();

// 添加插件资源路径
Method addAssetPathMethod = HiddenApiReflection.findMethod(AssetManager.class, "addAssetPath", String.class);
addAssetPathMethod.invoke(assetManager, pluginApk.getAbsolutePath());

// 构建新的 Resouces
Resources newResources = new Resources(assetManager, preResources.getDisplayMetrics(), preResources.getConfiguration());

让插件使用这个新的 Resources 有两种方式:

#
1 修改插件 Activity、Serive 的 getAsset 和 getResouces,替换成新的 resouces
2 为插件创建自己的 LoadedApk,替换 LoadedApk 中的 resouces 为成新的 resouces

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,以达到加载插件中组件的目的。

#
1 360 的 DroidPlugin,为插件创建一个 LoadedApk,替换掉 LoadedApk 中的组件信息,此方案涉及到大量的 hook,反射的地方非常多,而且还要适配不同的 Android 版本
2 欺骗 ActivityManagerService,思路是先在宿主中定义一个占位 Activity,然后也是在启动 Activity 过程中通过反射 hook Instrumentation 的 IActivityManager、ActivityThread 中 Handler 的 callback 进行替换,如双开软件 VirtualApp,阿里的 Altas 等
3.4.2 静态代理方案

静态代理方案相对来说,会比较好理解一点。因为它不需要去 Hook 任何代码,主要在宿主中创建一个代理的 Activity,叫 ProxyActivity, ProxyActivity 内部有一个对插件 Activity 的引用,让 ProxyActivity的任何生命周期函数都调用插件中的 Activity 中同名的函数。这是 dynamic-load-apk 插件化框架所创。

3.4.3 提前预埋插件所用的四大组件

还有最后一种方案,是我认为最简单的,即提前预埋好四大组件,如预埋多个 Activity(区分不同启动模式)、Service 等,插件对这些预埋的组件进行实现。其实插件所用的组件一般比较固定,我们要做的是做好不同插件使用的组件管理。这种方案可以避免掉上述四大组件的各种问题,无需多余的 hook、反射处理。如 AAB 机制就是会提前讲宿主和 dynamic feature 的 AndroidManifest 文件进行合并,放置在宿主包中,不允许插件对这些四大组件配置进行动态更新。

这种方案需要注意一点就是 ContentProvider:

应用程序在创建 Application 的过程中,会执行 handleBindApplication(), 将 AndroidManifest 中 ContentProvider 进行安装,所以 ContentProvider 的初始化时机是非常早的。这时如果插件 Apk 没有安装,则会导致这些 ContentProvider 找不到实现类,出现崩溃。我们可以用一个空的 ContentProvider 骗过 App 启动校验,插件安装完成后再对真实的 ContentProvider 进行初始化

3.5 现有插件化框架技术方案对比

讲完了插件化需要解决的几个核心问题,那么我们最后来看下目前市面上的插件化框架对这些问题分别是如何选型处理的:

# dex & so resources 四大组件
dynamic-load-apk(滴滴) 独立的 ClassLoader 独立 Resources 静态代理模式开创者,代理组件生命周期
Altas(阿里) 功能强大,支持 bundle 独立调试,dex & so 加载大量 hook 系统层级构建 LoadedApk 等,类似 VirtualApp 构建独立的 LoadedApk 大量 hook 系统层级绕过检测
Shadow(腾讯) 独立 ClassLoader 独立 Resources 使用静态代理模式进行生命周期分发,编译期生成代理类
VirtualApp(Lody,双开) 虚拟容器,大量地 hook、反射,构建独立的 LoadedApk、上下文等 构建独立的 LoadedApk 提前占位,启动组件时通过 hook、替换手段绕过系统检测
Qigsaw(爱奇艺,基于 AAB) 支持单 & 多 ClassLoader 加载模式 AAB 打包会自动处理资源 id 冲突问题,加载时使用合并式资源处理 AAB 打包提前合并清单文档到基础包,不支持四大组件配置的动态更新

总结:

这些插件化框架都很优秀,是行业的先驱,功能也很完备,但是实际落地过程中有一些问题,如:

  • 不再维护,部分框架还停留在 15年,gradle 插件、打包适配等比较陈旧
  • 功能庞杂,包含多种加载模式,以及大量衍生功能地适配处理,导致稳定性、接入成本剧增,后期维护成本高
  • 打包功能侵入宿主 App,适配成本高
  • 只适合于特定的业务场景,如 AAB,需要每次基于基础包重新构建发布
  • 使用了一些隐私 API,有政府整改风险

4. 58App 最小插件化实现

在背景所有说的业务中,我们使用插件化作为技术方案的原因是为了减少包大小。不需要完整插件化框架这么多功能,如新增组件能力、多种加载模式的切换,以及一些其他的边缘能力,我们只需要最核心的插件安装、加载能力。其实 Shadow、dynamic-load-apk 就是如此,适用于独立的业务模块,反射少,独立 ClassLoader 和 Resources,四大组件使用静态代理模式进行生命周期分发,但即使这些框架,仍然具有代码复杂、接入成本高的问题,或者项目太老,很多环境和代码未做适配。如果有一个具备完全可运行的、接入成本低的、稳定高的插件化框架,其实更利于落地推广。

58同城 Android 端之前已使用基于 AAB 的动态化框架进行了落地:

  • 厂商包:包大小控制在 50M 以内
  • 市场包:基于版本级别的线上 AB 测
  • 剪包:招聘和房产剪包,用于外链投放
58同城Android端-最小插件化框架实战和原理分析_第12张图片

此框架之前在 58App 上线动态更新十余次,单次更新用户最高 800w,那为什么信安人脸认证动态化不继续使用此框架呢?主要有以下两个原因:

#
版本限制 此框架有 App 版本限制,每次都需要基于基础包重新构建
需要作为人脸认证 SDK 能力 人脸认证 SDK 不仅在同城上应用,在本地版、安居客以及其他创新型应用上都有使用,需要将动态化能力内置在 SDK 中,一方面其他接入 App 也可享受包大小减少的收益,另一方面,其他接入 App 无需依赖相关动态化能力的支持

以上就是关于信安人脸动态化的技术选型,对于插件化的几个痛点,解决方案如下:

#
安全性 插件下载安装做摘要校验
dex & so 独立的 ClassLoader 加载,仅1处稳定反射
Resources 独立的 Resources,仅1处稳定反射,无需处理资源 id 冲突问题
四大组件 对于四大组件无动态更新诉求,采用四大组件配置提前预埋,无任何 hook 与反射

接下来,我们来看下 58App 最小插件化框架的设计和实现。

4.1 框架设计

58同城Android端-最小插件化框架实战和原理分析_第13张图片

可以看到结构非常简单:

编译期:

  • 插件打包上传能力
  • 插件资源处理能力

运行期:

  • 插件管理:包含插件的版本、路径、apk、libs 的管理
  • 插件安装:插件下载、校验,插件的 apk 拷贝、libs 抽取存储,安装标记等
  • 插件加载:dex & so 加载,资源加载

4.2 插件打包

主要处理插件资源和插件打包上传,无需侵入宿主打包流程,执行:

../gradle uploadPluginRelease

成功后,在 build/outputs/apk/debug(release) 下会生成:

--build/outputs/apk/debug(release)
--- plugin-upload-infos.json (上传的插件版本、md5、url)
--- plugin_manifest.xml (清单文件,需要将内容拷贝合并到宿主/接入 SDK 的清单文件)
--- **arm64-v8a.apk
--- **armeabi-v7a.apk
--- **universal.apk

plugin-upload-infos.json 内容如下,eg:

{
    "version": "1.0", 
    "infos": [
        {
            "abi": "armeabi-v7a", 
            "url": "https://wos2.58cdn.com.cn/FgHcBazYFgLi/cutpackage/PluginApp-armeabi-v7a-debug-1653323406181.apk", 
            "md5": "0804443b61a079262ff760f33f76c077"
        }, 
        {
            "abi": "arm64-v8a", 
            "url": "https://wos2.58cdn.com.cn/FgHcBazYFgLi/cutpackage/PluginApp-arm64-v8a-debug-1653323409379.apk", 
            "md5": "34767a82a47a240c1bbd363ea6e615ea"
        }
    ]
}

资源处理这块,对插件 Activity、Service 资源获取方法做编译织入 PluginResourcesManager.getResources("pluginName")

58同城Android端-最小插件化框架实战和原理分析_第14张图片

PluginResourcesManager 如下:

public final class PluginResourcesManager {
    private static final Map resourcesMap = new HashMap<>();
    private static final Map assetManagerMap = new HashMap<>();

    public static Resources getResources(String pluginName) {
        if (resourcesMap.containsKey(pluginName)) {
            return resourcesMap.get(pluginName);
        }
        try {
            AssetManager assetManager = AssetManager.class.newInstance();
            Method addAssetPathMethod = HiddenApiReflection.findMethod(AssetManager.class, "addAssetPath", String.class);
            ApkPlugin apkPlugin = new ApkPlugin(pluginName, "", "");
            File pluginApk = PluginPathManager.getInstance().getPluginApk(apkPlugin);
            addAssetPathMethod.invoke(assetManager, pluginApk.getAbsolutePath());

            Resources preResources = WBPluginLoader.getContext().getResources();
            Resources newResources = new Resources(assetManager, preResources.getDisplayMetrics(), preResources.getConfiguration());
            resourcesMap.put(pluginName, newResources);
            assetManagerMap.put(pluginName, assetManager);
            return newResources;
        } catch (Throwable e) {
            return WBPluginLoader.getContext().getResources();
        }
    }

    public static AssetManager getAssetManager(String pluginName) {
        if (assetManagerMap.containsKey(pluginName)) {
            return assetManagerMap.get(pluginName);
        }
        Resources resources = getResources(pluginName);
        return resources.getAssets();
    }
}

4.3 插件管理

58同城Android端-最小插件化框架实战和原理分析_第15张图片
data/data/${packageName}
- app_wbplugins
-- pluginName1
     - code_cache (代码缓存)
     - nativeLib (so 目录)
        - arm64-v8a/armeabi-v7a
     - oat (oat 优化目录)
     - base.apk (插件 apk)
     - mark.json (安装标记,包含版本信息)
-- pluginName2
     ...

4.4 插件安装

插件安装这一块,流程如下:

58同城Android端-最小插件化框架实战和原理分析_第16张图片

安装标记如下,eg:

{"name":"TestPlugin","version":"1.0","abi":"armeabi-v7a"}

4.5 插件加载

加载 dex & so

插件 ClassLoader:

final class PluginDexClassLoader extends BaseDexClassLoader {
    private PluginDexClassLoader(List dexPaths,
                                File optimizedDirectory,
                                String librarySearchPath,
                                ClassLoader parent) throws Throwable {
        super((dexPaths == null) ? "" : TextUtils.join(File.pathSeparator, dexPaths), optimizedDirectory, librarySearchPath, parent);
        UnKnownFileTypeDexLoader.loadDex(this, dexPaths, optimizedDirectory);
    }

    static PluginDexClassLoader create(List dexPaths,
                                      File optimizedDirectory,
                                      File librarySearchFile) throws Throwable {
        PluginDexClassLoader cl = new PluginDexClassLoader(
                dexPaths,
                optimizedDirectory,
                librarySearchFile == null ? null : librarySearchFile.getAbsolutePath(),
                PluginDexClassLoader.class.getClassLoader()
        );
        return cl;
    }
 }

重写 App 默认的 PathClassLoader 的双亲委派模式:

  • 第一步,获取 App 运行的默认 ClassLoader
  • 扩展默认 ClassLoader 的 class、library 加载,优先使用原始默认的 ClassLoader 加载,加载失败则使用插件 ClassLoader 加载
  • 设置此新的 ClassLoader 为 App 运行的默认 ClassLoader (替换 context.mPackageInfo 的 ClassLoader,时机需要在 Application 的 attachBaseContext())
public final class PluginDelegateClassloader extends PathClassLoader {
    private static BaseDexClassLoader originClassLoader;

    PluginDelegateClassloader(ClassLoader parent) {
        super("", parent);
        originClassLoader = (BaseDexClassLoader) parent;
    }

    private static void reflectPackageInfoClassloader(Context baseContext, ClassLoader reflectClassLoader) throws Exception {
        Object packageInfo = HiddenApiReflection.findField(baseContext, "mPackageInfo").get(baseContext);
        if (packageInfo != null) {
            HiddenApiReflection.findField(packageInfo, "mClassLoader").set(packageInfo, reflectClassLoader);
        }
    }

    public static void inject(ClassLoader originalClassloader, Context baseContext) throws Exception {
        Context ctx = baseContext;
        while (ctx instanceof ContextWrapper) {
            ctx = ((ContextWrapper) ctx).getBaseContext();
        }
        PluginDelegateClassloader classloader = new PluginDelegateClassloader(originalClassloader);
        reflectPackageInfoClassloader(ctx, classloader);
    }

    @Override
    protected Class findClass(String name) throws ClassNotFoundException {
        try {
            return originClassLoader.loadClass(name);
        } catch (ClassNotFoundException error) {
            Set splitDexClassLoaders = PluginClassLoaders.getInstance().getClassLoaders();
            for (PluginDexClassLoader loader : splitDexClassLoaders) {
                Class clazz = loader.loadClassItself(name);
                if (clazz != null) {
                    return clazz;
                }
            }
            throw error;
        }
    }

    @Override
    public Class loadClass(String name) throws ClassNotFoundException {
        return findClass(name);
    }

    @Override
    public String findLibrary(String name) {
        String libName = originClassLoader.findLibrary(name);
        if (libName == null) {
            Set splitDexClassLoaders = PluginClassLoaders.getInstance().getClassLoaders();
            for (PluginDexClassLoader classLoader : splitDexClassLoaders) {
                libName = classLoader.findLibraryItself(name);
                if (libName != null) {
                    break;
                }
            }
        }
        return libName;
    }
}

资源加载

资源加载请见 插件打包PluginResourcesManager

4.6 遇到的问题

1. 启动阿里认证报 Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0 in tid 28105 (SGBackgroud)

这个问题前后排查 1周多,阿里认证使用了安全套件,其本身 so 为 apk 插件格式:

58同城Android端-最小插件化框架实战和原理分析_第17张图片

反编译相关代码其使用的也是独立 ClassLoader 加载,最后经过二分、查看 apk 等方式,查找到原因,编译出的 demo Apk 的 META-INFO 中没有签名信息,阿里安全套件对签名文件有检测。最后通过在 demo app 中显示指定签名文件解决。

2. 独立资源 appcompat 库问题

androidx.appcompat:appcompat

阿里认证库已适配 AndroidX,动态包由于使用的是独立的 ClassLoader 和 Resources,如果插件包依赖 appcompat 会出现以下问题:

#
1 插件 appcompat 版本与宿主 appcompat 版本不兼容,如接口/类/资源,而宿主 App 会优先装载 appcompat 的 class 到内存,导致插件执行 appcompat 出现各种问题
2 插件包体积变大,约增大 1.8M

最终,选取了插件包不依赖 appcompat,统一交由宿主进行依赖,目前大多数 App 均已适配 AndroidX,这种方案无需维护插件 appcompat 和宿主 appcompat 的版本,同时也能对插件本身进行瘦身。

5. 总结

插件化的原理其实不难,核心点就几个。各种插件化框架对于这些核心痛点也已经有了成熟的解决方案,目前插件化能在 58App 落地也是站在先驱的肩膀上,找到了最合适的方案进行微创新与落地。

你可能感兴趣的:(插件化篇,android)