By Xinsu and Guangjiu from Xianyu Technology
At present, Xianyu's major business scenarios have been implemented using Flutter, and stream layout is the most common scenario (such as search, product details). With the rapid iteration and increasing complexity of the business; capability and performance requirements for streaming scenarios are rising.
In terms of capability (such as card exposure, scrolling anchor, waterfall layouts), native and some open source solutions in Flutter gradually fail to meet Xianyu's needs due to the evolving business and changing requirements.
In terms of performance, the list scrolling fluency in the streaming scenario is deteriorating due to the increasing complexity of business which needs to be solved to improve the user experience.
To solve the preceding business problems, we designed a common page layout for streaming data named PowerScrollView.
Before conducting the architecture design, we had thoroughly investigated the native scrolling containers, such as UICollectionView (for iOS) and RecyclerView (for Android). We were deeply impressed by the Section concept of UICollectionView and inspired by the architecture design of RecyclerView. Due to the uniqueness of Flutter, we cannot copy it. Our goal is to design a better scrolling container that is combined with the mature native scrolling containers based on the characteristics of Flutter.
Flutter native has commonly used ListView and GridView, which have a relatively simple layout and simple functions. Officially, the advanced Widget for CustomScrollView is also provided. Our design is based on CustomScrollView, which is designed by splicing multiple Slivers to adapt to more complex application scenarios.
From the perspective of usage, a list consists of several Sections, and a Section is divided into three parts: header, content, and footer. The header refers to the head of a paragraph, which generally serves as the header decoration of a Section and supports to absorb the top. The footer is the end of the paragraph, which is used as the tail decoration of a Section. The list provides some functions, such as pull to refresh and load more. The content is the body of a Section that supports the common layouts, such as list, grid, waterfall flow, and is user-defined. The content of a Section consists of any number of cells. A cell is the smallest item in a list.
Starting from the Flutter native container, CustomScrollView supports the combination of any multiple Slivers that provides SliverList, SliverGrid, SliverBox, which basically meets our requirements. We correspond the header and the footer of a Section respectively to a SliverBox, correspond the content to a SliverList or SliverGrid, and then develop a SliverWaterfall for waterfall layout. Lastly, a Sliver is added to the head and end of the list to refresh and load more.
As the following figure shows, PowerScrollView is divided into four parts - Data Source Manager, Controller, Event Callback, and Refresh Configuration.
We improved the core features of PowerScrollView for business, such as automatic exposure, scroll to a certain index, waterfall flow, and refresh to load more. Let's understand the first two features.
In Flutter, we usually have to put exposure in the build function, which makes the exposure messy and incorrectly exposes the part that is in the screen buffer. Additionally, the code has been exposed so often that it leaves it disordered and unrecognizable, making the business hard to run. Exposure capability is a core requirement of various businesses. PowerScrollView is uniformly encapsulated and delivered to users through event callbacks.
As we know, in PowerScrollView, we encapsulate the item with the smallest granularity using a cell. The encapsulation of items greatly increases our control. Therefore, we have customized the StatefulElement of a cell. It records the current element in the mount or unmount lifecycle and maintains the element on the tree in an external list using the InheritedWidget.
During the scrolling process of PowerScrollView, we traverse and check the element array to filter the elements on the screen for performing the exposure callback. The elements that are filtered out are the buffer elements, and the array is maintained to avoid multiple exposures of a single element on the screen.
We add a configurable parameter that controls the scroll sampling rate to reduce multiple traversal checks in the scrolling process. This parameter allows us to set the element to be checked only after a certain distance.
In complex scenarios, cell height is 0 at first and extended after the template is downloaded. In this case, the element list data is large and incorrect, so we need to filter that out. However, when the cell is refreshed and has its actual height, we need to expose it properly. Therefore, we monitor the change of size in a cell. When height changes from 0 to a non-zero value, it notifies the upper layer to perform exposure.
Flutter itself provides the ability to scroll to the position distance. However, in business scenarios, we generally do not know the distance to scroll, and we know only which one to scroll to, which makes many interactions impossible on the Flutter side. We will analyze this problem in several scenarios.
In an actual streaming business scenario, the entire list of containers is often refreshed due to the update of the data source. This includes container changes that have loaded another page of data, deleted or inserted a cell, even a button in a cell.
The extensive refresh is often the leading cause of list container stalling and low fluency, which seriously affects the user experience. Therefore, we need to minimize the scope of the Widget tree to refresh and reduce Element rebuild calls to implement local refresh.
Why is it time-consuming to refresh the list container? The following is a brief introduction to the refresh process of Viewport.
After a list container is dirty, two key operations are performed:
First, let's talk about the layout process of Viewport. The core of this method is to find the position of the current center sliver (the default is in the first child) and then traverse each sliver up and down in Viewport. Each child sliver calculates the range of child index displayed currently to layout each child in displayable range based on scrollOffset of current Viewport in Scrollview and size of Viewport and cacheExtent (sliverConstraints).
In the following legend, the child index of the layout required in the visual range of SliverList is 2 to 3. The child index of the layout is 0 to 3 in SliverGrid.
And then, let's focus on the Element rebuild process of all slivers in Viewport, which is the key to cause a time-consuming refresh of the list container.
It's critical to pay attention to the implementation of several common layouts such as SliverList, SliverGrid, and the customized waterfall layout named SliverWaterfall. These layouts inherit from SliverMultiBoxAdaptorWidget, a basic class of sliver for managing multiple-child (Box model). Its corresponding element is SliverMultiBoxAdaptorElement which is mainly responsible for creating, updating, and removing the child. This is where local refresh needs to be processed in a fine-grained manner.
SliverMultiBoxAdaptorElement maintains two maps internally, caches child element and child widget, and lazily builds its own child when ViewPort needs it (the layout process mentioned before).
The process of rebuilding is time-consuming because all child widget caches need to be cleared, the child widget needs to be rebuilt, and the child Element needs to be updated. If you encounter data changes, such as insert and delete, it is very likely that the element cannot be reused, which increases the rebuilding cost.
After understanding the basic principles, we are thinking about making some optimizations to take the changed parts to build and layout when the content of the list container changes, such as insert, delete, and LoadMore.
First of all, we think that the method of rebuilding all elements of sliver is too simple. We can achieve the purpose of local refreshing by precisely controlling childWidgets and childElements in sliver Element.
Now let's take a look at how to implement the precise control of childWidgets and childElements to achieve local refresh based on specific scenarios.
In typical scenarios that require local refresh, the number of container elements often changes. In common CustomScrollview usage, you can specify the childCount during creation. If the mode of childCount changes, you need to rebuild the list container.
The first step is to avoid rebuilding the entire container due to the change in the number of elements inside the sliver.
Although you can use childCount as the null value and decide whether it is the last child to achieve variable childCount based on null returned by the builder. This method is not recommended because it is not very suitable for common usage and results in additional costs for users.
It is easy to modify the access method of childCount by inheriting SliverChildBuilderDelegate.
Implementation of LoadMore is simple. Just implement the following two main steps:
1) Clear the cache of widgets to prevent excessive memory usage during the loading process. Save widgets with the same index in _childrenElements. Here is a point that needs special attention: To filter widgets as a null value; otherwise, the widget at this position cannot be displayed. (The last index in _childWidgets will be a null value, and for more information about why a null widget is inserted, please read the source code).
2) Finally, a dirty sliver is displayed to re-layout children:
The following figure shows the time consumption of the two LoadMore methods based on the comparison of TimeLine data by using Dart DevTools:
Timeline of SetState
Timeline of LoadMore
First, sort out the content of childWidgets, and then adjust the mapping between widgets and indexes in childWidgets based on the deleted index.
The next step is to process _childElements. If the index to be deleted has not been created, you only need to dirty the layout information of RenderObject of the current sliver and re-layout itself. Note that this process will not re-layout the child that the current viewport has displayed.
Otherwise as shown below, you need to deactivate the child element that is about to be deleted, and remove the corresponding RenderObject from the Render tree:
This process maintains the relationship between previousSibling and nextSibling of ParentData in RenderObject of the child.
And then, change the correspondence relations between Element and index in _childElements.
After that, update the slot of each child:
Finally, mark RenderObject of the sliver dirty and layout and refresh the next frame.
The implementation process of Insert is similar to the content mentioned before, and you may refer to the same if required.
Both UITableView and UICollectionView on iOS and RecyclerView on Android support cell reuse. In the list container of Flutter, can we reuse elements without changing the framework layer?
First of all, let's analyze the process in which the element is recycled. SliverMultiBoxAdaptorElement caches elements through _childElements. When scroll exceeds the viewport display and pre-loading range, or data source changes, the unnecessary elements are recycled by calling collectGarbage.
By rewriting collectGarbage, we can intercept the child element that is supposed to be deactivated and put it into the buffer pool without using keepAlive. When an element needs to be created, it should be obtained from the buffer pool first.
Although the principle is simple, pay more attention to specific points - the element to be cached needs to be removed from childList instead of being destroyed. If it is set to the defunct state, then it cannot be reused.
Because card layouts in the businesses are the same, and it is easy to reuse the logic. Only reuse the card types that can achieve the best results.
In the actual sliding process, if too many cells need to be built within a frame, it is easy to cause the frame to drop, but the user will feel stuck. In order to avoid this problem, we have introduced the placeholder mechanism at the cell level.
You can customize a simpler Widget for each item. In this way, when there are many tasks in a frame, you can first build a placeholder for rendering through certain policies, and then you can build the actual cell after delaying to the next few frames. Since the Viewport has the buffers above and below, the user cannot see the placeholder when the number of delayed frame settings decreases, and thus the service is not affected. The most apparent function of the placeholder is peak clipping because a long frame is divided into the next few frames.
The following data shows how to use a complex commodity card in a waterfall scenario while using the Pixel XL model. From the data point of view, the average_frame_build_time_millis increases, but the 90th_percentile_frame_build_time_millis, 99th_percentile_frame_build_time_millis, and the worst_frame_build_time_millis significantly reduce, and the missed_frame_build_budget_count also decreases.
It should be noted that, in a complex scenario where cells are built, even if one frame does time out, sub-frames with the cell as the minimum granularity have no optimization effect. For example, in a mobile phone with poor performance, A complex cell framing may reduce fluency. In this case, you need to reduce the cell complexity or the framing granularity.
PowerScrollView has been fully used on multiple core pages of Xianyu, as shown in the following figure:
Users benefit from the perfect capabilities, high performance, and low costs.
After continuously improving the capabilities and optimizing the smoothness of the list container, PowerScrollView has been able to better support the services of Xianyu in-stream layout, which provides a better experience for users.
However, on some low-end models, the performance of the long list is still imperfect. For example, Waterfall and other scenarios require complex layout calculation, and the layout calculation process needs to be optimized, which are the directions that we need to continue to explore.
At present, the implementation of reuse is not stable. In the future, we will go deep into the Flutter engine to find ways to improve the reuse capability so that PowerScrollView genuinely becomes an efficient flow layout solution.
In addition, for end-to-end R&D, we are exploring the combination of list containers and dynamic templates to implement the cloud-terminal page building.
Flutter Architecture Design and Application in Streaming Scenarios
56 posts | 4 followers
FollowXianYu Tech - May 20, 2021
卓凌 - September 8, 2020
Hanks - April 21, 2020
XianYu Tech - March 11, 2020
XianYu Tech - September 9, 2020
XianYu Tech - August 6, 2020
56 posts | 4 followers
FollowExplore Web Hosting solutions that can power your personal website or empower your online business.
Learn MoreProvides a control plane to allow users to manage Kubernetes clusters that run based on different infrastructure resources
Learn MoreA secure image hosting platform providing containerized image lifecycle management
Learn MoreA low-code development platform to make work easier
Learn MoreMore Posts by XianYu Tech