Всем привет, меня зовут Алексей, и я отвечаю за разработку android-приложений в Константе. У нас в компании есть несколько проектов с большим набором функций, часть из которых присутствует во всех (или, по крайней мере, во многих) разделах интерфейса приложения. Речь идет об авторизации (регистрация + вход), добавлении товаров в корзину, информации о балансе пользователя, уведомлениях о новых входящих сообщениях и т.д.

В этой статье я расскажу, как наша команда воспользовалась одной фичей языка Kotlin в своих корыстных целях :) Вы увидите, что существует жизнь без наследования, и что любая задача может иметь несколько решений.

В первую очередь статья может быть полезна начинающим разработчикам, которые уже познакомились с базовым принципами объектно-ориентированного программирования, такими как абстракция, инкапсуляция, наследование и полиморфизм, а также владеют основными библиотеками и инструментами, актуальными для современной Android-разработки (Android Navigation Components, Hilt, RecyclerView).

Моя цель – показать вам, что существуют другие возможные приёмы и паттерны, а также объяснить почему любая задача может быть решена разными способами. Пример будет основан на паттерне MVVM и достаточно упрощен, чтобы сконцентрироваться на организации кода, связанного со сквозной логикой приложения. В частности, RecyclerView заменён на ScrollView + Linearlayout, все интеракторы являются моками намеренно.

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

@AndroidEntryPoint
class CatalogListFragment : Fragment(R.layout.fragment_catalog_list) {

    val viewModel by viewModels<CatalogListViewModel>()

    private var catalogContainer: LinearLayout? = null
    private var cartItemsCount: TextView? = null
    private var cartFab: FloatingActionButton? = null

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        catalogContainer = view.findViewById(R.id.catalog_container)
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.catalogItems.collect { items ->
                showCatalogItems(items)
            }
        }

        cartItemsCount = view.findViewById(R.id.cart_items_count)
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.cartItemsCount.collect {
                cartItemsCount?.text = it.toString()
            }
        }

        cartFab = view.findViewById<FloatingActionButton?>(R.id.cart_fab)?.apply {
            setOnClickListener {
                showCartDialog()
            }
        }
    }

    private fun showCatalogItems(items: List<CatalogItem>) {
        // ...
    }

    private fun showCartDialog() {
        // ...
    }

}
@HiltViewModel
class CatalogListViewModel @Inject constructor(
    private val catalogInteractor: CatalogInteractor,
    private val cartInteractor: CartInteractor,
) : ViewModel() {

    val _catalogItems: MutableStateFlow<List<CatalogItem>> = MutableStateFlow(emptyList())
    val catalogItems: Flow<List<CatalogItem>> = _catalogItems
    val cartItems: Flow<List<CartItem>> = cartInteractor.cartItems
    val cartItemsCount: Flow<Int> = cartInteractor.totalItemsCount

    init {
        viewModelScope.launch {
            _catalogItems.emit(
                catalogInteractor.getCatalogItems()
            )
        }
    }

    fun addToCart(item: CatalogItem) {
        viewModelScope.launch {
            cartInteractor.addCatalogItem(item)
        }
    }

}

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

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

abstract class BaseCartViewModel(
    private val cartInteractor: CartInteractor,
) : ViewModel() {

    val cartItems: Flow<List<CartItem>> = cartInteractor.cartItems
    val cartItemsCount: Flow<Int> = cartInteractor.totalItemsCount

    fun addToCart(item: CatalogItem) {
        viewModelScope.launch {
            cartInteractor.addCatalogItem(item)
        }
    }

    fun removeCartItem(item: CartItem) {
        viewModelScope.launch {
            cartInteractor.removeCartItem(item)
        }
    }
}

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

@HiltViewModel
class CatalogListViewModel @Inject constructor(
    private val catalogInteractor: CatalogInteractor,
    cartInteractor: CartInteractor,
) : BaseCartViewModel(cartInteractor) {

    val _catalogItems: MutableStateFlow<List<CatalogItem>> = MutableStateFlow(emptyList())
    val catalogItems: Flow<List<CatalogItem>> = _catalogItems

    init {
        viewModelScope.launch {
            _catalogItems.emit(
                catalogInteractor.getCatalogItems()
            )
        }
    }

}
@HiltViewModel
class CatalogDetailsViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val catalogInteractor: CatalogInteractor,
    cartInteractor: CartInteractor,
) : BaseCartViewModel(cartInteractor) {

    private var catalogItem: CatalogItem? = null
    private val _itemInfo: MutableStateFlow<String> = MutableStateFlow("")
    val itemInfo: Flow<String> = _itemInfo

    init {
        // ...
    }

    fun addToCart() {
        catalogItem?.also {
            addToCart(it)
        }
    }

}

Всё выглядит прекрасно, но у нас осталось дублирование кода в слое представления (во фрагментах). Почему бы не пойти тем же путём и не сделать базовый фрагмент:

abstract class BaseCartFragment(
    @LayoutRes contentLayoutId: Int
) : Fragment(contentLayoutId) {

    abstract val vm: BaseCartViewModel

    private var cartItemsCount: TextView? = null
    private var cartFab: FloatingActionButton? = null

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        cartItemsCount = view.findViewById(R.id.cart_items_count)
        viewLifecycleOwner.lifecycleScope.launch {
            vm.cartItemsCount.collect {
                cartItemsCount?.text = it.toString()
            }
        }

        cartFab = view.findViewById<FloatingActionButton?>(R.id.cart_fab)?.apply {
            setOnClickListener {
                showCartDialog()
            }
        }
    }

    private fun showCartDialog() {
        // ...
    }

}
@AndroidEntryPoint
class CatalogListFragment : BaseCartFragment(R.layout.fragment_catalog_list) {

    val viewModel by viewModels<CatalogListViewModel>()

    override val vm: BaseCartViewModel
        get() {
            return viewModel
        }

    private var catalogContainer: LinearLayout? = null

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        catalogContainer = view.findViewById(R.id.catalog_container)
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.catalogItems.collect { items ->
                showCatalogItems(items)
            }
        }
    }

    private fun showCatalogItems(items: List<CatalogItem>) {
        // ...
    }

}
@AndroidEntryPoint
class CatalogDetailsFragment : BaseCartFragment(R.layout.fragment_catalog_details) {

    val viewModel by viewModels<CatalogDetailsViewModel>()

    override val vm: BaseCartViewModel
        get() {
            return viewModel
        }

    private var text: TextView? = null
    private var addToCart: ImageView? = null

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        text = view.findViewById(R.id.text)

        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.itemInfo.collect {
                text?.text = it
            }
        }

        addToCart = view.findViewById<ImageView?>(R.id.add_to_cart)?.apply {
            setOnClickListener {
                viewModel.addToCart()
            }
        }
    }

}

В целом уже всё работает, но в данной реализации есть некоторые проблемы. Базовый фрагмент надеется, что наследники будут иметь в верстке FloatingActionButton, причем именно с идентификатором bucket_fab, иначе всё молча перестанет работать, но и это еще не все сложности.

Теперь давайте представим, что продуктологи/дизайнеры/заказчики решили добавить кнопки входа на все ключевые экраны в том случае, когда пользователь не авторизован. Следуя нашей прошлой логике, нужно делать базовые абстрактные BaseAuthControlsFragment и BaseAuthControlsViewModel:

abstract class BaseAuthControlsFragment(
    @LayoutRes contentLayoutId: Int
) : Fragment(contentLayoutId) {

    abstract val vm: BaseAuthControlsViewModel

    private var authControlsContainer: LinearLayout? = null
    private var signUp: Button? = null
    private var signIn: Button? = null


    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        authControlsContainer = view.findViewById(R.id.auth_controls_container)
        signUp = view.findViewById<Button?>(R.id.sign_up)?.apply {
            setOnClickListener {
                vm.onSignUpClick()
            }
        }
        signIn = view.findViewById<Button?>(R.id.sign_in)?.apply {
            setOnClickListener {
                vm.onSignInClick()
            }
        }
        viewLifecycleOwner.lifecycleScope.launch {
            vm.authControlsState.collect {
                when (it) {
                    AuthControlsState.AVAILABLE -> authControlsContainer?.visibility == View.VISIBLE
                    AuthControlsState.UNAVAILABLE -> authControlsContainer?.visibility == View.GONE
                }
            }
        }
    }
}
abstract class BaseAuthControlsViewModel(
    private val authInteractor: AuthInteractor
) : ViewModel() {

    val authControlsState: Flow<AuthControlsState> = authInteractor.authState.map {
        when (it) {
            AuthState.AUTHORIZED -> AuthControlsState.UNAVAILABLE
            AuthState.UNAUTHORIZED -> AuthControlsState.AVAILABLE
        }
    }

    fun onSignUpClick() {
        viewModelScope.launch {
            authInteractor.auth()
        }
    }

    fun onSignInClick() {
        viewModelScope.launch {
            authInteractor.auth()
        }
    }

}

Теперь нужно унаследовать эти классы на тех экранах, на которых нам нужны кнопки. К сожалению, в Kotlin (как в и Java) недоступно множественное наследование и придётся делать каскадное наследование, чтобы получить обе функциональности на одном экране. Абсолютно непонятно, какой из этих фрагментов/вьюмоделей должен быть выше в иерархии наследования. Как всегда вопросов больше, чем ответов.

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

  • Композиция — вариант ассоциации, при которой часть целого не может существовать вне главного объекта, объект А полностью управляет временем жизни объекта B.

class A {
  private val b = B()
}
  • Агрегация — часть целого имеет своё время жизни, объект A получает ссылку на объект B извне и использует его.

class A(
  private val b: B
) {
  // ...
}

Попробуем применить агрегацию для совместного использования BaseCartViewModel и BaseAuthControlsViewModel

@HiltViewModel
class CatalogListViewModel @Inject constructor(
    private val catalogInteractor: CatalogInteractor,
    private val cartViewModel: BaseCartViewModel,
    private val authControlsViewModel: BaseAuthControlsViewModel,
) : ViewModel() {

    val _catalogItems: MutableStateFlow<List<CatalogItem>> = MutableStateFlow(emptyList())
    val catalogItems: Flow<List<CatalogItem>> = _catalogItems

    val cartItems: Flow<List<CartItem>> = cartViewModel.cartItems
    val cartItemsCount: Flow<Int> = cartViewModel.cartItemsCount
    val authControlsState: Flow<AuthControlsState> = authControlsViewModel.authControlsState

    init {
        viewModelScope.launch {
            _catalogItems.emit(
                catalogInteractor.getCatalogItems()
            )
        }
    }

    fun addToCart(item: CatalogItem) {
        cartViewModel.addToCart(item)
    }

    fun removeCartItem(item: CartItem) {
        cartViewModel.removeCartItem(item)
    }

    fun onSignUpClick() {
        authControlsViewModel.onSignUpClick()
    }

    fun onSignInClick() {
        authControlsViewModel.onSignUpClick()
    }

}

У нас получилось использовать корзину и авторизацию в рамках одной вьюмодели, но всё еще много болейрплейт-кода. Вспоминаем, что создатели языка Kotlin уже решили эту проблему, добавив делегаты в язык.

Определим интерфейсы этих двух функциональностей:

interface CartVMDelegate {
    val cartItems: Flow<List<CartItem>>
    val cartItemsCount: Flow<Int>

    fun addToCart(item: CatalogItem)
    fun removeCartItem(item: CartItem)
}

interface AuthControlsVMDelegate {

    val authControlsState: Flow<AuthControlsState>

    fun onSignUpClick()
    fun onSignInClick()
}

Теперь вьюмодели наших экранов могут стать более чистыми:

@HiltViewModel
class CatalogListViewModel @Inject constructor(
    private val catalogInteractor: CatalogInteractor,
    private val cartVMDelegate: CartVMDelegate,
    private val authControlsVMDelegate: AuthControlsVMDelegate,
) : ViewModel(),
    AuthControlsVMDelegate by authControlsVMDelegate,
    CartVMDelegate by cartVMDelegate {

    val _catalogItems: MutableStateFlow<List<CatalogItem>> = MutableStateFlow(emptyList())
    val catalogItems: Flow<List<CatalogItem>> = _catalogItems

    init {
        viewModelScope.launch {
            _catalogItems.emit(
                catalogInteractor.getCatalogItems()
            )
        }
    }

}

При этом мы всегда можем переопределить любой метод и добавить логи, аналитику и т.д., если это понадобится:

override fun onSignInClick() {
    analytics.logEvent(/**/)
    authControlsVMDelegate.onSignInClick()
}

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

class AuthControlsViewDelegate {

    private var authControlsContainer: LinearLayout? = null
    private var signUp: Button? = null
    private var signIn: Button? = null

    fun setUp(
        viewLifecycleOwner: LifecycleOwner,
        authControlsContainer: LinearLayout,
        viewModel: AuthControlsVMDelegate
    ) {
        this.authControlsContainer = authControlsContainer
        signUp = authControlsContainer.findViewById<Button?>(R.id.sign_up)?.apply {
            setOnClickListener {
                viewModel.onSignUpClick()
            }
        }
        signIn = authControlsContainer.findViewById<Button?>(R.id.sign_in)?.apply {
            setOnClickListener {
                viewModel.onSignInClick()
            }
        }
        viewLifecycleOwner.lifecycleScope.launch {
            viewModel.authControlsState.collect {
                when (it) {
                    AuthControlsState.AVAILABLE -> authControlsContainer.visibility = View.VISIBLE
                    AuthControlsState.UNAVAILABLE -> authControlsContainer.visibility = View.GONE
                }
            }
        }
    }

}
@AndroidEntryPoint
class CatalogDetailsFragment : Fragment(R.layout.fragment_catalog_details) {

    val viewModel by viewModels<CatalogDetailsViewModel>()

    private var text: TextView? = null
    private var addToCart: ImageView? = null

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // ...
      AuthControlsViewDelegate().setUp(
        viewLifecycleOwner = viewLifecycleOwner,
        authControlsContainer = view.findViewById(R.id.auth_controls_container),
        viewModel = viewModel
      )
      // ...
    }

}

Итоговый код примера доступен на github

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

Надеюсь, что наш опыт поможет новым и существующим проектам стать более чистыми и поддерживаемыми, а значит — стать лучше.

Спасибо за внимание!

Комментарии (0)