Stories

Detail Return Return

LayoutUnit & Subpixel Layout - Stories Detail

LayoutUnit & Subpixel Layout

引言

為了更好的支持移動端和 PC 端的縮放,WebKit 增加了subpixel layout(次像素/亞像素佈局)為此他們還改變了 rendering tree。一個次像素單元在 WebKit 內被稱為 LayoutUnit 用於取代之前使用整數來佈局一個元素在頁面中位置和大小。從 2013 年開始 WebKit 就已經開啓了這個 flag。

LayoutUnit

LayoutUnit 是邏輯像素的一種抽象表示,在 WebKit 的實現中它是一個像素的 1/64,這樣我們就可以使用整數來進行佈局計算,避免了使用浮點數計算而丟失精度的問題。

雖然我們現在在佈局計算時使用了 LayoutUnit,但是在最終將計算值渲染對應到設備上時仍然會出現計算值不能與物理像素對齊的情況。因為計算出的值可能是一個小數而 1 個物理像素已經不能再進行切割。所以出現了這樣一個問題,次像素如何與物理像素進行對齊?

回到我們實際的編程過程中,我們會有很多場景遇到次像素的問題,只是很多人不會關注,或者會忽略掉這些細節。比如如果一個 box 的寬度是 10px,我們把它平均分成 3 份,那麼裏面的三個盒子的寬度分別是多少呢,3.3333px?再比如我們在使用 rem 佈局的時候有時候會發現一個正方形設置了 border-raduis 預期讓它展示成一個圓形,在一些設備上卻並不那麼圓,在整體比較小的時候可能會被渲染成一個橢圓形。以及這種時候這個元素還設置了一個 background-size 覆蓋整個容器但是背景卻被切掉了一小塊。這些問題不是那麼容易被發現,但是確實是存在的。

場景

現在我們有一個 50px 的容器(DPR 為 1)將他分成 3 份,必然會出現小數的情況,看看每一份渲染在屏幕上的寬度是多少。

<div class="container">
    <div style="background: #111"></div>
    <div style="background: #222"></div>
    <div style="background: #333"></div>
</div>
.container {
    display: flex;
    width: 50px;
    height: 30px;
    background: #999;
}
.container div {
    flex: 1;
    height: 100%;
}
const getWidth = () => {
    const container = document.querySelector('.container');
    const nodes = Array.prototype.slice.call(container.children);
    nodes.forEach((i, index) => {
        console.log(
            `${index} width: ${i.clientWidth}, computed width: ${
                i.getBoundingClientRect().width
            }`
        );
    });
};
getWidth();
// console
0 width: 17, computed width: 16.671875
1 width: 16, computed width: 16.671875
2 width: 17, computed width: 16.671875

我們發現三份的 clientWidth 並不是一樣的,其中一個會少一像素,但是它們的寬度加起來仍然是容器的寬度。而通過 getBoundingClientRect 獲得的計算值卻是一樣的,但是並不像我們預期的一樣是 50/3 = 16.666666667,而是 16.671875 看起來也並沒有什麼四捨五入的關係。但是從上面的例子中我們可以得到的一個結論就是,上面三份中的寬度最終在屏幕上並不是完全一致的,這也會導致我們在其他場景下遇到類似的問題,比如説在一個頁面中同一個組件渲染出來的元素在頁面的多個位置上可能表現出不一致的情況,有些元素可能渲染出來會多 1px 或者少 1px,在像素越小的地方對比度就會越明顯,比如一個高度是 3px,另一個 2px,這樣就會看出明顯的差異。而如果一個是 100px 另一個是 101px,你可能就沒有感知了。

上面還有一個問題沒有解決,就是計算值和我們預期不一致。這裏就可以通過 LayoutUnit 來解釋。上面我們提到在佈局的使用會使用 subpixel layout 把一個像素分成 64 份。這樣我們看看 WebKit 在佈局的時候是怎麼就算的:

1. container width: 50px * 64 => 3200
2. 每一個子 div: 3200 / 3 = round(1066.666666667) => 1067
3. 最終計算值: 1067 / 64 => 16.671875

通過上面的計算我們發現結果和 getBoundingClientRect 獲得的值完全吻合,所以這裏計算元素大小的時候瀏覽器內核使用了 subpixel layout,而不是直接使用原來的 pixels。

這裏仍然面臨了另一個問題,我們使用 subpixel layout 計算出來的值仍然是一個小數,但是我們佈局的時候是如何和物理像素進行對齊的呢?上面少掉 1px 的元素僅是因為把 getBoundingClientRect 的值進行四捨五入?那這樣也應該全是 17px,而單單中間的一個元素少了一像素?

如何對齊

在進行 subpixel 和 pixel 之間轉換時,有兩種方式,一種是 enclosingIntRect 另一種是 pixelSnappedIntRect 在上述的例子中使用了第二種轉換方式。

上面的圖中,灰色格子代表物理像素,藍色區域表示 subpixel layout 計算值,黑色區域表示最終 subpixel -> pixel 的對齊結果。

enclosingIntRect 算法

x: floor(x)
y: floor(y)
maxX: ceil(x + width)
maxY: ceil(y + height)
width: ceil(x + width) - floor(x)
height: ceil(y + height) - floor(y)

這種計算方式很簡單,直接選擇最小的完全能覆蓋住計算結果的物理像素區域。

pixelSnappedIntRect 算法:

y: round(y)
maxX: round(x + width)
maxY: round(y + height)
width: round(x + width) - round(x)
height: round(y + height) - round(y)

pixelSnappedIntRect 的計算也很簡單,它直接 round 到離自己最近的一個物理像素。

接着上面的例子,我們現在把 50px 分層 6 份來模擬計算下看看每一份的寬度計算值應該是多少:

1. container width: 50px * 64 => 3200
2. 每一個子 div: 3200 / 6 = round(533.333333333) => 533
3. 最終計算值: 533 / 64 => 8.328125
// log
0 width: 8, computed width: 8.328125
1 width: 9, computed width: 8.328125
2 width: 8, computed width: 8.328125
3 width: 8, computed width: 8.328125
4 width: 9, computed width: 8.328125
5 width: 8, computed width: 8.328125

看到 js 算出來的值和我們算出來的是一致的,並不簡單的是 50/6 = 8.333333333。在最終渲染的時候:

  • 第一個元素:直接從容器左邊開始繪製,發現 8.328125 多餘的小數無法解決直接 round 到最近物理像素,得到 8px 繪製空間,但是在邏輯空間上第一個元素佔用了第 9 個像素 0.328125px 空間,為了和物理像素對齊,下一個元素應該在繪製時加上這塊空間。
  • 第二個元素:8 + 8.328125 + .328125 => 16.65625 => round(16.65625) => 17,這裏第二個元素加第一個元素寬度應該是 17px 所以第二個元素寬度是 9px 而不是 8px,這裏其實兩個元素加起來還不足 17px,由於對齊規則四捨五入,讓第二個元素直接到 17px,在第三個元素繪製時其實左邊還有 17 - 16.65625 => 0.34375px 的邏輯空間。
  • 第三個元素:由於左邊還有剩餘的邏輯空間,17 + 8.328125 - 0.34375 => 24.984375 => round(24.984375) => 25。此時寬度來到了 25,減去之前第一二個元素的寬度 17,得到第三個元素寬度為 8px。
  • 第四個元素:按照上述規則就不具體説明,25 + 8.328125 - (25 - 24.984375) => 33.3125 => round(33.3125) => 33;33 - 25 => 8px。
  • 第五個元素:33 + 8.328125 + 0.3125 => 41.640625 => round(41.640625) => 42;42 - 33 => 9px。
  • 第六個元素:剩餘 50-41.640625 => 8.359375,對齊到最近的空間剩餘 8px。

和上述 js 獲取的 clientWidth 結果 8,9,8,8,9,8 完全一致。所以這裏元素的大小可以通過 pixelSnappedIntRect 對齊方式來解釋為什麼有些元素會多/少一像素並且出現是「沒有規則」的。

如何選擇

上面介紹了 2 種對齊方式,那麼在什麼場景下 WebKit 用什麼算法呢?以及所有的佈局都會使用 subpixel layout 麼?

為了保證一些場景下的一致性渲染,並不是所有場景都會使用 subpixel,比如説在計算 border 的時候就不會,這樣避免了我們設置了一個 border,渲染出來的元素上邊可能會比下邊還多出 1px。以及在大部分的場景中計算元素的大小的時候會使用 pixelSnappedIntRect 。在少數的一些 case 下會使用 enclosingIntRect 計算,比如一個 RenderBlock 中的 SVG 盒子,因為需要保證盒子能完全包含住子樹。具體的細節可以參看 WebKit 的文檔或源碼。

最佳實踐?

同一組件不同結果

佈局計算值有小數帶來渲染結果不一致的情況經常發生在 rem 佈局中,由於 DPR 的轉換導致一些設備下很多場景都是小數。比如下面就是一個常見的真實業務場景。

在實現一個 popup 組件或者 dialog 組件時經常會有一些選項,css 也是能繪製出來的,比如上面 9 個紅色的圓點,它們都是一個組件,預期寬高都是 10px,但是通過一系列的換算後第一個卻變成了 9px,第二個又是 10px,9 個原點渲染出來不盡相同。

下面實現的選擇項 icon 是沒有問題的,為了避免這種不一致我們選擇使用了圖片,svg 或者直接 base64 一張 png 到組件裏面,但是如果將 png 圖作為一個背景放入一個固定寬高的盒子中仍然可能有問題。

背景割裂

如果有一個容器寬高都是 10px,設置一個 backgroundImage,大小和容器一樣大,可能會出現背景被「割裂」的情況,因為容器可能會被渲染成 9 x 10 導致背景圖一部分內容不可見。可以通過給容器設置一點點 padding 來解決這個問題。

還有很多類似這種渲染結果不符合我們預期的 case 基本都是因為使用 rem 佈局導致的,解決它就是儘量 rem to px 時讓它不要是一個小數,或者直接使用 px,或者不要使用 rem 佈局!


  • 文中 demo 參見 https://codepen.io/Jiavan/pen...
  • 推薦閲讀 https://trac.webkit.org/wiki/...

如有錯誤歡迎指正,文章原文 https://github.com/Jiavan/blo... 轉載請註明出處。

Add a new Comments

Some HTML is okay.