Flutter 曝光自动上报

2,076 阅读4分钟

一、现存曝光的问题

用户数据埋点与分析一些对于跟踪用户的行为尤为重要,曝光是常见的埋点行为之一,如以下图所示的 A、B、C、D 块,通常的做法可能会在dom挂载的时候,例如 Vue 组件的 mount 方法。只是判断节点挂载与否作为曝光上报的时机,这样的做法缺乏一定的精准性,倘若产品会对曝光做更加精细的要求,例如

  • 坑位需要滑出一定比例的时候才出发曝光
  • 坑位需要在屏幕可见区域停留一定的时间才触发曝光事件
  • 坑位滑出视野,再次滑入视野时需要再次上报
  • 坑位在视野中上下反复移动只触发一次曝光

avatar

如上图所示, 对于以上的 A, B,C, D

  • 坑位A已经滑出了屏幕可见区域,处于不可见的状态,这样不能称之为曝光;
  • 坑位B即将向上从屏幕中可见区域滑出,从可见状态不可见
  • 坑位C在屏幕中央可视区域内,是可见的状态,可触发曝光;
  • 坑位D即将滑入屏幕中可见区域,从不可见到可见,当可见比例达到一定的阈值时,可触发曝光行为;

因此,严格来讲,页面上的A,和 D 不算曝光,但是当 D 重新滑入可见区域一定的比例,并满足停留时间时,它又可以触发上报,这样的情况在列表中尤为常见,在Flutter中如何实现更加精准的上报

二、Flutter 上报方案

在 Flutter 中,可以通过探测模块在页面的显示比例,如下图所示, 如下图所示,列表加载时,列表活跃的 item

[0-0] [0-1] [0-2]

[1-0] [1-1] [1-2]

[2-0] [2-1] [2-2]

[3-1] [3-2] [3-3]

[4-0] [4-1] [4-2]

[5-0] [5-1] [5-2]

avatar

三、原理解析

此过程可参考前两节(layout 和 paint) avatar

Build: 构建 Widget 树,通过createElement 创建对应的 element, 创建对应的 renderObject Layout: 自上而下传递父节点的约束 constraints, 自下而上求取每个节点的大小,最终确定节点的位移 Paint: 构建 Layer, 当layer append 到父节点上时,会触发layer 的attach 方法

avatar

因此探测 Layer的可见比例主要是通过自定义布局实现

自定义 widget:在 flutter 中,并不是所有的widget都有对应的 renderObejct, 可以通过继承

SingleChildRenderObjectWidget 类来创建自定义的widget
    class VisibilityDetector extends SingleChildRenderObjectWidget {
  const VisibilityDetector({
    required Key key,
    required Widget child,
    required this.onVisibilityChanged,
  })
  final VisibilityChangedCallback? onVisibilityChanged;
  RenderVisibilityDetector createRenderObject(BuildContext context) {
    return RenderVisibilityDetector(
      key: key!,
      onVisibilityChanged: onVisibilityChanged,
    );
  }
  void updateRenderObject(
      BuildContext context, RenderVisibilityDetector renderObject) {
    renderObject.onVisibilityChanged = onVisibilityChanged;
  }
}

在 flutter 中,最终的绘制行为是通过 renderObject完成,在本例的createRenderObject中返回自定义的 renderObject

自定义paint 方法

class RenderVisibilityDetector extends RenderProxyBox {
  RenderVisibilityDetector({
    RenderBox? child,
    required this.key,
    required VisibilityChangedCallback? onVisibilityChanged,
  })   : _onVisibilityChanged = onVisibilityChanged,
        super(child);
  final Key key;
  VisibilityChangedCallback? _onVisibilityChanged;
  
  set onVisibilityChanged(VisibilityChangedCallback? value) {
    _onVisibilityChanged = value;
    markNeedsCompositingBitsUpdate();
    markNeedsPaint();
  }

  void paint(PaintingContext context, Offset offset) {
    final layer = VisibilityDetectorLayer(
        key: key,
        widgetSize: semanticBounds.size,
        paintOffset: offset,
        onVisibilityChanged: onVisibilityChanged!);
    context.pushLayer(layer, super.paint, offset);
  }
}

当外界设置onVisibilityChanged 属性时,则标记这个renderObject 为脏,即需要重新绘制的, 在调用 paint 方法时,返回自定义的 layer

自定义 layer 在 layer 方法中实现相关 layer 可视范围的探测,并通报外层

class VisibilityDetectorLayer extends ContainerLayer {
  VisibilityDetectorLayer(
      {required this.key,
      required this.widgetSize,
      required this.paintOffset,
        bool canRepeatReport = true,
      required this.onVisibilityChanged})
        _layerOffset = Offset.zero;
  static Timer? _timer;
  static final _activeLayers = <Key, VisibilityDetectorLayer>{};
  static final _updatedKeys = <Key>{};

  static final _lastVisibility = <Key, VisibilityInfo>{};
  bool filter = false; // 是否需要过滤
  bool canRepeatReport = true; // 该layer是否允许重复上报
  final Key key;
  final Size widgetSize;
  Offset _layerOffset;
  final Offset paintOffset;
  final VisibilityChangedCallback onVisibilityChanged;
  void _scheduleUpdate() {
    _updatedKeys.add(key);
    _scheduleCallbacks();
  }

  static void _scheduleCallbacks() {
    final updateInterval = VisibilityDetectorController.instance.updateInterval;
    if (_timer == null) {
      _timer = Timer(updateInterval, _handleTimer);
    } else {
      assert(_timer!.isActive);
    }
  }

  static void _handleTimer() {
    _timer = null;
     VisibilityDetectorController.exposureTimeMap.forEach((key, exposureLayer) {
    if (!_updatedKeys.contains(key)) {
      _updatedKeys.add(key);
    }
  });
    SchedulerBinding.instance!
        .scheduleTask<void>(_processCallbacks, Priority.touch);
  }

  static void _processCallbacks() {
    for (final key in _updatedKeys) {
      final layer = _activeLayers[key];
      if (layer != null) {
        layer._fireCallback(force: false);
      }
    }
    _updatedKeys.clear();
  }

  void _fireCallback({required bool force}) {
    late VisibilityInfo info;
    if (!attached) {
      _activeLayers.remove(key);
      info = VisibilityInfo(
        key: key,
        size: _lastVisibility[key]?.size,
      );
    } else {
      final widgetBounds = _computeWidgetBounds();
      info = VisibilityInfo.fromRects(
        key: key,
        widgetBounds: widgetBounds,
        clipRect: _computeClipRect(),
      );
    }
    final oldInfo = _lastVisibility[key];
    final visible = !info.visibleBounds.isEmpty && info.visibleFraction > VisibilityDetectorController.exposureFraction;
    if (oldInfo == null) {
      if (!visible) {
        // oldInfo 为空,则说明上次为不可见,而当本次依旧是不可见时,不处理
        return;
      }
    }
    // 是否符合上报的条件
    bool isFirstReport = !filter; // 首次上报
    // 符合上报的条件
    bool canReport = (isFirstReport || (filter && canRepeatReport && oldInfo == null)) && visible;
    int nowTime = new DateTime.now().millisecondsSinceEpoch;
    ExposureTimeLayer? prevLayer = VisibilityDetectorController.exposureTimeMap[key];

    if (visible) {
      _lastVisibility[key] = info;
      if (canReport) {
        if (prevLayer != null && prevLayer.time > 0) {
          if ((nowTime - prevLayer.time) > VisibilityDetectorController.exposureTime) {
            filter = true;
            onVisibilityChanged(info);
          } else {
            VisibilityDetectorController.exposureTimeMap[key]?.layer = this;
            _scheduleCallbacks();
          }
        } else {
          VisibilityDetectorController.exposureTimeMap[key] = ExposureTimeLayer(nowTime, this);
          _scheduleCallbacks();
        }
      }
    } else {
        _lastVisibility.remove(key);
        _scheduleCallbacks();
    }
  }

  void addToScene(ui.SceneBuilder builder, [Offset layerOffset = Offset.zero]) {
    _layerOffset = layerOffset;
    _scheduleUpdate();
    super.addToScene(builder, layerOffset);
  }

  void attach(Object owner) {
    _activeLayers[key] = this;
    super.attach(owner);
    _scheduleUpdate();
  }

  void detach() {
    super.detach();
    _scheduleUpdate();
  }
}

_activeLayers: 记录当前屏幕中可见的的 Layer _updatedKeys: 记录当前更新的 Layer 的 key, 没当 layer attach 或者 detach 的时候,则将对应的key添加到该集合中 _lastVisibility: 记录屏幕可见layer key 的集合,当layer 变为 invisible时,需要从该集合中移除对应的key VisibilityInfo: layer 的的位置信息,包括它的大小,在屏幕中的可视区域 _fireCallback: 处理曝光上报的相关逻辑

avatar

设置定时器,每隔一段时间往dart 的事件列表中添加一个检测任务,检测任务

dart的事件调度阶段有: transientCallbacks主要处理动画计算,动画状态的更新 midFrameMicrotasks处理transientCallbacks阶段触发的Microtasks persistentCallbacks主要处理build/layout/paint postFrameCallbacks主要在下一帧之前,做一些清理工作或者准备工作 idle不产生Frame的空闲期,可以处理Tasks(由SchedulerBinding.scheduleTask触发),microtasks(由scheduleMicrotask触发),定时器的回调,响应事件处理(例如:用户的输入) 详情可参考: segmentfault.com/a/119000001…