根据上文中所分析的思路,我们来具体实现:
- 首先,创建我们的activity,并且重写
Factory2
的方法。 - 然后,自定义我们的
CustomAppcompatInflater
继承AppcompatInflater
。本来我们应该接下来重写他的onCreate()
方法的,但是他是final,所以我们就自定义一个方法,功能和它一致就可以了,因为最后我们需要的是创建具体的控件的子类,所以这对我们没什么影响。 - 最后,创建我们具体需要改变颜色的控件
CustomButton
继承MaterialButton
,上文也说了,一定注意继承的是该控件的最终子类,否者不会支持MaterialButton
。 - 该创建类就三个,有这三个类我们就可以实现加载布局过程中拦截
Button
的属性进行自定义操作。 - 接下来我们仿照系统兼容包处理方法,在
acitivity
的重写Factory2
方法中创建自定义的CustomAppcompatInflater
对象,然后返回自定义的方法,创建view
,自定义方法中拿着获取到的控件name
和attributes
创建出我们的自定义控件,接下来上代码:
attrs.xml
<!-- Button控件继承TextView,此处parent语法通过,但无效果,不像style.xml -->
<declare-styleable name="CustomButton">
<attr name="android:background" />
<attr name="android:textColor" />
<!-- 字体属性 -->
</declare-styleable>
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
if (openChangeSkin() && !ignoreView(name)) {
if (viewInflater == null) {
viewInflater = new CustomAppCompatViewInflater(context);
}
viewInflater.setName(name);
viewInflater.setAttrs(attrs);
return viewInflater.autoMatch();
}
return super.onCreateView(parent, name, context, attrs);
}
/**
* 自定义控件加载器(可以考虑该类不被继承)
*/
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;
}
/**
* @return 自动匹配控件名,并初始化控件对象
*/
public View autoMatch() {
View view = null;
switch (name) {
case BUTTON:
view = new CustomButton(context, attrs);
this.verifyNotNull(view, name);
break;
}
return view;
}
/**
* 校验控件不为空(源码方法,由于private修饰,只能复制过来了。为了代码健壮,可有可无)
*
* @param view 被校验控件,如:AppCompatTextView extends TextView(v7兼容包,兼容是重点!!!)
* @param name 控件名,如:"ImageView"
*/
private void verifyNotNull(View view, String name) {
if (view == null) {
throw new IllegalStateException(this.getClass().getName() + " asked to inflate view for <" + name + ">, but returned null");
}
}
}
/**
* 继承TextView兼容包,9.0源码中也是如此
* 参考:AppCompatViewInflater.java
* 86行 + 138行 + 206行
*/
public class CustomButton extends MaterialButton implements ViewsMatch {
private AttrsBean attrsBean;
public CustomButton(Context context) {
this(context, null);
}
public CustomButton(Context context, AttributeSet attrs) {
this(context, attrs, R.attr.buttonStyle);
}
public CustomButton(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
attrsBean = new AttrsBean();
// 根据自定义属性,匹配控件属性的类型集合,如:background + textColor
TypedArray typedArray = context.obtainStyledAttributes(attrs,
R.styleable.CustomButton,
defStyleAttr, 0);
// 存储到临时JavaBean对象
attrsBean.saveViewResource(typedArray, R.styleable.CustomButton);
// 这一句回收非常重要!obtainStyledAttributes()有语法提示!!
typedArray.recycle();
}
@Override
public void skinnableView() {
// 根据自定义属性,获取styleable中的background属性
int key = R.styleable.CustomButton[R.styleable.CustomButton_android_background];
// 根据自定义属性,获取styleable中的textColor属性
key = R.styleable.CustomButton[R.styleable.CustomButton_android_textColor];
int textColorResourceId = attrsBean.getViewResource(key);
if (textColorResourceId > 0) {
if (SkinManager.getInstance().isDefaultSkin()) {
ColorStateList color = ContextCompat.getColorStateList(getContext(), textColorResourceId);
setTextColor(color);
} else {
ColorStateList color = SkinManager.getInstance().getColorStateList(textColorResourceId);
setTextColor(color);
}
}
}
}
以上代码,在每次主题切换的时候遍历控件调用skinnableView()方法,该方法中判断是否存在主题资源文件,根据资源id获取宿主或者主题包里面的对应颜色值设置颜色。
加载主题包
直接上代码,这个个人觉得没太大必要细讲,一看就能懂,而且代码都注释很清楚,值得注意的是里面三个位置的TODO注释:
public class SkinManager {
private static SkinManager instance;
private Application application;
private Resources appResources; // 用于加载app内置资源
private Resources skinResources; // 用于加载皮肤包资源
private String skinPackageName = ""; // 皮肤包资源所在包名(注:皮肤包不在app内,也不限包名)
private boolean isDefaultSkin = true; // 应用默认皮肤(app内置)
private static final String ADD_ASSET_PATH = "addAssetPath"; // 方法名
private Map<String, SkinCache> cacheSkin;
private SkinManager(Application application) {
this.application = application;
appResources = application.getResources();
cacheSkin = new HashMap<>();
}
/**
* 单例方法,目的是初始化app内置资源(越早越好,用户的操作可能是:换肤后的第2次冷启动)
*/
public static void init(Application application) {
if (instance == null) {
synchronized (SkinManager.class) {
if (instance == null) {
instance = new SkinManager(application);
}
}
}
}
public static SkinManager getInstance() {
return instance;
}
/**
* 加载皮肤包资源
*
* @param skinPath 皮肤包路径,为空则加载app内置资源
*/
public void loaderSkinResources(String skinPath) {
// 优化:如果没有皮肤包或者没做换肤动作,方法不执行直接返回!
if (TextUtils.isEmpty(skinPath)) {
isDefaultSkin = true;
return;
}
// 优化:app冷启动、热启动可以取缓存对象
if (cacheSkin.containsKey(skinPath)) {
isDefaultSkin = false;
SkinCache skinCache = cacheSkin.get(skinPath);
if (null != skinCache) {
skinResources = skinCache.getSkinResources();
skinPackageName = skinCache.getSkinPackageName();
return;
}
}
try {
// 创建资源管理器(此处不能用:application.getAssets())
AssetManager assetManager = AssetManager.class.newInstance();
// 由于AssetManager中的addAssetPath和setApkAssets方法都被@hide,目前只能通过反射去执行方法
Method addAssetPath = assetManager.getClass().getDeclaredMethod(ADD_ASSET_PATH, String.class);
// 设置私有方法可访问
addAssetPath.setAccessible(true);
// 执行addAssetPath方法
addAssetPath.invoke(assetManager, skinPath);
//==============================================================================
// 如果还是担心@hide限制,可以反射addAssetPathInternal()方法,参考源码366行 + 387行
//==============================================================================
// 创建加载外部的皮肤包(net163.skin)文件Resources(注:依然是本应用加载)
skinResources = new Resources(assetManager,
appResources.getDisplayMetrics(), appResources.getConfiguration());
// 根据apk文件路径(皮肤包也是apk文件),获取该应用的包名。兼容5.0 - 9.0(亲测)
skinPackageName = application.getPackageManager().getPackageArchiveInfo(skinPath, PackageManager.GET_ACTIVITIES).packageName;
// 无法获取皮肤包应用的包名,则加载app内置资源
isDefaultSkin = TextUtils.isEmpty(skinPackageName);
if (!isDefaultSkin) {
cacheSkin.put(skinPath, new SkinCache(skinResources, skinPackageName));
}
Log.e("skinPackageName >>> ", skinPackageName);
} catch (Exception e) {
e.printStackTrace();
// 发生异常,预判:通过skinPath获取skinPacakageName失败!
isDefaultSkin = true;
}
}
/**
* 参考:resources.arsc资源映射表
* 通过ID值获取资源 Name 和 Type
*
* @param resourceId 资源ID值
* @return 如果没有皮肤包则加载app内置资源ID,反之加载皮肤包指定资源ID
*/
private int getSkinResourceIds(int resourceId) {
// 优化:如果没有皮肤包或者没做换肤动作,直接返回app内置资源!
if (isDefaultSkin) return resourceId;
// 使用app内置资源加载,是因为内置资源与皮肤包资源一一对应(“netease_bg”, “drawable”)
String resourceName = appResources.getResourceEntryName(resourceId);
String resourceType = appResources.getResourceTypeName(resourceId);
// 动态获取皮肤包内的指定资源ID
// getResources().getIdentifier(“netease_bg”, “drawable”, “com.netease.skin.packages”);
int skinResourceId = skinResources.getIdentifier(resourceName, resourceType, skinPackageName);
// 源码1924行:(0 is not a valid resource ID.)
// TODO: 2020/8/5 此处有问题,当我主题包中没有该资源的时候会导致isDefault变成true,这就导致了遍历view的时候无法改变主题,然而这里skinResourceId == 0是对每一个资源的判断不能代表整个资源包
// isDefaultSkin = skinResourceId == 0;
// TODO: 2020/8/5 此处直接返回资源包获取的id值,交给下一步操作判断当前id应该是获取的宿主的还是资源包的
return skinResourceId;
}
public boolean isDefaultSkin() {
return isDefaultSkin;
}
//==============================================================================================
// TODO: 2020/8/5 此处根据获取的资源包id来判断是该加载宿主还是资源包
public int getColor(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return (ids == 0 || ids == resourceId) ? appResources.getColor(resourceId) : skinResources.getColor(ids);
}
public ColorStateList getColorStateList(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return (ids == 0 || ids == resourceId) ? appResources.getColorStateList(resourceId) : skinResources.getColorStateList(ids);
}
// mipmap和drawable统一用法(待测)
public Drawable getDrawableOrMipMap(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return (ids == 0 || ids == resourceId) ? appResources.getDrawable(resourceId) : skinResources.getDrawable(ids);
}
public String getString(int resourceId) {
int ids = getSkinResourceIds(resourceId);
return (ids == 0 || ids == resourceId) ? appResources.getString(resourceId) : skinResources.getString(ids);
}
// 返回值特殊情况:可能是color / drawable / mipmap
public Object getBackgroundOrSrc(int resourceId) {
// 需要获取当前属性的类型名Resources.getResourceTypeName(resourceId)再判断
String resourceTypeName = appResources.getResourceTypeName(resourceId);
switch (resourceTypeName) {
case "color":
return getColor(resourceId);
case "mipmap": // drawable / mipmap
case "drawable":
return getDrawableOrMipMap(resourceId);
}
return null;
}
// 获得字体
public Typeface getTypeface(int resourceId) {
// 通过资源ID获取资源path,参考:resources.arsc资源映射表
String skinTypefacePath = getString(resourceId);
// 路径为空,使用系统默认字体
if (TextUtils.isEmpty(skinTypefacePath)) return Typeface.DEFAULT;
return isDefaultSkin ? Typeface.createFromAsset(appResources.getAssets(), skinTypefacePath)
: Typeface.createFromAsset(skinResources.getAssets(), skinTypefacePath);
}
}
总结:两篇文章,大致分析了整个实现过程,最后总结梳理一下,首先是我们实现主体更好的切入点是Factory接口,整个接口是专门拦截控件的,然后仿造系统兼容包的实现自定义Inflater,创建我们的兼容对象,通过我们兼容对象来自定义颜色等属性,这里的颜色获取就是通过宿主或者主题资源包来获得,主题资源包我们通过资源加载的方式通过宿主的上下文加载资源包的资源,获取对应的属性值,因为我们资源包的资源定义名称和宿主保持一致。