Android와 iOS에서 Jetbrains Compose를 사용하여 UI 코드 공유하기 - (2) : 기존 프로젝트 Migration

이태훈·2022년 12월 19일
1

Darwin Compose

목록 보기
4/4

안녕하세요, 이번엔 저번에 공유드렸던 Jetbrains Compose를 Darwin OS 환경에서 실행시키는 방법에 대한 변경사항과 기존에 feature wise modular로 구성했던 프로젝트를 Kotlin Multiplatform으로 변경하면서 Jetbrains Compose를 적용시키는 법에 대해 공유드리겠습니다. 전체 코드는 아래에 있습니다.

https://github.com/TaehoonLeee/multi-module-clean-architecture/tree/multi-platform

Darwin Compose 변경 사항

기존에는 iOS의 uikitX64를 타겟으로 빌드해서 직접 iOS Emulator를 띄우면서 실행했었습니다.

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("Minesweeper") {
			Column {
				Spacer(modifier = Modifier.height(48.dp))
				MainContent()
			}
		}
		window!!.makeKeyAndVisible()
		return true
	}
}

또는,

fun main() {
    defaultUIKitMain("Minesweeper", Application("Minesweeper") {
    	MainContent()
    })
}

이런 방법에서 이제는 직접 XCode에서 빌드할 수 있도록 샘플이 바뀌었습니다.

import UIKit
import Example

@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 = Main_iosKt.createExampleViewController()
        window?.rootViewController = viewController
        window?.makeKeyAndVisible()
        
        return true
    }

}

이 방식으로 실제로 iOS 앱을 만들 수 있고 App Store에도 배포할 수 있습니다. 참고 링크

이 방식을 토대로 기존 프로젝트를 Kotlin Multiplatform Mobile로 변경했던 과정을 공유드리겠습니다.

Kotlin Multiplatform으로 변경

프로젝트 구성 환경

우선 프로젝트 구성 환경은 다음과 같습니다.

Android Studio : Android Studio Dolphin
XCode : 13.4.1
AGP : 7.3.1
kotlin : 1.7.20
Jetbrains Compose : 1.2.1

기존 프로젝트는 다음과 같이 구성되어있었습니다.

  • app module
  • common module
  • data module
  • domain module
  • features
    • gallery module
    • item module
  • presentation module

android app module을 제외한 모듈들을 kotlin multiplatform으로 하나씩 바꿔보겠습니다.

Version Sharing

여러 모듈에 버전을 공유하기 위해 version catalog를 사용하겠습니다.

// gradle/libs.version.toml
[versions]

compileSdkVersion = "33"
minSdkVersion = "26"
targetSdkVersion = "33"
versionCode = "1"
versionName = "1.0"

koin = "3.2.2"
ktor = "2.2.1"
kotlin = "1.7.20"
coroutines = "1.6.4"
sqldelight = "1.5.4"
gradlePlugin = "7.3.1"
multiplatform-paging = "3.1.1-0.1.1"
jetbrains-compose="1.3.0-beta04-dev889"
decompose="1.0.0-beta-01-native-compose"

[plugins]
android-application = { id = "com.android.application", version.ref = "gradlePlugin" }
android-library = { id = "com.android.library", version.ref = "gradlePlugin" }
kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
sqldelight = { id = "com.squareup.sqldelight", version.ref = "sqldelight" }
jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrains.compose" }

[libraries]

kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlin-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.4.1" }

androidx-compat = { module = "androidx.appcompat:appcompat", version = "1.5.1" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version = "1.6.1" }

sqldelight-coroutines = { module = "com.squareup.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-native = { module = "com.squareup.sqldelight:native-driver", version.ref = "sqldelight" }
sqldelight-android = { module = "com.squareup.sqldelight:android-driver", version.ref = "sqldelight" }

ktor-core = { module = "io.ktor:ktor-client-core", version.ref="ktor" }
ktor-darwin = { module = "io.ktor:ktor-client-darwin", version.ref="ktor" }
ktor-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktor-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }

koin = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
multiplatform-imageloader = { module = "io.github.qdsfdhvh:image-loader", version = "1.2.3.1" }
multiplatform-paging-common = { module = "app.cash.paging:paging-common", version.ref="multiplatform.paging" }
multiplatform-paging-runtime = { module = "app.cash.paging:paging-runtime", version.ref="multiplatform.paging" }

decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" }
decompose-compose = { module = "com.arkivanov.decompose:extensions-compose-jetbrains", version.ref = "decompose" }

[bundles]
ktor = [
    "ktor-json",
    "ktor-logging",
    "ktor-content-negotiation"
]
decompose = [
    "decompose",
    "decompose-compose"
]

domain module

@Suppress("DSL_SCOPE_VIOLATION")
plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.kotlinx.serialization)
}

kotlin {
    jvm()
    ios()

    sourceSets.commonMain {
        dependencies {
	        implementation(libs.koin)
            implementation(libs.kotlin.coroutines)
            implementation(libs.kotlinx.serialization)
            implementation(libs.multiplatform.paging.common)
        }
    }
}

Model

data class Item(
    val title: String,
    val description: String
)

@Serializable
data class UnsplashPhoto(
    val id: String,
    val description: String?,
    val urls: UnsplashPhotoUrls,
    val user: UnsplashUser
) {

    @Serializable
    data class UnsplashPhotoUrls(
        val raw: String,
        val full: String,
        val regular: String,
        val small: String,
        val thumb: String,
    )

    @Serializable
    data class UnsplashUser(
        val name: String,
        val username: String
    )
}

Repository

interface ItemRepository {
    fun getItems(): Flow<List<Item>>
    fun insertItem(item: Item)
    fun clearItem()
}

interface UnsplashRepository {
    fun getSearchResult(query: String): Flow<PagingData<UnsplashPhoto>>
}

Interactor

class GetSearchResultUseCase(
    private val unsplashRepository: UnsplashRepository
) {

    operator fun invoke(query: String) = unsplashRepository.getSearchResult(query)
}

class GetItemListUseCase(
    private val itemRepository: ItemRepository
) {
    operator fun invoke() = itemRepository.getItems()
}

class InsertItemUseCase(
    private val itemRepository: ItemRepository
) {

    operator fun invoke(item: Item) = itemRepository.insertItem(item)
}

class ClearItemUseCase(
	private val itemRepository: ItemRepository
) {

	operator fun invoke() = itemRepository.clearItem()
}

Dependency Injection

val interactorModule = module {
	singleOf(::GetItemListUseCase)
	singleOf(::GetSearchResultUseCase)
	singleOf(::InsertItemUseCase)
	singleOf(::ClearItemUseCase)
}

val domainModule = interactorModule

Koin의 Constructor DSL을 활용해주었습니다.

도메인 모듈의 차이점은 Paging Library가 Jetpack Paging에서 CashApp의 Paging으로 바뀌었고, 의존성 주입을 힐트가 아닌 코인으로 하기 때문에 JSR-330을 사용하지 못 합니다. 따라서 명시적으로 의존성 그래프를 작성해줘야 합니다. 그 외의 것들은 코드만 봐도 충분히 이해 가능한 부분입니다.

Data Module

기존 데이터 모듈과의 차이점은

  • Http Client가 Ktor로 강제
  • 캐싱은 Room이 아닌 SQLDelight로
  • 의존성 주입은 Hilt가 아닌 Koin으로

이렇게 세 부분이 바뀌었습니다.

Networking

class UnsplashApiExecutor(
    private val httpClient: HttpClient
) {
    suspend fun searchPhotos(
        query: String,
        page: Int,
        perPage: Int
    ): Result<UnsplashResponse> {
        return try {
            val response = httpClient.get("search/photos") {
                parameter("query", query)
                parameter("page", page)
                parameter("per_page", perPage)
            }.body<HttpResponse>()

            if (response.status.isSuccess()) {
                Result.Success(response.body(), response.status.value)
            } else {
                Result.ApiError(response.status.description, response.status.value)
            }
        } catch (e: Exception) {
            Result.NetworkError(e)
        }
    }
}

Caching

// build.gradle.kts
sqldelight {
    database("ItemDatabase") {
        packageName = "com.example.data"
    }
}

// commonMain
expect class DatabaseDriverFactory {
    fun createDriver(): SqlDriver
}

// androidMain
actual class DatabaseDriverFactory(
    private val context: Context
) {
    actual fun createDriver(): SqlDriver {
        return AndroidSqliteDriver(ItemDatabase.Schema, context, "item.db")
    }
}

// iosMain
actual class DatabaseDriverFactory {
    actual fun createDriver(): SqlDriver {
        return NativeSqliteDriver(ItemDatabase.Schema, "item.db")
    }
}

build.gradle에서 sqldelight를 사용하기 위해 configuration을 정의해주고, 각 타겟에 맞게 DatabaseDriver를 생성해주는 클래스를 정의했습니다.

// commonMain/sqldelight/com/example/data/Item.sq
CREATE TABLE Item (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    description TEXT NOT NULL,
    title TEXT NOT NULL
);

selectAll:
SELECT * FROM Item;

insert:
INSERT INTO Item VALUES (?, ?, ?);

deleteAll:
DELETE FROM Item;

캐싱할 Item 모델의 데이터베이스 테이블을 정의해줍니다.

Repository Implementation

class ItemRepositoryImpl(
    databaseDriverFactory: DatabaseDriverFactory
) : ItemRepository {

    private val database = ItemDatabase(databaseDriverFactory.createDriver())
    private val simpleMapper: (Long, String, String) -> Item = { _, description, title ->
        Item(title, description)
    }

    override fun getItems(): Flow<List<Item>> {
        return database.itemQueries
            .selectAll(simpleMapper)
            .asFlow()
            .map(Query<Item>::executeAsList)
    }

    override fun insertItem(item: Item) {
        database.itemQueries.insert(null, item.description, item.title)
    }

    override fun clearItem() {
        database.itemQueries.deleteAll()
    }
}

class UnsplashRepositoryImpl(
    private val unsplashApiExecutor: UnsplashApiExecutor
) : UnsplashRepository {

    override fun getSearchResult(query: String): Flow<PagingData<UnsplashPhoto>> {
        return Pager(
            config = PagingConfig(
                pageSize = 20,
                maxSize = 100,
                enablePlaceholders = false
            ),
            pagingSourceFactory = { UnsplashPagingSource(query, unsplashApiExecutor) }
        ).flow
    }
}

internal class UnsplashPagingSource(
    private val query: String,
    private val unsplashApiExecutor: UnsplashApiExecutor
) : PagingSource<Int, UnsplashPhoto>() {

    override fun getRefreshKey(state: PagingState<Int, UnsplashPhoto>): Int? {
        return state.anchorPosition
    }

    @Suppress("UNCHECKED_CAST")
    override suspend fun load(params: PagingSourceLoadParams<Int>): PagingSourceLoadResult<Int, UnsplashPhoto> {
        val position = params.key?: 1
        val response = unsplashApiExecutor.searchPhotos(query, position, params.loadSize)

        return try {
            if (response is Result.Success) {
                PagingSourceLoadResultPage(
                    data = response.data.results,
                    prevKey = if (position == 1) null else position - 1,
                    nextKey = if (position == response.data.totalPages) null else position + 1
                )
            } else {
                PagingSourceLoadResultInvalid<Int, UnsplashPhoto>()
            }
        } catch (e: Exception) {
            PagingSourceLoadResultError<Int, UnsplashPhoto>(e)
        } as PagingSourceLoadResult<Int, UnsplashPhoto>
    }
}

Dependency Injection

expect val databaseModule: Module

val networkModule = module {
    factory {
        HttpClient {
            defaultRequest {
                headers {
                    append("Accept-Version", "v1")
                    append(HttpHeaders.Authorization, "Client-ID ti90oMOJyxTN-gKrvE39bi6LM2tbMAdOvey4QMKES0k")
                }
                url {
                    protocol = URLProtocol.HTTPS
                    host = "api.unsplash.com"
                }
            }
            install(ContentNegotiation) {
                json(Json { ignoreUnknownKeys = true })
            }
            install(Logging) {
                logger = Logger.DEFAULT
                level = LogLevel.ALL
            }
        }
    }
    factoryOf(::UnsplashApiExecutor)
}

val repositoryModule = module {
    singleOf(::UnsplashRepositoryImpl) bind UnsplashRepository::class
    singleOf(::ItemRepositoryImpl) bind ItemRepository::class
}

val dataModule get() = databaseModule + networkModule + repositoryModule

Domain Module과 마찬가지로 Koin의 Constructor DSL을 활용해주었습니다.

common module

kotlin multiplatform에서의 common module은 image loader와 jetbrains compose에 jetpack compose의 paging extension을 옮긴 걸로 구성했습니다.

@Suppress("DSL_SCOPE_VIOLATION")
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.jetbrains.compose)
    alias(libs.plugins.kotlin.multiplatform)
}

kotlin {
    ios()
    android()

    sourceSets.commonMain {
        dependencies {
            implementation(libs.kotlin.coroutines)
            implementation(libs.multiplatform.imageloader)
            implementation(libs.multiplatform.paging.common)

            implementation(compose.foundation)

            implementation(libs.bundles.decompose)
        }
    }
}

android {
    namespace = "com.example.common"
    compileSdk = libs.versions.compileSdkVersion.get().toInt()

    defaultConfig {
        minSdk = libs.versions.minSdkVersion.get().toInt()
        targetSdk = libs.versions.targetSdkVersion.get().toInt()
    }
}

Paging Extensions

class LazyPagingItems<T: Any>(
	private val flow: Flow<PagingData<T>>
) {

	var itemSnapshotList by mutableStateOf(
		ItemSnapshotList<T>(0, 0, emptyList())
	)
		private set

	val itemCount: Int get() = itemSnapshotList.size

	private val differCallback: DifferCallback = object : DifferCallback {
		override fun onChanged(position: Int, count: Int) {
			if (count > 0) {
				updateItemSnapshotList()
			}
		}

		override fun onInserted(position: Int, count: Int) {
			if (count > 0) {
				updateItemSnapshotList()
			}
		}

		override fun onRemoved(position: Int, count: Int) {
			if (count > 0) {
				updateItemSnapshotList()
			}
		}
	}

	private val pagingDataDiffer = object : PagingDataDiffer<T>(
		differCallback = differCallback,
		mainDispatcher = Dispatchers.Main
	) {
		override suspend fun presentNewList(
			previousList: NullPaddedList<T>,
			newList: NullPaddedList<T>,
			lastAccessedIndex: Int,
			onListPresentable: () -> Unit
		): Int? {
			onListPresentable()
			updateItemSnapshotList()
			return null
		}
	}

	private fun updateItemSnapshotList() {
		itemSnapshotList = pagingDataDiffer.snapshot()
	}

	operator fun get(index: Int): T? {
		pagingDataDiffer[index]
		return itemSnapshotList[index]
	}

	var loadState: CombinedLoadStates by mutableStateOf(
		CombinedLoadStates(
			refresh = InitialLoadStates.refresh,
			prepend = InitialLoadStates.prepend,
			append = InitialLoadStates.append,
			source = InitialLoadStates
		)
	)
		private set

	suspend fun collectLoadState() {
		pagingDataDiffer.loadStateFlow.collect {
			loadState = it
		}
	}

	suspend fun collectPagingData() {
		flow.collectLatest {
			pagingDataDiffer.collectFrom(it)
		}
	}
}

private val IncompleteLoadState: LoadState = LoadStateNotLoading(false)
private val InitialLoadStates = LoadStates(
	LoadStateLoading,
	IncompleteLoadState,
	IncompleteLoadState
)

@Composable
fun <T: Any> Flow<PagingData<T>>.collectAsLazyPagingItems(): LazyPagingItems<T> {
	val lazyPagingItems = remember(this) { LazyPagingItems(this) }

	LaunchedEffect(lazyPagingItems) {
		lazyPagingItems.collectPagingData()
	}
	LaunchedEffect(lazyPagingItems) {
		lazyPagingItems.collectLoadState()
	}

	return lazyPagingItems
}

fun <T: Any> LazyListScope.items(
	items: LazyPagingItems<T>,
	itemContent: @Composable LazyItemScope.(value: T?) -> Unit
) {
	items(items.itemCount) { index ->
		itemContent(items[index])
	}
}

Jetpack Compose의 Paging Extensions을 KMM의 페이징 라이브러리에 맞게 카피한 코드입니다.

Image Loader

// commonMain
internal var imageLoader: ImageLoader? = null

@Composable
expect fun createImageLoader(): ImageLoader

@Composable
fun SimpleAsyncImage(
	source: String,
	modifier: Modifier = Modifier
) {
	CompositionLocalProvider(LocalImageLoader provides createImageLoader()) {
		Image(
			modifier = modifier,
			contentDescription = null,
			contentScale = ContentScale.FillWidth,
			painter = rememberAsyncImagePainter(source)
		)
	}
}

// androidMain
@Composable
actual fun createImageLoader(): ImageLoader {
	val context = LocalContext.current

	return if (imageLoader != null) imageLoader!! else {
		ImageLoaderBuilder(context)
			.memoryCache {
				MemoryCacheBuilder(context)
					.maxSizePercent(.25)
					.build()
			}
			.diskCache {
				DiskCacheBuilder()
					.directory(context.cacheDir.resolve("image_cache").toOkioPath())
					.maxSizeBytes(512L * 1024 * 1024)
					.build()
			}
			.build()
			.also(::imageLoader::set)
	}
}

// iosMain
@Composable
actual fun createImageLoader(): ImageLoader {
	return if (imageLoader != null) imageLoader!! else {
		ImageLoaderBuilder()
			.memoryCache {
				MemoryCacheBuilder()
					.maxSizePercent(.25)
					.build()
			}
			.diskCache {
				val cacheDir = NSFileManager.defaultManager
					.URLForDirectory(NSCachesDirectory, NSUserDomainMask, null, true, null)
					?.path
					.orEmpty()

				DiskCacheBuilder()
					.directory(cacheDir.toPath().resolve("image_cache"))
					.maxSizeBytes(512L * 1024 * 1024)
					.build()
			}
			.build()
			.also(::imageLoader::set)
	}
}

멀티플랫폼용 컴포즈 이미지 로더는 링크의 라이브러리를 사용했습니다.

Feature Wise Module

이 모듈에서는 각 피쳐에 해당하는 부분을 구현하겠습니다. UI Business Logic을 멀티플랫폼에서 구현하기 위해 Decompose를 사용했습니다.

@Suppress("DSL_SCOPE_VIOLATION")
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.jetbrains.compose)
    alias(libs.plugins.kotlin.multiplatform)
}

android {
    namespace = "com.example.features.gallery"
    compileSdk = libs.versions.compileSdkVersion.get().toInt()
    sourceSets["main"].manifest.srcFile("src/AndroidManifest.xml")
}

kotlin {
    ios()
    android()

    sourceSets.commonMain {
        dependencies {
            implementation(projects.domain)
            implementation(projects.common)

            implementation(compose.material)
            implementation(compose.foundation)

            implementation(libs.koin)
            implementation(libs.kotlin.coroutines)
            implementation(libs.bundles.decompose)
            implementation(libs.multiplatform.paging.common)
        }
    }
}
interface GalleryComponent {

    val state: Value<GalleryComponentState>

    data class GalleryComponentState(
        val searchResult: Flow<PagingData<UnsplashPhoto>>
    )
}

class GalleryComponentImpl(
    componentContext: ComponentContext
) : GalleryComponent, ComponentContext by componentContext, KoinComponent {

    private val viewModel = instanceKeeper.getOrCreate { GalleryViewModel(get()) }

    override val state: Value<GalleryComponent.GalleryComponentState> = viewModel.state

}

이 부분은 Decompose 라이브러리의 컴포넌트를 구현해주는 부분입니다. 인터페이스를 정의해주는 이유는 Integration Test와 Compose의 Preview를 용이하게 사용하기 위함입니다.

class GalleryViewModel(
    getSearchResult: GetSearchResultUseCase
) : InstanceKeeper.Instance {

    private val viewModelScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
    val state: Value<GalleryComponent.GalleryComponentState> = MutableValue(
        GalleryComponent.GalleryComponentState(
            getSearchResult(DEFAULT_QUERY).cachedIn(viewModelScope)
        )
    )

    override fun onDestroy() {
        viewModelScope.cancel()
    }

    private companion object {
        private const val DEFAULT_QUERY = "cats"
    }

}

Decompose의 Lifecycle에 맞게 뷰모델을 정의해줍니다.

@Composable
fun GalleryScreen(galleryComponent: GalleryComponent) {
    val state by galleryComponent.state.subscribeAsState()
    val pagingItems = state.searchResult.collectAsLazyPagingItems()

    LazyColumn {
        items(pagingItems) {
            SimpleAsyncImage(
                source = it?.urls?.regular?: "",
                modifier = Modifier.fillMaxSize().height(250.dp)
            )
        }
    }
}

Jetpack Compose와 Paging 3 Library를 사용한 모습과 똑같은 것을 볼 수 있습니다.

Item Feature - Gradle

@Suppress("DSL_SCOPE_VIOLATION")
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.jetbrains.compose)
    alias(libs.plugins.kotlin.multiplatform)
}

android {
    namespace = "com.example.features.item"
    compileSdk = libs.versions.compileSdkVersion.get().toInt()
    sourceSets["main"].manifest.srcFile("src/AndroidManifest.xml")
}

kotlin {
    ios()
    android()

    sourceSets.commonMain {
        dependencies {
            implementation(projects.domain)
            implementation(projects.common)

            implementation(compose.material)
            implementation(compose.foundation)

            implementation(libs.koin)
            implementation(libs.kotlin.coroutines)
            implementation(libs.bundles.decompose)
        }
    }
}

Item Feature - Decompose Component

interface ItemComponent {
    val state: Value<ItemComponentState>

    fun onInsertItem()
    fun onClearItem()

    data class ItemComponentState(
        val items: List<Item>
    )
}

class ItemComponentImpl(
    componentContext: ComponentContext
) : ItemComponent, ComponentContext by componentContext, KoinComponent {

    private val viewModel = instanceKeeper.getOrCreate {
        ItemViewModel(get(), get(), get())
    }

    override val state: Value<ItemComponent.ItemComponentState> = viewModel.state

    override fun onInsertItem() = viewModel.insertItem()
    override fun onClearItem() = viewModel.clear()
}

Item Feature - ViewModel

class ItemViewModel(
    getItem: GetItemListUseCase,
    private val clearItem: ClearItemUseCase,
    private val insertItem: InsertItemUseCase
) : InstanceKeeper.Instance {

    private val viewModelScope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())

    val state: Value<ItemComponentState> = getItem()
        .map(::ItemComponentState)
        .valueIn(ItemComponentState(emptyList()), SharingStarted.Eagerly, viewModelScope)

    fun insertItem() {
        insertItem(Item("tmp", "tmp"))
    }

    fun clear() {
        clearItem()
    }

    override fun onDestroy() {
        viewModelScope.cancel()
    }
}

Item Feature - UI

@Composable
fun ItemScreen(itemComponent: ItemComponent) {
    val uiState by itemComponent.state.subscribeAsState()

    LazyColumn {
        item {
            Row(
                modifier = Modifier.fillMaxSize(),
                horizontalArrangement = Arrangement.SpaceEvenly
            ) {
                Button(onClick = itemComponent::onInsertItem) {
                    Text("Insert Item")
                }
                Button(onClick = itemComponent::onClearItem) {
                    Text("Clear Item")
                }
            }
        }

        items(uiState.items) {
            ItemCard(it)
        }
    }
}

@Composable
fun ItemCard(item: Item) {
    Row {
        Text(item.title)
        Text(item.description)
    }
}

Presentation Module

프레젠테이션 모듈에서는 각 피쳐의 구현부를 취합하여 최종 모듈로 나타냅니다.

Gradle

@Suppress("DSL_SCOPE_VIOLATION")
plugins {
	alias(libs.plugins.android.library)
	alias(libs.plugins.kotlin.parcelize)
	alias(libs.plugins.jetbrains.compose)
	alias(libs.plugins.kotlin.multiplatform)
}

android {
	namespace = "com.example.presentation"
	compileSdk = libs.versions.compileSdkVersion.get().toInt()
	sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")

	defaultConfig {
		minSdk = libs.versions.minSdkVersion.get().toInt()
		targetSdk = libs.versions.targetSdkVersion.get().toInt()
	}

	compileOptions {
		sourceCompatibility = JavaVersion.VERSION_1_8
		targetCompatibility = JavaVersion.VERSION_1_8
	}
}

kotlin {
	val xcFramework = XCFramework("Example")
	ios {
		binaries.framework {
			isStatic = true
			baseName = "Example"
			xcFramework.add(this)
		}
	}
	android()

	sourceSets.commonMain {
		dependencies {
			implementation(projects.domain)
			implementation(projects.features.item)
			implementation(projects.features.gallery)

			implementation(compose.foundation)
			implementation(compose.material)

			implementation(libs.koin)
			implementation(libs.kotlin.coroutines)
			implementation(libs.bundles.decompose)
		}
	}

	sourceSets.getByName("androidMain") {
		dependencies {
			implementation(libs.androidx.compat)
			implementation(libs.androidx.activity.compose)
		}
	}

	sourceSets.getByName("iosMain") {
		dependencies {
			implementation(projects.data)
		}
	}
}

Common Decompose Component

interface RootComponent {

    val childStack: Value<ChildStack<*, Child>>

    fun navigate(route: String)

    sealed class Child {
        class Gallery(val component: GalleryComponent) : Child()
        class Item(val component: ItemComponent) : Child()
        object Empty : Child()
    }
}

class RootComponentImpl(
    context: ComponentContext
) : RootComponent, ComponentContext by context {

    private val navigation = StackNavigation<Configuration>()

    override val childStack: Value<ChildStack<*, RootComponent.Child>> = childStack(
        source = navigation,
        initialConfiguration = Configuration.Gallery,
        handleBackButton = true,
        childFactory = ::resolveChild
    )

    override fun navigate(route: String) {
        val destination = when (route) {
            "item" -> Configuration.Item
            "gallery" -> Configuration.Gallery
            "empty" -> Configuration.Empty
            else -> throw IllegalStateException()
        }

        navigation.replaceCurrent(destination)
    }

    private fun resolveChild(configuration: Configuration, componentContext: ComponentContext): RootComponent.Child {
        return when (configuration) {
            is Configuration.Item -> RootComponent.Child.Item(ItemComponentImpl(componentContext))
            is Configuration.Gallery -> RootComponent.Child.Gallery(GalleryComponentImpl(componentContext))
            is Configuration.Empty -> RootComponent.Child.Empty
        }
    }

    private sealed interface Configuration : Parcelable {
        @Parcelize
        object Item : Configuration

        @Parcelize
        object Gallery : Configuration

        @Parcelize
        object Empty : Configuration
    }
}

Common UI

@Composable
@OptIn(ExperimentalDecomposeApi::class)
fun ExampleApp(rootComponent: RootComponent) {
    Scaffold(
        bottomBar = {
            BottomBar(rootComponent)
        }
    ) {
        Children(rootComponent.childStack) {
            when (val child = it.instance) {
                is RootComponent.Child.Item -> ItemScreen(child.component)
                is RootComponent.Child.Gallery -> GalleryScreen(child.component)
                is RootComponent.Child.Empty -> Spacer(Modifier.fillMaxSize())
            }
        }
    }
}

@Composable
private fun BottomBar(rootComponent: RootComponent) {
    BottomNavigation {
        val navBackStackEntry = rootComponent.childStack.active.instance
        val tabs = listOf(
            "item" to Icons.Default.List,
            "gallery" to Icons.Default.Search,
            "empty" to Icons.Default.Person
        )
        tabs.forEach { (route, icon) ->
            BottomNavigationItem(
                icon = {
                    Image(
                        imageVector = icon,
                        contentDescription = null
                    )
                },
                onClick = { rootComponent.navigate(route) },
                selected = rootComponent.childStack.active.instance == navBackStackEntry
            )
        }
    }
}

Android Entry Point

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val rootComponent = RootComponentImpl(defaultComponentContext())
        setContent {
            ExampleApp(rootComponent)
        }
    }
}

iOS Entry Point

private val rootComponent by lazy {
    RootComponentImpl(
        DefaultComponentContext(LifecycleRegistry())
    )
}

fun createExampleViewController(): UIViewController {
    startKoin {
        modules(dataModule + domainModule)
    }

    return Application("Example") {
        Column {
            Spacer(Modifier.height(100.dp))
            ExampleApp(rootComponent)
        }
    }
}

이렇게 Android와 iOS의 어플리케이션 모듈을 제외한 것들을 구현한 것을 정리했습니다.

Android Application은 Android App Module을 만들어 presentation의 androidMain Source Set에서 구현한 Activity를 사용하시면 됩니다.

iOS Application은 해당 모듈을 어떻게 XCode에서 사용할 것이냐에 따라 다릅니다.

  • Regular Framework
  • XCFramework
  • CocoaPods

위의 세 가지 방법으로 Kotlin Multiplatform 모듈을 XCode에서 사용할 수 있습니다.

위의 예제는 XCFramework로 빌드하여 XCode에서 사용 가능합니다. 따라서, XCFramework로 빌드해서 사용하는 방법을 먼저 알려드리겠습니다.

먼저, build.gradle 파일을 보겠습니다.

kotlin {
	val xcFramework = XCFramework("Module Name")
    ios {
        binaries.fraemwork {
            isStatic = true
            baseName = "Module Name"
            xcFramework.add(this)
        }
    }
}

위처럼 XCFramework를 정의하고, 해당 모듈(예제에서는 presentation module)의 assembleModuleNameXCFramework를 실행합니다.

그러면, 해당 모듈의 아래와 같은 경로에 XCFramework 가 생성됩니다.
build/XCFramework/variants/ModuleName.xcframework

varinats는 어떤 build variants로 gradle task를 실행했느냐에 따라 다릅니다. 기본적으로 debug로 실행되고 명시적으로 assembleModuleNameReleaseXCFramework와 같이 Release Build Typed으로 지정해줄 수 있습니다.

이 XCFramework를 XCode에서 import하여 아래와 같이 사용하면 됩니다.

import UIKit
import ModuleName

@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 = Main_iosKt.createExampleViewController()
        window?.rootViewController = viewController
        window?.makeKeyAndVisible()
        
        return true
    }

}

Error Fix

java.lang.IllegalStateException: No file for ...

위와 같은 에러 발생시 에러가 발생하는 Composable Function에 internal keyword를 붙이시면 됩니다.

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

0개의 댓글