写点什么

Flutter 无埋点 SDK 实现

发布于: 刚刚

前言

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


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


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-repogit apply --3way path-for-aspectd-package/0001-aspectd.patchrm 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 getWarning: 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/metaRunning "flutter pub get" in aspectd...                            744msRunning "flutter pub get" in example...                             7.8s
复制代码

显示我们修改了 kernel 依赖。


  1. 运行 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:1Xcode build done.                                           23.1sFailed to build iOS appError 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 ,再进行编译。


  1. 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 的方式,也让用户集成会更加困难,也是一个需要考虑的问题。



发布于: 刚刚阅读数: 2
用户头像

GrowingIO 技术团队经验分享 2020.05.09 加入

GrowingIO(官网网站www.growingio.com)的官方技术专栏,内容涵盖微服务架构,前端技术,数据可视化,DevOps,大数据方面的经验分享。 公众号:GrowingIO技术团队

评论

发布
暂无评论
Flutter 无埋点SDK实现