1. 程式人生 > >[Android Pro] so 動態載入—解決sdk過大問題

[Android Pro] so 動態載入—解決sdk過大問題

原文地址: https://blog.csdn.net/Rong_L/article/details/75212472

前言

相信Android 開發中大家或多或少都會整合一些第三方sdk, 而其中難免要會使用到他們的so檔案。但有時,你會發現這些so檔案過多,對於一些需要經常更新的應用來說,這將會大大浪費使用者的流量。而有些sdk的整合僅僅是隻為了一個不是必須的功能,我們完全有充足的理由用一些技術的手段來解決因這部分sdk整合帶來的安裝包大小問題。

so目錄

觀察發現,很多sdk的大小主要集中在so檔案上。為了儘可能多的適應不同cup,sdk通常會提供不同二進位制檔案,這些檔案被分門別類地放在armeabi,x86,mips等目錄下。這裡我們有必要了解下這些目錄的含義。

目錄 cpu型別
armeabi ARM 通用cpu
armeabi-v7a 支援浮點運算ARM cpu,向下相容armeabi
arm64-v8a ARM 64位cpu, 向下相容armeabi-v7a
x86 x86通用型cpu
x86_64 x86 64位cpu
不同cpu在apk應用安裝時,會查詢對應的目錄,比如,arm64位機子,會優先檢視apk中是否有arm64-v8a目錄,如果有,則採用該目錄下的so檔案,如果沒有,則會查詢相容的目錄。一旦確定下目錄之後,其他的目錄便不會再去管了。(日後如果在確定的目錄下沒有找到對應的so檔案,也不會去其他目錄中找到)

目前市面上大部分手機都相容armeabi-v7a,哪怕x86的cpu也會相容(效能會有損耗)。所以armeabi-v7a目錄建議一定配置,其相比armeabi在效能上有很大的提升。

動態載入so

再次回到前言中的問題,我們有沒有什麼辦法能夠減少so的大小,從而減少apk安裝包的大小呢?
1. 如果不太在意效能的損耗,那麼我們完全可以只適配armeabi-v7a包和x86包,讓64位機器執行32位的so檔案。
2. 單獨出arm版本和x86版本,這樣也可以減少一半的so大小。

可如果你覺得這樣包還是太大,比如我們現在用的crosswalk瀏覽器核心,單個so檔案就達到了27M,同時適配x86的話,會達到58M, 這是我們所無法接受的事情!

於是乎開始想有沒有什麼辦法能把so檔案與apk檔案分離開來,在程式執行的時候來把so檔案下載下來,並載入程式去載入。從而實現動態載入so檔案的目的。

System.load 與 System.loadLibrary

google出的結果直接導向了System.load和System.loadLibrary這兩個方法。
system.load 引數中載入的so的路徑,比如:system.load(“/data/data/com.codemao.android/libs/libcrosswalk.so”)
system.loadLibrary引數中傳入的是so的名稱,比如system.loadLibrary(“crosswalk”), 系統會自動根據名稱與機器的cpu型號,找到對應的so目錄,並載入對應的lib crosswalk.so檔案。
(兩者檔案都只能在app的私有目錄下)

那這樣子的話,是不是我們從遠端下載完so檔案之後,解壓到app私有目錄下,在呼叫sdk的地方呼叫system.load主動載入so之後,就可以實現動態載入so檔案了呢?

同學,你真是太天真啦!我們回想下自己寫的so檔案是如何呼叫的?是不是在需要使用的類裡主動調了system.loadLibrary呢?sdk也一樣,sdk在自己的程式碼裡主動呼叫了system.loadLibrary。而這時,我們so檔案因為沒有隨著apk安裝到手機上,並不在它的尋找範圍之內,最後的結果是你即使呼叫了system.load載入了so檔案,理論是可以找到相應的native方法了,但是sdk在呼叫system.loadLibrary時會丟擲找不到對應的so檔案的錯誤。

外掛化如何處理so

這該如何處理sdk內部呼叫loadLibrary丟擲的異常資訊呢?apk內的so檔案最終被放到了/data/app/com.codemao.android/lib/下面,我們總不能把遠端下載下來的so檔案放入這裡吧,可/data/app這個目錄下面的檔案我們是沒有許可權去執行讀寫操作的。

這裡我們想到了另一個問題,外掛化可以執行另一個apk,而apk裡面難免會有so,那宿主程式又是如何處理外掛的so檔案呢?
查詢之後發現:原文地址

有時候我們在開發外掛的時候,可能會呼叫so檔案,一般來說有兩種方案:
一種是在載入外掛的時候,先把外掛中的so檔案釋放到本地目錄,然後在把目錄設定到DexClassLoader類載入器的nativeLib中。
一種在外掛初始化的時候,釋放外掛中的so檔案到本地目錄,然後使用System.load方法去全路徑載入so檔案
這兩種方式的區別在於,
第一種方式的程式碼邏輯放在了宿主工程中,同時so檔案可以放在外掛的任意目錄中,然後在解壓外掛檔案找到這個so檔案釋放即可。
第二種方式的程式碼邏輯是放在了外掛中,同時so檔案只能放在外掛的assets目錄中,然後通過把外掛檔案設定到程式的AssetManager中,最後通過訪問assets中的so檔案進行釋放。

我們自己apk使用的classloader是pathclassloader, 那我們是不是隻要把so所在的目錄加入到pathclassloader的nativeLib之中就好了呢? 
讓我們再次來看下system.loadLibrary:

public static void loadLibrary(String libname) {  
    Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname);  
} 

Runtime.java

synchronized void loadLibrary0(ClassLoader loader, String libname) {  
    if (libname.indexOf((int)File.separatorChar) != -1) {  
        throw new UnsatisfiedLinkError(  
"Directory separator should not appear in library name: " + libname);  
    }  
    String libraryName = libname;  
    //loader這裡傳入的是pathclassloader, 不為空
    if (loader != null) {  
        //呼叫findLibrary找到so路徑
        String filename = loader.findLibrary(libraryName);  
        if (filename == null) {    
            throw new UnsatisfiedLinkError(loader + " couldn't find \"" +  
                                           System.mapLibraryName(libraryName) + "\"");  
        }  
        //呼叫doLoad載入找到的so檔案
        String error = doLoad(filename, loader);  
        if (error != null) {  
            throw new UnsatisfiedLinkError(error);  
        }  
        return;  
    }
    ...//以下邏輯我們可以暫且忽略
    }

好的,這裡我們看到載入的過程主要兩步: 
1. 呼叫pathclassloader.findLibrary,先找到對應的so檔案 
2. 呼叫doLoad加入找到的so檔案

那我們來看下classloader.findLibrary是如何找到對應so檔案的:

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

pathList 是BaseDexClassLoader 裡的DexPathList物件(注意6.0 開始nativeLibraryDirectories放的不在是File, 不過載入邏輯是一樣的, 要注意適配。)

public String findLibrary(String libraryName) {
        String fileName = System.mapLibraryName(libraryName);
        for (File directory : nativeLibraryDirectories) {
            File file = new File(directory, fileName);
            if (file.exists() && file.isFile() && file.canRead()) {
                return file.getPath();
            }
        }
        return null;
    }

這裡主要做的事:
1. 呼叫system.mapLibraryName, 補全名稱, 如比libraryName=crosswalk, 補全之後會是lib crosswalk.so
2. 遍歷nativeLibraryDirectories,看下目錄下面有對應的檔案嗎

哈哈,到這裡,機會來了,我們只要把遠端下載so的目錄通過反射的方式放入nativeLibraryDirectories中就ok啦,真是太激動啦!!!

適配與實現方案

為了儘量減少效能損耗,我們先根據cpu的型別確定自己要下載的so檔案,之後再用反射的方式把so的目錄加入到classloader中,這樣便可以解決so過大而引起apk包過大的問題。

但我們前面說過,6.0之後的DexPathList與6.0之前的DexPathList不一樣,這裡要注意適配的問題,

6.0之後findLibrary 變為了:

 public String findLibrary(String libraryName) {
        String fileName = System.mapLibraryName(libraryName);
        for (Element element : nativeLibraryPathElements) {
            String path = element.findNativeLibrary(fileName);
            if (path != null) {
                return path;
            }
        }
        return null;
}

6.0和之前的:

public String findLibrary(String libraryName) {
        String fileName = System.mapLibraryName(libraryName);
        for (File directory : nativeLibraryDirectories) {
            File file = new File(directory, fileName);
            if (file.exists() && file.isFile() && file.canRead()) {
                return file.getPath();
            }
        }
        return null;
    }

 

Element 中的程式碼如下:

public String findNativeLibrary(String name) {
            maybeInit();
            if (isDirectory) {
                String path = new File(dir, name).getPath();
                if (IoUtils.canOpenReadOnly(path)) {
                    return path;
                }
            } else if (zipFile != null) {
                String entryName = new File(dir, name).getPath();
                if (isZipEntryExistsAndStored(zipFile, entryName)) {
                  return zip.getPath() + zipSeparator + entryName;
                }
            }
            return null;
 }

所以我這裡直接給出適配好的關鍵程式碼,供大家參考

/**
* 將 so所在的目錄放入PathClassLoader裡的nativeLibraryDirectories中
*
* @param context
*/
public void installSoDir(Context context) {

    //安卓4.0以下不維護
    if(Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
        return ;
    }
    File soDirFile = context.getDir(soDir, Context.MODE_PRIVATE);
    if(!soDirFile.exists()) {
        soDirFile.mkdirs();
    }
    if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        v23Install(soDirFile, context);
    } else {
        v14Install(soDirFile, context);
    }
}

private void v14Install(File soDirFile, Context context) {
        PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
        Object pathList = getPathList(pathClassLoader);
        if(pathList != null) {
            //獲取當前類的屬性
            try {
                Field nativeLibraryDirectoriesField = pathList.getClass().getDeclaredField("nativeLibraryDirectories");
                nativeLibraryDirectoriesField.setAccessible(true);
                Object list = nativeLibraryDirectoriesField.get(pathList);
                if(list instanceof List) {
                    ((List) list).add(soDirFile);
                } else if(list instanceof File[]) {
                    File[] newList = new File[((File[]) list).length + 1];
                    System.arraycopy(list, 0 , newList, 0, ((File[]) list).length);
                    newList[((File[]) list).length] = soDirFile;
                    nativeLibraryDirectoriesField.set(pathList, newList);
                }
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
        }
    }

private void v23Install(File soDirFile, Context context) {
        PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
        Object pathList = getPathList(pathClassLoader);
        if(pathList != null) {
            //獲取當前類的屬性
            try {
                Field nativeLibraryPathField = pathList.getClass().getDeclaredField("nativeLibraryPathElements");
                nativeLibraryPathField.setAccessible(true);
                Object list = nativeLibraryPathField.get(pathList);
                Class<?> elementType = nativeLibraryPathField.getType().getComponentType();
                Constructor<?> constructor = elementType.getConstructor(File.class, boolean.class, File.class, DexFile.class);
                constructor.setAccessible(true);
                Object element = constructor.newInstance(soDirFile, true, null, null);
                if(list instanceof List) {
                    ((List) list).add(element);
                } else if(list instanceof Object[]) {
                    Object[] newList = new File[((Object[]) list).length + 1];
                    System.arraycopy(list, 0 , newList, 0, ((Object[]) list).length);
                    newList[((Object[]) list).length] = element;
                    nativeLibraryPathField.set(pathList, newList);
                }
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            } catch (InstantiationException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            }
        }
    }
private Object getPathList(Object classLoader) {
        Class cls = null;
        String pathListName = "pathList";
        try {
            cls = Class.forName("dalvik.system.BaseDexClassLoader");
            Field declaredField = cls.getDeclaredField(pathListName);
            declaredField.setAccessible(true);
            return declaredField.get(classLoader);
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return null;
    }

參考文章

Android中so使用知識和問題總結以及外掛開發過程中載入so的方案解析
Android專案針對libs(armeabi,armeabi-v7a,x86)進行平臺相容
Android JNI之System.loadLibrary()流程