场景
前段时间,产品又提出了新的需求,要把app的主题换成圣诞节的主题,过后再换回来。一种思路就是跟夜间模式那样,准备多套主题资源放在app内的资源文件夹内,切换时调用不用的主题即可,但这样无疑增加了app的包体积,而且如果有新的主题资源包要加进来,用户又得更新整个app,这样的更新方式肯定是不好的,这种情况下我们可以考虑另外一种思路,动态加载资源主题包的apk文件。
先看看最终实现的效果对比:
原理
动态加载apk有两种方式:
- 一种是将资源主题包的apk安装到手机上再读取apk内的资源,这种方式的原理是将宿主app和插件app设置相同的sharedUserId,这样两个app将会在同一个进程中运行,并可以相互访问内部资源了。
- 一种是不用安装资源apk的方式。其原理是通过DexClassLoader类加载器去加载指定路径下的apk、dex或者jar文件,反射出R类中相应的内部类然后根据资源名来获取我们需要的资源id,然后根据资源id得到对应的图片或者xml文件。
实现
无论是哪种方式,我们都需要新的资源包,我们新建一个android工程,把需要更换的新图片和xml资源文件放在这个工程对应的目录下,注意,文件名必需和宿主app内对应的文件名相同,因为后面反射是根据资源名去找资源id。然后将这个工程打包成apk并使用跟宿主app相同的签名文件签名,在app启动的Activity中需要加一个检查是否有新的资源包和是否需要删掉资源包的接口(需要后台人员配合写接口),如果有就下载apk,至于安装apk和不安装apk这两种方式哪种更好,我觉得安装apk这种方式不太友好,即使我们可以做到安装后在桌面上没有启动图标,但还是有一个安装的过程,对用户来说,可能不知道这是什么东西,以为又安装了什么新应用,所以我会使用不安装apk来更新这种方式,这里也还是要记录下安装apk方式是怎么做的。
准备资源包
新建工程Skin-Plugin,将要更换的图片或者xml文件放在对应的drawable文件夹内,在AndroidManifest.xml中增加shareUserId,然后打包成apk文件。如果是不需要安装apk的,就不用设置shareUserId了。
shareUserId这个值可以随意设定,但是必须和宿主app里面的设置为相同才行。
我这里只更新几个icon图标和底部tab的selector资源。
AndroidManifest配置如上图所示,需要注意的是,让app不在桌面上生成应用图标,需要将启动activity去掉下面的过滤配置:
去掉上述配置后这个工程是无法执行Run操作了,但是不要紧,不影响打包成apk。
加载安装的apk
前面说过要提供一个接口下载新的资源包,下载后自动安装,我们在使用这些资源的地方去检查资源apk有没有安装,如果有,就加载资源包中的资源,将检查apk是否安装的方法写到工具类中,这里需要传入资源app的包名。
/**
* apk是否已安装
* @param packageName
* @return true已经安装,false未安装或者已经卸载。
*/
public static boolean checkApkInstalled(Context context, String packageName) {
if (packageName == null || "".equals(packageName)) {
return false;
}
try {
ApplicationInfo info = context.getPackageManager().getApplicationInfo(packageName, PackageManager.GET_UNINSTALLED_PACKAGES);
return true;
} catch (PackageManager.NameNotFoundException e) {
return false;
}
}
检查到安装了插件apk后,需要创建一个插件apk内的上下文对象,因为只有插件apk的上下文对象才能获取到它的Resourece对象,从而通过插件上下文获取资源id。
//获取对应插件中的上下文,通过它可得到插件的Resource
Context pluginContext = this.createPackageContext(packageName, CONTEXT_IGNORE_SECURITY | CONTEXT_INCLUDE_CODE);
//获取资源id
int resId = pluginContext.getResources().getIdentifier(......);
加载未安装的apk
同样的这种方式也要提供一个资源包,用户启动app时在后台静默下载插件apk文件,保存到指定的路径下。我们要加载这个插件,就需要一个插件的类加载器,而不是宿主app的类加载器,这时候只能去手动构建DexClassLoader,再通过类加载器,反射出R类中相应的内部类进而获取我们需要的资源id。
/**
* 加载apk获得内部资源id
* @param context
* @param pluginPath apk路径
*/
public static int getResId(Context context, String pluginPath, String apkPackageName, String resName) {
try {
//在应用安装目录下创建一个名为app_dex文件夹目录,如果已经存在则不创建
File optimizedDirectoryFile = context.getDir("dex", Context.MODE_PRIVATE);
// 构建插件的DexClassLoader类加载器,参数:
// 1、包含dex的apk文件或jar文件的路径,
// 2、apk、jar解压缩生成dex存储的目录,
// 3、本地library库目录,一般为null,
// 4、父ClassLoader
DexClassLoader dexClassLoader = new DexClassLoader(pluginPath, optimizedDirectoryFile.getPath(), null, ClassLoader.getSystemClassLoader());
//通过使用apk自己的类加载器,反射出R类中相应的内部类进而获取我们需要的资源id
Class> clazz = dexClassLoader.loadClass(apkPackageName + ".R$drawable");
Field field = clazz.getDeclaredField(resName);//得到名为resName的这张图片字段
return field.getInt(R.id.class);//得到图片id
} catch (Exception e) {
e.printStackTrace();
}
return 0;
}
其中第二个参数是插件apk的全路径,文件名必需是带.apk,第三个参数是插件apk的包名,第四个参数是资源名。
/**
* 获取插件apk的包名
* @param context
* @param pluginPath 插件apk的绝对路径
* @return
*/
public static String getPluginPackagename(Context context, String pluginPath) {
PackageManager pm = context.getPackageManager();
PackageInfo pkgInfo = pm.getPackageArchiveInfo(pluginPath, PackageManager.GET_ACTIVITIES);
if (pkgInfo != null) {
ApplicationInfo appInfo = pkgInfo.applicationInfo;
String pkgName = appInfo.packageName;//包名
return pkgName;
}
return null;
}
只有资源id还不够,还需要插件apk的Resources对象,因为只有它才能根据资源id获取到对应的资源。
/**
* 获取对应插件的Resource对象
* @param context
* @param pluginPath 插件apk的路径,带apk名
* @return
*/
public static Resources getPluginResources(Context context, String pluginPath) {
try {
AssetManager assetManager = AssetManager.class.newInstance();
// 反射调用方法addAssetPath(String path)
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
// 将插件Apk文件添加进AssetManager
addAssetPath.invoke(assetManager, pluginPath);
// 获取宿主apk的Resources对象
Resources superRes = context.getResources();
// 获取插件apk的Resources对象
Resources mResources = new Resources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
return mResources;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
获取插件的Resources对象其实就是用反射的方式调用了AssetManager
类的addAssetPath
方法,这个方法的目的是将插件apk里的资源都加载到AssetManager对象中进行管理,然后来构建插件apk的Resources。至于为什么要用反射,看看addAssetPath
的源码:
/**
* Add an additional set of assets to the asset manager. This can be
* either a directory or ZIP file. Not for use by applications. Returns
* the cookie of the added asset, or 0 on failure.
* {@hide}
*/
public final int addAssetPath(String path) {
int res = addAssetPathNative(path);
return res;
}
这里有个注解@hide,表示即使它是public的,但是外界仍然无法访问它的,因为android sdk导出的时候会自动忽略隐藏的api,因此只能通过反射来调用。
// 根据资源名去加载新的资源
String pluginPath = Environment.getExternalStorageDirectory().toString() + "/dynamicload/download/skin-plugin.apk";
if (item.getResName() != null) {
Drawable drawable = Util.getPluginResources(mContext, pluginPath).getDrawable(Util.getResId(mContext, pluginPath, Util.getPluginPackagename(mContext, pluginPath), item.getResName()));
imageView.setImageDrawable(drawable);
}
至此就完成了动态加载插件apk资源,当我们需要切换回原来的资源时,只需要将资源包删除即可,或者重新构建一个资源包,让用户去下载,由于我们是运行时加载,所以当更换了资源包时,第一次打开只是去下载这个插件资源包,再次打开时才会去加载。
代码下载地址:
https://github.com/shenhuniurou/BlogDemos/tree/master/DynamicLoadDemo