如何让一个TextView无限显示字符串而不发生ANR(ArrayDeque详解)

📅 2026/7/29 22:47:03
如何让一个TextView无限显示字符串而不发生ANR(ArrayDeque详解)
实时日志、行情、聊天消息……很多场景都要让一个TextView持续不断地吐出字符串。 但 naive 写法跑一会儿就ANR甚至OOM。本文用ArrayDeque做有界滚动缓冲 配合主线程限频批量刷新既能无限显示又绝不卡死。顺带把ArrayDeque的 4 个高频坑一次讲清。0. 问题场景与一句话结论场景有一路高频字符串流后台线程不断产生日志 / 传感器数据 / 推送消息要实时显示在一个TextView上程序可能连续跑几小时甚至几天。naive 写法为什么崩// ❌ 每来一条就在主线程直接 append文本无限增长 fun onLog(line: String) { textView.append(line \n) }TextView的文本无限变长每次setText/append都要对整段文本重新 measure/layout/draw成本随长度 O(n) 增长高频append把主线程打满无法响应输入与 VSync →5 秒输入分发超时 → ANR若生产方在子线程还会直接抛Only the original thread that created a view hierarchy can touch its views。一句话结论用有界的ArrayDeque做滚动缓冲只保留最近 N 条再主线程定时批量刷新TextView限频 批量。内存稳定、layout 成本恒定、主线程永不被打满 → 无限显示而不 ANR。1. 为什么 naive 写法会 ANR机制拆解TextView的文本是CharSequenceappend/setText会触发measure重新计算文本宽度/行数开销随字符数增长layout按行排版文本越长越慢draw把整段文本画到画布。当文本从 1KB 涨到 10MB每次刷新都慢一个数量级。如果每条日志都立刻append主线程会一直卡在 1→3 步根本没空处理触摸事件和帧信号。系统规则输入事件 5 秒无响应即 ANR广播 10 秒、服务 20 秒同理。关键点问题不在显示而在无限增长 高频刷新。两条解药限长缓冲只留最近 N 条文本长度有上限 → layout 成本恒定限频把每条刷新一次改成每 200ms 批量刷新一次 → 主线程负载砍到 1/几十。2. 主角ArrayDeque是什么ArrayDeque是 Kotlin 标准库提供的双端队列doubly-ended queue既能当栈又能当队列底层是可扩容的循环数组增删首尾均摊 O(1)。private val logBuffer ArrayDequeString() // │ │ │ // │ │ └─ Kotlin 标准库实现JVM 上是 kotlin.collections.ArrayDeque // │ │ 注意它 ≠ java.util.ArrayDeque但语义一致 // │ └─ 只读引用只能赋值一次不能再指向别的容器 // └─ 可见性仅当前类/文件内部可见它正好适合做最近 N 条日志的滚动缓冲——头进尾出、自动淘汰最旧。但它有 4 个坑每个都和上面的 ANR 问题直接相关。3. 痛点一val只是不能换容器容器内数据可随意修改 ⭐val只保证logBuffer这个变量名永远指向同一个ArrayDeque实例对它内部调用add / remove / clear / set完全合法。看到val就以为不可变 / 线程安全 / 改不了是最大误解。private val logBuffer ArrayDequeString() fun demo() { logBuffer ArrayDeque() // ❌ 编译错误Val cannot be reassigned不能换容器 logBuffer.add(hello) // ✅ 容器内容随便改增 logBuffer.addFirst(head) // ✅ 改头插 logBuffer.removeLast() // ✅ 改删尾 logBuffer.clear() // ✅ 改清空 }记住val是引用不可变不是数据不可变。本场景我们正需要内容可变——缓冲要不断增删所以val完全正确别去改成var那反而能误赋值为新容器丢掉旧数据。4. 痛点二多线程同时add/poll会并发错乱ArrayDeque不是线程安全的。日志生产方往往是多个后台线程同时add或一写一读会出现数据丢失后写覆盖先写位置凭空抛出IndexOutOfBoundsException/NoSuchElementException极端情况head/tail错乱死循环或读到脏数据。解决方案 A外部加synchronized推荐且便于做有界private val logBuffer ArrayDequeString() private val lock Any() fun onLog(line: String) synchronized(lock) { logBuffer.add(line) } // 写 fun snapshot(): ListString synchronized(lock) { ArrayList(logBuffer) } // 读复制解决方案 B并发队列ConcurrentLinkedDequeimport java.util.concurrent.ConcurrentLinkedDeque private val logBuffer ConcurrentLinkedDequeString() fun onLog(line: String) logBuffer.addLast(line) // 线程安全 fun pollOne(): String? logBuffer.pollFirst() // 线程安全⚠️ 但并发队列 ≠ 自带容量上限ConcurrentLinkedDeque是无界的且size是 O(n) 遍历。要做到严格有界 线程安全最稳的还是自带锁的有界缓冲见第 7 节。方案对比速查方案线程安全有界适用场景ArrayDeque裸用❌❌单线程、局部临时缓冲ArrayDequesynchronized✅自己实现多生产者 需精确容量上限本场景首选ConcurrentLinkedDeque✅❌无界高并发、不限制长度LinkedBlockingDeque(capacity)✅✅ 阻塞生产者/消费者、要背压5. 痛点三不能存入null但空字符串没问题ArrayDeque拒绝null塞进去运行期直接 NPE。但编译期 vs 运行期的差异是坑点private val logBuffer ArrayDequeString() // 元素类型是非空 String logBuffer.add(null) // ❌ 编译期报错non-null type String logBuffer.add() // ✅ 完全 OK logBuffer.add( ) // ✅ 空白字符串也没问题为何仍是坑日志常来自 Java 代码、JSON 解析、Any?强转类型信息可能漏过去运行期才暴露 NPE。防御写法fun onLog(line: String?) { logBuffer.add(line ?: null) // 显式过滤 null用占位符 // 或 logBuffer.addLast(line.orEmpty()) // null - }结论存永远安全存null永远不行编译拦得住就拦拦不住就运行期 NPE。6. 痛点四无限add会持续吃内存最终引发 OOM / ANRArrayDeque没有任何容量上限。每次add只是按需扩容通常翻倍旧日志永不回收。长时间运行的日志场景内存会一直涨最终OOM缓冲占满内存被系统杀死ANR 推手结合第 1 节——缓冲越大主线程snapshot().join()出的字符串越长setText的 layout 越慢 → 雪崩式 ANR。解药就是有界插入前若已达上限先丢最旧的队首再插新的环形保留最近 N 条。// ❌ 错误跑一个月内存涨到天上去最终 OOM/ANR fun onLog(line: String) logBuffer.add(line) // ✅ 正确超过上限丢弃最旧长度恒定 fun onLog(line: String) { while (logBuffer.size MAX) logBuffer.removeFirst() logBuffer.addLast(line) }这正是无限显示而不 ANR的核心开关把无限增长关进 N 条的笼子里。7. 综合最佳实践有界缓冲 限频刷新可直接抄把 4 个坑一次堵上并接上TextView// 有界滚动缓冲并发安全 过滤 null 限长解决内存/ANR class LogBuffer(private val maxSize: Int 500) { private val buffer ArrayDequeString() // val引用固定内容可变 private val lock Any() // 生产方子线程/回调调用 fun append(line: String?) synchronized(lock) { val safe line ?: null // 痛点三null - 占位 while (buffer.size maxSize) { buffer.removeFirst() // 痛点四超界丢最旧 } buffer.addLast(safe) } // 消费方主线程定时调用复制当前内容不清空滚动窗口 fun snapshot(): ListString synchronized(lock) { ArrayList(buffer) } } // ── 在 Activity 中使用 ── class LogActivity : AppCompatActivity() { private val buffer LogBuffer(maxSize 500) private val uiHandler Handler(Looper.getMainLooper()) private lateinit var tv: TextView // 多个后台线程同时生产也不乱痛点二靠 synchronized 解决 private val producers List(3) { i - thread(name producer-$i) { while (!Thread.interrupted()) { buffer.append([$i] tick ${System.nanoTime()}) Thread.sleep(5) } } } // 每 200ms 批量刷新一次限频 批量主线程永不被打满痛点四收尾 private val refresh object : Runnable { override fun run() { tv.text buffer.snapshot().joinToString(\n) // 始终只有最近 500 条 uiHandler.postDelayed(this, 200) // ~5 次/秒layout 成本恒定 } } override fun onCreate(s: Bundle?) { super.onCreate(s) tv TextView(this).also { setContentView(it) } uiHandler.post(refresh) } override fun onDestroy() { super.onDestroy() uiHandler.removeCallbacks(refresh) // 停掉刷新避免泄漏 producers.forEach { it.interrupt() } } }为什么这版不 ANR缓冲恒为 ≤500 条 →snapshot().join()出的字符串长度有上限 →setText的 layout 成本恒定刷新限频到 5 次/秒 → 主线程有余力处理输入与帧信号。程序可以无限跑下去。8. 一页速查表建议截图带走维度真相正确做法val语义只锁定引用不锁定内容想不可变要用List/不可变包装改内容add/remove/clear/set全部合法本场景正需要内容可变别误改var线程安全ArrayDeque非线程安全synchronized或ConcurrentLinkedDeque存null禁止运行期 NPE用orEmpty()/?:占位过滤存允许完全没问题放心用容量无界无限add会涨内存→OOM/ANR自己限长超 N 就removeFirstTextView 刷新每条都append必 ANR限频 批量主线程定时setText9. 自测小问分享时可以用来互动val logBuffer ArrayDequeString()后logBuffer.clear()合法吗——合法改内容不算改引用。后台 3 个线程同时append不加锁会怎样——数据错乱 / 抛异常 / 可能死循环。textView.append(line)每来一条就调用跑久了会怎样——文本无限增长 主线程被打满 → ANR。只换ConcurrentLinkedDeque能解决内存无限增长吗——不能它无界还得自己限长或用有界阻塞队列。想保留最近 500 条且永不 ANR核心两步是——① 有界缓冲超 500 丢最旧② 主线程限频批量刷新 TextView。logBuffer.add(null)编译能过吗运行会怎样——非空类型下编译不过平台类型/反射下运行期 NPE。分享完毕。一句话TextView 无限显示 有界ArrayDeque滚动缓冲VAL 引用不变、内容可变、线程加锁、过滤 null、超限丢旧 主线程限频批量刷新。四个坑全堵上ANR 自然消失。