攜程酒店 Flutter 性能優化實踐

作者簡介

Qifan,攜程高級工程師,專注移動端開發;Yinuo,攜程高級工程師,專注移動端開發;popeye,攜程軟件技術專家,關注移動端跨端技術,致力於快速,高性能地支撐業務開發。

一 、前言

攜程酒店業務使用 Flutter 技術開發的時間快接近兩年,這期間有列表頁、詳情頁、相冊頁等頁面使用了 Flutter 技術棧進行了跨平臺整合,大大提高了研發效率。在開發過程中,也遇到了一些性能相關問題和用戶反饋,比如長列表滾動卡頓、頁面打開時間較長、頁面打開後部分數據加載時間較長等問題。爲解決這些問題,我們選用了多個性能指標監控業務運行狀態,藉助性能檢測工具定位問題,並查閱源碼、文檔等資源解決問題,形成了這篇文章。

同時在不斷的需求迭代和代碼更新過程中,APP 的性能穩定性持續受到挑戰,爲此我們建立了線上性能監控系統,通過量化,治理,監控三方面手段,持續改善 APP 性能和用戶體驗。目前頁面的各種性能指標諸如 FPS、TTI、內存等都達到了不錯的效果,本文將介紹我們在優化過程中所遇到的問題和採取的主要優化方案。

二、FPS&TTI 提升性能優化

2.1 常用性能指標和卡頓定義

對於客戶端應用來說,流暢度是影響用戶使用體驗的關鍵因素。流暢度低主要有:低 FPS、高 TTI、卡頓。這些現象出現時,頁面會出現不連續的動畫,頁面刷新會短暫停頓,打開新頁面速度較慢,新頁面出現白屏或者較長時間的加載動畫,用戶做點擊滑動等交互時頁面不響應。

用戶操作 FPS 的定義是每秒傳輸幀數 (Frames Per Second),是圖像領域的概念。對於手機客戶端來說,主流顯示屏的刷新率爲 60Hz,高端手機顯示屏刷新率可以達到 120Hz 及以上。理想情況下,頁面繪製的 FPS 和屏幕刷新率一致。屏幕畫面刷新次數越多,屏幕可以展示的動態細節越多,所以數值越高越好。TTI 的定義是從頁面加載開始到頁面處於完全可交互狀態 (Time To Interactive),完全可交互狀態指的是頁面有內容呈現並且用戶可以進行操作。

2.2 FPS 優化的工具介紹

Flutter 官方提供了三種應用編譯選項,debug 模式、release 模式和 profile 模式。當我們需要做性能分析的時候,需要打包 profile 模式的應用,這個模式的性能接近 release 模式,並且有性能相關的信息分析。我們使用的工具是官方提供開發者工具中的 Performance View,並選擇了 Enhance tracing 模式。

圖 1 幀渲染時間柱狀圖

上圖是幀渲染時間,橫座標是幀號,縱座標是繪製時間,藍色代表該幀滿足 60fps,橙色代表不滿足 60fps。從這張圖可以快速定位到繪製時間較長的幀,而下圖是選中某幀之後,UI 繪製和光柵化時間,如果選擇了 Enhance tracing 模式,可以看到耗時較長的方法、widget build。

前文已經介紹過 FPS 的定義,對於 flutter 繪製而言,每幀繪製耗時前三的是 UI 繪製時間、光柵化時間、vsync ahead。UI 繪製時間主要是 widget build、layout、paint,簡單認爲是 CPU 時間;光柵化時間可以簡單認爲是 GPU 時間;vsync ahead 是 vsync 信號與 widget build 之間的延時。

圖 2 Widget build 耗時與對應執行的方法

2.3 具體實踐方案

a) 控制 setState 次數,使用 Provider 機制減小刷新範圍

我們的業務開發是 MVVM 結構的,數據驅動 UI 更新。UI 的繪製佔了性能開銷的很大部分,減少不必要的 UI 繪製、控制 UI 繪製的範圍這兩種方法能顯著改善性能。

減少不必要的 UI 繪製是通過控制 build 次數實現的。widget build 是通過 setState 方法或者 builder 方法觸發的,在業務中,儘量減少非必要的 setState,只有真正頁面數據發生變化,頁面狀態變化時才調用 setState 方法。對於 builder 方法,可以實現 shouldRebuild 等接口,增加觸發 builder 方法的限制。

控制 UI 繪製的範圍是通過改變 widget 樹層級實現的。MVVM 中數據觸發 UI 更新的方式有很多,我們的業務主要用到了 Provider 機制,這是一種觀察者模式設計。如下圖所示,對於左邊的 widget 樹,如果只需要更新 Container 容器配置和 Icon 圖標配置,那麼可以將 selector 拆分到這兩個 widget 的雙親 widget,實現了 Text widget 不刷新。對於 widget 樹較大的業務,這樣的改動能顯著提升 FPS。

圖 3 Widget 樹結構優化以減少 build 次數

b) 預構建 widget (AnimatedBuilder)

圖 4 酒店詳情頁頭部使用預構建減少 build 次數

上圖是酒店詳情頁頭部沉浸式動畫的 UI,頭部展開的過程中,圖片和圖片上的蒙層需要重新繪製,圖片上部 SHA logo 不需要重新繪製,圖片下部 tab 欄不需要重新繪製,對於這個需求的做法是用 AnimatedBuilder。

AnimatedBuilder 提供了幾個可選參數,animation 是對動畫的監聽,builder 是動畫過程中需要重新繪製的部分,child 是動畫過程中不需要重新繪製的部分,child 作爲參數會傳入 builder 中。下面的僞代碼是一個例子,動畫過程中 Text 並不會多次繪製。 

 @override
    Widget build(BuildContext context) {
      return AnimatedBuilder(
        animation: _controller,
        child: Container(
          width: 200.0,
          height: 200.0,
          color: Colors.green,
          child: const Center(
            child: Text('Text!'),
          ),
        ),
        builder: (BuildContext context, Widget child) {
          return Transform.rotate(
            angle: _controller.value * 2.0 * math.pi,
            child: child,
          );
        },
      );
    }

對於詳情頁頭部沉浸式動畫的例子,可以把 widget 樹進行拆分,只有圖片和圖片蒙層放入 builder 方法中,其餘的 widget 作爲 child 傳入 builder,同時用 Stack widget 實現兩部分 UI 的組合,這樣改進之後,FPS 在動畫過程中有較大提升。

c) const widget

對於 dart 語法,需要分清楚 final 和 const 關鍵字的區別。關鍵字 final 的意思是一次賦值,不能改變;而關鍵字 const 的意思是常量,確定的值。這兩者的區別是 final 變量在第一次使用時被初始化,而 const 變量是一個編譯時替換爲常量值。同樣的,對於 const widget,這個 widget 在編譯階段就已經確定,不會有狀態的變化和成員變量更新。const widget 特別適合於標籤、特殊 Icon 等可以複用的 UI,性能開銷較小。

d) 減少耗時計算,放到 Isolate

Flutter 應用中的 Dart 代碼執行在 UI Runner 中,而 Dart 是單線程的,我們平時使用的異步任務 Future 都是在這個單線程的 Event Queue 之中,通過 Event Loop 來按順序執行。需要避免將一些耗時計算放在 UI 線程,可以把耗時計算放到 Isolate 去執行。

e) 懶加載

能夠實現懶加載的有 ListView.builder、PageView.builder 和 GridView.builder,這些 widget 可以用戶長列表或重複容器結構的 UI,通過判斷單個 item 是否在屏幕內或者將要進入屏幕位置而進行繪製。與之對應的是 Column、Row 等一次性繪製 widget,對於重複結構的數據,儘量避免使用這些組件。

如下圖中,酒店周邊景點美食購物列表和附近同類型酒店列表都實現了按需加載。酒店周邊景點美食購物列表的卡片數量超過 20 個,最初使用 Row 組件構建時,第一次構建時間超過 25ms,達不到 60FPS 的 16ms 繪製時間要求。當然,按需加載也有性能開銷,出現在列表的滑動過程中。如果一次性全部構建了列表,滑動過程中不會觸發新的構建,滑動流暢度體驗更好,但是第一次構建時的卡頓感明顯。

圖 5 酒店詳情頁周邊內容運用懶加載減少構建次數

f) 分幀渲染

錯峯加載方案使用分幀渲染,分幀渲染的原理是將一棵 Widget 樹中的部分繪製時間較長的節點在第一幀時只佔位不繪製,等到下一幀開始時,節點替換佔位 UI,單獨使用一幀時間繪製。

在酒店詳情頭部信息繪製中運用了分幀渲染技術,下左圖未使用分幀渲染,下右圖對圖片 tab 欄、酒店設施標籤、點評模塊、地址欄使用分幀渲染。從結果看,減少了 3 次卡頓和 1 次輕微卡頓,流暢幀佔比超過 90%。

圖 6 分幀渲染在詳情頁頭部運用的效果

佈局與繪製的基本單位是一棵 widget 樹,分幀渲染的原理是將佈局與繪製時間較長的子 widget 先用 Container 佔位,再等下一幀開始時單獨渲染。使用佔位 widget 的僞代碼如下,build 方法返回佔位 widget,並在 widget 構建幀結束時替換佔位 widget 並觸發繪製。

 @override
  void initState() {
    super.initState();
    result = widget.placeHolder;
    replaceWidget ();
  }
  @override
  Widget build(BuildContext context) {
    return result;
  }
  void replaceWidget() {
    SchedulerBinding.instance.addPostFrameCallback((t) {
      TaskQueue.instance.scheduleTask(() {
        if (mounted)
          setState(() {
            result = widget.child;
          });
      }, Priority.animation, id: widget.index);
    });
  }

幀的繪製狀態可以從 SchedulerBinding 獲得,同時建立隊列保證一幀執行一個子 widget 繪製。

// 等待當前幀結束時替換佔位widget並觸發繪製
  await SchedulerBinding.instance.endOfFrame;
  // 執行任務隊列中的繪製任務
  final TaskEntry<dynamic> entry = _taskQueue.first;
  entry.run();

2.4  UI GPU 問題定位與優化

GPU 問題主要集中在底層渲染耗時上。有時候 Widget 樹雖然構造起來容易,但在 GPU 線程下的渲染卻很耗時。涉及 Widget 裁剪、蒙層這類多視圖疊加渲染,或是由於缺少緩存導致靜態圖像的反覆繪製,都會明顯拖慢 GPU 的渲染速度。可以使用性能圖層提供的兩項參數,負責檢查多視圖疊加的視圖渲染開關 checkerboardOffscreenLayers 和負責檢查緩存的圖像開關 checkerboardRasterCacheImages 來檢查這種模塊的存在。

a) checkerboardOffscreenLayers

多視圖疊加通常會用到 Canvas 裏的 savaLayer 方法,這個方法在實現一些特定的效果(比如半透明)時非常有用,但由於其底層實現會在 GPU 渲染上涉及多圖層的反覆繪製,因此會帶來較大的性能問題。對於 saveLayer 方法使用情況的檢查,我們只要在 MaterialApp 的初始化方法中,將 checkerboardOffscreenLayers 開關設置爲 true,分析工具就會自動幫我們檢測多視圖疊加的情況了,使用了 saveLayer 的 Widget 會自動顯示爲棋盤格式,並隨着頁面刷新而閃爍。

不過,saveLayer 是一個較爲底層的繪製方法,因此我們一般不會直接使用它,而是會通過一些功能性 Widget,在涉及需要剪切或半透明蒙層的場景中間接地使用。所以一旦遇到這種情況,我們需要思考一下是否一定要這麼做,能不能通過其他方式來實現。如下圖所示,因爲詳情頭部 bar 用到高斯模糊,同時使用 ClipRRect 裁切圓角,ClipRRect 會調到 savelayer 接口,所以該部分產生閃爍。

圖 7 詳情頁頭部圖片標題欄中裁切樣式應用

b) checkerboardRasterCacheImages

從資源的角度看,另一類非常消耗性能的操作是,渲染圖像。這是因爲圖像的渲染涉及 I/O、GPU 存儲,以及不同通道的數據格式轉換,因此渲染過程的構建需要消耗大量資源。

爲了緩解 GPU 的壓力,Flutter 提供了多層次的緩存快照,這樣 Widget 重建時就無需重新繪製靜態圖像了。與檢查多視圖疊加渲染的 checkerboardOffscreenLayers 參數類似,Flutter 也提供了檢查緩存圖像的開關 checkerboardRasterCacheImages,來檢測在界面重繪時頻繁閃爍的圖像(即沒有靜態緩存)。

我們可以把需要靜態緩存的圖像加到 RepaintBoundary 中,RepaintBoundary 可以確定 Widget 樹的重繪邊界,如果圖像足夠複雜,Flutter 引擎會自動將其緩存,避免重複刷新。當然,因爲緩存資源有限,如果引擎認爲圖像不夠複雜,也可能會忽 RepaintBoundary。

2.5 頁面預加載提升 TTI

網頁應用的主要流程有三步,通過鏈接打開頁面,發送服務請求獲得頁面數據,將頁面數據展示在頁面上。對客戶端應用來說,頁面之間跳轉是相對確定的,數據在頁面之間存在共享的可能,預加載的工作是在打開頁面之間預先獲得頁面的數據,從而減少打開頁面到頁面展示的時間。

預加載數據有三種常見方法,第二個頁面的數據在第一個頁面的服務結果中獲得;第二個頁面的數據在客戶端其它頁面中預先獲得並緩存;第二個頁面的服務請求在打開頁面之前發送。

a) 預加載頁面數據

頁面數據預獲取的方案,實現方法是在上一個頁面提前獲取服務數據,在用戶跳轉到當前頁面時,直接從緩存獲取,節省了數據的網絡傳輸時間,達到快速展示當前頁面內容的效果。目前在酒店核心預訂流程,都運用了數據預加載技術,如下圖所示。

圖 8 酒店業務預加載頁面數據的應用

結合酒店業務特點,數據預加載需要考慮幾個方面問題,第一,酒店預訂流程頁面 PV 量都很高,酒店列表和詳情頁 PV 都是千萬級別,所以需要考慮數據預加載的時機,避免服務的資源浪費。第二,酒店列表,詳情,填單頁都有價格信息,價格信息對用戶來說是動態信息,實時都有變價可能,所以需要考慮數據預加載的緩存策略,避免因爲價格的前後不一致造成用戶誤解。

在實現全流程預加載方案之後,我們酒店預訂流程頁面的慢加載率從初始值的 42.90% 降低至現階段的 8.05%。

b) 預加載 ViewModel

與數據預獲取的方案相比,預加載 ViewModel 更進一步,將預獲取的數據處理成 ViewModel 形式,在打開頁面時直接用 ViewModel 進行展示。這種方案減少了業務對數據處理的時間。

圖 9 酒店詳情頁預加載 ViewModel 技術的應用

上圖是杭州綠城尊藍錢江豪華精選酒店在酒店列表頁和酒店詳情頁頭部的 UI 對比。可以看出,酒店詳情頁頭部的信息主要是酒店名稱、星級、榜單、特色設施、點評、開業裝修時間等信息,這些信息和列表頁酒店卡片信息存在重合。如果用戶瀏覽的軌跡爲從酒店列表頁到酒店詳情頁,那麼可以直接將列表頁的數據帶入酒店詳情頁作爲頭部展示。

圖 10 酒店詳情頁預加載 ViewModel 的數據流

上圖爲詳情頁頭部預加載的主要流程。我們的 flutter 業務代碼採用 MVVM 的結構,將服務請求的結果處理完的數據放入 ViewModel 中,ViewModel 的數據更新通過 Provider 機制觸發頁面 UI 更新。

圖中可以開到,詳情頁頭部 ViewModel 的數據有兩個來源,分別是列表頁服務請求的結果和詳情頁服務請求的結果。這兩個服務請求結果到 ViewModel 的業務流程不一樣,列表頁的服務結果數據通過 URL 參數的方式傳入詳情頁,而詳情頁服務結果可以直接生成詳情頁頭部的 ViewModel。

圖中還有一個重要模塊是列表頁服務結果和詳情頁服務結果之間的通用緩存 DataCache,它的功能是實現頁面之間數據的一致性。頁面上的數據可以由服務更新,也可以由用戶交互更新。業務的 ViewModel 依賴這個通用緩存,數據更新會觸發頁面 UI 更新。

三、Flutter 服務通道優化

3.1 背景

因爲我們 APP 採用的私有服務協議,目前發服務的動作還是在 Native 代碼上,而酒店的核心頁面已經轉到了 Flutter 上。通過 Flutter 框架提供的通道技術 Native 到 Flutter 的數據傳輸通道需要對數據做一次額外的序列化及反序列化的傳輸,同時傳輸的過程比較耗時,會阻塞 UI 的渲染主線程,對頁面的加載會造成明顯的影響。我們檢測到這個環節之後和框架一起對 Flutter 的底層框架進行了改造,可以實現數據流直接的透傳,同時不阻塞 UI 主線程,性能得到了極大的提升。

優化前,通過服務返回的數據流傳遞到 flutter 使用,整個過程要經歷以下 4 步:

整個過程鏈路長,數據傳輸量大,效率低,影響到頁面加載性能,如下圖所示

圖 11 優化前的業務服務請求數據流

改造後,通過服務返回的數據流,直接傳輸到 Flutter 側,在 Flutter 直接進行 PB 的反序列化,傳輸性能得到極大提升。

整個過程鏈路短,數據傳輸量小,效率高,如下圖所示:

圖 12 優化後的業務服務請求數據流

其中 MethodChannel 的編解碼器由 JsonMethodCodec 換成了 StandardMethodCodec。因爲 StandardMethodCodec 可以避免轉換 JsonString 的操作,能節省傳輸時間。

3.2 Flutter 中使用 Protobuf

在 flutter 中使用 Protobuf,首先需要將 proto 契約文件轉化成 dart 文件,可以藉助官方編譯工具 protoc 進行編譯。

a) 獲取 protoc 工具

安裝 C++

sudo apt-get install autoconf automake libtool curl make g++ unzip

安裝 Protobuf 發行版

https://github.com/protocolbuffers/protobuf/releases

下載完成之後,解壓,進到目錄中執行下面命令編譯安裝

./configure

make

make check

sudo make install

sudo ldconfig # refresh shared library cache.

安裝 protoc-gen-dart 插件

dart pub global activate protoc_plugin

在 Terminal 中執行 protoc 命令生成 dart 文件

protoc --dart_out=. <文件名>.proto

圖 13 生成的契約文件結構

b)  使用生成的 dart 契約文件

執行 flutter pub add protobuf 命令,修改項目的 pubspec.yaml,在 dependencies 中加上: protobuf: ^2.0.1

編寫如下測試代碼:

圖 14 使用契約的樣例代碼

執行後可以得到如下結果:

圖 15 執行結果

其中,生成 Person 的類繼承了 Protobuf 包裏的 GeneratedMessage 類,序列化和反序列化由基類實現。但是這種方式不能根據需要定製化生成契約文件。因此,爲了更好的兼容 Json 格式的數據,可以使用 FreeMarker 模板引擎定製化生成契約文件。

圖 16 使用 FreeMarker 生成契約的文件結構

3.3 使用 FreeMarker 定製化生成 dart 契約文件

FreeMarker 是一款模板引擎:即一種基於模板和要改變的數據,並用來生成輸出文本(HTML 網頁、電子郵件、配置文件、源代碼等)的通用工具。它不是面向最終用戶的,而是一個 Java 類庫,是一款程序員可以嵌入他們所開發產品的組件。

下面介紹如何使用 FreeMarker 和 protoc 命令生成任意編程語言的契約文件

1)下載 FreeMarker 最新版 jar 包

https://freemarker.apache.org/freemarkerdownload.html

2)下載 Protobuf 對應版本的 jar 包

https://mvnrepository.com/artifact/com.google.protobuf/protobuf-java

3)在 Java 項目中導入對應 jar 包

圖 17 項目中導入工具方法

4)編寫 Java 程序

圖 18 程序流程圖

程序的流程如上圖所示。首先使用 protoc 命令生成對應的描述文件,其次將描述文件轉換成對應 java 對象,最後使用 FreeMarker 模板引擎生成任意語言的契約文件。

圖 19 程序的實現

由上圖可知,模板引擎的輸入是一個 classModel 對象。如下圖實現了將描述文件轉化成 classModel 對象的功能。

圖 20 程序的實現(續)

FTL 模板文件如下圖所示:

圖 21 模版文件

5)執行代碼輸出契約文件

圖 22 輸出的契約文件

這樣就可以實現了根據 proto 文件自定義生成任意編程語言的契約文件。

3.4 Json 與 Protobuf 的性能對比

我們對比了相同報文情況下 Json 和 Protobuf 在序列化和反序列化上所花費的時間。從下圖可知,Protobuf 在序列化和反序列化相同大小報文時比 Json 花費的時間大大減少了,也大大提高了我們獲取數據的速度。

圖 23 序列化、反序列化時間

四、內存泄漏治理

4.1 內存泄漏的常用監控手段

內存泄漏是一個比較嚴重的問題,如果出現,對 App 的穩定性和用戶體驗都有非常大影響。因此對這塊的監控和治理也是我們非常關注的一塊。

在監控方面 Flutter 現在比較通用的方法就是利用 Expando 中的弱引用去監控我們要檢查是否有泄漏的對象,如果出現則從 VM 中獲取其引用鏈接,從而分析其泄漏原因。我們的框架也利用此方法監控了我們 app 中的每個頁面是否在退出時還存在泄漏。

另外通過 Flutter 的 Dev tool 中的內存監控工具也能實現對泄漏對象的發現。比如對於酒店詳情頁面,反覆進入和退出此頁面,如果有泄漏會發現,在內存監控工具中出現此頁面多個的對象存活,此時基本可以判斷出此頁面出現了泄漏了。下圖的第一列是類名,第二、三列是實例數量,第四、五列是對應分配的字節數。

圖 24 酒店詳情的內存泄漏監控

4.2 內存泄漏的治理

下面介紹一下,我們在我們頁面的內存泄漏治理中發現的一些導致泄漏的原因和解決的辦法。

a) 調用 Native 的 Plugin 時,對 Future 的 Then 設置的閉包沒有關閉

在調用 Native 的 Plugin 接口時,有時會設置一個 Then 的閉包,期望在這個閉包裏去處理這個 Plugin 的返回結果。這個閉包會註冊到引擎的全局變量裏面,如果 Native 調用了 result 的 listener,這個 Then 的閉包會走到,然後會被清除掉。如果某些 case,Native 沒有調用,則這個閉包會泄露,如果這個閉包所屬的 Model 能引用到頁面對象的話,則會造成整個頁面的泄露。

比如下面這個例子,我們進入 flutter 頁面時會調這個 plugin,但是 native 對應的 result 則必須在某些 case 情況下才會回調。而大部分情況下,是不會回調的,從而造成整個頁面的泄露。解決方法是把 future 轉換成 stream,然後我們在頁面退出時 cancel 掉,就能避免閉包的泄漏。

例子:調用 Native 的 Plugin 時出現泄漏的情況

Flutter 側的調用:

void callNative() {
FlutterBridge.callNative("method", map).then((value) {
     do some thing;});
}

Native 的響應:

override fun flutterPluginAction ( result: MethodChannel.Result){
if (condition) {
        result.success(ret)
    } else {
        do something;
    }
}

可以看到 Native 在接受到這個 plugin 調用時,對於 result 的調用返回不是一直都會做的,它需要等到滿足條件纔會做這件事情,而如果它不做這件事情,對應的 flutter 那邊的閉包就會一直被保存在引擎中,這個引用鏈也會一直存在,從而造成這個引用鏈上的對象都泄漏了。

解決的方法:

void callNative () {
Future future = FlutterBridge. callNative ("method");
    _streamSubscription?.cancel();
    _streamSubscription = future?.asStream()?.listen((value)
{
        do something;
});
}

我們的解決方式,就是對這種異步但不能確定回調是否一定完成的情況,換成用 StreamSubscription 去監聽。然後當頁面退出時做一下 cancel 的動作,這樣就能避免泄漏的發生。

void onPageDestroy() {
    _ streamSubscription?.cancel();
}

這種等待對異步調用的回調監聽其實都可能存在類似問題,只不過如果是單純在 Dart 中的異步調用一般不會存在這種不回調的情況。但是對於 plugin 這種跟 native 的交互的地方,我們在初期接觸 flutter 時沒有關注到這塊,有可能會造成遺漏。

b) 一些觀察者模式中的訂閱者在頁面退出時沒有取消訂閱

這種是大家比較熟悉的一種情況。常見的例子有例如像 Timer,EventBusCenter.defaultBus 和 LifeCycleObserver 等。這些訂閱者如果在頁面退出時不需要了,需要記得取消掉。否則也會造成內存泄漏,這種情況我們也應該避免。

五、小結

性能優化是一件不斷持續,不斷深入的事情。我們通過本文中所介紹的改進措施對頁面性能實現了很大的優化,達到了不錯的效果。後續也會在此基礎之上對還可提高的地方繼續加深,同時也會對已經驗證實行有效的方案去做一些抽象,封裝工作,後續提供通用的解決方案。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/sFF0R_wVBO6e3OSo27V1kg