聊聊 React Native 屏幕適配那些事兒
作者:張子君
原文:https://segmentfault.com/a/1190000039805723
寫在前面
在我從事 React Native(以下簡稱 RN)開發的兩年工作中,自己與團隊成員時常會遇到一些令人疑惑的屏幕適配問題,如:全屏 mask 樣式無法覆蓋整個屏幕、1 像素邊框有時無法顯示、特殊機型佈局錯亂等。另外,部分成員對 RN 獲取屏幕參數的 API——Dimensions.get('window')
與Dimensions.get('screen')
最終返回的值代表的意義也存在疑惑。其實 RN 的適配比較簡單,我將在此文中闡述適配原理,提出適配方案,並針對部分特殊問題一一解釋其原因,原則上能覆蓋所有機型的適配。若有遺漏與不當之處,歡迎指出,共同交流。
往期精彩 RN 文章推薦:-【從源碼分析】可能是全網最實用的 React Native 異常解決方案【建議收藏】
適合閱讀羣體
-
有一定 RN 開發經驗,瞭解 RN js 模塊如何與原生模塊通信;
-
有 RN 適配經驗,懂了,但沒完全懂的那種;
-
想了解 RN 適配;
爲什麼需要適配
保證界面在不同的設備屏幕上都能按設計圖效果展示,統一用戶視覺與操作體驗
常見適配名詞闡述
如果你從網上去搜屏幕適配,你搜到的博文中一定都會有以下一大堆名詞及其解釋
-
適配:不同屏幕下,元素顯示效果一致
-
屏幕尺寸:指的是屏幕對角線的長度
-
px(單位): px 實際是 pixel(像素)的縮寫,根據 維基百科的解釋,它是圖像顯示的基本單元,既不是一個確定的物理量,也不是一個點或者小方塊,而是一個抽象概念。所以在談論像素時一定要清楚它的上下文!
-
分辨率 :是指寬度上和高度上最多能顯示的物理像素點個數
-
設備物理像素:指設備能控制顯示的最小物理單位,意指顯示器上一個個的點。從屏幕在工廠生產出的那天起,它上面設備像素點就固定不變了,和屏幕尺寸大小有關
-
設備獨立像素(設備邏輯像素):計算機座標系統中得一個點,這個點代表一個可以由程序使用的虛擬像素 (比如: css 像素),這個點是沒有固定大小的,越小越清晰,然後由相關係統轉換爲物理像素
-
CSS 像素 :css px 和物理像素的對應關係, 與 viewport 的縮放有關 scale = 1/dpr 時 1px 對應一個 物理像素
-
DPI:打印設備印刷點密度。每 inch 多少個點
-
PPI:設備物理像素密度。每 inch 多少個物理像素
-
DPR:設備像素比 = 設備物理像素 / 設備獨立像素(CSS 像素)
看完這些名詞後大多數人的感覺:懂了,但沒完全懂~ 我們先忘記這些名詞概念,只記住以下 4 個概念:
-
適配:不同屏幕下,元素顯示效果一致
-
設備獨立像素 = 設備邏輯像素 = CSS 像素
-
DPR:設備像素比 = 設備物理像素 / 設備獨立像素(CSS 像素)
-
設計圖與編碼中的尺寸都是 CSS 像素
OK,下面,正菜開始!客官們請跟我這邊來。
RN 的尺寸單位
要做 RN 適配得先明白 RN 樣式的尺寸單位。在 RN 的官網有明確標註:
All dimensions in React Native are unitless, and represent density-independent pixels. React Native 中的尺寸都是無單位的,表示的是與設備像素密度無關的邏輯像素點。
爲什麼是無單位的邏輯像素點呢?
因爲 RN 是個跨平臺的框架,在 IOS 上通常以邏輯像素單位 pt 描述尺寸,在 Android 上通常以邏輯像素單位 dp 描述尺寸,RN 選哪個都不好,既然大家意思相同,乾脆不帶單位,在哪個平臺渲染就默認用哪個單位。RN 提供給開發者的就是已經通過 DPR(設備像素比)轉換過的邏輯像素尺寸,開發者無需再關心因爲設備 DPR 不同引起的尺寸數值計算問題在有些博文中,會提到 RN 已經做好了適配,其實指的就是這個意思。
適配方案
注意:本文示例與描述中設計圖尺寸標準都爲 375X667 (iPhone6/7/8)
對於 RN 適配,我總結爲以下**口訣:****一理念,一像素,一比例;****局部盒子全部按比例;****遇到整頁佈局垂直方向彈一彈;**安卓需要處理狀態欄。
一理念
適配就是不同屏幕下,元素顯示效果一致的理念怎麼理解呢?舉個栗子:假設有一個元素在 375X667 的設計圖上標註爲 375X44,即寬度佔滿整個屏幕,高度 44px。如果我們做好了 RN 的屏幕適配,那麼:在 iPhone 6/7/8(375X667) 機型與 iPhone X(375X812) 機型上,此元素渲染結果會佔滿屏幕寬度;在 iPhone 6/7/8 Plus(414X736) 機型上, 此元素渲染結果也應占滿屏幕寬度;
打個現實生活中的比方:聯合國根據恩格爾係數的大小,對世界各國的生活水平有一個劃分標準,即一個國家平均家庭恩格爾係數大於 60% 爲貧窮;50%-60% 爲溫飽;40%-50% 爲小康;30%-40% 屬於相對富裕;20%-30% 爲富足;20% 以下爲極其富裕。假設要實現小康生活,不管你是哪個國家的人民,發達國家也好,發展中國家也好,家庭的恩格爾係數都必須達到 40%-50%。這裏,國家就可以理解爲手機屏幕、生活水平就理解爲元素渲染效果。至於上述的一些名詞,如:物理像素,像素比等,你可以理解爲國家的貨幣以及貨幣匯率。畢竟,程序設計源自生活。
那麼,正在搬磚的你,小康了嗎~?
一像素
RN style 中所有的尺寸,包括但不限於 width、height、margin、padding、top、left、bottom、right、fontSize、lineHeight、transform 等都是邏輯像素(web 玩家可以理解爲 css 像素)
h3: {
color: '#4A4A4A',
fontSize: 13,
lineHeight: 20,//邏輯像素
marginLeft: 25,
marginRight: 25,
},
一比例
設備邏輯像素寬度比例爲了更好的視覺與用戶操作體驗,目前流行的移動端適配方案,在大小上都是進行寬度適配,在佈局上垂直方向自由排列。這樣做的好處是:保證在頁面上元素大小都是按設計圖進行等比例縮放,內容恰好只能鋪滿屏幕寬度;垂直方向上內容如果超出屏幕,可以通過手指上滑下拉查看頁面更多內容。當然,如果你想走特殊路子,設計成高度適配,水平方向滑動也是可以的。回到上面 “一理念” 的例子,在 iPhone 6/7/8 Plus(414X736)機型上,渲染一個設計圖 375 尺寸元素的話,很容易計算出,我們實際要設置的寬度應爲:375 * 414/375 = 414。這裏的414/375
就是設備邏輯像素寬度比例
公式:WLR = 設備寬度邏輯像素/設計圖寬度
WLR(width logic rate 縮寫),散裝英語,哈哈。在這裏,設備的寬度邏輯像素我建議用
Dimensions.get('window').width
獲取,具體緣由,後面會進行解釋。[Q1]
那麼,在目標設備上要設置的尺寸計算公式就是:size = 設置圖上元素size * WLR
小學四則運算,非常簡單!其實所有的適配都是圍繞一個比例在做,如 web 端縮放、rem 適配、postcss plugin 等,大道萬千,殊途同歸!
局部盒子全部按比例
爲了方便理解,這裏的 “盒子” 意思等同於 web 中的“盒模型”。
局部盒子全部按比例。意思就是 RN 頁面中的元素大小、位置、內外邊距等涉及尺寸的地方,全部按上述一比例中的尺寸計算公式進行計算。如下圖所示:
這樣渲染出來的效果,會最大限度的保留設計圖的大小與佈局設計效果。
爲什麼說是最大限度,這裏先留做一個問題,後文中解釋。[Q2]
到這裏,可能有新手同學會問:**爲什麼在垂直方向上不用設備高度邏輯像素比例進行計算?**因爲 設備高度邏輯像素/設計圖高度
不一定會等於 設備寬度邏輯像素/設計圖寬度
,會引起盒子拉伸。比如,現在按照設計圖在 iPhone X(375X812) 上渲染一個 100X100px 的正方形盒子,寬度邏輯像素比例是 1,高度邏輯像素比例是 812/667≈1.22,如果寬度與高度分別按前面的 2 個比例計算,那麼最終盒模型的 size 會變成:
view1: {
width: 100,
height: 122,
},
好嘛,好好的一個正方形被拉伸成長方形了!
這顯然是要不得的。講到這裏,RN 適配其實已經完成 70% 了,對,就是玩乘除法~
遇到整頁佈局垂直方向彈一彈
何爲整頁佈局?
內容剛好鋪滿整頁,沒有溢出屏幕外。
這裏的彈一彈,指的是 flex 佈局。在 RN 中,默認都是 flex 佈局,並且方向是 column,從上往下佈局。**爲啥要彈一彈呢?**我們先來看移動端頁面佈局常見的整頁上中下分區佈局設計,以 TCL IOT 單品舊版 UI 設計爲例:
按照設計,在 iPhone 6/7/8 機型 (375X667) 上恰好鋪滿整頁,在 iPhone 6/7/8 機型 plus(414X736)機型上根據上述的適配方法,其實也是近似鋪滿的,因爲 414/375≈736/667
。但是,在 iPhone X(375X812) 機型上,如果按照設計圖從上往下佈局,會出現底下空出一截的情況:
此時有兩種處理方法:
-
底部 - 操控菜單欄區域使用絕對定位
bottom:0
固定在底部,最頂部 - 狀態欄 + 標題欄是固定在頂部的,不需要處理,然後計算並用絕對定位微調頂部 - 設備信息展示區,中部 - 設備狀態區的位置,使它們恰好平分多出來的空白空間,讓頁面看起來更加協調; -
頂部 - 設備信息展示區,中部 - 設備狀態區,底部 - 操控菜單欄區域使用父容器包裹,利用 RN flex 彈性佈局的特性,設置
justifyContent:'space-between'
使得這 3 個區域垂直方向上下兩端對齊,中間區域上下平分多出來的空白區域。第 1 種,每個設備都需要去計算空白區域大小,再去微調元素位置,十分麻煩。我推薦第 2 種,編碼上更加簡單。這就是 “彈一彈” 有同學會擔心第 2 種方式會導致中間區域垂直方向上跨度非常大,頁面看起來不協調。但是在實際中,設備屏幕高度邏輯像素很少會有比 667 大非常多的,多出的空白區域比較小,UI 效果還是可以的,目前我們上線的 N 款產品中也都是使用的這種方式,請放心食用。
到此爲止,如果按照以往 web 端的適配經驗,RN 適配應該已經完成了,但是,還是有坑的。
安卓需要處理狀態欄
RN 雖然是跨平臺的,但是在 ios 與 Android 上渲染效果卻不一樣。最明顯的就是狀態欄了。如下圖所示:
Android 在不設置 StatusBar
的 translucent
屬性爲true
時,是從狀態欄下方開始繪製的。這與我們的適配目標不吻合,因爲在我們的設計圖中,整頁的佈局設計是覆蓋了狀態欄的。所以,建議將 Android 的狀態欄 translucent
屬性設爲true
,整個頁面交給我們開發者自己去佈局。
<StatusBar translucent={true} />
如果你已經看到這裏,恭喜你,同學,掌握 RN 的適配了,可以應對 90% 以上的場景。
但是還有一些奇奇怪怪的場景以及一些 API 你可能不太理解,這包含在剩下的 10% 適配場景中或在其中幫助你理解與調試,沒關係,我下面繼續闡述。有些會涉及到源碼,如果你有興趣,可以繼續跟我看下去。
下面的內容非常非常多,但是對我個人而言,這部分纔是我此次分享,想帶給大家的最重要的部分。
一些奇奇怪怪又有意思的東西
這部分內容非常多,請酌情閱讀
Dimensions
API
Dimensions
是 RN 提供的一個獲取設備尺寸信息的 API。我們可以用它來獲取屏幕的寬高,這是做適配的核心 API。它提供了兩種獲取方式:
const {windowWidth,windowHeight} = Dimensions.get('window');
const {screenWidth,screenHeight} = Dimensions.get('screen');
官方文檔上並沒有說明這兩種獲取方式的結果的含義與區別是什麼。在實際開發中,這兩種方式獲取的結果有時相同,有時又有差異,讓部分同學感到困惑:我到底該使用哪一個纔是正確的?**我推薦你一直使用 Dimensions.get('window')。只有通過它獲取的結果,纔是我們真正可以操控繪製的區域。**首先,明確這兩種方式獲取的結果的含義:
-
Dimensions.get('window')
——獲取視口參數 width、height、scale、fontScale -
Dimensions.get('screen')
——獲取屏幕參數 width、height、scale、fontScale 其中,在設備屏幕同狀態的默認情況下 screen 的 width、height 永遠是≥window 的 width、height,因爲,window 獲取的參數會排除掉狀態欄高度(translucent 爲 false 時)以及底部虛擬菜單欄高度。 當此安卓機設置了狀態欄translucent
爲true
並且沒有開啓虛擬菜單欄時,Dimensions.get('window')
就會與Dimensions.get('screen')
獲取的 width、height 一致,否則就不同。這就是本段開始時有時相同,有時又有差異的問題的答案。
這並非靠猜想或空穴來風,直接源碼安排上:
因作者設備有限,本文源碼僅從 Android 平臺分析,ios 的源碼,有 ios 經驗的同學可以按照思路自行查閱。準備:按照官方文檔新建一個 Demo RN 工程。爲了穩定性,我們使用前面的一個 RN 版本 0.62.0。命令如下:
npx react-native init Demo --version 0.62.0
step1. 先找到 RN 的該 API 的 js 文件。node_modules\react-native\Libraries\Utilities\Dimensions.js
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow
*/
'use strict';
import EventEmitter from '../vendor/emitter/EventEmitter';
import RCTDeviceEventEmitter from '../EventEmitter/RCTDeviceEventEmitter';
import NativeDeviceInfo, {
type DisplayMetrics,
type DimensionsPayload,
} from './NativeDeviceInfo';
import invariant from 'invariant';
type DimensionsValue = {
window?: DisplayMetrics,
screen?: DisplayMetrics,
...
};
const eventEmitter = new EventEmitter();
let dimensionsInitialized = false;
let dimensions: DimensionsValue;
class Dimensions {
/**
* NOTE: `useWindowDimensions` is the preffered API for React components.
*
* Initial dimensions are set before `runApplication` is called so they should
* be available before any other require's are run, but may be updated later.
*
* Note: Although dimensions are available immediately, they may change (e.g
* due to device rotation) so any rendering logic or styles that depend on
* these constants should try to call this function on every render, rather
* than caching the value (for example, using inline styles rather than
* setting a value in a `StyleSheet`).
*
* Example: `const {height, width} = Dimensions.get('window');`
*
* @param {string} dim Name of dimension as defined when calling `set`.
* @returns {Object?} Value for the dimension.
*/
static get(dim: string): Object {
invariant(dimensions[dim], 'No dimension set for key ' + dim);
return dimensions[dim];
}
/**
* This should only be called from native code by sending the
* didUpdateDimensions event.
*
* @param {object} dims Simple string-keyed object of dimensions to set
*/
static set(dims: $ReadOnly<{[key: string]: any, ...}>): void {
// We calculate the window dimensions in JS so that we don't encounter loss of
// precision in transferring the dimensions (which could be non-integers) over
// the bridge.
let {screen, window} = dims;
const {windowPhysicalPixels} = dims;
if (windowPhysicalPixels) {
window = {
width: windowPhysicalPixels.width / windowPhysicalPixels.scale,
height: windowPhysicalPixels.height / windowPhysicalPixels.scale,
scale: windowPhysicalPixels.scale,
fontScale: windowPhysicalPixels.fontScale,
};
}
const {screenPhysicalPixels} = dims;
if (screenPhysicalPixels) {
screen = {
width: screenPhysicalPixels.width / screenPhysicalPixels.scale,
height: screenPhysicalPixels.height / screenPhysicalPixels.scale,
scale: screenPhysicalPixels.scale,
fontScale: screenPhysicalPixels.fontScale,
};
} else if (screen == null) {
screen = window;
}
dimensions = {window, screen};
if (dimensionsInitialized) {
// Don't fire 'change' the first time the dimensions are set.
eventEmitter.emit('change', dimensions);
} else {
dimensionsInitialized = true;
}
}
/**
* Add an event handler. Supported events:
*
* - `change`: Fires when a property within the `Dimensions` object changes. The argument
* to the event handler is an object with `window` and `screen` properties whose values
* are the same as the return values of `Dimensions.get('window')` and
* `Dimensions.get('screen')`, respectively.
*/
static addEventListener(type: 'change', handler: Function) {
invariant(
type === 'change',
'Trying to subscribe to unknown event: "%s"',
type,
);
eventEmitter.addListener(type, handler);
}
/**
* Remove an event handler.
*/
static removeEventListener(type: 'change', handler: Function) {
invariant(
type === 'change',
'Trying to remove listener for unknown event: "%s"',
type,
);
eventEmitter.removeListener(type, handler);
}
}
let initialDims: ?$ReadOnly<{[key: string]: any, ...}> =
global.nativeExtensions &&
global.nativeExtensions.DeviceInfo &&
global.nativeExtensions.DeviceInfo.Dimensions;
if (!initialDims) {
// Subscribe before calling getConstants to make sure we don't miss any updates in between.
RCTDeviceEventEmitter.addListener(
'didUpdateDimensions',
(update: DimensionsPayload) => {
Dimensions.set(update);
},
);
// Can't use NativeDeviceInfo in ComponentScript because it does not support NativeModules,
// but has nativeExtensions instead.
initialDims = NativeDeviceInfo.getConstants().Dimensions;
}
Dimensions.set(initialDims);
module.exports = Dimensions;
這個 Dimensions.js 模塊初始化了 Dimensions 參數信息,我們的 Dimensions.get() 方法就是獲取的其中的信息。並且,該模塊指出了信息的來源:
//...
initialDims = NativeDeviceInfo.getConstants().Dimensions;
//...
Dimensions.set(initialDims);
let {screen, window} = dims
const {windowPhysicalPixels} = dims
const {screenPhysicalPixels} = dims
//...
dimensions = {window, screen};
數據來源是來自原生模塊中的 DeviceInfo module。好嘛,我們直接去找安卓源碼,看看它提供的是啥玩意兒。
step2: 從 node_modules\react-native\android\com\facebook\react\react-native\0.62.0\react-native-0.62.0-sources.jar 中取到安卓源碼 jar 包。
下載下來,保存到本地。step3: 使用工具 java decompiler 反編譯react-native-0.62.0-sources.jar
:
可以看到,有很多 package。我們直奔 com.facebook.react.modules
,這個模塊是原生爲 RN jsc 提供的絕大部分 API 的地方。
step4: 打開 com.facebook.react.modules.deviceinfo.DeviceInfoModule.java
:
看圖中紅色方框標記的地方,就是在上述 js 中模塊中
initialDims = NativeDeviceInfo.getConstants().Dimensions;
設備的初始尺寸信息來源於此。step5: 打開 DisplayMetricsHolder.java
, 找到getDisplayMetricsMap()
方法:
怎麼樣,windowPhysicalPixels
& screenPhysicalPixels
是不是很熟悉? 而它們的屬性字段width
、height
、scale
、fontScale
、densityDpi
等是不是經常用過一部分?沒錯,你在開始的Dimensions.js
中見過它們:
嚴格來說,Dimensions.js
還漏了個densityDpi
(設備像素密度)沒有解構出來~ ok,那我們看它們最開始的數據來源:
result.put("windowPhysicalPixels", getPhysicalPixelsMap(sWindowDisplayMetrics, fontScale));
result.put("screenPhysicalPixels", getPhysicalPixelsMap(sScreenDisplayMetrics, fontScale));
分別來自:sWindowDisplayMetrics
、sScreenDisplayMetrics
。其中,sWindowDisplayMetrics
通過
DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
DisplayMetricsHolder.setWindowDisplayMetrics(displayMetrics);
設置;sScreenDisplayMetrics
通過
DisplayMetrics screenDisplayMetrics = new DisplayMetrics();
screenDisplayMetrics.setTo(displayMetrics);
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
Assertions.assertNotNull(wm, "WindowManager is null!");
Display display = wm.getDefaultDisplay();
// Get the real display metrics if we are using API level 17 or higher.
// The real metrics include system decor elements (e.g. soft menu bar).
//
// See:
// http://developer.android.com/reference/android/view/Display.html#getRealMetrics(android.util.DisplayMetrics)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
display.getRealMetrics(screenDisplayMetrics);
} else {
// For 14 <= API level <= 16, we need to invoke getRawHeight and getRawWidth to get the real
// dimensions.
// Since react-native only supports API level 16+ we don't have to worry about other cases.
//
// Reflection exceptions are rethrown at runtime.
//
// See:
// http://stackoverflow.com/questions/14341041/how-to-get-real-screen-height-and-width/23861333#23861333
try {
Method mGetRawH = Display.class.getMethod("getRawHeight");
Method mGetRawW = Display.class.getMethod("getRawWidth");
screenDisplayMetrics.widthPixels = (Integer) mGetRawW.invoke(display);
screenDisplayMetrics.heightPixels = (Integer) mGetRawH.invoke(display);
} catch (InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
throw new RuntimeException("Error getting real dimensions for API level < 17", e);
}
}
DisplayMetricsHolder.setScreenDisplayMetrics(screenDisplayMetrics);
設置。在安卓中 context.getResources().getDisplayMetrics();
只會獲取可繪製區域尺寸信息,默認會去除頂部狀態欄以及底部虛擬菜單欄;而設置 screenDisplayMetrics 時,雖然有去區分版本,但最終都是獲取的整個屏幕的物理分辨率。因此,可以真正有理有據的解釋開頭的情況了。並且完完全全從 js 層到原生層講述了Dimensions
API,好吧,講這一個就囉裏囉嗦的了,各位看官明白了嗎?
全屏 mask 樣式無法覆蓋整個屏幕
這個問題出現在部分老舊安卓機上,大概在 2016~2018 年左右的中低端機型,榮耀機型居多。這類手機自帶底部虛擬菜單欄,並且在使用時可以自動 / 手動隱藏。**問題情境:**當彈出一個帶 mask 的自定義 Modal 時,如果設置了 mask 高度是 Dimensions.get('window').height,在隱藏底部虛擬菜單欄後,底部會空出一截無法被 mask 遮罩。**問題原因:**隱藏菜單欄後,頁面可繪製區域高度已經發生了變化,而目前所渲染的視圖還是上一次未隱藏菜單欄狀態下的。**解決方案:**監聽屏幕狀態變化,這一點官網其實已經特別指出了 (https://www.react-native.cn/d...%E4%BD%86%E6%98%AF%E5%BE%88%E5%A4%9A%E5%90%8C%E5%AD%A6%E8%87%AA%E5%8A%A8%E5%BF%BD%E7%95%A5%E4%BA%86%E3%80%82) 使用 Dimensions.addEventListener()
監聽並設置 mask 高度,重點是要改變 state,通過 state 驅動視圖更新。當然,也要記得移除事件監聽Dimensions.removeEventListener()
1 像素邊框有時無法顯示
RN 的 1 像素邊框,通常是指:StyleSheet.hairlineWidth
它是一個常量,渲染效果會符合當前平臺最細的標準。但是,在列表子項中設置時,經常會有部分列表子項丟失這根線,而且詭異的是,同一根線,有些手機顯示正常,有些手機不顯示,甚至有些機型上線條會比較 “胖”。
老規矩,源碼搬一搬:在 node_modules\react-native\Libraries\StyleSheet\StyleSheet.js 中可以找到:
let hairlineWidth: number = PixelRatio.roundToNearestPixel(0.4);
if (hairlineWidth === 0) {
hairlineWidth = 1 / PixelRatio.get();
}
然後在 node_modules\react-native\Libraries\Utilities\PixelRatio.js 中找到:
/**
* Rounds a layout size (dp) to the nearest layout size that corresponds to
* an integer number of pixels. For example, on a device with a PixelRatio
* of 3, `PixelRatio.roundToNearestPixel(8.4) = 8.33`, which corresponds to
* exactly (8.33 * 3) = 25 pixels.
*/
static roundToNearestPixel(layoutSize: number): number {
const ratio = PixelRatio.get();
return Math.round(layoutSize * ratio) / ratio;
}
這原理就是渲染一條 0.4 邏輯像素左右的線,值不一定是 0.4,要根據roundToNearestPixel
換算成最能佔據整數個物理像素的一個值,與設備 DPR 有關,也是上述 Dimensions
中的scale
屬性值。最差的情況就是在 DPR 小於 1.25 時,等於1 / PixelRatio.get()
。按照上面的規則計算,再怎麼樣,總歸還是應該會顯示的。但是,這裏我們要先引入 2 個概念——像素網格對齊以及 JavaScript number 精度:
我們在設置邏輯像素時可以任意指定精度,但是設備渲染時,實際是按一個一個的物理像素顯示的,物理像素永遠整個的。爲了能保證在任意精度的情況也能正確顯示,RN 渲染時會做像素網格對齊; JavaScript 沒有真正意義上的整數。它的數字類型是基於 IEEE 754 標準實現的,採用的 64 位二進制的 “雙精度” 格式。數值之間會存在一個 “機器精度” 誤差,通常是Math.pow(2,-52)
.
概念說完,我們來看例子:
假設現在有個 DPR=1.5 的安卓機,在頁面上下渲染 2 個height = StyleSheet.hairlineWidth
的 View, 按照上面計算規則,此時height = StyleSheet.hairlineWidth≈0.66666667
, 理想情況佔據 1px 物理像素。但實際情況可能是:
因爲 js 數字精度問題, Math.round(0.4 * 1.5) / 1.5
再乘 1.5
不一定等於1
,有可能是大於 1,有可能是小於 1,當然,也可能等於 1。覺得困惑嗎?給你看一道常見面試題咯:0.1+0.2 === 0.3 // false
怎麼樣?明白了嗎?哈哈 而物理像素是整個的,大於 1 時,會佔據 2 個物理像素,小於 1 時可能佔據 1 個也可能不佔據,等於 1 時,正常顯示。這就是像素網格對齊,導致設置 StyleSheet.hairlineWidth 顯示出現了 3 種情況:
-
顯示比預期要粗;
-
顯示正常;
-
不顯示;
**解決辦法:**大部分情況下,StyleSheet.hairlineWidth
其實都是表現良好的。如果出現這個問題,你可以試試選用一個0.4~1
的一個值去設置尺寸:
wrapper:{
height:.8,
backgroundColor:'#333'
}
然後查看渲染效果,選一個最適合的。
總結
在本文中,我首先介紹了 RN 適配的方案,並總結了一個適配口訣送給大家。如果你理解了這個口訣,就基本掌握了 RN 適配;然後,從源碼的角度,帶大家追本溯源講述了適配核心 API——Dimensions
的含義以及其值的來源;最後,解釋了 “全屏 mask 無法覆蓋整個屏幕” 以及 “1 像素邊框有時無法顯示” 的現象或問題。**希望你看完本文有所收穫!**如果你覺得不錯,歡迎點贊與收藏並推薦給身邊的朋友,感謝您的鼓勵與認可!有任何問題也歡迎留言或者私信我原創不易,轉載需取得本人同意。
FQA
-
劉海屏、異形屏怎麼適配?推薦開啓 “沉浸式” 繪製。ios 默認開啓,Android 需要設置
<StatusBar translucent={true} />
。然後根據劉海、異形屏實際情況設置頂部狀態欄 + 標題欄的高度。 -
iPad 等大屏平板電腦怎麼適配?需要看實際業務。如果需求只需保持跟手機端一致,那麼可以直接用我的這套方案。如果還要求橫屏豎屏適配,那麼你需要使用
Dimensions.addEventListener()
監聽並設置此時 RN 視口參數,計算比例時,都以監聽到的值爲標準,再做適配。 -
爲什麼說適配是最大限度還原設計?(正文中的 [Q2]) 在 “1 像素邊框有時無法顯示” 的章節中,我提到了像素網格對齊以及 js 數字精度問題。在做適配時,我們最終設置的值都是根據比例進行計算的,這個計算結果會有精度誤差,再加上像素網格對齊,在渲染後,存在某些特殊情況,例如在某一塊區域內連續渲染大量的小元素節點時,會導致與設計圖存在細微區別。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/B7pdJ8Bgy3kZEId_E2MuOQ