安卓之插件化开发使用 DexClassLoader&AssetManager 来更换皮肤
dangdang
8年前
<p>这篇文章主要使用DexClassLoader来实现插件化更换皮肤,即将皮肤独立出来做成一个皮肤插件apk,当用户想使用该皮肤时需下载(不需要安装)对应的皮肤插件apk</p> <h3>效果图</h3> <p><img src="https://simg.open-open.com/show/dcd911f44c9edb5f114ea2858d67702e.gif"></p> <p>【为方便测试,主要通过改变背景图来简单地展示皮肤更换】</p> <h2>一、DexClassLoader</h2> <p>如果使用DexClassLoader来实现插件化皮肤更换,我们需要去下载(不需安装)我们的皮肤插件apk:</p> <ol> <li> <p>DexClassLoader 可以加载外部的 apk、jar 或 dex文件,并且会在指定的 outpath 路径存放其 dex 文件。</p> </li> <li> <p>构造函数:</p> <p>DexClassLoader(String dexPath, String optimizedDirectory, String libraryPath, ClassLoader parent)</p> <p>dexPath:需被解压的apk路径,不能为空。</p> <p>optimizedDirectory:解压后的.dex文件的存储路径,不能为空。这个路径强烈建议使用应用程序的私有路径,不要放到sdcard上,否则代码容易被注入攻击。</p> <p>libraryPath:c/c++库的路径,可以为null,若有相关库,须填写。</p> <p>parent:父亲加载器,一般为context.getClassLoader(),使用当前上下文的类加载器。</p> </li> <li> <p>下面为什么要使用到扩展DexClassLoader?:</p> <p>这里使用DexClassLoader是为了加载 <strong>插件apk</strong> 中的dex文件,加载dex文件后系统就可以在dex中找到我们要使用的class类R.java,在R.java中包含着资源等的id,通过id我们可以获取到资源。</p> </li> </ol> <h2>二、主应用apk的逻辑</h2> <ol> <li> <p>为了方便测试,我们将插件apk存放在SD卡中,主应用apk再去获取。所以在主应用中需要读写SD卡内容的权限:</p> <pre> <code class="language-java"><uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/></code></pre> </li> <li> <p>使用SharedPreferences来记录皮肤的改变:</p> <pre> <code class="language-java">SharedPreferences skinType; skinType = getPreferences(Context.MODE_PRIVATE); String skin = skinType.getString("skin",null); if(skin!=null) installSkin(skin);</code></pre> </li> <li> <p>按钮点击事件的响应:</p> <pre> <code class="language-java">public void changeSkin1(View view) { installSkin("dog"); } public void changeSkin2(View view) { installSkin("girl"); }</code></pre> </li> <li> <p>所以我们更好皮肤的重点在 <strong>installSkin</strong> 函数中:</p> <pre> <code class="language-java">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(); } }</code></pre> </li> <li> <p>上面我们是通过findPlugins(String plugName)来查找皮肤插件apk是否存在:</p> <pre> <code class="language-java">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; }</code></pre> </li> <li> <p>上面我们是通过getSkinApkResource(this,apkPath)来获取插件apk的Resources对象的。接下来是对获取Resources对象的源码追踪:</p> <p>a. 通常获取资源时使用getResource获得Resource对象,通过这个对象我们可以访问相关资源。通过跟踪源码发现,其实 getResource 方法是Context的一个抽象方法。</p> <pre> <code class="language-java">/** Return a Resources instance for your application's package. */ public abstract Resources getResources();</code></pre> <p>b. 而getResource的具体实现是在ContextImpl类(Context的实现类)中实现的,获取的Resource对象是应用的全局变量mResource。</p> <pre> <code class="language-java">public Resources getResources(){ return mResources; }</code></pre> <p>c. 然后继续跟踪ContextImpl类中的全局变量mResource如何实现,发现 mResources 由一个LoadApk对象packageInfo来创建。</p> <pre> <code class="language-java">Resources resources = packageInfo.getResources(mainThread);</code></pre> <p>接着继续跟踪LoadApk这个类中的getResources方法:</p> <pre> <code class="language-java">public Resources getResources(ActivityThread mainThread){ if(mResources==null){ mResources = mainThread.getTopLevelResources(mResDir,mSplitResDirs....) } return mResources; }</code></pre> <p>d. 接着继续跟踪 <strong>ActivityThread</strong> 这个类中的 <strong>getTopLevelResources</strong> 方法发现调用的是 <strong>ResourcesManager</strong> 类的 <strong>getTopLevelResources</strong> 方法。于是继续追踪该方法:在这个方法中,有一个Resources对象的弱引用,当弱引用对象被释放掉时会重新调用 <strong>r = new Resources(assets, dm, config);</strong> 来创建Resources对象再放入虚引用中。</p> <p>其中 <strong>AssetManager对象 assets参数</strong> 加载了应用的apk路径:assets.addAssetPath(resDir) ,其中resDir为apk的路径。dm, config参数可以分别为手机的屏幕信息和手机的配置信息。</p> <p>为此我们可以通过 <strong>new Resources(assets, dm, config)</strong> 来创建加载皮肤插件apk资源的Resources</p> <pre> <code class="language-java">//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<Resources> 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<Resources> 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<Resources>(r)); return r; } }</code></pre> <p>e. 因此我们可以通过 <strong>new Resources(assets, dm, config)</strong> 来创建加载 <strong>皮肤插件apk资源的Resources</strong> :</p> <pre> <code class="language-java">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()); }</code></pre> <p>f. 创建插件皮肤apk资源的Resources需要 <strong>AssetManager</strong> 对象,而AssetManager对象无法通过AssetManager类的构造方法来创建,于是采用反射机制来创建,并调用 <strong>addAssetPath</strong> 加载皮肤插件apk路径:</p> <pre> <code class="language-java">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; }</code></pre> </li> <li> <p>获取加载皮肤插件apk资源的 <strong>Resources对象</strong> 后,我们还需要获取皮肤背景图的 <strong>id</strong> :</p> <pre> <code class="language-java">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; }</code></pre> </li> </ol> <h2>三、皮肤插件apk的逻辑</h2> <ol> <li> <p>注意皮肤插件的包名的设置,要与主应用的逻辑一致,可通过皮肤名获取到包名。例:</p> <pre> <code class="language-java"><manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.cxmscb.cxm.girl" ></code></pre> </li> <li> <p>皮肤插件不需要启动Activity:可以清除Activity、其布局文件及其注册。</p> </li> <li> <p>在子程序的drawable中添加皮肤资源文件(注意文件名的设置与主应用的逻辑一致)。例:</p> <p><img src="https://simg.open-open.com/show/56eaea80621f3c42a58dc2aa99243d12.jpg"></p> </li> </ol> <h3>后续问题:</h3> <p>1.在apk打包后可能会对皮肤插件进行混淆,混淆后的资源id会被更换,这样会导致资源无法被主应用反射到。</p> <p>2.上述主应用的逻辑并未完整,为了方便演示省去了皮肤插件的下载(不需要安装)</p> <ol> <li>皮肤插件apk最好存放在较私密的地方</li> </ol> <h3>Github : <a href="/misc/goto?guid=4959740250238520195" rel="nofollow,noindex">Github</a></h3> <h3>参考:</h3> <p><a href="/misc/goto?guid=4959740250334815633" rel="nofollow,noindex">https://yq.aliyun.com/articles/8129</a></p> <p><a href="/misc/goto?guid=4959667903676795723" rel="nofollow,noindex">ANDROID应用程序插件化研究之DEXCLASSLOADER</a></p> <p> </p> <p>来自:http://blog.csdn.net/cxmscb/article/details/52448139</p> <p> </p>