在我從事React Native(如下簡稱RN)開發的兩年工做中,本身與團隊成員時常會遇到一些使人疑惑的屏幕適配問題,如:全屏mask樣式沒法覆蓋整個屏幕、1像素邊框有時沒法顯示、特殊機型佈局錯亂等。另外,部分紅員對RN獲取屏幕參數的API——Dimensions.get('window')
與Dimensions.get('screen')
最終返回的值表明的意義也存在疑惑。
其實RN的適配比較簡單,我將在此文中闡述適配原理,提出適配方案,並針對部分特殊問題一一解釋其緣由,原則上能覆蓋全部機型的適配。如有遺漏與不當之處,歡迎指出,共同交流。css
往期精彩RN文章推薦:
- 【從源碼分析】多是全網最實用的React Native異常解決方案【建議收藏】
保證界面在不一樣的設備屏幕上都能按設計圖效果展現,統一用戶視覺與操做體驗html
若是你從網上去搜屏幕適配,你搜到的博文中必定都會有如下一大堆名詞及其解釋
看完這些名詞後大多數人的感受:懂了,但沒徹底懂\~
咱們先忘記這些名詞概念,只記住如下4個概念:java
OK,下面,正菜開始!客官們請跟我這邊來。node
要作RN適配得先明白RN樣式的尺寸單位。
在RN的官網有明確標註:react
All dimensions in React Native are unitless, and represent density-independent pixels.
React Native 中的尺寸都是無單位的,表示的是與設備像素密度無關的邏輯像素點。
由於RN是個跨平臺的框架,在IOS上一般以邏輯像素單位pt描述尺寸,在Android上一般以邏輯像素單位dp描述尺寸,RN選哪一個都很差,既然你們意思相同,乾脆不帶單位,在哪一個平臺渲染就默認用哪一個單位。
RN提供給開發者的就是已經經過DPR(設備像素比)轉換過的邏輯像素尺寸,開發者無需再關心由於設備DPR不一樣引發的尺寸數值計算問題
在有些博文中,會提到RN已經作好了適配,其實指的就是這個意思。android
注意:本文示例與描述中設計圖尺寸標準都爲 375X667 (iPhone6/7/8)
對於RN適配,我總結爲如下口訣:
一理念,一像素,一比例;
局部盒子所有按比例;
遇到整頁佈局垂直方向彈一彈;
安卓須要處理狀態欄。ios
適配就是不一樣屏幕下,元素顯示效果一致的理念
怎麼理解呢?
舉個栗子:
假設有一個元素在375X667的設計圖上標註爲375X44,即寬度佔滿整個屏幕,高度44px。若是咱們作好了RN的屏幕適配,那麼:
在iPhone 6/7/8(375X667)機型與iPhone X(375X812)機型上,此元素渲染結果會佔滿屏幕寬度;
在iPhone 6/7/8 Plus(414X736)機型上,此元素渲染結果也應占滿屏幕寬度;git
打個現實生活中的比方:
聯合國根據恩格爾係數的大小,對世界各國的生活水平有一個劃分標準,即一個國家平均家庭恩格爾係數大於60%爲貧窮;50%-60%爲溫飽;40%-50%爲小康;30%-40%屬於相對富裕;20%-30%爲富足;20%如下爲極其富裕。
假設要實現小康生活,無論你是哪一個國家的人民,發達國家也好,發展中國家也好,家庭的恩格爾係數都必須達到40%-50%。
這裏,國家就能夠理解爲手機屏幕、生活水平就理解爲元素渲染效果。
至於上述的一些名詞,如:物理像素,像素比等,你能夠理解爲國家的貨幣以及貨幣匯率。畢竟,程序設計源自生活。github
那麼,正在搬磚的你,小康了嗎~?
RN style 中全部的尺寸,包括但不限於width、height、margin、padding、top、left、bottom、right、fontSize、lineHeight、transform等都是邏輯像素(web玩家能夠理解爲css像素)web
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
APIDimensions
是RN提供的一個獲取設備尺寸信息的API。咱們能夠用它來獲取屏幕的寬高,這是作適配的核心API。
它提供了兩種獲取方式:
const {windowWidth,windowHeight} = Dimensions.get('window'); const {screenWidth,screenHeight} = Dimensions.get('screen');
官方文檔上並無說明這兩種獲取方式的結果的含義與區別是什麼。在實際開發中,這兩種方式獲取的結果有時相同,有時又有差別,讓部分同窗感到困惑:我到底該使用哪個纔是正確的?
我推薦你一直使用Dimensions.get('window')
。只有經過它獲取的結果,纔是咱們真正能夠操控繪製的區域。
首先,明確這兩種方式獲取的結果的含義:
Dimensions.get('window')
——獲取視口參數width、height、scale、fontScaleDimensions.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,好吧,講這一個就囉裏囉嗦的了,各位看官明白了嗎?
這個問題出如今部分老舊安卓機上,大概在2016~2018年左右的中低端機型,榮耀機型居多。這類手機自帶底部虛擬菜單欄,而且在使用時能夠自動/手動隱藏。
問題情境:
當彈出一個帶mask的自定義Modal時,若是設置了mask 高度是 Dimensions.get('window').height,在隱藏底部虛擬菜單欄後,底部會空出一截沒法被mask遮罩。
問題緣由:
隱藏菜單欄後,頁面可繪製區域高度已經發生了變化,而目前所渲染的視圖仍是上一次未隱藏菜單欄狀態下的。
解決方案:
監聽屏幕狀態變化,這一點官網其實已經特別指出了(https://www.react-native.cn/d...
使用 Dimensions.addEventListener()
監聽並設置mask高度,重點是要改變state,經過state驅動視圖更新。
固然,也要記得移除事件監聽Dimensions.removeEventListener()
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像素邊框有時沒法顯示」的現象或問題。
但願你看完本文有所收穫!
若是你以爲不錯,歡迎點贊與收藏並推薦給身邊的朋友,感謝您的鼓勵與承認!
有任何問題也歡迎留言或者私信我
原創不易,轉載需取得本人贊成。
<StatusBar translucent={true} />
。而後根據劉海、異形屏實際狀況設置頂部狀態欄+標題欄的高度。Dimensions.addEventListener()
監聽並設置此時RN視口參數,計算比例時,都以監聽到的值爲標準,再作適配。