Android와 iOS에서 Jetbrains Compose를 사용하여 UI 코드 공유하기

이태훈·2022년 5월 31일
0

Darwin Compose

목록 보기
1/4

안녕하세요. 오늘은 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

프로젝트 gradle

프로젝트 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를 확인하시면 됩니다.

공통 UI Compose

iOS와 Android에서 공유할 UI 코드를 짜기 위해 모듈을 만들어 줍니다.

프로젝트 루트에서 New Module을 한 다음, Java or Kotlin Library로 생성을 한 다음 작업을 시작하겠습니다.

먼저 gradle 파일을 작업해주겠습니다

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")
}

Jetbrains Compose

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를 사용해 뷰를 그려보겠습니다.

iOS

마찬가지로 모듈을 하나 만들어줍니다.

그런 다음 바로 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 함수는 두 가지 방법으로 작성할 수 있습니다.

Old Main Function

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
	}
}

New Main Function

또는,

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 등등으로 바꾸어 실행시킬 수 있습니다.

Export ViewController

위의 두 방법은 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에서 실행해주시면 됩니다.

Android

안드로이드에서는 일반적으로 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()
        }
    }
}

Conclusion

Jetbrains 에서 MPP용으로 Compose를 출시하여 iOS에도 적용시킬 수 있도록 계속해서 업데이트가 되고 있습니다.

훗날 Compose로 Android, iOS 두 플랫폼 모두 공통된 UI를 안정적으로 짤 수 있게 되면 좋겠습니다.

다음 포스팅에는 Jetbrains Compose와 Decompose를 이용해 프로젝트를 구성해보겠습니다. 감사합니다.

Error fix (Update at 2022.12.18)

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를 지정해주면 됩니다.


전체 코드
https://github.com/TaehoonLeee/darwin-compose

profile
https://www.linkedin.com/in/%ED%83%9C%ED%9B%88-%EC%9D%B4-7b9563237

0개의 댓글