赞
踩
换肤功能,是很多公司项目中的重点功能,仅仅会用那是远远不够的,需要对换肤有全面整体的把握,了解底层实现原理,才能在后面的开发中举一反三,事半功倍。
对于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的不同前缀来加载不同类来实现。
以上拓展均为临时发散,本篇不再继续深究,后续会再出单独篇幅深入展开研究…
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。