1. 程式人生 > >手淘移動端適配的方案學習和相關思考

手淘移動端適配的方案學習和相關思考

flexible方案是手淘經過多年的摸索和實戰,總結出來的一套移動端適配方案。這個方案在多螢幕適配以及相關bug修復上做的還是不錯的。這也是在讀了原始碼之後才有了更深一層的理解,後面會詳細解讀。

專案回顧

首先來說一下之前做的一個專案,是關於騰訊眾創空間的H5活動頁面的製作。因為那會是剛去實習沒多久,算是剛剛熟悉了公司的業務流程,接著專案組長就給我分配了這樣一個任務。說實話,當時剛剛接手這個任務的時候,心理還是有點小興奮的,畢竟之前理論知識學習了這麼久,現在能有機會來個實戰,這對於我這個入職不就的實習生來說會是一個不錯的實踐機會。
在拿到設計部門給的設計稿和思路稿之後,便開始了整個頁面的製作。因為畢竟第一次接觸移動端的開發,剛開始有點小不適應,上手之後就好了。由於這個專案催的急,自己匆匆忙忙的趕著做,在歷經4天時間終於把它搞定了(扯了這麼白話,下面來具體說說專案的整個實現流程)。

專案實戰

當時設計人員給的設計稿是基於iphone5(640×1136)的。整個頁面的佈局工作還算比較輕鬆,比較麻煩是的關鍵幀動畫的延遲時間的控制和背景音樂的按時播放問題(主要是時間軸把握不好)。當時專案組長跟我說前一個動畫開始的時間加上這個動畫的執行時間就是下一個動畫的開始時間。嘗試了好多次,最後終於搞定了。在把所有的靜態頁面都完成之後,剩下的一個最大的任務就是移動端的適配工作了。

移動端的適配

當時採用的方法是:首先通過JS獲取到當前裝置螢幕的寬度(通過document.documentElement.clientWidth獲取到),然後求出當前螢幕的寬度和設計稿寬度的比例(高度的處理方法一致)。最後在指令碼檔案中,獲取到頁面的所有圖片,根據移動裝置的不同,動態修改每一張圖片的寬度和高度,當時也結合了CSS3中的vw和vh

特性來進行適配。當時由於時間比較緊張,在匆匆忙忙完成適配之後便把所有頁面打包發給組長了。至此,自己的第一個H5頁面告一段落。

首次嘗試存在的問題

後來在手機端測試:頁面在普通螢幕下是沒有問題,但是在retina螢幕下就會出現圖片模糊的情況,這是什麼鬼?
經過一番網上查閱資料和思考,得到一個結論:是因為點陣圖畫素點不夠,從而導致圖片模糊。因為自己之前做適配的時候,就拿設計稿的尺寸來說640×1136,而iPhone5的螢幕尺寸是320×568。根據之前的方案,求出的頁面縮放比為0.5,而這樣做相當於把圖片的尺寸縮小了一半,結果就導致1個位影象素對應於4個裝置物理畫素,就會導致圖片模糊(後來想想,這麼做就把設計稿大小要×2的效果給破壞了)。

理論上:1個位影象素對應於1個物理畫素,圖片才能得到完美清晰的展示
關於移動端畫素的知識,在這裡不多說了,詳情見我的這篇部落格H5移動端開發學習總結

對於dpr=2的retina螢幕而言,1個位影象素對應於4個物理畫素,由於單個位影象素不可以再進一步分割,所以只能就近取色,從而導致圖片模糊。
所以,對於圖片高清問題,比較好的方案就是兩倍圖片(@2x)。
如:200×300(css pixel)img標籤,就需要提供400×600的圖片。
如此一來,點陣圖畫素點個數就是原來的4倍,在retina螢幕下,點陣圖畫素點個數就可以跟物理畫素點個數形成 1 : 1的比例,圖片自然就清晰了。

手淘flexible方案學習

原理:在所有資源載入之前執行這個JS。執行這個JS後,會在<html>元素上增加一個data-dpr屬性,以及一個font-size樣式。JS會根據不同的裝置新增不同的data-dpr值,比如說2或者3,同時會給html加上對應的font-size的值,比如說75px。如此一來,頁面中的元素,都可以通過rem單位來設定。他們會根據html元素的font-size值做相應的計算,從而實現螢幕的適配效果。
之前也用這個方案寫過幾個小Demo,最近又找時間把裡面的實現原理梳理了一下。

;(function(win, lib) {
 //原始碼部分 
})(window, window['lib'] || (window['lib'] = {}));

這個外掛也採用了傳統外掛的封裝形式,採用了匿名函式自執行的方式將程式碼封裝起來。這樣做的好處是可以避免全域性變數的汙染,此外將window作為實現傳入匿名函式中,這樣一來可以減少全部變數的查詢,提高效能。
另外一個引數window[‘lib’] || (window[‘lib’] = {} –> 如果lib已經定義(window[‘lib’]能獲取到),就傳這個lib,如果沒有定義就給lib賦值空物件,並傳入lib。為了避免重複定義。
這個時候在flexible.js裡面的lib其實就已經是window.lib了(js中物件按引用傳遞)。

flexible.js原始碼分析

    var doc = win.document;//獲取到document
    var docEl = doc.documentElement;//獲取到html
    var metaEl = doc.querySelector('meta[name="viewport"]');//獲取到視口標籤
    var flexibleEl = doc.querySelector('meta[name="flexible"]');//獲取手動設定的meta來控制dpr值
    var dpr = 0;//裝置縮放比
    var scale = 0;//螢幕縮放比  dpr與scale是倒數關係
    var tid;//定時器變數
    var flexible = lib.flexible || (lib.flexible = {});

這段程式碼對相應的dom元素進行了快取獲取,這樣可以減少dom的訪問次數,畢竟dom操作太昂貴,我們在實際程式設計中應該儘量減少dom操作。

 //如果頁面中存在meta標籤
    if (metaEl) {
        console.warn('將根據已有的meta標籤來設定縮放比例');
        var match = metaEl.getAttribute('content').match(/initial\-scale=([\d\.]+)/);
        // console.log(match);
        if (match) {
            scale = parseFloat(match[1]);
            // console.log(scale);
            dpr = parseFloat(1 / scale);//兩者是倒數關係
            // console.log(dpr);
        }
    } else if (flexibleEl) {
        /*
        這裡是判斷是否存在手動設定的meta標籤
        其中initial-dpr會把dpr強制設定為給定的值。如果手動設定了dpr之後,不管裝置是多少的dpr,都會強制認為其dpr是你設定的值。
        在此不建議手動強制設定dpr,因為在Flexible中,只對iOS裝置進行dpr的判斷,對於Android系列,始終認為其dpr為1。
         */
        var content = flexibleEl.getAttribute('content');
        if (content) {
            var initialDpr = content.match(/initial\-dpr=([\d\.]+)/);
            var maximumDpr = content.match(/maximum\-dpr=([\d\.]+)/);
            if (initialDpr) {
                dpr = parseFloat(initialDpr[1]);
                scale = parseFloat((1 / dpr).toFixed(2));
            }
            if (maximumDpr) {
                dpr = parseFloat(maximumDpr[1]);
                scale = parseFloat((1 / dpr).toFixed(2));
            }
        }
    }

這段程式碼首先會判斷頁面中是否已經存在相應的meta標籤,如果存在,將會給出一個警告:將根據已有的meta標籤來設定縮放比例。

/*
    在Flexible中,只對iOS裝置進行dpr的判斷,對於Android系列,始終認為其dpr為1。
     */
    if (!dpr && !scale) {
        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;//scale和dpr成倒數關係
    }

下面這段程式碼在頁面中不存在相應的meta標籤時,會自動建立一個meta標籤,並會根據頁面的dpr來設定相應的頁面縮放比。個人覺得這一點設計的很人性化,開發人員可以自己定義meta標籤,如果沒有定義,則程式碼會自動幫你根據不同的裝置生成相應的meta標籤,這個很不錯。

 //給html標籤設定自定義屬性data-dpr
    docEl.setAttribute('data-dpr', dpr);
    //通過JS來動態改寫meta標籤
    //如果不存在metaEl,則動態建立meta標籤
    if (!metaEl) {
        metaEl = doc.createElement('meta');
        metaEl.setAttribute('name', 'viewport');
        metaEl.setAttribute('content', 'initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no');
        if (docEl.firstElementChild) {
            // console.log(docEl.firstElementChild);//head
            //這裡是將新建立的meta標籤插入到head標籤中
            docEl.firstElementChild.appendChild(metaEl);
        } else {
        //如果沒有head標籤,則新建立一個包裹元素
            var wrap = doc.createElement('div');
            wrap.appendChild(metaEl);
            doc.write(wrap.innerHTML);
        }
    }

這裡寫圖片描述
在dpr為2的時候,scale為0.5
這裡寫圖片描述
在dpr為3的時候,scale為0.3333333
這樣做目的:當然是為了保證頁面的大小與設計稿尺寸的一致性,比如設計稿如果是750的橫向解析度,那麼實際頁面的device-width,以iphone6來說,也等於750,這樣的話設計稿上標註的尺寸只要除以基準值就能夠轉換為rem了。

//重新整理當前頁面的rem基準值
    function refreshRem(){
        //獲取裝置的寬度
        // console.log(docEl.getBoundingClientRect());
        var width = docEl.getBoundingClientRect().width;
        if (width / dpr > 540) {
            //給螢幕設定最大的寬度值(1080,dpr是2),防止頁面在PC端展示遭到破壞
            width = 540 * dpr;
        }
        var rem = width / 10;//Flexible會將視覺稿分成100份(主要為了以後能更好的相容vh和vw)
        // console.log(rem);
        //設定html元素的字型大小作為基準值
        docEl.style.fontSize = rem + 'px';
        //當前頁面的rem基準值
        flexible.rem = win.rem = rem;
    }

getBoundingClientRect();該方法獲得頁面中某個元素的左,上,右和下分別相對瀏覽器視窗的位置以及這個元素的寬和高,這個方法返回的是一個物件,即Object,該物件有是個屬性:top,left,right,bottom,width和height。
此外,手淘對於頁面大小設定了一個臨界點,當裝置豎著時橫向物理解析度大於1080時,html的font-size就不會變化了,原因是:這樣的解析度已經可以去訪問電腦版頁面了,防止移動端頁面在PC端展示遭到破壞。

//監聽resize事件
 //當裝置螢幕尺寸發生變化時,更新當前頁面的rem基準值
    win.addEventListener('resize', function() {
        clearTimeout(tid);
        tid = setTimeout(refreshRem, 300);
    }, false);
 //監聽pageshow事件
    win.addEventListener('pageshow', function(e) {
        if (e.persisted) {
            clearTimeout(tid);
            tid = setTimeout(refreshRem, 300);
        }
    }, false);

火狐和Opera有一個特性,名叫”往返快取”(back-forward cache,或bfcache),可以在使用者使用瀏覽器的”後退”和”前進”按鈕時加快頁面的轉換速度。這個快取中不僅儲存著頁面資料,還儲存了DOM和JavaScript的狀態;實際上是將整個頁面都儲存在了記憶體裡。如果頁面位於bfcache中,那麼再次開啟該頁面時就不會觸發load事件。
此外,火狐還提供了一些新事件:
pageshow事件:這個事件在頁面顯示時觸發,無論該頁面是否來自bfcache。在重新載入的頁面中,pageshow會在load事件觸發後觸發;而對於bfcache中的頁面,pageshow會在頁面狀態完全恢復的那一刻觸發。
另外要注意:雖然這個事件的目標是document,但必須將其事件處理程式新增到window。pageshow事件的event物件還包含一個名為persisted的布林值屬性。如果頁面被儲存在了bfcache中,則這個屬性的值為true,否則這個屬性值為false。

    /*
    針對不同的瀏覽器做domReady相容
    IE6,7,8都不支援DOMContentLoaded事件
     */
    if (doc.readyState === 'complete') {//針對不支援DOMContentLoaded事件做相容
        //根據不同的dpr來設定不同的字型大小,因為防止頁面設定了縮放scale屬性值而導致不同裝置上字型大小不一致
        doc.body.style.fontSize = 12 * dpr + 'px';
    } else {//如果支援DOMContentLoaded事件,則直接使用
        doc.addEventListener('DOMContentLoaded', function(e) {
            // alert("DOMContentLoaded");
            doc.body.style.fontSize = 12 * dpr + 'px';
        }, false);
    }

window的load事件會在頁面中的一切都載入完畢時觸發,但這個過程可能會因為要載入的外部資源過多而頗費周折。而DOMContentLoaded事件則在形成完整的DOM樹之後就會觸發,不理會圖片、js檔案、css檔案或者其他資源是否已經下載完畢。
與load事件不同,DOMContentLoaded支援在頁面下載的早期新增事件處理程式,這就意味著使用者能夠儘早地與頁面進行互動。

document.readyState:返回當前文件的狀態

  • uninitialized - 還未開始載入
  • loading - 載入中
  • interactive - 已載入,文件與使用者可以開始互動
  • complete - 載入完成
//把rem轉換為px
    flexible.rem2px = function(d) {
        var val = parseFloat(d) * this.rem;
        if (typeof d === 'string' && d.match(/rem$/)) {
            val += 'px';
        }
        return val;
    }
    //把px轉換為rem
    flexible.px2rem = function(d) {
        var val = parseFloat(d) / this.rem;
        if (typeof d === 'string' && d.match(/px$/)) {
            val += 'rem';
        }
        return val;
    }

上面的程式碼是用於px和rem之間的轉換的,當然我們也可以採用less和sass這樣的css處理器中的混合巨集來實現。
less使用舉例:

//定義一個變數和一個mixin
@baseFontSize: 75;//基於視覺稿橫屏尺寸/100得出的基準font-size
.px2rem(@name, @px){
    @{name}: @px / @baseFontSize * 1rem;
}
//使用示例:
.container {
    .px2rem(height, 240);
}
//less翻譯結果:
.container {
    height: 3.2rem;
}

小結

  • 動態設定viewport的scale
scale = 1 / dpr;
metaEl = doc.createElement('meta');
metaEl.setAttribute('name', 'viewport');
metaEl.setAttribute('content', 'initial-scale=' + scale + ', maximum-scale=' + scale + ', minimum-scale=' + scale + ', user-scalable=no');
  • 動態計算html的font-size
var width = docEl.getBoundingClientRect().width;
var rem = width / 10;
docEl.style.fontSize = rem + 'px';
  • 佈局的時候,各元素的css尺寸=設計稿標註尺寸/設計稿橫向解析度/10
    注:設計稿橫向解析度/10即頁面佈局基準值

目前手淘已經給我們提供了一個開源的解決方案,具體請檢視:傳送門

相關問題思考

retina下,border: 1px問題

我們正常的寫css,像這樣border: 1px;,在retina螢幕下,會有什麼問題呢?
這裡寫圖片描述
注:圖片來源於傳送門
對於一條1px寬的直線,它們在螢幕上的物理尺寸的確是相同的,不同的其實是螢幕上最小的物理顯示單元,即裝置物理畫素,所以對於一條直線,iphone5它能顯示的最小寬度其實是圖中的紅線圈出來的灰色區域,用css來表示,理論上說是0.5px。
所以,設計師想要的retina下border: 1px;,其實就是1物理畫素寬,對於css而言,可以認為是border: 0.5px;,這是retina下(dpr=2)下能顯示的最小單位。
然而,無奈並不是所有手機瀏覽器都能識別border: 0.5px;,ios7以下,android等其他系統裡,0.5px會被當成為0px處理,那麼如何實現這0.5px呢?
對於iphone5(dpr=2),新增如下的meta標籤,設定viewport(scale 0.5)

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

這樣,頁面中的所有的border: 1px都將縮小0.5,從而達到border: 0.5px;的效果。

如何在css編碼中還原視覺稿的真實寬高

假如我們拿到的是一個針對iphone6的高清視覺稿 750×1334,如果有一個區塊,在psd檔案中量出:寬高750×300px的div,那麼如何轉換成rem單位呢?
公式如下:
rem = px / 基準值;
對於一個iphone6的視覺稿,它的基準值就是75。所以,在確定了視覺稿(即確定了基準值)後,通常我們會用less寫一個mixin(混合巨集),像這樣:

// 例如: .px2rem(height, 80);
.px2rem(@name, @px){
    @{name}: @px / 75 * 1rem;
}

所以,對於寬高750×300px的div,我們用less就這樣寫:

.px2rem(width, 750);
.px2rem(height, 300);

轉換成css,就是這樣:

width: 10rem; // -> 750px
height: 4rem; // -> 300px

最後因為dpr為2,頁面scale了0.5,所以在手機螢幕上顯示的真實寬高應該是375×150px,就剛剛好(達到了一個CSS畫素對應一個裝置物理畫素的效果)。