首頁/ 汽車/ 正文

Android進階寶典——外掛化1(載入外掛中類)

什麼是外掛化?外掛化對於Android應用能起到什麼好處?可能對於外掛化不熟悉的夥伴們都會有這個疑問,或許你在專案中已經遇到過這個問題,只不過是不知道需要採用什麼樣的方式去解決,我們看下面這個場景。

Android進階寶典——外掛化1(載入外掛中類)

一個應用主模組20M,其他3個模組可以看做是3個App,分別佔5M、15M、15M,如果打包,那麼整個包體積為55M;如果我們需要做包體積壓縮,那麼這3個實打實的app無論怎麼壓縮都會佔用app的體積。

那麼如果使用外掛化技術呢?最終打出的包體積只有20M,其他3個模組都是以外掛的方式存在,而Main App則是能夠支援外掛的宿主App,

所以外掛化的特點就是不需要安裝就能執行app

1 外掛化解決的問題

(1)app功能模組越來越多,包體積增大

其實這是一個app成為大型app的必經之路,模組越加越多,所以就如前文講解的一樣,採用外掛的方式,當需要啟動一個app的時候,將外掛下載下來,呼叫外掛中的方法執行app。

(2)模組解耦

每個外掛其實在app中都可以看做是一個單獨的模組,如果採用外掛化的方式,那麼可以將每個功能抽離為單獨的module,每個module可以獨立執行,不會出現多個模組耦合在一塊的問題

(3)多應用之間相互呼叫

這個其實我們在使用支付寶、淘寶的時候,經常會使用到,例如從閒魚app中跳轉到支付寶、或者跳轉到淘寶,支援相互呼叫。

插播一個Android進階開發資料~

Android進階寶典——外掛化1(載入外掛中類)

插播一個資料

2 元件化和外掛化的區別

在實際專案中,元件化是使用最頻繁的,例如

Android進階寶典——外掛化1(載入外掛中類)

將app分為多個模組,每個模組都是一個元件,在開發過程中,元件之間可以相互依賴,也可以單獨作為app除錯,最終打包的時候,是將這些元件合併到一起打包成一個apk。

而外掛化和元件化類似的是,app同樣被分為多個模組,但是每個模組都有一個宿主和多個外掛,也就是說每個模組都是一個apk,最終打包的時候宿主apk和外掛apk分開打包

3 外掛化設計思路

在設計一個框架的時候,往往需要想明白目的是什麼?外掛是一個apk,如果我們想要啟動這個外掛,主要有以下幾個關鍵點:

(1)如何啟動外掛(2)如何載入外掛中的類(3)如何載入外掛中的資源(4)如何呼叫外掛中的類和方法

3。1 Android的類載入機制

如果想要載入外掛中的類,那麼對於Android的類載入機制必須要了解,在之前Tinker熱修復專題中,其實已經介紹了Android的類載入機制,那麼這裡再簡單介紹一下。

Android類載入和Java不同的在於,Android擁有自己的類載入器,看下圖

Android進階寶典——外掛化1(載入外掛中類)

3。1。1 PathClassLoader和DexClassLoader有啥區別?

在Android中常用的兩個類載入器,分別是PathClassLoader和DexClassLoader,兩者的區別我們稍後分析,先看下具體的原始碼分析。

public class PathClassLoader extends BaseDexClassLoader { /** * Creates a {@code PathClassLoader} that operates on a given list of files * and directories。 This method is equivalent to calling * {@link #PathClassLoader(String, String, ClassLoader)} with a * {@code null} value for the second argument (see description there)。 * * @param dexPath the list of jar/apk files containing classes and * resources, delimited by {@code File。pathSeparator}, which * defaults to {@code “:”} on Android * @param parent the parent class loader */ public PathClassLoader(String dexPath, ClassLoader parent) { super(dexPath, null, null, parent); } /** * Creates a {@code PathClassLoader} that operates on two given * lists of files and directories。 The entries of the first list * should be one of the following: * *

    *
  • JAR/ZIP/APK files, possibly containing a “classes。dex” file as * well as arbitrary resources。 *
  • Raw “。dex” files (not inside a zip file)。 *
* * The entries of the second list should be directories containing * native library files。 * * @param dexPath the list of jar/apk files containing classes and * resources, delimited by {@code File。pathSeparator}, which * defaults to {@code “:”} on Android * @param librarySearchPath the list of directories containing native * libraries, delimited by {@code File。pathSeparator}; may be * {@code null} * @param parent the parent class loader */ public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) { super(dexPath, null, librarySearchPath, parent); }}

我們可以看到,PathClassLoader是繼承自BaseDexClassLoader,其中只有兩個構造方法,先不著急,再看下DexClassLoader的原始碼

public class DexClassLoader extends BaseDexClassLoader { /** * Creates a {@code DexClassLoader} that finds interpreted and native * code。 Interpreted classes are found in a set of DEX files contained * in Jar or APK files。 * *

The path lists are separated using the character specified by the * {@code path。separator} system property, which defaults to {@code :}。 * * @param dexPath the list of jar/apk files containing classes and * resources, delimited by {@code File。pathSeparator}, which * defaults to {@code “:”} on Android * @param optimizedDirectory this parameter is deprecated and has no effect since API level 26。 * @param librarySearchPath the list of directories containing native * libraries, delimited by {@code File。pathSeparator}; may be * {@code null} * @param parent the parent class loader */ public DexClassLoader(String dexPath, String optimizedDirectory,String librarySearchPath, ClassLoader parent) { super(dexPath, null, librarySearchPath, parent); } //Android 8。0 以前的原始碼 public DexClassLoader(String dexPath, String optimizedDirectory,String librarySearchPath, ClassLoader parent) { super(dexPath, new File(optimizedDirectory), librarySearchPath, parent); }}

DexClassLoader同樣是繼承自BaseDexClassLoader,而且跟PathClassLoader中構造方法傳入的值是一樣的,當前版本是Android10版本,其實在Android 8。0之前的版本,第二個引數是必須要傳入的,optimizedDirectory是dex最佳化之後生成odex檔案儲存地址,但是Android 8。0之後,就直接傳null了

所以在Android 8.0之前,PathClassLoader和DexClassLoader還是有區別的,但是在Android 8.0之後,兩者就是一樣的了,所以網上之前的老部落格還在區分兩者的區別,其實是不對的了

3。1。2 PathClassLoader和BootClassLoader

BaseDexClassLoader是PathClassLoader繼承上的父類,但是並不代表BaseDexClassLoader是PathClassLoader的父類載入器,我們透過程式碼可以看一下

Log。e(“TAG”, “classLoader $classLoader parent ${classLoader。parent}”)

我們在MainActivity中列印下日誌,我們可以發現就是MainActivity的類載入器是PathClassLoader,而PathClassLoader的父類載入器是BootClassLoader

E/TAG: classLoader dalvik。system。PathClassLoader[DexPathList[[zip file “/data/app/com。lay。image_process-n8633iv_VMBnRO2AEqJ4rg==/base。apk”],nativeLibraryDirectories=[/data/app/com。lay。image_process-n8633iv_VMBnRO2AEqJ4rg==/lib/x86, /system/lib]]] parent java。lang。BootClassLoader@3ab8f00

那麼PathClassLoader和BootClassLoader分別加在什麼類呢?

Log。e(“TAG”, “activity ${Activity::class。java。classLoader}”)

透過之前的程式碼,我們可以看到,

應用內的類都是PathClassLoader來載入(包括三方庫),而Activity的類載入器是BootClassLoader,也就是說Android SDK中的類是由BootClassLoader來載入的

3。1。3 雙親委派機制

和Java的類載入機制一樣,Android類載入同樣遵循雙親委派機制,我們看下ClassLoader的loadClass方法。

protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException{ // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { try { if (parent != null) { c = parent。loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class。 c = findClass(name); } } return c;}

(1)首先透過類的全類名,查詢這個類是不是已經被載入過了,如果已經載入過了,那麼直接返回;

(2)如果沒有被載入過,那麼首先會判斷父類載入器是否為空,如果不為空那麼就交給父類載入器去載入,依次遞迴,如果某個父類載入器載入過了,那麼就返回,如果所有的父類載入器都遍歷過了,而且不能去載入這個類,那麼就自己去載入;

(3)自己怎麼載入呢?就是從DexPathList中取出dex檔案載入其中類,跟我們今天講的外掛化就聯絡起來了,其實就是透過PathClassLoader或者DexClassLoader去載入

那麼

為什麼要使用雙親委派機制呢?其實更多的是為了安全性考慮

,假如我們自己寫了一個String類,想要代替系統的String,這個其實是不可能的,因為系統SDK中的類已經被BootClassLoader載入過了,我們應用內的String類就不會再次被載入。

3。2 載入外掛中的類

透過前面對於類載入機制的簡單瞭解,我們知道,外掛中類其實就可以透過ClassLoader來載入,所以我們先嚐試載入外掛中某個類,呼叫它的方法。

Android進階寶典——外掛化1(載入外掛中類)

外掛也是一個apk,其中有一個TestPlugin類

class TestPlugin { fun getPluginInfo():String{ return “this is my first plugin” }}

TestPlugin透過編譯成class檔案後,轉為dex檔案打包進入apk,我們可以模擬這個場景

Android進階寶典——外掛化1(載入外掛中類)

將class轉換為dex檔案,採用下面的命令列

dx ——dex ——output=/Users/xxx/Desktop/dx/plugin。dex com/lay/plugin/TestPlugin。class

這裡需要注意一點就是,/Users/xxx/Desktop/dx是class檔案所在包名的字首,/Users/xxx/Desktop/dx/com/lay/plugin/TestPlugin。class是class檔案所在的全路徑,只有這樣才能生成dex檔案,不然可能會報錯

java。lang。RuntimeException: com/lay/plugin/TestPlugin。class: file not foundat com。android。dex。util。FileUtils。readFile(FileUtils。java:51)at com。android。dx。cf。direct。ClassPathOpener。processOne(ClassPathOpener。java:168)at com。android。dx。cf。direct。ClassPathOpener。process(ClassPathOpener。java:143)at com。android。dx。command。dexer。Main。processOne(Main。java:678)at com。android。dx。command。dexer。Main。processAllFiles(Main。java:575)at com。android。dx。command。dexer。Main。runMonoDex(Main。java:310)at com。android。dx。command。dexer。Main。runDx(Main。java:288)at com。android。dx。command。dexer。Main。main(Main。java:244)at com。android。dx。command。Main。main(Main。java:95)

這樣,我們得到dex檔案之後,可以將其放在sd卡下面,透過ClassLoader去載入某個類。

Android進階寶典——外掛化1(載入外掛中類)

透過建立PathClassLoader或者DexClassLoader,去載入外掛(dex)中類,獲取Class物件,透過反射可以生成一個類物件,獲取到getPluginInfo方法後,呼叫這個方法

val loader = PathClassLoader(“/sdcard/plugin。dex”, null, MainActivity::class。java。classLoader)val clazz = loader。loadClass(“com。lay。plugin。TestPlugin”)try { val testPluginObj = clazz。newInstance() val getPluginInfoMethod = clazz。getMethod(“getPluginInfo”) val result = getPluginInfoMethod。invoke(testPluginObj) Log。e(“TAG”, “result $result”)} catch (e: Exception) {}

2022-09-12 16:45:10。761 8306-8306/com。lay。image_process E/TAG: result this is my first plugin

其實從這裡就能驗證,無論是PathClassLoader還是DexClassLoader,都可以載入未安裝apk中的類。

3。2 宿主和外掛dex合併

在上一小節中,我們採用了反射的方式,載入dex檔案中的類,但是實際的專案開發中,夥伴們認為這種方式可取嗎?顯然不可取,一個外掛可能有上千個方法,都採用反射的方式去呼叫,那豈不是太荒唐了,所以我們想,既然宿主apk能夠載入apk中所有的類和資源,那麼能不能把外掛中的類和資源也全部捎帶上呢?

首先我們先看一下宿主apk載入的流程,之前上一小節中,我們看到了類載入的雙親委派機制,其實應用中的類都是由PathClassLoader載入的,所以我們看下PathClassLoader是如何載入類的。

因為PathClassLoader只有兩個構造方法,所以直接去它父類BaseDexClassLoader中檢視原始碼;在ClassLoader的loadClass方法中,我們看到如果沒有其他父類載入器能夠載入這個類,就會由當前類載入器呼叫findClass方法區載入,所以我們看下BaseDexClassLoader中的findClass方法。

3。2。1 DexPathList和dexElements

@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException { List suppressedExceptions = new ArrayList(); Class c = pathList。findClass(name, suppressedExceptions); if (c == null) { ClassNotFoundException cnfe = new ClassNotFoundException( “Didn‘t find class ”“ + name + ”“ on path: ” + pathList); for (Throwable t : suppressedExceptions) { cnfe。addSuppressed(t); } throw cnfe; } return c;}

BaseDexClassLoader中的findClass中,呼叫了pathList的findClass方法,如果沒有找到,那麼就會丟擲ClassNotFoundException的異常,那麼pathList是什麼呢?

pathList是BaseDexClassLoader中的一個變數DexPathList,是在BaseDexClassLoader的構造方法中完成初始化,會將dexPath作為引數傳遞進來,其實在上一小節中,我們在建立PathClassLoader的時候,其實已經初始化了這個DexPathList

/** * @hide */public BaseDexClassLoader(String dexPath, File optimizedDirectory,String librarySearchPath, ClassLoader parent, boolean isTrusted) { super(parent); this。pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted); if (reporter != null) { reportClassLoaderChain(); }}

既然後綴帶有一個List,我們猜到這個資料結構應該是個陣列,那麼我們看下DexPathList到底是個什麼

DexPathList(ClassLoader definingContext, String dexPath,String librarySearchPath, File optimizedDirectory, boolean isTrusted) { if (definingContext == null) { throw new NullPointerException(“definingContext == null”); } if (dexPath == null) { throw new NullPointerException(“dexPath == null”); } if (optimizedDirectory != null) { if (!optimizedDirectory。exists()) { throw new IllegalArgumentException( “optimizedDirectory doesn’t exist: ” + optimizedDirectory); } if (!(optimizedDirectory。canRead() && optimizedDirectory。canWrite())) { throw new IllegalArgumentException( “optimizedDirectory not readable/writable: ” + optimizedDirectory); } } this。definingContext = definingContext; ArrayList suppressedExceptions = new ArrayList(); // save dexPath for BaseDexClassLoader this。dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions, definingContext, isTrusted);}

在DexPathList中,有一個非常重要的成員變數dexElements

,我們看過apk包的話,應該會看到有很多dex檔案。所以我們傳入的dexPath下,可能存在多個dex檔案,那麼dexElements其實就是儲存這些dex檔案的,我們可以看到,在DexPathList的構造方法中,呼叫了makeDexElements方法,其實就是將dex檔案儲存在dexElements陣列中。

private static List splitPaths(String searchPath, boolean directoriesOnly) { List result = new ArrayList<>(); if (searchPath != null) { for (String path : searchPath。split(File。pathSeparator)) { if (directoriesOnly) { try { StructStat sb = Libcore。os。stat(path); if (!S_ISDIR(sb。st_mode)) { continue; } } catch (ErrnoException ignored) { continue; } } result。add(new File(path)); } } return result;}

首先在makeDexElements方法中,首先呼叫了splitPaths方法,這個方法就是

將傳入的dexPath路徑下全部的dex檔案儲存在一個List集合中,作為第一個引數,傳入到makeDexElements方法中

private static Element[] makeDexElements(List files, File optimizedDirectory,List suppressedExceptions, ClassLoader loader, boolean isTrusted) { Element[] elements = new Element[files。size()]; int elementsPos = 0; /* * Open all files and load the (direct or contained) dex files up front。 */ for (File file : files) { if (file。isDirectory()) { // We support directories for looking up resources。 Looking up resources in // directories is useful for running libcore tests。 elements[elementsPos++] = new Element(file); } else if (file。isFile()) { String name = file。getName(); DexFile dex = null; if (name。endsWith(DEX_SUFFIX)) { // Raw dex file (not inside a zip/jar)。 try { dex = loadDexFile(file, optimizedDirectory, loader, elements); if (dex != null) { elements[elementsPos++] = new Element(dex, null); } } catch (IOException suppressed) { System。logE(“Unable to load dex file: ” + file, suppressed); suppressedExceptions。add(suppressed); } } else { try { dex = loadDexFile(file, optimizedDirectory, loader, elements); } catch (IOException suppressed) { /* * IOException might get thrown “legitimately” by the DexFile constructor if * the zip file turns out to be resource-only (that is, no classes。dex file * in it)。 * Let dex == null and hang on to the exception to add to the tea-leaves for * when findClass returns null。 */ suppressedExceptions。add(suppressed); } if (dex == null) { elements[elementsPos++] = new Element(file); } else { elements[elementsPos++] = new Element(dex, file); } } if (dex != null && isTrusted) { dex。setTrusted(); } } else { System。logW(“ClassLoader referenced unknown path: ” + file); } } if (elementsPos != elements。length) { elements = Arrays。copyOf(elements, elementsPos); } return elements;}

然後,makeDexElements方法中,建立了一個Element陣列,將之前傳入的List集合中檔案分組,將帶有。dex字尾的檔案和其他檔案(夾)區分放置

看了這麼多,核心在於宿主類載入器如何載入apk中的類呢?看下findClass方法

public Class<?> findClass(String name, List suppressed) { for (Element element : dexElements) { Class<?> clazz = element。findClass(name, definingContext, suppressed); if (clazz != null) { return clazz; } } if (dexElementsSuppressedExceptions != null) { suppressed。addAll(Arrays。asList(dexElementsSuppressedExceptions)); } return null;}

我們可以看到,findClass是遍歷dexElements陣列,呼叫Element的findClass方法,如果找到了這個類,那麼就直接return,

所以如果我們想在宿主app中,載入外掛中的類,是不是就可以將外掛中的dexElements合併到宿主的dexElements,就可以直接呼叫了

3。2。2 實現dex合併工具

透過上面的原始碼,我們可以得到dex合併的思路

(1)獲取宿主的dexElements

(2)獲取外掛的dexElements

(3)將宿主的dexElements和外掛的dexElements合併成新的dexElements

(4)將合併之後的dexElements賦值給宿主的dexElements

//獲取宿主的dexElementprivate fun findBaseDexElement(context: Context): Array<*>? { val baseClassLoader = context。classLoader val clazz = Class。forName(“dalvik。system。BaseDexClassLoader”) //獲取DexPathList val pathListFiled = clazz。getDeclaredField(“pathList”) pathListFiled。isAccessible = true val pathList = pathListFiled。get(baseClassLoader) //獲取宿主的dexElement val dexClazz = Class。forName(“dalvik。system。DexPathList”) val dexElementsFiled = dexClazz。getDeclaredField(“dexElements”) dexElementsFiled。isAccessible = true return dexElementsFiled。get(pathList) as Array<*>?}

透過反射獲取BaseDexClassLoader中的pathList成員變數,然後透過DexPathList來獲取對應宿主的dexElements

private fun findPluginDexElement(context: Context, pluginDexPath: String): Array<*>? { //載入外掛的類載入器 val classLoader = PathClassLoader(pluginDexPath, null, context。classLoader) val clazz = Class。forName(“dalvik。system。BaseDexClassLoader”) val pathListFiled = clazz。getDeclaredField(“pathList”) pathListFiled。isAccessible = true //這樣獲取到的就是外掛中的DexPathList val pathList = pathListFiled。get(classLoader) //獲取外掛的dexElement val dexClazz = Class。forName(“dalvik。system。DexPathList”) val dexElementsFiled = dexClazz。getDeclaredField(“dexElements”) dexElementsFiled。isAccessible = true return dexElementsFiled。get(pathList) as Array<*>?}

對於外掛類,宿主啟動的時候並沒有載入進來,所以不能使用宿主的類載入器,需要新建一個PathClassLoader來載入對應路徑下的apk,這樣就能生成對應的dexElements,才可以透過反射去獲取。

接下來就是需要合併兩個dexElement,因為透過反射是沒法獲取返回值型別,所以返回的型別是Object型別,

那麼我們可以建立一個Object陣列,然後重新賦值給宿主的dexElements嗎?顯然不行,我們透過原始碼可以看到,宿主的dexElements需要的是Element型別的陣列,所以需要透過反射來建立陣列

private fun makeNewDexElements( baseDexElement: Array<*>?, pluginDexElement: Array<*>?): Any? { if (baseDexElement != null && pluginDexElement != null) { val newDexElements = java。lang。reflect。Array。newInstance( baseDexElement。javaClass。componentType, baseDexElement。size + pluginDexElement。size ) System。arraycopy(baseDexElement, 0, newDexElements, 0, baseDexElement。size) System。arraycopy( pluginDexElement, 0, newDexElements, baseDexElement。size, pluginDexElement。size ) return newDexElements } return null}

建立了新的newDexElements陣列之後,透過系統的arraycopy方法,將兩個陣列複製到新的陣列中。

dexElementsFiled。set(pathList, newDexElements)

最終,將組合之後的Element陣列重新賦值給宿主app的dexElements。

PluginDexMergeManager。loadPluginDex(this,“/sdcard/plugin-debug。apk”)

其實apk外掛的儲存一般是儲存在服務端,然後從服務端拉取下來,下載然後注入到宿主app中,這裡只是模擬放在了sdcard下面,但是這裡可能存在一個問題,就是第一次啟動速度比較慢,但是也只是第一次,後續下載完成之後,就直接取本地快取即可。

其實在DexPathList中,提供了一個方法addDexPath,可以將dex檔案儲存的路徑傳進去,然後內部自動將dex檔案跟與宿主dexElements組合在一起

public void addDexPath(String dexPath, File optimizedDirectory, boolean isTrusted) { final List suppressedExceptionList = new ArrayList(); final Element[] newElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptionList, definingContext, isTrusted); if (newElements != null && newElements。length > 0) { final Element[] oldElements = dexElements; dexElements = new Element[oldElements。length + newElements。length]; System。arraycopy( oldElements, 0, dexElements, 0, oldElements。length); System。arraycopy( newElements, 0, dexElements, oldElements。length, newElements。length); } if (suppressedExceptionList。size() > 0) { final IOException[] newSuppressedExceptions = suppressedExceptionList。toArray( new IOException[suppressedExceptionList。size()]); if (dexElementsSuppressedExceptions != null) { final IOException[] oldSuppressedExceptions = dexElementsSuppressedExceptions; final int suppressedExceptionsLength = oldSuppressedExceptions。length + newSuppressedExceptions。length; dexElementsSuppressedExceptions = new IOException[suppressedExceptionsLength]; System。arraycopy(oldSuppressedExceptions, 0, dexElementsSuppressedExceptions, 0, oldSuppressedExceptions。length); System。arraycopy(newSuppressedExceptions, 0, dexElementsSuppressedExceptions, oldSuppressedExceptions。length, newSuppressedExceptions。length); } else { dexElementsSuppressedExceptions = newSuppressedExceptions; } }}

這種方式同樣可以採用反射呼叫,具體的實現大家可以動手寫一寫!本節主要介紹瞭如何載入外掛中的類,呼叫外掛中類的方法,後續會繼續介紹載入資原始檔的實現。

作者:Ghelper

連結:https://juejin。cn/post/7142475355293499422

相關文章

頂部