Четыре платформы — один код. Что такое Compose Multiplatform?
Разработчики давно грезили о возможности писать кроссплатформенный код — такой, который запускался и работал бы одинаково в любой операционной системе любой архитектуры. Сегодня принципом «Write once, run anywhere», когда-то прогремевшим в связи с появлением языка Java, трудно кого-либо удивить. И все же есть ниша, в которой не так много кроссплатформенных технологий: это UI-разработка.
Не будет преувеличением сказать, что на сегодняшний день есть только два UI-фреймворка, которые позволяют запускать один и тот же UI на разных платформах и широко представлены на рынке: React Native и Flutter. Казалось бы, чего еще желать? Сразу две технологии предоставляют возможность шарить UI-фичи между платформами и прекрасно с этим справляются. Но эта статья — не о них, а об их младшем собрате, удобном и мощном инструменте мобильной и десктопной разработки — Compose Multiplatform.
Его основой стал Jetpack Compose — первый декларативный UI-фреймворк для Android. Однако благодаря Jetbrains и Kotlin Multiplatform на настоящий момент его можно запускать почти где угодно и на чем угодно: Android, iOS, Windows, Linux, MacOS и в браузере (отдельные умельцы-энтузиасты также переиспользуют код на WearOS).
Здесь не будет традиционного ломания копий по поводу того, какой из фреймворков лучше, но позволю себе поделиться одним личным впечатлением: попробовав React и затем окунувшись в Jetpack Compose, я остался с ворохом вопросов к первому и восторгом от простоты и интуитивности второго. Когда разрабатываешь интерфейсы при помощи Compose, в голове крутится одна мысль: понять реактивность React’a дано не каждому сеньору, но понять принцип работы Compose сможет любой школьник.
Понятно, что эти два фреймфорка отличает их «целевая аудитория»: в React Native приходят фронтенды веб-разработки, а в Compose Multiplatform — разрабы под Android, которые не хотят выходить из зоны комфорта и, не изучая новые языки и технологии «дотянуться» до других платформ. Но вот вам взгляд со стороны: на момент знакомства с обоими технологиями я был одинаково далек и от декларативной веб-разработки, и от андроида. Может быть, эта статья станет стимулом познакомиться с этой новой для вас технологией и получить от нее такой же кайф, какой каждый день получаю я. Что же касается Flutter, я воздержусь от комментариев, потому что пока не пробовал его сам.
Сегодня мы попробуем понять, легко ли перенести код, написанный только под андроид на чистом Jetpack Compose на другие платформы. (Спойлер: не легко, а очень легко.) Мы напишем простой, но рабочий прототип мессенджера, который можно запускать как десктопное приложение, мобильное приложение на Android и iOS, а также в браузере. Код самого приложения можно найти здесь.
Чтобы начать новый проект на Compose Multiplatform, используем темплейт на Github.
Первоначальный сетап, предоставляемый темплейтом, уже содержит в себе очень простой Hello-world-интерфейс. Проект уже разбит на модули: androidApp, iosApp, desktopApp и shared (мы добавим к ним ещё jsApp, но об этом позже). Модули, как нетрудно догадаться, это целевые платформы приложения. Внутри модуля shared каждой из платформ соответствует свой сорссет (sourceset, набор файлов исходного кода). Все сорссеты имеют общую часть — commonMain, это код, который переиспользуется на 100%.
Зачем такая многоуровневая структура? Можно построить проект и на одних сорссетах, но тогда потеряются преимущества многомодульной структуры 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
корневого проекта.
В 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-приложении!
Что еще интересного можно сделать с нашим мессенджером? Как вариант — добавить переключение темы в интерфейсе. В изначальной имплементации 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
)
}
}
}
Результат — возможность переключаться с светлой темы на темную и обратно на всех платформах!