Flutter列表曝光

497 阅读4分钟

前言

通常商品列表都展示了非常多的物件,产品为了统计用户的列表曝光和点阅效果,我们则需要定义曝光规则,计算物件曝光,获取曝光数据之后进行上传...

技术实现

方案一

依赖插件

如果项目没有用到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,
      ));
}