【问题标题】:What would be the best way to handle state in compose avoiding recomposition using a view model as a state handle?在使用视图模型作为状态句柄避免重组时,处理状态的最佳方法是什么?
【发布时间】:2023-01-16 05:28:49
【问题描述】:

我正在构建一个 android 应用程序来计算用户的体重指数 (BMI):

我不确定使用视图模型作为状态持有者来处理状态的最佳方式是什么。为了让你帮助我,我会尽可能多地描述我的问题,从文件开始:

HomeUiState.kt 网站

负责存储UI状态。

data class HomeUiState(
    val weight: String,
    val height: String,
    val currentBmiCalculated: String,
)

HomeViewModel.kt

负责控制 UI 状态并处理可组合的操作,例如敬酒:

class HomeViewModel(
    private val firstState: HomeUiState,
    private val stringToFloatConvertor: StringToFloatConvertorUseCase,
    private val calculateBmi: CalculateBmiUseCase,
) : ViewModel() {

    companion object {
        private const val FIRST_VALUE = ""

        val Factory: ViewModelProvider.Factory = viewModelFactory {
            initializer {
                HomeViewModel(
                    stringToFloatConvertor = StringToFloatConvertorUseCaseImpl(),
                    calculateBmi = CalculateImcUseCaseImpl(),
                    firstState = HomeUiState(
                        weight = FIRST_VALUE,
                        height = FIRST_VALUE,
                        currentBmiCalculated = "Not calculated yet",
                    )
                )
            }
        }
    }

    private val _uiState: MutableStateFlow<HomeUiState> = MutableStateFlow(firstState)
    private val _uiAction = MutableSharedFlow<HomeUiAction>()

    val uiState = _uiState.asStateFlow()
    val uiAction = _uiAction.asSharedFlow()

    fun dispatchUiEvent(uiEvent: HomeUiEvent) {
        when (uiEvent) {
            is HomeUiEvent.OnEnterHeightValue -> _uiState.update { it.copy(height = uiEvent.value) }
            is HomeUiEvent.OnEnterWeightValue -> _uiState.update { it.copy(weight = uiEvent.value) }
            is HomeUiEvent.OnCalculateButtonClick -> onCalculateButtonClick()
            is HomeUiEvent.OnClearButtonClick -> {
                _uiState.update { firstState }
                emitAction(HomeUiAction.MoveCursorToHeight)
            }
            is HomeUiEvent.OnProfileIconClick -> emitAction(HomeUiAction.NavigateToProfileScreen)
        }
    }

    private fun onCalculateButtonClick() {
        stringToFloatConvertor(uiState.value.weight)?.let { weight ->
            stringToFloatConvertor(uiState.value.height)?.let { height ->
                _uiState.update {
                    it.copy(currentImcCalculated = "${calculateBmi(weight, height)}")
                }
            } ?: showErrorToast(R.string.invalid_height_value)
        } ?: showErrorToast(R.string.invalid_weight_value)
        emitAction(HomeUiAction.HideKeyboard)
    }

    private fun showErrorToast(@StringRes message: Int) =
        emitAction(HomeUiAction.ShowErrorToast(message))

    private fun emitAction(action: HomeUiAction) {
        viewModelScope.launch {
            _uiAction.emit(action)
        }
    }
}

主屏幕.kt

所以在这里我有我的不同之处,因为我不确定我是否应该将我的视图模型完全传输给HomeContent,或者我是否应该根据documentation itself of not transmitting the view model to the descendant functions 的建议进行传输。但在我看来,当将 ui 状态分解为 HomeContent 的几个参数时,仅更改 1 个参数时,所有 HomeContent 组件是否都会受到影响? 只是为了澄清我正在考虑的选项:

选项 1 - 仅将必要的传递给 HomeContent:

这样,通过将 UI 状态分解为多个参数,如果 1 个参数发生变化,整个函数就会重新组合,对吗?

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun HomeScreen(
    navController: NavController,
    viewModel: HomeViewModel,
) {
    val context = LocalContext.current
    val focusRequester = remember { FocusRequester.Default }
    val keyboardController = LocalSoftwareKeyboardController.current
    val uiState by viewModel.uiState.collectAsState()

    LaunchedEffect(key1 = Unit) {
        viewModel.uiAction.collectLatest { action ->
            when (action) {
                is HomeUiAction.ShowErrorToast -> Toast
                    .makeText(context, context.getText(action.messageId), Toast.LENGTH_SHORT)
                    .show()
                is HomeUiAction.MoveCursorToHeight -> focusRequester.requestFocus()
                is HomeUiAction.NavigateToProfileScreen -> navController.navigate("profile")
                is HomeUiAction.HideKeyboard -> keyboardController?.hide()
            }
        }
    }

    Scaffold(
        topBar = {
            HomeAppBar(
                onProfileClick = { viewModel.dispatchUiEvent(HomeUiEvent.OnProfileIconClick) }
            )
        }
    ) {
        HomeContent(
            height = uiState.height,
            weight = uiState.weight,
            onHeightChange = {
                viewModel.dispatchUiEvent(HomeUiEvent.OnEnterHeightValue(it))
            },
            onWeightChange = {
                viewModel.dispatchUiEvent(HomeUiEvent.OnEnterWeightValue(it))
            },
            onClear = {
                viewModel.dispatchUiEvent(HomeUiEvent.OnClearButtonClick)
            },
            onCalculate = {
                viewModel.dispatchUiEvent(HomeUiEvent.OnCalculateButtonClick)
            },
            focusRequester = focusRequester,
            bmiResult = uiState.currentImcCalculated
        )
    }
}

@Composable
private fun HomeContent(
    height: String,
    weight: String,
    bmiResult: String,
    onHeightChange: (String) -> Unit,
    onWeightChange: (String) -> Unit,
    onClear: () -> Unit,
    onCalculate: () -> Unit,
    focusRequester: FocusRequester,
) {
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "BMI Calculator",
            style = TextStyle(
                color = Color.Black,
                fontWeight = FontWeight.Bold,
                fontSize = 24.sp
            )
        )
        Spacer(modifier = Modifier.height(32.dp))
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp)
        ) {
            BmiEditText(
                value = height,
                label = "Height (m)",
                onValueChange = onHeightChange,
                modifier = Modifier
                    .fillMaxWidth(2 / 5f)
                    .focusRequester(focusRequester)
            )
            Spacer(modifier = Modifier.fillMaxWidth(1 / 3f))
            BmiEditText(
                value = weight,
                label = "Weight (kg)",
                onValueChange = onWeightChange,
                modifier = Modifier.fillMaxWidth()
            )
        }
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = onCalculate) {
            Text(text = "Calculate")
        }
        Button(onClick = onClear) {
            Text(text = "Clear")
        }
        Spacer(modifier = Modifier.height(16.dp))
        Text(text = "BMI result: $bmiResult")
    }
}

选项 2 - 将整个视图模型传递给 HomeContent

这样,由于视图模型实例保持不变,函数将不再被重构,对吧?

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun HomeScreen(
    navController: NavController,
    viewModel: HomeViewModel,
) {
    val context = LocalContext.current
    val focusRequester = remember { FocusRequester.Default }
    val keyboardController = LocalSoftwareKeyboardController.current

    LaunchedEffect(key1 = Unit) {
        viewModel.uiAction.collectLatest { action ->
            when (action) {
                is HomeUiAction.ShowErrorToast -> Toast
                    .makeText(context, context.getText(action.messageId), Toast.LENGTH_SHORT)
                    .show()
                is HomeUiAction.MoveCursorToHeight -> focusRequester.requestFocus()
                is HomeUiAction.NavigateToProfileScreen -> navController.navigate("profile")
                is HomeUiAction.HideKeyboard -> keyboardController?.hide()
            }
        }
    }

    Scaffold(
        topBar = {
            HomeAppBar(
                onProfileClick = { viewModel.dispatchUiEvent(HomeUiEvent.OnProfileIconClick) }
            )
        }
    ) {
        HomeContent(
            viewModel = viewModel,
            focusRequester = focusRequester,
        )
    }
}

@Composable
private fun HomeContent(
    viewModel: HomeViewModel,
    focusRequester: FocusRequester,
) {
    val uiState by viewModel.uiState.collectAsState()
    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "BMI Calculator",
            style = TextStyle(
                color = Color.Black,
                fontWeight = FontWeight.Bold,
                fontSize = 24.sp
            )
        )
        Spacer(modifier = Modifier.height(32.dp))
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp)
        ) {
            BmiEditText(
                value = uiState.height,
                label = "Height (m)",
                onValueChange = {
                    viewModel.dispatchUiEvent(HomeUiEvent.OnEnterHeightValue(it))
                },
                modifier = Modifier
                    .fillMaxWidth(2 / 5f)
                    .focusRequester(focusRequester)
            )
            Spacer(modifier = Modifier.fillMaxWidth(1 / 3f))
            BmiEditText(
                value = uiState.weight,
                label = "Weight (kg)",
                onValueChange = {
                    viewModel.dispatchUiEvent(HomeUiEvent.OnEnterWeightValue(it))
                },
                modifier = Modifier.fillMaxWidth()
            )
        }
        Spacer(modifier = Modifier.height(16.dp))
        Button(
            onClick = {
                viewModel.dispatchUiEvent(HomeUiEvent.OnCalculateButtonClick)
            }
        ) {
            Text(text = "Calculate")
        }
        Button(
            onClick = {
                viewModel.dispatchUiEvent(HomeUiEvent.OnClearButtonClick)
            }
        ) {
            Text(text = "Clear")
        }
        Spacer(modifier = Modifier.height(16.dp))
        Text(text = "BMI result: ${uiState.currentBmiCalculated}")
    }
}

问题

考虑到这些选项,遵循良好的社区实践,这两者中哪一个最能避免不必要的重组?

【问题讨论】:

  • 我会推荐 - “只将必要的传递给 HomeContent”。
  • 但在这种情况下,HomeContent 不会在 1 个参数更改时完全不必要地重组自身吗? @Abhimanyu
  • 如果屏幕托管在 Activity/Fragment 内,则您只能从 Activity/Fragment 传递 uiState 并监听 Activity/Fragment 内的 uistate (collectAsState) 的变化,如果您使用组合导航,只需将 uiState 发送到屏幕和导航 NavHost 的可组合 {} 块内仅传递 uiState

标签: android kotlin android-jetpack-compose


【解决方案1】:

选项 1 是您应该使用的选项。因为您永远不应该将 ViewModel 传递下去。原因是 viewModels 被 compose 编译器标记为 unstable,它导致了可组合函数 non-skippable。这样做的问题是,即使您传递的参数未更改,也不会跳过重组,并且您的 UI 元素将被重新绘制。

对于选项 1,如果 1 个参数发生变化,它将被重组,这是正确的,但由于 Compose 可以智能地仅重组发生变化的组件,因此您不会有问题。

有关详细信息,您可以查看以下资源。

https://twitter.github.io/compose-rules/rules/

https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8

【讨论】:

    猜你喜欢
    • 2018-07-03
    • 1970-01-01
    • 2015-04-28
    • 1970-01-01
    • 2023-04-05
    • 2021-09-09
    • 2017-04-04
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多