同一份 React Native 代码,装到 iPhone 上标题栏顶进了状态栏,换到一台 720p 的老安卓上,字又小得看不清。这类问题在模拟器上很难一次暴露干净,你手边能开的分辨率就那么几种,真机一到就全冒出来了。适配这事说难不难,麻烦在于它散在四五个地方,平台判断、API 的双端支持、图标倍图、字号和间距的换算,漏掉任意一块都会露馅。
这篇把这几块串成一条线讲清楚,从最基础的 Platform.OS 一直到基于 PixelRatio 的等比换算工具,最后还会把老工具函数里一处一直没被发现的判断错误改掉。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 为什么 RN 写了一遍代码还是要做双端适配,适配到底在补哪个缺口
- 用
Platform.OS和Platform.select做平台分支,以及它们的取舍 - 怎么读懂官方 API 文档里那些
android/ios前缀标记 - 组件选型时该怎么判断一个组件是不是真的双端可用
- 图标的
1x/2x/3x倍图规则,以及引用时最容易犯的那个错 - 用
PixelRatio+Dimensions写一套字号和宽高的等比换算工具 - 老版工具函数里那处
PixelRatio === 2的判断为什么永远不成立 - 这套 2019 年的写法放到现在,哪些该换、哪些还能用
# 一、RN 的适配到底在补什么缺口
先把概念理顺,后面的 API 才不会记成八股。
React Native 里写的所有尺寸数字都是无单位的。你写 width: 100,iOS 上它是 100pt,Android 上它是 100dp。这两个单位背后是同一个思路,就是「与设备无关的逻辑像素」,系统会拿设备的像素密度把它换算成真正的物理像素。所以你不需要像写 CSS 那样纠结 px 还是 rem,RN 已经帮你抹掉了一层。
那既然抹掉了,为什么还要适配呢?
因为 RN 只抹平了「像素密度」,没抹平另外三件事。一是屏幕的逻辑宽高本身不一样,iPhone SE 是 320pt 宽,iPhone 11 Pro Max 是 414pt 宽,同样写 width: 300 在两台机器上占的视觉比例差了一大截。二是两个系统的原生行为不一样,比如 iOS 的根视图默认是顶到状态栏下面的,Android 不是。三是 API 本身就有单端独占的部分。
所以适配要干的活其实就三样,判断平台、按比例换算、给资源准备多套倍图。下面一个个说。
# 二、Platform.OS 做平台分支
最直接的一招就是运行时判断当前平台。原文里那个状态栏的例子非常典型。
在 iOS 上,如果你没有用 SafeAreaView 之类的容器包一层,根视图默认会占据状态栏的位置,导航栏的内容就会和时间、电量图标糊在一起。Android 上系统会自己留出状态栏的空间,不需要你补。于是就有了这种写法,给 StatusBar 的外层容器按平台设一个高度。
<View style={{height: Platform.OS === 'ios' ? 20:0}}>
<StatusBar {...this.props.statusBar} />
</View>;
这里的 20 是那个年代 iOS 状态栏的经典高度。这个数字现在已经不能写死了,后面第八节会展开说。
如果分支不止一个属性,用三元表达式会很快糊掉。Platform.select 更适合成块的样式,它接收一个以平台名为键的对象,返回当前平台对应的那份。
import { Platform } from 'react-native'
const styles = StyleSheet.create({
container: {
...Platform.select({
ios: { paddingTop: 20, shadowOpacity: 0.2 },
android: { paddingTop: 0, elevation: 4 }
})
}
})
这个写法的好处是把两端的差异集中在一处,别人接手时一眼能看出「这里两端不一样」。散在各处的三元判断就没这个效果了。
还有一种更彻底的做法是按文件名分平台,同一个组件写成 Toast.ios.js 和 Toast.android.js,引用时只写 import Toast from './Toast',打包器会自动挑对应平台的那份。差异大到样式分支已经写不动的时候,用这招比在一个文件里塞满 if 干净得多。
# 三、留意 API 文档里的 android 和 ios 标记
并不是所有 React Native 的 API 或组件属性都同时兼容两端。官方文档在这类属性和方法前面会加上平台前缀。
android renderToHardwareTextureAndroid bool
ios shouldRasterizeIOS bool
上面这两行,renderToHardwareTextureAndroid 只在 Android 上生效,shouldRasterizeIOS 只在 iOS 上生效。它俩其实是同一件事的两端实现,都是把视图提升成一层独立的纹理交给 GPU,做动画时能省掉重复的重绘。
这里有个坑要注意。这类单端属性写到另一端上通常不会报错,只是静默失效。你在 Android 上加了 shouldRasterizeIOS 想优化动画,跑起来一点变化都没有,还以为是自己参数没调对,实际上这个属性压根没被消费。查这类问题最快的方式就是回文档看前缀,比在代码里瞎试快得多。
同理,某些方法在两端都存在但行为不同。Alert 的按钮数量、StatusBar 的部分属性、Linking 能识别的 scheme,都属于这一类。写之前扫一眼文档下方的平台说明,能省不少事。
# 四、组件选型先看双端支持
2019 年那会儿导航组件是个典型例子。当时 RN 里同时存在 NavigatorIOS 和 Navigator 两个选择,从文档能看出 NavigatorIOS 只支持 iOS,Navigator 两端都支持。要做双端应用,Navigator 才是那个能用的。
选组件的判断标准我一般看三条。第一条是文档或 README 里有没有明确写双端支持,只写了一端的直接排除。第二条是看仓库的 example 工程里有没有 android 目录,只有 ios 目录的多半是单端库。第三条是翻 issue 列表搜一下另一端的关键字,很多库名义上支持双端,实际上另一端的 bug 挂了一年没人修。
我自己的感受是,第三条最有用,也最容易被跳过。
顺带说一句时效性。NavigatorIOS 和 Navigator 这两个组件在后续版本里都已经被移除了,现在做导航基本都用 React Navigation 这类社区方案。原文这一节的结论依然成立,只是例子换了主角,「选组件前先确认双端支持」这条判断标准并没有过时。
# 五、图片适配用 1x 2x 3x 三套图
无论 Android 还是 iOS,现在不同分辨率的设备越来越多,图标要在这些设备上都不糊,就得为每个图标准备 1x、2x、3x 三种尺寸。React Native 会根据屏幕的像素密度动态挑选合适的那张。
目录结构长这样。
└── img
├── check.png
├── check@2x.png
└── check@3x.png
引用的时候只写标准分辨率那一张。
<Image source={require('./img/check.png')} />
这里就是最容易犯错的地方了。如果你写成 require('./img/check@2x.png'),那么应用在所有设备上都只会加载 check@2x.png,自动挑选的机制直接失效。在 2x 屏上看不出问题,到了 3x 屏上图标就开始发虚,而且这种糊法很轻微,不对比着看根本发现不了。
原因不复杂。RN 的打包器在解析 require 的时候,会把 check.png、check@2x.png、check@3x.png 归成同一个资源的三个变体,运行时按 PixelRatio 挑一个。你直接指名 @2x,等于告诉它「这就是一个独立资源」,变体关系就断了。
所以规则很简单,文件按 @2x @3x 命名放好,代码里永远只引用不带后缀的那个名字。
# 六、字体和宽高的等比换算
前面几节解决的是「有没有」的问题,这一节解决「大小对不对」的问题。
设计稿一般按某个固定宽度出,iOS 常见的是 750,Android 早年常见 720。你拿到的标注是设计稿上的物理像素,要变成代码里的逻辑像素,就得按当前屏幕宽度做一次等比缩放。原文的做法是封装两个工具函数,一个管字号,一个管宽高间距。
先看字号这个。
// utils/FontSize.js
import { PixelRatio, Dimensions } from 'react-native';
let { width, height } = Dimensions.get('window');
export let FontSize = (size) => {
if (PixelRatio === 2) {
// iphone 5s and older Androids
if (width < 360) {
return size * 0.95;
}
// iphone 5
if (height < 667) {
return size;
// iphone 6-6s
} else if (height >= 667 && height <= 735) {
return size * 1.15;
}
// older phablets
return size * 1.25;
}
if (PixelRatio === 3) {
// catch Android font scaling on small machines
// where pixel ratio / font scale ratio => 3:3
if (width <= 360) {
return size;
}
// Catch other weird android width sizings
if (height < 667) {
return size * 1.15;
// catch in-between size Androids and scale font up
// a tad but not too much
}
if (height >= 667 && height <= 735) {
return size * 1.2;
}
// catch larger devices
// ie iphone 6s plus / 7 plus / mi note 等等
return size * 1.27;
}
// if older device ie pixelRatio !== 2 || 3 || 3.5
return size;
};
这段的思路是按像素密度分档,再在每档里按屏幕宽高细分,给出一个手工调过的缩放系数。相比纯线性缩放,它的好处是字号不会在大屏上被放得过大,毕竟大屏用户的阅读距离没变。
但这段代码有个致命问题,我当时也是排查了半天才看出来。
PixelRatio 是从 react-native 里 import 进来的模块对象,拿它去和数字 2 做 === 比较,永远是 false。也就是说上面三个 if 分支一个都进不去,函数不管传什么都直接走到最后 return size,等于什么都没做。要拿到真正的像素密度,得调 PixelRatio.get()。
改法是这样。