Используем делегаты в android-приложениях02.11.2022 23:46
Всем привет, меня зовут Алексей, и я отвечаю за разработку 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()
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(R.id.cart_fab)?.apply {
setOnClickListener {
showCartDialog()
}
}
}
private fun showCatalogItems(items: List) {
// ...
}
private fun showCartDialog() {
// ...
}
}
@HiltViewModel
class CatalogListViewModel @Inject constructor(
private val catalogInteractor: CatalogInteractor,
private val cartInteractor: CartInteractor,
) : ViewModel() {
val _catalogItems: MutableStateFlow> = MutableStateFlow(emptyList())
val catalogItems: Flow> = _catalogItems
val cartItems: Flow> = cartInteractor.cartItems
val cartItemsCount: Flow = 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> = cartInteractor.cartItems
val cartItemsCount: Flow = 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> = MutableStateFlow(emptyList())
val catalogItems: Flow> = _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 = MutableStateFlow("")
val itemInfo: Flow = _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(R.id.cart_fab)?.apply {
setOnClickListener {
showCartDialog()
}
}
}
private fun showCartDialog() {
// ...
}
}
@AndroidEntryPoint
class CatalogListFragment : BaseCartFragment(R.layout.fragment_catalog_list) {
val viewModel by viewModels()
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) {
// ...
}
}
@AndroidEntryPoint
class CatalogDetailsFragment : BaseCartFragment(R.layout.fragment_catalog_details) {
val viewModel by viewModels()
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(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
abstract class BaseAuthControlsViewModel(
private val authInteractor: AuthInteractor
) : ViewModel() {
val authControlsState: Flow = 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()
}
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> = MutableStateFlow(emptyList())
val catalogItems: Flow> = _catalogItems
val cartItems: Flow> = cartViewModel.cartItems
val cartItemsCount: Flow = cartViewModel.cartItemsCount
val authControlsState: Flow = 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>
val cartItemsCount: Flow
fun addToCart(item: CatalogItem)
fun removeCartItem(item: CartItem)
}
interface AuthControlsVMDelegate {
val authControlsState: Flow
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> = MutableStateFlow(emptyList())
val catalogItems: Flow> = _catalogItems
init {
viewModelScope.launch {
_catalogItems.emit(
catalogInteractor.getCatalogItems()
)
}
}
}
При этом мы всегда можем переопределить любой метод и добавить логи, аналитику и т.д., если это понадобится:
override fun onSignInClick() {
analytics.logEvent(/**/)
authControlsVMDelegate.onSignInClick()
}
Кажется, все проблемы во вьюмоделях решены. Вернёмся к фрагментам: отдельные куски кода, обрабатывающие данные от делегатов вьюмоделей, тоже можно вынести в отдельные классы и подключать с помощью композиции в нужные фрагменты — в этом случае фрагмент будет полностью управлять жизненным циклом делегата, его существование отдельно от фрагмента бессмысленно:
@AndroidEntryPoint
class CatalogDetailsFragment : Fragment(R.layout.fragment_catalog_details) {
val viewModel by viewModels()
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
Такой способ организации кода может быть применён не только для переиспользования общей логики между несколькими экранами, но также для декомпозиции больших вьюмоделей, содержащих много частей, каждая из которых имеет свой алгоритм работы и зависимости. При таком подходе можно написать тесты отдельно на каждый компонент вьюмодели.
Надеюсь, что наш опыт поможет новым и существующим проектам стать более чистыми и поддерживаемыми, а значит — стать лучше.