阅读 974

App极限瘦身: 动态下发so

改不完的 Bug,写不完的矫情。公众号 杨正友 现在专注移动基础开发 ,涵盖音视频和 APM,信息安全等各个知识领域;只做全网最 Geek 的公众号,欢迎您的关注!

前言

一般来说,作为一个成熟的应用,native 文件会越来越多,资源文件过大安卓的包体越来越大,包体积的增长,逐渐带来了一些不利影响,比如用户安装成功率降低,CDN 流量费用增加,流失部分付费渠道方,新拓展的渠道方抱怨包体过大,限制了渠道拓展,那么如何解决这种业务痛点呢? 今天就详细给大家介绍一下 Android 动态化管理 so

比如 斗鱼 日本客户端项目就同时支持 arm32/arm64/x86/x86-V7a 四种 ,so 体积成倍地上涨。因此,能不能将非主要的 abi 相关的 so 文件动态化,也成为了国际化出海项目瘦身优化不得不优先考虑的问题。希望能通过包体优化,降低流量成本,避免由于包体过大导致的用户流失。

系统加载 so 库的工作流程

当我们调用当调用 System#loadLibrary("xxx" ) 后,Android Framework 都干了些了啥?

大致流程示意图如下:

参考腾讯Bugly

市场调研

方案分析:

1. JNI 代码内置方案

代码隔离方案比较适合新增的 Native 模块,一开始就奔着动态化、延迟加载的方向去。

2. 插件化方案

单独把 so 文件单独打包进插件包,JNI 代码保留在宿主代码内部,插件化方案虽然比较不错,但是向 nativeLibraryDirectories 注入 so 插件路径带来的 集合并发修改 问题。由于 nativeLibraryDirectories 的具体实现是一个 ArrayList 实例,其元素读写操作自身是不保证线程安全的,而我们在 Worker 线程加载 so 插件的环节最后需要将新的 so 文件路径注入到 ArrayList 集合里,如果这时候刚好有另一个线程因为执行“so loading”操作而正在遍历集合元素,则会抛出 ConcurrentModificationException(ArrayList 内部实现)

方案落地

经过一轮调查发现 杜小菜 so 动态加载方案 还是值得推荐的,他有如下优势:

    1. 注入路径后,加载 so 的姿势不变
    1. 支持各种 CPU 架构平台
    1. 按需加载 so

急需解决的问题

1. 安全性问题

所有可执行代码在拷贝安装到安全路径(比如 Android 的 data/data 内部路径)之前,都有被劫持或者破坏的风险。so 动态化也不得不考虑这个安全性问题,最好的做法是每次加载 so 库之前都对其做一次安全性校验。那么怎么做安全性检查呢?

最简单的方式是记录 so 文件的 MD5 或者 CRC 等 Hash 信息(粒度可以是每个单独的 so 文件,或者一批 so 文件的压缩包),将信息内置到 APK 内部或者服务器(如果保存在服务器,客户端需要通过类似 HTTPS 之类的可信通道获取这些数据),通过校验 so 文件 Hash 信息是否一致来确保安全性。

我们具体看一下代码实现吧~

// ARM手机主动检测so,进入核心的activity时,开启延时检测,先停掉下载,减少对页面加载的影响,x秒后重新下载
    public void checkArmSoDelayWhenCreate(int delayMillis) {
        //test();//  todo 测试时开启,并注释以下代码,方便测试下载和加载so的交互

        if (localSoStatus.isDownloading) {
            ThreadManager.getBackgroundPool().execute(this::pauseDownloadTask);
        }
        weakHandler.removeCallbacks(startCheckRunnable);
        weakHandler.postDelayed(startCheckRunnable, delayMillis);
    }
复制代码

放在单独的线程中检测 so 是否完整

    private void checkSoLibsInBackThread() {
        weakHandler.removeCallbacks(startCheckRunnable);
        final ThreadManager.ThreadPoolProxy singlePool = ThreadManager.getSinglePool("so-download");
        //避免产生重复的检测任务。
        singlePool.remove(checkSoRunnable);
        singlePool.execute(checkSoRunnable);
    }

复制代码

接下来我们详细了解一下具体的检测逻辑吧

  • 3.1 zip 文件存在,则校验是否合法,md5 校验
  String soZipPath = soFileDownloader.getSoZipFilePath(SOURCE_MD5);
        final boolean allSoFilesExist = isAllSoFilesExist(soZipPath);
        //统计触发检测时,不存在so的情况
        StatisticsForSoLoader.sendSoFilesNotExist(allSoFilesExist);
        boolean hasInstalledSoPath = soFileDownloader.hasInstalledSoPath();
        localSoStatus.hasInstalledSoPath = hasInstalledSoPath;
        final boolean isPrepared = allSoFilesExist && hasInstalledSoPath;
复制代码
  • 3.2 完整解压,不完整删除缓存,重新下载
      localSoStatus.isPrepared = isPrepared;
       Log.d(TAG, "handleSoBackground isPrepared=" + isPrepared);
       if (isPrepared) {//一切就绪,回调出去,ok
           if (soLoaderListener != null) {
               soLoaderListener.prepared(true);
           } else {//回调出去继续执行
               MkWeexSoLoader.reloadWeexSoLib(this::notifyCallback);
           }
           return;
       }

           private void startDownload(SoLoaderListener soLoaderListener, String soZipPath) {
               //pauseDownloadTask();//每次下载前暂停上次任务,防止so读写出现问题
               String soUrl = getServerUrl();
               soFileDownloader.downloadSoFile(soUrl, soZipPath, soLoaderListener);
           }
复制代码
  • 3.3 是否存在 soNameList 里面指定的 so 文件
       for (File currentFile : currentFiles) {
            final String currentFileName = currentFile.getName();
            //so库,size>0,且是预先定义的合法so,统计so个数
            final boolean contains = allSoNameList.contains(currentFileName);
            if (currentFileName.endsWith(".so") && currentFile.length() > 0 && contains) {
                localSoFileCount++;
            }
        }
        //如果本地下载目录中的so文件总数目,少于应该有的so文件数目,说明不完整
        localSoStatus.isAllSoFilesExist = localSoFileCount >= allSoNameList.size();
        return localSoStatus.isAllSoFilesExist;
复制代码
  • 然后下载 so 库 zip 包,比对服务端的 MD5 值和客户端的 MD5 值是否一致
          localSoStatus.hasInstalledSoPath = hasInstalledSoPath;
                localSoStatus.hasDownloadSoZip = true;//标记是否下载成功
                localSoStatus.isZipLegal = checkSoZipMd5;//标记zip是否合法,md5校验ok则认为合法
                localSoStatus.isDownloading = false;
                boolean isPrepared = false;
                if (!checkSoZipMd5) {
                    StatisticsForSoLoader.sendZipFileStatus(false);//统计不合法
                    deleteOldCache();//不合法删除
                    if (countRetryDownload < 3) {// retry
                        reStartDownload();
                        countRetryDownload++;
                        return;
                    }
                    notifyPreparedCallback(false);
                }
复制代码

判断 zip 是否有更新,如果有更新,则需要重新下载

  public boolean isZipNeedUpdate(String md5) {
        String zipDirPath = getDownloadZipTempDir() + File.separator + md5;
        File zipRootFile = new File(zipDirPath);
        if (!zipRootFile.exists()) {//如果带md5的zip缓存路径不存在,说明需要重新下载,so更新了。
            Log.d(TAG, "app upgrade...");
            StatisticsForSoLoader.sendOverwriteInstallApp();
            deleteOldCache();//so更新,删除旧的zip缓存和so文件
            return true;
        }
        return false;
    }
复制代码
  • 下载完成后,解压,解压经常失败,所以要进行两次解压处理
       //下载成功
                    handler.post(() -> {
                        if (listener != null) {
                            listener.download(true, soUrl);
                        }
                    });
                    //解压
                    final boolean unZip = doUnZip(soZipPath);

                    //重新校验文件完整性
                    isPrepared = notifyPrepared(hasInstalledSoPath);

                    localSoStatus.isPrepared = isPrepared;
                    localSoStatus.isUnZip = unZip;//标记是否下载成功

                    if (isPrepared) {//加载成功后重新加载一次weex的库
                        MkWeexSoLoader.reloadWeexSoLib(() -> notifyPreparedCallback(true));
                    } else {
                        notifyPreparedCallback(false);
                    }
复制代码
  • 里面核心还是通过 ZipInputStream 实现的
            is = new ZipInputStream(new FileInputStream(zipFilePath));
            ZipEntry zipEntry;
            while ((zipEntry = is.getNextEntry()) != null) {
                String subfilename = zipEntry.getName();
                if (zipEntry.isDirectory()) {
                    File subDire = new File(folderPath + subfilename);
                    if (subDire.exists() && subDire.isDirectory()) {
                        continue;
                    } else if (subDire.exists() && subDire.isFile()) {
                        subDire.delete();
                    }
                    subDire.mkdirs();
                } else {
                    File subFile = new File(folderPath + subfilename);
                    if (subFile.exists()) {
                        continue;
                    }
                    final File parentFile = subFile.getParentFile();
                    if (parentFile != null && !parentFile.exists()) {
                        parentFile.mkdirs();
                    }
                    subFile.createNewFile();
                    os = new FileOutputStream(subFile);
                    int len;
                    byte[] buffer = new byte[5120];
                    while ((len = is.read(buffer)) != -1) {
                        os.write(buffer, 0, len);
                        os.flush();
                    }
                }
            }
复制代码
  • 解压完毕判断 so 文件是否解压并且完整存在,不完整,需要下载,下载前需要暂停上次任务,防止 so 读写出现问题
    private void startDownload(SoLoaderListener soLoaderListener, String soZipPath) {
        pauseDownloadTask();//每次下载前暂停上次任务,防止so读写出现问题
        String soUrl = getServerUrl();
        soFileDownloader.downloadSoFile(soUrl, soZipPath, soLoaderListener);
    }
复制代码
  • 如果本地下载目录中的 so 文件总数目,少于预定义在集合里 so 文件数目,说明不完整
  public boolean isSoUnzipAndExist() {
        String targetSoDir = SoFileDownloader.getLocalSoInstallDir();
        File dir = new File(targetSoDir);
        File[] currentFiles = dir.listFiles();
        if (currentFiles == null || currentFiles.length == 0) {
            return false;
        }
        int localSoFileCount = 0;
        for (File currentFile : currentFiles) {
            final String currentFileName = currentFile.getName();
            //so库,size>0,且是预先定义的合法so,统计so个数
            final boolean contains = allSoNameList.contains(currentFileName);
            if (currentFileName.endsWith(".so") && currentFile.length() > 0 && contains) {
                localSoFileCount++;
            }
        }
        //如果本地下载目录中的so文件总数目,少于应该有的so文件数目,说明不完整
        localSoStatus.isAllSoFilesExist = localSoFileCount >= allSoNameList.size();
        return localSoStatus.isAllSoFilesExist;
    }
复制代码
  • 再看 zip 包是否存在,如果有的话要再次解压
    public synchronized boolean checkSoZipMd5(String soZipPath) {
        if (TextUtils.isEmpty(soZipPath)) {
            return false;
        }
        final File localSoZipFile = new File(soZipPath);
        if (!localSoZipFile.exists() || localSoZipFile.length() == 0) {
            return false;
        }
        final String localSoMd5 = MD5Utils.getMd5(localSoZipFile);
        //Logger.d(TAG, "localSoMd5=" + localSoMd5);
        final boolean md5Equals = MkSoManager.SOURCE_MD5.equals(localSoMd5);
        if (!md5Equals) {//非法zip包直接删除,未下载完成的包不是这个路径,放心!
            FileUtils.deleteFile(soZipPath);
        }
        return md5Equals;
    }
复制代码
  • 解压完毕后就直接通过 injectLocalSoLibraryPath 注入驱动
    /**
     * 直接指定你so下载的路径
     */
    public static boolean installLocalSoPath(Context context) {
        try {
            String targetSoDir = SoFileDownloader.getLocalSoInstallDir();
            File soDir = new File(targetSoDir);
            if (!soDir.exists()) {
                soDir.mkdirs();
            }
            final ClassLoader classLoader = context.getApplicationContext().getClassLoader();
            boolean hasInstalledSoPath = LoadLibraryUtil.injectLocalSoLibraryPath(classLoader, soDir);
            if (!hasInstalledSoPath) {//只统计注入失败的情况,几乎不存在失败
                StatisticsForSoLoader.sendInstallPathStatus(false);
                Log.d(TAG, "installLocalSoPath=" + false + ",targetDir=" + targetSoDir);
            }
            MkSoManager.get().getLocalSoStatus().hasInstalledSoPath = hasInstalledSoPath;
            return hasInstalledSoPath;
        } catch (Throwable e) {
            Log.e(TAG, "installLocalSoPath error " + e);
        }
        return false;
    }
复制代码

不过 Hash 信息一般都会随之 so 文件的变动而改变,每次都需要调整这些数据比较麻烦,优化方案是“通过类似 APK 安装包签名校验的方式来确保安全性”:将 so 文件打包成 APK 格式的插件包并使用 Android Keystore 进行签名,将 Keystore 的指纹信息保存在宿主包内部,安全检验环节只需要校验插件包的签名信息是否和内置的指纹信息一致即可,具体可以参考文章链接

2. 版本控制问题

我们发布了某一个版本宿主 APK 和与之对应的 so 插件包,而这个版本的 so 是有 Bug 的可能导致 APP 崩溃。通过版本控制流程,我们可以在服务端禁用这个版本的 so 插件,从而使客户端进入“so 插件不可用”的逻辑,而不至于执行有问题的代码。那么代码该如何实现呢?这边提供了一下伪代码:

    public static void checkX86AndDownload(String baseDownloadUrl) {
        //final boolean isX86Phone = isX86Phone();
        // TODO: 2017/8/3 需要重新构建下载信息,sdk信息+线上的地址和版本号
        if (!checkSoVersion()) {//  || !isX86Phone 无需下载
            return;
        }
        //todo 接口获取下载路径和版本信息
        String cpuABI = MkSoManager.getMkSupportABI();
        String soUrl = baseDownloadUrl + cpuABI + ".zip";
        SoFileDownloader.init().downloadSoFile(cpuABI, soUrl);
    }

    /**
     * 根据服务器版本配置,确定是否下载
     */
    private static boolean checkSoVersion() {
        // TODO: 2017/8/3 与服务端校验,符合当前sdk对应的版本,检测本地so文件完整性
        return true;
    }
复制代码

3. abi 兼容性判断

abi 兼容性是 so 插件特有的动态化问题,除了考虑 so 插件是否安全之外,我们还需要检查 so 插件包里的 so 库 abi 信息是否与宿主目前运行时的 abi 一致。考虑这么一种情况:宿主 APK 里面内置了 ARM32 和 AMR64 两种 so 文件,同样插件包里也内置这两种 so 文件,当宿主 APK 安装在 ARM32 的设备上,动态加载 so 插件的时候,我们必须只解压并加载相应 AMR32 的 so 插件,对于 ARM64 的设备也是同样的道理。也就是说:同样的 APK 宿主,同样的 so 插件,安装在不同 abi 设备上时,动态化框架的插件处理行为是不一样的,那么具体实现逻辑是怎样的呢?

首先定义一个 LocalSoStatus 类,方便业务对下载逻辑进行自定义扩展

public class LocalSoStatus {
    public boolean hasInstalledSoPath = false;// 是否注入so路径
    public boolean isDownloading = false;// 是否正在下载
    public int progress = 0;// 下载进度
    public boolean hasStartDownload = false;// 是否启动过下载
    public boolean hasDownloadSoZip = false;// 是否成功下载zip文件
    public boolean isZipLegal = false;// zip文件是否合法
    public boolean isUnZip = false;// zip文件是否解压成功
    public boolean isAllSoFilesExist = false;// so文件是否完整存在
    public boolean isPrepared = false;// so注入成功并且本地文件完整
    public boolean hasLoadSoSuccess = false;// 测试是否成功加载过so库

    @Override
    public String toString() {
        return "LocalSoStatus{" +
                "hasInstalledSoPath=" + hasInstalledSoPath +
                ", isDownloading=" + isDownloading +
                ", progress=" + progress +
                ", hasStartDownload=" + hasStartDownload +
                ", hasDownloadSoZip=" + hasDownloadSoZip +
                ", isZipLegal=" + isZipLegal +
                ", isUnZip=" + isUnZip +
                ", isAllSoFilesExist=" + isAllSoFilesExist +
                ", isPrepared=" + isPrepared +
                ", hasLoadSoSuccess=" + hasLoadSoSuccess +
                '}';
    }
}
复制代码
  • 直接指定你 so 下载的路径,通过反射获取 android.os.SystemProperties 私有方法 get ro.product.cpu.abi 可以动态获取 CPU 架构
    /**
     * 获取设备的cpu架构类型
     */
    public static String getCpuArchType() {
        if (!TextUtils.isEmpty(cpuArchType)) {
            return cpuArchType;
        }
        try {
            Class<?> clazz = Class.forName("android.os.SystemProperties");
            Method get = clazz.getDeclaredMethod("get", new Class[]{String.class});
            cpuArchType = (String) get.invoke(clazz, new Object[]{"ro.product.cpu.abi"});
        } catch (Exception e) {
        }

        try {
            if (TextUtils.isEmpty(cpuArchType)) {
                cpuArchType = Build.CPU_ABI;//获取不到,重新获取,可能不准确?
            }
        } catch (Exception e) {
        }
        if (TextUtils.isEmpty(cpuArchType)) {
            cpuArchType = "armeabi-v7a";
        }
        cpuArchType = cpuArchType.toLowerCase();
        return cpuArchType;
    }
复制代码

4. System#load 加载代码侵入问题

使用 System.load("{安全路径}/libxxx.so") 。Native 代码在开发阶段完全可以用传统的内置方案进行调试,在集成阶段再按动态化的方案打包,这也就意味着我们必须频繁地在 System#load 和 System#loadLibrary("xxx" ) 直接来回修改,代码侵入性问题非常严重

通过 System#loadLibrary("xxx" ) 加载 so 库, Android Framework 会遍历当前上下文的 ClassLoader 实例里的 nativeLibraryDirectories 数组,在数组里所有的文件路径下查找文件名为 libxxx.so 的文件,所以我们的解决思路就是在安装好 so 插件之后,将其所在的内部安全路径注入到这个 nativeLibraryDirectories 数组里,即可实现通过 System#loadLibrary 加载,代码如下:

第一步: 通过反射,注入 so 文件注入到 nativeLibraryDirectories 路径
    private static final class V14 {
        private static void install(ClassLoader classLoader, File folder) throws Throwable {
           // 反射宿主 APK 的 ClassLoader 的 pathList成员变量
            Field pathListField = MkReflectUtil.findField(classLoader, "pathList");
            // 获取这个成员变量 在 宿主 APK 的 ClassLoader 对象的取值
            Object dexPathList = pathListField.get(classLoader);
            // 将被加载的 被加载的 so 实例存储到 dexPathList
            MkReflectUtil.expandArray(dexPathList, "nativeLibraryDirectories", new File[]{folder});
        }
    }
复制代码

需要注意的事项是: 不同的系统 SDK 版本因为其版本差异性,需要执行不同反射逻辑

SDK 版本: 14
    private static final class V14 {
        private static void install(ClassLoader classLoader, File folder) throws Throwable {
           // 反射宿主 APK 的 ClassLoader 的 pathList成员变量
            Field pathListField = MkReflectUtil.findField(classLoader, "pathList");
            // 获取这个成员变量 在 宿主 APK 的 ClassLoader 对象的取值
            Object dexPathList = pathListField.get(classLoader);
            // 将被加载的 被加载的 so 实例存储到 dexPathList
            MkReflectUtil.expandArray(dexPathList, "nativeLibraryDirectories", new File[]{folder});
        }
    }
复制代码
SDK 版本: 23
 private static final class V23 {
        private static void install(ClassLoader classLoader, File folder) throws Throwable {
            Field pathListField = MkReflectUtil.findField(classLoader, "pathList");
            Object dexPathList = pathListField.get(classLoader);

            Field nativeLibraryDirectories = MkReflectUtil.findField(dexPathList, "nativeLibraryDirectories");
            List<File> libDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);

            //去重
            if (libDirs == null) {
                libDirs = new ArrayList<>(2);
            }
            final Iterator<File> libDirIt = libDirs.iterator();
            while (libDirIt.hasNext()) {
                final File libDir = libDirIt.next();
                if (folder.equals(libDir)) {
                    libDirIt.remove();
                    Log.d(TAG, "dq libDirIt.remove() " + folder.getAbsolutePath());
                    break;
                }
            }

            libDirs.add(0, folder);
            Field systemNativeLibraryDirectories =
                    MkReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
            List<File> systemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);

            //判空
            if (systemLibDirs == null) {
                systemLibDirs = new ArrayList<>(2);
            }
            //Log.d(TAG, "dq systemLibDirs,size=" + systemLibDirs.size());

            // 获得Element[] 数组
            Method makePathElements = MkReflectUtil.findMethod(dexPathList, "makePathElements", List.class, File.class, List.class);
            ArrayList<IOException> suppressedExceptions = new ArrayList<>();
            libDirs.addAll(systemLibDirs);
           // 输出调用对象,插件APK所在目录,插件APK的全路径,和用于存储IO异常的List,获得Element[] 返回
            Object[] elements = (Object[]) makePathElements.invoke(dexPathList, libDirs, null, suppressedExceptions);
            Field nativeLibraryPathElements = MkReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
            nativeLibraryPathElements.setAccessible(true);
            nativeLibraryPathElements.set(dexPathList, elements);
        }
    }
复制代码
SDK 版本: 25
 private static final class V25 {
        private static void install(ClassLoader classLoader, File folder) throws Throwable {
            Field pathListField = MkReflectUtil.findField(classLoader, "pathList");
            Object dexPathList = pathListField.get(classLoader);
            Field nativeLibraryDirectories = MkReflectUtil.findField(dexPathList, "nativeLibraryDirectories");

            List<File> libDirs = (List<File>) nativeLibraryDirectories.get(dexPathList);
            //去重
            if (libDirs == null) {
                libDirs = new ArrayList<>(2);
            }
            final Iterator<File> libDirIt = libDirs.iterator();
            while (libDirIt.hasNext()) {
                final File libDir = libDirIt.next();
                if (folder.equals(libDir)) {
                    libDirIt.remove();
                    Log.d(TAG, "dq libDirIt.remove()" + folder.getAbsolutePath());
                    break;
                }
            }

            libDirs.add(0, folder);
            //system/lib
            Field systemNativeLibraryDirectories = MkReflectUtil.findField(dexPathList, "systemNativeLibraryDirectories");
            List<File> systemLibDirs = (List<File>) systemNativeLibraryDirectories.get(dexPathList);

            //判空
            if (systemLibDirs == null) {
                systemLibDirs = new ArrayList<>(2);
            }
            //Log.d(TAG, "dq systemLibDirs,size=" + systemLibDirs.size());

            Method makePathElements = MkReflectUtil.findMethod(dexPathList, "makePathElements", List.class);
            libDirs.addAll(systemLibDirs);

            Object[] elements = (Object[]) makePathElements.invoke(dexPathList, libDirs);
            Field nativeLibraryPathElements = MkReflectUtil.findField(dexPathList, "nativeLibraryPathElements");
            nativeLibraryPathElements.setAccessible(true);
            nativeLibraryPathElements.set(dexPathList, elements);
        }
    }
复制代码

注入 so 路径的逻辑如下:

  1. APK 的 ClassLoader 的 pathList 的成员变量,
  2. pathList 实际上是 SoPathList, 类的实例 的内部 成员变量 List 实例
  3. 这个 List 存储的是 被加载的 so 文件实例

我们看一下代码实现吧~

    /**
     * 1. 通过反射拿到dexElements的取值
     * 2. 将 findField 方法获取到的 object[] 插入到数组的最前面。
     * 3. 被插入的 object[] 数组就是外部修复包存储路径集合编译后形成的队列
     *    即外部修复包的资源和 .class 队列
     * @param instance 宿主 APK 的 ClassLoader实例的成员变量 pathList(DexPathList类似)
     * @param fieldName 需要被反射和替换的 DexPathList 类对象的成员变量 "dexElements", 用于存储 .dex 加载对象dex
     * @param extraElements 被加载的插件 apk 的 .dex实例列表
     */
    public static void expandFieldArray(Object instance, String fieldName, Object[] extraElements)
            throws NoSuchFieldException, IllegalArgumentException, IllegalAccessException {
        // 1 通过反射获取 classLoader 实例的成员变量 pathList(DexPathList类的实例)的成员变量dexElements
        Field jlrField = findField(instance, fieldName);
        // 2 获取当前dexElements 这个成员变量在classLoader 实例的成员变量 pathList(DexPathList类的实例)中的取值
        Object[] original = (Object[]) jlrField.get(instance);
        // 3 新建一个数组,这个数组用来容纳 宿主 apk .dex 文件加载出来的elements[] 和 插件apk .dex 文件加载出来的 elements[]
        Object[] combined = (Object[]) Array.newInstance(original.getClass().getComponentType(), original.length + extraElements.length);
      // 4 先把插件 apk 中获取的elements[] 以及 dexFileArr复制到数组里面,方便我们动态加载
        System.arraycopy(extraElements, 0, combined, 0, extraElements.length);
       // 5 再把apk所有的 dexElements 成员变量取值复制到数组里面
        System.arraycopy(original, 0, combined, extraElements.length, original.length);
         // 6 覆盖 dexElements 成员变量取值
        jlrField.set(instance, combined);
    }
复制代码

遇到的问题

问题一

N 开始情况就不一样了:libxxx.so 能正常加载,而 liblog.so 会出现加载失败错误

E/ExceptionHandler: Uncaught Exception java.lang.UnsatisfiedLinkError: dlopen failed: library "liblog.so" not found
at java.lang.Runtime.loadLibrary0(Runtime.java:xxx)
at java.lang.System.loadLibrary(System.java:xxx)
复制代码
问题一原因分析

Android P 以后,Linker 里检索的路径在创建 ClassLoader 实例后就被系统通过 Namespace 机制绑定了,当我们注入新的路径之后,虽然 ClassLoader 里的路径增加了,但是 Linker 里 Namespace 已经绑定的路径集合并没有同步更新

问题一解决方案

完全自己控制 so 文件的检索逻辑

ARM 手机主动检测 so,进入核心的 activity 时,开启延时检测,先停掉下载,减少对页面加载的影响,x 秒后重新下载

    public void checkArmSoDelayWhenCreate(int delayMillis) {

        if (localSoStatus.isDownloading) {
            ThreadManager.getBackgroundPool().execute(this::pauseDownloadTask);
        }
        weakHandler.removeCallbacks(startCheckRunnable);
        weakHandler.postDelayed(startCheckRunnable, delayMillis);
    }
复制代码

放在单独的线程中检测 so 是否完整

    private void checkSoLibsInBackThread() {
        weakHandler.removeCallbacks(startCheckRunnable);
        final ThreadManager.ThreadPoolProxy singlePool = ThreadManager.getSinglePool("so-download");
        // 避免产生重复的检测任务。
        singlePool.remove(checkSoRunnable);
        singlePool.execute(checkSoRunnable);
    }

复制代码

接下来我们详细了解一下具体的检测逻辑吧

zip 文件存在,则校验是否合法,md5 校验

  String soZipPath = soFileDownloader.getSoZipFilePath(SOURCE_MD5);
        final boolean allSoFilesExist = isAllSoFilesExist(soZipPath);
        //统计触发检测时,不存在so的情况
        StatisticsForSoLoader.sendSoFilesNotExist(allSoFilesExist);
        boolean hasInstalledSoPath = soFileDownloader.hasInstalledSoPath();
        localSoStatus.hasInstalledSoPath = hasInstalledSoPath;
        final boolean isPrepared = allSoFilesExist && hasInstalledSoPath;
复制代码

完整解压,不完整删除缓存,重新下载

      localSoStatus.isPrepared = isPrepared;
       Log.d(TAG, "handleSoBackground isPrepared=" + isPrepared);
       if (isPrepared) { // 一切就绪,回调出去,ok
           if (soLoaderListener != null) {
               soLoaderListener.prepared(true);
           } else { // 回调出去继续执行
               MKWeexSoLoader.reloadWeexSoLib(this::notifyCallback);
           }
           return;
       }

           private void startDownload(SoLoaderListener soLoaderListener, String soZipPath) {
               //pauseDownloadTask();//每次下载前暂停上次任务,防止so读写出现问题
               String soUrl = getServerUrl();
               soFileDownloader.downloadSoFile(soUrl, soZipPath, soLoaderListener);
           }
复制代码

是否存在 soNameList 里面指定的 so 文件

       for (File currentFile : currentFiles) {
            final String currentFileName = currentFile.getName();
            // so库,size>0,且是预先定义的合法so,统计so个数
            final boolean contains = allSoNameList.contains(currentFileName);
            if (currentFileName.endsWith(".so") && currentFile.length() > 0 && contains) {
                localSoFileCount++;
            }
        }
        // 如果本地下载目录中的so文件总数目,少于应该有的so文件数目,说明不完整
        localSoStatus.isAllSoFilesExist = localSoFileCount >= allSoNameList.size();
        return localSoStatus.isAllSoFilesExist;
复制代码

问题二:将相关加载代码挪出静态代码块

so 动态化改造之后,如果项目后续开发中有人不小心在 so 插件尚未安装完成之前引用了相关的 JNI 类,则在改造成动态化的时候,最好将相关加载代码挪出静态代码块,并且增加 so 加载失败时候的 onFail 逻辑

  • 如果是 X86 的手机,初始化 x86 平台的 so 文件名列表
    isX86Phone = isX86Phone();
         if (isX86Phone) {
            SOURCE_MD5 = MD5_X86;
            initX86SoFileNameList();
        }
            private void initX86SoFileNameList() {
                if (allSoNameList != null) {
                    addAgoraSoLibs();// xxxxxx库
                     ```
                }
            }
复制代码
  • 如果是 armeabi-v7a 的手机,初始化 armeabi-v7a 平台的 so 文件名列表
else {
            SOURCE_MD5 = MD5_ARMEABI_V7A;
            initArmSoFileNameList();
        }

private void initArmSoFileNameList() {
                if (allSoNameList != null) {
                    addAgoraSoLibs();
                    addWeexSolibs();
                }
            }
    private void addAgoraSoLibs() {
        allSoNameList.add("xxxxxx.so");
       ```
       ```
    }

private void addWeexSolibs() {//weex 核心库,x86 arm都需要下发,不需要的不要乱加入
           allSoNameList.add("xxxxxx.so");
        ```
        ```
}
复制代码
  • 检测 so 库是否准备好了。ARM 手机只有声网相关业务和 weex 的创建,需要检测 so
  • 其他不需要;x86 手机则无论如何需要检测
    public void checkSoLibReady(Context context, boolean isNeedCheckWhenArm, CheckSoCallback callback) {
        if (isX86Phone && isNeedCheckWhenArm) {//如果是x86手机,arm需要检测的地方无视
            doCallback(callback);
            return;
        }
        if (!isX86Phone && !isNeedCheckWhenArm) {//arm手机,无需检测则无视
            doCallback(callback);
            return;
        }
        this.mCallback = callback;
        boolean doCheck = doCheck(context);
        if (doCheck) {//直接callback回去
            doCallback(callback);
        }
    }

复制代码

然后再把检测 so 的回调传给业务层处理

public interface CheckSoCallback {
    void prepared();
}
复制代码

问题三: Google Play Store 动态代码禁用问题

包含有动态代码的 APK 包是无法上传到 Play Store 的,可以向 APK 客户端下发绑定版本的“一个主资源包 + 一个 patch 包”,体积上限个 1G。so 动态化和版本绑定非,一旦发布就无法修改

问题四: 部分 ROM 机型删了 Build.VERSION.PREVIEW_SDK_INT 属性,导致无法获取 SDK 版本信息

   @TargetApi(Build.VERSION_CODES.M)
    private static int getPreviousSdkInt() {
        try {
            return Build.VERSION.PREVIEW_SDK_INT;
        } catch (Throwable ignore) {
        }
        return 1;
    }
复制代码

动态化加载打点统计

看似完美的方案,每个 ROM 手机千差万别,出现问题,总得及时定位,下面就说一下怎样打点,把事件带到线上吧

  • 统计启动应用后的第一次检测,获取 dispatch 地址后进入
  • 统计触发检测,so 有没有准备好
  • 统计开始下载 so
  • 统计下载状态,0 成功,1 失败,msg 是错误信息
  • 统计用户触发的重新下载逻辑
  • 统计暂停过下载任务
  • 统计 zip 包解压情况
  • 统计 zip 包是否合法完整
  • 统计 so 是否准备,0 成功,1 失败,msg 是错误信息。应该 99%以上会成功,所以目前只统计准备失败的情况,
  • 统计 weex 渲染的情况
  • 统计安装 so 路径是否成功
  • 统计提示过移动网络的次数
  • 统计显示了 loading ui 的情况
  • 统计不兼容的情况
  • 统计 app 覆盖安装,并且 so 有升级的情况

总结

实际项目中,so 动态下发遇到的坑比较多,熟悉系统加载 so 库的工作流程和反射流程。才能解决动态化过程中的安全性问题,版本控制问题,abi 兼容性判断和 System#load 加载代码侵入问题。当然理论是基石,线上打点分析 so 状态和网络状态才能保证我们应用在线上的稳定性。关于 App 瘦身可以聊的东西太多,如果本篇文章阅读量超过 2000,下一节写一下关于 png 转 webpng 自动化转化教程,满足大家对 App 瘦身的好奇心