RecyclerView性能优化之异步预加载

前言

首先需要强调的是,这篇文章是对我之前写的《浅谈RecyclerView的性能优化》文章的补充,建议大家先读完这篇文章后再来看这篇文章,味道更佳。

当时由于篇幅的原因,并没有深入展开讲解,于是有很多感兴趣的朋友纷纷留言表示:能不能结合相关的示例代码讲解一下到底如何实现?那么今天我就结合之前讲的如何优化onCreateViewHolder的加载时间,讲一讲如何实现onCreateViewHolder的异步预加载,文章末尾会给出示例代码的链接地址,希望能给你带来启发。

分析

之前我们讲过,在优化onCreateViewHolder方法的时候,可以降低item的布局层级,可以减少界面创建的渲染时间,其本质就是降低view的inflate时间。因为onCreateViewHolder最大的耗时部分,就是view的inflate。相信读过LayoutInflater.inflate源码的人都知道,这部分的代码是同步操作,并且涉及到大量的文件IO的操作以及锁操作,通常来说这部分的代码快的也需要几毫秒,慢的可能需要几十毫秒乃至上百毫秒也是很有可能的。 如果真到了每个ItemView的inflate需要花上上百毫秒的话,那么在大数据量的RecyclerView进行快速上下滑动的时候,就必然会导致界面的滑动卡顿、不流畅。

那么如何你的程序里真的有这样一个列表,它的每个ItemView都需要花上上百毫秒的时间去inflate的话,你该怎么做?

  • 首先就是对布局进行优化,降低item的布局层级。但这点的优化往往是微乎其微的。
  • 其次可能就是想办法让设计师重新设计,将布局中的某些内容删除或者折叠了,对暂不展示的内容使用ViewStub进行延迟加载。不过说实在话,你既然有能力让设计师重新设计的话,还干个球的开发啊,直接当项目经理不香吗?
  • 最后你可能会考虑不用xml写布局,改为使用代码自己一个一个new布局。话说回来了,一个使用xml加载的布局都要花上上百毫秒的布局,可能xml都快上千行下去了,你确定要自己一个一个new下去?

以上的方式,都是建立在列表布局可以修改的情况下,如果我们使用的列表布局是第三方已经提供好的呢?(例如广告SDK等)

那么有没有什么办法既可以不用修改当前的xml布局,又可以极大地缩短布局的加载时间呢?毫无疑问,布局异步加载将为你打开新的世界。

原理

Google官方很早就发现了XML布局加载的性能问题,于是在androidx中提供了异步加载工具AsyncLayoutInflater。其本质就是开了一个长期等待的异步线程,在子线程中inflate view,然后把加载好的view通过接口抛出去,完成view的加载。

一般来说,对于复杂的列表,往往都对应了复杂的数据,而这复杂的数据往往又是通过服务器获取而来。所以一般来说,一个列表在加载前,往往先需要访问服务器获取数据,然后再刷新列表显示,而这访问服务器的时间大约也在300ms~1000ms之间。很多开发人员对这段时间往往没有加以利用,只是加上一个loading动画了事。

其实对于这一段事务真空的时间窗口,我们可以提前进行列表的ItemView的加载,这样等数据请求下来刷新列表的时候,我们onCreateViewHolder的时候就可以直接到已经事先预加载好的View缓存池中直接获取View传到ViewHolder中使用,这样onCreateViewHolder的创建时间几乎耗时为0,从而极大地提升了列表的加载和渲染速度。详细的流程可以参见下图:

预加载流程图.png

实现

上面我简单地讲解了一下原理,下一步就是考虑如何实现这样的效果了。

预加载缓存池

首先在预加载前,我们需要先创建一个缓存池来存储预加载的View对象。

这里我选择使用SparseArray进行存储,key是Int型,存放布局资源的layoutId,value是Object型,存放的是这类布局加载View的集合。

这里的集合类型我选择的是LinkedList,因为我们的缓存需要频繁的添加和删除操作,并且LinkedList实现了Deque接口,具备先入先出的能力。

这里View的引用我选择的是软引用SoftReference,之所以不采用WeakReference, 目的就是希望缓存能多存在一段时间,避免内存的频繁释放和回收造成内存的抖动。

private static class ViewCache {

    private final SparseArray<LinkedList<SoftReference<View>>> mViewPools = new SparseArray<>();

    @NonNull
    public LinkedList<SoftReference<View>> getViewPool(int layoutId) {
        LinkedList<SoftReference<View>> views = mViewPools.get(layoutId);
        if (views == null) {
            views = new LinkedList<>();
            mViewPools.put(layoutId, views);
        }
        return views;
    }

    public int getViewPoolAvailableCount(int layoutId) {
        LinkedList<SoftReference<View>> views = getViewPool(layoutId);
        Iterator<SoftReference<View>> it = views.iterator();
        int count = 0;
        while (it.hasNext()) {
            if (it.next().get() != null) {
                count++;
            } else {
                it.remove();
            }
        }
        return count;
    }

    public void putView(int layoutId, View view) {
        if (view == null) {
            return;
        }
        getViewPool(layoutId).offer(new SoftReference<>(view));
    }

    @Nullable
    public View getView(int layoutId) {
        return getViewFromPool(getViewPool(layoutId));
    }

    private View getViewFromPool(@NonNull LinkedList<SoftReference<View>> views) {
        if (views.isEmpty()) {
            return null;
        }
        View target = views.pop().get();
        if (target == null) {
            return getViewFromPool(views);
        }
        return target;
    }
}

getViewFromPool方法我们可以看出,这里对于ViewCache来说,每次取出一个缓存View使用的是pop方法,我们都会将它从Pool中移除。

布局加载者

因为view的加载方法,涉及到三个参数: 资源Id-resourceId, 父布局-root和是否添加到根布局-attachToRoot。

public View inflate(int resource, ViewGroup root, boolean attachToRoot) {
    
}

这里在onCreateViewHolder方法中attachToRoot恒为false,因此异步布局加载只需要前面两个参数以及一个回调接口即可,即如下的定义:

public interface ILayoutInflater {
    /**
     * 异步加载View
     *
     * @param parent   父布局
     * @param layoutId 布局资源id
     * @param callback 加载回调
     */
    void asyncInflateView(@NonNull ViewGroup parent, int layoutId, InflateCallback callback);
    /**
     * 同步加载View
     *
     * @param parent   父布局
     * @param layoutId 布局资源id
     * @return 加载的View
     */
    View inflateView(@NonNull ViewGroup parent, int layoutId);
}

public interface InflateCallback {

    void onInflateFinished(int layoutId, View view);
}

至于接口实现的话,就直接使用Google官方提供的异步加载工具AsyncLayoutInflater来实现。

public class DefaultLayoutInflater implements PreInflateHelper.ILayoutInflater {

    private AsyncLayoutInflater mInflater;

    private DefaultLayoutInflater() {}

    private static final class InstanceHolder {
        static final DefaultLayoutInflater sInstance = new DefaultLayoutInflater();
    }

    public static DefaultLayoutInflater get() {
        return InstanceHolder.sInstance;
    }

    @Override
    public void asyncInflateView(@NonNull ViewGroup parent, int layoutId, PreInflateHelper.InflateCallback callback) {
        if (mInflater == null) {
            Context context = parent.getContext();
            mInflater = new AsyncLayoutInflater(new ContextThemeWrapper(context.getApplicationContext(), context.getTheme()));
        }
        mInflater.inflate(layoutId, parent, (view, resId, parent1) -> {
            if (callback != null) {
                callback.onInflateFinished(resId, view);
            }
        });
    }

    @Override
    public View inflateView(@NonNull ViewGroup parent, int layoutId) {
        return InflateUtils.getInflateView(parent, layoutId);
    }
}

预加载辅助类

有了预加载缓存池ViewCache和异步加载能力的提供者IAsyncInflater,下面就是来协调这两者进行合作,完成布局的预加载和View的读取。

首先需要定义的是根据ViewGroup和layoutId获取View的方法,提供给Adapter的onCreateViewHolder方法使用。

  • 首先我们需要去ViewCache中去取是否已有预加载好的view供我们使用。如果有则取出,并进行一次预加载补充给ViewCache。
  • 如果没有,就只能同步加载布局了。
public View getView(@NonNull ViewGroup parent, int layoutId, int maxCount) {
    View view = mViewCache.getView(layoutId);
    if (view != null) {
        UILog.dTag(TAG, "get view from cache!");
        preloadOnce(parent, layoutId, maxCount);
        return view;
    }
    return mLayoutInflater.inflateView(parent, layoutId);
}

对于预加载布局,并加入缓存的方法实现。

  • 首先我们需要去ViewCache查询当前可用缓存的数量,如果可用缓存的数量大于等于最大数量,即不需要进行预加载。
  • 对于需要预加载的,需要计算预加载的数量,如果当前没有强制执行的次数,就直接按剩余最大数量进行加载,否则取强制执行次数和剩余最大数量的最小值进行加载。
  • 对于预加载完毕获取的View,直接加入到ViewCache中。
public void preload(@NonNull ViewGroup parent, int layoutId, int maxCount, int forcePreCount) {
    int viewsAvailableCount = mViewCache.getViewPoolAvailableCount(layoutId);
    if (viewsAvailableCount >= maxCount) {
        return;
    }
    int needPreloadCount = maxCount - viewsAvailableCount;
    if (forcePreCount > 0) {
        needPreloadCount = Math.min(forcePreCount, needPreloadCount);
    }
    for (int i = 0; i < needPreloadCount; i++) {
        // 异步加载View
        mLayoutInflater.asyncInflateView(parent, layoutId, new InflateCallback() {
            @Override
            public void onInflateFinished(int layoutId, View view) {
                mViewCache.putView(layoutId, view);
            }
        });
    }
}

Adapter中执行预加载

有了预加载辅助类PreInflateHelper,下面我们只需要直接调用它的preload方法和getView方法即可。这里需要注意的是,ViewHolder中ItemView的ViewGroup就是RecyclerView它本身,所以Adapter的构造方法需要传入RecyclerView供预加载辅助类进行预加载。

public class OptimizeListAdapter extends MockLongTimeLoadListAdapter {
    private static final class InstanceHolder {
        static final PreInflateHelper sInstance = new PreInflateHelper();
    }
    
    public static PreInflateHelper getInflateHelper() {
        return OptimizeListAdapter.InstanceHolder.sInstance;
    }

    public OptimizeListAdapter(RecyclerView recyclerView) {
        getInflateHelper().preload(recyclerView, getItemLayoutId(0));
    }

    @Override
    protected View inflateView(@NonNull ViewGroup parent, int layoutId) {
        return getInflateHelper().getView(parent, layoutId);
    }
}

对比实验

模拟耗时场景

为了能够模拟inflateView的极端情况,这里我简单给inflateView增加300ms的线程sleep来模拟耗时操作。

/**
 * 模拟耗时加载
 */
public static View mockLongTimeLoad(@NonNull ViewGroup parent, int layoutId) {
    try {
        // 模拟耗时
        Thread.sleep(300);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    return LayoutInflater.from(parent.getContext()).inflate(layoutId, parent, false);
}

对于模拟耗时加载的Adapter,我们调用上面的方法创建ViewHolder。

public class MockLongTimeLoadListAdapter extends BaseRecyclerAdapter<NewInfo> {
    /**
     * 这里是加载view的地方, 使用mockLongTimeLoad进行mock
     */
    @Override
    protected View inflateView(@NonNull ViewGroup parent, int layoutId) {
        return InflateUtils.mockLongTimeLoad(parent, layoutId);
    }
}

而对于异步加载的耗时模拟,我则是copy了AsyncLayoutInflater的源码,然后修改了它在InflateThread中的加载方法:

private static class InflateThread extends Thread {
    public void runInner() {
        // 部分代码省略....
        // 模拟耗时加载
        request.view = InflateUtils.mockLongTimeLoad(request.inflater.mInflater,
                request.parent, request.resid);
    }
}

对比数据

优化前

优化前.gif
优化前日志.png

优化后

优化后.gif
优化后日志.png

从上面的动图和日志,我们不难看出在优化前,每个onCreateViewHolder的耗时都在之前设定的300ms以上,这就导致了列表滑动和刷新都会产生比较明显的卡顿。

而再看优化后的效果,不仅列表滑动和刷新效果非常丝滑,而且每个onCreateViewHolder的耗时都在0ms,极大地提升了列表的刷新和渲染性能。

总结

相信看完以上内容后,你会发现写了这么多,无非就是把onCreateViewHolder中加载布局的操作提前,并放到了子线程中去处理,其本质依然是空间换时间,并将列表数据网络请求到列表刷新这段事务真空的时间窗口有效利用起来。

本文的全部源码我都放在了github上, 感兴趣的小伙伴可以下下来研究和学习。

项目地址: https://github.com/xuexiangjys/XUI/tree/master/app/src/main/java/com/xuexiang/xuidemo/fragment/components/refresh/sample/preload

我是xuexiangjys,一枚热爱学习,爱好编程,勤于思考,致力于Android架构研究以及开源项目经验分享的技术up主。获取更多资讯,欢迎微信搜索公众号:【我的Android开源之旅】

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349

推荐阅读更多精彩内容