Flutter 无埋点SDK实现

1,987 阅读9分钟

前言

先看下各个平台自动化埋点支持

平台

特点

自动化埋点方案

Android

Java支持编译器静态代理/运行期动态代理

有成熟方案和产品,例如GrowingIO、神策、友盟

iOS

OC提供了强大的运行时和动态性

有成熟方案和产品,例如GrowingIO、神策、友盟

Flutter

Dart反射支持很弱,Flutter禁用了反射机制,考虑从编译期代码插桩实现

无成熟方案和产品

从编译期进行代码插桩,则需要修改编译期的中间件文件。

Dart文件编译会先编译成Dill文件,然后再编译成二进制代码。Flutter 无埋点SDK实现

如果能在编译器拿到Dill文件,然后进行修改插桩,再进行编译成Binary Code就可以达到AOP埋点的效果

flutter_tool是flutter的编译工具,其并没有提供接口供开发者hook,以及修改编译流程,那么要实现这个步骤,我们就需要修改flutter_tool这个工具。

闲鱼的AspectD就使用了这个思想GitHub - XianyuTech/aspectd: AOP for Flutter(Dart)

基于闲鱼的ApectD来开展后续的工作,这里的 Flutter SDK 完全依赖于原生 SDK,不具有单独运行的能力。

AspectD的使用

首先配置好flutter环境(flutter sdk、dart sdk、fvm、环境变量等),这里使用版本信息如下:

    • Flutter version 2.2.2 at /Users/sheng/GrowIO/flutter
    • Framework revision d79295af24 (4 months ago), 2021-06-11 08:56:01 -0700
    • Engine revision 91c9fc8fe0
    • Dart version 2.13.3
    • Pub download mirror https://pub.flutter-io.cn
    • Flutter download mirror https://storage.flutter-io.cn

1. 下拉aspectd仓库

这里我们对aspectd进行了部分修改,以满足我们的无埋点要求。

git clone https://github.com/growingio/aspectd.git

2. 修改build_tool,通过git patch方式

  • git patch

    cd path-for-flutter-git-repo git apply --3way path-for-aspectd-package/0001-aspectd.patch rm bin/cache/flutter_tools.stamp

path-for-flutter-git-repo表示flutter的路径 path-for-aspectd-package表示aspectd的路径

这里可能会存在git apply错误的情况,可以打开0001-aspectd.patch文件,根据变动自行添加修改。

AspectD通过改写Flutter中的flutter_tools进行修改Dill文件,变动了两个文件:

  • flutter/packages/flutter_tools/lib/src/aspectd.dart 添加

  • flutter/packages/flutter_tools/lib/src/build_system/targets/common.dart 修改

AspectD通过git patch方式,给flutter的分支添加了这些变动。

  • 环境设置

在 ~/.bash_profile 文件中添加

export PUB_HOSTED_URL=https://pub.flutter-io.cn //国内用户需要设置
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn //国内用户需要设置

注:git@github.com: Permission denied 问题,需要你设置ssl证书

然后在aspectd根目录执行 flutter pub get

sheng@chengpengdeMacBook-Pro aspectd % flutter pub get
[KWLM]:pub get
Warning: You are using these overridden dependencies:
! kernel 0.0.0 from git git@github.com:XianyuTech/sdk.git at c9f1a5 in pkg/kernel
! meta 1.3.0 from git git@github.com:XianyuTech/sdk.git at c9f1a5 in pkg/meta
Running "flutter pub get" in aspectd...                            744ms
Running "flutter pub get" in example...                             7.8s

显示我们修改了kernel依赖。

3. 运行example

aspectd/ 、aspectd/aspectd_impl/ 、aspectd/example/ 这3个目录我们都需要进行 flutter pub get

然后进入到aspectd源码目录的example中执行:flutter run --debug --verbose ,也可以直接Android Studio中打开,运行Main

如果/aspectd/lib/src/flutter_frontend_server/下生成了frontend_server.dart.snapshot则表示此次编译 aspectd_impl 成功了。

你也可以不使用flutter run来生成frontend_server.dart.snapshot,使用下面的命令

dart --deterministic --packages=/Users/sheng/GrowIO/aspectd-ex/lib/src/flutter_frontend_server/package_config.json --snapshot=/Users/sheng/GrowIO/aspectd-ex/lib/src/flutter_frontend_server/frontend_server.dart.snapshot --snapshot-kind=kernel /Users/sheng/GrowIO/aspectd-ex/lib/src/flutter_frontend_server/starter.dart

如果frontend_server.dart.snapshot没有生成,运行会显示build完成,但是无法运行。

Launching lib/main.dart on iPhone SE (2nd generation) in debug mode...
lib/main.dart:1
Xcode build done.                                           23.1s
Failed to build iOS app
Error output from Xcode build:
↳
    ** BUILD FAILED **
Xcode's output:
↳
    /Users/sheng/GrowIO/aspectd/aspectd_impl/.packages does not exist.
    Did you run "flutter pub get" in this directory?
    Command PhaseScriptExecution failed with a nonzero exit code
    note: Using new build system
    note: Building targets in parallel
    note: Planning build
    note: Constructing build description
    warning: The iOS Simulator deployment target 'IPHONEOS_DEPLOYMENT_TARGET' is set to 8.0, but the range of supported deployment target versions is 9.0 to 14.0.99. (in target 'Runner' from project 'Runner')
Could not build the application for the simulator.
Error launching application on iPhone SE (2nd generation).
Exited (sigterm)

值得注意的是,

  • aspectd/ 对应调试aspectd的transform代码部分工程,即修改/aspectd/lib/src/ 下的代码是需要该工程的

  • aspectd/aspectd_impl/ 是添加的 Hook 相关的代码部分

  • aspectd/example/ 则是工程demo

每次修改 aspectd hook 相关的代码需要先执行 flutter clean ,再进行编译。

4. hook相关

  • hook写在哪里,怎么hook?

参考aspectd的 README,至此apsectd的集成就告一段落。

自动化埋点

由于Flutter可以依赖于原生SDK,原生SDK包含事件发送逻辑,网络传输逻辑,并且发送 App打开关闭事件、 App访问事件 、自定义事件等,那么Flutter部分只需要传递如下事件到原生SDK:

  1. 点击元素事件

  2. 元素内容改变事件

  3. 页面曝光事件

点击事件

对于点击事件,则需要hook点击触发方法,暂时分成两步,在以下时机进行切面

  /// click event aop step 1
  /// hittest
@Call("package:flutter/src/gestures/hit_test.dart", "HitTestTarget",
      "-handleEvent")
  /// click event aop step 2
  /// callback
@Call("package:flutter/src/gestures/recognizer.dart", "GestureRecognizer",
      "-invokeCallback")

关于路径Path的获取,则是通过 Element 中向上遍历父级元素 visitAncestorElements 方法,将一整条元素链存入数组。

相关代码可以去仓库 aspectd/growing_aop_impl.dart at master · growingio/aspectd 查看。

路径过滤

从上述方法 中最终获取到的路径 Path 包含多余的系统元素,例如:

MyHomePage/Semantics/Builder/RepaintBoundary/IgnorePointer/AnimatedBuilder/Stack/DecoratedBox/DecoratedBoxTransition/FractionalTranslation/SlideTransition/FractionalTranslation/SlideTransition/CupertinoPageTransition/AnimatedBuilder/RepaintBoundary/Semantics/FocusScope/Actions/PageStorage/Offstage/Semantics/TickerMode/Overlay/Semantics/FocusScope/AbsorbPointer/Listener/HeroControllerScope/Navigator/IconTheme/IconTheme/CupertinoTheme/Theme/AnimatedTheme/Builder/DefaultTextStyle/CustomPaint/Banner/CheckedModeBanner/Title/Directionality/Semantics/Localizations/MediaQuery/Focus/FocusTraversalGroup/Actions/Semantics/Focus/Shortcuts/WidgetsApp/HeroControllerScope/ScrollConfiguration/MaterialApp/MyApp/[root]

我们需要过滤掉系统元素,参考 Flutter Inspector 工具的实现以及 /kernel/lib/transformations/track_widget_constructor_locations.dart /flutter/packages/flutter/lib/src/widgets/widget_inspector.dart 文件代码,其实现逻辑为:

/kernel/lib/transformations/track_widget_constructor_locations.dart 文件在编译期通过一个 transformer 使得所有的 widget 实现了抽象类 _HasCreationLocation ,_HasCreationLocation 包含了文件位置信息,如果文件是否是用户自己创建,则会记录进Path,但这个功能只会在debug模式下启用,所以我们需要自己实现,那么参考 track_widget_constructor_locations 实现,我们需要通过AspectD插入一个transformer,来完成所有的 widget 实现了抽象类_HasCreationLocation 的操作。

这里提供了自己实现的代码供参考: aspectd/track_widget_custom_location.dart at master · growingio/aspectd

Transformer实现

对track_widget_constructor_locations 实现介绍

_CustomHasCreationLocation 对应 _HasCreationLocation ,因为我们不能和Inspector一致,同理还有 _creationLocationParameterName 以及 _locationFieldName 。

抽象类 _CustomHasCreationLocation 其实是外部实现的,示例中写在 growing_impl.dart 文件中,在 _resolveFlutterClasses 方法中会判断路径,来获取该抽象类。

aspectd/growing_impl.dart at master · growingio/aspectd

aspectd/track_widget_custom_location.dart at master · growingio/aspectd

再就是 RootUrl 的判断,Flutter Inspector 中通过 /flutter/packages/flutter/lib/src/widgets/widget_inspector.dart 来获取 RootUrl 的,这里我们暂时在 track_widget_constructor_locations 中保存 main.dart 的路径前段,来判断是否是用户的工程创建,然后将这个 RootUrl 保存在了 _CustomLocation 实体中,具体可以在 _constructLocation 查看

ConstructorInvocation _constructLocation(
  Location location, {
  String name,
  ListLiteral parameterLocations,
  bool showFile: true,
}) {
  final List<NamedExpression> arguments = <NamedExpression>[
    new NamedExpression('line', new IntLiteral(location.line)),
    new NamedExpression('column', new IntLiteral(location.column)),
    new NamedExpression('rootUrl', new StringLiteral(_rootUrl)),
  ];

然后以此判断是否是自己创建

bool _isLocalElement(Element element) {
    Widget widget = element.widget;
    if (widget is _CustomHasCreationLocation) {
      _CustomHasCreationLocation creationLocation =
      widget as _CustomHasCreationLocation;
      if (creationLocation._customLocation.isProjectRoot()) {
        return true;
      }
    }
    return false;
  }

最终过滤多余 Element 后,Path 路径如下:

MyApp/MaterialApp/MyHomePage/Scaffold/Center/Column/GestureDetector/Text

元素内容改变事件

对于此类事件,暂时只对常见文本框进行了处理,对文本内容改变的方法进行了切面:

  /// text value changed
  /// EditableTextState
  @Execute("package:flutter/src/widgets/editable_text.dart", "EditableTextState",
      "-updateEditingValue")

代码链接:aspectd/growing_aop_impl.dart at master · growingio/aspectd

除了改变的 Text 内容,还需要 路径Path 以及当前 页面Page 等关键信息, 路径Path 可以参考元素的点击事件处理,页面Page信息则需要我们自己记录堆栈。

页面曝光事件

在阅读Flutter源码过程中,是有一个类似储存页面堆栈的机制的,叫做 RouteEntry ,我们可以依此展开。

这里为了获取上下文信息,又对 buildPage 方法进行了切面。

  /// 1. Page Push - get only RouteEntry
  @Execute("package:flutter/src/widgets/navigator.dart", "_RouteEntry", "-handlePush")
  /// 2. Page Pop - get only RouteEntry
  @Execute(
      "package:flutter/src/widgets/navigator.dart", "_RouteEntry", "-handlePop")
  /// 3. Page Build
  /// can get context and widget
  @Execute("package:flutter/src/material/page.dart",
      "MaterialRouteTransitionMixin", "-buildPage")
  /// 4. Page Build
  /// can get context and widget
  @Execute("package:flutter/src/cupertino/route.dart",
      "CupertinoRouteTransitionMixin", "-buildPage")

具体代码可以查看:aspectd/growing_aop_impl.dart at master · growingio/aspectd

然后再在对应的方法中,记录页面信息以及页面堆栈,既可以达到我们预想的效果。具体代码参考 aspectd/growing_aop_impl.dart at master · growingio/aspectd 中 handlePush 、handleBuildPage、handlePop 的处理。

可视化埋点(圈选)

可视化埋点需要遍历页面所有元素,并将可以选择的元素上传,依于之前的操作,我们已经做了页面的存储,则可以通过页面子元素的遍历,遍历页面上所有元素信息。此外,也需要监听页面变动,以选择合适的时机来遍历。

Flutter 每次元素变动,或者刷新会触发 DrawFrame 方法

  /// Draw Frame - 每次变动刷新
  /// SchedulerBinding:support window.onBeginFrame/window.onDrawFrame call back
  @Execute("package:flutter/src/scheduler/binding.dart", "SchedulerBinding",
      "-handleDrawFrame")

在此方法中,通过 Element 的 visitChildElements 方法遍历所有子元素,同时过滤系统元素,则可以达到我们想要的效果。

  void webcircleSend() {
    /// 圈选遍历逻辑
    if (GrowingAutotracker.getInstance().webCircleRunning) {
      if (pageList.isEmpty) {
        GIOLogger.debug(
            "handleDrawFrame webcircle error : no found page entry");
        return;
      }
      GrowingPageEntry entry = pageList.last;
      entry.context.visitChildElements((element) {
        traverseElement(element, entry.context as Element, false, 0);
      });

      circleElments.forEach((child) {
        GIOLogger.debug("circleElement : " + child.toString());
      });
      Map<String, dynamic> map = <String, dynamic>{};
      Map<String, dynamic> page = <String, dynamic>{};

      /// translate entry to map
      List<Map> elements = <Map>[];
      circleElments.forEach((element) {
        elements.add(element.toMap());
      });
      map["elements"] = elements;

      var element = entry.context as Element;
      final RenderBox box = element.renderObject as RenderBox;
      final size = box.size;
      final offset = box.localToGlobal(Offset.zero);
      MediaQueryData queryData = MediaQueryData.fromWindow(ui.window);
      if (queryData.devicePixelRatio > 1) {
        page["left"] = offset.dx*queryData.devicePixelRatio;
        page["top"] = offset.dy*queryData.devicePixelRatio;
        page["width"] = size.width*queryData.devicePixelRatio;
        page["height"] = size.height*queryData.devicePixelRatio;
      } else {
        page["left"] = offset.dx;
        page["top"] = offset.dy;
        page["width"] = size.width;
        page["height"] = size.height;
      }
      page["path"] = _getPagePath(entry);
      page["title"] = entry.titile;
      page["isIgnored"] = false;

      /// pages
      map["pages"] = <Map>[page];
      GrowingAutotracker.getInstance().flutterWebCircleEvent(map);
      GIOLogger.debug('handleDrawFrame circle ' + map.toString());
      circleElments.clear();
    }
  }

  void traverseElement(Element element,Element parent, bool isIgnored, int z) {
    // GIOLogger.debug("reversedObjc " + element.widget.runtimeType.toString());
    if (_isLocalElement(element)) {
      String? elementType = null;
      if (element.widget is IgnorePointer) {
        /// ignorePointer will ignore all subtree if is ignoring
        IgnorePointer widget = element.widget as IgnorePointer;
        if (widget.ignoring) {
          element.visitChildElements((child) {
            traverseElement(child,element, true,z++);
          });
          return;
        }
      }else if (element.widget is RawMaterialButton || element.widget is MaterialButton || element.widget is FloatingActionButton || element.widget is AppBar) {
        /// because of is local element, Gesture is create by system
        /// RawMaterialButton is super class of RaisedButton、FlatButton、OutlineButton
        // [RawMaterialButton,MaterialButton,FloatingActionButton].takeWhile((e) => element.widget is e).isNotEmpty;
        elementType = "BUTTON";
      }else if (element.widget is TextFormField || element.widget is TextField) {
        elementType = "INPUT";
      }else if (element.widget is ListView || element.widget is CustomScrollView || element.widget is SingleChildScrollView || element.widget is GridView) {
        elementType = "LIST";
      }else if (parent.widget is GestureDetector) {
        /// gesture click enable
        elementType = "TEXT";
      }

      if (elementType != null) {
        GrowingCircleElement circle = GrowingCircleElement();
        final RenderBox box = element.renderObject as RenderBox;
        final size = box.size;
        final offset = box.localToGlobal(Offset.zero);
        MediaQueryData mediaQuery = MediaQueryData.fromWindow(ui.window);
        if (mediaQuery.devicePixelRatio > 1) {
          circle.rect = Rect.fromLTWH(offset.dx*mediaQuery.devicePixelRatio, offset.dy*mediaQuery.devicePixelRatio, size.width*mediaQuery.devicePixelRatio, size.height*mediaQuery.devicePixelRatio);
        }else {
          circle.rect = Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height);
        }
        var parser = GrowingElementParser(element, currentPage());
        var parentParser = GrowingElementParser(parent, currentPage());
        circle.xpath = parser.xpath;
        circle.parentXPath = parentParser.xpath;
        circle.content = parser.content;
        circle.index = parser.index;
        circle.page = _getPagePath(currentPage());
        circle.zLevel = z;
        circle.isContainer = false;
        circle.isIgnored = isIgnored;
        circle.nodeType = elementType;
        circleElments.add(circle);
      }
      element.visitChildElements((child) {
        traverseElement(child,element, isIgnored,z++);
      });
    }else {
      element.visitChildElements((child) {
        traverseElement(child,parent, isIgnored,z++);
      });
    }


  }

然后将所有信息传输至原生SDK,由原生SDK进行发送。

结尾

此部分代码仍在开发中,可视化埋点部分在iOS上初步顺利,在Android平台上仍有截图黑屏,对原生SDK代码侵入性较强等问题,同时依赖aspectd的方式,也让用户集成会更加困难,也是一个需要考虑的问题。