Android 以apk包方式共享资源(动态换肤)的实现方式

来源:互联网 发布:金山软件安全卫士 编辑:程序博客网 时间:2024/06/07 18:14
一 应用场景:
之前的一个项目是一个Android系统的系统应用的重构开发,项目中有很多个应用,这些 应用有许多相同的界面和交互;另外,这一套应用的界面可能会需要经常调整来适配不同的客户需求。为了减少开发和维护的工作量,我把这些应用的资源统一起来 一起维护,相同的资源不需要维护2份,并且适配新资源(layout、drawable、多国语言等)工作量也能做到最小。另外, 当用户想要更换皮肤界面时, 也只需要替换这一个资源包就可以了, 耦合度比较低。

二 优点:
1. 动态换肤;
2. 风格相同的一组应用可以共享一个资源包, 方便维护,也方便动态换肤, 想想都觉得爽!

三 缺点:
1. 有扩展控件需要配置到资源包中时, 宿主apk代码中无法从layout中获取扩展控件并强制类型转换,也就是说在宿主apk中无法使用扩展控件的扩展方法。原因在第六点:遇到的问题 中解释。 

四 实现方式:
实现方式是使用 Context.createPackageContext(String packageName,  int flags)  方法来加载一个apk包的Context,有了Context后就可以得到Resources和LayoutInflater, 再通过反射机制可以得到资源ID。

把加载资源apk包的工作封装到ResLoader中, 代码如下:

[java] view plain copy
  1. package com.example.resapktest;  
  2.   
  3. import java.lang.reflect.Field;  
  4. import java.util.HashMap;  
  5. import android.content.Context;  
  6. import android.content.pm.PackageManager.NameNotFoundException;  
  7. import android.content.res.Resources;  
  8. import android.graphics.drawable.Drawable;  
  9. import android.util.Log;  
  10. import android.view.LayoutInflater;  
  11. import android.view.View;  
  12.   
  13. public class ResLoader  
  14. {  
  15.     private static final String TAG = "ResLoader";    
  16.     private Context mContext = null;  
  17.     private Context mResApkContext = null;  
  18.     private LayoutInflater mResApkInflater = null;  
  19.     private String mResApkPackage = null;  
  20.     private ResIDMap mResIDMap = null;  
  21.     private Resources mResApkResources = null;  
  22.   
  23.     public ResLoader(Context context)  
  24.     {  
  25.         this.mContext = context;  
  26.     }  
  27.       
  28.     public View loadLayout(String resource)  
  29.     {  
  30.         if (null != resource)         
  31.         {  
  32.             int id = mResIDMap.mSkinIDMap.get(resource);  
  33.             return mResApkInflater.inflate(id, null);             
  34.         }  
  35.         return null;  
  36.     }  
  37.       
  38.     public View loadLayout(int id)  
  39.     {  
  40.         if(id <= 0return null;  
  41.         return mResApkInflater.inflate(id, null);  
  42.     }  
  43.       
  44.     public int findIDByName(String resource)  
  45.     {  
  46.         if (null == resource)  
  47.         {  
  48.             return -1;  
  49.         }  
  50.         return mResIDMap.mSkinIDMap.get(resource);  
  51.     }  
  52.       
  53.     public String getString(String resource)  
  54.     {  
  55.         if (null == resource)  
  56.         {  
  57.             return null;  
  58.         }  
  59.         int id = mResIDMap.mSkinIDMap.get(resource);  
  60.         return mResApkContext.getString(id);  
  61.     }  
  62.   
  63.     public float getDimension(String resource)  
  64.     {  
  65.         if (null == resource)  
  66.         {  
  67.             return 0;  
  68.         }  
  69.         int id = mResIDMap.mSkinIDMap.get(resource);  
  70.         return mResApkResources.getDimension(id);  
  71.     }  
  72.       
  73.     public Drawable getDrawable(String resource)  
  74.     {  
  75.         if (null == resource)  
  76.         {  
  77.             return null;  
  78.         }  
  79.         int id = mResIDMap.mSkinIDMap.get(resource);  
  80.         return mResApkResources.getDrawable(id);  
  81.     }  
  82.       
  83.     public Context getSkinContext()  
  84.     {  
  85.         return mResApkContext;  
  86.     }  
  87.       
  88.     public ResIDMap getResIDMap()  
  89.     {  
  90.         return mResIDMap;  
  91.     }  
  92.       
  93.     /** 
  94.      *  
  95.     * Title: loadResApk  
  96.     * @Description: 
  97.     * @param resApk the res package name,such as "com.example.resapk" 
  98.      */  
  99.     public boolean loadResApk(String resApk)  
  100.     {  
  101.         if (mResApkPackage != null   
  102.                 && 0 == resApk.compareToIgnoreCase(mResApkPackage)) {  
  103.             return false;  
  104.         }  
  105.           
  106.         if(resApk == nullreturn false;  
  107.           
  108.         try {     
  109.             // 创建资源apk包的Context  
  110.             mResApkContext = mContext.createPackageContext(resApk,   
  111.                 Context.CONTEXT_IGNORE_SECURITY | Context.CONTEXT_INCLUDE_CODE);  
  112.               
  113.             // 获取资源apk包的Resources  
  114.             mResApkResources = mResApkContext.getResources();  
  115.               
  116.             // 获取资源apk包的LayoutInflater  
  117.             mResApkInflater = (LayoutInflater) mResApkContext.getSystemService(  
  118.                     Context.LAYOUT_INFLATER_SERVICE);     
  119.               
  120.             mResApkPackage = resApk;  
  121.               
  122.             // 使用反射机制把资源apk包中的资源名及id获取出来  
  123.             mResIDMap = new ResIDMap(mResApkContext, mResApkPackage);                     
  124.         }  
  125.         catch (NameNotFoundException e)  
  126.         {  
  127.             e.printStackTrace();  
  128.             return false;  
  129.         }  
  130.           
  131.         return true;  
  132.     }  
  133.       
  134.     public class ResIDMap  
  135.     {  
  136.         public HashMap<String, Integer> mSkinIDMap = null;  
  137.         @SuppressWarnings("unused")  
  138.         private Context mContext = null;  
  139.         public ResIDMap(Context context, String skinPackage)  
  140.         {  
  141.             mContext = context;  
  142.             mSkinIDMap = getResIDMap(context, skinPackage);  
  143.         }  
  144.           
  145.         /** 
  146.          *  
  147.         * Title: getResIDMap  
  148.         * @Description: 
  149.         * @param resApkContext  the context of the resource apk,  
  150.         * create by createPackageContext  
  151.         * @param resApkPackagename the resource apk package name,  
  152.         * such as com.example.resapk 
  153.         * @return 
  154.          */  
  155.         private HashMap<String, Integer> getResIDMap(Context resApkContext,   
  156.                 String resApkPackagename)  
  157.         {  
  158.             HashMap<String, Integer> mResIDMap = new HashMap<String, Integer>();  
  159.             if (null == resApkContext || null == resApkPackagename)  
  160.             {  
  161.                 return mResIDMap;  
  162.             }  
  163.             try  
  164.             {  
  165.                 Class<?> RClass = resApkContext.getClassLoader().loadClass(  
  166.                         resApkPackagename+".R");  
  167.                 Class<?>[] cl = RClass.getClasses();  
  168.                 for (int i = 0; i < cl.length; i++)  
  169.                 {  
  170.                     Field field[] = cl[i].getFields();  
  171.                     for (int j = 0; j < field.length; j++)  
  172.                     {  
  173.                         if(field[j].getType().getCanonicalName().equals("int[]")){  
  174.                             Log.d(TAG,"continue");  
  175.                             continue;  
  176.                         }  
  177.                         mResIDMap.put(field[j].getName(), field[j].getInt(  
  178.                                 field[j].getName()));  
  179.                     }  
  180.                 }  
  181.             }  
  182.             catch (ClassNotFoundException e)  
  183.             {  
  184.                 e.printStackTrace();  
  185.             }  
  186.             catch (IllegalArgumentException e)  
  187.             {  
  188.                 e.printStackTrace();  
  189.             }  
  190.             catch (IllegalAccessException e)  
  191.             {  
  192.                 e.printStackTrace();  
  193.             }  
  194.             return mResIDMap;  
  195.         }  
  196.     }     
  197.   
  198. }  

在Activity中调用, 以下是MainAcitvity.java的代码:

[java] view plain copy
  1. package com.example.resapktest;  
  2.   
  3. import java.lang.reflect.InvocationTargetException;  
  4. import java.lang.reflect.Method;  
  5. import android.app.Activity;  
  6. import android.os.Bundle;  
  7. import android.util.Log;  
  8. import android.view.Menu;  
  9. import android.view.MenuItem;  
  10. import android.view.View;  
  11.   
  12. public class MainActivity extends Activity {  
  13.     ResLoader mResLoader;  
  14.       
  15.     @Override  
  16.     protected void onCreate(Bundle savedInstanceState) {  
  17.         super.onCreate(savedInstanceState);  
  18.         Log.i("MainActivity""ResApkTest");  
  19.         //setContentView(R.layout.activity_main);  
  20.           
  21.         mResLoader = new ResLoader(getApplicationContext());  
  22.           
  23.         // 加载资源apk包  
  24.         mResLoader.loadResApk("com.example.resapk");  
  25.           
  26.         // 使用字符串加载 Layout  
  27.         View activityMain = mResLoader.loadLayout("activity_main");   
  28.           
  29.         // 使用ID 加载Layout  
  30.         activityMain = mResLoader.loadLayout(com.example.resapk.R.layout.activity_main);  
  31.           
  32.         if(activityMain != null) {  
  33.             setContentView(activityMain);  
  34.             View view = activityMain.findViewById(  
  35.                     com.example.resapk.R.id.time_clock_2);  
  36.             view.setAlpha(0.5f);              
  37.             Clock_setTextColor(view, 0xff0000ff);             
  38.         }  
  39.     }  
  40.       
  41.     public void Clock_setTextColor(View view, int color) {  
  42.         Class clockClass = view.getClass();  
  43.         try {  
  44.             Method method = clockClass.getMethod("setTextColor"int.class);              
  45.             method.invoke(view, color);           
  46.         } catch (NoSuchMethodException e) {  
  47.             e.printStackTrace();  
  48.         } catch (IllegalArgumentException e) {  
  49.             e.printStackTrace();  
  50.         } catch (IllegalAccessException e) {  
  51.             e.printStackTrace();  
  52.         } catch (InvocationTargetException e) {  
  53.             e.printStackTrace();  
  54.         }     
  55.     }     
  56.   
  57.     @Override  
  58.     public boolean onCreateOptionsMenu(Menu menu) {  
  59.         // Inflate the menu; this adds items to the action bar if it is present.  
  60.         getMenuInflater().inflate(R.menu.main, menu);  
  61.         return true;  
  62.     }  
  63.   
  64.     @Override  
  65.     public boolean onOptionsItemSelected(MenuItem item) {  
  66.         // Handle action bar item clicks here. The action bar will  
  67.         // automatically handle clicks on the Home/Up button, so long  
  68.         // as you specify a parent activity in AndroidManifest.xml.  
  69.         int id = item.getItemId();  
  70.         if (id == R.id.action_settings) {  
  71.             return true;  
  72.         }  
  73.         return super.onOptionsItemSelected(item);  
  74.     }  
  75. }  

五 过程优化:
1. 资源以id方式访问:
    最开始时打算将资源apk中的资源名称及ID通过反射机制读取出来, 记录在map中,访问资源时先用资源名称获取到ID,再通过ID获取到真正的资源, 后来觉得这样太影响宿主程序的编写了, 每个资源都需要用字符串来标识,从代码编写和资源管理来说都相当的不科学。既然资源apk包中的资源经过编译之后id已经固定下来并记录到资源apk的R.java当中, 我们为什么不能直接引用这个R.java中的id呢?  当然可以,而且不用拷贝到宿主工程中,而是直接引用资源apk中的R.java文件, 即使用工程的Build Path -> Link Source方式,具体如下:

右击ResApkTest工程, 选择Build Path -> Link Source, 在弹框中选中ResApk包中的Gen目录,把这个目录命名为ResApk_Gen 。点击OK。


此时就是在宿主工程ResApkTest中使用ResApk中R.java中的ID了, 引用方式可以看MainAcitvity中的代码, 如:
// 使用ID 加载Layout
activityMain = mResLoader.loadLayout(com.example.resapk.R.layout.activity_main);
View view = activityMain.findViewById( com.example.resapk.R.id.time_clock_2);


2. 扩展控件(view)的处理:
    项目开发工程中经常会用到自定义控件, 因为类加载器不同, 暂时没有在宿主工程中正常访问ResApk中加载出来的扩展控件的方法,原因在第六点解释,这里给出替代方案, 也是使用类的反射机制, 这里各个工程的依赖关系如下:


在ExtendView中有一个自定义控件Clock(完整代码请参考下载链接,此处不给出), Clock有个自己扩展的方法:
[java] view plain copy
  1. public void setTextColor(int color) {  
  2.         mColor = color;  
  3.  }  
在ResApkTest中无法直接使用此方法,需要用反射机制来调用, 这里封装一个函数用来实现这个调用,代码如下:
[java] view plain copy
  1. public void Clock_setTextColor(View view, int color) {  
  2.         Class clockClass = view.getClass();  
  3.         try {  
  4.             Method method = clockClass.getMethod("setTextColor"int.class);              
  5.             method.invoke(view, color);              
  6.         } catch (NoSuchMethodException e) {  
  7.             e.printStackTrace();  
  8.         } catch (IllegalArgumentException e) {  
  9.             e.printStackTrace();  
  10.         } catch (IllegalAccessException e) {  
  11.             e.printStackTrace();  
  12.         } catch (InvocationTargetException e) {  
  13.             e.printStackTrace();  
  14.         }     
  15.     }  
那么调用方式就是:
[java] view plain copy
  1. if(activityMain != null) {  
  2.             setContentView(activityMain);  
  3.             View view = activityMain.findViewById(  
  4.                     com.example.resapk.R.id.time_clock_2);  
  5.             view.setAlpha(0.5f);              
  6.             Clock_setTextColor(view, 0xff0000ff);              
  7.       }  
如果有很多扩展方法, 那么这样调用起来会有些繁琐, 尽量把这部分调用封装起来,不要影响到客户端代码的编写。

六 遇到的问题:
1. 在使用扩展控件(view)时, 调用ResApk包中的扩展view的扩展方法需要用反射机制来实现, 有没有可能像使用正常的方法一样来使用扩展view的扩展方法呢?
以下是我做的一些尝试,事实上,我是在尝试失败之后才使用反射机制来实现这个功能的。
想要正常调用扩展控件,那么ResApkTest应该要对ExtendView可见, 即以某种方式引用ExtendView.jar; 以下是我尝试的依赖关系:



修改MainActivity的onCreate函数为以下内容:

[java] view plain copy
  1. protected void onCreate(Bundle savedInstanceState) {  
  2.         super.onCreate(savedInstanceState);  
  3.         Log.i("MainActivity""ResApkTest");  
  4.         //setContentView(R.layout.activity_main);  
  5.           
  6.         mResLoader = new ResLoader(getApplicationContext());  
  7.           
  8.         // 加载资源apk包  
  9.         mResLoader.loadResApk("com.example.resapk");  
  10.           
  11.         // 使用字符串加载 Layout  
  12.         View activityMain = mResLoader.loadLayout("activity_main");      
  13.           
  14.         // 使用ID 加载Layout  
  15.         activityMain = mResLoader.loadLayout(com.example.resapk.R.layout.activity_main);        
  16.         View view = null;  
  17.         if(activityMain != null) {  
  18.             setContentView(activityMain);  
  19.             view = activityMain.findViewById(  
  20.                     com.example.resapk.R.id.time_clock_2);  
  21.             view.setAlpha(0.5f);              
  22.             Clock_setTextColor(view, 0xff0000ff);              
  23.         }          
  24.   
  25.         ClassLoader loader = getClassLoader();  
  26.         Log.i("Demo""ResApkTest.apk 默认的类加载器 "+loader);            
  27.             
  28.         Log.i("Demo""ResApkTest.apk 包中的Clock类加载器   "+Clock.class.getClassLoader());  
  29.         Log.i("Demo""ResApk.apk 包中的Clock类加载器  "+view.getClass().getClassLoader());  
  30.           
  31.         Log.i("Demo""ResApkTest.apk 包中的  Clock.class hashcode "+Clock.class.hashCode());  
  32.         Log.i("Demo""ResApk.apk 包中的  Clock.class hashcode "+view.getClass().hashCode());  
  33.   
  34.         Log.i("Demo""打印 ResApkTest 中类加载器的 parent");  
  35.         ClassLoader resApkTestLoader = Clock.class.getClassLoader();  
  36.         while (resApkTestLoader != null) {  
  37.             Log.i("Demo""ResApkTest parent Loader  " + resApkTestLoader);  
  38.             resApkTestLoader = resApkTestLoader.getParent();  
  39.         }            
  40.   
  41.         Log.i("Demo""打印 ResApk 中类加载器的 parent");  
  42.         ClassLoader resApkLoader = view.getClass().getClassLoader();  
  43.         while (resApkLoader != null) {  
  44.             Log.i("Demo""ResApk parent Loader  " + resApkLoader);  
  45.             resApkLoader = resApkLoader.getParent();  
  46.         }  
  47.           
  48.         Log.i("Demo""view = " + view);  
  49.         Clock clock = (Clock)view;          
  50.     }  

运行MainActivity, 得到以下打印信息和异常:

[plain] view plain copy
  1. 01-01 00:28:33.429: I/Demo(2808): ResApkTest.apk 默认的类加载器 dalvik.system.PathClassLoader[/data/app/com.example.resapktest-1.apk]  
  2. 01-01 00:28:33.429: I/Demo(2808): ResApkTest.apk 包中的Clock类加载器   dalvik.system.PathClassLoader[/data/app/com.example.resapktest-1.apk]  
  3. 01-01 00:28:33.429: I/Demo(2808): ResApk.apk 包中的Clock类加载器  dalvik.system.PathClassLoader[/data/app/com.example.resapk-2.apk]  
  4. 01-01 00:28:33.429: I/Demo(2808): ResApkTest.apk 包中的  Clock.class hashcode 1093584792  
  5. 01-01 00:28:33.429: I/Demo(2808): ResApk.apk 包中的  Clock.class hashcode 1093481360  
  6. 01-01 00:28:33.429: I/Demo(2808): 打印 ResApkTest 中类加载器的 parent  
  7. 01-01 00:28:33.429: I/Demo(2808): ResApkTest parent Loader  dalvik.system.PathClassLoader[/data/app/com.example.resapktest-1.apk]  
  8. 01-01 00:28:33.429: I/Demo(2808): ResApkTest parent Loader  java.lang.BootClassLoader@40c3c5c0  
  9. 01-01 00:28:33.429: I/Demo(2808): 打印 ResApk 中类加载器的 parent  
  10. 01-01 00:28:33.429: I/Demo(2808): ResApk parent Loader  dalvik.system.PathClassLoader[/data/app/com.example.resapk-2.apk]  
  11. 01-01 00:28:33.429: I/Demo(2808): ResApk parent Loader  java.lang.BootClassLoader@40c3c5c0  
  12. 01-01 00:28:33.429: I/Demo(2808): view = com.example.extendview.Clock@412d8700  
  13. 01-01 00:28:33.429: D/AndroidRuntime(2808): Shutting down VM  
  14. 01-01 00:28:33.429: W/dalvikvm(2808): threadid=1: thread exiting with uncaught exception (group=0x40c35300)  
  15. 01-01 00:28:33.439: E/AndroidRuntime(2808): FATAL EXCEPTION: main  
  16. 01-01 00:28:33.439: E/AndroidRuntime(2808): java.lang.RuntimeException: Unable to start activity ComponentInfo{com.example.resapktest/com.example.resapktest.MainActivity}: java.lang.ClassCastException: com.example.extendview.Clock cannot be cast to com.example.extendview.Clock  

开始很困惑, 为什么com.example.extendview.Clock cannot be cast to com.example.extendview.Clock ?明明是相同的类呀。后来寻根问底,发现是这的确是两个不同的类,它们的类对象的hash code 不一样:

01-01 00:28:33.429: I/Demo(2808): ResApkTest.apk 包中的  Clock.class hashcode 1093584792
01-01 00:28:33.429: I/Demo(2808): ResApk.apk 包中的  Clock.class hashcode 1093481360
它们的类加载器也不一样
01-01 00:28:33.429: I/Demo(2808): ResApkTest.apk 包中的Clock类加载器   dalvik.system.PathClassLoader[/data/app/com.example.resapktest-1.apk]
01-01 00:28:33.429: I/Demo(2808): ResApk.apk 包中的Clock类加载器  dalvik.system.PathClassLoader[/data/app/com.example.resapk-2.apk]
这些都说明它们不是同一个类。
回头看看 Context.createPackageContext 函数的注释:

Return a new Context object for the given application name. This Context is the same as what the named application gets when it is launched, containing the same resources and class loader. Each call to this method returns a new instance of a Context object; Context objects are not shared, however they share common state (Resources, ClassLoader, etc) so the Context instance itself is fairly lightweight.

也就是说Context.createPackageContext加载出来的Context还是跟说被加载的Application相关, 跟主调的Application不一样。

后来我尝试了很多方法来实现用ResApkTest中的类加载器来加载ResApk中的类, 发现都不能实现需要的功能, 其中还参考了这篇关于插件开发的博文:

http://blog.csdn.net/jiangwei0910410003/article/details/41384667

里面有涉及类加载器的说明, 讲得比较好, 读者可以去看看。

这个问题总结一下:

    涉及资源apk和宿主apk都使用到的扩展View, 因为2个apk的类加载器不同,加载出来的类对象也不同,因此从apk A中加载出来的ClassA类无法与从apk B中加载出来的ClassA前置类型转换。
    本文Demo 代码位于:
http://download.csdn.net/detail/romantic_energy/8793793
需要的朋友自己去下载。