VK消息屏幕如何呈现?

VK在做什么以减少渲染滞后?如何显示非常大的消息而不杀死UiThread?如何减少RecyclerView中的滚动延迟?



我的经验是基于在VK Android应用程序中绘制消息屏幕的工作,其中有必要显示大量信息,同时在UI上施加最少的制动。

我从事Android编程已有近十年了,之前是PHP / Node.js的自由职业者。现在-高级Android开发人员VKontakte。

在Mobius 2019莫斯科会议上我的报告的剪辑和录像带下。


该报告揭示了三个主题



看屏幕:


该消息在五个屏幕上的某处。它们很可能与我们在一起(在转发邮件的情况下)。标准工具将不再起作用。即使在顶级设备上,一切也可能滞后。
好吧,除此之外,UI本身也很多样化:

  • 加载日期和指标,
  • 服务讯息
  • 文字(表情符号,链接,电子邮件,主题标签),
  • 机器人键盘
  • 约40种显示附件的方式,
  • 转发的消息树。

问题出现了:如何使滞后次数尽可能小?在简单消息的情况下和批量消息的情况下(以上视频的边缘情况)。


标准解决方案


RecyclerView及其附加组件


RecyclerView有各种附加组件。

  • setHasFixedSize(boolean

许多人认为列表项的大小相同时需要此标志。但实际上,从文档来看,情况恰恰相反。这是RecyclerView的大小恒定且独立于元素的时间(大约不是wrap_content)。设置标志有助于略微提高RecyclerView的速度,从而避免不必要的计算。

  • setNestedScrollingEnabled(boolean

略微优化,禁用NestedScroll支持。根据此屏幕上的NestedScroll,我们没有CollapsingToolbar或其他功能,因此我们可以安全地将此标志设置为false。

  • setItemViewCacheSize(cache_size

设置内部RecyclerView缓存。

许多人认为RecyclerView的机制是:

  • 屏幕上显示一个ViewHolder;
  • 有一个存储ViewHolder的RecycledViewPool;
  • ViewHolder — RecycledViewPool.

实际上,一切都有些复杂,因为这两件事之间有一个中间缓存。它称为ItemViewCache。它的本质是什么?当ViewHolder离开屏幕时,它不会放在RecycledViewPool中,而是放在中间缓存(ItemViewCache)中。适配器中的所有更改都适用于可见的ViewHolder和ItemViewCache内部的ViewHolder。对于RecycledViewPool中的ViewHolder,不会应用更改。

通过setItemViewCacheSize,我们可以设置此中间缓存的大小。
它越大,滚动在短距离上的速度就越快,但是更新操作将花费更长的时间(由于ViewHolder.onBind等)。

RecyclerView的实现方式以及其缓存的结构是一个相当大而复杂的主题。你可以读一篇很棒的文章,他们在其中详细介绍了所有内容。

优化OnCreate / OnBind


另一个经典的解决方案是优化onCreateViewHolder / onBindViewHolder:

  • 简单的布局(我们尝试尽可能使用FrameLayout或Custom ViewGroup),
  • 繁重的操作(解析链接/表情符号)是在消息加载阶段异步完成的,
  • 用于格式化名称,日期等的StringBuilder,
  • 以及其他减少这些方法工作时间的解决方案。

跟踪Adapter.onFailedToRecyclerView()




您有一个列表,其中某些元素(或其中的一部分)使用alpha进行了动画处理。当前处于动画过程中的View离开屏幕时,它不会进入RecycledViewPool。为什么?RecycledViewPool看到View现在已被View.hasTransientState标志设置为动画,并忽略了它。因此,下次您上下滚动时,将不会从RecycledViewPool拍摄图片,而是重新创建。

最正确的决定是当ViewHolder离开屏幕时,您需要取消所有动画。



如果您需要尽快获得修补程序,或者您是懒惰的开发人员,那么在onFailedToRecycle方法中,您始终可以始终返回true,并且一切正常,但是我不建议这样做。



跟踪透支和探查器


检测问题的经典方法是透支和探查器跟踪。

Overdraw-像素的重绘次数:层越少,像素重绘的次数越少,速度越快。但是根据我的观察,在现代现实中,这并不会对性能产生太大影响。



Profiler-又名Android Monitor,位于Android Studio中。在其中,您可以分析所有调用的方法。例如,打开消息,上下滚动,查看调用了哪些方法以及花费了多长时间。



左半部分是创建/渲染View / ViewHolder所需的Android系统调用。我们要么无法影响他们,要么我们将需要花费很多精力。

右半部分是我们在ViewHolder中运行的代码。

1号的调用块是对正则表达式的调用:它们在某个地方被忽略了并且忘记了将操作放在后台线程上,从而使滚动速度降低了20%。

2号呼叫模块-Fresco,一个用于显示图片的库。在某些地方它不是最佳选择,目前尚不清楚该滞后如何处理,但是如果我们能够解决它,我们将再节省约15%的时间。

也就是说,解决这些问题,我们可以得到〜35%的增长,这非常酷。

差异


你们中的许多人都以其标准形式使用DiffUtil:有两个列表-称为,比较和推送更改。在主线程上执行所有这些操作会有些昂贵,因为列表可能非常大。因此,通常DiffUtil计算在后台线程上运行。

ListAdapter和AsyncListDiffer为您做到这一点。 ListAdapter扩展了常规Adapter并异步启动所有内容-只需创建一个commitList,更改的整个计算便会飞向内部后台线程。 ListAdapter可以考虑频繁更新的情况:如果您连续调用三次,则只会获取最后的结果。

DiffUtil本身仅用于某些结构更改-消息的外观,其更改和删除。对于某些快速更改的数据,这是不合适的。例如,当我们上传照片或播放音频时。此类事件经常发生-每秒几次,如果每次运行DiffUtil,您将获得很多额外的工作。

动画制作


曾经有一个Animation框架-相当微不足道,但还是有一些。我们像这样与他一起工作:

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

问题在于,getTranslationX()参数将在动画前后返回相同的值。这是因为“动画”更改了视觉表示,但没有更改物理特性。



在Android 3.0中,出现了Animator框架,该框架更正确,因为它更改了对象的特定物理属性。



后来,出现了ViewPropertyAnimator,并且每个人仍然不十分了解它与Animator的区别。

我会解释。假设您需要对角平移-沿x,y轴移动View。您很可能会编写典型的代码:

val animX = ObjectAnimator.ofFloat(view, “translationX”, 100f)
val animY = ObjectAnimator.ofFloat(view, “translationY”, 200f)
AnimatorSet().apply {
    playTogether(animX, animY)
    start()
}

您可以将其缩短:

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

执行view.animate()时,将隐式启动ViewPropertyAnimator。

为什么需要它?

  1. 易于阅读和维护代码。
  2. 批处理操作动画。

在最后一种情况下,我们更改了两个属性。当我们通过动画师执行此操作时,将为每个Animator分别调用动画滴答。也就是说,setTranslationX和setTranslationY将分别调用,而View将分别执行更新操作。

就ViewPropertyAnimator而言,更改是同时发生的,因此可以节省操作量,并且可以更好地优化属性本身的更改。

您可以使用Animator来实现,但是您将不得不编写更多代码。此外,使用ViewPropertyAnimator,可以确保将尽可能优化动画。为什么?Android有一个RenderNode(DisplayList)。大致来说,它们缓存onDraw的结果并在重画时使用它。ViewPropertyAnimator直接与RenderNode一起使用,并对其应用动画,从而避免了onDraw调用。

许多View属性也可以直接影响RenderNode,但不是全部。也就是说,使用ViewPropertyAnimator时,可以确保使用最有效的方法。如果突然有某种ViewPropertyAnimator无法完成的动画,那么也许您应该考虑一下并进行更改。

动画:TransitionManager


通常,人们认为该框架用于从一个活动转移到另一个活动。实际上,它可以以不同的方式使用,并大大简化了动画更改结构的实现。假设我们有一个播放语音消息的屏幕。我们用十字架将其关闭,然后模具上升。怎么做?动画非常复杂:播放器以alpha闭合,而不是平移,而是改变其高度。同时,我们的清单增加并且还改变了高度。



如果玩家是列表的一部分,那么动画将非常简单。但是对于我们来说,玩家不是列表中的一个元素,而是一个完全独立的View。

也许我们将开始编写某种Animator,然后遇到问题,崩溃,开始锯拐杖并加倍代码。并会得到类似下面的屏幕。



使用TransitionManager,您可以使一切变得更简单:

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

所有动画都会在后台自动发生。看起来很神奇,但是如果深入了解它的工作原理,结果发现TransitionManager只是订阅所有View,捕获其属性中的更改,计算diff,在必要时创建必要的动画器或ViewPropertyAnimator,并尽可能高效地执行所有操作。TransitionManager允许我们快速轻松地实现消息部分中的动画。

定制解决方案




这是性能和随之而来的问题所基于的最基本的内容。当您的消息显示在10个屏幕上时该怎么办?如果您注意的话,那么我们所有的元素都位于彼此的正下方。如果我们认为ViewHolder不是一条消息,而是数十种不同的ViewHolders,那么一切都会变得简单得多。

对于我们来说,该消息已变为10个屏幕并不是问题,因为在一个具体示例中,我们现在仅显示六个ViewHolders。我们的布局很简单,代码更易于维护,除了一个-没有其他特殊问题外,该怎么做?



有简单的ViewHolders-它们是经典的日期分隔符,Load more,等等。和BaseViewHolder-消息的有条件基本ViewHolder。它具有基本的实现和几个特定的​​实现-TextViewHolder,PhotoViewHolder,AudioViewHolder,ReplyViewHolder等。大约有70个。

BaseViewHolder负责什么?


BaseViewHolder仅负责绘制头像和所需的气泡,以及转发消息的行-左侧为蓝色。



内容的具体实现已由其他BaseViewHolder继承人执行:TextViewHolder仅显示文本,FwdSenderViewHolder-转发消息的作者,AudioMsgViewHolder-语音消息,等等。



有一个问题:宽度怎么办?想象至少在两个屏幕上的消息。尚不清楚要设置什么宽度,因为一半是可见的,一半是不可见的(甚至尚未创建)。绝对无法衡量所有事情,因为它存在。 have,我得utch一拐。在某些情况下,消息非常简单:纯文本或语音-通常由一个项目组成。



在这种情况下,请使用经典的wrap_content。对于复杂的情况,当一条消息由几部分组成时,我们采用并强制每个ViewHolder固定宽度。具体在这里-220 dp。



如果文本很短并且转发了邮件,则右侧有一个空白区域。这是无法逃脱的,因为性能更为重要。几年来,一直没有投诉-也许有人注意到了,但总的来说,每个人都习惯了。



有边缘情况。如果我们用标贴回应消息,则可以专门为这种情况指定宽度,以使其看起来更漂亮。

我们在加载消息的阶段分为ViewHolders:我们开始后台加载消息,将其转换为项目,它们直接显示在ViewHolders中。



全局RecycledViewPool


使用我们的Messenger的机制是,人们不会坐在同一聊天室中,而是经常在他们之间闲逛。在标准方法中,当我们进入聊天室并离开聊天室时,RecycledViewPool(和其中的ViewHolder)被简单破坏,并且每次我们花费资源创建ViewHolder时。

这可以通过全局RecycledViewPool解决:

  • 在Application框架内,RecycledViewPool作为单例存在;
  • 当用户在屏幕之间移动时,可在消息屏幕上重用;
  • 设置为RecyclerView.setRecycledViewPool(池)。

有陷阱,重要的是要记住两件事:

  • 您转到屏幕,单击后退,退出。问题在于屏幕上的那些ViewHolders被扔掉了,并且没有返回到池中。这是固定的,如下所示:
    LinearLayoutManager.recycleChildrenOnDetach = true
  • RecycledViewPool具有局限性:每个ViewType最多只能存储五个ViewHolders。

如果屏幕上显示9个TextView,则只有五个项目将返回到RecycledViewPool,其余的将被丢弃。您可以更改RecycledViewPool的大小:
RecycledViewPool.setMaxRecycledViews(viewType,大小),

但它是一种悲哀写在用你的双手各ViewType,因为你可以写你的RecycledViewPool,扩大标准之一,并使其NOLIMIT。通过链接,您可以下载完成的实现。

DiffUtil并不总是有用的


这是一个经典案例-下载,播放音轨和语音消息。在这种情况下,DiffUtil会发送垃圾邮件。



我们的BaseViewHolder具有抽象的updateUploadProgress方法。

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

要引发事件,我们需要绕过所有可见的ViewHolder:

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

这是一个简单的操作,屏幕上不可能有十个以上的ViewHolder。这种方法原则上不能落后。如何找到可见的ViewHolder?天真的实现将是这样的:

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

但是有一个问题。我之前提到的中间缓存ItemViewCache包含活动的ViewHolders,它们根本不会出现在屏幕上。上面的代码不会影响它们。直接地,我们也无法解决它们。然后拐杖来帮助我们。创建一个WeakSet来存储指向ViewHolder的链接。此外,对于我们而言,只需绕过此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叠加层


考虑故事的例子。以前,如果某人对带有贴纸的故事有反应,我们将其显示为:



看起来很丑。我想做得更好,因为故事内容生动有趣,而且我们在那里有一个小广场。但是我们想要得到这样的东西:



有一个问题:我们的消息被分解为ViewHolder,它们严格位于彼此之间,但是在这里它们重叠了。立即不清楚如何解决此问题。您可以创建另一个ViewType“历史记录+贴纸”或“历史记录+语音消息”。因此,我们将拥有140个视图而不是70个ViewType。不,我们需要提出一些更方便的方法。



您会想到Android中最喜欢的拐杖之一。例如,我们正在做某事,但Pixel Perfect并未收敛。要解决此问题,您需要删除所有内容并从头开始写,但是要懒惰。结果,我们可以使margin = -2dp(负),现在一切都准备就绪。但是,这种方法不能在这里使用。如果将边距设置为负,则标签将移动,但其占据的位置将保持空白。但是我们有ItemDecoration,其中itemOffset可以设为负数。而且有效!结果,我们得到了预期的覆盖,并且在同一时间,每个ViewHolder都是朋友。

只需一行即可获得精美的解决方案。

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

闲人


这是带星号的情况,它很复杂,在实践中并不经常需要,但是了解这种方法的存在很重要。

首先,我将告诉您UiThread主线程的工作方式。通用方案:有一个任务事件队列,其中通过handler.post设置任务,并通过该队列进行无限循环。也就是说,UiThread只是while(true)。如果有任务,我们将执行它们;如果没有,我们将等待它们出现。



在我们通常的现实中,Handler负责将任务放入队列,而Looper不断地绕过队列。对于UI,有些任务不是很重要。例如,用户阅读了一条消息-当我们立即或在20毫秒后将其显示在UI上时,这对我们来说并不重要。用户不会注意到差异。那么,也许只有在空闲时才值得在主线程上运行此任务吗?也就是说,很高兴知道何时调用awaitNewTask行。对于这种情况,Looper有一个addIdleHandler,它在task.isEmpty代码触发时触发。

Looper.myQueue()。AddIdleHandler()

然后,最简单的IdleHandler实现将如下所示:

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

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

同样,您可以衡量应用程序的正常启动。

表情符号


我们使用自定义表情符号代替系统表情符号。这是一个表情符号在不同年份在不同平台上的外观示例。左和右表情符号非常好,但是在中间...



还有第二个问题:



每行都是相同的表情符号,但是它们产生的情感是不同的。我最喜欢右下角,但我仍然不明白这意味着什么。

VKontakte有一辆自行车。在〜2014年,我们略微更改了一个表情符号。也许有人记得-“棉花糖”是。换衣服后,一场小暴动开始了。当然,他没有达到“退墙”的水平,但是反应是非常有趣的。这告诉我们解释表情符号的重要性。

表情符号的制作方式:我们有一个大的位图,它们全部组装在一个大的“地图集”中。其中有几个-在不同的DPI下。还有一个EmojiSpan,其中包含信息:我在某某位置(x,y)绘制某某某位图中的“某某某”表情符号。
还有一个ReplacementSpan,可让您在Span下显示某些内容而不是文本。
也就是说,您可以在文本中找到表情符号,并用EmojiSpan包裹起来,然后系统将绘制所需的表情符号,而不是系统的表情符号。



备择方案


膨胀


有人可能会说,由于充气速度很慢,为什么不用手创建布局,避免充气。从而避免了100500 ViewHolder,从而加快了所有工作。这是一种错觉。在您做某事之前,值得对其进行评估。

Android具有Debug类,它具有startMethodTracing和stopMethodTracing。

Debug.startMethodTracing(“trace»)
inflate(...)
Debug.stopMethodTracing()

这将使我们能够收集有关特定代码执行时间的信息。



而且我们看到这里的膨胀甚至是不可见的。四分之一的时间用于加载可绘制对象,四分之一的时间用于加载颜色。而在etc部分的某处是我们的通货膨胀。

我试图将XML布局转换为代码,并保存了大约0.5毫秒。实际上,增长并不是最令人印象深刻的。而且代码变得更加复杂。也就是说,重写没有多大意义。

此外,实际上,许多人根本不会遇到此问题,因为通常仅在应用程序变得很大时才会发生长时间膨胀。例如,在我们的VKontakte应用程序中,大约有200-300个不同的屏幕,所有资源的加载都崩溃了。对此尚不清楚。最有可能的是,您将必须编写自己的资源管理器。

安科


Anko最近已弃用。无论如何,Anko并不是魔术,而是简单的语法糖。它以相同的方式将所有内容转换为有条件的新View()。因此,Anko没有任何好处。

光刻/颤振


我为什么要结合两个完全不相关的东西?因为这与技术无关,而是与向技术迁移的复杂性有关。您不仅可以借阅并转移到新图书馆。

尚不清楚这是否会提高性能。我们不会遇到新的问题,因为数百万使用完全不同的设备的人每分钟都会使用我们的应用程序(您可能甚至没有听说过其中的四分之一)。此外,消息是非常大的代码库。不可能立即重写所有内容。由于技术的炒作而这样做是愚蠢的。特别是当Jetpack Compose隐约出现在某个地方时。

Jetpack撰写


Google都以这个库的形式向我们承诺从天上来的吗哪,但它仍然处于alpha状态。以及何时发布-尚不清楚。我们是否能以目前的形式获得它也不清楚。现在还为时过早。让它稳定运行,关闭主要错误。只有这样,我们才能朝他的方向看。

一个大的自定义视图


那些使用各种即时通讯程序的人还讨论了另一种方法:“采用并编写一个大的自定义视图,没有复杂的层次结构”。

缺点是什么?

  • 很难维护。
  • 在当前现实中这没有意义。

在Android 4.3中,View内部的内部缓存系统得到了提升。例如,如果视图未更改,则不调用onMeasure。并且使用先前测量的结果。

在Android 4.3-4.4中,出现了RenderNode(DisplayList),用于缓存渲染。让我们看一个例子。假设对话框列表中有一个单元格:化身,标题,字幕,读取状态,时间,另一个化身。有条件-10个元素。我们编写了自定义视图。在这种情况下,更改一个属性时,我们将重新测量所有元素。也就是说,只需花费额外的资源。对于ViewGroup,其中每个元素都是单独的View,当您更改一个View时,我们将仅使一个View无效(除非该View影响其他View的大小)。

摘要


因此,您已经了解到,我们将经典的RecyclerView与标准优化结合使用。有一部分是非标准的,其中最重要和最基本的是将消息拆分为ViewHolder。当然,您可以说这是狭义的适用范围,但是这种方法也可以投影到其他事物上,例如,在一个具有1万个字符的大文本上。它可以分为几个段落,其中每个段落都是一个单独的ViewHolder。

还有必要最大化@WorkerThread上的所有内容:解析链接DiffUtils-从而尽可能多地卸载@UiThead。

全局RecycledViewPool允许您在消息屏幕之间移动,而不必每次都创建ViewHolder。

但是,我们还有其他重要的事情尚未决定,例如,长期膨胀,或者更确切地说,是从资源中加载数据。

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

All Articles