primers

In the previous article, the “animation” scheme was used to realize the effect of bullet screen, and the container control was customized. Each bullet screen was taken 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 animation from right to left.

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

The reason for this is that the container control builds all the barrage views ahead of time and stacks them on the right side of the screen. If the amount of barrage 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 preloading unnecessary barrage, is it possible to preload only a limited number of barrage?

Only load limited child view and can scroll control, is not RecyclerView! Instead of converting all the data in the Adapter to a View ahead of time, it preloads one screen of data and continuously loads new data as it scrolls.

In order to use RecyclerView to realize the barrage effect, you have to “custom LayoutManager”.

Customize layout parameters

The first step in the custom LayoutManager: inheritance RecyclerView. LayoutManger:

class LaneLayoutManager: RecyclerView.LayoutManager() {
    override fun generateDefaultLayoutParams(a): RecyclerView.LayoutParams {}
}
Copy the code

According to the prompt AndroidStudio, must implement a generateDefaultLayoutParams () method. It is used to generate a custom LayoutParams object with the purpose of carrying custom properties in the layout parameters.

There is no need to customize layout parameters in the current scenario, so you can implement this method like this:

override fun generateDefaultLayoutParams(a): RecyclerView.LayoutParams {
    return RecyclerView.LayoutParams(
        RecyclerView.LayoutParams.WRAP_CONTENT,
        RecyclerView.LayoutParams.WRAP_CONTENT
    )
}
Copy the code

Said used RecyclerView. LayoutParams.

First reload

The most important part of customizing LayoutManager is defining how to lay out the entries.

For the LinearLayoutManager, the entries are spread out linearly in one direction. When the list is first displayed, entries are populated one by one from the top to the bottom of the list, which is called “first population.”

For LaneLayoutManager, the first fill is “fill a list of bullets right next to the end of the list (out of the screen, not visible)”.

A source of LinearLayoutManager how to fill the table item analysis, in a previous article RecyclerView interview questions | rolling table when the item is being filled or recycled? The results are as follows:

  1. LinearLayoutManager inonLayoutChildren()Layout entries in the.
  2. Key methods for laying out entries includefill()andlayoutChunk(), the former represents a single population of the list, the latter represents a single population of the entry.
  3. Pass one in a fill actionwhileThe loop populates the entries until the list is empty. This process is represented in pseudocode as follows:
public class LinearLayoutManager {
   // Layout entry
   public void onLayoutChildren(a) {
       // Fill in the entry
       fill() {
           while(List has free space){// Populate a single entry
               layoutChunk(){
                   // make the entry a subview
                   addView(view)
               }
           }
       }
   }
}
Copy the code
  1. To avoid recreating the view every time you populate a new entry, you need to retrieve the entry view from the RecyclerView cache, calledRecycler.getViewForPosition(). A detailed explanation of this method can be found hereRecyclerView caching mechanism | how to reuse table?

After reading the source code and understanding the principle, bullet screen layout can be copied to write:

class LaneLayoutManager : RecyclerView.LayoutManager() {
    private val LAYOUT_FINISH = -1 // Mark the end of the fill
    private var adapterIndex = 0 // List adapter index

    // The longitudinal spacing of the barrage
    var gap = 5
        get() = field.dp
        
    // Layout the child
    override fun onLayoutChildren(recycler: RecyclerView.Recycler? , state:RecyclerView.State?). {
        fill(recycler)
    }
    // Fill in the entry
    private fun fill(recycler: RecyclerView.Recycler?). {
        // The height of the available barrage layout, i.e. the list height
        var totalSpace = height - paddingTop - paddingBottom
        var remainSpace = totalSpace
        // Continue filling entries as long as the space is sufficient
        while (goOnLayout(remainSpace)) {
            // Populate a single entry
            val consumeSpace = layoutView(recycler)
            if (consumeSpace == LAYOUT_FINISH) break
            // Update remaining space
            remainSpace -= consumeSpace
        }
    }
    
    // Whether there is any space left for padding and whether there is more data
    private fun goOnLayout(remainSpace: Int) = remainSpace > 0 && currentIndex in 0 until itemCount

    // Populate a single entry
    private fun layoutView(recycler: RecyclerView.Recycler?).: Int {
        // 1. Obtain the entry view from the cache pool
        OnCreateViewHolder () and onBindViewHolder() are triggered if the cache is not hit.
        valview = recycler? .getViewForPosition(adapterIndex) view ? :return LAYOUT_FINISH // The entry view fails to be obtained
        // 2. Make the entry view a list child
        addView(view)
        // 3. Measure table item view
        measureChildWithMargins(view, 0.0)
        // The height of the available barrage layout, i.e. the list height
        var totalSpace = height - paddingTop - paddingBottom
        // The number of lanes in a barrage, that is, how many rounds can be lengthways in the list
        val laneCount = (totalSpace + gap) / (view.measuredHeight + gap)
        // Calculate the swimlane of the current entry
        val index = currentIndex % laneCount
        // Calculates the top, bottom, left, and right borders of the current entry
        val left = width // The left side of the barrage is on the right side of the list
        val top = index * (view.measuredHeight + gap)
        val right = left + view.measuredWidth
        val bottom = top + view.measuredHeight
        // 4. Layout table item (this method takes into account ItemDecoration)
        layoutDecorated(view, left, top, right, bottom)
        val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)? .let { it.topMargin + it.bottomMargin } ? :0
        // Continue to get the next entry view
        adapterIndex++
        // Returns the number of pixels consumed to fill the entry
        return getDecoratedMeasuredHeight(view) + verticalMargin
    }
}
Copy the code

Each horizontal line for barrage rolling is called a “swimming lane”.

The swimlanes are spread vertically from the top of the list to the bottom. List height/lane height = number of swimlanes.

The fill() method uses the “remaining height of the list >0” as a loop to continuously fill the swimlane with entries. It goes through four steps:

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

After these four steps, the entry’s position relative to the list has been determined, and the entry’s view has been rendered.

Run the demo, sure enough, see nothing…

List scrolling logic has not been added, so entries with layouts on the outer right side of the list remain invisible. However, you can use the Layout Inspector tool in AndroidStudio to verify that the initial fill code is correct:

The Layout Inspector uses a wireframe to represent off-screen controls, as shown in the figure below. The outer right edge of the list is filled with four entries.

Automatic scrolling barrage

In order to see populated entries, the list must scroll spontaneously.

The most direct solution is constantly invoke RecyclerView. SmoothScrollBy (). We wrote an extended method for countdown:

fun <T> countdown(
    duration: Long.// Total countdown time
    interval: Long.// Countdown interval
    onCountdown: suspend (Long) - >T // Countdown callback
): Flow<T> =
    flow { (duration - interval downTo 0 step interval).forEach { emit(it) } }
        .onEach { delay(interval) }
        .onStart { emit(duration) }
        .map { onCountdown(it) }
        .flowOn(Dispatchers.Default)
Copy the code

Use Flow to build an asynchronous data Flow that emits a countdown remaining time each time. Detailed explanation about the Flow can click Kotlin asynchronous | Flow principles and application scenarios

You can then automatically scroll the list like this:

countdown(Long.MAX_VALUE, 50) {
    recyclerView.smoothScrollBy(10.0)
}.launchIn(MainScope())
Copy the code

Scroll 10 pixels to the left every 50 ms. The effect is shown below:

Keep filling the barrage

Because only the initial padding is done, that is, only one entry is filled for each lane, there is no subsequent barrage as the first row of entries rolls onto the screen.

LayoutManger. OnLayoutChildren () will only be in the list layout for the first time call once, the first barrage will only perform a filling. In order to continuously display the barrage, you must constantly fill in the entries as you scroll.

Before a RecyclerView interview questions | rolling table when the item is being filled or recycled? After analyzing the source code of continuous filling entries while the list is scrolling, the conclusion is quoted as follows:

  1. RecyclerView determines how many new entries need to be filled into the list based on the expected scrolling displacement before scrolling occurs.
  2. Performance in the source code, that is, inscrollVerticallyBy()In the callfill()Fill the entry:
public class LinearLayoutManager {
   @Override
   public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
       return scrollBy(dy, recycler, state);
   }
   
   int scrollBy(int delta, RecyclerView.Recycler recycler, RecyclerView.State state) {...// Fill in the entry
       final int consumed = mLayoutState.mScrollingOffset + fill(recycler, mLayoutState, state, false); . }}Copy the code

For the scene of bullet barrage, you can also write a similar:

class LaneLayoutManager : RecyclerView.LayoutManager() {
    override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler? , state:RecyclerView.State?).: Int {
        return scrollBy(dx, recycler) 
    }

    override fun canScrollHorizontally(a): Boolean {
        return true // indicates that the list can be scrolled horizontally}}Copy the code

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

RecyclerView’s scrolling is carried out section by section, and the displacement of each section of the scrolling will be passed through scrollHorizontallyBy(). This method typically populates new entries based on displacement, and then triggers the scrolling of the list. List scrolling source code analysis can click RecyclerView scrolling is how to achieve? (a) | unlock reading source new posture.

ScrollBy () encapsulates the logic for continuously populating entries based on scrolling. (Analysis later)

The logic of continuous entry filling is a little more complicated than that of the initial entry filling, which is just a matter of spreading the entry from top to bottom to fill the height of the list by swimlane. In the case of continuous filling, it is necessary to calculate which swimming lane is about to run out according to the rolling distance (the swimming lane without bullet screen display), and only fill the entry for the exhausted swimming lane.

In order to quickly obtain a depleted swimlane, a “swimlane” structure is abstracted to preserve the rolling information of the swimlane:

/ / lane
data class Lane(
    var end: Int.// The horizontal coordinate of the barrage at the end of the lane
    var endLayoutIndex: Int.// Index of the layout of the barrage at the end of the lane
    var startLayoutIndex: Int // Index of the layout of the swimlane head barrage
)
Copy the code

The swimlane structure contains three data, namely:

  1. Horizontal coordinate of the barrage at the end of the swimming lane: It is the right value of the last barrage in the swimming lane, that is, the distance of its right side relative to the left side of RecyclerView. This value is used to determine whether the lane will dry up after a displacement of the roll.
  2. Index of the layout of the barrage at the end of the lane: This is the index of the layout of the last barrage in the lane, which is recorded for easy passagegetChildAt()Gets the view of the last barrage in the swimlane. (The layout index is different from the adapter index. RecyclerView only holds a limited number of entries, so the value range of the layout index is [0,x]. The value of X is a little more than that of a screen entry, while the value of the adapter index is [0,∞] for the barrage.)
  3. Lane head barrage layout index: similar to 2, for easy access to the view of the first barrage in the lane.

With the help of the swimlane structure, we need to reconstruct the logic of the initial entry filling:

class LaneLayoutManager : RecyclerView.LayoutManager() {
    // The last barrage to be filled during the initial fill
    private var lastLaneEndView: View? = null
    // All lanes
    private var lanes = mutableListOf<Lane>()
    // First reload
    override fun onLayoutChildren(recycler: RecyclerView.Recycler? , state:RecyclerView.State?). {
        fillLanes(recycler, lanes)
    }
    // Fill the barrage through the loop
    private fun fillLanes(recycler: RecyclerView.Recycler? , lanes:MutableList<Lane>) {
        lastLaneEndView = null
        // Continue to fill the barrage if there is room in the vertical direction of the list
        while (hasMoreLane(height - lanes.bottom())) {
            // Fill a single barrage into the swimlane
            val consumeSpace = layoutView(recycler, lanes)
            if (consumeSpace == LAYOUT_FINISH) break}}// Fill a single barrage and record the swimlane information
    private fun layoutView(recycler: RecyclerView.Recycler? , lanes:MutableList<Lane>): Int {
        valview = recycler? .getViewForPosition(adapterIndex) view ? :return LAYOUT_FINISH
        measureChildWithMargins(view, 0.0)
        val verticalMargin = (view.layoutParams as? RecyclerView.LayoutParams)? .let { it.topMargin + it.bottomMargin } ? :0
        val consumed = getDecoratedMeasuredHeight(view) + if (lastLaneEndView == null) 0 else verticalGap + verticalMargin
        If the list can hold a new lane vertically, create a new lane, otherwise stop filling
        if (height - lanes.bottom() - consumed > 0) {
            lanes.add(emptyLane(adapterIndex))
        } else return LAYOUT_FINISH

        addView(view)
        // Get the latest added lane
        val lane = lanes.last()
        // Calculate the top, bottom, left and right borders of the barrage
        val left = lane.end + horizontalGap
        val top = if (lastLaneEndView == null) paddingTop elselastLaneEndView!! .bottom + verticalGapval right = left + view.measuredWidth
        val bottom = top + view.measuredHeight
        // Locate the barrage
        layoutDecorated(view, left, top, right, bottom)
        // Update the end-of-lane abscissa and layout index
        lane.apply {
            end = right
            endLayoutIndex = childCount - 1 // Because this is the newly appended entry, its index must be the largest
        }

        adapterIndex++
        lastLaneEndView = view
        return consumed
    }
}
Copy the code

The initial padding of the barrage is also a process of continuously adding lanes in the vertical direction. The logic to determine whether to add lanes is as follows: The height of the List is the bottom value of the current swimming Lane. The pixel value of this padding is > 0.

fun List<Lane>.bottom(a)= lastOrNull()? .getEndView()? .bottom ? :0
Copy the code

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

class LaneLayoutManager : RecyclerView.LayoutManager() {
    data class Lane(var end: Int.var endLayoutIndex: Int.var startLayoutIndex: Int)
    private fun Lane.getEndView(a): View? = getChildAt(endLayoutIndex)
}
Copy the code

Theoretically, “get the last barrage view in the swimlane” should be the method provided by Lane. But is it unnecessary to define it as an extension of Lane and also inside the LaneLayoutManager?

If defined inside a Lane, the layoutManager.getChildat () method cannot be accessed in that context, and if defined only as a private method of LaneLayoutManager, endLayoutIndex cannot be accessed. So the idea is to integrate the two contexts.

Go back to the logic of continuing to fill the barrage while scrolling:

class LaneLayoutManager : RecyclerView.LayoutManager() {
    override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler? , state:RecyclerView.State?).: Int {
        return scrollBy(dx, recycler) 
    }
    // Determine how many entries to fill according to the displacement size
    private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?).: Int {
        // Returns if the list has no children or does not scroll
        if (childCount == 0 || dx == 0) return 0
        // Update lane information before scrolling begins
        updateLanesEnd(lanes)
        // Get the absolute scroll value
        val absDx = abs(dx) 
        // Walk through all lanes and fill the dry lanes with bullets
        lanes.forEach { lane ->
            if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
        }
        // Scroll the list's foot: move the entry the same distance in the opposite direction of the finger's move
        offsetChildrenHorizontal(-absDx)
        return dx
    }
}
Copy the code

The logic for continuous reload while scrolling follows this order:

  1. Update the swimlane information
  2. Fill the dry lane with barrage
  3. Trigger the scroll

Both 1 and 2 occur before the actual rolling. Before the rolling, the rolling displacement has been obtained. According to the displacement, the swimming lane that will be exhausted after the rolling occurs can be calculated:

// Whether the lanes are dry
private fun Lane.isDrainOut(dx: Int): Boolean = getEnd(getEndView()) - dx < width
// Get the right value of the entry
private fun getEnd(view: View?). = 
    if (view == null) Int.MIN_VALUE 
    else getDecoratedRight(view) + (view.layoutParams as RecyclerView.LayoutParams).rightMargin
Copy the code

The determination of lane exhaustion is based on whether the right side of the last barrage in the lane is smaller than the width of the list after shifting dx to the left. If less than, it means that the barrage in the swimming lane has been fully displayed, and it is necessary to continue filling the barrage:

// Fill a new barrage when it scrolls
private fun layoutViewByScroll(recycler: RecyclerView.Recycler? , lane:Lane) {
    valview = recycler? .getViewForPosition(adapterIndex) view ? :return
    measureChildWithMargins(view, 0.0)
    addView(view)
    
    val left = lane.end + horizontalGap
    valtop = lane.getEndView()? .top ? : paddingTopval right = left + view.measuredWidth
    val bottom = top + view.measuredHeight
    layoutDecorated(view, left, top, right, bottom)
    lane.apply {
        end = right
        endLayoutIndex = childCount - 1
    }
    adapterIndex++
}
Copy the code

The fill logic is almost the same as the initial fill, the only difference being that the roll fill cannot be returned early because of insufficient space, because the lane is filled.

Why update lane information before filling a depleted lane?

// Update lane information
private fun updateLanesEnd(lanes: MutableList<Lane>){ lanes.forEach { lane -> lane.getEndView()? .let { lane.end = getEnd(it) } } }Copy the code

Because RecyclerView is a section of scrolling, it looks like a lost distance, scrollHorizontallyBy() may be called back a dozen times, each callback, the barrage will advance a small section, that is, the horizontal coordinate of the barrage at the end of the swimlane will change, which has to be synchronized to the Lane structure. Otherwise the calculation of lane depletion will be wrong.

Infinite scrolling barrage

After initial and continuous filling, the barrage is rolling smoothly. So how to make the only barrage data infinite rotation?

Just do a little trick with the Adapter:

class LaneAdapter : RecyclerView.Adapter<ViewHolder>() {
    / / data set
    private val dataList = MutableList()
    override fun getItemCount(a): Int {
        // Set the entry to infinity
        return Int.MAX_VALUE
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        val realIndex = position % dataList.size
        ...
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: MutableList<Any>) {
        valrealIndex = position % dataList.size ... }}Copy the code

Set the amount of data in the list to infinity, modulo the adapter index when creating an entry view and binding data to it.

Recycling barrage

The last challenge is how to recover the barrage. If there is no recycling, sorry about RecyclerView.

LayoutManager defines an entry for the reclaim entry:

public void removeAndRecycleView(View child, @NonNull Recycler recycler) {
    removeView(child);
    recycler.recycleView(child);
}
Copy the code

Recycling logic can eventually be delegated to Recycler. For an analysis of the source code for Recycler, see the following article:

  1. What RecyclerView caching mechanism | recycling?
  2. RecyclerView caching mechanism | recycling where?
  3. RecyclerView animation principle | change the posture to see the source code (pre – layout)
  4. RecyclerView animation principle | pre – layout, post – the relationship between the layout and scrap the cache
  5. RecyclerView interview question | what item in the table below is recycled to the cache pool?

For a barrage scenario, when will the barrage be recovered?

Of course it was the moment when the barrage rolled off the screen!

How can I capture this moment?

By calculating the displacement before each roll, of course!

In addition to continuing to fill the barrage while scrolling, it is also necessary to continue to recycle the barrage (this is written in the source code, I just copied) :

private fun scrollBy(dx: Int, recycler: RecyclerView.Recycler?).: Int {
    if (childCount == 0 || dx == 0) return 0
    updateLanesEnd(lanes)
    val absDx = abs(dx)
    // Keep filling the barrage
    lanes.forEach { lane ->
        if (lane.isDrainOut(absDx)) layoutViewByScroll(recycler, lane)
    }
    // Keep reclaiming the barrage
    recycleGoneView(lanes, absDx, recycler)
    offsetChildrenHorizontal(-absDx)
    return dx
}
Copy the code

Here’s the full version of scrollBy(), which is filled when scrolling and immediately recycled:

fun recycleGoneView(lanes: List<Lane>, dx: Int, recycler: RecyclerView.Recycler?).{ recycler ? :return
    // Walk the lane
    lanes.forEach { lane ->
        // Get lane head barragegetChildAt(lane.startLayoutIndex)? .let { startView ->// Recycle the splash screen if it has rolled off the screen
            if (isGoneByScroll(startView, dx)) {
                // Retrieve the barrage view
                removeAndRecycleView(startView, recycler)
                // Update lane information
                updateLaneIndexAfterRecycle(lanes, lane.startLayoutIndex)
                lane.startLayoutIndex += lanes.size - 1}}}}Copy the code

Recycle is the same as fill, also by traversing to find the vanishing barrage, recycle it.

The logic to judge the disappearance of barrage is as follows:

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

If the right of the barrage is shifted to the left dx and less than 0, it indicates that the barrage has been rolled off the list.

After recycling the barrage, detach it from RecyclerView. This operation will affect the layout index of the other barrage in the list. If an element in an array is deleted, the index of all subsequent elements is reduced by one:

fun updateLaneIndexAfterRecycle(lanes: List<Lane>, recycleIndex: Int) {
    lanes.forEach { lane ->
        if (lane.startLayoutIndex > recycleIndex) {
            lane.startLayoutIndex--
        }
        if (lane.endLayoutIndex > recycleIndex) {
            lane.endLayoutIndex--
        }
    }
}
Copy the code

Traverses all swimlanes. As long as the layout index of the splash screen at the head of the swimlane is greater than the recycle index, subtract it by one.

performance

Open GPU rendering mode again:

The experience was smooth and the bar chart did not cross the warning line.

talk is cheap, show me the code

The complete code can be searched for LaneLayoutManager in the REPo by clicking here.

conclusion

I spent a lot of time looking at the source code before, and there was also the question “what’s the use of looking at the source code so much?” Such doubts. This performance optimization is a good response. Because read RecyclerView source code, it solves the problem in the mind. This seed will germinate when there are performance issues with barrage. There are many solutions, and as the seed grows in the brain, so does the sprout. So look at the source code is to sow seeds, although not immediately germinate, but one day will bear fruit.