Каков правильный способ связи между viewModel и компонуемым пользовательским интерфейсом, чтобы избежать ошибки одновременного изменения?

В моем проекте время от времени возникает ошибка "Unsupported concurrent change during composition" -> Вероятно, это вызвано одновременным обновлением объекта State из разных потоков. Я пытаюсь понять, почему это могло произойти.

На данный момент это мои знания с помощью пользователей stackoverflow @Leviathan и @chuckj.

Мой псевдокод/логика выглядит следующим образом (в конце я добавлю больше кода):

  1. У меня есть HomeState для модели, используемой в HomeViewModel, а затем я передаю данные в HomeScreen.
  2. Я определил mutableStateOf<HomeState> для хранения данных и разрешения на передачу.
ViewModel {
    val userId = mutableStateOf<Int?>(null) // I know it could be in HomeState and I will refactor it during these changes
    val homeState = mutableStateOf(HomeState(isAddressAddedFromOnboarding))
}
  1. Я также определяю FlowCallbacks для обратной связи с HomeScreen. В ViewModel я передаю значение в поток, а затем собираю его на экране.
ViewModel {
  private val _ageVerificationSuccessCallback = MutableSharedFlow<AgeVerificationEventSource>()
    val ageVerificationSuccessCallback = _ageVerificationSuccessCallback.asSharedFlow()

[...]
fun emitAge = _ageVerificationSuccessCallback.emit(value) 
}

В HomeScreen (составной)

fun HomeScreen(
    homeState: HomeState,
    onAgeVerificationSuccess: Flow<AgeVerificationEventSource>) {

  LaunchedEffect("AgeVerificationSuccess") {
        onAgeVerificationSuccess.collectLatest { source ->
     actionLogicWithSourceValue()   
}
    }

  1. Интеракторы с потоком и интеракторы с потоком и функцией приостановки. У меня есть другой тип взаимодействия, при котором обычно я просто получаю от него ценность в viewModel, а затем обновляю свой HomeState. У меня около 15 таких интеракторов. то есть
  class GetStoreOpenInfoInteractor(
) {

    suspend fun invoke(): Flow<StoreAvailability> {
        return combine(
            storeRepository.getCurrentDarkStore().distinctUntilChanged(),
            basketInteractor.invoke().distinctUntilChanged()
        )
    [...]
class GetAccountTypeInteractor(
    private val accountTypeRepository: AccountRepository
) {

    fun invoke(): Flow<AccountType> {
        return accountTypeRepository.getAccountType()
    }

Одно из предложений заключалось в том, что это плохой подход и мне следует вместо сбора использовать combine(). Итак, я попробовал, но, к сожалению, не могу объединить функции приостановки, но в любом случае я попробовал это с 10 интеракторами, которые не являются приостановкой и обратным потоком.

Это мой код, но разве я не могу просто собрать состояние обновления? Комбинировать видимо проблематично и не работает не знаю почему именно.

[...]
: ViewModel() {

    private val _homeState = MutableStateFlow(HomeState(isAddressAddedFromOnboarding))
    val homeState: StateFlow<HomeState> = _homeState.asStateFlow()

init {
viewModelScope.launch {
            combine(
                getAccountTypeInteractor.invoke(),
                getDeliveryEstimatedTimeInteractor.invoke(),
                getInventoryInteractor.invoke(),
                getSliderItemsInteractor.invoke(),
                getBestSellersProductsInteractor.invoke(),
                getSpecialCategoriesInteractor.invoke(),
                getCurrentAddressInteractor.invoke(),
                getVerificationAgeStateInteractor.invoke(),
                getIsNewDarkStoreSyncInteractor.invoke(),
                getAdditionalMenuOptionsInteractor.invoke(),
                getBasketTotalValueWithoutDiscountInteractor.invoke(),
                checkIsStoriesDataLoadedInteractor()
            ) { accountType, deliveryEstimatedTime, inventory, sliders, bestSellers, specialCategories, currentAddress, ageVerificationState, isLoading, additionalMenuOptions, basketTotalValueWithoutDiscount, storiesDataLoaded, ->


// it doesn't log anything, not even null
                Log.d("homeStateVALUES", """
        accountType: $accountType,
        deliveryEstimatedTime: $deliveryEstimatedTime,
        inventory: $inventory,
        sliders: $sliders,
        bestSellers: $bestSellers,
        specialCategories: $specialCategories,
        currentAddress: $currentAddress,
        ageVerificationState: $ageVerificationState,
        isLoading: $isLoading,
        additionalMenuOptions: $additionalMenuOptions,
        basketTotalValueWithoutDiscount: $basketTotalValueWithoutDiscount,
        storiesDataLoaded: $storiesDataLoaded
    """)

                HomeState(
                    isAddressAddedFromOnboarding = isAddressAddedFromOnboarding,
                    isLogged = accountType == AccountType.STANDARD_ACCOUNT,
                    chipTime = deliveryEstimatedTime,
                    categories = inventory.map {
                        it.copy(subCategories = emptyList())
                    },
                    sliderItems = sliders,
                    bestsellers = bestSellers,
                    specialCategories = specialCategories,
                    currentAddress = currentAddress,
                    ageVerificationState = ageVerificationState,
                    isLoading = isLoading,
                    additionalMenuOptions = additionalMenuOptions,
                    basketTotalValueWithoutDiscount = basketTotalValueWithoutDiscount,
                    storiesDataLoaded = storiesDataLoaded
                )
            }.stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(1000L),
                initialValue = _homeState.value
            )
        }
}


onTriggeredEvent~ 
is HomeEvent.OnFreeCouponAccepted -> {
                viewModelScope.launch {
                    _homeState.updateFlow {
                        copy(isAddressAddedFromOnboarding = false)
                    }
                }
            }


HomeScreen
fun HomeScreen(
    state: StateFlow<HomeState> // I'm using here viewModel.homeState
) {

    val homeState by state.collectAsStateWithLifecycle()

Чакж пишет:

Вам нужно искать записи в объекты MutableState непосредственно в @Компонуемая функция. Почти всегда это неправильный поступок. А MutableState следует изменять либо только в составе, либо только вне композиции, никогда и то и другое. Нарушение этого правила приведет к исключение одновременного изменения выше.

И я пошел искать любой код, который мог бы обеспечить композицию внутри, и я понимаю, что такой код, который у меня есть в моем HomeScreen другом компонуемом DoubleBackToLeaveApp, который использует внутренний mutableStateOf, может обеспечить рекомпозицию, поэтому я представляю ситуацию, в которой я меняю свой HomeState на в то же время в DoubleBackToLeaveAppmutableStateOf изменениях и это вызывает ошибку concurrent change во время композиции, я прав, не могли бы вы подтвердить? Но с другой стороны, это была бы ерунда: каждая другая библиотека, другой компонент компоновки с самосознанием работает и создается таким образом, так что, по моему мнению, это не может быть проблемой. В любом случае, почему compose просто не создает очередь композиций и вместо сбоя просто добавляет рекомпозицию в очередь, для меня это кажется ошибкой и недостаточной заботой о рекомпозиции.

@Composable
fun HomeScreen() {
    val scaffoldState = rememberScaffoldState()
    val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp
    val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp
    val screenHeight by remember { mutableStateOf(screenHeightDp) }
    val screenWidth by remember { mutableStateOf(screenWidthDp) }

       val scope = rememberCoroutineScope() // it's not needed in refactor during this change I will delete and use only one coroutineScope
    val focusManager = LocalFocusManager.current
    val listState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()
    [...]

@Composable
fun DoubleBackToLeaveApp(drawerState: DrawerState) {
    val doubleBackToExitPressedOnce = remember { mutableStateOf(true) }
    val backScope = rememberCoroutineScope()
    val backContext = LocalContext.current

    BackHandler(doubleBackToExitPressedOnce.value) { 
[...]

Composable используется в HomeScreen

@Composable
fun DoubleBackToLeaveApp(drawerState: DrawerState) {
    val doubleBackToExitPressedOnce = remember { mutableStateOf(true) }
    val backScope = rememberCoroutineScope()
    val backContext = LocalContext.current

Этот пример неправильный? Может ли это вызвать эту ошибку? Здесь состояние создается в компонуемом виде, и в то же время у меня есть логика viewModel с моим external state.

Должен ли я изменить его на:

@Composable
fun DoubleBackToLeaveApp(
    drawerState: DrawerState,
    doubleBackToExitPressedOnce: MutableState<Boolean>, // take care in my HomeState as other params?
    backScope: CoroutineScope
) {
    val backContext = LocalContext.current

Another example is it wrong here in your opinion? ` isAsked` is created in `@Composable` so again I should use `HomeState` and deliver it to this component, right?
@OptIn(ExperimentalComposeUiApi::class, ExperimentalPermissionsApi::class)
@Composable
fun TurnNotificationOn(
    onFinish: () -> Unit
) {
    val notificationPermissionState: PermissionState

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        notificationPermissionState = rememberPermissionState(
            Manifest.permission.POST_NOTIFICATIONS
        )
    } else {
        onFinish()
        return
    }

    val isAsked = remember { mutableStateOf(false) }

    LaunchedEffect(notificationPermissionState.status) {
        if (isAsked.value) {
            onFinish()
        }
        isAsked.value = true
    }
  1. Видите ли вы какую-либо проблему в моем коде, которая могла бы вызвать этот сбой? Если да, то напишите, потому что воспроизвести это действительно сложно и я не вижу в этом никакой проблемы. Например, с моей точки зрения, я не обновляюсь напрямую из HomeScreen бизнес-логики, которая сохраняется в mutableStateOf в ViewModel.

  2. Является ли объединение лучшим подходом, чем сбор? Это было представлено здесь Как исправить «Неподдерживаемое одновременное изменение во время композиции»?

  3. Могу ли я использовать LaunchedEffect, а затем собирать внутри этого запущенного эффекта callbackflow из модели представления?

ИМХО, да. Это не должно вызвать проблем с перекомпозицией.

LaunchedEffect запускается один раз, когда первоначальная композиция занимает место, и он защищен от повторного запуска от количество потенциальных рекомпозиций (если оно не параметризовано и не некоторые параметры изменились). Поэтому это хорошо для бега анимации, закусочная и т. д.

LaunchedEffect также является компонуемым и запускает сопрограмму. за кулисами.

помните, что CoroutineScope также предоставляет вам сопрограмму, но это не Сборный. И это по уважительной причине. В противном случае он будет работать каждая рекомпозиция независимо от того. Поэтому его можно использовать, например, внутри обратных вызовов событий (например, onClick) для запуска функций приостановки.

Вот мои изменения на данный момент:

в viewModel

private val _homeState = MutableStateFlow(HomeState(isAddressAddedFromOnboarding))
    val homeState: StateFlow<HomeState> = _homeState.stateIn(
            scope = viewModelScope,
            started = SharingStarted.Eagerly,
            initialValue = _homeState.value,
        )

в HomeScreen

    val homeStateCollector = homeStateFlow.collectAsState()
    val homeState = homeStateCollector.value

Этот пост слишком длинный и несфокусированный, чтобы на него можно было ответить. Нам не нужна история того, что вы сделали и что сказали другие. Пожалуйста, опишите только текущую проблему и удалите все остальное. Сделайте его как можно более кратким и сосредоточьтесь на одной проблеме. И убедитесь, что вы: Не используете MutableState в модели представления. Не собирайте данные в модели представления или LaunchedEffect. Не используйте блок init модели представления для чего-либо, связанного с потоком. Вы строго придерживаетесь схемы, которую я показал в ответе на другой вопрос. Этот вопрос должен быть новым, а не повторением старого вопроса.

Leviathan 31.05.2024 16:55
0
1
70
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Используйте свой собственный комбайн с hot flow, например stateIn, вероятно, когда вы используете комбайн потока, он не подписывается на него с самого начала.

    private val inventoryFlow = getInventoryInteractor.invoke()
        .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())

Остальное создайте таким же образом.

    val homeState: StateFlow<HomeState> =
        combine(
            accountTypeFlow,
            deliveryEstimatedTimeFlow,
            inventoryFlow,
[...]
    private val _homeState = MutableStateFlow(HomeState(isAddressAddedFromOnboarding))

Тогда он должен работать правильно.

Другие вопросы по теме