前言
今天我们来聊聊app里动态换肤实现原理
换肤分为动态换肤和静态换肤
一、静态换肤原理
这种换肤的方式,也就是我们所说的内置换肤,就是在APP内部放置多套相同的资源。进行资源的切换。
这种换肤的方式有很多缺点,比如, 灵活性差,只能更换内置的资源、apk体积太大,在我们的应用Apk中等一般图片文件能占到apk大小的一半左右。
当然了,这种方式也并不是一无是处, 比如我们的应用内,只是普通的 日夜间模式 的切换,并不需要图片等的更换,只是更换颜色,那这样的方式就很实用。
二、动态换肤实现原理探讨
动态换肤包括替换图片资源、布局颜色、字体、文字颜色、状态栏和导航栏颜色;
动态换肤步骤包括:
采集需要换肤的控件
加载皮肤包
替换资源
1、皮肤包是什么样的文件
通过解析网易云音乐的皮肤包来理解
通过模拟器下载网易云音乐并更换皮肤。
在设备/data/data/com.netease.cloudmusic/files/theme目录下可以找到我们的皮肤包并cp到电脑上。
修改文件格式为zip,并解压。
经过上述步骤我们得到以下文件
我们可以看到,他的文件内容和我们平时apk的内容格式完全一致;
2、探讨实现原理
从sdk源码中寻找答案,这里只看主要流程
首先Activity的onCreate()方法里面我们都要去调用setContentView(int id) 来指定当前Activity的布局文件:
- public void setContentView(@LayoutRes int layoutResID) {
- // 调用window的setContentView
- getWindow().setContentView(layoutResID);
- initWindowDecorActionBar();
- }
Window - PhoneWindow
- @Override
- public void setContentView(int layoutResID) {
- // 调用 LayoutInflater的inflate
- mLayoutInflater.inflate(layoutResID, mContentParent);
- }
LayoutInflater
- @Override
- public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
- // Temp is the root view that was found in the xml 创建根布局
- final View temp = createViewFromTag(root, name, inflaterContext, attrs);
- // Inflate all children under temp against its context. 创建子布局 最后也是调用createViewFromTag
- rInflateChildren(parser, temp, attrs, true);
- }
- void rInflate(XmlPullParser parser, View parent, Context context, AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
- // 循环调用createViewFromTag创建子布局
- while (((type = parser.next()) != XmlPullParser.END_TAG ||parser.getDepth() >depth) && type != XmlPullParser.END_DOCUMENT) {
- final View view = createViewFromTag(parent, name, context, attrs);
- }
- }
- // 从这个方法中我们看到 尝试通过各种Factory来创建View
- public final View tryCreateView(@Nullable View parent, @NonNull String name,@NonNull Context context,@NonNull AttributeSet attrs) {
- View view;
- if (mFactory2 != null) {
- view = mFactory2.onCreateView(parent, name, context, attrs);
- } else if (mFactory != null) {
- view = mFactory.onCreateView(name, context, attrs);
- } else {
- view = null;
- }
- if (view == null && mPrivateFactory != null) {
- view = mPrivateFactory.onCreateView(parent, name, context, attrs);
- }
- return view;
- }
- View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,boolean ignoreThemeAttr) {
- // 这里是比较重要的地方
- // 尝试通过Factory来创建View
- View view = tryCreateView(parent, name, context, attrs);
- // 如果没有Factory来创建,那么就调用下面方法创建View
- if (view == null) {
- if (-1 == name.indexOf('.')) {
- // 系统提供的View 不带.的 比如View ,ImageView,TextView
- view = onCreateView(context, parent, name, attrs);
- } else {
- // 第三方View或者自定义view 比如com.cbb.xxxView
- view = createView(context, name, null, attrs);
- }
- }
- }
- // 看到这里应该比较疑惑 为什么这里只传了android.view. ,很多view明明都不在这个包下
- protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
- // 最终还是调用createView , 传入了系统view的全名
- return createView(name, "android.view.", attrs);
- }
- public final View createView(@NonNull Context viewContext, @NonNull String name,@Nullable String prefix, @Nullable AttributeSet attrs)throws ClassNotFoundException, InflateException {
- // 缓存中获取View的构造方法
- Constructor<? extends View> constructor = sConstructorMap.get(name);
- // 没有缓存则反射获得View的构造方法 并缓存
- // 需要注意的是 这里使用的view两个参数的构造方法
- if (constructor == null) {
- clazz = Class.forName(prefix != null ? (prefix + name) : name, false,
- mContext.getClassLoader()).asSubclass(View.class);
- constructor = clazz.getConstructor(mConstructorSignature);
- constructor.setAccessible(true);
- sConstructorMap.put(name, constructor);
- }
- // 使用构造方法创建view
- final View view = constructor.newInstance(args);
- }
- // sdk提供了设置Factory 的方法
- public void setFactory2(Factory2 factory) {
- // 这里需要注意mFactorySet 会被如果设置过会被设为true,所以后面我们在设置Factory前需要将其置为false
- if (mFactorySet) {
- throw new IllegalStateException("A factory has already been set on this LayoutInflater");
- }
- mFactorySet = true;
- }
上述流程分析,我们了解到我们在setContentView开始到创建出view的过程,我们可看到系统在创建view之前会尝试用Factory来创建view,那么我们也可以通过设置自定义Factory来代替系统自带的创建。
上面分析中有个疑问,为什么这里只传了android.view. ,很多view明明都不在这个包下却可以成功创建?这里简单分析一下这个过程
- public static LayoutInflater from(Context context) {
- LayoutInflater LayoutInflater =
- (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
- return LayoutInflater;
- }
上面是LayoutInflater的实例化,我们看到实际返回的是context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
我们从源码中追溯上去
传入的为Activity的context ,Activity的getSystemService(String name)
ContextThemeWrapper的getSystemService(String name)
ContextWrapper中getBaseContext().getSystemService(name)
getBaseContext()返回的mBase
ContextWrapper中的attachBaseContext(Context base)赋值
Activity中的attachBaseContext(context);
Activity中的attach()方法中attachBaseContext(context)
在ActivityThread中performLaunchActivity方法中调用Activity的attach()方法
在ActivityThread中performLaunchActivity方法中ContextImpl appContext = createBaseContextForActivity(r)实例化了Context
ContextImpl中getSystemService(String name)调用SystemServiceRegistry.getSystemService(this, name)返回;
拿着LAYOUT_INFLATER_SERVICE去SystemServiceRegistry中寻找发现返回的是PhoneLayoutInflater类
经过上述步骤我们看到了实际返回的是PhoneLayoutInflater类
- public class PhoneLayoutInflater extends LayoutInflater {
- private static final String[] sClassPrefixList = {
- "android.widget.",
- "android.webkit.",
- "android.app."
- };
- @Override protected View onCreateView(String name, AttributeSet attrs) throws ClassNotFoundException {
- for (String prefix : sClassPrefixList) {
- View view = createView(name, prefix, attrs);
- if (view != null) {
- return view;
- }
- }
- return super.onCreateView(name, attrs);
- }
- }
从上述PhoneLayoutInflater源码中可以看到PhoneLayoutInflater是LayoutInflater的子类,所以实际是拼接的这三个包下的,如果没有则就是原来的view包下。
上述疑问就得到了解决
3、实现view布局的拦截
拦截系统view的创建
- public class SkinLayoutFactory implements LayoutInflater.Factory2 {
- // 包目录列表
- private static final String[] sClassPrefixList = {
- "android.widget.",
- "android.webkit.",
- "android.app.",
- "android.view."
- };
- // view构造方法的两个参数
- private static final Class<?>[] mConstructorSignature = new Class[]{
- Context.class, AttributeSet.class};
- // 用户缓存已经反射获得的构造方法,防止后续同一个类型的view重复反射
- private static final HashMap<String, Constructor<? extends View>> sConstructorMap =
- new HashMap<String, Constructor<? extends View>>();
- @Nullable
- @Override
- public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
- // 创建view
- View view = createViewFromTag(context, name, attrs);
- Log.e("Skin", "name = " + name + " , view = " + view);
- return view;
- }
- /**
- * 创建view
- * 通过判断是否包含.来确定是否区分两种view类型
- *
- * @param name 可能为TextView , 也可能为xxx.xxx.xxxView
- */
- private View createViewFromTag(Context context, String name, AttributeSet attrs) {
- View view;
- if (-1 == name.indexOf('.')) {
- view = createViewByPkgList(context, name, attrs);
- } else {
- view = createView(context, name, attrs);
- }
- return view;
- }
- /**
- * 通过遍历系统包来尝试创建view,如果上个没有创建成功有异常会被catch,然后继续尝试下一个包名来创建
- *
- * @param name 可能为TextView
- */
- private View createViewByPkgList(Context context, String name, AttributeSet attrs) {
- for (String prefix : sClassPrefixList) {
- try {
- View view = createView(context, prefix + name, attrs);
- if (view != null) {
- return view;
- }
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- return null;
- }
- /**
- * 真正的开始创建view
- *
- * @param name name 格式为xxx.xxx.xxxView
- */
- private View createView(Context context, String name, AttributeSet attrs) {
- Constructor<? extends View> constructor = sConstructorMap.get(name);
- if (null == constructor) {
- try {
- Class<? extends View> aClass = context.getClassLoader().loadClass(name).asSubclass
- (View.class);
- constructor = aClass.getConstructor(mConstructorSignature);
- sConstructorMap.put(name, constructor);
- } catch (Exception e) {
- }
- }
- if (null != constructor) {
- try {
- return constructor.newInstance(context, attrs);
- } catch (Exception e) {
- }
- }
- return null;
- }
- }
上述代码我们基本都是cp的系统源码,从而实现我们自己来创建view,现在我们要开始设置Factory,利用sdk提供的ActivityLifecycleCallbacks的来实现。
- // 系统提供的可以监听整个app activity的生命周期
- public class SkinActivityLifecycle implements Application.ActivityLifecycleCallbacks {
- @Override
- public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) {
- // 拿到对应的layoutInflater 创建skinLayoutFactory 并设置进去
- setFactory2(activity);
- }
- /**
- * 监听到activity 生命周期设置Factory 来拦截系统的view创建
- * 需要注意的地方为 需要将mFactorySet置为false
- * 这里有个缺陷 :>28 那么这个属性就不能使用反射来改变了 系统禁止了
- * 可以考虑直接反射来修改Factory的值 这个系统没有限制 这里没有实践
- */
- private void setFactory2(Activity activity){
- LayoutInflater layoutInflater = LayoutInflater.from(activity);
- try {
- //Android 布局加载器 使用 mFactorySet 标记是否设置过Factory
- //如设置过抛出一次
- //设置 mFactorySet 标签为false
- Field field = LayoutInflater.class.getDeclaredField("mFactorySet");
- field.setAccessible(true);
- field.setBoolean(layoutInflater, false);
- } catch (Exception e) {
- e.printStackTrace();
- }
- SkinLayoutFactory skinLayoutFactory = new SkinLayoutFactory();
- LayoutInflaterCompat.setFactory2(layoutInflater, skinLayoutFactory);
- }
- }
- public class SkinManager {
- private static SkinManager instance;
- private Application application;
- private SkinActivityLifecycle skinActivityLifecycle;
- public static void init(Application application) {
- synchronized (SkinManager.class) {
- if (null == instance) {
- instance = new SkinManager(application);
- }
- }
- }
- public static SkinManager getInstance() {
- return instance;
- }
- private SkinManager(Application application) {
- this.application = application;
- //注册Activity生命周期回调
- skinActivityLifecycle = new SkinActivityLifecycle();
- application.registerActivityLifecycleCallbacks(skinActivityLifecycle);
- }
- }
上述完成后,我们运行后可以输出
从这个我们可以我们拦截了系统view的创建来由我们自己创建,至于log输出的这个view列表,大家也应该很熟悉就是DecorView的结构,这里就不做赘述,到了这里我们已经完成了view的创建部分
4、实现换肤
筛选需要的view
上述我们已经拦截了所有的view,实际换肤只需要将需要换肤的view缓存下来就可以了,这里我们通过view的属性来筛选view。
// 只需要设置了这些属性的view
- public class SkinAttribute {
- static {
- mAttributes.add("background");
- mAttributes.add("src");
- mAttributes.add("textColor");
- mAttributes.add("drawableLeft");
- mAttributes.add("drawableTop");
- mAttributes.add("drawableRight");
- mAttributes.add("drawableBottom");
- }
- // 筛选view
- public void load(View view, AttributeSet attrs) {
- // 这个view 设置的可以被替换的属性列表
- List<SkinPair> 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);
- // 如果不是通过@符号引用的都不管了 比如?护着#之类的 - 实际?也是可能需要换的,这里为了方便
- if (!attributeValue.startsWith("@")) {
- continue;
- }
- //资源id
- int resId = Integer.parseInt(attributeValue.substring(1));
- if (resId != 0) {
- //可以被替换的属性
- SkinPair skinPair = new SkinPair(attributeName, resId);
- skinPairs.add(skinPair);
- }
- }
- }
- // 上述已经将这个view需要修改的属性保存进skinPairs了
- // 判断skinPairs是否为空 ,不为空就将这个view以后属性信息缓存起来
- if (!skinPairs.isEmpty() || view instanceof TextView) {
- SkinView skinView = new SkinView(view, skinPairs);
- // 去修改样式
- skinView.applySkin();
- mSkinViews.add(skinView);
- }
- }
- /**
- * 遍历view设置样式
- */
- public void applySkin() {
- for (SkinView mSkinView : mSkinViews) {
- mSkinView.applySkin();
- }
- }
- // 需要换肤的view和和属性
- static class SkinView {
- View view;
- List<SkinPair> skinPairs;
- public SkinView(View view, List<SkinPair> skinPairs) {
- this.view = view;
- this.skinPairs = skinPairs;
- }
- // 设置样式 这里都是在皮肤包里面寻找 如果找不到 返回的就是默认的
- public void applySkin() {
- for (SkinPair skinPair : skinPairs) {
- Drawable left = null, top = null, right = null, bottom = null;
- switch (skinPair.attributeName) {
- case "background":
- Object background = SkinResources.getInstance().getBackground(skinPair
- .resId);
- //Color
- 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;
- default:
- break;
- }
- if (null != left || null != right || null != top || null != bottom) {
- ((TextView) view).setCompoundDrawablesWithIntrinsicBounds(left, top, right,
- bottom);
- }
- }
- }
- }
- // 用于保存属性名称和id
- static class SkinPair {
- String attributeName;
- int resId;
- public SkinPair(String attributeName, int resId) {
- this.attributeName = attributeName;
- this.resId = resId;
- }
- }
- }
下面我们在创建view的地方进行筛选并缓存
- public class SkinLayoutFactory implements LayoutInflater.Factory2, Observer {
- public View onCreateView(@Nullable View parent, @NonNull String name, @NonNull Context context, @NonNull AttributeSet attrs) {
- // 创建view
- View view = createViewFromTag(context, name, attrs);
- Log.e("Skin", "name = " + name + " , view = " + view);
- //筛选符合属性的View
- skinAttribute.load(view, attrs);
- return view;
- }
- }
到这里,我们创建view的时候通过SkinAttribute类我们可以筛选出可能需要更换皮肤的view,然后保存了每个view和其属性的对关系,我们需要替换的时候就遍历缓存的view然后重新设置的对应属性的。
5、制作皮肤包
新建一个Android project/module
将需要替换的颜色或者图片拷贝的项目中,需注意和原来项目的中的名称要一致。
所有的都替换完成后,直接rebuild,拷贝出生成的apk包
可以将名称改为任何你想要的,比如这里我修改为了theme.skin,这就是皮肤包了
将其拷贝近手机文件下 - 实际应用中应该网络下载之类的
6、加载皮肤包
- public class SkinManager extends Observable {
- /**
- * 使用皮肤包
- *
- * @param path 皮肤包地址
- */
- public void loadSkin(String path) {
- if (TextUtils.isEmpty(path)) {
- // 传入空 用默认的
- SkinPreference.getInstance().setSkin("");
- SkinResources.getInstance().reset();
- } else {
- try {
- AssetManager assetManager = AssetManager.class.newInstance();
- // 添加资源进入资源管理器
- Method addAssetPath = assetManager.getClass().getMethod("addAssetPath", String
- .class);
- addAssetPath.setAccessible(true);
- addAssetPath.invoke(assetManager, path);
- // 系统resources
- Resources resources = application.getResources();
- // 外部资源 sResource
- Resources sResource= new Resources(assetManager, resources.getDisplayMetrics(),
- resources.getConfiguration());
- //获取外部Apk(皮肤包) 包名
- PackageManager mPm = application.getPackageManager();
- PackageInfo info = mPm.getPackageArchiveInfo(path, PackageManager
- .GET_ACTIVITIES);
- String packageName = info.packageName;
- // 皮肤包资源传入工具类SkinResources中方便后续查找
- SkinResources.getInstance().applySkin(sResource, packageName);
- //保存当前使用的皮肤包
- SkinPreference.getInstance().setSkin(path);
- } catch (Exception e) {
- e.printStackTrace();
- }
- }
- //通知观察者
- setChanged();
- notifyObservers();
- }
- }
上面代码将theme.skin加载了,SkinResources是一个工具类,这里传入了传入了外部皮肤包的Resources,举例作用如下
- // 根据本app中的资源id寻找皮肤包中的资源id
- public int getIdentifier(int resId) {
- if (isDefaultSkin) {
- return resId;
- }
- //在皮肤包中不一定就是 当前程序的 id
- //获取对应id 在当前的名称 colorPrimary
- // 所以要先获取当前名称和类型 再去皮肤包中查找对应的id
- String resName = mAppResources.getResourceEntryName(resId);
- String resType = mAppResources.getResourceTypeName(resId);
- int skinId = mSkinResources.getIdentifier(resName, resType, mSkinPkgName);
- return skinId;
- }
- // 根据资源id获得颜色
- public int getColor(int resId) {
- // 如果显示默认皮肤 就返回默认的
- if (isDefaultSkin) {
- return mAppResources.getColor(resId);
- }
- // 获得在皮肤包的资源id 两个包中的统一名称资源可能id不一样
- int skinId = getIdentifier(resId);
- if (skinId == 0) {
- // 返回皮肤包中的资源
- return mAppResources.getColor(resId);
- }
- return mSkinResources.getColor(skinId);
- }
这里加载完皮肤包后,我们需要做的就是通知到view去更新,这里代码就不贴出来了
已经有的页面通知SkinAttribute调用applySkin()去遍历已经缓存的view去设置
后续打开的页面,包括退出重新进入app,那么就要在SkinLayoutFactory调用onCreateView创建view的时候调用SkinAttribute的load方法去设置
至此我们可以实现一些基本的功能,测试一波
这里已经成功的实现了换肤
总结
动态换肤步骤:
采集需要换肤的控件
加载皮肤包
替换资源