Android插件化探索(三)免安装运行Activity(上)

【Android插件化探索(一)类加载器DexClassLoader】
【Android插件化探索(二)资源加载】

前情提要

在上一篇中有一个细节没有提到,那就是getResourcesForApplication和AssetManager的区别。

getResourcesForApplication

getResourcesForApplication(String packageName),很显然需要传入一个包名,换言之,这个插件必须已经被安装在系统内,然后才能通过包名来获取资源。你可能会想,不安装照样可以获取包名啊。的确,通过pm.getPackageArchiveInfo()可以获取安装包信息。但是,这些包都是没有在PMS中注册的。如果仍然这样获取,会提示如下错误。

1
android.content.pm.PackageManager$NameNotFoundException: com.maplejaw.hotplugin

现在我们就从源码角度来分析getResourcesForApplication。源码在ApplicationPackageManager中,如下:

1
2
3
4
5
6
@Override
public Resources getResourcesForApplication(String appPackageName)
throws NameNotFoundException {

return getResourcesForApplication(
getApplicationInfo(appPackageName, sDefaultFlags));
}

可以看出内部调用了重载方法。getApplicationInfo返回的是ApplicationInfo对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Override
public Resources getResourcesForApplication(@NonNull ApplicationInfo app){

//...
//省略了部分源码
final Resources r = mContext.mMainThread.getTopLevelResources(
sameUid ? app.sourceDir : app.publicSourceDir,
sameUid ? app.splitSourceDirs : app.splitPublicSourceDirs,
app.resourceDirs, app.sharedLibraryFiles, Display.DEFAULT_DISPLAY,
null, mContext.mPackageInfo);
if (r != null) {
return r;
}

}

最终走的仍旧是ActivityThread的getTopLevelResources,ActivityThread里面的相关源码我就不分析了,跟上一篇是一样的也是调用ResourcesManager中的getTopLevelResources,这里不做赘述。

现在我们主要来看看getApplicationInfo里面做了什么?

1
2
3
4
5
6
7
  @Override
public ApplicationInfo getApplicationInfo(String packageName, int flags) throws NameNotFoundException {
ApplicationInfo ai = mPM.getApplicationInfo(packageName, flags, mContext.getUserId());
//...
//省略了部分源码
throw new NameNotFoundException(packageName);
}

mPM的初始化源码如下,可以看出是一个PMS(PackageManagerService)对象。

1
2
3
4
5
6
7
8
public static IPackageManager getPackageManager() {
if (sPackageManager != null) {
return sPackageManager;
}
IBinder b = ServiceManager.getService("package");
sPackageManager = IPackageManager.Stub.asInterface(b);
return sPackageManager;
}

继续深究,找出PMS中相关源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Override
public ApplicationInfo getApplicationInfo(String packageName, int flags, int userId) {
if (!sUserManager.exists(userId)) return null;
enforceCrossUserPermission(Binder.getCallingUid(), userId, false, "get application info");
// writer
synchronized (mPackages) {
PackageParser.Package p = mPackages.get(packageName);
if (DEBUG_PACKAGE_INFO) Log.v(
TAG, "getApplicationInfo " + packageName
+ ": " + p);
if (p != null) {
PackageSetting ps = mSettings.mPackages.get(packageName);
if (ps == null) return null;
// Note: isEnabledLP() does not apply here - always return info
return PackageParser.generateApplicationInfo(
p, flags, ps.readUserState(userId), userId);
}
if ("android".equals(packageName)||"system".equals(packageName)) {
return mAndroidApplication;
}
if ((flags & PackageManager.GET_UNINSTALLED_PACKAGES) != 0) {
return generateApplicationInfoFromSettingsLPw(packageName, flags, userId);
}
}
return null;
}

可以看出会去mPackages中找,然而根本就找不到,因为根本就没有安装。

AssetManager

AssetManager这里就不做赘述了,上一篇已经简单看过,可以直接指定目录。换言之,也就更加灵活。

从上面可以看出,AssetManager比getResourcesForApplication要灵活很多,使用场景也更广。

免安装运行Activity(上)

看完了前面的部分,我们知道可以通过DexClassLoader来加载类,通过AssetManager可以来加载资源。可是现在问题来了,怎么运行一个未安装APK中的Activity?Activity不仅有类有资源,最最重要的是,它有生命!。

我们先来看看按照之前的写法会发生什么状况吧,首先在插件的PluginClass中加入启动Activity的代码如下:

1
2
3
4
5
6
7
8
9
  public void startPluginActivity(Context context, Class<?> cls) {
Intent intent=new Intent(context,cls);
context.startActivity(intent);
}

public void startPluginActivity(Context context) {
Intent intent=new Intent(context,PluginActivity.class);
context.startActivity(intent);
}

然后修改核心测试代码,分别测试两种形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

private void useDexClassLoader(String path){
loadResources(path);
File codeDir=getDir("dex", Context.MODE_PRIVATE);

//创建类加载器,把dex加载到虚拟机中
ClassLoader classLoader = new DexClassLoader(path,codeDir.getAbsolutePath() ,null,
this.getClass().getClassLoader());
//获得包管理器
PackageManager pm = getPackageManager();
PackageInfo packageInfo=pm.getPackageArchiveInfo(path,PackageManager.GET_ACTIVITIES);
String packageName=packageInfo.packageName;

try {
Class<?> clazz = classLoader.loadClass(packageName+".PluginClass");
Comm obj = (Comm) clazz.newInstance();
obj.startPluginActivity(this,classLoader.loadClass(packageName+".PluginActivity"));
// obj.startPluginActivity(this);

} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}

果然没有想象中那么轻松,直接报错提示。

1
android.content.ActivityNotFoundException: Unable to find explicit activity class {com.maplejaw.hotfix/com.maplejaw.hotplugin.PluginActivity}; have you declared this activity in your AndroidManifest.xml?

提示找不到Activity,是否在AndroidManifest.xml中声明?说的也是,并没有在宿主APK中进行声明啊,插件APK的清单是没有效果的。于是怀着满满的自信在AndroidManifest.xml中加入声明。

1
<activity android:name="com.maplejaw.hotplugin.PluginActivity"/>

笔者心想,这回应该可以了吧,再次运行测试。WTF!又报错。

1
java.lang.RuntimeException: Unable to instantiate activity ComponentInfo{com.maplejaw.hotfix/com.maplejaw.hotplugin.PluginActivity}: java.lang.ClassNotFoundException: Didn't find class "com.maplejaw.hotplugin.PluginActivity" on path: DexPathList[[zip file "/data/app/com.maplejaw.hotfix-2/base.apk"],nativeLibraryDirectories=[/vendor/lib64, /system/lib64]]

从打印信息可以看出提示没有找到该类。这就奇怪了,明明可以找到PluginClass类,为什么提示找不到PluginActivity这个类呢?简直没有道理啊。

为了进行对比,笔者故意修改核心测试代码去加载一个不存在的PluginClass2类,看看有什么提示。

1
java.lang.ClassNotFoundException: Didn't find class "com.maplejaw.hotplugin.PluginClass2" on path: DexPathList[[zip file "/storage/emulated/0/2.apk"],nativeLibraryDirectories=[/vendor/lib64, /system/lib64]]

同样提示找不到该类。
但是!!!注意看DexPathList这里,它们指向的dex目录居然不一样。换言之,它们两个的ClassLoader不是同一个。
我们先不想其他问题,暂时不去研究startActivity的源码(下篇探索动态代理会进行研究)。我们先来想一个解决思路,有没有一种方法可以将dex目录指向到插件APK的dex?

替换ClassLoader

要更改dex目录指向谈何容易啊,更何况还要同时兼顾两个dex目录。幸亏ClassLoader遵循着双亲委托原则,让这一切变得不是特别困难。

还记得我们在第一篇DexClassLoader中提到过,一个BaseClassLoader对应一个DexPathList 吗?

1
2
3
4
public BaseDexClassLoader(String dexPath, File optimizedDirectory,String libraryPath, ClassLoader parent) {
super(parent);
this.pathList = new DexPathList(this, dexPath, libraryPath, optimizedDirectory);
}

那么我们把启动Activity的那个ClassLoader替换成我们的,不就间接的改变了dex目录指向吗?你可能会担心,替换成我们的ClassLoader,那宿主APK中的类还找得到吗?由于双亲委托原则,会首先从父ClassLoader中去找,只要我们的父ClassLoader是默认的系统ClassLoader即可。

所以,我们现在的任务是要把ClassLoader替换掉,翻了翻源码,发现ClassLoader对象在LoadedApk中

LoadedApk

而ActivityThread中有着相关引用。

这里写图片描述

于是做了如下反射替换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private void replaceClassLoader(ClassLoader dLoader,String resPath){
try{
String packageName = this.getPackageName();
ClassLoader loader=ClassLoader.getSystemClassLoader();
Class<?> loadApkCls =loader.loadClass("android.app.LoadedApk");
Class<?> activityThreadCls =loader.loadClass("android.app.ActivityThread");

//获取ActivityThread对象
Method currentActivityThreadMethod=activityThreadCls.getMethod("currentActivityThread");
Object currentActivityThread= currentActivityThreadMethod.invoke(null);
//反射获取mPackages中的LoadedApk
Field filed=activityThreadCls.getDeclaredField("mPackages");
filed.setAccessible(true);
Map mPackages= (Map) filed.get(currentActivityThread);
WeakReference wr = (WeakReference) mPackages.get(packageName);
//反射修改LoadedApk中的mClassLoader
Field classLoaderFiled=loadApkCls.getDeclaredField("mClassLoader");
classLoaderFiled.setAccessible(true);
classLoaderFiled.set(wr.get(),dLoader);


}catch(Exception e){
e.printStackTrace();
}

}

插件Activity的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class PluginActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_plugin);
Log.i("JG", "包名:"+getPackageName());
Log.w("JG", "代码路径:"+getPackageCodePath());
Log.e("JG", "资源路径:"+getPackageResourcePath());

}

@Override
protected void onStart() {
super.onStart();
Log.d("JG","onStart");
}

@Override
protected void onResume() {
super.onResume();
Log.d("JG","onResume");
}

@Override
protected void onPause() {
super.onPause();
Log.d("JG","onPause");
}

@Override
protected void onStop() {
super.onStop();
Log.d("JG","onStop");
}

@Override
protected void onDestroy() {
super.onDestroy();
Log.d("JG","onDestroy");
}

运行测试通过,Activity是能启动了,生命周期完全正常,但是发现资源却完全加载不了,一片白(也有加载到宿主界面的,那是因为资源id刚好和插件的重复)!控制台打印信息如下:
这里写图片描述
可以看出代码路径和资源路径全部指向了宿主APK,即使使用loadResources也完全没有效果,因为一个Activity一个Context,我们的loadResources只对那个Activity的Context有效果。迫不得已,又去翻看了源码,最后在上面的反射基础中加入如下反射修改LoadedApk中的mResDir代码。

1
2
3
4
//反射修改LoadedApk中的资源目录
Field filed2=loadApkCls.getDeclaredField("mResDir");
filed2.setAccessible(true);
filed2.set(wr.get(),resPath);

测试,启动成功,加载出插件的界面。查看控制台,发现成功修改资源目录,生命周期完全正常。
image_1ajsv6rti1ocj1umj10un1kqc11b5l.png-34.1kB
但是呢,这种方法是有弊端的,因为反射导致它彻底改变了资源目录,假如你要回到宿主Activity还要重新切换目录才行。不由得想,要是资源也有双亲委托该有多好啊。

合并DexPathList

这种方式类似于热修复方案。将插件的dexElements插入到系统的dexElements中,这样我们启动Activity时就不会提示找不到该类。在第一篇中,我们简单看过DexPathList源码,现在再来回顾下。
首先,一个ClassLoader一个DexPathList。
这里写图片描述
然后,一个DexPathList中含有一个dexElements数组
这里写图片描述
最后,加载类时从dexElements数组中遍历。
这里写图片描述

好了,思路很清晰,通过反射,将插件的dexElements与宿主的合并,并赋值给宿主的dexPathList。
实现方案如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
 private void combinePathList(ClassLoader loader){
//获取系统的classloader
PathClassLoader pathLoader = (PathClassLoader) getClassLoader();

try {
//反射dexpathlist
Field pathListFiled = Class.forName("dalvik.system.BaseDexClassLoader").getDeclaredField("pathList");
pathListFiled.setAccessible(true);
//反射dexElements
Field dexElementsFiled=Class.forName("dalvik.system.DexPathList").getDeclaredField("dexElements");
dexElementsFiled.setAccessible(true);
//获取系统的pathList
Object pathList1= pathListFiled.get(pathLoader);
//获取系统的dexElements
Object dexElements1=dexElementsFiled.get(pathList1);

//获取插件的pathlist
Object pathList2= pathListFiled.get(loader);
//获取插件的dexElements
Object dexElements2=dexElementsFiled.get(pathList2);
//合并dexElements
Object combineDexElements=combineArray(dexElements1,dexElements2);
//设置给系统的dexpathlist
dexElementsFiled.set(pathList1,combineDexElements);

} catch (IllegalArgumentException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}



//合并两个数组,返回一个新数组
private static Object combineArray(Object arrayLhs, Object arrayRhs) {
Class<?> localClass = arrayLhs.getClass().getComponentType();
int i = Array.getLength(arrayLhs);
int j = i + Array.getLength(arrayRhs);
Object result = Array.newInstance(localClass, j);
for (int k = 0; k < j; ++k) {
if (k < i) {
Array.set(result, k, Array.get(arrayLhs, k));
} else {
Array.set(result, k, Array.get(arrayRhs, k - i));
}
}
return result;
}

测试通过,成功启动Activity。
但是,同样需要在清单文件注册,同样加载不了资源。仍然需要去反射替换掉LoadedApk中的资源目录。

源码下载地址:https://github.com/maplejaw/HotPluginDemo

最后

关于上面启动免安装Activity的方案,可以看出存在很明显的缺陷,首先,需要在清单文件提前注册,此外资源反射修改也很蛋疼。如果不想用反射,我们可以提前将资源内置于宿主中,或者纯用JAVA代码来写。

但是,这两种方案总归很麻烦。有没有更好的方案呢?没错,就是动态代理!
下一篇准备探索动态代理启动Activity。