关于前端:万字干货IM-会话列表卡顿优化实践内附代码

11次阅读

共计 16560 个字符,预计需要花费 42 分钟才能阅读完成。

本文干货短缺,篇幅较长,倡议珍藏后浏览,防止迷路。融云公众号后盾回复【列表卡顿】,可间接支付本文波及所有材料。

一、背景
作为一款 IM 软件,会话列表是用户首先接触到的界面,会话列表滑动是否晦涩对用户的体验有着很大的影响。随着性能的一直减少,会话列表上要展现的信息也越来越多。咱们发现打完 Call 返回到会话列表界面进行滑动,可能呈现重大的卡顿。于是咱们开始对会话列表卡顿状况进行具体的剖析。

二、卡顿的起因
提到卡顿起因,咱们都会说是因为在 16ms 内无奈实现渲染导致的。那么为什么须要在 16ms 内实现呢?以及在 16ms 以内须要实现什么工作?

2.1 刷新率(RefreshRate)与帧率(FrameRate)

刷新率指的是屏幕每秒刷新的次数,是针对硬件而言的。目前大部分的手机刷新率都在 60Hz(屏幕每秒钟刷新 60 次),有局部高端机采纳的 120Hz(比方 iPad Pro)。

帧率是每秒绘制的帧数,是针对软件而言的。通常只有帧率与刷新率保持一致,咱们看到的画面就是晦涩的。所以帧率在 60FPS 时咱们就不会感觉到卡。

如果帧率为每秒钟 60 帧,而屏幕刷新率为 30Hz,那么就会呈现屏幕上半局部还停留在上一帧的画面,屏幕的下半局部渲染进去的就是下一帧的画面 —— 这种状况被称为画面撕裂;相同,如果帧率为每秒钟 30 帧,屏幕刷新率为 60Hz,那么就会呈现相连两帧显示的是同一画面,这就呈现了卡顿。

所以单方面的晋升帧率或者刷新率是没有意义的,须要两者同时进行晋升。

因为目前大部分 Android 机屏幕都采纳的 60Hz 的刷新率,为了使帧率也能达到 60FPS,那么就要求在 16.67ms 内实现一帧的绘制(1000ms/60Frame = 16.666ms / Frame)。

2.2 VSYNC

因为显示器是从最下面一行像素开始,向下逐行刷新,所以从最顶端到最底部的刷新是有时间差的。

如果帧率(FPS)大于刷新率,那么就会呈现前文提到的画面撕裂,如果帧率再大一点,那么下一帧的还没来得及显示,下下一帧的数据就笼罩上来了,两头这帧就被跳过了,这种状况被称为跳帧。

为了解决这种帧率大于刷新率的问题,引入了垂直同步的技术,简略来说就是显示器每隔 16ms 发送一个垂直同步信号(VSYNC),零碎会期待垂直同步信号的到来,才进行一帧的渲染和缓冲区的更新,这样就把帧率与刷新率锁定。

2.3 零碎是如何生成一帧的

在 Android4.0 以前,解决用户输出事件,绘制、栅格化都由 CPU 中利用主线程执行,很容易造成卡顿。次要起因在于主线程的工作太重,要解决很多事件,其次 CPU 中只有大量的 ALU 单元(算术逻辑单元),并不善于做图形计算。


Android4.0 当前利用默认开启硬件加速。开启硬件加速当前,CPU 不善于的图像运算就交给了 GPU 来实现,GPU 中蕴含了大量的 ALU 单元,就是为实现大量数学运算设计的(所以挖矿个别用 GPU)。硬件加速开启后还会将主线程中的渲染工作交给独自的渲染线程(RenderThread),这样当主线程将内容同步到 RenderThread 后,主线程就能够释放出来进行其余工作,渲染线程实现接下来的工作。

那么残缺的一帧流程如下:


(1) 首先在第一个 16ms 内,显示器显示了第 0 帧的内容,CPU/GPU 解决完第一帧。

(2) 垂直同步信号到来后,CPU 马上进行第二帧的解决工作,解决完当前交给 GPU。显示器则将第一帧的图像显示进去。

整个流程看似没有什么问题,然而一旦呈现帧率(FPS)小于刷新率的状况,画面就会呈现卡顿。


图上的 A 和 B 别离代表两个缓冲区。因为 CPU/GPU 解决工夫超过了 16ms,导致在第二个 16ms 内,显示器本应该显示 B 缓冲区中的内容,当初却不得不反复显示 A 缓冲区中的内容,也就是掉帧了(卡顿)。

因为 A 缓冲区被显示器所占用,B 缓冲区被 GPU 所占用,导致在垂直同步信号 (VSync) 到来时 CPU 没方法开始解决下一帧的内容,所以在第二个 16ms 内,CPU 并没有触发绘制工作。

2.4 三缓冲区(Triple Buffer)

为了解决帧率(FPS)小于屏幕刷新率导致的掉帧问题,Android4.1 引入了三级缓冲区。

在双缓冲区的时候,因为 Display 和 GPU 各占用了一个缓冲区,导致在垂直同步信号到来时 CPU 没有方法进行绘制。那么当初新增一个缓冲区,CPU 就能在垂直同步信号到来时进行绘制工作。


在第二个 16ms 内,尽管还是反复显示了一帧,然而在 Display 占用了 A 缓冲区,GPU 占用了 B 缓冲区的状况下,CPU 仍然能够应用 C 缓冲区实现绘制工作,这样 CPU 也被充沛地利用起来。后续的显示也比拟顺畅,无效地防止了 Jank 进一步的加剧。

通过绘制的流程咱们晓得,呈现卡顿是因为掉帧了,而掉帧的起因在于垂直同步信号到来时,还没有筹备好数据用于显示。所以咱们要解决卡顿,就要尽量缩短 CPU/GPU 绘制的工夫,这样就能保障在 16ms 内实现一帧的渲染。

三、问题剖析
有了以上的实践根底,咱们开始剖析会话列表卡顿的问题。因为 Boss 应用的 Pixel5 属于高端机,卡顿并不显著,咱们特意从测试同学手中借来了一台中低端机。


优化之前,手机刷新率是多少:
是 60Hz 没问题。

去高通网站上查问一下 SDM450 具体的架构:


能够看该手机的 CPU 是 8 核 A53 Processor。


A53 Processor 个别在大小核架构中当作小核来应用,其次要作用是省电,那些性能要求很低的场景个别由它们负责,比方待机状态、后盾执行等,而 A53 也的确把功耗做到了极致。

在三星 Galaxy A20s 手机上,全都采纳该 Processor,并且没有大核,那么处理速度天然不会很快,这也就要求咱们的 APP 优化得更好才行。

在有了对手机大抵的理解当前,咱们应用工具来查看一下卡顿点。

首先关上零碎自带的 GPU 出现模式分析工具,对会话列表进行查看。


能够看见直方图曾经高出了天际。在图中最上面有一条绿色的水平线(代表 16ms),超过这条水平线就有可能呈现掉帧。


依据 Google 给出的色彩对应表,咱们来看看耗时的大略地位。首先咱们要明确,尽管该工具叫 GPU 出现模式分析工具,然而其中显示的大部分操作产生在 CPU 中。

其次依据色彩对照表大家可能也发现了,谷歌给出的色彩跟真机上的色彩对应不上。所以咱们只能判断耗时的大略地位。

从咱们的截图中能够看见,绿色局部占很大比例,其中一部分是 Vsync 提早,另外一部分是输出解决 + 动画 + 测量 / 布局。

Vsync 提早图标中给出的解释为两个间断帧之间的操作所花的工夫。其实就是 SurfaceFlinger 在下一次散发 Vsync 的时候,会往 UI 线程的 MessageQueue 中插入一条 Vsync 到来的音讯,而该音讯并不会马上执行,而是期待后面的音讯被执行结束当前,才会被执行。所以 Vsync 提早指的就是 Vsync 被放入 MessageQueue 到被执行之间的工夫。这部分工夫越长阐明 UI 线程中进行的解决越多,须要将一些工作分流到其余线程中执行。

输出解决、动画、测量 / 布局这部分都是垂直同步信号达到并开始执行 doFrame 办法时的回调。


void doFrame(long frameTimeNanos, int frame) {
  //... 省略无关代码
      try {Trace.traceBegin(Trace.TRACE_TAG_VIEW, "Choreographer#doFrame");
            AnimationUtils.lockAnimationClock(frameTimeNanos / TimeUtils.NANOS_PER_MS);
      
            mFrameInfo.markInputHandlingStart();
            // 输出解决
            doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);

            mFrameInfo.markAnimationsStart();
            // 动画
            doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
            doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos);

            mFrameInfo.markPerformTraversalsStart();
            // 测量 / 布局
            doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);

            doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
        } finally {AnimationUtils.unlockAnimationClock();
            Trace.traceEnd(Trace.TRACE_TAG_VIEW);
        }

}

这部分如果比拟耗时,须要查看是否在输出事件回调中是否执行了耗时操作,或者是否有大量的自定义动画,又或者是否布局档次过深导致测量 View 和布局消耗太多的工夫。

四、优化计划及实际
4.1 异步

有了大略的方向当前,咱们开始对会话列表进行优化。

在问题剖析中,咱们发现 Vsync 提早占比很大,所以咱们首先想到的是将主线程中的耗时工作剥离进去,放到工作线程中执行。为了更快地定位主线程办法耗时,能够应用滴滴的 Dokit 或者腾讯的 Matrix 进行慢函数定位。

咱们发现在会话列表的 ViewModel 中,应用了 LiveData 订阅了数据库中用户信息表的变更、群信息表的变更、群成员表的变更。只有这三张表有变动,都会从新遍历会话列表,进行数据更新,而后告诉页面刷新。


这部分逻辑在主线程中执行,耗时大略在 80ms 左右,如果会话列表多,数据库表数据变更大,这部分的耗时还会减少。


 mConversationListLiveData.addSource(getAllUsers(), new Observer<List<User>>() {
            @Override
            public void onChanged(List<User> users) {if (users != null && users.size() > 0) {
                    // 遍历会话列表
                    Iterator<BaseUiConversation> iterable = mUiConversationList.iterator();
                    while (iterable.hasNext()) {BaseUiConversation uiConversation = iterable.next();
                        // 更新每个 item 上用户信息
                        uiConversation.onUserInfoUpdate(users);
                    }
                    mConversationListLiveData.postValue(mUiConversationList);
                }
            }
        });

既然这部分比拟耗时,咱们能够将遍历更新数据的操作放到子线程中执行,执行结束当前再调用 postValue 办法告诉页面进行刷新。

咱们还发现每次进入会话列表时都须要从数据库中获取会话列表数据,加载更多时也会从数据库中读取会话数据。读取到会话数据当前,咱们会对获取到的会话进行过滤操作,比方不是同一个组织下的会话则应该过滤掉。过滤实现当前会进行去重,如果该会话曾经存在,则更新以后会话;如果不存在,则创立一个新的会话并增加到会话列表,而后还须要对会话列表按肯定规定进行排序,最初再告诉 UI 进行刷新。



这部分的耗时为 500ms-600ms,并且随着数据量的增大耗时还会减少,所以这部分必须放到子线程中执行。然而这里必须留神线程平安问题,否则会呈现数据屡次被增加,会话列表上呈现多条反复的数据。

4.2 减少缓存

在查看代码的时候,咱们发现有很多中央会获取以后用户的信息,而以后用户信息保留在了本地 SP 中(后改为 MMKV),并且以 Json 格局存储。那么在获取用户信息的时候会从 SP 中先读取进去(IO 操作),再反序列化为对象(反射)。

 /**
  * 获取以后用户信息
  */
   public UserCacheInfo getUserCache() {
        try {String userJson = sp.getString(Const.USER_INFO, "");
            if (TextUtils.isEmpty(userJson)) {return null;}
            Gson gson = new Gson();
            UserCacheInfo userCacheInfo = gson.fromJson(userJson, UserCacheInfo.class);
            return userCacheInfo;
        } catch (Exception e) {e.printStackTrace();
        }
        return null;
    }

每次都这样获取以后用户的信息会十分的耗时。为了解决这个问题,咱们将第一次获取的用户信息进行缓存,如果内存中存在以后用户的信息则间接返回,并且在每次批改以后用户信息的时候,更新内存中的对象。

  /**
    * 获取以后用户信息
    */
    public UserCacheInfo getUserCacheInfo(){
        // 如果以后用户信息曾经存在,则间接返回
        if(mUserCacheInfo != null){return mUserCacheInfo;}
        // 不存在再从 SP 中读取
        mUserCacheInfo = getUserInfoFromSp();
        if (mUserCacheInfo == null) {mUserCacheInfo = new UserCacheInfo();
        }
        return mUserCacheInfo;
    }

  /**
    * 保留用户信息
    */
    public void saveUserCache(UserCacheInfo userCacheInfo) {
        // 更新缓存对象 
        mUserCacheInfo = userCacheInfo;
        // 将用户信息存入 SP
        saveUserInfo(userCacheInfo);
    }

4.3 缩小刷新次数

在这个计划里,一方面要缩小不合理的刷新,另外一方面要将局部全局刷新改为部分刷新。

在会话列表的 ViewModel 中,LiveData 订阅了数据库中用户信息表的变更、群信息表的变更、群成员表的变更。只有这三张表有变动,都会从新遍历会话列表,进行数据更新,而后告诉页面刷新。逻辑看似没问题,然而却把告诉页面刷新的代码写在循环当中,也就是每更新完一条会话数据,就告诉页面刷新一次,如果有 100 条会话就须要刷新 100 次。

mConversationListLiveData.addSource(getAllUsers(), new Observer<List<User>>() {
            @Override
            public void onChanged(List<User> users) {if (users != null && users.size() > 0) {
                    // 遍历会话列表
                    Iterator<BaseUiConversation> iterable = mUiConversationList.iterator();
                    while (iterable.hasNext()) {BaseUiConversation uiConversation = iterable.next();
                        // 更新每个 item 上用户信息
                        uiConversation.onUserInfoUpdate(users);
                        // 未优化前的代码,频繁告诉页面刷新
                        //mConversationListLiveData.postValue(mUiConversationList);
                    }
                    mConversationListLiveData.postValue(mUiConversationList);
                }
            }
        });

将告诉页面刷新的代码提取到循环里面,期待数据更新结束当前刷新一次即可。

咱们 APP 外面有个草稿性能,每次从会话里进去,都须要判断会话的输入框中是否存在未删除文字(草稿),如果有,则保存起来并在会话列表上显示【Draft】+ 内容,用户下次再进入会话后将草稿还原。因为草稿的存在,每次从会话退回到会话列表都须要刷新一下页面。在未优化之前,此处采纳的是全局刷新,而咱们其实只须要刷新刚刚退出的会话对应的 item 即可。


对于一款 IM 利用,揭示用户音讯未读是一个常见的性能。在会话列表的用户头像下面会显示以后会话的音讯未读数,当咱们进入会话当前,该未读数须要清零,并且更新会话列表。在未优化之前,此处采纳的也是全局刷新,这部分其实也能够改为刷新单条 item。


咱们的 APP 新增了一个叫做 typing 的性能,只有有用户在会话外面正在输出文字,在会话列表上就会显示某某某 is typing… 的文案。在未优化之前,此处也是采纳列表全局刷新,如果在好几个会话中同时有人 typing,那么基本上整个会话列表就会始终处于刷新的状态。所以此处也改为了部分刷新,只刷新以后有人 typing 的会话 item。


4.4 onCreateViewHolder 优化


在剖析 Systrace 报告时,咱们发现了图中这种状况 —— 一次滑动随同着大量的 CreateView 操作。为什么会呈现这种状况呢?咱们晓得 RecyclerView 自身是存在缓存机制的,滑动中如果新展现的 item 布局跟老的统一,就不会再执行 CreateView,而是复用老的 item,执行 bindView 来设置数据,这样可缩小创立 view 时的 IO 和反射耗时。

那么这里为什么跟预期不一样呢?咱们先来看看 RecyclerView 的缓存机制。RecyclerView 有 4 级缓存,咱们这里只剖析罕用的 2 级:

mCachedViews

mRecyclerPool

mCachedViews 的默认大小为 2,当 item 刚刚被移出屏幕可视范畴时,item 就会被放入 mCachedViews 中,因为用户很可能再从新将 item 移回到屏幕可视范畴,所以放入 mCachedViews 中的 item 是不须要从新执行 createView 和 bindView 操作的。

mCachedViews 中采纳 FIFO 准则,如果缓存数量达到最大值,那么先进入的 item 会被移出并放入到下一级缓存中。

mRecyclerPool 是 RecycledViewPool 类型,其中依据 item 类型创立对应的缓存池,每个缓存池默认大小为 5,从 mCachedViews 中移除的 item 会被革除掉数据,并依据对应的 itemType 放入到相应的缓存池中。这里有两个值得注意的中央,第一个就是 item 被革除了数据,这意味着下次应用这个 item 时须要从新执行 bindView 办法来重设数据;另外一个就是依据 itemType 的不同,会存在多个缓存池,每个缓存池的大小默认为 5,也就是说不同类型的 item 会放入不同的缓冲池中,每次在显示新的 item 时会先找对应类型的缓存池,看外面是否有能够复用的 item,如果有则间接复用后执行 bindView,如果没有则要从新创立 view,须要执行 createView 和 bindView 操作。

Systrace 报告中呈现大量的 CreateView,阐明在复用 item 时呈现了问题,导致每次显示新的 item 都须要从新创立。

咱们来思考一种极其场景,咱们会话列表中分为 3 种类型的 item:

群聊 item

单聊 item

密聊 item

咱们一屏能展现 10 个 item。其中前 10 个 item 都是群聊类型。从 11 个开始到 20 个都是单聊 item,从 21 个到 30 个都是密聊 item。


从图中咱们能够看到群聊 1 和群聊 2 曾经被移出了屏幕,这时候会被放入 mCachedViews 缓存中。而单聊 1 和单聊 2 因为在 mRecyclerPool 的单聊缓存池中找不到能够复用的 item,所以须要执行 CreateView 和 BindView 操作。


因为之前移出屏幕的都是群聊,所以单聊 item 进入时始终没用方法从单聊缓存池中拿到能够复用的 item,所以始终须要 CreateView 和 BindView。直到单聊 1 进入到缓存池,也就是上图所示,如果行将进入屏幕的是单聊 item 或者群聊 item,都是能够复用的,惋惜进来的是密聊,因为密聊缓存池中没用能够复用的 item,所以接下来进入屏幕的密聊 item 也都须要执行 CreateView 和 BindView。整个 RecyclerView 的缓存机制在这种状况下,根本生效。

这里额定提一句,为什么群聊缓存池中是群聊 1 ~ 群聊 5,而不是群聊 6 ~ 群聊 10?这里不是画错了,而是 RecyclerView 判断,在缓存池满了的状况下,就不会再退出新的 item。

  /**
         * Add a scrap ViewHolder to the pool.
         * <p>
         * If the pool is already full for that ViewHolder's type, it will be immediately discarded.
         *
         * @param scrap ViewHolder to be added to the pool.
         */
        public void putRecycledView(ViewHolder scrap) {final int viewType = scrap.getItemViewType();
            final ArrayList<ViewHolder> scrapHeap = getScrapDataForType(viewType).mScrapHeap;
            // 如果缓存池大于等于最大可缓存数,则返回
            if (mScrap.get(viewType).mMaxScrap <= scrapHeap.size()) {return;}
            if (DEBUG && scrapHeap.contains(scrap)) {throw new IllegalArgumentException("this scrap item already exists");
            }
            scrap.resetInternal();
            scrapHeap.add(scrap);
        }

到这里也就能够解释,为什么咱们从 Systrace 报告中发现了如此多的 CreateView。晓得了问题所在,那么咱们就须要想方法解决。屡次创立 View 次要是因为复用机制生效或者没有很好的运作导致,而生效的起因次要在于咱们同时有 3 种不同的 item 类型,如果咱们能将 3 种不同的 item 变为一种,那么咱们就能在单聊 4 进入屏幕时,从缓存池中拿到能够复用的 item,从而省去 CreateView 的步骤,间接 BindView 重置数据。


有了思路当前,咱们在查看代码时发现,无论是群聊、单聊还是密聊,应用的都是同一个布局,齐全能够采纳同一个 itemType。以前之所以离开,是因为应用了一些设计模式,想让群聊、单聊、密聊在各自的类中实现,也不便当前如果有新的扩大会更不便清晰。这时候就须要在性能和模式上有所取舍,然而认真一想,会话列表下面不同类型的聊天,布局根本是统一的,不同聊天类型仅仅在 UI 展现上有所不同,这些不同咱们能够在 bindView 时从新设置。


咱们在注册的时候只注册 BaseConversationProvider,这样 itemType 类型就只有这一个。GroupConversationProvider、PrivateConversationProvider、SecretConversationProvider 都继承于 BaseConversationProvider 类,onCreateViewHolder 办法只在 BaseConversationProvider 类实现。在 BaseConversationProvider 类中蕴含一个 List,用于保留 GroupConversationProvider、PrivateConversationProvider、SecretConversationProvider 这三个对象,在执行执行 bindViewHolder 办法时,先执行父类的办法,在这外面解决一些三种聊天类型公共的逻辑,比方头像、最初一条音讯发送的工夫等,处理完毕当前通过 isItemViewType 判断以后是哪种聊天,并且调用相应的子类 bindViewHolder 办法,进行子类特有的数据处理。这里须要留神重用时导致的页面显示谬误,比方在密聊中批改了会话题目的色彩,然而因为 item 的复用,导致群聊的会话题目色彩也扭转了。

通过革新当前,咱们就能够省去大量 的 CreateView 操作(IO+ 反射),让 RecyclerView 的缓存机制能够良好的运行。

4.5 预加载 + 全局缓存

尽管咱们缩小了 CreateView 的次数,然而咱们在首次进入时第一屏还是须要 CreateView,并且咱们发现 CreateView 的耗时也挺长。

这部分工夫能不能优化掉?咱们首先想到的是在 onCreateViewHolder 时采纳异步加载布局的形式,将 IO、反射放在子线程来做,起初这个计划被去掉了,具体起因后文会说。如果不能异步加载,那么咱们就思考将创立 View 的操作提前来执行并且缓存下来。

咱们首先创立了一个 ConversationItemPool 类,该类用于在子线程中预加载 item,并且将它们缓存起来。当执行 onCreateViewHolder 时间接从该类中获取缓存的 item,这样就能够缩小 onCreateViewHolder 执行耗时。

 /**
         * Add a scrap ViewHolder to the pool.
         * <p>
         * If the pool is already full for that ViewHolder's type, it will be immediately discarded.
         *
         * @param scrap ViewHolder to be added to the pool.
         */
        public void putRecycledView(ViewHolder scrap) {final int viewType = scrap.getItemViewType();
            final ArrayList<ViewHolder> scrapHeap = getScrapDataForType(viewType).mScrapHeap;
            // 如果缓存池大于等于最大可缓存数,则返回
            if (mScrap.get(viewType).mMaxScrap <= scrapHeap.size()) {return;}
            if (DEBUG && scrapHeap.contains(scrap)) {throw new IllegalArgumentException("this scrap item already exists");
            }
            scrap.resetInternal();
            scrapHeap.add(scrap);
        }

ConversationItemPool 中咱们应用了一个线程平安队列来缓存创立的 item。因为是全局缓存,所以这里要留神内存透露的问题。那么咱们预加载多少个 item 适合呢?通过咱们对不同分辨率测试机的比照,首屏展现的 item 数量个别为 10-12 个,因为在第一次滑动时,前 3 个 item 是拿不到缓存的,也须要执行 CreateView 办法,那么咱们还须要把这 3 个也算上,所以咱们这边设置预加载数量为 16 个。之后在 onViewDetachedFromWindow 办法中将 View 进行回收再次放入缓存池。

  @Override
    public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        // 从缓存池中取 item
        View view = ConversationListItemPool.getInstance().getItemFromPool();
        // 如果没取到,失常创立 Item
        if(view == null) {view = LayoutInflater.from(parent.getContext()).inflate(R.layout.rc_conversationlist_item,parent,false);
        }
        return ViewHolder.createViewHolder(parent.getContext(), view);
    }

留神在 onCreateViewHolder 办法中要有降级操作,万一没取到缓存 View,须要失常创立一个应用。这样咱们胜利地将 onCreateViewHolder 的耗时升高到了 2 毫秒甚至更低,在 RecyclerView 缓存失效时,能够做到 0 耗时。

解决从 XML 创立 View 耗时的计划,除了在异步线程中预加载,还能够应用一些开源库比方 X2C 框架,次要原理就是在编译期间将 XML 文件转换为 JAVA 代码来创立 View,省去 IO 和反射的工夫。或者应用 jetpack compose 申明式 UI 来构建布局。

4.6 onBindViewHolder 优化

咱们在查看 Systrace 报告时,还发现,除了 CreateView 耗时,BindView 居然也很耗时,而且这个耗时甚至超过了 CreateView。这样在一次滑动过程中,如果有 10 个 item 新展现进去,那么耗时将达到 100 毫秒以上。这是相对不能承受的,于是咱们开始清理 onBindViewHolder 的耗时操作。

首先咱们必须分明 onBindViewHolder 办法中只用于 UI 设置,不应该做任何的耗时操作和业务逻辑解决,咱们须要把耗时操作和业务解决提前解决好,存入数据源中。

咱们在查看 onBindViewHolder 办法时发现,如果用户头像不存在,会再生成一个默认的头像,该头像会以用户名首字母来生成。在该办法中,首先进行了 MD5 加密,而后创立 Bitmap,再压缩,再存入本地(IO)。这一系列操作十分的耗时,所以咱们决定把该操作从 onBindViewHolder 中提取进去,提前将生成数据放入数据源,用的时候间接从数据源中获取。

咱们的会话列表下面,每条会话都须要显示最初一条音讯的发送工夫,工夫显示格局非常复杂,每次在 onBindViewHolder 中都会将最初一条音讯的毫秒数格式化成相应的 String 来显示。这部分也十分耗时,咱们把这部分的代码也提取进去解决,在 onBindViewHolder 中只须要从数据源中取出格式化好的字符串显示即可。


在咱们的头像下面会显示以后未读音讯数量,然而这个未读音讯数有几种不同的状况:

未读音讯数是个位数,则背景图是圆的;
未读音讯数是两位数,背景图是椭圆;

未读音讯数大于 99,显示 99+,背景图会更长;
该音讯被屏蔽,只显示一个小圆点,不显示数量。
如下图:


因为存在这几种状况,此处的代码间接依据未读音讯数,设置了不同的 png 背景图片。这部分的背景其实齐全能够采纳 Shape 来实现。如果应用 PNG 图片的话,须要对 png 进行解码,而后再由 GPU 渲染,图片解码会耗费 CPU 资源。而 Shape 信息会间接传到底层由 GPU 渲染,速度更快。所以咱们将 png 图片替换为 Shape 实现。

除了图片的设置,在 onBindViewHolder 中用的最多的就是 TextView,TextView 在文本测量上破费的工夫占文本设置的很大比例,这部分测量的工夫其实是能够放在子线程中执行的,Android 官网也意识到了这点,所以在 Android P 推出了一个新的类:PrecomputedText,该类能够让最耗时的文本测量在子线程中执行。因为该类是 Android P 才有,所以咱们能够应用 AppCompatTextView 来代替 TextView,在 AppCompatTextView 中做了版本兼容性解决。


 AppCompatTextView tv = (AppCompatTextView) view;
 // 用这个办法代替 setText
 tv.setTextFuture(PrecomputedTextCompat.getTextFuture(text,tv.getTextMetricsParamsCompat(),
                    ThreadManager.getInstance().getTextExecutor()));

应用起来很简略,原理这里就不赘述了,能够自行谷歌。在低版本中还应用了 StaticLayout 来进行渲染,能够加快速度,具体能够看 Instagram 的一篇文章(文章链接可通过微信公众号后盾回复【列表卡顿】获取)。

4.7 布局优化

除了缩小 BindView 的耗时以外,布局的层级也影响着 onMeasure 和 onLayout 的耗时。咱们在应用 GPU 出现模式分析工具时发现测量和布局破费了大量的工夫,所以咱们打算缩小 item 的布局层级。

在未优化之前,咱们 item 布局的最大层级为 5。其实有些只是为了管制显隐不便而多减少了一层布局来包裹,咱们最初应用束缚布局,将最大层级升高到了 2 层。

除此之外咱们还查看了是否存在反复设置背景色彩的状况,因为反复设置背景色彩会导致适度绘制。所谓适度绘制指的是某个像素在同一帧内被绘制了屡次。如果不可见的 UI 也在做绘制操作,这会导致某些区域的像素被绘制了屡次,节约大量的 CPU、GPU 资源。


除了去掉反复的背景,咱们还能够尽量减少应用透明度,Android 零碎在绘制透明度时会将同一个区域绘制两次,第一次是原有的内容,第二次是新加的透明度成果。基本上 Android 中的透明度动画都会造成适度绘制,所以能够尽量减少应用透明度动画,在 View 下面也尽量不要应用 alpha 属性。具体原理能够参考谷歌官网视频(视频链接可通过微信公众号后盾回复【列表卡顿】获取)。

在应用束缚布局来缩小层级,并且去掉反复背景当前,咱们发现还是会有点卡。在网上查阅相干材料,发现也有网友反馈在 RecyclerView 的 item 中应用束缚布局会有卡顿的问题,应该是束缚布局的 Bug 导致,咱们也查看了一下咱们应用的束缚布局版本号。


用的是 beta 版本,咱们改为最新稳定版 2.1.0。发现状况好了很多。所以商业利用尽量不要应用测试版本。

4.8 其余优化

除了下面所说的优化点,还有一些小的优化点:

(1)比方应用高版本的 RecyclerView,会默认开启预取性能。


从上图中咱们能够看见,UI 线程实现数据处理交给 Render 线程当前就始终处于闲暇状态,须要期待个 Vsync 信号的到来才会进行数据处理,而这闲暇工夫就被白白浪费了,开启预取当前就能正当地应用这段闲暇工夫。


(2)将 RecyclerView 的 setHasFixedSize 办法设置为 true。当咱们的 item 宽高固定时,应用 Adapter 的 onItemRangeChanged()、onItemRangeInserted()、onItemRangeRemoved()、

onItemRangeMoved() 这几个办法更新 UI,不会从新计算大小。

(3)如果不应用 RecyclerView 的动画,能够通过 ((SimpleItemAnimator) rv.getItemAnimator()).setSupportsChangeAnimations(false) 把默认动画敞开来晋升效率。

五、弃用的计划
在做会话列表卡顿优化过程中,咱们采纳了一些优化计划,然而最终没有采纳,这里也列出加以阐明。

5.1 异步加载布局

在前文中有提到,咱们在缩小 CreateView 耗时的过程中,最后打算采纳异步加载布局的形式来将 IO、反射放在子线程中执行。咱们应用的是谷歌官网的 AsyncLayoutInflater 来异步加载布局,该类会将布局加载实现当前回调告诉咱们。然而它个别用于 onCreate 办法中。而在 onCreateViewHolder 办法中须要返回 ViewHolder,所以没有方法间接应用。

为了解决这个问题,咱们自定义了一个 AsyncFrameLayout 类,该类继承于 FrameLayout,咱们会在 onCreateViewHolder 办法中将 AsyncFrameLayout 作为 ViewHolder 的根布局增加进去,并且调用自定义的 inflate 办法,进行异步加载布局,加载胜利当前再把加载胜利的布局增加到 AsyncFrameLayout 中,作为 AsyncFrameLayout 的子 View。


 public void inflate(int layoutId, OnInflateCompleted listener) {new AsyncLayoutInflater(getContext()).inflate(layoutId, this, new AsyncLayoutInflater.OnInflateFinishedListener() {
            @Override

            public void onInflateFinished(@NonNull @NotNull View view, int resid, @Nullable @org.jetbrains.annotations.Nullable ViewGroup parent) {
                // 标记曾经 inflate 实现
                isInflated = true; 
                // 加载完布局当前,增加为 AsyncFrameLayout 中
                parent.addView(view);
                if (listener != null) {
                    // 加载完数据后,须要从新申请 BindView 绑定数据
                    listener.onCompleted(mBindRequest);
                }
                mBindRequest = null;
            }
        });
    }

这里留神,因为是异步执行,所以在 onCreateViewHolder 执行实现当前,会执行 onBinderViewHolder 办法,而这时候布局是很有可能没有加载实现的,所以须要用一个标记为 isInflated 来标识布局是否加载胜利,如果没有加载实现,就先不绑定数据。同时要记录本次 BindView 申请,当布局加载实现当前,被动地调用一次去刷新数据。

没有采纳此办法的次要起因在于会减少布局层级,在应用预加载当前,能够不应用此计划。

5.2 DiffUtil

DiffUtil 是谷歌官网提供的一个数据比照工具,它能够比照两组新老数据,找出其中的差别,而后告诉 RecyclerView 进行刷新。DiffUtil 应用 Eugene W. Myers 的差分算法(相干材料可通过公众号后盾回复【列表卡顿】获取)来计算将一个列表转换为另一个列表的起码更新次数。然而比照数据时也会耗时,所以也能够采纳 AsyncListDiffer 类,把比照操作放在异步线程中执行。

在应用 DiffUtil 中咱们发现,要比照的数据项太多了,为了解决这个问题,咱们对数据源进行了封装,在数据源里增加了一个示意是否更新的字段,把所有变量改为 private 类型,并且提供 set 办法,在 set 办法中对立将是否更新的字段设置为 true。这样在进行两组数据比照时,咱们只须要判断该字段是否为 true,就晓得是否存在更新。

想法是美妙的,然而在理论封装数据源时发现,类中还有类(也就是类中有对象,不是根本数据类型),内部齐全能够通过先 get 到一个对象,而后通过改对象的援用批改其中的字段,这样就跳过了 set 办法。如果要解决这个问题,那么咱们须要在封装类中提供类中类属性的所有 set 办法,并且不提供类中类的 get 办法,改变十分的大。

如果仅仅是这个问题,还能够解决,然而咱们发现会话列表下面有一个性能,就是每当其中一个会话收到了新音讯,那么该会话会挪动到会话列表的第一位。因为地位产生了扭转,整个列表都须要刷新一次,这就违反了应用 DiffUtil 进行部分刷新的初衷了。比方会话列表第五个会话收到了新音讯,这时第五个会话须要挪动到第一个会话,如果不刷新整个列表,就会呈现反复会话的问题。因为这个问题的存在,咱们弃用了 DiffUtil,因为就算解决了反复会话的问题,收益仍然不会很大。

5.3 滑动进行时刷新

为了防止会话列表大量刷新操作,咱们将会话列表滑动时的数据更新给记录了下来,期待滑动进行当前再进行刷新。然而在理论测试过程中,进行后的刷新会导致界面卡顿一次,中低端机上比拟显著,所以放弃了此策略。

5.4 提前分页加载

因为会话列表数量可能很多,所以咱们采纳分页的形式来加载数据。为了保障用户感知不到加载期待的工夫,咱们打算在用户将要滑动到列表完结地位之前获取更多的数据,让用户无痕地下滑。想法是现实的,然而实际过程中也发现在中低端机上会有一瞬间的卡顿,所以该办法也临时先弃用。

除了以上计划被弃用了,咱们在优化过程中发现,其它品牌类似产品的会话列表滑动其实速度并没特地快,如果滑动速度慢的话,那么在一次滑动过程中须要展现的 item 数量就会小,这样一次滑动就不须要渲染过多的数据。这其实也是一个优化点,前面咱们可能会思考升高滑动速度的实际。

六、总结
在开发过程中,随着业务的一直新增,咱们的办法和逻辑复杂度也会一直减少,这时候肯定要留神办法耗时,耗时重大的尽量提取到子线程中执行。应用 Recyclerview 时千万不要无脑刷新,能部分刷的绝不全局刷,能提早刷的绝不马上刷。在剖析卡顿的时候能够联合工具进行,这样效率会进步很多,通过 Systrace 发现大略的问题和排查方向当前,能够通过 Android Studio 自带的 Profiler 来进行具体代码的定位。

正文完
 0