Site icon AppTractor

TOAD: Kotlin-First архитектурный шаблон, который наконец-то сделал мои ViewModel скучными

Признаюсь честно.

В прошлом году я нашел файл ViewModel в нашей кодовой базе, который разросся до 847 строк. Восемьсот сорок семь. Строки бизнес-логики, управления состоянием, вызовов API и столько блоков viewModelScope.launch, что индикатор скрола в моей IDE выглядел как точка отчаяния.

Все мы через это проходили. Начинаешь с чистого, простого ViewModel. Затем отдел разработки просит добавить «ещё одну фичу». Потом ещё одну. И ещё одну. Прежде чем ты это поймешь, ты уже смотришь на класс, который делает всё — от получения данных пользователя до вычисления смысла жизни.

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

Я назвал это TOAD (переводится как Жаба) — Typed Object Action Dispatch (диспетчеризация действий типизированных объектов).

Проблема, о которой никто не хочет говорить

Давайте будем честны в отношении того, что происходит в большинстве проектов Android/KMP.

Начинается все так:

class ProfileViewModel : ViewModel() {
  fun loadProfile() { /* clean, simple */ }
}

И через шесть месяцев вы здесь:

class ProfileViewModel : ViewModel() {
  fun loadProfile() { /* ... */ }
  fun updateName() { /* ... */ }
  fun updateEmail() { /* ... */ }
  fun uploadPhoto() { /* ... */ }
  fun deleteAccount() { /* ... */ }
  fun changePassword() { /* ... */ }
  fun connectSocialMedia() { /* ... */ }
  fun verifyPhone() { /* ... */ }
  fun updateNotificationSettings() { /* ... */ }
  fun exportData() { /* ... */ }
  fun toggleDarkMode() { /* ... */ }
// ... 30 more methods
}

Каждый раз, когда вам нужно добавить новую функцию, вы вскрываете ViewModel и добавляете ещё один метод. Класс разрастается. Тесты становятся сложнее. Проверка кода занимает больше времени. А тот разработчик, который написал половину методов? Он ушёл полгода назад.

Это не просто неряшливый код. Это нарушение одного из самых фундаментальных принципов проектирования программного обеспечения: принципа открытости/закрытости.

Программные сущности должны быть открыты для расширения, но закрыты для модификации.

Каждый запрос на добавление функции означает модификацию ViewModel. Мы делаем это наоборот.

Что если бы ViewModel никогда не нужно было менять?

Этот вопрос не давал мне покоя. Как бы выглядела ViewModel, если бы она, будучи написанной, никогда не нуждалась в модификации?

Ответ пришёл из неожиданного места: из шаблона проектирования «Команда» (Command ).

Что если бы вместо методов в ViewModel у нас были объекты, представляющие действия? Самодостаточные, тестируемые, заменяемые объекты, которые инкапсулируют ровно один фрагмент бизнес-логики?

// Instead of this:
viewModel.loadProfile()
// What about this:
viewModel.runAction(LoadProfile)

Этот небольшой сдвиг в мышлении изменил всё.

Представляем TOAD

TOAD расшифровывается как:

Главная идея такова: в вашей ViewModel есть всего одна публичная функция. И всё. Одна.

class ProfileViewModel(...) : ToadViewModel<ProfileState, ProfileEvent>(...) {
  fun runAction(action: ProfileAction) = dispatch(action)
}

Новая фича? Не трогайте ViewModel. Создайте новое действие:

data object ShareProfile : ProfileAction() {
  override suspend fun execute(
    dependencies: ProfileDependencies,
    scope: ActionScope<ProfileState, ProfileEvent>
  ) {
      val shareUrl = dependencies.userRepo.getShareUrl()
      scope.sendEvent(ProfileEvent.ShareProfile(shareUrl))
    }
}

Вот и всё. ViewModel остаётся неизменным. Тесты для существующих функций остаются действительными. Риск что-либо сломать? Практически нулевой.

Анатомия TOAD

Позвольте мне рассказать, как это работает на самом деле.

1. ViewState — ваш единственный источник истины

Состояние неизменяемо. Всегда. Изменения происходят через copy().

data class ProfileState(
  val isLoading: Boolean = false,
  val user: User? = null,
  val error: String? = null
) : ViewState

2. ViewEvent — одноразовые побочные эффекты

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

sealed interface ProfileEvent : ViewEvent {
  data class ShowToast(val message: String) : ProfileEvent
  data object NavigateToSettings : ProfileEvent
}

3. ActionDependencies — внедряются один раз, используются везде

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

class ProfileDependencies(
  override val coroutineScope: CoroutineScope,
  val userRepository: UserRepository,
  val analyticsTracker: AnalyticsTracker
) : ActionDependencies()

4. ViewAction — сердце TOAD

Здесь происходит волшебство. Каждое действие представляет собой типизированный объект, который точно знает, с каким состоянием он работает, какие события может генерировать и какие зависимости ему необходимы:

data object LoadProfile : ProfileAction() {
  override suspend fun execute(
    dependencies: ProfileDependencies,
    scope: ActionScope<ProfileState, ProfileEvent>
  ) {
      scope.setState { copy(isLoading = true) }
      val user = dependencies.userRepository.getCurrentUser()
      scope.setState { copy(isLoading = false, user = user) }
      dependencies.analyticsTracker.track("profile_loaded")
    }
}

5. ActionScope — контролируемый доступ к состоянию

Действия не получают прямого доступа к StateFlow. Они получают контролируемый ActionScope, который обеспечивает безопасные и четко определенные операции:

class ActionScope<S : ViewState, E : ViewEvent> {
  val currentState: S
  fun setState(reducer: S.() -> S)
  fun sendEvent(event: E)
  suspend fun withLoading(...)
}

6. ToadViewModel — оркестратор

Единственная задача ViewModel — управлять действиями. Это скучно. А скучность — это именно то, что нам нужно:

abstract class ToadViewModel<S : ViewState, E : ViewEvent>(
initialState: S
) : ViewModel() {
  abstract val dependencies: ActionDependencies
  val state: StateFlow<S>
  val events: Flow<E>
  protected fun dispatch(action: ViewAction<...>)
}

Почему это меняет всё

1. Истинное соответствие принципам открытости/закрытости

В TOAD добавление функций означает добавление файлов, а не их изменение:

2. Действия тестировать невероятно легко

Тестирование традиционной ViewModel подразумевает создание экземпляра всей модели со всеми её зависимостями. А тестирование действия TOAD?

@Test
fun `LoadProfile updates state with user data`() = runTest {
  val mockRepo = mockk<UserRepository>()
  val mockScope = mockk<ActionScope<ProfileState, ProfileEvent>>(relaxed = true)
  coEvery { mockRepo.getCurrentUser() } returns User("John")
  LoadProfile.execute(ProfileDependencies(TestScope(), mockRepo), mockScope)
  verify { mockScope.setState(match { it(ProfileState()).user?.name == "John" }) }
}

Никакого создания экземпляров ViewModel. Никакой сложной настройки. Только действие, его зависимости и утверждения.

3. Типобезопасность, которую вы можете почувствовать

Универсальная сигнатура ViewAction<D, S, E> гарантирует, что вы случайно не используете ProfileAction с SettingsViewModel. Компилятор это обнаруживает. Не ваши пользователи.

4. Идеально подходит для проверки кода

При проверке запроса на слияние, добавляющего новую функцию, вы видите:

И всё

Никакой прокрутки 500-строчного diff-файла ViewModel в попытке найти, что изменилось.

Пример использования в реальных условиях

Вот как выглядит использование TOAD в Composable:

@Composable
fun ProfileScreen(viewModel: ProfileViewModel = koinViewModel()) {
  val state by viewModel.state.collectAsStateWithLifecycle()
  LaunchedEffect(Unit) {
  viewModel.events.collect { event ->
    when (event) {
      is ProfileEvent.ShowToast -> showToast(event.message)
      is ProfileEvent.NavigateToSettings -> navController.navigate("settings")
      }
    }
  }
  ProfileContent(
    state = state,
    onRefresh = { viewModel.runAction(RefreshProfile) },
    onEditClick = { viewModel.runAction(OpenEditDialog) },
    onPhotoClick = { viewModel.runAction(UploadPhoto(it)) }
  )
}

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

Неожиданные преимущества

После нескольких месяцев использования TOAD в продакшене меня удивили некоторые преимущества:

Быстрее онбординг: Новым разработчикам не нужно понимать всю ViewModel целиком. Они могут посмотреть на одно действие, полностью его понять и внести свой вклад.

Более понятная отладка: Когда что-то идет не так, вы точно знаете, какое действие вызвало проблему. Не нужно искать причину в монолитном классе.

Более безопасный рефакторинг: Перемещаете действие в другой модуль? Просто переместите файл. Никаких изменений в ViewModel.

Естественное владение кодом: Разные члены команды могут владеть разными действиями, не мешая друг другу.

Когда НЕ следует использовать TOAD

Я не буду делать вид, что это идеально подходит для каждой ситуации.

Если ваш экран содержит одну или две простые операции, TOAD может быть излишним. Традиционная ViewModel с двумя методами вполне подойдет. Серьезно.

Если ваша команда глубоко вовлечена в другую архитектуру (библиотеки MVI, такие как Orbit, MVI Kotlin и т. д.), миграция может оказаться нецелесообразной.

Но если вы начинаете с нуля или ваши ViewModels выходят из-под контроля, попробуйте TOAD.

Начало работы

TOAD — это всего лишь шаблон — нет необходимости устанавливать какую-либо библиотеку. Основная реализация занимает около 150 строк кода на Kotlin.

Вот каркас:

// StateContracts.kt
interface ViewState

interface ViewEvent

abstract class ActionDependencies {
  abstract val coroutineScope: CoroutineScope
}
// ActionContracts.kt
interface ViewAction<D : ActionDependencies, S : ViewState, E : ViewEvent> {
    suspend fun execute(dependencies: D, scope: ActionScope<S, E>)
}

class ActionScope<S : ViewState, E : ViewEvent>(
    private val stateFlow: MutableStateFlow<S>,
    private val eventChannel: Channel<E>
) {
    val currentState: S get() = stateFlow.value
    fun setState(reducer: S.() -> S) = stateFlow.update(reducer)
    fun sendEvent(event: E) = eventChannel.trySend(event)
}
// ToadViewModel.kt
abstract class ToadViewModel<S : ViewState, E : ViewEvent>(
initialState: S
) : ViewModel() {
    protected abstract val dependencies: ActionDependencies
    private val _state = MutableStateFlow(initialState)
    val state: StateFlow<S> = _state.asStateFlow()
    private val _events = Channel<E>(Channel.BUFFERED)
    val events: Flow<E> = _events.receiveAsFlow()
    protected fun <D : ActionDependencies> dispatch(action: ViewAction<D, S, E>) {
        viewModelScope.launch {
            action.execute(dependencies as D, ActionScope(_state, _events))
        }
    }
}

Вот и весь фреймворк. Скопируйте, вставьте и начинайте создавать.

Заключительные мысли

Я не ставил перед собой цель создать архитектурный шаблон. Я просто хотел, чтобы мои ViewModel перестали разрастаться до неуправляемых размеров.

TOAD возник из разочарования, итераций и упрямой веры в то, что должен быть лучший способ. Это не революционно — это просто шаблон Команда, продуманно примененный к современной разработке Android/KMP.

Но иногда лучшие решения не революционны. Они просто… правильны.

Ваши ViewModel должны быть скучными. Ваши действия должны быть интересными.

Попробуйте TOAD. Ваше будущее «я» (и ваши рецензенты кода) скажут вам спасибо.

Источник

Exit mobile version