前言
通常商品列表都展示了非常多的物件,产品为了统计用户的列表曝光和点阅效果,我们则需要定义曝光规则,计算物件曝光,获取曝光数据之后进行上传...
技术实现
方案一
依赖插件
如果项目没有用到getx和列表下拉刷新组件,可以不引用
dependencies:
get: ^4.6.5
dependencies:
pull_to_refresh: ^2.0.0
RectGetter
import 'package:flutter/material.dart';
/// 需要实时获得某个Widget的Rect信息时使用该控件
/// 可选传GlobalKey和无参两种构造方式,之后利用对象本身或者构造传入的key以获取信息
/// Use this widget to get a widget`s rectangle information in real-time .
/// It has 2 constructors , pass a GlobalKey or use default key , and then
/// you can use the key or object itself to get info .
class RectGetter extends StatefulWidget {
final Widget child;
final GlobalKey<RectGetterState> globalKey;
/// 持有某RectGetter对象的key时利用该方法获得其child的rect
/// Use this static method to get child`s rectangle information when had a custom GlobalKey
static Rect? getRectFromKey(GlobalKey<RectGetterState> globalKey) {
var object = globalKey.currentContext?.findRenderObject();
var translation = object?.getTransformTo(null).getTranslation();
var size = object?.semanticBounds.size;
if (translation != null && size != null) {
return Rect.fromLTWH(
translation.x, translation.y, size.width, size.height);
} else {
return null;
}
}
/// create a custom GlobalKey , use this way to avoid type exception in dart2 .
static GlobalKey<RectGetterState> createGlobalKey() {
return GlobalKey<RectGetterState>();
}
/// 传GlobalKey构造,之后可以RectGetter.getRectFromKey(key)的方式获得Rect
/// constructor with key passed , and then you can get child`s rect by using RectGetter.getRectFromKey(key)
const RectGetter({required this.globalKey, required this.child})
: super(key: globalKey);
/// 生成默认GlobalKey的命名无参构造,调用对象的getRect方法获得Rect
/// Use defaultKey to build RectGetter , and then use object itself`s getRect() method to get child`s rect
factory RectGetter.defaultKey({required Widget child}) {
return RectGetter(
globalKey: GlobalKey(),
child: child,
);
}
Rect? getRect() => getRectFromKey(globalKey);
/// 克隆出新对象实例,避免同一GlobalKey在组件树上重复出现导致的问题
/// make a clone with different GlobalKey
RectGetter clone() {
return RectGetter.defaultKey(
child: child,
);
}
@override
RectGetterState createState() => RectGetterState();
}
class RectGetterState extends State<RectGetter> {
@override
Widget build(BuildContext context) => widget.child;
}
ExposureList
/// 列表曝光(处理曝光规则计算,提供方法及数据给调用者)
class ExposureList {
// 是否已初始化检测曝光
bool initExposure = false;
/// 曝光物件id集合(用于同一次行为去重,退出页面后再重新计算)
final List<String> _allList = [];
/// 曝光物件数据集合(用于数据统计)
final List<ExposureEntity> _exposureList = [];
/// 所有item集合
final Map<String, ExposureEntity> _listItemKeys = {};
/// 父控件的key
final GlobalKey<RectGetterState> globalKey = RectGetter.createGlobalKey();
ExposureList();
/// 初始化item
initItem(String id, {int? index, String? title}) {
// 如果不需要通过打日志的方式来查看曝光的位置和标题,可以不传index和title
GlobalKey<RectGetterState> _globalKey = RectGetter.createGlobalKey();
ExposureEntity entity =
ExposureEntity(id, _globalKey, index: index, title: title);
// id不会因为数据的变化而变化,请以id作为key值,不要用index作为key值
_listItemKeys[id] = entity;
return _globalKey;
}
/// 初始检测曝光(列表数据首次加载完成后调用)
initCheckExposure() {
if (!initExposure) {
initExposure = true;
Future.delayed(const Duration(milliseconds: 500)).then((value) {
checkExposure();
});
}
}
/// 检测曝光(在列表滑动停止后调用)
checkExposure() {
// 获取列表的Rect
var rect = RectGetter.getRectFromKey(globalKey);
_listItemKeys.forEach((id, entity) {
// 获取item的Rect
var itemRect = RectGetter.getRectFromKey(entity.globalKey);
if (itemRect != null && rect != null) {
// 曝光因子
const double exposeFactor = 0.5;
// 列表item高度
double itemHeight = itemRect.bottom - itemRect.top;
// 曝光高度,例如:列表item高度的一半显示在屏幕中就算曝光
double exposeHeight = exposeFactor * itemHeight;
// 计算曝光,判断item是否可见,如果在物件曝光规则内可见则算是曝光
bool visible = (itemRect.top + exposeHeight > rect.top) &&
(itemRect.bottom < rect.bottom + exposeHeight);
if (visible) {
// 如果曝光,且之前没有曝光过,则添加到曝光列表中
if (!_allList.contains(id)) {
// 添加到曝光列表中
_allList.add(id);
_exposureList.add(entity);
}
debugPrint('曝光列表 index:${entity.index},title:${entity.title},id:${entity.id}');
}
}
});
}
/// 获取曝光数量(可用于判断曝光数量是否超过一定大小)
int exposureCount() {
return _exposureList.length;
}
/// 获取曝光列表(可用于上传时获取曝光数据)
List<ExposureEntity> getExposureList() {
// 深拷贝,防止外部修改
return List.from(_exposureList);
}
/// 移除已曝光的元素(每次曝光数据上传完成之后调用)
removeExposureList(List<ExposureEntity> list) {
for (ExposureEntity entity in list) {
// 移除符合条件的元素(已曝光的元素)
_exposureList.removeWhere((element) => (entity.id == element.id));
}
}
/// 清空数据(退出页面后调用)
clearList() {
_allList.clear();
}
}
/// 曝光实体
class ExposureEntity {
final String id;
final int? index;
final String? title;
final GlobalKey<RectGetterState> globalKey;
ExposureEntity(this.id, this.globalKey, {this.index, this.title});
}
ListPage
import 'package:get/get.dart';
/// 列表页面
class ListPage extends BasePageWidget {
final Map? map;
const ListPage({Key? key, this.map}) : super(key: key);
@override
_ListPageState createState() => _ListPageState();
}
class _ListPageState extends BasePageState<ListPage>
with TickerProviderStateMixin, WidgetsBindingObserver {
// 逻辑数据层
late ListLogic logic;
@override
void initState() {
super.initState();
// 添加生命周期监听
WidgetsBinding.instance.addObserver(this);
// 初始化赋值
logic = Get.put(ListLogic(), tag: '${widget.hashCode}');
}
@override
void onDidPushNext() {
super.onDidPushNext();
// 暂时离开页面时(例如进入搜索 / 详情等子页面)
logic.sendListExposure();
}
@override
void onDidPop() {
super.onDidPop();
// 退出页面时(例如点击返回按钮)
logic.sendListExposure();
}
/// 初次进入widget时,不执行AppLifecycleState的回调
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
switch (state) {
case AppLifecycleState.resumed:
// 应用程序可见(从后台重新切换到前台时候被调用)
break;
case AppLifecycleState.paused:
// 应用程序不可见(只要应用程式为不可见时都被会调用)
break;
case AppLifecycleState.inactive:
// 切换到后台时,随后pause也会被回调
logic.sendListExposure();
break;
case AppLifecycleState.detached:
// 应用程序仍然驻留在Flutter引擎上,在没有视图的情况下运行
break;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: const Text('列表页面'),
),
body: GetBuilder<ListLogic>(
tag: '${widget.hashCode}',
builder: (logic) {
return Container(
width: double.infinity,
color: Colors.white,
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollEndNotification) {
// 滑动停止后,检查曝光
logic.exposureList.checkExposure();
// 曝光列表如果超过30个则发送数据
if (logic.exposureList.exposureCount() > 30) {
logic.sendListExposure();
}
}
return true;
},
child: RectGetter(
globalKey: logic.exposureList.globalKey,
child: SmartRefresher(
controller: logic.refreshController,
scrollController: logic.scrollController,
enablePullDown: true,
enablePullUp: true,
onRefresh: logic.pullDownToRefresh,
onLoading: logic.pullUpToLoadMore,
header: const WaterDropHeader(
complete: Text('刷新成功'), // 刷新成功Widget
),
footer: const ClassicFooter(
loadStyle: LoadStyle.ShowWhenLoading,
completeDuration: Duration(milliseconds: 500),
),
child: SingleChildScrollView(
child: Column(
children: [
// 这里可以选择性添加一些自定义的Widget
//...
logic.dataList.isEmpty
? const Padding(
padding: EdgeInsets.only(top: 320),
child: Text('正在加载数据...'))
: ListView.builder(
// 顶部空白处理
padding: EdgeInsets.zero,
shrinkWrap: true,
physics:
const NeverScrollableScrollPhysics(),
itemCount: logic.dataList.length,
itemBuilder: (context, index) {
Map item = logic.dataList[index];
String id = item['id'];
String title = item['title'];
GlobalKey<RectGetterState> _globalKey =
logic.exposureList.initItem(id,
index: index, title: title);
return RectGetter(
globalKey: _globalKey,
child: _listItem(item),
);
})
],
),
)),
),
),
);
},
));
}
_listItem(map) {
return Column(
children: [
Container(
width: double.infinity,
height: 100.px,
color: Colors.white,
child: Center(child: Text(map['title'])),
),
const Divider(height: 1, color: Colors.grey)
],
);
}
@override
void dispose() {
Get.delete<ListLogic>(tag: '${widget.hashCode}');
// 移除生命周期监听
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
}
ListLogic
import 'package:get/get.dart';
/// 列表页逻辑
class ListLogic extends GetxController with GetSingleTickerProviderStateMixin {
// 页码
int _page = 1;
// 列表数据
List dataList = [];
// 列表滑动控制器
final ScrollController scrollController = ScrollController();
// 列表刷新控制器
final RefreshController refreshController =
RefreshController(initialRefresh: false);
// 列表曝光
ExposureList exposureList = ExposureList();
@override
void onInit() {
super.onInit();
// 列表滚动监听
scrollController.addListener(() {});
// 加载数据
_loadData(_page, refresh: true);
}
/// 下拉刷新
pullDownToRefresh() async {
_loadData(_page, refresh: true);
}
/// 上拉加载更多
pullUpToLoadMore() async {
_loadData(_page);
}
/// 加载数据
_loadData(int page, {bool refresh = false}) async {
if (refresh) {
_page = 1;
dataList.clear();
refreshController.resetNoData();
}
// 模拟http请求获取列表数据
await Future.delayed(const Duration(seconds: 2));
List list = [];
final int size = dataList.length;
for (int i = size; i < size + 20; i++) {
String id =
'${i.toString()}_' + DateTime.now().millisecondsSinceEpoch.toString();
list.add({'id': id, 'title': '测试数据: $id'});
}
dataList.addAll(list);
_page++;
if (refresh) {
refreshController.refreshCompleted();
} else {
refreshController.loadComplete();
}
// 模拟没有更多数据
if (dataList.length > 200) {
refreshController.loadNoData();
}
// 拿到列表数据后
if (dataList.isNotEmpty) {
exposureList.initCheckExposure();
}
update();
}
/// 发送列表曝光(根据自己的曝光规则进行定义即可)
/// 定义统计曝光规则:
/// 1.每次离开列表页面时候发送列表曝光
/// 2.每次数据超过30条的时候发送列表曝光
/// 3.在同一次用户行为之内,同一笔物件曝光多次只统计一次(用户从进入列表页面到退出列表页面算一次用户行为,不包括跳转子页面)
sendListExposure() {
List<ExposureEntity> list = exposureList.getExposureList();
if (list.isNotEmpty) {
StringBuffer ids = StringBuffer();
for (ExposureEntity entity in list) {
if (ids.isEmpty) {
ids.write(entity.id);
} else {
ids.write(',${entity.id}');
}
}
// 拿到曝光数据后进行上传
debugPrint('曝光列表,数据上传:$ids');
// 上传成功后清空已曝光的列表数据
exposureList.removeExposureList(list);
}
}
@override
void onClose() {
exposureList.clearList();
super.onClose();
}
}
方案二
依赖插件
dependencies:
# https://pub.dev/packages/visibility_detector/install
visibility_detector: ^0.4.0+2
曝光组件
Widget _buildItem(Map map, int index) {
return VisibilityDetector(
key: Key('$index'),
onVisibilityChanged: (visibilityInfo) {
// 获取可见比例
var val = (visibilityInfo.visibleFraction * 100);
int visiblePercentage = ((val.isNaN || val.isInfinite) ? 0 : val).toInt();
},
child: Container(
width: double.infinity,
height: 200,
));
}