详细的使用方式请参考 ThemeSkinning,本文的核心是学习换肤实现原理,不会详细介绍这个库如何使用,该库中关于夜间模式的内容和改变字体相关内容也暂时不去关心。
应用的assets目录下的字体文件和皮肤包如下图所示。
开始分析
首先定义用于观察者接口,需要换肤的观察者可以实现这个接口
public interface ISkinUpdate {
void onThemeUpdate();
}
默认情况下支持的换肤属性在AttrFactory中定义了。
public class AttrFactory {
private static HashMap<String, SkinAttr> sSupportAttr = new HashMap<>();
static {
sSupportAttr.put("background", new BackgroundAttr());
sSupportAttr.put("textColor", new TextColorAttr());
sSupportAttr.put("src", new ImageViewSrcAttr());
}
//...
}
首先应用的Application要继承SkinBaseApplication,在启动的时候加载皮肤文件。
public class SkinBaseApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
SkinManager.getInstance().init(this);
}
}
SkinManager的init方法
public void init(Context ctx) {
context = ctx.getApplicationContext();
//设置字体
TypefaceUtils.CURRENT_TYPEFACE = TypefaceUtils.getTypeface(context);
//注释1处
setUpSkinFile(context);
//...
String skin = SkinConfig.getCustomSkinPath(context);
if (SkinConfig.isDefaultSkin(context)) {//使用默认皮肤,则不需要加载皮肤文件
return;
}
//注释2处,加载皮肤文件
loadSkin(skin, null);
}
}
在注释1处,调用setUpSkinFile方法,将assets/skin目录下的皮肤复制到指定目录
private void setUpSkinFile(Context context) {
try {
String[] skinFiles = context.getAssets().list(SkinConfig.SKIN_DIR_NAME);
for (String fileName : skinFiles) {
File file = new File(SkinFileUtils.getSkinDir(context), fileName);
if (!file.exists()) {//如果文件不存在,则拷贝
SkinFileUtils.copySkinAssetsToDir(context, fileName,
SkinFileUtils.getSkinDir(context));
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
注释2处,加载皮肤文件,使用AsyncTask来实现。loadSkin方法部分代码
@Override
protected Resources doInBackground(String... params) {//后台加载
String skinPkgPath = SkinFileUtils.getSkinDir(context) + File.separator + params[0];
// skinPackagePath:/storage/emulated/0/Android/data/solid.ren.themeskinning/cache/skin/theme-20180417.skin
SkinL.i(TAG, "skinPackagePath:" + skinPkgPath);
File file = new File(skinPkgPath);
if (!file.exists()) {
return null;
}
/**
* 把路径在
* /storage/emulated/0/Android/data/solid.ren.themeskinning/cache/skin/xxx.skin
* 的文件作为asset的一部分
*/
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);
/**
* 根据asset来获取Resources
*/
Resources superRes = context.getResources();
Resources skinResource = ResourcesCompat.getResources(assetManager, superRes.getDisplayMetrics(), superRes.getConfiguration());
//保存当前的皮肤路径
SkinConfig.saveSkinPath(context, params[0]);
isDefaultSkin = false;
//返回新的Resources
return skinResource;
}
@Override
protected void onPostExecute(Resources result) {
mResources = result;
if (mResources != null) {
//...
//注释1处,通知变更皮肤
notifySkinUpdate();
}
}
上面代码主题流程就是把应用cache/skin/目录下的皮肤包作为asset的一部分,然后
从asset中加载皮肤包中的Resources,这个Resources包含了皮肤包中的所有资源信息。然后通知变更皮肤。
我们看一下注释1处,调用了notifySkinUpdate方法。
@Override
public void notifySkinUpdate() {
if (mSkinObservers != null) {//如果存在需要换肤的观察者,就通知观察者
for (ISkinUpdate observer : mSkinObservers) {
observer.onThemeUpdate();
}
}
}
这个方法是在ISkinLoader接口中定义的。
//用来添加、删除、通知需要换肤的观察者
public interface ISkinLoader {
//观察者的类型是ISkinUpdate
void attach(ISkinUpdate observer);
void detach(ISkinUpdate observer);
void notifySkinUpdate();
}
SkinBaseActivity实现了ISkinUpdate接口,需要实现换肤的功能可以直接继承SkinBaseActivity。
SkinBaseActivity部分代码
//SkinInflaterFactory
private SkinInflaterFactory mSkinInflaterFactory;
@Override
protected void onCreate(Bundle savedInstanceState) {
mSkinInflaterFactory = new SkinInflaterFactory(this);
//设置setFactory2为mSkinInflaterFactory,使用mSkinInflaterFactory来创建view
LayoutInflaterCompat.setFactory2(getLayoutInflater(), mSkinInflaterFactory);
super.onCreate(savedInstanceState);
changeStatusColor();
}
@Override
public void onThemeUpdate() {
mSkinInflaterFactory.applySkin();
}
接下来看SkinInflaterFactory这个类是实现换肤的关键。我们创建View靠的就是这个类。
SkinInflaterFactory的四个参数的onCreateView方法
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
//注释1处,
boolean isSkinEnable = attrs.getAttributeBooleanValue(SkinConfig.NAMESPACE, SkinConfig.ATTR_SKIN_ENABLE, false);
AppCompatDelegate delegate = mAppCompatActivity.getDelegate();
//注释2处
View view = delegate.createView(parent, name, context, attrs);
//...
//支持换肤
if (isSkinEnable || SkinConfig.isGlobalSkinApply()) {
if (view == null) {//注释3处
view = ViewProducer.createViewFromTag(context, name, attrs);
}
if (view == null) {
return null;
}
//注释4处
parseSkinAttr(context, attrs, view);
}
return view;
}
在注释1处,如果我们需要为布局中的某些view提供换肤功能的时候,可以这样
这样,只有tv_text_color
会实现换肤操作。
注释2处和注释3处先来创建view。我们不去研究其中的细节。
注释4处应用皮肤属性。
private void parseSkinAttr(Context context, AttributeSet attrs, View view) {
//保存所有的换肤属性
List<SkinAttr> viewAttrs = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
//注释1处如果给view设置了style
if ("style".equals(attrName)) {
//获取 textColor ,background 和 src属性值
int[] skinAttrs = new int[]{android.R.attr.textColor, android.R.attr.background,android.R.attr.src};
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, skinAttrs, 0, 0);
int textColorId = a.getResourceId(0, -1);
int backgroundId = a.getResourceId(1, -1);
int srcId = a.getResourceId(2, -1);
if (textColorId != -1) {
String entryName = context.getResources().getResourceEntryName(textColorId);
String typeName = context.getResources().getResourceTypeName(textColorId);
SkinAttr skinAttr = AttrFactory.get("textColor", textColorId, entryName, typeName);
//添加color属性
if (skinAttr != null) {
viewAttrs.add(skinAttr);
}
}
if (backgroundId != -1) {
String entryName = context.getResources().getResourceEntryName(backgroundId);
String typeName = context.getResources().getResourceTypeName(backgroundId);
SkinAttr skinAttr = AttrFactory.get("background", backgroundId, entryName, typeName);
//保存background属性
if (skinAttr != null) {
viewAttrs.add(skinAttr);
}
}
if (srcId != -1) {
String entryName = context.getResources().getResourceEntryName(srcId);
String typeName = context.getResources().getResourceTypeName(srcId);
SkinAttr skinAttr = AttrFactory.get("src", srcId, entryName, typeName);
SkinL.w(TAG, " srcId in style is supported:" + "\n" +
" resource id:" + backgroundId + "\n" +
" attrName:" + attrName + "\n" +
" attrValue:" + attrValue + "\n" +
" entryName:" + entryName + "\n" +
" typeName:" + typeName);
if (skinAttr != null) {
viewAttrs.add(skinAttr);
}
}
a.recycle();
continue;
}
//注释2处,换肤支持并以“@”开头的属性值,例如@color/red
if (AttrFactory.isSupportedAttr(attrName) && attrValue.startsWith("@")) {
try {
// //获取resource id,去掉开头的"@"
int id = Integer.parseInt(attrValue.substring(1));
if (id == 0) {
continue;
}
//entryName,eg:text_color_selector
String entryName = context.getResources().getResourceEntryName(id);
//typeName,eg:color、drawable
String typeName = context.getResources().getResourceTypeName(id);
SkinAttr mSkinAttr = AttrFactory.get(attrName, id, entryName, typeName);
//添加换肤属性
if (mSkinAttr != null) {
viewAttrs.add(mSkinAttr);
}
} catch (NumberFormatException e) {
SkinL.e(TAG, e.toString());
}
}
}
//注释3处,如果换肤属性不为null,构建SkinItem
if (!SkinListUtils.isEmpty(viewAttrs)) {
SkinItem skinItem = new SkinItem();
//注释4处,注意,我们这里把view保存了起来,
skinItem.view = view;
skinItem.attrs = viewAttrs;
mSkinItemMap.put(skinItem.view, skinItem);
if (SkinManager.getInstance().isExternalSkin() ||
SkinManager.getInstance().isNightMode()) {
//注释5处,换肤
skinItem.apply();
}
}
}
注释1处,获取style中的 textColor , background ,src属性值
<item name="android:textColor">@color/item_tv_title_color</item>
<item name="android:background">@drawable/ic_homepage_car</item>
<item name="android:src">@drawable/ic_homepage_car</item>
//注释2处,获取换肤支持并以“@”开头的属性值
android:textColor="@color/text_color_black"
android:background="@color/colorPrimary"
android:src="@drawable/ic_homepage_car"
注释3处,如果换肤属性不为null,构建SkinItem。
注释4处,注意注意,我们这里把view保存到了mSkinItemMap中,用于后来的动态换肤。并且我们要注意在合适的时机清除这些view避免内存泄漏。
注释5处,换肤。
public class SkinItem {
public View view;
public List<SkinAttr> attrs;
public SkinItem() {
attrs = new ArrayList<>();
}
public void apply() {
if (SkinListUtils.isEmpty(attrs)) {
return;
}
for (SkinAttr at : attrs) {
at.apply(view);
}
}
//...
}
SkinAttr是一个抽象类,默认的实现类如下
BackgroundAttr
public class BackgroundAttr extends SkinAttr {
@Override
protected void applySkin(View view) {
if (isColor()) {
int color = SkinResourcesUtils.getColor(attrValueRefId);
view.setBackgroundColor(color);
} else if (isDrawable()) {
Drawable bg = SkinResourcesUtils.getDrawable(attrValueRefId);
view.setBackgroundDrawable(bg);
}
}
//...
}
ImageViewSrcAttr
public class ImageViewSrcAttr extends SkinAttr {
@Override
protected void applySkin(View view) {
if (view instanceof ImageView) {
ImageView iv = (ImageView) view;
if (isDrawable()) {
iv.setImageDrawable(SkinResourcesUtils.getDrawable(attrValueRefId));
} else if (isColor()) {
iv.setImageDrawable(new ColorDrawable(SkinResourcesUtils.getColor(attrValueRefId)));
}
}
}
}
TextColorAttr
public class TextColorAttr extends SkinAttr {
@Override
protected void applySkin(View view) {
if (view instanceof TextView) {
TextView tv = (TextView) view;
if (isColor()) {
tv.setTextColor(SkinResourcesUtils.getColorStateList(attrValueRefId));
}
}
}
//...
}
这个三个属性内部都是使用SkinResourcesUtils来获取属性值。
我们看一下SkinResourcesUtils的getColor方法。
public static int getColor(int resId) {
//调用SkinManager的getColor方法。
return SkinManager.getInstance().getColor(resId);
}
SkinManager的getColor方法。
public int getColor(int resId) {
//获取原始color
int originColor = ContextCompat.getColor(context, resId);
if (mResources == null || isDefaultSkin) {//如果不需要换肤,就直接返回
return originColor;
}
//下面这些操作获取皮肤包里的color
String resName = context.getResources().getResourceEntryName(resId);
int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
Log.d(TAG, "\ngetColor: resId=" + resId + "\nresName=" + resName + "\ntrueResId=" + trueResId + "\nskinPackageName=" + skinPackageName);
int trueColor;
if (trueResId == 0) {
trueColor = originColor;
} else {
trueColor = mResources.getColor(trueResId);
}
return trueColor;
}
SkinInflaterFactory的applySkin方法
public void applySkin() {
//如果mSkinItemMap为空则返回。
if (mSkinItemMap.isEmpty()) {
return;
}
for (View view : mSkinItemMap.keySet()) {
if (view == null) {
continue;
}
mSkinItemMap.get(view).apply();
}
}
自定义换肤属性
该库默认支持的换肤属性只有 background
,textColor
,src
,如果需要其他的换肤属性,就需要自己定义了。
那么如何自定义呢?举个例子
TabLayout大家应该都用过吧。它下面会有一个指示器,当我们换肤的时候也希望这个指示器的颜色也跟着更改。
- 新建TabLayoutIndicatorAttr继承SkinAttr
public class TabLayoutIndicatorAttr extends SkinAttr {
@Override
protected void applySkin(View view) {
if (view instanceof TabLayout) {
TabLayout tl = (TabLayout) view;
if (isColor()) {//表示属性值类型是color类型
//获取颜色
int color = SkinResourcesUtils.getColor(attrValueRefId);
//设置指示器的颜色
tl.setSelectedTabIndicatorColor(color);
}
}
}
}
- 然后将TabLayoutIndicatorAttr添加到AttrFactory中
SkinConfig.addSupportAttr("tabIndicatorColor", new TabLayoutIndicatorAttr());
AttrFactory.addSupportAttr(attrName, skinAttr);
public static void addSupportAttr(String attrName, SkinAttr skinAttr) {
sSupportAttr.put(attrName, skinAttr);
}
这个时候SkinInflaterFactory的parseSkinAttr方法中
if (AttrFactory.isSupportedAttr(attrName) && attrValue.startsWith("@")) {
//...
}
这时候就会处理app:tabIndicatorColor="@color/colorPrimaryDark"
属性,并添加到viewAttrs
中。这样就可以了。
动态添加view支持换肤
先看定义的接口,需要动态添加view并支持换肤可以实现这个接口
public interface IDynamicNewView {
//添加多个换肤属性
void dynamicAddView(View view, List<DynamicAttr> pDAttrs);
//添加一个换肤属性
void dynamicAddView(View view, String attrName, int attrValueResId);
}
SkinBaseFragment和SkinBaseActivity都实现了这个接口,而SkinBaseFragment内部还是通过它的宿主Activity来添加View的。
public class SkinBaseFragment extends Fragment implements IDynamicNewView {
private IDynamicNewView mIDynamicNewView;
@Override
public void onAttach(Context context) {
super.onAttach(context);
try {
//为mIDynamicNewView赋值
mIDynamicNewView = (IDynamicNewView) context;
} catch (ClassCastException e) {
mIDynamicNewView = null;
}
}
@Override
public final void dynamicAddView(View view, List<DynamicAttr> pDAttrs) {
if (mIDynamicNewView == null) {
throw new RuntimeException("IDynamicNewView should be implements !");
} else {
mIDynamicNewView.dynamicAddView(view, pDAttrs);
}
}
@Override
public final void dynamicAddView(View view, String attrName, int attrValueResId) {
mIDynamicNewView.dynamicAddView(view, attrName, attrValueResId);
}
//...
}
新建一个DynamicAddFragment来做测试
public class DynamicAddFragment extends SkinBaseFragment {
private LinearLayout ll_dynamic_view;
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_dynamic_add, container, false);
ll_dynamic_view = view.findViewById(R.id.ll_dynamic_view);
createDynamicView();
return view;
}
private void createDynamicView() {
ImageView imageView = new ImageView(getContext());
imageView.setBackgroundResource(R.mipmap.mipmap_img);
imageView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
dynamicAddView(imageView, "background", R.mipmap.mipmap_img);
ll_dynamic_view.addView(imageView);
List<DynamicAttr> attrList = new ArrayList<>(2);
attrList.add(new DynamicAttr("textColor", R.color.item_tv_title_color));
attrList.add(new DynamicAttr("background", R.color.item_tv_title_background));
for (int i = 0; i < 10; i++) {
TextView textView1 = new TextView(getContext());
textView1.setText("我是动态创建的TextView" + i + ",我也可以换肤");
textView1.setTextColor(getResources().getColor(R.color.item_tv_title_color));
ViewGroup.MarginLayoutParams params = new ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
params.setMargins(20, 20, 20, 20);
textView1.setLayoutParams(params);
//添加多个属性
dynamicAddView(textView1, attrList);
ll_dynamic_view.addView(textView1);
dynamicAddFontView(textView1);
}
}
//...
}
SkinBaseActivity部分代码
@Override
public void dynamicAddView(View view, List<DynamicAttr> pDAttrs) {
mSkinInflaterFactory.dynamicAddSkinEnableView(this, view, pDAttrs);
}
@Override
public void dynamicAddView(View view, String attrName, int attrValueResId) {
mSkinInflaterFactory.dynamicAddSkinEnableView(this, view, attrName, attrValueResId);
}
可以看到在SkinBaseActivity内部是通过mSkinInflaterFactory来动态添加支持换肤的属性的view。
我们看一下
public void dynamicAddSkinEnableView(Context context, View view, List<DynamicAttr> attrs) {
//存放换肤属性列表
List<SkinAttr> viewAttrs = new ArrayList<>();
SkinItem skinItem = new SkinItem();
//保存view
skinItem.view = view;
for (DynamicAttr dAttr : attrs) {
int id = dAttr.refResId;
String entryName = context.getResources().getResourceEntryName(id);
String typeName = context.getResources().getResourceTypeName(id);
SkinAttr mSkinAttr = AttrFactory.get(dAttr.attrName, id, entryName, typeName);
//换肤属性
viewAttrs.add(mSkinAttr);
}
skinItem.attrs = viewAttrs;
//应用属性
skinItem.apply();
//添加到mSkinItemMap中去。
addSkinView(skinItem);
}
注意要在适当的时机移除动态添加的view
public class SkinBaseFragment extends Fragment implements IDynamicNewView {
@Override
public void onDestroyView() {
removeAllView(getView());
super.onDestroyView();
}
protected void removeAllView(View v) {
if (v instanceof ViewGroup) {
ViewGroup viewGroup = (ViewGroup) v;
for (int i = 0; i < viewGroup.getChildCount(); i++) {
removeAllView(viewGroup.getChildAt(i));
}
removeViewInSkinInflaterFactory(v);
} else {
removeViewInSkinInflaterFactory(v);
}
}
private void removeViewInSkinInflaterFactory(View v) {
if (getSkinInflaterFactory() != null) {
//此方法用于Activity中Fragment销毁的时候,移除Fragment中的View
getSkinInflaterFactory().removeSkinView(v);
}
}
}
SkinInflaterFactory的removeSkinView方法
public void removeSkinView(View view) {
SkinL.i(TAG, "removeSkinView:" + view);
SkinItem skinItem = mSkinItemMap.remove(view);
if (skinItem != null) {
SkinL.w(TAG, "removeSkinView from mSkinItemMap:" + skinItem.view);
}
}
总结一下换肤的主要流程
- 创建一个Android phone&Tablet Module类型的皮肤包。这个包里面只有颜色和图片等资源文件,没有类文件。这些资源文件的名字要和app的资源文件名字一样。
- 将这个皮肤包打包成apk,然后重命名为.skin结尾的文件(例如theme-20171126.skin)放入app的assets文件夹下。
- 在应用启动的时候,将skin文件复制到app的缓存目录下面(例如/storage/emulated/0/Android/data/solid.ren.themeskinning/cache/skin/xxx.skin)。
- 创建一个全新的AssetManager,然后将解压后的文件路径添加到
AssetManager。
AssetManager assetManager = AssetManager.class.newInstance();
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.invoke(assetManager, skinPkgPath);
使用新创建的AssetManager来创建一个全新的Resources。这个Resources里面只有和换肤相关的资源文件。
在换肤资源的时候,从这个全新的Resources里面取资源。例如获取一个color
public int getColor(int resId) {
//获取原始color
int originColor = ContextCompat.getColor(context, resId);
if(mResources == null || isDefaultSkin) { //如果不需要换肤,就直接返回
return originColor;
}
//下面这些操作获取皮肤包里的color
String resName = context.getResources().getResourceEntryName(resId);
int trueResId = mResources.getIdentifier(resName, "color", skinPackageName);
Log.d(TAG, "\ngetColor: resId=" + resId + "\nresName=" + resName + "\ntrueResId=" + trueResId +
"\nskinPackageName=" + skinPackageName);
int trueColor;
if(trueResId == 0) {
trueColor = originColor;
} else {
trueColor = mResources.getColor(trueResId);
}
return trueColor;
}
- 自定义SkinInflaterFactory用来创建view。在创建view的过程中动态保存可以换肤的属性集合,并把换肤属性集合和view实例保存在一个map中。
- 当用户调用换肤方法的时候,则遍历换肤属性集合,将属性值设置给view。
- 在适当的时机比如Activity finish或者fragment ondestroyView的时候清除map里面保存的换肤属性集合和view实例。
参考链接