Flutter 之全埋点思考与实现,精心整理
result.add(BoxHitTestEntry(this, position));return true;}}return false;
调用父类的
hitTest
方法,也就是GestureBinding
的hitTest
方法:
@override // from HitTestablevoid 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 中,具备手势点击事件的组件的实现,可直接使用的组件层面主要分为以下(也可以其它纬度分类):
直接使用 Listener 组件监听事件
其他基于对手势识别器
GestureRecoginzer
的实现:
使用
GestureDetector
组件使用
FloatButton
、InkWell
...等结构为:xx--xx->GestureDecector
->Listener
这种依托于GestureDecector
->Listener
的组件类似
Switch
,内部也是基于GestureRecoginzer
实现的组件
针对第二点,在遇到多个手势冲突的时候,为了确定最终响应的手势,还得经过一个"手势竞技场"的过程,也就是在上图中recognizer
手势识别器以上部分的调用结构,在"手势竞技场"中胜利的才能最终将事件响应组件层面。
以上为手势事件的一个大概的流程分析,了解了其原理与基本流程,能更好的帮助我们去完成自动埋点功能的实现。如果对 Flutter 手势事件原理还有不清楚的可以去查阅其它资料或者留言交流。
5.AOP
通过上面的描述,首先我们肯定是可以在响应的单击、双击、长按回调函数通过直接调用 SDK 埋点代码来获得我们的数据,那么如何才能实现这一步的自动化呢?
AOP:在指定的切点插入指定的代码,将所有的代码插桩逻辑几种在一个 SDK 内处理,可以最大程度的不侵入我们的业务。
目前阿里闲鱼开源的一款面向 Flutter 设计的 AOP 框架:Aspectd,具体的使用不多做介绍,看 github 地址即可。
通过上述手势事件的分析,选择以下两个切入点(当然也有其它的切入方式):
HitTestTarget
的handleEvent(PointerEvent event,HitTestEntry entry)
方法;GestureRecognizer
的invokeCallback<T>(String name,RecognizerCallback<T> 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;}
大体的实现思路如下:
通过 Map 记录事件唯一的
pointer
标识符与响应的RenderObject
的映射关系,只记录_path
中的第一个,也就是命中测试的最小 child,且记录下当前事件序列的pointer
(pointer
在一个事件序列中是唯一的值,每发生一次手势事件,它会自增 1);在
GestureRecognizer
的invokeCallback<T>(String name,RecognizerCallback<T> callback,{String debugReport})
方法中,通过上面记录的的pointer
,在 Map 中取出RenderObject
,取debugCreator
属性得到Element
,再得到对应的widget
;
在上述第 2 步中,其实存在一个问题,就是RenderObject
的debugCreator
字段,这个字段表示负责创建此 render object 的对象,源码中创建过程写在aessert
中,所以其实只能在 debug 模式下获取到,它在源码中实际创建位置在RenderObjectElement
的mount
,在update
执行更新的时候同样也会更新:
@overridevoid 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", "RenderObjectElement", "-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','RenderObjectElement','-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<LayoutChangedNotification>[0]/PhysicalModel[0]/AnimatedPhysicalModel[0]/Material[0]/PrimaryScrollController[0]/_ScaffoldScope[0]/Scaffold[0]/MyHomePage[0].../MyApp[0]
经过对比发现,这似乎确实是我们代码中创建的组件的路径没错,但是好像中间多了很多奇怪的组件路径,这似乎不是我们自己创建的,这里还是存在一些问题要优化。
6.关于组件 ID 的优化
组件路径 ID 过长:
组件的路径 ID 很长。因为 Flutter 布局嵌套包装的特点,如果一直向上搜索父亲节点,会一直搜索到MyApp
这里,中间还会包含很多系统内部创建的组件。
不同平台特性:(==去掉这点,无需优化,因为平台特性只会出现在系统内部节点,自己编写的除非有特别的判断,否则不会出现差异性==)
在不同的平台,为了保持某些平台的特性风格,可能会出现路径中某个节点不一致的情况(比如在 IOS 平台的路径可能会出现一个侧滑的节点,其他平台没有)。例如以"Cupertino"、"Material"开头的这种组件,要选择屏蔽掉差异。
动态插入 Widget 不稳定
根据上面定义的规则,在页面元素不发生变动的情况下,基本上是能保证"稳定性"与"唯一性",但是如果页面元素发生动态变化,或者在不同的版本之间 UI 进行了改版,此时我们定义的规则就会变的不够稳定,也可能不再唯一,比如下图所示:
在插入一个 Widget 后,我们的GestureDetector
的路径变成了Contain[0]/Column[0]/Contain[2]/GestureDetector[0]
,与之前相比发生了变化,这点优化比较简单:将同级兄弟节点的位置,变成相同类型的组件的位置。优化后的组件路径为:Contain[0]/Column[0]/Contain[1]/GestureDetector[0]
。这样在插入一个非同类型的 Widget 后,其路径依旧不变,但如果插入的是同类型的还是会发生改变,所以这个是属于相对的稳定。
那么剩下的问题如何优化呢?
7.Dart 元编程解决遗留问题
问题 1:我们实际获取到的路径并不是我们在代码中创建的组件路径,比如:
//我们自己代码创建一个 Contain@overrideWidget build(BuildContext context){return Contain(child:Text('text'),);}//实际上 Contain 的内部 build 函数,会做层层的包装,其他组件也是类似情况 @overrideWidget 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 独特的嵌套方式,每个组件在搜索父节点时最终会搜索到 main 中,实际其实我们只需要以当前页面为划分即可。
如何解决呢?注意到当我们使用 Flutter 自带的工具Flutter Inspector
观测我们创建的页面时,出现的是我们想要的组件展示情况:
通过图中可以看到,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
与_HasCreationLocation
,是在编译期通过Dart Kernel Transformer
实现的,与 Android 中的ASM
实现 Transform 类似,Dart 在编译期间也是有一个个的 Transform 来实现一些特定的操作的,这部分可以在 Dart 的源码中找到。
而widget_inspctor
的这个功能,就是在 debug 模式的编译期间,通过一个特定的 Transform,让所有的Widget
实现了抽象类_HasCreationLocation
,同时改造了Widget
的构造器函数,添加一个命名参数(_Location
类型),通过 AST,给_Location
属性赋值,实现 transform 的转换。
但是,这个功能是只能在 debug 模式下开启的,我们要达到这个效果,只能自己实现一个 Transform,支持在非 debug 模式下也能使用。而且,我们可以直接利用aspectd
的已有功能,稍微改造一下,添加一个自己的 Transform,而且不需要添加 widget 创建的行列等复杂的信息,只需要能够区分 widget 是开发者自己项目创建的即可,也就是只需要一个标识即可。
同样的在实现的过程中也有几点要注意:
对于创建 widget 的时候,如果加了
const
修饰,比如下面示例,是需要单独作为一个 Transform 来处理的。
Text widget = const Text('文字');Contain(child:const Text('文字'),);
在 debug 下可以用
TreeNode
的Location
字段做区分,但是在 release 下这个字段是 null,不能按照这个区分出自己项目创建的 widget。如果使用 Aspectd 的话,自己添加的改造 T
ransform 要添加在 Aspectd 内部实现的几个 Transform 之前。因为 Aspectd 提供的比如 call api,在用在构造函数的时候,会将方法调用处替换掉,我们如果在这个后面注入会无效。所以转换的顺序应该是修改普通构造在最前面,其次是处理常量声明表达式,最后是 Aspectd 自己的转换。
参考源码的track_widget_constructor_locations.dart
的实现,Transform 实现的关键代码如下:
自己定义的一个类,让 widget 实现这个类,注意该类定义的时候需要我们在
main
方法中直接或者间接的使用到,对应的_resolveFlutterClasse
方法也要修改。
void _resloveFlutterClasses(Iterable<Library> libraries){for(Library library in libraries){final Uri importUri = library.importUri;if(importUri != null && importUri.scheme == 'package'){//自己定义类的完整路径,比如是:example/local_widget_track_class.dartif(importUri.path = 'example/local_widget_track_class.dart'){for(Class cls in library.classes){//定义的类名,比如是:LocalWidgetLocationif(cls.name = 'LocalWidgetLocation'){_localWidgetLocation = cls;}}}else if(importUri.path == 'flutter/src/widgets/framework.dart'|| ....){...}}}}
继承
Transformer
主要需要实现visitStaticInvocation
、visitConstructorInvocation
方法:
@overrideStaticInvocation visitStaticInvocation(StaticInvocation node) {node.transformChildren(this);final Procedure target = node.target;if (!target.isFactory) {return node;}final Class constructedClass = target.enclosingClass;if (!_isSubclassOfWidget(constructedClass)) {return node;}
_addLocationArgument(node, target.function, constructedClass);return node;}
@overrideConstructorInvocation visitConstructorInvocation(ConstructorInvocation node) {node.transformChildren(this);final Constructor constructor = node.target;final Class constructedClass = constructor.enclosingClass;if(_isSubclassOfWidget(constructedClass)){_addLocationArgument(node, constructor.function, constructedClass);return node;}
void _addLocationArgument(InvocationExpression node, FunctionNode function,Class constructedClass) {_maybeAddCreationLocationArgument(node.arguments,function,ConstantExpression(BoolConstant(true)),);}
void _maybeAddCreationLocationArgument(Arguments arguments,FunctionNode function,Expression creationLocation,) {if (_hasNamedArgument(arguments, _creationLocationParameterName)) {return;}if (!_hasNamedParameter(function, _creationLocationParameterName)) {if (function.requiredParameterCount !=function.positionalParameters.length) {return;}}final NamedExpression namedArgument = NamedExpression(_creationLocationParameterName, creationLocation);namedArgument.parent = arguments;arguments.named.add(namedArgument);}
对于加了
const
修饰的 Widget,单独作为一个 Transform 来处理注入属性,该 Transform 需要重写visitConstantExpression
方法,通过给InstanceConstant
的filedValue
字段添加一个值达到我们需要的效果。
Text widget = const Text('文字');Contain(child:const Text('文字'),);
//Transform 示例代码如下:@overrideTreeNode visitConstantExpression(ConstantExpression node) {node.transformChildren(this);if (node.constant is InstanceConstant) {InstanceConstant instanceConstant = node.constant;Class clsNode = instanceConstant.classReference.node;if (clsNode is Class && _isSubclassOf(clsNode, _widgetClass)) {final Name fieldName = Name(_locationFieldName,_localCreatedClass.enclosingLibrary,);Reference useReference = _localFieldReference;final Field locationField =Field(fieldName, isFinal: true, reference: useReference,isConst: true);useReference.node = locationField;Constant constant = BoolConstant(true);instanceConstant.fieldValues.putIfAbsent(useReference, () => constant);}}
return super.visitConstantExpression(node);}
以上代码的实现思路其实并不难,可以对 Dart 源码中的类似实现多参考参考。通过上述的 Transform 转换,我们可以完美的解决『多余组件路径』的问题,现在我们得到的路径是实打实的我们自己代码创建的 widget 路径:
GestureDetector[0]/ Column[0]/Center[0]/Scaffold[0]/MyHomePage[0]/MaterialApp[0]/MyApp[0]
同时,因为直接使用Listener
组件的时候,调用不会经过GestureRecognizer
的invokeCallback
方法的,所以要过滤掉这个情况单独处理。是直接自己代码创建Listener
则以该Listener
为节点计算 path id,否则交由后续的invokeCallback
处理计算 path。修改后的代码如下:
@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) {bool localListenerWidget = false;if (target is RenderPointerListener) {///处理单独使用 ListenerRenderPointerListener pointerListener = target;if (pointerListener.onPointerDown != null &&pointerEvent is PointerDownEvent) {DebugCreator debugCreator = pointerListener.debugCreator;dynamic widget;debugCreator.element.visitAncestorElements((element) {if (element.widget is Listener) {widget = element.widget;if (widget.isLocal != null && widget.isLocal) {localListenerWidget = true;String elementPath = getElementPath(element);//丰富当前事件的信息 richJsonInfo(element, element, 'onTap', elementPath);}//else if(...) //可以过滤侧滑返回可能影响到的情况。因为它本身设置的 HitTestBehavior.translucent,点击到侧滑栏区域它会成为我们认为的最小 widget}return false;});}}if (!localListenerWidget) {if (curPointerCode > prePointerCode) {clearClickRenderMapData();}if (!clickRenderMap.containsKey(curPointerCode)) {clickRenderMap[curPointerCode] = target;}}}prePointerCode = curPointerCode;target.handleEvent(pointerEvent, entry);}
对于路径,还需要继续优化:对于点击的组件,我们得确定当前显示的页面是哪个页面或者路由,以此拆分出页面。对此,我们监听ModalRoute
的buildPage
方法,该方法是个抽象方法,不同类型的路由不同的具体实现,我们对每个页面做拆分,拆分为以当前页面节点为搜索的终止节点,得出实际的 path id 路径,代码大致如下所示:
class CurPageInfo {Type curScreenPage;Type curDialogPage;ModalRoute curRoute;BuildContext curPageContext;CurPageInfo(this.curScreenPage, this.curPageContext);}
@Call('package:flutter/src/widgets/routes.dart', 'ModalRoute', '-buildPage')@pragma('vm:entry-point')dynamic hookRouteBuildPage(PointCut pointcut) {ModalRoute target = pointcut.target;List<dynamic> positionalParams = pointcut.positionalParams;WidgetsBinding.instance.addPostFrameCallback((callback) {BuildContext buildContext = positionalParams[0];bool isLocal = false;while (buildContext != null && !isLocal) {buildContext.visitChildElements((ele) {dynamic widget = ele.widget;if (widget.isLocal != null && widget.isLocal) {isLocal = widget.isLocal;print('当前页面的 Page = isLocal');if(target.opaque){ ///opaque 是不透明的意思。true 就是表示不透明 curPageInfo = CurPageInfo(widget.runtimeType,positionalParams[0]);}else{curPageInfo.curPageContext = positionalParams[0];///第一个参数还是上个 Page 页面 curPageInfo.curDialogPage = widget.runtimeType;}return;}buildContext = ele;});}curPageInfo.curRoute = target;});return target.buildPage(positionalParams[0], positionalParams[1], positionalParams[2]);}
值得注意的是,Flutter 中的弹窗 Dialog 的显示也是一个route
,一般来说这个不能当做页面的,所以在计算当前 Page 的时候要特殊处理。
优化过后,现在得到的路径就是:
GestureDetector[0]/ Column[0]/Center[0]/Scaffold[0]/MyHomePage[0]
可以看到现在确实可以以 Page 页面对路径做拆分。
通过以上的方式,将数据采集完成后,剩下只需要将原始数据转化为我们需要的数据格式即可(比如转化封装成标准 Json),在采集的时候我们可以添加更多的属性字段(比如手机版本型号、App 的版本、时间戳...)来丰富这个采集的事件,然后以队列的形式存储到我们的数据库中,上报服务器后可以删除数据库的已上报数据。
8.实现过程中的其他问题点
在落地到实际的项目中去实现的时候,当然也会遇到一些其他的问题,比如:
类似
Cupertino
风格的侧滑返回导致点击时统计的这种错误问题;如何丰富当前点击组件的信息,如果获取当前组件中需要的例如文字、图片等信息;
对特殊组件的兼容处理;
Aspectd
框架本身的一些问题;实现可视化埋点功能工具;
...
一些落地中的问题多花点时间都是可以解决的,可能大家发现后会有更好的解决方案,这里因为篇幅问题不多做介绍。而关于Ascpetd
的一些实现与使用上的问题,我给该框架提了 PR,部分已经被合并了,有兴趣的可以去看看,如果了解到该框架的实现原理,其实会发现可以实现的功能,远不止框架本身这么简单,比如上面对组件的 transform 也是有相同的思想。
评论