什么?RecyclerView中获取点击位置的接口被废弃了?

2,065 阅读2分钟

本文同步发表于我的微信公众号,在微信搜索“郭霖”即可关注,每个工作日都有文章更新。

各位小伙伴们,大家好。上个礼拜,我在公众号的某篇文章下面看到这样一条留言:

什么?holder.adapterPosition 被划线不推荐使用了?

《第三行代码》这才刚刚出版,竟然就有 API 被弃用了,我决定对这个问题好好研究一下,并加急写一篇文章进行分析。

仔细一看,holder.adapterPosition 这不就是我们平时在 RecyclerView 里面用于获取点击位置的方法么,常用写法如下:

holder.itemView.setOnClickListener {
	val position = holder.adapterPosition
	Log.d("TAG", "you clicked position $position")
}

这个方法相信每个人都用过不下千百遍,这种方法怎么会被废弃呢?于是我到 Android 的官网去查了一下文档,果然,getAdapterPosition() 方法被标记成了废弃:

我帮大家翻译一下这段英文:这个方法当多个 adapter 嵌套时会存在歧义。如果你是在一个 adapter 的上下文中调用这个方法,你可能想要调用的是 getBindingAdapterPosition() 方法。如果你想获得的 position 是如同在 RecyclerView 中看到的那样,你应该调用 getAbsoluteAdapterPosition() 方法。

看完这段解释是不是还是一脸懵逼?但我已经尽可能翻译得准确了。

我在看完这段解释之后也是不能理解,为什么这个方法当多个 adapter 嵌套时会存在歧义?多个 adapter 嵌套让我容易联想到 RecyclerView 中嵌套 RecyclerView,但是好像 Google 长久以来并不推荐这种做法,更不太可能为这种做法废弃 API。

百思不得其解的时候,我突然想起来前几天隔壁鸿洋大神的公众号里推荐了一篇文章,讲的是 Google 新推出了一个 MergeAdapter。直觉告诉我,可能是和这个新功能有关。

不过 MergeAdapter 是在 RecyclerView 1.2.0 版本中才新增的,而官网目前 RecyclerView 的最新稳定版本还是 1.1.0。1.2.0 还在 alpha 阶段,连 beta 阶段都没到:

库还没稳定,文档却先标为废弃了,Google 这个做法也真是有点急不可耐。

那么 MergeAdapter 到底有什么作用呢?我简单看了一下介绍就明白了,因为这就是我一直想要追求的功能啊!

它的主要作用很简单,就是将多个 Adapter 合并到一起。

你可能会说,为什么我的 RecyclerView 里面会有多个 Adapter 呢?那是因为你或许还没有遇到过这样的需求,而我就遇到了。

两年前我在做 giffun 这个项目时,查看 GIF 图详情的界面就是使用 RecyclerView 来做的。

可能你没有想到这个界面会是一个 RecyclerView,但是它确实就是如此,界面中的内容主要分成了如上图所示的 3 部分。

那么一个 RecyclerView 中怎么能显示 3 种完全不同的内容呢?我当时是在 Adapter 当中使用了多种不同的 viewType 来实现的:

override fun getItemViewType(position: Int) = when (position) {
	0 -> DETAIL_INFO
	1 -> if (commentCount == -1) {
		LOADING_COMMENTS
	} else if (commentCount == 0 || commentCount == -2) {
		NO_COMMENT
	} else {
		HOT_COMMENTS
	}
	2 -> ENTER_COMMENT
	else -> super.getItemViewType(position)
}

可以看到,这里根据不同的 position,返回了不同的 viewType。当 position 是 0 的时候,返回 DETAIL_INFO,也就是 gif 详情区域。当 position 是 1 的时候,返回 LOADING_COMMENTS、NO_COMMENT、HOT_COMMENTS 中的一种,用于展示评论内容。当 position 是 2 的时候,返回 ENTER_COMMENT,也就是评论输入框区域。

giffun 的源码是完全公开的,你可以到这里查看这个类的完整代码:

github.com/guolindev/g…

那么这种写法有没有什么问题呢?最主要的问题就是,代码耦合性太高了。其实这几种不同的 viewType 之间完全没有任何关联性,将它们都写到同一个 Adapter 当中会让这个类显得比较臃肿,后期也就更加难为维护。

而 MergeAdapter 就是为了解决这种情况而出现的。它可以让你将几个业务逻辑没有关联的 Adapter 分开编写,最后再将它们合并到一起,并设置给 RecyclerView。

这里我准备使用一个非常简单的例子来演示一下 MergeAdapter 的用法。

首先,确保你使用的 RecyclerView 版本不低于 1.2.0-alpha02,否则是没有 MergeAdapter 这个类的:

dependencies {
    implementation 'androidx.recyclerview:recyclerview:1.2.0-alpha02'
}

接下来创建两个非常简单的 Adapter,一个 TitleAdapter 和一个 BodyAdapter,待会我们会用 MergeAdapter 将这两个 Adapter 合并到一起。

TitleAdapter 代码如下:

class TitleAdapter(val items: List<String>) : RecyclerView.Adapter<TitleAdapter.ViewHolder>() {

    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val text: TextView = view.findViewById(R.id.text)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_view, parent, false)
        val holder = ViewHolder(view)
        return holder
    }

    override fun getItemCount() = items.size

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.text.text = items[position]
    }

}

这是一个 Adapter 最简单的实现,没有任何逻辑在里面,只是为了显示一行文字。item_view 是个只包含一个 TextView 控件的简单布局,这里就不展示其中的代码了。

然后 BodyAdapter 的代码如下:

class BodyAdapter(val items: List<String>) : RecyclerView.Adapter<BodyAdapter.ViewHolder>() {

    inner class ViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val text: TextView = view.findViewById(R.id.text)
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_view, parent, false)
        val holder = ViewHolder(view)
        return holder
    }

    override fun getItemCount() = items.size

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        holder.text.text = items[position]
    }

}

基本上就是复制过来的代码,和 TitleAdapter 没有什么区别。

然后我们在 MainActivity 当中就可以这样使用了:

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val titleItems = generateTitleItems()
        val titleAdapter = TitleAdapter(titleItems)

        val bodyItems = generateBodyItems()
        val bodyAdapter = BodyAdapter(bodyItems)

        val mergeAdapter = MergeAdapter(titleAdapter, bodyAdapter)

        recyclerView.layoutManager = LinearLayoutManager(this)
        recyclerView.adapter = mergeAdapter
    }

    private fun generateTitleItems(): List<String> {
        val list = ArrayList<String>()
        repeat(5) { index ->
            list.add("Title $index")
        }
        return list
    }

    private fun generateBodyItems(): List<String> {
        val list = ArrayList<String>()
        repeat(20) { index ->
            list.add("Body $index")
        }
        return list
    }
	
}

可以看到,这里我编写了 generateTitleItems() 和 generateBodyItems() 这两个方法,分别用于给两个 Adapter 生成数据集。然后创建了 TitleAdapter 和 BodyAdapter 的实例,并使用 MergeAdapter 将它们合并到一起。合并的方式很简单,就是将你要合并的所有 Adapter 的实例都传入到 MergeAdapter 的构造方法当中即可。

最后,将 MergeAdapter 设置到 RecyclerView 当中,整个过程结束。

是不是非常简单?几乎和之前 RecyclerView 的用法没有任何区别。

现在运行一下程序,效果如下图所示:

可以看到,TitleAdapter 和 BodyAdapter 中的数据是合并到一起显示的,同时也就说明,我们的 MergeAdapter 已经成功生效了。

到这里为止都还算很好理解,但是接下来,我要给大家一个灵魂拷问了。

如果这时,我想要监听 BodyAdapter 中元素的点击事件,那么调用 getAdapterPosition() 方法,获得的到底是 BodyAdapter 中元素的点击位置,还是合并之后元素的点击位置呢?

你会发现,这个时候 getAdapterPosition() 方法已经会造成歧义了,这也就是开篇那段英文所描述的问题。

而解决办法当然也很简单,Google 废弃了 getAdapterPosition() 方法,但是却又提供了 getBindingAdapterPosition() 和 getAbsoluteAdapterPosition() 这两个方法。从名字上就可以看出来了,一个是用于获取元素位于当前绑定 Adapter 的位置,一个是用于获取元素位于 Adapter 中的绝对位置。

如果觉得我上面的解释还不够清楚,通过下面的示例看一下你立马就能明白了。

我们修改 BodyAdapter 中的代码,在里面加入监听当前元素点击事件的代码,如下所示:

class BodyAdapter(val items: List<String>) : RecyclerView.Adapter<BodyAdapter.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_view, parent, false)
        val holder = ViewHolder(view)
        holder.itemView.setOnClickListener {
            val position = holder.bindingAdapterPosition
            Toast.makeText(parent.context, "You clicked body item $position", Toast.LENGTH_SHORT).show()
        }
        return holder
    }

    ...
}

可以看到,这里调用的是 getBindingAdapterPosition() 方法,并通过 Toast 弹出当前点击元素的位置。

运行一下程序,效果如下图所示:

很明显,我们获取到的点击位置是元素位于 BodyAdapter 中的位置。

再修改一下 BodyAdapter 中的代码,将 getBindingAdapterPosition() 方法换成 getAbsoluteAdapterPosition() 方法:

class BodyAdapter(val items: List<String>) : RecyclerView.Adapter<BodyAdapter.ViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        val view = LayoutInflater.from(parent.context).inflate(R.layout.item_view, parent, false)
        val holder = ViewHolder(view)
        holder.itemView.setOnClickListener {
            val position = holder.absoluteAdapterPosition
            Toast.makeText(parent.context, "You clicked body item $position", Toast.LENGTH_SHORT).show()
        }
        return holder
    }

    ...
}

然后重新运行程序,如下所示:

结果一目了解,获取到的点击位置是元素位于合并后 Adapter 中的位置。

最后整理一下结论吧:

  1. 如果你没有使用 MergeAdapter,那么 getBindingAdapterPosition() 和 getAbsoluteAdapterPosition() 方法的效果是一模一样的。
  2. 如果你使用了 MergeAdapter,getBindingAdapterPosition() 得到的是元素位于当前绑定 Adapter 的位置,而 getAbsoluteAdapterPosition() 方法得到的是元素位于合并后 Adapter 的绝对位置。

文章写到这里,也就把开篇 “木空” 同学提出的问题彻底分析完毕,我觉得本篇文章也可以算得上是一篇《第一行代码 第 3 版》的扩展文章吧。

另外说一下,由于《第一行代码 第 3 版》已经出版,以后未来我自己编写的所有文章都会使用 Kotlin 语言,Java 就不再使用了,想学习 Kotlin 语言的朋友们可以考虑一下这本书。

由于这是我第一次尝试编写编程语言类型的内容,本来心里不是特别有底,但是看到第一批读者普遍反馈好评之后,我现在更加坚信这本书的质量了。我的 QQ 群里有个群友还说,自己之前学过几轮 Kotlin 了,都没有这本书讲得好,看得我也是心里暖暖的。

关注我的技术公众号“郭霖”,每天都有优质技术文章推送。