Используем делегаты в android-приложениях

от автора

Всем привет, меня зовут Алексей, и я отвечаю за разработку 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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/company/constanta/blog/696162/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *