什么是多倍屏?
大家可能听过一倍屏、二倍屏等等这类的名词,那么所谓的 多倍屏 到底是什么呢?
一些基本概念
设备独立像素 —— DIP
独立于设备的像素,又称设备逻辑像素。可以简单的理解为我们常说的 “分辨率”。
打开 chrome 开发者工具,选择 iphone12,可以看到对应的 DIP 为 390X844:
设备独立像素 = 设备逻辑像素 = CSS 像素(缩放为1)
(当页面缩放为 200% 时,1个 CSS像素 = 2x2 个设备独立像素)
即假设现在有一个宽度为 390px 的 div,那么它刚好可以撑满 iphone12 的一行;如果这个 div 高度为 844px,那么它将充满整个屏幕。
所以,DIP 可以通过 window.screen.width 以及 window.screen.height 获取。
设备物理像素 —— DP
设备屏幕实际物理像素,是在设备出厂时就固定的。
显示屏是由一个个物理像素点组成的,通过控制每个像素的颜色,就可以使屏幕显示出不同的图像。
以 iphone 12 为例,可以在官网上查到相关参数。
表示屏幕横向有 1170 个像素点,纵向有 2532 个像素点。
一个设备独立像素里可能包含1个或者多个物理像素点,包含的越多则屏幕看起来越清晰。
所谓 4k 屏就是屏幕横向有 4096 个物理像素。
设备像素比 —— DPR
DPR 的全称是 Device Pixel Ratio,未缩放状态下,是设备物理像素和设备独立像素之间的初始比例关系。
DPR = 设备物理像素(DP) / 设备独立像素(DIP)
以 iphone12 为例,DPR = 1170 / 390 or DPR = 2352 / 844,最终可以得出 iphone12 的 DPR 为 3。
(划重点!)所谓多倍屏中的 “倍” 指的就是 DPR
![]()
我们常说的 Retina 屏指的就是 DPR > 1 的设备,这是 Jobs 在发布 iphone4 时推出的。
以 DPR = 2 为例,把 4(2x2) 个像素当 1 个像素使用,这样让屏幕看起来更精致
(左图是 DPR = 1 的设备, 右图是 DPR = 2 的设备)
【如何获取 DPR】
- 在 Web 中可以通过
window.devicePixelRatio获取 DPR - 在 React Native 中可以通过
PixelRatio.get()获取 DPR
PPI
PPI 全称是 Pixel per Inch,即每英寸的物理像素。可以视为设备屏幕的物理像素密度。
以 iphone12 为例:
PPI =
DPI
还有一个常见的名词叫 DPI,全称是 Docts per Inch,即每英寸的点数。
它最早是用于印刷上的计量单位,可以指喷墨打印的墨点。如今它也可以指屏幕的物理像素。
在移动设备上,DPI = PPI
在安卓上,有下面几种常见的 DPI 类型:
| DPI | DPR | |
|---|---|---|
| mdpi | 160 | 1 |
| hdpi | 240 | 1.5 |
| xdpi | 320 | 2 |
| xxdpi | 480 | 3 |
Web 移动端中的屏幕适配
什么叫屏幕适配?
其实说白了就是一句话:按比例还原设计稿。
CSS 媒体查询
前端同学最常接触的适配方案之一应该就是媒体查询了。
CSS 媒体查询就是根据浏览器所支持的一些规则进行自定义适配,比如:
// 根据宽度适配
@media (max-width: 1000px) {
// ...
}
// 根据分辨率适配
@media (max-resolution: 300dpi) {
// ...
}
// 根据 dpr 适配
@media (-webkit-min-device-pixel-ratio: 2.0) {
// ...
}
完整规则可以参阅 developer.mozilla.org/zh-CN/docs/…
【CSS 媒体查询存在缺点】
- 只能针对某一特定范围的设备进行适配,无法做到高保真 100% 还原
- 写法比较繁琐
百分比适配
百分比适配方案是前端同学另一个最常使用的方案了。
举个例子:
Design 给出了一个设计稿,设计稿上页面尺寸是 375x680
页面上有一个输入框,尺寸是 300x60
那么按照百分比来算:
输入框的宽度: 343 / 375 = 91.47%
输入框的高度:60 / 680 = 8.82%
那么百分比方案可以完美解决适配问题吗?
如果 CSS 有一个全局通用的基准单位,那么答案是可以。
但是很遗憾,CSS 官方对于百分比的定义是这样的:
百分比值总要相对于另一个量,比如长度。每个允许使用百分比值的属性,同时也要定义百分比值参照的那个量。这个量可以是相同元素的另一个属性的值,也可以是祖先元素的某个属性的值,甚至是格式化上下文的一个度量(比如包含块的宽度)。
具体来说:
rem 适配
rem 适配方案的出现解决了全局通用的基准单位问题。
rem 是根据网页根元素 html 来设置字体大小。
比较知名的 rem 库有 flexible:
它的核心原理就是:根据
document.documentElement.clientWidth动态修改<html> 的 font-size,页面其他元素使用 rem 作为长度单位进行布局,从而实现页面的等比缩放。
【存在问题】
rem 方案最大的问题就是它并非一个 纯 CSS 方案,需要引入一定的 JS 代码。
vw 适配
为了解决需要 JS 辅助的问题,vw 方案诞生了。
vw 等于初始包含块(html)宽度的 1%,也就是说:
- 1vw 等于 window.innerWidth 的数值的 1%
- 1vh 等于window.innerHeight 的数值的 1%
按照刚才的设计稿,那么:
输入框的宽度为: 343 / 375 = 91.47% = 91.47vw
输入框的改度为:60 / 375 = 16% = 16vw
我们在开发时也可以借助一些插件包去实现这个转换,比如 postcss-px-to-viewport
Web 移动端中的图片适配
什么叫图片适配?
其实就是要做到在不同 DPR 的设备下,图片都能正常高清展示。
高倍图
第一种解决方案就是无脑使用高倍图。
假设现在需要一张 CSS 像素为 32x32 的图片。
考虑到现在已经有了 DPR = 3 的设备,那么要图片可以在这种类型设备上也正常高清展示,这里就需要使用 96x96 的图片。
虽然这样在 DPR = 1,DPR = 2 上的设备也能够很好的展示,但是并不可取,会造成大量的带宽浪费。
img srcset
那么能不能根据 DPR 来决定选择哪张图呢?答案是肯定的。
img 标签提供的 srcset 属性就是来干这个事的。
<img
src="img.png"
srcset="img.png 1x,
img@2x.png 2x"
/>
这里的 1x 2x 指的就是 dpr,表示:
- 当屏幕的 dpr = 1 时,使用
img.png这张图 - 当屏幕的 dpr = 2 时,使用
img@2x.png这张图
w 宽度描述符
srcset属性还有一个 w 宽度描述符
<img
width="300"
src="img.png"
srcset="img.png 300w,
img@2x.png 600w
img@3x.png 1200w"
/>
假设当前设备 dpr=2,CSS 宽度为 375px, 图片 CSS 宽度为 300px:
分别用上面三个宽度描述符除以 300:
- 300 / 300 = 1
- 600 / 300 = 2
- 1200 / 300 = 4
上面计算得出的就是像素密度,所以此时选择的是 600w 的 img@2x 图片。
srcset 配合 sizes 属性一起使用,可以覆盖更多的场景,比如一次性适配移动端 & PC 端:
<img
sizes="(min-width: 600px) 600px, 300px"
src="img.png"
srcset="img.png 1x,
img.png 2x"
/>
上面这段代码中 sizes 的含义是:
- 如果当前屏幕的 CSS 宽度大于等于 600px,则图片的 CSS 宽度就是 600px,否则是 300px
那么在 PC 端设备上:
假设当前设备 dpr=1,CSS 宽度为 1920px:
则此时 图片 CSS 宽度为 600px。
分别用上面三个宽度描述符除以 600:
- 300 / 600 = 0.5
- 600 / 600 = 1
- 1200 / 600 = 2
所以此时选择的是 600w 的 img@2x 图片
React Native 中的屏幕适配与图片适配
看完了 Web 移动端的适配,我们再以最知名的跨端框架 React Naitve 为例,来看看跨端的适配是如何实现的。
屏幕适配
在 React Native 中,所有的维度都是无单位的,并且表示与密度无关的像素。
因为 RN 的跨平台特性,所以就干脆不带单位,使用平台默认单位进行渲染。 一般来说:
- 在 IOS 设备上默认单位就是
pt - 在 Android 设备上默认单位就是
dp
换句话说:
RN提供给开发者的就是已经通过 DPR 转换过的逻辑像素尺寸,开发者无需再关心因为设备DPR不同引起的尺寸数值计算问题
图片适配
对于开发者而言,React Native 的图片适配非常简单,Image 组件已经帮我们做好了。
我们只需要准备好几张不同倍数的图片,然后再 Image 组件中进行简单的引用:
import { Image } from 'react-native'
<Image source={require('/assets/images/tick.png') />
React Native Image 组件是如何实现图片适配的?
那么,Image 组件内部具体是如何实现图片适配的呢?
我们都知道,React Native 底层提供的组件,最终渲染出来的实际上都是 Native 端所实现的 UI。
Image 组件也不例外。因此,我们可以从 JS 层 以及 Native 层分别来探索它是如何实现图片适配的。
JS 层 —— 图片资源打包
import { Image } from 'react-native'
<Image source={require('/assets/images/tick.png') />
先来看看前面这段 Image 组件的使用,在经过 metro 打包后是什么样子的:
- 首先可以看到本地图片资源在 RN 中被视为一个
模块来处理 - RN 打包时会查询该图片对应都有哪些
scales - 每个模块都有独立的 id,比如这里的 1508
JS 层 —— 图片资源注册
从前面打包后的 jsbundle 可以看到,在打包本地图片资源时,RN 会调用 registerAsset 方法进行图片资源的注册。
那么我们来看看 RN 这块相关的源码:
RN 维护了一个本地图片资源的数组 assets,同时暴露了两个方法:
registerAsset注册图片资源,返回的 id 作为该资源的唯一标识getAssetByID通过资源 id 获取图片资源
const assets: Array<PackagerAsset> = [];
function registerAsset(asset: PackagerAsset): number {
// `push` returns new array length, so the first asset will
// get id 1 (not 0) to make the value truthy
return assets.push(asset);
}
function getAssetByID(assetId: number): PackagerAsset {
return assets[assetId - 1];
}
module.exports = {registerAsset, getAssetByID};
JS 层 —— 图片资源解析
再从 Image组件的源码来看看它是如何运行的,以 IOS 为例:
首先可以看到组件内部是先通过 resolveAssetSource 方法来获取到图片资源的 source
然后再传给 Native 的 RCTImageView 去渲染
简化后的源码如下:
let Image = (props: ImagePropsType) => {
// 获取 source
const source = resolveAssetSource(props.source) || {
uri: undefined,
width: undefined,
height: undefined,
}
// ...
// 输出 RCTImageView
return (
<ImageViewNativeComponent {...props} source={source} />
)
}
resolveAssetSource
这个方法顾名思义是解析图片的 source,从而获取该图片资源。
它的执行逻辑如下:
- 如果 source 是 object 类型则不处理直接返回
- 会通过前面提到的
getAssetByID获取到该 source 对应的图片资源 - 再通过
AssetSourceResolver进行进一步的解析,最终得到图片资源的json 信息
// 图片资源的 json 信息
type ResolvedAssetSource = {
__packager_asset: boolean,
width: ?number,
height: ?number,
uri: string,
scale: number,
};
// resolveAssetSource 源码
function resolveAssetSource(source: any): ResolvedAssetSource {
if (typeof source === 'object') {
return source;
}
const asset = AssetRegistry.getAssetByID(source);
if (!asset) {
return null;
}
const resolver = new AssetSourceResolver(
getDevServerURL(),
getScriptURL(),
asset,
);
if (_customSourceTransformer) {
return _customSourceTransformer(resolver);
}
return resolver.defaultAsset();
}
AssetSourceResolver
上面提到了 AssetSourceResolver,在实例化这个类时,需要传入三个属性:
- serverUrl:适用于远程图片,这个表示存储该远程图片的服务端地址
- jsbundleUrl:适用于本地图片,表示本地 jsbundle 的地址
- asset:需要解析的资源
从 resolveAssetSource 的源码可以看到最终输出的是 AssetSourceResolver.defaultAsset()
所以我们重点研究下调用 defaultAsset 方法具体发生了什么。
简化后的源码大致如下:(完整源码)
- 首先会通过 getScaleAssetPath 获取对应的倍图路径
- 然后封装图片 json 信息并返回
class AssetSourceResolver {
serverUrl: ?string;
// where the jsbundle is being run from
jsbundleUrl: ?string;
// the asset to resolve
asset: PackagerAsset;
constructor(serverUrl: ?string, jsbundleUrl: ?string, asset: PackagerAsset) {
this.serverUrl = serverUrl;
this.jsbundleUrl = jsbundleUrl;
this.asset = asset;
}
// 外部调用 defaultAsset
defaultAsset(): ResolvedAssetSource {
return this.scaledAssetURLNearBundle();
}
// 获取对应倍图路径
scaledAssetURLNearBundle(): ResolvedAssetSource {
const path = this.jsbundleUrl || 'file://';
return this.fromSource(
// Assets can have relative paths outside of the project root.
// When bundling them we replace `../` with `_` to make sure they
// don't end up outside of the expected assets directory.
path + getScaledAssetPath(this.asset).replace(/\.\.\//g, '_'),
);
}
// 封装图片 json 信息
fromSource(source: string): ResolvedAssetSource {
return {
__packager_asset: true,
width: this.asset.width,
height: this.asset.height,
uri: source,
scale: pickScale(this.asset.scales, PixelRatio.get()),
};
}
// 选择当前设备对应的倍数
static pickScale: (scales: Array<number>, deviceScale?: number) => number =
pickScale;
}
getScaledAssetPath
这个方法主要干了三件事:
- 通过
AssetSourceResolve.pickScale获取当前设备 scale - 拼接本地图片路径后缀:
@${scale}x - 返回对应 scale 的图片路径
这也就能解释为什么 React Native 中不同倍图的图片命名要用 @x 来区分了
/**
* Returns a path like 'assets/AwesomeModule/icon@2x.png'
*/
function getScaledAssetPath(asset: PackagerAsset): string {
const scale = pickScale(asset.scales, PixelRatio.get());
const scaleSuffix = scale === 1 ? '' : '@' + scale + 'x';
const assetDir = getBasePath(asset);
return assetDir + '/' + asset.name + scaleSuffix + '.' + asset.type;
}
pickScale
它会通过一个叫 PixelRatio 的东西先获取到当前设备的 DPR,然后遍历你图片资源所支持的 scales,找到最合适当前设备的倍数:
- 依次遍历该图片资源所支持的倍数,如果有倍数有大于等于该设备 DPR 的,则使用该倍数的图片
- 如果图片资源支持的所有倍数都小于该设备 DPR,则使用最大倍数的图片
export function pickScale(scales: Array<number>, deviceScale?: number): number {
if (deviceScale == null) {
deviceScale = PixelRatio.get();
}
// Packager guarantees that `scales` array is sorted
for (let i = 0; i < scales.length; i++) {
if (scales[i] >= deviceScale) {
return scales[i];
}
}
// If nothing matches, device scale is larger than any available
// scales, so we return the biggest one. Unless the array is empty,
// in which case we default to 1
return scales[scales.length - 1] || 1;
}
【PixelRatio】
再拓展看看 PixelRatio 的源码:源码地址
它的 get 方法实际是返回了 window Dimension 的 scale ,
而 Dimensions 数据来源于 Native 端提供的 DeviceInfo 源码地址
// PixelRatio
class PixelRatio {
/**
* Returns the device pixel density. Some examples:
*
* - PixelRatio.get() === 1
* - mdpi Android devices (160 dpi)
* - PixelRatio.get() === 1.5
* - hdpi Android devices (240 dpi)
* - PixelRatio.get() === 2
* - iPhone 4, 4S
* - iPhone 5, 5c, 5s
* - iPhone 6
* - iPhone 7
* - iPhone 8
* - iPhone SE
* - xhdpi Android devices (320 dpi)
* - PixelRatio.get() === 3
* - iPhone 6 Plus
* - iPhone 7 Plus
* - iPhone 8 Plus
* - iPhone X
* - xxhdpi Android devices (480 dpi)
* - PixelRatio.get() === 3.5
* - Nexus 6
*/
static get(): number {
return Dimensions.get('window').scale;
}
}
// Dimensions
let initialDims: ?$ReadOnly<DimensionsPayload> =
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);
},
);
initialDims = NativeDeviceInfo.getConstants().Dimensions;
}
Dimensions.set(initialDims);
Native 层 —— 解析图片 JSON 信息
Image 组件经过一系列 JS 层的处理得到图片资源的 json 信息之后,接下来就是交给 Native 层去处理了。
观看源码大致的处理流程就是:
- 如果 json 信息是字典类型(也就是常说的 class),那么会解析出其中 size / scale / packagerAsset 等信息并渲染图片资源
- 如果是 string 类型,则表示远程图片,需要请求获取
(博主对 Native 的代码还不是很熟,如有错误欢迎指正~)
+ (RCTImageSource *)RCTImageSource:(id)json
{
if (!json) {
return nil;
}
NSURLRequest *request;
CGSize size = CGSizeZero;
CGFloat scale = 1.0;
BOOL packagerAsset = NO;
if ([json isKindOfClass:[NSDictionary class]]) {
if (!(request = [self NSURLRequest:json])) {
return nil;
}
size = [self CGSize:json];
scale = [self CGFloat:json[@"scale"]] ?: [self BOOL:json[@"deprecated"]] ? 0.0 : 1.0;
packagerAsset = [self BOOL:json[@"__packager_asset"]];
} else if ([json isKindOfClass:[NSString class]]) {
request = [self NSURLRequest:json];
} else {
RCTLogConvertError(json, @"an image. Did you forget to call resolveAssetSource() on the JS side?");
return nil;
}
RCTImageSource *imageSource = [[RCTImageSource alloc] initWithURLRequest:request size:size scale:scale];
imageSource.packagerAsset = packagerAsset;
return imageSource;
}
流程总览
最后,我们来总结一下总体的解析与渲染流程: