android 轻量级的夜间模式切换

作者: android源码探索 | 来源:发表于2020-01-26 16:20 被阅读0次

    欢迎大家下载我个人开发的app安琪花园

    前言


    我总结的这个并不是说从网络下载皮肤包 。而是通过换肤的原理来实现白天模式和夜间模式的切换。
    因为在我当前开发的项目,夜间模式的切换实现逻辑是通过recreateActivity来实现的。
    但是这个有一个缺点,就是每次切换明显能看到闪烁,且卡顿一下。
    当我研究过了换肤的原理,并接入到了项目里面就能解决上面的问题, 夜间模式的切换就比较自然。

    看一下修改过后的效果图


    tuhaokuai_1580118181.gif

    实现原理


    1. 首先在res文件夹下面建立白天模式 和夜间模式的文件夹,分别对应白天和夜间模式的资源
    2. 在渲染Activity的时候,将要换肤的控件实现某一个特定的接口,并把控件里面的属性保存到实体类里面
    3. 当点击夜间模式切换的按钮时, 调用集合里面的控件的接口方法,达到换肤的目的

    具体是如何实现的


    从setContentView开始分析,这个方法最终会执行到PhonwWindow里面的setContentView方法

     @Override
        public void setContentView(int layoutResID) {
            // Note: FEATURE_CONTENT_TRANSITIONS may be set in the process of installing the window
            // decor, when theme attributes and the like are crystalized. Do not check the feature
            // before this happens.
            if (mContentParent == null) {
                installDecor();
            } else if (!hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
                mContentParent.removeAllViews();
            }
    
            if (hasFeature(FEATURE_CONTENT_TRANSITIONS)) {
                final Scene newScene = Scene.getSceneForLayout(mContentParent, layoutResID,
                        getContext());
                transitionTo(newScene);
            } else {
                mLayoutInflater.inflate(layoutResID, mContentParent);
            }
            mContentParent.requestApplyInsets();
            final Callback cb = getCallback();
            if (cb != null && !isDestroyed()) {
                cb.onContentChanged();
            }
            mContentParentExplicitlySet = true;
        }
    
    

    从上面的的代码里面可以看到这样的一句代码: mLayoutInflater.inflate(layoutResID, mContentParent);

    所以着重看一下LayoutInflater.inflate方法


    这个方法就是去解析布局资源里面的控件。从源码分析最终会执行到如下的代码:

     void rInflate(XmlPullParser parser, View parent, Context context,
                AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
    
            final int depth = parser.getDepth();
            int type;
            boolean pendingRequestFocus = false;
    
            while (((type = parser.next()) != XmlPullParser.END_TAG ||
                    parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
    
                if (type != XmlPullParser.START_TAG) {
                    continue;
                }
    
                final String name = parser.getName();
    
                if (TAG_REQUEST_FOCUS.equals(name)) {
                    pendingRequestFocus = true;
                    consumeChildElements(parser);
                } else if (TAG_TAG.equals(name)) {
                    parseViewTag(parser, parent, attrs);
                } else if (TAG_INCLUDE.equals(name)) {
                    if (parser.getDepth() == 0) {
                        throw new InflateException("<include /> cannot be the root element");
                    }
                    parseInclude(parser, context, parent, attrs);
                } else if (TAG_MERGE.equals(name)) {
                    throw new InflateException("<merge /> must be the root element");
                } else {
                    final View view = createViewFromTag(parent, name, context, attrs);
                    final ViewGroup viewGroup = (ViewGroup) parent;
                    final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
                    rInflateChildren(parser, view, attrs, true);
                    viewGroup.addView(view, params);
                }
            }
    
            if (pendingRequestFocus) {
                parent.restoreDefaultFocus();
            }
    
            if (finishInflate) {
                parent.onFinishInflate();
            }
        }
    

    从上面的源码里面我们可以看到如下的一句代码: final View view = final View view = createViewFromTag(parent, name, context, attrs);; 这句代码的作用就是创建出xml文件中的控件的实例

    接下来分析一下 final View view = createViewFromTag(parent, name, context, attrs);

    是如何创建出控件的, 继续从源码分析


    View createViewFromTag(View parent, String name, Context context, AttributeSet attrs,
                boolean ignoreThemeAttr) {
          *** 省略 ***
            try {
                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);
                }
    
                if (view == null) {
                    final Object lastContext = mConstructorArgs[0];
                    mConstructorArgs[0] = context;
                    try {
                        if (-1 == name.indexOf('.')) {
                            view = onCreateView(parent, name, attrs);
                        } else {
                            view = createView(name, null, attrs);
                        }
                    } finally {
                        mConstructorArgs[0] = lastContext;
                    }
                }
    
                return view;
            } catch (InflateException e) {
                throw e;
    
            } 
    *** 省略了部分代码 ***
     }
    

    上面的源码省略了一部分不相关的的代码,保留了核心的代码

    最核心的代码如下:

      if (mFactory2 != null) {
                    view = mFactory2.onCreateView(parent, name, context, attrs);
                } else if (mFactory != null) {
                    view = mFactory.onCreateView(name, context, attrs);
                } else {
                    view = null;
                }
    

    **分析到了这里,我可以先诉你,让createViewFromTag方法里面执行mFactory2.oncreateView分支 就是本节换肤的突破口, 得到View实例不希望走系统默认的onCreateView方法,而是调用mFactory2.onCreateView方法,这样我们就能在mFactory2的onCreateView方法里面添加自己的逻辑**

    在默认的情况下LayoutInflater里面的mFactory2属性是为null,所以根本就不会执行到mFactory2.onCreateView分支

    如何让mFactory2不为空呢? 这就需要从Activity的源码开始分析


    1580121669020.jpg

    从上面的截图可知, Activity实现了LayoutInflater.Factory2接口, 如何把activity实例赋值给LayoutInflater 的mFactory2属性, 那么mFactory2的实例就不为空,代码就能按照上面我们的设想去执行。

    接下来就是让LayoutInflater里面的mFactory2的值 不为null


    首先我们分析一下, 对于 一个项目里面的Activity一般情况下都是有一个基类的Activity, 而关于一些换肤的逻辑,当然就可以在基类里面处理了。对于我当前的项目而言,我的基类Activity为:


    image.png

    从上面的的onCreate方法分析知:LayoutInflaterCompat.setFactory2(layoutInflater, this); LayoutInflaterCompate通过反射将layoutinflater的mFactory2实例设置为当前的activity. 这样createViewFromTag方法得到的View实例就是调用 mFactory2.onCreateView方法。 也就是调用了上面的截图方法

    从给的截图知,控件的创建是委托给CustomAppCompatViewInflater来创建的。接下来看一下

    CustomAppCompatViewInflater源码:

    public final class CustomAppCompatViewInflater {
    
        private String name; // 控件名
        private Context context; // 上下文
        private AttributeSet attrs; // 某控件对应所有属性
    
        public CustomAppCompatViewInflater(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 "LinearLayout":
                    view = new SkinnableLinearLayout(context, attrs);
                    this.verifyNotNull(view, name);
                    break;
                case "View":
                    view = new SkinnableView(context, attrs);
                    this.verifyNotNull(view, name);
                    break;
            }
            return view;
        }
    }
    

    具体分析一下autoMatch方法, 说得直白点,就是一种偷天换日的做法, LayoutInflater解析到需要一个LinearLayout控件,但是并不是直接new一个LinearLayout返回, 而是new了一个SkinnableLinearLayout控件。 这个控件是继承自LinearLayout,只是加入了一些换肤需要的逻辑。主要分析一下LinearLayout,对于其它控件的逻辑是类似的

    具体分析一下SkinnableLinearLayout做了什么处理

    public class SkinnableLinearLayout extends LinearLayout implements ViewsMatch {
    
        private AttrsBean attrsBean;
    
        public SkinnableLinearLayout(Context context) {
            this(context, null);
        }
    
        public SkinnableLinearLayout(Context context, AttributeSet attrs) {
            this(context, attrs, 0);
        }
    
        public SkinnableLinearLayout(Context context, AttributeSet attrs, int defStyleAttr) {
            super(context, attrs, defStyleAttr);
    
            attrsBean = new AttrsBean();
    
            // 根据自定义属性,匹配控件属性的类型集合,如:background
            TypedArray typedArray = context.obtainStyledAttributes(attrs,
                    R.styleable.SkinnableLinearLayout,
                    defStyleAttr, 0);
            // 存储到临时JavaBean对象
            attrsBean.saveViewResource(typedArray, R.styleable.SkinnableLinearLayout);
            // 这一句回收非常重要!obtainStyledAttributes()有语法提示!!
            typedArray.recycle();
        }
    
        @Override
        public void skinnableView() {
            // 根据自定义属性,获取styleable中的background属性
            int key = R.styleable.SkinnableLinearLayout[R.styleable.SkinnableLinearLayout_android_background];
            // 根据styleable获取控件某属性的resourceId
            int backgroundResourceId = attrsBean.getViewResource(key);
            if (backgroundResourceId > 0) {
                // 兼容包转换
                Drawable drawable = ContextCompat.getDrawable(getContext(), backgroundResourceId);
                setBackground(drawable);
            }
        }
    }
    

    从上面的源码可以提取到两个重要信息:

    1. attrsBean.saveViewResource(typedArray, R.styleable.SkinnableLinearLayout);

    2. 实现了ViewsMatch接口,并重写了skinnableView方法。

    第一个信息,是将控件里面的所有的属性值保存到AttrsBean里面。并提供了getViewResource方法
    public class AttrsBean {
    
        private SparseIntArray resourcesMap;
        private static final int DEFAULT_VALUE = -1;
    
        public AttrsBean() {
            resourcesMap = new SparseIntArray();
        }
        public void saveViewResource(TypedArray typedArray, int[] styleable) {
            for (int i = 0; i < typedArray.length(); i++) {
                int key = styleable[i];
                int resourceId = typedArray.getResourceId(i, DEFAULT_VALUE);
                resourcesMap.put(key, resourceId);
            }
        }
        public int getViewResource(int styleable) {
            return resourcesMap.get(styleable);
        }
    }
    
    
    第二信息,当点击夜间模式切换的时候肯定会调用skinnableView,来达到换肤的目的
        public void skinnableView() {
            // 根据自定义属性,获取styleable中的background属性
            int key = R.styleable.SkinnableLinearLayout[R.styleable.SkinnableLinearLayout_android_background];
            // 根据styleable获取控件某属性的resourceId
            int backgroundResourceId = attrsBean.getViewResource(key);
            if (backgroundResourceId > 0) {
                // 兼容包转换
                Drawable drawable = ContextCompat.getDrawable(getContext(), backgroundResourceId);
                setBackground(drawable);
            }
        }
    
    对于LinearLayout,换肤的话主要是针对背景, 那如果是TextView的话,
    换肤可能不仅有背景  还有字体颜色值 。
    上面的代码就是改变背景的一段代码。如果改变字体颜色 逻辑也是类似的。
    
    Drawable drawable = ContextCompat.getDrawable(getContext(), backgroundResourceId);
    如果是白天模式: 则取白天模式的资源, 
    如果是夜间模式: 则取夜间模式的资源 
    

    在点击夜间模式切换的时候,应该先将系统的夜间模式给切换, 切换后才调用对应的skinnableView换肤


    public void toggle() {
            if (sUiNightMode == Configuration.UI_MODE_NIGHT_YES) {
                notNight();
            } else {
                night();
            }
        }
    
        public static boolean isNightMode() {
            return sUiNightMode == Configuration.UI_MODE_NIGHT_YES;
        }
    
        public void notNight() {
            updateConfig(Configuration.UI_MODE_NIGHT_NO);
            AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
            SharedPrefsUtil.save("config", "isNight", false);
            AppManager.getAppManager().changeSkin();
        }
    
        public void night() {
            updateConfig(Configuration.UI_MODE_NIGHT_YES);
            AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
            SharedPrefsUtil.save("config", "isNight", true);
            AppManager.getAppManager().changeSkin();
        }
    
    private void updateConfig(int uiNightMode) {
            AppCompatActivity activity = mActivity.get();
            if (activity == null) {
                throw new IllegalStateException("Activity went away?");
            }
            Resources resources = activity.getResources();
            DisplayMetrics dm = resources.getDisplayMetrics();
            Configuration newConfig = new Configuration(resources.getConfiguration());
            newConfig.uiMode &= ~Configuration.UI_MODE_NIGHT_MASK;
            newConfig.setLocale(getLanguageMode());
            newConfig.uiMode |= uiNightMode;
            resources.updateConfiguration(newConfig, dm);
            sUiNightMode = uiNightMode;
            if (mPrefs != null) {
                mPrefs.edit().putInt(PREF_KEY, sUiNightMode).apply();
            }
        }
    

    调用上面的toggle方法就可以将系统的白天模式和夜间模式进行切换,虽然模式切换了,但是还得调用 skinnableView方法才能将控件换肤, 所以在notNight方法和night方法最后 有这样一句代码, AppManager.getAppManager().changeSkin(); 就是将项目里面启动的每一个activity进行换肤

    public void changeSkin() {
           for (Activity activity : activityStack) {
               if(activity instanceof BaseActivity)
                   ((BaseActivity) activity).changeSkin();
           }
       }
    

    上面一直说到的换肤只是针对某一个控件进行换肤。但是一个Activity页面肯定不止一个控件
    所以这里面肯定会用到递归的逻辑。

    public void changeSkin() {
            if (needCheckLightStatusBar()) {
                checkLightStatusBar();
                forNavigation(ContextCompat.getColor(this, R.color.day_mode_background_color));
            }
            applyViews(getWindow().getDecorView());
        }
    
    protected void applyViews(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++) {
                    applyViews(parent.getChildAt(i));
                }
            }
        }
    
    

    从上面的代码看到了 applyViews(View view) 方法,这个方法会递归的调用viewsMatch.skinnableView()
    来达到换肤的目的。

    以上就是一个整个换肤的逻辑。 有什么不对的地方 欢迎大家共同探讨。

    公众号:

    qrcode_for_gh_c78cd816dc53_344.jpg

    相关文章

      网友评论

        本文标题:android 轻量级的夜间模式切换

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