回顧過去
說起獲取屏幕高度,不知道你是如何理解這個高度范圍的?是以應(yīng)用顯示區(qū)域高度作為屏幕高度還是手機屏幕的高度。
那么我們先看一下平時使用獲取高度的方法:
public static int getScreenHeight(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Display display = wm.getDefaultDisplay();
DisplayMetrics dm = new DisplayMetrics();
display.getMetrics(dm);
return dm.heightPixels;
}
//或
public static int getScreenHeight(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Point point = new Point();
wm.getDefaultDisplay().getSize(point);
return point.y;
}
// 或
public static int getScreenHeight(Context context) {
return context.getResources().getDisplayMetrics().heightPixels;
}
// 貌似還有更多的方法
以上三種效果一致,只是寫法略有不同。
當(dāng)然你或許使用的是這種:
public static int getScreenHeight(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Display display = wm.getDefaultDisplay();
DisplayMetrics dm = new DisplayMetrics();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
display.getRealMetrics(dm);
} else {
display.getMetrics(dm);
}
return dm.heightPixels;
}
// 其他幾種寫法大同小異
...
這個方法判斷了系統(tǒng)大于等于Android 4.2時,使用getRealMetrics(getRealSize)來獲取屏幕高度。那么這里發(fā)生了什么,為什么會這樣?
其實在Andoird 4.0時,引入了虛擬導(dǎo)航鍵,如果你繼續(xù)使用getMetrics之類的方式,獲取的高度是去除了導(dǎo)航欄的高度的。
當(dāng)時因為在4.0和4.2之間還沒有的getRealMetrics這個方法,所以甚至需要添加下面的適配代碼:
try {
heightPixels = (Integer) Display.class.getMethod("getRawHeight").invoke(display);
} catch (Exception e) {
}
現(xiàn)在不會還有人適配4.4甚至5.0一下的機子了吧,不會吧不會吧。。。所以歷史的包袱可以去掉了。

上面方法名都是getScreenHeight,可是這個高度范圍到底和你需要的是否一致。這個需要開發(fā)時注意,我的習(xí)慣是ScreenHeight對應(yīng)用顯示的高度,不包括導(dǎo)航欄(非全屏下),RealHeight來指包含導(dǎo)航欄和狀態(tài)欄的高度(getRealMetrics)。
PS:以前也使用過AndroidUtilCode這個工具庫,里面將前者方法名定義為getAppScreenHeight,后者為getScreenHeight。也是很直觀的方法。
下文中我會以自己的習(xí)慣,使用ScreenHeight和RealHeight來代表兩者。
我印象中華為手機很早就使用了虛擬導(dǎo)航鍵,如下圖(圖片來源):

比較特別的是,當(dāng)時華為的導(dǎo)航欄還可以顯示隱藏,注意圖中左下角的箭頭。點擊可以隱藏,上滑可以顯示。即使這樣,使用getScreenHeight也可以準(zhǔn)確獲取高度,隱藏了ScreenHeight就等于RealHeight。
上述的這一切在“全面屏”時代沒有到來之前,沒有什么問題。
立足當(dāng)下
小米MIX的發(fā)布開啟了全面屏?xí)r代(16年底),以前的手機都是16:9的,記得雷布斯在發(fā)布會上說過,他們費了很大的力氣說服了谷歌去除了16:9的限制(從Android 7.0開始)


全面屏手機是真的香,不過隨之也帶來適配問題。首當(dāng)其沖的就是劉海屏,各家有各自的獲取劉海區(qū)域大小的方法。主要原因還是國內(nèi)競爭的激烈,各家為了搶占市場,先于谷歌定制了自己的方案。這一點讓人想起了萬惡的動態(tài)權(quán)限適配。。。
其實在劉海屏之下,還隱藏一個導(dǎo)航欄的顯示問題,也就是本篇的重點。全面屏追求更多的顯示區(qū)域,隨之帶來了手勢操作。在手勢操作模式下,導(dǎo)航欄是隱藏狀態(tài)。
本想著可以和上面提到的華為一樣,隱藏獲取的就是RealHeight,顯示就是減去導(dǎo)航欄高度的ScreenHeight。然而現(xiàn)實并不是這樣,下表是我收集的一些全面屏手機各高度的數(shù)據(jù)。

ScreenHeight一欄中括號內(nèi)表示顯示導(dǎo)航欄時獲取的屏幕高度。
大致的規(guī)律總結(jié)如下:
- 在有劉海的手機上,ScreenHeight不包含狀態(tài)欄高度。
- 小米手機在隱藏顯示導(dǎo)航欄時,ScreenHeight不變,且不包含導(dǎo)航欄高度。
其中vivo手機,屏幕高度加狀態(tài)欄高度大于真實高度(2201 + 84 > 2280)。本以為差值79是劉海高度,但查看vivo文檔后發(fā)現(xiàn),vivo劉海固定27dp(81px),也還是對不上。。。
一加6最奇怪,有三種設(shè)置模式。使用側(cè)邊全屏手勢時,手勢底部有一個小條,NavigationBar高度變?yōu)?2。(2159 + 42 = 2075 + 126 = 2201)也就是說這種模式也屬于有導(dǎo)航欄的情況。

這時如果你需要獲取準(zhǔn)確的ScreenHeight,只有通過RealHeight - NavigationBar來實現(xiàn)了。
所以首先需要判斷當(dāng)前導(dǎo)航欄是否顯示,再來決定是否減去NavigationBar高度。
先看看老牌的判斷方法如下:
public boolean isNavigationBarShow(){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
Display display = getWindowManager().getDefaultDisplay();
Point size = new Point();
Point realSize = new Point();
display.getSize(size);
display.getRealSize(realSize);
return realSize.y!=size.y;
} else {
boolean menu = ViewConfiguration.get(this).hasPermanentMenuKey();
boolean back = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_BACK);
if(menu || back) {
return false;
}else {
return true;
}
}
}
此方法通過比較ScreenHeight和RealHeight是否相等來判斷。如果對比上面表中的數(shù)據(jù),那只有OPPO Find X可以判斷成功。也有一些方法通過ScreenHeight和RealHeight差值來計算導(dǎo)航欄高度。顯然這些方法已無法再使用。
所以搜索了一下相關(guān)信息,得到了下面的代碼:
/**
* 是否隱藏了導(dǎo)航鍵
*
* @param context
* @return
*/
public static boolean isNavBarHide(Context context) {
try {
String brand = Build.BRAND;
// 這里做判斷主要是不同的廠商注冊的表不一樣
if (!StringUtils.isNullData(brand) && (Rom.isVivo() || Rom.isOppo())) {
return Settings.Secure.getInt(context.getContentResolver(), getDeviceForceName(), 0) != 0;
} else if (!StringUtils.isNullData(brand) && Rom.isNokia()) {
//甚至 nokia 不同版本注冊的表不一樣, key 還不一樣。。。
return Settings.Secure.getInt(context.getContentResolver(), "swipe_up_to_switch_apps_enabled", 0) == 1
|| Settings.System.getInt(context.getContentResolver(), "navigation_bar_can_hiden", 0) != 0;
} else
return Settings.Global.getInt(context.getContentResolver(), getDeviceForceName(), 0) != 0;
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
/**
* 各個手機廠商注冊導(dǎo)航鍵相關(guān)的 key
*
* @return
*/
public static String getDeviceForceName() {
String brand = Build.BRAND;
if (StringUtils.isNullData(brand))
return "navigationbar_is_min";
if (brand.equalsIgnoreCase("HUAWEI") || "HONOR".equals(brand)) {
return "navigationbar_is_min";
} else if (Rom.isMiui()||Rom.check("XIAOMI")) {
return "force_fsg_nav_bar";
} else if (Rom.isVivo()) {
return "navigation_gesture_on";
} else if (Rom.isOppo()) {
return "hide_navigationbar_enable";
} else if (Rom.check("samsung")) {
return "navigationbar_hide_bar_enabled";
} else if (brand.equalsIgnoreCase("Nokia")) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return "navigation_bar_can_hiden";
} else {
return "swipe_up_to_switch_apps_enabled";
}
} else {
return "navigationbar_is_min";
}
}
可以看到包含了華為、小米、vivo、oppo 、三星甚至諾基亞的判斷。這就是適配的現(xiàn)實狀況,不要妄想尋找什么通用方法,老老實實一個個判斷吧。畢竟幺蛾子就是這些廠家搞出來的,廠家魔改教你做人。
這種方法在上面的測試機中都親測準(zhǔn)確有效。
不過這個判斷方法不夠嚴(yán)謹(jǐn),比如其他品牌手機使用此方法,那么結(jié)果都是false。用這樣的結(jié)果來計算高度顯得不夠嚴(yán)謹(jǐn)。
根據(jù)前面提到問題發(fā)生的原因是全面屏帶來的(7.0及以上)。所以我們可以先判斷是否是全面屏手機(屏幕長寬比例超過1.86以上),然后判斷是否顯示導(dǎo)航欄,對于不確定的機型,我們還是使用原先的ScreenHeight。盡量控制影響范圍。
我整理的代碼如下(補充了一加、錘子手機判斷):
/**
* @author weilu
**/
public class ScreenUtils {
private static final String BRAND = Build.BRAND.toLowerCase();
public static boolean isXiaomi() {
return Build.MANUFACTURER.toLowerCase().equals("xiaomi");
}
public static boolean isVivo() {
return BRAND.contains("vivo");
}
public static boolean isOppo() {
return BRAND.contains("oppo") || BRAND.contains("realme");
}
public static boolean isHuawei() {
return BRAND.contains("huawei") || BRAND.contains("honor");
}
public static boolean isOneplus(){
return BRAND.contains("oneplus");
}
public static boolean isSamsung(){
return BRAND.contains("samsung");
}
public static boolean isSmartisan(){
return BRAND.contains("smartisan");
}
public static boolean isNokia() {
return BRAND.contains("nokia");
}
public static boolean isgoogle(){
return BRAND.contains("google");
}
public static int getRealScreenHeight(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Display display = wm.getDefaultDisplay();
DisplayMetrics dm = new DisplayMetrics();
display.getRealMetrics(dm);
return dm.heightPixels;
}
public static int getRealScreenWidth(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Display display = wm.getDefaultDisplay();
DisplayMetrics dm = new DisplayMetrics();
display.getRealMetrics(dm);
return dm.widthPixels;
}
public static int getScreenHeight(Context context) {
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Display display = wm.getDefaultDisplay();
DisplayMetrics dm = new DisplayMetrics();
display.getMetrics(dm);
return dm.heightPixels;
}
/**
* 判斷設(shè)備是否顯示NavigationBar
*
* @return 其他值 不顯示 0顯示 -1 未知
*/
public static int isNavBarHide(Context context) {
// 有虛擬鍵,判斷是否顯示
if (isVivo()) {
return vivoNavigationEnabled(context);
}
if (isOppo()) {
return oppoNavigationEnabled(context);
}
if (isXiaomi()) {
return xiaomiNavigationEnabled(context);
}
if (isHuawei()) {
return huaWeiNavigationEnabled(context);
}
if (isOneplus()) {
return oneplusNavigationEnabled(context);
}
if (isSamsung()) {
return samsungNavigationEnabled(context);
}
if (isSmartisan()) {
return smartisanNavigationEnabled(context);
}
if (isNokia()) {
return nokiaNavigationEnabled(context);
}
if (isGoogle()) {
// navigation_mode 三種模式均有導(dǎo)航欄,只是高度不同。
return 0;
}
return -1;
}
/**
* 判斷當(dāng)前系統(tǒng)是使用導(dǎo)航鍵還是手勢導(dǎo)航操作
*
* @param context
* @return 0 表示使用的是虛擬導(dǎo)航鍵,1 表示使用的是手勢導(dǎo)航,默認(rèn)是0
*/
public static int vivoNavigationEnabled(Context context) {
return Settings.Secure.getInt(context.getContentResolver(), "navigation_gesture_on", 0);
}
public static int oppoNavigationEnabled(Context context) {
return Settings.Secure.getInt(context.getContentResolver(), "hide_navigationbar_enable", 0);
}
public static int xiaomiNavigationEnabled(Context context) {
return Settings.Global.getInt(context.getContentResolver(), "force_fsg_nav_bar", 0);
}
private static int huaWeiNavigationEnabled(Context context) {
return Settings.Global.getInt(context.getContentResolver(), "navigationbar_is_min", 0);
}
/**
* @param context
* @return 0虛擬導(dǎo)航鍵 2為手勢導(dǎo)航
*/
private static int oneplusNavigationEnabled(Context context) {
int result = Settings.Secure.getInt(context.getContentResolver(), "navigation_mode", 0);
if (result == 2) {
// 兩種手勢 0有按鈕, 1沒有按鈕
if (Settings.System.getInt(context.getContentResolver(), "buttons_show_on_screen_navkeys", 0) != 0) {
return 0;
}
}
return result;
}
public static int samsungNavigationEnabled(Context context) {
return Settings.Global.getInt(context.getContentResolver(), "navigationbar_hide_bar_enabled", 0);
}
public static int smartisanNavigationEnabled(Context context) {
return Settings.Global.getInt(context.getContentResolver(), "navigationbar_trigger_mode", 0);
}
public static int nokiaNavigationEnabled(Context context) {
boolean result = Settings.Secure.getInt(context.getContentResolver(), "swipe_up_to_switch_apps_enabled", 0) != 0
|| Settings.System.getInt(context.getContentResolver(), "navigation_bar_can_hiden", 0) != 0;
if (result) {
return 1;
} else {
return 0;
}
}
public static int getNavigationBarHeight(Context context){
int resourceId = context.getResources().getIdentifier("navigation_bar_height", "dimen", "android");
if (resourceId > 0) {
return context.getResources().getDimensionPixelSize(resourceId);
}
return 0;
}
private static boolean isAllScreenDevice(Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
// 7.0放開限制,7.0以下都不為全面屏
return false;
} else {
int realWidth = getRealScreenWidth(context);
int realHeight = getRealScreenHeight(context);
float width;
float height;
if (realWidth < realHeight) {
width = realWidth;
height = realHeight;
} else {
width = realHeight;
height = realWidth;
}
// Android中默認(rèn)的最大屏幕縱橫比為1.86
return height / width >= 1.86f;
}
}
/**
* 獲取去除導(dǎo)航欄高度的剩余高度(含狀態(tài)欄)
* @param context
* @return
*/
public static int getScreenContentHeight(Context context) {
if (isAllScreenDevice(context)) {
int result = isNavBarHide(context);
if (result == 0) {
return getRealScreenHeight(context) - getNavigationBarHeight(context);
} else if (result == -1){
// 未知
return getScreenHeight(context);
} else {
return getRealScreenHeight(context);
}
} else {
return getScreenHeight(context);
}
}
}
有人會問,這些key都是哪里來的?畢竟我在廠商文檔也沒有翻到。
我能想到的辦法是查看SettingsProvider,它是提供設(shè)置數(shù)據(jù)的Provider,分有Global、System、Secure三種類型,上面代碼中可以看到不同品牌存放在的類型都不同。我們可以通過adb命令查看所有數(shù)據(jù),根據(jù)navigation等關(guān)鍵字去尋找。比如查看Secure的數(shù)據(jù):
adb shell settings list secure
或者:
ContentResolver cr = context.getContentResolver();
Uri uri = Uri.parse("content://settings/secure/");
Cursor cursor = cr.query(uri, null, null, null, null);
while (cursor.moveToNext()) {
String name = cursor.getString(cursor.getColumnIndex("name"));
String value = cursor.getString(cursor.getColumnIndex("value"));
Log.d("settings:", name + "=" + value);
}
cursor.close();
這樣如果有上面兼容不到的機型,可以使用這個方法適配。也歡迎你的補充反饋。
費了這么大的勁獲取到了準(zhǔn)確的高度,可能你會說,還不如直接獲取ContentView的高度:
public static int getContentViewHeight(Activity activity) {
View contentView = activity.getWindow().getDecorView().findViewById(android.R.id.content);
return contentView.getHeight();
}
這個結(jié)果和上述計算的高度一致,唯一的限制是需要在onWindowFocusChanged之后調(diào)用,否則高度為0。這個我們可以根據(jù)實際情況自行選用。
已知問題
- 網(wǎng)上有許多同類代碼,發(fā)現(xiàn)會將vivo和oppo都使用navigation_gesture_on這一個key。我在oppo Find x中發(fā)現(xiàn)此key并不存在,不知是否和系統(tǒng)版本有關(guān)。如果是的話,又需要判斷oppo的系統(tǒng)版本了。
- 上面提到的獲取導(dǎo)航欄高度的方法在部分手機中無效,無效的原因是因為導(dǎo)航欄隱藏時,獲取高度就為0。所以判斷是否顯示導(dǎo)航欄是關(guān)鍵。
- 劉海的出現(xiàn),很多人會吐槽丑,所以廠家想到了隱藏劉海的方式(掩耳盜鈴),比如下面是Redmi K30的設(shè)置頁面:

設(shè)置劉海顯示頁
第二種沒啥特別,就是狀態(tài)欄強制為黑色。這里我懷疑因為這個設(shè)置,導(dǎo)致在有劉海的手機上,ScreenHeight不包含狀態(tài)欄高度。
最糟糕的是第三種,隱藏后狀態(tài)欄在劉海外。例如Redmi K30在開啟后,ScreenHeight 為2174,RealHeight為2304,而關(guān)閉時為2175 和 2400。這下連萬年不變的RealHeight也變化了,這太不real了,大家自行體會。不過目前發(fā)現(xiàn)未影響適配方案,不知其他手機如何。
對于是否隱藏劉海,其實也是有各家的判斷的,比如小米:
// 0:顯示劉海,1:隱藏劉海
Settings.Global.getInt(context.getContentResolver(), "force_black", 0);
- 有些App會使用修改density的屏幕適配方案,這會影響獲取導(dǎo)航欄高度的方法。比如130px的導(dǎo)航欄適配后獲取到的是136px。所以這里需要使用getSystem中的density轉(zhuǎn)換回去:
public static int getNavigationBarHeight(Context context){
int resourceId = context.getResources().getIdentifier("navigation_bar_height", "dimen", "android");
if (resourceId > 0) {
int height = context.getResources().getDimensionPixelSize(resourceId);
// 兼容屏幕適配導(dǎo)致density修改
float density = context.getResources().getDisplayMetrics().density;
if (DENSITY != density) {
return dpToPx(px2dp(context, height));
}
return height;
}
return 0;
}
public static final float DENSITY = Resources.getSystem().getDisplayMetrics().density;
public static int dpToPx(int dpValue) {
return (int) (dpValue * DENSITY + 0.5f);
}
public static int px2dp(Context context, int px) {
return (int) (px / context.getResources().getDisplayMetrics().density + 0.5);
}
getSystem源碼如下:
/**
* Return a global shared Resources object that provides access to only
* system resources (no application resources), is not configured for the
* current screen (can not use dimension units, does not change based on
* orientation, etc), and is not affected by Runtime Resource Overlay.
*/
public static Resources getSystem() {
synchronized (sSync) {
Resources ret = mSystem;
if (ret == null) {
ret = new Resources();
mSystem = ret;
}
return ret;
}
}
它不受資源覆蓋的影響,我們可以通過它將值轉(zhuǎn)換回來。
展望未來
本篇看似聊的獲取高度這件事,其實伴隨導(dǎo)航欄的發(fā)展演進(jìn),核心是是如何判斷導(dǎo)航欄是否顯示。
通過上面的介紹,總結(jié)一下就是在“全面屏?xí)r代”,如果你想獲取屏幕高度,就不要使用ScreenHeight了。否則會出現(xiàn)UI展示上的問題。而且這種問題,線上也不會崩潰,難以發(fā)現(xiàn)。以前在支付寶中就發(fā)現(xiàn)過 PopupWindow彈出高度不正確的問題,過了好久才修復(fù)了。
至于屏幕寬度,也不清楚隨著折疊屏、環(huán)繞屏的到來會不會造成影響。但愿不要吧,碎片化越來越嚴(yán)重了。。。
最后,如果本文對你有啟發(fā)有幫助,點個贊可好?