你知道你的手机是几倍屏吗?

1,666 阅读12分钟

什么是多倍屏?

大家可能听过一倍屏、二倍屏等等这类的名词,那么所谓的 多倍屏 到底是什么呢?

image.png

一些基本概念

设备独立像素 —— DIP

独立于设备的像素,又称设备逻辑像素。可以简单的理解为我们常说的 “分辨率”

打开 chrome 开发者工具,选择 iphone12,可以看到对应的 DIP 为 390X844

image.png

设备独立像素 = 设备逻辑像素 = CSS 像素(缩放为1)

(当页面缩放为 200% 时,1个 CSS像素 = 2x2 个设备独立像素)

即假设现在有一个宽度为 390px 的 div,那么它刚好可以撑满 iphone12 的一行;如果这个 div 高度为 844px,那么它将充满整个屏幕。

所以,DIP 可以通过 window.screen.width 以及 window.screen.height 获取。

设备物理像素 —— DP

设备屏幕实际物理像素,是在设备出厂时就固定的。

显示屏是由一个个物理像素点组成的,通过控制每个像素的颜色,就可以使屏幕显示出不同的图像。

以 iphone 12 为例,可以在官网上查到相关参数。

表示屏幕横向有 1170 个像素点,纵向有 2532 个像素点

image.png

一个设备独立像素里可能包含1个或者多个物理像素点,包含的越多则屏幕看起来越清晰。

所谓 4k 屏就是屏幕横向有 4096 个物理像素。

设备像素比 —— DPR

DPR 的全称是 Device Pixel Ratio,未缩放状态下,是设备物理像素设备独立像素之间的初始比例关系

DPR = 设备物理像素(DP) / 设备独立像素(DIP)

以 iphone12 为例,DPR = 1170 / 390 or DPR = 2352 / 844,最终可以得出 iphone12 的 DPR 为 3

(划重点!)所谓多倍屏中的 “倍” 指的就是 DPR

image.png

我们常说的 Retina 屏指的就是 DPR > 1 的设备,这是 Jobs 在发布 iphone4 时推出的。

image.png

以 DPR = 2 为例,把 4(2x2) 个像素当 1 个像素使用,这样让屏幕看起来更精致

image.png (左图是 DPR = 1 的设备, 右图是 DPR = 2 的设备)

【如何获取 DPR】

  • 在 Web 中可以通过 window.devicePixelRatio 获取 DPR
  • 在 React Native 中可以通过 PixelRatio.get() 获取 DPR

PPI

PPI 全称是 Pixel per Inch,即每英寸的物理像素。可以视为设备屏幕的物理像素密度。

以 iphone12 为例:

PPI = Math.sqrt(11701170+25322532)/6.1Math.sqrt(1170*1170 + 2532*2532) / 6.1

image.png

DPI

还有一个常见的名词叫 DPI,全称是 Docts per Inch,即每英寸的点数。

它最早是用于印刷上的计量单位,可以指喷墨打印的墨点。如今它也可以指屏幕的物理像素。

在移动设备上,DPI = PPI

在安卓上,有下面几种常见的 DPI 类型:

DPIDPR
mdpi1601
hdpi2401.5
xdpi3202
xxdpi4803

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 媒体查询存在缺点】

  1. 只能针对某一特定范围的设备进行适配,无法做到高保真 100% 还原
  2. 写法比较繁琐

百分比适配

百分比适配方案是前端同学另一个最常使用的方案了。

举个例子:

Design 给出了一个设计稿,设计稿上页面尺寸是 375x680

页面上有一个输入框,尺寸是 300x60

那么按照百分比来算:

输入框的宽度: 343 / 375 = 91.47%

输入框的高度:60 / 680 = 8.82%

那么百分比方案可以完美解决适配问题吗

如果 CSS 有一个全局通用的基准单位,那么答案是可以。

但是很遗憾,CSS 官方对于百分比的定义是这样的:

百分比值总要相对于另一个量,比如长度。每个允许使用百分比值的属性,同时也要定义百分比值参照的那个量。这个量可以是相同元素的另一个属性的值,也可以是祖先元素的某个属性的值,甚至是格式化上下文的一个度量(比如包含块的宽度)。

具体来说:

image.png

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 中,所有的维度都是无单位的,并且表示与密度无关的像素

image.png

因为 RN 的跨平台特性,所以就干脆不带单位,使用平台默认单位进行渲染。 一般来说:

  • 在 IOS 设备上默认单位就是 pt
  • 在 Android 设备上默认单位就是 dp

换句话说:

RN提供给开发者的就是已经通过 DPR 转换过的逻辑像素尺寸,开发者无需再关心因为设备DPR不同引起的尺寸数值计算问题

图片适配

对于开发者而言,React Native 的图片适配非常简单,Image 组件已经帮我们做好了。

我们只需要准备好几张不同倍数的图片,然后再 Image 组件中进行简单的引用:

image.png
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 打包后是什么样子的:

image.png

  • 首先可以看到本地图片资源在 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,从而获取该图片资源。

它的执行逻辑如下:

  1. 如果 source 是 object 类型则不处理直接返回
  2. 会通过前面提到的 getAssetByID 获取到该 source 对应的图片资源
  3. 再通过 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

这个方法主要干了三件事:

  1. 通过 AssetSourceResolve.pickScale 获取当前设备 scale
  2. 拼接本地图片路径后缀:@${scale}x
  3. 返回对应 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;
}

流程总览

最后,我们来总结一下总体的解析与渲染流程:

image.png