使用 AsyncListUtil 优化 RecyclerView

简评:AsyncListUtil 在 Android API 23 就被加入到 support.v7 当中了,但似乎长久以来都被忽视了,其实在合适的场景中还是挺有用的。

AsyncListUtil 是一个用于异步内容加载的类,在 Android API 23 时被加入到 support.v7 当中。不过好像很多人对它还并不了解,网上也没有太多相关的资料。今天这里就来介绍下 AsyncListUtil 的用法。

首先,AsyncListUtil 通常和 RecyclerView 搭配使用的。其能够在后台线程中加载 Cursor 数据,同时保持 UI 和缓存的同步来实现更好的用户体验。不过 AsyncListUtil 是通过单个线程加载数据,因此适用于从二级存储(比如硬盘)中加载数据,而不适用于从网络加载数据的情况。

RecyclerView 的结构

相信绝大部分 Android 开发者对此都已经非常熟悉了。

RecyclerView + AsyncListUtil 的结构

可以看到 AsyncListUtil 是通过 AsyncListUtil.ViewCallback 来判断当前数据可见的范围,再通过 AsyncListUtil.DataCallback 从后台加载所需的数据,并在加载完成时通知 AsyncListUtil.ViewCallback。
因此要使用 AsyncListUtil,首先需要继承实现 AsyncListUtil.DataCallbackAsyncListUtil.ViewCallback 这两个抽象类。
下面我们通过代码来看看实际要怎样实现?先上效果图:

数据
作者实现了一个简单的 python 脚本 生成了 100,000 条数据并存放在 SQLite 数据库中。每一条数据都有 id, title 和 content 三个属性。其中的 title 和 content 都是通过 DWYL’s english-words repository 随机生成。

ItemSource

class Item(var title: String, var content: String)

interface ItemSource {
    fun getCount(): Int
    fun getItem(position: Int): Item
    fun close()
}

定义 SQLiteItemSource 来从 SQLite 中获取数据:

class SQLiteItemSource(val database: SQLiteDatabase) : ItemSource {
    private var _cursor: Cursor? = null
    private val cursor: Cursor
        get() {
            if (_cursor == null || _cursor?.isClosed != false) {
                _cursor = database.rawQuery("SELECT title, content FROM data", null)
            }
            return _cursor ?: throw AssertionError("Set to null or closed by another thread")
        }

    override fun getCount() = cursor.count

    override fun getItem(position: Int): Item {
        cursor.moveToPosition(position)
        return Item(cursor)
    }

    override fun close() {
        _cursor?.close()
    }
}

private fun Item(c: Cursor): Item = Item(c.getString(0), c.getString(1))

Callbacks
为了创建 AsyncListUtil,我们需要传入 DataCallbackViewCallback

首先让我们实现 DataCallback:

private class DataCallback(val itemSource: ItemSource) : AsyncListUtil.DataCallback<Item>() {
    override fun fillData(data: Array<Item>?, startPosition: Int, itemCount: Int) {
        if (data != null) {
            for (i in 0 until itemCount) {
                data[i] = itemSource.getItem(startPosition + i)
            }
        }
    }

    override fun refreshData(): Int = itemSource.getCount()

    fun close() {
        itemSource.close()
    }
}

DataCallback 是用来为 AsyncListUtil 提供数据访问,其中所有方法都会在后台线程中调用。

其中有两个方法必需要实现:

  • fillData(data, startPosition, itemCount) - 当 AsyncListUtil 需要更多数据时,将会在后台线程调用该方法。
  • refreshData() - 返回刷新后的数据个数。

再实现 ViewCallback:

private class ViewCallback(val recyclerView: RecyclerView) : AsyncListUtil.ViewCallback() {
    override fun onDataRefresh() {
        recyclerView.adapter.notifyDataSetChanged()
    }

    override fun getItemRangeInto(outRange: IntArray?) {
        if (outRange == null) {
            return
        }
        (recyclerView.layoutManager as LinearLayoutManager).let { llm ->
            outRange[0] = llm.findFirstVisibleItemPosition()
            outRange[1] = llm.findLastVisibleItemPosition()
        }

        if (outRange[0] == -1 && outRange[1] == -1) {
            outRange[0] = 0
            outRange[1] = 0
        }
    }

    override fun onItemLoaded(position: Int) {
        recyclerView.adapter.notifyItemChanged(position)
    }
}

AsyncListUtil 通过 ViewCallback 主要是做两件事:

  • 通知视图数据已经更新(onDataRefresh);
  • 了解当前视图所展示数据的位置,从而确定什么时候获取更多数据或释放掉目前不在窗口内的旧数据(getItemRangeInto)。

接下来实现 ScrollListener 来调用 AsyncListUtil 的 onRangeChanged() 方法:

private class ScrollListener(val listUtil: AsyncListUtil<in Item>) : RecyclerView.OnScrollListener() {
    override fun onScrollStateChanged(recyclerView: RecyclerView?, newState: Int) {
        listUtil.onRangeChanged()
    }
}

Adapter

至此,AsyncListUtil 所需要的组件都准备好了,可以来实现我们的 RecyclerView.Adapter 了:

class AsyncAdapter(itemSource: ItemSource, recyclerView: RecyclerView) : RecyclerView.Adapter<ViewHolder>() {
    private val dataCallback = DataCallback(itemSource)
    private val listUtil = AsyncListUtil(Item::class.java, 500, dataCallback, ViewCallback(recyclerView))
    private val onScrollListener = ScrollListener(listUtil)

    fun onStart(recyclerView: RecyclerView?) {
        recyclerView?.addOnScrollListener(onScrollListener)
        listUtil.refresh()
    }

    fun onStop(recyclerView: RecyclerView?) {
        recyclerView?.removeOnScrollListener(onScrollListener)
        dataCallback.close()
    }

    override fun onBindViewHolder(holder: ViewHolder?, position: Int) {
        holder?.bindView(listUtil.getItem(position), position)
    }

    override fun getItemCount(): Int = listUtil.itemCount

    override fun onCreateViewHolder(@NonNull parent: ViewGroup, viewType: Int): ViewHolder {
        val inf = LayoutInflater.from(parent.context)
        return ViewHolder(inf.inflate(R.layout.item, parent, false))
    }
}

其中实例化 AsyncListUtil 时的 500 表示分页大小。

要注意的一点是 listUtil.getItem(position) 在指定 position 对应的数据仍在被加载时会返回 null ,因此需要在 ViewHolder 中处理当 item 为 null 的情况:

class ViewHolder(itemView: View?) : RecyclerView.ViewHolder(itemView) {
    private val title: TextView? = itemView?.findViewById(R.id.title)
    private val content: TextView? = itemView?.findViewById(R.id.content)

    fun bindView(item: Item?, position: Int) {
        title?.text = "$position ${item?.title ?: "loading"}"
        content?.text = item?.content ?: "loading"
    }
}

这里当 item 为 null 时,就简单的显示 "loading"。

最后,在 Activity 中把所有的这些组合起来:

class MainActivity : AppCompatActivity() {
    private lateinit var recyclerView: RecyclerView
    private lateinit var adapter: AsyncAdapter
    private lateinit var itemSource: SQLiteItemSource

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        recyclerView = findViewById(R.id.recycler)

        itemSource = SQLiteItemSource(getDatabase(this, "database.sqlite"))
        adapter = AsyncAdapter(itemSource, recyclerView)

        recyclerView.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
        recyclerView.adapter = adapter
    }

    override fun onStart() {
        super.onStart()
        adapter.onStart(recyclerView)
    }

    override fun onStop() {
        super.onStop()
        adapter.onStop(recyclerView)
    }
}

完整项目代码可以在 Github 上找到:jasonwyatt/AsyncListUtil-Example

原文:how-to-use-asynclistutil
延伸阅读:
理解 Android 新的依赖方式
RecyclerView 实现快速滚动
现代 Android 开发资源汇总

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

推荐阅读更多精彩内容