Контроллер представления#
VCI (View-Controller Interface) располагается между серверной логикой State и Jetpack Compose-представлением.
Ключевые функции контроллера:
обновление экрана
управление доступными действиями пользователя
управление визуальными компонентами
хранение данных главного потока
Контроллер переживает пересоздание представления, например при повороте экрана.предоставление навигационных функций
Внимание
Весь код, связанный с UI-слоем, должен находиться именно в представлении,
а бизнес-данные остаются внутри соответствующего State.
Создание контроллера#
Контроллер представления должен наследовать базовый класс SmStateVciAbst<S : SmState>.
Шаблон#
class UsersVci : SmStateVciAbst<UsersState>() {
/* nullable RecordList, чтобы не использовать lateinit */
private var usersORL = nullObservableRecordList
override fun onInit(state: UsersState) {
usersORL = state.usersRS.observableRecordList
}
override fun afterEnter(state: UsersState) {
usersORL = state.usersRS.observableRecordList // подписываемся на изменения
}
override fun newScreen() = provideUsersView(this)
override fun getTitle() = "Пользователи"
}
Жизненный цикл#
Фаза |
Описание |
|---|---|
|
Вызывается один раз, когда VCI создан. Подписаться на |
|
Каждый раз, когда |
|
Перед уходом со страницы. Сохранить позицию списков, закрыть диалоги, отправить накопленные изменения в стейт. |
Общие свойства и методы#
navigator— НавигаторlazyListState— используется для хранения позиции основного списка представления
Работа с данными#
Инициализация данных#
Kotlin требует, чтобы все значения по возможности были инициализированы. Для удобства в контроллере объявлены начальные значения:
nullObservableRecordListnullObservableRecord
Подписка на набор строк#
Compose-экраны не могут обращаться к БД и ресурсам серверного потока напрямую. Поэтому данные обычно собираются в серверном потоке и сохраняются в состоянии. Для работы с данными, сохранёнными в состоянии, используется механизм привязки. Это позволяет безопасно использовать данные в главном потоке, полученные из серверного потока.
Классы, публикующие данные для состояния:
RecordSetSingleRecordRecordValue
Внутри себя они содержат:
observableRecordListobservableRecordHolder
Это позволяет использовать стандартные механизмы подписки Compose.
Привязка происходит в момент инициализации:
override fun onInit(state: UsersState) {
usersORL = state.usersRS.observableRecordList // подписались один раз
}
Пример использования привязанных данных:
@Composable
fun UsersView(vci: UsersVci, pv: PaddingValues) {
val users by vci.usersORL.observeAsState() // ← живой список
LazyColumn(Modifier.padding(pv)) {
items(users) { or ->
Text(or.getValueAsString("name"))
}
}
}
Подписка происходит в главном потоке. Все изменения данных, пришедшие из серверного потока, автоматически попадают в Compose.
Чтение и модификация значений#
Каждый элемент списка — это RecordData.
Доступ к полям идёт через геттеры/сеттеры.
Пример чтения:
val email = or.getValueAsString("email")
val age = or.getValueAsInt ("age")
Пример изменения:
IconButton(onClick = { or.setValue("isSelected", 1) }) { /* ... */ }
Примечание
Синхронизация изменений между главным потоком и серверным потоком происходит в beforeExit() VCI.
Когда пользователь завершит работу с экраном, мы можем сбросить изменения в БД одной пачкой.
Планирование серверных команд#
Основные понятия:
задачи — выполняются в контексте текущего
State, не меняют стек состояний. Одновременно может
быть запланировано несколько задач.переходы — добавляют или удаляют состояния из стека состояний.
Одновременно может быть запланирован только один переход.
Переход происходит транзакционно: SQLite-транзакция,FileManagerи пользовательский экран
согласованно перейдут в новое состояние или, в случае ошибки, произойдёт откат до начала выполнения перехода.
См. также
StateEventProcessor
Для отправки действий в серверный поток используется планировщик getSts().
Методы getSts():
postSharedTask:withLock("Загрузка…") { getSts<UsersState>().postSharedTask("refresh") { st -> st.dbs.getApi(UserApi::class).updateFromServer() } .onSuccess { // успешное завершение // можем запланировать еще работу, переходы и т.д. // например, getSts().postBack{} } .onFailure { showBaseDialog("Ошибка", it.msg) } }
Совет
Используйте
withLock, чтобы надёжно показать индикатор и гарантированно его убирать.postSharedTask()
Перед/после выполнения задачи происходит синхронизация данных между контроллерами.postAsyncTask()
Выполняет задачу без событий синхронизации.postCall()
Планирование перехода, при этом состояние добавляется в стек состояний; основной метод перехода вперёд.postBack()
Планирование перехода назад (снятие состояния со стека); основной метод перехода назад.postCallWithResult()
Планирование перехода вперёд с ожиданием результата.postBackWithResult()
Планирование перехода назад (снятие состояния со стека) с результатом для ожидающего состояния.
Внимание
Коллбеки onSuccess / onFailure приходят в главном потоке, блокировка UI не требуется.
Чтобы получить результат postCallWithResult, нужно вызвать зеркальный метод postBackWithResult.
Если результат не будет передан при возвращении, произойдёт ошибка; сам результат обрабатывается в параметре onResult.
При попытке вызвать postBackWithResult без ожидающего состояния также будет выброшена ошибка.
Таким образом, разработчик всегда знает, когда что-то не пришло или было отправлено по ошибке.
Примеры для каждого варианта:#
postAsyncTask — без синхронизации#
fun sendAnalyticsEvent(event: String) {
getSts().postAsyncTask("analyticsEvent") { st ->
st.dbs.execSql(
"INSERT INTO AnalyticsLog(event) VALUES(?)",
event
)
}
// без `afterEnter`
}
postCall / postCallState — переход на новый экран#
// Переход на экран камеры без результата
fun toCamera() {
showLoadingView()
getSts().postCallState<GsCameraState> { tb ->
tb.onBefore { bb ->
bb.afterSubscribe { stTo ->
stTo.cameraUseCaseState = GsCameraUseCaseState.IMAGE_CAPTURE
}
}
}
}
postBack — возврат назад#
override fun smPostBack() {
if (mediaPreviewState.value == MediaPreviewState.HIDDEN) {
getSts().postBack { /* можно настроить onBefore/onAfter, если нужно */ }
} else {
mediaPreviewState.value = MediaPreviewState.HIDDEN
showBottomBar()
}
}
postCallStateWithResult — переход с ожиданием результата#
Пример: открываем камеру, ждём результат CameraResult, обрабатываем его в текущем VCI.
fun toQrScanner() {
showLoadingView()
getSts().postCallStateWithResult<GsCameraState, CameraResult>(
body = { tb ->
tb.onBefore { bb ->
bb.afterSubscribe { stTo ->
stTo.cameraUseCaseState = GsCameraUseCaseState.QRCODE_SCANNER
stTo.qrDelegate = this@CreateDemandVci
}
}
},
onResult = { result ->
stopLoadingView()
if (result is CameraResult.Qr) {
showBaseDialog(
title = "Информация",
msg = result.text
)
}
}
)
}
postBackWithResult — возврат назад с результатом#
Экран-дочерний возвращает результат родителю:
fun backWithQr(qrCode: String) {
showLoadingView()
getSts().postBackWithResult(CameraResult.Qr(qrCode))
}
fun backErrorWithQr(e: Throwable) {
showLoadingView()
getSts().postBackWithResult(CameraResult.Error(e))
}
Композиция бизнес-логики#
Используйте
getStsтолько из VCI.
Это позволит:избежать ошибок доступа к данным из разных потоков;
повысить читаемость кода.
@Composable
fun Toolbar(vci: UsersVci) {
IconButton(onClick = { vci.refreshUsers() }) { /* ... */ }
}
// в VCI
fun refreshUsers() {
showLoadingView()
getSts().postSharedTask("refresh") { st ->
st.dbs.getApi(UserApi::class).syncFromServer()
}.onSuccess {
usersORL.refresh() // обновляем список
stopLoadingView()
}.onFailure {
showBaseDialog(
"Ошибка",
it.message ?: "Не удалось обновить данные",
isError = true
)
}
}
Меню#
Список пунктов меню задаётся в событии afterEnter:
override fun afterEnter(state: UsersState) {
topGsMenuItems.setOf(
GsMenuItem("Обновить") { refresh() },
GsMenuItem("Выход") { navigator.doLogout() }
)
bottomGsMenuItems.single("Добавить") { addUser() }
}
События навигации#
onBack— обработчик кнопки «Назад».
Расширение навигатора#
Методы расширения навигатора объявлены в интерфейсе NavigableVcp и могут быть реализованы в
контроллере представления.
Можно переопределить:
newScreen— представление типа VcpScreengetTitle— заголовокtopGsMenuItems— элементыTopBarbottomGsMenuItems— элементыBottomBarshowFAB— видимость FABFab— собственная реализация@Composable Fab()fabClick— действия на FAB
Пример FAB#
override fun showFAB() = usersORL.size > 0
override fun Fab() =
SmallFloatingActionButton(onClick = ::addUser) {
Icon(Icons.Default.PersonAdd, contentDescription = null)
}
Стандартные диалоги#
showBaseDialog(
title = "Удалить пользователя?",
okText = "Да",
dismissText = "Нет",
onOk = ::confirmDelete
)
Event Bus#
Flow-шина событий работает на всё приложение:
subscribeEventBus<SyncDone>("SyncDone") {
navigator.setNeedSync(false)
}
VcpScreen#
Класс-адаптер, который отдаёт Composable-дерево как функцию Content(pv: PaddingValues).
Именно screen попадает в Navigator для реального рендеринга во вкладку стека.
PaddingValues определяет размеры отступов для меню.
Стандартные делегаты#
Делегаты типизируют стандартные события для обработки сообщений.
Стандартные делегаты:
QrCodeScannerDelegate
Внимание
Экспериментальный функционал, находится в разработке.
Полный пример#
class DemandListVci : SmStateVciAbst<DemandListState>() {
private var demandORL = nullObservableRecordList
val isRefreshing = mutableStateOf(false)
/* 1. Init one time */
override fun onInit(state: DemandListState) {
demandORL = state.demandRS.observableRecordList
onBack { smVci -> smVci.smPostBack() }
}
/* 2. Every enter */
override fun afterEnter(state: DemandListState) {
demandORL = state.demandRS.observableRecordList
}
/* 3. Exit */
override fun beforeExit(state: DemandListState) = Unit
/* 4. UI */
override fun newScreen() = provideDemandListView(this)
override fun getTitle() = "Заявки"
/* 5. Actions */
fun refresh() {
showLoadingView()
getSts<DemandListState>().postSharedTask("refresh") { st ->
st.dbs.getApi(EamDemandApi::class).syncFromServer()
}.onSuccess {
stopLoadingView()
}.onFailure {
stopLoadingView()
showBaseDialog("Ошибка", it.message ?: "")
}
}
}
Хорошие практики#
Не держите ссылок на
RecordData, всегда получайте новую черезobserveAsState().
Каждый раз там будет актуальная строка.Вызывайте только публичные методы VCI.
Не трогайтеdbsиз Composable.Из VCI в
Stateпереходите черезpostSharedTask()/postAsyncTask().
Никогда не ходите вStateнапрямую.Держите бизнес-логику в
State.Держите сетевые/SQL-операции — в
Api/Pkg.Помните: VCI — это тонкий контроллер UI.
Он должен оставаться ответственным за подписки, показ, навигацию и т.д.
Изоляция VCI позволяет делать код простым, тестируемым и устойчивым к изменениям.Используйте собственные
ExecutorSubscriptionдля Bluetooth, камеры, видео и т.д. вместоpostAsyncTask.
Это снизит нагрузку на серверный поток.
Плохие практики#
Не злоупотребляйте методом
refreshView().
Этот метод уже вызывается навигатором, ручной вызов нужен только в особых случаях.