在使用 Flutter 開發應用的過程中我們經常遇到需要展示一組連續元素的情景。這時我們通常會選擇使用 ListView 元件。在電商場景中,被展示的元素通常是一組商品、一組店鋪又或是一組優惠券資訊。把這些資訊正確的展示出來僅僅是第一步,通常業務同學為了統計使用者的瀏覽習慣、活動的展示效果還會讓我們上報列表元素的曝光資訊。
什麼是曝光資訊?
什麼是曝光是資訊呢?簡單來說就是使用者實際看到了一個列表中的哪些元素?實際展示給使用者的這部分元素使用者瀏覽了多少次?
讓我們通過一個簡單示例應用來說明:
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())));
}
複製程式碼
上面這段程式碼建立了一個卡片列表。假設我們像下面這樣操作:
應用啟動時預設展示了第 0、1、2 張卡片,接著我們向下瀏覽到第 3 張卡片,這時第 0 張卡片已經離開螢幕可視區域。最後我們重新回到頂部,第 0 張卡片再次進入可視區域。
此時的曝光資料就是:
0 -> 1 -> 2 -> 3 -> 0
複製程式碼
在瞭解了什麼是曝光資訊以後,讓我們來看看如何統計這類資訊。在講解具體方案之前,先讓我們看看 ListView 元件的工作原理。
ListView 的基本工作原理
由於 ListView 元件的具體實現原理有很多細節,這裡我們只從巨集觀上介紹和曝光邏輯相關的部分。
讀過 ListView 元件文件的小夥伴應該都知道 ListView 元件的子元素都是按需載入的。換句話說,只有在可視區域的元素才會被初始化。這樣做可以保證不論列表中有多少子元素,ListView 元件對系統資源的佔用始終可以保持在一個比較低的水平。
按需載入的子元素是如何動態建立的呢?先讓我們看看 ListView 的建構函式。
通常我們有 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
,從而關閉了“預載入”。在複雜頁面中快速划動時有可能會有延遲載入的情況,這需要大家根據自己具體的場景評估。本文中介紹的方案也不是實現曝光統計邏輯的唯一方式,只是為大家提供一個思路。歡迎一起討論 :)。
本文中示例應用的完整程式碼可以在這裡找到。