開發與維運

複雜業務如何保證Flutter的高性能高流暢度?

作者:閒魚技術-三蒞

背景

高性能高流暢度一直是Flutter團隊宣傳的一大亮點,也是當初閒魚選擇Flutter的重要因素之一,但是隨著複雜業務的應用落地,通過Flutter頁面和原生頁面滑動流暢度對比,我們開始產生懷疑,因為部分Flutter頁面流暢度明顯低於Native,是Flutter的宣傳言過其實還是我們開發人員使用姿勢有問題,今天我們就來具體分析下。

Flutter渲染原理簡介

優化之前我們先來介紹下Flutter的渲染原理,通過這部分基礎瞭解渲染流程以及主要耗時花費

flutter視圖樹包含了三顆樹:Widget、Element、RenderObject

  • Widget: 存放渲染內容、它只是一個配置數據結構,創建是非常輕量的,在頁面刷新的過程中隨時會重建
  • Element: 同時持有Widget和RenderObject,存放上下文信息,通過它來遍歷視圖樹,支撐UI結構
  • RenderObject: 根據Widget的佈局屬性進行layout,paint ,負責真正的渲染

從創建到渲染的大體流程是:根據Widget生成Element,然後創建相應的RenderObject並關聯到Element.renderObject屬性上,最後再通過RenderObject來完成佈局排列和繪製。

例如下面這段佈局代碼

Container(
      color: Colors.blue,
      child: Row(
        children: <Widget>[
          Image.asset('image'),
          Text('text'),
        ],
      ),
    );

對應三棵樹的結構如下圖

瞭解了這三棵樹,我們再來看下頁面刷新的時候具體做了哪些操作

當需要更新UI的時候,Framework通知Engine,Engine會等到下個Vsync信號到達的時候,會通知Framework進行animate, build,layout,paint,最後生成layer提交給Engine。Engine會把layer進行組合,生成紋理,最後通過Open Gl接口提交數據給GPU, GPU經過處理後在顯示器上面顯示,如下圖:

結合前面的例子,如果text文本或者image內容發生變化會觸發哪些操作呢?

Widget是不可改變,需要重新創建一顆新樹,build開始,然後對上一幀的element樹做遍歷,調用他的updateChild,看子節點類型跟之前是不是一樣,不一樣的話就把子節點扔掉,創造一個新的,一樣的話就做內容更新,對renderObject做updateRenderObject操作,updateRenderObject內部實現會判斷現在的節點跟上一幀是不是有改動,有改動才會別標記dirty,重新layout、paint,再生成新的layer交給GPU,流程如下圖:

到這裡大家對Flutter在渲染方面有基本的理解,作為後面優化部分內容理解的基礎

性能分析工具及方法

下面來看下性能分析工具,注意,統計性能數據一定要在真機+profile模式下運行,拿到最接近真實的體驗數據。

performance overlay

平時常用的性能分析工具有performance overlay,通過他可以直觀看到當前幀的耗時,但是他是UI線程和GPU線程分開展示的,UI Task Runner是Flutter Engine用於執行Dart root isolate代碼,GPU Task Runner被用於執行設備GPU的相關調用。綠色的線表示當前幀,出現紅色則表示耗時超過16.6ms,也就是發生丟幀現象

Dart DevTool

另一個工具是Dart DevTool ,就是早期的Observatory,官方提供的性能檢測工具。它的 timeline 界面可以讓逐幀分析應用的 UI 性能。但是目前還是預覽版,存在一些問題。

profile模式下運行起來,點擊android studio底部的菜單按鈕,會彈出一個網頁

點擊頂部的Timeline菜單

這個時候滑動頁面,每一幀的耗時會以柱形bar的形式顯示在頁面上,每條bar代表一個frame,同時用不同顏色區分UI/GPU線程耗時,這個時候我們要分析卡頓的場景就需要選中一條紅色的bar(總耗時超過16.6ms),中間區域的Frame events chart顯示了當前選中的frame的事件跟蹤,UI和GPU事件是獨立的事件流,但它們共享一個公共的時間軸。

選中Frame events chart中的某個事件,以上圖為例Layout耗時最長,我們選中它,會在底部Flame chart區域顯示一個自頂向下的堆棧跟蹤,每個堆棧幀的寬度表示它消耗CPU的時長,消耗大量CPU時長的堆棧是我們首要分析的重點,後面就是具體分析堆棧,定位卡頓問題。

debug調試工具

另外還有一些debug調試工具可以輔助查看更多信息,注意,只能在debug模式下使用分析,拿到的數據不能作為性能標準

debugProfileBuildsEnabled:向 Timeline 事件中添加每個widget的build 信息

debugProfilePaintsEnabled: 向 timeline 事件中添加每個renderObject的paint 信息

debugPaintLayerBordersEnabled:每個layer會出現一個邊框,幫助區分layer層級

debugPrintRebuildDirtyWidgets:打印標記為dirty的widgets

debugPrintLayouts:打印標記為dirty的renderObjects

debugPrintBeginFrameBanner/debugPrintEndFrameBanner:打印每幀開始和結束

實例分析

瞭解這些工具下面我們來看個簡單的demo具體分析下,一個由Column、Container、ListView嵌套的佈局,其中有個定時器控制Text中顯示的文本實時更新

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';

class TestDemo extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _TestDemoState();
  }
}

class _TestDemoState extends State<TestDemo> {
  int _count = 0;
  Timer _timer;
  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(Duration(milliseconds: 1000), (t) {
      setState(() {
        _count++;
      });
    });
  }
  @override
  void dispose() {
    if (_timer != null) {
      if (_timer.isActive) {
        _timer.cancel();
      }
    }
    super.dispose();
  }
  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        appBar: new AppBar(
          title: new Text("Test Demo"),
        ),
        body: content()
    );
  }
  Widget content(){
    Widget result = Column(
      children: <Widget>[
        Container(
          margin: EdgeInsets.fromLTRB(10,10,10,5),
          height: 100,
          color: Color(0xff1fbfbf),
        ),
        Container(
          margin: EdgeInsets.fromLTRB(10,5,10,10),
          height: 100,
          color: Color(0xff1b8bdf),
        ),
        Container(
          height: 100,
          child: ListView.builder(
              scrollDirection: Axis.horizontal,
              itemCount: 5,
              itemBuilder: (context, index) {
                return Container(
                  width: 70,
                  height: 70,
                  child: Image.asset(
                    'assets/images/common_empty.png',
                    width: 50,
                    height: 50,
                  ),
                );
              }),
        ),

        Container(
            margin: EdgeInsets.fromLTRB(10,20,10,10),
            height: 100,
            width: 350,
            color: Colors.yellow,
            child: Center(
              child:
              Text(
                _count.toString(),
                style: TextStyle(fontSize: 18, fontWeight:FontWeight.bold),
              ),
            )
        ),
      ],
    );
    return result;
  }
}

大部分widget都是靜態的,只有黃色Container中包含一個內容一直刷新的Text,這個時候我們打開debugProfileBuildsEnabled,用Timeline分析下它的渲染耗時,可以通過Frame events chart中顯示的build層級非常深

結合第一部分渲染原理我們瞭解到,每次定時器刷新text數字的時候,整個頁面widget樹都會重新build,但其實只有最底層Container中的Text內容在改變,沒有必要刷新整顆樹,所以這裡我們的優化方案是提高build效率,降低Widget tree遍歷的出發點,將setState刷新數據儘量下發到底層節點,所以將Text單獨抽取成獨立的Widget,setState下發到抽取出的Widget內部

class _TestDemoState extends State<TestDemo> {
  
  ...

  Widget content(){
    Widget result = Column(
      children: <Widget>[
        ...
        Container(
            margin: EdgeInsets.fromLTRB(10,20,10,10),
            height: 100,
            width: 350,
            color: Colors.yellow,
            child: Center(
              child:
                  CountText()
            )
        ),
      ],
    );
    return result;
  }
}

class CountText extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _CountTextState();
  }
}

class _CountTextState extends State<CountText> {
  int _count = 0;
  Timer _timer;
  @override
  void initState() {
    super.initState();
    _timer = Timer.periodic(Duration(milliseconds: 1000), (t) {
      setState(() {
        _count++;
      });
    });
  }

  @override
  void dispose() {
    if (_timer != null) {
      if (_timer.isActive) {
        _timer.cancel();
      }
    }
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text(
      _count.toString(),
      style: TextStyle(fontSize: 18, fontWeight:FontWeight.bold),
    );
  }
}

修改後的Timeline顯示如下圖:

build層級明顯減少,總耗時也明顯降低

接下來分析下Paint過程有沒有可以優化的部分,我們打開debugProfilePaintsEnabled變量分析可以看到Timeline顯示的paint層級

通過debugPaintLayerBordersEnabled = true;顯示layer邊框可以看到不斷變化的Text和其他Widget都是在同一個layer中的,這裡我們想到的優化點是利用RepaintBoundary提高paint效率,它為經常發生顯示變化的內容提供一個新的隔離layer,新的layer paint不會影響到其他layer

RepaintBoundary(
          child: Container(
              margin: EdgeInsets.fromLTRB(10,20,10,10),
              height: 100,
              width: 350,
              color: Colors.yellow,
              child: Center(
                  child: CountText()
              )
          ),
        ),

看下優化後的效果

可以看到我們為黃色的Container建立了單獨的layer,並且paint的層級減少很多

總結常見問題

  1. 提高build效率,setState刷新數據儘量下發到底層節點
  2. 提高paint效率,RepaintBoundry創建單獨layer減少重繪區域

這兩個我們之前的例子已經具體分析過

  1. 減少build中邏輯處理,因為widget在頁面刷新的過程中隨時會通過build重建,build調用頻繁,我們應該只處理跟UI相關的邏輯
  2. 減少saveLayer(ShaderMask、ColorFilter、Text Overflow)、clipPath的使用,saveLayer會在GPU中分配一塊新的繪圖緩衝區,切換繪圖目標,這個操作是在GPU中非常耗時的,clipPath會影響每個繪圖指令,做相交操作,之外的部分剔除掉,所以這也是個耗時操作
  3. 減少Opacity Widget 使用,尤其是在動畫中,因為他會導致widget每一幀都會被重建,可以用 AnimatedOpacity 或 FadeInImage 進行代替

以上內容介紹了些Flutter常見的性能問題以及我們怎麼用工具檢測這個問題,在平時開發過程中要留意規避這類問題

Flutter-DX案例分析

近期我們做了個Flutter端的動態化模板渲染方案Flutter-DX,它使用集團DinamicX的DSL,通過下發DSL模板,在Flutter側實現動態解析渲染。具體介紹可以參考之前的文章:

如何在Flutter上實現高性能的動態模板渲染

做一個高一致性、高性能的Flutter動態渲染,真的很難麼?

這裡不再詳細介紹。

儘管進行了一次渲染架構升級,很大程度上提升性能表現,但是通過高可用線上統計,發現在長列表場景下fps值沒有達到預期值,所以需要進一步分析哪些操作導致的耗時問題。

以搜索頁頁面結構為例,外部是GridView的容器,裡面都是一個個DX模板組成的寶貝card,滑動過程中發現流暢度要明顯偏低

所以我們做了以下的優化措施

1.針對Sliver滑動的優化,sliver在滑動過程中,有一個超出屏幕上下250像素的一個緩存區

在列表滾動過程中,DX card不斷的被重建和銷燬,沒有任何緩存機制,我們在其中加了個緩存池,流程如下,避免element不斷的被銷燬和創建,一定程度提高流暢度

2. 通過Timeline分析發現TextPaint的layout耗時顯著,進一步對比分析發現,同樣的UI顯示,帶換行符的長文本長度layout耗時明顯偏高,

後來確認帶換行符的文本會影響佈局效率,具體分析可以查看 issue

這裡我們做的優化措施是在判斷只有一行文本顯示的情況下,截取換行符前的內容作為text文本,從而提升TextPaint layout效率。

除此之外,還有一些減少佈局層級和簡化build流程,預加載緩存等措施,實現將FPS提升3個點,達到一定程度的優化效果。

總結

以上內容分析了flutter的渲染原理以及遇到卡頓問題可以用哪些工具從哪些方向入手分析,Flutter雖然一直宣稱流暢度是一大亮點,但也存在一定的優化空間,以及需要開發者掌握一定的開發技巧才能達到更絲滑的體驗。

Leave a Reply

Your email address will not be published. Required fields are marked *