Разработка
Создаем пассивный UI в Jetpack Compose
Пассивный пользовательский интерфейс имеет существенные преимущества перед тем, чтобы запихивать весь код в один компонент, особенно по мере роста сложности данного компонента.
Декларативный пользовательский интерфейс часто описывается как создание пользовательского интерфейса, который является «функциональным» по своей природе. То есть данные входят, а пользовательский интерфейс выходит. Это замечательно в абстрактном смысле, но в мире, где приложения без дополнительных побочных эффектов не особенно полезны для конечных пользователей, как этого можно добиться? В этом посте мы рассмотрим, как применять концепции и паттерны для создания «пассивного» или «тупого» пользовательского интерфейса — пользовательского интерфейса, который не делает ничего, кроме демонстрации визуальной картинки. Во многом этот пост является духовным наследником предыдущего поста о StateHolders, так что, возможно, будет полезно освежить в памяти те идеи, прежде чем продолжить чтение.
Пассивный UI
В своей статье 2006 года под названием «Пассивный View» Мартин Фаулер вводит концепцию абстрагирования логики представления в контроллере архитектуры MVC, чтобы устранить зависимость между представлением и моделью, рассматривая контроллер как арбитра между двумя другими компонентами. Опираясь на эту идею, можно представить себе composable UI, который не имеет внутреннего состояния и полностью полагается на внешнего координатора. Этот координатор будет управлять состоянием и обновлять его перед отправкой унифицированного объекта состояния в компонент для рендеринга. Это отделяет логику рендеринга от логики манипулирования состоянием, обеспечивая четкую границу ответственности. Вот пример, который поможет проиллюстрировать эту идею.
Прежде чем продолжить, следует отметить, что здесь будут обсуждаться функции с заглавной буквой «С», то есть функции компонентов, рендеримые Jetpack Compose, и функции со строчной буквой «с», то есть функции, предназначенные для повторного использования и объединения для совместного использования кода. Различие в регистре будет проводиться по мере необходимости.
Пример
Чтобы показать, что достигается, в этом примере мы будем работать в обратном направлении от менее идеального состояния к более. Для начала здесь представлен компонент, смешивающий логику состояния и рендеринга в один компонент.
@Composable fun ToggleInput() { var isOn by remember { mutableStateOf(false) } LaunchedEffect(isOn) { if (isOn) { Log.i("Toggle", "Turned Input On") } } val title = if (isOn) { R.string.toggle_state_on } else { R.string.toggle_state_off } Column { Text(text = stringResource(title)) Checkbox( checked = isOn, onCheckedChange = { isOn = !isOn } ) } }
Этот компонент выполняет огромную работу:
- Рендерит переключатель
- Получает строковый ресурс на основе состояния переключателя
- Рендерит строку состояния переключателя
- Записывает в лог, когда переключатель включен
- Обрабатывает логику переключения флага
isOn
Логика может быть отделена от рендеринга, так что все, о чем заботится UI-компонент — это отображение заголовка и флажка и вызов updateToggle
.
data class ToggleInputState( val title: String, val isOn: Boolean, val updateToggle: (Boolean) -> Unit, ) @Composable fun rememberToggleInputState(): ToggleInputState { var isOn by remember { mutableStateOf(false) } LaunchedEffect(isOn) { if (isOn) { Log.i("Toggle", "Turned Input On") } } val title = if (isOn) { R.string.toggle_state_on } else { R.string.toggle_state_off } return ToggleInputState( title = stringResource(id = title), isOn = isOn, updateToggle = { isOn = it }, ) } @Composable fun ToggleInput(state: ToggleInputState) { Column { Text(text = state.title) Checkbox( checked = state.isOn, onCheckedChange = state.updateToggle, ) } }
Теперь ToggleInput
отвечает только за рендеринг на основе текущего состояния. Это имеет несколько преимуществ, включая то, что ToggleInput
и rememberToggleInputState
теперь можно тестировать изолированно, и мы теперь контролируем API, доступный нашему UI-компоненту. Этот паттерн также имеет ряд преимуществ для композиции предварительных просмотров, потому что компонент теперь можно легко отобразить в любом желаемом состоянии, в данном примере во включенном или выключенном. Кроме того, превью могут отображаться с жестко заданным значением состояния или использовать rememberToggleInputState
в зависимости от конкретного случая использования. Пример улучшенного предварительного просмотра приведен ниже.
@Preview @Composable fun PreviewToggleInputWithHolder() { ToggleInput(state = rememberToggleInputState()) } @Preview @Composable fun PreviewToggleInputWithState() { val state = ToggleInputState( title = "Test Title", isOn = true, updateToggle = {}, ) ToggleInput(state = state) } @Preview @Composable fun PreviewToggleInputWithStateOff() { val state = ToggleInputState( title = "Test a Different Title", isOn = false, updateToggle = {}, ) ToggleInput(state = state) }
Последнее улучшение теперь можно внести в наш state holder, когда он выведен за пределы UI-компонента. Логика управления состоянием переключения может быть разделена и объединена с логикой обработки побочных эффектов и загрузки строковых ресурсов без изменения ToggleInput
или ToggleInputState
.
data class ToggleState( val isOn: Boolean, val updateToggle: (Boolean) -> Unit, ) @Composable fun rememberToggleState(): ToggleState { var isOn by remember { mutableStateOf(false) } return ToggleState( isOn = isOn, updateToggle = { isOn = it }, ) } data class ToggleInputState( val title: String, val isOn: Boolean, val updateToggle: (Boolean) -> Unit, ) @Composable fun rememberToggleInputState( toggleState: ToggleState = rememberToggleState(), ): ToggleInputState { LaunchedEffect(toggleState.isOn) { if (toggleState.isOn) { Log.i("Toggle", "Turned Input On") } } val title = if (toggleState.isOn) { R.string.toggle_state_on } else { R.string.toggle_state_off } return ToggleInputState( title = stringResource(id = title), isOn = toggleState.isOn, updateToggle = toggleState.updateToggle, ) }
Теперь стало еще проще тестировать конкретную функциональность в отдельности (например, rememberToggleInputState
с isOn
true или false и вызовы updateToggle
из rememberToggleState
). Кроме того, если состояние переключения понадобится в другом месте приложения, rememberToggleState
можно будет использовать повторно в этом месте, а не повторять одни и те же строки кода снова и снова.
Почему пассивный UI
Надеемся, предыдущий пример уже продемонстрировал положительные результаты перехода на использование пассивного пользовательского интерфейса. Чтобы быть уточнить, вот некоторые преимущества использования пассивного UI для компонентов.
- Разделение ответственности — разделение логики рендеринга и логики манипулирования состоянием делает код более легким для чтения, тестирования и сопровождения.
- Больше возможностей для тестирования — благодаря разделению логики тесты могут быть узко заточены на UI-компоненты и компоненты состояния, чтобы протестировать именно то, что требуется.
- Лучшие предварительные просмотры — как мы показали, сomposable превью становятся более гибкими и мощными при использовании пассивного UI.
- Поощряет переиспользуемые состояния — разбиение состояния на сomposable функции создает лучшие и более гибкие куски кода.
- Контролируемый API — благодаря созданию хорошо определенного объекта состояния и передаче его в “пассивный” Composable, изменения состояния хорошо описываются и хорошо понимаются.
Вывод
Пассивный пользовательский интерфейс имеет существенные преимущества перед тем, чтобы запихивать весь код в один компонент, особенно по мере роста сложности данного компонента. Как и в случае с любым новым паттерном, попробуйте начать применять паттерн небольшими частями, чтобы понять, стоят ли результаты дополнительных усилий по реализации. Чтобы поиграть с представленным выше кодом, загляните в gist. До следующего раза, спасибо!