首页 > 代码库 > android之换肤原理解读
android之换肤原理解读
如下是解读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(); } }; @Override protected Resources doInBackground(String... params) { try { if (params.length == 1) {//加载皮肤包,并且将包的路径加到资源管理器中AssetManager String 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(); } }; @Override protected Resources doInBackground(String... params) { try { if (params.length == 1) {//加载皮肤包,并且将包的路径加到资源管理器中AssetManager String 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()方法一探究竟,那我们就一起看看:
@Override public void notifySkinUpdate() { if(skinObservers == null) return; for(ISkinUpdate observer : skinObservers){ observer.onThemeUpdate(); } }
当打开app的时候,不管曾经是否换肤,由于skinObservers为null,所以直接返回
那么我们就要看看这个观察者skinObservers在哪儿初始化,哪儿订阅的了;
我们会发现初始化的方法、订阅的地方以及取消订阅的地方如下:
@Override public void attach(ISkinUpdate observer) { if(skinObservers == null){ skinObservers = new ArrayList<ISkinUpdate>(); } if(!skinObservers.contains(skinObservers)){ skinObservers.add(observer); } } @Override public 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两个类中调用:如下
@Override protected void onResume() { super.onResume(); SkinManager.getInstance().attach(this); } @Override protected void onDestroy() { super.onDestroy(); SkinManager.getInstance().detach(this); mSkinInflaterFactory.clean(); }传的是this,那么她们肯定实现了接口
public interface ISkinUpdate { void onThemeUpdate(); }
自然而然,我们就来看看onThemeUdapte做了什么,它就能更换皮肤了?
@Override public void onThemeUpdate() { if(!isResponseOnSkinChanging){ return; } mSkinInflaterFactory.applySkin(); }
isResponseOnSkinChanging
这个默认是true,也没地方改变它的默认值,我们先不管,直接跳到下面那行mSkinInflaterFactory.applySkin();还是看看mSkinInflaterFactory到底是什么鬼,在哪儿初始化的,查看知道在oncreate方法中:
private SkinInflaterFactory mSkinInflaterFactory; @Override protected 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()咯:
@Override public 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则不是自定义的view if ("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 = http://www.mamicode.com/attrs.getAttributeValue(i);>
这个方法是解析组件的所有属性,并将得到的可以换肤的所有属性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,这样就能获得了皮肤,直接返回设置颜色就可以换肤成功了;
到这里终于结束了!!!,理解有误的地方,敬请指正!!!
android之换肤原理解读