在使用 Flutter 開發應用的過程當中咱們常常遇到須要展現一組連續元素的情景。這時咱們一般會選擇使用 ListView 組件。在電商場景中,被展現的元素一般是一組商品、一組店鋪又或是一組優惠券信息。把這些信息正確的展現出來僅僅是第一步,一般業務同窗爲了統計用戶的瀏覽習慣、活動的展現效果還會讓咱們上報列表元素的曝光信息。html
什麼是曝光是信息呢?簡單來講就是用戶實際看到了一個列表中的哪些元素?實際展現給用戶的這部分元素用戶瀏覽了多少次?git
讓咱們經過一個簡單示例應用來講明:github
import 'package:flutter/material.dart'; class Card extends StatelessWidget { final String text; Card({ @required this.text, }); @override Widget build(BuildContext context) { return Container( margin: EdgeInsets.only(bottom: 10.0), color: Colors.greenAccent, height: 300.0, child: Center( child: Text( text, style: TextStyle(fontSize: 40.0), ), ), ); } } class HelloFlutter extends StatelessWidget { final items = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]; @override Widget build(BuildContext context) { return ListView.builder( itemCount: items.length, itemBuilder: (BuildContext context, int index) { return Card(text: '$index'); }, ); } } void main() { runApp(MaterialApp( debugShowCheckedModeBanner: false, home: Scaffold( appBar: AppBar(title: Text('hello flutter')), body: HelloFlutter()))); } 複製代碼
上面這段代碼建立了一個卡片列表。假設咱們像下面這樣操做:bash
應用啓動時默認展現了第 0、一、2 張卡片,接着咱們向下瀏覽到第 3 張卡片,這時第 0 張卡片已經離開屏幕可視區域。最後咱們從新回到頂部,第 0 張卡片再次進入可視區域。markdown
此時的曝光數據就是:app
0 -> 1 -> 2 -> 3 -> 0
複製代碼
在瞭解了什麼是曝光信息之後,讓咱們來看看如何統計這類信息。在講解具體方案以前,先讓咱們看看 ListView 組件的工做原理。less
因爲 ListView 組件的具體實現原理有不少細節,這裏咱們只從宏觀上介紹和曝光邏輯相關的部分。ide
讀過 ListView 組件文檔的小夥伴應該都知道 ListView 組件的子元素都是按需加載的。換句話說,只有在可視區域的元素纔會被初始化。這樣作能夠保證不論列表中有多少子元素,ListView 組件對系統資源的佔用始終能夠保持在一個比較低的水平。函數
按需加載的子元素是如何動態建立的呢?先讓咱們看看 ListView 的構造函數。oop
一般咱們有 3 種方式建立一個 ListView (注:爲方便閱讀,三種建立方式中共同的參數已被省去):
ListView({ List<Widget> children, }) ListView.builder({ int: itemCount, IndexedWidgetBuilder itemBuilder, }) ListView.custom({ SliverChildDelegate childrenDelegate, }) 複製代碼
你們可能對前兩種比較熟悉,分別是傳入一個子元素列表或是傳入一個根據索引建立子元素的函數。其實前兩種方式都是第三種方式的「快捷方式」。由於 ListView 內部是靠這個 childrenDelegate
屬性動態初始化子元素的。
以 ListView({List<Widget> children})
爲例,其構造函數以下:
ListView({ ... List<Widget> children: const <Widget>[], }) : childrenDelegate = new SliverChildListDelegate( children, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, ), super( key: key, ... ); 複製代碼
可見,這裏自動幫咱們建立了一個 SliverChildListDelegate
的實例。而SliverChildListDelegate
是抽象類 SliverChildDelegate
的子類。SliverChildListDelegate
中主要邏輯就是實現了 SliverChildDelegate
中定義的 build
方法:
@override Widget build(BuildContext context, int index) { assert(children != null); if (index < 0 || index >= children.length) return null; Widget child = children[index]; assert(child != null); if (addRepaintBoundaries) child = new RepaintBoundary.wrap(child, index); if (addAutomaticKeepAlives) child = new AutomaticKeepAlive(child: child); return child; } 複製代碼
邏輯很簡單,根據傳入的索引返回 children
列表中對應的元素。
每當 ListView 的底層實現須要加載一個元素時,就會把該元素的索引傳遞給 SliverChildDelegate
的 build
方法,由該方法返回具體的元素。當經過 ListView.builder
方式建立 ListView 時,構造函數自動幫咱們建立的是 SliverChildBuilderDelegate
實例(點此查看相關代碼)。
看到這裏你可能會問,說了這麼多,和曝光統計有什麼關係呢?
在 SliverChildDelegate
內部,除了定義了 build
方法外,還定義了一個名爲 didFinishLayout
的方法:
void didFinishLayout(int firstIndex, int lastIndex) {} 複製代碼
每當 ListView 完成一次 layout 以後都會調用該方法。同時傳入兩個索引值。這兩個值分別是這次 layout 中第一個元素和最後一個元素在 ListView 全部子元素中的索引值。也就是可視區域內的元素在子元素列表中的位置。咱們只要比較兩次 layout 之間這些索引值的差別就能夠推斷出有哪些元素曝光了,哪些元素隱藏了。
然而不管是 SliverChildListDelegate
仍是 SliverChildBuilderDelegate
的代碼中,都沒有 didFinishLayout
的具體實現。因此咱們須要編寫一個它們的子類。
首先讓咱們定義一個實現了 didFinishLayout
方法的 SliverChildBuilderDelegate
的子類:
class MySliverChildBuilderDelegate extends SliverChildBuilderDelegate { MySliverChildBuilderDelegate( Widget Function(BuildContext, int) builder, { int childCount, bool addAutomaticKeepAlives = true, bool addRepaintBoundaries = true, }) : super(builder, childCount: childCount, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries); @override void didFinishLayout(int firstIndex, int lastIndex) { print('firstIndex: $firstIndex, lastIndex: $lastIndex'); } } 複製代碼
而後將咱們示例應用中建立 ListView 的代碼改成使用咱們新建立的類:
Widget build(BuildContext context) { return ListView.custom( childrenDelegate: MySliverChildBuilderDelegate( (BuildContext context, int index) { return Card(text: '$index'); }, childCount: items.length, ), ); } 複製代碼
從新在模擬器中啓動咱們的實例程序能夠看到:
首先咱們能夠看到調試終端中輸出了咱們打印的調試信息。可是仔細觀察會發現輸出的信息和咱們指望的並不徹底一致。首先咱們打開首屏時,但是區域內只展現了 3 張卡片,但終端中輸出的 lastIndex
倒是 3,這意味着 ListVivew 組件實際渲染了 4 張卡片。其次,隨着咱們划動屏幕將第 1 張卡片劃出可視區域後,firstIndex
並無當即從 0 變成 1,而是在咱們繼續划動一段距離後才改變。
通過查閱文檔並閱讀相關源碼,咱們瞭解到 ListView 中還有一個 cacheExtent
的概念。能夠簡單理解成一個「預加載」的區域。也就是說出如今可視區域上下各 cacheExtent
大小區域內的元素會被提早加載。雖然咱們建立 ListView 時並無指定該值,但因爲該屬性有一個默認值,因此仍是影響咱們的曝光統計。
如今讓咱們更新示例應用的代碼,明確把 cacheExtent
設置爲 0.0
:
return ListView.custom( childrenDelegate: MySliverChildBuilderDelegate( (BuildContext context, int index) { return Card(text: '$index'); }, childCount: items.length, ), cacheExtent: 0.0, ); 複製代碼
重啓示例應用:
能夠看到此次咱們已經能夠正確獲取當前渲染元素的索引值了。
剩下的邏輯就很簡單了,咱們只須要在 MySliverChildBuilderDelegate
中記錄並比較每次 didFinishLayout
收到的參數就能夠正確的獲取曝光元素的索引了。具體的代碼就不貼在這裏了,文末會給出實例應用的代碼庫地址。
讓咱們看看完成後的效果吧:
因爲強制把 cacheExtent
強制設置爲了 0.0
,從而關閉了「預加載」。在複雜頁面中快速划動時有可能會有延遲加載的狀況,這須要你們根據本身具體的場景評估。本文中介紹的方案也不是實現曝光統計邏輯的惟一方式,只是爲你們提供一個思路。歡迎一塊兒討論 :)。
本文中示例應用的完整代碼能夠在這裏找到。