美文网首页
Android App实现动态换肤

Android App实现动态换肤

作者: 小名坎坎 | 来源:发表于2021-06-17 09:57 被阅读0次

    准备阶段

    • 皮肤包是什么样的文件?
    • 动态换肤的思想是什么?
    皮肤包是什么样的文件?

    我们通过解析网易云音乐的皮肤包来理解

    1. 通过模拟器下载网易云音乐并更换皮肤。
    2. 在设备/data/data/com.netease.cloudmusic/files/theme目录下可以找到我们的皮肤包并cp到电脑上。
    3. 修改文件格式为zip,并解压。
    经过上述步骤我们得到以下文件 网易云音乐皮肤包

    我们可以看到,他的文件内容和我们平时apk的内容格式完全一致,那这样后续我们也可以同样方法来制作皮肤包。

    动态换肤的方案是什么?- 缓存需要换肤的view,然后设置新样式

    所有我们先要了解view的创建,下面我们从sdk源码中寻找答案,这里只看主要流程,不看其他 - 基于sdk版本30。

    • 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包下。
    上述疑问就得到了解决。

    开始编码 (只贴出关键类与关键代码)

    拦截系统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);
        }
    }
    
    上述完成后,我们运行后可以输出 image.png

    从这个我们可以我们拦截了系统view的创建来由我们自己创建,至于log输出的这个view列表,大家也应该很熟悉就是DecorView的结构,这里就不做赘述,到了这里我们已经完成了view的创建部分。

    开始实现换肤
    1. 筛选需要的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然后重新设置的对应属性的。

    1. 制作皮肤包
    • 新建一个Android project/module
    • 将需要替换的颜色或者图片拷贝的项目中,需注意和原来项目的中的名称要一致。
    • 所有的都替换完成后,直接rebuild,拷贝出生成的apk包
    • 可以将名称改为任何你想要的,比如这里我修改为了theme.skin,这就是皮肤包了
    • 将其拷贝近手机文件下 - 实际应用中应该网络下载之类的
    1. 加载皮肤包
    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方法去设置
    至此我们可以实现一些基本的功能,测试一波 原图1
    原图2
    替换过后... 替换1 替换2
    这里已经成功的实现了换肤,退出后重新进入也是显示设置皮肤,但是很多地方也许不够完善,这里只是阐明一个方法,而且关于字体和状态栏都没有替换,后续将继续去替换字体和状态栏部分。

    相关文章

      网友评论

          本文标题:Android App实现动态换肤

          本文链接:https://www.haomeiwen.com/subject/pkmzeltx.html