11、点击事件处理(一):在 Adapter 中设置 OnClickListener

点击事件,是列表交互的灵魂。没有点击的列表,就像没有按钮的遥控器——摆着好看,但没法用。

今天我们来聊聊,在 RecyclerView 的 Adapter 里,怎么给每个 item 绑定点击事件。我最早接触 RecyclerView 时,第一反应是「直接在 item 的 XML 里写 android:onClick 不就行了?」嗯,后来发现这条路走不通。RecyclerView 的 item 是动态回收复用的,你没法像 Activity 里那样直接绑一个方法名。

那怎么办?最经典的做法,就是在 Adapter 内部设置 OnClickListener。说白了,就是在 onBindViewHolder 的时候,给 itemView 或者 itemView 里的某个子 View 注册点击监听。

11.1 基础写法:在 onBindViewHolder 中设置监听

先看一个最直接的写法。假设我们有一个简单的列表,展示用户姓名,点击后弹出一个 Toast。

class UserAdapter(private val userList: List<User>) :
    RecyclerView.Adapter<UserAdapter.UserViewHolder>() {

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

    override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
        val user = userList[position]
        holder.bind(user)

        // 直接在 onBindViewHolder 中设置点击事件
        holder.itemView.setOnClickListener {
            Toast.makeText(
                holder.itemView.context,
                "点击了:${user.name}",
                Toast.LENGTH_SHORT
            ).show()
        }
    }

    override fun getItemCount() = userList.size

    class UserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val tvName: TextView = itemView.findViewById(R.id.tvName)

        fun bind(user: User) {
            tvName.text = user.name
        }
    }
}

这段代码能跑,但有个小问题。每次 onBindViewHolder 被调用时,都会重新设置一次 OnClickListener。虽然 RecyclerView 会复用 ViewHolder,但 onBindViewHolder 在 item 滚动进出屏幕时会频繁触发。你想想看,一个 ViewHolder 可能被绑定 10 次,那就注册了 10 次监听器。虽然不会导致崩溃,但性能上不优雅。

我的习惯:我个人更倾向于在 ViewHolder 的构造函数中,或者 init 块里,一次性设置好点击监听。然后在 bind 方法里,通过回调把数据传出去。这样监听器只注册一次,干净利落。

11.2 优化方案:在 ViewHolder 中注册监听

把点击事件的注册挪到 ViewHolder 的初始化阶段。这样每个 ViewHolder 只注册一次监听,不管它被绑定多少次。

class UserAdapter(private val userList: List<User>) :
    RecyclerView.Adapter<UserAdapter.UserViewHolder>() {

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

    override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
        holder.bind(userList[position])
    }

    override fun getItemCount() = userList.size

    class UserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val tvName: TextView = itemView.findViewById(R.id.tvName)

        init {
            // 一次性注册点击监听
            itemView.setOnClickListener {
                val position = adapterPosition
                if (position != RecyclerView.NO_POSITION) {
                    Toast.makeText(
                        itemView.context,
                        "点击了第 ${position + 1} 项",
                        Toast.LENGTH_SHORT
                    ).show()
                }
            }
        }

        fun bind(user: User) {
            tvName.text = user.name
        }
    }
}

这里有个细节要注意:adapterPosition 在 init 块里是拿不到的,因为 ViewHolder 还没绑定数据。但点击事件是延迟触发的,用户点击时 ViewHolder 已经绑定了数据,所以 adapterPosition 能正确返回当前的位置。

注意:千万不要在 init 块里直接使用 adapterPosition 来获取数据。因为 init 执行时,ViewHolder 还没有和任何数据关联。我曾经见过有人这么写,结果点击时永远返回的是 -1 或者上一次的位置。嗯,踩过这个坑。

11.3 传递点击事件到 Activity 或 Fragment

在实际项目中,点击事件往往需要通知到外部——比如跳转页面、弹出对话框、或者更新 ViewModel 里的数据。这时候就需要把点击事件通过回调接口传递出去。

定义一个接口:

interface OnItemClickListener {
    fun onItemClick(user: User, position: Int)
}

在 Adapter 中持有这个接口的引用:

class UserAdapter(
    private val userList: List<User>,
    private val listener: OnItemClickListener
) : RecyclerView.Adapter<UserAdapter.UserViewHolder>() {

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

    override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
        holder.bind(userList[position])
    }

    override fun getItemCount() = userList.size

    inner class UserViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
        private val tvName: TextView = itemView.findViewById(R.id.tvName)

        init {
            itemView.setOnClickListener {
                val position = adapterPosition
                if (position != RecyclerView.NO_POSITION) {
                    listener.onItemClick(userList[position], position)
                }
            }
        }

        fun bind(user: User) {
            tvName.text = user.name
        }
    }
}

在 Activity 或 Fragment 中使用:

val adapter = UserAdapter(userList, object : OnItemClickListener {
    override fun onItemClick(user: User, position: Int) {
        Toast.makeText(this@MainActivity, "点击了 ${user.name}", Toast.LENGTH_SHORT).show()
        // 可以在这里做跳转、更新数据等操作
    }
})
recyclerView.adapter = adapter
核心要点:回调接口让 Adapter 和外部解耦。Adapter 只负责「谁被点击了」,不负责「点击后做什么」。做什么的逻辑交给调用方去实现。这是单一职责原则的体现。

11.4 点击事件与数据绑定的关系

如果你用了 DataBinding 或 ViewBinding,点击事件的处理方式会略有不同。但底层逻辑是一样的——最终还是通过 setOnClickListener 来注册。

我个人建议,在项目初期或者简单场景下,直接用回调接口的方式。等列表逻辑复杂了,再考虑引入 DataBinding 或者 DiffUtil 等高级特性。不要一开始就上重型武器,容易把自己绕晕。

11.5 本章知识体系

下面这张图展示了点击事件处理的核心流程和关键决策点:

点击事件处理核心流程 用户点击 Item 注册监听的位置 onBindViewHolder 每次绑定都注册 ViewHolder.init 只注册一次 通过回调接口通知外部 推荐使用 ViewHolder.init 方式 + 回调接口

从图中可以看出,核心决策在于「在哪里注册监听」。我个人强烈推荐在 ViewHolder 的 init 块中注册,然后在回调中通过 adapterPosition 获取当前位置。这样既保证了性能,又保持了代码的清晰度。

小技巧:如果你需要监听 item 内部某个子 View 的点击(比如点赞按钮、删除按钮),思路完全一样。在 init 块里给那个子 View 设置 setOnClickListener,然后通过回调把事件传出去。我曾经在一个社交 App 里,一个 item 上有 5 个可点击区域,都是这么处理的。

好了,点击事件的基础处理就聊到这里。记住一个原则:监听器只注册一次,数据通过回调传递。这样你的 Adapter 既高效又易于维护。