1
头图

Author: Shinjuku, Koshu

At present, the main business scenarios of Xianyu have been implemented using Flutter, among which streaming layout is the most common page layout scenario (such as search, product details, etc.). With the rapid iteration of business and the continuous improvement of business complexity, the capabilities and performance requirements for streaming scenarios are getting higher and higher;

  • In terms of capabilities, the most common capabilities such as card exposure, scrolling anchor points, waterfall flow layout, etc., as business and requirements continue to change, Flutter's native and some open source solutions are gradually unable to meet our needs;
  • In terms of performance, the smoothness of list scrolling in streaming scenarios gradually deteriorates with the increase of business complexity, and it is urgent to solve it to improve the user experience.

In response to the above problems faced in business, we designed a set of universal page layout solutions in streaming scenarios, and we named it PowerScrollView.

Overall architecture design

Before the architecture design, we fully investigated the native scroll containers: UICollectionView (iOS) and RecyclerView (Android). Among them, the Section concept of UICollectionView impressed us, and the architectural design of RecyclerView also inspired us. Due to the uniqueness of Flutter, we can't copy it, so our goal is to combine the Native mature rolling container with the characteristics of Flutter to design a more excellent rolling container.

Flutter natively has commonly used ListView and GridView, their layout is relatively simple, and their functions are relatively simple. The official also provides an advanced Widget of CustomScrollView. CustomScrollView is spliced by multiple Slivers to adapt to more complex usage scenarios. We will design based on CustomScrollView.

From the perspective of usage, the entire list is composed of several Sections, and Section is divided into three parts: header, content, and footer. Header is the head of the paragraph, which can generally be used as the head decoration of the Section to support whether to ceiling or not; footer is the paragraph The tail is used as the tail decoration of the Section. The list has more capabilities for pull-down refresh and loading; content is the body of the Section, which supports common layout methods: lists, grids, waterfalls, and customization. The content of the section consists of any number of cells, and the cell is the item with the smallest granularity in the list.

Starting from the Flutter native container, CustomScrollView supports any combination of Sliver. Sliver provides SliverList, SliverGrid, SliverBox, etc., which basically meet our requirements. We map the header and footer of the Section to a SliverBox, and the content to a SliverList or SliverGrid, and then develop a SliverWaterfall separately for the waterfall layout; then insert more Slivers for refreshing and loading at the head and tail of the entire list.

We divide PowerScrollView into four parts: data source manager, controller, event callback, and refresh configuration. As shown below.

  • Data source manager: used for data management, which involves section initialization and usual addition, deletion, modification, and checking;
  • Controller: Mainly used to control PowerScrollView refresh, load more, control scroll to a certain position, etc.;
  • Event callbacks: We classify events so that when used externally, we can only monitor the callbacks needed;
  • Refresh configuration: In order to improve the flexibility of refresh, we will extract the refresh separately, which can use the standard refresh components provided by us, or it can be customized.

Perfect function

We have perfected the core requirements of business use for PowerScrollView, including automatic exposure, scrolling to a certain index, waterfall flow, refreshing and loading more capabilities. The following will focus on the first two parts.

Auto exposure capability

In Flutter, you usually have to put the exposure in the build function, which makes the exposure messy. The part that is not on the screen but in the screen buffer will be exposed by mistake, and there are multiple exposure problems, and the code is bloated and messy. Makes the business layer a headache. Exposure ability is a core requirement for all kinds of businesses. We encapsulate it in PowerScrollView and give it to users through event callbacks.

As we know earlier, in PowerScrollView, we use cells to encapsulate items with the smallest granularity, because the encapsulation of items greatly enhances our control. Because of this, we customize the StatefulElement of the cell, mount and unmount the current element during the life cycle of the element, and use the InheritedWidget to maintain the element on the tree in the outside list.

During the scrolling process of PowerScrollView, we will traverse and check the element array, filter the elements on the screen for exposure callback. The ones that are filtered out are the elements of the buffer, and an array is maintained to avoid multiple exposures of a single element on the current screen.

In order to reduce the multiple times of traversing and checking the element array during scrolling, we have added a configurable parameter that controls the scrolling sampling rate. Through this parameter, we can control the scrolling for a certain distance before checking.

In a complex scene, there will be a situation where the cell height is 0 first, and the template is downloaded and then stretched. In this case, the entire element list data will be very large and the data is incorrect. We need to filter this out. But when the cell is refreshed, it has a real height, and we need to make the correct exposure. So we monitored the size change in the cell, and when the height changed from 0 to non-zero, we notified the upper layer to make an exposure.

Scroll to an index

Flutter itself provides the ability to scroll to the position distance, but in general business scenarios, we don't know the distance to scroll, at most we know which number to scroll to, which makes many interactions on the Flutter side impossible. We will analyze this problem in several scenarios.

Scenario 1: When the cell of the target index to be scrolled is in the view tree (current screen and buffer), since we have maintained an element array of the screen and buffer, we can traverse to find it, and then scroll it to the visible area. Can.

Scenario 2: When the cell of the target index to be scrolled is not in the view tree, first we compare the current screen index with the target index to determine whether to scroll up or down. Then, scroll for a specific distance at a faster speed, and then recurse after scrolling until the target index is found. Due to the uncertainty of the scrolling distance and time, there will be no animation effects in extreme cases, and ordinary animation effects may be a bit blunt.

Performance optimization

Why do partial refresh

In actual streaming business scenarios, the entire list container is often refreshed due to data source updates: for example, loading the next page of data, deleting or inserting a certain cell, or even a button state change of a certain cell.

Excessive refresh range is often the main reason that causes the list container to freeze and reduce fluency, which seriously affects the user's operating experience. Therefore, we need to minimize the scope of dirty refresh of the Widget tree, reduce the call of Element rebuild, and realize the ability of partial refresh.

Viewport refresh process

Why is it that the dirty and refreshing of the entire list container will cause serious time-consuming? Let's take a brief look at the refresh process of the Viewport.

After the list container is dirty, it will do two key operations:

  • All elements of Viewport sliver will be rebuild;
  • Viewport will also be re-layout, and all sliver will be re-layout.

Let’s take a look at the Viewport layout process first: The core of this method is to find the current center sliver (the first child by default), and then traverse each sliver of the Viewport upwards and downwards; each child sliver is based on the current Viewport. ScrollOffset in Scrollview, Viewport size and cacheExtent size and other information (SliverConstraints), calculate the current index range of the child that needs to be displayed, and layout each child in the displayable range.

In the following figure, the child index that needs to be laid out in the visible range of SliverList is 2\~3; the child index that needs to be laid out in SliverGrid is 0\~3.

Let's look at the process of element rebuild of all slivers in Viewport. This process is the key to the time-consuming refresh of the list container.

Let’s take a look at several common layouts SliverList, SliverGrid and the implementation of our custom waterfall flow layout SliverWaterfall. They all inherit from SliverMultiBoxAdaptorWidget, a sliver base class that manages multiple children (Box models); its corresponding Element is SliverMultiBoxAdaptorElement is mainly responsible for the life cycle related work such as the creation, update, and removal of the child. This is where the partial refresh needs to be finely processed.

SliverMultiBoxAdaptorElement maintains two Maps internally to cache child element and child widget, and build its own child lazily when ViewPort needs it (the layout process mentioned above).

The rebuild process is time-consuming because it is necessary to clear all child widget caches, rebuild child widgets, and update child Element; if data changes, such as insert and delete, are encountered, it is very likely that the element cannot be reused, so the cost of rebuild will be higher.

The realization principle of partial refresh

After figuring out the basic principles, we are thinking, when the content of the list container changes (such as insert, delete, LoadMore), can we make some optimizations so that only the changed parts go to build and layout?

First of all, we think that the method of rebuild all elements of sliver is too simple and rude. We can achieve the purpose of partial refresh by more precise control of childWidgets and childElements in the sliver element.

Let's take a look at how to achieve precise control of childWidgets and childElements for specific scenarios, and achieve the ability of partial refresh.

Variable child count

In common scenes that require partial refresh, the number of container elements often changes. In the common use of CustomScrollview, the childCount is specified when it is created. When the childCount method changes, the list container needs to be rebuilt.

The first step is to avoid the problem of having to rebuild the entire container due to changes in the number of internal elements in sliver.

Although it is also possible to use childCount to be empty, and determine whether to achieve the purpose of variable childCount according to the way the builder returns null to determine whether it is the last child, but this method does not conform to common habits and will increase additional costs for the user, so This method is not used.

The approach is relatively simple, by inheriting from SliverChildBuilderDelegate, modify the childCount acquisition method.

Partial refresh of LoadMore

The implementation of LoadMore is relatively simple, and there are two main things that need to be done:

1. Clean up the widgets cache to prevent excessive memory usage during the loading process; save the widget with the same index in _childElements; here is a point that needs special attention: you must filter the widgets that are null, otherwise the widgets in this location cannot Normal display; (The last index of _childWidgets will be a null value, why you insert a null widget? You can read the source code to find the answer)

2. Finally hit the dirty sliver and re-layout the children:

Use the TimeLine data of Dart DevTools to compare the time-consuming situation of the two LoadMore methods as shown below:

Timeline of SetState:

LoadMore's timeline:

Partial refresh of Delete

First, organize the content of childWidgets, and readjust the corresponding relationship between widget and index in childWidgets according to the index of delete.

Next is the processing of _childElements. If the index that needs to be deleted has not been created, you only need to dirty the layout information of the RenderObject of the current sliver and re-layout yourself. Note that this process will not re-layout the child already displayed in the current viewport.

Otherwise, find the child element to be deleted, deactivate the corresponding element, and remove the corresponding RenderObject from the Render tree:

This process will also maintain the relationship between the previousSibling and nextSibling of ParentData in the child's RenderObject.

Next, adjust the correspondence between Element and index in _childElements.

Finally update the slot of each child:

Finally, the RenderObject of sliver is dirty, and the next frame is re-layout and refreshed.

Partial refresh of Insert

The implementation process of Insert is similar to the above, you can implement it yourself according to the above process, so I won't repeat it here.

Element reuse capability

Whether it is UITableView of iOS, UICollectionView or RecyclerView of Android, all support cell reuse capabilities; in Flutter's list container, can the reuse of elements be achieved without modifying the framework layer?

First, let's analyze the process of element being recycled. SliverMultiBoxAdaptorElement caches elements through _childElements. When scrolling beyond the display of the viewport and the preload range or the data source changes, it will call the collectGarbage method to reclaim unnecessary elements.

We can rewrite collectGarbage to intercept the deactive child element and put it into the buffer pool without using keepAlive; when it is necessary to create an element, it is first obtained from the buffer pool.

Although the principle is relatively simple, there are some points to note: the element that needs to be cached needs to be removed from the childList through the remove method, instead of actually destroying the element, if it is set to the defunct state, so Unable to reuse.

Because the layout of cards in the business is basically the same, the logic of reuse is relatively simple. In fact, the best results can be achieved by reusing card types.

Frame rendering

In the actual sliding process, if too many cells need to be built within one frame, it is easy to cause frame drop, and the user will feel stuck. In order to reduce this situation, we introduced a placeholder mechanism at the cell level:

The user can customize a relatively simple Widget for each item, so that when there are many tasks in one frame, through a certain strategy, the placeholder is built first for rendering, and the actual cell build is delayed until a few frames later. Since there are buffers both above and below the viewport, users have no chance to see the placeholder when the delayed frame settings are less, so there will be no business impact. The most obvious role of placeholder is peak clipping, and the longer frame time will be divided by the next few frames.

The following data is a scene of using a complex product card in a waterfall, and the model used is Pixel XL. From the data point of view, framing has increased the average time consumption, but the 90, 99, and the longest frame time consumption have all been significantly reduced, and the number of lost frames has also been reduced.

It is worth noting that for scenes where cells are too complex, even if one frame is built, there will be no optimization effect for framing with the cell as the smallest granularity. The analogy is to a cell phone with very poor performance. Framing may reduce smoothness. At this time, it is necessary to reduce cell complexity or reduce the granularity of framing.

Actual application scenarios

PowerScrollView has been used in full on multiple core pages of Xianyu, as shown in the figure below:

Complete capabilities, excellent performance, and low access costs all benefit users a lot.

Summary and outlook

After continuous improvement of the list container capabilities and continuous optimization of fluency, PowerScrollView has now been able to better support the business under the idle fish streaming layout, and provide users with a better experience.

But on some low-end models, the performance of the long list is still not satisfactory; waterfalls and other scenes that require complex layout calculations, how to better optimize the layout calculation process, these are the directions that we need to continue to explore.

At present, the implementation of reuse is still relatively rough, and in the future, it will go deep into the Flutter engine to find ways to improve the reuse ability, so that PowerScrollView truly becomes an efficient streaming layout solution.

In addition, in terms of end-to-end R&D, we are exploring the combination of list containers and dynamic templates to achieve a page building solution that integrates end-to-cloud.

, 3 mobile technology practices & dry goods for you to think about every week!


阿里巴巴终端技术
336 声望1.3k 粉丝

阿里巴巴移动&终端技术官方账号。