Android外掛化完美實現程式碼資源載入及原理講解 附可執行demo
*本篇文章已授權微信公眾號 guolin_blog (郭霖)獨家釋出 。
我們通過前4篇的分解,分別將外掛化設計到的知識點全部梳理了一遍,如果沒有看過的,建議先看前面4篇
6. 外掛化資源的使用及動態載入 附demo
好了上面介紹了之前準備的知識點後今天我們做一個真正的可執行的啟動外掛demo,要知道一個外掛可能是隨時從網上下載下來的,那麼也就是說其實這個apk不會被安裝,那麼如果不被安裝,怎麼能被載入呢,
又如何管理外掛中四大元件的生命週期呢,沒有生命週期的四大元件是沒有意義的。而且Activity是必須要在AndroidManifest中註冊的,不註冊就會丟擲異常,那麼怎麼能繞過這個限制呢,還有,一個apk中肯定會用過各種資源,那麼又該如何動態的載入資源呢。下面我們就帶著這些問問一一的來解決,實現外掛化,或者或是模組化。
先來看一下最終的執行結果
分析思路:
程式碼的動態載入:
apk被安裝之後,apk的檔案程式碼以及資源會被系統存放在固定的目錄比如/data/app/package_name/base-1.apk)中,系統在進行類載入的時候,會自動去這一個或者幾個特定的路徑來尋找這個類
但是要知道外掛apk是不會被安裝的,那麼系統也就不會講我們的代理及資源存在在這個目錄下,換句話說系統並不知道我們外掛apk中的任何資訊,也就根本不可能載入我們外掛中的類。我們之前分析過應用的啟動過程,其實就是啟動了我們的主Activity,然後在ActivityThread的performLaunchActivity方法中建立的Activity物件並回調了attch和onCreate方法,我們再來看一下建立Activity物件時的程式碼(沒看過應用啟動過程的建議先看看
java.lang.ClassLoader cl = r.packageInfo.getClassLoader();
activity = mInstrumentation.newActivity(cl, component.getClassName(), r.intent);
StrictMode.incrementExpectedActivityCount(activity.getClass());
r.intent.setExtrasClassLoader(cl);
系統通過待啟動的Activity的類名className,然後使用ClassLoader物件cl把這個類載入,最後使用反射建立了這個Activity類的例項物件。我們看一下這個cl物件是通過r.packageInfo.getClassLoader()被賦值的,這個r.packageInfo是一個LoadedApk型別的物件,我們看看這個LoadedApk是什麼東西。
/**
* Local state maintained about a currently loaded .apk.
* @hide
*/
public final class LoadedApk {
private static final String TAG = "LoadedApk";
private final ActivityThread mActivityThread;
private ApplicationInfo mApplicationInfo;
final String mPackageName;
private final String mAppDir;
private final String mResDir;
private final String[] mSplitAppDirs;
private final String[] mSplitResDirs;
private final String[] mOverlayDirs;
private final String[] mSharedLibraries;
private final String mDataDir;
private final String mLibDir;
private final File mDataDirFile;
private final ClassLoader mBaseClassLoader;
private final boolean mSecurityViolation;
private final boolean mIncludeCode;
private final boolean mRegisterPackage;
private final DisplayAdjustments mDisplayAdjustments = new DisplayAdjustments();
Resources mResources;
private ClassLoader mClassLoader;
private Application mApplication;
private final ArrayMap<Context, ArrayMap<BroadcastReceiver, ReceiverDispatcher>> mReceivers
= new ArrayMap<Context, ArrayMap<BroadcastReceiver, LoadedApk.ReceiverDispatcher>>();
private final ArrayMap<Context, ArrayMap<BroadcastReceiver, LoadedApk.ReceiverDispatcher>> mUnregisteredReceivers
= new ArrayMap<Context, ArrayMap<BroadcastReceiver, LoadedApk.ReceiverDispatcher>>();
private final ArrayMap<Context, ArrayMap<ServiceConnection, LoadedApk.ServiceDispatcher>> mServices
= new ArrayMap<Context, ArrayMap<ServiceConnection, LoadedApk.ServiceDispatcher>>();
private final ArrayMap<Context, ArrayMap<ServiceConnection, LoadedApk.ServiceDispatcher>> mUnboundServices
= new ArrayMap<Context, ArrayMap<ServiceConnection, LoadedApk.ServiceDispatcher>>();
。。。
}
註釋的大概意思是LoadedApk物件是APK檔案在記憶體中的表示。 Apk檔案的相關資訊,諸如Apk檔案的程式碼和資源,甚至程式碼裡面的Activity,Service等四大元件的資訊我們都可以通過此物件獲取。我們知道了r.packageInfo是一個LoadedApk物件了,我們再看他是在哪被賦值的,我們順著程式碼往前倒,performLaunchActivity,handleLaunchActivity,到了H類裡,這一個Handler,
switch (msg.what) {
case LAUNCH_ACTIVITY: {
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStart");
final ActivityClientRecord r = (ActivityClientRecord) msg.obj;
r.packageInfo = getPackageInfoNoCheck(
r.activityInfo.applicationInfo, r.compatInfo);
handleLaunchActivity(r, null);
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
} break;
在這裡通過getPackageInfoNoCheck方法被賦值,進去看看
public final LoadedApk getPackageInfoNoCheck(ApplicationInfo ai,
CompatibilityInfo compatInfo) {
return getPackageInfo(ai, compatInfo, null, false, true, false);
}
又呼叫了getPackageInfo,再進去
private LoadedApk getPackageInfo(ApplicationInfo aInfo, CompatibilityInfo compatInfo,
ClassLoader baseLoader, boolean securityViolation, boolean includeCode,
boolean registerPackage) {
synchronized (mResourcesManager) {
WeakReference<LoadedApk> ref;
if (includeCode) {
ref = mPackages.get(aInfo.packageName);
} else {
ref = mResourcePackages.get(aInfo.packageName);
}
LoadedApk packageInfo = ref != null ? ref.get() : null;
if (packageInfo == null || (packageInfo.mResources != null
&& !packageInfo.mResources.getAssets().isUpToDate())) {
if (localLOGV) Slog.v(TAG, (includeCode ? "Loading code package "
: "Loading resource-only package ") + aInfo.packageName
+ " (in " + (mBoundApplication != null
? mBoundApplication.processName : null)
+ ")");
packageInfo =
new LoadedApk(this, aInfo, compatInfo, baseLoader,
securityViolation, includeCode &&
(aInfo.flags&ApplicationInfo.FLAG_HAS_CODE) != 0, registerPackage);
if (mSystemThread && "android".equals(aInfo.packageName)) {
packageInfo.installSystemApplicationInfo(aInfo,
getSystemContext().mPackageInfo.getClassLoader());
}
if (includeCode) {
mPackages.put(aInfo.packageName,
new WeakReference<LoadedApk>(packageInfo));
} else {
mResourcePackages.put(aInfo.packageName,
new WeakReference<LoadedApk>(packageInfo));
}
}
return packageInfo;
}
}
從一個叫mPackages的map集合中試圖獲得快取,如果快取不存在直接new一個,然後存入map集合。
好了到這裡,其實我們就有了大概的思路,而且有兩種實現方法。
1. 首先如果我們想要載入我們的外掛apk我們需要一個Classloader,那麼我們知道系統的Classloader是通過LoadedApk物件獲得的,而如果我們想要載入我們自己的外掛apk,就需要我們自己構建一個LoadedApk物件,然後修改其中的Classloader物件,因為系統的並不知道我們的外掛apk的資訊,所有我們就要建立自己的ClassLoader物件,然後全盤接管載入的過程,然後通過hook的思想將我們構建的這個LoadedApk物件存入那個叫mPackages的map中,這樣的話每次在獲取LoadedApk物件時就可以在map中得到了。然後在到建立Activity的時候得到的Classloader物件就是我們自己改造過的cl了,這樣就可以載入我們的外部外掛了。這種方案需要我們hook掉系統系統的n多個類或者方法,因為建立LoadedApk物件時還需要一個ApplicationInfo的物件,這個物件就是解析AndroidManifest清單得來的,所以還需要我們自己手動解析外掛中的AndroidManifest清單,這個過程及其複雜,不過360的DroidPlugin就使用了這種方法。
2. 既然我們知道如果想啟動外掛apk就需一個Classloader,那麼我們換一種想法,能不能我們將我們的外掛apk的資訊告訴系統的這個Classloader,然後讓系統的Classloader來幫我們載入及建立呢?答案是肯定,之前我們說過講過android中的Classloader主要分析PathClassLoader和DexClassLoader,系統通過PathClassLoader來載入系統類和主dex中的類。而DexClassLoader則用於載入其他dex檔案中的類。他們都是繼承自BaseDexClassLoader。(如果沒有看過的建議先看看 外掛化知識詳細分解及原理 之ClassLoader及dex載入過程)
我們再簡單的回顧一下
這個類中維護這一個dexElements的陣列,在findClass的時候會遍歷陣列來查詢
public Class findClass(String name, List<Throwable> suppressed) {
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
類在被載入的時候是通過BaseDexClassLoader的findClass的方法,其實最終呼叫了DexPathList類的findClass,DexPathList類中維護著dexElements的陣列,這個陣列就是存放我們dex檔案的陣列,我們只要想辦法將我們外掛apk的dex檔案插入到這個dexElements中系統就可以知道我們的外掛apk資訊了,也自然就可以幫我們載入並建立對應的類。但是到這裡還有一個問題,那就是Activity必須要在AndroidManifest註冊才行,這個檢查過程是在系統底層的,我們無法干涉,可是我們的外掛apk是動態靈活的,宿主中並不固定的寫死註冊哪幾個Activity,如果寫死也就失去了外掛的動態靈活性。
但是我們可以換一種方式,我們使用hook思想代理startActivity這個方法,使用佔坑的方式,也就是說我們可以提前在AndroidManifest中固定寫死一個Activity,這個Activity只不過是一個傀儡,我們在啟動我們外掛apk的時候使用它去系統層校檢合法性,然後等真正建立Activity的時候再通過hook思想攔截Activity的建立方法,提前將資訊更換回來建立真正的外掛apk。
總結一下分析結果
1.startActivity的時候最終會走到AMS的startActivity方法,
2.系統會檢查一堆的資訊驗證這個Activity是否合法。
3.然後會回撥ActivityThread的Handler裡的 handleLaunchActivity
4.在這裡走到了performLaunchActivity方法去建立Activity並回調一系列生命週期的方法
5.建立Activity的時候會建立一個LoaderApk物件,然後使用這個物件的getClassLoader來建立Activity
6.我們檢視getClassLoader()方法發現返回的是PathClassLoader,然後他繼承自BaseDexClassLoader
7.然後我們檢視BaseDexClassLoader發現他建立時建立了一個DexPathList型別的pathList物件,然後在findClass時呼叫了pathList.findClass的方法
8.然後我們檢視DexPathList類中的findClass發現他內部維護了一個Element[] dexElements的dex陣列,findClass時是從陣列中遍歷查詢的,
根據我們的分析結果我們整理一下實現步驟,下面有完整的實現demo下載,可執行
1. 首先我們通過DexClassloader建立一個我們自己的DexClassloader物件去載入我們的外掛apk,因為之前分析過,只有DexClassloader才能載入其他的dex檔案,至於引數的意思之前的篇幅都講過,不在囉嗦
//dex優化後路徑
String cachePath = MainActivity.this.getCacheDir().getAbsolutePath();
//外掛apk的路徑
String apkPath = Environment.getExternalStorageDirectory().getAbsolutePath() + "/chajian_demo.apk";
//建立一個屬於我們自己外掛的ClassLoader,我們分析過只能使用DexClassLoader
DexClassLoader mClassLoader = new DexClassLoader(apkPath, cachePath,cachePath, getClassLoader());
2. 拿到宿主apk裡ClassLoader中的pathList物件和我們Classloader的pathList,因為最終載入時是執行了pathList.findClass方法
//拿到本應用的ClassLoader
PathClassLoader pathLoader = (PathClassLoader) MyApplication.getContext().getClassLoader();
//獲取宿主pathList
Object suZhuPathList = getPathList(pathLoader);
//獲取外掛pathList
Object chaJianPathList = getPathList(loader);
3. 然後我們拿到宿主pathList物件中的Element[]和我們建立的Classloader中的Element[]
//獲取宿主陣列
Object suzhuElements = getDexElements(suZhuPathList)
//獲取外掛陣列
Object chajianElements = getDexElements(chaJianPathList)
4. 因為我們要加入一個dex檔案,那麼原陣列的長度要增加,所有我們要新建一個新的Element型別的陣列,長度是新舊的和
//獲取原陣列型別
Class<?> localClass = suzhu.getClass().getComponentType();
//獲取原陣列長度
int i = Array.getLength(suzhu);
//外掛陣列加上原陣列的長度
int j = i + Array.getLength(chajian);
//建立一個新的陣列用來儲存
Object result = Array.newInstance(localClass, j);
5. 將我們的外掛dex檔案和宿主原來的dex檔案都放入我們新建的陣列中合併
//一個個的將dex檔案設定到新陣列中
for (int k = 0; k < j; ++k) {
if (k < i) {
Array.set(result, k, Array.get(suzhu, k));
} else {
Array.set(result, k, Array.get(chajian, k - i));
}
}
6. 將我們的新陣列設值給pathList物件
setField(suZhuPathList, suZhuPathList.getClass(), "dexElements", result);
7. 代理系統啟動Activity的方法,然後將要啟動的Activity替換成我們佔坑的Activity已達到欺騙系統去檢查的目的.
這裡我們又要在繼續分析了,我們要攔截startActivity,之前我們分析啟動過程時知道,最終會呼叫ActivityManagerNative.getDefault().startActivity,其實也就是ActivityManagerService中的startActivity。
int result = ActivityManagerNative.getDefault()
.startActivity(whoThread, who.getBasePackageName(), intent,
intent.resolveTypeIfNeeded(who.getContentResolver()),
token, target != null ? target.mEmbeddedID : null,
requestCode, 0, null, options);
我們再看看ActivityManagerNative.getDefault()的方法
static public IActivityManager getDefault() {
return gDefault.get();
}
返回了一個gDefault.get(),繼續看
private static final Singleton<IActivityManager> gDefault = new Singleton<IActivityManager>() {
protected IActivityManager create() {
IBinder b = ServiceManager.getService("activity");
if (false) {
Log.v("ActivityManager", "default service binder = " + b);
}
IActivityManager am = asInterface(b);
if (false) {
Log.v("ActivityManager", "default service = " + am);
}
return am;
}
};
看到這個程式碼是不是很熟悉,因為我們第一篇分析Binder機制的時候就知道了,這其實就是aidl的方式遠端通訊方式,沒看過的可以去看一下 外掛化知識詳細分解及原理 之Binder機制
Singleton是系統提供的單例輔助類,這個類在4.x加入的,如果需要適配其他版本,請自行查閱原始碼
由於AMS需要頻繁的和我們的應用通訊,所有系統使用了一個單例把這個AMS的代理物件儲存了起來;這也是AMS這個系統服務與其他普通服務的不同之處,這樣我們就不需要通過ServiceManager去Hook,我們只需要簡單地Hook掉這個單例即可。 外掛化知識詳細分解及原理 之代理,hook,反射。
那麼也就是說我們要代理ActivityManagerNative.getDefault()的這個返回值就好了,也就是AMS的代理物件,有的朋友可能會問為什麼不直接代理AMS,因為AMS是系統的,不在我們的程序中,我們能操作的只有這個AMS的代理類
//獲取ActivityManagerNative的類
Class<?> activityManagerNativeClass = Class.forName("android.app.ActivityManagerNative");
//拿到gDefault欄位
Field gDefaultField = activityManagerNativeClass.getDeclaredField("gDefault");
gDefaultField.setAccessible(true);
//從gDefault欄位中取出這個物件的值
Object gDefault = gDefaultField.get(null);
// gDefault是一個 android.util.Singleton物件; 我們取出這個單例裡面的欄位
Class<?> singleton = Class.forName("android.util.Singleton");
//這個gDefault是一個Singleton型別的,我們需要從Singleton中再取出這個單例的AMS代理
Field mInstanceField = singleton.getDeclaredField("mInstance");
mInstanceField.setAccessible(true);
//ams的代理物件
Object rawIActivityManager = mInstanceField.get(gDefault);
現在我們已經拿到了這個ams的代理物件,現在我們需要建立一個我們自己的代理物件去攔截原ams中的方法,
class IActivityManagerHandler implements InvocationHandler {
...
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("startActivity".equals(method.getName())) {
Log.e("Main","startActivity方法攔截了");
// 找到引數裡面的第一個Intent 物件
Intent raw;
int index = 0;
for (int i = 0; i < args.length; i++) {
if (args[i] instanceof Intent) {
index = i;
break;
}
}
raw = (Intent) args[index];
//建立一個要被掉包的Intent
Intent newIntent = new Intent();
// 替身Activity的包名, 也就是我們自己的"包名"
String stubPackage = MyApplication.getContext().getPackageName();
// 這裡我們把啟動的Activity臨時替換為 ZhanKengActivitiy
ComponentName componentName = new ComponentName(stubPackage, ZhanKengActivitiy.class.getName());
newIntent.setComponent(componentName);
// 把我們原始要啟動的TargetActivity先存起來
newIntent.putExtra(AMSHookHelper.EXTRA_TARGET_INTENT, raw);
// 替換掉Intent, 達到欺騙AMS的目的
args[index] = newIntent;
Log.e("Main","startActivity方法 hook 成功");
Log.e("Main","args[index] hook = " + args[index]);
return method.invoke(mBase, args);
}
return method.invoke(mBase, args);
}
}
然後我們使用動態代理去代理上面獲取的ams
// 建立一個這個物件的代理物件, 然後替換這個欄位, 讓我們的代理物件幫忙幹活,這裡我們使用動態代理,
//動態代理依賴介面,而ams實現與IActivityManager
Class<?> iActivityManagerInterface = Class.forName("android.app.IActivityManager");
//返回代理物件,IActivityManagerHandler是我們自己的代理物件,具體程式碼請下載demo
Object proxy = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
new Class<?>[] { iActivityManagerInterface }, new IActivityManagerHandler(rawIActivityManager));
//將我們的代理設值給singleton的單例
mInstanceField.set(gDefault, proxy);
8. 等系統檢查完了之後,再次代理攔截系統建立Activity的方法將原來我們替換的Activity再次替換回來,已到達啟動不在AndroidManifest註冊的目的
這裡我們繼續分析怎麼將我們前面存入要開啟的Activity再換回來,我們之前分析應用的啟動過程時知道,系統檢查完了Activity的合法性後,會回撥ActivityThread裡的scheduleLaunchActivity方法,然後這個方法傳送了一個訊息到ActivityThread的內部類H裡,這是一個Handler,看一下程式碼
private class H extends Handler {
...
public void handleMessage(Message msg) {
case LAUNCH_ACTIVITY: {
Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "activityStart");
final ActivityClientRecord r = (ActivityClientRecord) msg.obj;
r.packageInfo = getPackageInfoNoCheck(
r.activityInfo.applicationInfo, r.compatInfo);
handleLaunchActivity(r, null);
Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);
} break;
...
}
}
那麼我們如果想替換回我們的資訊就要從這裡入手了,至於Handler的訊息機制這裡不會深入,大概我們看一下Handler怎麼處理訊息的
/**
* Handle system messages here.
*/
public void dispatchMessage(Message msg) {
if (msg.callback != null) {
handleCallback(msg);
} else {
if (mCallback != null) {
if (mCallback.handleMessage(msg)) {
return;
}
}
handleMessage(msg);
}
}
從上面我們看到,如果這個傳遞的機制
如果傳遞的Message本身就有callback,那麼直接使用Message物件的callback方法;
如果Handler類的成員變數mCallback不為空,那麼首先執行這個mCallback回撥;
如果mCallback的回撥返回true,那麼表示訊息已經成功處理;直接結束。
如果mCallback的回撥返回false,那麼表示訊息沒有處理完畢,會繼續使用Handler類的handleMessage方法處理訊息。
通過上面給出的H的部分程式碼我們看到他只重新了Handler的handleMessage方法,並沒有設定Callback,那麼我們就可以利用這一點,給這個H設定一個Callback讓他在走handleMessage之前先走我們的方法,然後我們替換回之前的資訊,再讓他走H的handleMessage
先獲取到當前的ActivityThread物件
Class<?> activityThreadClass = Class.forName("android.app.ActivityThread");
//他有一個方法返回了自己
Method currentActivityThreadMethod = activityThreadClass.getDeclaredMethod("currentActivityThread");
currentActivityThreadMethod.setAccessible(true);
//執行方法得到ActivityThread物件
Object currentActivityThread = currentActivityThreadMethod.invoke(null);
// 由於ActivityThread一個程序只有一個,我們獲取這個物件的mH欄位,也就是H這個Handler
Field mHField = activityThreadClass.getDeclaredField("mH");
mHField.setAccessible(true);
//得到H這個Handler
Handler mH = (Handler) mHField.get(currentActivityThread);
//建立一個我們的CallBack並賦值給mH
Field mCallBackField = Handler.class.getDeclaredField("mCallback");
mCallBackField.setAccessible(true);
//設定我們自己的CallBackField,具體替換資訊程式碼請下載demo檢視
mCallBackField.set(mH, new ActivityThreadHandlerCallback(mH));
我們的CallBack的部分程式碼,具體替換資訊程式碼請下載demo檢視
try {
// 把替身恢復成真身
Field intent = obj.getClass().getDeclaredField("intent");
intent.setAccessible(true);
Intent raw = (Intent) intent.get(obj);
Intent target = raw.getParcelableExtra(AMSHookHelper.EXTRA_TARGET_INTENT);
raw.setComponent(target.getComponent());
Log.e("Main","target = " + target);
} catch (Exception e) {
throw new RuntimeException("hook launch activity failed", e);
}
9. 最後呼叫我們之前寫的這些程式碼,越早越好,在Application裡呼叫也行,在Activity的attachBaseContext方法中也行
開始已經給出執行圖,不在貼了,如果下載demo,ChaJianHuaTest是宿主應用,請將ChaJianDemo的apk檔案放入sd卡根目錄,因為demo中直接寫死了路徑
到這裡我們已經把外掛apk中的一個activity載入到了宿主中,有的人會問,生命週期沒說呢,其實現在我們的這個外掛Activity已經有了生命週期,因為我們使用了一個佔坑的Activity去欺騙系統檢查,後來我們又替換了我們自己真正要啟動的Activity,這個時候系統並不知道,所以系統還在傻傻的替我們管理者佔坑的Activity的生命週期。有的朋友會問為什麼系統可以將佔坑的生命週期給了我們真正的Activity呢?
AMS與ActivityThread之間對於Activity的生命週期的互動,並沒有直接使用Activity物件進行互動,而是使用一個token來標識,這個token是binder物件,因此可以方便地跨程序傳遞。Activity裡面有一個成員變數mToken代表的就是它,token可以唯一地標識一個Activity物件,這裡我們只不過替換了要啟動Activity的資訊,並沒有替換這個token,所以系統並不知道執行的這個Activity並不是原來的那個,
public final void scheduleLaunchActivity(Intent intent, IBinder token, int ident,
ActivityInfo info, Configuration curConfig, CompatibilityInfo compatInfo,
String referrer, IVoiceInteractor voiceInteractor, int procState, Bundle state,
PersistableBundle persistentState, List<ResultInfo> pendingResults,
List<ReferrerIntent> pendingNewIntents, boolean notResumed, boolean isForward,
ProfilerInfo profilerInfo) {
}
public final void scheduleResumeActivity(IBinder token, int processState,
boolean isForward, Bundle resumeArgs) {
updateProcessState(processState, false);
sendMessage(H.RESUME_ACTIVITY, token, isForward ? 1 : 0);
}
這裡我們已經完美的啟動了一個外掛apk中的Activity,但是還是有缺點,那就是我們外掛的Activity中不能使用資源,只能使用程式碼佈局,因為我們的外掛apk現在屬於宿主,而宿主根本就不知道外掛apk中的資源存在,而且每一個apk都有自己的資源物件存在。
給出的demo中已經解決可以載入資源的問題,但是由於篇幅的問題,會在下一篇中再詳細原理,下一篇完了後我們的外掛化也就徹底說完了,覺得不錯希望支援一下,謝謝。敬請期待下一篇,外掛化資源的動態載入及使用。
完整demo地址 如果覺得不錯請給個star