一、现存曝光的问题
用户数据埋点与分析一些对于跟踪用户的行为尤为重要,曝光是常见的埋点行为之一,如以下图所示的 A、B、C、D 块,通常的做法可能会在dom挂载的时候,例如 Vue 组件的 mount 方法。只是判断节点挂载与否作为曝光上报的时机,这样的做法缺乏一定的精准性,倘若产品会对曝光做更加精细的要求,例如
- 坑位需要滑出一定比例的时候才出发曝光
- 坑位需要在屏幕可见区域停留一定的时间才触发曝光事件
- 坑位滑出视野,再次滑入视野时需要再次上报
- 坑位在视野中上下反复移动只触发一次曝光
如上图所示, 对于以上的 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]
三、原理解析
此过程可参考前两节(layout 和 paint)
Build: 构建 Widget 树,通过createElement 创建对应的 element, 创建对应的 renderObject Layout: 自上而下传递父节点的约束 constraints, 自下而上求取每个节点的大小,最终确定节点的位移 Paint: 构建 Layer, 当layer append 到父节点上时,会触发layer 的attach 方法
因此探测 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: 处理曝光上报的相关逻辑
设置定时器,每隔一段时间往dart 的事件列表中添加一个检测任务,检测任务
dart的事件调度阶段有: transientCallbacks主要处理动画计算,动画状态的更新 midFrameMicrotasks处理transientCallbacks阶段触发的Microtasks persistentCallbacks主要处理build/layout/paint postFrameCallbacks主要在下一帧之前,做一些清理工作或者准备工作 idle不产生Frame的空闲期,可以处理Tasks(由SchedulerBinding.scheduleTask触发),microtasks(由scheduleMicrotask触发),定时器的回调,响应事件处理(例如:用户的输入) 详情可参考: segmentfault.com/a/119000001…