返回 登录
6

知乎和简书的夜间模式实现套路

Hello,大家好,我是 Clock。今天要写的这篇文章主题是关于夜间模式的实现套路。本来这篇文章是上周要写的,结果因为上周末有其他事情,所以拖到这个周末才完成。曾经和薇薇(钛媒体漂亮的程序媛)聊过夜间模式实现的问题,当时薇薇酱负责钛媒体客户端的重构工作,有个夜间模式功能在考虑要不要用 Android M 新加的夜间模式特性。凭借稍微有点老司机的经验,我直接说了 NO。按照以往的套路,通常新出的功能都会有坑,或者向下兼容性的问题。自己弄弄 Demo 玩玩是可以的,但是引入企业开发还是谨慎点,说白了就是先等等,让大家把坑填完了再用。果然,Android M 发正式版的时候,预览版里面的夜间模式功能被暂时移除了(哈哈哈哈,机智如我,最新发布的 Android N 正式版已经有夜间模式了,大家可以去玩玩)。

前言

好了,回归正题,说回夜间模式。在网上看到很多童鞋都说用什么什么框架来实现这个功能,然后仔细去看一下各个推荐的框架,发现其实都是动态换肤的,动态换肤可比夜间模式要复杂多了,未免大材小用了。说实话,我一直没用什么好思路,虽然网上有童鞋提供了一种思路是通过 setTheme 然后再 recreate Activity 的方式,但是这样带来的问题是非常多的,看起来就相当不科学(为什么不科学,后文会说)。于是,直接想到了去逆向分析那些夜间模式做得好的应用的源代码,学习他们的实现套路。所以,本文的实现思路来自于编写这些应用的夜间模式功能的童鞋,先在这里向他们表示感谢。我的手机里面使用高频的应用不少,其中简书和知乎是属于夜间模式做得相当 nice 的。先给两个效果图大家对比感受下

简书 知乎

如果大家仔细观察,肯定会发现,知乎的切换效果更漂亮些,因为它有一个渐变的效果。那么它们的夜间模式到底是如何实现的呢?别急接着往下看,你也可以。

实现套路

这里先展示一下我的实现效果吧

简书实现效果 知乎实现效果

此处分为两个部分,一部分是 xml 文件中要干的活,一部分是 Java 代码要实现的活,先说 xml 吧。

XML 配置

首先,先写一套UI界面出来,上方左边是两个 TextView,右边是两个 CheckBox,下方是一个 RecyclerView ,实现很简单,这里我不贴代码了。

接着,在 styles 文件中添加两个 Theme,一个是日间主题,一个是夜间主题。它们的属性都是一样的,唯一区别在于颜色效果不同。


    <!--白天主题-->
    <style name="DayTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <item name="colorPrimary">@color/colorPrimary</item>
        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
        <item name="colorAccent">@color/colorAccent</item>
        <item name="clockBackground">@android:color/white</item>
        <item name="clockTextColor">@android:color/black</item>
    </style>

    <!--夜间主题-->
    <style name="NightTheme" parent="Theme.AppCompat.Light.DarkActionBar">
        <item name="colorPrimary">@color/color3F3F3F</item>
        <item name="colorPrimaryDark">@color/color3A3A3A</item>
        <item name="colorAccent">@color/color868686</item>
        <item name="clockBackground">@color/color3F3F3F</item>
        <item name="clockTextColor">@color/color8A9599</item>
    </style>

需要注意的是,上面的 clockTextColor 和 clockBackground 是我自定义的 color 类型属性

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <attr name="clockBackground" format="color" />
    <attr name="clockTextColor" format="color" />
</resources>

然后再到所有需要实现夜间模式功能的 xml 布局文件中,加入类似下面设置,比如我在 RecyclerView 的 Item 布局文件中做了如下设置

稍稍解释下其作用,如 TextView 里的 android:textColor=”?attr/clockTextColor” 是让其字体颜色跟随所设置的 Theme。到这里,xml 需要做的配置全部完成,接下来是 Java 代码实现了。

Java 代码实现

大家可以先看下面的实现代码,看不懂的童鞋可以边结合我代码下方实现思路解说。


package com.clock.study.activity;

import ...

/**
 * 夜间模式实现方案
 *
 * @author Clock
 * @since 2016-08-11
 */
public class DayNightActivity extends AppCompatActivity implements CompoundButton.OnCheckedChangeListener {

    private final static String TAG = DayNightActivity.class.getSimpleName();
    /**用于将主题设置保存到SharePreferences的工具类**/
    private DayNightHelper mDayNightHelper;

    private RecyclerView mRecyclerView;

    private LinearLayout mHeaderLayout;
    private List<RelativeLayout> mLayoutList;
    private List<TextView> mTextViewList;
    private List<CheckBox> mCheckBoxList;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        supportRequestWindowFeature(Window.FEATURE_NO_TITLE);
        initData();
        initTheme();
        setContentView(R.layout.activity_day_night);
        initView();
    }

    private void initView() {
        mRecyclerView = (RecyclerView) findViewById(R.id.recycler_view);
        RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false);
        mRecyclerView.setLayoutManager(layoutManager);
        mRecyclerView.setAdapter(new SimpleAuthorAdapter());

        mHeaderLayout = (LinearLayout) findViewById(R.id.header_layout);

        mLayoutList = new ArrayList<>();
        mLayoutList.add((RelativeLayout) findViewById(R.id.jianshu_layout));
        mLayoutList.add((RelativeLayout) findViewById(R.id.zhihu_layout));

        mTextViewList = new ArrayList<>();
        mTextViewList.add((TextView) findViewById(R.id.tv_jianshu));
        mTextViewList.add((TextView) findViewById(R.id.tv_zhihu));

        mCheckBoxList = new ArrayList<>();
        CheckBox ckbJianshu = (CheckBox) findViewById(R.id.ckb_jianshu);
        ckbJianshu.setOnCheckedChangeListener(this);
        mCheckBoxList.add(ckbJianshu);
        CheckBox ckbZhihu = (CheckBox) findViewById(R.id.ckb_zhihu);
        ckbZhihu.setOnCheckedChangeListener(this);
        mCheckBoxList.add(ckbZhihu);

    }

    @Override
    public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) {
        int viewId = buttonView.getId();
        if (viewId == R.id.ckb_jianshu) {
            changeThemeByJianShu();

        } else if (viewId == R.id.ckb_zhihu) {
            changeThemeByZhiHu();

        }
    }

    private void initData() {
        mDayNightHelper = new DayNightHelper(this);
    }

    private void initTheme() {
        if (mDayNightHelper.isDay()) {
            setTheme(R.style.DayTheme);
        } else {
            setTheme(R.style.NightTheme);
        }
    }

    /**
     * 切换主题设置
     */
    private void toggleThemeSetting() {
        if (mDayNightHelper.isDay()) {
            mDayNightHelper.setMode(DayNight.NIGHT);
            setTheme(R.style.NightTheme);
        } else {
            mDayNightHelper.setMode(DayNight.DAY);
            setTheme(R.style.DayTheme);
        }
    }

    /**
     * 使用简书的实现套路来切换夜间主题
     */
    private void changeThemeByJianShu() {
        toggleThemeSetting();
        refreshUI();
    }

    /**
     * 使用知乎的实现套路来切换夜间主题
     */
    private void changeThemeByZhiHu() {
        showAnimation();
        toggleThemeSetting();
        refreshUI();
    }

    /**
     * 刷新UI界面
     */
    private void refreshUI() {
        TypedValue background = new TypedValue();//背景色
        TypedValue textColor = new TypedValue();//字体颜色
        Resources.Theme theme = getTheme();
        theme.resolveAttribute(R.attr.clockBackground, background, true);
        theme.resolveAttribute(R.attr.clockTextColor, textColor, true);

        mHeaderLayout.setBackgroundResource(background.resourceId);
        for (RelativeLayout layout : mLayoutList) {
            layout.setBackgroundResource(background.resourceId);
        }
        for (CheckBox checkBox : mCheckBoxList) {
            checkBox.setBackgroundResource(background.resourceId);
        }
        for (TextView textView : mTextViewList) {
            textView.setBackgroundResource(background.resourceId);
        }

        Resources resources = getResources();
        for (TextView textView : mTextViewList) {
            textView.setTextColor(resources.getColor(textColor.resourceId));
        }

        int childCount = mRecyclerView.getChildCount();
        for (int childIndex = 0; childIndex < childCount; childIndex++) {
            ViewGroup childView = (ViewGroup) mRecyclerView.getChildAt(childIndex);
            childView.setBackgroundResource(background.resourceId);
            View infoLayout = childView.findViewById(R.id.info_layout);
            infoLayout.setBackgroundResource(background.resourceId);
            TextView nickName = (TextView) childView.findViewById(R.id.tv_nickname);
            nickName.setBackgroundResource(background.resourceId);
            nickName.setTextColor(resources.getColor(textColor.resourceId));
            TextView motto = (TextView) childView.findViewById(R.id.tv_motto);
            motto.setBackgroundResource(background.resourceId);
            motto.setTextColor(resources.getColor(textColor.resourceId));
        }

        //让 RecyclerView 缓存在 Pool 中的 Item 失效
        //那么,如果是ListView,要怎么做呢?这里的思路是通过反射拿到 AbsListView 类中的 RecycleBin 对象,然后同样再用反射去调用 clear 方法
        Class<RecyclerView> recyclerViewClass = RecyclerView.class;
        try {
            Field declaredField = recyclerViewClass.getDeclaredField("mRecycler");
            declaredField.setAccessible(true);
            Method declaredMethod = Class.forName(RecyclerView.Recycler.class.getName()).getDeclaredMethod("clear", (Class<?>[]) new Class[0]);
            declaredMethod.setAccessible(true);
            declaredMethod.invoke(declaredField.get(mRecyclerView), new Object[0]);
            RecyclerView.RecycledViewPool recycledViewPool = mRecyclerView.getRecycledViewPool();
            recycledViewPool.clear();

        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }

        refreshStatusBar();
    }

    /**
     * 刷新 StatusBar
     */
    private void refreshStatusBar() {
        if (Build.VERSION.SDK_INT >= 21) {
            TypedValue typedValue = new TypedValue();
            Resources.Theme theme = getTheme();
            theme.resolveAttribute(R.attr.colorPrimary, typedValue, true);
            getWindow().setStatusBarColor(getResources().getColor(typedValue.resourceId));
        }
    }

    /**
     * 展示一个切换动画
     */
    private void showAnimation() {
        final View decorView = getWindow().getDecorView();
        Bitmap cacheBitmap = getCacheBitmapFromView(decorView);
        if (decorView instanceof ViewGroup && cacheBitmap != null) {
            final View view = new View(this);
            view.setBackgroundDrawable(new BitmapDrawable(getResources(), cacheBitmap));
            ViewGroup.LayoutParams layoutParam = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                    ViewGroup.LayoutParams.MATCH_PARENT);
            ((ViewGroup) decorView).addView(view, layoutParam);
            ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(view, "alpha", 1f, 0f);
            objectAnimator.setDuration(300);
            objectAnimator.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    super.onAnimationEnd(animation);
                    ((ViewGroup) decorView).removeView(view);
                }
            });
            objectAnimator.start();
        }
    }

    /**
     * 获取一个 View 的缓存视图
     *
     * @param view
     * @return
     */
    private Bitmap getCacheBitmapFromView(View view) {
        final boolean drawingCacheEnabled = true;
        view.setDrawingCacheEnabled(drawingCacheEnabled);
        view.buildDrawingCache(drawingCacheEnabled);
        final Bitmap drawingCache = view.getDrawingCache();
        Bitmap bitmap;
        if (drawingCache != null) {
            bitmap = Bitmap.createBitmap(drawingCache);
            view.setDrawingCacheEnabled(false);
        } else {
            bitmap = null;
        }
        return bitmap;
    }
}

实现思路和代码解说:

  1. DayNightHelper 类是用于保存夜间模式设置到 SharePreferences 的工具类,在 initData 函数中被初始化,其他的 View 和 Layout 都是界面布局,在 initView 函数中被初始化;
  2. 在 Activity 的 onCreate 函数调用 setContentView 之前,需要先去 setTheme,因为当 View 创建成功后 ,再去 setTheme 是无法对 View 的 UI 效果产生影响的;
  3. onCheckedChanged 用于监听日间模式和夜间模式的切换操作;
  4. refreshUI 是本实现的关键函数,起着切换效果的作用,通过 TypedValue 和 Theme.resolveAttribute 在代码中获取 Theme 中设置的颜色,来重新设置控件的背景色或者字体颜色等等。需要特别注意的是 RecyclerView 和 ListView 这种比较特殊的控件处理方式,代码注释中已经说明,大家可以看代码中注释
  5. refreshStatusBar 用于刷新顶部通知栏位置的颜色;
  6. showAnimation 和 getCacheBitmapFromView 同样是本实现的关键函数,getCacheBitmapFromView 用于将 View 中的内容转换成 Bitmap(类似于截屏操作那样),showAnimation 是用于展示一个渐隐效果的属性动画,这个属性作用在哪个对象上呢?是一个 View ,一个在代码中动态填充到 DecorView 中的 View(不知道 DecorView 的童鞋得回去看看 Android Window 相关的知识)。知乎之所以在夜间模式切换过程中会有渐隐效果,是因为在切换前进行了截屏,同时将截屏拿到的 Bitmap 设置到动态填充到 DecorView 中的 View 上,并对这个 View 执行一个渐隐的属性动画,所以使得我们能够看到一个漂亮的渐隐过渡的动画效果。而且在动画结束的时候再把这个动态添加的 View 给 remove 了,避免了 Bitmap 造成内存飙升问题。对待知乎客户端开发者这种处理方式,我必须双手点赞外加一个大写的服。

到这里,实现套路基本说完了,简书和知乎的实现套路如上所述,区别就是知乎多了个截屏和渐隐过渡动画效果而已。

一些思考

整理逆向分析的过程,也对夜间模式的实现有了不少思考,希望与各位童鞋们探讨分享。

最初步的逆向分析过程就发现了,知乎和简书并没有引入任何第三方框架来实现夜间模式,为什么呢?

因为我看到的大部分都实现夜间模式的思路都是用开源的换肤框架,或多或少存在着些 BUG。简书和知乎不用可能是出于框架不稳定性,以及我前面提到的用换肤框架来实现夜间模式大材小用吧。(我也只是瞎猜,哈哈哈)

前面我提到,通过 setTheme 然后再去 Activity recreate 的方案不可行,为什么呢?

我认为不可行的原因有两点,一个是 Activity recreate 会有闪烁效果体验不加,二是 Activity recreate 涉及到状态状态保存问题,如自身的状态保存,如果 Activity 中包含着多个 Fragment ,那就更加头疼了。

知乎和简书设置夜间模式的位置,有点巧妙,巧妙在哪?

知乎和简书出发夜间模式切换的地方,都是在 MainActivity 的一个 Fragment 中。也就是说,如果你要切换模式时,必须回到主界面,此时只存在主界面一个 Activity,只需要遍历主界面更新控件色调即可。而对于其他设置夜间模式后新建的 Activity ,只需要在 setContentView 之前做一下判断并 setTheme 即可。

总结

关于简书和知乎夜间模式功能实现的套路就讲解到这里,整个实现套路都是我通过逆向分析简书和知乎的代码取得,这里再一次向实现这些代码的童鞋以示感谢。当然,上面的代码我是经过精简提炼过的,在原先简书和知乎客户端中的实现代码还做了相应的抽象设计和递归遍历等等,这里是为了方便讲解而做了精简。如果有童鞋喜欢这种实现套路,也可以自己加以抽象封装。这里也推荐各位童鞋一个我常用的思路,就是当你对一个功能没有思路时,大可找一些实现了这类功能的优秀应用进行逆向代码分析。需要实现代码的童鞋,可以访问:

https://github.com/D-clock/AndroidStudyCode

也欢迎大家关注我的简书Github


2016 年 9 月 23-24 日,由 CSDN 和创新工场联合主办的“MDCC 2016 移动开发者大会• 中国”(Mobile Developer Conference China)将在北京• 国家会议中心召开,来自 iOS、Android、跨平台开发、产品设计、VR 开发、移动直播、人工智能、物联网、硬件开发、信息无障碍10个领域的技术专家将分享他们在各自行业的真知灼见。

从 8 月 8 日起至 9 月 4 日,MDCC 大会门票处于 6.8 折优惠票价阶段,五人以上团购更有特惠,限量供应(票务详情链接6.8折优惠,欲购从速!

评论