上一篇我們談到整個Flutter App+Framework在Web上可以直接編譯成Javascript來執行,而Flutter Framework在最終產出Layer Tree後,便會呼叫dart:ui
來進行實際的渲染。我們也在上一篇看到,mobile上的dart:ui
是透過Flutter Engine中的Skia來實作,而在web上則是使用DOM+Canvas。實際上到底怎麼實作,就讓我們繼續看下去。
首先來看看這個我們一直提到的Layer Tree,實際上到底長什麼樣子。以Flutter官方經典的初始Counter App為例,它的Layer Tree最後會變成像這樣:
這時候已經幾乎看不出我們最初的Widget Tree結構了,無關繪圖的一切都被移除,只留下最純粹的繪圖相關資訊。
根節點的Scene
是dart:ui中,用來代表一個完整畫面的class,我們會呼叫Layer.addToScene
,把圖層一個個加入Scene中。
Layer
則是Flutter Framework中,用來表示各種圖層的class。TransformLayer和OffsetLayer應該很容易理解,PhysicalModel可以提供光線/陰影效果,在這裡就是AppBar和FloatingActionItem的陰影。除此之外,常見的Layer還有像是提供透明效果的OpcaityLayer、對圖像做濾鏡的ImageFilterLayer、用來裁切的ClipRRectLayer...等等。
最後的葉節點一定是PictureLayer
,也就是真正要畫出一些東西的Layer。而PictureLayer裡包含的Picture
,是dart:ui
裡的一個class:
/// An object representing a sequence of recorded graphical operations.
///
/// To create a [Picture], use a [PictureRecorder].
///
/// A [Picture] can be placed in a [Scene] using a [SceneBuilder], via
/// the [SceneBuilder.addPicture] method. A [Picture] can also be
/// drawn into a [Canvas], using the [Canvas.drawPicture] method.
class Picture extends NativeFieldWrapperClass2
最後一步,就是透過Canvas.drawPicture
來呼叫底層的繪圖函數,然後我們就可以進入瀏覽器的世界了。
void _drawPicture(Picture picture) native 'Canvas_drawPicture';
再複習一次,在mobile上,dart:ui是透過呼叫C++寫成的Flutter Engine裡的Skia來繪圖的。而在web上,dart:ui是直接呼叫瀏覽器的DOM和Canvas API來進行繪圖,最後輸出的一個結合HTML, CSS, Canvas的DOM。這個DOM會長什麼樣子呢?我們可以把同一個Counter App佈署到瀏覽器上,再來檢視它:
main.dart.js
想必就是我們的main.dart
,但實際上它是Flutter Framework的entry point,不是我們的entry point。我們等一下會看到我們的main.dart到底在那裡。flt-glass-pane
是Flutter用來攔截所有瀏覽器互動事件的元素,就像是在瀏覽器畫面上,蓋上一層手機的玻璃面板一樣。畢竟我們現在不是由HTML Element的onClick或input來進行互動,因此必須把所有事件攔截下來,交由Flutter Framework處理。flt-scene-host
就是用來掛載我們最終產生出來的Scene,之後會看到它裡面包含了和我們的Layer Tree非常相似的DOM Tree
flt-ruler-host
就很有趣了,還記得我們上一篇提到的,關於Text Layout的小問題嗎?Flutter就是在這個隱藏的區域把Text交給瀏覽器實際渲染出來,再去讀取它的長寬、底線等數據,然後傳回RenderObject進行Layout。
進入flt-scene-host之後,我們就會看到flt-scene
,直接對應dart:ui的Scene class,代表一個完整的畫面。再進去的flt-transform自然就是對應到TransformLayer,可以看到它其實就是透過CSS的transform-origin
和transform
來實作,雖然這裡最上層的TransformLayer沒有真的做任何transform,只是最為一個基底。後面所有Layer的效果,都是透過這樣的DOM Element+CSS一層層實現的。
把這部份完全展開的話會很難閱讀,因此剩下的部份我們還是畫成圖:
可以看到這裡的DOM Tree和Layer Tree幾乎是完全一對一的關係。除了明顯的flt-transform, flt-offset, flt-picture之外,PhysicalModel對應到的是兩層flt-clip和flt-clip-interior。
這裡的flt-picture
被分成兩類,可以用DOM(HTML+CSS)來繪製的,和可以用Canvas來繪製的,而它們底下分別建立了flt-dom-canvas
和flt-canvas
。可能要稍微釐清一下,這裡的flt-dom-canvas,或Flutter官方所謂的DomCanvas Backend,指的是把DOM當作Canvas來繪圖的一種實作方式,並不是DOM API+Canvas API。另一方面,flt-canvas,則是使用瀏覽器的Canvas API,被官方稱為BitmapCanvas的另一種實作方式。
一般來說,如果一個Picture用兩種模式繪製都可以,我們會優先選擇DomCanvas。主要原因是HTML+CSS是有被瀏覽器的Display List支援的,代表我們可以把Picture柵格化的優化完全交給瀏覽器的渲染引擎處理。同時,當我們進行任意的transform時,也不用擔心pixelation的問題。
沒辦法透過HTML+CSS繪製的部份,我們才會選擇使用Canvas API。Web的CanvasRenderingContext2D
其實和Flutter的Canvas極為相似,因此dart:ui可以很輕易的進行對應。另外,Canvas的繪圖效能也相當高,因為我們不用去維護一個不斷變動的DOM Tree。Canvas在瀏覽器上是以Bitmap來顯示的,因此被稱為BitmapCanvas,這也是它最大的問題。不但平時佔用較多記憶體外,縮放時更需要重新分配記憶體,而且還會造成pixelation。因此,Flutter會盡量優先選擇透過DomCanvas來繪圖。
最後,讓我們回到瀏覽器來找找看看,我們先前失蹤的main.dart在哪裡?如果我們查看main.dart.js的原始碼:
"use strict";
// Attach source mapping.
var mapperEl = document.createElement("script");
mapperEl.defer = true;
mapperEl.async = false;
mapperEl.src = "stack_trace_mapper.js";
document.head.appendChild(mapperEl);
// Attach require JS.
var requireEl = document.createElement("script");
requireEl.defer = true;
requireEl.async = false;
requireEl.src = "require.js";
// This attribute tells require JS what to load as main (defined below).
requireEl.setAttribute("data-main", "main_module.bootstrap"); <--- This is our lead
document.head.appendChild(requireEl);
然後我們再進入main_module.bootstrap,稍微往下拉一點:
....
let modulePaths = {
"web_entrypoint.dart": "web_entrypoint.dart.lib",
"packages/flutter_app/main.dart": "packages/flutter_app/main.dart.lib", // <---- Find it!
"packages/flutter/widgets.dart": "packages/flutter/widgets.dart.lib",
"packages/flutter/src/widgets/will_pop_scope.dart": "packages/flutter/src/widgets/will_pop_scope.dart.lib",
"packages/flutter/src/widgets/widget_span.dart": "packages/flutter/src/widgets/widget_span.dart.lib",
"packages/flutter/rendering.dart": "packages/flutter/rendering.dart.lib",
"packages/flutter/src/rendering/wrap.dart": "packages/flutter/src/rendering/wrap.dart.lib",
"packages/flutter/src/rendering/layer.dart": "packages/flutter/src/rendering/layer.dart.lib",
"packages/flutter/painting.dart": "packages/flutter/painting.dart.lib",
....
這裡我們會看到整個Flutter Framework,連同我們的App,都被打包成packages讀取進來了。最後我們進入flutter_app/main.dart.lib
(flutter_app是我的專案名稱):
....
main.MyHomePage = class MyHomePage extends framework.StatefulWidget {
get title() {
return this[title$];
}
set title(value) {
super.title = value;
}
createState() {
return new main._MyHomePageState.new();
}
};
....
雖然經過dart2js
編譯器的強大優化之後的Javascript程式碼並不適合直接閱讀,但一番搜索之後還是可以找到一些我們熟悉的文字。事實上,如果我們進入開發人員工具設定,把Javascript Source Mapping關掉:
然後回到IDE下個中斷點並讓它中斷:
再回到瀏覽器的開發人員工具...:
這邊也同步中斷在對應的Javascript程式碼了!是不是很酷炫呢?
好了,有關Flutter Web的運作機制就先說到這。再次回顧整個Flutter架構,在mobile上是透過Flutter Engine的Skia來繪圖的,而在web上是透過瀏覽器的HTML, CSS和Canvas來繪圖的。
不過,不知道你有沒有想到,其實Chrome瀏覽器上也有Skia啊?這些HTML, CSS, Canvas,繞了一圈最後還不是靠Skia來繪圖。我們的dart:ui,難道就不能跳過HTML/CSS/Canvas,直接使用底層的Skia嗎?就算沒有Javascript的API,透過WebAssembly總可以了吧。
沒錯!如果你有在關注Flutter Web的新聞的話,應該有看到前陣子Flutter更新後加入的CanvasKit模式,大幅提昇了Flutter Web的效能,它背後正是靠WebAssembly+Skia來實現的。具體的運作機制,我們下次有機會再聊吧。