Flutter之全埋点思考与实现

浩浩
• 阅读 2289

1. 背景

用户数据分析与埋点,在互联网产品的设计与迭代中是不可缺少的一部分,利用用户的行为规律、用户画像,能在很大程度上帮助团队制定合适的运营策略与产品方向。

随着产品的迭代与业务的发展,对业务团队的敏捷性与创新性提出了更高的要求,而通过大数据的手段在一定程度上可以帮助我们实现这个愿景,同时,良好的数据分析可以帮助我们进行更好更优的决策。

一般我们会采集的数据会包括用户的点击行为操作、页面浏览量(PV)、页面停留时长、访客(UV)等等。而对于产生的数据本身,其整个流程主要可以总结为以下几点:

  • 数据采集
  • 数据上报
  • 数据存储
  • 数据分析
  • 数据展示

我们常说的『埋点』就是数据采集领域的术语,通过采集到用户产生的原始数据,进行一定层次的过滤,达到产品运营的需求。数据采集的方式也可以说是埋点的几种方式。

现状、痛点

目前公司App产品在未引入Flutter之前,一直采用纯原生的埋点功能,原生的实现方案相对来说是比较完善的,采用混合开发后,随着迭代后Flutter模块的增多,仅仅在Flutter侧以代码埋点通过channel调用原生模块埋点接口的方式以逐渐不能满足我们的需求,即使这种方式能精准采集到需要的信息,但是对于App产品来说,耗费的成本逐渐增大,运营、开发、测试,都需要参与,沟通成本也逐渐增大。另外一方面,现在市面已有的第三方统计平台,要么不支持Flutter,要么也只是提供一个简单的插件提供接口手动调用,我们迫切的需要一个类似原生的自动化埋点方案解决问题。

原生的实现方式

1. 无痕埋点

俗称"全埋点"、"无埋点",通过在端上自动采集并上报尽可能多的数据,根据一定的规则过滤筛选出自己需要的可用数据。

优点:

  • 能很大程度减少开发、测试的重复工作,不需要对业务标识进行唯一的区分,ID的规则由设计的SDK和产品沟通约定好即可,减少业务人员后续的沟通成本和使用步骤
  • 数据可以回溯并相对全面

缺点:

  • 需要设计出一套全埋点的技术成品,能获取到准确的指标数据,前期的技术投入大
  • 数据量大。需要后端落地后进行大量处理,采用智能系统分析平台或者是数据库查询数据聚合。同时需要产品进行自我还原业务场景。

2. 可视化埋点

可视化埋点是通过运营人员在可视化的工具选择需要收集的埋点数据,端侧获取配置后,再基于预先设置的规则,通过组件或控件精准采集,根据配置条件自动埋点上报的方式。

优点:很大程度减少开发、测试的重复工作,数据量可靠,可以在线上可视化工具动态的进行埋点配置,无需每次等到发版才能生效。

缺点:采集信息不够灵活,并且无法解决数据回溯的问题

2. 具体实现

无痕埋点:以无痕埋点为切入点,结合现在已有的原生方案,迁移到Flutter平台。

无痕埋点需要自动采集数据,因此针对页面、控件等元素需要生成其 ID,该 ID 需尽量具备『唯一性』和『稳定性』。『唯一性』非常好理解,因为对于任意元素而言,其 ID 应该是与其他所有元素都不同的,这样我们才能根据 ID 唯一标识出那个我们想要的元素,采集上来的数据才是准确的,不重复的。而『稳定性』则是说,元素的 ID 应尽量不受版本的变动而改变,这样后期关联业务含义的操作才会更加便捷。

1. Flutter页面ID的规则

根据"唯一性"与"稳定性",将页面所在类的类型作为ID,它是相对唯一的,除了页面复用,基本不存在其他类名相同的页面(不同的package例外),其次它是相对稳定的,除了修改类名情况下才会改变,除了一些页面重大的改版之外不会轻易修改类名。在Flutter中,页面也是Widget,因此,ID定义规则如下:

ID = Widget Type+"额外参数"(widget为当前前台显示的页面)

2. Flutter页面的PV、UV

一旦有了页面的唯一ID的生成规则,我们就可以在页面曝光的时候,去生成这个ID,然后上传即可实现页面的PV、UV指标。至于页面的曝光时机,在Flutter存在接口RouteObserver

//继承这个类,在MaterialApp中可配置,可以配置多个Observer
class RouteObserver<R extends Route<dynamic>> extends NavigatorObserver {

   void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
         ...
   }
    void didPush(...){...}
    ...

} 

能监控页面的曝光时机还不够,有时我们不仅仅需要的是知道进入了哪个页面,还需要知道在某个页面停留了多长的时间,并且应用在前后台的切换也要计算进去。同样的,在Flutter中存在监听页面的生命周期的接口WidgetsBindingObserver:

abstract class WidgetsBindingObserver {
    //省略部分代码
  /// Called when the system puts the app in the background or returns
  /// the app to the foreground.
  ///
  /// An example of implementing this method is provided in the class-level
  /// documentation for the [WidgetsBindingObserver] class.
  ///
  /// This method exposes notifications from [SystemChannels.lifecycle].
  void didChangeAppLifecycleState(AppLifecycleState state) { }
} 

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

enum AppLifecycleState {
    resumed,
    inactive,
    paused,
    detached,
} 

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

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

3. Flutter组件ID的规则

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

Flutter之全埋点思考与实现

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

widget、Element、RenerObject关系 ![img](https://img-blog.csdnimg.cn/img\_convert/854c4c235ed3f6b6452b0f6cf0aab4bb.png)

Flutter之全埋点思考与实现

三棵树 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 extends Widget>();
    T findAncestorStateOfType<T extends State>();
    T findAncestorRenderObjectOfType<T extends RenderObject>();
    ...还有其他的省略...
} 

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观测,得到如下图的一个调用结构:

Flutter之全埋点思考与实现

上图中,_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<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();
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<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执行更新的时候同样也会更新:

@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", "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这里,中间还会包含很多系统内部创建的组件。

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

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

  3. 动态插入Widget不稳定

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

Flutter之全埋点思考与实现

在插入一个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观测我们创建的页面时,出现的是我们想要的组件展示情况:

Flutter之全埋点思考与实现

Flutter之全埋点思考与实现

通过图中可以看到,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](https://github.com/dart-lang/sdk/wiki/Kernel-Documentation).
///
/// 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('文字'),
    ); 
  2. 在debug下可以用TreeNodeLocation字段做区分,但是在release下这个字段是null,不能按照这个区分出自己项目创建的widget。

  3. 如果使用Aspectd的话,自己添加的改造Transform要添加在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.dart
                if(importUri.path = 'example/local_widget_track_class.dart'){
                    for(Class cls in library.classes){
                        //定义的类名,比如是:LocalWidgetLocation
                        if(cls.name = 'LocalWidgetLocation'){
                            _localWidgetLocation = cls;
                        }
                    }
                }else if(importUri.path == 'flutter/src/widgets/framework.dart'|| ....){
                    ...
                }
            }
        }
    } 
  • 继承Transformer主要需要实现visitStaticInvocationvisitConstructorInvocation方法:

     @override
      StaticInvocation 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;
      }
    
      @override
      ConstructorInvocation 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示例代码如下:
      @override
      TreeNode 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) {
        ///处理单独使用Listener
        RenderPointerListener 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 = ${widget.runtimeType} isLocal = $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也是有相同的思想。

3.结果展示

完成了上述的操作后,整合出了一个Demo,展示如下:

1. 常规click组件(主要是GestureDecector、Listener、GestureDecector衍生类)Flutter之全埋点思考与实现

2.几个特殊的Click Widget: Flutter之全埋点思考与实现

3.在Dialog上的采集: Flutter之全埋点思考与实现

4.单一列表数据: Flutter之全埋点思考与实现

5.带复杂组件的列表: Flutter之全埋点思考与实现

6.Tab实现: Flutter之全埋点思考与实现

无埋点功能数据采集不是万能的,不是银弹,比如这种组件唯一性方案,是会随着版本的迭代可能发生改变的(可以通过上传的App版本号来区分不同版本的数据的,区分后,很多数据都是可以归纳在一起的),对于可视化埋点来说,不同的版本的版本的数据不能完全通用。知晓实现的原理,才能了解在哪些情况下数据是不准确的,以技术人员的角度去看待这些问题,解释这些问题,才能避免产品运营战略上的方向错误。

4.结语

该方案在公司产品的实施,从开始到现在,大概有一年的时间,中间也一直在优化,为了针对自己公司产品,某些地方也做了定制化处理,不过基本原理依旧没怎么改变。因个人水平有限,如果文章中有错误或者可以更加优化的地方,欢迎交流一起进步。另文中一些地方由于篇幅受限,没有一一展开处理,后续有时间再分享出来。

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Karen110 Karen110
3年前
一篇文章带你了解JavaScript日期
日期对象允许您使用日期(年、月、日、小时、分钟、秒和毫秒)。一、JavaScript的日期格式一个JavaScript日期可以写为一个字符串:ThuFeb02201909:59:51GMT0800(中国标准时间)或者是一个数字:1486000791164写数字的日期,指定的毫秒数自1970年1月1日00:00:00到现在。1\.显示日期使用
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
待兔 待兔
4个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
3年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
10个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这