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