Привет, меня зовут Сергей, я Android Lead в компании Broniboy —  сервисе мультидоставки, который работает не только с ресторанами, но и с магазинами, аптеками и обычными людьми. Учитывая уровень конкуренции на этом рынке, каждый клиент на счету. Достаточно одной ошибки, чтобы потерять его, поэтому нам нужен надёжный канал поддержки пользователей. Наш выбор пал на чат, простой и понятный пользователю канал связи. Он позволяет не только оперативно реагировать на жалобы, но и привности частичку «человечности» в общение с техподдержкой.

Изучив готовые SDK и взвесив все «за» и «против», мы решили написать собственный SDK чата. Одна из основных идей — переиспользование кода между платформами, а сегодня это Android, iOS и Web. Поэтому, для MVP мы решили попробовать Kotlin Multiplatform.

Изучив несколько примеров и библиотек на просторах Интернета, выбрали такой стек технологий:

  • Ktor и Kotlin Serialization для сети;

  • SQDelight для хранения;

  • Koin для DI;

  • JetBrain Compose, MVIKotlin и Decompose в качестве каркаса архитектуры приложения;

  • и Coroutines для связи всего этого воедино.

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

Data

Data-слой оказался довольно простым: Ktor и Coroutines имеют исчерпывающую документацию, SQDelight напоминает Room, а Koin ничем не отличается от Android-варианта. Конечно, при использовании Ktor ощущается нехватка Retrofit’а с его аннотациями, но их успешно заменяют DSL-сборщики. А поддержка OkHttpClient позволяет переиспользовать клиент из основного приложения.

private val client = HttpClient(OkHttp) {
    engine { preconfigured = OkHttpClient() }
    install(JsonFeature)
}

suspend fun test(): String {
    return client.get { url("https://google.com") }
}

С SQDelight всё ещё проще: это Room, который генерирует все классы за нас, достаточно лишь указать схему.

CREATE TABLE message (
  id INTEGER PRIMARY KEY,
  text TEXT NOT NULL
);

messages:
SELECT *
FROM message;

insert:
INSERT INTO message
VALUES ?;

С Koin ситуация немного отличается, так как мы хотим инициализировать его не глобально, а только на время работы с чатом. К счастью, для этого в Koin есть механизм Context Isolation, который позволяет изолировать зависимости SDK, создавая и удаляя их при необходимости.

interface ChatKoinComponent : KoinComponent {

    override fun getKoin() = koinApp.koin

    companion object {
        private var koinApp: KoinApplication = koinApplication {...}
    }
}

UI

Переходим к другому концу нашей архитектуры — UI. Хотя мы смогли легко реализовать простой интерфейс , добиться результата «как в макете» оказалось не так просто.

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

@Composable
internal fun InputField(inputTextState: String) {
    Row(
            modifier = Modifier
                .fillMaxWidth()
                .height(IntrinsicSize.Min), // выровнять высоту иконки и поля ввода
            verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(modifier = Modifier.fillMaxHeight())
        BasicTextField(
                value = inputTextState,
                onValueChange = {...},
                modifier = Modifier
                    .weight(1f)
                    .fillMaxHeight(),
                maxLines = 3,
                decorationBox = { innerTextField ->
                    Box(contentAlignment = Alignment.CenterStart) { // выровнять текст по середине поле ввода
                        if (inputTextState.isEmpty()) {
                            Text(
                                    text = "Hint...",
                            )
                        }
                        innerTextField()
                    }
                }
        )
    }
}

Ссылки в тексте. В Compose нет autolink’а, поэтому пришлось использовать обычный TextView.

AndroidView(
        factory = {
            TextView(it).apply {
                movementMethod = BetterLinkMovementMethod.newInstance().apply {
                    setOnLinkClickListener { _, url ->
                        onLinkClick(url)
                        true
                    }
                }
            }
        },
        modifier = Modifier.weight(weight = 1f, fill = false),
        {
            it.text = message.text
            LinkifyCompat.addLinks(it, Linkify.WEB_URLS)
        }
)

BottomSheet. Изменение состояния BottomSheet через методы:

val bottomSheetState = rememberBottomSheetState(BottomSheetValue.Collapsed)
if (bottomSheetState.currentValue != BottomSheetValue.Collapsed) {
    DisposableEffect(bottomSheetState) {
        onDispose { component.onDismiss() } // слушаем закрытие BottomSheet
    }
}
val bottomSheetStateUpdate by component.bottomSheetStateUpdate.collectAsState()
LaunchedEffect(bottomSheetStateUpdate) { // состояние BottomSheet можно изменить только методами, используем LaunchedEffect
    when (bottomSheetStateUpdate) {
        BottomSheetState.Expanded -> bottomSheetState.expand()
        BottomSheetState.Collapsed -> bottomSheetState.collapse()
    }
}

Объединив получившиеся слои, мы проверили, что всё корректно работает. После этого осталось реализовать слой бизнес-логики, связав всё воедино.

Domain

Имея интерфейсы с обеих сторон и опробовав разные подходы, мы реализовали логичную архитектуру, подходящую для наших экранов:

Composable, который отображает состояние экрана и осуществляет навигацию:

Children(routerState = component.routerState) {
    when (val child = it.instance) {
        is ChatRoot.Child.Main -> ChatMainContent(child.component)
    }
}

Component, который реагирует на изменение состояния экрана:

internal class ChatRootComponent(componentContext: ComponentContext) : ChatRoot, ComponentContext by componentContext {

    private val router = router<Configuration, ChatRoot.Child>(::createChild)
    private val store = instanceKeeper.getStore { ChatRootStore(storeFactory) }
    override val routerState = router.state
    override val state = store.states

    private fun createChild(configuration: Configuration, componentContext: ComponentContext): ChatRoot.Child {...}
}

Store, который загружает и обрабатывает информацию:

internal class ChatRootStore(storeFactory: StoreFactory) : ChatRootStore, Store<Intent, State, Label> by storeFactory.createStore() {

    private class ExecutorImpl : CoroutineExecutor<Intent, Unit, State, Result, Label>() {
        override fun executeAction(action: Unit, getState: () -> State) {...}
        override fun executeIntent(intent: Intent, getState: () -> State) {...}
    }

    private object ReducerImpl : Reducer<State, Result> {
        override fun State.reduce(result: Result): State {...}
    }
}

И на этом всё, структура приложения готова!

Заключение

Мы считаем эксперимент успешным. Kotlin Multiplatform действительно позволяет переиспользовать значительную часть логики. При этом всегда можно обойти проблемные места с помощью expect/actual. Это добавляет гибкости в использовании общего модуля среди разных платформ.

Благодаря мультиплатформенности, нам удалось ощутимо сократить затраты на разработку SDK. Для каждой платформы нам необходимо реализовать только UI-слой, а бизнес-логика будет переиспользована из общего модуля. Более того, благодаря этому чат на всех платформах работает единообразно.