Shadow的全動態設計原理解析

咱們在宣傳Shadow的時候說了Shadow具備兩大特性,其中一個叫作「全動態插件框架」。這篇文章就講這個特性。咱們很早以前用過一款基於數百反射私有API實現的插件框架,在前面的文章也提過,在這種插件框架裏要不停的兼容新版本的Android系統,OEM系統。尤爲是Activity的attach方法,常常須要兼容。還有殼子Activity上也偶爾要補充覆蓋方法。但實際上,這些需求都知足不了。由於最先咱們也是像市面上能見到的其餘插件框架同樣,將插件框架打包在宿主裏的。因此,這些修復和更新代碼只有在宿主的下一個版本才能生效。改Bug就還好,下一個版本就沒問題了,可是新特性就麻煩了,須要保證使用了新特性的插件不會被老版本的宿主啓動。編程

動態化的基本原理

咱們先直接回顧動態化的基本原理,再說明Shadow是如何應用這個基本原理的。動態化的基本原理很是簡單,是Java的基本知識。可是確實有不少人沒能完全理解,也不會靈活運用。安全

Java代碼編譯的時候是沒有連接過程的。連接過程指的是傳統的C語言在編譯的時候分爲兩個步驟,一是將源碼編譯成機器碼,對於其中引用了其餘文件的符號(好比文件中的全局變量),暫時用符號名代替。而後第二個步驟是連接步驟,在這個步驟中將前一個步驟中暫時使用的符號名真正替換爲實際內存地址。C語言的這個編譯過程在Java角度來看,就至關於Java源碼先編譯成了字節碼,對於源碼中引用的其餘類,先暫時在字節碼中以名稱代替。而後在一個連接過程當中將字節碼中的名稱替換爲其餘類的真正實現代碼。不過,實際狀況是,Java就沒有這個連接過程。Java編譯的字節碼中保存的就是其餘類的名稱。其餘類的實現是在運行時纔去查找的。所以這一過程又至關因而C語言的動態連接過程。咱們前面提到的C語言編譯過程被稱爲靜態連接。因此,有一些學過C語言的人評論Java說,Java這門語言是徹底動態連接的語言,是一門動態語言。這裏說「動態語言」指的是連接過程是動態的。咱們平時若是說「動態語言」還有一種可能性指的是類型是否動態,Java是一門靜態類型語言,這兩個靜態、動態不要搞混了。bash

除了一些優化成Native實現的特殊系統類,Java的類都是在運行時由ClassLoader動態加載的。若是類A引用了類B,在類A的代碼執行到要用B時,就會向加載了本身的ClassLoader查找類B的實現。找到了類B的實現,才能new出來B的實例,才能繼續執行,或者是才能調用B的靜態方法。並且同一個ClassLoader加載的同一個名字的類纔是運行時實際上的同一個類。一個類A有public static final int a靜態域,想問有沒有可能類B和類C中相同的代碼System.out.println(A.a);會打印出來不一樣的值?答案應該是「有可能的」。由於在精心構造的ClassLoader結構下,類B和類C可能分別由不一樣的ClassLoader加載的,那麼它們向各自的ClassLoader請求到的類A的實現多是不一樣的。就算是類A的實現只有一份,類B和類C加載到的類A也是兩個不一樣的類。一旦用反射修改其中一個的a靜態域,另外一個的a靜態域是不會跟着變化的。框架

Java還有兩個和動態化相關的特性,一個是接口,另外一個是向上轉型。ide

Class<?> implClass = classLoader.loadClass("com.xxx.AImpl");
Object implObject = implClass.newInstance();
A a = (A) implObject;
複製代碼

這裏假設classLoader動態加載了一些Java類,其中就有一個類叫作com.xxx.AImpl,AImpl繼承自A,或者AImpl實現了A接口。注意這裏用了強制類型轉換,是由於代碼層面是將Object類型向下轉型成了A。但實際上咱們知道implObject的類型是AImpl,AImpl轉換成A是一個向上轉型。向上轉型老是安全的。因此用這種方法老是能夠先定義出接口,精心設計接口,讓接口足夠通用和穩定。只要接口不變,它的實現老是能夠修改的。咱們將接口打包在宿主中,接口就輕易不能更新了。可是它的實現老是能夠更新的。函數

全部的插件框架中,Activity的加載都是這樣的,new一個DexClassLoader加載插件apk。而後從插件ClassLoader中load指定的插件Activity名字,newInstance以後強轉爲Activity類型使用。實際上Android系統自身在啓動Activity時也是這樣作的。因此這就是插件機制能動態更新Activity的基本原理。學習

因此,全部的插件框架在解決的問題都不是如何動態加載類,而是動態加載的Activity沒有在AndroidManifest中註冊,該如何能正常運行。若是Android系統沒有AndroidManifest的限制,那麼全部插件框架都沒有存在的必要了。由於Java語言自己就支持動態更新實現的能力。優化

Manager的動態化

Shadow的Manager的功能就是管理插件,包括插件的下載邏輯、入口邏輯,預加載邏輯等。反正就是一切尚未進入到Loader以前的全部事情。ui

因爲Manager就是一個普通類,不是Android系統規定要在Manifest中註冊才能使用的類,因此Manager的動態化就是通常性的動態加載實現。編碼

爲了讓宿主中的固定代碼足夠的少,咱們給Manager定義的接口就是一個相似傳統Main函數的接口。

void enter(Context context, long formId, Bundle bundle, EnterCallback callback);
複製代碼

這就是Manager的惟一方法,宿主中只會調用這個方法。傳入當前界面的Context以便打開下一個插件Activity。將全部插件中可能用到的參數經過Bundle傳給插件。定義一些fromId,用來讓Manager的實現邏輯分辨這一次enter是從哪裏來的。實際上在宿主中的每一處enter調用均可以設置不一樣的fromId,就至關於讓Manager知道調用來自宿主中的哪一行代碼了。再傳入一個EnterCallback供Manager能夠返回一個動態加載的View做爲插件的Loading View。

Loader的動態化

Loader就是負責加載插件Activity,而後實現插件Activity的生命週期等功能的那部分核心邏輯了。不少插件框架就只有Loader這部分功能,或者說只開源了Loader這部分功能。通常來講,Loader是宿主到插件的橋樑。好比說咱們要在宿主中執行Loader的代碼,才能Hack一些系統類,讓它們加載插件Activity。或者在宿主中的代理殼子Activity中,也要使用Loader去加載插件Activity完成轉調功能。因此一般宿主代碼就直接依賴了Loader的代碼。這就是爲何其餘插件框架都須要將插件框架自己的代碼打包在宿主中。

稍複雜一點的問題就是代理殼子ContainerActivity須要和PluginActivity經過Loader相互調用。因此Shadow應用前面提到的動態化原理時,作了雙向的接口,能夠看到代碼中的HostActivityDelegateHostActivityDelegator。經過定義出這兩個接口,能夠避免ContainerActivity和Loader相互加載對方時還須要加載對方所依賴的其餘類。定義成接口,就只須要加載這個接口就好了。

經過這個設計,插件框架的絕大部分須要修改或修復的代碼就均可以動態發佈了。而且也使得在同一個宿主中能夠有多個不一樣實現的Loader,這樣業務就能夠針對業務自身的bug修改Loader的代碼,不會影響其餘業務了。緊急狀況下Loader也能夠耦合業務邏輯。

Container的動態化

Container就是那些註冊在宿主AndroidManifest中的代理殼子。因爲Activity的建立是系統根據Activity的名字直接經過宿主的PathClassLoader構造的,因此這些Activity必須打包在宿主中才能處於PathClassLoader,才能被系統找到。因此Container是不能放到Loader中,經過動態加載的通常方法加載的。由於前面提到的通常方法都是要new一個新的ClassLoader加載動態實現的。

可是咱們業務的宿主對合入代碼的增量要求極其嚴格,是要求0增量合入的。也就是咱們合入代碼的同時還要優化原有代碼,使總體0增量。增量既包含安裝包體積增量,也包含方法數增量。

因此作了Loader的動態化仍是不夠的,由於代理殼子Activity上須要提早Override很是多的方法。同時因爲定義了Delegate和Delegator接口,還在Delegator接口上又添加了superOnCreate等方法,致使Activity上每有一個須要Override的方法,就要增長4個方法數,而Activity上大概有350個方法。

Container的實現因爲前面Loader的動態化已經變得很是簡單了,不管是什麼方法,都是轉調給Delegate接口,本身不實現任何邏輯。按理說能夠認爲不會有什麼Bug了,至少將方法所有覆蓋實現,Container即便不動態化也是能夠長期使用的。

Android系統的虛擬機和通常的JVM有一點不太同樣,就是能夠經過反射修改private final域。這在通常的JVM上是不能成功的,讀過《Java編程思想》的同窗可能還記得專門有這段講解。而ClassLoader類的parent域,偏偏就是private final域。ClassLoader的parent指向的是ClassLoader的「雙親」,就是「雙親委派」中的那個「雙親」(如今去學習這個概念的同窗注意這裏的「雙」是沒有意義的,不存在兩個「親」)。宿主的PathClassLoader就是一個有正常「雙親委派」邏輯的ClassLoader,它加載任何類以前都會委託本身的parent先去加載這個類。若是parent可以加載到,本身就不會加載了。所以,咱們能夠經過修改ClassLoader的parent,爲ClassLoader新增一個parent。將本來的BootClassLoader <- PathClassLoader結構變爲BootClassLoader <- DexClassLoader <- PathClassLoader,插入的DexClassLoader加載了ContainerActivity就可使得系統在向PathClassLoader查找ContainerActivity時可以正確找到實現。

因此咱們就迫於無奈作了Container的動態化,也在這個動態化中使用了惟一一次反射修改私有變量。這裏要認可,Shadow開源的所有代碼中確實有這一處反射。跟Shadow宣傳的零反射是有點衝突的。這裏值得辯駁一點的是,零反射是和傳統插件框架解決動態加載Activity等組件時是否使用反射來對比的。Container的動態化,乃至Shadow的dynamic層對於解決其餘插件框架相同的問題來講都不是必要的部分。特別是Container的動態化是可選的。

ClassLoader的parent域不屬於非公開API,甚至不是Android的代碼,而是JDK的代碼。並且這個反射的實現不須要硬編碼「parent」這個單詞,由於有getParent這個方法可使咱們經過運行時對比肯定parent域。因此,這一處反射實現仍是比較安全的,實際上咱們線上運行了3年了,也歷來沒見過失敗的狀況。

Container的動態化雖然能夠說是沒必要要的,但確實仍是有好處的。有了Container的動態化,咱們就不必一次性實現Container上的全部須要Override的方法了。能夠在業務須要時再添加。

關於Container的動態化,能夠具體看com.tencent.shadow.dynamic.host.DynamicRuntime這個類的實現。

另外Runtime雖然包含了Container,可是實際上只有Container須要這樣動態化。Runtime中的其餘類是由於簡化實現的關係放在了一塊兒,其餘類是能夠按傳統作法加載的,只須要在PluginClassLoader上方便可。

關於動態加載接口實現的實踐經驗

最後再分享一點關於加載接口實現的經驗。咱們在Shadow裏專門寫了一個ApkClassLoader類,封裝了com.tencent.shadow.dynamic.host.ApkClassLoader#getInterface泛型方法,能夠直接得到接口類型實例。能夠注意到,這個方法是不支持有參數的構造器的。看咱們代碼歷史,能夠找到有參數版本的實現,可是最終刪掉了。由於我發現,傳一組參數類型class令牌,並不能在編譯期跟實現類的構造器關聯起來。就是說實現類的構造器若是參數列表變化了,這邊調用getInterface的參數沒有修改是不能在編譯期發現的。會等到運行時纔會拋出找不到那種參數列表的構造器的異常。因此我改爲了定義工廠接口的形式,也就是getInterface老是取出來一個工廠接口,而後再經過工廠接口build有參數的對象。這樣實現類的構造參數列表變化,就能在編譯期檢查出來了。

能夠查看Shadow代碼中的com.tencent.shadow.dynamic.host.LoaderFactorycom.tencent.shadow.dynamic.host.ManagerFactory來分析我講的區別。

總結

Shadow將咱們定義的插件框架的全部部分所有實現了動態化加載,使得插件框架自身的問題能夠動態修復,也使得插件框架成爲了插件包的一部分,避免了插件須要適配不一樣版本插件框架的問題。

這個特性在實踐中比無Hack實現更爲重要。由於它甚至使得咱們在不跟宿主版本的狀況下,不改宿主一行代碼,就把數百反射實現的舊框架替換爲了無Hack實現的Shadow框架。使得咱們在作這個切換時,能夠徹底不考慮舊框架的維護了。

相關文章
相關標籤/搜索