Android每週一輪子:android-pluginmgr(外掛化)

Jensen95發表於2018-04-03

前言

之前所做的一個專案為一個嵌入到遊戲中,具備商城,支付等功能的SDK,由於遊戲動態更新的問題,SDK因此也需要具備動態更新的能力,否則每一次的SDK更新都要強制遊戲釋出新版本了,本著該原則,限於部分歷史原因,專案中採用了一個比較老的外掛化方案android-pluginmgr,對於SDK的核心功能,全部抽離出放在外掛中,通過這種方式可以實現對於核心功能的動態更新。

SDK設計

Github地址

基礎使用

  • 在 Application中初始化外掛
@Override
public void onCreate(){
   PluginManager.init(this);
   //...
}
複製程式碼
  • 從Apk中載入外掛
PluginManager mgr = PluginManager.getSingleton();
File myPlug = new File("/mnt/sdcard/Download/myplug.apk");
PlugInfo plug = pluginMgr.loadPlugin(myPlug).iterator().next();
複製程式碼

從目錄中載入相應的外掛,通過PlugInfo來儲存外掛資訊。

  • 啟動外掛中的Activity
start activity: mgr.startMainActivity(context, plug);
複製程式碼

Activity的啟動通過呼叫PluginManager的startMainActivity。

  • 外掛驗證功能
  PluginManager.getSingleton().setPluginOverdueVerifier(new PluginOverdueVerifier() {
          @Override
          public boolean isOverdue(File originPluginFile, File targetExistFile) {
              //check If the plugin has expired
              return true;
          }
      });
複製程式碼

提供了一個回撥,我們可以實現這個回撥中的方法來根據自己的需求做自定義的外掛過期校驗。

原始碼實現分析

PluginManager的初始化

1.執行緒的判斷

if (!isMainThread()) {
            throw new IllegalThreadStateException("PluginManager must init in UI Thread!");
}
複製程式碼

需要確保其初始化操作發生在主執行緒。

2.生成確定相應的裝載優化生成檔案目錄

this.context = context;
//外掛輸出路徑
File optimizedDexPath = context.getDir(Globals.PRIVATE_PLUGIN_OUTPUT_DIR_NAME, Context.MODE_PRIVATE);
dexOutputPath = optimizedDexPath.getAbsolutePath();
dexInternalStoragePath = context.getDir(
                Globals.PRIVATE_PLUGIN_ODEX_OUTPUT_DIR_NAME, Context.MODE_PRIVATE
        );
複製程式碼

3.部分Hook替換操作

DelegateActivityThread delegateActivityThread = DelegateActivityThread.getSingleton();
Instrumentation originInstrumentation = delegateActivityThread.getInstrumentation();
if (!(originInstrumentation instanceof PluginInstrumentation)) {
            PluginInstrumentation pluginInstrumentation = new PluginInstrumentation(originInstrumentation);
            delegateActivityThread.setInstrumentation(pluginInstrumentation);
        }
複製程式碼

此處DelegateActivityThread的作用是通過反射拿到當前的ActivityThread,同時通過反射來獲取其內部的Instrumentation和對Instrumentation進行設定。

PluginInstrumentation 繼承自DelegateInstrumentation,DelegateInstrumentation持有了原有的Instrumentation,對於其中的大部分方法通過代理的方式,將其轉交給原有的Instrumention進行處理,對於幾個Activity啟動相關的核心方法進行了重寫。

Instrumentation

外掛裝載過程

if (pluginSrcDirFile.isFile()) {
       PlugInfo one = buildPlugInfo(pluginSrcDirFile, null, null);
       if (one != null) {
           savePluginToMap(one);
       }
      return Collections.singletonList(one);
 }
複製程式碼

此處已經省略了對於目錄的一些判空操作的程式碼,首先判斷給定檔案路徑是為目錄還是一個檔案,如果是一個檔案則進行構建,如果是一個目錄,則會對該目錄進行遍歷,然後進行單個檔案執行的操作。首先根據給定的檔案,構造出一個外掛資訊,然後將該外掛資訊存入到我們的記憶體中存放PlugInfo的一個Map之中。

 Map<String, PlugInfo> pluginPkgToInfoMap = new ConcurrentHashMap<String, PlugInfo>()
複製程式碼

所以其核心操作就是buildPlugInfo。構建過程則為建立一個PlugInfo物件出來,具體步驟為對外掛進行解析,來補充PlugInfo的相關屬性。

構建外掛資訊

1.設定PlugInfo的檔案路徑資訊,傳入的外掛位置和初始化時設定的路徑如果不一致,則進行拷貝操作。

 PlugInfo info = new PlugInfo();
 info.setId(pluginId == null ? pluginApk.getName() : pluginId);

 File privateFile = new File(dexInternalStoragePath,
                targetFileName == null ? pluginApk.getName() : targetFileName);

info.setFilePath(privateFile.getAbsolutePath());
//如果檔案不在相同的地方,則進行復制
if(!pluginApk.getAbsolutePath().equals(privateFile.getAbsolutePath())) {
      copyApkToPrivatePath(pluginApk, privateFile);
}
複製程式碼

2.裝載解析Manifest

String dexPath = privateFile.getAbsolutePath();
//Load Plugin Manifest
PluginManifestUtil.setManifestInfo(context, dexPath, info);
複製程式碼

根據當前的dex路徑來獲得到Manifest,然後解析該檔案,得到其中的Activity,Service,Receiver,Provider資訊,然後將這些資訊分別用來設定到PlugInfo相應的屬性中。

3.裝載資原始檔

AssetManager am = AssetManager.class.newInstance();
am.getClass().getMethod("addAssetPath", String.class)
                    .invoke(am, dexPath);
info.setAssetManager(am);
Resources hotRes = context.getResources();
Resources res = new Resources(am, hotRes.getDisplayMetrics(),
           hotRes.getConfiguration());
 info.setResources(res);
複製程式碼

通過反射獲取到執行AssetManager的addAssetPath方法,將其設定到外掛的路徑中,然後利用當前的AssetManager來構造一個Resource物件。將該物件設定到PlugInfo中。用來後續對外掛中資源裝載時使用。

4.設定ClassLoader

 PluginClassLoader pluginClassLoader = new PluginClassLoader(info, dexPath, dexOutputPath
                , getPluginLibPath(info).getAbsolutePath(), pluginParentClassLoader);
 info.setClassLoader(pluginClassLoader);
複製程式碼

繼承自DexClassLoader寫的ClassLoader,相比於DexClassLoader增加了一個PlugInfo屬性,同時在建構函式中為其賦值。

5.建立Application,設定Application資訊

ApplicationInfo appInfo = info.getPackageInfo().applicationInfo;
Application app = makeApplication(info, appInfo);
attachBaseContext(info, app);
info.setApplication(app);
複製程式碼

建立Application物件,attachBaseContext,在這裡為什麼要用attachBaseContext呢?這就設定到Context的一些問題了,先看下程式碼中attachbaseContext中核心程式碼。

Field mBase = ContextWrapper.class.getDeclaredField("mBase");
mBase.setAccessible(true);
mBase.set(app, new PluginContext(context.getApplicationContext(), info));
複製程式碼

Application繼承自ContextWrapper,其具備獲取資源問及那,獲取包管理器,獲取應用程式上下文等等,而這些方法的實現都是通過attachBaseContext方法為在ContextWrapper設定一個context的實現類,attachBaseContext()方法其實是由系統來呼叫的,它會把ContextImpl物件作為引數傳遞到attachBaseContext()方法當中,從而賦值給mBase物件,之後ContextWrapper中的所有方法其實都是通過這種委託的機制交由ContextImpl去具體實現的。因此這裡需要我們手動為Application設定上這個Context的實現類。

到此為止,我們已經完成了我們SDK的初始化過程和我們的外掛的裝載過程。這個時候,我們可能需要對於我們外掛中一些功能類的呼叫,或者是啟動其中的Activity。

外掛資訊構建

Activity的啟動

//從外掛中查詢當前Activity資訊
ActivityInfo activityInfo = plugInfo.findActivityByClassName(targetActivity);

//構建建立Activiyt的相關物件
CreateActivityData createActivityData = new CreateActivityData(activityInfo.name, plugInfo.getPackageName());
intent.setClass(from, activitySelector.selectDynamicActivity(activityInfo));

//設定標誌啟動來自外掛的Activity
intent.putExtra(Globals.FLAG_ACTIVITY_FROM_PLUGIN, createActivityData);
from.startActivity(intent);
複製程式碼

根據目標Activity從我們建立的PlugInfo中找到相關的Activity資訊。通過Activity名和外掛的包名來建立一個Activity的資訊。selectDynamicActivity是我們在宿主類中設定的一個動態代理類,將其設定我們跳轉的一個目標。然後通過intent攜帶FLAG_ACTIVITY_FROM_PLUGIN的標記下的Activity的資訊,這個時候通過當前的Activity來啟動。啟動MainActivity則為對向其傳遞的Activity資訊做一個改變,直接啟動。

Activity的啟動後面實際上是通過Instrumentation中的execStartActivity來執行啟動新的Activity,Instrumentation中對於execStartActivity有許多的過載方法。在這些方法執行之前都會呼叫一個方法:replaceIntentTargetIfNeed,replaceIntentTargetIfNeed()用來對跳轉到外掛Activity進行相應的處理。在方法中進行的處理如下:

//判斷是否啟動來自外掛的Activity
if (!intent.hasExtra(Globals.FLAG_ACTIVITY_FROM_PLUGIN) && currentPlugin != null){
      ComponentName componentName = intent.getComponent();
      if (componentName != null){
            //獲取包名和Activity名
            String pkgName = componentName.getPackageName();
            String activityName = componentName.getClassName();
            if (pkgName != null){
               CreateActivityData createActivityData = new CreateActivityData(activityName, currentPlugin.getPackageName());
               ActivityInfo activityInfo = currentPlugin.findActivityByClassName(activityName);
               if (activityInfo != null) {
                   intent.setClass(from, PluginManager.getSingleton().getActivitySelector().selectDynamicActivity(activityInfo));
                   intent.putExtra(Globals.FLAG_ACTIVITY_FROM_PLUGIN, createActivityData);
//為Intent設定額外的classLoader                   intent.setExtrasClassLoader(currentPlugin.getClassLoader());
                }
             }
          }
 }
複製程式碼

如果Intent中沒有來自外掛的標識,然後當前的外掛資訊不為null,則會根據外掛資訊提取出相關的資訊,然後對Intent進行一系列的設定。

在經過一系列處理,和AMS之間互動等之後,最終會呼叫ActivityThreadperformLaunchActivity來進行Activity的建立和啟動,首先是通過相應的類裝載器建立出Activity物件,然後呼叫其相應的生命週期函式,這個過程都是系統自動執行。在performLaunchActivity中具體執行的任務有以下幾個。

1.首先從intent中解析出目標activity的啟動引數。

2.通過Activity的無參構造方法來new一個物件,物件就是在這裡new出來,實際的呼叫是Instrumentation的newActivity函式,這個函式也是我們在Hook中要重寫的。

3.然後為該Activity設定上Application,Context,Instrumentation等資訊。然後通過Instrumentation的callActivityOnCreate呼叫Activity的onCreate函式,使得其具備了生命週期。

此處我們的實現是通過我們本地的一個Activity作為樁,也就是說我們實際呼叫的Activity是我們本地的一個Activity,然後對其中一些步驟做Hook,對於其中的一些資訊的檢測,缺失處理。

這個過程,我們要對newActivity()進行Hook,還要對callActivityOnCreate()進行Hook,newActivity的實現程式碼

CreateActivityData activityData = (CreateActivityData) intent.getSerializableExtra(Globals.FLAG_ACTIVITY_FROM_PLUGIN);
if (activityData != null && PluginManager.getSingleton().getPlugins().size() > 0) {
            //這裡找不到外掛資訊就會拋異常的,不用擔心空指標
     PlugInfo plugInfo;
     plugInfo =       
 PluginManager.getSingleton().tryGetPluginInfo(activityData.pluginPkg);
     plugInfo.ensureApplicationCreated();
    if (activityData.activityName != null){
            className = activityData.activityName;
            cl = plugInfo.getClassLoader();
     }
}
return super.newActivity(cl, className, intent);
複製程式碼

Activity的建立中,獲取Intent中的內容,然後將其中的資訊進行解析,然後從中解析出相關屬性,配置給Activity,然後呼叫原有父類中的方法,這個Intent在發起的時候,我們告訴系統的是呼叫的是我們本地插的一個Activity,但是在實際建立的時候,通過newActivity的時候,建立出的Activity是我們外掛中的Activity。 Activity的建立之後,接下來需要呼叫其生命週期函式,然後這個過程需要我們對其再次進行Hook,新增進我們的相關操作。對於其中的程式碼,我們逐步來分析。

  lookupActivityInPlugin(activity);
複製程式碼

該方法執行的操作

 ClassLoader classLoader = activity.getClass().getClassLoader();
 if (classLoader instanceof PluginClassLoader){
        currentPlugin = ((PluginClassLoader)classLoader).getPlugInfo();
  }else{
        currentPlugin = null;
 }
複製程式碼

執行該方法之後,會為currentPlugin賦值。當currentPlugin不為null時,也就是表明此時確定了該Activity是來自外掛。

Context baseContext = activity.getBaseContext();
PluginContext pluginContext = new PluginContext(baseContext, currentPlugin);
複製程式碼

在PluginContext中進行了對於獲取資源,類裝載器等一些資訊方法的重寫。對於其中的一些資源獲取,ClassLoader的獲取等,都是通過PlugInfo中的資訊進行設定。然後再通過反射的方式對這些原有的獲取方式進行替換。

Reflect.on(activity).set("mResources", pluginContext.getResources());
Field field = ContextWrapper.class.getDeclaredField("mBase");
field.setAccessible(true);
field.set(activity, pluginContext);
Reflect.on(activity).set("mApplication", currentPlugin.getApplication());
複製程式碼

獲取Activity的一些主題,

 ActivityInfo activityInfo = currentPlugin.findActivityByClassName(activity.getClass().getName());
int resTheme = activityInfo.getThemeResource();
if (resTheme != 0) {
    boolean hasNotSetTheme = true;
    Field mTheme = ContextThemeWrapper.class
                                    .getDeclaredField("mTheme");
     mTheme.setAccessible(true);
     hasNotSetTheme = mTheme.get(activity) == null;
     if (hasNotSetTheme) {
           changeActivityInfo(activityInfo, activity);
           activity.setTheme(resTheme);
     }
}
複製程式碼

如果當前Activity未設定主題,則對Activity的資訊進行替換。呼叫了方法 changeActivityInfo

在Activity的啟動過程中,對於Activity相關的內容通過之前儲存在外掛資訊中的內容通過反射的方式進行設定。

Activity啟動流程

總結

該外掛的實現比較簡單,通過該外掛可以幫助我們回顧前兩篇講的App啟動,資源裝載,類裝載問題,該外掛在2年前已經停止更新維護,其功能上相比現有的一些成熟方案,如Replugin,VirtualApk等存在很大進步空間,但是由於其實現簡單,非常方便我們去了解這一個技術的實現流程,對於後續外掛化程式碼閱讀非常有幫助。接下來是對於360 RePlugin的原始碼分析。

相關文章