Создаём первое приложение для 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>. В нашем случае это
@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
Готово. Вы великолепны.
Оба наши приложения работают и работают одинаково.
Ссылка на ресурсы.
Информационные материалы, которые использовались: