技術乾貨 | Flutter線上程式設計實踐總結

有道技術團隊發表於2021-11-11

1.Flutter架構

Flutter的架構主要分成三層:Framework,Engine,Embedder。

1.Framework使用dart實現,包括Material Design風格的Widget,Cupertino(針對iOS)風格的Widgets,文字/圖片/按鈕等基礎Widgets,渲染,動畫,手勢等。 此部分的核心程式碼是:flutter倉庫下的flutter package,以及sky_engine倉庫下的io,async,ui(dart:ui庫提供了Flutter框架和引擎之間的介面)等package。

2.Engine使用C++實現,主要包括:Skia,Dart和Text。Skia是開源的二維圖形庫,提供了適用於多種軟硬體平臺的通用API。

3.Embedder是一個嵌入層,即把Flutter嵌入到各個平臺上去,這裡做的主要工作包括渲染Surface設定,執行緒設定,以及外掛等。 從這裡可以看出,Flutter的平臺相關層很低,平臺(如iOS)只是提供一個畫布,剩餘的所有渲染相關的邏輯都在Flutter內部,這就使得它具有了很好的跨端一致性。
圖1

2.Flutter檢視繪製

對於開發者來說,使用最多的還是framework,我就從Flutter的入口函式開始一步步往下走,分析一下Flutter檢視繪製的原理。

在Flutter應用中,main()函式最簡單的實現如下

// 引數app是一個widget,是Flutter應用啟動後要展示的第一個Widget。
void runApp(Widget app) {
  WidgetsFlutterBinding.ensureInitialized()
    ..scheduleAttachRootWidget(app)
    ..scheduleWarmUpFrame();
}

1.WidgetsFlutterBinding

WidgetsFlutterBinding繼承自BindingBase 並混入了很多Binding,檢視這些 Binding的原始碼可以發現這些Binding中基本都是監聽並處理Window物件(包含了當前裝置和系統的一些資訊以及Flutter Engine的一些回撥)的一些事件,然後將這些事件按照Framework的模型包裝、抽象然後分發。

WidgetsFlutterBinding正是粘連Flutter engine與上層Framework的“膠水”。

  1. GestureBinding:提供了window.onPointerDataPacket 回撥,繫結Framework手勢子系統,是Framework事件模型與底層事件的繫結入口。
  2. ServicesBinding:提供了window.onPlatformMessage 回撥, 用於繫結平臺訊息通道(message channel),主要處理原生和Flutter通訊。
  3. SchedulerBinding:提供了window.onBeginFrame和window.onDrawFrame回撥,監聽重新整理事件,繫結Framework繪製排程子系統。
  4. PaintingBinding:繫結繪製庫,主要用於處理圖片快取。
  5. SemanticsBinding:語義化層與Flutter engine的橋樑,主要是輔助功能的底層支援。
  6. RendererBinding: 提供了window.onMetricsChanged 、window.onTextScaleFactorChanged 等回撥。它是渲染樹與Flutter engine的橋樑。
  7. WidgetsBinding:提供了window.onLocaleChanged、onBuildScheduled 等回撥。它是Flutter widget層與engine的橋樑。

WidgetsFlutterBinding.ensureInitialized()負責初始化一個WidgetsBinding的全域性單例,程式碼如下

class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {
  static WidgetsBinding ensureInitialized() {
    if (WidgetsBinding.instance == null)
      WidgetsFlutterBinding();
    return WidgetsBinding.instance;
  }
}

看到這個WidgetsFlutterBinding混入(with)很多的Binding,下面先看父類BindingBase:

abstract class BindingBase {
   ...
  ui.SingletonFlutterWindow get window => ui.window;//獲取window例項
  @protected
  @mustCallSuper
  void initInstances() {
    assert(!_debugInitialized);
    assert(() {
      _debugInitialized = true;
      return true;
    }());
  }
}

看到有句程式碼Window get window => ui.window連結宿主作業系統的介面,也就是Flutter framework 連結宿主作業系統的介面。系統中有一個Window例項,可以從window屬性來獲取,看看原始碼:

// window的型別是一個FlutterView,FlutterView裡面有一個PlatformDispatcher屬性
ui.SingletonFlutterWindow get window => ui.window;
// 初始化時把PlatformDispatcher.instance傳入,完成初始化
ui.window = SingletonFlutterWindow._(0, PlatformDispatcher.instance);
// SingletonFlutterWindow的類結構
class SingletonFlutterWindow extends FlutterWindow {
  ...
  // 實際上是給platformDispatcher.onBeginFrame賦值
  FrameCallback? get onBeginFrame => platformDispatcher.onBeginFrame;
  set onBeginFrame(FrameCallback? callback) {
    platformDispatcher.onBeginFrame = callback;
  }
  
  VoidCallback? get onDrawFrame => platformDispatcher.onDrawFrame;
  set onDrawFrame(VoidCallback? callback) {
    platformDispatcher.onDrawFrame = callback;
  }
  
  // window.scheduleFrame實際上是呼叫platformDispatcher.scheduleFrame()
  void scheduleFrame() => platformDispatcher.scheduleFrame();
  ...
}
class FlutterWindow extends FlutterView {
  FlutterWindow._(this._windowId, this.platformDispatcher);
  final Object _windowId;
  // PD
  @override
  final PlatformDispatcher platformDispatcher;
  @override
  ViewConfiguration get viewConfiguration {
    return platformDispatcher._viewConfigurations[_windowId]!;
  }
}

2.scheduleAttachRootWidget

scheduleAttachRootWidget緊接著會呼叫WidgetsBinding的attachRootWidget方法,該方法負責將根Widget新增到RenderView上,程式碼如下:

 void attachRootWidget(Widget rootWidget) {
    final bool isBootstrapFrame = renderViewElement == null;
    _readyToProduceFrames = true;
    _renderViewElement = RenderObjectToWidgetAdapter<RenderBox>(
      container: renderView,
      debugShortDescription: '[root]',
      child: rootWidget,
    ).attachToRenderTree(buildOwner!, renderViewElement as RenderObjectToWidgetElement<RenderBox>?);
    if (isBootstrapFrame) {
      SchedulerBinding.instance!.ensureVisualUpdate();
    }
  }

renderView變數是一個RenderObject,它是渲染樹的根。renderViewElement變數是renderView對應的Element物件。可見該方法主要完成了根widget到根 RenderObject再到根Element的整個關聯過程。

RenderView get renderView => _pipelineOwner.rootNode! as RenderView;

renderView是RendererBinding中拿到PipelineOwner.rootNode,PipelineOwner在 Rendering Pipeline 中起到重要作用:

隨著 UI 的變化而不斷收集『 Dirty Render Objects 』隨之驅動 Rendering Pipeline 重新整理 UI。

簡簡單講,PipelineOwner是『RenderObject Tree』與『RendererBinding』間的橋樑。

最終呼叫attachRootWidget,執行會呼叫RenderObjectToWidgetAdapter的attachToRenderTree方法,該方法負責建立根element,即RenderObjectToWidgetElement,並且將element與widget 進行關聯,即建立出 widget樹對應的element樹。如果element 已經建立過了,則將根element 中關聯的widget 設為新的,由此可以看出element 只會建立一次,後面會進行復用。BuildOwner是widget framework的管理類,它跟蹤哪些widget需要重新構建。程式碼如下

RenderObjectToWidgetElement<T> attachToRenderTree(BuildOwner owner, [RenderObjectToWidgetElement<T> element]) {
  if (element == null) {
    owner.lockState(() {
      element = createElement();
      assert(element != null);
      element.assignOwner(owner);
    });
    owner.buildScope(element, () {
      element.mount(null, null);
    });
  } else {
    element._newWidget = this;
    element.markNeedsBuild();
  }
  return element;
}
 

3.scheduleWarmUpFrame

runApp的實現中,當呼叫完attachRootWidget後,最後一行會呼叫 WidgetsFlutterBinding 例項的 scheduleWarmUpFrame() 方法,該方法的實現在SchedulerBinding 中,它被呼叫後會立即進行一次繪製(而不是等待"vsync" 訊號),在此次繪製結束前,該方法會鎖定事件分發,也就是說在本次繪製結束完成之前Flutter將不會響應各種事件,這可以保證在繪製過程中不會再觸發新的重繪。

下面是scheduleWarmUpFrame() 方法的部分實現(省略了無關程式碼):

void scheduleWarmUpFrame() {
  ...
  Timer.run(() {
    handleBeginFrame(null); 
  });
  Timer.run(() {
    handleDrawFrame();  
    resetEpoch();
  });
  // 鎖定事件
  lockEvents(() async {
    await endOfFrame;
    Timeline.finishSync();
  });
 ...
}

該方法中主要呼叫了handleBeginFrame() 和 handleDrawFrame() 兩個方法

檢視handleBeginFrame() 和 handleDrawFrame() 兩個方法的原始碼,可以發現前者主要是執行了transientCallbacks佇列,而後者執行了 persistentCallbacks 和 postFrameCallbacks 佇列。

1. transientCallbacks:用於存放一些臨時回撥,一般存放動畫回撥。

可以通過SchedulerBinding.instance.scheduleFrameCallback 新增回撥。

2. persistentCallbacks:用於存放一些持久的回撥,不能在此類回撥中再請求新的繪製幀,持久回撥一經註冊則不能移除。

SchedulerBinding.instance.addPersitentFrameCallback(),這個回撥中處理了佈局與繪製工作。

3. postFrameCallbacks:在Frame結束時只會被呼叫一次,呼叫後會被系統移除,可由 SchedulerBinding.instance.addPostFrameCallback() 註冊。

注意,不要在此類回撥中再觸發新的Frame,這可以會導致迴圈


真正的渲染和繪製邏輯在RendererBinding中實現,檢視其原始碼,發現在其initInstances()方法中有如下程式碼:

void initInstances() {
  ... // 省略無關程式碼
  addPersistentFrameCallback(_handlePersistentFrameCallback);
}
void _handlePersistentFrameCallback(Duration timeStamp) {
  drawFrame();
}
void drawFrame() {
  assert(renderView != null);
  pipelineOwner.flushLayout(); // 佈局
  pipelineOwner.flushCompositingBits(); //重繪之前的預處理操作,檢查RenderObject是否需要重繪
  pipelineOwner.flushPaint(); // 重繪
  renderView.compositeFrame(); // 將需要繪製的位元資料發給GPU
  pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
}

需要注意的是:由於RendererBinding只是一個mixin,而with它的是WidgetsBinding,所以需要看看WidgetsBinding中是否重寫該方法,檢視WidgetsBinding的drawFrame()方法原始碼:

@override
void drawFrame() {
 ...//省略無關程式碼
  try {
    if (renderViewElement != null)
      buildOwner.buildScope(renderViewElement); 
    super.drawFrame(); //呼叫RendererBinding的drawFrame()方法
    buildOwner.finalizeTree();
  } 
}

在呼叫RendererBinding.drawFrame()方法前會呼叫 buildOwner.buildScope() (非首次繪製),該方法會將被標記為“dirty” 的 element 進行 rebuild()
我們再來看WidgetsBinding,在initInstances()方法中建立BuildOwner物件,然後執行buildOwner!.onBuildScheduled = _handleBuildScheduled;,這裡將_handleBuildScheduled賦值給了buildOwnder的onBuildScheduled屬性。

BuildOwner物件,它負責跟蹤哪些widgets需要重新構建,並處理應用於widgets樹的其他任務,其內部維護了一個_dirtyElements列表,用以儲存被標“髒”的elements。

每一個element被新建時,其BuildOwner就被確定了。一個頁面只有一個buildOwner物件,負責管理該頁面所有的element。

// WidgetsBinding
void initInstances() {
  ...
  buildOwner!.onBuildScheduled = _handleBuildScheduled;
  ...
  }());
}

當呼叫buildOwner.onBuildScheduled()時,便會走下面的流程。

// WidgetsBinding類
void _handleBuildScheduled() {
  ensureVisualUpdate();
}
// SchedulerBinding類
void ensureVisualUpdate() {
    switch (schedulerPhase) {
      case SchedulerPhase.idle:
      case SchedulerPhase.postFrameCallbacks:
        scheduleFrame();
        return;
      case SchedulerPhase.transientCallbacks:
      case SchedulerPhase.midFrameMicrotasks:
      case SchedulerPhase.persistentCallbacks:
        return;
    }
  }

當schedulerPhase處於idle狀態,會呼叫scheduleFrame,然後經過window.scheduleFrame()中的performDispatcher.scheduleFrame()去註冊一個VSync監聽

 void scheduleFrame() {
    ...

    window.scheduleFrame();
    ...
  }

4.小結

Flutter從啟動到顯示影像在螢幕主要經過:首先監聽處理window物件的事件,將這些事件處理包裝為Framework模型進行分發,通過widget建立element樹,接著通過scheduleWarmUpFrame進行渲染,接著通過Rendererbinding進行佈局,繪製,最後通過呼叫ui.window.render(scene)Scene資訊發給Flutter engine,Flutter engine最後呼叫渲染API把影像畫在螢幕上。

我大致整理了一下Flutter檢視繪製的時序圖,如下
圖2

3.Flutter效能監控

在對檢視繪製有一定的瞭解後後,思考一個問題,怎麼在檢視繪製的過程中去把控效能,優化效能,我們先來看一下Flutter官方提供給我們的兩個效能監控工具

1.Dart VM Service

1.observatory

observatory: 在engine/shell/testings/observatory可以找到它的具體實現,它開啟了一個ServiceClient,用於獲取dartvm執行狀態.flutter app啟動的時候會生成一個當前的observatory伺服器的地址

flutter: socket connected in service Dart VM Service Protocol v3.44 listening on http://127.0.0.1:59378/8x9XRQIBhkU=/

圖3
比方說選擇了timeline後,可以進行效能分析,如圖
圖4

2.devTools

devTools也提供了一些基本的檢測,具體的細節沒有Observatory提供的完善. 可視性比較強

可以通過下面命令安裝

flutter pub global activate devtools

安裝完成後通過devtools命令開啟,輸入DartVM地址
圖5

開啟後的頁面

圖6

devtools中的timeline就是performance,我們選擇之後頁面如下,操作體驗上好了很多
圖7
observatory與devtools都是通過vm_service實現的,網上使用指南比較多,這邊就不多贅述了,我這邊主要介紹一下Dart VM Service (後面 簡稱 vm_service)

是 Dart 虛擬機器內部提供的一套 Web 服務,資料傳輸協議是 JSON-RPC 2.0。

不過我們並不需要要自己去實現資料請求解析,官方已經寫好了一個可用的 Dart SDK 給我們用:vm_service。 vm_service 在啟動的時候會在本地開啟一個 WebSocket 服務,服務 URI 可以在對應的平臺中獲得:

1)Android 在 FlutterJNI.getObservatoryUri() 中;

2)iOS 在 FlutterEngine.observatoryUrl 中。

有了 URI 之後我們就可以使用 vm_service 的服務了,官方有一個幫我們寫好的 SDK: vm_service

 Future<void> connect() async {
    ServiceProtocolInfo info = await Service.getInfo();
    if (info.serverUri == null) {
      print("service  protocol url is null,start vm service fail");
      return;
    }
    service = await getService(info);
    print('socket connected in service $info');
    vm = await service?.getVM();
    List<IsolateRef>? isolates = vm?.isolates;
    main = isolates?.firstWhere((ref) => ref.name?.contains('main') == true);
    main ??= isolates?.first;
    connected = true;
  }

  
  Future<VmService> getService(info) async {
    Uri uri = convertToWebSocketUrl(serviceProtocolUrl: info.serverUri);
    return await vmServiceConnectUri(uri.toString(), log: StdoutLog());
  }

獲取frameworkVersion,呼叫一個VmService例項的callExtensionService,傳入'flutterVersion',就能拿到當前的flutter framework和engine資訊

 Future<Response?> callExtensionService(String method) async {
    if (_extensionService == null && service != null && main != null) {
      _extensionService = ExtensionService(service!, main!);
      await _extensionService?.loadExtensionService();
    }
    return _extensionService!.callMethod(method);
  }

圖8

獲取記憶體資訊,呼叫一個VmService例項的getMemoryUsage,就能拿到當前的記憶體資訊

  Future<MemoryUsage> getMemoryUsage(String isolateId) =>
      _call('getMemoryUsage', {'isolateId': isolateId});

圖9

獲取 Flutter APP 的 FPS,官方提供了好幾個辦法來讓我們在開發 Flutter app 的過程中可以使用檢視 fps等效能資料,如devtools,具體見文件 Debugging Flutter apps 、Flutter performance profiling 等。

// 需監聽fps時註冊
void start() {
  SchedulerBinding.instance.addTimingsCallback(_onReportTimings);
}
// 不需監聽時移除
void stop() {
  SchedulerBinding.instance.removeTimingsCallback(_onReportTimings);
}
void _onReportTimings(List<FrameTiming> timings) {
  // TODO
}

2.崩潰日誌捕獲上報

flutter 的崩潰日誌收集主要有兩個方面:

1)flutter dart 程式碼的異常(包含app和framework程式碼兩種情況,一般不會引起閃退,你猜為什麼)

2)flutter engine 的崩潰日誌(一般會閃退)

Dart 有一個 Zone 的概念,有點類似sandbox的意思。不同的 Zone 程式碼上下文是不同的互不影響,Zone 還可以建立新的子Zone。Zone 可以重新定義自己的printtimersmicrotasks還有最關鍵的how uncaught errors are handled 未捕獲異常的處理

runZoned(() {
    Future.error("asynchronous error");
}, onError: (dynamic e, StackTrace stack) {
    reportError(e, stack);
});
 

1.Flutter framework 異常捕獲

註冊 FlutterError.onError 回撥,用於收集 Flutter framework 外拋的異常。

FlutterError.onError = (FlutterErrorDetails details) {
    reportError(details.exception, details.stack);
};

2.Flutter engine 異常捕獲

flutter engine 部分的異常,以Android 為例,主要為 libfutter.so發生的錯誤。

這部份可以直接交給native崩潰收集sdk來處理,比如 firebase crashlytics、 bugly、xCrash 等等

我們需要將 dart 異常及堆疊通過 MethodChannel傳遞給 bugly sdk 即可。

收集到異常之後,需要查符號表(symbols)還原堆疊。

首先需要確認該 flutter engine 所屬版本號,在命令列執行:

flutter --version
輸出如下:

Flutter 2.2.3 • channel stable • https://github.com/flutter/flutter.git
Framework • revision f4abaa0735 (4 months ago) • 2021-07-01 12:46:11 -0700
Engine • revision 241c87ad80
Tools • Dart 2.13.4

可以看到 Engine 的 revision 為 241c87ad80。

其次,在 flutter infra 上找到對應cpu abi 的 symbols.zip 並下載,解壓後,可以得到帶有符號資訊的 debug so 檔案—— libflutter.so,然後按照平臺文件上傳進行堆疊還原就可以了,如bugly平臺就提供了上傳工具

java -jar buglySymbolAndroid.jar -i xxx

4.Flutter效能優化

在業務開發中我們要學會用devtools來檢測工程效能,這樣有助於我們實現健壯性更強的應用,在排查過程中,我發現視訊詳情頁存在渲染耗時的問題,如圖
圖10

1.build耗時優化

VideoControls控制元件的build耗時是28.6ms,如圖

圖11
所以這裡我們的優化方案是提高build效率,降低Widget tree遍歷的出發點,將setState重新整理資料儘量下發到底層節點,所以將VideoControl內觸發重新整理的子元件抽取成獨立的Widget,setState下發到抽取出的Widget內部

優化後為11.0ms,整體的平均幀率也達到了了60fps,如圖

圖12

2.paint耗時優化

接下來分析下paint過程有沒有可以優化的部分,我們開啟debugProfilePaintsEnabled變數分析可以看到Timeline顯示的paint層級,如圖
圖13

我們發現頻繁更新的_buildPositionTitle和其他Widget在同一個layer中,這裡我們想到的優化點是利用RepaintBoundary提高paint效率,它為經常發生顯示變化的內容提供一個新的隔離layer,新的layer paint不會影響到其他layer

看下優化後的效果,如圖

3.小結

在Flutter開發過程中,我們用devtools工具排查定位頁面渲染問題時,主要有兩點:

1.提高build效率,setState重新整理資料儘量下發到底層節點。

2.提高paint效率,RepaintBoundry建立單獨layer減少重繪區域。

當然 Flutter 中效能調優遠不止這一種情況,build / layout / paint 每一個過程其實都有很多能夠優化的細節。

5.總結

1.回顧

這篇文章主要從三個維度來介紹Flutter這門技術,分別為繪製原理講解,我們review了一下原始碼,發現整個渲染過程就是一個閉環,Framework,Engine,Embedder各司其職,簡單來說就是Embedder不斷拿回Vsync訊號,Framework將dart程式碼交給Engine翻譯成跨平臺程式碼,再通過Embedder回撥宿主平臺;效能監控就是不斷得在這個迴圈中去插入我們的哨兵,觀察整個生態,獲取異常資料上報;效能優化通過一次專案實踐,學習怎麼用工具提升我們定位問題的效率。

2.優缺點

優點:

我們可以看到Flutter在檢視繪製過程中形成了閉環,雙端基本保持了一致性,所以我們的開發效率得到了極大的提升,效能監控和效能優化也比較方便。

缺點:

1)宣告式開發 動態操作檢視節點不是很友好,不能像原生那樣指令式程式設計,或者像前端獲取dom節點那般容易

2)實現動態化機制,目前沒有比較好的開源技術可以去借鑑

相關文章