You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Compose快照状态列表协程搜索时触发ConcurrentModificationException

问题分析与解决

为什么会抛出ConcurrentModificationException?

核心原因有两个:

  1. SnapshotStateList的线程不安全特性:你用mutableStateListOf创建的fullListOfTodos是Compose的快照状态列表,它只能在主线程访问和修改。但你直接在Dispatchers.Default(CPU后台线程)里调用indexOfFirst和访问列表元素,绕过了Compose快照机制的线程校验,导致多线程下状态不一致,触发并发修改检测。
  2. 协程取消时的状态冲突:重复点击刷新会取消旧协程,此时旧协程可能正处于两个阶段:要么在后台线程遍历列表执行indexOfFirst,要么刚切回主线程准备修改列表。旧协程的取消操作会触发快照状态校验,新协程同时操作同一列表,两者冲突就会抛出异常。

另外,indexOfFirst内部会创建列表迭代器,SnapshotStateList的迭代器会实时检查列表修改状态——迭代过程中如果列表被其他线程/协程修改,就会立刻抛出该异常。

修复方案

1. 严格遵守Compose状态线程规则,优化查找逻辑

把所有对fullListOfTodos的操作移到主线程,同时用Map缓存id到索引的映射,避免每次遍历数千条数据:

// ViewModel中维护id到索引的缓存Map
private val todoIdToIndex = mutableMapOf<Long, Int>()

// 初始化或更新Todo列表时同步缓存Map
fun updateTodoList(newList: List<TodoModel>) {
    fullListOfTodos.clear()
    fullListOfTodos.addAll(newList)
    todoIdToIndex.clear()
    newList.forEachIndexed { index, todo ->
        todoIdToIndex[todo.id] = index
    }
}

fun fetchFreshTodoItemDetails(idsToRefresh: List<Long>) {
    fetchJob?.cancel()
    fetchJob = viewModelScope.launch {
        when (val todosResponse = apiService.getTodos(idsToRefresh)) {
            is ApiResult.Success -> {
                if (todosResponse.value.isEmpty()) return@launch
                todosResponse.items.forEach { todo ->
                    // 直接从缓存取索引,无需遍历列表
                    val indexOfTodo = todoIdToIndex[todo.id] ?: return@forEach
                    val currentTodo = fullListOfTodos[indexOfTodo]
                    val updatedTodo = TodoModel(/* 更新需要修改的字段 */)
                    // 主线程安全修改列表
                    fullListOfTodos[indexOfTodo] = updatedTodo
                }
            }
            // 处理错误情况
            else -> {}
        }
    }
}

2. 协程取消时终止无效操作

在关键步骤检查协程是否活跃,避免取消后继续执行状态修改:

fun fetchFreshTodoItemDetails(idsToRefresh: List<Long>) {
    fetchJob?.cancel()
    fetchJob = viewModelScope.launch {
        when (val todosResponse = apiService.getTodos(idsToRefresh)) {
            is ApiResult.Success -> {
                if (todosResponse.value.isEmpty()) return@launch
                todosResponse.items.forEach { todo ->
                    // 检查协程是否还活跃,取消后直接跳过
                    ensureActive()
                    
                    val indexOfTodo = fullListOfTodos.indexOfFirst { it.id == todo.id }
                    if (indexOfTodo == -1) return@forEach
                    
                    val currentTodo = fullListOfTodos[indexOfTodo]
                    val updatedTodo = TodoModel(/* 更新字段 */)
                    
                    ensureActive()
                    fullListOfTodos[indexOfTodo] = updatedTodo
                }
            }
            else -> {}
        }
    }
}

3. 用线程安全容器做中间存储

如果必须在后台处理数据,先把更新后的Todo存在线程安全的容器里,再一次性同步到快照列表:

private val tempTodoMap = ConcurrentHashMap<Long, TodoModel>()

fun fetchFreshTodoItemDetails(idsToRefresh: List<Long>) {
    fetchJob?.cancel()
    fetchJob = viewModelScope.launch {
        when (val todosResponse = apiService.getTodos(idsToRefresh)) {
            is ApiResult.Success -> {
                if (todosResponse.value.isEmpty()) return@launch
                // 后台线程安全更新临时Map
                withContext(Dispatchers.Default) {
                    todosResponse.items.forEach { todo ->
                        tempTodoMap[todo.id] = todo.copy(/* 更新字段 */)
                    }
                }
                // 主线程一次性同步到快照列表
                fullListOfTodos.forEachIndexed { index, todo ->
                    tempTodoMap[todo.id]?.let { updatedTodo ->
                        fullListOfTodos[index] = updatedTodo
                    }
                }
                tempTodoMap.clear()
            }
            else -> {}
        }
    }
}

内容的提问来源于stack exchange,提问作者c_idle

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.22 22:27:20