안녕하세요, 이번엔 저번에 공유드렸던 Jetbrains Compose를 Darwin OS 환경에서 실행시키는 방법에 대한 변경사항과 기존에 feature wise modular로 구성했던 프로젝트를 Kotlin Multiplatform으로 변경하면서 Jetbrains Compose를 적용시키는 법에 대해 공유드리겠습니다. 전체 코드는 아래에 있습니다.
https://github.com/TaehoonLeee/multi-module-clean-architecture/tree/multi-platform
기존에는 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로 변경했던 과정을 공유드리겠습니다.
우선 프로젝트 구성 환경은 다음과 같습니다.
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 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"
]
@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)
}
}
}
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
)
}
interface ItemRepository {
fun getItems(): Flow<List<Item>>
fun insertItem(item: Item)
fun clearItem()
}
interface UnsplashRepository {
fun getSearchResult(query: String): Flow<PagingData<UnsplashPhoto>>
}
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()
}
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을 사용하지 못 합니다. 따라서 명시적으로 의존성 그래프를 작성해줘야 합니다. 그 외의 것들은 코드만 봐도 충분히 이해 가능한 부분입니다.
기존 데이터 모듈과의 차이점은
이렇게 세 부분이 바뀌었습니다.
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)
}
}
}
// 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 모델의 데이터베이스 테이블을 정의해줍니다.
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>
}
}
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을 활용해주었습니다.
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()
}
}
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의 페이징 라이브러리에 맞게 카피한 코드입니다.
// 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)
}
}
멀티플랫폼용 컴포즈 이미지 로더는 링크의 라이브러리를 사용했습니다.
이 모듈에서는 각 피쳐에 해당하는 부분을 구현하겠습니다. 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를 사용한 모습과 똑같은 것을 볼 수 있습니다.
@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)
}
}
}
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()
}
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()
}
}
@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)
}
}
프레젠테이션 모듈에서는 각 피쳐의 구현부를 취합하여 최종 모듈로 나타냅니다.
@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)
}
}
}
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
}
}
@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
)
}
}
}
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val rootComponent = RootComponentImpl(defaultComponentContext())
setContent {
ExampleApp(rootComponent)
}
}
}
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에서 사용할 것이냐에 따라 다릅니다.
위의 세 가지 방법으로 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
}
}
위와 같은 에러 발생시 에러가 발생하는 Composable Function에 internal keyword를 붙이시면 됩니다.