Как стать автором
Обновить
140.79
hh.ru
HR Digital

Адаптация Jetpack Compose в hh.ru

Время на прочтение24 мин
Количество просмотров5K

Представьте: теплый осенний вечер, на столе чашечка чего-нибудь вкусного, за окном порхают пожелтевшие листья и тонко насвистывает ветер. Но на душе скребутся коварные мыши. И вам точно известно, откуда эти мыши растут: еще летом вышел стабильный Jetpack Compose, а вы таки не затащили его в ваши продакшн-приложения. 

И тут в вашу светлую голову забредает шальная мысль: “Почему бы не начать творить что-нибудь эдакое, великое и прекрасное, чтобы все ахнули в восхищении и увидели, какой вы замечательный сотрудник?” Обычно после таких потрясающих идей есть варианта развития событий: либо в проекте действительно появляется нечто прекрасное, либо всё становится просто ужасным. А вот как получилось у нас, я вам сегодня и расскажу. 

Всем привет! Меня зовут Паша Стрельченко, я Android-разработчик в hh.ru. В этой статье поведаю историю о том, как начиналась адаптация Jetpack Compose в нашем продакшн-приложении. 

С чего начинается адаптация

Мы поставили перед собой довольно амбициозную задачу: реализовать все компоненты дизайн-системы на Compose. Причем не просто абстрактно реализовать, а именно внутри проекта hh.ru. И разница здесь существенная.

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

Поэтому, когда вы только-только затаскиваете тот же Jetpack Compose в ваш проект, могут возникнуть сложности как с гредлом (Gradle). Например, у вас может быть:

  • Маленькая или не совсем та версия, которая поддерживается Jetpack Compose.

  • Не та версия Android SDK. Например, для Jetpack Compose 1.1 требуется minCompileSdk 31, а поднять такую версию соответственно это определённый объем работы.

  • Не та версия Kotlin. И поднятие версии Kotlin — не настолько быстрый процесс, как хотелось бы. 

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

А приведи пример проблемы

Экспериментируя с реализацией различных View, я решил опробовать работу с accessibility в Compose. Написал код, запустил Talkback (специальная утилита для accessibility, для незрячих людей, которые щелкают на определенный элемент экрана, и этот talkback говорит им на что нажали) и... приложение начало крашится при старте. Я долго не мог понять в чём дело, потому что выбрасываемое исключение было не очень понятным.

А дело было в том, что некоторые версии AndroidX библиотек были ниже рекомендуемых для взаимодействия с Jetpack Compose. Но для того чтобы это понять, пришлось потратить довольно много времени. В общем, подключите Jetpack Compose к вашему приложению и проверьте, все ли версии библиотек у вас правильные, хватает ли вам версии Gradle, все ли хорошо с версией Kotlin-а и так далее. 

У нас не было особых проблем, потому что мы своевременно обновляем версии Gradle, Kotlin и Android. Поэтому нам достаточно было написать маленький convention-плагин, который умеет подключать Jetpack Compose в конкретный модуль, а не на весь проект сразу. Так и поступили, и у нас всё завелось. 

Небольшой convention-плагин для подключения Compose-а к модулю
import com.android.build.gradle.BaseExtension

configure<BaseExtension> {
    @Suppress("UnstableApiUsage")
    with(buildFeatures) {
        compose = true
    }

    @Suppress("UnstableApiUsage")
    composeOptions {
        kotlinCompilerExtensionVersion = "1.1.1
    }
}

dependencies {
    add("implementation", "androidx.compose.runtime:runtime:1.1.1")
}

Дизайн-система в hh.ru

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

Компоненты дизайн-системы

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

Атомы дизайн-системы
Атомы дизайн-системы

Не забыли они и про страницы с отдельными компонентами, например, баннерами:

У баннеров есть 4 разных варианта цветовых схем и по 7 вариантов расположения компонентов
У баннеров есть 4 разных варианта цветовых схем и по 7 вариантов расположения компонентов

Или, например, кнопки:

Различных вариантов кнопок гораздо больше: 7 вариантов контейнера (Title / Title Subtitle / etc) умножаем на 12 цветовых схем, получаем 84 варианта >__<
Различных вариантов кнопок гораздо больше: 7 вариантов контейнера (Title / Title Subtitle / etc) умножаем на 12 цветовых схем, получаем 84 варианта >__<

Почти каждый компонент дизайн-системы имеет несколько разных вариантов.

Дизайн-система в hh.ru — довольно увесистая конструкция с разными компонентами. Она действительно обширная, и мы потратили массу времени, когда реализовывали её на обычных xml-вьюшках. Сегодня у нас есть один большой и жирный модуль, внутри которого обитают: большое количество xml-ресурсов, тьма Kotlin-овских файлов, которые обеспечивают работу с этими ресурсами и так далее, и тому подобное.

Модуль дизайн-системы на XML
Мы потратили немало времени на реализацию дизайн-системы на XML
Мы потратили немало времени на реализацию дизайн-системы на XML

Я помню этот модуль совсем крохой, но за пару лет он превратился в довольно крупную особь с десятками компонентов.

Мы старались сделать всё то же самое на Jetpack Compose. Один доблестный разработчик запилил отдельный модуль design system compose, начал набрасывать в него атомы и некоторые готовые компоненты, но довольно скоро понял, что в одиночку затащить такой объем работ — слишком объемная и практически нереализуемая задача. Вернее, задача-то реализуемая, но прежде чем все эти компоненты будут реализованы, Jetpack Compose уже несколько раз обновится, выпустит сто пятьдесят стабильных версий, и к этому моменту придется всё переписывать.

Было и вправду грустно
Это был результат нескольких недель "внеклассных" занятий
Это был результат нескольких недель "внеклассных" занятий

Какие-то компоненты получалось сделать довольно легко, а над некоторыми приходилось повозиться: например, наши кнопки не совсем вписывались в стандартный компоузовский Button (хотя бы из-за наших стилей текста, которые тоже не матчатся на MaterialTheme, и кастомных минимальных размеров), и пришлось реализовывать их с нуля на Surface-е. Banner-ы тоже отняли время, потому что из-за специфичных отступов у разных элементов я всё никак не мог решить, стоит ли делать его на Constraint-е (он в Compose-е выглядит слегка неудобно), или же попробовать сделать на обычных Column-ах и Row.

Смена фокуса

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

Это важно, потому что мы стопудово не сможем:

  • Сразу отказаться от всего фреймворка фрагментов.

  • Сразу перейти на навигацию на компоузовских функциях.

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

Первая жертва для Jetpack Compose 

В качестве первой жертвы мы выбрали экран о приложении. Во-первых, он очень простой — без всякой там бизнес-логики и прочего. Состоит просто из захардкоженной модельки, которая отправляется на рендер во фрагмент. 

Как выглядит экран "О приложении"?

Как видите, ничего сверхъестественного.

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

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

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

Найди пять отличий
Сравниваем XML (слева) и Compose (справа)
Сравниваем XML (слева) и Compose (справа)

Отличия точно есть, и даже больше пяти =)

Схема работы экрана "О приложении"

Но что конкретно мы делали? Для начала кратко расскажу про схему работы этого экрана. Как я уже писал выше, она максимально проста: у нас был фрагмент, который в специальном коллбэке onCreateViev настраивал некоторые вьюшки и адаптер для  RecyclerView, также была ViewModel, генерирующая специальный UiState и отправляющая ее во фрагмент. А фрагмент, в свою очередь, брал этот State и отправлял его в RecyclerView на рендер. 

Небольшая схема для наглядности

Типичный экран на RecyclerView.

Итак, что мы сделали. Во-первых, мы почти не трогали существующую ViewModel. Внутри нее, до перевода на Compose, был специальный UiConverter, который принимал на вход захардкоженную модель и отдавал список элементов для RecyclerView.

Примерный код XML-ой ViewModel
internal class AboutViewModel @Inject constructor(
    private val deps: AboutDeps,
    private val uiConverter: AboutUiConverter,
) : ManualStateViewModel<Nothing, AboutUiState>() {

    ...

    override fun onFirstAttach() {
        super.onFirstAttach()
        setState(
            state = uiConverter.toUiState(
              deps.getAboutScreenModel(), listeners
            )
        )
    }

}

ManualStateViewModel — это класс из нашего фреймворка, который отличается от обычной ViewModel наличием rx-ового BehaviorSubject-а под капотом для подписки на UiState.

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

А вот так сделали для Compose-варианта
internal class AboutComposeViewModel @Inject constructor(
    private val deps: AboutDeps,
) : ManualStateViewModel<Nothing, AboutScreenModel>() {

    override fun onFirstAttach() {
        super.onFirstAttach()

        setState(deps.getAboutScreenModel())
    }
	
  	...

}

UiConverter для этого экрана стал бесполезен, но это не значит, что он будет не нужен на всех остальных экранах: иногда требуется отобразить на множестве разных экранов один и тот же кусочек UI, тогда конвертеры могут смапить domain-сущность в одну и ту же ui-модель.

Во-вторых, мы немножечко почистили фрагмент. Раньше у нас в нем были настройки вьюшек, RecyclerView-адаптера и специальный метод, который назывался RenderState. Он принимал на вход UiState и отправлял ячейки в RecyclerView.

Как было раньше
internal class AboutFragment : BaseFragment(R.layout.fragment_about) {

  	...
  
    private val viewModel: AboutViewModel by viewModelPlugin(
        renderState = this::renderState,
        viewModelProvider = { di.getInstance() }
    )

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        view.findViewById<MaterialToolbar>(DesignSystemR.id.toolbar)?.apply {
            setTitle(R.string.about_screen_title)
            setNavigationIcon(DesignSystemR.drawable.ic_arrow_back)
            setNavigationOnClickListener { activity?.onBackPressed() }
        }
        setupRecyclerView()
    }
    
    private fun setupRecyclerView() {
        with(binding.fragmentAboutRecyclerContent) {
            isNestedScrollingEnabled = false
            adapter = delegateAdapter
            layoutManager = LinearLayoutManager(context)
        }
    }

		// Метод для отрисовки UiState-а 
    private fun renderState(state: AboutUiState) {
        delegateAdapter.submitList(state.items)
    }
   

}

После перевода на Compose в методе onCreateView у нас осталось только создание компоузовской вьюшки, а метод renderState немного преобразился. Внутри него мы вызываем метод setContent, чтобы связать мир XML с миром Jetpack Compose. И вот туда мы отправляли ту захардкоженную модельку, которую нам нужно. Таким образом наш фрагмент немного похудел, из него исчезла настройка вьюшек. 

Как сделано сейчас
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
  return ComposeView(requireContext()).also(ComposeView::addSystemTopMargin)
}


private fun renderState(model: AboutScreenModel) {
  (view as? ComposeView)?.setContent {
    HHTheme {
      AboutScreen(
        aboutScreenModel = model,
        clicks = AboutComposeClicks(
          onBackIconClicked = { activity?.onBackPressed() },
          onOfficialPageLinkClicked = { viewModel.onLinkClicked(it) },
          onSocialNetworkLinkClicked = { viewModel.onSocialNetworkLinkClicked(it) },
          onRateAppClicked = { viewModel.onRateAppClicked() },
        ),
        modifier = Modifier.fillMaxSize()
      )
    }
  } ?: error("Root view is not ComposeView, or null | $view")
}

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

Последнее, что мы сделали — верстку секций экрана на Compose. До перехода у нас был простой экран с Toolbar и RecyclerView. Всё это было обернуто в LinearLayout.

Действительно хотите на это посмотреть?..
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <include
        android:id="@+id/fragment_about_appbar"
        layout="@layout/toolbar" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/fragment_about_recycler_content"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:clipToPadding="false"
        android:persistentDrawingCache="animation|scrolling"
        android:scrollbarStyle="outsideOverlay" />

</LinearLayout>

Я же говорил! Простой экран.

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

Кода стало больше, но он стал проще
@Composable
internal fun AboutScreen(
    viewModel: AboutComposeViewModel,
    modifier: Modifier = Modifier,
    onBackIconClicked: () -> Unit,
) {
    val state = viewModel.subscribeUiState(initial = AboutScreenModel()).value

    Scaffold(
        topBar = { AboutScreenToolbar(onBackIconClicked) },
        backgroundColor = HHColors.white,
        modifier = modifier,
    ) {
        Column(
            modifier = Modifier
                .verticalScroll(rememberScrollState()),
        ) {
            AboutScreenHeader(
                headerModel = state.headerModel,
                modifier = Modifier
                    .padding(start = 16.dp, end = 16.dp, top = 28.dp, bottom = 24.dp)
                    .align(Alignment.CenterHorizontally)
                    .testTag(AboutScreenTestTags.header),
            )
            LinksSection(
                viewModel = viewModel,
                links = state.links,
            )

            if (state.socialNetworkLinks.isNotEmpty()) {
                Spacer(modifier = Modifier.size(HHDimens.spacer.xs))
                SectionHeaderSmall(header = stringResource(id = R.string.about_screen_official_pages))
                LinksSection(
                    viewModel = viewModel,
                    links = state.socialNetworkLinks,
                )
                Spacer(modifier = Modifier.size(HHDimens.spacer.m))
            }
        }
    }
}

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

@Composable
private fun AboutScreenHeader(
    headerModel: AboutScreenHeaderModel,
    modifier: Modifier = Modifier,
) {
    Column(
        modifier = modifier,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        headerModel.headerAppIcon?.let { iconRes ->
            Image(
                painter = painterResource(id = iconRes),
                contentDescription = null,
                modifier = Modifier
                    .size(76.dp),
            )
        }
				SpacerS()
        Text(
            text = headerModel.appName,
            style = HHTextStyles.Title1,
            color = HHColors.black,
        )
        SpacerXXS()
        Text(
            text = stringResource(
              id = R.string.about_screen_app_version, 
              headerModel.appVersion
            ),
            style = HHTextStyles.Caption2,
            color = HHColors.gray,
        )
    }
}

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

Выводы после перевода первого экрана

После перевода этого простого экрана на Compose мы сделали следующие выводы:

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

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

Во-вторых, стало проще делать множество превью. В XML у нас был специальный namespace, который назывался tools. Благодаря ему можно было менять внешний вид вашей View при работе с Layout-дизайнером: цвет background-а, шрифты, цвет текста и многое другое. Однако, чтобы показать несколько превью одной и той же вьюшки с какими-то разными параметрами приходилось заморачиваться. Либо создавать кастомную View, внутри которой делать парсинг разных атрибутов, либо делать копипаст в отдельный временный XML-ный файл: вставлять код, менять атрибуты и смотреть, как выглядит. 

В Compose есть специальная аннотация @Preview, благодаря которой вы можете либо взять отдельный кусочек этой вьюшки и отправить ее на устройство, либо сделать несколько функций, отмеченных аннотаций превью и посмотреть, как они смотрятся в Layout-дизайнере вместе. Это довольно удобно, когда вы разрабатываете новый компонент.

Но есть нюанс

До недавнего времени аннотация @Preview работала только в application-модулях, но к счастью это исправили. Однако в документации до сих пор не написано, что для корректной работы этой фичи нужно добавлять несколько дополнительных зависимостей в build.gradle.

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

Немного про AdapterDelegate

Многие уже работали с обычными RecyclerView и делали так называемые Adapter Delegate-ы. Это специальные кусочки будущего адаптера RecyclerView, которые умеют отображать определенный ViewType, чтобы вы могли комбинировать различные ViewType’s в одном списке. 

Каждый AdapterDelegate нацелен на определенную модельку элемента списка и умеет инфлейтить и биндить именно ее.

Код из нашего фреймворка делегатов

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

class AddressAdapterDelegate(
  private val action: (AddressDisplayableItem) -> Unit
) : BaseAdapterDelegate<AddressDisplayableItem, DisplayableItem, BaseViewHolder>() {

    override fun onCreateViewHolder(layoutInflater: LayoutInflater, parent: ViewGroup): RecyclerView.ViewHolder {
        return fromLayoutId(R.layout.item_address_suggest, layoutInflater, parent)
    }

    override fun isForViewType(item: DisplayableItem, items: List<DisplayableItem>, position: Int): Boolean {
        return item is AddressDisplayableItem
    }

    override fun onBindViewHolder(item: AddressDisplayableItem, viewHolder: BaseViewHolder) {
        with(viewHolder.getViewBinding(ItemAddressSuggestBinding::bind)) {
            itemAddressSuggestPrimary.text = item.firstAddress
            itemAddressSuggestSecondary.text = item.secondAddress
            root.setOnClickListener { action.invoke(item) }
        }
    }
}

Для каждого ViewType вы создавали отдельный AdapterDelegate, потом привязывали их к единому Delegate менеджеру, который в итоге превращался в RecyclerView-адаптер. Затем вы привязывали его к RecyclerView и ваш список мог отображать несколько типов. 

private fun initDelegationAdapter(): DelegationAdapter<DisplayableItem> {
  return DelegationAdapter<DisplayableItem>().withDelegates(
    ArticleDelegate { articleCode -> viewModel.onItemClicked(articleCode) },
    LoadingAdapterDelegate(),
    ErrorAdapterDelegate(
      defaultText = getString(ApplicantCoreUiBaseR.string.default_list_paginator_next_page_error),
      refreshButtonText = getString(ApplicantCoreUiBaseR.string.action_update),
      click = { viewModel.onLoadNextPageAction() }
    )
  )
}

Но тут наш дизайнер придумал ячейки – Cell-ы. Их идея заключалась в том, что каждая ячейка списка теперь состоит из левой и правой частей. Есть некоторое количество вариантов левой части и некоторое количество вариантов правой. Дизайнеры их очень весело комбинируют и получают огромное количество вариантов для элементов RecyclerView. 

Огромное — это сколько?

Дизайнеры нарисовали 14 вариантов левой части и 7 вариантов правой, всего-то 98 разных ячеек.

Мы посмотрели, какое API для работы с ячейками получилось у наших коллег из iOS, и оно нам очень понравилось. Если присмотреться, то они просто объявляли некоторые алиасы к определенным контейнерам, которые умели принимать и левую, и правую часть, а затем проинвентаризовали их двумя дженериками — дженерик левой части и дженерик правой. 

Парочка примеров описанных ячеек
public typealias IconTitleChevronItem = LeftRightItem<LeftIconTitle, RightChevron>

public typealias CheckboxSubtitleDetailItem = LeftRightItem<LeftCheckboxSubtitle, RightDetail>

public typealias ExpandTagsItem = LeftRightItem<ExpandTagsContent, LeftRightEmptyContent>

Очень классная фича Swift — можно описать generic-тип и потом просто создавать объект указанного типа:

public final class ContainerCell<ContentView: ContainerContentView>: UICollectionViewCell {

  ...

	private let itemContentView: ContentView

  public override init(frame: CGRect = .zero) {
    // ну вот просто взяли и создали что-то из generic-а, как так-то?
    itemContentView = ContentView(frame: frame).configureForAutoLayout()

    super.init(frame: frame)

    backgroundColor = .clear
    clipsToBounds = false

    setupItemContentView()
    setupLongPresspGestureRecognizer()
  }

}

Благодаря этому, просто описав typealias можно создать все нужные объекты под капотом ячейки.

Мы решили сделать точно так же, и… Прошли через все пресловутые стадии – отрицание, гнев, торг и далее по списку.

Из состояния “да всё мы сейчас сделаем в два счета” мы перешли к подозрениям, что задача сложнее, чем нам казалось. А затем и вовсе очень сильно расстроились, потому что у нас не получилось сделать ровно то же самое, что у ребят в iOS.

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

Пример ячейки из дизайн-системы
class IconTitleCell<DataModel>(
    val id: String,
    override val dataModel: DataModel,
    override val icon: CellIcon,
    override val title: CellTitle,
    override val isDisabled: Boolean = false,
    override val separatorType: SeparatorType = SeparatorType.FULL,
    override val clickListener: IconTitleCellClickListener<DataModel>? = null,
    override val paddingStart: Padding = Padding.NONE,
) : CompoundCell<ImageTitleLeftCellModel, EmptyRightCellModel, DataModel>(
    leftModel = ImageTitleLeftCellModel(
        image = icon.toCellImage(),
        title = title
    ),
    rightModel = EmptyRightCellModel(),
    dataModel = dataModel,
    isDisabled = isDisabled,
    separatorType = separatorType,
    clickListener = clickListener
),
    WithCellIcon,
    WithCellTitle,
    WithCompoundCell<ImageTitleLeftCellModel, EmptyRightCellModel, DataModel> {

    override val diffingStrategy: CellDiffingStrategy by IdContentDiffingStrategy(
        diffId = id,
        diffContent = DiffContent(
            leftModel = leftModel,
            baseDiffContent = getBaseDiffContent()
        )
    )


    private data class DiffContent<DataModel>(
        private val leftModel: ImageTitleLeftCellModel,
        private val baseDiffContent: BaseDiffContent<DataModel>,
    )

}

Тонна generic-ов, куча интерфейсов, специальная diffing-стратегия, чтобы корректно сравнивать одну ячейку с другой, множество общих для всех ячеек override-параметров... В таком виде даже простые ячейки описывались довольно сложно и имели некоторые подводные камни в реализации.

А на Compose весь наш фреймворк ячеек уместился в 80 строк.

Весь фреймворк ячеек
@Composable
fun HHCellCarcass(
    isEnabled: Boolean,
    separatorStyle: SeparatorStyle,
    modifier: Modifier = Modifier,
    cornersStyle: CornersStyle = CornersStyle.Rectangle,
    onClick: (() -> Unit)? = {},
    right: @Composable (() -> Unit)? = null,
    left: @Composable () -> Unit,
) {
    val shape = cornersStyle.toShape()
    val alpha = when {
        onClick == null || isEnabled -> ThemeConstants.FULL_ALPHA_VALUE
        else -> ThemeConstants.DISABLED_ELEMENT_ALPHA_VALUE
    }

    val clickAndSemanticsModifier = if (onClick != null) {
        Modifier.clickable(
            interactionSource = remember { MutableInteractionSource() },
            indication = rememberRipple(color = HHColors.ripple),
            enabled = isEnabled,
            onClickLabel = null,
            role = null,
            onClick = onClick
        )
    } else {
        Modifier
    }

    CellCarcassBox(
        alpha = alpha,
        shape = shape,
        separatorStyle = separatorStyle,
        modifier = modifier,
        clickAndSemanticsModifier = clickAndSemanticsModifier,
        right = right,
        left = left
    )
}

@Composable
private fun CellCarcassBox(
    alpha: Float,
    shape: Shape,
    separatorStyle: SeparatorStyle,
    modifier: Modifier,
    clickAndSemanticsModifier: Modifier,
    right: @Composable (() -> Unit)? = null,
    left: @Composable () -> Unit,
) {
    Box(
        modifier = modifier
            .alpha(alpha)
            .clip(shape)
            .then(clickAndSemanticsModifier)
    ) {
        Column(verticalArrangement = Arrangement.Top) {
            Row(
                horizontalArrangement = Arrangement.Start,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Box(Modifier.weight(1f)) {
                    left()
                }
                if (right != null) {
                    Spacers.XSSpacer(Spacers.Direction.Horizontal)
                    right()
                } else {
                    Spacers.MSpacer(Spacers.Direction.Horizontal)
                }
            }
            Row {
                CellSeparator(
                    modifier = Modifier
                        .weight(1f),
                    separatorStyle = separatorStyle
                )
            }
        }
    }
}

С момента выхода видео в "Охэхэнных историях" ячейки немного усложнились. Во-первых, мы поняли, что нам неудобно проставлять weight в каждой ячейке для растягивания левой части, поэтому вытащили weight в основной каркас. А во-вторых, у нас появилась специальная expandable-ячейка, для которой нужно было убирать click без изменений alpha и семантики.

Как было в видео
@Composable
internal fun HHCellCarcass(
    isEnabled: Boolean,
    separatorStyle: SeparatorStyle,
    modifier: Modifier = Modifier,
    cornersStyle: CornersStyle = CornersStyle.Rectangle,
    onClick: () -> Unit = {},
    content: @Composable RowScope.() -> Unit,
) {
    Surface(
        modifier = modifier
            .alpha(if (isEnabled) ThemeConstants.FULL_ALPHA_VALUE else ThemeConstants.DISABLED_ELEMENT_ALPHA_VALUE),
        shape = cornersStyle.toShape(),
        interactionSource = remember { MutableInteractionSource() },
        indication = rememberRipple(color = HHColors.colorRipple),
        enabled = isEnabled,
        onClick = onClick
    ) {
        Column(verticalArrangement = Arrangement.Top) {
            Row(
                horizontalArrangement = Arrangement.Start,
                verticalAlignment = Alignment.CenterVertically
            ) {
                content()
            }
            Row {
                CellSeparator(
                    modifier = Modifier
                        .weight(1f),
                    separatorStyle = separatorStyle
                )
            }
        }
    }
}

И этот каркас тоже прекрасно решал свою задачу.

Весь фреймворк написался буквально за один вечер. Здесь есть каркас, который умеет принимать в себя левую и правую части, контролирует общие параметры (“ячейка доступна/недоступна”, “включить/выключить альфу”, “показать/не показать разделитель в списке” и многое другое).

Пример ячейки на Compose
@Composable
fun CheckboxCell(
    checked: Boolean,
    title: String,
    modifier: Modifier = Modifier,
    titleAlignment: Alignment.Vertical = Alignment.Top,
    subtitle: String? = null,
    isEnabled: Boolean = true,
    separatorStyle: SeparatorStyle = SeparatorStyle.None,
    onClick: () -> Unit,
    onCheckboxClick: (() -> Unit)? = null,
) {
    HHCellCarcass(
        isEnabled = isEnabled,
        separatorStyle = separatorStyle,
        modifier = modifier,
        onClick = onClick
    ) {
        CellLeftCheckbox(
            checked = checked,
            title = title,
            titleAlignment = titleAlignment,
            subtitle = subtitle,
            onCheckboxClick = onCheckboxClick,
        )
    }
}

Да и сами ячейки стали попроще. Да, в них по-прежнему осталось большое количество разных параметров, но само содержимое этих ячеек значительно упростилось. Кажется, это большая победа Compose.

Что там с UI-тестами

В рамках перевода нашего простого экрана на Jetpack Compose мы решили заодно поресерчить, как обстоит вопрос с UI-тестами. Спойлер: их можно писать, и они работают уже сейчас. 

В процессе ресерча мы слезли с наших форков Kaspresso и Marathon, на которых сидели аж два года. Они существовали у нас, потому что нам хотелось сделать Allure-отчеты с шагами, но современные версии Kaspresso и Marathon уже поддерживают это прямо из коробки и, более того, полностью поддерживают функциональность работы с Jetpack Compose. Поэтому мы наконец-то пересели на стабильные версии. 

Что такое Allure-отчёты с шагами?..

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

Мы попробовали написать "гибридные" UI-тесты. Они проходились по части XML-ных экранов, переходили на экран с Jetpack Compose-виджетами, а затем обратно. В общем, немножко потестили навигацию, как работают assert-ы в Compose-контексте, и вроде всё работает.

Код одного из тестов для Compose-экрана
internal class AboutScreenTest : AppicantTestCase() {

    private val aboutComposeScreen = AboutComposeScreen(composeTestRule)

    @Test
    @MoreScreenSuit
    fun checkAboutScreenHeader() {
        init {
            // do nothing
        }.run {
            step("Переходим на экран About") {
                navigation { openMoreScreen() }
                moreScreen.actions { openAbout() }
            }

            step("Проверяем состояние экрана для \"России\"") {
                aboutComposeScreen {
                    checks {
                        assertHeaderIsDisplayed()
                        assertHeaderText("HeadHunter")
                        assertOfficialPagesSectionDisplayed()
                    }

                    actions {
                        clickOnBackButton()
                    }
                }
            }

            step("Меняем страну поиска и возвращаемся на экран About") {
                moreScreen.actions { changeCountry("Беларусь") }
                navigation { openMoreScreen() }
                moreScreen.actions { openAbout() }
            }

            step("Ещё раз проверяем состояние экрана") {
                aboutComposeScreen {
                    checks {
                        assertHeaderIsDisplayed()
                        assertHeaderText("rabota.by")
                        assertOfficialPagesSectionDisplayed()
                    }
                }
            }
        }
    }

}

Кстати, с тестами была одна хитрость — для взаимодействия с Compose-виджетами вам нужен специальный ComposeTestRule. И нужно внимательно выбрать fabric-метод для создания такого Rule-а, потому что можно случайно запустить несколько Activity вашего приложения. В нашем случае подошёл createEmptyComposeRule:

@get:Rule
val composeTestRule = createEmptyComposeRule()

При этом Kakao теперь поддерживает Compose и вы можете писать мини-Page Object-ы для, например, ваших кастомных вьюшек, чтобы упростить жизнь тестировщикам. Всё просто: пишете специальный Page Object для баннера или кнопочки, а тестировщики будут их использовать. 

Пример кастомного Page Object-а
class ComposeBannerKNode(
    semanticsProvider: SemanticsNodeInteractionsProvider,
    nodeMatcher: NodeMatcher = NodeMatcher(
        matcher = SemanticsMatcher(
            description = "Empty matcher",
            matcher = { true }
        )
    ),
) : BaseNode<ComposeBannerKNode>(semanticsProvider, nodeMatcher) {

    private val title: KNode = child {
        hasTestTag(ComposeTestTags.banner.title)
    }

    private val message: KNode = child {
        hasTestTag(ComposeTestTags.banner.message)
    }

    private val primaryButton: KNode = child {
        hasTestTag(ComposeTestTags.banner.primaryButton)
    }

    private val secondaryButton: KNode = child {
        hasTestTag(ComposeTestTags.banner.secondaryButton)
    }

}

В качестве аналога обычным идентификаторам в Jetpack Compose используется специальный Semantics.testTag. Это те же идентификаторы, по которым можно искать определенные элементы, виджеты, взаимодействовать с ними, и так далее. А еще можно пробрасывать собственные кастомные семантические свойства. Это может потребоваться, например, для сравнения какой-нибудь картинки

Удивительно, но в данный момент в Jetpack Compose при попытке протестировать ту же картинку, что засетилась, вы не можете не можете достучаться до свойств той картинки, которая вам нужна. Каждый элемент экрана — это просто какая-то нода, у которой нет никаких свойств, и вы можете взаимодействовать только с семантикой. Приходится пробрасывать свои собственные свойства. 

А что-нибудь ещё интересное пробовали?

Еще в рамках ресерча мы пробовали написать unit-тесты на отдельные виджеты с помощью Kaspresso и Robolectric. Такие тесты могут пригодиться, если вы решите написать сложную кастомную вьюшку, в которой пробрасывается сложная модель. Тогда в рамках unit-тестов получится проверить, правильно ли сетятся те или иные свойства. 

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

Как планируем мигрировать на Compose?

Итак, мы перевели экран на Jetpack Compose, поресерчили UI-тесты, показали демо команде и решили, что эксперимент был успешным. Дальше мы захотели двигаться в сторону миграции на Jetpack Compose. Но как именно мы будем двигаться?..

Существует несколько стратегий миграции с одной технологии на другую. 

Первую можно условно назвать “революция” — это когда вы говорите: “Ребята, с завтрашнего дня мы все пишем на Jetpack Compose”. Да, не у всех есть опыт работы с этой технологией, есть куча нерешенных проблем, вы не знаете, как будете реализовывать тот или иной элемент вашей дизайн-системы. Зато вся команда прокачивается разом и вы сразу пишете на новой технологии. Это быстрый путь, но жестокий, ненадежный и нестабильный. Если завтра будет релиз и понадобится срочно что-то сделать, неизбежно возникнут трудности и проблемы. 

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

И третья стратегия — это нечто среднее между первыми двумя, ее можно назвать “программа раннего доступа” (EAP — early access program). С помощью платформенной команды решаете какую-то часть проблем (но не все), затем выбираете команду, которая готова страдать больше других и просите их писать на новой технологии. Эта команда будет сталкиваться с разными проблемами, будет решать их самостоятельно и, главное, прокачиваться. Другие команды до некоторых пор будут продолжать сидеть на старой технологии, зато когда решит попробовать Compose, будут уже две команды, которые готовы им помогать.

EAP — более быстрый путь, чем “реформа”, и более надежный, чем “революция”. Так что мы решили, что будем двигаться по пути от “реформы” к “early access program”. Выделим команду, которая будет собирать все шишки и создавать компоненты дизайн-системы, а при декомпозиции каждого продуктового портфеля, будем звать продуктовую команду и вместе с ними оценивать, насколько готова наша дизайн-система к реальному production-экрану. И если всё отлично, то мы начнем перетаскивать всех разработчиков на Jetpack Compose. 

И как успехи?

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

Состояние доски в марте

Стикеры постепенно "зеленели", это грело душу.

Мы понимали, что Jetpack Compose — это молодая технология, поэтому многого из коробки просто нет. Нам не хватило тогда, например, простого XML-флажочка includeFontPadding.

Зачем этот атрибут нужен?

Когда ваши дизайнеры рисуют типографику в Figma, они обычно центрируют текст внутри контейнера с текстом. В Android у нас нет возможности центрировать текст внутри контейнера из коробки. Но есть специальный лайфхак с includeFontPadding и возможностью указать специальное значение firstBaselineToTopHeight и lastBaselineToBottomHeight, чтобы попробовать сделать pixel perfect текста вместе с Figma. 

В Compose этого флажочка нет, поэтому если наложить друг на друга экраны "О приложении" на XML и Compose, то станет заметно, что Compose-версия немного отличается от XML.

Как это выглядит?

Если наложить два изображения друг на друга, видим, что надписи на Compose-версии слегка уехали вниз:

Душа перфекциониста в этот момент негодует (╯ ° □ °) ╯ (┻━┻)

Все надписи слегка уехали вниз, потому что у шрифтов есть отступы (pagging-и), и убрать их мы пока не можем. Есть некоторые workaround-ы для этой ситуации:

  • Использовать внутри Compose-вёрстки AndroidView, куда вставить XML-ную TextView, чтобы использовать нужные атрибуты.

  • Подхачить файлы шрифтов, чтобы убрать padding-и и написать немного кода в Modifier-ах.

  • Дождаться версии Compose-а 1.2.0, чтобы наконец-то воспользоваться нормальным решением.

Еще нам не хватило нормальной работы с Bottom Sheet-ами. В Material-библиотеке Jetpack Compose есть некоторые виджеты для работы с Bottom Sheet-ами, но они не такие удобные как хотелось бы. Например, если нужно показать несколько Bottom Sheet-ов на одном экране, приходится создавать дополнительные классы для переключения между диалогами. Плюс еще там требуется, чтобы определенный виджет был корнем вашей иерархии — это не всегда удобно. В какой-то момент мы забросили попытки писать Bottom Sheet-ы на Compose, и начали использовать привычные BottomSheetDialogFragment-ы, с той лишь разницей, что вёрстку делали на Compose-е.

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

Как это выглядело?

Как и в случае с Banner-ами, кнопками, ячейками — здесь куча вариантов.

Мы думали, что у нас получится так же удобно, как и с ячейками, но, к сожалению, NavBar иногда используется и как CollapsigToolbar. Поэтому, когда мы попробовали реализовать это, у нас сходу не получилось удобного API. Чтобы сделать сколько-нибудь приличную реализацию, нам потребовалось довольно много времени, чтобы реализовать этот компонент.

Как я уже сказал, Jetpack Compose — молодая технология, поэтому из коробки чего-то может не хватать. В частности, например, нет такой простой вещи как Linkify. Это когда отправленная вами ссылка в чатике подсвечивается как ссылка, а при нажатии на нее открывается браузер. Этой штуки Compose сегодня просто нет, приходится ее реализовывать руками. Это не очень сложно, но неприятно. 

Подведём итоги

Мы смогли начать адаптацию, поэтому я думаю, что у вас всё тоже получится. Главное — начать с простенького запуска вашего приложения с подключенным Jetpack Compose.

Затем попробуйте перевести хотя бы один простенький экран на Jetpack Compose, оцените риски, насколько сложно будет реализовывать ваши компоненты дизайн-системы, насколько для вас будет трудным взаимодействие между фрагментом и Compose-овским кодом. Все это очень важно проверить на ранней стадии, прежде чем бросаться в глубокий омут этой миграции. 

И последняя рекомендация — скорее всего будет проще, если вы уже используете UDF-архитектуру. Если у вас уже есть, например, тот же Presenter или же ViewModel-ка, которая отдает единый UI-стейт на рендер в ваш фрагмент. Тогда вы сможете просто заменить XML-код по рендерингу на вашу Compose-реализацию и будет гораздо проще. 

Теги:
Хабы:
Всего голосов 18: ↑15 и ↓3+12
Комментарии15

Публикации

Информация

Сайт
hh.ru
Дата регистрации
Дата основания
Численность
501–1 000 человек
Местоположение
Россия