Четыре платформы — один код. Что такое Compose Multiplatform?

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-дерева, а на одном-единственном (да-да, как во Flutter). Поэтому наш index.html будет выглядеть так:



    
        
        ComposeConnect
        
    
    
    

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

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

2)

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 = MutableStateFlow(exampleUiState.getValue("composers"))
    val conversationUiState: StateFlow = _conversationUiState

    private val _selectedUserProfile: MutableStateFlow = MutableStateFlow(null)
    val selectedUserProfile: StateFlow = _selectedUserProfile

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

    private val _drawerShouldBeOpened: MutableStateFlow = MutableStateFlow(false)
    val drawerShouldBeOpened: StateFlow = _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,
) {
    private val _messages: MutableList = initialMessages.toMinitialMessages.toMutableList()
    val messages: List = _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 = MutableStateFlow(ThemeMode.LIGHT)
    val themeMode: StateFlow = _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
            )
        }
    }
}

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

© Habrahabr.ru