Создаём своё первое приложение для Kotlin Multiplatform
В настоящее время мы переживаем бум появления новых технологий и подходов к написанию мобильных приложений. Одной из них является развивающийся SDK от компании JetBrains для мультиплатформенной разработки Kotlin Multiplatfrom (KMP) .
Основная идея KMP, как и других кросс-платформенных SDK — оптимизация разработки путем написания кода один раз и последующего его использования на разных платформах.
Согласно концепции JetBrains, Kotlin Multiplatform не является фреймворком. Это именно SDK, который позволяет создавать модули с общим кодом, подключаемые к нативным приложениям.
Написанный на Kotlin модуль компилируется в JVM байткод для Android и LLVM байткод для iOS.
Этот модуль (Shared, Common) содержит переиспользуемую бизнес-логику. Платформенные модули iOS/Android, к которым подключен Shared/Common, либо используют написанную логику напрямую, либо имплементируют свою реализацию в зависимости от особенностей платформы.
Общая бизнес-логика может включать в себя:
- сервисы для работы с сетью;
- сервисы для работы с БД;
- модели данных.
Также в нее могут входить архитектурные компоненты приложения, напрямую не включающие UI, но с ним взаимодействующие:
- ViewModel;
- Presenter;
- Интеракторы и т.п.
Концепцию Kotlin Multiplatform можно сравнить с реализацией Xamarin Native. Однако, в KMP нет модулей или функционала, реализующих UI. Эта логическая нагрузка ложится на подключенные нативные проекты.
Рассмотрим подход на практике и попробуем написать наше первое приложение Kotlin Multiplatform.
Для начала нам потребуется установить и настроить инструменты:
- Android Sdk
- Xcode с последним iOS SDK.
- Intelij IDEA CE или Android Studio. Обе IDE позволяют создавать и настраивать проекты для Kotlin Multiplatform. Но если в Intelij IDEA проект создается автоматически, то в Android Studio большую часть настроек надо сделать вручную. Если вам привычнее работать именно с Android Studio, то подробное руководство по созданию проекта можно посмотреть в документации на Kotlinlang.org
Мы рассмотрим создание проекта с помощью Intelij IDEA.
Выбираем меню File → New → Create Project:
В появившемся окне выбираем тип проекта Kotlin → Mobile Android/iOS|Gradle
Далее стандартно задаем путь к JDK, имя и расположение проекта
После нажатия кнопки Finish проект сгенерируется и будет почти готов к работе.
Рассмотрим, что у нас получилось:
Мультиплатформенные проекты Kotlin обычно делятся на несколько модулей:
- модуль переиспользуемой бизнес-логики (Shared, commonMain и т.п);
- модуль для IOS приложения (iOSMain, iOSTest);
- модуль для Android приложения (androidMain, androidTest).
В них располагается наша бизнес-логика. Сам код базового примера мы разберем немного позже.
Код нативного Android приложения располагается в каталоге main, как если бы мы создавали проект по шаблону обычного Android.
iOS приложение создается автоматически и располагается в каталоге iOSApp:
Перед тем, как мы проверим работоспособность базового решения, необходимо сделать следующие финальные настройки:
В local.properties зададим путь к SDK Android:
Создадим конфигурацию для работы Android приложения:
Готово.
Теперь вызовем команду gradle wrapper
для сборки нашего модуля общей логики:
После сборки модуль для бизнес-логики для Android приложения доступен в app/build/libs:
Путь к библиотеке прописывается стандартно, в блоке dependencies
файла build.gradle:
Теперь наш проект сконфигурирован для запуска Android приложения:
Осталось сделать настройки для запуска приложения iOS.
В файле build.gradle(:app) необходимо изменить настройку архитектура проекта, чтобы наше приложение поддерживало как реальные устройства, так и эмуляторы.
Меняем:
На
После выполнения сборки создастся фреймворк в app/build/bin/ios:
Intelij IDEA автоматически создает в gradle файле код для генерации, подключения и встраивания фреймворка в IOS проект:
При ручной настройке проекта (например, через Android Studio) этот код потребуется указать самостоятельно.
После синхронизации gradle iOS проект готов к запуску и проверке с помощью XCode.
Проверяем, что у нас получилось. Открываем проект iOS через iosApp.xcodeproj:
Проект имеет стандартную структуру, за исключением раздела app, где мы получаем доступ к коду наших модулей на Kotlin.
Фреймворк действительно подключен автоматически во всех соответствующих разделах проекта:
Запускаем проект на эмуляторе:
Теперь разберем код самого приложения на базовом примере.
Используемую в проекте бизнес-логику можно разделить на:
- переиспользуемую (общую);
- платформенную реализацию.
Переиспользуемая логика располагается в проекте commonMain в каталоге kotlin и разделяется на package. Декларации функций, классов и объектов, обязательных к переопределению, помечаются модификатором expect
:
Реализация expect
-функционала задается в платформенных модулях и помечается модификатором actual
:
Вызов логики производится в нативном проекте:
Все очень просто.
Теперь попробуем по тем же принципам сделать что-то посложнее и поинтереснее. Например, небольшое приложение для получения и отображение списка новостей для iOS и Android.
Приложение будет иметь следующую структуру:
В общей части (Common) расположим бизнес-логику:
- сетевой сервис;
- сервис для запросов новостей.
В модулях iOS/Android приложений оставим только UI компоненты для отображения списка и адаптеры. iOS часть будет написана на Swift, Android – на Kotlin. Здесь в плане работы не будет ничего нового.
Организуем архитектуру приложений по простому паттерну MVP. Презентер, обращающийся к бизнес-логике, также вынесем в Common часть. Также поступим и с протоколом для связи между презентером и экранами UI:
interface INewsListView :IView {
fun setupNews(list: List)
}
Начнем с бизнес-логики. Т.к весь функционал будет в модуле common, то мы будем использовать в качестве библиотек решения для Kotlin Multiplatform:
1. Ktor – библиотека для работы с сетью и сериализации.
В build.gradle (:app) пропишем следующие зависимости:
commonMain {
dependencies {
…
implementation("io.ktor:ktor-client-core:1.3.2")
implementation("io.ktor:ktor-client-json:1.3.2")
implementation("io.ktor:ktor-client-serialization:1.3.2")
}
}
androidMain {
dependencies {
…..
implementation("io.ktor:ktor-client-android:1.3.2")
implementation("io.ktor:ktor-client-json-jvm:1.3.2")
implementation("io.ktor:ktor-client-serialization-jvm:1.3.2")
}
}
iosMain {
dependencies {
//….
implementation("io.ktor:ktor-client-ios:1.3.2")
implementation("io.ktor:ktor-client-json-native:1.3.2")
implementation("io.ktor:ktor-client-serialization-native:1.3.2")
}
}
Также добавим поддержку плагина сериализации:
plugins {
….
id 'org.jetbrains.kotlin.plugin.serialization' version '1.3.72'
}
apply plugin: 'kotlinx-serialization'
2. Kotlin Coroutines – для организации многопоточной работы.
commonMain {
dependencies {
…
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-common:1.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime-common:0.14.0")
….
}
}
androidMain {
dependencies {
…
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime:0.14.0")
….
}
}
iosMain {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-common:1.3.5-native-mt")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-runtime-native:0.14.0")
….
}
}
При добавлении зависимости в iOS проект обратите внимание, что версия библиотеки должна быть обязательно native-mt и совместима с версией плагина Kotlin multiplatform.
При организации многопоточности с помощью Coroutines необходимо передавать контекст потока (CoroutineContext
), в котором логика будет исполняться. Это платформозависимая логика, поэтому используем кастомизацию с помощью expect
/actual
.
В commonMain создадим Dispatchers.kt, где объявим переменные:
expect val defaultDispatcher: CoroutineContext
expect val uiDispatcher: CoroutineContext
Реализация в androidMain создается легко. Для доступа к соответствующим потокам используем CoroutineDispatchers Main
(UI поток) и Default
(стандартный для Coroutine
):
actual val uiDispatcher: CoroutineContext
get() = Dispatchers.Main
actual val defaultDispatcher: CoroutineContext
get() = Dispatchers.Default
С iOS труднее. Та версия Kotlin Native LLVM компилятора, которая используется в Kotlin Multiplatform, не поддерживает background очереди. Это давно известная проблема, которая к сожалению, еще не исправлена
Поэтому попробуем обходной маневр как временное решение проблемы.
actual val uiDispatcher: CoroutineContext
get() = MainDispatcher
actual val defaultDispatcher: CoroutineContext
get() = MainDispatcher
private object MainDispatcher: CoroutineDispatcher() {
override fun dispatch(context: CoroutineContext, block: Runnable) {
dispatch_async(dispatch_get_main_queue()) {
try {
block.run().freeze()
} catch (err: Throwable) {
throw err
}
}
}
}
Мы создаем свой CoroutineDispatcher
, где прописываем выполнение логики в асинхронной очереди dispatch_async
.
Также нам понадобится свой scope для работы сетевого клиента:
iOS
actual fun ktorScope(block: suspend () -> Unit) {
GlobalScope.launch(MainDispatcher) { block() }
}
Android
actual fun ktorScope(block: suspend () -> Unit) {
GlobalScope.launch(Dispatchers.Main) { block() }
}
Применим это при реализации сетевого клиента на Ktor:
interface INetworkService {
suspend fun getData(path: String, serializer: KSerializer,completed: (ContentResponse)->Unit)
}
class NetworkService : INetworkService{
private val httpClient = HttpClient()
override suspend fun getData(path: String, serializer: KSerializer,completed: (ContentResponse)->Unit){
//Для ktor используем свой скоуп
ktorScope {
var contentResponse = ContentResponse()
try {
val json = httpClient.get {
url {
protocol = URLProtocol.HTTPS
host = NetworkConfig.shared.apiUrl
encodedPath = path
header("X-Api-Key", NetworkConfig.shared.apiKey)
}
}
print(json)
val response = kotlinx.serialization.json.Json.nonstrict.parse(serializer, json)
contentResponse.content = response
} catch (ex: Exception) {
val error = ErrorResponse()
error.message = ex.message.toString()
contentResponse.errorResponse = error
print(ex.message.toString())
}
//Ответ отдаем в UI-поток
withContext(uiDispatcher) {
completed(contentResponse)
}
}
}
}
Парсинг реализуем с помощью сериализатора типа KSerializer<T>
. В нашем случае это NewsList.serializer()
. Пропишем реализацию в сервисе новостей:
@TheadLocal
class NewsService{
companion object {
val shared = NewsApi()
}
val networkService = NetworkService()
suspend fun getNewsList(completed: (ContentResponse)->Unit){
val path = "v2/top-headlines?language=en"
networkService.getData(path, NewsList.serializer(),completed)
}
}
Вызывать бизнес-логику будем в презентере. Для полноценной работы с coroutines нам надо будет создать scope:
class PresenterCoroutineScope(
context: CoroutineContext
) : CoroutineScope {
private var onViewDetachJob = Job()
override val coroutineContext: CoroutineContext = context + onViewDetachJob
fun viewDetached() {
onViewDetachJob.cancel()
}
}
и добавить его в презентер. Вынесем в базовый класс:
abstract class BasePresenter(private val coroutineContext: CoroutineContext) {
protected var view: T? = null
protected lateinit var scope: PresenterCoroutineScope
fun attachView(view: T) {
scope = PresenterCoroutineScope(coroutineContext)
this.view = view
onViewAttached(view)
}
…
}
Теперь создадим презентер NewsListPresenter
для нашего модуля. В инициализатор передадим defaultDispatcher
:
class NewsPresenter:BasePresenter(defaultDispatcher){
var service: NewsApi = NewsApi.shared
var data: ArrayList = arrayListOf()
fun loadData() {
//запускаем в скоупе
scope.launch {
service.getNewsList {
val result = it
if (result.errorResponse == null) {
data = arrayListOf()
data.addAll(result.content?.articles ?: arrayListOf())
view?.setupNews(data)
}
}
}
}
}
Обратите внимание! Из-за особенностей текущей работы Kotlin Native с многопоточностью в IOS работа с синглтонами может привести к крашу. Поэтому для корректной работы надо добавить аннотацию @ThreadLocal
для используемого объекта:
class NewsService{
@ThreadLocal
companion object {
val shared = NewsService()
}
…
}
Осталось подключить логику к нативным IOS и Android модулям и обработать ответ от Presenter:
class NewsListVC: UIViewController {
private lazy var presenter: NewsPresenter? = {
let _presenter = NewsPresenter()
_presenter.attachView(view: self)
return _presenter
}()
override func viewDidLoad() {
super.viewDidLoad()
self.presenter?.loadData()
}
extension NewsListVC : INewsListView {
func setupNews(list: [NewsItem]) {
…
}
Android:
class NewsActivity : AppCompatActivity(), INewsListView {
….
private var _presenter: NewsPresenter? = null
override fun onCreate(savedInstanceState: Bundle?) {
_presenter = NewsPresenter()
_presenter?.attachView(this)
…
}
override fun setupNews(list: List) {
….
}
Запускаем сборку common модуля gradle wrapper, чтобы сборки обновились. Проверяем работу приложений:
Android:
iOS:
Готово. Вы великолепны.
Оба наши приложения работают и работают одинаково.
Ссылка на ресурсы.
Информационные материалы, которые использовались: