什麼是外掛化?外掛化對於Android應用能起到什麼好處?可能對於外掛化不熟悉的夥伴們都會有這個疑問,或許你在專案中已經遇到過這個問題,只不過是不知道需要採用什麼樣的方式去解決,我們看下面這個場景。
一個應用主模組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進階開發資料~
插播一個資料
2 元件化和外掛化的區別
在實際專案中,元件化是使用最頻繁的,例如
將app分為多個模組,每個模組都是一個元件,在開發過程中,元件之間可以相互依賴,也可以單獨作為app除錯,最終打包的時候,是將這些元件合併到一起打包成一個apk。
而外掛化和元件化類似的是,app同樣被分為多個模組,但是每個模組都有一個宿主和多個外掛,也就是說每個模組都是一個apk,最終打包的時候宿主apk和外掛apk分開打包
3 外掛化設計思路
在設計一個框架的時候,往往需要想明白目的是什麼?外掛是一個apk,如果我們想要啟動這個外掛,主要有以下幾個關鍵點:
(1)如何啟動外掛(2)如何載入外掛中的類(3)如何載入外掛中的資源(4)如何呼叫外掛中的類和方法
3。1 Android的類載入機制
如果想要載入外掛中的類,那麼對於Android的類載入機制必須要了解,在之前Tinker熱修復專題中,其實已經介紹了Android的類載入機制,那麼這裡再簡單介紹一下。
Android類載入和Java不同的在於,Android擁有自己的類載入器,看下圖
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)。 *
我們可以看到,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來載入,所以我們先嚐試載入外掛中某個類,呼叫它的方法。
外掛也是一個apk,其中有一個TestPlugin類
class TestPlugin { fun getPluginInfo():String{ return “this is my first plugin” }}
TestPlugin透過編譯成class檔案後,轉為dex檔案打包進入apk,我們可以模擬這個場景
將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去載入某個類。
透過建立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
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
在DexPathList中,有一個非常重要的成員變數dexElements
,我們看過apk包的話,應該會看到有很多dex檔案。所以我們傳入的dexPath下,可能存在多個dex檔案,那麼dexElements其實就是儲存這些dex檔案的,我們可以看到,在DexPathList的構造方法中,呼叫了makeDexElements方法,其實就是將dex檔案儲存在dexElements陣列中。
private static List
首先在makeDexElements方法中,首先呼叫了splitPaths方法,這個方法就是
將傳入的dexPath路徑下全部的dex檔案儲存在一個List集合中,作為第一個引數,傳入到makeDexElements方法中
private static Element[] makeDexElements(List
然後,makeDexElements方法中,建立了一個Element陣列,將之前傳入的List集合中檔案分組,將帶有。dex字尾的檔案和其他檔案(夾)區分放置
看了這麼多,核心在於宿主類載入器如何載入apk中的類呢?看下findClass方法
public Class<?> findClass(String name, List
我們可以看到,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
這種方式同樣可以採用反射呼叫,具體的實現大家可以動手寫一寫!本節主要介紹瞭如何載入外掛中的類,呼叫外掛中類的方法,後續會繼續介紹載入資原始檔的實現。
作者:Ghelper
連結:https://juejin。cn/post/7142475355293499422