您的位置:首页 > 移动开发 > Android开发

Android夜间模式调研总结

2016-09-09 09:57 579 查看
一、综述
已经有很多成熟的产品实现了夜间模式 ,总体来讲,此功能已经比较成熟,但是具体到单个产品,是否一定要上夜间模式还是需要慎重考虑的,因为对成熟产品的改造需要很大的工作量,并且夜间模式作为一种规范,会在后续的开发过程中不断影响工作量投入(每实现一个新的界面,都需要考虑对夜间模式的支持)。如果是一个对界面样式没有强烈要求的应用,不妨考虑通过降低屏幕亮度或者给整体界面增加半透明蒙层来支持夜间阅读效果。

1.1 降低屏幕亮度
操作屏幕亮度需要权限:
[align=left]
<uses-permission android:name="android.permission.WRITE_SETTINGS"/>
[/align]
有两种降低屏幕亮度的方案:
1)降低单个Activity亮度
主要代码如下:
public static void setBrightness(Activity activity, float brightness) {
Window window = activity.getWindow();

WindowManager.LayoutParams layoutParams = window.getAttributes();
brightness = Math.max( 0, Math.min(brightness, 1));
layoutParams.screenBrightness = brightness;

window.setAttributes(layoutParams);
}

2)降低手机系统全局亮度
主要思路:
切换模式时,获取手机当前的亮度值并保存(用于退出应用时还原到此亮度) ---> 如果手机打开自动亮度调节则关闭自动调节, 再设置合适的较低亮度(夜间模式) --->退出应用时还原保存的初始亮度,并重设自动亮度调节模式。

主要代码如下:
获取当前亮度:
/**
* 获取当前系统亮度 ,失败返回defaultValue,获取成功返回正常非负数 (0~255)
*/
public static int getSystemBrightness(Context context, int defaultValue) {
try {
return Settings.System. getInt(
context.getContentResolver(), Settings.System.SCREEN_BRIGHTNESS );
} catch (Exception e) {
e.printStackTrace();
}
return defaultValue;
}

获取当前的亮度调节模式:
/**
* 自动调节亮度的配置
* 返回Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC或SCREEN_BRIGHTNESS_MODE_MANUAL
*/
public static int getScreenBrightnessMode(Context context) {
try {
return Settings.System. getInt(context.getContentResolver(),
Settings.System.SCREEN_BRIGHTNESS_MODE );
} catch (Exception e) {
e.printStackTrace();
}
//0 手动 1自动,可以用 -1代表未知
return -1 ;
}

设置亮度:
/**亮度介于0~255之间 */
public static void saveBrightness(
ContentResolver contentResolver, int brightnessValue) {
String uriName = Settings.System.SCREEN_BRIGHTNESS ;
Uri uri = android.provider.Settings.System.getUriFor(uriName);
android.provider.Settings.System.putInt(
contentResolver, uriName, brightnessValue);
contentResolver.notifyChange(uri, null);
}

设置亮度调节模式:
/**
* 设置自动调节亮度模式
*/
public static void setScreenBrightnessMode(Context context, int mode) {
if (mode != Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL
&& mode != Settings.System.SCREEN_BRIGHTNESS_MODE_AUTOMATIC ) {
return;
}

Settings.System.putInt(context.getContentResolver(),
Settings.System.SCREEN_BRIGHTNESS_MODE , mode);
}

注意:如果应用异常关闭,系统亮度就无法恢复。

1.2 半透明黑色蒙层
给界面增加一层半透明事件穿透(不可点击不能focus)的全屏View,减少屏幕亮度。
WindowManager manager = (WindowManager)getSystemService(Context.WINDOW_SERVICE);
WindowManager.LayoutParams params = new WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT ,WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.TYPE_APPLICATION ,
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE |WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
PixelFormat.TRANSLUCENT);

params.gravity = Gravity.TOP;
params.y = 10 ;

TextView tv = new TextView( this);
tv.setBackgroundColor(0xAA000000 );
manager.addView(tv,params);


二、常用的夜间模式实现
上面提了两种简单的支持用户夜间阅读的方案,不过很明显,这两种方案都并不让人满意,因为方案的最终效果是以降低色差对比度来在夜间保护眼睛的,这会影响用户的信息获取能力。对于一个复杂或者说用户量很大的应用,我们需要一套真正的夜间模式。
真正的夜间模式应该是什么样的呢?
手机屏幕上展现的数据,基本就是文字/图片/视频/音频四种基本形式,音频不用考虑。我们应该采取四个步骤来实现一个好的夜间模式:
1)对界面背景,正常模式下白色等浅色背景应该变成黑色/灰色之类的深色背景,来保证屏幕亮度的降低,避免夜间阅读对眼睛的伤害;
2)对文字,因为应用大部分区域的颜色都变为了深色,为了让用户能方便的看到深色背景上的内容,文字字体颜色应该变为浅色,来形成对比效果;
3)对图片,应用内可能加载深色图片和浅色图片,一个好的夜间模式,应该对图片上一层蒙板,来避免加载浅色图片带来的视觉刺激;
4)对视频,视频部分不建议使用蒙板来降低亮度,因为可能会影响用户的信息接收能力,通常是在播放界面增加亮度变化功能,由用户来决定屏幕亮度。

回到夜间模式实现的主题,我们的最大挑战在于前两条,因为对图片的蒙层,我们可以通过第一节中的全局蒙层或屏幕亮度来处理,不是急迫需要解决的部分,对视频的亮度调节,本身就可以作为视频一项功能实现,实现方法上一节也有描述了。
另外,Android自身对夜间模式的支持很弱,很多方案都要求切换了夜间模式后recreate界面来使切换生效,但这是非常影响体验的。替代方案说来也很简单:在界面View树上找到所有需要切换模式的View,遍历View来确定哪些View需要刷新界面。不过说来简单,做起来却很复杂,这种方案要考虑动态添加View的情况,包括代码内动态添加View、ListView、RecyclerView、GridView、ViewPager等容器的情形,还要考虑如果创建了多个Activity时,对每个Activity进行遍历,性能消耗不小。

Android切换夜间模式影响界面上所有View支持的drawable的属性(background/src等)和支持颜色的属性(textColor/textColorHint),我们需要一种方式来告诉View在切换到夜间模式时和正常情况下,应该使用哪种资源进行展示。目前有以下几种方式:

2.1 切换theme
利用Android主题来实现夜间模式需要四步:
第一步,在attr.xml中定义属性:
<resources>
<attr name="textColor" format="reference|color"/>
<attr name="background" format="reference|color"/>
</resources>

第二步,然后在style中定义属性值:
<style name="AppTheme_Light" >
<item name="textColor">@android:color/black</ item>
<item name="android:background">@android:color/white</ item>
</style>

<style name="AppTheme_Night">
<item name="textColor">@android:color/white</ item>
<item name="android:background">@android:color/black</ item>
</style>

第三步,View上应用style(?attr/nbackground表示使用style内的此属性作为view 的background):
<TextView
android :id="@+id/textview"
android :layout_width="wrap_content"
android :layout_height="wrap_content"
android :text="Hello World!"
android :background="?attr/nbackground"
android :textColor="?attr/ntextColor"/>

第四步,在Java代码内根据情况设置主题并刷新界面:
isNight = !isNight ;
if(isNight ) {
setTheme(R.style.AppTheme_Night );
} else {
setTheme(R.style.AppTheme_Light );
}
refreshUI();

private void refreshUI() {
TypedValue background = new TypedValue();
TypedValue textColor = new TypedValue();
Resources.Theme theme = getTheme();
theme.resolveAttribute(R.attr.nbackground, background, true);
theme.resolveAttribute(R.attr.ntextColor, textColor, true);
mTextView .setTextColor(textColor.data);
mTextView.setBackgroundResource(background.resourceId);
}

一般来讲,setTheme方法要在setContentView之前调用才能生效,那么用户点击界面切换夜间模式怎么才能快速生效呢?Android提供的是recreate重新创建Activity,但这种体验非常差,所以一般都是通过类似上面的解析来解析出属性,遍历View树来动态刷新。
优点:Android主题本来就是为控制界面展示而设计的,使用此方案,系统支持程度高,相关接口完善。
缺点:1)动态遍历的性能消耗;2)应用的颜色、drawable比较多,需要定义很多自定义属性,style文件会越来越大。

2.2 换肤框架
换肤框架有很多种实现,但是用来做夜间模式,稍微有点大材小用的感觉。这里简单的说下换肤框架的原理:
1)基于theme的内部资源加载(定义attr/theme,使用setTheme切换),和上面的2.1相同

2)利用View的Tag
代表框架:AndroidChangeSkin,通过View的Tag来存储夜间模式的Drawable/Color引用
多套皮肤使用相同名称加不同后缀来区分,假设文本颜色item_text_color有一套默认皮肤,一套绿色皮肤一套红色皮肤,则要定义三个资源item_text_color,item_text_color_red,item_text_color_green。
优点:Android支持度高
缺点:需要自定义Tag;部分View的Tag被其他逻辑占用
用法举例:
<TextView
android :layout_width="wrap_content"
android :layout_height="wrap_content"
android :tag="skin:item_text_color:textColor"
android :text="@string/hello_world"
android :textColor="@color/item_text_color"/>


3)自定义View(在setTheme后遍历并立刻刷新View)
自定义View来实现主题切换,在XML内全部使用自定义的View。
代表框架:MultipleTheme
优点:灵活性比较高,每类View都可以自己决定如何支持夜间模式
缺点:对代码的侵入性较大,xml和java代码都有不小的改动
弥补措施:自定义LayoutInflater的创建View方法,返回自定义View
用法举例:
public interface IThemeChange {
void changeTheme(int theme);
}

再自定义一套View使用:
public class ThemeTextView extends TextView implements IThemeChange {
public ThemeTextView(Context context) {
super(context);
}

public ThemeTextView(Context context, AttributeSet attrs) {
super(context, attrs);
}

@Override
public void changeTheme(int theme) {
//change theme
}
}

最后对界面所有View调用刷新逻辑:
for(View view : mViews) {
if (view instanceof IThemeChange) {
((IThemeChange) view).changeTheme(theme);
}
}

弥补措施可以见2.3的实现。

4)手动绑定View和要改变的资源类型
本质还是setTheme的应用,只是封装了调用关系
代表框架:Colorful
优点:成熟的框架
缺点:1)在每个Activity内需要指定所有View的属性,对java代码侵入性比较大;2)采用setTheme要定义很多attr和style的值。
用法举例:
/**
* 设置Activivty各个视图与颜色属性的关联
*/
private void setupColorful() {
ViewGroupSetter listViewSetter = new ViewGroupSetter(mNewsListView);
// 绑定ListView的Item View 中的news_title视图,在换肤时修改它的text_color属性
listViewSetter.childViewTextColor(R.id.news_title, R.attr.text_color);

// 构建Colorful对象来绑定View与属性的对象关系
mColorful = new Colorful.Builder( this)
.backgroundDrawable(R.id.root_view, R.attr.root_view_bg)
// 设置 view的背景图片
.backgroundColor(R.id.change_btn, R.attr.btn_bg)
// 设置背景色
.textColor(R.id.textview, R.attr.text_color)
.setter(listViewSetter) // 手动设置 setter
.create(); // 设置文本颜色
}

/**
* 切换主题
*/
private void changeThemeWithColorful() {
if (!isNight) {
mColorful.setTheme(R.style.NightTheme);
} else {
mColorful.setTheme(R.style.DayTheme);
}
isNight = !isNight;
}


5)动态资源替换,替换resources,
代表框架:AndroidSkinLoader
AndroidSkinLoader利用的是2.3节中谈到的代理LayoutInflater的onCreateView过程来创建View的原理,在创建View的过程中将View的backgound/textColor等属性的值取出,并与View一起存到列表内,在切换皮肤时遍历列表,通过对原始id/属性值做转化,找到当前皮肤对应的资源id/属性值,刷新View。
优点:对现有布局和java代码影响比较小
缺点:1)依赖系统实现,存在兼容性问题,存在接口变化的可能(Android 5.1就修改了getDrawable接口的定义);2)皮肤文件在新的apk包内,框架加载新的apk包来换肤。
用法举例:
<TextView
android :id="@+id/detail_text"
android :layout_width="wrap_content"
android :layout_height="wrap_content"
android :lineSpacingExtra="6sp"
android :layout_margin="10dp"
skin :enable="true"
android :textSize="18sp"
android:textColor="@color/color_new_item_synopsis" />

Java代码:
private static final String SKIN_NAME = "BlackFantacy.skin";
private static final String SKIN_DIR = Environment
.getExternalStorageDirectory() + File.separator + SKIN_NAME;
File skin = new File(SKIN_DIR);
SkinManager.getInstance().load(skin.getAbsolutePath(),
new ILoaderListener() {
@Override
public void onStart() {
}

@Override
public void onSuccess() {
}

@Override
public void onFailed() {
}
});

注意:皮肤包(后缀名为.skin,BlackFantacy.skin)本质上是一个apk文件,该apk文件不包含代码,只包含资源文件
在皮肤包工程中(示例工程为skin/BlackFantacy)添加需要换肤的同名的资源文件,直接编译生成apk文件,再更改后缀名为.skinj即可(防止用户点击安装)

6)对resources进行hack,替换sPreloadedDrawables/sPreloadedColorDrawables/sPreloadedColorStateLists,
Resources类将各种加载的资源文件用上面几个成员变量保存了起来,通过反射可以把已加载的资源替换掉,然后刷新界面即可换肤。
优点:对现有布局和java代码影响比较小
缺点:1)依赖系统实现,存在兼容性问题;2)要完成drawable加载、XML解析、drawable padding、9png等问题;
暂未找到开源方案,但头条里有这部分代码,估计使用了此策略。

2.3 代理LayoutInflater
LayoutInflater用来加载所有XML的布局,并将其解释成View,我们可以通过改变生成View的过程来解析自定义属性,为View设置合适的白天/夜间模式资源,刷新时通过View树遍历来更新界面。但是LayoutInflater只用于加载xml布局,对于动态生成的View,还是需要特殊处理。

LayoutInflater的setFactory/setFactory2接口可以优先生成View。
首先我们需要自定义属性:
<declare-styleable name="nightMode" >
<attr name="src" format="reference|color" />
<attr name="textColor" format="reference|color"/>
<attr name="background" format="reference|color"/>
</declare-styleable >

然后在布局内使用:
<TextView
android :id="@+id/textview"
android :layout_width="wrap_content"
android :layout_height="wrap_content"
android :text="Hello World!"
nightMode :background="#000000"
nightMode :textColor="#FFFFFF"/>

最后在界面onCreate之前将属性读出设置到View内存储起来:
LayoutInflater. from(this).setFactory2(new LayoutInflater.Factory2() {
@Override
public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {
try {
View view = createView(layoutInflater, privateFactory ,
parent, name, context, attrs);
TypedArray array = context.obtainStyledAttributes(
attrs, R.styleable.nightMode );
int bgDrawableId = array.getResourceId(R.styleable.nightMode_background , 0 );
int textColor = array.getColor(R.styleable.nightMode_textColor , Integer.MAX_VALUE );

if(0 != bgDrawableId) {
view.setTag(R.id.nightMode_background , bgDrawableId);
}
if(Integer.MAX_VALUE != textColor) {
view.setTag(R.id.dayMode_textColor , textColor);
}

return view;
} catch (Exception ex){
Log.d( TAG, "onCreateView1()| error happened", ex);
return null;
}
}
}

每个View都存储自己的日间模式和夜间模式的相关属性,切换时遍历界面View树,重新设置相关属性。
此方案对于的简化方案是在XML内定义TAG:
<TextView
android :id="@+id/textview"
android :layout_width="wrap_content"
android :layout_height="wrap_content"
android :text="Hello World!">

<tag
android:id="@id/dayMode_background"
android:value= "@mipmap/ic_launcher" />
</TextView>

但Android4.0以下不支持此tag标签,而且这种写法的会导致xml布局可读性下降。

2.4 资源id映射
维护一个白天模式资源与夜间模式资源的一一映射关系,在动态刷新界面时,通过当前使用的资源id找到另一种模式的资源id,按新的资源刷新显示界面。
不过因为View内基本不维护资源id,我们还是需要借助tag或其他方式来维护资源id。
与AndroidSkinLoader框架类似,不过AndroidSkinLoader加载其他apk内的同名资源来换肤,此方案在当前应用内寻找其他资源来换肤。
优点:基本不用改动xml代码
缺点:初期需要形成映射表,新增功能时,映射表会增大,最终map比较大;

2.5 Android uiMode
Android自身在车载模式下支持通过设置Configuration的uiMode来决定界面是否显示夜间模式。这时候Android会通过加载夜间模式的资源文件夹来显示界面。
Resources resources = getResources();
DisplayMetrics dm = resources.getDisplayMetrics();
Configuration config = resources.getConfiguration();
config.uiMode &= ~Configuration.UI_MODE_NIGHT_MASK ;
config.uiMode |= on ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO ;
resources.updateConfiguration(config, dm);

上面这段代码会修改Configuration,Resources加载资源时会通过id和Configuration来决定加载的资源名称,夜间模式下加载的是以-night结尾的资源文件目录,如drawable-night,values-night等。
此方法也是界面创建前才生效,界面创建后点击夜间模式切换怎么办呢?
可以recreate,不过体验不好。或者通过上面提到的hack resources来刷新已经加载过的drawable资源。
优点:夜间模式的资源文件可以定义在独立的*-night文件夹,可读性比较好
缺点:不支持实时切换,需要界面recreate,想要实时切换,同样需要填不少坑。
反编译今日头条,能看到类似代码,估计用的就是uiMode+hackResources来实现夜间模式的。

三、实时刷新界面的关键点分析
3.1 如何刷新动态创建的View
1)对动态创建的View,在创建时,将其加入一个列表内,每次切换模式时,都遍历列表进行刷新;
2)对新创建的View,根据当前模式决定View显示。

3.2 如何刷新RecyclerView/ListView/GridView
刷新此类View需要两步:

1)对其内部显示的子元素进行刷新;
2)对缓存的元素进行刷新,有两种策略:
第一种,在相应的Adapter的getView回调内对convertView确定该显示哪种模式;
第二种,将其缓存子元素的View池清空。这样上下滑动时就会重新创建子元素,也就可以在创建之初决定显示模式;

第一种情况比较简单,对第二种方案,若目标是ListView/GridView,两者都继承自AbsListView:可采取以下方式刷新:
public void refreshListView(AbsListView listView) {
for (int i = 0; i< listView.getChildCount();i++) {
View view = listView.getChildAt(i);
if(view instanceof MyTextView) {
((MyTextView) view).setNight(isNight );
}
}
mAdapter .setNight(isNight);
try {
Field binField = AbsListView.class.getDeclaredField("mRecycler");
binField.setAccessible(true);
Object bin = binField.get(listView);
Class cls = Class.forName("android.widget.AbsListView.RecycleBin");
Method clearMethod = cls.getDeclaredMethod("clear" , new Class[0]);
clearMethod.setAccessible(true);
clearMethod.invoke(bin, new Class[0]);
Log.d( TAG, "refreshListView()| success");
} catch (Exception e) {
e.printStackTrace();
}
}

若目标是RecyclerView:
public void refreshRecyclerView(RecyclerView recyclerView) {
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(recyclerView), new Object[0]);
RecyclerView.RecycledViewPool recycledViewPool = recyclerView.getRecycledViewPool();
recycledViewPool.clear();

} catch (Exception e) {
e.printStackTrace();
}
}


四、WebView夜间模式
WebView 不能使用主题属性(theme attributes)来控制显示,实现WebView的夜间模式/换肤一般是通过修改css样式来实现,切换过程可以通过webview.loadUrl("javascript:***");来刷新界面(当然,前提是加载的网页是我们可控的,如果加载第三方网页,就不是这么简单了)。
HTML代码如下:
<html>
<head>
<title >WebView夜间模式 </title>
</head>

<script language="javascript">
var MODE_DAY = 0;
var MODE_NIGHT = 1;
function switchMode(mode) {
var textDiv = document.getElementById("textDiv");

if(MODE_DAY == mode) {
document.body.style.background="#ffffff";
textDiv.style.color="black";
} else {
document.body.style.background="#000000"
textDiv.style.color="white";
}
}
</script>

<body style="background:#FFFFFF">

<div id="textDiv" style= "font-color:#000000">文本颜色</ div>
<br />
<button type="button" onclick="switchMode(MODE_NIGHT)"> 夜间模式</button >
<button type="button" onclick="switchMode(MODE_DAY)"> 白天模式</button >

</body>
</html>

主要逻辑在switchMode方法中,switchMode定义在script中,可以被WebView加载,加载方法如下:
switch (view.getId()) {
case R.id.day_mode_btn:
mWebView.loadUrl("javascript:switchMode(MODE_DAY)" );
break;
case R.id.night_mode_btn:
mWebView.loadUrl("javascript:switchMode(MODE_NIGHT)" );
break;
}

整体来讲,js的网页切换显示模式与Android原生的原理是相同的,都是通过对已经显示的显示区域属性进行设置,并立刻刷新。
效果:





五、总结
本文总结了一些夜间模式的实现方案与思路,可能还有遗漏,但已经基本够用了。我们可以看到,夜间模式的实现有常用的几种套路,明白这些套路,后面再看到其他的框架估计也是八九不离十。我们来总结下套路吧:
1)简单套路:改变屏幕亮度,给界面加蒙层;
2)基于Theme的实现:通过定义多套主题和相应自定义属性来实现换肤或夜间模式;
3)基于View的Tag:在Tag内定义格式化结构,表明要切换的属性和相应值,解析并完成相关切换;
4)自定义View:使用自定义View实现切换主题的接口,来完成模式切换;
5)拦截LayoutInflater的创建View方法,拦截后的思路很多:
创建自定义View;解析原生默认View的相应属性(background/src/textColor等);指定ViewId与自定义theme的元素的绑定关系;两种模式间的资源id映射;等等……
6)基于Resources资源替换:解析新的apk包内的资源替换当前资源包内的所有同名资源;两种模式的资源id映射等;
7)基于Resources的hack:自己解析所有皮肤的资源,并将其反射放入Resources内,再刷新界面;
8)基于Android的UIMode:改变Android的配置(Configuration),让其解析资源时从res/*-night资源文件夹内解析资源;
9)WebView套路:调用js方法刷新DOM树。

整体来讲,基于ViewTag的AndroidChangeSkin框架和基于Resources资源替换的AndroidSkinLoader两个框架方案最为成熟,代码侵入性较小。不过AndroidChangeSkin框架实现的功能不多,扩展性也还有点问题,个人建议如果想使用成熟框架,则使用AndroidSkinLoader,如果工作量充足,自己实现也可以,各方案原理都不复杂。

最后,在切换了皮肤后,可以通过recreate重新创建界面,也可以通过对界面的View树/DOM树遍历刷新界面

参考文献:
http://www.2cto.com/kf/201507/419283.html 屏幕亮度
http://www.jb51.net/article/79720.htm
http://blog.csdn.net/richie0006/article/details/51104877 UIMode
http://www.jianshu.com/p/3b55e84742e5 基于Theme
http://blog.csdn.net/wwj_748/article/details/44810283 WebView夜间模式
http://www.tuicool.com/articles/iM3iMvM UIMode
http://blog.csdn.net/lmj623565791/article/details/51503977 LayoutInflater的onCreateView讲解
http://blog.csdn.net/gupengnn/article/details/49071713 换肤总结
http://willsunforjava.iteye.com/blog/1663355 资源访问机制
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息