换肤功能,是很多公司项目中的重点功能,仅仅会用那是远远不够的,需要对换肤有全面整体的把握,了解底层实现原理,才能在后面的开发中举一反三,事半功倍。
对于Android项目来说,皮肤是什么,皮肤就是UI界面,换皮肤无非就是字体颜色、背景图等这些用户看得见的界面。所以换皮肤最为重要的就是换Android工程下res下面的资源文件,也即如下图所在的资源:
Google在Android10(API 29)就已经开始支持深色模式,自定义适配方案是使用资源限定符,就像横向布局适配是添加layout-land资源,高密度资源适配是添加drawable-hdpi资源,其自定义深色模式的适配方案则是在res-night下定义一套资源
在该深色模式资源文件下,所用资源命名和正常资源相同,例如相同的drawable/color/style,那么当系统切换为深色模式时,系统会自动识别并使用res-night下面的资源文件,从而切换为我们想要的深色效果。
换肤功能就类似Google的深色模式,要实现各种换肤功能我们只需要替换对应的资源文件即可,让view布局重新加载新的资源文件。
首先我们通过上一篇文章了解下 LayoutInflater Factory,通过关于Factory的介绍,我们得出结论:自定义Factory,然后通过setFactory方法设置给系统,那么在系统创建View时则可以进行自定义样式的干预。接下来我们来看看本文研究框架的核心实现原理。
框架 Android-Skin-Loader,官方的版本太旧了,经过改造适配了最新的AndroidX控件以及能正常生成皮肤包,下载地址 Android-Skin-Loader,其工程结构如图
工程中android-skin-loader-sample是一个使用例子,android-skin-loader-skin是一套皮肤包,android-skin-loader-lib为支持换肤的library,下面我们就来一一介绍了。
在需要换肤的组件上配置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" />
public class SkinApplication extends Application {
public void onCreate() {
super.onCreate();
initSkinLoader();
}
private void initSkinLoader() {
SkinManager.getInstance().init(this);
SkinManager.getInstance().load();
}
}
Activity继承android-skin-loader-lib中的Base组件
public class MyActivity extends BaseActivity{
}
此module为皮肤包组件,里面是没有任何代码的皮肤资源,用于替换主工程中的资源文件,文件目录如下:
该目录下的资源文件命名需和替换的资源名称保持一样,则才能通过相同的资源名称查到皮肤资源进行替换。
该module为application,可编译出来apk作为皮肤包,可修改后缀名为.skin文件,作为皮肤包放到主工程目录下或者进行网络下载加载。
换肤的核心逻辑,我们分为三步走:
第一步:加载换肤包到内存中;
第二步:收集所有换肤的View;
第三步:用换肤包中的资源替换View的原有资源。
换肤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替换什么资源呢,这就需要下一步探究了。
当我们收集到所有需要换肤的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方法去实际操作换肤。
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换肤。
在项目中有时候我们不得不在代码中动态的设置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进行换肤。
综上所述,换肤框架的整个核心流程如下:
(1)加载换肤皮肤包到内存中;
(2)收集所有换肤的View;
(3)用换肤的资源替换View的原有资源。
在这里我们可以通过另外一种方案实现换肤,通过上文我们明白最终执行换肤功能的是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的不同前缀来加载不同类来实现。
以上拓展均为临时发散,本篇不再继续深究,后续会再出单独篇幅深入展开研究…