Jetpack Compose: как правильно принудительно перекомпоновать, когда объект mutableStateOf изменяется из viewModel?

Вид:

val viewModel = hiltViewModel<ActivityViewModel>()

Text("STATE: ${viewModel.state.activity?.invitation?.state?.title}")

Модель просмотра:

@HiltViewModel
class ActivityViewModel @Inject constructor(
    private val repository: ActivityRepository,
    @ApplicationContext private val context: Context,
) : ViewModel() {
    var state by mutableStateOf(ActivityScreenState())
        private set

    suspend fun fetchActivity(id: String) {
        val resource = repository.fetchActivity(id)
        val activity = resource.data

        resource.errorMessage?.let {
            Toast.makeText(context, it, Toast.LENGTH_SHORT).show()
        }

        state = state.copy(
            isLoading = false,
            activity = activity,
        )
    }

    suspend fun accept(invitation: Invitation) {
        val tempActivity = state.activity
        tempActivity?.invitation?.state = InvitationState.ACCEPTED

        state = state.copy(
            activity = tempActivity,
        )
    }
}

Активитскринстате:

data class ActivityScreenState(
    val isLoading: Boolean = true,
    val activity: Activity? = null,
)

data class Activity(
    val id: String,
    val invitation: Invitation?,
)

data class Invitation(
    val id: String,
    var state: InvitationState,
)

enum class InvitationState(val title: String) {
    ACCEPTED("accepted"),
    DECLINED("declined"),
}

У меня есть класс данных viewModel, ActivityScreenState, который содержит класс данных Activity. Когда я обновляю Activity внутри ActivityScreenState, оно не перекомпоновывает мое составное представление, но я знаю, что оно изменится, если я запишу его в журнал.

Я пробовал искать, но не нашел, что делаю не так. Также выяснилось, что он перекомпонуется только в том случае, если я аннулирую активность внутри ActivityScreenState.

Я делаю что-то не так или это баг?

Прежде всего, под "перекомпилировать" вы подразумеваете "перекомпоновать", так что исправьте вопрос. И присваиваете ли вы activity ненулевое значение в любой точке кода?

dev.tejasb 03.07.2024 06:48

@dev.tejasb спасибо, обновил вопрос. Да, я сначала получаю активность из API, а затем пытаюсь обновить ее после того, как пользователи нажимают кнопку. См. обновленный фрагмент кода модели представления.

kironet 03.07.2024 06:52

можешь попробовать val state by remember { viewModel.state } Text("STATE: ${state.activity?.inspectionInvitation?.state?.title ?: "Unknown"}")

dev.tejasb 03.07.2024 07:10

@dev.tejasb Я получаю выделенную ошибку Type 'TypeVariable(T)' has no method 'getValue(Nothing?, KProperty<*>)' and thus it cannot serve as a delegate

kironet 03.07.2024 07:27

замените by на =

dev.tejasb 03.07.2024 07:33

@dev.tejasb спасибо, ошибку избавили. Но теперь он вообще не перекомпоновывает представление (даже при первой выборке данных).

kironet 03.07.2024 07:44

можешь попробовать MutableStateFlow вместо mutableStateOf и использовать .collectAsState() в своей композиции?

dev.tejasb 03.07.2024 07:47

Та же проблема, не работает даже при первом получении данных. var state = MutableStateFlow(InspectionActivityScreenState()) val state = viewModel.state.collectAsState() Text("STATE: ${state.value.activity?.inspectionInvitation?.state?.title ?: "Unknown"}")

kironet 03.07.2024 07:54

когда вы используете MutableStateFlow, вам нужно заменить state = state.copy(isLoading = false, activity = activity) на state.update {it.copy(isLoading = false,activity = activity)}

dev.tejasb 03.07.2024 08:10

спасибо, да, теперь он работает при первой загрузке, но не перекомпоновывается после обновления state.activity (исходная проблема).

kironet 03.07.2024 08:24

Я заметил, что это не работает только при обновлении активности. Если я обновляю что-нибудь еще внутри ActivityScreenState, это работает, и представление перестраивается. Может быть, Jetpack Compose не знает, как перекомпоновать вложенные объекты?

kironet 03.07.2024 09:50

Я взял на себя смелость обновить ваш код, чтобы он скомпилировался. Убедитесь, что это именно то, что вы хотите задать, и внесите необходимые изменения.

Leviathan 03.07.2024 10:07
1
12
209
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

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

Причина в том, что класс данных использует equals и hashCode для структурного равенства == и с

@Suppress("UNCHECKED_CAST")
fun <T> structuralEqualityPolicy(): SnapshotMutationPolicy<T> =
    StructuralEqualityPolicy as SnapshotMutationPolicy<T>

private object StructuralEqualityPolicy : SnapshotMutationPolicy<Any?> {
    override fun equivalent(a: Any?, b: Any?) = a == b

    override fun toString() = "StructuralEqualityPolicy"
}

@StateFactoryMarker
fun <T> mutableStateOf(
    value: T,
    policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)

Вы не обновляете параметры ActivityScreenState

suspend fun accept(invitation: Invitation) {
    val tempActivity = state.activity
    tempActivity?.invitation?.state = InvitationState.ACCEPTED

    state = state.copy(
        activity = tempActivity
    )
}

вы не меняете никаких свойств конструктора

data class ActivityScreenState(
    val isLoading: Boolean = true,
    val activity: Activity? = null,
)

вы фактически меняете параметр Activity, в то время как экземпляр остается прежним.

у вас должен быть новый activity, пока вы устанавливаете тот же activity после изменения state = InvitationState.ACCEPTED, или вы можете использовать referentialEqualityPolicy(), который запускает рекомпозицию при назначении нового объекта.

suspend fun accept(invitation: Invitation) {
    val newActivity = state.activity.copy(activity = ...new instance here with copy or creating new Activity instance with new invitation)

    state = state.copy(
        activity = newActivity
    )
}

В общем, если вы хотите принудительно обновить с тем же значением или другой ссылкой, вы можете изменить snapshotMutationPolicies.

Например

@Preview
@Composable
fun ForceRecpompositionSample() {
    Column {
        Composable1()
        Composable2()
    }
}

@Composable
fun Composable1() {
    var myCounter by remember {
        mutableStateOf(MyCounter(0))
    }

    Column(
        modifier = Modifier.border(2.dp, getRandomColor()).fillMaxWidth().padding(8.dp)
    ) {
        Button(
            onClick = {
                myCounter = myCounter.copy(value = 5)
            }
        ) {
            Text("Update MyCounter")

        }
        Text("Value: ${myCounter.value}")
    }
}

@Composable
fun Composable2() {
    var myCounter by remember {
        mutableStateOf(
            value = MyCounter(0),
            policy = referentialEqualityPolicy()
        )
    }

    Column(
        modifier = Modifier.border(2.dp, getRandomColor()).fillMaxWidth().padding(8.dp)
    ) {
        Button(
            onClick = {
                myCounter = myCounter.copy(value = 5)
            }
        ) {
            Text("Update MyCounter")

        }
        Text("Value: ${myCounter.value}")
    }
}

data class MyCounter(val value: Int)

если вы отметите второй компонуемый объект, вы увидите, что вы запускаете рекомпозицию, даже установив то же значение MyCounter, в то время как значение по умолчанию не используется в Composable1.

getRandom color — это функция, которая визуально возвращает новый цвет при рекомпозиции наблюдателя.

fun getRandomColor() =  Color(
    red = Random.nextInt(256),
    green = Random.nextInt(256),
    blue = Random.nextInt(256),
    alpha = 255
)

Вы даже можете принудительно выполнить рекомпозицию со значениями Int или String, если измените политику никогдаEquals, например

@Preview
@Composable
fun ForceRecompositionSample2() {
    var counter by remember {
        mutableStateOf(
            value = 0,
            policy = neverEqualPolicy()
        )
    }

    Column(
        modifier = Modifier.border(2.dp, getRandomColor()).fillMaxWidth().padding(8.dp)
    ) {
        Button(
            onClick = {
                counter = 5
            }
        ) {
            Text("Update MyCounter")

        }
        Text("Value: ${counter}")
    }
}

Если вы хотите использовать маршрут класса данных, вы можете обновить предыдущий пример как

data class MyCounter(
    val value: Int,
    val innerCounter: InnerCounter = InnerCounter()
)

data class InnerCounter(var value: Int = 0)

И обновить InnerCounter и запустить проверку рекомпозиции Composable2

@Composable
fun Composable1() {
    var myCounter by remember {
        mutableStateOf(MyCounter(0))
    }

    Column(
        modifier = Modifier.border(2.dp, getRandomColor()).fillMaxWidth().padding(8.dp)
    ) {

        Button(
            onClick = {
                val innerCounter = myCounter.innerCounter
                val newValue = innerCounter.value + 1
                innerCounter.value = newValue
                myCounter = myCounter.copy()
            }
        ) {
            Text("Update MyCounter")

        }
        Text("Value: ${myCounter.value}")
    }
}

@Composable
fun Composable2() {
    var myCounter by remember {
        mutableStateOf(
            value = MyCounter(0)
        )
    }

    Column(
        modifier = Modifier.border(2.dp, getRandomColor()).fillMaxWidth().padding(8.dp)
    ) {
        Button(
            onClick = {
                val innerCounter = myCounter.innerCounter
                val newValue = innerCounter.value + 1
                myCounter =
                    myCounter.copy(innerCounter = myCounter.innerCounter.copy(value = newValue))
            }
        ) {
            Text("Update MyCounter")

        }
        Text("Value: ${myCounter.value}")
    }
}

Если вы имеете дело с классами данных, убедитесь, что параметры основного конструктора изменились в соответствии с политикой по умолчанию. Если вы хотите запускать одноразовые события, вы можете перейти к классу, который не имеет равных и реализации хэш-кода, или использовать другие предопределенные или свои собственные SnapshotMutationPolicy

Thracian 03.07.2024 10:03

Спасибо! referentialEqualityPolicy() сделал свое дело! Не знал, что это такое. Также спасибо за хорошее объяснение.

kironet 03.07.2024 10:26

Пожалуйста. Другой тоже должен работать. Позвольте мне показать на другом примере. Если вы измените параметры конструктора класса данных, как во втором, вы сможете запустить рекомпозицию.

Thracian 03.07.2024 10:26

Другой тоже работает. Спасибо

kironet 03.07.2024 10:31

Есть ли какие-либо недостатки в использовании referentialEqualityPolicy? Я имею в виду, за исключением того, что он срабатывает даже без изменения значения (что не должно происходить «случайно»)

kironet 03.07.2024 10:34

нет, недостатков нет. Я использую их для разовых мероприятий. override fun equivalent(a: Any?, b: Any?) = a === b — проверка рекомендаций. Хотя это зависит от вашей реализации. Если вы не хотите запускать рекомпозицию каждый раз при копировании, используйте второй маршрут. Вы можете проверить это руководство для получения дополнительных ссылок. github.com/SmartToolFactory/Jetpack-Compose-Tutorials/blob/…

Thracian 03.07.2024 10:36

По сути, они говорят, когда и с изменением того, что вы хотите, инициировать рекомпозицию. Например, политика NeverEqual возвращает false, и поэтому любое значение, независимо от того, что вызывает рекомпозицию. Вы также можете реализовать свою с некоторой логикой, если хотите

Thracian 03.07.2024 10:38

Отлично! Большое спасибо @Thracian

J.K 03.07.2024 11:46

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