全面屏是手机业界对于超高屏占比手机设计的一个宽泛的定义。从字面上解释就是,手机的正面全部都是屏幕,四个边框位置都是采用无边框设计,追求接近100%的屏占比。但受限于目前的技术,还不能做到手机正面屏占比100%的手机。现在业内所说的全面屏手机是指真实屏占比可以达到80%以上,拥有超窄边框设计的手机。
全面屏手机屏幕的宽高比例比较特殊,不再是以前的16:9。比如三星的Galaxy S8屏幕分辨率是:2960×1440,对应的屏幕比例为:18.5:9。VIVO X20手机屏幕分辨率是2160x1080,对应的屏幕比例:18:9。对于这种奇葩的屏幕比例,APP开发者该如何去优化自己的应用,才能在这些手机上显示的更加完美呢?
下面,从以下两个方面来探究APP完美适配全面屏手机的方法:
由于全面屏手机的高宽比比之前大,如果不适配的话,Android默认为最大的宽高比是1.86(即16:9),小于全面屏手机的宽高比,因此在全面屏手机上打开没有适配全面屏的App时,上下就会显示空白空间。
针对此问题,Android官方提供了适配方案,即提高App所支持的最大屏幕纵横比,实现起来也比较简单,在AndroidManifest.xml中做如下配置即可:
其中,ratio_float表示宽高比,为浮点数,官方建议为2.1或更大。例如,好医生APP针对华为的全面屏的ratio_float设置为2.3.4。
另外,如果只是针对某个Activity,可以直接在AndroidManifest中针对Activity标签添加android:resizeableActivity = “true”,但是此设置只针对Activity生效,且增加了此属性该activity也会支持分屏显示。
当然,max_aspect值也支持在Java代码中动态地设置。例如:
public void setMaxAspect() {
ApplicationInfo applicationInfo = null;
try {
applicationInfo = getPackageManager().getApplicationInfo(getPackageName(), PackageManager.GET_META_DATA);
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
}
if(applicationInfo == null){
throw new IllegalArgumentException(" get application info = null, has no meta data! ");
}
applicationInfo.metaData.putString("android.max_aspect", "2.1");
}
除了设置max_aspect外,为了适配全面屏,还需要在布局上进行一些优化,即能用百分比布局的尽量不要用dp。
先看一下dp的定义:Density-independent pixel (dp)独立像素密度。标准是160dpi,即1dp对应1个pixel,计算公式为:px = dp * (dpi / 160)。
即屏幕密度越大,1dp对应 的像素点越多。上面的公式中有个dpi,dpi为DPI是Dots Per Inch(每英寸所打印的点数),也就是当设备的dpi为160的时候1px=1dp;
使用dp来布局虽然非常方便,但是dp并不能够解决所有的适配问题。例如,为4.3寸屏幕准备的UI,运行在5.0寸的屏幕上,很可能在右侧和下侧存在大量的空白。因此,从API 23开始,Google提供了百分比布局方案,在Android中使用百分比布局,需要在build.gradle中添加如下依赖:
compile 'com.android.support:percent:23.0.1'
当然,除了百分比布局外,官方建议使用的是ConstraintLayout,ConstraintLayout布局具有如下的一些优点:
可以极大地减少布局的嵌套,提升界面渲染性能;
可以使用可视化的方式来编写Android布局文件,非常方便;
跟上面介绍的几种布局对比,可以更方便地实现百分比布局,适配全面屏也毫无压力;
适配虚拟导航键是适配全面屏的重要内容,由于不同手机厂商对系统做了不同的修改,因此对系统界面底部的NavigationBar处理方式也就各不相同。例如,有些手机系统有NavigationBar,有些手机没有,还有则是在设置增加开关,让用户选择是否启用NavigationBar。
好在Android系统提供了相关的方法,可以在WindowManagerService.java源码中找到hasNavigationBar方法,该方法就是用来判断是否存在NavigationBar。
@Override
public boolean hasNavigationBar() {
return mPolicy.hasNavigationBar();
}
但是,WindowManagerService是系统服务,开发者是无法直接调用这个方法的。可以继续看PhoneWindowManager,PhoneWindowManager提供了一个hasNavigationBar(),源码如下:
@Override
public boolean hasNavigationBar() {
return mHasNavigationBar;
}
再看看给PhoneWindowManager的mHasNavigationBar赋值的地方。
public void setInitialDisplaySize(Display display, int width, int height, int density) {
...
mHasNavigationBar = res.getBoolean(com.android.internal.R.bool.config_showNavigationBar);
String navBarOverride = SystemProperties.get("qemu.hw.mainkeys");
if ("1".equals(navBarOverride)) {
mHasNavigationBar = false;
} else if ("0".equals(navBarOverride)) {
mHasNavigationBar = true;
}
...
}
从上面代码可以看出,mHasNavigationBar的值的设定是由两处决定的:
从系统的资源文件中取设定值config_showNavigationBar;
系统获取“qemu.hw.mainkeys”的值,这个值可能会覆盖上面获取到的mHasNavigationBar的值;
自定义NavigationBar判断方法
既然系统没有提供直接的方法来判断NavigationBar是否存在,我们可以仿照PhoneWindowManager给mHasNavigationBar赋值的方法,自己去实现一个判断NavigationBar的方法。
public static boolean hasNavigationBar(Context context) {
boolean hasNavigationBar = false;
Resources rs = context.getResources();
int id = rs.getIdentifier("config_showNavigationBar", "bool", "android");
if (id > 0) {
hasNavigationBar = rs.getBoolean(id);
}
try {
//反射获取SystemProperties类,并调用它的get方法
Class systemPropertiesClass = Class.forName("android.os.SystemProperties");
Method m = systemPropertiesClass.getMethod("get", String.class);
String navBarOverride = (String) m.invoke(systemPropertiesClass, "qemu.hw.mainkeys");
if ("1".equals(navBarOverride)) {
hasNavigationBar = false;
} else if ("0".equals(navBarOverride)) {
hasNavigationBar = true;
}
} catch (Exception e) {
e.printStackTrace();
}
return hasNavigationBar;
}
当然,网上也提供了很多其他的方式,例如,通过通过WindowManagerGlobal获取windowManagerService,然后通过反射拿到IWindowManager。
public static boolean deviceHasNavigationBar() {
boolean haveNav = false;
try {
Class> windowManagerGlobalClass = Class.forName("android.view.WindowManagerGlobal");
Method getWmServiceMethod = windowManagerGlobalClass.getDeclaredMethod("getWindowManagerService");
getWmServiceMethod.setAccessible(true);
Object iWindowManager = getWmServiceMethod.invoke(null);
Class> iWindowManagerClass = iWindowManager.getClass();
Method hasNavBarMethod = iWindowManagerClass.getDeclaredMethod("hasNavigationBar");
hasNavBarMethod.setAccessible(true);
haveNav = (Boolean) hasNavBarMethod.invoke(iWindowManager);
} catch (Exception e) {
e.printStackTrace();
}
return haveNav;
}
现在很多的手机没有底部实体的Home键和Back键,为了支持虚拟导航键,大部分手机都提供了虚拟的导航键,开发者可以通过上面的方法hasNavigationBar获取手机是否支持虚拟导航键。当然,也可以在【设置】面板中来手动打开或关闭虚拟导航键,并且部分手机还支持使用手势来开启和关闭导航键。
那么,对于开发者来说,怎么知道是否开启了虚拟导航键呢,又如何进行适配呢?
private static final String NAVIGATION_GESTURE = "navigation_gesture_on";
private static final int NAVIGATION_GESTURE_OFF = 0;
/**
* @return false 判断是否使用虚拟导航键,true表示使用的是手势,默认是false
*/
public static boolean vivoNavigationGestureEnabled(Context context) {
int val = Settings.Secure.getInt(context.getContentResolver(), NAVIGATION_GESTURE, NAVIGATION_GESTURE_OFF);
return val != NAVIGATION_GESTURE_OFF;
}
判断当前系统是否存在并开启了NavigationBar,就要结合上面给出的两个方法一起判断才准确。
boolean hasNavigationBar = hasNavigationBar(this) && !vivoNavigationGestureEnabled(this);
对于大多数视频播放类的应用,在播放视频的时候,肯定希望能够隐藏NavigationBar和StatusBar。对于这种需求,在Android 4.1以上的系统里也有很好的支持,google官方给出下面的Example:
View decorView = getWindow().getDecorView();
// Hide both the navigation bar and the status bar.
// SYSTEM_UI_FLAG_FULLSCREEN is only available on Android 4.1 and higher, but as
// a general rule, you should design your app to hide the status bar whenever you
// hide the navigation bar.
int uiOptions = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_FULLSCREEN;
decorView.setSystemUiVisibility(uiOptions);
但是这么做也是有缺陷的,Google共给出了5个注意事项:
显然,View.SYSTEM_UI_FLAG_HIDE_NAVIGATION和View.SYSTEM_UI_FLAG_FULLSCREEN这个两个属性使用起来根本无法满足我们需要在应用中隐藏NavigationBar的需求。不过,好在Android4.4版本提供了沉浸式全屏的概念。沉浸式全屏的应用在Android4.4的手机上会自动全屏显示,并不会出现恼人的虚拟键问题。
并且,Android 4.4 中提供了View.SYSTEM_UI_FLAG_IMMERSIVE和View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY标签, 需要和View.SYSTEM_UI_FLAG_HIDE_NAVIGATION、View.SYSTEM_UI_FLAG_FULLSCREEN一起使用, 才能实现沉浸模式。
基于此,我们可以自己封装一个虚拟按键栏的显示隐藏逻辑。
public void showBar(){
int uiOptions = getWindow().getDecorView().getSystemUiVisibility();
int newUiOptions = uiOptions;
boolean isImmersiveModeEnabled =
((uiOptions | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) == uiOptions);
if (isImmersiveModeEnabled) {
Log.i(TAG, "Turning immersive mode mode off. ");
//先取 非 后再 与, 把对应位置的1 置成0,原本为0的还是0
if (Build.VERSION.SDK_INT >= 14) {
newUiOptions &= ~View.SYSTEM_UI_FLAG_HIDE_NAVIGATION;
}
if (Build.VERSION.SDK_INT >= 16) {
newUiOptions &= ~View.SYSTEM_UI_FLAG_FULLSCREEN;
}
if (Build.VERSION.SDK_INT >= 18) {
newUiOptions &= ~View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
}
getWindow().getDecorView().setSystemUiVisibility(newUiOptions);
}
}
public void hideBar() {
// The UI options currently enabled are represented by a bitfield.
// getSystemUiVisibility() gives us that bitfield.
int uiOptions = getWindow().getDecorView().getSystemUiVisibility();
int newUiOptions = uiOptions;
boolean isImmersiveModeEnabled =
((uiOptions | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY) == uiOptions);
if (!isImmersiveModeEnabled) {
Log.i(TAG, "Turning immersive mode mode on. ");
if (Build.VERSION.SDK_INT >= 14) {
newUiOptions |= View.SYSTEM_UI_FLAG_HIDE_NAVIGATION;
}
if (Build.VERSION.SDK_INT >= 16) {
newUiOptions |= View.SYSTEM_UI_FLAG_FULLSCREEN;
}
if (Build.VERSION.SDK_INT >= 18) {
newUiOptions |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
}
getWindow().getDecorView().setSystemUiVisibility(newUiOptions);
}
}