Какое бы Android-приложение мы ни собирались создать, нам в любом случае нужно будет управлять состоянием, поэтому понимание того, как лучше всего это делать, является очень важным. К счастью, существует Jetpack Compose, который предлагает нам простые и интуитивно понятные способы управления состоянием наших приложений.

Jetpack Compose является декларативным, вследствие чего единственный способ обновить composable — это вызвать или пересоздать тот же composable с новыми аргументами. Аргументы, которые мы передаем composable, зачастую представляют собой состояние, которое мы хотим показать пользователю. Поэтому всякий раз, когда состояние изменяется, мы просто перерисовываем наш composable с новыми аргументами.

Jetpack Compose реализует управление состоянием посредством двух API: mutableStateOf и Remember.

Прежде чем мы углубимся в детали каждого из этих API, нам нужно оговорить и запомнить 3 термина:

  1. Композиция (Composition): описание пользовательского интерфейса, построенного Jetpack Compose, созданного путем выполнения composable-функций.

  2. Изначальная композиция (Initial Composition): первоначальное создание композиции, т.е. когда Jetpack Compose выполняет composable-функции в первый раз.

  3. Перекомпозиция (Re-composition): повторный запуск или выполнение composabl’ов с целью обновления композиции.

Утвердив эти термины, мы можем продолжить.

Remember

Мы можем сохранять элементы в памяти в Jetpack Compose с помощью composable remember. Когда мы используем его для сохранения значения, это значение загружается в память при первом создании composable.

Всякий раз, когда composable пересоздается, значение, хранящееся в памяти, возвращается в этот composable.

MutableStateOf

API mutableStateOf создает observable интерфейс MutableState<T>, который содержит значение.

Любое изменение этого значения запланирует перекомпозицию каждого composable, который считывает это значение.

Комбинация этих двух API позволяет нам эффективно управлять состоянием наших composabl’ов. Давайте рассмотрим простой пример работы с этими API на практике.

При создании поля для ввода с помощью jetpack compose по умолчанию оно не будет автоматически обновляться, когда пользователь вводит текст. Чтобы реализовать это, нам нужно вручную управлять состоянием ввода, обновляя его значение всякий раз, когда пользователь вводит текст, и мы можем сделать этого, используя комбинацию mutableStateOf и remember:

@Composable
fun InputGreeting() {
    val text = remember { mutableStateOf("")}
    OutlinedTextField(
        value = text.value,
        onValueChange = {
            text.value = it
        }
    )
    Text(text = "Hello ${text.value}")
}

В приведенном выше фрагменте кода мы создали переменную состояния под названием text. Мы указываем, что это изменяемая (mutable) переменная состояния, и мы также хотим ее запомнить (remember).

Таким образом наша изменяемая переменная состояния будет храниться в памяти composable и будет возвращаться ему всякий раз, когда происходит перекомпозиция.

Мы устанавливаем значение нашего OutlinedTextField равным значению внутри переменной состояния, и тем самым мы и считываем актуальное значение из нашей переменной состояния и будем уведомлены всякий раз, когда переменная состояния изменяется.

Всякий раз, когда пользователь вводит текст в текстовое поле, тригерится функция onValueChange, которая обновляет значение нашей переменной состояния. Когда значение в переменной состояния обновляется, оно запускает перекомпозицию любого composable, который берет значение из этой переменной состояния.

И наконец, мы выводим на экран все, что пользователь вводит в поле ввода, в качестве приветствия.

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

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

Примечания к управлению состоянием

Когда composable использует remember, чтобы сохранить значение, для этого composable создается внутреннее состояние (internal state), и это делает composable stateful (т.е. с фиксацией состояния). Хотя это может быть удобно, composabl’ы с внутренними состояниями, как правило, в меньшей степени реюзабельны и их сложнее тестировать.

Но есть и другой подход, который заключается в том, чтобы делать composable stateless (т.е. без фиксации состояния) и просто передавать любое состояние, которое ему нужно отобразить, в качестве аргумента. Самый простой способ реализовать это — “поднятие” состояния (state hoisting).

Поднятие состояния

Это шаблон, в рамках которого мы создаем два composable: внешний composable и внутренний composable. Мы помещаем состояние во внешний composable, который берет на себя управление состоянием и передает состояние внутреннему composable в качестве аргумента. Таким образом, внешний composable может разделять состояние между несколькими composabl’ами. В дополнение к этому, внутренний composable также может быть более реюзабельным.

Давайте рассмотрим еще один пример:

@Composable
fun GreetingContainer() {
    val text = remember { mutableStateOf("") }
    InputGreeting(
        value = text.value,
        onValueChange = {
            text.value = it
        }
    )
    Question(value = text.value)
}
@Composable
fun InputGreeting(
    value: String,
    onValueChange: (String) -> Unit,
) {

    OutlinedTextField(
        value = value,
        onValueChange = onValueChange
    )
    Text(text = "Hello $value")
}
@Composable
fun Question(value: String) {
    Text(text = "How are you today $value ?")
}

В приведенном выше примере мы переместили наше состояние из composable InputGreeting в composable GreetingContainer.

Теперь GreetingContainer занимается управлением состояния и передает требуемое состояние в InputGreeting в качестве аргумента. InputGreeting получает значение, а также функцию onValueChange для триггера обновления состояния.

Наш GreetingContainer также находится в том же состоянии, что и composable Question.

Как мы видим, использование шаблона поднятия состояния позволяет нам управлять состоянием различных composable из одного центра, разделять состояние между несколькими composable, а также обеспечивает правильную инкапсуляцию. Наши composable Question и InputGreeting отделены от того, как реализовано управление состоянием, и если мы вносим изменения в GreetingContainer, нам не нужно вносить изменения ни в один из внутренних composable, при условии, что мы передаем правильные аргументы.

Использование подъема состояния реализует шаблон однонаправленного потока данных. В этом шаблоне состояние передается от GreetingContainer к внутренним composable Question и InputGreeting, а события передаются от InputGreeting к GreetingContainer

Держатели состояния

В то время как внутреннее управление состоянием и поднятие состояния могут помочь нам эффективно управлять состоянием в пределах небольшого количества composabl’ов, оно может стать непосильным, когда мы имеем дело с большим количеством composabl’ов, большими данными состояния и сложной логикой. В таких случаях целесообразно задействовать держателей состояния (state holders).

Держатель состояния — это класс или файл, который управляет логикой и состоянием composabl’ов, владеет состоянием элементов пользовательского интерфейса и его логикой. Когда composable содержит сложную логику пользовательского интерфейса, которая охватывает состояние нескольких composabl’ов, лучше всего использовать держатели состояния.

Рассмотрим пример:

class ApplicationState(initialName: String) {
    private val rejectedNames: List<String> = listOf("Name1", "Name2", "Name3", "Name4")
    var name by mutableStateOf(initialName)
    val shouldDisplayName: Boolean
        get() = !rejectedNames.contains(name)
    val greeting: String
        get() = if (rejectedNames.contains(name)) {
            "You are not welcome your name is not allowed"
        } else {
            if (name.isNotEmpty()) {
                "Welcome $name"
            } else {
                ""
            }
        }

    fun updateName(newName: String) {
        name = newName
    }
}

@Composable
fun rememberApplicationState(initialName: String): ApplicationState = remember {
    ApplicationState(initialName = initialName)
}

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

Наконец, у нас есть функция, которая обновляет переменную.

Держатели состояния всегда нужно запоминать (remember), чтобы сохранить их в композиции и предотвратить создание нового каждый раз, когда происходит перекомпозиция.

В нашем примере мы делаем это, создавая composable rememberApplicationState, который просто возвращает запомненный инстанс состояния нашего приложения. Мы можем использовать этот composable в других наших composable.

@Composable
fun CustomApplication() {
    val applicationState = rememberApplicationState(initialName = "")
    InputGreeting(value = applicationState.name, onValueChange = {
        applicationState.updateName(it)
    })
    if (applicationState.shouldDisplayName) {
        Text(text = applicationState.greeting)
        Question(value = applicationState.name)
    } else {
        Text(text = applicationState.greeting)
    }
}

Выше в CustomApplication мы используем держатель состояния applicationState для управления всеми состояниями composable. Для нашего composable InputGreeting мы устанавливаем значение applicationState.name, а для onValueChange мы передаем лямбду, которая вызывает updateName из нашего держателя состояния.

Мы также используем shouldDisplayName из держателя состояния для выполнения условного рендеринга в зависимости от того, какое имя вводит пользователь. Если введенное имя допустимо, мы отображаем одно значение, а если нет, то отображаем другое.

Модели представлений

Модели представлений (ViewModels) — это особые виды держателей состояния, которые отвечают за доступ к другим уровням в приложении, таким как уровень данных или бизнес-уровень, а также за подготовку данных приложения для отображения на экране.

Они переживают изменения конфигурации и могут отслеживать жизненный цикл активити или фрагментов.

Их лучше всего использовать с composabl’ами уровня экрана, чтобы они могли управлять состоянием всего экрана.

Давайте посмотрим пример того, как их можно использовать на практике.

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

Для начала давайте добавим:

implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.4.0-beta01"

Первое, о чем нам нужно позаботится, — это управление состоянием поля ввода, поэтому для этого мы создадим класс, который будет непосредственно отвечать за управление этим состоянием.

enum class InputType {
    TEXT,
    EMAIL,
    PASSWORD,
    NUMBER
}
data class InputState(
    val text: String = "",
    val isValid: Boolean = true,
    val type: InputType,
    val errorMessage: String = ""
)

Приведенный выше класс управляет состоянием, связанным с полем ввода, включая фактическое значение в поле ввода, допустим ли ввод, тип поля ввода и сообщение об оибке, если такое имеется.

В большинстве реальных примеров поля ввода не существуют изолированно, они обычно сгруппированы в формы, поэтому давайте создадим еще один класс, который будет хранить состояние всех полей ввода в форме, а также общее состояние формы:

data class LoginFormState(
    val email : InputState = InputState(type = InputType.EMAIL),
    val password: InputState = InputState(type = InputType.PASSWORD),
    val formValid: Boolean
)

Теперь, когда у нас есть держатели для состояния ввода, а также состояния формы, мы можем создать нашу модель представления. Эта модель представления будет отвечать за управление и обновление общего состояния формы, а также за состояние различных composabl’ов для ввода данных:

class LoginViewModel : ViewModel() {
    private val _state = mutableStateOf(LoginFormState(formValid = true))
    val state: State<LoginFormState> = _state
}

Наша модель представления наследуется от ViewModel, затем мы создаем переменную состояния _state, которая отражает общее состояние LoginForm.

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

Чтобы обойти это, мы создали еще одну переменную состояния под названием state. Эта переменная имеет тип State<T>, который является иммутабельным вариантом API состояния jetpack compose. Мы переопределяем геттер state, чтобы он возвращал инстанс нашей изменяемой переменной состояния _state, поэтому всякий раз, когда к state обращаются извне класса, мы возвращаем иммутабельную версию нашей _state .

Теперь, когда мы подготовили наше состояние, нам нужно создать способ изменения нашей переменной состояния извне нашей loginViewModel. Мы делаем это с помощью событий. Следуя шаблону однонаправленного потока данных, наше состояние будет отправляться на наш экран из нашей модели представления, и наш экран будет отправлять в модель представления события, чтобы изменить данные в нашем состоянии.

Давайте создадим sealed класс, который будет представлять события, запускаемые экраном логина в систему для обновления наших значений состояния:

sealed class LoginEvent {
    data class EnteredEmail(val value: String) : LoginEvent()
    data class EnteredPassword(val value: String) : LoginEvent()
    data class FocusChange(val focusFieldName: String) : LoginEvent()
}

Наш LoginEvent состоит из трех классов данных, один из которых представляет ситуацию, когда пользователь вводит текст в поле ввода электронной почты, другой — поле ввода пароля, а третий — всякий раз, когда на поле ввода изменяется фокус. Эти классы имеют поле для значения, которое будет передано в модель представления.

Теперь нам нужно создать в LoginViewModel функции, которые можно использовать для обработки событий, которые будут поступать с нашего экрана логина. Поэтому мы изменим модель представления следующим образом:

class LoginViewModel : ViewModel() {
    private val _state = mutableStateOf(LoginFormState(formValid = true))
    val state: State<LoginFormState> = _state

    fun createEvent(event: LoginEvent) {
        onEvent(event)
    }

    private fun onEvent(event: LoginEvent) {
        when (event) {
            is LoginEvent.EnteredEmail -> {
                _state.value = state.value.copy(
                    email = state.value.email.copy(
                        text = event.value
                    )
                )
            }
            is LoginEvent.EnteredPassword -> {
                _state.value = state.value.copy(
                    password = state.value.password.copy(
                        text = event.value
                    )
                )
            }
            is LoginEvent.FocusChange -> {
                when (event.focusFieldName) {
                    "email" -> {                      
                        val emailValid = validateInput(state.value.email.text, InputType.EMAIL)
                        _state.value = state.value.copy(
                            email = state.value.email.copy(
                                isValid = emailValid,
                                errorMessage = "Email is not valid"
                            ),
                            formValid = emailValid
                        )
                    }
                    "password" -> {                  
                        val passwordValid = validateInput(
                            state.value.password.text,
                            InputType.PASSWORD
                        )
                        _state.value = state.value.copy(
                            password = state.value.password.copy(
                                isValid = passwordValid,
                                errorMessage = "Password is not valid"
                            ),
                            formValid = passwordValid
                        )
                    }
                }
            }
        }
    }

    private fun validateInput(inputValue: String, inputType: InputType): Boolean {
        when (inputType) {
            InputType.EMAIL -> {
                return !TextUtils.isEmpty(inputValue) && android.util.Patterns.EMAIL_ADDRESS.matcher(
                    inputValue
                ).matches()
            }
            InputType.PASSWORD -> {
                return !TextUtils.isEmpty(inputValue) && inputValue.length > 5

            }
            InputType.TEXT -> {
                // пользовательская логика проверки текстового ввода
                return true
            }
            InputType.NUMBER -> {
                // пользовательская логика проверки числового ввода
                return true
            }

        }

    }
}

 У нас появилось три новых метода:

createEvent — это публичный метод, который будет использоваться нашим экраном для передачи событий в нашу модель представления.

onEvent — это приватный метод, который содержит пользовательскую логику обновления переменной состояния на основе типа полученного события, а также значения, переданного вместе с событием. В этом методе мы используем when, чтобы определить, какое событие мы получили, и выполнить соответствующую логику. Когда получено событие EnteredEmail или EnteredPassword, мы обновляем переменную состояния, копируя в него значения предыдущего состояния и изменяя поля состояния электронной почты и пароля соответственно.

Когда передается событие FocusChange, мы вызываем наш служебный метод validateInput, который просто содержит логику проверки.

Обратите внимание, что мы смотрим на имя поля, переданное вместе с событием, и выполняем некоторую проверку, а затем обновляем состояние формы на основе результата нашей проверки.

Теперь давайте создадим пользовательское поле ввода, которое расширяет composable OutlinedTextField:

@Composable
fun CustomInputField(
    value: String,
    placeholder: String,
    modifier: Modifier = Modifier,
    hasError: Boolean = false,
    errorMessage: String = "",
    onFocusChange: (FocusState) -> Unit,
    onValueChange: (String) -> Unit,
    textColor: Color = Color.Black,
) {
    val touched = remember {
        mutableStateOf(false)
    }
    OutlinedTextField(
        value = value,
        onValueChange = {
            touched.value = true
            onValueChange(it)
        },
        modifier = modifier.onFocusChanged {
            if (touched.value) onFocusChange(it);
        },
        isError = hasError,
        placeholder = {
            Text(
                text = placeholder, style = TextStyle(
                    textAlign = TextAlign.Center
                )
            )
        },
        colors = TextFieldDefaults.outlinedTextFieldColors(
            errorBorderColor = Color.Red,
            errorLabelColor = Color.Red,
            errorLeadingIconColor = Color.Red,
            textColor = textColor,
            focusedBorderColor = Color.Green,
            unfocusedBorderColor = Color.LightGray,
        ),

        shape = RoundedCornerShape(20),
    )
    if (hasError) {
        Text(
            text = errorMessage,
            color = Color.Red,
            modifier = Modifier.padding(start = 10.dp)
        )
    }
}

Composable CustomInputField просто расширяет функциональность OutlinedTextField для отображения сообщения об ошибках, так как по умолчанию это не поддерживается OutlinedTextField.

Теперь, когда мы подготовили все детали, мы можем собрать их вместе на экране логина:

@Composable
fun Login(
    loginViewModel: LoginViewModel = viewModel()
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(10.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        val loginFormState = loginViewModel.state.value
        CustomInputField(
            value = loginFormState.email.text,
            placeholder = "Email",
            onFocusChange = {
                loginViewModel.createEvent(
                    LoginEvent.FocusChange("email")
                )
            },
            onValueChange = { value ->
                loginViewModel.createEvent(
                    LoginEvent.EnteredEmail(value)
                )
            },
            hasError = !loginFormState.email.isValid,
            errorMessage = loginFormState.email.errorMessage,
            textColor = Color.Black
        )
        Spacer(modifier = Modifier.height(20.dp))
        CustomInputField(
            value = loginFormState.password.text,
            placeholder = "Password",
            onFocusChange = {
                loginViewModel.createEvent(
                    LoginEvent.FocusChange("password")
                )
            },
            onValueChange = { value ->
                loginViewModel.createEvent(
                    LoginEvent.EnteredPassword(value)
                )
            },
            hasError = !loginFormState.password.isValid,
            errorMessage = loginFormState.password.errorMessage,
            textColor = Color.Black
        )
        Spacer(modifier = Modifier.height(10.dp))
        Button(
            onClick = {},
            modifier = Modifier
                .fillMaxWidth()
                .height(56.dp),
            colors = ButtonDefaults.buttonColors(
                backgroundColor = MaterialTheme.colors.primary,
                contentColor = Color.White,
                disabledBackgroundColor = Color.Gray
            ),
            shape = RoundedCornerShape(20),
            enabled = loginFormState.formValid

        ) {
            Text(text = "Login")
        }
    }
}

В нашем экране логина, мы принимаем в качестве параметра инстанс LoginViewModel и инициализируем его по умолчанию.

Затем мы создаем переменную с именем loginFormState, которая ссылается на переменную state в нашей модели представления. Мы используем эту переменную состояния для управления нашими полями ввода.

Давайте подробнее рассмотрим одно из наших полей ввода:

CustomInputField(
            value = state.email.text,
            placeholder = "Email",
            onFocusChange = {
                loginViewModel.createEvent(
                    LoginEvent.FocusChange("email")
                )
            },
            onValueChange = { value ->
                loginViewModel.createEvent(
                    LoginEvent.EnteredEmail(value)
                )
            },
            hasError = !state.email.isValid,
            errorMessage = state.email.errorMessage,
            textColor = Color.Black
        )

В поле ввода выше мы устанавливаем значение на email.text — это гарантирует, что значение, отображаемое в поле ввода, является тем, что хранится в нашем состоянии. В onFocusChange мы передаем лямбду, которая вызывает createEvent из loginViewModel. Событие, которое мы здесь создаем, является FocusChange, что вызовет проверку внутри модели представления.

onValueChange мы также передаем в лямбде, которая вызывает createEvent с EnteredEmail, а также передаем обновленное значение от пользователя в событие.

Затем мы устанавливаем параметр hasError отрицательным, если поле электронной почты валидно, или положительным, если оно недопустимо. Затем мы устанавливаем в errorMessage сообщение об ошибке из состояния электронной почты.

Наконец, мы деактивируем кнопку в нашем composable Button, если свойство isValid false.

Заключение

Когда мы запускаем приложение, мы видим, что все состояние нашей формы управляется из нашей модели представления. Это упрощает тестирование нашего экрана логина, поскольку он отделен от управления состоянием.

В дополнение к этому, все наше управление состоянием централизовано в одном месте и, следовательно, его легче отлаживать. Поскольку при изменении конфигурации модель представления сохраняется, то сохраняется также и состояние нашей формы.

Код, использованный в этой статье, можно найти здесь.


Перевод материала подготовлен в рамках специализации "Android Developer".

Комментарии (1)


  1. Viacheslav01
    18.03.2022 01:16
    +1

    Задорно, когда вообще не понятную (но очень модную) магию называют "интуитивно понятной")