关于前端:Flutter-无埋点SDK实现

3次阅读

共计 11970 个字符,预计需要花费 30 分钟才能阅读完成。

前言

先看下各个平台自动化埋点反对

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

Dart 文件编译会先编译成 Dill 文件,而后再编译成二进制代码。

如果能在编译器拿到 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 的形式,也让用户集成会更加艰难,也是一个须要思考的问题。

正文完
 0