Android-Skin-Loader换肤框架剖析

一. 简介

换肤功能,是很多公司项目中的重点功能,仅仅会用那是远远不够的,需要对换肤有全面整体的把握,了解底层实现原理,才能在后面的开发中举一反三,事半功倍。

1. 基本原理

对于Android项目来说,皮肤是什么,皮肤就是UI界面,换皮肤无非就是字体颜色、背景图等这些用户看得见的界面。所以换皮肤最为重要的就是换Android工程下res下面的资源文件,也即如下图所在的资源:
在这里插入图片描述
Google在Android10(API 29)就已经开始支持深色模式,自定义适配方案是使用资源限定符,就像横向布局适配是添加layout-land资源,高密度资源适配是添加drawable-hdpi资源,其自定义深色模式的适配方案则是在res-night下定义一套资源
在这里插入图片描述
在该深色模式资源文件下,所用资源命名和正常资源相同,例如相同的drawable/color/style,那么当系统切换为深色模式时,系统会自动识别并使用res-night下面的资源文件,从而切换为我们想要的深色效果。
换肤功能就类似Google的深色模式,要实现各种换肤功能我们只需要替换对应的资源文件即可,让view布局重新加载新的资源文件。

2. LayoutInflater

首先我们通过上一篇文章了解下 LayoutInflater Factory,通过关于Factory的介绍,我们得出结论:自定义Factory,然后通过setFactory方法设置给系统,那么在系统创建View时则可以进行自定义样式的干预。接下来我们来看看本文研究框架的核心实现原理。

二. 框架Android-Skin-Loader解析

框架 Android-Skin-Loader,官方的版本太旧了,经过改造适配了最新的AndroidX控件以及能正常生成皮肤包,下载地址 Android-Skin-Loader,其工程结构如图
在这里插入图片描述
工程中android-skin-loader-sample是一个使用例子,android-skin-loader-skin是一套皮肤包,android-skin-loader-lib为支持换肤的library,下面我们就来一一介绍了。

1. android-skin-loader-sample使用教程

1.1 xml布局配置

在需要换肤的组件上配置skin:enable=“true”

	<Button
        android:layout_width="match_parent"
        android:layout_height="100dp"
        android:background="@drawable/news_item_selector"
        android:textColor="@color/color_sel_skin_btn_text"
        skin:enable="true" />

	<TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textColor="@color/news_item_text_color_selector"
        android:textSize="20sp"
        skin:enable="true" />

1.2 Application初始化

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

1.3 Activity继承

Activity继承android-skin-loader-lib中的Base组件

public class MyActivity extends BaseActivity{
}

2. android-skin-loader-skin

此module为皮肤包组件,里面是没有任何代码的皮肤资源,用于替换主工程中的资源文件,文件目录如下:

该目录下的资源文件命名需和替换的资源名称保持一样,则才能通过相同的资源名称查到皮肤资源进行替换。
该module为application,可编译出来apk作为皮肤包,可修改后缀名为.skin文件,作为皮肤包放到主工程目录下或者进行网络下载加载。

3. android-skin-loader-lib

换肤的核心逻辑,我们分为三步走:
第一步:加载换肤包到内存中;
第二步:收集所有换肤的View;
第三步:用换肤包中的资源替换View的原有资源。

3.1 SkinManager:加载换肤包

换肤library中最为重要的类是SkinManager,是一个皮肤管理核心类,控制着换肤最为核心的逻辑。在SkinManager类中mResources为引用着换肤包资源的对象,需要换肤的时候从该资源中获取数据,那么该mResources怎么获取到的呢?
Application初始化的时候调用了SkinManager.getInstance().load(),跟进源码

/**
	 * Load resources from apk in asyc task
	 * @param skinPackagePath path of skin apk
	 * @param callback callback to notify user
	 */
	@SuppressLint("StaticFieldLeak")
	public void load(String skinPackagePath, final ILoaderListener callback) {
		
		new AsyncTask<String, Void, Resources>() {
			@Override
			protected Resources doInBackground(String... params) {
				try {
					if (params.length == 1) {
						String skinPkgPath = params[0]; //皮肤包路径
						
						File file = new File(skinPkgPath); 
						if(file == null || !file.exists()){
							return null;
						}
						
						PackageManager mPm = context.getPackageManager();
						//通过加载皮肤包路径可获得PackageInfo信息
						PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES);
						skinPackageName = mInfo.packageName; //皮肤包的包名

						AssetManager assetManager = AssetManager.class.newInstance();
						//addAssetPath声明了@UnsupportedAppUsge,所以反射获取AssetManager addAssetPath的方法
						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(); //实现是通知观察者更新,后面第4小节分析
				}else{
					isDefaultSkin = true;
					if (callback != null) callback.onFailed();
				}
			};
		}.execute(skinPackagePath);
	}

其中重要方法PackageInfo mInfo = mPm.getPackageArchiveInfo(skinPkgPath, PackageManager.GET_ACTIVITIES)是根据皮肤包路径利用PackageManager类获取到了PackageInfo信息。最终生成Resources对象,该对象所持有的资源就是皮肤路径下面的资源。
既然皮肤包的资源有了,换肤的时候直接设置给对应的View即可,但是需要给哪些View替换什么资源呢,这就需要下一步探究了。

3.2 SkinInflaterFactory:收集所有可替换皮肤View类

当我们收集到所有需要换肤的view后,如果需要换肤我们就遍历该集合一一换肤即可,那么如何收集xml中所有需要换肤的view?

1、自定义Factory,并设置给LayoutInflater

public class BaseActivity extends Activity {
    private SkinInflaterFactory mSkinInflaterFactory;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mSkinInflaterFactory = new SkinInflaterFactory();
        getLayoutInflater().setFactory(mSkinInflaterFactory);
    }
    ......

2、实现SkinInflaterFactory

	/**
	 * Store the view item that need skin changing in the activity
	 * 全局变量mSkinItems集合存储了activity中需要换肤的view
	 */
	private List<SkinItem> mSkinItems = new ArrayList<SkinItem>();
	
	@Override
	public View onCreateView(String name, Context context, AttributeSet attrs) {
		// if this is NOT enable to be skined , simplly skip it 
		//根据该view是否声明了 skin:enable="true" 
		boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
		//声明了enable="true",则启用皮肤替换规则,才会有换肤功能
        if (!isSkinEnable){
        		return null;
        }
	    //内部实现view = LayoutInflater.from(context).createView(name, "", attrs);
		View view = createView(context, name, attrs);
		if (view == null){
			return null;
		}
		parseSkinAttr(context, attrs, view);
		return view;
	}

	/**
	 * Collect skin able tag such as background , textColor and so on
	 * 收集View所有标签支持换肤的属性,例如View中的background/textColor都要收集
	 */
	private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
		List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>();
		//android:backgroud="@2131034169"
		for (int i = 0; i < attrs.getAttributeCount(); i++){ //循环变量view的所有标签属性
			//android:background=@2131034169  android:attrName=attrValue
			String attrName = attrs.getAttributeName(i); //例:backgroud/textColor
			String attrValue = attrs.getAttributeValue(i); //例:@2131034169
			//针对不支持的属性,则不添加到换肤集合里面,支持的例如:backgroud/textColor
			if(!AttrFactory.isSupportedAttr(attrName)){
				continue;
			}
			// attrValue = @2131034169
		    if(attrValue.startsWith("@")){
				try {
					int id = Integer.parseInt(attrValue.substring(1)); // id = 2131034169
					//@drawable/my_icon  @typeName/entryName
					String entryName = context.getResources().getResourceEntryName(id); //通过id获取资源名称,例:my_icon
					String typeName = context.getResources().getResourceTypeName(id); //通过id获取资源属性,例:drawable
					//通过id属性构建皮肤属性对象,后续换肤的实际操作者则由各个SkinAttr完成,例:如下TextColorAttr
					SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName); 
					if (mSkinAttr != null) {
						viewAttrs.add(mSkinAttr);
					}
				} catch (Exception e) {
					e.printStackTrace();
				}
		    }
		}
		
		if(!ListUtils.isEmpty(viewAttrs)){
			SkinItem skinItem = new SkinItem(); //一个view对应一个SkinItem
			skinItem.view = view;
			skinItem.attrs = viewAttrs; //一个view对应多个属性标签
			mSkinItems.add(skinItem); //将需要换肤的所有view都添加到mSkinItems集合中
			if(SkinManager.getInstance().isExternalSkin()){ //外部资源则更新view界面
				skinItem.apply(); //如下SkinItem中apply()
			}
		}
	}

以上就收集到了所有换肤的view,最终都存储到了List mSkinItems的集合里面。后面如果有换肤的需求的话,就直接遍历该集合里面的所有SkinItem,拿到存储的SkinAttr,调用对应的apply方法去实际操作换肤。

3.3 notifySkinUpdate:换肤替换

BaseActivity中的onResume方法中注册了对换肤的监听

	@Override
	protected void onResume() {
		super.onResume();
		SkinManager.getInstance().attach(this); //添加观察,使用见第1小节
	}
	
	@Override
    public void onThemeUpdate() {
        ......
        mSkinInflaterFactory.applySkin();
    }

这里面将Activity添加到观察者集合里面,当换肤调用SkinManager.load(skinPath)方法,则生成mResources后会触发集合分发通知notifySkinUpdate()方法,当分发到BaseActivity中onThemeUpdate后,再用我们自定义的Factory去触发在换肤集合mSkinItems里面的View,整个调用链如下:
换肤——>SkinManager.load(skinPath)——>SkinManager.notifySkinUpdate——>BaseActivity.onThemeUpdate——>mFactory.applySkin

启动换肤功能,启用换肤方法如下

public void applySkin(){
	if(ListUtils.isEmpty(mSkinItems)){
		return;
	}
	for(SkinItem si : mSkinItems){
		if(si.view == null){
			continue;
		}
		si.apply();
	}
}

public class SkinItem {	
	public List<SkinAttr> attrs;	
	public void apply(){
		......
		for(SkinAttr attr : attrs){
			attr.apply(view); //调用SkinAttr的apply方法,如TextColorAttr中apply
		}
	}
}

public class TextColorAttr extends SkinAttr {
	@Override
	public void apply(View view) { //换肤的最终实际执行方法
		if(view instanceof TextView){
			TextView tv = (TextView)view;
			if(RES_TYPE_NAME_COLOR.equals(attrValueTypeName)){
				tv.setTextColor(SkinManager.getInstance().getColor(attrValueRefId));
			}
		}
	}
}

public int getColor(int resId){ //resId为当前工程的资源id
		int originColor = context.getResources().getColor(resId);
		if(mResources == null || isDefaultSkin){ //无外部资源或是默认皮肤的话,直接取当前工程的资源
			return originColor;
		}
		//通过资源id获取到资源名称,例:my_color
		String resName = context.getResources().getResourceEntryName(resId); 
		//通过资源名称my_color又从皮肤资源获取资源id,例:2130745655
		int trueResId = mResources.getIdentifier(resName, "color", skinPackageName); 
		int trueColor = 0;
		
		try{
			//从mResources皮肤资源中通过皮肤id获取到资源色值
			trueColor = mResources.getColor(trueResId); 
		}catch(NotFoundException e){
			e.printStackTrace();
			trueColor = originColor;
		}
		return trueColor;
	}

如上通过 mSkinItems——>SkinItem——>SkinAttr——>mResources.getColor——>setTextColor调用链,设置View换肤。

3.4 代码动态换肤

在项目中有时候我们不得不在代码中动态的设置background/textColor,针对这种情况就无法在xml文件中申明生效,所以框架中在类SkinInflaterFactory中也定义了代码中动态换肤的方法,如下:

//android:background="@drawable/my_icon" @typeName/entryName
public void dynamicAddSkinEnableView(Context context, View view, String attrName, int attrValueResId){	
	int id = attrValueResId;
	String entryName = context.getResources().getResourceEntryName(id); //例如:my_icon
	String typeName = context.getResources().getResourceTypeName(id); //例如:drawable
	SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
	SkinItem skinItem = new SkinItem();
	skinItem.view = view;
	List<SkinAttr> viewAttrs = new ArrayList<SkinAttr>();
	viewAttrs.add(mSkinAttr);
	skinItem.attrs = viewAttrs;
	addSkinView(skinItem);
}
//添加到换肤集合里面
public void addSkinView(SkinItem item){
	mSkinItems.add(item);
}

需要将对应的换肤View包装成SkinItem添加到mSkinItems换肤集合中,在后续的applySkin()方法中才能从集合中拿到对应View进行换肤。

4. 总结

综上所述,换肤框架的整个核心流程如下:
(1)加载换肤皮肤包到内存中;
(2)收集所有换肤的View;
(3)用换肤的资源替换View的原有资源。

5. 拓展

拓展一

在这里我们可以通过另外一种方案实现换肤,通过上文我们明白最终执行换肤功能的是SkinItem中SkinAttr,在我们自定义的TextColorAttr/BackgroundAttr中我们是通过SkinManager.getColor(int resId)来获取到资源的,在getColor方法中获取资源的方法是mResources.getIdentifier(resName, “color”, skinPackageName),那么我们就可以传递不同的resName来获取到不同的资源,例如:假设我们原来的资源是
在这里插入图片描述
那么我们可以通过mResources.getIdentifier(resName+"_skin1", “color”, skinPackageName)方法获取到如下路径的资源。
在这里插入图片描述
如此来实现资源的替换。
由于此种方案是需要将所有皮肤资源嵌入到工程中,会在一定程度上增加APK包的大小,我们就不在这里展开讨论了。

拓展二

SkinInflaterFactory中createView方法实现核心逻辑为view = LayoutInflater.from(context).createView(name, “prefix”, attrs),那么如果要替换所有系统的控件,是不是可以通过设置View的不同前缀来加载不同类来实现。

以上拓展均为临时发散,本篇不再继续深究,后续会再出单独篇幅深入展开研究…

  • 2
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值