【Android 進階】完美插件化實現(xiàn)钻趋,compose 開發(fā)動態(tài)加載

插件化.jpg

https://juejin.cn/post/6973888932572315678
https://zhaomenghuan.js.org/blog/android-plugin-framework-proxy-hook.html
https://juejin.cn/post/7028196921143459870

前言

溫度爬升茄唐,蚊蟲也開始猖狂了起來川梅。燥熱的空氣里圾笨,穿梭著幾只置身死于度外的飛蟲,全然沒有在意我這個執(zhí)掌著生死的巨人赁温,讓人欽佩坛怪。

一直在小公司徘徊淤齐,在小團隊里面摸爬滾打股囊。面試中往往會被面試官對于一些平時用不到的技能細致追問,想要跳高只能開始學習工作中用不到的技能更啄,不然就會陷入死循環(huán)怪圈

  • 你沒這塊工作經驗稚疹,單位不要。
  • 又因為沒單位要祭务,所以就沒有工作經驗内狗。
    所以只能自己偷偷默默學習,以demo當作經驗义锥。

名詞解釋

雙親委派機制 :在類加載器中柳沙,指的是當一個類加載器收到了類加載的請求的時候,他不會直接去加載指定的類拌倍,而是把這個請求委托給自己的父加載器去加載赂鲤。 只有父加載器無法加載這個類的時候噪径,才會由當前這個加載器來負責類的加載。

正文

首先附上demo 源碼鏈接
插件化一直都沒有嘗試過数初。聽起來高大上找爱,重要的是面試中被問到的概率也是居高不下。
在開發(fā)過程中泡孩,不用安裝app就能運行新的apk是多么美妙的事情车摄,插件化對于我們的工程應用也有實際意義。本文就以compose Demo項目完整實現(xiàn)一下插件化仑鸥。完成插件的生成裝載吮播,并成功跳轉展示對應的插件頁面。

一眼俊,插件化的方案

在Android中薄料,真正安裝一個apk的過程很簡單,就只是將apk文件拷貝到對應的目錄中泵琳,并且解壓出對應的so文件就好了摄职,其余就是一些解析,掃描組件获列,校驗谷市,dex優(yōu)化的操作,用戶安裝包都是拷貝存放在data/app中击孩。
啟動app之后迫悠,會從zygote fork一個新的進程,并使用ClassLoader加載apk巩梢。
而我們要動態(tài)加載创泄,也是利用了DexClassLoader來加載我們的插件apk。
但是比起真正的安裝apk括蝠,我們也有以下問題鞠抑。

  1. 如何利用ClassLoader加載apk?
  2. 沒有注冊對應manifest忌警,在啟動activity的時候怎么繞過Activity限制搁拙?
  3. 沒有加載資源文件,要如何獲确唷箕速?

接下來的內容中,以解決以上問題為主要任務朋譬,實現(xiàn)完整的插件app Activity啟動盐茎,展示頁面的過程。

本文是對于compose項目的插件化徙赢,所以暫不涉及xml布局資源的加載獲取字柠。compose布局由代碼完成滑进。

二,DexClassLoader加載apk

如果需要將apk放置到緩存文件夾之外募谎,需要配置好存儲權限扶关,并動態(tài)請求,否則利用DexClassLoader加載會報錯:No original dex files found for dex location

接下來進入正題数冬。

我們先創(chuàng)建一個工程节槐。目錄如下所示

plugin_pjoject
    - app  //主工程app
    - plugin_app  //一個插件app
    - plugin_base  //基礎類庫,包含插件宿主Activity拐纱,加載插件工具類铜异,和基礎插件接口類

接下來在plugin_base中編寫加載代碼

插件apk的生成只需要將plugin_app 直接打包出apk就行,不需要簽名秸架,不會進行簽名校驗揍庄。
方便起見,我們將插件 apk 直接放到assets里面东抹。
代碼邏輯如下:

  1. 在app啟動的時候蚂子,先拷貝assets中的插件plugin.apk到緩存目錄中
  2. 生成一個對應的DexClassLoader ,專門用來后續(xù)加載插件中的類缭黔。
object PluginLoader {
    fun loadPlugin() {
        val inputStream = Utils.getApp().assets.open("plugin.apk")
        val filesDir = Utils.getApp().externalCacheDir
        val apkFile = File(filesDir?.absolutePath, "plugin.apk")
        apkFile.writeBytes(inputStream.readBytes())

        val dexFile = File(filesDir, "dex")
        FileUtils.createOrExistsDir(dexFile)
        "輸出dex路徑${dexFile}".logI()
        pluginClassLoader = DexClassLoader(apkFile.absolutePath, dexFile.absolutePath, null, this.javaClass.classLoader)
    }
}
///插件的類加載器
lateinit var pluginClassLoader: DexClassLoader;

這樣我們就得到pluginClassLoader食茎。

DexClassLoader源碼分析

1. 關聯(lián)android dalvik源碼

要想對源碼進行分析,肯定要先能看到源碼馏谨,我們默認在AndroidStudio中無法查看DexClassLoader源碼,在manager中下載了對應source也一樣别渔。所以我們需要自己下載對應源碼和as關聯(lián),具體做法參考下面文章學習:
http://www.reibang.com/p/9af6d2fadcb1

2. 雙親委派機制

在關聯(lián)好源碼后惧互,我們先看CLassLoader類中最關鍵的loadClass方法

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        // 檢查是否被加載過
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            if (parent != null) {
                ///使用父加載器先加載類
                c = parent.loadClass(name, false);
            } else {
                c = findBootstrapClassOrNull(name);
            }

            if (c == null) {
                // 如果仍然沒找到哎媚,就執(zhí)行findClass尋找
                c = findClass(name);
            }
        }
        return c;
    }
}

從該版本加載器代碼中看到,類加載的默認機制就是先從祖先類加載開始加載喊儡,如果找不到就一層層子加載器加載拨与。其中findClass只是個空方法,交由子類自己實現(xiàn)具體代碼管宵。
其中findBootstrapClassOrNull方法更是直接私有化截珍,并返回空,看起來并沒有實際意義箩朴。

值得注意的是,里面有個靜態(tài)方法createSystemClassLoader秋度,其方法返回了一個PathClassLoader作為系統(tǒng)類的加載器炸庞。

重點:在我們的需求中,我們只需要加載宿主或者系統(tǒng)中不存在的類荚斯,所以只需要創(chuàng)建一個加載器埠居,并將原來的加載器作為父加載器就行了查牌。
這樣既能加載插件新類,又不影響原來的舊類滥壕。

從中也能看到纸颜,如果我們想要優(yōu)先讓自己的類加載器加載,就需要實現(xiàn)子類覆寫loadClass方法绎橘,自定義加載邏輯胁孙。

3. DexClassLoader 對于apk的加載實現(xiàn)

我們先來看下DexClassLoader代碼。

/**
一個類加載器称鳞,它從包含classes.dex條目的.jar和.apk文件加載類涮较。這可用于執(zhí)行未作為應用程序的一部分安裝的代碼。
在 API 級別 26 之前冈止,此類加載器需要一個應用程序私有的可寫目錄來緩存優(yōu)化的類狂票。使用Context.getCodeCacheDir()創(chuàng)建這樣一個目錄:
  File dexOutputDir = context.getCodeCacheDir();
不要在外部存儲上緩存優(yōu)化的類。外部存儲不提供保護您的應用程序免受代碼注入攻擊所必需的訪問控制熙暴。
*/
public class DexClassLoader extends BaseDexClassLoader {
    public DexClassLoader(String dexPath, String optimizedDirectory, 
            String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}

DexClassLoader代碼很簡單闺属,本來它和PathClassLoader的區(qū)別就是可以自定義optimizedDirectory,但從注釋中可知周霉,為了安全起見屋剑,該參數在api 26也就是android 8.0之后就已經失效了,優(yōu)化輸出地址不再可以自由配置诗眨,而有系統(tǒng)統(tǒng)一設置唉匾。該加載器的所有的實現(xiàn)都在BaseDexClassLoader中,只是進行了optimizedDirectory的屏蔽操作匠楚。
接下來看下BaseDexClassLoader的加載方法

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    // 首先巍膘,檢查該類是否存在于我們的共享庫中。
    //...省略
    //檢查該類加載器操作的 dexPath 中是否存在相關類芋簿。
    List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
    Class c = pathList.findClass(name, suppressedExceptions);
    //...省略異常處理
    return c;
}

其中關鍵就是通過**pathList**``.findClass通過DexPathList集合來查找峡懈。再定位到DexPathList.findClass代碼

public Class<?> findClass(String name, List<Throwable> 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處理了。
dexElements初始化代碼在makeDexElements方法中与斤,其中掃描了我們給的apk路徑所有文件(給的路徑可以是多個肪康,以pathSeparator分割),并將dex后綴的文件加載為DexFile撩穿,并和file一起組裝為Element對象放置到Element數組中磷支,給dexELements賦值。
這就完成了apk內所有dex文件的加載食寡。
接下來的DexFile具體加載定義類的代碼都是native代碼雾狈,分析就到此為止。

4. 最后總結下流程

對于我們的插件apk加載的過程如下抵皱。

  1. 以apk為路徑善榛,構建DexClassLoader
  2. BaseDexClassLoader加載apk內的所有dex文件辩蛋,加載為Element
  3. 插件內的類在需要的時候,在經歷過雙親委托的父節(jié)點加載器加載后移盆,被DexClassLoader加載出來
    所以對于加載插件悼院,我們只需要創(chuàng)建一個專用的DexClassLoader就行了,如下:
DexClassLoader(apkFile.absolutePath, dexFile.absolutePath, null, this.javaClass.classLoader)

三咒循,startActivity 流程初步分析

1. Activity注冊校驗

加載出類之后据途,正常的代碼我們已經可以執(zhí)行了。但是在安卓中剑鞍,我們想要顯示界面昨凡,還要面對安卓對activity的注冊校驗。沒有注冊過的activity直接是無法打開的蚁署。會報錯:
Unable to find explicit activity class **; have you declared this activity in your AndroidManifest.xml?*
這是因為在我們startActivity的時候便脊,Instrumentation中會進行校驗」飧辏看源碼可知哪痰,startActivity的流程如下:

content.png

對于Activity注冊的校驗,就是在Instumentation.checkStartActivityResult中進行的久妆。

2. Instrumentation 代理

我們從源碼中得知晌杰,控制跳轉的代碼由Instumentation處理,所以我們可以對他做文章筷弦。
我們想要正常打開我們的插件頁面肋演,用的是容器思想,建立一個容器activity烂琴,承載插件的頁面爹殊,而要兼容startActivity 直接配置容器Class,只需要代理Instumentation對象奸绷,進行容器替換處理就可以了梗夸。
我們先實現(xiàn)自己的Instrumentation代理類:

class PluginInstrumentation(var instrumentation:Instrumentation): Instrumentation() {

    @SuppressLint("DiscouragedPrivateApi")
    fun execStartActivity(
        who: Context?, contextThread: IBinder?, token: IBinder?, target: Activity?,
        intent: Intent?, requestCode: Int, options: Bundle?
    ): ActivityResult? {
        try {
            val pluginClazz = pluginClassLoader.loadClass(intent?.component?.className)
            var newIntent=intent;
            if (pluginClazz.superclass == IPluginActivityInterface::class.java) {
                newIntent=Intent(who,HostActivity::class.java)
                intent?.extras?.let {
                    newIntent.putExtras(it)
                }
                newIntent.putExtra(HostActivity.ARG_PLUGIN_CLASS_NAME,pluginClazz.name)
            }
            val execStartActivity: Method = Instrumentation::class.java.getDeclaredMethod(
                "execStartActivity",
                Context::class.java,
                IBinder::class.java,
                IBinder::class.java,
                Activity::class.java,
                Intent::class.java,
                Int::class.javaPrimitiveType,
                Bundle::class.java
            )

            return execStartActivity.invoke(instrumentation, who, contextThread, token, target, newIntent, requestCode, options) as ActivityResult
        } catch (e: Exception) {
            e.printStackTrace()
        }
        return null
    }
}

在代理類中,我們重寫了execStartActivity号醉。對于插件Activity的Intent意圖反症,替換成HostActivity。其余的插件相關頁面邏輯畔派,我們在HostActivity中進行調用铅碍。
這樣就能繞過注冊限制,展示插件中定義的頁面和布局了父虑。

需要注意的是该酗,對于其中的插件類,我們是用專門的pluginClassLoader加載的士嚎。

3. 替換instrumentation代理類

我們先看instumentation 的初始化代碼呜魄。

final void attach(Context context, ActivityThread aThread,
        Instrumentation instr, IBinder token, int ident,
        Application application, Intent intent, ActivityInfo info,
        CharSequence title, Activity parent, String id,
        NonConfigurationInstances lastNonConfigurationInstances,
        Configuration config, String referrer, IVoiceInteractor voiceInteractor,
        Window window, ActivityConfigCallback activityConfigCallback, IBinder assistToken,
        IBinder shareableActivityToken) {
    attachBaseContext(context);
///...
    mInstrumentation = instr;
    }

可以看到mInstrumentation在attach方法中被賦值,我們想要代理改變量莱衩,就只需要在attach之后爵嗅,利用反射重新賦值代理類就行了。
我們從源碼中觀察到笨蚁,attachActivityThread.performnLaunchActivity中被調用睹晒,也在
mInstrumentation.callActivityOnCreate的調用之前,而onCreate的執(zhí)行就在該調用鏈中括细。
所以我們只需要在Activity.onCreate中完成mInstrumentation的代理類替換就行了伪很。

這一塊代碼我們通過對Application添加activity生命周期監(jiān)聽實現(xiàn) ,代碼如下奋单,這一塊代碼的執(zhí)行通過實現(xiàn)自己的ContentProvider實現(xiàn)锉试。利用框架對provider的初始化機制,實現(xiàn)xml注冊就無感注冊監(jiān)聽览濒,具體代碼可以看源碼中的PluginContentProvider類呆盖。

override fun onCreate(): Boolean {
    Utils.getApp().registerActivityLifecycleCallbacks(object :ActivityLifecycleCallbacks{
        @SuppressLint("DiscouragedPrivateApi")
        override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
            // 拿到原始的 mInstrumentation字段
            val mInstrumentationField: Field = Activity::class.java.getDeclaredField("mInstrumentation")
            mInstrumentationField.isAccessible = true
            // 創(chuàng)建代理對象
            val originalInstrumentation: Instrumentation = mInstrumentationField.get(activity) as Instrumentation
            mInstrumentationField.set(activity, PluginInstrumentation(originalInstrumentation))
        }
        //... 省略其他生命周期代碼
    })
    return true
}

四,利用宿主Activity替換掉實際打開的Activity

在完成mInstrumentation替換后贷笛,我們還需要完善我們的宿主容器HostActivtiy以及對應插件Activity的接口应又,完成以下幾個目的:

  1. 像打開正常的activity一樣,打開插件activity
  2. 占位Activity到插件Activity的轉換
  3. 無侵入式插件Activity開發(fā)

完成插件Activity的抽象代理類(插件Activity只是寫法和正常Activity一樣乏苦,其實不是Activity的子類)株扛,模擬正常Activity的生命周期

簡單起見,我們就先只定義了setContentView汇荐,onCreategetIntent 方法洞就,其他生命周期方法在后續(xù)需要的時候在完善。重點是registerHostActivity方法拢驾,我們通過該方法注冊宿主Activity奖磁,并在其各個生命周期中的各個方法,都交給mHostActivity代理完成繁疤。

abstract class IPluginActivityInterface {
    lateinit var mHostActivity: HostActivity
    fun registerHostActivity(hostActivity: HostActivity) {
        mHostActivity = hostActivity;
    }

    fun getIntent() = mHostActivity.intent
    open fun onCreate(savedInstanceState: Bundle?){}
    fun setContentView(layoutResID: Int) {
        mHostActivity.setContentView(layoutResID)
    }
}

HostActivity中咖为,實例化插件IPluginActivityInterface

我們在HostActivityonCreate方法中取出之前Instumentation中傳遞的ARG_PLUGIN_CLASS_NAME,通過反射實例化出插件Activity

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    intent.getStringExtra(ARG_PLUGIN_CLASS_NAME)?.let {
        val clazz= pluginClassLoader.loadClass(it)
        pluginActivity = clazz.newInstance() as? IPluginActivityInterface
        pluginActivity?.registerHostActivity(this)
    }
    pluginActivity?.onCreate(savedInstanceState)
}

這樣就能正常加載插件Activity了稠腊。

四躁染,compose ui開發(fā)和兼容

插件模塊中的ui開發(fā)使用的是compose。對于compose的兼容非常簡單架忌,我們簡單實現(xiàn)個setContent拓展方法吞彤,中轉調用宿主activity的setContent方法就行了,如下:

fun IPluginActivityInterface.setContent(
    parent: CompositionContext? = null,
    content: @Composable () -> Unit
) {
    mHostActivity.setContent(parent,content)
}

這樣我們就能很正常的開發(fā)插件App了。如圖所示


image.png

界面開發(fā)完成后饰恕,自然就是跳轉了挠羔。
跳轉到PluginActivity的代碼,也和普通Activity沒有區(qū)別埋嵌,直接startActivity破加,代碼如下:

val intent = Intent().apply {
    component = ComponentName(context, "com.example.plugin_app.PluginsActivity")
}
context.startActivity(intent)

至此,完整的app雹嗦,插件app開發(fā)已經實現(xiàn)范舀。更多的細節(jié)在源碼中

最后

吁了一口長氣。
成功的完成了這個小文章的撰寫了罪。
最后放上示例demo的gif吧


image.png
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末锭环,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子泊藕,更是在濱河造成了極大的恐慌辅辩,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,490評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件吱七,死亡現(xiàn)場離奇詭異汽久,居然都是意外死亡,警方通過查閱死者的電腦和手機踊餐,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,581評論 3 395
  • 文/潘曉璐 我一進店門景醇,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人吝岭,你說我怎么就攤上這事三痰。” “怎么了窜管?”我有些...
    開封第一講書人閱讀 165,830評論 0 356
  • 文/不壞的土叔 我叫張陵散劫,是天一觀的道長。 經常有香客問我幕帆,道長获搏,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,957評論 1 295
  • 正文 為了忘掉前任失乾,我火速辦了婚禮常熙,結果婚禮上,老公的妹妹穿的比我還像新娘碱茁。我一直安慰自己裸卫,他們只是感情好,可當我...
    茶點故事閱讀 67,974評論 6 393
  • 文/花漫 我一把揭開白布纽竣。 她就那樣靜靜地躺著墓贿,像睡著了一般茧泪。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上聋袋,一...
    開封第一講書人閱讀 51,754評論 1 307
  • 那天队伟,我揣著相機與錄音,去河邊找鬼舱馅。 笑死缰泡,一個胖子當著我的面吹牛刀荒,可吹牛的內容都是我干的代嗤。 我是一名探鬼主播,決...
    沈念sama閱讀 40,464評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼缠借,長吁一口氣:“原來是場噩夢啊……” “哼干毅!你這毒婦竟也來了?” 一聲冷哼從身側響起泼返,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤硝逢,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后绅喉,有當地人在樹林里發(fā)現(xiàn)了一具尸體渠鸽,經...
    沈念sama閱讀 45,847評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,995評論 3 338
  • 正文 我和宋清朗相戀三年柴罐,在試婚紗的時候發(fā)現(xiàn)自己被綠了徽缚。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,137評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡革屠,死狀恐怖凿试,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情似芝,我是刑警寧澤那婉,帶...
    沈念sama閱讀 35,819評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站党瓮,受9級特大地震影響详炬,放射性物質發(fā)生泄漏。R本人自食惡果不足惜寞奸,卻給世界環(huán)境...
    茶點故事閱讀 41,482評論 3 331
  • 文/蒙蒙 一呛谜、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧蝇闭,春花似錦呻率、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,023評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春元践,著一層夾襖步出監(jiān)牢的瞬間韭脊,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,149評論 1 272
  • 我被黑心中介騙來泰國打工单旁, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留沪羔,地道東北人。 一個月前我還...
    沈念sama閱讀 48,409評論 3 373
  • 正文 我出身青樓象浑,卻偏偏與公主長得像蔫饰,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子愉豺,可洞房花燭夜當晚...
    茶點故事閱讀 45,086評論 2 355

推薦閱讀更多精彩內容