안녕하세요. 오늘은 Kotlin MultiPlatform Mobile에서 Android, iOS에서 UI 코드를 공유하기 위해 Jetbrains Compose와 Skioko를 적용해보겠습니다.
우선 프로젝트 구성 환경은 다음과 같습니다.
Android Studio : Android Studio Chipmunk
XCode : 13.4.1
AGP : 7.2.1
kotlin : 1.6.21
Jetbrains Compose : 1.2.0-alpha01-dev686
프로젝트 root에 있는 gradle입니다.
plugins {
id("com.android.application") version "7.2.1" apply false
id("com.android.library") version "7.2.1" apply false
id("org.jetbrains.kotlin.android") version "1.6.21" apply false
id("org.jetbrains.kotlin.jvm") version "1.6.21" apply false
id("org.jetbrains.compose") version "1.2.0-alpha01-dev686" apply false
}
allprojects {
repositories {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}
maven url은 Maven Repository에 들어가서 Compose Repository를 확인하시면 됩니다.
iOS와 Android에서 공유할 UI 코드를 짜기 위해 모듈을 만들어 줍니다.
프로젝트 루트에서 New Module을 한 다음, Java or Kotlin Library로 생성을 한 다음 작업을 시작하겠습니다.
먼저 gradle 파일을 작업해주겠습니다
plugins {
kotlin("multiplatform")
id("com.android.library")
id("org.jetbrains.compose")
}
kotlin {
ios()
android()
sourceSets {
commonMain {
dependencies {
implementation(project(mapOf("path" to ":shared")))
implementation(compose.ui)
implementation(compose.runtime)
implementation(compose.material)
implementation(compose.foundation)
}
}
}
}
android {
compileSdk = 31
sourceSets["main"].manifest.srcFile("src/main/AndroidManifest.xml")
}
MainUi.kt
@Composable
fun MainContent() {
// Compose UI Code 작성
}
안드로이드 모듈에서 이 모듈을 사용하기 위해 AndroidManifest.xml을 정의해줍니다.
<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.example.uicompose" />
그런 다음 iOS에서 Jetbrains Compose를 사용해 뷰를 그려보겠습니다.
마찬가지로 모듈을 하나 만들어줍니다.
그런 다음 바로 gradle 파일을 작업합니다.
import org.jetbrains.compose.experimental.dsl.IOSDevices
plugins {
kotlin("multiplatform")
kotlin("native.cocoapods")
id("com.android.library")
id("org.jetbrains.compose")
}
version = "1.0"
kotlin {
android()
listOf(iosX64("uikitX64"), iosArm64("uikitArm64")).forEach {
it.binaries {
executable {
entryPoint = "main"
freeCompilerArgs += listOf(
"-linker-option", "-framework", "-linker-option", "Metal",
"-linker-option", "-framework", "-linker-option", "CoreText",
"-linker-option", "-framework", "-linker-option", "CoreGraphics",
"-Xverify-compiler=false"
)
}
}
}
cocoapods {
summary = "Some description for the Shared Module"
homepage = "Link to the Shared Module homepage"
ios.deploymentTarget = "14.1"
podfile = project.file("../iosApp/Podfile")
framework {
baseName = "shared"
}
}
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.material)
implementation(compose.foundation)
}
}
val uikitMain by creating {
dependsOn(commonMain)
}
getByName("uikitX64Main").dependsOn(uikitMain)
getByName("uikitArm64Main").dependsOn(uikitMain)
}
}
android {
compileSdk = 32
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
defaultConfig {
minSdk = 28
targetSdk = 32
}
}
compose.experimental {
uikit.application {
bundleIdPrefix = "com.example"
projectName = "DarwinCompose"
deployConfigurations {
simulator("IPhone12Pro") {
//Usage: ./gradlew iosDeployIPhone12ProDebug
device = IOSDevices.IPHONE_12_PRO
}
}
}
}
그런 다음 iOS를 실행시킬 main 함수를 작성합니다.
main 함수는 두 가지 방법으로 작성할 수 있습니다.
fun main() {
val args = emptyArray<String>()
memScoped {
val argc = args.size + 1
val argv = (arrayOf("skikoApp") + args).map { it.cstr.ptr }.toCValues()
autoreleasepool {
UIApplicationMain(argc, argv, null, NSStringFromClass(SkikoAppDelegate))
}
}
}
class SkikoAppDelegate : UIResponder, UIApplicationDelegateProtocol {
companion object : UIResponderMeta(), UIApplicationDelegateProtocolMeta
@ObjCObjectBase.OverrideInit
constructor() : super()
private var _window: UIWindow? = null
override fun window() = _window
override fun setWindow(window: UIWindow?) {
_window = window
}
override fun application(application: UIApplication, didFinishLaunchingWithOptions: Map<Any?, *>?): Boolean {
window = UIWindow(frame = UIScreen.mainScreen.bounds)
window!!.rootViewController = Application("DarwinCompose") {
Column {
Spacer(modifier = Modifier.height(48.dp))
MainContent()
}
}
window!!.makeKeyAndVisible()
return true
}
}
또는,
fun main() {
defaultUIKitMain("DarwinCompose", Application("DarwinCompose") {
MainContent()
})
}
이렇게 간단하게 작성할 수도 있습니다.
Jetbrains Compose가 Skiko Library를 사용하기 때문에 SkikoAppDelegate라는 네이밍이 붙여지지 않았나 싶습니다.
실제로, Skiko의 iOS Sample을 보면 다음과 같습니다.
fun main() {
val args = emptyArray<String>()
memScoped {
val argc = args.size + 1
val argv = (arrayOf("skikoApp") + args).map { it.cstr.ptr }.toCValues()
autoreleasepool {
UIApplicationMain(argc, argv, null, NSStringFromClass(SkikoAppDelegate))
}
}
}
class SkikoAppDelegate : UIResponder, UIApplicationDelegateProtocol {
companion object : UIResponderMeta(), UIApplicationDelegateProtocolMeta
@ObjCObjectBase.OverrideInit
constructor() : super()
private var _window: UIWindow? = null
override fun window() = _window
override fun setWindow(window: UIWindow?) {
_window = window
}
override fun application(application: UIApplication, didFinishLaunchingWithOptions: Map<Any?, *>?): Boolean {
window = UIWindow(frame = UIScreen.mainScreen.bounds)
window!!.rootViewController = SkikoViewController(
SkikoUIView(
SkiaLayer().apply {
gesturesToListen = SkikoGestureEventKind.values()
skikoView = GenericSkikoView(skiaLayer, object : SkikoView {
val paint = Paint().apply { color = Color.RED }
override fun onRender(canvas: Canvas, width: Int, height: Int, nanoTime: Long) {
canvas.clear(Color.CYAN)
val ts = nanoTime / 5_000_000
canvas.drawCircle( (ts % width).toFloat(), (ts % height).toFloat(), 20f, paint )
}
})
}
)
)
window!!.makeKeyAndVisible()
return true
}
}
많이 유사한 것을 볼 수 있습니다.
맥 OS 기준으로
./gradlew iosDeployIPhone12ProDebug
를 실행시켜주면 됩니다. 원하는 아이폰 기종에 따라 IPhone8 등등으로 바꾸어 실행시킬 수 있습니다.
위의 두 방법은 gradle task를 통해 iOS Appplication을 실행하는 방법입니다.
하지만, gradle로 앱을 디버깅하고 배포하는 것 보다는 아무래도 XCode에서 위의 과정을 진행하면 좋을 것 같습니다.
아래와 같은 방법으로 XCode에서 Kotlin Multiplatform에서 구현한 Jetbrains Compose를 사용할 수 있습니다.
위의 코드를 보면 Application Composable 함수가 UIViewController를 반환하는 것을 알 수 있습니다.
public fun Application(title: kotlin.String /* = compiled code */, content: @androidx.compose.runtime.Composable () -> kotlin.Unit /* = compiled code */): platform.UIKit.UIViewController { /* compiled code */ }
이 함수를 export하여 XCode에서 UIViewController를 사용할 수 있습니다.
fun viewController() = Application("DarwinCompose") {
MainContent()
}
CocoaPods나 Framework, XCFramework등을 통해 XCode에서 다음과 같이 사용할 수 있습니다.
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
window = UIWindow(frame: UIScreen.main.bounds)
let viewController = MainKt.viewController()
window?.rootViewController = viewController
window?.makeKeyAndVisible()
return true
}
}
XCode에서 실행해주시면 됩니다.
안드로이드에서는 일반적으로 Jetpack Compose를 적용시키는 것과 마찬가지로 진행해주시면 됩니다.
먼저 androidApp/build.gradle.kts 파일을 구성해줍니다.
plugins {
id("com.android.application")
kotlin("android")
}
android {
compileSdk = 32
defaultConfig {
applicationId = "com.example.darwincompose.android"
minSdk = 28
targetSdk = 32
versionCode = 1
versionName = "1.0"
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.2.0-beta03"
}
}
dependencies {
implementation(project(":shared"))
implementation(project(":ui-compose"))
implementation("androidx.activity:activity-compose:1.4.0")
implementation("androidx.appcompat:appcompat:1.4.2")
}
그런 다음 ui-compose 모듈에서 작성해준 Compose 코드를 사용하면 됩니다.
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainContent()
}
}
}
Jetbrains 에서 MPP용으로 Compose를 출시하여 iOS에도 적용시킬 수 있도록 계속해서 업데이트가 되고 있습니다.
훗날 Compose로 Android, iOS 두 플랫폼 모두 공통된 UI를 안정적으로 짤 수 있게 되면 좋겠습니다.
다음 포스팅에는 Jetbrains Compose와 Decompose를 이용해 프로젝트를 구성해보겠습니다. 감사합니다.
e: Module "org.jetbrains.compose.runtime:runtime-saveable (org.jetbrains.compose.runtime:runtime-saveable-uikitx64)" has a reference to symbol androidx.compose.runtime/remember|-2215966373931868872[0]. Neither the module itself nor its dependencies contain such declaration.
위와 같은 에러가 발생할 경우 project 루트 폴더에 있는 gradle.properties에
kotlin.native.cacheKind=none
추가해주면 해결됩니다. (참고)
Field 'dataPathSize' is required for type with serial name 'org.jetbrains.compose.experimental.uikit.internal.DeviceData', but it was missing
위와 같은 에러가 발생할 경우 XCode 업데이트해주면 해결됩니다.
e: java.lang.IllegalStateException: No file for ...
위와 같은 에러가 발생할 경우 해당 에러가 발생하는 Composable 함수에 internal keyword를 지정해주면 됩니다.