写点什么

Flutter 之全埋点思考与实现,精心整理

用户头像
Android架构
关注
发布于: 刚刚

result.add(BoxHitTestEntry(this, position));return true;}}return false;


  • 调用父类的hitTest方法,也就是GestureBindinghitTest方法:


@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);}


调用到GestureBindingdispatchEvent方法:


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 中,具备手势点击事件的组件的实现,可直接使用的组件层面主要分为以下(也可以其它纬度分类):


  1. 直接使用 Listener 组件监听事件

  2. 其他基于对手势识别器GestureRecoginzer的实现:


  • 使用GestureDetector组件

  • 使用FloatButtonInkWell...等结构为:xx--xx->GestureDecector->Listener这种依托于GestureDecector->Listener的组件

  • 类似Switch,内部也是基于GestureRecoginzer实现的组件


针对第二点,在遇到多个手势冲突的时候,为了确定最终响应的手势,还得经过一个"手势竞技场"的过程,也就是在上图中recognizer手势识别器以上部分的调用结构,在"手势竞技场"中胜利的才能最终将事件响应组件层面。


以上为手势事件的一个大概的流程分析,了解了其原理与基本流程,能更好的帮助我们去完成自动埋点功能的实现。如果对 Flutter 手势事件原理还有不清楚的可以去查阅其它资料或者留言交流。

5.AOP

通过上面的描述,首先我们肯定是可以在响应的单击、双击、长按回调函数通过直接调用 SDK 埋点代码来获得我们的数据,那么如何才能实现这一步的自动化呢?


AOP:在指定的切点插入指定的代码,将所有的代码插桩逻辑几种在一个 SDK 内处理,可以最大程度的不侵入我们的业务。


目前阿里闲鱼开源的一款面向 Flutter 设计的 AOP 框架:Aspectd,具体的使用不多做介绍,看 github 地址即可。


通过上述手势事件的分析,选择以下两个切入点(当然也有其它的切入方式):


  • HitTestTargethandleEvent(PointerEvent event,HitTestEntry entry)方法;

  • GestureRecognizerinvokeCallback<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;}


大体的实现思路如下:


  1. 通过 Map 记录事件唯一的pointer标识符与响应的RenderObject的映射关系,只记录_path中的第一个,也就是命中测试的最小 child,且记录下当前事件序列的pointer(pointer在一个事件序列中是唯一的值,每发生一次手势事件,它会自增 1);

  2. GestureRecognizerinvokeCallback<T>(String name,RecognizerCallback<T> callback,{String debugReport})方法中,通过上面记录的的pointer,在 Map 中取出RenderObject,取debugCreator属性得到Element,再得到对应的widget;


在上述第 2 步中,其实存在一个问题,就是RenderObjectdebugCreator字段,这个字段表示负责创建此 render object 的对象,源码中创建过程写在aessert中,所以其实只能在 debug 模式下获取到,它在源码中实际创建位置在RenderObjectElementmount,在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 的优化

  1. 组件路径 ID 过长:


组件的路径 ID 很长。因为 Flutter 布局嵌套包装的特点,如果一直向上搜索父亲节点,会一直搜索到MyApp这里,中间还会包含很多系统内部创建的组件。


  1. 不同平台特性:(==去掉这点,无需优化,因为平台特性只会出现在系统内部节点,自己编写的除非有特别的判断,否则不会出现差异性==)


在不同的平台,为了保持某些平台的特性风格,可能会出现路径中某个节点不一致的情况(比如在 IOS 平台的路径可能会出现一个侧滑的节点,其他平台没有)。例如以"Cupertino"、"Material"开头的这种组件,要选择屏蔽掉差异。


  1. 动态插入 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 是开发者自己项目创建的即可,也就是只需要一个标识即可。


同样的在实现的过程中也有几点要注意:


  1. 对于创建 widget 的时候,如果加了const修饰,比如下面示例,是需要单独作为一个 Transform 来处理的。


Text widget = const Text('文字');Contain(child:const Text('文字'),);


  1. 在 debug 下可以用TreeNodeLocation字段做区分,但是在 release 下这个字段是 null,不能按照这个区分出自己项目创建的 widget。

  2. 如果使用 Aspectd 的话,自己添加的改造 T


《Android学习笔记总结+最新移动架构视频+大厂安卓面试真题+项目实战源码讲义》
浏览器打开:qq.cn.hn/FTe 免费领取
复制代码


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主要需要实现visitStaticInvocationvisitConstructorInvocation方法:


@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方法,通过给InstanceConstantfiledValue字段添加一个值达到我们需要的效果。


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组件的时候,调用不会经过GestureRecognizerinvokeCallback方法的,所以要过滤掉这个情况单独处理。是直接自己代码创建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);}


对于路径,还需要继续优化:对于点击的组件,我们得确定当前显示的页面是哪个页面或者路由,以此拆分出页面。对此,我们监听ModalRoutebuildPage方法,该方法是个抽象方法,不同类型的路由不同的具体实现,我们对每个页面做拆分,拆分为以当前页面节点为搜索的终止节点,得出实际的 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.实现过程中的其他问题点

在落地到实际的项目中去实现的时候,当然也会遇到一些其他的问题,比如:


  1. 类似Cupertino风格的侧滑返回导致点击时统计的这种错误问题;

  2. 如何丰富当前点击组件的信息,如果获取当前组件中需要的例如文字、图片等信息;

  3. 对特殊组件的兼容处理;

  4. Aspectd框架本身的一些问题;

  5. 实现可视化埋点功能工具;

  6. ...


一些落地中的问题多花点时间都是可以解决的,可能大家发现后会有更好的解决方案,这里因为篇幅问题不多做介绍。而关于Ascpetd的一些实现与使用上的问题,我给该框架提了 PR,部分已经被合并了,有兴趣的可以去看看,如果了解到该框架的实现原理,其实会发现可以实现的功能,远不止框架本身这么简单,比如上面对组件的 transform 也是有相同的思想。

用户头像

Android架构

关注

还未添加个人签名 2021.10.31 加入

还未添加个人简介

评论

发布
暂无评论
Flutter之全埋点思考与实现,精心整理