Создаём своё первое приложение для 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.

Для начала нам потребуется установить и настроить инструменты:

  1. Android Sdk
  2. Xcode с последним iOS SDK.
  3. 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 обычно делятся на несколько модулей:

  • модуль переиспользуемой бизнес-логики (SharedcommonMain и т.п);
  • модуль для IOS приложения (iOSMainiOSTest);
  • модуль для Android приложения (androidMainandroidTest).

В них располагается наша бизнес-логика. Сам код базового примера мы разберем немного позже.

Код нативного 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:

Готово. Вы великолепны.

Оба наши приложения работают и работают одинаково.

Ссылка на ресурсы.

Информационные материалы, которые использовались: