Android 消息页从仓库取回通知列表,准备筛出未读项并按会话显示。代码里参数类型写着
List,页面却可能在下一次刷新前看到内容变化;图片接口要的是ByteArray,手上的List<Byte>又不能直接传。问题牵涉的并非一套"集合语法",而是数据能否被修改、怎样组织、何时计算。以下以 Kotlin 2.4.21 及 Android 的 JVM 目标为基线。
先按顺序、去重或键查找选择容器,再明确共享与复制边界;普通集合上的 filter、map、groupBy 立即产生结果,Sequence 则推迟中间计算。
1. List 只读,为何页面仍会看到变化
List 保留顺序且允许重复,适合展示行;Set 按相等性去重,适合已选 ID;Map 用键查值,适合会话 ID 到标题的索引。各自的 MutableList、MutableSet、MutableMap 接口才提供增删改方法。不过,只读接口限制的是这条引用能做什么,不能冻结它指向的对象:
kotlin
val backing = mutableListOf("首页")
val shown: List<String> = backing
backing += "设置"
println(shown) // [首页, 设置]
val snapshot: List<String> = backing.toList()
val selected = mutableSetOf(3L, 3L, 8L) // 只有两个不同 ID
val titleById = mutableMapOf(3L to "首页")
titleById[8L] = "设置"
shown 没有 add 方法,仍与 backing 共享底层列表;snapshot 是一次浅拷贝,之后向 backing 增删条目不会改变这个列表的结构,但若元素是可变对象,两个列表仍引用同一批元素。val 只禁止变量重新赋值,val x = mutableListOf(...) 的内容照样可改;var x: List<String> 可以换一份列表,却不能通过 x 增删。
UI 状态需要稳定的一帧数据时,仓库内部可以维护可变工作集,向页面交付新的只读快照及不可变展示对象。Set 的去重、Map 的键查找依靠 equals 和 hashCode;把会变化的字段放进键或集合元素,修改后可能再也查不到它。插入顺序也不等于业务排序,展示要求按时间或优先级排列时应显式排序。
listOf()、setOf()、mapOf() 返回只读接口,适合表达"调用方只需要读";不要据此推断所有元素也不可变。相同地,给函数参数写 List<Notice> 是在收窄该函数的修改权限,并没有要求调用方交出所有权。若调用方可能同时改动同一份可变列表,单靠只读类型无法解决竞态;应明确快照时点或采用同步机制。对于页面状态,先拷贝容器,再把可变领域对象转换成展示值,通常比到处加锁更容易推理。
2. SDK 要数组时,不能只看元素类型
Android 图像、音频或 Java 接口常要求 IntArray、ByteArray。Array<Int> 与 IntArray 并非同一种类型:在 JVM 上,前者是引用元素数组,后者对应 int[],与要求原生整数数组的 API 签名对齐。
kotlin
fun opaquePixels(pixels: IntArray): IntArray {
val output = pixels.copyOf()
for (index in output.indices) {
output[index] = output[index] or 0xFF000000.toInt()
}
return output
}
val boxed: Array<Int> = arrayOf(1, 2, 3)
val raw: IntArray = boxed.toIntArray()
val sameValues = raw.contentEquals(intArrayOf(1, 2, 3))
函数先复制输入,再改写每个像素整数的高位;这里仅演示数组操作,不是完整图像处理算法。数组长度固定,元素却可通过下标改写。copyOf() 和 toIntArray() 都会生成新数组;Array<Int> 不能直接交给参数类型为 IntArray 的函数。比较两个数组的内容要用 contentEquals,普通 == 比的是数组对象是否相同,嵌套数组可用 contentDeepEquals。日志要看元素可用 contentToString()。
字节缓冲区还有编码、长度和字节序问题,类型匹配不代表数据格式正确。多个线程或异步任务共享数组时,也要决定谁拥有缓冲区以及是否复制;固定长度不会阻止另一方改写内容。
数组初始化也要注意默认值。IntArray(4) 得到四个零,IntArray(4) { index -> index * 2 } 才会按位置生成值;arrayOfNulls<String>(4) 则得到可空引用数组。把 List<Int> 转成 IntArray,或把数组转成列表,都是一次显式的数据形状转换,通常要分配新容器。若接口要求的是可增长的条目表,数组反而会增加扩容与复制工作;选择它应来自固定长度或 SDK 签名,而不是"数组看起来更底层"。
3. 从通知列表到会话分组
集合类型确定后,变换链应从页面要的输出倒推。若页面需要"每个会话的未读标题",先过滤已读项,再按会话 ID 分组,最后把每组变成标题列表:
kotlin
data class Notice(val chatId: Long, val title: String, val read: Boolean)
fun unreadTitlesByChat(notices: List<Notice>): Map<Long, List<String>> =
notices
.filter { !it.read }
.groupBy { it.chatId }
.mapValues { (_, group) -> group.map { it.title } }
filter 决定保留哪些对象,map 对每项做转换,groupBy 把同键项放进列表。这里先分组再提取标题,因为先丢掉 chatId 就无从分组。map 即使映射出 null,结果长度也不会缩短;要丢掉空结果可用 mapNotNull。普通集合上的这些操作立即执行,并创建中间容器;filter 返回的新列表仍共享原来的元素对象。
若页面只要每个会话的未读数量,构造所有分组列表就多做了工作,可以在过滤后使用 groupingBy { it.chatId }.eachCount()。groupBy 适合确实需要组内元素的场景,但它不会替你对组内通知做业务排序。对有限的页面数据,清晰的链通常足够;不要仅为省一个临时列表就先把代码改成复杂状态机。
变换链还有状态更新时机的问题:若每次界面重绘都从同一批通知重新过滤、分组,工作会重复。更适合在仓库数据更新时计算展示模型,再让页面消费结果。需要排查"某条消息为何没出现"时,依次检查过滤条件、分组键、最终映射,比在一段循环里追踪多个可变容器容易。groupBy 的键也应是稳定 ID;把整个可变消息对象拿来当键,会把相等性风险重新带回来。
4. 只取前几项,与处理待办,是两种需求
本地搜索可能只要前五条匹配标题。若在完整列表上连续 filter、map、take,前面的变换已遍历并产生中间结果;转成 Sequence 后,中间步骤按需执行,收够五条便可以停止:
kotlin
fun preview(records: List<Notice>): List<String> =
records.asSequence()
.filter { !it.read }
.map { it.title.trim() }
.filter { it.isNotEmpty() }
.take(5)
.toList()
val pending = ArrayDeque<String>()
pending.addLast("下载")
pending.addLast("解析")
val next = pending.removeFirstOrNull() // 下载
pending.addFirst("紧急刷新")
val last = pending.removeLastOrNull() // 解析
toList() 是上面序列的终端操作;只建立序列不会运行过滤和转换。同一条序列若被重复遍历,转换可能重复执行,因此不要把有副作用的网络调用藏进 map。序列也不是自动更快:数据量小、只做一步转换时,普通集合操作更简单;sorted() 需要看完整输入,groupBy() 也要生成分组结果,不能把整条链当作固定内存流水线。来自可变列表的序列同样不是快照,遍历前列表被改动会影响读到的数据。
ArrayDeque 解决的是另一件事:从头或尾增删待处理项。这里使用 Kotlin 标准库类型;removeFirstOrNull() 在空队列时返回 null,比会抛异常的版本更适合可为空的待办。它可以做先进先出队列,也可以从尾部撤回;但不会自动按优先级排序,也不提供跨线程安全保证。多个生产者和消费者并行工作时,应选并发容器或协程通道,并明确取消与关闭规则。
惰性计算还会移动错误发生的时刻。建立序列时即使写了会抛异常的转换,也可能直到 toList() 才真正抛出;调试日志和异常处理应围绕终端操作安排。ArrayDeque 则没有这种求值延迟,addLast 当下就改变容器。两者放在同一个任务里并不冲突:先从来源按需挑出少量工作项,再明确地加入队列;队列中的项何时被执行,仍由消费端决定。
选容器时先看数据形状和 API 签名,再看计算时机:页面要稳定状态就交付快照,需要固定长度缓冲区就用相应数组,只取少量结果才考虑序列,双端待办才用 ArrayDeque。这些选择都比把所有数据一律写成 List 更容易守住边界。