1. 程式人生 > 其它 >外掛化實現原理(學習筆記六)

外掛化實現原理(學習筆記六)

技術標籤:android進階學習隨記android外掛化原理

介紹:

外掛化本質上來說是執行沒有安裝的apk,支援外掛的app一般稱為宿主。宿主提供上下文環境通常作為主APP在執行時載入和執行外掛,這樣便可將app中一些不常用的功能模組做成外掛,一方面可以減少安裝包的大小,另一方面可以實現APP功能的動態擴充套件

優勢:

讓使用者不用重新安裝 APK 就能升級應用功能,減少發版本頻率,增加使用者體驗。提供一種快速修復線上 BUG 和更新的能力。按需載入不同的模組,實現靈活的功能配置,減少伺服器對舊版本介面相容壓力。模組化、解耦合、並行開發、 65535 問題。

實現原理:

在Android中的外掛化技術,可以理解為動態載入的過程,分為下面幾步:

  • 把可執行檔案( .so/dex/jar/apk 等)拷貝到應用 APP 內部。
  • 載入可執行檔案,更換靜態資源調
  • 用具體的方法執行業務邏輯

Android 專案中,動態載入技術按照載入的可執行檔案的不同大致可以分為兩種:

  1. 動態載入 .so 庫
  2. 動態載入 dex/jar/apk檔案

第一點,Android在NDK中就使用了動態,動態載入.so庫並通過JNI呼叫封裝好的方法,後者一般是由C/C++編譯而成,執行在Native層,效率比執行在虛擬機器層上要高,通常用來完成一些對效能要求比較高的工作。

第二點,基於 ClassLoader 的動態載入 dex/jar/apk 檔案,也是現在普遍使用的動態載入,使用這種方式實現外掛化,需要解決一些問題:

  1. 如何載入外掛的類
  2. 如何載入外掛的資源
  3. 如何呼叫外掛類

1.載入外掛的類

java檔案編譯後生產的是class檔案,而apk檔案包含一個或多個classes.dex,它是把所有的class檔案合併優化後生產的,jvm載入class檔案,而android的DVM和ART載入的dex檔案,二者都是用Classloader載入的,二者載入的檔案型別不同,還是會有一些區別,主要需要了解android的ClassLoader如何載入dex檔案。

ClassLoader是一個抽象類,實現類主要分為兩種型別:系統類載入器和自定義載入器。
其中系統的類載入器分為三種:

  • BootClassLoader 用於載入 Android Framwork層class檔案
  • PathClassLoader 用於載入 Android應用程式的類,可載入指定的dex,zip,apk中的classes.dex
  • DexClassLoader 用於載入指定的dex,zip,apk中的classes.dex
PathClassLoader 與 DexClassLoader

PathClassLoader 和 DexClassLoader 都是繼承自 BaseDexClassLoader,且類中只有構造方法,它們的類載入邏輯完全寫在 BaseDexClassLoader 中,並且在8.0之前,它們二者的唯一區別是第二個引數 optimizedDirectory,這個引數的意思是生成的 odex(優化的dex)存放的路徑,PathClassLoader 直接為null,而 DexClassLoader 是使用使用者傳進來的路徑,而在8.0之後,二者就完全一樣了。

載入原理

假設apk的檔案是dexPath,需要載入裡面的Test類,通過以下程式碼可以實現:

DexClassLoader dexClassLoader = new DexClassLoader(dexPath,context.getCacheDir().getAbsolutePath(),
	null, context.getClassLoader());
Class<?> clazz = dexClassLoader.loadClass("com.daipi.plugin.Test");

DexClassLoader 類中沒有 loadClass 方法,該方法是在父類ClassLoader中實現

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException{
    // 檢測這個類是否已經被載入 --> 1
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
                // 如果parent不為null,則呼叫parent的loadClass進行載入
                c = parent.loadClass(name, false);
            } else {
                // 正常情況下不會走這兒,因為 BootClassLoader 重寫了 loadClass 方法,結束了遞迴
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {

        }
        if (c == null) {
            // 如果仍然找不到,就呼叫 findClass 去查詢 --> 2
            c = findClass(name);
        }
    }
    return c;
}
// -->1 檢測這個類是否已經被載入
protected final Class<?> findLoadedClass(String name) {
    ClassLoader loader;
    if (this == BootClassLoader.getInstance())
        loader = null;
    else
        loader = this;
// 最後通過 native 方法實現查詢
    return VMClassLoader.findLoadedClass(loader, name);
}
// -->2 載入器一般都會重寫這個方法,定義自己的載入規則
protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}
// /libcore/libart/src/main/java/java/lang/VMClassLoader.java
native static Class findLoadedClass(ClassLoader cl, String name);

首先檢測這個類是否已經被載入了,如果已經載入了,直接獲取並返回。如果沒有被載入,parent 不為null,則呼叫parent的loadClass進行載入,依次遞迴,如果找到了或者載入了就返回,如果即沒找到也載入不了,才自己去載入。這個過程就是我們常說的 雙親委託機制
到這裡可以知道,BootClassLoader是最後一個載入器,所以我們來看下它是如何結束向上遞迴查詢的。

class BootClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        return Class.classForName(name, false, null);
    }
    @Override
    protected Class<?> loadClass(String className, boolean resolve)
            throws ClassNotFoundException {
        Class<?> clazz = findLoadedClass(className);
        if (clazz == null) {
            clazz = findClass(className);
        }
        return clazz;
    }
}

我們發現 BootClassLoader 重寫了 fifindClass 和 loadClass 方法,並且在 loadClass 方法中,不再獲取parent,從而結束了遞迴。接著我們來看,如果所有的parent都沒載入成功的情況下,DexClassLoader是如何載入的。通過查詢我們發現在它的父類BaseDexClassLoader中,重寫了findClass方法

//libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
// 在 pathList 中查詢指定的 Class
    Class c = pathList.findClass(name, suppressedExceptions);
    return c;
}
public BaseDexClassLoader(String dexPath, File optimizedDirectory,
                          String librarySearchPath, ClassLoader parent) {
    super(parent);
// 初始化 pathList
    this.pathList = new DexPathList(this, dexPath, librarySearchPath, null);
}

接著來看DexPathList 類中的 fifindClass 方法

private Element[] dexElements;
public Class<?> findClass(String name, List<Throwable> suppressed) {
//通過 Element 獲取 Class 物件
    for (Element element : dexElements) {
        Class<?> clazz = element.findClass(name, definingContext, suppressed);
        if (clazz != null) {
            return clazz;
        }
    }
    return null;
}

我們發現Class物件就是從 Element 中獲得的,而每一個 Element 就對應一個 dex 檔案,因為我們的 dex 檔案可能有多個,所以這兒使用陣列 Element[]。到這兒我們的思路就出來了:

  1. 建立外掛的 DexClassLoader 類載入器,然後通過反射獲取外掛的 dexElements 值。
  2. 獲取宿主的 PathClassLoader 類載入器,然後通過反射獲取宿主的 dexElements 值。
  3. 合併宿主的 dexElements 與 外掛的 dexElements,生成新的 Element[]。
  4. 最後通過反射將新的 Element[] 賦值給宿主的 dexElements

實現程式碼:

public static void loadClass(Context context) {
    try {
        // 獲取 pathList 的欄位
        Class baseDexClassLoaderClass = Class.forName("dalvik.system.BaseDexClassLoader");
        Field pathListField = baseDexClassLoaderClass.getDeclaredField("pathList");
        pathListField.setAccessible(true);
        // 獲取 dexElements 欄位
        Class<?> dexPathListClass = Class.forName("dalvik.system.DexPathList");
        Field dexElementsField = dexPathListClass.getDeclaredField("dexElements");
        dexElementsField.setAccessible(true)
        /**
        * 獲取外掛的 dexElements[]
        */
        // 獲取 DexClassLoader 類中的屬性 pathList 的值
        DexClassLoader dexClassLoader = new DexClassLoader(apkPath,
                context.getCacheDir().getAbsolutePath(), null, context.getClassLoader());
        Object pluginPathList = pathListField.get(dexClassLoader);
        // 獲取 pathList 中的屬性 dexElements[] 的值--- 外掛的 dexElements[]
        Object[] pluginDexElements = (Object[]) dexElementsField.get(pluginPathList);
        /**
        * 獲取宿主的 dexElements[]
        */
        // 獲取 PathClassLoader 類中的屬性 pathList 的值
        PathClassLoader pathClassLoader = (PathClassLoader) context.getClassLoader();
        Object hostPathList = pathListField.get(pathClassLoader);
        // 獲取 pathList 中的屬性 dexElements[] 的值--- 宿主的 dexElements[]
        Object[] hostDexElements = (Object[]) dexElementsField.get(hostPathList);
        /**
        * 將外掛的 dexElements[] 和宿主的 dexElements[] 合併為一個新的 dexElements[]
        */
        // 建立一個新的空陣列,第一個引數是陣列的型別,第二個引數是陣列的長度
        Object[] dexElements = (Object[]) Array.newInstance(
                hostDexElements.getClass().getComponentType(),
                pluginDexElements.length + hostDexElements.length);
        //將外掛和宿主的dexElements[]的值放入新的陣列中
        System.arraycopy(hostDexElements, 0, dexElements,0, hostDexElements.length);
        System.arraycopy(pluginDexElements, 0, dexElements,hostDexElements.length,
                pluginDexElements.lengh);
    /**
    * 將生成的新值賦給 "dexElements" 屬性
    */
        hostDexElementsField.set(hostPathList, dexElements);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

2.載入外掛的資源

在專案中,我們一般通過 Resources 去訪問 res 中的資源,使用 AssetManager訪問assets中的資源

String appName = getResources().getString(R.string.app_name);
InputStream is = getAssets().open("icon_1.png);

實際上,Resources 類也是通過 AssetManager 類來訪問那些被編譯過的應用程式資原始檔的,不過在訪問之前,它會先根據資源 ID 查詢得到對應的資原始檔名。 而 AssetManager 物件既可以通過檔名訪問那些被編譯過的,也可以訪問沒有被編譯過的應用程式資原始檔。

AssetManager 載入資源的過程:

// android/app/LoadedApk
public Resources getResources() {
    if (mResources == null) {
        // 獲取 ResourcesManager 物件的單例,然後呼叫 getResources 方法去獲取 Resources 物件 --> 1
        mResources = ResourcesManager.getInstance().getResources(null, mResDir,
                splitPaths, mOverlayDirs, mApplicationInfo.sharedLibraryFiles,
                Display.DEFAULT_DISPLAY, null, getCompatibilityInfo(),getClassLoader());
    }
    return mResources;
}

// android/app/ResourcesManager
// --> 1
public @Nullable Resources getResources(@Nullable IBinder activityToken,
                                        @Nullable String resDir,
                                        @Nullable String[] splitResDirs,
                                        @Nullable String[] overlayDirs,
                                        @Nullable String[] libDirs,
                                        int displayId,
                                        @Nullable Configuration overrideConfig,
                                        @NonNull CompatibilityInfo compatInfo,
                                        @Nullable ClassLoader classLoader) {
    try {
        final ResourcesKey key = new ResourcesKey(
                resDir, // 這個就是 apk 檔案路徑
                splitResDirs,
                overlayDirs,
                libDirs,
                displayId,
                overrideConfig != null ? new Configuration(overrideConfig) : null, // Copy
                compatInfo);
        classLoader = classLoader != null ? classLoader : ClassLoader.getSystemClassLoader();
        // 獲取或者建立 Resources 物件 --> 2
        return getOrCreateResources(activityToken, key, classLoader);
    } finally {
        Trace.traceEnd(Trace.TRACE_TAG_RESOURCES);
    }
}
// --> 2
private @Nullable Resources getOrCreateResources(@Nullable IBinder activityToken,
                                                 @NonNull ResourcesKey key, @NonNull ClassLoader classLoader) {
    // 建立 ResourcesImpl 物件 --> 3
    ResourcesImpl resourcesImpl = createResourcesImpl(key);
    // resources 是 ResourcesImpl 的裝飾類
    return resources;
}

// --> 3
private @Nullable ResourcesImpl createResourcesImpl(@NonNull ResourcesKey key) {
    // 建立 AssetManager 物件 --> 4
    final AssetManager assets = createAssetManager(key);
    if (assets == null) {
        return null;
    }
    // 將 assets 物件傳入到 ResourcesImpl 類中
    final ResourcesImpl impl = new ResourcesImpl(assets, dm, config, daj);
    return impl;
}

// -->4
protected @Nullable AssetManager createAssetManager(@NonNull final ResourcesKey key) {
    AssetManager assets = new AssetManager();
    if (key.mResDir != null) {
    // 通過 addAssetPath 方法新增 apk 檔案的路徑
        if (assets.addAssetPath(key.mResDir) == 0) {
            Log.e(TAG, "failed to add asset path " + key.mResDir);
            return null;
        }
    }
    return assets;
}

通過上面程式碼的分析,我們知道了 apk 檔案的路徑是通過 assets.addAssetPath 方法設定的,所以如果我們想將外掛的 apk 檔案新增到宿主中,就可以通過反射修改這個地方。

實現步驟:

  1. 建立一個 AssetManager 物件,並呼叫 addAssetPath 方法,將外掛 apk 的路徑作為引數傳入。
  2. 將第一步建立的 AssetManager 物件作為引數,建立一個新的 Resources 物件,並返回給外掛使用。

程式碼:

public static Resources loadResource(Context context) {
    try {
        Class<?> assetManagerClass = AssetManager.class;
        AssetManager assetManager = (AssetManager) assetManagerClass.newInstance();
        Method addAssetPathMethod = assetManagerClass.getDeclaredMethod("addAssetPath",
                String.class);
        addAssetPathMethod.setAccessible(true);
        addAssetPathMethod.invoke(assetManager, apkPath);
        Resources resources = context.getResources();
        //用來載入外掛包中的資源
        return new Resources(assetManager, resources.getDisplayMetrics(),
                resources.getConfiguration());
    } catch (Exception e) {
        e.printStackTrace();
    }
    return null;
}

然後在宿主的自定義 Application 類中新增如下程式碼:

// 宿主程式碼
private Resources resources;
@Override
public void onCreate() {
    super.onCreate();
// 獲取新建的 resources 資源
    resources = LoadUtil.loadResource(this);
}
// 重寫該方法,當 resources 為空時,相當於沒有重寫,不為空時,返回新建的 resources 物件
@Override
public Resources getResources() {
    return resources == null ? super.getResources() : resources;
}

接著在外掛中,建立BaseActivity,如下:

// 外掛中程式碼
public abstract class BaseActivity extends Activity {
    @Override
    public Resources getResources() {
        if (getApplication() != null && getApplication().getResources() != null) {
        // 因為宿主重寫了該方法,所以獲取的將是新建立的 resources 物件
            return getApplication().getResources();
        }
        return super.getResources();
    }
}

然後讓外掛的 Activity 都繼承自 BaseActivity,這樣,外掛在獲取資源時,使用的就是在宿主中新建立的resources 物件,也就可以拿到資源了。

3.宿主啟動外掛的Activity

Activity是需要在清單檔案中註冊的,顯然,外掛的 Activity 沒有在宿主的清單檔案中註冊,這裡我們就需要使用 Hook 技術,來繞開系統的檢測。
Hook
首先我們在宿主裡面建立一個 ProxyActivity 繼承自 Activity,並且在清單中註冊。當啟動外掛Activity 的時候,在系統檢測前,找到一個Hook點,然後通過 Hook 將外掛 Activity 替換成 ProxyActivity,等到檢測完了後,再找一個Hook點,使用 Hook 將它們換回來,這樣就實現了外掛 Activity 的啟動。
要找到Hook點,首先需要了解Activity的啟動流程,這裡不做詳細描述。
總體上來說,Activity的啟動的通訊流程大致是APP–AMS–APP:

  1. 在進入 AMS 之前,找到一個 Hook 點,用來將外掛 Activity 替換為 ProxyActivity
  2. 在AMS出來後,再找一個 Hook 點,用來將 ProxyActivity 替換為外掛Activity

我們在專案中一般通過 startActivity(new Intent(this,PluginActivity.class)); 啟動 PluginActivity,如果我想換成啟動 ProxyActivity,呼叫方法 startActivity(new Intent(this,ProxyActivity.class)); 這樣就可以了。我們只要找到能夠修改 Intent 的地方,就可以作為 Hook 點,從這兒也可以看出 Hook 點並不是唯一的。

原始碼:

// android/app/Activity.java
@Override
public void startActivity(Intent intent) {
    this.startActivity(intent, null);
}
@Override
public void startActivity(Intent intent, @Nullable Bundle options) {
    startActivityForResult(intent, -1, options);
}
public void startActivityForResult(@RequiresPermission Intent intent, int requestCode,
                                   @Nullable Bundle options) {
    Instrumentation.ActivityResult ar = mInstrumentation.execStartActivity(
            this, mMainThread.getApplicationThread(), mToken, this,
            intent, requestCode, options);
}
// android/app/Instrumentation.java
public ActivityResult execStartActivity(
        Context who, IBinder contextThread, IBinder token, Activity target,
        Intent intent, int requestCode, Bundle options) {
    // 這兒就是我們的 Hook 點,替換傳入 startActivity 方法中的 intent 引數
    int result = ActivityManager.getService()
            .startActivity(whoThread, who.getBasePackageName(), intent,
                    intent.resolveTypeIfNeeded(who.getContentResolver()),
                    token, target != null ? target.mEmbeddedID : null,
                    requestCode, 0, null, options);
}

然後使用動態代理技術,生成一個動態代理物件,代理ActivityManager.getService() 返回的物件

// android/app/ActivityManager.java
public static IActivityManager getService() {
    return IActivityManagerSingleton.get();
}

可以看到,它返回的是 IActivityManager 類的物件。下面我們就生成代理物件,並且當執行的方法是 startActivity的時候,替換它的引數 intent。程式碼如下:

Object mInstanceProxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
        new Class[]{iActivityManagerClass}, new InvocationHandler() {
            @Override
            public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                // 當執行的方法是 startActivity 時作處理
                if ("startActivity".equals(method.getName())) {
                    int index = 0;
                // 獲取 Intent 引數在 args 陣列中的index值
                    for (int i = 0; i < args.length; i++) {
                        if (args[i] instanceof Intent) {
                            index = i;
                            break;
                        }
                    }
                    // 得到原始的 Intent 物件 -- (外掛)的Intent
                    Intent intent = (Intent) args[index];
                    // 生成代理proxyIntent -- (代理)的Intent
                    Intent proxyIntent = new Intent();
                    proxyIntent.setClassName("com.enjoy.pluginactivity",
                            ProxyActivity.class.getName());
                    // 儲存原始的Intent對像
                    proxyIntent.putExtra(TARGET_INTENT, intent);
                    // 使用proxyIntent替換陣列中的Intent
                    args[index] = proxyIntent;
                }
                return method.invoke(mInstance, args);
            }
        });

接著我們再使用反射將系統中的 IActivityManager 物件替換為我們的代理物件 mInstanceProxy

// android/app/ActivityManager.java
public static IActivityManager getService() {
    return IActivityManagerSingleton.get();
}
private static final Singleton<IActivityManager> IActivityManagerSingleton =
        new Singleton<IActivityManager>() {
            @Override
            protected IActivityManager create() {
                final IBinder b = ServiceManager.getService(Context.ACTIVITY_SERVICE);
                final IActivityManager am = IActivityManager.Stub.asInterface(b);
                return am;
            }
        };

通過上面的程式碼,我們知道 IActivityManager 是呼叫的 Singleton 裡面的 get 方法,所以下面我們再看下Singleton 是怎麼樣的

// android/util/Singleton
public abstract class Singleton<T> {
    private T mInstance;
    protected abstract T create();
    public final T get() {
        synchronized (this) {
            if (mInstance == null) {
                mInstance = create();
            }
            return mInstance;
        }
    }
}

可以看出,IActivityManagerSingleton.get() 返回的實際上就是 mInstance 物件。所以接下來我們要替換的就是這個物件。程式碼如下:

// 獲取 Singleton<T> 類的物件
Class<?> clazz = Class.forName("android.app.ActivityManager");
Field singletonField = clazz.getDeclaredField("IActivityManagerSingleton");
singletonField.setAccessible(true);
Object singleton = singletonField.get(null);
// 獲取 mInstance 物件
Class<?> singletonClass = Class.forName("android.util.Singleton");
Field mInstanceField = singletonClass.getDeclaredField("mInstance");
mInstanceField.setAccessible(true);
final Object mInstance = mInstanceField.get(singleton);
// 使用代理物件替換原有的 mInstance 物件
mInstanceField.set(singleton, mInstanceProxy);

到這兒我們的第一步就實現了,接著我們來實現第二步,在出來的時候,將它們換回去。從AMS到APP通訊會呼叫 Handler 的 handleMessage,所以下面我們看下 Handler 的原始碼

public void handleMessage(Message msg) {
}
public void dispatchMessage(Message msg) {
    if (msg.callback != null) {
        handleCallback(msg);
    } else {
        if (mCallback != null) {
            if (mCallback.handleMessage(msg)) {
                return;
            }
        }
        handleMessage(msg);
    }
}

當 mCallback != null 時,首先會執行 mCallback.handleMessage(msg),再執行 handleMessage(msg),所以我們可以將 mCallback 作為 Hook 點,建立它。ok,現在問題就只剩一個了,就是找到含有 intent 的物件,沒辦法,只能接著看原始碼。

// android/app/ActivityThread.java
public void handleMessage(Message msg) {
    switch (msg.what) {
        case LAUNCH_ACTIVITY: {
            final ActivityClientRecord r = (ActivityClientRecord) msg.obj;
            handleLaunchActivity(r, null, "LAUNCH_ACTIVITY");
        } break;
    }
}
static final class ActivityClientRecord {
    Intent intent;
}

可以看到,在 ActivityClientRecord 類中,剛好就有個 intent,而且這個類的物件,我們也可以獲取到,就是msg.obj。接下來就簡單了,實現程式碼如下,如果有興趣的同學也可從 handleLaunchActivity 方法一路跟下去,看看 ActivityClientRecord 的 intent 到底在哪使用的,這兒我們就不贅敘了。

// 獲取 ActivityThread 類的 物件
Class<?> clazz = Class.forName("android.app.ActivityThread");
Field activityThreadField = clazz.getDeclaredField("sCurrentActivityThread");
activityThreadField.setAccessible(true);
Object activityThread = activityThreadField.get(null);
// 獲取 Handler 物件
Field mHField = clazz.getDeclaredField("mH");
mHField.setAccessible(true);
final Handler mH = (Handler) mHField.get(activityThread);
// 設定 Callback 的值
Field mCallbackField = Handler.class.getDeclaredField("mCallback");
mCallbackField.setAccessible(true);
mCallbackField.set(mH, new Handler.Callback() {
    @Override
    public boolean handleMessage(Message msg) {
        switch (msg.what) {
            case 100:
                try {
                    // 獲取 proxyIntent
                    Field intentField = msg.obj.getClass().getDeclaredField("intent");
                    intentField.setAccessible(true);
                    Intent proxyIntent = (Intent) intentField.get(msg.obj);
                    // 目標 intent 替換 proxyIntent
                    Intent intent = proxyIntent.getParcelableExtra(TARGET_INTENT);
                    proxyIntent.setComponent(intent.getComponent());
                } catch (Exception e) {
                    e.printStackTrace();
                }
                break;
        }
        return false;
    }
});

總結

外掛化涉及的技術其實是非常多的,比如應用程式啟動流程、四大元件啟動流程、AMS原理、PMS原理、ClassLoader原理、Binder機制,動態代理等等。越學習越能感受到每一門技術都不簡單,需要積累的東西還有很多,加油。