知乎和簡書的夜間模式實現套路

jpvs7631 8年前發布 | 14K 次閱讀 安卓開發 Android開發 移動開發

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 即可。

總結

關于簡書和知乎夜間模式功能實現的套路就講解到這里, 整個實現套路都是我通過逆向分析簡書和知乎的代碼取得,這里再一次向實現這些代碼的童鞋以示感謝。 當然,上面的代碼我是經過精簡提煉過的,在原先簡書和知乎客戶端中的實現代碼還做了相應的抽象設計和遞歸遍歷等等,這里是為了方便講解而做了精簡。如果有童鞋喜歡這種實現套路,也可以自己加以抽象封裝。這里也推薦各位童鞋一個我常用的思路,就是當你對一個功能沒有思路時,大可找一些實現了這類功能的優秀應用進行逆向代碼分析。需要實現代碼的童鞋,

 

來自:http://www.jianshu.com/p/3b55e84742e5

 

 本文由用戶 jpvs7631 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!