换肤是什么?
通俗点来将就是修改View的属性。
就跟一个 setTextColor()
一样,区别只是在于换肤一次性操作的是多个View。
换肤的步骤
- 收集需要换肤的控件
- 加载皮肤包
- 替换资源
收集需要换肤的控件
对于批量修改View属性的操作,一个个收集肯定是不现实的,这个时候可以看下setContentView()
的实现。
其中有这么一段
if (mFactory2 != null) {
view = mFactory2.onCreateView(parent, name, context, attrs);
} else if (mFactory != null) {
view = mFactory.onCreateView(name, context, attrs);
} else {
view = null;
}
如果存在mFactory2
的话,那么view的创建过程就是由 mFactory2
的控制的,而Activity正好实现了对应的接口。
所以我们可以创建一个类 SkinActivity
,
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
//表示将创建view的工作交给当前这个Activity
LayoutInflater layoutInflater = LayoutInflater.from(this);
LayoutInflaterCompat.setFactory2(layoutInflater, this);
super.onCreate(savedInstanceState);
}
然后开始创建View
private CustomAppCompatViewInflater viewInflater;
@Override
public View onCreateView(@NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
if (openChangeSkin()) {
if (viewInflater == null) {
viewInflater = new CustomAppCompatViewInflater(context);
}
viewInflater.setName(name);
viewInflater.setAttrs(attrs);
View view = viewInflater.autoMatch();
if (view != null) {
return view;
}
}
return super.onCreateView(name, context, attrs);
}
自定义View加载器 CustomAppCompatViewInflater.java
/**
* 自定义控件加载器
* 这里的 AppCompatViewInflater是为了留个入口,对照源码的写法,可以不需要继承
*/
public final class CustomAppCompatViewInflater extends AppCompatViewInflater {
private String name; // 控件名
private Context context; // 上下文
private AttributeSet attrs; // 某控件对应所有属性
public CustomAppCompatViewInflater(@NonNull Context context) {
this.context = context;
}
public void setName(String name) {
this.name = name;
}
public void setAttrs(AttributeSet attrs) {
this.attrs = attrs;
}
/**
* 匹配控件,用自定义的控件替换,方便之后的换肤管理
*/
public View autoMatch() {
View view = null;
switch (name) {
case "LinearLayout":
view = new SkinnableLinearLayout(context, attrs);
break;
case "TextView":
view = new SkinnableTextView(context, attrs);
break;
case "ImageView":
view = new SkinnableImageView(context, attrs);
break;
case "Button":
view = new SkinnableButton(context, attrs);
break;
}
return view;
}
}
将所有的系统控件都替换成自定义的控件,便于统一管理。
只需要定义一个接口 ViewsMatch
public interface ViewsMatch {
/**
* 换肤函数
*/
void skinnableView();
}
接着所有自定义的替代控件都实现这个接口,就能做好统一管理了。
示例 SkinnableTextView.java
public class SkinnableTextView extends AppCompatTextView implements ViewsMatch {
private AttrsBean attrsBean;
public SkinnableTextView(Context context) {
this(context, null);
}
public SkinnableTextView(Context context, AttributeSet attrs) {
this(context, attrs, android.R.attr.textViewStyle);
}
public SkinnableTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
attrsBean = new AttrsBean();
// 根据自定义属性,匹配控件属性的类型集合,如:background + textColor
TypedArray typedArray = context.obtainStyledAttributes(attrs,
R.styleable.SkinnableTextView,
defStyleAttr, 0);
// 存储到临时JavaBean对象,这部分代码十分重要,需要保存控件属性的key,用于之后的替换操作
attrsBean.saveViewResource(typedArray, R.styleable.SkinnableTextView);
typedArray.recycle();
}
//具体换肤逻辑
@Override
public void skinnableView() {
// 根据自定义属性,获取styleable中的background属性
int key = R.styleable.SkinnableTextView
[R.styleable.SkinnableTextView_android_background];
// 根据styleable获取控件某属性的resourceId
int backgroundResourceId = attrsBean.getViewResource(key);
if (backgroundResourceId > 0) {
if (SkinManager.getInstance().loadSuccess()) {
Object skinResourceId = SkinManager.getInstance()
.getBackgroundOrSrc(backgroundResourceId);
if (skinResourceId instanceof Integer) {
int color = (int) skinResourceId;
setBackgroundColor(color);
} else {
Drawable drawable = (Drawable) skinResourceId;
setBackgroundDrawable(drawable);
}
} else {
Drawable drawable = ContextCompat.getDrawable
(getContext(), backgroundResourceId);
setBackgroundDrawable(drawable);
}
}
}
}
其他的自定义View也是类似的处理,这里就不贴代码了。
加载皮肤包
public void loaderSkinResource(String skinPath) {
if (TextUtils.isEmpty(skinPath)) return;
try {
//需要通过反射的方式调用对应的方法
AssetManager assetManager = AssetManager.class.newInstance();
Method method = assetManager.getClass().getDeclaredMethod("addAssetPath", String.class);
method.setAccessible(true);
method.invoke(assetManager, skinPath);
//创建皮肤包的资源管理器
skinResources = new Resources(assetManager, defaultResources.getDisplayMetrics(), defaultResources.getConfiguration());
skinPackageName = application.getPackageManager()
.getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES).packageName;
loadSkipSuccess = !TextUtils.isEmpty(skinPackageName);
} catch (Exception e) {
e.printStackTrace();
loadSkipSuccess = false;
}
}
这部分就是核心代码,还是通过AssetManager
来加载资源的,skinPath
是本地资源。在正常流程中还需要一个下载皮肤包的流程,这里为了方便解释就不加了。
使用AssetManager
的时候需要通过反射,调用一些不对外开放的函数 addAssetPath
。
loadSkipSuccess
变量用来标示资源是否加载成功,如果加载失败,就使用默认的皮肤资源兼容.
//根据传入的资源id,获取对于的resName和resType,再根据这两者和皮肤包的包名获取皮肤包下对应的资源id
private int getSkinResourceId(int resourceId) {
//外部皮肤加载失败,展示默认的
if (!loadSkipSuccess) return resourceId;
//根据默认的资源id获取资源文件的name和type,再通过这两者获取皮肤包的资源id。
//前提是默认的和外的皮肤包的资源文件保持命名和类型一致
String resName = defaultResources.getResourceEntryName(resourceId);
String resType = defaultResources.getResourceTypeName(resourceId);
int skinResourceId = skinResources.getIdentifier(resName, resType, skinPackageName);
loadSkipSuccess = skinResourceId != 0;
return skinResourceId == 0 ? resourceId : skinResourceId;
}
替换资源
能拿得到所有的控件,也能加载需要的皮肤包资源,最后就是替换资源了。
其实也很简单,就是遍历所有的自定义控件,同时调用他们的skinnableView
函数。
//入口
protected void switchSkin(String skinPath){
SkinManager.getInstance().loaderSkinResource(skinPath);
applySkinForView(getWindow().getDecorView());
}
//遍历替换资源,然后大功告成.
protected void applySkinForView(View view) {
if (view instanceof ViewsMatch) {
ViewsMatch viewsMatch = (ViewsMatch) view;
viewsMatch.skinnableView();
}
if (view instanceof ViewGroup) {
ViewGroup parent = (ViewGroup) view;
int childCount = parent.getChildCount();
for (int i = 0; i < childCount; i++) {
applySkinForView(parent.getChildAt(i));
}
}
}
皮肤包的生成
- 新建一个项目,资源文件的名字需要保持一致
- build.gradle 生成一个apk文件,将其放到服务端或者手机本地