前言
上一章我们讲解了布局原理与xml解析流程,以及资源加载流程、插件化换肤思路,可以查看上一章,本章咱们重点代码实现插件化换肤逻辑;
撸码实现
自定义 View 换肤控制
这里我们采用接口的方式,进行自定义 View 的换肤控制
public interface SkinCustomViewSupport {
void applySkin();
}
这里是一个预留接口,预留给哪些自定义的 View 且需要支持换肤的 View;
记录皮肤配置信息工具类
这里使用 MMVK、SP、DataStore、Room 等等都可以只要能用来记录当前配置的皮肤信息即可,并且在下次冷启动的时候能获取到即可,我这里使用了 SP;
public class SkinSharedPreference {
private static final String SKIN_SHARED = "skins";
private static final String KEY_SKIN_PATH = "skin-path";
private SharedPreferences mPref;
public void init(Context context) {
mPref = context.getSharedPreferences(SKIN_SHARED, Context.MODE_WORLD_WRITEABLE);
}
private SkinSharedPreference() {}
public static final class Holder {
private static final SkinSharedPreference instance = new SkinSharedPreference();
}
public static SkinSharedPreference getInstance() {
return Holder.instance;
}
/**
* 设置并记录当前生效的皮肤包信息
* @param skinPath 皮肤包路径
*/
public void setSkin(String skinPath) {
mPref.edit().putString(KEY_SKIN_PATH, skinPath).apply();
}
/**
* 重置
*/
public void reset() {
mPref.edit().remove(KEY_SKIN_PATH).apply();
}
/**
* 获取当前缓存的皮肤包信息
* @return str
*/
public String getSkin() {
return mPref.getString(KEY_SKIN_PATH, null);
}
}
通过name获取皮肤中对应的资源值
换肤的本质就是将皮肤包中的同名资源值进行替换,也就是说 :在setTextColor的时候,通过传入的 R.color.white 这个white 找到皮肤包中对应的white 的值,然后传递给 setTextColor;
通过上一章的讲解,Resources 中持有着 ResourcesImpl,ResourcesImpl 中持有着 AssetManager,我们只要拿到皮肤包的 Resources 就可以获取皮肤包中的资源值;所以我们需要一个 SkinResource 用来记录当前使用的是主 App 的 Resources 还是皮肤包中的 Resources,并通过getIdentifier getResourceEntryName getResourceTypeName来获取对应的资源值;
public class SkinResources {
// 原始 app 使用的 resources
private Resources mAppResources;
// 插件 apk 中的 resources
private Resources mSkinResources;
// 皮肤包的包名
private String mSkinPkgName;
// 当前加载的是不是默认皮肤,true是
private boolean isDefaultSkin = true;
private volatile static SkinResources instance;
public void init(Context context) {
mAppResources = context.getResources();
}
private SkinResources(Context context) {}
public static SkinResources getInstance() {
return instance;
}
// 重置信息
public void reset() {
mSkinResources = null;
mSkinPkgName = "";
isDefaultSkin = true;
}
// 应用传递进来的皮肤
public void applySkin(Resources resources, String pkgName) {
mSkinResources = resources;
mSkinPkgName = pkgName;
isDefaultSkin = TextUtils.isEmpty(pkgName) || resources == null;
}
/**
* 1. 通过主 app 中的 resId(R.color/drawable.XX) 获取到自己的名字
* 2. 根据名字和类型获取皮肤包中的 ID
*
* @param resId resId
*/
private int getIdentifier(int resId) {
if (isDefaultSkin) {
return resId;
}
//
String resName = mAppResources.getResourceEntryName(resId);
String resType = mAppResources.getResourceTypeName(resId);
return mSkinResources.getIdentifier(resName, resType, mSkinPkgName);
}
/**
* 获取颜色值
*
* @param resId resId
*/
public int getColor(int resId) {
if (isDefaultSkin) {
return mAppResources.getColor(resId);
}
int skinId = getIdentifier(resId);
if (skinId == 0) {
return mAppResources.getColor(resId);
}
return mSkinResources.getColor(resId);
}
public ColorStateList getColorStateList(int resId) {
if (isDefaultSkin) {
return mAppResources.getColorStateList(resId);
}
int skinId = getIdentifier(resId);
if (skinId == 0) {
return mAppResources.getColorStateList(resId);
}
return mSkinResources.getColorStateList(resId);
}
/**
* 获取 drawable
*
* @param resId resId
*/
public Drawable getDrawable(int resId) {
if (isDefaultSkin) {
return mAppResources.getDrawable(resId);
}
int skinId = getIdentifier(resId);
if (skinId == 0) {
return mAppResources.getDrawable(resId);
}
return mSkinResources.getDrawable(resId);
}
/**
* 可能是颜色值,可能是 drawable
*
* @param resId resId
*/
public Object getBackground(int resId) {
String resourceTypeName = mAppResources.getResourceTypeName(resId);
if ("color".equals(resourceTypeName)) {
return getColor(resId);
} else {
return getDrawable(resId);
}
}
}
状态栏的换肤支持
当我们页面进行换肤的时候,我们的状态栏也是应该要跟着整体颜色进行改变的,也就是说我们需要动态的修改对应页面状态栏的颜色,这里写一个工具类,用来切换状态栏
public class SkinThemeUtils {
private static final int[] APPCOMPAT_COLOR_PRIMARY_DARK_ATTRS = {android.R.attr.colorPrimaryDark};
private static final int[] STATUS_BAR_COLOR_ATTRS = {android.R.attr.statusBarColor, android.R.attr.navigationBarColor};
/**
* 获得 Theme 中属性中定义的资源 id
*
* @param context context
* @param attrs attrs
* @return int[]
* */
public static int[] getResId(Context context, int[] attrs) {
int[] resIds = new int[attrs.length];
TypedArray a = context.obtainStyledAttributes(attrs);
for (int i = 0; i < attrs.length; i++) {
resIds[i] = a.getResourceId(i, 0);
}
a.recycle();
return resIds;
}
/**
* 更新导航栏,状态栏的颜色值
*
* @param activity activity 当前页面
*
* */
public static void updateStatusBarColor(Activity activity) {
// 不处理 5.0 以下的
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return;
}
// 获得 statusBarColor 与 navigationBarColor
// 当与 colorPrimaryDark 不同时 以 statusBarColor 为准
int[] resIds = getResId(activity, STATUS_BAR_COLOR_ATTRS);
int statusBarColorResId = resIds[0];
int navigationBarColor = resIds[1];
// 如果直接在 style 中写入固定颜色值(而不是 @color/XXX ) 获得0,不处理,这里需要和业务侧订好规范
if (statusBarColorResId != 0) {
int color = SkinResources.getInstance().getColor(statusBarColorResId);
activity.getWindow().setStatusBarColor(color);
} else {
int colorPrimaryDarkResId = getResId(activity, APPCOMPAT_COLOR_PRIMARY_DARK_ATTRS)[0];
if (colorPrimaryDarkResId != 0) {
int color = SkinResources.getInstance().getColor(colorPrimaryDarkResId);
activity.getWindow().setStatusBarColor(color);
}
}
if (navigationBarColor != 0) {
int color = SkinResources.getInstance().getColor(navigationBarColor);
activity.getWindow().setNavigationBarColor(color);
}
}
}
记录所有需要换肤的View的所有属性
在创建 View 的时候,我们需要记录下这个 view 的所有可以换肤的属性,例如:
<TextView
android:id="@+id/storage"
android:layout_width="match_parent"
android:layout_height="50dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:text="@string/scope_storage"
android:textColor="@color/material_on_background_disabled"
android:gravity="center"/>
我们要拿到 TextView 的 textColor 属性,并记录下来,以及拿到对应的 id 值 记录下来
static class SkinNameValuePair {
// 属性名
String attributeName;
// 对应资源 id
int resId;
SkinNameValuePair(String attributeName, int resId) {
this.attributeName = attributeName;
this.resId = resId;
}
}
有时候,一个View 可能会设置多个支持换肤的属性,那么就需要一个集合来封装,这里声明了一个内部类,View 和对应的 List 集合;
public static class SkinView {
public View view;
// 这个 View 换肤的属性与它对应的 id 的集合
List<SkinNameValuePair> skinNameValuePairs;
SkinView(View view, List<SkinNameValuePair> skinPairs) {
this.view = view;
this.skinPairs = skinNameValuePairs;
}
void applySkin() {
applySkinSupport();
for (SkinNameValuePair skinPair : skinPairs) {
Drawable left = null, top = null, right = null, bottom = null;
switch (skinPair.attributeName) {
case "background":
Object background = SkinResources.getInstance().getBackground(skinPair.resId);
if (background instanceof Integer) {
view.setBackgroundColor((Integer) background);
} else {
ViewCompat.setBackground(view, (Drawable) background);
}
break;
case "src":
background = SkinResources.getInstance().getBackground(skinPair.resId);
if (background instanceof Integer) {
((ImageView) view).setImageDrawable(new ColorDrawable((Integer) background));
} else {
((ImageView) view).setImageDrawable((Drawable) background);
}
break;
case "textColor":
((TextView) view).setTextColor(SkinResources.getInstance().getColorStateList(skinPair.resId));
break;
case "drawableLeft":
left = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableTop":
top = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableRight":
right = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
case "drawableBottom":
bottom = SkinResources.getInstance().getDrawable(skinPair.resId);
break;
}
if (left != null || null != top || right != null || null != bottom) {
((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right, bottom);
}
}
}
private void applySkinSupport() {
if (view instanceof SkinCustomViewSupport) {
((SkinCustomViewSupport) view).applySkin();
}
}
}
接下来,就需要收集当前 View 支持换肤的属性,并将这些支持换肤的 View 的属性封装到一个集合中,整体就是下面这样的一个层级
接下来就是遍历这个 View 的所有要换肤的属性并记录下来;
class SkinAttributeAttrs {
private final List<SkinView> mSkinViews = new ArrayList<>();
private static final List<String> mAttributes = new ArrayList<>();
static {
mAttributes.add("background");
mAttributes.add("src");
mAttributes.add("textColor");
mAttributes.add("drawableLeft");
mAttributes.add("drawableTop");
mAttributes.add("drawableRight");
mAttributes.add("drawableBottom");
}
/**
* 寻找所有可以换肤的 view
* 以 ?开头的 和 以 @ 开头的属性值
*/
void searchView(View view, AttributeSet attrs) {
List<SkinNameValuePair> skinPairs = new ArrayList<>();
for (int i = 0; i < attrs.getAttributeCount(); i++) {
// 获取属性名
String attributeName = attrs.getAttributeName(i);
if (mAttributes.contains(attributeName)) {
// 获取属性值
String attributeValue = attrs.getAttributeValue(i);
// 比如 color,以 # 号写死的颜色
// 不支持换肤 android:textColor="#FFFFFFFF" 这种不知道插件皮肤对应的值
if (attributeValue.startsWith("#")) {
continue;
}
int resId;
if (attributeValue.startsWith("?")) {
// 以 ? 开头的属性值
int attrId = Integer.parseInt(attributeValue.substring(1));
resId = SkinThemeUtils.getResId(view.getContext(), new int[]{attrId})[0];
} else {
// 正常以 @ 开头的属性值
resId = Integer.parseInt(attributeValue.substring(1));
}
SkinNameValuePair skinPair = new SkinNameValuePair(attributeName, resId);
skinPairs.add(skinPair);
}
}
if (!skinPairs.isEmpty() || view instanceof SkinCustomViewSupport) {
SkinView skinView = new SkinView(view, skinPairs);
mSkinViews.add(skinView);
}
}
/**
* 对所有的 View 中的所有属性进行皮肤修改
*/
void applySkin() {
for (SkinView mSkinView : mSkinViews) {
mSkinView.applySkin();
}
}
}
接下来就是要接管 View 的创建过程了;
接管 View 的创建流程
上一章讲解过,接管 View 的创建过程,只需要替换 Factory2 接口即可,这里我们也实现 Factory2 接口
public class SkinLayoutInflaterFactory2 implements LayoutInflater.Factory2, Observer {
private static final String[] mClassPrefixList = {
"android.widget.",
"android.webkit.",
"android.app.",
"android.view.",
};
// 记录对应 view 的构造函数
private static final Class<?>[] mConstructorSignature = new Class[] {
Context.class, AttributeSet.class
};
private static final HashMap<String, Constructor<? extends View>> sConstructorMap = new HashMap<>(); // 当选择新皮肤后需要替换 View 与之对应的属性
// 页面属性管理器
private final SkinAttributeAttrs mSkinAttribute;
// 用于获取窗口的状态框的信息
private final Activity mActivity;
SkinLayoutInflaterFactory2(Activity activity) {
this.mActivity = activity;
this.mSkinAttribute = new SkinAttributeAttrs();
}
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
// 换肤就是在需要的时候替换 View 的属性(src background 等)
// 所以这里创建 view 从而修改 View 属性
View view = createSystemView(name, context, attrs);
if (view == null) {
view = createView(name, context, attrs);
}
if (null != view) {
// 加载属性
mSkinAttribute.searchView(view, attrs);
}
return view;
}
@Override
public View onCreateView(String name, Context context, AttributeSet attrs) {
View view = createSystemView(name, context, attrs);
if (view == null) {
view = createView(name, context, attrs);
}
if (null != view) {
// 处理换肤的逻辑
mSkinAttribute.searchView(view, attrs);
}
return view;
}
/**
* @param name View的类名
* @param context 上下文
* @param attrs 属性
* @return View对象
*/
private View createSystemView(String name, Context context, AttributeSet attrs) {
// 如果包含 . 则不是 SDK 中的 View 可能是自定义 View 包括 support 库中的 View
if (-1 != name.indexOf('.')) {
return null;
}
for (String s : mClassPrefixList) {
// 找到了对应的 View 之后返回
View view = createView(s + name, context, attrs);
if (view != null) {
return view;
}
}
return null;
}
/**
* 反射创建全类名View对象
*
* @param name 全类名View
* @param context 上下文
* @param attrs 属性
* @return View对象
*/
private View createView(String name, Context context, AttributeSet attrs) {
Constructor<? extends View> constructor = findConstructor(context, name);
try {
return constructor.newInstance(context, attrs);
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 根据全类名找到其对应的构造方法对象
*
* @param context 上下文
* @param name 全类名View
* @return 构造方法对象
*/
private Constructor<? extends View> findConstructor(Context context, String name) {
Constructor<? extends View> constructor = sConstructorMap.get(name);
if (constructor == null) {
try {
Class<? extends View> clazz = context.getClassLoader().loadClass(name).asSubclass(View.class);
constructor = clazz.getConstructor(mConstructorSignature);
constructor.setAccessible(true);
sConstructorMap.put(name, constructor);
} catch (Exception e) {
e.printStackTrace();
}
}
return constructor;
}
/**
* Activity(Observable)发出通知,这里就会执行
*
* 执行换肤操作
* */
@Override
public void update(Observable o, Object arg) {
SkinThemeUtils.updateStatusBarColor(mActivity);
mSkinAttribute.applySkin();
}
}
接管了 View 的创建流程,这里大部分都是直接抄的源码的创建逻辑,在 View 创建的时候,就可以替换成插件包中皮肤;
接下来就需要监听 Activity 的创建和销毁,在创建之后的回调了重置 mFactorySet 这里 API 也提供了相关接口;
public class SkinActivityLifeCycle extends AbsActivityLifeCycle {
private final Observable mObservable;
private final ArrayMap<Activity, SkinLayoutInflaterFactory2> mLayoutInflaterFactory = new ArrayMap<>();
SkinActivityLifeCycle(Observable observable) {
this.mObservable = observable;
}
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
/*
* onCreate方法执行之后 回调到这里,此时Activity的onCreate已经执行完毕,也就是setContentView方法已经执行结束,
* mFactorySet已经被设置为true,所以要反射修改为false,否则会抛出 IllegalStateException("A factory has already been set on this LayoutInflater")
* */
/*
* 更新状态栏
* */
SkinThemeUtils.updateStatusBarColor(activity);
/*
* 更新布局视图
* */
LayoutInflater layoutInflater = activity.getLayoutInflater();
try {
// Android布局加载器 使用 mFactorySet 标记是否设置过 Factory
// 如设置过,再次设置会抛出异常
// 设置 mFactorySet 为 false
Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
field.setAccessible(true);
field.setBoolean(layoutInflater, false);
} catch (Exception e) {
e.printStackTrace();
}
SkinLayoutInflaterFactory2 skinLayoutInflaterFactory = new SkinLayoutInflaterFactory2(activity);
LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutInflaterFactory);
mLayoutInflaterFactory.put(activity, skinLayoutInflaterFactory);
// 观察者模式
mObservable.addObserver(skinLayoutInflaterFactory);
}
@Override
public void onActivityDestroyed(Activity activity) {
SkinLayoutInflaterFactory2 skinLayoutInflaterFactory = mLayoutInflaterFactory.remove(activity);
SkinManager.getInstance().deleteObserver(skinLayoutInflaterFactory);
}
}
加载皮肤资源,初始化配置信息,
public class SkinManager extends Observable {
private Application mContext;
private Resources skinResources;
private SkinManager() {}
/**
* 初始化必须放在 application 中
* */
public void init(Application application) {
mContext = application;
SkinSharedPreference.getInstance().init(application);
SkinResources.init(application);
// 注册 Activity 生命周期,并设置被观察者
SkinActivityLifeCycle skinApplicationActivityLifeCycle = new SkinActivityLifeCycle(this);
application.registerActivityLifecycleCallbacks(skinApplicationActivityLifeCycle);
// 加载上次使用保存的皮肤
loadSkin(SkinSharedPreference.getInstance().getSkin());
}
public Resources getSkinResources() {
return skinResources;
}
public static SkinManager getInstance() {
return Holder.instance;
}
private static final class Holder {
private static final SkinManager instance = new SkinManager();
}
/**
* 加载皮肤并应用
* @param skin skin 皮肤路径
*
* 对外暴露这个方法,可以添加设置页面,增加换肤按钮,点击事件中 调用此方法,传递皮肤包路径
*
* */
public void loadSkin(String skin) {
if (TextUtils.isEmpty(skin)) {
// 还原默认皮肤
SkinSharedPreference.getInstance().reset();
SkinResources.getInstance().reset();
} else {
// 宿主 app 的 resources
Resources resources = mContext.getResources();
// 反射创建 AssetManager 与 Resources
try {
AssetManager assetManager = AssetManager.class.newInstance();
// 资源路径设置目录或者压缩包
// TODO 同一个 key 的颜色值 就会被替换掉,所以未打开的Activity页面,在初次打开的时候,使用的是接管 View 创建的过程,同时替换成新的皮肤资源
// TODO Resources进行getColor(R.color.xxx) 因为是同名的,这里已经替换成皮肤插件包中的Resources了,所以未打开过的Activity,执行onCreate方法中的setContentView
// TODO 中的inflate的时候 读取到的颜色值 就是插件包中的
Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String.class);
addAssetPath.setAccessible(true);
addAssetPath.invoke(assetManager, skin);
// 根据当前的设备显示器信息与配置(横竖屏,语言等) 创建 Resources,
skinResources = new Resources(assetManager, resources.getDisplayMetrics(), resources.getConfiguration());
// 获取外部 apk(皮肤包) 包名
PackageManager mPm = mContext.getPackageManager();
PackageInfo info = mPm.getPackageArchiveInfo(skin, PackageManager.GET_ACTIVITIES);
if(info == null) {
return;
}
String packageName = info.packageName;
// 这里就会进行一个赋值,如果皮肤插件包有值,就会给SkinResources进行赋值
SkinResources.getInstance().applySkin(skinResources, packageName);
// 记录
SkinSharedPreference.getInstance().setSkin(skin);
} catch (Exception e) {
//
}
}
// 通知采集的 view 更新皮肤
// 被观察者改变,通知所有的观察者
setChanged();
notifyObservers(null);
}
}
Application 中初始化插件化换肤框架
public class MyApplication extends Application {
public static Application sApplication;
@Override
public void onCreate() {
super.onCreate();
sApplication = this;
SkinManager.getInstance().init(this);
}
public static Application getApplication() {
return sApplication;
}
@Override
public Resources getResources() {
Resources skinResources = SkinManager.getInstance().getSkinResources();
if (skinResources != null) {
if (skinResources.getConfiguration() != getSuperResources().getConfiguration()
|| skinResources.getDisplayMetrics() != getSuperResources().getDisplayMetrics()) {
skinResources.updateConfiguration(getSuperResources().getConfiguration(),
getSuperResources().getDisplayMetrics());
}
return skinResources;
}
return getSuperResources();
}
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
// 适配 Android 12 屏幕切换 resources 不更新的问题
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) {
getSuperResources().updateConfiguration(newConfig, Resources.getSystem().getDisplayMetrics());
}
}
public Resources getSuperResources() {
return super.getResources();
}
}
换肤按钮切换通知
// 这里也可以是网络下载的地址
SkinManager.getInstance().loadSkin("/data/data/com.example.llc/skin/skin-debug.apk");
最终效果
PS:TODO 版本适配,以及编译版本和目标版本
mFactorySet 做了版本限制,当 targetSdkVersion > 27 的时候会抛出异常,因为涉及到了插件化Hook,demo中做了 compileSdkVersion 和 targetSdkVersion 都是 27 的限制,主要是为了讲解 xml 解析和布局原理,后面我会补上适配的内容;
根据注释提示,可以参考 AsyncLayoutInflater 的实现自己的 LayoutInflater 接管 View 的创建;
简历润色
简历上可写:深度理解布局原理和XML解析,可手写插件化换肤核心实现;
下一章预告
嵌套滑动原理,手写淘宝首页二级联动
欢迎三连
来都来了,点个关注点个赞吧,你的支持是我最大的动力;