移动端H5页面适配方案

移动端自适应方案

Posted by Li Yucang on April 9, 2018

移动端 H5 页面适配方案

手机设备屏幕尺寸不一,做移动端的 Web 页面,需要考虑在安卓/IOS 的各种尺寸设备上的兼容,这里总结的是针对移动端设备的页面,前端怎样做能更好地适配不同屏幕宽度的移动设备。

自适应与响应式的区别

响应式设计表示在不同的屏幕尺寸下,都有良好的布局和内容表现,简单一点的说,就是一个页面可以适配多种不同尺寸的屏幕,而且看上去还是设计良好的。为了实现这个目的,可能会利用 js 或者 css 去动态改变布局的尺寸,在这个过程中会伴随元素尺寸的改变,布局的改变,甚至会把元素隐藏,比如在 pc 端显示的页面转到移动端就会这样。不过现在的网页一般不同的客户端已经不再共用一套 dom 结构了,而是区分开来做自适应。然后每次用户访问的时候根据 User-agent 返回相应的页面。

而自适应往往考虑的是另一个方面,就是希望页面的设计与设计稿的设计比例一致,这个也是做自适应的目的,在这个过程中针对不同的屏幕宽度元素的尺寸也会改变,但是一般不会有布局改变,和元素的隐藏,那些按照 css 媒体查询写的自适应严格来说不叫自适应,因为断点之间会造成比例误差。

相关概念

viewport 视口

  • visual viewport(可见视口): 屏幕宽度
  • layout viewport(布局视口): DOM 宽度
  • ideal viewport(理想适口): 使布局视口就是可见视口

设备宽度(visual viewport)与 DOM 宽度(layout viewport), scale 的关系为:

(visual viewport)= (layout viewport)* scale

获取屏幕宽度(visual viewport)的尺寸:window.innerWidth/Height。获取 DOM 宽度(layout viewport)的尺寸:document.documentElement.clientWidth/Height

设置理想视口:

<meta name="viewport" content="width=device-width,initial-scale=1">

width=device-width 这句代码可以把布局视口设置成为浏览器(屏幕)的宽度。

initial-scale=1 的意思是初始缩放的比例是 1,使用它的时候,同时也会将布局视口的尺寸设置为缩放后的尺寸。而缩放的尺寸就是基于屏幕的宽度来的,也就起到了和 width=device-width 同样的效果。

从 Chrome32+版本开始是会默认禁用用户缩放的,但是考虑到兼容大部分设备,还是要加上其他设置,让 meta 标签能够有更好的容错性。也就是下面这段代码:

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no;">

另外,值得一提的是,我们在进行媒体查询的时候,查询的宽度值其实也是布局视口的宽度值。

物理像素(physical pixel)

物理像素又被称为设备像素(device pixels),他是显示设备中一个最微小的物理部件。每个像素可以根据操作系统设置自己的颜色和亮度。所谓的一倍屏、二倍屏(Retina)、三倍屏,指的是设备以多少物理像素来显示一个 CSS 像素,也就是说,多倍屏以更多更精细的物理像素点来显示一个 CSS 像素点,在普通屏幕下 1 个 CSS 像素对应 1 个物理像素,而在 Retina 屏幕下,1 个 CSS 像素对应的却是 4 个物理像素。关于这个概念,看一张”田”字示意图就会清晰了。物理像素开发者是无法获取的,它是自然存在的一种东西,该是多少就是多少。

设备独立像素(device independent pixels)

设备独立像素,不同于设备像素(物理像素),它是虚拟化的。比如说 css 像素,我们常说的 10px 其实指的就是它。

设备像素比 dpr(device pixel ratio)

设备像素比,其定义了物理像素和设备独立像素的对应关系。它的值可以按下面的公式计算得到:

设备像素比 = 物理像素 / 设备独立像素(垂直方向或水平方向)

在 Retina 屏的 iphone 上,devicePixelRatio 的值为 2,也就是说 1 个 css 像素相当于 2 个物理像素。通常所说的二倍屏(retina)的 dpr 是 2, 三倍屏是 3。可以通过 JS 来获取:window.devicePixelRatio

屏幕像素密度 PPI(pixel per inch)

屏幕像素密度是指一个设备表面上存在的像素数量,它通常以每英寸有多少像素来计算(PPI)。屏幕像素密度与屏幕尺寸和屏幕分辨率有关,在单一变化条件下,屏幕尺寸越小、分辨率越高,像素密度越大,反之越小。

屏幕像素密度 = 对角线分辨率 / 屏幕尺寸

以 iphone6 为例,分辨率为 1334 x 750,即屏幕上垂直有 1334 个物理像素,水平有 750 个物理像素。屏幕尺寸:4.7 英寸,注意英寸是长度单位,不是面积单位。4.7 英寸指的是屏幕对角线的长度,1 英寸等于 2.54cm。屏幕像素密度:326ppi,指的是每英寸屏幕所拥有的像素数,在显示器中,dpi=ppi。

Retina 屏幕&普通屏幕,模糊的由来

有时候我们会发现,当我们在适某一机型的时候,显示上没什么问题。但是一旦我换到另外一部手机,发现出现了模糊的情况,尤其以图片更为显著。

其实这个问题,就是涉及到了上面讲到的一个属性:设备像素比,即我们经常说的 dpr。下面先来看 dpr 的表现:

假设现在有一台 iphone6,那么它的设备独立像素是 375x667,dpr 为 2,尺寸是 4.7in,那么物理像素就是 750x1334。 同样的我们也有一台不知名的设备,它的设备独立像素刚好也是 375x667,尺寸也是 4.7in,但是 dpr 为 1,此时的物理像素就是 375x667。

于是,它们的屏幕表现如下:

在不同的屏幕上,无论是普通屏幕还是 retina 屏幕,css 像素所呈现的大小是一致的。(如果不理解这句话,可以写一个 2px 的正方形使用谷歌控制台移动设备调试,在不同的设备之间来回切换,你会发现大小其实是一样的。一开始我总以为这个 css 像素的实际宽高因为受到 dpr 的影响而在不同设备上的长宽是不一致的。)

不同的是,1 个 css 像素对应(覆盖)的物理像素个数。

所以,如果我们想要在这两个屏幕显示这么一个 css 样式:

width: 2px;
heigth: 2px;

在普通屏幕下,也就是 dpr 为 1 的屏幕中,1 个 css 像素对应(覆盖)的是一个物理像素。在 retina 屏幕下,1 个 css 像素对应(覆盖)的是 4 个物理像素。换句话说,就是 dpr 为 2 的设备。看下面这张图:

模糊的产生

知道了 1 个 css 像素覆盖的物理像素可能不同,就好理解为什么会出现模糊的情况了。

这里又讲到一个名词:位图像素

位图像素是栅格图像(如:png,jpg,gif 等)最小的数据单元。每一个位图像素都包含着一些自身的显示信息。(如:显示位置,颜色值,透明度等)

理论上来说,1 个位图像素对应 1 个物理像素,图片才能达到完美清晰的展示

但是上面说过,在 retina 屏幕上,会出现 1 个位图像素对应多个物理像素。

还是以 iphone6 为例,1 个位图像素对应 4 个物理像素。由于单个位图像素已经是最小的数据单位了,它不能再被进行切割。于是为了能够显示出来,就只能就近取色,从而导致所谓的图片模糊问题。如下:

如何解决

很明显,由于位图像素不够分而产生模糊的情况,解决的办法十分简单,就是使用跟 dpr 同个倍数大小的图片。比如 iphone6,一个 200x300 的 img 标签,原图就要提供 400x600 的大小。

那么当加载到 img 标签中,浏览器会自动对每 1px 的 css 像素减半,可以理解为此时还是维持着 1:1 的 css 像素:物理像素,不产生模糊。

这个做法其实就是手淘团队在做 retina 适配的一个重要的原理之一,后面会讲到,这里先放着不说。

其他

反向思考一下,如果普通屏幕,也就是 dpr 为 1 的屏幕,也使用了两倍的图片,会发生什么样的情况呢?

很明显,在普通屏幕下,200×300 的 img 标签,所对应的物理像素个数就是 200×300 个,而两倍图片的位图像素个数则是 200x300x4,于是就出现一个物理像素点对应 4 个位图像素点,所以它的取色也只能通过一定的算法进行缩减,显示结果就是一张只有原图像素总数四分之一,肉眼看上去虽然图片不会模糊,但是会觉得有点色差。(其实就是模糊的逆向过程)

用图片来表示就是:

解决方案

淘宝方案

核心可以浓缩为以下代码:

(function () {
    var dpr = window.devicePixelRatio;
    var meta = document.createElement('meta');
    var scale = 1 / dpr;
    meta.setAttribute('name', 'viewport');
    meta.setAttribute('content', 'width=device-width, user-scalable=no, initial-scale=' + scale +
      ', maximum-scale=' + scale + ', minimum-scale=' + scale);
    document.getElementsByTagName('head')[0].appendChild(meta);
    // 动态设置的缩放大小会影响布局视口的尺寸
      function resize() {
      var deviceWidth  = document.documentElement.clientWidth;
      document.documentElement.style.fontSize = (deviceWidth / 10) +'px';
         }
    resize();
    window.onresize = resize;
  })()

这段代码前半段:根据 dpr 不同,设置其缩放为 dpr 倒数。设置页面缩放可以使得 1 个 CSS 像素(1px)由 1 个设备像素来显示,从而提高显示精度;因此,设置 1/dpr 的缩放视口,可以画出 1px 的边框。

当动态缩放视口为 1/dpr, 计算所得的根元素 fontSize 也会跟着缩放,即若理想视口(scale=1), iPhone6 根元素 fontSize=16px; 若 scale=0.5, iPhone6 根元素 fontSize=32px; 因此设置视口缩放应放于设置根元素 fontSize 之前。

后半段:将根元素尺寸与布局视口关联起来,其他元素尺寸就可以设置 rem 单位来与布局视口绑定关系,以 200px 尺寸为例(设计稿是 640px),他们比例映射是这样的:

1rem = 64px;
1px = 1/64rem;
200px = 200 * (1/64)rem;

这里的 10rem 就是布局视口宽度,元素尺寸只要维持这个比例关系就行了,与 dpr 是没有关系的

x= 200 * (1/64)rem = 3.125rem

这里的计算可能会费一点时间,也有一些插件可以辅助把 px 转为 rem 的。最简单的方法是根据设计稿宽度调整参数,使 1rem=100px,比如当设计稿宽度为 640px 时,我们可以这么设置:

document.documentElement.style.fontSize = (deviceWidth / 6.4) +'px';

此时,1rem=100px,我们将设计稿上 px 长度除以 100 就是 rem 单位长度了。

那么为什么淘宝要引入 dpr,把布局放大再缩小呢,其中一点就是这个方案可以很好地解决 1px 边框的问题,对于高清屏来说设置 1px 像素大小,其实横跨的是 dpr 个设备像素,这样看起来线条不够细,与设计稿就产生出入,而通过布局放大再缩小的方案刚好就弥补了这个问题。但是随之而来也带来一个问题,字体大小发生了改变,在 scale 设置为 0.1 时基本就看不见了,原因是一般我们的字体大小的设置不会使用 rem,而是使用 px 单位,这里的字体大小没有随布局视口的放大而增大,却随页面的整体缩放而缩小了,这里就得要针对不同的 dpr 做响应的处理,在淘宝的代码中我们可以看到:

    docEl.setAttribute('data-dpr', dpr);

就是通过在根元素上挂载 dpr 信息,然后设置相应的 css 属性例如:

.p {
  font-size: 14px;

  [data-dpr="2"] & {
    font-size: 14px * 2;
  }

  [data-dpr="3"] & {
    font-size: 14px * 3;
  }
}

特别对于安卓手机,各种神奇的 dpr,如果每个都这样设置将会是灾难,所以淘宝非常聪明:

var isAndroid = win.navigator.appVersion.match(/android/gi);
var isIPhone = win.navigator.appVersion.match(/iphone/gi);
var devicePixelRatio = win.devicePixelRatio;
if (isIPhone) {
    // iOS下,对于2和3的屏,用2倍的方案,其余的用1倍方案
    if (devicePixelRatio >= 3 && (!dpr || dpr >= 3)) {
        dpr = 3;
    } else if (devicePixelRatio >= 2 && (!dpr || dpr >= 2)){
        dpr = 2;
    } else {
        dpr = 1;
    }
} else {
    // 其他设备下,仍旧使用1倍的方案
    dpr = 1;
}
scale = 1 / dpr;

够简单直接,安卓高清屏是不存在的,但是其实影响也不大,就是安卓屏的 1px 线条粗一点而已。

从前面可以知道淘宝引入 dpr 并不是为了做自适应的,而是为了解决 1px 问题的,当然也引入了其他难题,既然如此,放弃解决 1px 问题,不就简单得多,网易方案就是这么做的。

这里解释一下1px问题:

为什么说1px线条会变粗呢?事实就是它并没有变粗,就是css单位中的1px,对于dpr为2的设备,它实际能显示的最小值是0.5px。设计师口中说的1px是针对设备物理像素的,换算成css像素就是0.5px。一句话总结,border:1px solid black在任何屏幕上都是一样粗的,但是retina屏可以显示比这更细的边框。

那么前端工程师为什么不能直接写0.5px(css)呢?因为在老版本的系统里写0.5px(css)的话,会被浏览器解读为0px(css),就没有边框了。所以只能写成1px(css),实际在屏幕上显示出来就是设计师画的1px(真实像素)的2倍那么宽,所以设计师会觉得这个线太粗了,和他的设计稿不一样。在新版的系统里,已经开始逐渐支持0.5px(css)这种写法。所以如果设计师在大图上设计了一个1px(真实像素)的线的话,前端工程师直接除以2,写0.5px(css)就好了。

网易方案

 <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">

 (function () {
    var dpr = window.devicePixelRatio;
    function resize() {
      var deviceWidth = document.documentElement.clientWidth;
    document.documentElement.style.fontSize = (deviceWidth / 6.4) +'px';
   }
    resize();
    window.onresize = resize;
  })()

网易方案没有对 dpr 的不同进行适配,在不需要考虑 1px 边框时我们可以大胆使用网易的方案。

vm/vh:CSS 单位

vw(view-width), vh(view-height) 这两个单位是 CSS 新增的单位,表示视区宽度/高度,视区总宽度为 100vw, 总高度为 100vh。

视区指浏览器内部的可视区域大小:window.innerWidth/Height

使用时,设计稿上的比例就是 vw、vh 的值,完全不需要计算。至于兼容性,在 ie11+都是可以使用的。完全可以替代网易的方案,可以在不需要考虑 ie 的项目里放心使用。

小结

  • 适配不同屏幕宽度以及不同 dpr,通过动态设置 viewport(scale=1/dpr) + 根元素 fontSize + rem;

  • 若无需适配可显示 1px 线条,也可以不动态设置 scale,只使用动态设置根元素 fontSize + rem + 理想视口;

  • 当视口缩放(布局视口宽度改变),计算所得的根元素 fontSize 也会跟着缩放,即若理想视口(scale=1), iPhone6 根元素 fontSize=16px; 若 scale=0.5, iPhone6 根元素 fontSize=32px;

  • 布局长度全部使用 rem,font-size 使用 px,border、box-shadow、border-radius 等一些效果应该使用 px 作为单位;