image

Разработчики давно грезили о возможности писать кроссплатформенный код — такой, который запускался и работал бы одинаково в любой операционной системе любой архитектуры. Сегодня принципом «Write once, run anywhere», когда-то прогремевшим в связи с появлением языка Java, трудно кого-либо удивить. И все же есть ниша, в которой не так много кроссплатформенных технологий: это UI-разработка.

Не будет преувеличением сказать, что на сегодняшний день есть только два UI-фреймворка, которые позволяют запускать один и тот же UI на разных платформах и широко представлены на рынке: React Native и Flutter. Казалось бы, чего еще желать? Сразу две технологии предоставляют возможность шарить UI-фичи между платформами и прекрасно с этим справляются. Но эта статья — не о них, а об их младшем собрате, удобном и мощном инструменте мобильной и десктопной разработки — Compose Multiplatform.

image

Его основой стал Jetpack Compose — первый декларативный UI-фреймворк для Android. Однако благодаря Jetbrains и Kotlin Multiplatform на настоящий момент его можно запускать почти где угодно и на чем угодно: Android, iOS, Windows, Linux, MacOS и в браузере (отдельные умельцы-энтузиасты также переиспользуют код на WearOS).

Здесь не будет традиционного ломания копий по поводу того, какой из фреймворков лучше, но позволю себе поделиться одним личным впечатлением: попробовав React и затем окунувшись в Jetpack Compose, я остался с ворохом вопросов к первому и восторгом от простоты и интуитивности второго. Когда разрабатываешь интерфейсы при помощи Compose, в голове крутится одна мысль: понять реактивность React'a дано не каждому сеньору, но понять принцип работы Compose сможет любой школьник.

image

Понятно, что эти два фреймфорка отличает их «целевая аудитория»: в React Native приходят фронтенды веб-разработки, а в Compose Multiplatform — разрабы под Android, которые не хотят выходить из зоны комфорта и, не изучая новые языки и технологии «дотянуться» до других платформ. Но вот вам взгляд со стороны: на момент знакомства с обоими технологиями я был одинаково далек и от декларативной веб-разработки, и от андроида. Может быть, эта статья станет стимулом познакомиться с этой новой для вас технологией и получить от нее такой же кайф, какой каждый день получаю я. Что же касается Flutter, я воздержусь от комментариев, потому что пока не пробовал его сам.

Сегодня мы попробуем понять, легко ли перенести код, написанный только под андроид на чистом Jetpack Compose на другие платформы. (Спойлер: не легко, а очень легко.) Мы напишем простой, но рабочий прототип мессенджера, который можно запускать как десктопное приложение, мобильное приложение на Android и iOS, а также в браузере. Код самого приложения можно найти здесь.

Чтобы начать новый проект на Compose Multiplatform, используем темплейт на Github.

image

Первоначальный сетап, предоставляемый темплейтом, уже содержит в себе очень простой Hello-world-интерфейс. Проект уже разбит на модули: androidApp, iosApp, desktopApp и shared (мы добавим к ним ещё jsApp, но об этом позже). Модули, как нетрудно догадаться, это целевые платформы приложения. Внутри модуля shared каждой из платформ соответствует свой сорссет (sourceset, набор файлов исходного кода). Все сорссеты имеют общую часть — commonMain, это код, который переиспользуется на 100%.

image

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

Заглянем в модули каждого из таргетов, прямо в алфавитном порядке. В модуле androidApp содержится непременный для этого таргета AndroidManifest.xml и MainActivity.kt. По большому счету, этот Activity — просто входная точка для всего интерфейса на Android:

package com.myapplication

import MainView
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            Application()
        }
    }
}


Application — обычная Composable функция, которая содержится в модуле shared в сорссете commonMain и одинаковым образом вызывается во всех таргетах.

В desktopApp вызывается ровно эта же функция, но обернутая в Window. Этот простой метод создаёт Swing-окно и помещает в него наш интерфейс.

fun main() = application {
    Window(
        title = "ComposeConnect",
        onCloseRequest = ::exitApplication
    ) {
        Application()
    }
}


К слову, вообще весь интерфейс в десктопном таргете Compose Multiplatform под капотом имеет привязки к Swing или AWT, поэтому можно использовать многие методы из этих библиотек, например, файловый менеджер или сохранение графических ресурсов на диск при помощи awt.Image.

Перейдем к одной из самых интересных частей — iOS-таргету. Если до сих пор все точки входа в приложение были написаны на Kotlin/JVM, то в модуле iosApp нет ни строчки на котлине, а есть проект на Swift, который ссылается на модуль shared, а точнее, его сорссет iosMain:

import UIKit
import SwiftUI
import shared

struct ComposeView: UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> UIViewController {
        Main_iosKt.MainViewController()
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

struct ContentView: View {
    var body: some View {
        ComposeView()
                .ignoresSafeArea(.keyboard) // Compose has own keyboard handler
    }
}


Если выше Kotlin Multiplatform показывал себя в связке с Java, то здесь очевидна другая его крутая фича — Swift-interop. Функцию MainViewController из файла main.ios.kt мы вызываем прямо в Swift:

fun MainViewController() = ComposeUIViewController { Application() }


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

Для этого в Intellij Idea создадим новый модуль через File -> Project structure -> New module и выберем Compose Multiplatform. Далее Single platform и Web. Вновь созданному модулю нужно обрезать все «лишнее», оставив только build.gadle.kts скрипт. Чтобы включить модуль в основной проект, добавим соответствующий include(project(":jsApp")) в settings.gradle.kts корневого проекта.

image

В build.gradle.kts модуля shared нужно добавить соответствующий плагин компиляции и сорссет:

kotlin {
    js(IR) {
        browser()
        binaries.executable()
    }
    ...
    sourceSets {
      ...
        val jsMain by getting {}
    }
}


После обновления градла заглянем в сам модуль jsApp. Как для Android-таргета важен был AndroidManifest.xml, так для веба будет важен index.html, который находится в папке ресурсов нашего модуля. Сразу стоит оговориться, что в браузере приложение будет рисоваться не с помощью DOM-дерева, а на одном-единственном <canvas> (да-да, как во Flutter). Поэтому наш index.html будет выглядеть так:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>ComposeConnect</title>
        <script src="skiko.js"></script>
    </head>
    <body>
    <div id="root">
        <canvas id="ComposeTarget"></canvas>
    </div>
    <script src="jsApp.js"></script>
    </body>
</html>


Важнейшие части — это

1) skiko.js (skiko, aka Skia for Kotlin — графическая библиотека, на которой работает весь Compose Multiplatform, имплементирует низкоуровневые биндинги к собственно Skia для котлина на разных платформах),

2) <canvas id="ComposeTarget"></canvas>

3) jsApp.js (наше приложение, транспилированное в JavaScript-код).

Теперь в Main.kt напишем всего 5 строк:

fun main() {
    onWasmReady {
        Window { Application() }
    }
}


Этого достаточно, чтобы портировать интерфейс в браузер.

Осталось дело за малым — написать само приложение! Я остановил свой выбор на мессенджере и за основу взял JetChat — примерное приложение от Google на чистом Jetpack Compose. Самые важные компоненты приложения — это Scaffold, выполняющий функцию панели навигации, ConversationContent, в котором имплементирована сама чат-комната, и ProfileScreen, то есть экран просмотра профиля выбранного пользователя.

Для начала создадим класс MainViewModel, который играет ту же роль, что и ViewModel в Android. К сожалению, Compose Multiplatform пока не предоставляет своего класса ViewModel из коробки, для его кроссплатформенной имплементации обычно используют библиотеку Decompose. Я планирую сделать это в будущем, а пока попробуем обойтись простым классом с необходимыми нам StateFlow. В Application просто инициализируем класс:

@Composable
fun Application() {
    val viewModel = remember { MainViewModel() }
    ThemeWrapper(viewModel)
}


Сам класс MainViewModel пропишем так ряд обозреваемых интерфейсом StateFlow:

@Stable
class MainViewModel {
    private val _conversationUiState: MutableStateFlow<ConversationUiState> = MutableStateFlow(exampleUiState.getValue("composers"))
    val conversationUiState: StateFlow<ConversationUiState> = _conversationUiState

    private val _selectedUserProfile: MutableStateFlow<ProfileScreenState?> = MutableStateFlow(null)
    val selectedUserProfile: StateFlow<ProfileScreenState?> = _selectedUserProfile

    private val _themeMode: MutableStateFlow<ThemeMode> = MutableStateFlow(ThemeMode.LIGHT)
    val themeMode: StateFlow<ThemeMode> = _themeMode

    private val _drawerShouldBeOpened: MutableStateFlow<Boolean> = MutableStateFlow(false)
    val drawerShouldBeOpened: StateFlow<Boolean> = _drawerShouldBeOpened

    fun setCurrentConversation(title: String) {
        _conversationUiState.value = exampleUiState.getValue(title)
    }

    fun setCurrentAccount(userId: String) {
        _selectedUserProfile.value = exampleAccountsState.getValue(userId)
    }

    fun resetOpenDrawerAction() {
        _drawerShouldBeOpened.value = false
    }

    fun switchTheme(theme: ThemeMode) {
        _themeMode.value = theme
    }

    fun sendMessage(message: Message) {
        _conversationUiState.value.addMessage(message)
    }
}


В этом классе ConversationUiState — небольшой утилитарный класс, который удобно упаковывает все данные, необходимые нам для отображения отдельной чат-комнаты:

class ConversationUiState(
    val channelName: String,
    val channelMembers: Int,
    initialMessages: List<Message>,
) {
    private val _messages: MutableList<Message> = initialMessages.toMinitialMessages.toMutableList()
    val messages: List<Message> = _messages

    fun addMessage(msg: Message) {
        _messages.add(0, msg) // Add to the beginning of the list
    }
}


Чтобы поместить верхнюю панель приложения и поле ввода нового сообщения поверх самого списка сообщений, используем Box:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConversationContent(
    viewModel: MainViewModel,
    scrollState: LazyListState,
    scope: CoroutineScope,
    onNavIconPressed: () -> Unit,
) {
    val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
    val messagesState by viewModel.conversationUiState.collectAsState()
    Box(modifier = Modifier.fillMaxSize()) {
        Messages(messagesState, scrollState)
        Column(
            Modifier
                .align(Alignment.BottomCenter)
                .nestedScroll(scrollBehavior.nestedScrollConnection)
        ) {
            UserInput(
                onMessageSent = { content ->
                    val timeNow = getTimeNow()
                    val message = Message("me", content, timeNow)
                    viewModel.sendMessage(message)
                },
                resetScroll = {
                    scope.launch {
                        scrollState.scrollToItem(0)
                    }
                },
                // Use navigationBarsWithImePadding(), to move the input panel above both the
                // navigation bar, and on-screen keyboard (IME)
                modifier = Modifier.userInputModifier(),
            )
        }
        ChannelNameBar(
            channelName = messagesState.channelName,
            channelMembers = messagesState.channelMembers,
            onNavIconPressed = onNavIconPressed,
            scrollBehavior = scrollBehavior,
            // Use statusBarsPadding() to move the app bar content below the status bar
            modifier = Modifier.statusBarsPaddingMpp(),
        )
    }
}


Чтобы сохранить удобные Android-стили, которые применяются при помощи библиотеки Accompanist, воспользуемся удобным модификатором expect/actual, который позволяет кастомизировать функции, классы и переменные в зависимости от платформы. Для этого создадим пакет platform в commonMain и пропишем функцию userInputModifier. В действительности она будет влиять на отображение только на Android.

// shared/src/commonMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier

// shared/src/androidMain/kotlin/platform/modifier.kt
actual fun Modifier.userInputModifier(): Modifier = this.navigationBarsWithImePadding()

// shared/src/desktopMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier = this

// shared/src/iosMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier = this

// shared/src/jsMain/kotlin/platform/modifier.kt
expect fun Modifier.userInputModifier(): Modifier = this


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

И снова — в commonMain объявляем expect fun, а в другие сорссеты имплементации для конкретных таргетов. Все очень просто, например, для desktopMain так и запишем:

actual fun Modifier.pointerCursor() = pointerHoverIcon(PointerIcon.Hand, true)


За удобным enum классом PointerIcon в декстопе на самом деле скрывается AWT-событие, которое и меняет внешний вид курсора. В веб-среде, где интерфейс не «родной», такой метод не сработает, и придется поиграть с… CSS-стилями. Костыль ли это? Безусловно. Будем надеяться, со временем команда Compose Multiplatform выкатит красивое решение для веба, а пока пишем как есть:

actual fun Modifier.pointerCursor() = composed {
    val hovered = remember { mutableStateOf(false) }

    if (hovered.value) {
        document.body?.style?.cursor = "pointer"
    } else {
        document.body?.style?.cursor = "default"
    }

    this.pointerInput(Unit) {
        awaitPointerEventScope {
            while (true) {
                val pass = PointerEventPass.Main
                val event = awaitPointerEvent(pass)
                val isOutsideRelease = event.type == PointerEventType.Release &&
                        event.changes[0].isOutOfBounds(size, Size.Zero)
                hovered.value = event.type != PointerEventType.Exit && !isOutsideRelease
            }
        }
    }
}


Прекрасно! У нас получился вполне рабочий прототип мессенджера, который работает на Android, iOS, в браузере и standalone-приложении!

image

Что еще интересного можно сделать с нашим мессенджером? Как вариант — добавить переключение темы в интерфейсе. В изначальной имплементации JetChat тема зависит от основной темы системы. Это круто, но как сделать ее переключаемой по желанию пользователя?

Для этого укажем в build.gradle.kts в сорссете commonMain зависимости implementation(compose.material3) и implementation(compose.materialIconsExtended), создадим переменные AppLightColorScheme и AppDarkColorScheme типа ColorScheme с цветами для светлой и темной темы соответственно, а затем передадим одну из переменных в класс MaterialTheme:

@Composable
@Suppress("FunctionName")
fun ThemeWrapper(
    viewModel: MainViewModel
) {
    val theme by viewModel.themeMode.collectAsState()
    ApplicationTheme(theme) {
        Column {
            Conversation(
                viewModel = viewModel
            )
        }
    }
}

@Composable
fun ApplicationTheme(
    theme: ThemeMode = isSystemInDarkTheme().toTheme(),
    content: @Composable () -> Unit,
) {
    val myColorScheme = when (theme) {
        ThemeMode.DARK -> AppDarkColorScheme
        ThemeMode.LIGHT -> AppLightColorScheme
    }

    MaterialTheme(
        colorScheme = myColorScheme,
        typography = JetchatTypography
    ) {
        val rippleIndication = rememberRipple()
        CompositionLocalProvider(
            LocalIndication provides rippleIndication,
            content = content
        )
    }
}


Все, что нам остается — это прописать в MainViewModel новый StateFlow и метод для его изменения:

private val _themeMode: MutableStateFlow<ThemeMode> = MutableStateFlow(ThemeMode.LIGHT)
    val themeMode: StateFlow<ThemeMode> = _themeMode

fun switchTheme(theme: ThemeMode) {
        _themeMode.value = theme
    }


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

AppScaffold(
        scaffoldState = scaffoldState,
        viewModel = viewModel,
        onChatClicked = { title ->
            viewModel.setCurrentConversation(title)
            coroutineScope.launch {
                scaffoldState.drawerState.close()
            }
        },
        onProfileClicked = { userId ->
            viewModel.setCurrentAccount(userId)
            coroutineScope.launch {
                scaffoldState.drawerState.close()
            }
        },
        onThemeChange = { value ->
            viewModel.switchTheme(value.toTheme())
        }
    ) {...}


@Composable
@Suppress("FunctionName")
fun ThemeSwitch(viewModel: MainViewModel, onThemeChange: (Boolean) -> Unit) {
    Box(
        Modifier
            .defaultMinSize(300.dp, 48.dp)
            .fillMaxSize()
    ) {
        Row(
            modifier = Modifier
                .height(56.dp)
                .fillMaxWidth()
                .padding(horizontal = 12.dp)
                .clip(CircleShape)
        ) {

            val checkedState by viewModel.themeMode.collectAsState()
            val iconColor = MaterialTheme.colorScheme.onSecondary
            val commonModifier = Modifier.align(Alignment.CenterVertically)
            Icon(
                imageVector = Icons.Outlined.LightMode,
                contentDescription = "Light theme",
                modifier = commonModifier,
                tint = iconColor
            )
            Switch(
                checked = checkedState.toBoolean(),
                onCheckedChange = {
                    onThemeChange(it)
                },
                modifier = commonModifier
            )
            Icon(
                imageVector = Icons.Outlined.DarkMode,
                contentDescription = "Dark theme",
                modifier = commonModifier,
                tint = iconColor
            )
        }
    }
}


Результат — возможность переключаться с светлой темы на темную и обратно на всех платформах! ????

image

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

Комментарии (13)


  1. quaer
    14.05.2023 09:57
    +3

    Проект уже разбит на модули: androidApp, iosApp, desktopApp и shared

    Лезть в код не хочется, но хочется понять, почему при декларируемой мультиплатформенности надо иметь модули под каждую платформу. На том же Swing, к примеру, не надо писать разный код под Windows и Linux.

    Перейдем к одной из самых интересных частей — iOS-таргету. Если до сих
    пор все точки входа в приложение были написаны на Kotlin/JVM, то в
    модуле iosApp нет ни строчки на котлине, а есть проект на Swift, который ссылается на модуль shared, а точнее, его сорссет iosMain

    А как при этом код на Swift собирается? Можно ли обойтись без кода написаннного на Swift и всё приложение под iOS написать на Kotlin mutliplatform?

    Можете ещё разъяснить зачем введены expect и actual, почему не хватает понятий абстрактного класса, интерфейса и реализации, в чём их особенность?


    1. glider_skobb Автор
      14.05.2023 09:57
      +1

      Мне кажется, в данном случае речь идёт о другой кроссплатформенности. В Compose Multiplatform тоже не нужны отдельные модули под Windows и Linux, а под мобильные ОС они есть, но только для создания разных точек входа в программу. Мобильные и десктопные платформы имеют немало различий: например, на десктопе ваше приложение будет многооконным, а на Android вы вместо окна запустите асинхронный Activity, на iOS ещё что-то. Для этого и нужны сорссеты.

      Можно попробовать и запустить приложение на iOS совсем без Swift, например https://github.com/AlexGladkov/TeslaApp-Compose-Mpp, Но команда Jb дропнула поддержку такого подхода, видимо у них были на это веские причины.

      А expect/actual - просто удобный инструмент. Можно решить ту же задачу наследованием, но разработчики фреймворка избавляют нас от лишних телодвижений.


      1. quaer
        14.05.2023 09:57
        +1

        А expect/actual - просто удобный инструмент

        Как он работает-то? Открыл текст на сайте котлина и ничего не понял.

        но только для создания разных точек входа в программу.

        это что значит?


        1. qoj
          14.05.2023 09:57

          У expect есть compile-time проверка наличия actual для каждого таргета, в отличии от интерфейса.


      1. pjBooms
        14.05.2023 09:57

        Можно попробовать и запустить приложение на iOS совсем без Swift, например https://github.com/AlexGladkov/TeslaApp-Compose-Mpp, Но команда Jb дропнула поддержку такого подхода, видимо у них были на это веские причины.

        Можно и без Swift. В подходе выше проект XCode генерируется на лету. Подход оказался мало-жизнеспособным, потому что у проекта XCode очень много настроек, которые очень сложно поддержать на уровне градл плагина. Проще держать проект xcode внутри compose проекта, чтобы ничем не ограничивать разработчика в конфигурации xcode проекта (а также в выборе инструментов -- нативные отладчики, профилировщики). Держать Swift код в этом случае кажется логичным. Никто его не заставляет править/писать, но это может потребоваться в будущем, если к примеру захочется обернуть композное приложение нативной iOS навигацией. Если в будущем мы увидем, что в большинстве случаев разработчики не трогают xcode обертку вообще (или трогают как-то одинаково) и 100% кода пишут в common коде, конечно мы для них предоставим соответствующий тулинг, скрывающий конкретные платформы по максимуму. Но на данный момент кажется что до этих 100% еще очень далеко


  1. PackRuble
    14.05.2023 09:57
    +10

    Честно говоря, это не выглядит проще, чем сделать тоже самое на flutter...


    1. avdosev
      14.05.2023 09:57
      +2

      местами это даже выглядит сложнее, учитывая топорную простоту dart


  1. mybeliykot
    14.05.2023 09:57

    а для вышеописанной системы есть что-то вроде зеро-кодинг или лоу-кодинг или построитель (конструктор) форм или GUI?


    1. glider_skobb Автор
      14.05.2023 09:57
      +1

      Из таких тулзов слышал только про плагины в Figma, которые могут перевести дизайн в код на Jetpack Compose, этот код можно напрямую копипастить и вставлять в Compose Multiplatform

      Что касается лоукода - спасибо за идею, стоит на досуге написать )))


  1. Sazonov
    14.05.2023 09:57
    +1

    Как обстоят дела с интеропом C++ кода? Лично меня пока его отсутствие останавливает от изучения Flutter. И я продолжаю долбить Qt/Qml (который в версии 6.5 очень даже ничего).


    1. glider_skobb Автор
      14.05.2023 09:57
      +2

      В Kotlin Multiplatform есть специальный инструмент, cinterop. Самому не приходилось пользоваться, но наверное штука стоящая: https://kotlinlang.org/docs/native-c-interop.html#object-pinning

      Из более традиционного, конечно, есть jni еще


    1. pecheny
      14.05.2023 09:57

      В зависимости от задач, можете еще посмотреть о свежих фичах интеропа haxe
      и достаточно зрелом (но не единственном) UI фреймворке.


  1. Atreides07
    14.05.2023 09:57

    Пока еще сыровато и проигрывает flutter. Готовых компонентов и либ очень мало, iOS в состоянии alpha, web - experimental. Даже порог входа с учетом gradle и т.п. для новичков выше чем во flutter. Для серьезной кроссплатформенной разработки пока сыровато, но когда зарелизится, профит будет в том что многочисленные android разработчики смогут без особых проблем переключаться на кроссплатформу.