android之换肤原理解读

Posted 小钟视野

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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 = attrs.getAttributeValue(i);
			Log.d(TAG,"attrName = "+attrName);//属性name比如layout_width或者自定义属性name比如本次用的enable 值是true
			Log.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的name
					Log.d(TAG,"getResourceTypeName = "+typeName);//比如:color,drawable
					if (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,这样就能获得了皮肤,直接返回设置颜色就可以换肤成功了;

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




































以上是关于android之换肤原理解读的主要内容,如果未能解决你的问题,请参考以下文章

Javascript之换肤

Android一键换肤原理简述

Android一键换肤原理简述

Android一键换肤原理简述

今日头条Android岗面试题:说说Android动态换肤实现原理

Android 开发多线程之换个视角理解