void didChangeAppLifecycleState(AppLifecycleState state) { }
}
复制代码
其中AppLifecycleState
是个枚举类,包含四种状态:
enum AppLifecycleState {
resumed,
inactive,
paused,
detached,
}
复制代码
该接口通过以上四种状态,我们可以知道在某个页面停留的时长是多久。
以上是采集页面pv、uv、页面路径的基本思路,具体的代码不多做介绍,逻辑参考原生的实现即可。后面我着重介绍用户行为操作,点击行为埋点数据的采集实现。
对于组件的ID来说,它的规则要比页面的定义更加复杂。首先,Flutter的组件本身并没有一个id的概念,虽然Flutter的每个Widget都可以通过一个唯一key
去标识,但是在创建Widget的时候除非有特殊的需求(比如复用等),我们一般不会去传入一个key,所以需要换个思路:根据视图树。
[图片上传中…(image-c1c490-1605191361082-11)]
每个页面的组件都是根据其父子、兄弟关系构建出视图树绘制在页面上。从我们观测的组件的本身开始,在这个视图树上逐级向上遍历搜索,直到根节点,找到这个组件在这个树上的位置信息等特征信息,这样就能得到一个组件在视图树上的 一个组件路径,也就是说,我们可以根据这个路径,在视图树中定位到这个组件(图片引用自极客时间-Flutter专栏):
[图片上传中…(image-dfdb0a-1605191361082-10)]
三棵树 Flutter中,存在这么三棵树(为了便于理解我们抽象RenderObject
也为一个树),当我们点击了某个Widget的时候,我们期望的结果是可以通过这个Widget获取它在视图树上的位置,可惜的是Flutter中的Widget并没有一个类似"parent"和"child"属性可以供我们去获取,也没有提供接口让我们去获取,其实这也比较好理解,因为Widget本身就只是一个配置信息,这点在Widget源码中注释也有体现:“Describes the configuration for an [Element].”
再从Element
树入手,通过对Element
源码的阅读,Element
实现了BuildContext
,而BuildContext
它定义了一系列的接口去获取父子element
与指定的RenderObject
、指定类型的Widget
、指定的State
等等:
abstract class BuildContext {
…
///搜索Element父节点
void visitAncestorElements(bool visitor(Element element));
///搜索Element子节点
void visitChildElements(bool visitor(Element element));
T findAncestorWidgetOfExactType();
T findAncestorStateOfType();
T findAncestorRenderObjectOfType();
…还有其他的省略…
}
复制代码
Element
实现了具体的搜索方法:
void visitAncestorElements(bool visitor(Element element)) {
assert(_debugCheckStateIsActiveForAncestorLookup());
Element ancestor = _parent;
while (ancestor != null && visitor(ancestor))
ancestor = ancestor._parent;
}
复制代码
而根据Element,是可以通过element.widget
获取与之对应的Widget的,根据Widget也就得到了具体的路径。
而如果选择从RenderObejct
入手,它内部定义了获取父亲节点与子节点的方法:
abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
///获取树上的父节点
AbstractNode? get parent => _parent;
…
//遍历搜索子节点
void visitChildren(RenderObjectVisitor visitor) { }
…
}
复制代码
RenderObject
在源码中看似没有定义接口去直接获取对应的Element
的,更加无法直接去获取对应的Widget
,但是注意到它有一个debugCreator
属性:
/// The object responsible for creating this render object.
/// Used in debug messages.
Object? debugCreator;///表示这个render obejct表示负责创建此render object的对象,也就这个render object被谁持有
复制代码
虽然是个Object类型的,但是源码中对应的就是DebugCreator
类:
/// A wrapper class for the [Element] that is the creator of a [RenderObject].
///
/// Attaching a [DebugCreator] attach the [RenderObject] will lead to better error
/// message.
class DebugCreator {
/// Create a [DebugCreator] instance with input [Element].
DebugCreator(this.element);
/// The creator of the [RenderObject].
final Element element;
@override
String toString() => element.debugGetCreatorChain(12);
}
复制代码
在Element
的子类RenderObjectElement
的mount
和update
方法中对这个属性进行了创建:
@override
void mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
//省略部分代码…
_renderObject = widget.createRenderObject(this);
//省略部分代码…
assert(() {
//复制debugCreator属性方法(assert部分会在Release的时候删除)
_debugUpdateRenderObjectOwner();
return true;
}());
//省略部分代码…
}
@override
void update(covariant RenderObjectWidget newWidget) {
super.update(newWidget);
…
assert(() {
//复制debugCreator属性方法(assert部分会在Release的时候删除)
_debugUpdateRenderObjectOwner();
return true;
}());
…
}
void _debugUpdateRenderObjectOwner() {
assert(() {
//将当前Element传入到DebugCreator中保存。RenderObjectElement继承Element
_renderObject.debugCreator = DebugCreator(this);
return true;
}());
}
复制代码
可以看到通过这种方式,如果是可以通过在RenderObject
中的debugCreator
属性被赋值,那么是可以通过这个属性获取到对应的Element
的,也就可以获取到Widget
。但是通过代码也看到这个属性赋值定义在assert
中,Release下不会走这部分,所以这一块要做修改。
所以,如果能在点击的时候能直接或间接获取到Element
,根据上面路径的规则生成,对于上图中的GestureDetector
,它的路径为:
Contain[0]/Column[0]/Contain[1]/GestureDetector[0]
;
同时,为了防止不同页面中可能存在的路径相同情况,给这个路径加上当前页面的标识,所以path最后的规则为:
[ 页面ID:组件路径 ]。
为了更好的理解Flutter中的手势事件,下面简要的做一个分析:
Flutter中指针事件表示用户交互的原始触摸数据,例如PointerDownEvent
、PointerUpEvent
、PointerCancelEvent
等等,当手指触摸屏幕的时候,发生触摸事件,Flutter会确定触发的位置上有哪些组件,并将触摸事件交给最内层的组件去响应,事件会从最内层的组件开始,沿着组件树向根节点向上一级级冒泡分发。
通过对一个简单的GestureDetector
组件的点击回调的debug观测,得到如下图的一个调用结构:
[图片上传中…(image-c56bb-1605191361080-9)]
上图中,_rootRunUnary
以下为引擎自己实现的调用,会将收集到的事件传递到GestureBinding._handlePointerDataPacket
中:
mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
@override
void initInstances() {
super.initInstances();
_instance = this;
///binding初始化的时候设置了回调方法,接受引擎传来的事件数据
window.onPointerDataPacket = _handlePointerDataPacket;///onPointerDataPacket就是一个function
}
…
}
复制代码
GestureBinding._flushPointerEventQueue
方法就是对队列中的事件依次取出并进行处理:
final Queue _pendingPointerEvents = Queue();
void _flushPointerEventQueue() {
assert(!locked);
if (resamplingEnabled) {
_resampler.addOrDispatchAll(_pendingPointerEvents);
_resampler.sample(samplingOffset);
return;
}
// Stop resampler if resampling is not enabled. This is a no-op if
// resampling was never enabled.
_resampler.stop();
while (_pendingPointerEvents.isNotEmpty)
_handlePointerEvent(_pendingPointerEvents.removeFirst());
}
复制代码
所以,真正开始处理PointerEvent
应该是从GestureBinding
的_handlePointerEvent
方法开始:
void _handlePointerEvent(PointerEvent event) {
assert(!locked);
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent) {
assert(!_hitTests.containsKey(event.pointer));
hitTestResult = HitTestResult();///1.创建一个HitTestResult对象
hitTest(hitTestResult, event.position);///2.命中测试,实际先调用到RendererBinding的hitTest方法
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;///如果是PointerDownEvent,创建事件标识id与hitTestResult的映射
}
…
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
hitTestResult = _hitTests.remove(event.pointer);///事件序列结束后移除
} else if (event.down) {
///其他是事件重用Down事件避免每次都要去命中测试(比如:PointerMoveEvents)
hitTestResult = _hitTests[event.pointer];
}
…
if (hitTestResult != null ||
event is PointerHoverEvent ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
dispatchEvent(event, hitTestResult);///分发事件
}
}
复制代码
对代码中的几点注释说明:
如果是PointerDownEvent
或者是PointerSignalEvent
,直接创建一个HitTestResult
对象,该对象内部有一个_path
字段(集合);
调用hitTest
方法进行命中测试,而该方法就是将自身作为参数创建HitTestEntry
,然后将HitTestEntry
对象添加到HitTestResult
的_path
中。HitTestEntry
中只有一个HitTestTarget
字段。实际也就是将这个创建的HitTestEntry
添加到HitTestResult
的_path
字段中,当做事件分发冒泡排序中的一个路径节点。
///先RendererBinding的hitTest方法,方法定义如下:
void hitTest(HitTestResult result, Offset position) {
assert(renderView != null);
assert(result != null);
assert(position != null);
renderView.hitTest(result, position: position);
super.hitTest(result, position);
}
复制代码
内部调用主要就是两步:
RenderView
的hitTest
方法(从根节点RenderView开始命中测试):bool hitTest(HitTestResult result, { required Offset position }) {
if (child != null)
///内部会先对child进行命中测试
child!.hitTest(BoxHitTestResult.wrap(result), position: position);
result.add(HitTestEntry(this));///将自己添加到_path字段,作为一个事件分发的路径节点
return true;
}
///child是RenderBox类型对象,hitTest
方法在RenderBox中实现:
bool hitTest(HitTestResult result, { @required Offset position }) {
///…去掉assert部分
///这里就是判断点击的区域置是否在size范围,是否在当前这个RenderObject节点上
if (_size.contains(position)) {
///在当前节点,如果child与自己的hitTest命中测试有一个是返回true,就加入到HitTestResult中
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
复制代码
hitTest
方法,也就是GestureBinding
的hitTest
方法:@override // from HitTestable
void hitTest(HitTestResult result, Offset position) {
result.add(HitTestEntry(this));
}
复制代码
经过一系列的hitTest
后,通过一下判断:
if (hitTestResult != null ||
event is PointerHoverEvent ||
event is PointerAddedEvent ||
event is PointerRemovedEvent) {
assert(event.position != null);
dispatchEvent(event, hitTestResult);
}
复制代码
调用到GestureBinding
的dispatchEvent
方法:
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
…
for (final HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event.transformed(entry.transform), entry);
} catch (exception, stack) {
…
));
}
}
}
复制代码
该方法就是遍历_path
中的每个HitTestEntry
,取出target
进行事件的分发,而HitTestTarget
除了几个Binding
,其具体都是由RenderObject
实现的,所以也就是对每个RenderObject
节点进行事件分发,也就是我们说的“事件冒泡”,冒泡的第一个节点是最小child节点(最内部的组件),最后一个是GestureBinding
。
值得注意的是,Flutter中并没有机制去取消或者去停止事件进一步的分发,我们只能在
hitTestBehavior
中去调整组件在命中测试期内应该如何表现,而且只有通过命中测试的组件才能触发事件。
所以,_handlePointerEvent
方法主要就是不断通过hitTest
方法计算出所需的HitTestResult
,然后再通过dispatchEvent
对事件进行分发。
以上是简单的对Flutter的事件分发进行一个分析,具体到我们组件层面的使用,Flutter内部还做了较多的处理,在Flutter中,具备手势点击事件的组件的实现,可直接使用的组件层面主要分为以下(也可以其它纬度分类):
GestureRecoginzer
的实现:GestureDetector
组件FloatButton
、InkWell
…等结构为:xx–xx->GestureDecector
->Listener
这种依托于GestureDecector
->Listener
的组件Switch
,内部也是基于GestureRecoginzer
实现的组件针对第二点,在遇到多个手势冲突的时候,为了确定最终响应的手势,还得经过一个"手势竞技场"的过程,也就是在上图中recognizer
手势识别器以上部分的调用结构,在"手势竞技场"中胜利的才能最终将事件响应组件层面。
以上为手势事件的一个大概的流程分析,了解了其原理与基本流程,能更好的帮助我们去完成自动埋点功能的实现。如果对Flutter手势事件原理还有不清楚的可以去查阅其它资料或者留言交流。
通过上面的描述,首先我们肯定是可以在响应的单击、双击、长按回调函数通过直接调用SDK埋点代码来获得我们的数据,那么如何才能实现这一步的自动化呢?
AOP:在指定的切点插入指定的代码,将所有的代码插桩逻辑几种在一个SDK内处理,可以最大程度的不侵入我们的业务。
目前阿里闲鱼开源的一款面向Flutter设计的AOP框架:Aspectd,具体的使用不多做介绍,看github地址即可。
通过上述手势事件的分析,选择以下两个切入点(当然也有其它的切入方式):
HitTestTarget
的handleEvent(PointerEvent event,HitTestEntry entry)
方法;GestureRecognizer
的invokeCallback(String name,RecognizerCallback callback,{String debugReport})
方法;其代码大致如下所示:
@Call(“package:flutter/src/gestures/hit_test.dart”, “HitTestTarget”,
“-handleEvent”)
@pragma(“vm:entry-point”)
dynamic hookHitTestTargetHandleEvent(PointCut pointCut) {
dynamic target = pointCut.target;
PointerEvent pointerEvent = pointCut.positionalParams[0];
HitTestEntry entry = pointCut.positionalParams[1];
curPointerCode = pointerEvent.pointer;
if (target is RenderObject) {
if (curPointerCode > prePointerCode) {
clearClickRenderMapData();
}
if (!clickRenderMap.containsKey(curPointerCode)) {
clickRenderMap[curPointerCode] = target;
}
}
prePointerCode = curPointerCode;
target.handleEvent(pointerEvent, entry);
}
@Call(“package:flutter/src/gestures/recognizer.dart”, “GestureRecognizer”,
“-invokeCallback”)
@pragma(“vm:entry-point”)
dynamic hookinvokeCallback(PointCut pointcut) {
var result = pointcut.proceed();
if (curPointerCode > preHitPointer) {
String argumentName = pointcut.positionalParams[0];
if (argumentName == ‘onTap’ ||
argumentName == ‘onTapDown’ ||
argumentName == ‘onDoubleTap’) {
RenderObject clickRender = clickRenderMap[curPointerCode];
if (clickRender != null) {
DebugCreator creator = clickRender.debugCreator;
Element element = creator.element;
//通过element获取路径
String elementPath = getElementPath(element);
///丰富采集时间
richJsonInfo(element, argumentName, elementPath);
}
preHitPointer = curPointerCode;
}
}
return result;
}
复制代码
大体的实现思路如下:
pointer
标识符与响应的RenderObject
的映射关系,只记录_path
中的第一个,也就是命中测试的最小child,且记录下当前事件序列的pointer
(pointer
在一个事件序列中是唯一的值,每发生一次手势事件,它会自增1);GestureRecognizer
的invokeCallback(String name,RecognizerCallback callback,{String debugReport})
方法中,通过上面记录的的pointer
,在Map中取出RenderObject
,取debugCreator
属性得到Element
,再得到对应的widget
;在上述第2步中,其实存在一个问题,就是RenderObject
的debugCreator
字段,这个字段表示负责创建此render object的对象,源码中创建过程写在aessert
中,所以其实只能在debug模式下获取到,它在源码中实际创建位置在RenderObjectElement
的mount
,在update
执行更新的时候同样也会更新:
@override
void mount(Element parent, dynamic newSlot) {
super.mount(parent, newSlot);
//省略部分代码…
_renderObject = widget.createRenderObject(this);
//省略部分代码…
assert(() {
//assert部分会在Release的时候删除
_debugUpdateRenderObjectOwner();
return true;
}());
//省略部分代码…
}
void _debugUpdateRenderObjectOwner() {
assert(() {
//将当前Element传入到DebugCreator中保存。RenderObjectElement继承Element
_renderObject.debugCreator = DebugCreator(this);
return true;
}());
}
复制代码
为了让我们在AOP的时候,在Release模式下也能获取到这个数据,所以我们要特殊处理。既然在源码中它只能在debug
下创建,我们就创造条件让它在Release下也创建。
@Execute(‘package:flutter/src/widgets/framework.dart’,‘Element’,’-mount’)
@pragma(‘vm:entry-point’)
static dynamic hookElementMount(PointCut pointCut){
dynamic obj = pointCut.proceed;
Element element = pointCut.target;
if(kReleaseMode||kProfileMode){
//release和profile模式创建这个属性
element.renderObject.debugCreator = DebugCreator(element);
}
}
@Execute(‘package:flutter/src/widgets/framework.dart’,‘Element’,’-update’)
@pragma(‘vm:entry-point’)
static dynamic hookElementUpdate(PointCut pointCut){
dynamic obj = pointCut.proceed;
Element element = pointCut.target;
if(kReleaseMode||kProfileMode){
//release和profile模式创建这个属性
element.renderObject.debugCreator = DebugCreator(element);
}
}
复制代码
对debugCreator
字段处理完成后,我们就可以根据RenderObject
获取对应的Element
,获取到Element
也就可以去计算组件的path id了。
通过以上操作,在实际中,我们对一个GestureDetector
进行点击测试后,得到如下结果:
GestureDetector[0]/Column[0]/Contain[0]/BodyBuilder[0]/MediaQuery[0]/LayoutId[0]/CustomMultiChildLayout[0]/AnimatedBuilder[0]/DefaultTextStyle[0]/AnimatedDefaultTextStyle[0]/_InkFeatures[0]/NotificationListener[0]/PhysicalModel[0]/AnimatedPhysicalModel[0]/Material[0]/PrimaryScrollController[0]/_ScaffoldScope[0]/Scaffold[0]/MyHomePage[0]…/MyApp[0]
复制代码
经过对比发现,这似乎确实是我们代码中创建的组件的路径没错,但是好像中间多了很多奇怪的组件路径,这似乎不是我们自己创建的,这里还是存在一些问题要优化。
组件的路径ID很长。因为Flutter布局嵌套包装的特点,如果一直向上搜索父亲节点,会一直搜索到MyApp
这里,中间还会包含很多系统内部创建的组件。
在不同的平台,为了保持某些平台的特性风格,可能会出现路径中某个节点不一致的情况(比如在IOS平台的路径可能会出现一个侧滑的节点,其他平台没有)。例如以"Cupertino"、"Mat
erial"开头的这种组件,要选择屏蔽掉差异。
根据上面定义的规则,在页面元素不发生变动的情况下,基本上是能保证"稳定性"与"唯一性",但是如果页面元素发生动态变化,或者在不同的版本之间UI进行了改版,此时我们定义的规则就会变的不够稳定,也可能不再唯一,比如下图所示:
[图片上传中…(image-85fa0d-1605191361072-8)]
在插入一个Widget后,我们的GestureDetector
的路径变成了Contain[0]/Column[0]/Contain[2]/GestureDetector[0]
,与之前相比发生了变化,这点优化比较简单:将同级兄弟节点的位置,变成相同类型的组件的位置。优化后的组件路径为:Contain[0]/Column[0]/Contain[1]/GestureDetector[0]
。这样在插入一个非同类型的Widget后,其路径依旧不变,但如果插入的是同类型的还是会发生改变,所以这个是属于相对的稳定。
那么剩下的问题如何优化呢?
问题1:我们实际获取到的路径并不是我们在代码中创建的组件路径,比如:
//我们自己代码创建一个Contain
@override
Widget build(BuildContext context){
return Contain(
child:Text(‘text’),
);
}
//实际上Contain的内部build函数,会做层层的包装,其他组件也是类似情况
@override
Widget build(BuildContext context) {
Widget current = child;
if (child == null && (constraints == null || !constraints.isTight)) {
current = LimitedBox(
maxWidth: 0.0,
maxHeight: 0.0,
child: ConstrainedBox(constraints: const BoxConstraints.expand()),
);
}
…省略部分代码
if (alignment != null)
current = Align(alignment: alignment, child: current);
…省略部分代码
return current;
}
复制代码
因为这个情况,会导致出现三个情况:
如何解决呢?注意到当我们使用Flutter自带的工具Flutter Inspector
观测我们创建的页面时,出现的是我们想要的组件展示情况:
[图片上传中…(image-54a276-1605191361071-7)]
[图片上传中…(image-4ea4a5-1605191361071-6)]
通过图中可以看到,widgets
的展示形式完整的表示了我们自己页面代码中创建widget的结构,那么这个是如何实现的呢?
实际上,这个是通过一个WidgetInspectorService
的服务来实现的,一个被GUI工具用来与WidgetInspector
交互的服务。在Foundation/Binding.dart
中通过initServiceExtensions
注册,而且只有在debug环境下才会注册这个拓展服务。
通过对官方开源的dev-tools
源码的分析,其应用层面的关键方法如下:
// Returns if an object is user created.
//返回该对象是否自己创建的(这里我们针对的是widget)
bool _isLocalCreationLocation(Object object) {
final _Location location = _getCreationLocation(object);
if (location == null)
return false;
return WidgetInspectorService.instance._isLocalCreationLocation(location);
}
/// Creation locations are only available for debug mode builds when
/// the --track-widget-creation
flag is passed to flutter_tool
. Dart 2.0 is
/// required as injecting creation locations requires a
/// Dart Kernel Transformer.
///
/// Currently creation locations are only available for [Widget] and [Element].
_Location _getCreationLocation(Object object) {
final Object candidate = object is Element ? object.widget : object;
return candidate is _HasCreationLocation ? candidate._location : null;
}
bool _isLocalCreationLocation(_Location location) {
if (location == null || location.file == null) {
return false;
}
final String file = Uri.parse(location.file).path;
// By default check whether the creation location was within package:flutter.
if (_pubRootDirectories == null) {
// TODO(chunhtai): Make it more robust once
// https://github.com/flutter/flutter/issues/32660 is fixed.
return !file.contains(‘packages/flutter/’);
}
for (final String directory in _pubRootDirectories) {
if (file.startsWith(directory)) {
return true;
}
}
return false;
_location : null;
}
bool _isLocalCreationLocation(_Location location) {
if (location == null || location.file == null) {
return false;
}
final String file = Uri.parse(location.file).path;
// By default check whether the creation location was within package:flutter.
if (_pubRootDirectories == null) {
// TODO(chunhtai): Make it more robust once
// https://github.com/flutter/flutter/issues/32660 is fixed.
return !file.contains(‘packages/flutter/’);
}
for (final String directory in _pubRootDirectories) {
if (file.startsWith(directory)) {
return true;
}
}
return false;