preface

Today’s article to introduce the content, is we often use a scene: buried point. In order to quantitatively analyze the data of behavioral characteristics and optimize the product, we often need to report data buried point at a specific time, and exposure buried point is one of the high-frequency use scenarios.

The pain of sliding buried points

In Flutter, we usually report exposure buries during the initState lifecycle, which is of course not a problem in normal usage scenarios. However, this solution does not work in the sliding scenario, so let’s see.

Obviously, we printed widgets that weren’t shown otherwise. If you do so, the buried point report is not accurate, which will cause irreparable loss to the service.

ScrollView loading mechanism

Why does this happen? After reviewing the source code, we see that all ScrollViews are drawn in a Viewport area. To make sliding more smooth, scrollViews are usually loaded outside of the Viewport area, called cacheExtent. Items that fall into this cache are laid out even if they are not yet visible on the screen. InitState is then executed. ListView, a subclass of ScrollView, also uses this mechanism.

Naturally, the simplest solution would be to disable preloading:

ListView.builder( cacheExtent: 0, itemCount: 40, itemBuilder: (context, index) { return Item(index: index); },),Copy the code

Well, that’s the end of this article, have you learned.

The new problem

Just kidding, as you can easily imagine, there is a high probability that this will cause performance problems. In our real business, considering the worst device performance supported, and the complexity of the business, it is certainly not so simple to cancel the preloading can be solved.

When doing the test, it was found that if the caching mechanism was removed, the average frame rate would drop by 5-10 frames, which was still the test result on the better OnePlus phone, which was of course unacceptable. (Not to mention ListView performance issues with 1.x version Flutter itself)

So what we want is a highly accurate burying scheme for user behavior on Flutter without affecting the performance of the ScrollView.

broken

Once you’ve thought through the requirements, you’re halfway there. After we looked up the existing information in the industry, we found that idle fish technology has shared a better idea to solve the problem: reveal! How to design a high accuracy Buried point frame of Flutter [1]. However, there is no open source plan for this solution, so I have to write one myself. How do we solve this problem?

As mentioned earlier, each ScrollView has its own Viewport to determine its drawing scope. This Viewport generates a RenderObjectElement that can render the region individually, minimizing the impact of return. So the question now becomes we want to calculate when an Item goes into the Viewport.

A complex problem needs to be abstracted into a simpler problem and solved step by step. Let’s think of Item as a point and see what information is needed to calculate whether an Item is in a Viewport.

It’s easy to think about the Scroll Offset of the slide, the Viewport Length of the slide, and the item itself, That is, the Exposure Offset of the current item from the sliding starting point.

Imagine sliding, an Item slides in from the right side of the ViewPort, enters the ViewPort, is seen by the user, and then slides out from the left side of the ViewPort. We can abstract this process into the following four states:

  • (Scroll Offset + ViewPort Length < Exposure Offset)
  • (Scroll Offset + ViewPort Length > Exposure Offset)
  • The Item in the ViewPort
  • Exposure Offset < Scroll Offset

For the left to right, here are the states:

  • Exposure Offset < Scroll Offset
  • [Exposure Offset > Scroll Offset]
  • The Item in the ViewPort
  • (Scroll Offset + ViewPort Length < Exposure Offset)

It can be found from observation that the judging timing of Item is different from that of Item drawn from the left and right, so we need to distinguish the two sliding cases.

Now let’s add the Item Width and use the above conclusion to calculate it.

We’re going to assume for the moment that an exposure is only when an Item is fully mapped into the ViewPort.

  • (Scroll Offset + ViewPort Length < Exposure Offset)
  • (Scroll Offset + ViewPort Length > Exposure Offset)
  • The Item in the ViewPort
  • Exposure Offset + Item Width < Scroll Offset

For the left to right, here are the states:

  • Exposure Offset + Item Width < Scroll Offset

  • Exposure Offset + Item Width > Scroll Offset

  • The Item in the ViewPort

  • (Scroll Offset + ViewPort Length < Exposure Offset)

    How to obtain this information

Now that you know how, it’s just a matter of looking for the pieces of the puzzle.

Item size information

This one is relatively simple. We all know that we can use Widget BuildContext to get the corresponding RenderObject, and use it to get the current Item’s length and width.

// The size of the exposure pit is named here. For different sliding directions, we need to use different lengths. final exposurePitSize = (context.findRenderObject() as RenderBox).size;Copy the code

The context here is the context of the Item we want to expose or not. If you’re not sure about the concept, check out this article for an in-depth understanding of BuildContext[2].

Note: Not every Widget will create a RenderObject, only RenderObjectWidget will. ListView will default to help add a RepaintBoundary each Item, the Widget is a SingleChildRenderObjectWidget, So each Item actually has a corresponding RenderObject.

If (addRepaintBoundaries) child = RepaintBoundary(child: child);Copy the code

ViewPort Indicates the size

The ViewPort exists in the ListView hierarchy, so we need to find it from the ancestor node. Fortunately, Flutter already provides us with this method.

static RenderAbstractViewport? of(RenderObject? object) { while (object ! = null) { if (object is RenderAbstractViewport) return object; object = object.parent as RenderObject? ; } return null; }Copy the code

We have just got the Item corresponding to render objects, RenderAbstractViewport. Of can pass the RenderObject upward looking for ancestor nodes, Until we find the RenderAbstractViewport that’s closest to it, we’ll get the ViewPort information we want.

Size? getViewPortSize(BuildContext context) { final RenderObject? box = context.findRenderObject(); final RenderAbstractViewport? viewport = RenderAbstractViewport.of(box); assert(() { if (viewport ! = null) { debugPrint('Please make sure you have a `ScrollView` in ancestor'); return false; } return true; }); final Size? size = viewport? .paintBounds.size; return size; }Copy the code

The distance between the Item and the sliding start of the ViewPort

In getOffsetToReveal, another method of RenderAbstractViewport, we can get the starting position of the current RenderObject sliding relative to the ViewPort.

double getExposureOffset(BuildContext context) { final RenderObject? box = context.findRenderObject(); final RenderAbstractViewport? viewport = RenderAbstractViewport.of(box); if (viewport == null || box == null || ! Box. Attached) {return 0.0; } // box is the current Item's RenderObject // When alignment is 0, the relative offset from the start is obtained; // When alignment is 1, the relative offset from the end is obtained. Final RevealedOffset offsetRevealToTop = viewport. GetOffsetToReveal (box, 0.0, the rect: the rect. Zero). return offsetRevealToTop.offset; }Copy the code

Sliding distance

There are usually two ways to obtain the slip distance:

  • throughScrollControllerTo obtain.
  • Use the Scrollable WidgetNotificationMechanism.

Having to write ScrollController every time you write code seems a bit cumbersome, so we chose Notification. (It’s also more versatile)

Scroll Notification

The Scrollable Widget notifies its ancestors about scroll changes, which can be captured using a NotificationListener. Currently, there are the following types of Notification:

  • ScrollStartNotification: Scrolling starts when initiatedNotification.
  • ScrollUpdateNotification: Continuously initiate while scrollingNotification. (High frequency)
  • ScrollEndNotification: launched at the end of scrollNotification.
  • UserScrollNotification: Initiates notification when the user changes the scrolling direction. (Usually occurs when scrollViews in different directions are nested with each other)

We use a NotificationListener here to get the slide information.

Widget buildNotificationWidget(BuildContext context, Widget child) { return NotificationListener<ScrollNotification>( onNotification: (scrollNotification) {// Get the scrollinformation}, child: ScrollView,); }Copy the code

Solve information sharing problems

Look at this, it looks like we have all the pieces of the puzzle, but what does it feel like?

If you’re keen, you’ll see that with our current design, you can’t get all the information in one place.

Data acquisition location is inconsistent

Scroll Notification will only send Notification notifications to ancestor nodes, that is, we can’t get them at the Item level!

If we want to determine the buried point exposure in Item, we must obtain the scrollNotification in the higher ancestor node.

Of course, there are many solutions. Sharing state is a common Case in state management, but introducing a state management library in order to slide buried point exposure seems to be a bit of a waste. Therefore, it is better to use the primitive Inherit mechanism of Flutter to share data.

What is the Inherit mechanism

To understand the Inherit mechanism, you first need to understand the three trees of Flutter. There are many explanation articles on the Internet, so I won’t go into detail. If you are interested, you can see how the Widget, Element and Render of Flutter form tree structure in [3]. [4].

In a nutshell, the Inherit mechanism is a way to share data from top to bottom in a Flutter. We know that a Flutter builds its views through a tree structure, The InheritedWidget allows its data to be accessed by widgets in all of the child nodes.

Is it possible that each Element holds an InheritedElement called Map

? A reference to the Map of _inheritedWidgets when our Element is mounted to the Element Tree (_updateInheritance is called when the mount is performed), Will keep a copy of the _InheritedWidget references saved in parent.
,>

void _updateInheritance() { assert(_lifecycleState == _ElementLifecycle.active); _inheritedWidgets = _parent? ._inheritedWidgets; }Copy the code

The Element created by the InheritedWidget inserts itself into the map when it is mounted, thus enabling top-down data sharing.

@override void _updateInheritance() { assert(_lifecycleState == _ElementLifecycle.active); final Map<Type, InheritedElement>? incomingWidgets = _parent? ._inheritedWidgets; if (incomingWidgets ! = null) _inheritedWidgets = HashMap<Type, InheritedElement>.from(incomingWidgets); else _inheritedWidgets = HashMap<Type, InheritedElement>(); _inheritedWidgets! [widget.runtimeType] = this; }Copy the code

Based on this, we can complete the calculation of sliding buried point exposure, which is gratifying.

With it you

For experienced developers like us, when we see a good article like this, the first thing we want to do is try it out for ourselves.

Just bring it to me

So for your precious (water-skiing/chitchat/baby-sitting /…) Time, this sliding burying point scheme has landed in Pub warehouse [5], you can rest assured to eat.

Currently supported are:

  • Lazy exposure mode: exposure only when scrolling ends;
  • Exposure ratio: you can control the scope of Item display as an exposure;
  • Track when the Item leaves the visual range: the exposure time can be obtained;
  • Support for all ScrollViews: includesListView,GridView,CustomScrollViewAnd so on.

Write in the last

This solution was actually used in the company last year, but was not open source. Here also thanks to the valuable ideas provided by Xianyu Technology [7], recently gathered some bits and pieces of time to finish it, while the first day of National Day to write this article, I hope you can share a little harvest ~