Из этой книги вы узнали, как совместно использовать свою бизнес-логику в приложениях для Android, iOS и настольных ПК. Что, если бы вы могли пойти еще дальше и поделиться своим пользовательским интерфейсом Compose?
Правильно — наряду с Kotlin Multiplatform у вас теперь есть Compose Multiplatform , который позволяет вам делиться своим пользовательским интерфейсом Compose с приложениями для Android и настольных компьютеров.
Примечание . В этом приложении используется Learn — проект, созданный вами в главах с 11 по 14.
Обновление структуры вашего проекта
Чтобы следовать примерам кода в этом приложении, загрузите проект Starter и откройте 17-appendix-c-sharing-your-compose-ui-between-android-and-desktop/projects/starter в Android Studio.
Starter — это окончательная версия обучения из главы 14, без приложения для iOS и его кода для конкретной платформы. Он содержит основу проекта, который вы создадите здесь, а Final дает вам возможность сравнить свой код, когда вы закончите.
Чтобы поделиться своим пользовательским интерфейсом, вам нужно создать новый многоплатформенный модуль Kotlin. Это необходимо, потому что разные платформы имеют разные спецификации, а это означает, что вам нужно будет написать код для конкретной платформы. Это похоже на то, что вы делали на протяжении всей этой книги.
Начните с создания новой библиотеки KMM. Вы можете легко сделать это, щелкнув строку состояния Android Studio File , а затем New и New Module .
Затем выберите Kotlin Multiplatform Shared Module и установите:
- Имя модуля : общий интерфейс
- Имя пакета : com.raywenderlich.learn.ui
- Распространение фреймворка iOS : Обычный фреймворк
Нажмите « Готово » и дождитесь синхронизации проекта.
Как видите, в Learn появился новый модуль общего пользовательского интерфейса . Откройте файл settings.gradle.kts , чтобы убедиться, что он добавлен в ваш проект.
Android Studio напрямую поддерживает только KMM (Kotlin Multiplatform Mobile). Итак, когда вы пытаетесь добавить новый модуль и ориентируетесь на другие платформы, например настольные приложения, вам нужно будет вручную добавить эти цели.
Откройте shared-ui и переименуйте папку iosMain в desktopMain .
Теперь откройте общий интерфейс build.gradle.kts и удалите все ссылки на iOS. Начиная с начала этого файла:
- Двигаясь к
kotlin
разделу, удалите все цели iOS:
listOf( iosX64(), iosArm64(), iosSimulatorArm64() ).forEach { it.binaries.framework { baseName = "shared-ui" } }
- Теперь при
sourceSets
удалении всех полейiOS*Main
и :iOS*Test
val iosX64Main by getting val iosArm64Main by getting val iosSimulatorArm64Main by getting val iosMain by creating { dependsOn(commonMain) iosX64Main.dependsOn(this) iosArm64Main.dependsOn(this) iosSimulatorArm64Main.dependsOn(this) } val iosX64Test by getting val iosArm64Test by getting val iosSimulatorArm64Test by getting val iosTest by creating { dependsOn(commonTest) iosX64Test.dependsOn(this) iosArm64Test.dependsOn(this) iosSimulatorArm64Test.dependsOn(this) }
Теперь, когда ссылок на iOS больше нет, вернитесь в kotlin
раздел и под android()
мишенью добавьте:
jvm("desktop")
Это обязательно — иначе вы бы сгенерировали только библиотеку shared-ui для Android.
Наконец, в sourceSets
конфигурации добавьте desktopMain
свойство внизу:
val desktopMain by getting
Синхронизируйте проект. После его завершения посмотрите на структуру проекта. Он должен быть похож на этот:

Рис. C.1 – Иерархия представлений проекта
При создании библиотеки KMM Android Studio также добавляет файл Platform.kt во все папки и файл Greetings.kt внутрь commonMain . Вы можете удалить эти четыре файла. Это всего лишь заполнители, и они не будут использоваться в этом приложении.
Делитесь своим кодом пользовательского интерфейса
Хотя код обеих платформ очень похож, приложение для Android использует библиотеки, зависящие от платформы. Поскольку пользовательский интерфейс должен поддерживаться в обоих случаях, необходимо внести несколько изменений.
Как правило, наиболее распространенный сценарий заключается в том, что у вас есть приложение для Android, созданное с помощью Compose, которое вы хотите перенести на рабочий стол. Итак, вы начнете с перемещения пользовательского интерфейса из androidApp в shared-ui . В конце концов, вы удалите классы, которые больше не нужны, из desktopApp .
Прежде чем вы начнете, есть несколько вещей, которые следует учитывать:
- Библиотеки Android, использующие собственный SDK, зависят от платформы, поэтому их нельзя использовать в настольных приложениях.
- shared-ui следует тем же принципам общего модуля, который вы создали ранее: код должен быть полностью написан на Kotlin — даже в его сторонних библиотеках.
С этим пришло время начать свое путешествие. :]
Перенос кода пользовательского интерфейса Android на мультиплатформу
Начните с перемещения всех каталогов внутри androidApp/ui в shared-ui/commonMain/ui . Не перемещайте файл MainActivity.kt , так как действия специфичны для Android.
При появлении запроса о том, как следует выполнить перемещение, выберите «Переместить 8 пакетов в другой пакет», а затем, прежде чем нажимать refactor , убедитесь, что выбраны следующие параметры:
- Поиск в комментариях и строках.
- Поиск вхождений текста.
Android Studio откроет новое окно с перечислением нескольких проблем, обнаруженных в ходе этого процесса. Они связаны с ресурсами и библиотеками, которые необходимо добавить в shared-ui . Пока не беспокойтесь об этом. Нажмите Продолжить .
После завершения этой операции переместите каталог компонентов в shared-ui/commonMain . Он должен быть на том же уровне, что и папка ui . При появлении запроса о том, как следует выполнить перемещение, перед нажатием refactor подтвердите, что выбраны следующие параметры:
- Поиск в комментариях и строках.
- Поиск вхождений текста.
Нажмите Продолжить .
Примечание . В зависимости от текущего представления, которое вы выбрали для окна структуры проекта слева, вы не сможете перемещать файлы непосредственно в правую папку. Чтобы изменить это, выберите оконный режим Project Files .
Глядя на исходную папку androidApp , можно увидеть только два класса: MainActivity.kt и RWApplication . Все остальные классы пользовательского интерфейса теперь находятся в shared-ui .
Пришло время переместить файлы ресурсов. Начните с создания папки res внутри shared-ui/androidMain :
- Щелкните правой кнопкой мыши androidMain .
- Выберите Новый каталог .
- При появлении запроса выберите res из списка исходных наборов Gradle .
Подобно androidApp/res , эта папка будет содержать все ресурсы приложения, зависящие от платформы. Хотя вы можете переместить все файлы из папки res в это новое место, есть несколько файлов, которыми не обязательно делиться, поскольку они специфичны для Android:
- ic_launcher_background.xml — фон значка приложения.
- colors.xml , цвета, используемые для атрибутов XML.
- themes.xml определяет фон окна.
Переместите все папки, кроме значений, внутри androidApp/res в shared-ui .
Когда ваш код и его ресурсы перемещены в другой модуль, вам необходимо импортировать его в приложение androidApp . В противном случае MainActivity не сможет разрешить свой импорт.
Откройте build.gradle.kts из androidApp и в dependencies
разделе ниже shared
добавьте:
implementation(project(":shared-ui"))
Синхронизируйте и дождитесь завершения этой операции.
После этого откройте MainActivity.kt . Теперь все импорты должны быть разрешены. Тем не менее, модели представления все еще нуждаются в решении. Поскольку они специфичны для Android, вам необходимо использовать внешнюю библиотеку для их поддержки при нацеливании на многоплатформенность. Подробнее об этом можно прочитать в разделе «Использование LiveData и ViewModel» этой главы.
Написать Мультиплатформенный
Первоначально Jetpack Compose был представлен для Android как новый инструментарий пользовательского интерфейса, в котором можно было, наконец, отказаться от деклараций XML и вызовов findViewById и перейти к новой парадигме — декларативному пользовательскому интерфейсу. Это более лаконичный и современный подход — он отделен от версий API и позволяет быстрее создавать приложения.
Примечание . Вы можете узнать больше о Jetpack Compose для Android в Главе 3 «Разработка пользовательского интерфейса» и в учебных пособиях Jetpack Compose by на сайте raywenderlich.com.
Если вы посмотрите официальную документацию для Jetpack Compose, вы увидите, что на момент написания она состоит из семи библиотек:
- compose.animation : Анимации, которые вы можете легко использовать.
- compose.material : Система дизайна материалов для использования в компонентах.
- compose.material3 : Новейшая версия материального дизайна.
- compose.foundation : содержит базовые компоновочные объекты — столбец, текст, изображение и т. д.
- compose.ui : управляет вводом данных, рисованием и макетами.
- compose.runtime : он не зависит от платформы, что означает, что он не знает, что такое Android или пользовательский интерфейс. Это можно рассматривать как решение для управления деревом.
- compose.compiler : преобразует
@Composable
пользовательский интерфейс.
Их можно структурировать в виде следующей схемы высокого уровня:
Compose AnimationCompose Material 3Compose MaterialCompose FoundationCompose UICompose CompilerCompose RuntimeCompose UI Toolkit (Android)
Рис. C.2 Схема высокого уровня Jetpack Compose
На этом изображении вы можете видеть, что Jetpack Compose можно вставить в:
- Compose UI Toolkit , зависящий от платформы.
- Compose Plugins , который содержит среду выполнения Compose и компилятор.
Изменив Compose UI Toolkit, вы сможете использовать Compose на других платформах.
Благодаря Compose Multiplatform компания JetBrains предоставила именно такую поддержку. Это позволяет использовать Compose как для рабочего стола, так и для Интернета. В этой книге вы увидели, как разработать приложение для первой платформы и запустить его на macOS, Windows и Linux.
Различные версии Compose
Настольное приложение уже использует Compose для рабочего стола через подключаемый модуль JetBrains Compose. Чтобы Android и рабочий стол использовали один и тот же пользовательский интерфейс, модуль общего пользовательского интерфейса должен использовать один и тот же, а не версию Android, которую вы используете в androidApp .
Это необходимо, потому что в настоящее время существует ограничение между платформами Android и Multiplatform Compose. Google и JetBrains запускают обновления библиотек в разное время. Это означает, что один из наборов инструментов Compose UI Toolkit может использовать версию, несовместимую с другой. Поскольку вы совместно используете пользовательские интерфейсы для разных приложений, вам необходимо гарантировать, что все работает. Вы можете отслеживать ход решения этой проблемы с помощью Google IssueTracker.
Стоит отметить, что для стабильной работы плагин org.jetbrains.compose заменяет артефакты androidx.compose.* на артефакты от JetBrains. Это временное решение для работы с этими разными версиями.
Переход на мультиплатформу Compose
Откройте файл BookmarkContent.kt из shared-ui . Здесь вы увидите, что импорт в androidx.compose* не разрешается.
Вам нужно добавить плагин Compose Multiplatform и его библиотеки, чтобы решить эту проблему. Откройте файл build.gradle.kts из общего пользовательского интерфейса и импортируйте JetBrain Compose. В plugins
разделе перед com.android.library
добавьте:
id("org.jetbrains.compose") version "1.1.0"
Теперь добавьте библиотеки Compose, которые использует проект. Прокрутите вниз до sourceSets
и внутри commonMain
раздела добавьте:
dependencies { api(compose.foundation) api(compose.material) api(compose.runtime) api(compose.ui) }
Когда вы закончите, это будет выглядеть так:
val commonMain by getting { dependencies { api(compose.foundation) api(compose.material) api(compose.runtime) api(compose.ui) } }
Синхронизируйте проект и вернитесь к файлу BookmarkContent.kt .
Меньшее количество импортов, выделенных красным цветом, означает, что теперь проект может разрешать свои зависимости Compose.
Обновление общих зависимостей пользовательского интерфейса
Теперь, когда общий пользовательский интерфейс содержит пользовательский интерфейс вашего приложения, пришло время добавить недостающие библиотеки. Откройте файл build.gradle.kts из этого модуля и найдите commonMain/dependencies
. Обновите его, чтобы включить:
api(project(":shared")) api("org.jetbrains.kotlinx:kotlinx-datetime:0.3.2")
При появлении запроса щелкните, чтобы синхронизировать проект, чтобы он подключался к обеим библиотекам.
Использование сторонних библиотек
Хотя Compose Multiplatform делает первые шаги, сообщество внимательно следит за ней, выпуская библиотеки, которые помогают создать мост между приложениями для Android и настольных компьютеров.
Получение изображений
В приложении для Android вы использовали Coil для извлечения изображений. К сожалению, в настоящее время он не поддерживает Multiplatform , поэтому вы перенесете эту логику на новую: Kamel .
Kamel использует Ktor (подробнее об этой библиотеке вы можете прочитать в Главе 12, «Сеть») для извлечения мультимедиа. Этот API похож на Coil, поэтому вам не нужно будет вносить много изменений.
Откройте файл build.gradle.kts из shared-ui и в commonMain/dependencies
разделе добавьте Kamel:
api(project(":kamel-image"))
Здесь вы используете локальную версию Kamel, поскольку текущая версия не поддерживает последнюю версию Ktor .
Синхронизируйте проект.
В каталоге shared-ui/components откройте ImagePreview.kt . Этот файл содержит логику, необходимую для извлечения изображения из сети, и обрабатывает состояние запроса: успех , загрузка и ошибка .
Composable AddImagePreview
сначала проверяет, url
пусто ли. Если это не так, он создаст request
файл для загрузки изображения.
Kamel не работает с запросом напрямую в lazyPainterResource
, так что вы можете удалить эту логику. Обновите else
пункт на:
else { Box { //1 when (val resource = lazyPainterResource(url)) { //2 is Resource.Loading -> { Logger.d(TAG, "Loading image from uri=$url") AddImagePreviewEmpty(modifier) } //3 is Resource.Success -> { Logger.d(TAG, "Loading successful image from uri=$url") KamelImage( resource = resource, contentScale = ContentScale.Crop, contentDescription = "Image preview", modifier = modifier, crossfade = true ) } //4 is Resource.Failure -> { Logger.d(TAG, "Loading failed image from uri=$url. Reason=${resource.exception}") AddImagePreviewEmpty(modifier) } } } }
Вот пошаговая разбивка этой логики:
lazyPainterResource
является частью библиотеки Kamel и похожа на туrequest
, что была у вас раньше. Он возвращает текущее состояние запроса, черезResource.*
которое может быть либоLoading
,Success
либоFailure
.- Обработка состояния запроса, если это
loading
, это означает, что операция продолжается. Визуально он покажет заполнитель изображения, содержащий логотип приложения. - Если изображение доступно, результатом будет файл
success
.Image
Вместе с полученным файлом добавляется Composable . - Напротив, если результатом является
failure
, вы отобразите пустой предварительный просмотр.
Поскольку API для получения изображений перенесен на Kamel, не забудьте удалить все импорты Coil вместе с OptIn
аннотацией в начале файла. Прокрутите вверх ImagePreview.kt и удалите:
import androidx.compose.ui.platform.LocalContext import coil.annotation.ExperimentalCoilApi import coil.compose.ImagePainter import coil.compose.rememberImagePainter import coil.request.ImageRequest @OptIn(ExperimentalCoilApi::class)
Использование LiveData и ViewModels
Learn был построен с использованием LiveData и ViewModels , которые доступны в Android через библиотеку runtime-livedata . Поскольку он содержит специфичный для Android код, использовать ту же библиотеку в настольном приложении будет невозможно.
К счастью, вокруг Kotlin Multiplatform и Compose существует сильное сообщество, которое пытается сократить разрыв между Android и настольным компьютером и создает библиотеки, которые можно использовать на обеих платформах. Одна из таких библиотек — PreCompose . Он был написан Tlaster и поддерживает компоненты Android Jetpack Lifecycle, ViewModel, LiveData и Navigation в многоплатформенном режиме.
Поскольку на момент написания этой статьи версия на GitHub по-прежнему использует более старую версию Compose Multiplatform вместо импорта опубликованной библиотеки, Learn содержит исходный код проекта с парой изменений — все плагины/библиотеки теперь используют последние версии.
Теперь, когда вы знакомы с precompose , откройте файл build.gradle.kts из модуля shared-ui и после включения api(project())
добавьте:
api(project(":precompose"))
Синхронизируйте свой проект. После завершения этой операции вам потребуется обновить ViewModels вашего приложения . Откройте файл BookmarkViewModel.kt в модуле shared-ui и удалите импорт, который вам больше не нужен:
import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.raywenderlich.learn.ui.utils.SingleLiveEvent
Чтобы импортировать ViewModel()
и viewModelScope
, вам нужно добавить предварительную версию обоих классов:
import moe.tlaster.precompose.viewmodel.ViewModel import moe.tlaster.precompose.viewmodel.viewModelScope
Класс LiveData из этой библиотеки немного отличается от Android. Удалите _items
переменную и обновите items
объявление до:
val items: MutableState<List<RWEntry>> = mutableStateOf(emptyList())
Это также требует, чтобы вы изменили его использование. Обновите onNewBookmarksList
, чтобы установить items
значение:
items.value = bookmarks
Теперь откройте FeedViewModel.kt . Вам нужно будет внести аналогичные изменения.
Удалите импорт в библиотеки Android:
import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope
И добавьте те из precompose для ViewModel()
и viewModelScope
:
import moe.tlaster.precompose.viewmodel.ViewModel import moe.tlaster.precompose.viewmodel.viewModelScope
Наконец, удалите _profile
объявление и замените его profile
на:
val profile: MutableState<GravatarEntry> = mutableStateOf(GravatarEntry())
И обновите его использование onMyGravatarData
до:
profile.value = item
И замените импорт:
import androidx.lifecycle.MutableLiveData
С участием:
import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf
После обновления обеих моделей представления перейдите к androidApp и откройте файл MainActivity.kt . Здесь найдите их объявление и обновите его до:
private lateinit var bookmarkViewModel: BookmarkViewModel private lateinit var feedViewModel: FeedViewModel
Нет поддержки для вызоваby viewModel()
precompose . Вместо этого вам нужно инициализировать их внутри функции Composable. Вот почему они установлены как . Внутри добавить:lateinit``setContent
feedViewModel = viewModel { FeedViewModel() } bookmarkViewModel = viewModel { BookmarkViewModel() }
При появлении запроса добавьте:
import moe.tlaster.precompose.ui.viewModel
И переместите fetch
вызовы моделей представления ниже его инициализации.
Наконец, удалите вызов observeAsState()
, который больше не нужен для объектов LiveData , которые предварительно компонуют использование.
Не забудьте удалить теперь ненужный импорт:
import androidx.activity.viewModels import androidx.compose.runtime.livedata.observeAsState
После внесения обоих изменений удалите класс SingleLiveEvent , который находится в каталоге utils . Это специфично для Android и больше не нужно.
Управление навигацией
Библиотека precompose также поддерживает навигацию Android и рабочего стола между различными экранами. В случае Learn пользователь может переключаться между вкладками на нижней панели навигации.
Настольное приложение уже использует precompose , поэтому вам не нужно ничего делать. Однако Android использовал свои библиотеки, поэтому вам нужно внести здесь несколько изменений. Откройте файл MainActivity.kt внутри androidApp и замените класс, на который расширяется активность:
class MainActivity : PreComposeActivity()
Вам также необходимо удалить androidx.*
импорт:
import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity
И добавьте те из precompose :
import moe.tlaster.precompose.lifecycle.PreComposeActivity import moe.tlaster.precompose.lifecycle.setContent
Это на стороне приложения. Теперь вам нужно вернуться к общему модулю пользовательского интерфейса и сделать еще несколько обновлений.
Нижняя панель навигации на Android использует NavHost , который недоступен для мультиплатформы. К счастью, в precompose есть аналогичная функция под названием Navigator . Вам нужно будет заменить текущую реализацию, которая используется, NavHostController
на эту.
Откройте файл main/MainBottomBar.kt и замените тип NavHostController
на Navigator
. Вам нужно сделать это изменение на MainBottomBar
и AppBottomNavigation
.
Прокрутите вниз до места BottomNavigationItem
определения и найдите onClick
. Обновите эту функцию новой навигацией от Navigator . Замените текущую реализацию на:
if (!isSelected) { selectedIndex.value = index navController.navigate(screen.route) }
Вам не нужно задавать дополнительные настройки.
Как только это будет сделано, не забудьте удалить импорт:
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController
Теперь, когда вы обновили MainBottomBar , вам необходимо внести аналогичные изменения в MainContent.kt . Откройте этот файл и еще раз замените NavHostController
тип различных функций на Navigator
.
MainScreenNavigationConfigurations
Вам также необходимо импортировать NavHost
из precompose , заменить наstartDestination
, initialRoute
а composable
вызов на scene
.
Наконец, удалите androidx.*
импорт:
import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable
Последнее требуемое изменение находится в файле MainScreen.kt , когда navController
он определен:
val navController = rememberNavigator()
И удалите:
navController.enableOnBackPressed(false)
Который в настоящее время не поддерживается.
Наконец удалите импорт:
import androidx.navigation.compose.rememberNavController
Миграция библиотек только для JVM на Android
Библиотеки аккомпаниатора , которые использует приложение для Android, создаются только для этой платформы. К счастью, сообщество снова приходит на помощь с версией, поддерживающей рабочий стол. Его портировал пользователь Syer10 , и вы можете найти репозиторий на его GitHub .
Последний выпуск теперь поддерживает как Android, так и JVM, поэтому вы можете легко добавить его, чтобы изучать и делиться им на обеих платформах. Откройте общий интерфейс build.gradle.kts и добавьте в commonMain/dependencies :
api("ca.gosyer:accompanist-pager:0.20.1") api("ca.gosyer:accompanist-pager-indicators:0.20.1")
Синхронизируйте проект.
Хотя вы можете использовать версию Google для Android и версию Syer10 для настольных компьютеров, поскольку вы используете общий пользовательский интерфейс на обеих платформах, вам нужно использовать один и тот же интерфейс на обеих.
Чтобы обеспечить эту поддержку, обе библиотеки , файлы build.gradle.kts , были обновлены с целью Android:
plugins { //1 kotlin("multiplatform") id("org.jetbrains.compose") version "1.1.0" //2 id("com.android.library") } kotlin { //3 android { publishLibraryVariants("release", "debug") } //4 jvm("desktop") { testRuns["test"].executionTask.configure { useJUnitPlatform() } } //5 sourceSets { val commonMain by getting { dependencies { api(compose.material) api(compose.ui) implementation("androidx.annotation:annotation:1.3.0") implementation("io.github.aakira:napier:2.1.0") } } val commonTest by getting val androidMain by getting val androidTest by getting val desktopMain by getting val desktopTest by getting } } //6 android { compileSdk = 31 sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") //7 sourceSets["main"].res.srcDirs("src/androidMain/res", "src/commonMain/resources") defaultConfig { minSdk = 21 targetSdk = 31 } compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } }
Вот пошаговая разбивка этой логики:
- Поскольку вы собираетесь создать библиотеку для более чем одной платформы, вам необходимо включить
multiplatform
подключаемый модуль. - Кроме того, поскольку одной из этих платформ является Android, вам также необходимо импортировать
com.android.library
, чтобы вы могли определить конфигурации, установленные на 6. - Раньше эта версия аккомпаниатора просто генерировала версию JVM. Поскольку вы хотите создать версию для Android и настольного компьютера, вам нужно добавить обе цели в
kotlin
раздел. Здесь вы определяете, что он должен генерироватьdebug
иrelease
строить. - Чтобы легко идентифицировать настольную версию, вы устанавливаете ее имя внутри
jvm
цели. - Поскольку вы создаете более чем для одной платформы, библиотеки, которые использует проект, должны быть добавлены в файл
commonMain
dependencies
. Хотя одна из библиотек относится к Android, поскольку она не зависит от платформы, вам не нужно определять какие-либо другие библиотеки для других свойств. - Конфигурация, которая будет использоваться для создания сборки Android.
- Каталог ресурсов на commonMain будет иметь шрифты и строки приложения , поэтому вам нужно добавить его местоположение в
sourceSets
путь к классу.
Управление ресурсами
Обе платформы обрабатывают ресурсы совершенно по-разному. Android создает класс R во время сборки, который ссылается на все файлы, расположенные в папке res : drawables, strings, colors и т. д. Хотя это дает вам легкий доступ к файлам ресурсов приложения, он не будет работать на другой платформе.
Загрузка локальных изображений
Чтобы загрузить локальные изображения, вам нужно написать эту логику в Kotlin Multiplatform. Это необходимо, так как Android использует класс R для ссылки на изображения, а рабочий стол использует путь к изображению в папке ресурсов .
Также стоит отметить, что обе платформы используют разные форматы изображений. Android использует векторные рисунки, а рабочий стол использует PNG. Имея это в виду, вы не будете делиться этими ресурсами напрямую. Они должны находиться в своих собственных папках для конкретной платформы.
Со стороны Android вы уже скопировали все необходимые ресурсы из androidApp/res . Однако для настольных компьютеров они по-прежнему находятся в desktopApp .
Начните с создания каталога ресурсов внутри shared-ui/desktopMain . Вы можете легко создать его, щелкнув правой кнопкой мыши по этой папке и выбрав Создать ▸ Каталог ▸ ресурсы . Затем переместите папку с изображениями из desktopApp/resources в эту вновь созданную папку.
Со всеми изображениями в правильных папках вам нужно создать класс для ссылки на них. В commonMain/theme создайте Icons.kt и добавьте:
@Composable public expect fun icBrand(): Painter @Composable public expect fun icLauncher(): Painter @Composable public expect fun icMore(): Painter @Composable public expect fun icHome(): Painter @Composable public expect fun icBookmark(): Painter @Composable public expect fun icLatest(): Painter @Composable public expect fun icSearch(): Painter
Эти функции представляют все изображения, которые приложения в настоящее время используют. После того , как объявления ожиданий выполнены, теперь вам нужно написать фактические реализации в androidMain и desktopMain . Начиная с первого, создайте Icons.kt по тому же пути, который вы создали на desktopMain (вам потребуется создать пакет темы ): androidMain/kotlin/com/raywenderlich/shared/ui/theme/Icons. кт
И добавить:
@Composable public actual fun icBrand(): Painter = painterResource(R.drawable.ic_brand) @Composable public actual fun icLauncher(): Painter = painterResource(R.mipmap.ic_launcher) @Composable public actual fun icMore(): Painter = painterResource(R.drawable.ic_more) @Composable public actual fun icHome(): Painter = painterResource(R.drawable.ic_home) @Composable public actual fun icBookmark(): Painter = painterResource(R.drawable.ic_bookmarks) @Composable public actual fun icLatest(): Painter = painterResource(R.drawable.ic_latest) @Composable public actual fun icSearch(): Painter = painterResource(R.drawable.ic_search)
Каждая из этих функций будет обращаться к сгенерированному классу R и обращаться к соответствующей ссылке на рисование или MIP-карту.
Теперь вам нужно сделать то же самое для desktopMain . Создайте тот же файл Icons.kt , но на этот раз в desktopMain/kotlin/com/raywenderlich/shared/ui/theme/ (вам нужно будет снова создать пакет темы ).
Затем добавьте:
@Composable public actual fun icBrand(): Painter = painterResource("images/razerware.png") @Composable public actual fun icLauncher(): Painter = painterResource("images/ic_launcher.png") @Composable public actual fun icMore(): Painter = painterResource("images/ic_more.png") @Composable public actual fun icHome(): Painter = painterResource("images/ic_home.png") @Composable public actual fun icBookmark(): Painter = painterResource("images/ic_bookmarks.png") @Composable public actual fun icLatest(): Painter = painterResource("images/ic_latest.png") @Composable public actual fun icSearch(): Painter = painterResource("images/ic_search.png")
Хотя вы используете painterResource
на обеих платформах, это разные функции. Один из PainterResources.android.kt , а другой PainterResources.desktop.kt .
Со всеми ресурсами, правильно идентифицированными с их соответствующими функциями, вам нужно будет сделать довольно много обновлений, чтобы заменить текущие вызовы класса R этой новой реализацией.
Начиная с алфавита, вам необходимо внести следующие изменения в файлы commonMain :
common/EntryContent В AddEntryContent
Composable замените вызов на R.mipmap.ic_launcher
:
val icon = icLauncher()
И после этого, когда вы получаете доступ R.drawable.ic_more
, с помощью:
val icon = icMore()
И удалить импорт:
import androidx.compose.ui.res.painterResource
компоненты/ImagePreview В AddImagePreviewEmpty
Composable замените вызов на R.drawable.ic_brand
:
val icon = icBrand()
А затем удалите импорт:
import androidx.compose.ui.res.painterResource
main/BottomNavigationScreens Вам потребуется внести несколько изменений в этот класс. Он больше не может получать @DrawableRes
, но вместо этого его нужно установить как @Composable
. Это необходимо, поскольку все функции, которые ссылаются на изображения как painterResource
на составные, могут быть вызваны только из другой составной функции.
Замените drawResId
параметр на:
val icon: @Composable () -> Unit
Поскольку теперь он получает @Composable
, вам нужно заменить объекты, объявленные в этом файле:
object Home : BottomNavigationScreens( route = "Home", stringResId = R.string.navigation_home, icon = { Icon( painter = icHome(), contentDescription = R.string.navigation_home ) } ) object Bookmark : BottomNavigationScreens( route = "Bookmark", stringResId = R.string.navigation_bookmark, icon = { Icon( painter = icBookmark(), contentDescription = R.string.navigation_bookmark ) } ) object Latest : BottomNavigationScreens( route = "Latest", stringResId = R.string.navigation_latest, icon = { Icon( painter = icLatest(), contentDescription = R.string.navigation_latest ) } ) object Search : BottomNavigationScreens( route = "Search", stringResId = R.string.navigation_search, icon = { Icon( painter = icSearch(), contentDescription = R.string.navigation_search ) } )
И вы также можете удалить этот импорт:
import androidx.annotation.DrawableRes
И добавить:
import androidx.compose.material.Icon import com.raywenderlich.learn.ui.theme.icBookmark import com.raywenderlich.learn.ui.theme.icHome import com.raywenderlich.learn.ui.theme.icLatest import com.raywenderlich.learn.ui.theme.icSearch
Здесь все еще есть пара ошибок, связанных со строками приложения. Вы увидите, как подробно обновить эту логику, в разделе «Совместное использование строк» этого приложения.
main/MainBottomBar В AppBottomNavigation
Composable при определении icon
внутри BottomNavigationItem
замените Icon
вызов на:
screen.icon()
И удалите импорт:
import androidx.compose.material.Icon import androidx.compose.ui.res.painterResource
search/SearchContent В AddSearchField
Composable замените вызов leadingIcon
на painterResource
:
val icon = icSearch()
И удалить импорт:
import androidx.compose.ui.res.painterResource
Все сделано! Еще пара разделов, и вы получите полностью общий интерфейс вашего приложения.
Использование пользовательских шрифтов
Оба приложения должны использовать шрифт Bitter. Ранее вы переместили все папки из папки androidApp/res в commonMain/resources . Если вы откроете его, вы увидите, что там есть набор битов_*.ttf , представляющих шрифты, которые может использовать ваш текст.
Как и в предыдущем разделе, вам нужно создать многоплатформенную функцию для их загрузки.
Начните с создания файла Font.kt внутри commonMain/theme в модуле shared-ui . Этот класс будет содержать объявление функции:
@Composable expect fun Font(name: String, res: String, weight: FontWeight, style: FontStyle): Font
На шрифт можно ссылаться либо через класс R на Android, либо с помощью его пути на рабочем столе. Аргумент res
представляет это. weight
и style
соответствуют его свойствам, и, конечно же, name
его названию.
Откройте androidMain/theme и создайте соответствующий файл Font.kt со следующей фактической реализацией:
@Composable actual fun Font(name: String, res: String, weight: FontWeight, style: FontStyle): Font { val context = LocalContext.current val id = context.resources.getIdentifier(res, "font", context.packageName) return Font(id, weight, style) }
Чтобы сделать эту функцию универсальной для настольного приложения, res
она должна быть строкой.
Теперь перейдите к desktopMain / theme и добавьте реализацию Font.kt. Создайте файл и добавьте:
@Composable actual fun Font(name: String, res: String, weight: FontWeight, style: FontStyle): Font = androidx.compose.ui.text.platform.Font("font/$res.ttf", weight, style)
Наконец, создайте файл с именем Fonts.kt внутри commonMain/theme и добавьте объект, который будет содержать то, что BitterFontFamily
вы можете использовать:
object Fonts { @Composable fun BitterFontFamily() = FontFamily( Font( "BitterFontFamily", "bitter_bold", FontWeight.Bold, FontStyle.Normal ), Font( "BitterFontFamily", "bitter_extrabold", FontWeight.ExtraBold, FontStyle.Normal ), Font( "BitterFontFamily", "bitter_light", FontWeight.Light, FontStyle.Normal ), Font( "BitterFontFamily", "bitter_regular", FontWeight.Normal, FontStyle.Normal ), Font( "BitterFontFamily", "bitter_semibold", FontWeight.SemiBold, FontStyle.Normal ) ) }
Опять же, Font
созданные вами классы представляют Composable
функции, и, поскольку вы не можете ссылаться на них вне Composable, вы можете использовать эти шрифты непосредственно из свойства Typography в файле Type.kt.
Перед обновлением всех Text Composable этой новой типографикой вам необходимо удалить ссылки на класс R из Type.kt. Откройте этот файл и удалите:
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import com.raywenderlich.learn.android.R private val BitterFontFamily = FontFamily( Font(R.font.bitter_bold, FontWeight.Bold), Font(R.font.bitter_extrabold, FontWeight.ExtraBold), Font(R.font.bitter_light, FontWeight.Light), Font(R.font.bitter_regular), Font(R.font.bitter_semibold, FontWeight.SemiBold), )
Теперь, когда больше нет BitterFontFamily
, вам нужно удалить этот вызов из всех fontFamily
свойств. После этого вам необходимо вручную обновить все стили текста , так как невозможно ссылаться на созданные выше шрифты из типографики .
Начиная с commonMain/ui в алфавитном порядке , перейдите к:
- common/EmptyContent : в
Text
объявлении установитеfontFamily
аргумент:
fontFamily = Fonts.BitterFontFamily(),
- common/EntryContent : в
AddEntryContent
Composable найдите четыре вариантаText
использования и добавьте:
fontFamily = Fonts.BitterFontFamily(),
- home/HomeContent : прокрутите вниз до конца этого файла и
Text
добавьте:
fontFamily = Fonts.BitterFontFamily(),
- home/HomeSheetContent : найдите два
Text
вызова и добавьте:
fontFamily = Fonts.BitterFontFamily(),
- last/LatestContent : установите
fontFamily
вText
объявленияхAddNewPage
иAddNewPageEntry
:
fontFamily = Fonts.BitterFontFamily(),
- main/MainBottomBar : при определении при
BottomNavigationItem
добавленииText
:
fontFamily = Fonts.BitterFontFamily(),
- main/MainTopAppBar : обновите,
Text
чтобы он содержалfontFamily
аргумент:
fontFamily = Fonts.BitterFontFamily(),
- search/SearchContent : Наконец, при определении
placeholder
набораfontFamily
inText
:
fontFamily = Fonts.BitterFontFamily(),
Совместное использование строк
В настоящее время нет прямой поддержки этой функции. Это правда, что вы можете следовать подходу, аналогичному тому, который вы использовали для обмена локальными изображениями. Однако это потребует много времени и денег в обслуживании. Для каждой новой строки нужно создать три разные функции (одну общую и две на уровне платформы).
К счастью, команда разработчиков IceRock создала библиотеку moko-resources , которая поддерживает именно это и многое другое!
Начните с открытия файла build.gradle.kts , расположенного в корневом каталоге. В buildscript
разделе, внутри dependencies
, в конце списка добавить:
classpath("dev.icerock.moko:resources-generator:0.18.0")
Это добавит resources-generator
в проект. Теперь откройте файл build.gradle.kts , но на этот раз файл из shared-ui и добавьте его плагин:
id("dev.icerock.mobile.multiplatform-resources")
При этом вам необходимо установить имя пакета приложения для использования moko-resources . После plugins
объявления добавить:
multiplatformResources { multiplatformResourcesPackage = "com.raywenderlich.learn" }
Теперь вам нужно добавить библиотеку на commonMain/dependencies
раздел:
api("dev.icerock.moko:resources:0.18.0")
Нажмите «Синхронизировать» и подождите, пока проект загрузит эту новую библиотеку.
Строки в настольном приложении в настоящее время жестко запрограммированы. Этого достаточно для простого приложения, но если вы продолжаете добавлять новые функции, использующие строки, их будет проще поддерживать в одном файле. Более того, если вы хотите добавить поддержку интернационализации, вам потребуется несколько строковых файлов, чтобы ОС могла знать, какой из них следует загрузить.
Вы будете повторно использовать файл Android strings.xml в качестве общих строк на обеих платформах. Создайте папку ресурсов внутри shared-ui/commonMain , щелкнув правой кнопкой мыши commonMain и выбрав New ▸ Directory ▸ resources при появлении запроса.
Чтобы moko-resources работал, файлы строк должны находиться по определенному пути: commonMain/resources/MR/base . Создайте эти два каталога и переместите strings.xml из androidApp/res в это новое место.
Примечание . Если ваше приложение поддерживает интернационализацию, вам следует создать папку внутри MR с кодом страны языка, а затем переместить в это место соответствующий файл strings.xml .
Создайте проект. moko-resources сгенерирует несколько мультиплатформенных файлов (Android, рабочий стол и обычный), содержащих строки, которые будет использовать ваше приложение. Вы можете найти их по адресу:
- общий пользовательский интерфейс/сборка/сгенерированный/моко/
Прежде чем использовать любую из этих строк, не хватает одного шага: вам все еще нужно реализовать логику для доступа к ним.
Начните с создания файла Resources.kt в папке commonMain/utils в модуле shared-ui :
expect fun getString(resId: StringResource): String
Теперь вам нужно объявить фактические реализации как для Android, так и для рабочего стола. Начиная с первого, перейдите в androidMain/ui/utils и создайте соответствующий файл Resources.kt с:
actual fun getString(resId: StringResource): String { return StringDesc.Resource(resId).toString(appContext) }
Соответствует контекстуappContext
приложения , который уже объявлен в файле PlatformDatabase.kt и установлен в RWApplication.kt .
На desktopMain/ui/utils создайте оставшийся файл Resources.kt и добавьте:
actual fun getString(resId: StringResource): String { return StringDesc.Resource(resId).localized() }
После определения реализации вам нужно будет пройти через все классы и обновить ссылки на класс R. Вместо доступа к R.string.* они будут вызывать getString
функцию, которую вы только что создали.
Начиная с commonMain/ui в алфавитном порядке , перейдите к:
- bookmark/BookmarkContent :
BookmarkContent
в Composable обновитеstringResource
вызов до:
text = getString(MR.strings.empty_screen_bookmarks)
И удалите импорт:
import androidx.compose.ui.res.stringResource import com.raywenderlich.learn.android.R
- common/EntryContent.kt : Найдите все вызовы
stringResource
и последовательно обновите их до эквивалентаgetString()
. Сверху обновитеdescription
значение до:
val description = getString(MR.strings.description_feed_icon)
Затем замените ссылку на app_ray_wenderlich
на:
text = getString(MR.strings.app_ray_wenderlich),
Наконец, измените доступ description_more
к:
val description = getString(MR.strings.description_more)
Еще раз удалите импорт:
import androidx.compose.ui.res.stringResource import com.raywenderlich.learn.android.R
- компоненты/ImagePreview .kt : вам нужно внести только одно изменение. Прокрутите вниз
AddImagePreviewEmpty
и обновитеdescription
свойство, которое обращается к классу R , на:
val description = getString(MR.strings.description_preview_error)
И удалите неиспользуемый импорт:
import androidx.compose.ui.res.stringResource import com.raywenderlich.learn.android.R
- home/HomeSheetContent.kt : Найдите доступ к классу R. Первый является результатом условия if, используемого для определения того, что
text
следует отображать. Замените этот блок кода на:
val text = if (item.value.bookmarked) { getString(MR.strings.action_remove_bookmarks) } else { getString(MR.strings.action_add_bookmarks) }
После этого обновления типом text
свойства является String , поэтому вы можете удалить вызов stringResource
из Text
Composable ниже:
text = text
В конце файла есть еще одна ссылка на R. Замените этот вызов на:
text = getString(MR.strings.action_share_link),
И удалите импорт:
import androidx.compose.ui.res.stringResource import com.raywenderlich.learn.android.R
- last/LatestContent.kt :
LatestContent
в Composable обновите вызов строк до:
AddEmptyScreen(getString(MR.strings.empty_screen_loading))
Как всегда, удалите ненужный импорт:
import androidx.compose.ui.res.stringResource import com.raywenderlich.learn.android.R
- main/BottomNavigationScreens.kt :
@StringRes
ссылка на строку, характерная для платформы Android. Поскольку вы используете этот класс совместно с настольным приложением, вам необходимо обновить этот параметр до общего типа — String . ИзменитьstringResId
на:
val title: String,
При этом вам необходимо обновить все объекты, объявленные в этом классе.
Для home
объекта обновите stringResId
и contentDescription
, соответственно, до:
title = getString(MR.strings.navigation_home),
contentDescription = getString(MR.strings.navigation_home)
То же самое относится и к bookmark
объекту:
title = getString(MR.strings.navigation_bookmark),
contentDescription = getString(MR.strings.navigation_bookmark)
И к latest
:
title = getString(MR.strings.navigation_latest),
contentDescription = getString(MR.strings.navigation_latest)
Наконец, для search
:
title = getString(MR.strings.navigation_search),
contentDescription = getString(MR.strings.navigation_search)
Удалите ненужный теперь импорт:
import androidx.annotation.StringRes import com.raywenderlich.learn.android.R
- main/MainBottomBar.kt
BottomNavigationItem
: с предыдущим изменением вам необходимо обновить файлMainBottomBar
. ЗаменитеstringResource
вызов на:
text = screen.title,
И удалите его импорт:
import androidx.compose.ui.res.stringResource
- main/MainTopAppBar.kt : Замените
stringResource
вызов на:
text = getString(MR.strings.app_name),
И удалите импорт:
import androidx.compose.ui.res.stringResource import com.raywenderlich.learn.android.R
- search/SearchContent.kt : Это последний файл, который нужно обновить! Прокрутите вниз
AddSearchField
и найдите два вызоваstringResource
. В первом вы определяетеplaceholder
и должны быть обновлены до:
text = getString(MR.strings.search_hint),
Второй для leadingIcon
, и вам нужно изменить на description
:
val description = getString(MR.strings.description_search)
И, как всегда, удалите импорт:
import androidx.compose.ui.res.stringResource import com.raywenderlich.learn.android.R
Чего не хватает?
Сделав все эти изменения, вы почти закончили. Откройте проект desktopApp и:
- Удалите пользовательский интерфейс и компоненты , кроме файла Toast.kt .
- Переместите папку шрифтов из ресурсов в commonMain/desktopMain/resources .
- Откройте класс Utils.kt и удалите
SupressLint
аннотацию, специфичную для Android.
Вы должны оставить только Main.kt , который является точкой входа вашего приложения.
Здесь обновите MainScreen
вызов:
MainScreen( feeds = items, bookmarks = bookmarks, onUpdateBookmark = { updateBookmark(it) }, onShareAsLink = {}, onOpenEntry = { openLink(it) } )
Теперь откройте его build.gradle.kts и включите зависимость общего пользовательского интерфейса , которую вы создали в этом приложении. Чтобы избежать ненужных реализаций, вы можете заменить все библиотеки в этом разделе на:
implementation(project(":shared")) implementation(project(":shared-ui")) implementation(project(":shared-action")) implementation(compose.desktop.currentOs)
Сделайте то же самое для androidApp . Откройте его build.gradle.kts и замените dependencies
раздел на:
dependencies { implementation(project(":shared")) implementation(project(":shared-ui")) implementation(project(":shared-action")) implementation("com.google.android.material:material:1.5.0") }
Синхронизируйте свой проект и, наконец, скомпилируйте и запустите приложения для ПК и Android. Вы увидите такие экраны:

Рис. C.3 Лента в приложении для Android

Рис. C.4 Лента в настольном приложении
Куда пойти отсюда?
Поздравляем! Вы только что закончили Kotlin Multiplatform. Какая поездка! Из этой книги вы узнали, как совместно использовать бизнес-логику приложения на разных платформах: Android, iOS и настольных компьютерах. Теперь, когда вы освоили KMP, возможно, вам интересно узнать больше о Jetpack Compose и SwiftUI . Эти книги — идеальная отправная точка!