美文网首页iOS开发实践伪程序员Android开发精选
知乎和简书的夜间模式实现套路

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

作者: ec95b5891948 | 来源:发表于2016-08-28 18:20 被阅读13763次

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

相关文章

网友评论

  • Rex_叶然:测试了下 refreshUI有很多问题。我选择了重启activity然后去掉 activity的切换动画:cry:
  • Rex_叶然:refreshUI 有点麻烦哦 还要去每个拿对象,当然巧妙也是只用适配这一个activity ,不知道用window 类似toast层的动画挡住activity切换呢 会不会更好呢
  • Rex_叶然:所以说 夜间模式这种功能 最好在项目初期 就说明要搞,都成型了再搞,怎么都很麻烦,要么设置中间量,要么你拿到控件对象,怎么都得把整个项目折腾一遍
  • 6bb11a3c2df4:还想知道作者是怎么逆向看到java代码的
  • a98c05e99d6a:认真写的文章,点个赞。这种方式完全打乱自己的业务逻辑,而且嵌入成本太高。实现方式太low
  • SupLuo:博主你好,主题切换以及换肤,我发布了自己的一个框架,简单好使用,大家可以讨论讨论https://github.com/SupLuo/Easy/tree/master/skin 欢迎各位star
  • 时间沙漠:楼主,还有个问题没有解决,就是checkbox,在切换主题那个页面,不管是夜间还是白天,那个背景颜色,都还是红色的,没有改变。用什么方法去改变,小白求教!
  • 你家鹏大大:你好,间书的webview夜间模式是怎么实现的呢?现在是通过注入JS实现的,但是进入页面非常不流畅。 :sob:
  • zhuhf:思路挺好的,实际View比较多的时候,还是用递归处理比较好吧?
  • fendo:赞一个!!!
  • 王元_Trump:这也太麻烦了吧, 要是控件多分离的话 那岂不是乱死了 代码量也大
    Wing_Li:@王元_Trump 有什么好办法吗?
  • b642eded025e:这样切换颜色的话,怎么做到背景有按下效果呢?也就是背景不能用自定义的drawable了吗
    b642eded025e:@V吴木木 所以自定义类型应该是reference|color吧
  • 人失忆:icon怎么换
  • 追逐丶:写得真不错(*๓´╰╯`๓)♡
  • 大大世界:其实Android的资源文件的14个维度本身就支持 Day 和 Night 模式, 只是系统现在没有切换的方式。
    Android 6.0 之前的 Preview 版本的Setting中就实现了这个功能。
    但是这个会导致Activity的 Recreate, 所以用起来也比较烦。

    你可以参考一些车机, 之前我就在4.4的代码上把这个维度给实现了。 不过用起来不方便(Activity Recreate)

    不过根据趋势的话, 将来肯定还是会回归正统, 用资源的维度去实现。
  • 张贤同学:如果我是在一个fragment中去设置夜间模式,那么怎么通知其他的fragment和activity,来改变颜色比较好?一般是否用广播?
  • 捡淑:好套路
  • 天之大任:那个动画微技巧确实厉害
  • NKming:如果这样的话,每次每个被标记的view都得放进list里面来动态改变?
  • 聆易:恍然大悟,多谢作者!
  • apkcore:终于找到一个让我很满意的答案了,谢谢大神
  • 93a6588447dc:这个很久以前一直好奇知乎的换主题效果具体实现方式 未找到未果,今天终于...,向楼主耐下心来反编译去研究的求真精神学习。 :+1:
  • 最爱那个女孩:打赏按钮在哪:sweat:
  • 小胖0_0:大饼哥每篇文章都是精品!
  • howcity:不得不说,简书和知乎的夜间模式都很丑。。。
  • eaa862745d54:initData();
    initTheme();
    setContentView(R.layout.activity_day_night);
    获取控件应该在setContentView(R.layout.activity_day_night);之后哈,否则findViewById得到的时空哦!!!!
    ec95b5891948:@eaa862745d54 :flushed: 没错啊,我的代码是这么写
  • robinxdroid:可以,很6
  • songdehuai:多谢前辈的好文!
  • 顾之MN:我们之前做的项目就是重新打开这个activity完成的,闪烁的问题完全可以关闭重新startActivity(..,..),通过overridePendingTransition(...,...)动画来规避闪烁法人问题。多fragment直接通过Intent传递当前重新启动后展示第几个fragment。也挺不错的感觉!
    Clement_wu:感觉这样不行啊,如果fragment有线程在跑呢,状态太多,复杂一点的界面效果会很差
    顾之MN:@D_clock爱吃葱花 我们当时并没有做着个处理,重启后自动在最顶端,其实这个也能让人接受的。ListView和RecycleView这类的滚动到指定位置我们在切换的可以获取到当前所在位置也能保存下来,不过如果涉及到Item数据分页了可能麻烦点,就和你说的一样保存状态这个确实恶心,这个就是实现起来简单些,产品同意的话就一切好说! :smile:
    ec95b5891948:@maning0303 那fragment中的状态呢?例如当前有个列表滚动到某个位置,重启后显示fragment能恢复列表之前位置的状态吗?
  • 大鴈:师兄牛逼
  • 喧嚣的风儿:感谢 :smile:
    想问下文章中关于recycleView的刷新: recycledViewPool.clear();清掉了缓冲就会自动重新call onBindViewHolder 来重新加载吗?
    ec95b5891948:@喧嚣的风儿 对的
  • DreamWinter:感觉不至于这么麻烦
    9711922c6b29:@DreamWinter 同样感觉挺麻烦,不过都是这么实现的。不知道层主有什么技巧?

本文标题:知乎和简书的夜间模式实现套路

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