轉載自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>
需要注意的是,上面的 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(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
函數中被初始化,其他的 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