В этом уроке мы сделаем из двух стандартных android и ios проектов мультиплатформенный проект с общей библиотекой на Kotlin Multiplatform.

Для работы потребуется:

Для начала потребуются 2 проекта - Android и iOS созданные из шаблонов Android Studio и Xcode. Проекты нужно расположить в одну директорию, чтобы получилось так:

├── android-app
│   ├── build.gradle
│   ├── proguard-rules.pro
│   └── src
│       ├── androidTest
│       │   └── java
│       │       └── com
│       │           └── icerockdev
│       │               └── android_app
│       │                   └── ExampleInstrumentedTest.kt
│       ├── main
│       │   ├── AndroidManifest.xml
│       │   ├── java
│       │   │   └── com
│       │   │       └── icerockdev
│       │   │           └── android_app
│       │   │               └── MainActivity.kt
│       │   └── res
│       │       ├── drawable
│       │       │   └── ic_launcher_background.xml
│       │       ├── drawable-v24
│       │       │   └── ic_launcher_foreground.xml
│       │       ├── layout
│       │       │   └── activity_main.xml
│       │       ├── mipmap-anydpi-v26
│       │       │   ├── ic_launcher.xml
│       │       │   └── ic_launcher_round.xml
│       │       ├── mipmap-hdpi
│       │       │   ├── ic_launcher.png
│       │       │   └── ic_launcher_round.png
│       │       ├── mipmap-mdpi
│       │       │   ├── ic_launcher.png
│       │       │   └── ic_launcher_round.png
│       │       ├── mipmap-xhdpi
│       │       │   ├── ic_launcher.png
│       │       │   └── ic_launcher_round.png
│       │       ├── mipmap-xxhdpi
│       │       │   ├── ic_launcher.png
│       │       │   └── ic_launcher_round.png
│       │       ├── mipmap-xxxhdpi
│       │       │   ├── ic_launcher.png
│       │       │   └── ic_launcher_round.png
│       │       └── values
│       │           ├── colors.xml
│       │           ├── strings.xml
│       │           └── styles.xml
│       └── test
│           └── java
│               └── com
│                   └── icerockdev
│                       └── android_app
│                           └── ExampleUnitTest.kt
├── build.gradle
├── gradle
│   └── wrapper
│       ├── gradle-wrapper.jar
│       └── gradle-wrapper.properties
├── gradle.properties
├── gradlew
├── gradlew.bat
├── ios-app
│   ├── ios-app
│   │   ├── AppDelegate.swift
│   │   ├── Assets.xcassets
│   │   │   ├── AppIcon.appiconset
│   │   │   │   └── Contents.json
│   │   │   └── Contents.json
│   │   ├── Base.lproj
│   │   │   ├── LaunchScreen.storyboard
│   │   │   └── Main.storyboard
│   │   ├── Info.plist
│   │   └── ViewController.swift
│   └── ios-app.xcodeproj
│       ├── project.pbxproj
│       └── project.xcworkspace
│           └── contents.xcworkspacedata
└── settings.gradle

Для этого создаем Android проект, после чего модуль app переименуем в android-app (не забывая изменить имя модуля в settings.gradle) и создаем iOS проект ios-app в корень android проекта.

Для удобства можно просто скачать начальное состояние архивом.

Для добавления общей библиотеки нужно добавить новый gradle модуль (android и mpp библиотеки управляются системой сборки gradle). Для создания модуля нужно:
Создаем директорию mpp-library (так будет называться наш gradle модуль) рядом с приложениями, чтобы получилось:

├── android-app
├── ios-app
└── mpp-library

Создаем mpp-library/build.gradle в нем будет располагаться конфигурация мультиплатформенного модуля. Содержимое файла для начала будет:

apply plugin: 'com.android.library'
apply plugin: 'org.jetbrains.kotlin.multiplatform'

В settings.gradle добавляем подключение нового модуля:

include ':mpp-library'

git changes

После сделанных изменений можно запустить Gradle Sync и убедиться что модуль подключился, но не может сконфигурироваться, так как для Android библиотеки не хватает данных. Во первых не указаны версии Android SDK. Проставим их:
В mpp-library/build.gradle:

android {
    compileSdkVersion 28

    defaultConfig {
        minSdkVersion 21
        targetSdkVersion 28
    }
}

git changes

Теперь Gradle Sync будет сообщать о ошибке чтения AndroidManifest - этот файл должен присутствовать в любом Android модуле. Создаем mpp-library/src/main/AndroidManifest.xml с содержимым:

<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.icerockdev.library" /> 

git changes

После сделанных действий Gradle Sync успешно выполнится.

Настройка мобильных таргетов

Добавляем в mpp-library/build.gradle:

kotlin {
    targets {
        android()
        iosArm64()
        iosX64()
    }
}

git changes

Создаем директории:

git changes

После этого можем выполнить Gradle Sync и увидим что директории commonMain/kotlin и androidMain/kotlin посветились как директории с исходным кодом. Но iosMain/kotlin не подсветился так, к этому вернемся чуть дальше. Сначала сделаем чтобы все относящееся к Android находилось в androidMain.

Конфигурирование Android таргета

Переносим AndroidManifest.xml в androidMain: mpp-library/src/main/AndroidManifest.xmlmpp-library/src/androidMain/AndroidManifest.xml
git changes

Но после переноса можно увидеть что Gradle Sync опять не находит AndroidManifest. Это связано с тем что Android gradle plugin ничего не знает про kotlin multiplatform плагин. Чтобы корректно перенести все связанное с Android в androidMain требуется добавить специальную конфигурацию.

Добавляем в mpp-library/build.gradle:

android {
    //...

    sourceSets {
        main {
            setRoot('src/androidMain')
        }
        release {
            setRoot('src/androidMainRelease')
        }
        debug {
            setRoot('src/androidMainDebug')
        }
        test {
            setRoot('src/androidUnitTest')
        }
        testRelease {
            setRoot('src/androidUnitTestRelease')
        }
        testDebug {
            setRoot('src/androidUnitTestDebug')
        }
    }
}

git changes

Теперь Gradle Sync успешно выполняется.

Создаем mpp-library/src/commonMain/kotlin/HelloWorld.kt с содержимым:

object HelloWorld {
    fun print() {
        println("hello common world")
    }
}

git changes

Но IDE сообщит что Kotlin не сконфигурирован. Это связано с тем, что для общего кода нужно еще подключить kotlin stdlib.
В mpp-library/build.gradle:

kotlin {
    // ...

    sourceSets {
        commonMain {
            dependencies {
                implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
            }
        }
    }
}

git changes

Теперь IDE корректно распознает kotlin код и мы можем писать common код на чистом kotlin.

Сначала нужно подключить к android-app нашу общую библиотеку. Это делается так как и с любым другим kotlin/java модулем.
Добавляем в android-app/build.gradle:

dependencies {
    // ...

    implementation project(":mpp-library")
}

Далее вызовем нашу функцию print на главном экране.
Добавляем в android-app/src/main/java/com/icerockdev/android_app/MainActivity.kt:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {	
        // ...

        HelloWorld.print()
    }
}

git changes

Далее при Gradle Sync IDE сообщит что версия sdk в android-app ниже чем версия sdk в mpp-library - поднимем ее, чтобы мы могли подключить общую библиотеку (либо нужно понизить в общей библиотеке).
Изменяем в android-app/build.gradle минимальную версию androidSdk для совместимости с mpp-library:

android {
    // ...
    minSdkVersion 21
    // ...
}

git changes

После этого можем запустить android-app на эмуляторе и убедиться что в консоль (logcat) вывелось сообщение.

Добавляем Android specific код

Предположим что мы хотим использовать какой-то platform-specific api. Для этого мы можем добавить в общей библиотеке код для платформы.
Создаем mpp-library/src/androidMain/kotlin/AndroidHelloWorld.kt с содержимым:

import android.util.Log

object AndroidHelloWorld {
    fun print() {
        Log.v("MPP", "hello android world")
    }
}

Это позволит нам в Android версии общей библиотеки видеть еще один класс - AndroidHelloWorld и внутри платформенного кода мы можем использовать любой функционал платформы (в нашем случае использовали android.util.Log). Остается вызвать и эту функцию в приложении.

Добавляем в android-app/src/main/java/com/icerockdev/android_app/MainActivity.kt:

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {	
        // ...

        AndroidHelloWorld.print()
    }
}

git changes

После изменения можно запустить android-app чтобы убедиться что работает и логирование через println и через андроидный Log.

Ранее мы увидели что iosMain/kotlin не распознается IDE как директория с исходным кодом. Это связано с тем, что у нас инициализированы два таргета - iosArm64 и iosX64. Исходный код этих таргетов ожидается в iosArm64/kotlin и iosX64/kotlin соответственно. Из-за этого выбор либо дублировать код, либо использовать какой либо вариант обобщения. Рекомендуемый нами вариант - использовать symlink'и на iosMain. Это позволит не дублировать исходный код и иметь корректную во всех отношениях интеграцию IDE.

Создаем symlink'и mpp-library/src/iosArm64Main и mpp-library/src/iosX64Main, для этого делаем:

cd mpp-library/src
ln -s iosMain iosArm64Main
ln -s iosMain iosX64Main

git changes

После этого можем запустить Gradle Sync и увидеть что iosX64Main/kotlin и iosArm64/kotlin являются директориями с исходным кодом.

Теперь добавим ios-specific код для iOS, с использованием платформенного API. Для этого мы можем создать файл в IDE через любую из наших директорий-symlink'ов (iosX64Main,iosArm64Main) - они все равно ведут в одно и то же место.

Создаем mpp-library/src/iosMain/kotlin/IosHelloWorld.kt:

import platform.Foundation.NSLog

object IosHelloWorld {
    fun print() {
        NSLog("hello ios world")
    }
}

git changes

Можно увидеть что IDE корректно распознает платформенные API ios, имеет автоимпорт, автокомплит и навигацию к определению.

Теперь нужно собрать framework который мы сможем подключить к iOS приложению. Но для компиляции framework‘а нужно дополнить конфигурацию проекта.

В mpp-library/build.gradle заменим iosArm64() и iosX64() на вызов с блоком конфигурации:

kotlin {
    targets {
        // ...

        def configure = {
            binaries {
                framework("MultiPlatformLibrary")
            }
        }

        iosArm64("iosArm64", configure)
        iosX64("iosX64", configure)
    }
}

git changes

После этого можем вызвать Gradle Task :mpp-library:linkMultiPlatformLibraryDebugFrameworkIosX64 для компиляции framework‘а для симулятора. По итогу мы получим в директории build/bin/iosX64/MultiPlatformLibraryDebugFramework/ наш скомпилированный framework. И его нужно подключить к iOS приложению.

Интегрируем framework в iOS приложение

Открываем через Xcode ios-app/ios-app.xcodeproj и добавляем фреймворк к проекту. Для этого:
Добавляем сам фреймворк в проект.
step1step2

В итоге должны увидеть фреймворк следующим образом:
step3

После этого нужно добавить его в embed frameworks. После добавления появится дублирование в прилинкованных, нужно удалить один из прилинкованных, чтобы получилось как на скриншоте:
step4

И последнее - нужно добавить директорию где лежит фреймворк в доступные для поиска (директория ./../mpp-library/build/bin/iosX64/MultiPlatformLibraryDebugFramework). Это делается через Build Settings таргета приложения.
step5

git changes

Теперь можем обновить код экрана, добавив следующее в ios-app/ios-app/ViewController.swift:

import UIKit
import MultiPlatformLibrary

class ViewController: UIViewController {
  override func viewDidLoad() {
    // ...

    HelloWorld().print()
    IosHelloWorld().print()
  }
}

git changes

После этого можем запустить iOS приложение в симуляторе (не на девайсе! у нас собран фреймворк для симулятора, и настройки пока захардкожены для фреймворка симулятора).

При запуске увидим что работают оба способа логирования и они тоже по разному выглядят в результате, как и в случае Android.

Для более простой и привычной (для iOS разработчика) интеграции общей библиотеки в приложение можно использовать менеджер зависимостей CocoaPods. Он избавит нас от необходимости прописывать множество настроек в проекте и пересборки фреймворка отделно через Android Studio.

Принцип интеграции CocoaPods следующий - мы сделаем локальный pod, внутри которого содержится embed framework (как раз скомпилированный из Kotlin) и сам pod будет иметь только один этап сборки - скрипт с вызовом gradle задачи на сборку фреймворка.
Из-за особенности CocoaPods нужно чтобы фреймворк был всегда в одном и том же предсказуемом месте, у себя в конфигурации мы этим местом сделаем build/cocoapods/framework/MultiPlatformLibrary.framework.

Настраиваем компиляцию framework в единую директорию

В mpp-library/build.gradle добавляем: В начале файла:

import org.jetbrains.kotlin.gradle.plugin.mpp.Framework
import org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink

В конце файла:

tasks.toList().forEach { task ->
    if(!(task instanceof KotlinNativeLink)) return
    def framework = task.binary
    if(!(framework instanceof Framework)) return
    def linkTask = framework.linkTask

    def syncTaskName = linkTask.name.replaceFirst("link", "sync")
    def syncFramework = tasks.create(syncTaskName, Sync.class) {
        group = "cocoapods"

        from(framework.outputDirectory)
        into(file("build/cocoapods/framework"))
    }
    syncFramework.dependsOn(linkTask)
} 

Настраиваем local CocoaPod содержащий наш Framework

Создаем mpp-library/MultiPlatformLibrary.podspec:

Pod::Spec.new do |spec|
    spec.name                     = 'MultiPlatformLibrary'
    spec.version                  = '0.1.0'
    spec.homepage                 = 'Link to a Kotlin/Native module homepage'
    spec.source                   = { :git => "Not Published", :tag => "Cocoapods/#{spec.name}/#{spec.version}" }
    spec.authors                  = 'IceRock Development'
    spec.license                  = ''
    spec.summary                  = 'Shared code between iOS and Android'

    spec.vendored_frameworks      = "build/cocoapods/framework/#{spec.name}.framework"
    spec.libraries                = "c++"
    spec.module_name              = "#{spec.name}_umbrella"

    spec.pod_target_xcconfig = {
        'MPP_LIBRARY_NAME' => 'MultiPlatformLibrary',
        'GRADLE_TASK[sdk=iphonesimulator*][config=*ebug]' => 'syncMultiPlatformLibraryDebugFrameworkIosX64',
        'GRADLE_TASK[sdk=iphonesimulator*][config=*elease]' => 'syncMultiPlatformLibraryReleaseFrameworkIosX64',
        'GRADLE_TASK[sdk=iphoneos*][config=*ebug]' => 'syncMultiPlatformLibraryDebugFrameworkIosArm64',
        'GRADLE_TASK[sdk=iphoneos*][config=*elease]' => 'syncMultiPlatformLibraryReleaseFrameworkIosArm64'
    }

    spec.script_phases = [
        {
            :name => 'Compile Kotlin/Native',
            :execution_position => :before_compile,
            :shell_path => '/bin/sh',
            :script => <<-SCRIPT
MPP_PROJECT_ROOT="$SRCROOT/../../mpp-library"

"$MPP_PROJECT_ROOT/../gradlew" -p "$MPP_PROJECT_ROOT" "$GRADLE_TASK"
            SCRIPT
        }
    ]
end

Подключаем наш local CocoaPod к проекту

Создаем ios-app/Podfile:

# ignore all warnings from all pods
inhibit_all_warnings!

use_frameworks!
platform :ios, '11.0'

# workaround for https://github.com/CocoaPods/CocoaPods/issues/8073
# нужно чтобы кеш development pods корректно инвалидировался
install! 'cocoapods', :disable_input_output_paths => true

target 'ios-app' do
  # MultiPlatformLibrary
  # для корректной установки фреймворка нужно сначала скомпилировать котлин библиотеку
  pod 'MultiPlatformLibrary', :path => '../mpp-library'
end

В настройках проекта убираем ранее прописанную настройку FRAMEWORK_SEARCH_PATHS (для этого надо нажать backspace чтобы значение было удалено, а не редактировать значение оставив пустую строку).
step6

Вызываем pod install в директории ios-app (заранее нужно установить cocoapods).

git changes

После успешного pod install у нас получается прямая интеграция Xcode с фреймворком (включая автоматическую пересборку фреймворка при пересборке Xcode проекта). После установки pod'ов следует закрыть текущий Xcode проект и открыть теперь ios-app/ios-app.xcworkspace.

После этого можем запускать iOS приложение и увидеть что все работает.

(!) Возможно что при сборке через Xcode gradle не сможет выполниться сообщив что отсутствует java. В таком случае нужно установить java development kit. При запуске gradle через Android Studio используется встроенный в дистрибутив Android Studio вариант openjdk, поэтому там все работает из коробки.