How does the VK message screen render?

What is VK doing to reduce rendering lags? How to display a very large message and not kill UiThread? How to reduce scrolling delays in RecyclerView?



My experience is based on the work of drawing a message screen in the VK Android application, in which it is necessary to show a huge amount of information with a minimum of brakes on the UI.

I have been programming for Android for almost ten years, I was previously freelance for PHP / Node.js. Now - a senior Android developer VKontakte.

Under the cut - video and transcript of my report from the Mobius 2019 Moscow conference.


The report reveals three topics



Look at the screen:


This message is somewhere on five screens. And they may well be with us (in the case of forwarding messages). Standard tools will no longer work. Even on a top device, everything can lag.
Well, in addition to this, the UI itself is quite diverse:

  • loading dates and indicators,
  • service messages
  • text (emoji, link, email, hashtags),
  • bot keyboard
  • ~ 40 ways to display attachments,
  • tree of forwarded messages.

The question arises: how to make the number of lags as small as possible? Both in the case of simple messages, and in the case of bulk messages (edge-case from the video above).


Standard Solutions


RecyclerView and its add-ons


There are various add-ons for RecyclerView.

  • setHasFixedSize ( boolean)

Many people think that this flag is needed when the list items are the same size. But in fact, judging by the documentation, the opposite is true. This is when the size of the RecyclerView is constant and independent of the elements (roughly, not wrap_content). Setting the flag helps to slightly increase the speed of the RecyclerView so that it avoids unnecessary calculations.

  • setNestedScrollingEnabled ( boolean)

Minor optimization that disables NestedScroll support. We do not have CollapsingToolbar or other features depending on NestedScroll on this screen, so we can safely set this flag to false.

  • setItemViewCacheSize ( cache_size)

Setting up the internal RecyclerView cache.

Many people think that the mechanics of RecyclerView are:

  • there is a ViewHolder displayed on the screen;
  • there is a RecycledViewPool storing ViewHolder;
  • ViewHolder ā€” RecycledViewPool.

In practice, everything is a little more complicated, because there is an intermediate cache between these two things. It is called ItemViewCache. What is its essence? When the ViewHolder leaves the screen, it is not placed in the RecycledViewPool, but in the intermediate cache (ItemViewCache). All changes in the adapter apply to both the visible ViewHolder and the ViewHolder inside the ItemViewCache. And for the ViewHolder inside the RecycledViewPool, the changes are not applied.

Through setItemViewCacheSize we can set the size of this intermediate cache.
The larger it is, the faster the scroll will be over short distances, but the update operations will take longer (due to ViewHolder.onBind, etc.).

How RecyclerView is implemented and how its cache is structured is a rather large and complex topic. You can read a great article, where they talk in detail about everything.

Optimization OnCreate / OnBind


Another classic solution is to optimize onCreateViewHolder / onBindViewHolder:

  • easy layout (we try to use FrameLayout or Custom ViewGroup as much as possible),
  • heavy operations (parsing links / emoji) are done asynchronously at the stage of message loading,
  • StringBuilder for formatting name, date, etc.,
  • and other solutions that reduce the working time of these methods.

Tracking Adapter.onFailedToRecyclerView ()




You have a list in which some elements (or part of them) are animated with alpha. At the moment when View, being in the process of animation, leaves the screen, then it does not go to RecycledViewPool. Why? RecycledViewPool sees that the View is now animated by the View.hasTransientState flag, and simply ignores it. Therefore, the next time you scroll up and down, the picture will not be taken from RecycledViewPool, but will be created anew.

The most correct decision is when ViewHolder leaves the screen, you need to cancel all animations.



If you need a hotfix as soon as possible or you are a lazy developer, then in the onFailedToRecycle method you can simply always return true and everything will work, but I would not advise doing so.



Tracking Overdraw and Profiler


The classic way to detect problems is overdraw and profiler tracking.

Overdraw - the number of redraws of the pixel: the fewer layers and the less redraw the pixel, the faster. But according to my observations, in modern realities, this does not so much affect performance.



Profiler - aka Android Monitor, which is in Android Studio. In it, you can analyze all the called methods. For example, open messages, scroll up and down and see which methods were called and how long they took.



All that is in the left half are the Android system calls that are needed to create / render a View / ViewHolder. We either canā€™t influence them, or we will need to spend a lot of effort.

The right half is our code that runs in ViewHolder.

The call block at number 1 is a call to regular expressions: somewhere they overlooked and forgot to transfer the operation to the background thread, thereby slowing the scroll by ~ 20%.

Call block at number 2 - Fresco, a library for displaying pictures. It is not optimal in some places. It is not yet clear what to do with this lag, but if we can solve it, we will save another ~ 15%.

That is, fixing these problems, we can get an increase of ~ 35%, which is pretty cool.

Diffiff


Many of you use DiffUtil in its standard form: there are two lists - called, compared and pushed changes. Doing all this on the main thread is a bit expensive because the list can be very large. So usually DiffUtil computation runs on a background thread.

ListAdapter and AsyncListDiffer do this for you. The ListAdapter extends the regular Adapter and starts everything asynchronously - just make a submitList and the entire calculation of the changes flies to the internal background thread. The ListAdapter can take into account the case of frequent updates: if you call it three times in a row, it will take only the last result.

DiffUtil itself we use only for some structural changes - the appearance of the message, its change and deletion. For some quick-change data, it is not suitable. For example, when we upload a photo or play audio. Such events occur often - several times per second, and if you run DiffUtil each time, you will get a lot of extra work.

Animations


Once upon a time there was an Animation framework - rather meager, but still something. We worked with him like this:

view.startAnimation(TranslateAnimation(fromX = 0, toX = 300))

The problem is that the getTranslationX () parameter will return the same value before and after the animation. This is because Animation changed the visual representation, but did not change the physical properties.



In Android 3.0, the Animator framework appeared, which is more correct because it changed the specific physical property of the object.



Later, ViewPropertyAnimator appeared and everyone still does not really understand its difference from Animator.

I will explain. Let's say you need to do the translation diagonally - shift the View along the x, y axes. Most likely you would write typical code:

val animX = ObjectAnimator.ofFloat(view, ā€œtranslationXā€, 100f)
val animY = ObjectAnimator.ofFloat(view, ā€œtranslationYā€, 200f)
AnimatorSet().apply {
    playTogether(animX, animY)
    start()
}

And you can make it shorter:

view.animate().translationX(100f).translationY(200f) 

When you execute view.animate (), you implicitly launch ViewPropertyAnimator.

Why is it needed?

  1. Easier to read and maintain code.
  2. Batch operations animation.

In our last case, we changed two properties. When we do this through animators, animation ticks will be called separately for each Animator. That is, setTranslationX and setTranslationY will be called separately, and View will perform update operations separately.

In the case of ViewPropertyAnimator, the change occurs at the same time, so there is a savings due to fewer operations and the change in properties itself is better optimized.

You can achieve this with Animator, but you will have to write more code. In addition, using ViewPropertyAnimator, you can be sure that the animations will be optimized as much as possible. Why? Android has a RenderNode (DisplayList). Very roughly, they cache the result of onDraw and use it when redrawing. ViewPropertyAnimator works directly with RenderNode and applies animations to it, avoiding onDraw calls.

Many View properties can also directly affect the RenderNode, but not all. That is, when using ViewPropertyAnimator, you are guaranteed to use the most efficient way. If you suddenly have some kind of animation that cannot be done with ViewPropertyAnimator, then maybe you should think about it and change it.

Animations: TransitionManager


Usually, people associate that this framework is used to move from one Activity to another. In fact, it can be used differently and greatly simplify the implementation of the animation of structural changes. Suppose we have a screen on which a voice message plays. We close it with a cross, and the die goes up. How to do it? The animation is quite complicated: the player closes with alpha, while moving not through translation, but changes its height. At the same time, our list goes up and also changes the height.



If the player were part of the list, then the animation would be quite simple. But with us the player is not an element of the list, but a completely independent View.

Perhaps we would start writing some kind of Animator, then we would encounter problems, crashes, start sawing crutches and double the code. And would get something like the screen below.



With TransitionManager, you can make everything simpler:

TransitionManager.beginDelayedTransition(
        viewGroup = <LinearLayoutManager>,
        transition = AutoTransition())
playerView.visibility = View.GONE

All animation happens automatically under the hood. It looks like magic, but if you go deeper inside and see how it works, it turns out that the TransitionManager simply subscribes to all View, catches the changes in their properties, calculates diff, creates the necessary animators or ViewPropertyAnimator where necessary, and does everything as efficiently as possible. TransitionManager allows us to make animations in the message section quick and easy to implement.

Custom solutions




This is the most fundamental thing on which performance and the problems that follow are based. What to do when your message is on 10 screens? If you pay attention, then all of our elements are located exactly under each other. If we accept that ViewHolder is not one message, but dozens of different ViewHolders, then everything becomes much simpler.

Itā€™s not a problem for us that the message has become 10 screens, because now we display only six ViewHolders in a concrete example. We got an easy layout, the code is easier to maintain, and there are no special problems, except for one - how to do this?



There are simple ViewHolders - these are classic date separators, Load more, and so on. And BaseViewHolder - conditionally basic ViewHolder for the message. It has a basic implementation and several specific ones - TextViewHolder, PhotoViewHolder, AudioViewHolder, ReplyViewHolder and so on. There are about 70 of them.

What is BaseViewHolder responsible for?


BaseViewHolder is only responsible for drawing the avatar and the desired piece of bubble, as well as the line for forwarded messages - blue to the left.



The concrete implementation of the content is already carried out by other BaseViewHolder heirs: TextViewHolder displays only text, FwdSenderViewHolder - the author of the forwarded message, AudioMsgViewHolder - the voice message, and so on.



There is a problem: what to do with the width? Imagine a message on at least two screens. It is not very clear what width to set, because half is visible, half is not visible (and has not even been created yet). Absolutely everything cannot be measured, because it lays. I have to crutch a little, alas. There are simple cases when the message is very simple: purely text or voice - in general, consists of one Item.



In this case, use the classic wrap_content. For a complex case, when a message consists of several pieces, we take and force each ViewHolder a fixed width. Specifically here - 220 dp.



If the text is very short and the message is forwarded, there is an empty space on the right. There is no escape from this, because performance is more important. For several years, there were no complaints - maybe someone noticed, but in general, everyone got used to it.



There are edge cases. If we respond to a message with a sticker, then we can specify the width specifically for such a case, so that it looks prettier.

We split into ViewHolders at the stage of loading messages: we start the background loading of the message, convert it to item, they are directly displayed in ViewHolders.



Global RecycledViewPool


The mechanics of using our messenger are such that people do not sit in the same chat, but constantly go between them. In the standard approach, when we went into the chat and left it, the RecycledViewPool (and the ViewHolder in it) are simply destroyed, and every time we spend resources creating the ViewHolder.

This can be solved by global RecycledViewPool:

  • within the framework of Application, RecycledViewPool lives as a singleton;
  • reused on the message screen when the user walks between screens;
  • set as RecyclerView.setRecycledViewPool (pool).

There are pitfalls, it is important to remember two things:

  • you go to the screen, click back, exit. The problem is that those ViewHolders that were on the screen are thrown away, and not returned to the pool. This is fixed as follows:
    LinearLayoutManager.recycleChildrenOnDetach = true
  • RecycledViewPool has limitations: no more than five ViewHolders can be stored for each ViewType.

If 9 TextViews are displayed on the screen, only five items will be returned to the RecycledViewPool, and the rest will be thrown away. You can change the size of the RecycledViewPool:
RecycledViewPool.setMaxRecycledViews (viewType, size)

But itā€™s kind of sad to write on each ViewType with your hands, because you can write your RecycledViewPool, expanding the standard one, and make it NoLimit. By the link you can download the finished implementation.

DiffUtil is not always useful


Here is a classic case - download, playing an audio track and voice message. In this case, DiffUtil calls spam.



Our BaseViewHolder has an abstract updateUploadProgress method.

abstract class BaseViewHolder : ViewHolder {
    ā€¦
    fun updateUploadProgress(attachId: Int, progress: Float)
    ā€¦        
}

To throw an event, we need to bypass all visible ViewHolder:

fun onUploadProgress(attachId: Int, progress: Float) {
    forEachActiveViewHolder {
        it.updateUploadProgress(attachId, progress)
    }
}

This is a simple operation, it is unlikely that we will have more than ten ViewHolder on the screen. Such an approach cannot lag in principle. How to find visible ViewHolder? A naive implementation would be something like this:

val firstVisiblePosition = <...>
val lastVisiblePosition = <...>
for (i in firstVisiblePosition.. lastVisiblePosition) {
    val viewHolder = recycler.View.findViewHolderForAdapterPosition(i)
    viewHolder.updateUploadProgress(..)
}

But there's a problem. The intermediate cache that I mentioned earlier, ItemViewCache, contains active ViewHolders that simply do not appear on the screen. The code above will not affect them. Directly, we too cannot address them. And then crutches come to our aid. Create a WeakSet that stores links to ViewHolder. Further it is enough for us to simply bypass this WeakSet.

class Adapter : RecyclerView.Adapter {
    val activeViewHolders = WeakSet<ViewHolder>()
        
    fun onBindViewHolder(holder: ViewHolder, position: Int) {
        activeViewHolders.add(holder)
    }

    fun onViewRecycled(holder: ViewHolder) {
        activeViewHolders.remove(holder)
    }
}

ViewHolder Overlay


Consider the example of stories. Previously, if a person reacted to a story with a sticker, we displayed it like this:



It looks pretty ugly. I wanted to do better, because the stories are bright content, and we have a small square there. We wanted to get something like this:



There is a problem: our message is broken into ViewHolder, they are located strictly under each other, but here they overlap. Immediately it is not clear how to solve this. You can create another ViewType ā€œhistory + stickerā€ or ā€œhistory + voice messageā€. So, instead of 70 ViewType, we would have 140 ... No, we need to come up with something more convenient.



One of your favorite crutches in Android comes to mind. For example, we were doing something, but Pixel Perfect doesnā€™t converge. To fix this, you need to delete everything and write from scratch, but laziness. As a result, we can make margin = -2dp (negative), and now everything falls into place. But just such an approach cannot be used here. If you set a negative margin, the sticker will move, but the place it occupied will remain empty. But we have ItemDecoration, where itemOffset we can make a negative number. And it works! As a result, we get the expected overlay and at the same time there remains a paradigm where each ViewHolder is friend.

A beautiful solution in one line.

class OffsetItemDecoration : RecyclerViewItemDecoration() {
    overrride fun getItemOffsets(offset: Rect, ā€¦) {
        offset.top = -100dp
    }
}

Idlehandler


This is a case with an asterisk, it is complex and not so often needed in practice, but it is important to know about the existence of such a method.

First, Iā€™ll tell you how the main UiThread thread works. The general scheme: there is a tasks event queue in which tasks are set through handler.post, and an infinite loop that goes through this queue. That is, UiThread is just while (true). If there are tasks, we execute them, if not, we wait until they appear.



In our usual realities, Handler is responsible for putting tasks into the queue, and Looper endlessly bypasses the queue. There are tasks that are not very important for the UI. For example, a user read a message - it is not so important for us when we display it on the UI, right now or after 20 ms. The user will not notice the difference. Then, perhaps it is worth running this task on the main thread only when it is free? That is, it would be nice for us to know when the awaitNewTask line is called. For this case, Looper has an addIdleHandler that fires when the tasks.isEmpty code fires.

Looper.myQueue (). AddIdleHandler ()

And then the simplest implementation of IdleHandler will look like this:

@AnyThread
class IdleHandler {
    private val handler = Handler(Looper.getMainLooper())

    fun post(task: Runnable) {
        handler.post {
            Looper.myQueue().addIdleHandler {
                task.run()
                return@addIdleHandler false
            }
        }
    }
}

In the same way, you can measure an honest cold start of the application.

Emoji


We use our custom emoji instead of system ones. Here is an example of how emojis looked on different platforms in different years. The left and right emoji are pretty nice, but in the middle ...



There is a second problem:



Each row is the same emoji, but the emotions they reproduce are different. I like the bottom right most, I still don't understand what it means.

There is a bike from VKontakte. In ~ 2014, we slightly changed one emoji. Maybe someone remembers - "Marshmallow" was. After his change, a mini-riot began. Of course, he did not reach the ā€œreturn the wallā€ level, but the reaction was quite interesting. And this tells us about the importance of interpreting emoji.

How emojis are made: we have a big bitmap, where they are all assembled in one big ā€œatlasā€. There are several of them - under different DPI. And there is an EmojiSpan that contains information: I draw "such-and-such" emoji, it is in such-and-such a bitmap at such-and-such location (x, y).
And there is a ReplacementSpan that allows you to display something instead of text under Span.
That is, you find emoji in the text, wrap it with EmojiSpan, and the system draws the desired emoji instead of the system one.



Alternatives


Inflate


Someone may say that since inflate is slow, why not just create layout with your hands, avoiding inflate. And thereby speed up everything by avoiding the 100500 ViewHolder. It's a delusion. Before you do something, it is worth measuring it.

Android has a Debug class, it has startMethodTracing and stopMethodTracing.

Debug.startMethodTracing(ā€œtraceĀ»)
inflate(...)
Debug.stopMethodTracing()

It will allow us to collect information about the execution time of a particular piece of code.



And we see that here inflate as such is even invisible. A quarter of the time was spent loading drawable, a quarter was loading colors. And only somewhere in the etc part is our inflate.

I tried to translate the XML layout into code and saved somewhere around 0.5 ms. The increase, in fact, is not the most impressive. And the code has become much more complicated. That is, rewriting does not make much sense.

Moreover, in practice, many will not encounter this problem at all, because a long inflate usually occurs only when the application becomes very large. In our VKontakte application, for example, there are approximately 200-300 different screens, and loading of all resources crashes. What to do with this is still unclear. Most likely, you will have to write your own resource manager.

Anko


Anko has recently become deprecated. Anyway, Anko is not magic, but simple syntactic sugar. It translates everything to the conditional new View () in the same way. Therefore, there is no benefit from Anko.

Litho / Flutter


Why did I combine two completely unrelated things? Because this is not about technology, but about the complexity of migration to it. You canā€™t just borrow and move to a new library.

It is unclear whether this will give us a performance boost. And will we not get new problems, because millions of people with completely different devices use our application every minute (you probably havenā€™t even heard about a quarter of them). Moreover, messages are a very large code base. It is impossible to rewrite everything instantly. And doing it because of the hype of technology is stupid. Especially when the Jetpack Compose looms somewhere far away.

Jetpack compose


Google all promises us manna from heaven in the form of this library, but it is still in alpha. And when it will be in the release - it is not clear. Whether we can get it in its current form is also unclear. Itā€™s too early to experiment. Let it come out in stable, let the main bugs close. And only then will we look in his direction.

One Large Custom View


There is another approach that those who use various instant messengers talk about: "take and write one large Custom View, no complicated hierarchy."

What are the disadvantages?

  • Itā€™s hard to maintain.
  • It does not make sense in current realities.

With Android 4.3, the internal caching system inside View was pumped. For example, onMeasure is not called if the View has not changed. And the results of the previous measurement are used.

With Android 4.3-4.4, a RenderNode (DisplayList) appeared, caching rendering. Let's look at an example. Suppose there is a cell in the list of dialogs: avatar, title, subtitle, read status, time, another avatar. Conditionally - 10 elements. And we wrote Custom View. In this case, when changing one property, we will re-measure all the elements. That is, just spend the extra resources. In the case of the ViewGroup, where each element is a separate View, when changing one View, we will invalidate only one View (except when this View affects the size of others).

Summary


So, you have learned that we use the classic RecyclerView with standard optimizations. There is a part of non-standard ones, where the most important and fundamental is splitting the message into ViewHolder. Of course, you can say that this is narrowly applicable, but this approach can also be projected onto other things, for example, on a large text of 10 thousand characters. It can be divided into paragraphs, where each paragraph is a separate ViewHolder.

Itā€™s also worth maximizing everything on @WorkerThread: parsing links, DiffUtils - thereby unloading @UiThead as much as possible.

The global RecycledViewPool allows you to walk between message screens and not create a ViewHolder each time.

But there are other important things that we have not yet decided, for example, a long inflate, or rather, loading data from resources.

, Mobius 2019 Piter , . , , SQLite, . Mobius 2020 Piter .

All Articles