插件化技术从 2015 年就开始百花齐放,如: 奇虎 360 的 replugin,滴滴的 VirtualAPK,到现在的 VirtualApp,插件化经历了市场严峻的考验,也算逐步成熟,今天就带大家手把手实现一个插件化Activity框架,希望对你有所帮助~
插件化是一种动态加载四大组件的技术。最早是为了解决 65535 限制的问题,后来 Google 出来了 multidex 来专门解决
现在市面使用插件化一定程度上可以减少安装包大小,实现项目组件化,将项目拆分方便隔离,降低组件化耦合度太高的问题
当然插件化也能 实现 bug 热修复,由于虚拟机的存在,Java 本身是支持动态加载任意类的。只是安卓系统在四大组件上做了限制,当你尝试打开不在清单中的组件时,给你一个崩溃。
所谓插件化,本质上是为了绕过这个限制,使得应用可以自由地打开和使用四大组件。
插件化无非是为了解决类加载和资源加载的问题,资源加载一般是通过反射 AssertManager , 按照类加载划分,插件化一般分为静态代理和 Hook 的方式,使用插件化一般为了解决应用新版本覆盖慢的问题。
四大组件可动态加载,意味着用户不需要手动安装新版本的应用,我们也可以给用户提供新的功能和页面,或者在用户无感的情况下修复 bug。
public static void extractAssets(Context context, String sourceName) {
AssetManager am = context.getAssets();
InputStream is = null;
FileOutputStream fos = null;
try {
is = am.open(sourceName);
File extractFile = context.getFileStreamPath(sourceName);
fos = new FileOutputStream(extractFile);
byte[] buffer = new byte[1024];
int count = 0;
while ((count = is.read(buffer)) > 0) {
fos.write(buffer, 0, count);
}
fos.flush();
} catch (IOException e) {
e.printStackTrace();
} finally {
closeSilently(is);
closeSilently(fos);
}
}
因为没有上下文环境,上下文环境需要宿主提供给它,一个 DexClassLoader 就包含一个插件,
// 获取插件目录下的文件
File extractFile = mContext.getFileStreamPath(mApkName);
// 获取插件包路径
String dexPath = extractFile.getPath();
// 创建Dex输出路径
File fileRelease = mContext.getDir("dex", Context.MODE_PRIVATE);
// 构建 DexClassLoader 生成目录
mPluginClassLoader = new DexClassLoader(dexPath,
fileRelease.getAbsolutePath(), null, mContext.getClassLoader());
而 Hook 方式是把 dex 文件合并到宿主的 DexClassLoader 里面,但是绕过 AMS 清单文件注册的 Activity 会 抛 ClassNotFuoundException,所以需要 Hook startActivity 和 handleResumeActivity ,前者实现简单,兼容性好,而且插件是分离的,后者兼容性差,开发方便,但是如果多个插件如果有相同的类,就会出现问题。这里使用第一种方式来处理。
try {
AssetManager assetManager = AssetManager.class.newInstance();
Method method = AssetManager.class.getMethod("addAssetPath", String.class);
method.invoke(assetManager, dexPath);
mPluginResources = new Resources(assetManager, mContext.getResources().getDisplayMetrics(),
mContext.getResources().getConfiguration());
} catch (Exception e) {
Toast.makeText(mContext, "加载 Plugin 失败", Toast.LENGTH_SHORT).show();
}
静态代理实现方式很简单,不需要熟悉 Activity 启动流程什么的,直接面向接口编程,首先需要在宿主 App 加载插件狗仔 DExClassCloder 和 Resource 对象,有了 DexClassLoader,就可以加载插件里面的类 Resource 是通过反射 AssertManager 的 addAssertPath 创建一个 AssertManager,再构造 Resource 对象
public void parserApkAction() {
try {
Class packageParserClass = Class.forName("android.content.pm.PackageParser");
Object packageParser = packageParserClass.newInstance();
Method method = packageParserClass.getMethod("parsePackage", File.class, int.class);
File extractFile = mContext.getFileStreamPath(mApkName);
Object packageObject = method.invoke(packageParser, extractFile, PackageManager.GET_RECEIVERS);
Field receiversFields = packageObject.getClass().getDeclaredField("receivers");
ArrayList arrayList = (ArrayList) receiversFields.get(packageObject);
Class packageUserStateClass = Class.forName("android.content.pm.PackageUserState");
Class userHandleClass = Class.forName("android.os.UserHandle");
int userId = (int) userHandleClass.getMethod("getCallingUserId").invoke(null);
for (Object activity : arrayList) {
Class component = Class.forName("android.content.pm.PackageParser$Component");
Field intents = component.getDeclaredField("intents");
// 1.获取 Intent-Filter
ArrayList<IntentFilter> intentFilterList = (ArrayList<IntentFilter>) intents.get(activity);
// 2.需要获取到广播的全类名,通过 ActivityInfo 获取
// ActivityInfo generateActivityInfo(Activity a, int flags, PackageUserState state, int userId)
Method generateActivityInfoMethod = packageParserClass
.getMethod("generateActivityInfo", activity.getClass(), int.class,
packageUserStateClass, int.class);
ActivityInfo activityInfo = (ActivityInfo) generateActivityInfoMethod.invoke(null, activity, 0,
packageUserStateClass.newInstance(), userId);
Class broadcastReceiverClass = getClassLoader().loadClass(activityInfo.name);
BroadcastReceiver broadcastReceiver = (BroadcastReceiver) broadcastReceiverClass.newInstance();
for (IntentFilter intentFilter : intentFilterList) {
mContext.registerReceiver(broadcastReceiver, intentFilter);
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
有了 AssertManager 对象就可以访问资源文件了,但是插件是没有 Context 上下文环境的,这个上下文环境需要宿主提供给他,具体做法是通过 PackManager 获取插件入口的 Activity 注注入宿主 Context,这就完成了宿主 App 跳转插件 App 的步骤。但是插件 App 是没有上下文环境的,所以插件 App 里面是不能直接 startActivity,需要拿到宿主 Context startActivity
public interface ActivityInterface {
// 插入Activity上下文
void insertAppContext(Activity hostActivity);
// Activity各个生命周期方法
void onCreate(Bundle savedInstanceState);
void onStart();
void onResume();
void onPause();
void onStop();
void onDestroy();
}
在 BaseActivity 提供 startActivity,丢给宿主 Activity 去启动
public void startActivity(Intent intent) {
Intent newIntent = new Intent();
newIntent.putExtra("ext_class_name", intent.getComponent().getClassName());
mHostActivity.startActivity(newIntent);
}
public class PluginActivity extends BaseActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
findViewById(R.id.btn_start).setOnClickListener(
v -> startActivity(new Intent(mHostActivity, TestActivity.class))
);
}
}
// 测试插件Activity
public class TestActivity extends BaseActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_test);
}
}
这一步主要做的就是给插件注册一个宿主的 Context
// PorxyActivity
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// 获取到真正要启动的插件 Activity,然后执行 onCreate 方法
String className = getIntent().getStringExtra(EXT_CLASS_NAME);
try {
Class clazz = getClassLoader().loadClass(className);
ActivityInterface activityInterface = (ActivityInterface) clazz.newInstance();
// 注册宿主的 Context
activityInterface.insertAppContext(this);
activityInterface.onCreate(savedInstanceState);
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public void startActivity(Intent intent) {
String className = intent.getStringExtra(EXT_CLASS_NAME);
Intent proxyIntent = new Intent(this, ProxyActivity.class);
proxyIntent.putExtra(EXT_CLASS_NAME, className);
super.startActivity(proxyIntent);
}
这样其实就已经完成了 PluginActivity 的启动了,但是需要注意的是,在插件的 Activity 里面,我们不能再使用 this 了,因为插件并没有上下文环境,所以一些调用 Context 的方法都需要使用宿主的 Context 去执行,比如:
在 BaseActivity 提供 findViewById,可以查找布局 Id 文件
public View findViewById(int layoutId) {
return mHostActivity.findViewById(layoutId);
}
在 BaseActivity 提供 setContentView,方便渲染 UI 布局
public void setContentView(int resId) {
mHostActivity.setContentView(resId);
}
使用 DexClassLoader 加载插件的 Apk 通过代理的 Activity 去执行插件中的 Activity,加载对应的生命周期 通过反射调用 AssetManager 的 addAssetPath 来加载插件中的资源
我们真正打开的确实一个在插件包中定义的 Activity,这个 Activity 需要的信息在插件包中的,而不是宿主的。
插件 Activity 也同时重写了 attachBaseContext 方法。在这一步, 用插件的 classloader 和 Resources 实例创建一个自己的上下文,并用它替换 base context 传递给父类保存。如此一来,业务调用 getClassLoader()或者 getResources()时,取得的就都是插件的信息了。
你需要通过一个资源 ID 获取一个 drawable 的时候,取得的是 color 或者其他资源
主要发生在 8.0 以下版本。经过调查发现在 8.0 以下的插件包中,ContextThemeWrapper.mResources 是宿主的 Resource,而非插件的 Resource。从而导致同一个 ID 找到的资源不对应。
leakcanary 会使用栈顶的 activity 的 Resource 去加载它要显示的一张图片,但这个资源有可能不在当前插件中。
宿主和所有插件都依赖 leakcanary 即可。
本文主要是根据我自身实际投产的 插件组件化 实践,分享一些动态加载 SDK插件 时需要考虑的问题。内容主要包括插件化方案的共同问题、插件包 leakcanary 引发的崩溃、资源 Id 类型不匹配 、宿主Activity 找不到问题,千言万语汇成一句话:
插件有风险,投资须谨慎!