Flutter之PageView頁面緩存與KeepAlive

正文

如果要實現頁面切換和 Tab 佈局,我們可以使用 PageView 組件。需要註意,PageView 是一個非常重要的組件,因為在移動端開發中很常用,比如大多數 App 都包含 Tab 換頁效果、圖片輪動以及抖音上下滑頁切換視頻功能等等,這些都可以通過 PageView 輕松實現。

構造函數

PageView({
  Key? key,
  this.scrollDirection = Axis.horizontal, // 滑動方向
  this.reverse = false,
  PageController? controller,
  this.physics,
  List<Widget> children = const <Widget>[],
  this.onPageChanged,
  //每次滑動是否強制切換整個頁面,如果為false,則會根據實際的滑動距離顯示頁面
  this.pageSnapping = true,
  //主要是配合輔助功能用的,後面解釋
  this.allowImplicitScrolling = false,
  //後面解釋
  this.padEnds = true,
})

我們看一個 Tab 切換的實例,為瞭突出重點,我們讓每個 Tab 頁都隻顯示一個數字。

// Tab 頁面 
class Page extends StatefulWidget {
  const Page({
    Key? key,
    required this.text
  }) : super(key: key);
  final String text;
  @override
  _PageState createState() => _PageState();
}
class _PageState extends State<Page> {
  @override
  Widget build(BuildContext context) {
    print("build ${widget.text}");
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }
}
@override
Widget build(BuildContext context) {
  var children = <Widget>[];
  // 生成 10 個 Tab 頁
  for (int i = 0; i < 10; ++i) {
    children.add( Page( text: '$i'));
  }
  return PageView(
    // scrollDirection: Axis.vertical, // 滑動方向為垂直方向
    children: children,
  );
}

如果將 PageView 的滑動方向指定為垂直方向(上面代碼中註釋部分),則會變為上下滑動切換頁面。

頁面緩存

我們在運行上面示例時,可能已經發現:每當頁面切換時都會觸發新 Page 頁的 build,比如我們從第一頁滑到第二頁,然後再滑回第一頁時,控制臺打印如下:

flutter: build 0
flutter: build 1
flutter: build 0

可見 PageView 默認並沒有緩存功能,一旦頁面滑出屏幕它就會被銷毀, 和ListView/GridView 不一樣,在創建 ListView/GridView 時我們可以手動指定 ViewPort 之外多大范圍內的組件需要預渲染和緩存(通過 cacheExtent 指定),隻有當組件滑出屏幕後又滑出預渲染區域,組件才會被銷毀,但是不幸的是 PageView 並沒有 cacheExtent 參數!但是在真實的業務場景中,對頁面進行緩存是很常見的一個需求,比如一個新聞 App,下面有很多頻道頁,如果不支持頁面緩存,則一旦滑到新的頻道舊的頻道頁就會銷毀,滑回去時又得重新請求數據和構建頁面,這樣極度消耗性能。

按道理 cacheExtent 是 Viewport 的一個配置屬性,且 PageView 也是要構建 Viewport 的,那麼為什麼就不能透傳一下這個參數呢?於是筆者帶著這個疑問看瞭一下 PageView 的源碼,發現在 PageView 創建Viewport 的代碼中是這樣的:

child: Scrollable(
  ...
  viewportBuilder: (BuildContext context, ViewportOffset position) {
    return Viewport(
      // TODO(dnfield): we should provide a way to set cacheExtent
      // independent of implicit scrolling:
      // https://github.com/flutter/flutter/issues/45632
      cacheExtent: widget.allowImplicitScrolling ? 1.0 : 0.0,
      cacheExtentStyle: CacheExtentStyle.viewport,
      ...
    );
  },
)

我們發現 雖然 PageView 沒有透傳 cacheExtent,但是卻在allowImplicitScrolling 為 true 時設置瞭預渲染區域,註意,此時的緩存類型為 CacheExtentStyle.viewport,則 cacheExtent 則表示緩存的長度是幾個 Viewport 的寬度,cacheExtent 為 1.0,則代表前後各緩存一個頁面寬度,即前後各一頁。既然如此,那我們將 PageView 的 allowImplicitScrolling 置為 true 則不就可以緩存前後兩頁瞭?我們修改代碼,然後運行示例,發現在第一頁時,控制臺打印信息如下:

flutter: build 0
flutter: build 1 // 預渲染第二頁

當再滑回第一頁時,控制臺信息不變,這也就意味著第一頁緩存成功,它沒有被重新構建。但是如果我們從第二頁滑到第三頁,然後再滑回第一頁時,控制臺又會輸出 ”build 0“,這也符合預期,因為我們之前分析的就是設置 allowImplicitScrolling 置為 true 時就隻會緩存前後各一頁,所以滑到第三頁時,第一頁就會銷毀。

能緩存前後各一頁也貌似比不能緩存好一點,但還是不能徹底解決不瞭我們的問題。為什麼明明就是順手的事, flutter 就不讓開發者指定緩存策略呢?然後我們翻譯一下源碼中的註釋:

Todo:我們應該提供一種獨立於隱式滾動(implicit scrolling)的設置 cacheExtent 的機制。

放開 cacheExtent 透傳不就是順手的事麼,為什麼還要以後再做,是有什麼難題麼?這就要看看 allowImplicitScrolling 到底是什麼瞭,根據文檔以及註釋中 issue 的鏈接,發現PageView 中設置 cacheExtent 會和 iOS 中 輔助功能有沖突(讀者可以先不用關註),所以暫時還沒有什麼好的辦法。看到這可能國內的很多開發者要說我們的 App 不用考慮輔助功能,既然如此,那問題很好解決,將 PageView 的源碼拷貝一份,然後透傳 cacheExtent 即可。 考源碼的方式雖然很簡單,但畢竟不是正統做法,那有沒有更通用的方法嗎?有!可滾動組件提供瞭一種通用的緩存子項的解決方案,答案是有的。

KeepAlive

AumaticKeepAlive的組件的主要作用是將列表項的根 RenderObject 的 keepAlive 按需自動標記 為 true 或 false。為瞭方便敘述,我們可以認為根 RenderObject 對應的組件就是列表項的根 Widget,代表整個列表項組件,同時我們將列表組件的 Viewport區域 + cacheExtent(預渲染區域)稱為加載區域 :

  • 當 keepAlive 標記為 false 時,如果列表項滑出加載區域時,列表組件將會被銷毀。
  • 當 keepAlive 標記為 true 時,當列表項滑出加載區域後,Viewport 會將列表組件緩存起來;當列表項進入加載區域時,Viewport 從先從緩存中查找是否已經緩存,如果有則直接復用,如果沒有則重新創建列表項。

那麼 AutomaticKeepAlive 什麼時候會將列表項的 keepAlive 標記為 true 或 false 呢?答案是開發者說瞭算!Flutter 中實現瞭一套類似 C/S 的機制,AutomaticKeepAlive 就類似一個 Server,它的子組件可以是 Client,這樣子組件想改變是否需要緩存的狀態時就向 AutomaticKeepAlive 發一個通知消息(KeepAliveNotification),AutomaticKeepAlive 收到消息後會去更改 keepAlive 的狀態,如果有必要同時做一些資源清理的工作(比如 keepAlive 從 true 變為 false 時,要釋放緩存)。

我們基於上面 PageView 示例,實現頁面緩存,根據上面的描述實現思路就很簡單瞭:讓Page 頁變成一個 AutomaticKeepAlive Client 即可。為瞭便於開發者實現,Flutter 提供瞭一個 AutomaticKeepAliveClientMixin ,我們隻需要讓 PageState 混入這個 mixin,且同時添加一些必要操作即可:

class _PageState extends State<Page> with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context); // 必須調用
    return Center(child: Text("${widget.text}", textScaleFactor: 5));
  }
  @override
  bool get wantKeepAlive => true; // 是否需要緩存
}

代碼很簡單,我們隻需要提供一個 wantKeepAlive,它會表示 AutomaticKeepAlive 是否需要緩存當前列表項;另外我們必須在 build 方法中調用一下 super.build(context),該方法實現在 AutomaticKeepAliveClientMixin 中,功能就是根據當前 wantKeepAlive 的值給 AutomaticKeepAlive 發送消息,AutomaticKeepAlive 收到消息後就會開始工作。

現在我們重新運行一下示例,發現每個 Page 頁隻會 build 一次,緩存成功瞭。需要註意,如果我們采用 PageView.custom 構建頁面時沒有給列表項包裝 AutomaticKeepAlive 父組件,則上述方案不能正常工作,因為此時Client 發出消息後,找不到 Server,404 瞭.

KeepAliveWrapper

雖然我們可以通過 AutomaticKeepAliveClientMixin 快速的實現頁面緩存功能,但是通過混入的方式實現不是很優雅,因為必須更改 Page 的代碼,有侵入性,這就導致不是很靈活,比如一個組件能同時在列表中和列表外使用,為瞭在列表中緩存它,則我們必須實現兩份。為瞭解決這個問題,筆者封裝瞭一個 KeepAliveWrapper 組件,如果哪個列表項需要緩存,隻需要使用 KeepAliveWrapper 包裹一下它即可。

@override
Widget build(BuildContext context) {
  var children = <Widget>[];
  for (int i = 0; i < 10++i) {
    //隻需要用 KeepAliveWrapper 包裝一下即可
    children.add(KeepAliveWrapper(child:Page( text: '$i'));
  }
  return PageView(children: children);
}

下面是 KeepAliveWrapper 的實現源碼:

class KeepAliveWrapper extends StatefulWidget {
  const KeepAliveWrapper({
    Key? key,
    this.keepAlive = true,
    required this.child,
  }) : super(key: key);
  final bool keepAlive;
  final Widget child;
  @override
  _KeepAliveWrapperState createState() => _KeepAliveWrapperState();
}
class _KeepAliveWrapperState extends State<KeepAliveWrapper>
    with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return widget.child;
  }
  @override
  void didUpdateWidget(covariant KeepAliveWrapper oldWidget) {
    if(oldWidget.keepAlive != widget.keepAlive) {
      // keepAlive 狀態需要更新,實現在 AutomaticKeepAliveClientMixin 中
      updateKeepAlive();
    }
    super.didUpdateWidget(oldWidget);
  }
  @override
  bool get wantKeepAlive => widget.keepAlive;
}

可以看出也是基於AutomaticKeepAliveClientMixin實現瞭 bool get wantKeepAlive => widget.keepAlive;並且包裹瞭子組件。

總結

本章主要介紹瞭Pageview頁面緩存的兩種方式,AutomaticKeepAlive和KeepAliveWrapper包裹。另外還需要關註Viewport區域 + cacheExtent的緩存策略和場景。更多關於Flutter PageView頁面緩存的資料請關註WalkonNet其它相關文章!

推薦閱讀: