Android包体积优化-so动态加载

2,053 阅读14分钟

Android apk包占比最大的几块内容分别是so,代码dex,资源。
其中对so进行改造是投入产出比最高的,一方面它的体积占比一般最大,另一方面由于动态库本身也是在使用前一次性加载的,不像代码和资源那么复杂,相对牵涉的东西没那么多。

对so的常规优化有编译期压缩,移除符号表,根据abi单独打32位和64包等等,这些都属于静态的编译期优化,对于用户使用过程中so的代码加载和执行不会有任何影响,因此比较安全。
更激进的优化方式是将so变为动态加载的方式,通过接管系统load动态库的流程,在运行期对so进行加载运行,这种方式看见可以大幅降低so体积,但存在一定风险。

so动态加载的一系列理论基础在插件化和热修复时期就已经被广泛验证,对于包体积这种场景整体来说和插件化更类似,热修复的场景会更加复杂一些,因为当热修复加载so时,so已经被加载过了,这就比加载多了一个卸载或者是说更新的动作。

本文参照货拉拉动态资源管理这个开源项目,来看下生产环境怎么实现so的动态加载,在过程中也会加入一些热修复的场景进行对比。

1.资源打包

需求:这一步的目标是将要动态化的so包从编译流程中删除,即最终打出的apk包中是剔除了需要动态化的so包的,并且这些so包会被移动到 dynamic_res_store/input/so中。

这一步涉及两个地方,一个是dynamic_res_plugin这个module,它是编译的gradle插件,另一个是dynamic_plugin.gradle,它是插件的配置脚本。

/**
     * 配置要删除和拷贝的so文件
     * map的key为压缩包名称,值为压缩包包含的so文件列表
     * key为debug_all_test时,会压缩所有so包
     *
     * 1.dynamic_scan_so_map = [ demoSo : ['libnativelib.so,libdynamiclib.so'], ]
     * 2.ignore_so_files: ["libmmkv.so"],本项目中
     * 1、2等效
     */
    dynamic_scan_so_map = [
            demoSo : ['libdynamiclib.so'],
    ]

可以看到,demo中配置了动态化libdynamiclib.so这个动态库。

下面重点分析下插件内部对于so打包的相关实现,现在重点看dynamic_res_plugin这个module:
(1)载入配置,生成DynamicParam示例。
(2)创建任务。
其实这里的task是transform,分别创建了:
【1】TransformTask:执行替换System.loadlibrary操作||替换System.load操作。
【2】DeleteAndCopySoTask:删除||拷贝so。
【3】ZipResTask:对资源||so 进行压缩 生成配置文件。
(3)依次执行task。

TransformTask

执行替换System.loadlibrary操作||替换System.load操作。
这里不去看样板代码,直入主题:
(1)checkSystemLoadClass方法过滤出需要替换System.loadlibrary和System.load的类,进行asm classvisitor注入。
(2)在SystemLoadMethodVisitor,执行方法调用指令时进行查找:

@Override
    public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) {

        if (mParam.isReplaceLoadLibrary() && TextUtil.equals(owner, PluginConst.SYSTEM_CLASS) &&
                TextUtil.equals(name, PluginConst.LOAD_LIBRARY_METHOD) &&
                TextUtil.equals(descriptor, PluginConst.LOAD_LIBRARY_DESC)) {
            Log.debug(mParam, "System.loadLibrary replace " + mClsName);
            owner = PluginConst.CLASS_SO_LOAD_UTIL;
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, owner, name, descriptor, false);

            return;
        }

        if (mParam.isReplaceLoad() && TextUtil.equals(owner, PluginConst.SYSTEM_CLASS) &&
                TextUtil.equals(name, PluginConst.LOAD_METHOD) &&
                TextUtil.equals(descriptor, PluginConst.LOAD_LIBRARY_DESC)) {
            Log.debug(mParam, "System.load replace " + mClsName);
            owner = PluginConst.CLASS_SO_LOAD_UTIL;
            mv.visitMethodInsn(Opcodes.INVOKESTATIC, owner, name, descriptor, false);
            return;
        }

        mv.visitMethodInsn(opcode, owner, name, descriptor, isInterface);
    }

对于匹配到的System.load和System.loadLibrary指令,替换为SoLoadUtil的对应方法。

DeleteAndCopySoTask
@Override
    public void process(Project project, DynamicParam param) {
        project.afterEvaluate(new Action<Project>() {
            @Override
            public void execute(Project project) {
                //插入到任务中间merged和strip so task之间
                Task soFirstTask = TaskUtil.getSoFirstTask(project, param);
                Task soSecondTask = TaskUtil.getSoSecondTask(project, param);
                Task deleteTask = project.getTasks().create(PluginConst.Task.DELETE_SO);
                deleteTask.doLast(new Action<Task>() {
                    @Override
                    public void execute(Task task) {
                        deleteAndCopySo(param);
                    }
                });
                soSecondTask.dependsOn(deleteTask);
                deleteTask.dependsOn(soFirstTask);
            }
        });
    }

创建一个任务,插入到mergeXXXNativeLibs和stripXXXDebugSymbols任务之间。

自定义任务的开始,对mergeXXXNativeLibs的输出进行处理,此处目录为:

${projectDir}/sample_app/build/intermediates/merged_native_libs/xxx/out/lib
private String getSoDirName(File soFile, String abi, DynamicParam param) {
        //如果不是so文件,跳过
        if ((!soFile.isFile())
                || (!soFile.getName().endsWith(".so"))) {
            return "";
        }
        String fileName = soFile.getName();
        //dynamic_scan_so_map 扫描列表有so文件,我们使用扫描列表,只扫描列表文件
        if (!param.isScanSoMapEmpty()) {
            String pkgName = param.getScanSoPkgName(fileName);
            if (!TextUtil.isEmpty(pkgName)) {
                return pkgName + "_" + abi + "_so";
            }
            return "";
        }
        List<String> ignoreSoFiles = param.getIgnoreSoFiles();
        //忽略列表中包含so文件,则返回true,代表跳过文件
        if (!ignoreSoFiles.isEmpty()) {
            if (!ignoreSoFiles.contains(fileName)) {
                return param.getInputSoPrefix() + "_" + abi + "_so";
            }
            return "";
        }
        //2个列表都为空,则不扫描任何so文件
        return "";
    }

对于在gradle脚本配置中的so库,进行copy和delete操作,操作后,so包移动至dynamic_res_store/input/so中。
看到这个时机貌似有个问题,这样copy出来的so没有strip。

ZipResTask

扫描上一步的so文件目录,将他们逐个压缩,并将压缩包输出到指定目录。
这里的so压缩后转移到output目录。
接着,为每一个so压缩包生产一个DynamicPkgInfo类的常量,代表该动态资源。
该框架管理的所有动态资源,每一项都会对应一个DynamicPkgInfo实体类。
最终,还从收集到的DynamicPkgInfo集合,生成了DynamicResConst.java,用来对所有动态资源进行存储归档,这里我们还是只关注so相关的部分。

public interface IDynamicFileCreate {
    /**
     * 生成Java文件
     * @param pkgs 动态包数据
     * @param param 配置
     */
    void createFile(List<DynamicPkgInfo> pkgs, DynamicParam param);
}

具体的实现逻辑在JavaFileCreate类中。

这里我们执行一次打包命令,看下生产的相关动态打包产物。

image.png

在output目录中,生成了压缩后的so包,并按照abi分别压缩,在DynamicResConst文件中,记录了输出的so包相关信息。

public final class DynamicResConst {  
private static final DynamicPkgInfo DEMOSO_ARM64_V8A_SO = new DynamicPkgInfo (  
"demoSo_arm64-v8a_so",  
"demoSo_arm64-v8a_so.zip",  
com.lalamove.huolala.dynamicbase.DynamicResType.SO,  
-1,  
"http://url",  
-9999,  
68341,  
"bc1a1469411386c7e4fb888d662ae053",  
new DynamicPkgInfo.FolderInfo ( "demoSo_arm64-v8a_so",  
new DynamicPkgInfo.FolderInfo ( "arm64-v8a",  
new DynamicPkgInfo.FileInfo ( "libdynamiclib.so","9b807aff7bdcb4389dc68dbceb951059",202744))));  
  
private static final DynamicPkgInfo DEMOSO_ARMEABI_V7A_SO = new DynamicPkgInfo (  
"demoSo_armeabi-v7a_so",  
"demoSo_armeabi-v7a_so.zip",  
com.lalamove.huolala.dynamicbase.DynamicResType.SO,  
-1,  
"http://url",  
-9999,  
46645,  
"b09abdba8c0fd3935b9241f2437352cd",  
new DynamicPkgInfo.FolderInfo ( "demoSo_armeabi-v7a_so",  
new DynamicPkgInfo.FolderInfo ( "armeabi-v7a",  
new DynamicPkgInfo.FileInfo ( "libdynamiclib.so","d300b296a898e5d6651ea507a9451070",103988))));  
  
public static final DynamicSoInfo DEMOSO_SO = new DynamicSoInfo (  
new DynamicSoInfo.DynamicAbiInfo (  
com.lalamove.huolala.dynamicbase.SoType.ARM64_V8A , DEMOSO_ARM64_V8A_SO ),  
new DynamicSoInfo.DynamicAbiInfo (  
com.lalamove.huolala.dynamicbase.SoType.ARMEABI_V7A , DEMOSO_ARMEABI_V7A_SO ));  
}

这个信息记录文件还可以用来进行动态下载时的相关安全校验,这里不进一步展开。

2.资源应用

在了解了资源打包的整体流程后,接下来看下资源是怎么在apk运行时加载和应用的。

这块逻辑整体位于dynamic_res_core这个module中,可以看做是资源动态框架的sdk层。
该库包括了动态资源加载和应用全过程,分为5层实现。

外部接口层,主要为加载管理器和加载监听器,提供了所有外部的接口。
资源应用层,封装了几种内置动态资源的应用,字体资源,帧动画资源,so资源。
加载流程层,具体完成了资源的加载过程,主要采用状态模式实现,包括一个状态管理器,以及各种状态,例如检查本地版本状态,下载状态,校验文件状态等。
接口隔离层,主要是一些功能接口,例如下载功能,解压缩功能,上报功能等,隔离了底层实现。
具体实现层,各个具体功能的实现,例如数据库操作,java zip库等。

image.png

初始化

首先,在Application中进行了sdk的初始化,通过配置DynamicConfig,对DynamicResManager进行初始化,主要对SoLoader进行设置。

资源加载调用
 DynamicResManager.getInstance().getLoadSoManager().loadSo(DynamicResConst.DEMO_SO, new ILoadSoListener() {
                    @Override
                    public void onSucceed(String path) {
                        mContentTv.append(new NativeLib().stringFromJNI());
                        mContentTv.append("-");
                        mContentTv.append(new DynamicLib().stringFromJNI());
                    }

                    @Override
                    public void onError(Throwable t) {
                        mContentTv.append(t.getMessage());
                    }
                });

调用native方法,首先需要对动态so进行加载,采用回调式的写法。
执行DynamicLoadSoManager的loadSo方法。 通过一系列转换,执行到DynamicResManager的loadSo方法:

void loadSo(DynamicSoInfo info, ILoadSoListener listener) {
        mSoLoader.loadSo(info, listener);
    }

最终执行到AbstractSoLoader的loadSo方法。

对于前面so打包,通过asm替换loadLibrary实现,替换为SoLoadUtil实现:

public static void loadLibrary(String libName) {  
DebugLogUtil.d(" SoLoadUtil.loadLibrary " + libName + " " + Log.getStackTraceString(new Throwable()));  
DynamicResManager.getInstance().proxySystemLoadSo(libName);  
}

可以看到最终也是调用到AbstractSoLoader中。
也就是说,动态拆分出来的so库,可以采用asm hook方式,替换掉系统load api的方式加载,我们也可以主动调用加载api,传入打包自动生成的DynamicResConst.java信息的方式加载。

资源加载流程

根据传入的DynamicSoInfo,通过获取系统支持的abi,取出对应的DynamicPkgInfo。
中间有一些缓存相关的逻辑,直接跳过,发现加载回到DynamicResManager中。

public void load(DynamicPkgInfo pkg, ILoadResListener listener, LifecycleOwner owner, boolean callbackInMain) {
        if (pkg == null) {
            if (listener != null) {
                listener.onError(new DynamicResException(DynamicConst.Error.PARAM_CHECK, " pkg is null "));
            }
            return;
        }
        IStateMachine machine;
        //如果该资源没有对应的状态管理器,则创建
        if (!mMachines.containsKey(pkg.getId())) {
            ResCtx ctx = new ResCtx(pkg);
            machine = new DefaultStateMachine(ctx, mConfig, new LoadResDispatch(callbackInMain));
            //设置当前状态为初始状态
            machine.setCurrentState(new InitState());
            //将他放到缓存中
            mMachines.put(pkg.getId(), machine);
        } else {
            //否则,直接从缓存中获取已创建的machine
            machine = mMachines.get(pkg.getId());
        }
        //开始状态管理器执行
        machine.start(listener, owner);
    }

可以看出这里应用了一个状态机机制,这是因为如果so是从远端服务端上动态加载,中间涉及到多个流程:下载,校验,解压缩等。这里的细节不去过多探究,具体的实现策略可以移步货拉拉官方文章,里面有详细的应用流程步骤策略。这里整体的实现思路和glide的资源加载策略很相似,因为glide整体加载图片也涉及多个流程,以及多级缓存的相关实现。

直接来到so资源加载成功的回调方法中:

/**
     * 处理so动态资源加载成功结果
     *
     * @param pkg
     * @param info
     * @param listener
     */
    private void handleLoadSoSucceed(DynamicPkgInfo pkg, LoadResInfo info, ILoadSoListener listener) {
        //这里首先需要考虑缓存,因为有可能其他地方已经调用过加载so库的方法,并返回了加载结果
        //如果缓存中有的话,我们就可以直接使用,无需再次加载了
        if (isLoadAndDispatchSo(pkg, listener)) {
            return;
        }
        //判断资源包中是否存在文件
        if (info == null || info.files == null || info.files.size() == 0) {
            DynamicResException ex = new DynamicResException(DynamicConst.Error.APPLY, " handleLoadSoSucceed file is empty ");
            dispatchSoFail(listener, ex);
            reportFail(pkg, ex);
            return;
        }
        //如果该资源包不包含so文件,直接返回
        File soFile = info.files.get(0);
        if (!soFile.isFile() || !soFile.getName().endsWith(".so")) {
            DynamicResException ex = new DynamicResException(DynamicConst.Error.APPLY, "handleLoadSoSucceed no so file ");
            dispatchSoFail(listener, ex);
            reportFail(pkg, ex);
            return;
        }
        //如果找不到本机需要的abi,则直接返回
        File soAbi = soFile.getParentFile();
        if (soAbi == null || !mLibSupportSoList.contains(soAbi.getName())) {
            DynamicResException ex = new DynamicResException(DynamicConst.Error.APPLY, "handleLoadSoSucceed so abi error ");
            dispatchSoFail(listener, ex);
            reportFail(pkg, ex);
            return;
        }
        Context c = DynamicResManager.getInstance().getConfig().getAppContext();
        try {
            //将so加载到系统DePathList中的数组前面
            DexUtil.installDexAndSo(c.getClassLoader(), soAbi);
            //尝试加载等待队列中的所有so库
            loadAllFromWaitList(c);
            //缓存该so路径
            setSoLoad(pkg, soAbi.getAbsolutePath());
            //分发成功结果
            dispatchSoSucceed(listener, soAbi.getAbsolutePath());
            reportSucceed(pkg);
        } catch (Throwable t) {
            DynamicResException ex = new DynamicResException(DynamicConst.Error.APPLY, t);
            dispatchSoFail(listener, ex);
            reportFail(pkg, ex);
        }
    }

这段逻辑的核心功能是将动态加载到的so文件注入到app运行时中,这样后面的native方法执行才不会报错。

Android so动态化加载流程

这里需要首先了解一下,系统加载so库的工作流程,当我们调用 System#loadLibrary("xxx" ) 后,Android Framework 都干了些了啥?Android 的 so 加载机制,大致可以分为以下四个环节。

  • 安装 APK 包的时候,PMS 根据当前设备的 abi 信息,从 APK 包里拷贝相应的 so 文件。
  • 启动 APP 的时候, Android Framework 创建应用的 ClassLoader 实例,并将当前应用相关的所有 so 文件所在目录注入到当前 ClassLoader 相关字段。
  • 调用 System.loadLibrary("xxx"), framework 从当前上下文 ClassLoader 实例(或者用户指定)的目录数组里查找并加载名为 libxxx.so 的文件。
  • 调用 so 相关 JNI 方法。

可以看到,apk中原本打入的so包,相关信息会被收集在classloader中,待加载时通过去classloader中获取so文件信息并通过dlopen加载so库。
那我们的思路就是在使用native方法前,通过动态方式将外部so的相关信息注入到classloader中,参考一般插件化和热修复的套路,都是使用反射实现。

在此需要看下app使用ClassLoader的相关信息。
这里我们注入的是PathClassLoader:

public class PathClassLoader extends BaseDexClassLoader

所以重点在BaseDexClassLoader。

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;
}

这里的pathList是DexPathList类的实例。

下面看DexPathList:

final class DexPathList {
    /** list of native library directory elements */
    private final File[] nativeLibraryDirectories;
}
  • nativeLibraryDirectories列表:包含了本App自带so文件的查找路径(如data/app/包名/lib/arm64)

通过反射获取到nativeLibraryDirectories字段后,在数组的最前方插入动态加载的so文件路径。
通过查看源码发现不需要再构造Element,直接通过反射将增加后的数组set进DexPathList中即可。

private static void install(ClassLoader classLoader, File soFolder) throws Throwable {
            if (classLoader == null ||
                    soFolder == null ||
                    !soFolder.exists() ||
                    !soFolder.isDirectory()) {
                return;
            }
            Field pathListField = findField(classLoader, PATH_LIST);
            Object dexPathList = pathListField.get(classLoader);
            expandFieldArray(dexPathList, NATIVE_LIBRARY_DIRECTORIES, new File[]{soFolder});
        }

通过上述方式成功将动态so路径注入到ClassLoader中。

接下来可以执行真正的so加载:

@Override
    protected void realSoLoad(Context c, String libName) {
        try {
            //使用Relinker库加载so,解决动态加载时,liba依赖libB时,无法正确加载问题
            ReLinker.recursively().loadLibrary(c, libName);
            //加载成功,从等待队列中移除该库
            removeFormWaitList(libName);
        } catch (Throwable t) {
            //加载失败,将该库加入等待队列
            addToWaitList(libName);
        }
    }

可以看到,这里使用了Relinker这个第三方库,主要是解决so依赖时,需要同时加载的特性。
主要原因如下,纯搬运:

  • 假如我们有2个so文件,libA.so 和 libB.so, libA依赖libB,则当我们调用System.loadLibrary("libA") 的时候,android framework 会通过上面提到的调用链最终通过 dlopen 加载 libA.so 文件,并接着通过其依赖信息,自动使用 dlopen 加载 libB.so。
  • 在 Android N 以前,只要将 libA.so 和 libB.so 所在的文件目录路径都注入到当前 ClassLoader 的 nativeLibraryPathElements 里,则在加载 so 插件的时候,这两个文件都能正常被找到。 从 N 开始,libA.so 能正常加载,而 libB.so 会出现加载失败错误。
  • 因为Android Native 用来链接 so 库的 Linker.cpp dlopen 函数 的具体实现变化比较大(主要是引入了 Namespace 机制):以往的实现里,Linker 会在 ClassLoder 实例的 nativeLibraryPathElements 里的所有路径查找相应的 so 文件。
  • 更新之后,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新,所以出现了 libA.so 文件能找到,而 libB.so 找不到的情况。
  • 至于 Namespace 机制的工作原理,可以简单认为是一个以 ClassLoader 实例 HashCode 为 Key 的 Map,Native 层通过 ClassLoader 实例获取 Map 里存放的 Value(也就是 so 文件路径集合)。
资源自动加载流程

前面我们分析的都是手动加载应用so资源的流程,但我们前面也提到资源打包时会将so加载的api,如System.loadLibrary通过字节码替换为SoLoadUtil,前面已经提到该调用会调用到:

DynamicResManager.getInstance().proxySystemLoadSo(libName);

这里又会调用到AbstractSoLoader的proxySystemSoLoad中,下面看下其中的具体实现:

public final void proxySystemSoLoad(Context c, String libName) {
        //如果so库名称为空,直接返回
        if (TextUtils.isEmpty(libName)) {
            return;
        }
        //context为空,说明我们的动态管理系统可能没准备好,我们将该库名称放入待加载列表
        if (c == null) {
            addToWaitList(libName);
            return;
        }
        //真正加载so库的方法
        realSoLoad(c, libName);
    }

通过这段逻辑,可以看出自动应用so和手动应用so的区别,手动应用so库,会触发所有so库的加载机制,这里和自动应用so库存在一个并行的关系,所以如果自动应用so库先于手动应用触发,则会将加载的so记录到等待列表中,等待so资源加载完成后再次使用RelinkerSoLoader加载。
这个机制类似于android中的view.post,如果此时view还没有被attach到window上,则会将需要执行到消息队列的Runnable暂存于等待列表,等待attach后一次性执行。

3.结语

Android包体积优化,其中投入产出比最高的就是so的动态加载优化,而且由于so是在应用启动后加载,不像代码和资源涉及到自举的一系列复杂问题。
但这也并不意味着so的动态加载是一件容易的工作,因为其中涉及到native的一些相关知识,但好在经过之前插件化和热修复的积淀,so的动态加载技术已经比较成熟,这对于开发者来说是一个福音。