1. The background

User data analysis and burial point is an indispensable part in the design and iteration of Internet products. The use of user behavior rules and user portraits can help the team to develop appropriate operation strategy and product direction to a large extent.

With product iteration and business development, there are higher requirements for agility and innovation of business team, and big data can help us to realize this vision to a certain extent. At the same time, good data analysis can help us to make better and better decisions.

Generally, we will collect data including users’ click behavior operations, page views (PV), page duration, visitors (UV) and so on. As for the generated data itself, the whole process can be summarized as follows:

  • The data collection
  • The data reported
  • Data is stored
  • The data analysis
  • The data show

We often say that “buried point” is a term in the field of data collection, through the collection of original data generated by users, to carry out a certain level of filtering, to meet the needs of product operation. The way of data collection can also be said to be several ways of burying points.

Status quo, pain points

Before introducing Flutter, the company’s App products have always adopted the pure native buried point function. The native implementation scheme is relatively perfect. After adopting the mixed development, with the increase of Flutter modules after iteration, Only in Flutter side at code buried by buried channel call native module point interface to gradually can’t meet our demand, even if this way can the precision needed information collected, but for the App product, cost increase gradually, operation, development, testing, all need to participate in, communication cost also increases gradually. On the other hand, the existing third-party statistical platforms on the market either do not support Flutter or only provide a simple plug-in for manual invocation. We urgently need a similar native automated burying point solution to solve the problem.

Native implementation

1. No trace burying point

Commonly known as “full buried point” or “no buried point”, it automatically collects and reports as much data as possible on the terminal and filters out the available data it needs according to certain rules.

Advantages:

  • It can greatly reduce the repetitive work of development and testing, and there is no need to make a unique distinction for business identification. The rules of ID can be well agreed by the designed SDK and product communication, which can reduce the subsequent communication cost and use steps of business personnel
  • The data is traceable and relatively comprehensive

Disadvantages:

  • It is necessary to design a set of technical finished products with full buried points, which can obtain accurate index data and require large technical input in the early stage
  • Large amount of data. A large amount of processing is required after the back-end landing, and intelligent system analysis platform or database query data aggregation is adopted. At the same time, the product needs to self-restore the business scenario.

2. Visualize buried sites

Buried points can be visualized by selecting buried point data from a visual tool, obtaining configurations from the end to the end, and then automatically reporting buried point data based on preset rules through components or controls.

Advantages: greatly reduce the repetitive work of development and testing, reliable data volume, online visualization tools can be dynamically embedded configuration, no need to wait for the release of each time to take effect.

Disadvantages: Not flexible enough to collect information and unable to solve the problem of data backtracking

2. Implementation

Traceless burial point: With traceless burial point as the entry point, migrate to the Flutter platform in combination with the existing native solution.

Traceless buried points need automatic data collection, so for pages, controls and other elements need to generate their ID, the ID should be as “unique” and “stable” as possible. “Uniqueness” is easy to understand, because for any element, its ID should be different from all other elements, so that we can uniquely identify the element we want according to the ID, so that the data collected can be accurate and not repeated. “Stability” means that the ID of an element should not be changed from version to version, so that it is easier to associate business meanings later.

1. Rules for Flutter page IDS

According to the “uniqueness” and “stability”, the page where the type of a class as the ID, it is the only relative, in addition to the page reuse, basically there is no other name of the class of the same page (different package exception), secondly it is relatively stable, in addition to modify the name of the class will change, except for some major revision of page won’t easily modify the name of the class. In Flutter, pages are widgets, so ID definitions are as follows:

ID = Widget Type+” Additional parameters “(Widget is the current foreground page)

2. PV and UV of Flutter pages

Once we have a unique ID generation rule for the page, we can generate this ID when the page is exposed, and then upload the page to achieve PV and UV indicators. For page exposure timing, there is an interface RouteObserver on Flutter:

// Inheriting this class, configurable in MaterialApp, can configure multiple observers
class RouteObserver<R extends Route<dynamic>> extends NavigatorObserver {

   void didPop(Route<dynamic> route, Route<dynamic> previousRoute) {
         ...
   }
    voiddidPush(...) {... }... }Copy the code

It’s not enough to be able to monitor the exposure time of a page. Sometimes you need to know not only which page you’re on, but also how long you’re on a particular page, and how the app switches between the front and back. Also, there is an interface in Flutter that listens for the page lifecycle:

abstract class WidgetsBindingObserver {
    // Omit some code
  /// Called when the system puts the app in the background or returns
  /// the app to the foreground.
  ///
  /// An example of implementing this method is provided in the class-level
  /// documentation for the [WidgetsBindingObserver] class.
  ///
  /// This method exposes notifications from [SystemChannels.lifecycle].
  void didChangeAppLifecycleState(AppLifecycleState state) { }
}
Copy the code

AppLifecycleState is an enumeration class that contains four states:

enum AppLifecycleState {
    resumed,
    inactive,
    paused,
    detached,
}
Copy the code

The interface uses these four states to tell us how long we are on a page.

The above is the basic idea of collecting page PV, UV, page path, the specific code is not introduced, the logical reference to the original implementation. I focus on the user behavior operation, click behavior buried point data collection and implementation.

3. Rules for the Flutter component ID

The rules for component ids are more complex than the definition of a page. First of all, the components of a Flutter do not have an ID. Although every Widget of a Flutter can be identified by a unique key, we do not pass in a key when creating widgets unless there are special needs (e.g. reuse, etc.).

The components of each page are drawn on the page according to their parent-child and sibling relationships to construct a view tree. Starting from the observed component itself, search the view tree step by step until the root node, find the location information of the component in the tree and other characteristic information, so as to get a component path of a component in the view tree. In other words, according to this path, Locate this component in the view tree (image from the Geek Time-Flutter column) :

Start with Element tree again, through the reading of Element source code, Element implementation BuildContext, BuildContext defines a set of interfaces to get the parent Element with the specified RenderObject, the specified type of Widget, the specified State, and so on:

abstract class BuildContext {...///Search the Element parent node
   void visitAncestorElements(bool visitor(Element element));
   ///Search for Element child nodes
   void visitChildElements(bool visitor(Element element));
    
    T findAncestorWidgetOfExactType<T extends Widget>();
    T findAncestorStateOfType<T extends State>();
    T findAncestorRenderObjectOfType<T extendsRenderObject>(); . There are other omissions... }Copy the code

Element implements a specific search method:

void visitAncestorElements(bool visitor(Element element)) {
  assert(_debugCheckStateIsActiveForAncestorLookup());
  Element ancestor = _parent;
  while(ancestor ! =null && visitor(ancestor))
    ancestor = ancestor._parent;
}
Copy the code

Based on Element, the corresponding widget can be retrieved from the element.widget, and the specific path can be obtained from the widget.

If you choose to start with RenderObejct, it internally defines how to get parent and child nodes:

abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
    ///Gets the parent node in the tree
    AbstractNode? getparent => _parent; .// Traverse to search for child nodes
    void visitChildren(RenderObjectVisitor visitor) { }
    ...
}
Copy the code

RenderObject does not have an interface to retrieve the corresponding Element or Widget directly, but it does have a debugCreator property:

  /// The object responsible for creating this render object.
  /// Used in debug messages.
  Object? debugCreator;///Render OBEJCT specifies the object responsible for creating the Render Object, which holds the Render object
Copy the code

DebugCreator: DebugCreator: DebugCreator: DebugCreator: DebugCreator

/// A wrapper class for the [Element] that is the creator of a [RenderObject].
///
/// Attaching a [DebugCreator] attach the [RenderObject] will lead to better error
/// message.
class DebugCreator {
  /// Create a [DebugCreator] instance with input [Element].
  DebugCreator(this.element);

  /// The creator of the [RenderObject].
  final Element element;

  @override
  String toString() => element.debugGetCreatorChain(12);
}
Copy the code

This property is created in the mount and update methods of Element’s subclass RenderObjectElement:

@override
void mount(Element parent, dynamic newSlot) {
  super.mount(parent, newSlot);
 // omit some code...
  _renderObject = widget.createRenderObject(this);
// omit some code...
  assert(() {
      // Copy the debugCreator property method (the Assert part is removed on Release)
    _debugUpdateRenderObjectOwner();
    return true; } ());// omit some code...
}

  @override
  void update(covariant RenderObjectWidget newWidget) {
    super.update(newWidget); .assert(() {
            // Copy the debugCreator property method (the Assert part is removed on Release)
      _debugUpdateRenderObjectOwner();
      return true; } ()); . }void _debugUpdateRenderObjectOwner() {
    assert(() {
        // Pass the current Element to the DebugCreator for saving. RenderObjectElement inherited Element
      _renderObject.debugCreator = DebugCreator(this);
      return true; } ()); }Copy the code

As you can see, if the debugCreator property can be assigned in the RenderObject in this way, then the corresponding Element can be obtained from this property, and therefore the Widget can be obtained. But you can also see in your code that this attribute assignment is defined in Assert, and Release doesn’t do that, so you need to change that.

Therefore, if the Element can be obtained directly or indirectly when clicked, it can be generated according to the rules of the path above. For the GestureDetector in the figure above, its path is:

Contain [0] / Column [0] / Contain [1] / GestureDetector [0].

At the same time, in order to prevent the possible path in different pages from the same situation, add the current page identifier to the path, so the final rule of path is:

[Page ID: component path]

4. Analysis of Flutter events and gestures

To better understand gesture events in Flutter, here is a brief analysis:

Pointer events in Flutter represent the original touch data that the user interacts with, such as PointerDownEvent, PointerUpEvent, PointerCancelEvent, etc. Touch events occur when a finger touches the screen. A Flutter determines which components are at the trigger location and hands the touch event to the innermost component to respond to. The event starts at the innermost component and bubbles up the tree of components one level up to the root node.

By observing the debug of a click callback for a simple GestureDetector component, we get a call structure as shown below:

In the figure above, _rootRunUnary is a call implemented by the engine itself that passes the collected event to geSTUrebinding. _handlePointerDataPacket:

mixin GestureBinding on BindingBase implements HitTestable, HitTestDispatcher, HitTestTarget {
  @override
  void initInstances() {
    super.initInstances();
    _instance = this;
      ///Binding initializes with a callback method that accepts event data from the engine
    window.onPointerDataPacket = _handlePointerDataPacket;///OnPointerDataPacket is a function}... }Copy the code

The gesturebinding. _flushPointerEventQueue method fetches and processes the events in the queue:

final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();
void _flushPointerEventQueue() {
    assert(! locked);if (resamplingEnabled) {
      _resampler.addOrDispatchAll(_pendingPointerEvents);
      _resampler.sample(samplingOffset);
      return;
    }

    // Stop resampler if resampling is not enabled. This is a no-op if
    // resampling was never enabled.
    _resampler.stop();

    while (_pendingPointerEvents.isNotEmpty)
      _handlePointerEvent(_pendingPointerEvents.removeFirst());
  }
Copy the code

So, the actual handling of pointerEvents should start with the _handlePointerEvent method of GestureBinding:

  void _handlePointerEvent(PointerEvent event) {
    assert(! locked); HitTestResult? hitTestResult;if (event is PointerDownEvent || event is PointerSignalEvent) {
      assert(! _hitTests.containsKey(event.pointer)); hitTestResult = HitTestResult();///1. Create a HitTestResult object
      hitTest(hitTestResult, event.position);///2. HitTest, the actual first call to RendererBinding hitTest method
      if (event is PointerDownEvent) {
        _hitTests[event.pointer] = hitTestResult;///If it is a PointerDownEvent, create a mapping between the event id and hitTestResult}... }else if (event is PointerUpEvent || event is PointerCancelEvent) {
      hitTestResult = _hitTests.remove(event.pointer);///Removed after the event sequence ends
    } else if (event.down) {
        ///Other events are reusing Down events to avoid hitting a test every time (e.g. PointerMoveEvents)hitTestResult = _hitTests[event.pointer]; }...if(hitTestResult ! =null ||
        event is PointerHoverEvent ||
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      assert(event.position ! =null);
      dispatchEvent(event, hitTestResult);///Distribute events}}Copy the code

Several comments in the code:

  1. If it is a PointerDownEvent or PointerSignalEvent, create a HitTestResult object with an internal _PATH field (set).

  2. The hitTest is done by calling the hitTest method, which creates HitTestEntry with itself as a parameter and then adds the HitTestEntry object to the _path of HitTestResult. There is only one HitTestTarget field in HitTestEntry. In effect, this created HitTestEntry is added to the _path field of HitTestResult as a path node in the event distribution bubble sort.

     ///The RendererBinding hitTest method is defined as follows:
    void hitTest(HitTestResult result, Offset position) {
        assert(renderView ! =null);
        assert(result ! =null);
        assert(position ! =null);
        renderView.hitTest(result, position: position);
        super.hitTest(result, position);
      }
    Copy the code

    There are two main steps to an internal call:

    • Call RenderView’s hitTest method (hitTest from the root RenderView node):

        bool hitTest(HitTestResult result, { required Offset position }) {
          if(child ! =null)
              ///Internally, the child is hit tested firstchild! .hitTest(BoxHitTestResult.wrap(result), position: position); result.add(HitTestEntry(this));///Add yourself to_path field, as a path node for event distribution
          return true;
          }
             ///The 'hitTest' method is implemented in RenderBox:
           bool hitTest(HitTestResult result, { @required Offset position }) {
               ///. Get rid of assert
               ///This is to determine whether the clicked location is in the size range and on the current RenderObject node
             if (_size.contains(position)) {
               ///At the current node, if either child or its own hitTest returns true, it is added to HitTestResult
               if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
                 result.add(BoxHitTestEntry(this, position));
                 return true; }}return false;
      Copy the code
    • Call the parent’s hitTest method, that is, the GestureBinding hitTest method:

        @override // from HitTestable
        void hitTest(HitTestResult result, Offset position) {
          result.add(HitTestEntry(this));
        }
      Copy the code

After a series of hittests, the following judgments are made:

if(hitTestResult ! =null ||
    event is PointerHoverEvent ||
    event is PointerAddedEvent ||
    event is PointerRemovedEvent) {
  assert(event.position ! =null);
  dispatchEvent(event, hitTestResult);
}
Copy the code

Call the dispatchEvent method to GestureBinding:

void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
   ...
  for (final HitTestEntry entry in hitTestResult.path) {
    try {
      entry.target.handleEvent(event.transformed(entry.transform), entry);
    } catch(exception, stack) { .... ) ); }}}Copy the code

This method iterates through each HitTestEntry in _path, fetching target for event distribution. The HitTestTarget has several bindings, all of which are implemented by RenderObject. So we distribute events to each RenderObject node, which we call “event bubbling.” The first bubbling node is the smallest child node (the innermost component), and the last one is a GestureBinding.

Note that there is no mechanism in Flutter to cancel or stop the further distribution of events. We can only adjust how components should behave during the hit behavior of Flutter, and only components that pass the hit test can trigger events.

So, the _handlePointerEvent method basically calculates the required HitTestResult through the hitTest method and then dispatchEvent to distribute the event.

The above is a simple analysis of the event distribution of Flutter. Specifically, there is a lot of processing inside Flutter. In Flutter, components with gesture click events can be implemented.

  1. Use the Listener component directly to listen for events
  2. Others are based on the gesture recognizerGestureRecoginzerThe implementation of the:
    • useGestureDetectorcomponent
    • useFloatButton,InkWell. Such structure as: XX — XX ->GestureDecector->ListenerThis kind of reliance onGestureDecector->ListenerThe components of the
    • similarSwitchThe interior is also based onGestureRecoginzerImplemented components

On the second point, in order to determine which gesture to respond to when encountering multiple gesture conflicts, one has to go through a “gesture arena” process, which recognizer recognizer recognizers call structure above, and which wins in the “gesture arena” will ultimately translate to the event response component level.

The above is a general process analysis of gesture events, understanding its principle and basic process, can better help us to complete the realization of automatic burying point function. If you are not sure how the Flutter gesture event works, go to other sources or comment on it.

5.AOP

From the above description, we must be able to get our data from the click, double click, and long press callback function directly by calling the SDK buried code. So how can we automate this step?

AOP: insert the specified code at the specified pointcut, and process all the code in one SDK, which can maximize the non-intrusion into our business.

Aspectd, an AOP framework for Flutter design, is available on Github.

Based on the analysis of gesture events above, choose the following two entry points (of course, there are also other entry methods) :

  • HitTestTargetthehandleEvent(PointerEvent event,HitTestEntry entry)Methods;
  • GestureRecognizertheinvokeCallback<T>(String name,RecognizerCallback<T> callback,{String debugReport})Methods;

The code is roughly as follows:

 @Call("package:flutter/src/gestures/hit_test.dart"."HitTestTarget"."-handleEvent")
  @pragma("vm:entry-point")
  dynamic hookHitTestTargetHandleEvent(PointCut pointCut) {
    dynamic target = pointCut.target;
    PointerEvent pointerEvent = pointCut.positionalParams[0];
    HitTestEntry entry = pointCut.positionalParams[1];
    curPointerCode = pointerEvent.pointer;
    if (target is RenderObject) {
        if (curPointerCode > prePointerCode) {
          clearClickRenderMapData();
        }
        if(! clickRenderMap.containsKey(curPointerCode)) { clickRenderMap[curPointerCode] = target; } } prePointerCode = curPointerCode; target.handleEvent(pointerEvent, entry); }@Call("package:flutter/src/gestures/recognizer.dart"."GestureRecognizer"."-invokeCallback")
  @pragma("vm:entry-point")
  dynamic hookinvokeCallback(PointCut pointcut) {
    var result = pointcut.proceed();
    if (curPointerCode > preHitPointer) {
      String argumentName = pointcut.positionalParams[0];

      if (argumentName == 'onTap' ||
          argumentName == 'onTapDown' ||
          argumentName == 'onDoubleTap') {
        RenderObject clickRender = clickRenderMap[curPointerCode];
        if(clickRender ! =null) {
          DebugCreator creator = clickRender.debugCreator;
          Element element = creator.element;
          // Get the path from element
          String elementPath = getElementPath(element);
          ///Rich acquisition timerichJsonInfo(element, argumentName, elementPath); } preHitPointer = curPointerCode; }}return result;
  }
Copy the code

The general implementation idea is as follows:

  1. Log events unique by MappointerIdentifier and response ofRenderObjectIs only recorded_pathThe first, which is the smallest child to hit the test, and records the current sequence of eventspointer(pointerIs unique in a sequence of events and increments by 1 each time a gesture event occurs.
  2. inGestureRecognizertheinvokeCallback<T>(String name,RecognizerCallback<T> callback,{String debugReport})Method, through the record abovepointer, and is retrieved from MapRenderObject, takedebugCreatorProperties areElementAnd get the correspondingwidget;

The RenderObject debugCreator field represents the object that is responsible for creating the RenderObject. The creation process is written in aessert, so it can only be obtained in debug mode. It is actually created in the source code at the mount of RenderObjectElement, and is also updated when the update is executed:

@override
void mount(Element parent, dynamic newSlot) {
  super.mount(parent, newSlot);
 // omit some code...
  _renderObject = widget.createRenderObject(this);
// omit some code...
  assert(() {
      // The assert section will be removed on Release
    _debugUpdateRenderObjectOwner();
    return true; } ());// omit some code...
}

void _debugUpdateRenderObjectOwner() {
    assert(() {
        // Pass the current Element to the DebugCreator for saving. RenderObjectElement inherited Element
      _renderObject.debugCreator = DebugCreator(this);
      return true; } ()); }Copy the code

In order for this data to be available in Release mode when we are on AOP, we need to handle it in a special way. Since it can only be created under DEBUG in the source code, we will create conditions for it to be created under Release as well.

@Execute("package:flutter/src/widgets/framework.dart"."RenderObjectElement"."-mount")
@pragma('vm:entry-point')
static dynamic hookElementMount(PointCut pointCut){
    dynamic obj = pointCut.proceed;
    Element element = pointCut.target;
    if(kReleaseMode||kProfileMode){
        // The release and profile modes create this propertyelement.renderObject.debugCreator = DebugCreator(element); }}@Execute('package:flutter/src/widgets/framework.dart'.'RenderObjectElement'.'-update')
@pragma('vm:entry-point')
static dynamic hookElementUpdate(PointCut pointCut){
    dynamic obj = pointCut.proceed;
    Element element = pointCut.target;
    if(kReleaseMode||kProfileMode){
        // The release and profile modes create this propertyelement.renderObject.debugCreator = DebugCreator(element); }}Copy the code

After processing the debugCreator field, we can retrieve the corresponding Element based on the RenderObject. Once we obtain the Element, we can calculate the component’s path ID.

Through the above operations, we get the following results after a click test on a GestureDetector in practice:

GestureDetector[0]/Column[0]/Contain[0]/BodyBuilder[0]/MediaQuery[0]/LayoutId[0]/CustomMultiChildLayout[0]/AnimatedBuild er[0]/DefaultTextStyle[0]/AnimatedDefaultTextStyle[0]/_InkFeatures[0]/NotificationListener<LayoutChangedNotification>[0] /PhysicalModel[0]/AnimatedPhysicalModel[0]/Material[0]/PrimaryScrollController[0]/_ScaffoldScope[0]/Scaffold[0]/MyHomePa ge[0]... /MyApp[0]Copy the code

After comparison, this does seem to be the path of the component we created in our code, but there seem to be a lot of strange component paths in the middle, which we didn’t create ourselves, and there are still some issues to optimize.

6. Optimization of component ID

  1. The component path ID is too long:

    The component path ID is long. Because of the nested wrapper nature of the Flutter layout, if you search all the way up to the parent node, you will find MyApp, which also contains many components created within the system.

  2. Different platform features :(== remove this point, there is no need to optimize, because platform features will only appear in the internal nodes of the system, their own writing unless there is a special judgment, otherwise there will be no difference ==)

    On different platforms, a node in the path may be inconsistent in order to maintain the style of certain platforms’ features (for example, on IOS the path may have a slippage node, but not on other platforms). For example, if you start with “Cupertino” or “Material”, you want to screen out the differences.

  3. Dynamically inserting widgets is unstable

    According to the rules defined above, in the case of page elements don’t change, is basically can guarantee “stability” and “uniqueness”, but if the page elements changes dynamically, or between different versions of the UI has carried on the revision, we defined rules will become unstable, also may not be the only, such as shown below:

Contain[0]/Column[0]/Contain[2]/GestureDetector[0] Change the location of sibling nodes to the location of components of the same type. The optimized component path is Contain[0]/Column[0]/Contain[1]/GestureDetector[0]. In this way, when a Widget of a different type is inserted, its path remains the same, but it will change if the Widget of the same type is inserted, so it is relatively stable.

So how to optimize the rest of the problem?

7.Dart metaprogramming solves legacy issues

Problem 1: The actual path we get is not the component path we created in the code, for example:

// create a folder with your own code
@override
Widget build(BuildContext context){
    return Contain(
       child:Text('text')); }// Contain an internal build function that contains several layers of information, including other components
@override
  Widget build(BuildContext context) {
    Widget current = child;
    if (child == null && (constraints == null| |! constraints.isTight)) { current = LimitedBox( maxWidth:0.0,
        maxHeight: 0.0,
        child: ConstrainedBox(constraints: constBoxConstraints.expand()), ); }... Omit some codeif(alignment ! =null) current = Align(alignment: alignment, child: current); . Omit some codereturn current;
  }
Copy the code

Because of this, three things can happen:

  • When we get component paths in the above way, there are a lot of component paths that we don’t care about that much, even though they are components on the path, we really only want to focus on the part we created. The key is how to get rid of the “redundant component paths”.
  • System components sometimes use different components internally to support platform features in some cases, and these differences need to be masked.
  • Because of the unique nesting of Flutter, each component that searches for its parent node will end up in main. In fact, we only need to divide Flutter by the current page.

How to solve it? Notice that when we use the tool that comes with Flutter, the Flutter Inspector, to observe the page we created, what we want to see is the component display:

As you can see from the figure, the display form of widgets completely represents the structure of creating widgets in our own page code. How does this work?

In fact, this is implemented through a WidgetInspectorService service, a service that GUI tools use to interact with WidgetInspector. Dart is registered through initServiceExtensions in Foundation/Binding.dart, and this extension service is registered only in the Debug environment.

By analyzing the official open source source code of dev-Tools, the key methods of its application layer are as follows:

// Returns if an object is user created.
// Returns whether the object was created by itself (here we are dealing with widgets)
bool _isLocalCreationLocation(Object object) {
  final _Location location = _getCreationLocation(object);
  if (location == null)
    return false;
  return WidgetInspectorService.instance._isLocalCreationLocation(location);
}

/// Creation locations are only available for debug mode builds when
/// the `--track-widget-creation` flag is passed to `flutter_tool`The Dart is 2.0
/// required as injecting creation locations requires a
/// [Dart Kernel Transformer] (https://github.com/dart-lang/sdk/wiki/Kernel-Documentation).
///
/// Currently creation locations are only available for [Widget] and [Element].
_Location _getCreationLocation(Object object) {
  final Object candidate =  object is Element ? object.widget : object;
  return candidate is _HasCreationLocation ? candidate._location : null;
}


  bool _isLocalCreationLocation(_Location location) {
    if (location == null || location.file == null) {
      return false;
    }
    final String file = Uri.parse(location.file).path;

    // By default check whether the creation location was within package:flutter.
    if (_pubRootDirectories == null) {
      // TODO(chunhtai): Make it more robust once
      // https://github.com/flutter/flutter/issues/32660 is fixed.
      return! file.contains('packages/flutter/');
    }
    for (final String directory in _pubRootDirectories) {
      if (file.startsWith(directory)) {
        return true; }}return false;
  }
Copy the code

The two key classes, _Location and _HasCreationLocation, are implemented in Dart Kernel Transformer at compile time, similar to the Transform implemented by ASM in Android. Dart also has a Transform that implements specific operations at compile time, which can be found in the Dart source code.

Widget_inspctor implements the abstract _HasCreationLocation class for all widgets using a specific Transform during compilation in Debug mode, and modiizes the Widget’s constructor function. Add a named parameter (type _Location) and use the AST to assign a value to the _Location property to implement the transform.

However, this function can only be enabled in Debug mode. To achieve this effect, we can only implement a Transform, which can be used in non-Debug mode. Furthermore, we can take advantage of the existing features of AspectD and add a Transform of our own. We don’t need to add complicated information such as the column and column created by the widget. We just need to be able to tell that the widget was created by the developer’s own project.

There are also a few points to note in the implementation process:

  1. When creating a widget, a const modifier, as in the following example, needs to be handled as a separate Transform.

    Text widget = const Text('words');
    Contain(
     child:const Text('words'));Copy the code
  2. In debug, TreeNode Location field can be used to distinguish the widgets created by your project, but in Release, this field is null.

  3. If Aspectd is used, the Transform Transform you add will precede several transforms implemented within Aspectd. Because Aspectd’s call API, for example, replaces method calls when used in constructors, it would be invalid to inject after this. So the order of transformations should be to modify normal constructs first, then to deal with constant declaration expressions, and finally to Aspectd’s own transformations.

Track_widget_constructor_locations. Dart (transform_constructor_locations)

  • The widget implements this class. Note that the class definition needs to be directly or indirectly used in the main method, and the corresponding _resolveFlutterClasse method needs to be modified.

    void _resloveFlutterClasses(可迭代<Library> libraries){
        for(Library library in libraries){
            final Uri importUri = library.importUri;
            if(importUri ! =null && importUri.scheme == 'package') {// Define the full path of the class yourself, for example: example/local_widget_track_class.dart
                if(importUri.path = 'example/local_widget_track_class.dart') {for(Class cls in library.classes){
                        // Define the class name, such as LocalWidgetLocation
                        if(cls.name = 'LocalWidgetLocation'){ _localWidgetLocation = cls; }}}else if(importUri.path == 'flutter/src/widgets/framework.dart'| |...). {... }}}}Copy the code
  • Inheriting the Transformer main need to implement visitStaticInvocation, visitConstructorInvocation method:

      @override
      StaticInvocation visitStaticInvocation(StaticInvocation node) {
        node.transformChildren(this);
        final Procedure target = node.target;
        if(! target.isFactory) {return node;
        }
        final Class constructedClass = target.enclosingClass;
        if(! _isSubclassOfWidget(constructedClass)) {return node;
        }
    
        _addLocationArgument(node, target.function, constructedClass);
        return node;
      }
    
      @override
      ConstructorInvocation visitConstructorInvocation(ConstructorInvocation node) {
        node.transformChildren(this);
        final Constructor constructor = node.target;
        final Class constructedClass = constructor.enclosingClass;
        if(_isSubclassOfWidget(constructedClass)){
          _addLocationArgument(node, constructor.function, constructedClass);
        return node;
      }
          
       void _addLocationArgument(InvocationExpression node, FunctionNode function,
          Class constructedClass) {
        _maybeAddCreationLocationArgument(
          node.arguments,
          function,
          ConstantExpression(BoolConstant(true))); }void _maybeAddCreationLocationArgument(
        Arguments arguments,
        FunctionNode function,
        Expression creationLocation,
        ) {
      if (_hasNamedArgument(arguments, _creationLocationParameterName)) {
        return;
      }
      if(! _hasNamedParameter(function, _creationLocationParameterName)) {if(function.requiredParameterCount ! = function.positionalParameters.length) {return; }}final NamedExpression namedArgument = NamedExpression(_creationLocationParameterName, creationLocation);
      namedArgument.parent = arguments;
      arguments.named.add(namedArgument);
    }
    Copy the code
  • For const decorated widgets, the injected property is handled as a separate Transform that overrides the visitConstantExpression method, We do this by adding a value to the InstanceConstant filedValue field.

    Text widget = const Text('words');
    Contain(
     child:const Text('words'));//Transform example code is as follows:
      @override
      TreeNode visitConstantExpression(ConstantExpression node) {
        node.transformChildren(this);
          if (node.constant is InstanceConstant) {
            InstanceConstant instanceConstant = node.constant;
            Class clsNode = instanceConstant.classReference.node;
            if (clsNode is Class && _isSubclassOf(clsNode, _widgetClass)) {
              final Name fieldName = Name(
                _locationFieldName,
                _localCreatedClass.enclosingLibrary,
              );
              Reference useReference = _localFieldReference;
              final Field locationField =
                  Field(fieldName, isFinal: true, reference: useReference,isConst: true);
              useReference.node = locationField;
              Constant constant = BoolConstant(true); instanceConstant.fieldValues .putIfAbsent(useReference, () => constant); }}return super.visitConstantExpression(node);
      }
    Copy the code

The implementation of the above code is not difficult, and you can refer to similar implementations in the Dart source code. Using the Transform above, we can solve the problem of “redundant component paths” perfectly, and now we have the actual path of the widget created by our own code:

GestureDetector[0]/ Column[0]/Center[0]/Scaffold[0]/MyHomePage[0]/MaterialApp[0]/MyApp[0]
Copy the code

Also, because using the Listener component directly, the invokeCallback method of the GestureRecognizer is not called, it is important to filter out this case and handle it separately. If you create a Listener directly by your own code, use the Listener to calculate the path ID of the node. Otherwise, the path is calculated by subsequent invokeCallback. The modified code is as follows:

  @Call("package:flutter/src/gestures/hit_test.dart"."HitTestTarget"."-handleEvent")
  @pragma("vm:entry-point")
  dynamic hookHitTestTargetHandleEvent(PointCut pointCut) {
    dynamic target = pointCut.target;
    PointerEvent pointerEvent = pointCut.positionalParams[0];
    HitTestEntry entry = pointCut.positionalParams[1];
    curPointerCode = pointerEvent.pointer;
    if (target is RenderObject) {
      bool localListenerWidget = false;
      if (target is RenderPointerListener) {
        ///The Listener is used for processing alone
        RenderPointerListener pointerListener = target;
        if(pointerListener.onPointerDown ! =null &&
            pointerEvent is PointerDownEvent) {
          DebugCreator debugCreator = pointerListener.debugCreator;
          dynamic widget;
          debugCreator.element.visitAncestorElements((element) {
            if (element.widget is Listener) {
              widget = element.widget;
              if(widget.isLocal ! =null && widget.isLocal) {
                localListenerWidget = true;
                String elementPath = getElementPath(element);
                // Enrich information about current events
                richJsonInfo(element, element, 'onTap', elementPath);
              }
              //else if(...) // Can filter sideslip return may affect the situation. Because it set itself HitTestBehavior. Translucent, click the slide bar area it could be the minimum widgets we think
            }
            return false; }); }}if(! localListenerWidget) {if (curPointerCode > prePointerCode) {
          clearClickRenderMapData();
        }
        if(! clickRenderMap.containsKey(curPointerCode)) { clickRenderMap[curPointerCode] = target; } } } prePointerCode = curPointerCode; target.handleEvent(pointerEvent, entry); }Copy the code

For the path, we need to optimize further: for the clicked component, we need to determine which page or route is currently displayed to split the page. To do this, we listen to ModalRoute’s buildPage method, which is an abstract method with different implementations for different types of routes. We split each page into the end node searched by the current page node, resulting in the actual path ID path, with the code roughly as follows:

class CurPageInfo {
  Type curScreenPage;
  Type curDialogPage;
  ModalRoute curRoute;
  BuildContext curPageContext;
  CurPageInfo(this.curScreenPage, this.curPageContext);
}  

@Call('package:flutter/src/widgets/routes.dart'.'ModalRoute'.'-buildPage')
  @pragma('vm:entry-point')
  dynamic hookRouteBuildPage(PointCut pointcut) {
    ModalRoute target = pointcut.target;
    List<dynamic> positionalParams = pointcut.positionalParams;
      WidgetsBinding.instance.addPostFrameCallback((callback) {
        BuildContext buildContext = positionalParams[0];
        bool isLocal = false;
        while(buildContext ! =null && !isLocal) {
          buildContext.visitChildElements((ele) {
            dynamic widget = ele.widget;
            if(widget.isLocal ! =null && widget.isLocal) {
              isLocal = widget.isLocal;
              print('Page of the current Page =${widget.runtimeType} isLocal = $isLocal');
              if(target.opaque){   ///Opaque means not transparent. True means opaque
                curPageInfo = CurPageInfo(widget.runtimeType,positionalParams[0]);
              }else{
                curPageInfo.curPageContext = positionalParams[0];///The first parameter is the same as the previous Page
                curPageInfo.curDialogPage = widget.runtimeType;
              }
              return;
            }
            buildContext = ele;
          });
        }
        curPageInfo.curRoute = target;
      });
    return target.buildPage(positionalParams[0], positionalParams[1], positionalParams[2]);
  }
Copy the code

Note that the display of a Flutter popover Dialog is also a route. This should not be treated as a Page in general, so special treatment should be made when calculating the current Page.

After optimization, the resulting path is:

GestureDetector[0]/ Column[0]/Center[0]/Scaffold[0]/MyHomePage[0] 
Copy the code

You can see that it is now possible to actually split paths by Page Page.

After data collection is completed in the above way, the only thing left to do is to convert the original data into the data format we need (such as converting and packaging into standard Json), and we can add more attribute fields (such as mobile phone version and model, App version, timestamp…) during the collection. To enrich the collected events, and then stored in the form of queues in our database, the reported data of the database can be deleted after reporting to the server.

8. Other issues in the implementation process

When landing in the actual project to implement, of course, there will be some other problems, such as:

  1. similarCupertinoThe sideslip return style leads to this error in click-on-click statistics;
  2. How to enrich the current click component information, if the current component needs such as text, pictures and other information;
  3. Compatibility processing for special components;
  4. AspectdSome problems with the framework itself;
  5. Visualization buried point function tools;
  6. .

Some problems in the landing can be solved by spending more time, maybe you will find a better solution, because of the space problem is not introduced here. And some questions on the implementation and use of Ascpetd, I gave the framework PR, part has already been merged, interested can go to have a look, if learn the principle of the framework and the actually find can realize the function of, much more than the framework itself is so simple, such as in the face of the component on the transform is also have the same thoughts.

3. Results presentation

After completing the above operations, we put together a Demo that shows the following:

1. General click components (mainly GestureDecector, Listener, GestureDecector derived classes):

2. A few special Click Widgets:

3. Collection on Dialog:

4. Single list data:

5. Lists with complex components:

6. The Tab:

No function data collection is not everything. There’s mastercard &visa buried point is not a silver bullet, such as uniqueness, this component will be may change as the version of the iteration (can be uploaded the App version number to distinguish between different versions of the data, distinguish, after a lot of data can be summarized together), for visual buried point, The data of different versions is not completely universal. Only by knowing the realization principle, can we know which cases the data is inaccurate. Only by looking at these problems and explaining them from the perspective of technicians can we avoid the wrong direction of product operation strategy.

4. Conclusion

The implementation of this program in the company’s products has been optimized for about one year from the beginning to now. In order to target the company’s products, some places have also been customized, but the basic principle remains unchanged. Due to my limited level, if there are mistakes in the article or can be more optimized, welcome to communicate and make progress together. In addition, due to space limitation, some parts of the article are not dealt with one by one, and we will share them later.

This article without permission, refused to reprint any form!

In this paper, some of the concepts of buried point and data analysis are explained from the Internet, for the purpose of better understanding.