Two implementations and performance comparison of Android barrage -- custom LayoutManager

Introduction

In the previous article, the "animation" scheme was used to realize the bullet screen effect. The container control was customized. Each bullet screen was used as its sub control, and the initial position of the bullet screen was placed outside the right side of the container control. Each bullet screen was translated through the screen through the animation from right to left.

The performance of this scheme needs to be improved. Turn on GPU rendering mode:

The reason is that the container control builds all the bullet screen views in advance and stacks them on the right side of the screen. If the amount of bullet screen data is large, the container control will consume a lot of measure + layout time due to too many sub views.

Since the performance problem is caused by loading unnecessary barrages in advance, can we preload only a limited number of barrages?

The scrollable controls that only load a limited number of child views are RecyclerView! Instead of converting all the data in the Adapter into a View in advance, it preloads only one screen of data, and then continuously loads new data as it scrolls.

In order to realize the barrage effect with RecyclerView, you have to "customize LayoutManager".

Custom layout parameters

The first step of customizing LayoutManager: inherit recyclerview LayoutManger:

1.class LaneLayoutManager: RecyclerView.LayoutManager() {
2.override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {}
3.}
4.Copy code

According to the tips of Android studio, you must implement a method of generateDefaultLayoutParams(). It is used to generate a custom LayoutParams object to carry custom attributes in layout parameters.

There is no need to customize layout parameters in the current scene, so this method can be implemented as follows:

1.override fun generateDefaultLayoutParams(): RecyclerView.LayoutParams {
2.return RecyclerView.LayoutParams(
3.RecyclerView.LayoutParams.WRAP_CONTENT,
4.RecyclerView.LayoutParams.WRAP_CONTENT
5.)
6.}
7.Copy code

Indicates that the recyclerview LayoutParams.

Initial filling barrage

The most important step in customizing LayoutManager is to define how to layout table items.

For LinearLayoutManager, table items are spread linearly in one direction. When the list is displayed for the first time, the table items are filled one by one from the top to the bottom of the list, which is called "initial filling".

For LaneLayoutManager, the first fill is "filling a list of bullets to the end of the list (not visible outside the screen)".

On the source code analysis of how LinearLayoutManager fills table items, in a previous RecyclerView interview question | how are table items filled or recycled when scrolling? After analysis in, the following conclusions are quoted:

  1. LinearLayoutManager layouts table entries in the onLayoutChildren() method.
  2. The key methods of layout table items include fill() and layoutChunk(). The former represents a filling action of the list, and the latter represents filling a single table item.
  3. In a filling action, the table entries are continuously filled through a while loop until the remaining space in the list is used up. This process is represented by pseudo code as follows:

1.public class LinearLayoutManager { 
2.//Layout table item
3.public void onLayoutChildren() { 
4.//Fill table entries 
5.fill() { 
6.while(The list has space left){ 
7. //Populate a single table entry 
8.layoutChunk(){ 
9. //Make table entries child views
10. addView(view)
11.}
12.}
13. }
14.}
15.}
16.Copy code

  1. In order to avoid recreating the view every time a new table item is filled, you need to get the table item view from the RecyclerView cache, that is, call recycler getViewForPosition(). For a detailed explanation of this method, you can click RecyclerView caching mechanism | how to reuse table entries?

After reading the source code and understanding the principle, the barrage layout can be modeled as follows:

1.class LaneLayoutManager : RecyclerView.LayoutManager() { 
2.private val LAYOUT_FINISH = -1 //Mark fill end 
3.private var adapterIndex = 0 //List adapter index 
4 //Longitudinal spacing of barrage
5. var gap = 
6.get() = field.dp 
7.//Layout 8 override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?)  
9.{
10.fill(recycler)
11. }
12.//Fill table entries
13. private fun fill(recycler: RecyclerView.Recycler?) 
14.{
15.//Height available for barrage layout, i.e. list height
16.var totalSpace = height - paddingTop - paddingBottom
17.var remainSpace = totalSpace
18.//Continue to populate table entries as long as there is enough space
19.while (goOnLayout(remainSpace)) {
20.//Fill in a single table entry 21 val consumeSpace = layoutView(recycler)
22. if (consumeSpace == LAYOUT_FINISH) break
23. //Update remaining space
24.remainSpace -= consumeSpace
25.}
26.}
27.
28.//Is there any space left to fill # and # is there more data 29 private fun goOnLayout(remainSpace: Int) = remainSpace > 0 && adapterIndex in 0 until itemCount
30.
31.    //Populate a single table entry
32.    private fun layoutView(recycler: RecyclerView.Recycler?): Int {
33.        // 1. Get table item view from cache pool
34.        //If the cache misses, the # onCreateViewHolder() and # onBindViewHolder() are triggered
35.        val view = recycler?.getViewForPosition(adapterIndex)
36.        view ?: return LAYOUT_FINISH //If it fails to get the table item view, the filling ends
37.        // 2. Make the table item view into a list 38. addView(view)39 / / 3 Measurement item view 40 measureChildWithMargins(view, 0, 0) 41 / / the height of the available barrage layout, i.e. the list height is 42         var totalSpace = height - paddingTop - paddingBottom
43.        //The number of barrage lanes, that is, the vertical list can accommodate several barrages 44 Val. laneCount = (totalSpace + gap) / (view. Measuredheight + gap)45 / / calculate the lane where the current table item is located 46 Val index = adapterIndex% laneCount47 / / calculate the top, bottom, left and right borders of the current table item 48 Val # left = width / / the left side of the barrage is 49 on the right side of the list         val top = index * (view.measuredHeight + gap)
50.        val right = left + view.measuredWidth
51.        val bottom = top + view.measuredHeight
52.        // 4. Layout table items (this method takes into account item decoration) 53         layoutDecorated(view, left, top, right, bottom)
54.        val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?: 0
55.        //Continue to get the next table item view
56.        adapterIndex++
57.        //Returns the pixel value consumed by filling table entries 58         return getDecoratedMeasuredHeight(view) + verticalMargin.
59.    }
60.}
61.Copy code

Each horizontal line for the bullet screen to roll is called "lane".

Lanes are spread vertically from the top to the bottom of the list. List height / Lane height = number of lanes.

In the fill() method, the "remaining height of the list > 0" is taken as the cycle condition to continuously fill the table items into the swimlane. It has to go through four steps:

  1. Get table item view from cache pool
  2. Make the table item view a list child
  3. Measurement table item view
  4. Layout table item

After these four steps, the position of the table item relative to the list is determined, and the view of the table item has been rendered.

Run the demo, and sure enough ~, I didn't see anything...

The list scrolling logic has not been added, so the table items arranged on the outside of the right side of the list are still invisible. However, you can use the Layout Inspector tool of Android studio to verify the correctness of the first filling code:

The Layout Inspector will use wireframe to represent the controls outside the screen. As shown in the figure, the outside of the right side of the list is filled with four table items.

Automatic rolling barrage

In order to see the filled table items, you have to let the list scroll spontaneously.

The most direct solution is to keep calling recyclerview smoothScrollBy(). For this purpose, an extension method is written for Countdown:

1.fun <T> countdown( .
2.    duration: Long, //Total countdown time 
3.    interval: Long, //Countdown interval 
4.    onCountdown: suspend (Long) -> T //Countdown callback 
5.): Flow<T> = 
6.    flow { (duration - interval downTo 0 step interval).forEach { emit(it) } } 
7.        .onEach { delay(interval) } 
8.        .onStart { emit(duration) } 
9.        .map { onCountdown(it) }
10.        .flowOn(Dispatchers.Default)
11.Copy code

An asynchronous data Flow is constructed using Flow, which will emit the remaining time of the countdown each time. For a detailed explanation of Flow, click Kotlin asynchronous | Flow application scenario and principle

Then you can automatically scroll the list like this:

1.countdown(Long.MAX_VALUE, 50) {
2.    recyclerView.smoothScrollBy(10, 0)
3.}.launchIn(MainScope())
4.Copy code

Scroll 10 pixels to the left every 50 ms. The effect is shown in the following figure:

Continuous filling of barrage

Because only the initial filling is done, that is, only one table item is filled in each lane, there will be no subsequent barrage after the table items in the first row roll into the screen.

LayoutManger.onLayoutChildren() will only be called once when the list is laid out for the first time, that is, the initial filling of the barrage will only be executed once. In order to continuously display the bullet screen, the table entries must be filled continuously while scrolling.

A previous RecyclerView interview question | how are table items filled or recycled when scrolling? After analyzing the source code of continuously filling table items when the list scrolls, the following conclusions are quoted:

  1. Before scrolling, RecyclerView will decide how many new table items need to be filled in the list according to the expected scrolling displacement.
  2. It is shown on the source code, that is, calling fill() in scrollVerticallyBy() to fill in the table entry:

1.public class LinearLayoutManager { 
2.   @Override 3.   public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { 4.       return scrollBy(dy, recycler, state); 
5.   } 
6.
7.   int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) { 
8.       ... 
9.       //Fill in table item 10        final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false);
11.       ...
12.   }
13.}
14.Copy code

For the bullet screen scene, you can also write a similar one:

1.class LaneLayoutManager : RecyclerView.LayoutManager() { 2.override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int { 
3.        return scrollBy(dx, recycler)  
4.    } 
5. 
6.    override fun canScrollHorizontally(): Boolean { 
7.        return true //Indicates that the list can scroll horizontally 
8.    } 
9.}
10.Copy code

Overriding canScrollHorizontally() returns true to indicate that the list can scroll horizontally.

The scrolling of RecyclerView is carried out section by section, and the displacement of each section will be passed through scrollhorizontalyby(). Usually, in this method, new table items are filled according to the displacement, and then the scrolling of the list is triggered. For the source code analysis of list scrolling, you can click RecyclerView. How is the scrolling realized? (1) | unlock the new posture of reading source code.

scrollBy() encapsulates the logic of continuously populating table entries based on scrolling. (to be analyzed later)

The logic of continuous filling of table items is a little more complex than that of the first filling. For the first filling, just spread the table items from top to bottom according to the swimming lane and fill the height of the list. For continuous filling, it is necessary to calculate which lane is about to be exhausted according to the rolling distance (lanes without bullet screen display), and only fill the table items for the exhausted lanes.

In order to quickly obtain the exhausted lane, a "lane" structure must be abstracted to save the rolling information of the lane:

1.//Lane
2.data class Lane(
3.    var end: Int, //Abscissa of bullet screen at the end of swimming lane
4.    var endLayoutIndex: Int, //Layout index of the bullet screen at the end of the lane
5.    var startLayoutIndex: Int //Layout index of the barrage at the head of the swimming lane
6.)
7.Copy code

The swimlane structure contains three data:

  1. Abscissa of the bullet screen at the end of the lane: it is the right value of the last bullet screen in the lane, that is, the distance between its right side and the left side of the RecyclerView. This value is used to judge whether the lane will dry up after a period of rolling displacement.
  2. Layout index of the bullet screen at the end of the lane: it is the layout index of the last bullet screen in the lane. It is recorded to easily obtain the view of the last bullet screen in the lane through getChildAt(). (the layout index is different from the adapter index. RecyclerView only holds a limited number of table items, so the value range of the layout index is [0,x], and the value of X is slightly more than that of one screen table item. For the bullet screen, the value of the adapter index is [0, ∞])
  3. Layout index of the barrage at the head of the lane: similar to 2, in order to easily obtain the view of the first barrage in the lane.

With the help of the swimlane structure, we have to reconstruct the logic of filling table items for the first time:

1.class LaneLayoutManager : RecyclerView.LayoutManager() { 
2.    //Last filled barrage during initial filling 
3.    private var lastLaneEndView: View? = null 
4.   //All lanes 
5.    private var lanes = mutableListOf<Lane>() 
6.    //Initial filling of barrage 7     override fun onLayoutChildren(recycler: RecyclerView.Recycler?, state: RecyclerView.State?)  { 8.        fillLanes(recycler, lanes) 
9.    }
10.   //Fill the barrage by cycling 11     private fun fillLanes(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>) {
12.        lastLaneEndView = null
13.        //If there is still space in the vertical direction of the list, continue to fill barrage 14         while (hasMoreLane(height - lanes.bottom())) {
15.            //Fill a single barrage into the lane 16             val consumeSpace = layoutView(recycler, lanes)
17.            if (consumeSpace == LAYOUT_FINISH) break
18.        }
19.   }
20.    //Fill a single barrage and record lane information 21     private fun layoutView(recycler: RecyclerView.Recycler?, lanes: MutableList<Lane>): Int {
22.        val view = recycler?.getViewForPosition(adapterIndex)
23.        view ?: return LAYOUT_FINISH
24.        measureChildWithMargins(view, 0, 0)
25.        val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)?.let { it.topMargin + it.bottomMargin } ?:26        val consumed = getDecoratedMeasuredHeight(view) + if (lastLaneEndView == null) 0 else verticalGap + verticalMargin27        //If a new lane can be accommodated in the vertical direction of the list, create a new lane, otherwise stop filling 28} if (height - lanes.bottom() - consumed > 0) {29} lanes.add(emptyLane(adapterIndex))30} else} return LAYOUT_FINISH3132 # addView(view)33 / / get the newly added lane 34 # val # Lane = lanes Last() 35 / / calculate the upper, lower, left and right frames of the barrage 36 # val # left = lane end + horizontalGap37        val top = if (lastLaneEndView == null) paddingTop else lastLaneEndView!!.bottom + verticalGap38        val right = left + view.measuredWidth39        val bottom = top + view. Measuredhight40 / / locate the barrage 41 , layoutDecorated(view, left, top, right, bottom)42 / / update the abscissa at the end of the lane and the layout index 43 , lane Apply {44} end = right45} endLayoutIndex = childCount - 1 / / because it is a newly added table item, its index value must be the largest 46} 4748} adapterIndex++49} lastlaneendview = view50} return} consumed51} 52}
53.Copy code

The initial filling of the barrage is also a process of continuously adding lanes in the vertical direction. The logic to judge whether to add is as follows: List height - the bottom value of the current bottom Lane - the pixel value consumed by this filling of the barrage is > 0, where lanes Bottom() is an extension method of list < Lane >

1fun List<Lane>.bottom() = lastOrNull()?.getEndView()?.bottom ?: 02 Copy code

It gets the last Lane in the lane list, and then gets the bottom value of the last bullet screen view in the lane. Where getEndView() is defined as the extension method of lane:

1class LaneLayoutManager : RecyclerView.LayoutManager() {
2.    data class Lane(var end: Int, var endLayoutIndex: Int, var startLayoutIndex: Int)
3.    private fun Lane.getEndView(): View? = getChildAt(endLayoutIndex)
4.}
5.Copy code

Theoretically, "get the view of the last bullet screen in the Lane" should be the method provided by Lane. But is it unnecessary to define it as an extension method of Lane and also in the interior of LaneLayoutManager?

If it is defined inside Lane, layoutmanager cannot be accessed in this context If getchildat () method is only defined as the private method of LaneLayoutManager, endLayoutIndex cannot be accessed. So this is to integrate the two contexts.

Look back at the logic of continuously filling the barrage when scrolling:

1.class LaneLayoutManager : RecyclerView.LayoutManager() { 2.    override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler?, state: RecyclerView.State?): Int { 
3.        return scrollBy(dx, recycler)  
4.    } 
5.    //Determine how many table items to fill according to the displacement 6     private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?):  Int { 
7.        //If the list has no children or scrolling does not occur, return 
8.        if (childCount == 0 || dx == 0) return 0 
9.        //Update lane information before scrolling starts
10.        updateLanesEnd(lanes)
11.        //Get scroll absolute value
12.        val absDx = abs(dx) 
13.        //Traverse all lanes and fill the depleted lanes with barrages
14.        lanes.forEach { lane ->
15.            if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
16.       }
17.        //The foothold of scrolling list: translate the table item to the opposite direction of finger displacement by the same distance
18.        offsetChildrenHorizontal(-absDx)
19.       return dx
20.    }
21.}
22.Copy code

The logic of continuously filling the barrage during scrolling follows this order:

  1. Update lane information
  2. Fill the dry Lane with barrage
  3. Trigger scrolling

Among them, 1 and 2 occur before the real rolling. Before the rolling, the rolling displacement has been obtained. According to the displacement, the lane that will be exhausted after the rolling can be calculated:

1//Is the lane dry
2.private fun Lane.isDrainOut(dx: Int): Boolean = getEnd(getEndView()) - dx < width
3.//Get the {right} value of the table entry
4.private fun getEnd(view: View?) = 
5.    if (view == null) Int.MIN_VALUE 
6.    else getDecoratedRight(view) + (view.layoutParams as RecyclerView.LayoutParams).rightMargin
7.Copy code

The judgment basis of lane depletion is: whether the right side of the last bullet screen of the lane is smaller than the list width after moving dx to the left. If it is less than, it means that the barrage in the lane has been fully displayed. At this time, continue to fill the barrage:

1.//Fill a new Barrage as it rolls 
2.private fun layoutViewByScroll(recycler: RecyclerView.Recycler?, lane: Lane) { 3.    val view = recycler?.getViewForPosition(adapterIndex) 4    view ?: return 5.    measureChildWithMargins(view, 0, 0) 6    addView(view) 7 8    val left = lane.end + horizontalGap 9.    val top = lane.getEndView()?.top ?: paddingTop
10.    val right = left + view.measuredWidth
11.    val bottom = top + view.measuredHeight
12.    layoutDecorated(view, left, top, right, bottom)
13.    lane.apply {
14.        end = right
15.        endLayoutIndex = childCount - 1
16.    }
17.    adapterIndex++18}19 Copy code

The filling logic is almost the same as that of the first filling. The only difference is that the filling during scrolling cannot return in advance because of insufficient space, because it is filled by finding the right lane.

Why update lane information before filling a depleted lane?

1.//Update lane information
2.private fun updateLanesEnd(lanes: MutableList<Lane>) {
3.    lanes.forEach { lane ->
4.        lane.getEndView()?.let { lane.end = getEnd(it) }
5.   }
6.}
7.Copy code

Because the scrolling of RecyclerView is carried out section by section, it seems that it has rolled a lost distance. Scrollhorizontalyby() may have to callback more than ten times. With each callback, the barrage will advance a short section, that is, the abscissa of the barrage at the end of the Lane will change, which has to be changed in the Lane structure at the same time. Otherwise, the calculation of Lane depletion will go wrong.

Infinite rolling barrage

After the initial and continuous filling, the barrage can roll up smoothly. How to make the only barrage data rotate indefinitely?

Just make a small hand and foot on the Adapter:

1.class LaneAdapter : RecyclerView.Adapter<ViewHolder>() { 
2.    //Data set 
3.    private val dataList = MutableList()
4.    override fun getItemCount(): Int { 
5.        //Set table entry to infinity 
6.        return Int.MAX_VALUE 
7.   } 
8. 
9.    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
10.        val realIndex = position % dataList.size11        ...12    }1314    override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {15        val realIndex = position % dataList.size16        ...17    }18}19 Copy code

Set the data volume of the list to infinity. When creating the table item view and binding data for it, take the module of the adapter index.

Recovery barrage

The last remaining problem is how to recover the barrage. If there is no recycling, I'm sorry for the name RecyclerView.

The entry of recycling table items is defined in LayoutManager:

1.public void removeAndRecycleView(View child, @NonNull Recycler recycler) {
2.    removeView(child);
3.    recycler.recycleView(child);
4.}
5.Copy code

Recycling logic will eventually be delegated to Recycler. For the source code analysis of recycling table items, click the following article:

  1. RecyclerView cache mechanism | what is recycled?
  2. RecyclerView cache mechanism | where to recycle?
  3. RecyclerView animation principle | change the posture to see the source code (pre layout)
  4. RecyclerView animation principle | relationship between pre layout, post layout and scratch cache
  5. RecyclerView interview question | under what circumstances will table items be recycled to the cache pool?

For the barrage scene, when will the barrage be recovered?

Of course, the moment the barrage rolled out of the screen!

How can we capture this moment?

Of course, it is calculated by using the displacement before each rolling!

When scrolling, in addition to continuously filling the barrage, we also have to continuously recycle the barrage (that's what it says in the source code, I just plagiarize it):

1.private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?): Int { 2.    if (childCount == 0 || dx == 0) return 0 
3.    updateLanesEnd(lanes) 
4.    val absDx = abs(dx) 
5.    //Continuous filling of barrage 
6.    lanes.forEach { lane -> 
7.        if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane) 
8.    } 
9.    //Continuous recovery of barrage
10.    recycleGoneView(lanes, absDx, recycler)
11.    offsetChildrenHorizontal(-absDx)
12
    return dx
13.}
14.Copy code

This is the full version of scrollBy(). When scrolling, fill it first and then recycle it immediately:

1.fun recycleGoneView(lanes: List<Lane>, dx: Int, recycler: RecyclerView.Recycler?) { 
2.    recycler ?: return 
3.    //Traverse lanes 
4.    lanes.forEach { lane -> 
5.        //Get Lane head barrage 
6.        getChildAt(lane.startLayoutIndex)?.let { startView -> 
7.            //If the Lane head barrage has rolled off the screen, recycle it 
8.           if (isGoneByScroll(startView, dx)) { 
9.                //Recovery barrage view
10.                removeAndRecycleView(startView, recycler)
11.                //Update lane information
12.                updateLaneIndexAfterRecycle(lanes, lane.startLayoutIndex)
13.                lane.startLayoutIndex += lanes.size - 1
14.            }
15.        }
16.    }
17.}
18.Copy code

Recycling is the same as filling, but also through traversal to find the disappearing barrage and recycle it.

The logic for judging the disappearance of the barrage is as follows:

1.fun isGoneByScroll(view: View, dx: Int): Boolean = getEnd(view) - dx < 0
2.Copy code

If the right of the barrage is less than 0 after translating dx to the left, it indicates that the barrage has rolled out of the list.

After recovering the barrage, it will be detach ed from the RecyclerView. This operation will affect the layout index value of other barrages in the list. As if an element in the array is deleted, the index values of all subsequent elements will be reduced by one:

1.fun updateLaneIndexAfterRecycle(lanes: List<Lane>, recycleIndex: Int) { 
2.    lanes.forEach { lane -> 
3.        if (lane.startLayoutIndex > recycleIndex) { 
4.            lane.startLayoutIndex-- 
5.        } 
6.        if (lane.endLayoutIndex > recycleIndex) { 
7.            lane.endLayoutIndex-- 
8.        } 
9.   }
10.}
11.Copy code

Traverse all lanes. As long as the layout index of the bullet screen at the head of the lane is greater than the recycling index, it will be reduced by one.

performance

Turn on GPU rendering mode again:

The experience was very smooth, and the histogram did not exceed the warning line.

talk is cheap, show me the code

For the complete code, click here to search for LaneLayoutManager in this repo.

summary

I spent a lot of time looking at the source code before, and also produced "looking at the source code is so time-consuming, what's the use?" Such doubts. This performance optimization is a good response. Because I have seen the source code of RecyclerView, its idea and method of solving problems are planted in my head. This seed will germinate when there is a problem with the performance of the barrage. There are many solutions. What kind of seed is in the head, what kind of bud will grow. So the source code is to sow seeds. Although it can't germinate immediately, it will bear fruit one day.

Keywords: Python Back-end Programmer crawler Data Mining

Added by skeetley on Mon, 21 Feb 2022 10:06:57 +0200