写代码这些年,凡是列表里塞输入框的项目,几乎都要被同一个 bug 折磨一遍:明明在第一个 EditText 里输了“张三”,滑下去再回来,第五个输入框里也变成了“张三”。我最近接手的订单备注编辑页面就复现了这个问题,测试妹子一脸无辜地甩过来录屏,第一行的备注内容整整齐齐地长在了第五行的脸上。这个问题的根子不在 EditText,而在 RecyclerView/ListView 的复用机制。这篇就围绕这个经典场景,把病根、标准解法、高频坑位和进阶优化一次讲透。
1. 复用机制导致的EditText错乱:先搞清病根在哪
1.1 列表项为什么会“死而复生”
RecyclerView 和 ListView 的设计目标是一致的:屏幕上只显示有限几个 item,滚动时通过回收和复用 View 来降低内存开销。比如屏幕能放 8 个 item,RecyclerView 只会创建 8~12 个 ViewHolder,多出来的位置全靠滑动时把离开屏幕的 item 的 View 挪过来接着用。
这个机制本身很聪明,但它有一个天然副作用:item 一旦被复用,它携带的所有即时状态都会跟着走。普通控件无所谓,因为 onBindViewHolder 每次都会重新 setText、setImageResource 把状态重置一次。但 EditText 不一样,它是用户正在交互的控件,输入内容既属于 View 状态,又属于数据状态。
问题就出在这个交叉地带。如果在绑定 item 时没有把 EditText 的当前内容显式存到数据源里,而只是“随手留在了 View 上”,那这个内容就会跟着 ViewHolder 一起被搬到下一个 item 上,于是出现“第一个输入框的内容长到第五个输入框脸上”的灵异现场。
1.2 错乱的本质:视图状态残留与绑定时序
很多人一上来就怀疑是 TextWatcher 的问题,其实 TextWatcher 只是帮凶,真正的元凶是视图状态残留 + 数据绑定不彻底。
举个例子:item 0 的用户输入了“北京”,这时候 ViewHolder A 的 EditText 内容是“北京”。用户滑走,item 0 离开屏幕,ViewHolder A 被回收。item 5 滑入屏幕,系统把 ViewHolder A 分配给 item 5,调用 onBindViewHolder 时,如果开发者在绑定前没有把 editText 清空,或者没有重新 setText(item5.content),那 EditText 里自然还是“北京”。
更隐蔽的是时序问题。如果你给 EditText 挂了 TextWatcher 来实时更新数据源,数据源里的 item5.content 确实可能已经被“北京”污染了,因为 ViewHolder A 被复用到 item 5 之后,EditText 显示的还是旧文本,但 watcher 并没有主动触发(内容没变化),于是数据源和 UI 一起错。
ListView 时代这个问题更严重,因为 getView 里通常直接操作 convertView,很多人图省事不在 convertView 为空时重置状态。RecyclerView 强制 ViewHolder 模式以后,规范了一些,但 EditText 这种带交互状态的控件依然防不胜防。
2. 稳定解法:ViewHolder模式下EditText与数据源的强绑定
2.1 核心思路:把输入内容当数据,而不是当UI状态
解决这个问题的关键认知只有一句话:对于列表里的 EditText,UI 上的文字只是数据的投影,数据源才是唯一可信状态。
这听起来像废话,但实际执行时很容易跑偏。新手最常见的做法是只在初始化时 setText 一次,然后就不管了,输入的内容全靠 View 自己留着。这种思路在静态列表里没问题,一旦涉及复用、排序、删除、异步加载,立刻炸。
正确的做法是:每次 onBindViewHolder 时,用数据源里的字段覆盖 EditText 的显示内容;每次用户输入时,把 EditText 的最新内容回写到数据源对应 item 的字段里。视图负责展示,数据源负责记忆,两者靠绑定逻辑保持同步,互不依赖。
2.2 代码实现:Adapter 里如何安全地处理 EditText
文字不够直观,直接上一份我实际在用的 Kotlin 写法。这个写法处理了三个关键点:复用前移除旧监听器、内容回填时避免重复触发、光标位置保持。
class EditableAdapter( private val dataList: MutableList<EditModel> ) : RecyclerView.Adapter<EditableAdapter.EditorHolder>() { inner class EditorHolder(val binding: ItemEditBinding) : RecyclerView.ViewHolder(binding.root) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): EditorHolder { val binding = ItemEditBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return EditorHolder(binding) } override fun getItemCount(): Int = dataList.size override fun onBindViewHolder(holder: EditorHolder, position: Int) { val item = dataList[position] val editText = holder.binding.etContent // 1. 先移除旧 watcher,防止叠加触发 val oldWatcher = editText.getTag(R.id.tag_watcher) as? TextWatcher oldWatcher?.let { editText.removeTextChangedListener(it) } // 2. 仅当内容不一致时才回填,避免不必要的 setText val currentText = editText.text?.toString().orEmpty() if (currentText != item.content) { editText.setText(item.content) // 3. 回填后把光标移到最后,避免重新绑定时光标跳到开头 editText.setSelection(editText.text?.length ?: 0) } // 4. 创建新 watcher,只负责回写数据源 val watcher = object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { val pos = holder.bindingAdapterPosition if (pos != RecyclerView.NO_POSITION) { dataList[pos].content = s?.toString().orEmpty() } } } editText.addTextChangedListener(watcher) editText.setTag(R.id.tag_watcher, watcher) } }配套的数据模型:
data class EditModel( val id: Long, var content: String = "" )几个细节需要解释一下。
关于移除旧 watcher:onBindViewHolder 每次被调用时,如果之前已经 add 过一次 watcher,再 add 就会造成两个 watcher 同时监听同一个 EditText。后果是 afterTextChanged 里回写数据源时被执行两次,虽然结果相同,但如果你在回调里做了计数、长度限制、动态计算等逻辑,就等着被 bug 追着跑吧。把 watcher 存在 view 的 tag 里,绑定前先 remove 掉,这是最稳的清理方式。
关于 setText 的条件判断:每次 onBindViewHolder 就无条件 setText 一次,看起来没问题,但 setText 会触发 TextWatcher,从而走进 afterTextChanged 回写数据源。这时候如果数据源里的内容和 EditText 当前显示的内容一致,就白触发一次,还会造成不必要的性能开销。加上内容判断,只在真正需要回填时 setText,既省事又干净。
2.3 为什么 setSelection 能避免“光标跳开头”的诡异现象
这个小坑很容易被忽略。当 EditText 被复用并重新 setText 时,如果原来的光标位置比新文本长度大,系统可能会把光标重置到文本开头,或者定位到末尾附近。用户在编辑一半时滑动一下,再回来光标突然跳到第 0 位,后续输入的内容全跑到最前面,体验极其割裂。
绑定后统一 setSelection(editText.text.length),至少保证每次重新绑定时光标在最后,这是列表输入场景下最省心的策略。当然如果你想恢复用户离开时的精确光标位置,可以把 selectionStart 也存进数据模型,但这属于过度设计的范畴,一般情况下真没必要。
3. 高频坑位复盘:TextWatcher重复触发与position错位的真实场景
3.1 多个 TextWatcher 叠加:回调次数翻倍的暗雷
我在给一个模拟项目做代码审查时,看到过这样的写法:onCreateViewHolder 里给 EditText add 了一个 watcher,onBindViewHolder 里又 add 了一个。因为 ViewHolder 本身是复用的,onCreateViewHolder 只执行一次,这个还能忍。但另一个项目是在 onBindViewHolder 里直接 add,没有移除,结果同一个 EditText 上挂了 3 个 watcher 之后,每次输入一个字符,同一个回调逻辑被反复执行。
最直接的症状是:输入框内文字长度显示翻倍、字数统计异常、每次删除字符时数据源被回写多次。排查思路其实很简单:在 watcher 回调第一行打日志,连续输入几个字符看日志条数。如果按一次键盘冒了两三行日志,说明 watcher 叠加了。
解决办法就是我在 2.2 的写法里展示的:绑定前先 remove 旧的,再 add 新的,保证同一个 ViewHolder 的 EditText 上永远只有一个活跃 watcher。
3.2 setText 回填触发 watcher 导致的“数据打架”
假设你按标准写法做了数据绑定:onBindViewHolder 里 setText(item.content)。这时候 watcher 会被触发,走到 afterTextChanged,把 item.content 又写回 dataList[pos].content。如果这一切发生在绑定过程中,问题还不大,因为内容一致。
但如果你在 setText 之后、watcher 还没触发完之前,又对 item 做了其他操作,比如根据输入长度控制“还剩多少字”的提示文字,此时 oldText 和 newText 同时存在,你拿到的 s 可能是新值,而 item 里还是旧值,UI 就会闪一下。
更危险的是:如果 onBindViewHolder 里 setText 了一个格式化后的字符串(比如加了千分位分隔符),watcher 回写时把这个带格式的字符串存进数据源,下一次绑定时 setText 又会触发一次格式化,反复横跳之后,数据就彻底脏了。
我的建议是:TextWatcher 回写的是用户的原始输入,不要在 watcher 里做格式化,格式化只发生在展示层。如果确实需要格式化再回填,加一个 boolean 标志位(比如 isBinding),在 setText 之前置 true,afterTextChanged 里判断如果是绑定阶段就直接 return,不执行数据回写。
private var isBinding = false // onBindViewHolder 内 isBinding = true editText.setText(item.content) isBinding = false // watcher 内 override fun afterTextChanged(s: Editable?) { if (isBinding) return // 真正处理用户输入 }3.3 滑动到底部再滑回来:输入内容为什么凭空消失
这个问题和错乱是一体两面。如果 onBindViewHolder 里没有用数据源回填 EditText,而是只执行了一次 setText,之后全靠用户手动输入,那滑动后 item 被回收、再滑回来重新绑定时,EditText 已经被重置了。没有数据源兜底,内容自然消失。
我见过一个下午都在排查此事的同学,他的代码里数据源也有,onBindViewHolder 也写了 setText,但用户输入时没写回数据源,只在点击“保存”按钮时才一次性遍历所有 ViewHolder 来取值。中间任何一次滑动,数据源里的字段都是空字符串,来回滑两下输入内容就全没了。
这类问题的判断标准很简单:用户输入一个字,数据源立刻更新一个字,而不是等提交时才同步。如果你还在用“保存时遍历 ViewHolder”的方案,强烈建议改成实时回写,因为 ViewHolder 根本不是可靠的数据容器。
3.4 异步加载时 position 错位:回调鬼影
还有一种错位和 EditText 本身无关,但会直接影响列表内容的准确性:异步回调里直接使用 onBindViewHolder 传进来的 position。
比如你在绑定 item 时发起一个网络请求,请求返回后根据 position 更新那个 item 的文本。但由于 RecyclerView 的 item 在滑动后 position 会变,回调来临时的 position 可能已经指向另一个 item 了,于是新的文本被设置到错误的输入框里。
现在标准的做法是不要在异步回调里用 position,改用 ViewHolder 或稳定 id。如果你用 ViewHolder 作为 key,回调前先判断 holder 是否还是当前 item 的 holder:
override fun onBindViewHolder(holder: EditorHolder, position: Int) { val item = dataList[position] // 异步请求回来后只更新数据源 fetchData(item.id) { result -> dataList[position].content = result // 若该 holder 还停留在同一个 item 上,再刷新视图 if (holder.bindingAdapterPosition == position) { editText.setText(result) } } }这套逻辑的核心是:数据源永远先更新,视图刷新必须校验 holder 是否仍然对应同一个位置。
4. 进阶优化:防抖刷新、局部刷新与跨列表复用的边界处理
4.1 输入防抖与延迟同步,避免全局刷新卡顿
有些需求是 EditText 内容变化后要实时更新 item 里的其他区域,比如输入备注时同步显示“当前已输入 N 字”,或者输入标题时预览文章标题。
如果每次 afterTextChanged 里都调用 notifyDataSetChanged(),性能会非常差,因为输入一个字符就会触发全列表重绘,而且 EditText 会丢失焦点,用户根本没法连续输入。
更合理的做法是:输入过程中只更新 dataList[pos] 里的字段,让 EditText 自己负责展示(用户已经在 EditText 里看到自己的输入了),其他展示区域的数据依赖则用局部刷新,或者用一个极短的防抖延迟(例如 200~300ms)做一次轻量同步。
我自己常用的防抖写法:
private val syncRunnable = HashMap<Int, Runnable>() private val handler = Handler(Looper.getMainLooper()) // afterTextChanged 内 val pos = holder.bindingAdapterPosition dataList[pos].content = s?.toString().orEmpty() // 取消旧任务,300ms 后再更新相关区域 syncRunnable[pos]?.let { handler.removeCallbacks(it) } val task = Runnable { notifyItemChanged(pos, PAYLOAD_CONTENT) } syncRunnable[pos] = task handler.postDelayed(task, 300L)注意这里的 notifyItemChanged 用了 payload,这意味着 onBindViewHolder 里需要处理 partial bind。这样输入过程中列表不会疯狂重绘,停顿 300ms 后相关区域才刷新一次,焦点也基本不会被打断。
4.2 局部刷新与 payload 机制:不打断输入的刷新姿势
RecyclerView 的 payload 是个好东西。普通的 notifyItemChanged 会走完整的 onBindViewHolder,默认所有内容都会被重新绑定,EditText 焦点大概率保不住。而带 payload 的 notifyItemChanged(pos, payload) 只更新和这个 payload 相关的部分。
在 onBindViewHolder 里重写带 payload 的重载方法:
override fun onBindViewHolder( holder: EditorHolder, position: Int, payloads: MutableList<Any> ) { if (payloads.isNotEmpty()) { // 局部刷新:只需要更新相关 UI,不要动 EditText val payload = payloads.first() as? String if (payload == PAYLOAD_CONTENT) { holder.binding.tvCount.text = "${dataList[position].content.length}/200" } return } super.onBindViewHolder(holder, position, payloads) }这个方式能极大减少 EditText 被重新 setText 的次数,焦点、光标、输入法状态都能保持稳定。如果你项目里输入框联动刷新很频繁,这属于必会的技能。
4.3 跨列表、跨页面复用时维护 item 状态的边界
如果是 ViewPager2 嵌套 RecyclerView,或者多个列表共享同一份数据源,复用的范围就不仅限于单个 RecyclerView 了。ViewPager2 默认会预加载相邻页面的列表,而多个列表的 item 可能共用同一个 ViewHolder 池,错乱的概率更高。
这种场景下我的建议是:一定要保证数据源里存了完整的输入内容,绑定逻辑按 id 而不是 position 定位。两个列表对应同一个 id 的 item 时,它们各自 EditText 的显示内容都应该来自同一个数据字段,谁输入了,另一个下次绑定时就跟着变。
另外,在页面切换(比如 Fragment 销毁重建、ViewPager 切换 tab)时要主动把 EditText 的当前内容回写到数据源,不要依赖 watcher 的被动触发。因为页面销毁时 watcher 可能已经被回收,最后一次输入的内容会丢。
4.4 生命周期与内存回收:防止 watcher 泄漏
最后一个容易被忽视的点:TextWatcher 持有 ViewHolder 的引用,而 ViewHolder 持有 EditText,如果 watcher 没有被移除,可能会造成短期泄漏。尤其是在快速滚动的列表里,复用频率高、watcher 创建销毁频繁,虽然大多数情况下对象的生命周期会随着 View 一起结束,但保守起见,建议在 onViewDetachedFromWindow 或 Fragment 销毁时做一次清理。
如果你用的是我在 2.2 里的写法,watcher 每次绑定时都会先移除旧的再 add 新的,其实已经把泄漏窗口压到很小了。如果实在不放心,可以给 ViewHolder 写一个 release() 方法,在 onViewRecycled 里调用,把 EditText 上的 watcher 全部移除,顺便清空文本,让回收的 ViewHolder 以“干净”的状态进入复用池。
override fun onViewRecycled(holder: EditorHolder) { super.onViewRecycled(holder) holder.binding.etContent.removeTextChangedListener( holder.binding.etContent.getTag(R.id.tag_watcher) as? TextWatcher ) holder.binding.etContent.setText("") }这不算强制要求,但我习惯在涉及长列表、大量输入框的场景里加上它,多一道保险,心里踏实。
最后再多说一句个人体会。列表里出现 EditText,本质上说明这块业务的数据结构是活的、可编辑的。与其在 View 层反复打补丁,不如一开始就把数据模型设计成一个干净的“可编辑实体”,让 ViewHolder 退化成纯展示层,让数据源成为唯一真相。想清楚这一点,很多纠缠不清的 bug 都会自动消失。