안녕하세요 이번에는 Decompose에서 Darwin Compose를 구현해보겠습니다.
먼저 사용한 라이브러리 버전들은 다음과 같습니다.
Decompose의 release note에 따라 버전을 맞춰줍니다.
먼저 gradle을 구성해보겠습니다.
// build.gradle.kts
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
kotlin("plugin.serialization") version "1.6.21" apply false
id("org.jetbrains.compose") version "1.2.0-alpha01-dev675" apply false
}
tasks.register("clean", Delete::class) {
delete(rootProject.buildDir)
}
// settings.gradle.kts
pluginManagement {
repositories {
google()
gradlePluginPortal()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}
enableFeaturePreview("VERSION_CATALOGS")
dependencyResolutionManagement {
versionCatalogs {
create("deps") {
from(files("deps.version.toml"))
}
}
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
gradlePluginPortal()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
}
rootProject.name = "Decompose_Sample"
include(":androidApp")
include(":shared")
include(":ui-compose")
include(":darwin-compose")
별다른 특이사항은 없으니 넘어가겠습니다.
저는 version catalog를 사용했기 때문에 settings.gradle에 version catalog를 사용하기 위한 코드를 작성해주었습니다.
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.material)
implementation(compose.foundation)
implementation(deps.decompose.extension.compose)
}
}
}
}
android {
compileSdk = 31
sourceSets["main"].manifest.srcFile("src/main/AndroidManifest.xml")
}
ui-compose 모듈의 gradle도 특이사항은 없습니다.
안드로이드에서도 ui 코드를 공유하기 위해 android library로 모듈을 구성해주고 configuration을 작성해줬습니다.
import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget
plugins {
kotlin("multiplatform")
kotlin("native.cocoapods")
kotlin("plugin.serialization")
id("com.android.library")
id("kotlin-parcelize")
}
version = "1.0"
kotlin {
android()
...
sourceSets {
val commonMain by getting {
dependencies {
implementation(deps.kotlinx.coroutines)
implementation(deps.kotlinx.serialization.json)
api(deps.decompose.decompose)
implementation(deps.decompose.extension.compose)
implementation(deps.koin.core)
implementation(deps.bundles.ktor)
implementation(deps.bundles.mviKotlin)
}
}
val commonTest by getting
val androidMain by getting {
dependencies {
implementation(deps.bundles.compose)
implementation(deps.ktor.okHttp)
}
}
...
}
}
android {
compileSdk = 31
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
defaultConfig {
minSdk = 21
targetSdk = 31
}
}
...
}
중간에 생략된 코드들이 조금 있는데, 이 부분은 따른 모듈을 구성하는 코드라 생략했습니다.
마찬가지로 별 무리 없이 이해할 수 있는 코드들이라 넘어가겠습니다.
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose")
}
kotlin {
iosX64("uikitX64") {
binaries {
executable {
entryPoint = "com.example.decomposesample.main"
freeCompilerArgs += listOf(
"-linker-option", "-framework", "-linker-option", "Metal",
"-linker-option", "-framework", "-linker-option", "CoreText",
"-linker-option", "-framework", "-linker-option", "CoreGraphics"
)
}
}
}
sourceSets {
commonMain {
dependencies {
implementation(project(mapOf("path" to ":shared")))
implementation(project(mapOf("path" to ":ui-compose")))
implementation(compose.material)
implementation(compose.foundation)
}
}
}
}
kotlin.targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> {
binaries.all {
binaryOptions["memoryModel"] = "experimental"
binaryOptions["freezing"] = "disabled"
}
}
compose.experimental {
uikit.application {
bundleIdPrefix = "com.example"
projectName = "DecomposeSample"
deployConfigurations {
simulator("IPhone12Pro") {
device = org.jetbrains.compose.experimental.dsl.IOSDevices.IPHONE_12_PRO
}
}
}
}
kotlin {
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> {
binaries.all {
freeCompilerArgs += "-Xdisable-phases=VerifyBitcode"
}
}
}
Darwin Compose 모듈의 build gradle입니다.
낯선 코드들 부터 차근차근 보겠습니다.
kotlin.targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> {
binaries.all {
binaryOptions["memoryModel"] = "experimental"
binaryOptions["freezing"] = "disabled"
}
}
이 부분은 Kotlin MultiPlatform New Memoey Management의 가이드에 따라 작성해주었습니다.
저는 corotines 1.6.2 버전 및 ktor 2.0.2 버전을 사용했기 때문에 해당 스크립트를 작성했습니다.
compose.experimental {
uikit.application {
bundleIdPrefix = "com.example"
projectName = "DecomposeSample"
deployConfigurations {
simulator("IPhone12Pro") {
device = org.jetbrains.compose.experimental.dsl.IOSDevices.IPHONE_12_PRO
}
}
}
}
IPhone 시뮬레이터에 앱을 빌드시키기 위해 추가했습니다.
kotlin {
targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> {
binaries.all {
freeCompilerArgs += "-Xdisable-phases=VerifyBitcode"
}
}
}
공식 샘플을 보면
the current compose binary surprises LLVM, so disable checks for now.
위와 같이 첨언이 돼있습니다.
kotlin native가 llvm을 거쳐 실행 파일을 만들어 주는데, 이 과정에서 현재 jetbrains compose가 충돌이 일어나는가 싶습니다. 업데이트 되면서 해결이 될 것 같습니다.
먼저 buisness logic을 공유할 shared 모듈부터 구현해보겠습니다.
Domain Layer와 Data Layer, Presentation Layer 이전에 포스팅한 글을 참고해주시면 되겠습니다.
안드로이드 모듈에서 Jetpack Compose를 이용하여 뷰를 짜듯이 짜주시면 됩니다.
@Composable
fun RootContent(root: TmdbRoot) {
Children(routerState = root.routerState) {
when(val child = it.instance) {
is TmdbRoot.Child.Main -> MainContent(child.component)
}
}
}
@Composable
fun MainContent(main: TmdbMain) {
val model by main.model.subscribeAsState()
when (val result = model.movies) {
is Result.Success -> Text(text = "Result is Success")
}
}
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()
init {
startKoin()
}
private val lifecycle = LifecycleRegistry()
private val root = TmdbRootComponent(componentContext = DefaultComponentContext(lifecycle = lifecycle))
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("Minesweeper") {
Column {
Spacer(modifier = Modifier.height(48.dp))
RootContent(root)
}
}
window!!.makeKeyAndVisible()
return true
}
override fun applicationDidBecomeActive(application: UIApplication) {
lifecycle.resume()
}
override fun applicationWillResignActive(application: UIApplication) {
lifecycle.stop()
}
override fun applicationWillTerminate(application: UIApplication) {
lifecycle.destroy()
}
}
Decompose를 이용하지 않았을 때와 큰 차이가 없습니다.
마찬가지로 공통 Jetbrains Compose 모듈에서 컴포저블 함수를 가져와 skiko engine에서 그려줍니다.
먼저, koin의 모듈을 설정해줍니다.
init {
startKoin()
}
Decompose의 라이프 사이클을 맞추기 위해 아래와 같은 함수도 추가해줍니다.
private val lifecycle = LifecycleRegistry()
override fun applicationDidBecomeActive(application: UIApplication) {
lifecycle.resume()
}
override fun applicationWillResignActive(application: UIApplication) {
lifecycle.stop()
}
override fun applicationWillTerminate(application: UIApplication) {
lifecycle.destroy()
}
안드로이드에서는 예전과 같습니다.
코인 설정해주고 액티비티에서 뷰를 그려주면 됩니다.
class Application : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@Application)
modules(repositoryModule, interactorModule, networkModule, storeModule)
}
}
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val tmdbRoot = TmdbRootComponent(defaultComponentContext())
setContent {
RootContent(root = tmdbRoot)
}
}
}