Контроллер представления#

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() = "Пользователи"
}

Жизненный цикл#

Фаза

Описание

onInit(state)

Вызывается один раз, когда VCI создан. Подписаться на RecordSet, настроить меню.

afterEnter(state)

Каждый раз, когда State становится активным (после SmTrans или postSharedTask). Обновить UI-данные, получить результат вычислений в стейте.

beforeExit(state)

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

Общие свойства и методы#

  • navigatorНавигатор

  • lazyListState — используется для хранения позиции основного списка представления

Работа с данными#

Инициализация данных#

Kotlin требует, чтобы все значения по возможности были инициализированы. Для удобства в контроллере объявлены начальные значения:

  • nullObservableRecordList

  • nullObservableRecord

Подписка на набор строк#

Compose-экраны не могут обращаться к БД и ресурсам серверного потока напрямую. Поэтому данные обычно собираются в серверном потоке и сохраняются в состоянии. Для работы с данными, сохранёнными в состоянии, используется механизм привязки. Это позволяет безопасно использовать данные в главном потоке, полученные из серверного потока.

Классы, публикующие данные для состояния:

  • RecordSet

  • SingleRecord

  • RecordValue

Внутри себя они содержат:

  • observableRecordList

  • observableRecordHolder

Это позволяет использовать стандартные механизмы подписки 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 без ожидающего состояния также будет выброшена ошибка.
Таким образом, разработчик всегда знает, когда что-то не пришло или было отправлено по ошибке.

Примеры для каждого варианта:#

postSharedTask — задача с синхронизацией и обработкой результата#

fun refreshUsers() {
    showLoadingView()
    getSts().postSharedTask("refreshUsers") { st ->
        st.dbs.getApi(UserApi::class).updateFromServer()
    }
    .onSuccess {
        
    }
    .onFailure {
        stopLoadingView()
        showBaseDialog(
            title = "Ошибка",
            msg = it.message ?: "Не удалось обновить данные",
            isError = true
        )
    }
}

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 — представление типа VcpScreen

  • getTitle — заголовок

  • topGsMenuItems — элементы TopBar

  • bottomGsMenuItems — элементы BottomBar

  • showFAB — видимость FAB

  • Fab — собственная реализация @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().
    Этот метод уже вызывается навигатором, ручной вызов нужен только в особых случаях.