스마트 스쿨 플렛폼인 도담도담에서 선생님용 Android App을 만들때는 iOS의 점유율이 매우 낮았고 그래서 만들지 않았다.
그런데 점점 iOS의 점유율이 높아지며 선생님용 iOS앱이 필요했다....
iOS도 새로운 앱을 만든다 바쁘다 했고, 마침 안드로이드는 Compose로 모두 만들었기에 멀티플렛폼이 가능하지 않을까 생각이 들었다.
먼저 Build Logic부터 바꿀 필요가 존재했다.
KotlinMultiplatformExtension 을 통해 KMP의 설정을 제어할 수 있기 때문에 다음과 같이 확장함수를 만들었다.
fun Project.kotlin(block: KotlinMultiplatformExtension.() -> Unit) {
extensions.configure<KotlinMultiplatformExtension>(block)
}
그리고 다음과 같이 함수 한줄로 모든 설정이 가능하게 만들었다.
@OptIn(ExperimentalKotlinGradlePluginApi::class)
fun Project.setupMultiplatform() {
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
// remove compiler warring
sourceSets.commonMain {
compilerOptions {
freeCompilerArgs.add("-Xexpect-actual-classes")
}
}
}
android {
namespace?.let {
this.namespace = it
}
compileSdkVersion(34)
defaultConfig {
minSdk = 28
targetSdk = 34
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
}
또, 우리는 iOS설정도 지원해줘야 했기 때문에 다음과 같이 함수를 또 만들었다.
fun KotlinMultiplatformExtension.setIOS(name: String, bundleId: String? = null) {
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = name
isStatic = true
if (bundleId != null) {
binaryOptions["bundleId"] = bundleId
}
}
}
}
iOS에서 참조할 경우의 이름인 baseName과, bundleId를 설정하게 했다.
이를 통해서 MultiplatformPlguin을 만들어 참조 한줄로 편하게 설정이 가능해졌다!
class MultiplatformPlugin: Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.multiplatform")
}
setupMultiplatform()
}
}
}
빌드를 하는 도중 다음과 같은 상황에 맞부딪혔다.
Build was configured to prefer settings repositories over project repositories but repository ‘ivy’ was added by build file ‘shared/build.gradle.kts
그래서 열심히 찾아보니 기본적으로 setting.gradle.kts에 존재하는
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
떄문에 발생했다.
바로 지우면 해결.
Datastore Version 1.1.0 부터 KMP를 공식적으로 호환하기 시작했기에 큰 이상없이 전환에 성공헀다.
문제는 KeyStore였다.
Android는 기존 코드를 그대로 재사용했지만, iOS는 KeyChain을 이용해 구현해야했다.
흠.. 어쩌지?
안드로이드에서는 Datastore + Keystore, iOS는 KeyChain을 사용해 값을 저장하기로 했다.
기존에 사용하던 안드로이드 코드는 놔두고, iOS코드에 신경을 써야했다.
일단 commonMain에 간단한 인터페이스를 놔뒀다.
// DataStoreRepository.kt
interface DataStoreRepository {
val user: Flow<User>
val token: Flow<String>
suspend fun saveUser(id: String, pw: String, token: String)
suspend fun saveToken(token: String)
suspend fun deleteUser()
}
그후 안드로이드에서는 저장, 출력할때 암호화, 복호화를 하도록 했다.
iOS는 KVault 라이브러리의 코드를 참고해 아래와 같이 구현했다.
@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class)
class DataStoreRepositoryImpl : DataStoreRepository {
// use only dodam teacher ios logic
private val serviceName: String = "com.b1nd.dodam.teacher"
private val accessibility: CFStringRef? = kSecAttrAccessibleWhenUnlocked
private val _user = MutableStateFlow(
User(
id = value("id") ?: "",
pw = value("pw") ?: "",
token = value("token") ?: "",
),
)
override val user: Flow<User>
get() = _user
private val _token = MutableStateFlow(value("token") ?: "")
override val token: Flow<String>
get() = _token
override suspend fun saveUser(id: String, pw: String, token: String) {
add("id", id)
add("pw", pw)
add("token", token)
_user.value =
User(
id = id,
pw = pw,
token = token,
)
_token.emit(token)
}
override suspend fun saveToken(token: String) {
update("token", token)
_token.emit(token)
}
override suspend fun deleteUser() {
listOf("id", "pw", "token").forEach {
delete(it)
}
_user.emit(User())
_token.emit("")
}
private fun add(key: String, value: String): Boolean = context(key, value.toNSData()) { (account, data) ->
val query = query(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to account,
kSecValueData to data,
kSecAttrAccessible to accessibility,
)
SecItemAdd(query, null).validate()
}
private fun update(key: String, value: String): Boolean = context(key, value.toNSData()) { (account, data) ->
val query = query(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to account,
kSecReturnData to kCFBooleanFalse,
)
val updateQuery = query(
kSecValueData to data,
)
SecItemUpdate(query, updateQuery).validate()
}
private fun delete(key: String): Boolean = context(key) { (account) ->
val query = query(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to account,
)
SecItemDelete(query).validate()
}
private fun value(forKey: String): String? = context(forKey) { (account) ->
val query = query(
kSecClass to kSecClassGenericPassword,
kSecAttrAccount to account,
kSecReturnData to kCFBooleanTrue,
kSecMatchLimit to kSecMatchLimitOne,
)
memScoped {
val result = alloc<CFTypeRefVar>()
SecItemCopyMatching(query, result.ptr)
CFBridgingRelease(result.value) as? NSData
}
}.let {
it?.stringValue
}
private fun <T> context(vararg values: Any?, block: Context.(List<CFTypeRef?>) -> T): T {
val standard = mapOf(
kSecAttrService to CFBridgingRetain(serviceName),
kSecAttrAccessGroup to CFBridgingRetain(null),
)
val custom = arrayOf(*values).map { CFBridgingRetain(it) }
return block.invoke(Context(standard), custom).apply {
standard.values.plus(custom).forEach { CFBridgingRelease(it) }
}
}
private fun String.toNSData(): NSData? = NSString.create(string = this).dataUsingEncoding(NSUTF8StringEncoding)
private val NSData.stringValue: String
get() = NSString.create(this, NSUTF8StringEncoding) as String
private fun OSStatus.validate(): Boolean {
return (this.toUInt() == platform.darwin.noErr)
}
private class Context(val refs: Map<CFStringRef?, CFTypeRef?>) {
fun query(vararg pairs: Pair<CFStringRef?, CFTypeRef?>): CFDictionaryRef? {
val map = mapOf(*pairs).plus(refs.filter { it.value != null })
return CFDictionaryCreateMutable(
null,
map.size.convert(),
null,
null,
).apply {
map.entries.forEach { CFDictionaryAddValue(this, it.key, it.value) }
}.apply {
CFAutorelease(this)
}
}
}
}
생각보다 어려운 점은 없었다.
기존에 이미 Ktor를 도입했었었고, android native용으로 잡혀있던 것을 commonMain으로 이전했다.
그후 iOS 환경에서 로그인 에러가 발생했다!
바로 기존에 Ktor의 Engine으로 CIO를 채택하고 있었는데, iOS환경에서 TLS sessions are not supported on Native platform 에러가 발생했다.
이를 위해 android에서는 CIO엔진을, iOS에서는 Darwin 엔진을 사용해 해결했다.
internal expect fun getHttpClient(block: HttpClientConfig<*>.() -> Unit = {}): HttpClient
// iOS
internal actual fun getHttpClient(block: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(
engine = Darwin.create(),
block = block,
)
// Android
internal actual fun getHttpClient(block: HttpClientConfig<*>.() -> Unit): HttpClient = HttpClient(
engine = CIO.create(),
block = block,
)
또 기존에 작성된 테스트 코드를 Junit에서 Kolin Test로 전환하였다.
사실 여기는 폴더를 그대로 commonMain으로 이전하는 작업이 끝이였기에, 큰 문제가 없었다.
중간에 Task 'testClasses' not found in project 에러가 발생해
기존 build-logic의 setupMultiplatform 함수에 다음과 같이 코드를 추가해줬다.
@OptIn(ExperimentalKotlinGradlePluginApi::class)
fun Project.setupMultiplatform() {
kotlin {
// Task testClasses not found problem solve
task("testClasses")
androidTarget {
compilations.all {
kotlinOptions {
...
해당 에러가 왜 발생하는지는 잘 모르겠으나, 아직 초기버전이라 발생한다고 추측하고 있다.
링크
해당 작업과, 실제 화면을 모두 CMM으로 전환한 이후 Macbook M2 Air로 clean build시 약 1시간이 넘었다.
사유로는 iOS를 호환하기 위해 klib를 빌드하는 과정에서 빌드시간이 매우 길다는 것이고 또 그만큼 빌드가 길다보니 온도가 높아지고 이로 인해 쓰로틀링이 발생해서 더 오래걸린 것 같았다.
왠만해서는 KMP환경에서는 멀티모듈을 하지 않고, 혹시라도 해야한다면 Mackbook Pro를 사용하는 것을 추천한다..
좋은 글 잘 읽었습니다~ 화이팅하세요~~