Всем привет, я Android разработчик в компании Enaza подразделения Games, которая занимается дистрибуцией ключей для игр. В данном посте опишу опыт интеграции Jetpack Compose в существующий проект.
Проект: лаунчер мобильных игры по подписке.
Проект представляет из себя мобильное приложение написанное на Kotlin с использованием многомодульной архитектуры в качестве DI был использован Dagger Hilt, в качестве навигации был выбран frag-nav за свою гибкость открытия фрагментов из любой части приложения.
В данном опыте цель пере использовать дублирующиеся фрагменты дизайна интерфейса, а именно:
1. Карточка игры, использующаяся в личном кабинете в разделе игры, в поиске и каталоге

2. Экран активации промо кода, новый функционал, который зашел в приложение + он сам по себе не большой в верстке

Для начала нужно импортировать библиотеки compose через gradle
build.gradle.kts
dependencies { implementation(Dependencies.AndroidX.core) implementation(Dependencies.AndroidX.appCompat) implementation(Dependencies.Google.material) implementation(Dependencies.Compose.annotation) implementation(Dependencies.Compose.composeConstraint) implementation(Dependencies.Compose.composeMaterial) implementation(Dependencies.Compose.composeUi) implementation(Dependencies.Compose.composeUiTool) implementation(Dependencies.Compose.composeUiToolPreview) implementation(Dependencies.Compose.composeUiUtil) implementation(Dependencies.AndroidX.compose) implementation(Dependencies.Commons.codec) implementation(Dependencies.Coil.coil) implementation(Dependencies.Coil.coilCompose) }
Dependencies.kt
object Dependencies { object AndroidX { const val core = "androidx.core:core-ktx:1.6.0" const val appCompat = "androidx.appcompat:appcompat:1.3.0" const val activity = "androidx.activity:activity-ktx:1.3.1" const val constraintlayout = "androidx.constraintlayout:constraintlayout:1.1.3" const val recyclerview = "androidx.recyclerview:recyclerview:1.2.1" const val compose = "androidx.activity:activity-compose:1.4.0" } object Coil { const val coil = "io.coil-kt:coil-compose:1.3.2" const val coilCompose = "io.coil-kt:coil-compose:1.3.2" } object Compose { const val annotation = "androidx.annotation:annotation:1.2.0" const val composeUi = "androidx.compose.ui:ui:1.0.5" const val composeMaterial = "androidx.compose.material:material:1.0.5" const val composeUiUtil = "androidx.compose.ui:ui-util:1.0.5" const val composeUiToolPreview = "androidx.compose.ui:ui-tooling-preview:1.0.5" const val composeUiTool = "androidx.compose.ui:ui-tooling:1.0.5" const val composeNavigation = "androidx.navigation:navigation-compose:2.4.0-alpha10" const val composeLiveDate = "androidx.compose.runtime:runtime-livedata:1.0.5" const val composeConstraint = "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01" } }
Для разделения ответственности модулей, работу с компонентами Compose вынесену в отдельный модуль под названием compose-component в котором будут содержаться UI элементы, цвет, шрифты и screen @Composable контейнеры для отрисовки окон интерфейса приложения собранного из разных UI компонентов кнопочек, полей ввода, текста.

Рассмотрим Color.kt который содержит в себе цвета использующиеся в приложения
val uiBorder = Color(0x1f000000) val textHelp = Color(0xbdffffff) val uiBackground = Color(0xFF2B2C2F) val borderYear = Color(0x1AFFFFFF) val uiBlue = Color(0xFF0064FF) val uiError = Color(0xFFFF007A) val lightBlue = Color(0xFF349EFF) val blueBackgroundButton = Color(0xFF0064FF)
Shape.kt используется в Modifier для закругления границ UI элементов
val Shapes = Shapes( small = RoundedCornerShape(4.dp), medium = RoundedCornerShape(8.dp), large = RoundedCornerShape(16.dp) )
Type.kt содержит в себе шрифты с сопутствующими настройками для отображения текста в интерфейсе
private val Montserrat = FontFamily( Font(R.font.montserrat_regular, FontWeight.Normal), Font(R.font.montserrat_bold, FontWeight.Bold) )
placeholderTyp = TextStyle( fontFamily = Montserrat, fontSize = 16.sp, color = Color.LightGray, fontWeight = FontWeight.Light, lineHeight = 16.sp, letterSpacing = 1.25.sp )
PromoCodeUI.kt это screen окна активации промо кода включающий в себя две @Composable функции для состояния экрана активации с вводом промо кода и успешной активации
@Composable fun activatePromoCode( valuePromoCode: (String) -> Unit, onNext: () -> Unit, screenState: PromoCodeState? ) { var promoCodeInput by rememberSaveable { mutableStateOf("") } val state = PromoCodeState(screenState?.state ?: ScreenState.UNKNOWN) val isError = state.isFailed && promoCodeInput.isNotBlank() val focusRequester = FocusRequester() LaunchedEffect(true) { focusRequester.requestFocus() } ConstraintLayout(modifier = Modifier .fillMaxSize() .padding(horizontal = 30.dp)) { val (containerContent, header) = createRefs() createVerticalChain(containerContent, chainStyle = ChainStyle.Packed(0.3F)) Text( modifier = Modifier .fillMaxWidth() .constrainAs(header) { top.linkTo(parent.top, margin = 31.dp) //растягиваем по всей ширине контейнера с дальнейшим обрезанием текста если осуществился выход за границы width = Dimension.fillToConstraints }, text = "Промокод".uppercase(), style = headerCatalog, textAlign = TextAlign.Center ) Column( modifier = Modifier .fillMaxWidth() .constrainAs(containerContent) { top.linkTo(header.bottom) bottom.linkTo(parent.bottom) start.linkTo(parent.start, margin = 20.dp) end.linkTo(parent.end, margin = 20.dp) } ) { Text( modifier = Modifier .padding(bottom = 35.dp) .align(Alignment.CenterHorizontally), style = headerTyp, color = Color.White, textAlign = TextAlign.Center, text = "Чтобы начать пользоваться приложением активируйте промо код" ) GbTextField( modifier = Modifier .fillMaxWidth() .padding(bottom = if (isError) 10.dp else 20.dp) .align(Alignment.CenterHorizontally) .focusRequester(focusRequester), value = promoCodeInput, onValueChange = { promoCodeInput = it valuePromoCode.invoke(it) }, placeholder = "Введите промокод", isError = state.isFailed ) if (isError) { Text( modifier = Modifier .padding(bottom = 20.dp) .align(Alignment.Start), style = textFieldErrorTyp, fontSize = 14.sp, textAlign = TextAlign.Start, text = "Такого промокода не существует" ) } GbBlueButton( modifier = Modifier .fillMaxWidth() .height(50.dp) .align(Alignment.CenterHorizontally), textButton = "Активировать".uppercase(), onClick = onNext ) } } } @Composable fun successPromoCode( onFinish: () -> Unit, isCheckPermission: Boolean ) { Column( modifier = Modifier .fillMaxSize() .padding(start = 35.dp, end = 35.dp, top = 123.dp), horizontalAlignment = Alignment.CenterHorizontally ) { Image( painter = painterResource(id = R.drawable.ic_support_check_ok), contentDescription = null, colorFilter = ColorFilter.tint(Color.White), alignment = Alignment.Center, modifier = Modifier .padding(bottom = 35.dp) ) Text( modifier = Modifier .fillMaxWidth() .padding(bottom = 15.dp), text = "Промокод активирован".uppercase(), style = headerCatalog, textAlign = TextAlign.Center ) Text( modifier = Modifier .padding(bottom = 60.dp), style = headerTyp, color = Color.White, textAlign = TextAlign.Center, text = "Подписка успешно активирована" ) val textButtonSuccess = if (isCheckPermission) "Перейти к играм" else "Перейти к разрешениям" GbBlueButton( modifier = Modifier .fillMaxWidth() .height(50.dp), textButton = textButtonSuccess.uppercase(), onClick = onFinish ) } } @Preview() @Composable private fun PromoCodeContentPreview() { activatePromoCode({}, {}, screenState = PromoCodeState(ScreenState.FAILED)) }
Button.kt файлик с кнопочками использующиеся в проекте
@Composable internal fun GbStateCardButton( state: StateDownload, modifier: Modifier = Modifier, contentPadding: PaddingValues, onClick: () -> Unit ) { val isInstallComplete = state == StateDownload.installComplete Button( onClick = { onClick.invoke() }, contentPadding = contentPadding, modifier = modifier, colors = if (isInstallComplete) ButtonDefaults.outlinedButtonColors( backgroundColor = Color.Transparent, contentColor = uiError ) else ButtonDefaults.buttonColors( backgroundColor = uiBlue, contentColor = Color.White ), border = if (isInstallComplete) BorderStroke(1.dp, uiError) else null ) { when (state) { StateDownload.wait, StateDownload.error -> Text(text = stringResource(id = R.string.game_card_download), style = textBoldMontserrat) StateDownload.downloadComplete -> Text(text = stringResource(id = R.string.game_card_install), style = textBoldMontserrat) StateDownload.download -> Text(text = stringResource(id = R.string.game_card_stop), style = textBoldMontserrat) StateDownload.installComplete -> Text(text = stringResource(id = R.string.game_card_delete), style = textBoldMontserrat, color = uiError) } } }
StateDownload enum class опрделяющий состояние кнопки в карточке игры
enum class StateDownload { //игры скачивается, показывать кнопку остановить download, //игры скачана, показывать кнопку установить downloadComplete, //игра установлена, показывать кнопку удалить installComplete, //ожидание скачивания, показывать кнопку скачать wait, //ошибка скачивания, показывать кнопку скачать error }
PromoCodeState data class хранящий в себе состояние валидации введенного промо кода.
data class PromoCodeState(var state: ScreenState = ScreenState.UNKNOWN) { val isFailed: Boolean get() = state == ScreenState.FAILED val isSuccess: Boolean get() = state == ScreenState.SUCCESS }
В функции PromoCodeContentPreview() вывожу на preview экран ввода промо кода, то как он бы выглядел при запущенному эмуляторе или устройстве.

,
var promoCodeInput by rememberSaveable { mutableStateOf("") }
отличительной особенностью от remember является, что Saveable хранит в себе состояние введённого значения при перевороте экрана.
Начнём первую интеграцию, подружим fragment с @Composable функцией
PromoCodeFragment.kt
@AndroidEntryPoint class PromoCodeFragment : BaseFragment() { override val screenViewModel by viewModels<PromoCodeViewModel>() override var isCompose: Boolean = true override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { return ComposeView(requireContext()).apply { setContent { PromoCodeScreen( valuePromoCode = { screenViewModel.savePromoCode(it) }, onNext = { screenViewModel.checkPromoCode() }, onFinish = { }, isCheckPermission = requireArguments().getBoolean(CHECK_PERMISSION, false) ) } } } @Composable fun PromoCodeScreen( valuePromoCode: (String) -> Unit, onNext: () -> Unit, onFinish: () -> Unit, isCheckPermission: Boolean ) { val screenState = screenViewModel.promoCodeState.asLiveData().observeAsState() GbSurface { when(screenState.value?.state ?: ScreenState.UNKNOWN) { ScreenState.FAILED, ScreenState.UNKNOWN -> { activatePromoCode ( onNext = onNext, valuePromoCode = valuePromoCode, screenState = screenState.value ) } ScreenState.SUCCESS -> { hideSoftKeyboard() successPromoCode( onFinish = onFinish, isCheckPermission = isCheckPermission ) } } } } companion object { private const val CHECK_PERMISSION = "check_permission" fun newInstance(isCheck: Boolean) = PromoCodeFragment().apply { arguments = bundleOf(CHECK_PERMISSION to isCheck) } } }
для отображения ранее написанных screen в методе onCreateView фрагмента используем ComposeView(context: Context)
Теперь попробуем все списки RecyclerView использующиеся в проекте пере использовать, для этого возьмем источник данных UI это будет Adapter, если ранее для отображения элемента списка в методе onCreateViewHolder делали inflate layout то теперь здесь будем передавать compose view holder
class SearchAdapter( private val showCard: (CatalogTemplateAdapterModel) -> Unit ) : ListAdapter<CatalogTemplateAdapterModel, ComposeCardItemViewHolder>(DiffUtilCallback()) { private var arr: List<CatalogTemplateAdapterModel> = emptyList() override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, ): ComposeCardItemViewHolder { return ComposeCardItemViewHolder( showCard = showCard, composeView = ComposeView(parent.context) ) } override fun onViewRecycled(holderCardItem: ComposeCardItemViewHolder) { holderCardItem.composeView.disposeComposition() } override fun onBindViewHolder(holderCardItem: ComposeCardItemViewHolder, position: Int) { holderCardItem.bind(arr[position]) } override fun getItemCount() = arr.size fun setArr(catalogCard: List<CatalogTemplateAdapterModel>) { this.arr = catalogCard notifyDataSetChanged() } fun existCard() = arr.isNotEmpty() }
class ComposeCardItemViewHolder( val showCard: (CatalogTemplateAdapterModel) -> Unit, val composeView: ComposeView, private val width: Int = 0 ) : RecyclerView.ViewHolder(composeView) { init { composeView.setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) } fun bind(input: CatalogTemplateAdapterModel) { composeView.setContent { if(width != 0){ CartItem(input, showCard, modifier = Modifier.width(width.dp)) } else { CartItem(input, showCard) } } } }
во ViewHolder класса ComposeCardItemViewHolder в блоке init для корректной работы жизненного цикла item очищаем их при помощи установки стратегии ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed при удалении view очищаем item с данными.
Итоговый результат представлен на видео фрагменте

В методе bind класса ComposeCardItemViewHolder ранее было присваивание элементам интерфейса значений, теперь стало проще с compose передаём в @Composable функцию данные модельки и происходит отрисовка соответствующего контента.
Заключение: в итоге интеграция Compose в существующий проект занимает не так много сил, как это ожидается. Желаю всем попробовать и у себя в проекте применить методики compose, начинать нужно с малого.
Примечание: мой первый опыт публикаций статей на Habr.
