Flutter无痕埋点

5,148 阅读6分钟

Flutter无痕埋点

由于工作调整的原因,后面可能将不再接触flutter开发,本着技术共享不埋没的原则,开源一下flutter无痕埋点的技术方案,此方案完成于半年前,应该在闲鱼的无痕埋点方案开源前,与闲鱼的方案不太一样,大家有什么建议可以广泛留言。另外特别感谢永葵同学在此技术中的参与和共同努力。

思考

  • 既然是无痕埋点,Flutter如何实现AOP?像iOS,可以在运行时通过一些方法交换,消息转发来实现切面,但是由于目前Flutter不支持运行时的反射功能,所以第一时间放弃了这个方向。另外由于当时时间和资源上也不是很充足,也没有研究编译期的AOP方案,不过之后闲鱼出品的AspectD确实是个不错的AOP框架。切回正题,所以当时的选择时阅读Flutter的源码,试图寻找官方的API来解决问题。

Flutter如何遍历widget

  • 众所周知,在flutter中有widget树和element树,两者成一一对应的关系,那么想要遍历widget树,其实也就是遍历element树,在阅读源码的过程中发现framework.dart这个文件下一个visitChildElements(ElementVisitor visitor)的方法,也就是说,通过这个方法,我们可以遍历指定element下所有的elements,然后通过element拿到他所对应的widget。

页面埋点

导航栏监听

  • 要想做页面埋点,第一个想到的就是通过监听导航栏页面的变动,就可以监听到页面的变化。通过NavigatorObserver确实就可以监听到页面的push和pop,但是官方的这个类提供的方法并不能拿到具体跳转页面的信息(静态页面除外)。那么是不是可以当监听到页面push和pop时,直接遍历element树拿到widget信息呢?实践证明,当监听到push的同时去遍历会报错,因为这个时候页面还正在渲染,flutter的元素正在生成,所以遍历会有问题。所以需要监听页面的渲染完成的时机。

页面渲染监听

  • 如何做页面渲染的监听呢?我们可以看一下Flutter的启动函数
void runApp(Widget app) {
 WidgetsFlutterBinding.ensureInitialized()
   ..attachRootWidget(app)
   ..scheduleWarmUpFrame();
}

然后我们再观察下WidgetsFlutterBinding这个类,

class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding 

在这个类里做了非常多的绑定,渲染的绑定,手势的绑定等等。 SchedulerBinding这是一个调度器,调度任务的安排。 在SchedulerBinding中有一个控制渲染的方法

    void handleDrawFrame() {
   assert(_schedulerPhase == SchedulerPhase.midFrameMicrotasks);
   Timeline.finishSync(); // end the "Animate" phase
   try {
     // PERSISTENT FRAME CALLBACKS
     _schedulerPhase = SchedulerPhase.persistentCallbacks;
     for (FrameCallback callback in _persistentCallbacks)
       _invokeFrameCallback(callback, _currentFrameTimeStamp);

     // POST-FRAME CALLBACKS
     _schedulerPhase = SchedulerPhase.postFrameCallbacks;
     final List<FrameCallback> localPostFrameCallbacks =
         List<FrameCallback>.from(_postFrameCallbacks);
     _postFrameCallbacks.clear();
     for (FrameCallback callback in localPostFrameCallbacks)
       _invokeFrameCallback(callback, _currentFrameTimeStamp);
   } finally {
     _schedulerPhase = SchedulerPhase.idle;
     Timeline.finishSync(); // end the Frame
     assert(() {
       if (debugPrintEndFrameBanner)
         debugPrint('▀' * _debugBanner.length);
       _debugBanner = null;
       return true;
     }());
     _currentFrameTimeStamp = null;
   }
 }

根据源码我们可以看到当布局完成后会调用已经注册的回调postFrameCallbacks,官方很友好的开放了添加回调的方法。

void addPostFrameCallback(FrameCallback callback) {
   _postFrameCallbacks.add(callback);
 }

所以我们就可以当监听到导航栏路由变化时,并且监听到布局渲染后去遍历element树,拿到我们想要的widget,去获取widget上面的信息。

识别页面并获取页面信息

  • 在树的遍历上,我们需要用从下往上从左往右的方式去遍历,因为元素是从上往下从右往左添加到树上的。我们只需要找到第一个Scaffold,就可以确定页面,从它再往上取1-2层,拿到为页面的widget,这中间的一个路径就可以作为我们页面的标示了。当然你也可以加上Scaffold里页面的title。

点击埋点

全局捕获点击事件

  • 在flutter中存在一个手势竞技场,竞技场中最后获胜的手势可以响应点击事件。那么既然竞技场最后只能响应一个手势,但是又不能hook手势的onTap方法,那我们要如何实现全局捕获点击事件呢?同样我们仍然阅读flutter源码,可以看到TapGestureRecognizer中有一个如下方法。
void acceptGesture(int pointer) {
   super.acceptGesture(pointer);
   if (pointer == primaryPointer) {
     _checkDown(pointer);
     _wonArenaForPrimaryPointer = true;
     _checkUp();
   }
 }
 
void rejectGesture(int pointer) {
   super.rejectGesture(pointer);
   if (pointer == primaryPointer) {
     // Another gesture won the arena.
     assert(state != GestureRecognizerState.possible);
     if (_sentTapDown)
       _checkCancel('forced ');
     _reset();
   }
 }

上面的方法是手势的拒绝和添加。从源码中我们可以看出当另一个手势从竞技场胜出时,不会执行手势的成功回调,而是会执行手势的取消回调。那么我们是不是可以添加一个全局的手势,然后重写它的拒绝方法,让它内部执行手势的接受方法呢,这样我们自己添加的全局手势也能执行成功回调了?实践见证确实可以这样做。

识别点位标示并获取信息

  • 同样我们可以在全局手势的回调中拿到当前点位坐标。在回调中同样我们遍历element树,拿到符合点位坐标并且widget runTimeType 是Ink的最小单元。

最小单元: 蓝色,黄色,绿色都是Ink控件,白色为点击的点,那么绿色即为最小单元。

把Ink到Scroffld的中间路径为作为一个点击位的标示,当然中间可以过滤一些不需要的widget,不然路径会很长。另外可以获取按钮或者子控件的一些其他信息,如title和Image name。

遇到的坑

无痕埋点的方案基本上是上面的这些,但是中间我们还是一路踩坑,细节上有些需要特殊处理。

多子元素控件的处理

  • flutter中widget子组件可能不仅有child还有些childrens,所对应的element为SingleChildRenderObjectElement和MultiChildRenderObjectElement,所以在遍历element是要注意区分为哪种element类型,不同类型的遍历上略有不同。如在手势埋点中,在多子元素的组件中,从最后一个开始遍历,保证获取到的第一个命中的点击事件是相应的那个(界面上是最顶层的组件)。同样像ListView,Tab这种组件想要获取正确的路径位置,需要获取当前element在MultiChildRenderObjectElement中的正确位置。

Tab的特殊处理

  • 当Tab下的页面切换时,并不会触发路由的didPush和didPop,所以我们需要借助点击事件的获取,并分析出是tab切换所触发的,然后遍历获取到页面信息。

附言

  • 对文章有疑问或者有任何技术想要探讨的同学可以通过邮箱联系我 531889780@qq.com