В этом уроке мы сделаем из двух стандартных android и ios проектов мультиплатформенный проект с общей библиотекой на Kotlin Multiplatform.
Для работы потребуется:
xcode-select --install
);sudo gem install cocoapods
).Для начала потребуются 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'
После сделанных изменений можно запустить Gradle Sync
и убедиться что модуль подключился, но не может сконфигурироваться, так как для Android библиотеки не хватает данных. Во первых не указаны версии Android SDK. Проставим их:
В mpp-library/build.gradle
:
android {
compileSdkVersion 28
defaultConfig {
minSdkVersion 21
targetSdkVersion 28
}
}
Теперь Gradle Sync
будет сообщать о ошибке чтения AndroidManifest
- этот файл должен присутствовать в любом Android модуле. Создаем mpp-library/src/main/AndroidManifest.xml
с содержимым:
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.icerockdev.library" />
После сделанных действий Gradle Sync
успешно выполнится.
Добавляем в mpp-library/build.gradle
:
kotlin {
targets {
android()
iosArm64()
iosX64()
}
}
Создаем директории:
mpp-library/src/commonMain/kotlin/
mpp-library/src/androidMain/kotlin/
mpp-library/src/iosMain/kotlin/
После этого можем выполнить Gradle Sync
и увидим что директории commonMain/kotlin
и androidMain/kotlin
посветились как директории с исходным кодом. Но iosMain/kotlin
не подсветился так, к этому вернемся чуть дальше. Сначала сделаем чтобы все относящееся к Android находилось в androidMain
.
Переносим AndroidManifest.xml
в androidMain
: mpp-library/src/main/AndroidManifest.xml
→ mpp-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')
}
}
}
Теперь Gradle Sync
успешно выполняется.
Создаем mpp-library/src/commonMain/kotlin/HelloWorld.kt
с содержимым:
object HelloWorld {
fun print() {
println("hello common world")
}
}
Но IDE сообщит что Kotlin не сконфигурирован. Это связано с тем, что для общего кода нужно еще подключить kotlin stdlib.
В mpp-library/build.gradle
:
kotlin {
// ...
sourceSets {
commonMain {
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
}
}
}
}
Теперь 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()
}
}
Далее при Gradle Sync
IDE сообщит что версия sdk в android-app
ниже чем версия sdk в mpp-library
- поднимем ее, чтобы мы могли подключить общую библиотеку (либо нужно понизить в общей библиотеке).
Изменяем в android-app/build.gradle
минимальную версию androidSdk для совместимости с mpp-library
:
android {
// ...
minSdkVersion 21
// ...
}
После этого можем запустить android-app
на эмуляторе и убедиться что в консоль (logcat) вывелось сообщение.
Предположим что мы хотим использовать какой-то 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()
}
}
После изменения можно запустить 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
После этого можем запустить 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")
}
}
Можно увидеть что 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)
}
}
После этого можем вызвать Gradle Task
:mpp-library:linkMultiPlatformLibraryDebugFrameworkIosX64
для компиляции framework
‘а для симулятора. По итогу мы получим в директории build/bin/iosX64/MultiPlatformLibraryDebugFramework/
наш скомпилированный framework
. И его нужно подключить к iOS приложению.
Открываем через Xcode ios-app/ios-app.xcodeproj
и добавляем фреймворк к проекту. Для этого:
Добавляем сам фреймворк в проект.
В итоге должны увидеть фреймворк следующим образом:
После этого нужно добавить его в embed frameworks. После добавления появится дублирование в прилинкованных, нужно удалить один из прилинкованных, чтобы получилось как на скриншоте:
И последнее - нужно добавить директорию где лежит фреймворк в доступные для поиска (директория ./../mpp-library/build/bin/iosX64/MultiPlatformLibraryDebugFramework
). Это делается через Build Settings
таргета приложения.
Теперь можем обновить код экрана, добавив следующее в ios-app/ios-app/ViewController.swift
:
import UIKit
import MultiPlatformLibrary
class ViewController: UIViewController {
override func viewDidLoad() {
// ...
HelloWorld().print()
IosHelloWorld().print()
}
}
После этого можем запустить iOS приложение в симуляторе (не на девайсе! у нас собран фреймворк для симулятора, и настройки пока захардкожены для фреймворка симулятора).
При запуске увидим что работают оба способа логирования и они тоже по разному выглядят в результате, как и в случае Android.
Для более простой и привычной (для iOS разработчика) интеграции общей библиотеки в приложение можно использовать менеджер зависимостей CocoaPods. Он избавит нас от необходимости прописывать множество настроек в проекте и пересборки фреймворка отделно через Android Studio
.
Принцип интеграции CocoaPods следующий - мы сделаем локальный pod, внутри которого содержится embed framework (как раз скомпилированный из Kotlin) и сам pod будет иметь только один этап сборки - скрипт с вызовом gradle
задачи на сборку фреймворка.
Из-за особенности CocoaPods нужно чтобы фреймворк был всегда в одном и том же предсказуемом месте, у себя в конфигурации мы этим местом сделаем build/cocoapods/framework/MultiPlatformLibrary.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)
}
Создаем 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
Создаем 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
чтобы значение было удалено, а не редактировать значение оставив пустую строку).
Вызываем pod install
в директории ios-app
(заранее нужно установить cocoapods).
После успешного pod install
у нас получается прямая интеграция Xcode с фреймворком (включая автоматическую пересборку фреймворка при пересборке Xcode проекта). После установки pod'ов следует закрыть текущий Xcode проект и открыть теперь ios-app/ios-app.xcworkspace
.
После этого можем запускать iOS приложение и увидеть что все работает.
(!) Возможно что при сборке через Xcode gradle
не сможет выполниться сообщив что отсутствует java
. В таком случае нужно установить java development kit. При запуске gradle
через Android Studio
используется встроенный в дистрибутив Android Studio
вариант openjdk, поэтому там все работает из коробки.