安卓之插件化开发使用DexClassLoader&AssetManager来更换皮肤

这篇文章主要使用DexClassLoader来实现插件化更换皮肤,即将皮肤独立出来做成一个皮肤插件apk,当用户想使用该皮肤时需下载(不需要安装)对应的皮肤插件apk

效果图

【为方便测试,主要通过改变背景图来简单地展示皮肤更换】

一、DexClassLoader

如果使用DexClassLoader来实现插件化皮肤更换,我们需要去下载(不需安装)我们的皮肤插件apk:

  1. DexClassLoader 可以加载外部的 apk、jar 或 dex文件,并且会在指定的 outpath 路径存放其 dex 文件。

  2. 构造函数:

    DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent)

    dexPath:需被解压的apk路径,不能为空。

    optimizedDirectory:解压后的.dex文件的存储路径,不能为空。这个路径强烈建议使用应用程序的私有路径,不要放到sdcard上,否则代码容易被注入攻击。

    libraryPath:c/c++库的路径,可以为null,若有相关库,须填写。

    parent:父亲加载器,一般为context.getClassLoader(),使用当前上下文的类加载器。

  3. 下面为什么要使用到扩展DexClassLoader?:

    这里使用DexClassLoader是为了加载 插件apk 中的dex文件,加载dex文件后系统就可以在dex中找到我们要使用的class类R.java,在R.java中包含着资源等的id,通过id我们可以获取到资源。

二、主应用apk的逻辑

  1. 为了方便测试,我们将插件apk存放在SD卡中,主应用apk再去获取。所以在主应用中需要读写SD卡内容的权限:

    
    
    
  2. 使用SharedPreferences来记录皮肤的改变:

    SharedPreferences skinType; 
    
    skinType = getPreferences(Context.MODE_PRIVATE);
    String skin = skinType.getString("skin",null);
    
    if(skin!=null) installSkin(skin);
    
  3. 按钮点击事件的响应:

    public void changeSkin1(View view) {
        installSkin("dog");
    }
    
    public void changeSkin2(View view) {
        installSkin("girl");
    }
    
  4. 所以我们更好皮肤的重点在installSkin函数中:

    public void installSkin(String skinName) {
    
        // 通过皮肤名字查找皮肤apk是否存在,这里需要注意皮肤apk的命名
        // 存在则返回路径,否则返回null
        String apkPath = findPlugins(skinName);
    
        if (apkPath == null) {
            // 皮肤不存在时(可以静默下载皮肤)
            Toast.makeText(this, "请先安装皮肤", Toast.LENGTH_SHORT).show();
    
            // 皮肤插件被删除的情况,清空存储
            if (skinType.getString("skin", skinName).equals(skinName))
                skinType.edit().clear().commit();
    
        } else {
             // 皮肤apk存在,获取其包名。注意包名与皮肤名的关系
             String apkPackageName = "com.cxmscb.cxm."+skinName;
    
            /**
            *通常我们获取Resoueces对象使用的是context.getResources
            *但我们无法获取皮肤apk的context(因为皮肤apk没有安装)
            *在这里通过获取加载插件apk的AssetManager来获取插件apk的Reources对象
            */
            Resources resources = getSkinApkResource(this,apkPath);
    
            // 获取背景图片的id
            int bgId = getSkinBackgroundId(apkPath,skinName,apkPackageName);
    
            //通过插件的Resources对象和id获取背景图片
            rl.setBackgroundDrawable(resources.getDrawable(bgId));
    
            //保存记录
            skinType.edit().putString("skin",skinName).commit();
    
        }
    }
    
  5. 上面我们是通过findPlugins(String plugName)来查找皮肤插件apk是否存在:

    private String findPlugins(String plugName) {
    
        String apkPath = null;
    
        // 获取apk的路径 (为方便测试:将apk存放在SD卡的根目录下)
        apkPath = Environment.getExternalStorageDirectory()+"/"+ plugName+".apk";
    
        //皮肤apk存在时,才返回路径
        File file = new File(apkPath);
    
        if (file.exists()) {
    
            return apkPath;
        }
    
    
        return null;
    }
    
  6. 上面我们是通过getSkinApkResource(this,apkPath)来获取插件apk的Resources对象的。接下来是对获取Resources对象的源码追踪:

    a. 通常获取资源时使用getResource获得Resource对象,通过这个对象我们可以访问相关资源。通过跟踪源码发现,其实 getResource 方法是Context的一个抽象方法。

    /** Return a Resources instance for your application's package. */
    public abstract Resources getResources();
    

    b. 而getResource的具体实现是在ContextImpl类(Context的实现类)中实现的,获取的Resource对象是应用的全局变量mResource。

    public Resources getResources(){
        return mResources;
    }
    

    c. 然后继续跟踪ContextImpl类中的全局变量mResource如何实现,发现 mResources 由一个LoadApk对象packageInfo来创建。

    Resources resources = packageInfo.getResources(mainThread);
    

    接着继续跟踪LoadApk这个类中的getResources方法:

    public Resources getResources(ActivityThread mainThread){
    
        if(mResources==null){
                mResources = mainThread.getTopLevelResources(mResDir,mSplitResDirs....)
        }
    
        return mResources;
    }
    

    d. 接着继续跟踪ActivityThread这个类中的getTopLevelResources方法发现调用的是ResourcesManager类的getTopLevelResources方法。于是继续追踪该方法:在这个方法中,有一个Resources对象的弱引用,当弱引用对象被释放掉时会重新调用r = new Resources(assets, dm, config); 来创建Resources对象再放入虚引用中。

    其中AssetManager对象 assets参数 加载了应用的apk路径:assets.addAssetPath(resDir) ,其中resDir为apk的路径。dm, config参数可以分别为手机的屏幕信息和手机的配置信息。

    为此我们可以通过 new Resources(assets, dm, config)来创建加载皮肤插件apk资源的Resources

    //ResourcesManager
    
    public Resources getTopLevelResources(String resDir, int displayId,  
            Configuration overrideConfiguration, CompatibilityInfo compatInfo, IBinder token) {  
        final float scale = compatInfo.applicationScale;  
        ResourcesKey key = new ResourcesKey(resDir, displayId, overrideConfiguration, scale,  
                token);  
        Resources r;  
        synchronized (this) {  
            // Resources is app scale dependent.  
            if (false) {  
                Slog.w(TAG, "getTopLevelResources: " + resDir + " / " + scale);  
            }  
            WeakReference wr = mActiveResources.get(key);  
            r = wr != null ? wr.get() : null;  
            //if (r != null) Slog.i(TAG, "isUpToDate " + resDir + ": " + r.getAssets().isUpToDate());  
            if (r != null && r.getAssets().isUpToDate()) {  
                if (false) {  
                    Slog.w(TAG, "Returning cached resources " + r + " " + resDir  
                            + ": appScale=" + r.getCompatibilityInfo().applicationScale);  
                }  
                return r;  
            }  
        }  
    
        //if (r != null) {  
        //    Slog.w(TAG, "Throwing away out-of-date resources!!!! "  
        //            + r + " " + resDir);  
        //}  
    
        AssetManager assets = new AssetManager();  
        if (assets.addAssetPath(resDir) == 0) {  
            return null;  
        }  
    
        //Slog.i(TAG, "Resource: key=" + key + ", display metrics=" + metrics);  
        DisplayMetrics dm = getDisplayMetricsLocked(displayId);  
        Configuration config;  
        boolean isDefaultDisplay = (displayId == Display.DEFAULT_DISPLAY);  
        final boolean hasOverrideConfig = key.hasOverrideConfiguration();  
        if (!isDefaultDisplay || hasOverrideConfig) {  
            config = new Configuration(getConfiguration());  
            if (!isDefaultDisplay) {  
                applyNonDefaultDisplayMetricsToConfigurationLocked(dm, config);  
            }  
            if (hasOverrideConfig) {  
                config.updateFrom(key.mOverrideConfiguration);  
            }  
        } else {  
            config = getConfiguration();  
        }  
        r = new Resources(assets, dm, config);  
        if (false) {  
            Slog.i(TAG, "Created app resources " + resDir + " " + r + ": "  
                    + r.getConfiguration() + " appScale="  
                    + r.getCompatibilityInfo().applicationScale);  
        }  
    
        synchronized (this) {  
            WeakReference wr = mActiveResources.get(key);  
            Resources existing = wr != null ? wr.get() : null;  
            if (existing != null && existing.getAssets().isUpToDate()) {  
                // Someone else already created the resources while we were  
                // unlocked; go ahead and use theirs.  
                r.getAssets().close();  
                return existing;  
            }  
    
            // XXX need to remove entries when weak references go away  
            mActiveResources.put(key, new WeakReference(r));  
            return r;  
        }  
    }  
    

    e. 因此我们可以通过 new Resources(assets, dm, config)来创建加载皮肤插件apk资源的Resources

    private Resources getSkinApkResource(Context context, String apkPath) {
    
        // 获取加载插件apk的AssetManager
        AssetManager assetManager = createSkinApkAssetManager(apkPath);
    
        // 创建创建插件apk资源的Resources对象
        return new Resources(assetManager, context.getResources().getDisplayMetrics(), context.getResources().getConfiguration());
    
    }
    

    f. 创建插件皮肤apk资源的Resources需要AssetManager对象,而AssetManager对象无法通过AssetManager类的构造方法来创建,于是采用反射机制来创建,并调用addAssetPath加载皮肤插件apk路径:

    private AssetManager createSkinApkAssetManager(String apkPath) {
        AssetManager assetManager = null;
        try {
            assetManager = AssetManager.class.newInstance();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        try {
            AssetManager.class.getDeclaredMethod("addAssetPath", String.class).invoke(
                    assetManager, apkPath);
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
        return assetManager;
    }
    
  7. 获取加载皮肤插件apk资源的Resources对象后,我们还需要获取皮肤背景图的id

    private int getSkinBackgroundId(String apkPath, String skinName,String apkPackageName) {
    
        int id = 0;
        try {
    
            /**使用DexClassLoader可以加载未安装的apk中的dex
            * 构造方法的参数可参考文章前面的介绍
            */
            DexClassLoader dexClassLoader = new DexClassLoader(apkPath,this.getDir(skinName,Context.MODE_PRIVATE).getAbsolutePath(),null,this.getClassLoader());
    
            // 运用反射:在皮肤插件R文件的drawable类中寻找插件资源的id 
            Class forName = dexClassLoader.loadClass(apkPackageName+".R$drawable");
    
            // 获取成员变量的值
            for (Field field : forName.getDeclaredFields()) {
    
                // 获取包含“main_bg"名的资源id
                if (field.getName().contains("main_bg")) {
                    id = field.getInt(R.drawable.class);
                    return id;
                }
    
            }
    
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return id;
    }
    

三、皮肤插件apk的逻辑

  1. 注意皮肤插件的包名的设置,要与主应用的逻辑一致,可通过皮肤名获取到包名。例:

       
    
  2. 皮肤插件不需要启动Activity:可以清除Activity、其布局文件及其注册。

  3. 在子程序的drawable中添加皮肤资源文件(注意文件名的设置与主应用的逻辑一致)。例:

    这里写图片描述

后续问题:

1.在apk打包后可能会对皮肤插件进行混淆,混淆后的资源id会被更换,这样会导致资源无法被主应用反射到。

2.上述主应用的逻辑并未完整,为了方便演示省去了皮肤插件的下载(不需要安装)

  1. 皮肤插件apk最好存放在较私密的地方

Github : Github

参考:

https://yq.aliyun.com/articles/8129

ANDROID应用程序插件化研究之DEXCLASSLOADER

你可能感兴趣的:(Android学习)