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

Jetpack Compose中ViewModel更新错误变量致城墙绘制异常

城图生成器城墙功能绘制问题排查

我正在为D&D/TRPG开发一款城图生成器应用,核心功能是让用户点击创建城市区域,当前重点开发城墙功能:用户点击标记城墙顶点,完成操作后生成完整城墙。技术栈采用Kotlin/Jetpack Compose的Canvas组件+ViewModel实现,流程为:用户点击顶点,坐标传入ViewModel存入列表,Canvas实时绘制该列表;当用户再次点击第一个顶点时,将列表传入CityWall对象并清空列表,随后绘制最终的城墙。

目前遇到两个核心问题:

  • 进行中的城墙顶点列表无法实时绘制,仅在CityWall创建完成后才会显示;
  • 创建新墙时,会逐点重新绘制已完成的旧城墙。
    我已添加日志但无法理解:为何点击时更新的是CityWall,而非应该接收点击的进行中墙列表。

Activity代码

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            WallTestTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    val wallViewModel: WallViewModel = viewModel()
                    val wallUIState by wallViewModel.uiState.collectAsState()
                    MainScreen(
                        wallViewModel = wallViewModel,
                        inProgressWall = wallUIState.inProgressWall,
                        cityWall = wallUIState.cityWall
                    )
                }
            }
        }
    }
}//end Main Activity

@Composable
fun MainScreen(
    wallViewModel: WallViewModel,
    inProgressWall: MutableList<Offset>,
    cityWall: CityWall
){

    var clickMode: ClickMode by remember { mutableStateOf(ClickMode.AWAITINGFIRSTWALLCLICK) }

    fun doClick(clickedPoint: Offset) {
        Log.d("CLICK", "at $clickedPoint")
        when (clickMode){
            ClickMode.AWAITINGFIRSTWALLCLICK -> {
                Log.d("FIRST WALL", "in progress ${inProgressWall.toString()}")
                wallViewModel.startWall(clickedPoint)
                clickMode = ClickMode.AWAITINGWALLCLICK
            }
            ClickMode.AWAITINGWALLCLICK -> {
                if ((clickedPoint - inProgressWall[0]).magnitude() < 50) {
                    Log.d("CLICK CLOSE", "$clickedPoint ${inProgressWall[0]}")
                    wallViewModel.finaliseWall()
                    clickMode = ClickMode.AWAITINGFIRSTWALLCLICK
                } else {
                    Log.d("WALL CLICK", "in progress ${inProgressWall.toString()}")
                    wallViewModel.continueWall(clickedPoint)
                    clickMode = ClickMode.AWAITINGWALLCLICK
                }
            }
            else -> {}
        }
    }

    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .padding(10.dp)
            .background(color = Color.LightGray)
            .pointerInput(Unit) {
                detectTapGestures(
                    onTap = { doClick(it) }
                )
            }
    ){
        fun drawInProgressWall(wall: MutableList<Offset>) {
            for (towerIndex in wall.indices){
                drawCircle(color = Color.Red, radius = 30f, center = wall[towerIndex])
                drawLine(
                    Color.Cyan,
                    start = wall[towerIndex],
                    end = wall[(towerIndex + 1) % wall.size],
                    strokeWidth = 5f
                )
            }
        }

        fun drawWall(wall: CityWall) {
            Log.d("DRAW WALL", wall.vertexes.toString())
            for (towerIndex in wall.vertexes.indices){
                drawCircle(color = Color.Black, radius = 30f, center = wall.vertexes[towerIndex])
                drawLine(
                    Color.Black,
                    start = wall.vertexes[towerIndex],
                    end = wall.vertexes[(towerIndex + 1) % wall.vertexes.size],
                    strokeWidth = 20f
                )
            }
        }

        drawWall(cityWall)
        drawInProgressWall(inProgressWall)
    }//end canvas

    Box(modifier = Modifier.fillMaxSize()){
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .align(Alignment.BottomCenter),
            horizontalArrangement = Arrangement.SpaceEvenly
        ){
            Text(text = clickMode.toString())
        }
    }
}//end Main Screen

fun Offset.magnitude(): Float {
    return sqrt((this.x * this.x) + (this.y * this.y))
}

ViewModel代码

class WallViewModel : ViewModel() {

    var inProgressWall: MutableList<Offset> = mutableListOf()
    var cityWall = CityWall(vertexes = mutableListOf())

    private val _uiState = MutableStateFlow(
        WallUiState(
            inProgressWall = inProgressWall,
            cityWall = cityWall
        )
    )
    val uiState: StateFlow<WallUiState> = _uiState.asStateFlow()

    fun updateState() {
        Log.d("UPDATE", "InProgress is $inProgressWall")
        Log.d("UPDATE", "wall is ${cityWall.vertexes}")
        _uiState.update { it ->
            it.copy(
                inProgressWall = inProgressWall,
                cityWall = cityWall
            )
        }
    }

    fun startWall(clicked: Offset) {
        Log.d("START WALL", "at $clicked")
        inProgressWall.clear()
        inProgressWall.add(clicked)
        updateState()
    }//end Start Wall

    fun continueWall(clicked: Offset) {
        Log.d("CONTINUE WALL", "at $clicked")
        inProgressWall.add(clicked)
        updateState()
    }

    fun finaliseWall() {
        Log.d("FINISH WALL", "END")
        cityWall.vertexes = inProgressWall
        Log.d("NEW WALL BEFORE CLEAR", cityWall.vertexes.toString())
        inProgressWall.clear()
        Log.d("NEW WALL", cityWall.vertexes.toString())
        updateState()
        return
    }
}//end view model

data class WallUiState(
    val inProgressWall: MutableList<Offset>,
    val cityWall: CityWall
)

问题根源分析

  1. MutableList引用未更新导致Compose不重组:ViewModel中的inProgressWall是MutableList,调用clear()/add()时仅修改列表内容,列表引用未变。StateFlow的update方法传入的是同一个列表引用,Compose的collectAsState()无法感知内容变化,因此Canvas不会触发重绘,导致进行中的墙无法实时显示。
  2. 列表引用共享导致数据混乱:finaliseWall()中直接将cityWall.vertexes = inProgressWall,让两者指向同一个MutableList实例。后续inProgressWall.clear()会同时清空cityWall.vertexes,而创建新墙时往inProgressWall添加数据,cityWall.vertexes也会同步变化,这就是旧城墙被逐点绘制的原因。

解决方案

  1. 改用不可变列表,每次更新创建新实例:将inProgressWall改为List<Offset>,每次更新时生成新列表,确保StateFlow能感知到变化触发重组。
  2. 复制列表内容而非传递引用:finaliseWall()中复制inProgressWall的内容到cityWall,避免两者共享引用。
  3. 将CityWall的vertexes设为不可变:防止后续意外修改已完成的城墙数据。

修改后的ViewModel示例:

class WallViewModel : ViewModel() {

    private var inProgressWall: List<Offset> = emptyList()
    private var cityWall = CityWall(vertexes = emptyList())

    private val _uiState = MutableStateFlow(
        WallUiState(
            inProgressWall = inProgressWall,
            cityWall = cityWall
        )
    )
    val uiState: StateFlow<WallUiState> = _uiState.asStateFlow()

    private fun updateState() {
        _uiState.update {
            it.copy(
                inProgressWall = inProgressWall,
                cityWall = cityWall
            )
        }
    }

    fun startWall(clicked: Offset) {
        inProgressWall = listOf(clicked)
        updateState()
    }

    fun continueWall(clicked: Offset) {
        inProgressWall = inProgressWall + clicked
        updateState()
    }

    fun finaliseWall() {
        cityWall = cityWall.copy(vertexes = inProgressWall.toMutableList())
        inProgressWall = emptyList()
        updateState()
    }
}

data class WallUiState(
    val inProgressWall: List<Offset>,
    val cityWall: CityWall
)

data class CityWall(val vertexes: List<Offset>)

同时需要将MainScreen中drawInProgressWall的参数改为List<Offset>,适配不可变列表的变化。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.22 17:04:55