android之换肤原理解读

来源:互联网 发布:php soap json 编辑:程序博客网 时间:2024/05/16 04:30

如下是解读demo的链接,自行下载

https://github.com/fengjundev/Android-Skin-Loader

由于是开源的,而且对于想了解换肤功能的童鞋这个demo实在是通俗易懂,原理也很清晰,所以忍不住想要记录一下,

题外话:附上一篇换肤技术总结的博客,这是一篇动态换肤与本地换肤(传统的theme)换肤优劣势的详细比较,需要的童鞋,可以详细拜读,至少知道来源

http://blog.zhaiyifan.cn/2015/09/10/Android%E6%8D%A2%E8%82%A4%E6%8A%80%E6%9C%AF%E6%80%BB%E7%BB%93/


换肤功能用于公司的运营是常有的需求,毕竟皮肤对于app来说还是比较重要的;这个对于开发者来说并不关心, 我们只关心其技术原理。

一、换肤功能:

       解读的是一篇动态加载资源从而达到换肤 的效果,这也是换肤的一种潮流,行业上得换肤跟这个demo基本都大同小异,比较性能来说这个方案也是比较值得推荐

       既然是动态的,那就支持网络加载的资源、本地资源应该都要支持,而且是无缝隙,高性能的,废话不多说,先来看看设计者的思路


二、思路:

      前提:这demo只是用本地讲解 所以将要换肤的资源搞成apk,重新命名后缀为.skin,并保存在指定的sd目录中,方便加载,开发者也可通过网上下载保存到指定的sd目,方便支持线上换肤

       1、当点击换肤时,使用AsyncTask加载已保存在sd目录中的apk,并通过AssetManager反射添加资源路径的方法将apk的资源加载进去,然后通过new Resource将Assetmanager管理器注册,得到一个全新的资源管理者AssetManager,通过这个管理者与app原生的资源管理者作为区分,当加载资源的时候,就可以通过资源管理者的资源作为换肤,所以需要护肤时将这个新对象resource将其赋值给全局的resource,通过Resource对象实现换肤;若切换回默认app的皮肤时,就将默认app生成的resource赋值给resource,在其获取资源时,通过resource来控制是取得哪一套资源,从而实现换肤。

     2、当点击换肤,获取到resource之后,这里通过观察者模式去通知当前活动的页面进行换肤,而不是放在onResume实时监测,使用观察者就需要activity实现接口,这里通过在BaseActivity实现统一接口ISkinUpdate,统一进行注册,达到方便管理,方便换肤

     3、需要换肤就得知道哪些view需要换肤,通过设置inflate中的一个工厂Factory,这个工厂是用来创建一个view,有点类似hook,只要这个Factory返回一个view就不会再进行解析我们xml设置的view,每创建一个view之前factory都会执行一次,所以在这里通过设置自己自定义的实现接口Factory的SkinInflateFactory,就可以在其读取layout的xml文件生成view之前会执行onCreateView,通过hook这个点,即生成xml文件的view又可以满足我们所要读取需要换肤的view,并且判断当前view是否需要换肤,需要则直接设置相应的color或drawable。到此基本就这个思路

   

三、代码走读

   接下来一起看看代码走读:我的阅读习惯是从点击切换皮肤开始,然后一层层剥皮,需要用到的属性在哪里初始化,就跳转到哪里看看初始化的地方;读者在读demo的时候根据自己的习惯吧,这里就姑且按我的思维方式走。

  换肤嘛,当然是找到点击换肤的事件咯;

  找到如下类及其点击换肤响应事件的方法

public class SettingActivity extends BaseActivity {   /**    * Put this skin file on the root of sdcard    * eg:    * /mnt/sdcard/BlackFantacy.skin    */   private static final String SKIN_NAME = "BlackFantacy.skin";   private static final String SKIN_DIR = Environment         .getExternalStorageDirectory() + File.separator + SKIN_NAME;   private void initView() {      setNightSkinBtn.setOnClickListener(new OnClickListener() {         @Override         public void onClick(View v) {            onSkinSetClick();         }      });      setOfficalSkinBtn.setOnClickListener(new OnClickListener() {         @Override         public void onClick(View v) {            onSkinResetClick();         }      });   }   private void onSkinSetClick() {      if(!isOfficalSelected) return;      File skin = new File(SKIN_DIR);      if(skin == null || !skin.exists()){         Toast.makeText(getApplicationContext(), "请检查" + SKIN_DIR + "是否存在", Toast.LENGTH_SHORT).show();         return;      }      SkinManager.getInstance().load(skin.getAbsolutePath(),            new ILoaderListener() {               @Override               public void onStart() {                  L.e("startloadSkin");               }               @Override               public void onSuccess() {                  L.e("loadSkinSuccess");                  Toast.makeText(getApplicationContext(), "切换成功", Toast.LENGTH_SHORT).show();                  setNightSkinBtn.setText("黑色幻想(当前)");                  setOfficalSkinBtn.setText("官方默认");                  isOfficalSelected = false;               }               @Override               public void onFailed() {                  L.e("loadSkinFail");                  Toast.makeText(getApplicationContext(), "切换失败", Toast.LENGTH_SHORT).show();               }            });   }}

然后在这个设置类里边,根据响应事件,我们看到了加载皮肤的调用处:

SkinManager.getInstance().load(skin.getAbsolutePath(),
当然路径是如下:先不管,将demo的BlackFanTancy.skin放到sd卡就行

private static final String SKIN_NAME = "BlackFantacy.skin";   private static final String SKIN_DIR = Environment         .getExternalStorageDirectory() + File.separator + SKIN_NAME;

接下来看看SkinManager这个单例皮肤管理类:其中load的方法,进入如下:

public void load(String skinPackagePath, final ILoaderListener callback) {new AsyncTask<String, Void, Resources>() {protected void onPreExecute() {if (callback != null) {callback.onStart();}};@Overrideprotected Resources doInBackground(String... params) {try {if (params.length == 1) {//加载皮肤包,并且将包的路径加到资源管理器中AssetManagerString skinPkgPath = params[0];File file = new File(skinPkgPath);if (file == null || !file.exists()) {//Log.d(TAG, "!file.exists() skinPkgPath= " + skinPkgPath);Log.d(TAG, "!file.exists() = " + file.exists());return null;}Log.d(TAG,"skinPkgPath = "+skinPkgPath);PackageManager mPm = context.getPackageManager();PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);skinPackageName = mInfo.packageName;AssetManager assetManager = AssetManager.class.newInstance();Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);addAssetPath.invoke(assetManager, skinPkgPath);Resources superRes = context.getResources();Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration());SkinConfig.saveSkinPath(context, skinPkgPath);skinPath = skinPkgPath;isDefaultSkin = false;return skinResource;}return null;} catch (Exception e) {e.printStackTrace();return null;}};protected void onPostExecute(Resources result) {mResources = result;if (mResources != null) {if (callback != null) callback.onSuccess();notifySkinUpdate();}else{isDefaultSkin = true;if (callback != null) callback.onFailed();}};}.execute(skinPackagePath);}

这个方法传入了一个回调接口ILoadListner,以及皮肤绝对路径,随后通过AsyncTask异步执行加载路径上的skin资源

接下来看看其中三个方法

首先、onStart():没做什么工作,就是在加载之前判断callBack是否为空做一些初始化,我们这里没做什么初始化,只是打印而已

再次:doInBackground:重点的都在这个方法里边了:这个方法异步加载了资源,通过新创建Assetmanager与Resource建立对新加载的资源skin的管理,完成后通过SkinConfig.saveSkinPath();保存当前皮肤路径,以备下次再次打开app时默认加载皮肤还是上一次选中的。

最后:onPostExecut回到主线程处理更新皮肤;这里将新创建的resource对象保存到全局,由于callBack不为null,然后通过回调接口callBack.onSuccess()修改ui,以及调用notifySkinUpdate();猜测这个方法就是进行皮肤更新的方法。

到了这里这个线路基本完事儿;

细心好奇的你疑问肯定有两点:

1)、context是在哪儿赋值的

2)、notifySkinUpdate()到底做了什么工作


先来看看context到底在哪儿赋值的,当然是在当前类找了:你会发现下边这个方法

public void init(Context ctx){   context = ctx.getApplicationContext();}
这个很自然的看看其在哪儿调用的,快捷键ctrl+alt+H,只有一处方法调用,那就是Application,这是应用启动就初始化的如下

public class SkinApplication extends Application {      public void onCreate() {      super.onCreate();            initSkinLoader();   }   /**    * Must call init first    */   private void initSkinLoader() {      SkinManager.getInstance().init(this);      SkinManager.getInstance().load();   }}

看到这里,你会意外发现,咦,这里也有个load()那我们就会疑问这个load()是干啥用的,初始化的时候为何要调用它,这个load是在我们进入app时调用的,那就有理由猜想如下:

 1)、 上次登录app时我们还没切换过皮肤,还是默认皮肤,这个load是怎么工作的

 2)、上次登录app时我们切换皮肤了,那么这个load又是怎么工作的

带着这两个疑问,我们进入load()方法一探究竟呗

public void load(){   String skin = SkinConfig.getCustomSkinPath(context);   Log.d(TAG, "skin = " + skin);   load(skin, null);}
首先从sharePreference获取皮肤路径:分两步走

 <一>、没切换过皮肤,则skin得到的是默认

public  static final String     DEFALT_SKIN          =  "cn_feng_skin_default";

 <二>、切换了皮肤,则获取的是切换皮肤的路径:

然后往下走,神奇的发现调用了load(skin,null);  这个方法不就是前边我们分析过的吗,接下来我们再次看看这个方法,毕竟参数不一样了嘛:

/** * Load resources from apk in asyc task * @param skinPackagePath path of skin apk * @param callback callback to notify user */public void load(String skinPackagePath, final ILoaderListener callback) {new AsyncTask<String, Void, Resources>() {protected void onPreExecute() {if (callback != null) {callback.onStart();}};@Overrideprotected Resources doInBackground(String... params) {try {if (params.length == 1) {//加载皮肤包,并且将包的路径加到资源管理器中AssetManagerString skinPkgPath = params[0];File file = new File(skinPkgPath);if (file == null || !file.exists()) {//Log.d(TAG, "!file.exists() skinPkgPath= " + skinPkgPath);Log.d(TAG, "!file.exists() = " + file.exists());return null;}Log.d(TAG,"skinPkgPath = "+skinPkgPath);PackageManager mPm = context.getPackageManager();PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);skinPackageName = mInfo.packageName;AssetManager assetManager = AssetManager.class.newInstance();Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);addAssetPath.invoke(assetManager, skinPkgPath);Resources superRes = context.getResources();Resources skinResource = new Resources(assetManager,superRes.getDisplayMetrics(),superRes.getConfiguration());SkinConfig.saveSkinPath(context, skinPkgPath);skinPath = skinPkgPath;isDefaultSkin = false;return skinResource;}return null;} catch (Exception e) {e.printStackTrace();return null;}};protected void onPostExecute(Resources result) {mResources = result;if (mResources != null) {if (callback != null) callback.onSuccess();notifySkinUpdate();}else{isDefaultSkin = true;if (callback != null) callback.onFailed();}};}.execute(skinPackagePath);}

当然还是分两步走:由于callback为null,所以不在走callback回调,这里不做分析了

   1)默认皮肤时,skin为默认的

public  static final String     DEFALT_SKIN          =  "cn_feng_skin_default";

 由于这个是不存在的,所以当执行到doInBackground():

File file = new File(skinPkgPath);if (file == null || !file.exists()) {//Log.d(TAG, "!file.exists() skinPkgPath= " + skinPkgPath);Log.d(TAG, "!file.exists() = " + file.exists());return null;}
在这里会直接返回,不在加载皮肤;用的就是app中layout默认的颜色背景;到这里这个默认的分析完毕

  2)切换了皮肤:前边也分析保存了皮肤在sd中的绝对路径:所以这里获取的skin路径上一次皮肤的路径

       再往下走,除了callback不调用外,最后还是调用notifySkinUpdate()方法;


那么这个方法到底做了什么,肯定就是我们的换肤方法了;好奇的你肯定会进入notifySkinUpdate()方法一探究竟,那我们就一起看看:

@Overridepublic void notifySkinUpdate() {if(skinObservers == null) return;for(ISkinUpdate observer : skinObservers){observer.onThemeUpdate();}}

当打开app的时候,不管曾经是否换肤,由于skinObservers为null,所以直接返回

那么我们就要看看这个观察者skinObservers在哪儿初始化,哪儿订阅的了;

我们会发现初始化的方法、订阅的地方以及取消订阅的地方如下:

@Overridepublic void attach(ISkinUpdate observer) {if(skinObservers == null){skinObservers = new ArrayList<ISkinUpdate>();}if(!skinObservers.contains(skinObservers)){skinObservers.add(observer);}}@Overridepublic void detach(ISkinUpdate observer) {if(skinObservers == null) return;if(skinObservers.contains(observer)){skinObservers.remove(observer);}}

这一看这里都是重写的方法,细心的你会发现notifySkinUpdat()方法也是重写的,那么我们就看看它实现的接口

public class SkinManager implements ISkinLoader{
然后再看看attach(ISkinUpdate observer)和detach(ISkinUpdate observer)在哪儿调用,在查看之前我们有理由猜想,BaseActivity肯定是作为观察者实现了ISkinUpdate,已实时监测换肤功能;

接下来查看attach(ISkinUpdate observer)和detach(ISkinUpdate observer)调用:发现确实是BaseActivity和BaseFragementActivity两个类中调用:如下

@Overrideprotected void onResume() {   super.onResume();   SkinManager.getInstance().attach(this);}@Overrideprotected void onDestroy() {   super.onDestroy();   SkinManager.getInstance().detach(this);   mSkinInflaterFactory.clean();}

传的是this,那么她们肯定实现了接口

public interface ISkinUpdate {   void onThemeUpdate();}

自然而然,我们就来看看onThemeUdapte做了什么,它就能更换皮肤了?

@Overridepublic void onThemeUpdate() {   if(!isResponseOnSkinChanging){      return;   }   mSkinInflaterFactory.applySkin();}
isResponseOnSkinChanging
这个默认是true,也没地方改变它的默认值,我们先不管,直接跳到下面那行

   mSkinInflaterFactory.applySkin();
还是看看mSkinInflaterFactory到底是什么鬼,在哪儿初始化的,查看知道在oncreate方法中:

private SkinInflaterFactory mSkinInflaterFactory;@Overrideprotected void onCreate(Bundle savedInstanceState) {   super.onCreate(savedInstanceState);   mSkinInflaterFactory = new SkinInflaterFactory();   getLayoutInflater().setFactory(mSkinInflaterFactory);}
这到底是什么鬼,每次进入activity都要设置这个Factory,这里我们部队Factory开讲,下次再进行对它的源码深究,我们只要知道它是一个生产view的工厂类,在inflate的时候,通过每遍历一个layout的每个组件view之前都会检测Factory是否为null,若不为null,则会调用onCreaterView();

扩展:Factory是否要生成view,如果生成view,则不会在创建layout遍历的那个组件,所以通过这个Factory也可以更改返回显示的view:比如layout布局其中一个组件是Imageview,而通过Factory可以生成TextView代替ImageView;

接下来看看SkinInFlaterFactory这个类的onCreateView,这个类肯定实现了Factory接口,否则setFactory()的,所以每次在inFlate时由于factory不为空,肯定都会检测是否要调用onCreateView;所以setFactory必须设置在setContentView()方法之前;因为setContentView实际上也是调用inflate;

那就看看其oncreateView()咯:

@Overridepublic View onCreateView(String name, Context context, AttributeSet attrs) {// if this is NOT enable to be skined , simplly skip it//在xml的节点中设置,设置为true表示是需要换肤的view,否则跳过这个view,因为这个view不需要换肤boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);        if (!isSkinEnable){        return null;        }View view = createView(context, name, attrs);if (view == null){return null;}parseSkinAttr(context, attrs, view);return view;}

首先:就来看看这个isSkinEnable是什么:这个是我们在layout为组件设置的一个标志,标着这个组件是需要换肤的,如果不需要换肤的组件我们就不用往下走

其次:假设在组件中设置属性skin:enable=true,则就会往下执行 ,执行createView()以及parseSkinAttr(),以下分析这连个个方法:

  

private View createView(Context context, String name, AttributeSet attrs) {View view = null;try {Log.d("SkinInflaterFactory","name = "+name);if (-1 == name.indexOf('.')){//-1则不是自定义的viewif ("View".equals(name)) {view = LayoutInflater.from(context).createView(name, "android.view.", attrs);} if (view == null) {view = LayoutInflater.from(context).createView(name, "android.widget.", attrs);} if (view == null) {view = LayoutInflater.from(context).createView(name, "android.webkit.", attrs);} }else {            view = LayoutInflater.from(context).createView(name, null, attrs);        }L.i("about to create " + name);} catch (Exception e) { L.e("error while create 【" + name + "】 : " + e.getMessage());view = null;}return view;}
这里返回一个创建view,主要是判断:

这个组件view是否是用原生的还是自定义的:name是组件的名字:如TextView、ImageView,所示自定义的,则得到的是全名(包名+类名)


parseSkinAttr():

private void parseSkinAttr(Context context, AttributeSet attrs, View view) {List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>();for (int i = 0; i < attrs.getAttributeCount(); i++){String attrName = attrs.getAttributeName(i);String attrValue = attrs.getAttributeValue(i);Log.d(TAG,"attrName = "+attrName);//属性name比如layout_width或者自定义属性name比如本次用的enable 值是trueLog.d(TAG,"attrValue = "+attrValue);//属性的值if(!AttrFactory.isSupportedAttr(attrName)){continue;}    if(attrValue.startsWith("@")){try {int id = Integer.parseInt(attrValue.substring(1));String entryName = context.getResources().getResourceEntryName(id);String typeName = context.getResources().getResourceTypeName(id);SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);Log.d(TAG,"id = "+id);Log.d(TAG,"getResourceEntryName = "+entryName);//color的name或drawable的nameLog.d(TAG,"getResourceTypeName = "+typeName);//比如:color,drawableif (mSkinAttr != null) {viewAttrs.add(mSkinAttr);}} catch (NumberFormatException e) {e.printStackTrace();} catch (NotFoundException e) {e.printStackTrace();}    }}if(!ListUtils.isEmpty(viewAttrs)){SkinItem skinItem = new SkinItem();skinItem.view = view;skinItem.attrs = viewAttrs;mSkinItems.add(skinItem);if (SkinManager.getInstance().isExternalSkin()) {//这里是每次进入activity后fragment的时候都要判断是否需要换肤skinItem.apply();}}}

这个方法是解析组件的所有属性,并将得到的可以换肤的所有属性color或drawable属性id和属性名保存到一个viewAttrs,然后将viewAttrs和view所有相关值保存skinItem,随后将SkinItem缓存到mSkinItem集合中,接下来是判断当前是否需要换肤;假设是需要的,则我们来看看它是如何执行的

public void apply(){if(ListUtils.isEmpty(attrs)){return;}for(SkinAttr at : attrs){at.apply(view);}}
由于parseSKinAttr解析式已经将attrs设置,所不会为空,所以会执行for循环
查看at.apply(View),那么我们发现SkinAttr是个抽象类,抽象方法为apply(view),于是回过头看看parseSKinAttr这个方法在哪里调用的:惊奇的发现:

SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
用的竟让是个工厂方式,进去一瞧咯:

public static SkinAttr get(String attrName, int attrValueRefId, String attrValueRefName, String typeName){      SkinAttr mSkinAttr = null;      if(BACKGROUND.equals(attrName)){       mSkinAttr = new BackgroundAttr();   }else if(TEXT_COLOR.equals(attrName)){       mSkinAttr = new TextColorAttr();   }else if(LIST_SELECTOR.equals(attrName)){       mSkinAttr = new ListSelectorAttr();   }else if(DIVIDER.equals(attrName)){       mSkinAttr = new DividerAttr();   }else{      return null;   }      mSkinAttr.attrName = attrName;   mSkinAttr.attrValueRefId = attrValueRefId;   mSkinAttr.attrValueRefName = attrValueRefName;   mSkinAttr.attrValueTypeName = typeName;   return mSkinAttr;}
很容易就知道,这是生成什么属性,支持哪些换肤,主要有四个,所以只拿第一个BackgroundAttr类作为分析,其它原理一样:
public class BackgroundAttr extends SkinAttr {   @Override   public void apply(View view) {            if(RES_TYPE_NAME_COLOR.equals(attrValueTypeName)){         view.setBackgroundColor(SkinManager.getInstance().getColor(attrValueRefId));         Log.i("attr", "_________________________________________________________");         Log.i("attr", "apply as color");      }else if(RES_TYPE_NAME_DRAWABLE.equals(attrValueTypeName)){         Drawable bg = SkinManager.getInstance().getDrawable(attrValueRefId);         view.setBackground(bg);         Log.i("attr", "_________________________________________________________");         Log.i("attr", "apply as drawable");         Log.i("attr", "bg.toString()  " + bg.toString());                  Log.i("attr", this.attrValueRefName + " 是否可变换状态? : " + bg.isStateful());      }   }}
原来换肤最终的真相在这里:

view.setBackgroundColor(SkinManager.getInstance().getColor(attrValueRefId))
以及

 Drawable bg = SkinManager.getInstance().getDrawable(attrValueRefId);         view.setBackground(bg);
这里边的

SkinManager.getInstance().getColor(attrValueRefId)
SkinManager.getInstance().getDrawable(attrValueRefId)

做了什么:进去瞧瞧就知道了:这里选择一个来分析吧,其它都一样的

public int getColor(int resId){   int originColor = context.getResources().getColor(resId);   if(mResources == null || isDefaultSkin){      return originColor;   }   //通过默认的resId获取默认颜色的资源名,通过名字查找皮肤包一致的名字再获取生成的dstId   String resName = context.getResources().getResourceEntryName(resId);   int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);   int trueColor = 0;   try{      trueColor = mResources.getColor(trueResId);   }catch(NotFoundException e){      e.printStackTrace();      trueColor = originColor;   }   return trueColor;}

首先:通过app的context获取originColor,app的默认的颜色,若mResoutces=null(没切换皮肤)或isDefaultSkin=true(显示的是默认的),则直接返回显示默认color

否则:通过默认的resId获取默认颜色的资源名,通过名字查找皮肤包一致的名字再获取生成的dstId,得到dstId这里就是trueResId,这个id就是从新的Resource资源管理者获取的,就是换肤的皮肤颜色id,这样就能获得了皮肤,直接返回设置颜色就可以换肤成功了;

到这里终于结束了!!!,理解有误的地方,敬请指正!!!










3 0
原创粉丝点击