App極限瘦身: 動態下發so

小木箱發表於2020-11-21

改不完的 Bug,寫不完的矯情。公眾號 楊正友 現在專注移動基礎開發 ,涵蓋音視訊和 APM,資訊保安等各個知識領域;只做全網最 Geek 的公眾號,歡迎您的關注!

前言

一般來說,作為一個成熟的應用,native 檔案會越來越多,資原始檔過大安卓的包體越來越大,包體積的增長,逐漸帶來了一些不利影響,比如使用者安裝成功率降低,CDN 流量費用增加,流失部分付費渠道方,新擴充的渠道方抱怨包體過大,限制了渠道擴充,那麼如何解決這種業務痛點呢? 今天就詳細給大家介紹一下 Android 動態化管理 so

比如 鬥魚 日本客戶端專案就同時支援 arm32/arm64/x86/x86-V7a 四種 ,so 體積成倍地上漲。因此,能不能將非主要的 abi 相關的 so 檔案動態化,也成為了國際化出海專案瘦身優化不得不優先考慮的問題。希望能通過包體優化,降低流量成本,避免由於包體過大導致的使用者流失。

系統載入 so 庫的工作流程

當我們呼叫當呼叫 System#loadLibrary("xxx" ) 後,Android Framework 都幹了些了啥?

大致流程示意圖如下:

參考騰訊Bugly
參考騰訊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.classFile.classList.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 瘦身的好奇心

相關文章