+-
聊聊React Native屏幕适配那些事儿
首页 专栏 前端 文章详情
5
头图

聊聊React Native屏幕适配那些事儿

张子君 发布于 4 月 11 日
English

写在前面

在我从事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 在不设置 StatusBartranslucent属性为true时,是从状态栏下方开始绘制的。这与我们的适配目标不吻合,因为在我们的设计图中,整页的布局设计是覆盖了状态栏的。所以,建议将Android 的状态栏 translucent属性设为true,整个页面交给我们开发者自己去布局。

<StatusBar translucent={true} />

如果你已经看到这里,恭喜你,同学,掌握RN的适配了,可以应对90%以上的场景。

但是还有一些奇奇怪怪的场景以及一些API你可能不太理解,这包含在剩下的10%适配场景中或在其中帮助你理解与调试,没关系,我下面继续阐述。有些会涉及到源码,如果你有兴趣,可以继续跟我看下去。

下面的内容非常非常多,但是对我个人而言,这部分才是我此次分享,想带给大家的最重要的部分。

一些奇奇怪怪又有意思的东西

这部分内容非常多,请酌情阅读

1. DimensionsAPI

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时)以及底部虚拟菜单栏高度。 当此安卓机设置了状态栏translucenttrue并且没有开启虚拟菜单栏时,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是不是很熟悉?而它们的属性字段widthheightscalefontScaledensityDpi等是不是经常用过一部分?没错,你在开始的Dimensions.js中见过它们:

严格来说,Dimensions.js还漏了个densityDpi(设备像素密度)没有解构出来~
ok,那我们看它们最开始的数据来源:

result.put("windowPhysicalPixels", getPhysicalPixelsMap(sWindowDisplayMetrics, fontScale)); result.put("screenPhysicalPixels", getPhysicalPixelsMap(sScreenDisplayMetrics, fontScale));

分别来自:
sWindowDisplayMetricssScreenDisplayMetrics
其中,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层到原生层讲述了DimensionsAPI,好吧,讲这一个就啰里啰嗦的了,各位看官明白了吗?

全屏mask样式无法覆盖整个屏幕

这个问题出现在部分老旧安卓机上,大概在2016~2018年左右的中低端机型,荣耀机型居多。这类手机自带底部虚拟菜单栏,并且在使用时可以自动/手动隐藏。
问题情境:
当弹出一个带mask的自定义Modal时,如果设置了mask 高度是 Dimensions.get('window').height,在隐藏底部虚拟菜单栏后,底部会空出一截无法被mask遮罩。
问题原因:
隐藏菜单栏后,页面可绘制区域高度已经发生了变化,而目前所渲染的视图还是上一次未隐藏菜单栏状态下的。
解决方案:
监听屏幕状态变化,这一点官网其实已经特别指出了(https://www.react-native.cn/d...
使用 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数字精度问题。在做适配时,我们最终设置的值都是根据比例进行计算的,这个计算结果会有精度误差,再加上像素网格对齐,在渲染后,存在某些特殊情况,例如在某一块区域内连续渲染大量的小元素节点时,会导致与设计图存在细微区别。
前端 屏幕适配 react-native
阅读 577 更新于 4 月 11 日
举报
赞5 收藏4
分享
本作品系原创, 采用《署名-非商业性使用-禁止演绎 4.0 国际》许可协议
子君的前端花园
当一个园丁,分享一些前端干货,与诸君共勉!
关注专栏
avatar
张子君

做有价值的事,做有价值的人

482 声望
11 粉丝
关注作者
0 条评论
得票数 最新
提交评论
avatar
张子君

做有价值的事,做有价值的人

482 声望
11 粉丝
关注作者
宣传栏
目录

写在前面

在我从事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 在不设置 StatusBartranslucent属性为true时,是从状态栏下方开始绘制的。这与我们的适配目标不吻合,因为在我们的设计图中,整页的布局设计是覆盖了状态栏的。所以,建议将Android 的状态栏 translucent属性设为true,整个页面交给我们开发者自己去布局。

<StatusBar translucent={true} />

如果你已经看到这里,恭喜你,同学,掌握RN的适配了,可以应对90%以上的场景。

但是还有一些奇奇怪怪的场景以及一些API你可能不太理解,这包含在剩下的10%适配场景中或在其中帮助你理解与调试,没关系,我下面继续阐述。有些会涉及到源码,如果你有兴趣,可以继续跟我看下去。

下面的内容非常非常多,但是对我个人而言,这部分才是我此次分享,想带给大家的最重要的部分。

一些奇奇怪怪又有意思的东西

这部分内容非常多,请酌情阅读

1. DimensionsAPI

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时)以及底部虚拟菜单栏高度。 当此安卓机设置了状态栏translucenttrue并且没有开启虚拟菜单栏时,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是不是很熟悉?而它们的属性字段widthheightscalefontScaledensityDpi等是不是经常用过一部分?没错,你在开始的Dimensions.js中见过它们:

严格来说,Dimensions.js还漏了个densityDpi(设备像素密度)没有解构出来~
ok,那我们看它们最开始的数据来源:

result.put("windowPhysicalPixels", getPhysicalPixelsMap(sWindowDisplayMetrics, fontScale)); result.put("screenPhysicalPixels", getPhysicalPixelsMap(sScreenDisplayMetrics, fontScale));

分别来自:
sWindowDisplayMetricssScreenDisplayMetrics
其中,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层到原生层讲述了DimensionsAPI,好吧,讲这一个就啰里啰嗦的了,各位看官明白了吗?

全屏mask样式无法覆盖整个屏幕

这个问题出现在部分老旧安卓机上,大概在2016~2018年左右的中低端机型,荣耀机型居多。这类手机自带底部虚拟菜单栏,并且在使用时可以自动/手动隐藏。
问题情境:
当弹出一个带mask的自定义Modal时,如果设置了mask 高度是 Dimensions.get('window').height,在隐藏底部虚拟菜单栏后,底部会空出一截无法被mask遮罩。
问题原因:
隐藏菜单栏后,页面可绘制区域高度已经发生了变化,而目前所渲染的视图还是上一次未隐藏菜单栏状态下的。
解决方案:
监听屏幕状态变化,这一点官网其实已经特别指出了(https://www.react-native.cn/d...
使用 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数字精度问题。在做适配时,我们最终设置的值都是根据比例进行计算的,这个计算结果会有精度误差,再加上像素网格对齐,在渲染后,存在某些特殊情况,例如在某一块区域内连续渲染大量的小元素节点时,会导致与设计图存在细微区别。