Android-开发必看---Flutter之全埋点思考与实现,大厂必备

void didChangeAppLifecycleState(AppLifecycleState state) { }
}
复制代码

其中AppLifecycleState是个枚举类,包含四种状态:

enum AppLifecycleState {
resumed,
inactive,
paused,
detached,
}
复制代码

该接口通过以上四种状态,我们可以知道在某个页面停留的时长是多久。

以上是采集页面pv、uv、页面路径的基本思路,具体的代码不多做介绍,逻辑参考原生的实现即可。后面我着重介绍用户行为操作,点击行为埋点数据的采集实现。

3. Flutter组件ID的规则

对于组件的ID来说,它的规则要比页面的定义更加复杂。首先,Flutter的组件本身并没有一个id的概念,虽然Flutter的每个Widget都可以通过一个唯一key去标识,但是在创建Widget的时候除非有特殊的需求(比如复用等),我们一般不会去传入一个key,所以需要换个思路:根据视图树。

[图片上传中…(image-c1c490-1605191361082-11)]

每个页面的组件都是根据其父子、兄弟关系构建出视图树绘制在页面上。从我们观测的组件的本身开始,在这个视图树上逐级向上遍历搜索,直到根节点,找到这个组件在这个树上的位置信息等特征信息,这样就能得到一个组件在视图树上的 一个组件路径,也就是说,我们可以根据这个路径,在视图树中定位到这个组件(图片引用自极客时间-Flutter专栏):

widget、Element、RenerObject关系 img

[图片上传中…(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的子类RenderObjectElementmountupdate方法中对这个属性进行了创建:

@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:组件路径 ]。

4. Flutter中事件与手势分析

为了更好的理解Flutter中的手势事件,下面简要的做一个分析:

Flutter中指针事件表示用户交互的原始触摸数据,例如PointerDownEventPointerUpEventPointerCancelEvent等等,当手指触摸屏幕的时候,发生触摸事件,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);///分发事件
}
}
复制代码

对代码中的几点注释说明:

  1. 如果是PointerDownEvent或者是PointerSignalEvent,直接创建一个HitTestResult对象,该对象内部有一个_path字段(集合);

  2. 调用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);
}
复制代码

内部调用主要就是两步:

  • 调用RenderViewhitTest方法(从根节点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方法,也就是GestureBindinghitTest方法:

@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);
}
复制代码

调用到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(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;
}
复制代码

大体的实现思路如下:

  1. 通过Map记录事件唯一的pointer标识符与响应的RenderObject的映射关系,只记录_path中的第一个,也就是命中测试的最小child,且记录下当前事件序列的pointer(pointer在一个事件序列中是唯一的值,每发生一次手势事件,它会自增1);
  2. GestureRecognizerinvokeCallback(String name,RecognizerCallback callback,{String debugReport})方法中,通过上面记录的的pointer,在Map中取出RenderObject,取debugCreator属性得到Element,再得到对应的widget;

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

经过对比发现,这似乎确实是我们代码中创建的组件的路径没错,但是好像中间多了很多奇怪的组件路径,这似乎不是我们自己创建的,这里还是存在一些问题要优化。

6.关于组件ID的优化

  1. 组件路径ID过长:

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

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

在不同的平台,为了保持某些平台的特性风格,可能会出现路径中某个节点不一致的情况(比如在IOS平台的路径可能会出现一个侧滑的节点,其他平台没有)。例如以"Cupertino"、"MatAndroid-开发必看---Flutter之全埋点思考与实现,大厂必备_第1张图片
erial"开头的这种组件,要选择屏蔽掉差异。

  1. 动态插入Widget不稳定

根据上面定义的规则,在页面元素不发生变动的情况下,基本上是能保证"稳定性"与"唯一性",但是如果页面元素发生动态变化,或者在不同的版本之间UI进行了改版,此时我们定义的规则就会变的不够稳定,也可能不再唯一,比如下图所示:

[图片上传中…(image-85fa0d-1605191361072-8)]

在插入一个Widget后,我们的GestureDetector的路径变成了Contain[0]/Column[0]/Contain[2]/GestureDetector[0],与之前相比发生了变化,这点优化比较简单:将同级兄弟节点的位置,变成相同类型的组件的位置。优化后的组件路径为:Contain[0]/Column[0]/Contain[1]/GestureDetector[0]。这样在插入一个非同类型的Widget后,其路径依旧不变,但如果插入的是同类型的还是会发生改变,所以这个是属于相对的稳定。

那么剩下的问题如何优化呢?

7.Dart元编程解决遗留问题

问题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独特的嵌套方式,每个组件在搜索父节点时最终会搜索到main中,实际其实我们只需要以当前页面为划分即可。

如何解决呢?注意到当我们使用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;

你可能感兴趣的:(程序员,架构,移动开发,android)