Как я приложение на Compose писал

e49cd478ce6f2653f54a847216a0cfe7.png

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

И вот однажды, верстая очередной экран — распихивая кнопочки и инклуды с вложенными блоками, приходит озарение:»Частота упоминания Compose растет в экспоненциальной прогрессии, а регулярное откладывание полноценного изучения все сильнее отдаляет меня от экспертности в этом вопросе». 

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

Для ориентира по статье

Если вы хотите сразу перейти к Compose, то начинать можно с п.4, но я рекомендую читать с начала, чтобы мысли шли консистентно.

  1. Пара слов о том, кто я

  2. Compose. Вводная

  3. К применению на проекте

  4. Так, а чего про Compose то?!

    1. Кастомизация вьюшек и анимации

    2. Функции вместо Fragment’s

    3. Работа со списками

  5. А минусы то есть?

  6. Вердикт по Compose

  7. Дальнейшие планы

  8. Про проект поговорили, а чего по стеку?

  9. На этом все

Пара слов о том, кто я 

Меня зовут Виктор и я мобильный разработчик под Android с опытом в этой сфере 7+ лет и по совместительству основатель небольшой команды мобильной разработки Fill Team. Успел поработать как в маленьких стартапах, так и в крупных российских и зарубежных компаниях. Тем не менее на лютую экспертность не претендую, а лишь делюсь своим опытом.

А теперь вжух и обратно к Compose и применению на реальном проекте.

Compose. Вводная

Те, кто уже слышал в общих чертах, что такое Compose, можно переходить к следующему заголовку

Если, читатель, ты не совсем в курсе шо це за зверь такой — Compose, то тут очень коротко что это и с какими пирогами употребляется.

Полное наименование »Jetpack Compose» — новенький набор инструментов ныне рекомендуемый Android для создания пользовательского интерфейса. Отличается от классического подхода через xml тем, что является функциональным и все вьюшки (да и экраны), в конечном итоге, описываются как отдельные функции написанные на языке Kotlin.

Ощутимо упрощает и ускоряет разработку пользовательского интерфейса на Android и позволяет быстрее доносить до пользователей опыт взаимодействия с вашим продуктом.

Еще он может применяться не только c Android, но и в Desktop, Web и потенциально в iOS. Перспективно с точки зрения экономии бюджета компаний, которые пойдут в сторону кросс платформы без особой жертвы нативных особенностей платформы.

К применению на проекте

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

  • Берем примерный список экранов: пасскод, список паролей, просмотр пароля, создание/редактирование пароля, настройки

  • Рисуем «бесспорно профессиональную» схему экранов

  • Кидаем список и схему в дизайнера (благо такой имеется) и ждем пока полет творческой мысли специалиста по визуализации сотворит чудо

Примерно так выглядела схема экранов для дизайнера

Примерно так выглядела схема экранов для дизайнера

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

  • Ядро

  • Модули под экраны

  • Модуль под бизнесовую логику (под ту, что будет шариться)

  • Настраиваем gradle и прописываем все нужные зависимости в toml файлике

  • Набрасываем структуру директорий внутри модулей (чтобы все было по Clean, ну или почти все)

Еще много других телодвижений и вуаля. Получаем примерную структуру нашего проекта.

8e1789e9a2ce1b6f5e7466fd14a337ee.png

В общем, ничего особо интересного и все довольно стандартно. Еще несколько дней ожидания и тут подоспели результаты визуала.

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

На ките останавливаться не буду, расскажу в следующей статье, если будет желание у читателя.

9f61ed4d3d8be2e284cd399e90e7b15c.png

Красиво, правда? Я тоже получил эстетическое удовольствие. Обновил поля в дизайн системе, сделал пересборку aar«ника с UIKit и пошел постепенно делать красоту в коде приложения. 

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

Сейчас скажу только, что для того, чтобы пароли не текли, пришлось отказаться от сервера и сделать несколько слоев шифрования. Для шифрования использовалась симметрия AES с двумя ключами. Храню все только на устройстве, ну и для отдельных случаев добавил возможность штучного и полного переноса паролей в зашифрованном виде через физический носитель. 

Так, а чего по Compose то?!

Теперь о действительном профите от Compose с которым столкнулся лично я:

  • Кастомизация вьюшек и анимации

  • Функции вместо Fragment’s

  • Работа со списками

Это лишь тот минимум, который лично для меня сохранил множество часов на взаимодействие с семьей — вместо длительной верстки в xml и анимирования через ValueAnimator’ы и шейпы.

Кастомизация вьюшек и анимации

Что нам нужно сделать, когда мы хотим сделать вьюшку стандартным подходом, например, сделать индикатор введенных символов на экране ввода пасскода?

Паскод нарисованный дизайнером

Паскод нарисованный дизайнером

Если вы тоже об этом подумали, то правильно, шаги, примерно, следующие:

  • Описываем контракт в attrs.xml

  • Наследуемся от EditText и расширяем его (или можно использовать ViewGroup с несколькими View, которые будут символизировать нажатые цифры, или вообще по хардкору отрисовываем в onDraw наследника View)

  • А когда мы наследуемся, нужно еще прописать связку с контрактом в attrs.xml

  • А еще надо переопределить onMeasure и onLayout, при этом не забывая корректно мерить размеры в иерархии

  • Ну и если мы выбрали путь трушников и рисуем все на канве, тогда там вообще отдельные головняки начинаются (особенно, если нужно будет визуализировать состояния пинов анимировано, и, спойлер, в моем случае нужно)

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

Давайте глянем же, что нужно будет сделать если мы будем реализовывать композабл функцию отрисовки индикатора пасскода. В коде он у меня называется FTDotsIndicator.

Здесь можно посмотреть полный код индикатора

@Composable
@UiComposable
fun FTDotsIndicator(
    modifier: Modifier = Modifier,
    state: FTDotsIndicatorState,
    size: Int = FTTheme.styles.indicators.dots.integers.size,
    dotDiameterAccent: Dp = FTTheme.styles.indicators.dots.dimens.dotDiameterAccent,
    dotDiameterActive: Dp = FTTheme.styles.indicators.dots.dimens.dotDiameterActive,
    dotDiameter: Dp = FTTheme.styles.indicators.dots.dimens.dotDiameter,
    dotPaddings: Dp = FTTheme.styles.indicators.dots.dimens.dotPadding,
    dotsFailureColor: ColorType = FTTheme.palettes.indicators.dots.failureColor,
    dotsSuccessColor: ColorType = FTTheme.palettes.indicators.dots.successColor,
    dotsPassedColor: ColorType = FTTheme.palettes.indicators.dots.passedColor,
    dotsColor: ColorType = FTTheme.palettes.indicators.dots.color,
) {
    // Calculate real paddings between dots
    val maxDotCellSize = when {
        dotDiameterActive > dotDiameterAccent -> dotDiameterActive
        else -> dotDiameterAccent
    }
    val realPaddings = dotPaddings - maxDotCellSize + dotDiameter

    // Draw dots in concrete process visual state
    Row(
        horizontalArrangement = Arrangement.spacedBy(realPaddings),
        modifier = modifier,
    ) {
        // Animator for dots blinking
        val infinite = rememberInfiniteTransition(label = "ft_dots_infinite_transition_animation")
        val blinking by infinite.animateFloat(
            initialValue = 1f,
            targetValue = 0.25f,
            animationSpec = infiniteRepeatable(
                animation = tween(
                    durationMillis = DurationMaximum,
                    easing = FastOutLinearInEasing,
                    delayMillis = DelayMaximum,
                ),
                repeatMode = RepeatMode.Reverse
            ), label = "ft_dots_blinking_animation"
        )
        val alpha = when {
            state is FTDotsIndicatorState.Passed && state.blink -> blinking
            else -> 1f
        }

        // Draw dots in their individual box containers with maximum dot size
        repeat(size) { index ->
            // Draw individual box with maximum dot size
            Box(
                modifier = Modifier.size(maxDotCellSize),
                contentAlignment = Alignment.Center
            ) {

                // Animator for dot size depends at indicator state
                val dotSize by animateDpAsState(
                    targetValue = when {
                        state is FTDotsIndicatorState.Success ||
                                state is FTDotsIndicatorState.Failure -> dotDiameterAccent
                        state is FTDotsIndicatorState.Passed && index < state.size -> dotDiameterActive
                        else -> dotDiameter
                    }, label = "dt_dot_size_animation"
                )

                // Draw processable dot in its individual container
                Box(
                    modifier = Modifier
                        .size(dotSize)
                        .alpha(alpha)
                        .background(
                            colorType = when {
                                state is FTDotsIndicatorState.Passed && index < state.size -> {
                                    dotsPassedColor
                                }
                                state is FTDotsIndicatorState.Success -> dotsSuccessColor
                                state is FTDotsIndicatorState.Failure -> dotsFailureColor
                                else -> dotsColor
                            },
                            animate = state is FTDotsIndicatorState.Passed && state.blink ||
                                    state is FTDotsIndicatorState.Success ||
                                    state is FTDotsIndicatorState.Failure,
                            shape = CircleShape,
                        )
                )
            }
        }
    }
}

Больше не нужно выращивать attrs.xml с кучей параметров. Ура!

Все настраиваемые параметры — теперь просто параметры функции. Более того, так как мы завязали нашу композицию на дизайн системе, то и параметры все могут кастомизироваться как за счет простого изменения значения исходных полей в дизайн системе, так и индивидуально под индикатор в параметрах функции FTDotsIndicator.

Вызов же, в базовой имплементации вообще ляпота:

FTDotsIndicator(state = firstDots) 

// где firstDots - любой remember одного из трех состояний индикатора
//  FTDotsIndicatorState : [Empty; Failure; Success; Passed]

Compose позволяет сделать реализацию компоненты, совсем крошечной. Да, в срезе кода выше, где описывается полная FTDotsIndicator, может показаться, что кода не мало, но это только потому что там также реализована анимация состояний пинов индикатора.

Если говорим непосредственно про отрисовку, без анимирования, то это малый объем:

Здесь можно посмотреть код только на отрисовку состояний

@Composable
@UiComposable
fun FTDotsIndicator(
    modifier: Modifier = Modifier,
    state: FTDotsIndicatorState,
    size: Int = FTTheme.styles.indicators.dots.integers.size,
    ...,
) {

    // Calculate real paddings between dots
    val maxDotCellSize = when {
        dotDiameterActive > dotDiameterAccent -> dotDiameterActive
        else -> dotDiameterAccent
    }
    val realPaddings = dotPaddings - maxDotCellSize + dotDiameter

    // Draw dots in concrete process visual state
    Row(
        horizontalArrangement = Arrangement.spacedBy(realPaddings),
        modifier = modifier,
    ) {
        // Animator for dots blinking
        ...

        // Draw dots in their individual box containers with maximum dot size
        repeat(size) { index ->
            // Draw individual box with maximum dot size
            Box(
                modifier = Modifier.size(maxDotCellSize),
                contentAlignment = Alignment.Center
            ) {
                // Animator for dot size depends at indicator state
                ...

                // Draw processable dot in its individual container
                Box(
                    modifier = Modifier
                        .size(dotSize)
                        .alpha(alpha)
                        .background(
                            colorType = when {
                                state is FTDotsIndicatorState.Passed && index < state.size -> {
                                    dotsPassedColor
                                }
                                state is FTDotsIndicatorState.Success -> dotsSuccessColor
                                state is FTDotsIndicatorState.Failure -> dotsFailureColor
                                else -> dotsColor
                            },
                            animate = state is FTDotsIndicatorState.Passed && state.blink ||
                                    state is FTDotsIndicatorState.Success ||
                                    state is FTDotsIndicatorState.Failure,
                            shape = CircleShape,
                        )
                )
            }
        }
    }
}

Все остальное — это анимация мерцания, увеличения и уменьшения размера пинов.

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

Код ответственный за анимацию состояний пинов

@Composable
@UiComposable
fun FTDotsIndicator(
    modifier: Modifier = Modifier,
    state: FTDotsIndicatorState,
    size: Int = FTTheme.styles.indicators.dots.integers.size,
    .,
) {
    // Calculate real paddings between dots
    ...

    // Draw dots in concrete process visual state
    Row(
        horizontalArrangement = Arrangement.spacedBy(realPaddings),
        modifier = modifier,
    ) {
        // Animator for dots blinking
        val infinite = rememberInfiniteTransition(label = "ft_dots_infinite_transition_animation")
        val blinking by infinite.animateFloat(
            initialValue = 1f,
            targetValue = 0.25f,
            animationSpec = infiniteRepeatable(
                animation = tween(
                    durationMillis = DurationMaximum,
                    easing = FastOutLinearInEasing,
                    delayMillis = DelayMaximum,
                ),
                repeatMode = RepeatMode.Reverse
            ), label = "ft_dots_blinking_animation"
        )
        val alpha = when {
            state is FTDotsIndicatorState.Passed && state.blink -> blinking
            else -> 1f
        }

        // Draw dots in their individual box containers with maximum dot size
        repeat(size) { index ->
            // Draw individual box with maximum dot size
            Box(
                modifier = Modifier.size(maxDotCellSize),
                contentAlignment = Alignment.Center
            ) {
                // Animator for dot size depends at indicator state
                val dotSize by animateDpAsState(
                    targetValue = when {
                        state is FTDotsIndicatorState.Success ||
                                state is FTDotsIndicatorState.Failure -> dotDiameterAccent
                        state is FTDotsIndicatorState.Passed && index < state.size -> dotDiameterActive
                        else -> dotDiameter
                    }, label = "dt_dot_size_animation"
                )

                // Draw processable dot in its individual container
                ...
            }
        }
    }
}

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

Два состояния индикатора: красный (Failure) и бирюзовый (Passed + Success)

Два состояния индикатора: красный (Failure) и бирюзовый (Passed + Success)

Функции вместо Fragment’s

В стандартном подходе для реализации отдельного экрана или части экрана используются фрагменты. Правда в отдельных случаях до сих пор приходится ассоциировать Activity с отдельным экраном, но в целом проекты чаще Single Activity с разделением на экраны в виде Fragment«ов. 

В Compose же, экран — это функция. Функция, размеченная с помощью аннотацииComposable, — это дает понять Compose, что блок кода внутри функции может рекомпозироваться и отрисовываться на экране.

Так выглядит пример оределения экрана настроек в проекте

@Composable
fun SettingsScreen(
   viewModel: SettingsViewModel = getViewModel(),
   toSheetStateMapper: SettingsToSheetWrapperStateMapper = getKoin().get(),
   toChildActionMapper: SettingsToChildActionMapper = getKoin().get(),
) {
  ...
  // тут другие Composable функции с элементами экрана настроек
}

А вот так выглядит точка входа (место вызова)

SettingsScreen()

Элегантно, правда? Если бы я писал тот же экран в стандартном подходе, то у меня на него ушло бы примерно в 1,5–2 раза больше времени. 

Но самое приятное, что все это в перспективе очень хорошо дружится с кроссплатформой. А то, что кроссплатформенное, как правило, дешевле для конечного заказчика.

Работа со списками

Ну и отдельный бенефит — это работа со списками. Не важно с какими: динамичными, статичными. Любые списки в Compose — это чистое удовольствие.

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

  • Адаптер напиши

  • ViewHolder«ы напиши

  • RecyclerView вклей в страницу

  • Итемы сверстай

И это еще, если повезет и не будет вложенных списков или, чего хуже, вложенных списков с повторяющимся контентом (например лента категорий фильмов). Ужас.

Что же мы имеем при работе с Compose?  Если у тебя статический список — Column { … } / Row { … }. Если у тебя динамический список — LazyColumn { … } / LazyRow { … }. А дальше Composable функции для отрисовки итемов списков внутри с прямой завязкой на источник data объектов от источника.

Вот пример визуализации динамического списка токенов (паролей) в приложении

@Composable
private fun BoxScope.TokensContent(
    records: List,
    viewModel: TokensViewModel,
) {
    LazyColumn(
        modifier = Modifier
            .systemBarsPadding()
            .defaultMinSize(minHeight = 270.dp)
            .align(Alignment.BottomCenter)
            .wrapContentHeight(),
        contentPadding = PaddingValues(vertical = 24.dp),
        verticalArrangement = Arrangement.spacedBy(4.dp)
    ) {
        item { // тут у нас идет заголовочный итем
            Row(
                modifier = Modifier.padding(horizontal = dimensionResource(id = R.dimen.title_horizontal_padding)),
                verticalAlignment = Alignment.Bottom
            ) {
                TitleItem(
                    value = stringResource(id = R.string.tokens_title)
                )

                TokensSize(viewModel)
            }

            Spacer(modifier = Modifier.size(28.dp))
        }

        // а здесь мы пробегаемся по списко записей (токенов)
        //  и отрисовываем из все друг за другом
        records.forEach { record ->
            item(record.uid) {
                when {
                    record is RecordUiData.TokenUi -> {
                        TokenSection(
                            hasPassword = record.password.isNotEmpty(),
                            hasLogin = record.login.isNotEmpty(),
                            title = record.title,
                            onClickCopyPassword = {
                                viewModel.setAction(TokensAction.CopyPassword(record.id))
                            },
                            onClickCopyLogin = {
                                viewModel.setAction(TokensAction.CopyLogin(record.id))
                            },
                        ) {
                            viewModel.setAction(TokensAction.ShowInfo(record.id))
                        }
                    }
                    record is RecordUiData.NewsUi && NewsPlacement.TOKENS in record.placement -> {
                        ...
                    }
                }

            }
        }

        item {
            Spacer(modifier = Modifier.size(72.dp))
        }
    }
}

Честно скажу, LazyColumn сохранило мне не просто мгновения жизни, оно сохранило мне часы. Помимо скорости в разработке, Compose еще и более интуитивен в плане анализа кода, так как все композиции пишутся на том же языке, что и логическая часть, на Kotlin. 

Что же касается перформанса, то все отлично работает при отрисовке сотен карточек, ощутимой просадки по скорости отрисовки не заметил (плюс, если грузить по человечески: не все итемы сразу, а пачками, то все работает достаточно шустро). Ух, а если еще и KMP затащить.

А минусы то есть?

Разумеется есть. Например, тулинга хоть и много, но работает он пока не весь стабильно. Регулярно отваливаются превьюшки, периодами подлагивают дебаг сборки. Есть проблемы с библиотечными решениями, например, с привычный нам BottomSheet, в нынешней реализации местами работают неадекватно: не прибивается к низу экрана и fling жест может создавать артефакты (приходится допиливать под себя).

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

Вердикт по Compose

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

Что-то сильно завязанное на бизнес логику и сложное с точки зрения UI (например, работу с камерой) я бы пока не стал переписывать, так как там есть свои тонкости и не все они еще стабильно работают. Но в целом пора тащить.

Дальнейшие планы

В дальнейшие планы входит продолжать углубляться в Compose, притянуть KMP (чтобы шарить функционал еще и на iOS). Что же касается планов по остальному:

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

  • Развивать UIKit и пошарить его для публичного доступа на Gitlab

  • Написать пару статей о том, как устроена часть по безопасности в приложении, рассказать про дизайн систему в коде и ее реализацию на уровне UIKit; рассказать про небольшую библиотеку для межмодульной навигации, которую пришлось сделать под проект; и другое

  • Интегрировать KMP и вынести часть бизнесовой логики (делиться своим опытом, глядишь, окажусь для кого полезным)

Про проект поговорили, а чего по стеку?

Для тех, кому интересен наиболее полный стек проекта:

  • Принцип к построению Clean Architecture + MVVM + Многомодульная архитектура с подходом: модуль — максимально независимая единица

  • Coroutines; StateFlow

  • Room для локального хранения паролей в зашифрованном виде

  • Тестирование: Mockito; JUnit

  • Koin (можно было Dagger, но в планах кроссплатформа, а там лучше Koin)

  • Security-Crypto (androidx) как раз таки для упаковки пасов

  • Crashlytics

  • Compose

На этом все

Спасибо за внимание, надеюсь было итересно. А если заинтересовал проект, на котором я обкатывал Compose, то с ним можно ознакомиться в Google Play: https://play.google.com/store/apps/details? id=team.fill.keys (работает из под VPN или с телефона).

© Habrahabr.ru