知乎和簡書的Android客戶端夜間模式實現方式

轉載自http://blog.coderclock.com/2016/08/28/android/知乎和簡書的夜間模式實現套路/

說到夜間模式,在網上看到很多童鞋都說用什么什么框架來實現這個功能,然后仔細去看一下各個推薦的框架,發現其實都是動態換膚的,動態換膚可比夜間模式要復雜多了,未免大材小用了。說實話,我一直沒用什么好思路,雖然網上有童鞋提供了一種思路是通過 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>

需要注意的是,上面的 clockTextColorclockBackground 是我自定義的 color 類型屬性

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

然后再到所有需要實現夜間模式功能的 xml 布局文件中,加入類似下面設置,比如我在 RecyclerViewItem 布局文件中做了如下設置


稍稍解釋下其作用,如 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(typedValuse.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 函數中被初始化,其他的 ViewLayout 都是界面布局,在 initView 函數中被初始化;

2.在 ActivityonCreate 函數調用 setContentView 之前,需要先去 setTheme,因為當 View 創建成功后 ,再去 setTheme 是無法對 ViewUI 效果產生影響的;

3.onCheckedChanged 用于監聽日間模式和夜間模式的切換操作;

4.refreshUI 是本實現的關鍵函數,起著切換效果的作用,通過 TypedValueTheme.resolveAttribute 在代碼中獲取 Theme 中設置的顏色,來重新設置控件的背景色或者字體顏色等等。需要特別注意的是 RecyclerViewListView 這種比較特殊的控件處理方式,代碼注釋中已經說明,大家可以看代碼中注釋

5.refreshStatusBar 用于刷新頂部通知欄位置的顏色;

6.showAnimationgetCacheBitmapFromView 同樣是本實現的關鍵函數getCacheBitmapFromView 用于將 View 中的內容轉換成 Bitmap(類似于截屏操作那樣),showAnimation 是用于展示一個漸隱效果的屬性動畫,這個屬性作用在哪個對象上呢?是一個 View ,一個在代碼中動態填充到 DecorView 中的 View(不知道 DecorView 的童鞋得回去看看 Android Window 相關的知識)。知乎之所以在夜間模式切換過程中會有漸隱效果,是因為在切換前進行了截屏,同時將截屏拿到的 Bitmap 設置到動態填充到 DecorView 中的 View 上,并對這個 View 執行一個漸隱的屬性動畫,所以使得我們能夠看到一個漂亮的漸隱過渡的動畫效果。而且在動畫結束的時候再把這個動態添加的 Viewremove 了,避免了 Bitmap 造成內存飆升問題。對待知乎客戶端開發者這種處理方式,我必須雙手點贊外加一個大寫的服。

到這里,實現套路基本說完了,簡書和知乎的實現套路如上所述,區別就是知乎多了個截屏和漸隱過渡動畫效果而已。


一些思考

整理逆向分析的過程,也對夜間模式的實現有了不少思考,希望與各位童鞋們探討分享。

最初步的逆向分析過程就發現了,知乎和簡書并沒有引入任何第三方框架來實現夜間模式,為什么呢?

因為我看到的大部分都實現夜間模式的思路都是用開源的換膚框架,或多或少存在著些 BUG。簡書和知乎不用可能是出于框架不穩定性,以及我前面提到的用換膚框架來實現夜間模式大材小用吧。(我也只是瞎猜,哈哈哈)

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

我認為不可行的原因有兩點,一個是 Activity recreate 會有閃爍效果體驗不加,二是 Activity recreate 涉及到狀態狀態保存問題,如自身的狀態保存,如果 Activity 中包含著多個 Fragment ,那就更加頭疼了。

知乎和簡書設置夜間模式的位置,有點巧妙,巧妙在哪?

知乎和簡書出發夜間模式切換的地方,都是在 MainActivity 的一個 Fragment 中。也就是說,如果你要切換模式時,必須回到主界面,此時只存在主界面一個 Activity,只需要遍歷主界面更新控件色調即可。而對于其他設置夜間模式后新建的 Activity ,只需要在 setContentView 之前做一下判斷并 setTheme 即可。


總結

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,362評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,013評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,346評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,421評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,146評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,534評論 1 325
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,585評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,767評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,318評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,074評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,258評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,828評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,486評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,916評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,156評論 1 290
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,993評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,234評論 2 375

推薦閱讀更多精彩內容