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-repo
git apply --3way path-for-aspectd-package/0001-aspectd.patch
rm bin/cache/flutter_tools.stamp
path-for-flutter-git-repo表示flutter的路径这里可能会存在git apply错误的情况,可以打开0001-aspectd.patch文件,根据变动自行添加修改。
path-for-aspectd-package表示aspectd的路径
AspectD通过改写Flutter中的flutter_tools进行修改Dill文件,变动了两个文件:
- flutter/packages/flutter_tools/lib/src/aspectd.dart 添加
- flutter/packages/flutter_tools/lib/src/build_system/targets/common.dart 修改
- 环境设置
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
4. hook相关
- hook写在哪里,怎么hook?
自动化埋点 由于Flutter可以依赖于原生SDK,原生SDK包含事件发送逻辑,网络传输逻辑,并且发送 App打开关闭事件、 App访问事件 、自定义事件等,那么Flutter部分只需要传递如下事件到原生SDK:
- 点击元素事件
- 元素内容改变事件
- 页面曝光事件
/// 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 arguments = [
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 map = {};
Map page = {};
/// translate entry to map
List
然后将所有信息传输至原生SDK,由原生SDK进行发送。
结尾 此部分代码仍在开发中,可视化埋点部分在iOS上初步顺利,在Android平台上仍有截图黑屏,对原生SDK代码侵入性较强等问题,同时依赖aspectd的方式,也让用户集成会更加困难,也是一个需要考虑的问题。
【Flutter 无埋点SDK实现】
文章图片
推荐阅读
- Flutter的ListView
- 运用flutter|运用flutter 构建一个发布版(release)APK
- Flutter自定义view|Flutter自定义view —— 闯关进度条
- Flutter|Flutter SwiftUI React 对比
- flutter设置沉浸式状态栏
- vue项目+wxjssdk,config配置解决iPhone端签名错误的问题
- 2019-12-02|2019-12-02 flutter的环境配置
- Flutter开发之布局Widget
- JS开发者值得一看的Flutter入门
- 开放人脸SDK的相关资源