动态加载插件apk中的资源

场景

前段时间,产品又提出了新的需求,要把app的主题换成圣诞节的主题,过后再换回来。一种思路就是跟夜间模式那样,准备多套主题资源放在app内的资源文件夹内,切换时调用不用的主题即可,但这样无疑增加了app的包体积,而且如果有新的主题资源包要加进来,用户又得更新整个app,这样的更新方式肯定是不好的,这种情况下我们可以考虑另外一种思路,动态加载资源主题包的apk文件。

先看看最终实现的效果对比:

动态加载插件apk中的资源_第1张图片
1701036.png

原理

动态加载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里面的设置为相同才行。

动态加载插件apk中的资源_第2张图片
12081.png

我这里只更新几个icon图标和底部tab的selector资源。

动态加载插件apk中的资源_第3张图片
12082.png

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

你可能感兴趣的:(动态加载插件apk中的资源)