Привет! Меня зовут Никита и я Android-разработчик. Сегодня я хочу рассказать, как нам вместе с командой Nexign удалось реализовать сборку бизнес-сценариев в приложении для регистрации новых клиентов.
В этой статье постараюсь минимизировать привязку к конкретному фреймворку, платформе или языку. Цель статьи – показать не готовое решение, а архитектурный подход. Так как речь пойдет об Android-приложении, то примеры кода будут на Kotlin и Koin DI. Надеюсь, что статья будет понятна всем мобильным разработчикам.
Немного контекста
У нескольких телеком-операторов установлено наше приложение для регистрации новых клиентов. Ему уже несколько лет, три года назад появилось новое требование по «нелинейной» навигации.
Стандартно процедура регистрации состоит из последовательности шагов, таких как «Ввод персональных данных», «Корзина», «Подписание договора» и др. Новая фича должна дать возможность пользователям проходить регистрацию в условно-произвольном порядке. Например, сначала наполнить корзину (седьмой шаг), а потом вернуться к вводу персональных данных. При этом нельзя подписать договор, пока все предшествующие шаги не будут выполнены.
Перед командой разработки встало несколько вопросов:
Как пользователь будет перемещаться по сценарию в произвольном порядке?
На каком уровне будет определяться доступность или недоступность экрана?
Как управлять навигацией?
Сразу оговорюсь: использование Backend Driven UI (BDUI) не наш случай, так как приложение работает в offline-first режиме.
Экран навигации
Сначала о том, как это выглядит для пользователя. Мы взяли за основу компонент Backdrop из Material Design.
Он состоит из двух слоев:
Front layer – для основного контента экрана
Back layer – для навигации по шагам сценария

Обратите внимание, что часть экранов недоступна – отмечена замком. Они станут доступны, когда будут заполнены предшествующие им экраны. Как только экран заполнен и все необходимые проверки по заполнению выполнены успешно, то этот экран отмечается зеленой галочкой. Кроме того, у пользователя есть панель навигации с кнопками «Назад» и «Далее», позволяющая ему перемещаться по экранам последовательно.
Проблема
Нам сразу было понятно, что ни одно стандартное решение по навигации в чистом виде нам не подойдет.
Нужна надстройка, которая:
на основе бизнес-логики решает доступен экран или нет;
управляет переходом по шагам;
остается гибкой, чтобы можно было собирать разные сценарии как конструктор.
В результате мы создали механизм, который назвали Screenario.
Сценарий
Для определения сценария создали одноименную модель данных.
data class ScenarioTemplate(
val scenarioId: String,
val name: String,
val screenTemplates: List<ScreenTemplate>,
val onCancel: suspend () -> Boolean = { true },
val onStart: suspend () -> Unit = {},
val onFinish: suspend () -> Unit = {},
val isCancelWithDialog: suspend () -> Boolean = { true },
) : KoinComponent
В модели данных присутствуют:
уникальный идентификатор, позволяющий получить сценарий из репозитория;
колбэки начала/отмены/завершения сценария;
список экранов;
дополнительные настройки.
Внимательные читатели уже обратили внимание на странное применение KoinComponent. Дело вот в чем. Чтобы работать со сценарием, его нужно откуда-то достать. И DI граф нам показался наиболее удобным хранилищем.
Механизм следующий:
Пользователь выбирает нужный сценарий на главном меню.
Модуль навигации вызывает сценарий по scenarioId.
Koin ищет сценарий по scenarioId, который является Koin Qualifier, в графе.
Найденный сценарий загружается в память и доступен из репозитория.
Так как сценарий является Koin-компонентом, то временем его жизни легко управлять стандартными механизмами DI.
Экран
Для описания экрана используется специальная модель данных:
data class ScreenTemplate(
val screenId: String,
val screenInteractor: ScreenInteractor,
val initialStatus: StepItemStatus = StepItemStatus.Empty,
val dependencyToUnlock: Set<ScreenDependencyToUnlock> = emptySet(),
)
Модель данных экрана описывают следующие свойства:
Уникальный идентификатор экрана – необходим для того, чтобы позже смапить его в Fragment/Compose Screen.
Статус по умолчанию – после запуска сценария данный статус будет у этого экрана.
Условие доступности – экраны со статусами, участвующие в разблокировке данного экрана.·
Самое интересное – screenInteractor. Это вторая killer-фича нашего решения – переиспользование presentation-слоя с разной доменной реализацией. Коротко отмечу, что это бизнес-логика экрана в конкретном сценарии. Подробнее будет описано ниже.
Пример собранного сценария
val myScenarioModule = module {
single(named(ScenarioIds.MY_SCENARIO.name)) {
ScenarioTemplate(
scenarioId = ScenarioIds.MY_SCENARIO.name,
name = string(R.string.my_scenario_title),
screenTemplates = listOf(
ScreenTemplate(
screenId = ScreenIds.SCREEN_1.name,
screenUseCase = get<Screen1Interactor>(),
),
ScreenTemplate(
screenId = ScreenIds.SCREEN_2.name,
screenInteractor = get<Screen2Interactor>(QualifierNames.getNamed { Screen2SpecificImpl }),
initialStatus = StepItemStatus.Locked,
dependencyToUnlock = setOf(
ScreenDependencyToUnlock(
id = ScreenIds.SCREEN_1.name,
statusesToUnlock = setOf(StepItemStatus.Success),
)
),
),
)
)
}
}
В примере сценарий состоит из двух экранов. Второй экран SCREEN_2 доступен только после того, как экран SCREEN_1 будет в статусе Success. Оба экрана получают свою бизнес логику как Koin-зависимость, только первый экран получит некую дефолтную реализацию, а второй – специфичную для данного сценария.
Сам экран разделен на три модуля:
api – модуль, содержащий только модели данных уровня domain и интерфейсы. Обеспечивает межмодульное взаимодействие
ui – платформенный presentation-слой. Содержит модели стейта экрана, Fragment/Composable fun и другие запчасти.
impl – data и domain слои с реализацией бизнес логики. Причем, impl-модулей может быть у экрана несколько. Именно здесь создается реализация ScreenInteractorImpl, которая подключается к экрану через сценарий. Этот модуль не содержит платформенных зависимостей
Такой подход обеспечил нам следующие преимущества:
изоляция бизнес-логики разных экранов;
возможность переиспользования уже реализованной бизнес-логики в разных сценариях;
платформо-независимоть domain-слоя - прицел на мультиплатформу
Обработка сценария
Но как же именно к presentation-слою подключается нужный domain? Ниже пример создания Screen1ViewModel в DI-модуле.
val screen1UiModule = module {
viewModel {
Screen1ViewModel(
interactor = get<CurrentScreenUseCase>() // получаем из DI use-case экрана
.currentScreenInteractor as Screen1Interactor, // interacror экрана приводим к нужному типу
)
}
}
Обработкой собранного сценария занимается модуль, который назвали Screenario. Он включает в себя репозитории для доступа к сценарию, а также CurrentScenarioUseCase – для управления сценарием и CurrentScreenUseCase – для управления экраном.
CurrentScenarioUseCase может:
определить текущий статус каждого экрана в сценарии – доступен, заполнен, заблокирован;
запустить сценарий по идентификатору (выше писал, что это DI Qualifier);
запустить подсценарий – это такой же сценарий, только он не перетирает предыдущий, а вызывается «поверх» него. Как только подсценарий будет завершён или отменен, продолжится выполнение основного сценария с прерванного шага. Например, шаги наполнения корзины (выбор номера, SIM-карты, тарифа) являются подсценарием;
завершить или отменить сценарий;
извлечь или восстановить часть экранов в сценарии;
перемещаться по экранам, проверяя их доступность.
CurrentScreenUseCase – предоставляет ScreenInteractor текущего экрана.
ScreenUseCase – интерфейс, который должен реализовать InteractorImpl каждого экрана. Он выступает в роли контракта для обработки экрана в рамках сценария. Его метод onNext() описывает условие корректного завершения шага: все данные на экране валидны и все связанные с экраном запросы выполнены успешно. При успешном завершении экрана его статус изменяется на StepItemStatus.Success и происходит навигация к следующему экрану в сценарии.
Пример реализации логики экрана
interface Screen1Interactor : ScreenUseCase
class Screen1InteractorImpl : Screen1Interactor {
override suspend fun onNext(): StepItemStatus {
return if (isScreenFieldsValid()) { // Как-то проверяем, что все поля экрана заполнены корректно
StepItemStatus.Success
} else {
showWarning() // Показать ошибку
StepItemStatus.Empty
}
}
}
Метод onNext() будет вызван, когда пользователь нажмет кнопку «Далее». Screen1InteractorImpl обработает это событие и вернет статус. Если вернется статус Success, то произойдет навигация на следующий экран. Кроме того, будет выполнена перепроверка условий statusesToUnlock для разблокировки других экранов, зависящих от пройденного.
В завершение
Решение выглядит непривычно разработчикам, использующим стандартные средства навигации. Однако, практика показала, что решение легко поддается масштабированию благодаря следующим факторам:
декларативное описание сценария;
возможность переиспользования presentation-слоя;
изоляция бизнес-логики экрана, предназначенной для разных сценариев.
Кроме того, данное решение можно адаптировать под упрощенный BDUI – модель сценария можно получить с сервера, а не описывать внутри приложения. Но это лишь одна из возможных перспектив.
Надеюсь, статья была полезна и дала вам пищу для размышлений о том, как сделать приложение более адаптивными к изменениям бизнес-требований. Подойдет ли такой подход для ваших задач? Пишите в комментариях о своем опыте и с какими сложностями в проектировании бизнес-логики сталкиваетесь вы.