배경
KMP 및 CMP 소개
이에 대해 잘 모르는 독자들의 기본적인 이해를 돕기 위해 자주 등장하는 용어에 대한 간략한 개요를 소개합니다:
- CMP: 최신 UI 프레임워크인 Jetpack Compose를 사용하여 크로스 플랫폼 개발을 가능하게 하는 KMP 기반 기술입니다. 멀티플랫폼 컴포즈는 개발자가 UI 코드를 공유하고 다양한 플랫폼에서 네이티브 사용자 인터페이스를 만들 수 있게 해줍니다. 현재 안드로이드, 데스크톱, iOS에 대한 안정적인 지원이 전체 마이그레이션 프로세스
이 두 기술의 조합을 통해 개발자는 Android와 데스크톱 모두에서 아름답고 일관된 사용자 인터페이스를 보다 효율적으로 구축할 수 있습니다.
최종 결과
주요 하이라이트
- 데스크톱 작성의 창 관리를 직접 디자인하고, 창과 활동이 서로 대응할 수 있도록 DSL로 창을 구성했습니다.
- DAO 마이그레이션은 동적 프록시를 통해 이루어지며, 간단한 코딩으로 원래의 룸 DAO를 계속 사용할 수 있습니다.
- ......
IDE
개발 초기 며칠 동안 이 앱을 개발하기 위해 Fleet, Android Studio, IDEA를 여러 번 사용해 보았지만 결국 AS를 계속 사용했습니다. 개발 환경은 기본적으로 IDEA와 동일하며 데스크톱 앱은 작은 버튼을 클릭하여 바로 실행할 수 있습니다. 다른 두 가지를 사용하지 않는 이유는 다음과 같습니다:
함대: 아직은 약간 초보적인 수준이며, 다양한 창을 띄우기가 어렵고, AI 필러가 없습니다.
아이디어: 자동 생성된 예상/실제가 매우 이상한 위치에 생성됩니다. 예를 들어 안드로이드 쪽의 경우 androidMain과 kotlin.androidMain이 표시되고 전자를 선택하면 아무 일도 일어나지 않고 후자를 선택하면
Xxx.android.commonMain.kotlin.kt파일 이름이 생성되는데 이는 말이 안 됩니다!AS: 안드로이드 개발 경험 상속, 데스크톱도 직접 실행 가능
전체 마이그레이션 프로세스
코드를 작성하는 동안 마이그레이션 프로세스의 일부도 문서화했습니다.
마이그레이션 전
전체 프로젝트를 마이그레이션할 때는 조심해야 합니다. 공식적으로 마이그레이션을 하기 전에 몇 가지 준비 작업을 했습니다.
마이그레이션 조건에 대해 생각하기
KMP + CMP로 마이그레이션하려는 프로젝트에는 다음과 같은 몇 가지 이점이 있습니다:
UI는 거의 전적으로 Jetpack Compose로 마이그레이션할 수 있습니다.
- 하지만 호버링 창, 코드 에디터에 사용되는 XML은
- 웹뷰, 마크다운 텍스트와 같이 뷰이기도 한 일부 컨트롤이 있습니다.
사용되는 타사 라이브러리 중 일부는 그 자체로 크로스 플랫폼입니다.
- kotlinx.serialization을 사용하는 JSON 직렬화 프레임워크, 크로스 플랫폼
- kotlinx.coroutines
- LiveData를 전혀 사용하지 말고 크로스 플랫폼인 Flow로 전환하세요.
코드는 거의 전적으로 Kotlin
대상은 데스크톱+안드로이드만 해당되며, 둘 다 자바 런타임 환경을 가지고 있기 때문에 commonMain은 java.io.File, java.util.Date 등과 같은 자바 API를 사용할 수 있으며 코드 변경이 많지 않을 것입니다.
하지만 여전히 많은 문제가 있습니다. 안드로이드 프로젝트로서 컨텍스트, 활동 등 안드로이드 전용 API와 관련된 코드가 많습니다. 코드와 Github 저장소를 검색하여 마이그레이션 전에 가능한 변경 사항을 다음과 같이 나열해 보았습니다. 코드와 Github 리포지토리를 검색하여 마이그레이션 전에 가능한 변경 사항을 다음과 같이 나열해 보았습니다.
가능한 변경 사항
현재 코드와 관련될 수 있는 KMP 프로젝트 README에 나열된 변경 사항을 확인하세요.
Main
| 스포츠 이벤트 | 안드로이드 원본 버전 | KMP | 발생 가능한 문제 |
|---|
기타
- Toast
- Log
- Handler
- 다양한 컨텍스트 관련
- 파일 읽기 관련
- 방송, 서비스
- Intent
- 활동에는 여전히 플랫폼 관련 활동으로 처리해야 하는 몇 가지 활동이 있습니다.
- Cache
- Locale
- Assets 의 파일은
- 이미지 선택, 파일 선택
- 사진이 로드됩니다:qdsfdhvh/compose-imageloader: Compose Image library for Kotlin Multiplatform.
- BuildConfig: yshrsmz/BuildKonfig: BuildConfig for Kotlin Multiplatform Project
실제 프로세스
처음 2~3일
- 처음 2~3일 동안은 '재사용 가능한 빌드 스크립트'를 구성하려고 했지만 계속 실패하고 실패하고 실패해서 머릿속이 빙빙 돌았습니다.
- 도중에 Gradle 플러그인 작성 방식이 변경되어 새 버전에서는 Gradle 플러그인 검색( plugins.gradle.org/search )에서 찾을 수 있는 아이디를 사용하여 플러그인을 사용하도록 제안하고 있습니다.
- 하지만 어려운 점은 결국 잘 풀리지 않았다는 것입니다.
1월 26일
26일에는 스크립트 구성을 구축하는 것을 포기하기로 결정하고 일단 사전 실험으로 실제 코드를 복사하여 붙여넣기하여 실행해 보기로 했습니다. 같은 날 안드로이드와 데스크톱에서 자바스크립트 실행을 완료했습니다.
1월 27일 - 28일
지난 이틀 동안, 우리는 주로 네트워크 요청만 있지만 여전히 많은 파일을 포함하는 원본 코드가 모든 측면을 포함하기 때문에 이중 엔드 작업에서 OkHttp + Retrofit을 완료했습니다. 이 과정에서 Json 직렬화, CacheManager, 간단한 테스트 환경 구축도 완료했습니다.
1월 29일
리소스 마이그레이션 검증을 시도하기 시작했습니다. moko-리소스에서 6차례 시도한 결과, 데스크톱 플랫폼에서 해당 컴파일이 올바르게 생성되지 않는 것을 발견했습니다. 이슈 목록에 있는 비슷한 문제도 보류 중이었습니다. 그래서 저는 리브레스를 사용하기로 전환했습니다.
Libres는 양쪽 끝에서 모두 잘 작동하므로 문자열 리소스를 사용할 수 있습니다. 이를 위해서는 strings_en.xml, strings_zh.xml을 작성하고 특정 디렉터리에서 appContext.externalDir 통해 항목을 구성한 다음 gradle 작업을 실행하여 직접 형식으로 사용되는 Kotlin의 ResStrings 클래스를 생성해야 합니다. 즉, 코드의 상당 부분을 수정해야 합니다.
또한 런타임에 Libres는 다음과 같은 Java 스타일 형식을 요구합니다.
<string name="tip_reset_fingerprint">The fingerprint information saved for the current account (%1$s) on this device seems to have been cleared. Would you like to re-add the fingerprint and send a verification code to verify your email (%2$s)?</string>
로 변경해야 합니다.
<string name="tip_reset_fingerprint">The fingerprint information saved for the current account ${username} on this device seems to have been cleared. Would you like to re-add the fingerprint and send a verification code to verify your email ${email}?</string>
Gradle 작업에서 라이브러리에 해당하는 작업을 찾아서
그리고 리브레스는 해당 코드를 생성합니다:
// StringsEn.kt
public object StringsEn : Strings {
override val hint: String = "Hint"
override val tip_reset_fingerprint: LibresFormatTipResetFingerprint =
LibresFormatTipResetFingerprint("The fingerprint information saved for the current account %1${'$'}s on this device seems to have been cleared. Would you like to re-add the fingerprint and send a verification code to verify your email %2${'$'}s?")
}
// FormattedClasses.kt
public class LibresFormatTipResetFingerprint(
private val `value`: String,
) {
public fun format(username: String, email: String): String = formatString(value,
arrayOf(username,email))
}
호출할 때 수동으로 .format().
context.getString(R.string.xxx)
string(R.string.xxx) // 에서 유사한 메서드를 호출하는 전역 함수를 찾아보세요.
stringResoucres(R.string.xxx) // Jetpack Compose 문자열을 가져오는 방법
// 와 매개변수화된
string\(R.string.([^,]+),\W(.+)\)
위의 내용은 AS의 정규식에 의존하는 좋은 대체 방법입니다.
// 를 매개변수로
string\(R.string.([^,]+),\W(.+)\) -> ResStrings.$1.format($2) (문자열이 아닌 매개 변수를 추가하여 교체한 후 수동으로 변경해야 할 수도 있습니다. .toString()
// 매개변수 없이
string\(R.string.(\w+)\) -> ResStrings.$1
stringResource\(R.string.(\w+)\) -> ResStrings.$1
stringResource\(id = R.string.(\w+)\) -> ResStrings.$1
그러나 원래 리소스 ID를 참조했지만 지금은 약간만 마이그레이션할 수 있는 class Student(val nameId: Int) 카테고리와 같이 대체하기 좋지 않은 다른 항목도 있습니다.
1월 31일
2023년의 마지막 날인데도 저는 여전히 마이그레이션에 어려움을 겪고 있습니다. 오늘 제가 한 작업은 Room에서 Sqldelight로 데이터베이스를 마이그레이션하는 것이었습니다.
또한 글을 작성할 때 SQL로 작성된 모든 정수형 필드의 경우 생성된 클래스 대응 항목이 기본적으로 Long으로 설정된 반면, 원래 정의는 모두 Int로 되어 있어 많은 비호환성이 발생한다는 사실을 발견했습니다. 나중에 수동으로 다음과 같이 지정해야 한다는 것을 알게 되었습니다.
import kotlin.Boolean;
CREATE TABLE foo(
is_bar INTEGER AS Boolean
);
Text to List<Int> 등도 마찬가지입니다. 예를 들어 데이터베이스를 정의할 때 ColumnAdapter를 전달해야 합니다:
val listOfStringsAdapter = object : ColumnAdapter<List<String>, String> {
override fun decode(databaseValue: String) =
if (databaseValue.isEmpty()) {
listOf()
} else {
databaseValue.split(",")
}
override fun encode(value: List<String>) = value.joinToString(separator = ",")
}
val queryWrapper: Database = Database(
driver = driver,
hockeyPlayerAdapter = hockeyPlayer.Adapter(
cup_winsAdapter = listOfStringsAdapter
)
)
이미 작성한 TypeConverter를 어떻게 변환하나요? ChatGPT의 도움으로 이 작업을 수행할 수 있습니다.
val listOfStringsAdapter = object : ColumnAdapter<List<String>, String> { override fun decode(databaseValue: String) = if (databaseValue.isEmpty()) { listOf() } else { databaseValue.split(",") } override fun encode(value: List<String>) = value.joinToString(separator = ",") }변환해야 하는 코드입니다:
class LanguageListConverter{ @TypeConverter fun languagesToJson(languages : List<Language>) : String = JsonX.toJson(languages) @TypeConverter fun jsonToLanguages(json : String) : List<Language> { return JsonX.fromJson(json) } } // ....생성한 코드는 싱글톤 객체이며, JsonX는 제가 기존에 구현한 것이므로 변경할 필요가 없습니다.
기본 유형의 경우 함께 제공되는 다른 패키지를 사용할 수 있습니다:
Primitives¶
A sibling module that adapts primitives for your convenience.
dependencies { implementation("app.cash.sqldelight:primitive-adapters:2.0.1") }The following adapters exist:
- FloatColumnAdapter - kotlin을 검색합니다..Float for an SQL type implicitly stored as kotlin.Double
- IntColumnAdapter - kotlin을 검색합니다..Int for an SQL type implicitly stored as kotlin.Long
- ShortColumnAdapter - kotlin을 검색합니다..Short for an SQL type implicitly stored as kotlin.Long
Flow로 전환해야 하는 경우 제공하는 다른 확장 프로그램을 사용할 수 있습니다:
implementation("app.cash.sqldelight:coroutines-extensions:2.0.1")
val players: Flow<List<HockeyPlayer>> =
playerQueries.selectAll()
.asFlow()
.mapToList(Dispatchers.IO)
페이징도 제공됩니다: plugins.gradle.org/search
다른 한 가지는 원본 빈의 처리인데, 이제 SqlDelight에서 생성되므로 원본 빈을 생략할 수 있지만 호출 위치의 코드를 변경하지 않기 위해 여기서는 계속 유형 별칭을 사용합니다.
// 원본 데이터 클래스 JsBean(xxx)을 삭제합니다., xxx, )
typealias JsBean = com.funny.translation.database.Plugin
2023년이면 끝입니다.
class DataSaverProperties(private val filePath: String) : DataSaverInterface() {
private val properties = Properties()
init {
try {
FileReader(filePath).use { reader ->
properties.load(reader)
}
} catch (e: Exception) {
// 파일이 존재하지 않는 것과 같은 예외 처리하기
}
}
private fun saveProperties() {
FileWriter(filePath).use { writer ->
properties.store(writer, null)
}
}
override fun <T> saveData(key: String, data: T) {
properties[key] = data.toString()
saveProperties()
}
override fun <T> readData(key: String, default: T): T {
val value = properties.getProperty(key) ?: return default
return when (default) {
is Int -> value.toIntOrNull() as T? ?: default
is Long -> value.toLongOrNull() as T? ?: default
is Boolean -> value.toBoolean() as T ?: default
is Double -> value.toDoubleOrNull() as T? ?: default
is Float -> value.toFloatOrNull() as T? ?: default
is String -> value as T
else -> value as T
}
}
override fun remove(key: String) {
properties.remove(key)
saveProperties()
}
override fun contains(key: String): Boolean {
return properties.containsKey(key)
}
}
그러면 정상적으로 작동합니다.
CompositionLocalProvider(
LocalDataSaver provides DataSaverUtils // in desktop, = DataSaverProperties(xxx)
) {
MaterialTheme {
Box(Modifier.fillMaxSize()) {
// 나머지는 생략하세요.
var switch by rememberDataSaverState<Boolean>(
key = "switch",
initialValue = false
)
Switch(checked = switch, onCheckedChange = { switch = it })
Toast(
modifier = Modifier.align(Alignment.BottomEnd)
)
}
}
}
이 라이브러리에 관심이 있으시면 언제든지 클릭하여 자세히 알아보세요. 또한 조만간 KMP로 마이그레이션할 예정입니다.
또한 프로젝트에서 사용할 수 있도록 FunnySaltyFish/CMaterialColors: Jetpack 컴포즈 머티리얼 디자인 색상 | Jetpack 컴포즈에서 머티리얼 디자인 색상 사용의 복사본을 만들었습니다. MaterialColors.A003을 프로젝트에 추가합니다.
매월 2일
테마 마이그레이션이 완료되었으며, Android 플랫폼은 다양한 테마를 지원하지만 데스크톱은 고정된 색상만 지원합니다.
또한 데스크톱용 마크다운을 추가하고 스타일을 약간 조정했습니다. 또한 CMP용 탐색, 뷰모델, 라이프사이클 라이브러리인 PreCompose에 대한 종속성을 추가했지만 실제로는 사용하지 않았습니다.
매월 3일
저는 KMP용 컨텍스트 마이그레이션 작업을 시작했는데, 사용해야 할 코드가 너무 많아서 마이그레이션해야 하는 코드의 양을 최소화하고 싶었기 때문에 가능한 한 타입알리아를 사용했습니다. 컨텍스트의 경우 추상 클래스이기 때문에 구현하기가 매우 쉽습니다.
// commonMain
expect abstract class KMPContext
expect fun KMPContext.openAssetsFile(fileName: String): InputStream
expect fun KMPContext.readAssetsFile(fileName: String): String
expect val LocalKMPContext: ProvidableCompositionLocal<KMPContext>
expect val appCtx: KMPContext
// androidMain
actual typealias KMPContext = android.content.Context
actual val LocalKMPContext = LocalContext
actual val appCtx: KMPContext
get() = BaseApplication.ctx
actual fun KMPContext.openAssetsFile(fileName: String): InputStream {
return runBlocking {
ByteArrayInputStream(resource("assets/$fileName").readBytes())
}
}
actual fun KMPContext.readAssetsFile(fileName: String): String {
return runBlocking {
resource("assets/$fileName").readBytes().decodeToString()
}
}
// Desktop Main
actual abstract class KMPContext
actual val LocalKMPContext: ProvidableCompositionLocal<KMPContext> =
staticCompositionLocalOf { appCtx }
actual val appCtx = object : KMPContext() { }
actual fun KMPContext.openAssetsFile(fileName: String): InputStream {
return runBlocking {
ByteArrayInputStream(resource("assets/$fileName").readBytes())
}
}
actual fun KMPContext.readAssetsFile(fileName: String): String {
return runBlocking {
runCatching {
resource("assets/$fileName").readBytes().decodeToString()
}.getOrDefault("")
}
}
사용된 함수 리소스는 크로스 플랫폼에서 사용할 수 있는 commonMain/resources 디렉터리의 파일에 액세스하기 위한 CMP 함수이므로 크로스 플랫폼 리소스도 구현되어 있습니다.
액티비티의 경우
public class Activity extends ContextThemeWrapper
implements LayoutInflater.Factory2,
Window.Callback, KeyEvent.Callback,
OnCreateContextMenuListener, ComponentCallbacks2,
Window.OnWindowDismissedCallback,
ContentCaptureManager.ContentCaptureClient {}
활동은 타입알리아로 직접 처리하기에는 너무 많은 인터페이스를 구현합니다. 며칠 더 탐색한 끝에 마침내 다음과 같은 형태가 나타났습니다:
// common
expect open class KMPActivity()
// android
actual typealias KMPActivity = AppCompatActivity
fun KMPActivity.toastOnUi(msg: String) {
appCtx.toastOnUi(msg)
}
// desktop
actual open class KMPActivity: KMPContext()
expect open class BaseActivity() : KMPActivity
// android
actual open class BaseActivity : KMPActivity() {
private lateinit var callback: OnBackPressedCallback
lateinit var activityResultLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 모든 초기화 등이 포함된 원래의 BaseActivity입니다.
}
}
데스크톱에서는 창 관리 기능이 추가로 추가됩니다.
actual open class BaseActivity : KMPActivity() {
lateinit var windowState: WindowState
val windowShowState = mutableStateOf(false)
var data: DataType? = null
open fun onShow() {
Log.d("BaseActivity", "onShow: $this")
}
open fun onStart() {
Log.d("BaseActivity", "onStart: $this")
}
fun finish() {
windowShowState.value = false
}
override fun toString(): String {
return "Activity: ${this::class.simpleName}, show = ${windowShowState.value}"
}
}
onShow, onStart는 직접 작성한 간단한 수명 주기로, 데이터는 다른 활동 점프에서 전달된 매개 변수를 수신하는 데 사용되며, 자세한 내용은 소스 코드를 참조하세요.
5월 5일
모든 등록 및 로그인 로직을 포함한 기존 로그인 모듈의 마이그레이션이 이날 완료되었습니다. 이 부분은 여러 클래스가 관련되어 있어 마이그레이션 속도가 느렸습니다. Android 플랫폼은 androidx.biometric을 사용하여 지문 로그인을 지원합니다. 이 기능은 예상/실제를 통해 마이그레이션되었으며, 이제 데스크톱에서는 자주 발생하는 Build.VERSION.SDK_INT >= Build.VERSION_CODES.M 대신 새로운 변수인 supportBiometric을 추가하도록 조정된 null 구현으로 사용됩니다.
expect val supportBiometric: Boolean
// android
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.M)
actual val supportBiometric: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
// desktop
actual val supportBiometric: Boolean = false
UI 수준에서 이를 결정하여 지문 관련 UI 코드를 제거합니다.
마이그레이션 프로세스 중에 원본 프로젝트에서 드로어블을 commonMain/resources/drawble 폴더로 이동한 다음
painterResource\(id \= R\.drawable\.(.+?)\)
->
org.jetbrains.compose.resources.painterResource("drawable/$1.png")
매월 6일
데이터베이스에 대한 실제 호출을 포함하는 메인 모듈 마이그레이션을 시작했습니다. 제 원래 프로젝트는 Room을 사용하고 있었기 때문에 Dao를 많이 사용했습니다:
@Dao
interface TransHistoryDao {
@Query("select * from table_trans_history where id in " +
"(select max(id) as id from table_trans_history group by sourceString) order by id desc")
fun queryAllPaging(): PagingSource<Int, TransHistoryBean>
@Query("delete from table_trans_history where id = :id")
fun deleteTransHistory(id: Int)
@Query("delete from table_trans_history where sourceString = :sourceString")
fun deleteTransHistoryByContent(sourceString: String)
@Insert
fun insertTransHistory(transHistoryBean: TransHistoryBean)
@Query("select * from table_trans_history where time between :startTime and :endTime")
fun queryAllBetween(startTime: Long, endTime: Long): List<TransHistoryBean>
@Query("delete from table_trans_history")
fun clearAll()
}
원본 코드에서는 모두 이와 같이 사용됩니다:
var allHistories = transHistoryDao.queryAllBetween(START_TIME, END_TIME)
하지만 SqlDelight로 마이그레이션한 후에도 마찬가지로 쿼리를 사용해야 합니다:
var allHistories = appDB.transHistoryQueries.queryAllBetween(START_TIME, END_TIME).executeAsList()
조금씩 변경하는 것은 분명히 많은 작업이므로 새로운 클래스 나 메서드를 작성하여 일부 기술의 도움으로이 차이를 지울 수있는 방법이 있습니까? 이 새로운 클래스를 다른 다오에 적용한 다음 가능한 한 변경하지 않고 호출할 수 있을까요?
익숙하지 않아서 ChatGPT에 문의했더니 정말 효과가 있는 것 같은 해결책을 제시해 주었습니다:
import java.lang.reflect.InvocationHandler
import java.lang.reflect.Method
import java.lang.reflect.Proxy
class UnifiedDaoProxy(private val roomDao: Any, private val sqlDelightQueries: Any) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<out Any>?): Any? {
return when (method.name) {
"queryAllBetween" -> {
callAppropriateMethod(method, args, List::class.java, TransHistoryBean::class.java)
}
"clearAll" -> {
callAppropriateMethod(method, args)
}
// 통합해야 하는 다른 메소드 추가
else -> throw UnsupportedOperationException("Method ${method.name} not supported")
}
}
private fun callAppropriateMethod(
method: Method,
args: Array<out Any>?,
expectedReturnType: Class<*>? = null,
expectedGenericType: Class<*>? = null
): Any? {
val returnType = method.returnType
return when {
roomDao.javaClass.methods.any { it.name == method.name && it.returnType == returnType } ->
callMethod(roomDao, method, args, expectedReturnType, expectedGenericType)
sqlDelightQueries.javaClass.methods.any { it.name == method.name && it.returnType == returnType } ->
callMethod(sqlDelightQueries, method, args, expectedReturnType, expectedGenericType)
else -> throw UnsupportedOperationException("Method ${method.name} not found")
}
}
private fun callMethod(
target: Any,
method: Method,
args: Array<out Any>?,
expectedReturnType: Class<*>? = null,
expectedGenericType: Class<*>? = null
): Any? {
val result = method.invoke(target, *args.orEmpty())
if (expectedReturnType != null && !expectedReturnType.isAssignableFrom(method.returnType)) {
throw UnsupportedOperationException("Method ${method.name} does not return expected type.")
}
if (expectedGenericType != null && result is List<*> && result.isNotEmpty()) {
val actualGenericType = result[0].javaClass
if (!expectedGenericType.isAssignableFrom(actualGenericType)) {
throw UnsupportedOperationException("Method ${method.name} does not return expected generic type.")
}
}
return result
}
}
// 팩토리 함수를 사용하여 프록시 생성
inline fun <reified T : Any> createUnifiedDao(roomDao: Any, sqlDelightQueries: Any): T {
val handler = UnifiedDaoProxy(roomDao, sqlDelightQueries)
return Proxy.newProxyInstance(
T::class.java.classLoader,
arrayOf(T::class.java),
handler
) as T
}
private const val TAG = "DaoProxy"
/**
* Dao 호출을 SqlDelight 호출로 변환하기
*
*
*/
class DaoProxy(private val sqlDelightQueries: Any) : InvocationHandler {
override fun invoke(proxy: Any, method: Method, args: Array<out Any>?): Any? {
return callAppropriateMethod(method, args)
}
private fun callAppropriateMethod(
method: Method,
args: Array<out Any>?
): Any? {
val sqldelightMethod = sqlDelightQueries.javaClass.methods.find {
it.name == method.name && it.parameterCount == method.parameterCount
}
sqldelightMethod ?: throw UnsupportedOperationException("Method ${method.name} not found")
Log.d(TAG, "find sqldelightMethod: $sqldelightMethod")
val returnType = method.returnType
// 타입 쿼리로 강력한 변환<ExpectedGenericType>
val query = sqldelightMethod.invoke(sqlDelightQueries, *args.orEmpty()) as? Query<*> ?: return null
// 쿼리를 호출하는 메서드
return when (returnType) {
List::class.java -> query.executeAsList()
Flow::class.java -> query.executeAsFlowList()
else -> callAndConvert(returnType, query)
}
}
/**
* 적절한 타입 변환으로 메서드를 호출하는 것은 현재
* 1. 반환값이 Long이고 Dao의 반환값이 Int인 경우 Int로 변환합니다.
*/
private fun callAndConvert(
daoReturnType: Class<*>,
query: Query<*>
): Any? {
val executedQuery = query.executeAsOneOrNull() ?: return null
return when {
daoReturnType == Int::class.java && executedQuery is Long -> {
executedQuery.toInt()
}
else -> {
executedQuery
}
}
}
}
// 팩토리 함수를 사용하여 프록시 생성
inline fun <reified T : Any> createDaoProxy(sqlDelightQueries: Any): T {
val handler = DaoProxy(sqlDelightQueries)
return Proxy.newProxyInstance(
T::class.java.classLoader,
arrayOf(T::class.java),
handler
) as T
}
inline fun <reified RowType : Any> Query<RowType>.executeAsFlowList(): Flow<List<RowType>> {
return this.asFlow().mapToList(Dispatchers.IO)
}
그런 다음 프로퍼티를 확장하여 원본 appDB.xxDao를 유효하게 만듭니다.
val Database.transHistoryDao by lazy {
createDaoProxy<TransHistoryDao>(appDB.transHistoryQueries)
}
자세한 내용은 소스 코드를 참조하세요.
매월 8일.
main.ui를 마이그레이션할 때 androidx.paging:paging-compose 제공하는 LazyPagingItems에 문제가 발생했지만 현재 sqldelight에서 제공하는 페이징 확장에는 이 라이브러리가 포함되어 있지 않습니다. 다행히도 해당 소스 코드는 몇 개의 파일에 불과하므로 간단히 복사하여 붙여넣고 android.os.Parcel 관련 부분을 예상/실제 구현하면 문제가 해결됩니다.
1월 9일.
여러 가지 기본 컴포넌트를 마이그레이션하는 긴 과정 끝에 마침내 MainContent를 실행하고 Rhino-Js를 호출하여 암호화 로직을 수행하는 실제 코드를 완성했습니다. 이 과정에서 github Rhino 1.7.14 오류 Failed resolution of: Ljavax/lang/model/SourceVersion;, javax.lang.model.SourceVersion으로 인해 Android에서 더 이상 사용할 수 없음 - 이번 이슈 #4119에서도 이에 대해 자세히 다루고 있습니다. 이 이슈에서도 이 문제를 자세히 다루고 있으며, 1.7.14.1 릴리스에서 수정된 것으로 보이지만 안타깝게도 아직 릴리스되지 않았습니다. Rhino를 1.7.13으로 다운그레이드하면 문제가 해결되었습니다.
이 외에도 java.lang.RuntimeException: No Context associated with current Thread at org.mozilla.javascript.Context.getContext(Context.java:2452) 오류가 발생하여 ChatGPT에 문의 한 결과 멀티 스레드 환경 컨텍스트 때문이며 "스레드"가 문제의 원인에 해당하지 않으므로 contextFactory.enterContext() 변경하라는 조언을 듣고 문제도 해결되었습니다.
Android를 실행한 후 데스크톱에서 다시 실행하려고 시도했는데 첫 번째 오류가 발생했습니다!
java.lang.UnsatisfiedLinkError: 'int org.jetbrains.skiko.SystemTheme_awtKt.getCurrentSystemTheme()'
그런 다음 다음 코드를 실행하자마자 새로운 문제가 발생하여 오류가 보고되었습니다.
viewModelScope.launch(Dispatchers.IO) { // <--- 이 줄에서 오류가 발생합니다.
appDB.jsDao.getAllJs().forEach { jsBean ->
}
}
Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
FunnySaltyFish/CMaterialColors: Jetpack Compose Material Design Colors | 제트팩 컴포즈에서 머티리얼 디자인 컬러 사용하기, 데스크톱에서 kotlinx-coroutines-android 종속성 제거하기
configurations.commonMainApi {
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-android")
}
성공적으로 실행 중입니다. 드디어 데스크톱에서 실행됩니다!
월 10 일
10일 하루 동안 계속 코드를 두드리고 두드려서 드디어 TransActivity에서 LoginActivity로 이전을 성공적으로 완료했는데, 안드로이드 쪽 구현은 BaseActivity에 lateinit var activityResultLauncher: ActivityResultLauncher<Intent> 추가하고 onCreate에서 초기화를 한 후 object ActivityManager.start 메서드를 통해 Activity를 찾아 launch(Intent())를 실행하는 것이고, 데스크톱은 Activity와 Window별로 하나씩 해당 방식으로 창 표시와 숨김을 관리하는 것으로, 각 Window의 선언을 완료하는 간단한 DSL을 제가 직접 설계해봤습니다. 안드로이드 쪽의 구현은 BaseActivity에 _를 추가하고 onCreate에서 초기화한 후 _ 메서드를 통해 Activity를 찾아 launch(Intent())를 실행하는 방식이고, 데스크톱은 Activity와 Window가 하나씩 해당되는 방식을 통해 스스로 Window의 표시와 숨기기를 관리하고, 각 Window의 선언을 스스로 완료하도록 간단한 DSL을 설계했으며, 현재 사용 측면의 API는 다음과 같습니다:
application {
WindowHolder {
addWindow<TransActivity>(
rememberWindowState(),
show = true,
onCloseRequest = { exitApplication() }
) { transActivity ->
CompositionLocalProvider(LocalActivityVM provides transActivity.activityViewModel) {
AppNavigation(navController = rememberNavController().also {
ActivityManager.findActivity<TransActivity>()?.navController = it
}, exitAppAction = ::exitApplication)
}
}
addWindow<LoginActivity>(
rememberWindowState(
placement = WindowPlacement.Floating,
width = 360.dp,
height = 700.dp,
),
) { loginActivity ->
LoginNavigation(
onLoginSuccess = {
Log.d("Login", "로그인에 성공했습니다.: : $it")
if (it.isValid()) AppConfig.login(it, updateVipFeatures = true)
loginActivity.finish()
}
)
}
}
}
예를 들어, 원래는 KotlinLogging에서 로그가 제공되었지만 실행 인터페이스에서 출력이 없는 것을 발견하고 결국 println으로 전환하여 로그를 입력하기로 결정한 후, 원래 활동 점프가 데스크톱에서 반응하지 않았고 나중에 일부 변수가 재구조화를 트리거하지 않는 방식으로 작성된 것을 발견하는 등 프로세스 중간에 많은 함정이 있었습니다. 예를 들어, 중간에
Reading a state that was created after the snapshot was taken or in a snapshot that has not yet been applied
그런 다음 상태 읽기 타이밍이 잘못되었다는 것이 밝혀졌고 또 한 번의 수정이 필요했습니다. 어쨌든 이제야 정상적으로 조회되고 있습니다.
1월 11일
오늘 다양한 다른 페이지의 마이그레이션을 시작했고, 인프라가 개선되고 있으므로 이번에는 여러 페이지를 함께 마이그레이션할 예정이므로 R.string.xxx 및 R.drawble의 다양한 용도를 전역적으로 교체해야 하는 횟수를 줄이는 데 도움이 될 것입니다.
@OptIn(ExperimentalResourceApi::class)
@Composable
fun painterDrawableRes(name: String, suffix: String = "png") = painterResource("drawable/${name}.$suffix")
그런 다음 글로벌 교체 + 약간의 수정이 필요했는데, 시간이 좀 걸렸지만 다행히도 그렇게 어렵지는 않았습니다.
val exportFileLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.CreateDocument("plain/text")
) { uri ->
if (uri == null) return@rememberLauncherForActivityResult
// 불필요한 코드 생략
uri.writeText(context, "$text
${"-".repeat(20)}
$watermark")
context.toastOnUi(string(id = R.string.export_success))
}
onClick = {
exportFileLauncher.launch("result_${remark}.txt")
}
이제 이것은 확실히 KMP화될 것입니다. 런처를 작성하세요!
/**
* KMP Launcher
*
*/
abstract class Launcher<Input, Output> {
abstract fun launch(input: Input)
}
expect class FileLauncher<Input>: Launcher<Input, Uri?> {
override fun launch(input: Input)
}
@Composable
expect fun rememberCreateFileLauncher(
mimeType: String = "*/*",
onResult: (Uri?) -> Unit = {},
): FileLauncher<String>
@Composable
expect fun rememberOpenFileLauncher(
onResult: (Uri?) -> Unit = {},
): FileLauncher<Array<String>
그런 다음 Android 쪽에서 다음 레이어를 래핑합니다.
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import com.eygraber.uri.Uri
import com.eygraber.uri.toUri
import android.net.Uri as AndroidUri
actual class FileLauncher<Input>(
private val activityResultLauncher: ManagedActivityResultLauncher<Input, AndroidUri?>
) : Launcher<Input, Uri?>() {
actual override fun launch(input: Input) {
activityResultLauncher.launch(input)
}
}
@Composable
actual fun rememberCreateFileLauncher(
mimeType: String,
onResult: (Uri?) -> Unit
): FileLauncher<String> {
val res: ManagedActivityResultLauncher<String, AndroidUri?> = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument(mimeType)) {
onResult(it?.toUri())
}
return FileLauncher(res)
}
@Composable
actual fun rememberOpenFileLauncher(
onResult: (Uri?) -> Unit
): FileLauncher<Array<String>> {
val res = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) {
onResult(it?.toUri())
}
return FileLauncher(res)
}
데스크톱에서는 이를 위해 awt API를 사용합니다.
그런 다음 SystemUiCntroller가 사용되는 곳을 발견하고 소스 코드를 살펴보니 파일 하나뿐이어서 복사본을 만들어서 데스크톱에 빈 구현을 제공했습니다. Finished
1월 12일
오늘은 주로 코드 에디터 관련 모듈을 마이그레이션하는데, 이 부분은 안드로이드 관련성이 강해서 당분간은 androidMain에서만 구현합니다. 마이그레이션 과정은 다른 부분과 비슷하지만 어려운 부분은 많은 코드 수정 후 앱이 성공적으로 빌드되고 실행될 수 있지만 해당 부분으로 실행되면 바로 오류를 보고한다는 것입니다:
java.lang.NoClassDefFoundError: Failed resolution of: xxx
반나절의 과정 끝에 클래스가 Java로 작성되었다는 사실을 알게 되었고, 회의적인 마음으로 Kotlin으로 변환한 후 괜찮았습니다! KMP 프로젝트의 androidMain과 같은 폴더에는 Java 코드를 작성할 수 없지만, Java로 작성된 순수 안드로이드 라이브러리 모듈이나 컴파일된 제품을 사용할 수 있다는 점은 꽤 놀랍고 약간 까다로운 점인 것 같습니다.
1월 13일
java.io.FileNotFoundException: No content provider: file%3A%2F%2F%2Fstorage%2Femulated%2F0%2FAndroid%2Fdata%2Fcom.funny.translation.kmp.debug%2Fcache%2Ftemp_captured_image.jpg
개인 경로에만 액세스하기 때문에 내 애플리케이션에서 콘텐츠 제공자를 사용하지 않았기 때문에 위의 내용을 살펴보고 콘텐츠 제공자가 오류를보고하는 것을 보았고 몇 가지 비교 후 다음을 추가하려고했습니다.
java.io.FileNotFoundException: No content provider: content%3A%2F%2Fcom.funny.translation.kmp.debug.fileprovider%2Fcache%2Ftemp_captured_image.jpg
이 콘텐츠 제공업체가 제공한 경로가 문제가 아니라고 생각했는데, 정말 이상하네요 ...... 나중에 한참을 고민한 끝에 ChatGPT에서 이렇게 말했습니다:
마지막으로 생성된 FileProvider URI를 올바르게 처리하고 있는지 확인하세요. 오류 메시지에서 URI가
content%3A%2F%2Fcom.funny.translation.kmp.debug.fileprovider%2Fcache%2Ftemp_captured_image.jpg되어 있는데, 이는 URL 인코딩으로 인한 것일 수 있습니다. 올바른 파일 경로를 확인하기 위해 URI를 사용할 때 디코딩해야 할 수도 있습니다.
이해가 되네요! 디코딩 문제인 것 같습니다. 전체 경로를 다시 살펴본 결과 uri는 딥링크의 일부로 인코딩되어 있으므로 여기서 디코딩되어야 합니다. 그렇다면 왜 문제가 되지 않았을까요? 안드로이드 플랫폼용 Uri였고, 디코딩이 Uri.parse에서 이루어졌기 때문에 자동으로 디코딩이 되지 않고, AndroidUri와 KMPUri의 상호 변환을 포함하는 여러 연산으로 인해 자동으로 디코딩이 되지 않았기 때문인 것 같습니다. 디코딩 줄을 추가하면 완료됩니다.
// 먼저 URL 디코딩하기
val androidImageUri = Uri.decode(imageUri.toString()).toUri()
1월 14일
오늘을 기점으로 Android는 기본적으로 그 과정을 마쳤으며, 오늘은 데스크톱 체크인을 하는 데 시간을 보냈습니다.
또한 긴 번역에서 주석 업데이트 기능이 데스크톱에서 작동하지 않는 것 같고, 로깅 및 중단 점 이후 sqldelight에서 생성 된 코드가 실제로 실행되지만 DB 파일은 변경되지 않는 것을 발견했습니다. 지금 당장 수정하지는 않겠지만 문제를 제기했습니다.
데스크톱에서 몇 가지 버그를 수정한 후 아이콘과 제목을 추가한 것이 전부입니다. 나머지 시간은 패키징에 보냈습니다. 몇 가지 작업을 구성한 후 릴리스 패키지를 누르는 데 실패한 것을 발견했습니다:
Direct local .aar file dependencies are not supported when building an AAR. The resulting AAR would be broken because the classes and Android resources from any local .aar file dependencies would not be packaged in the resulting AAR. Previous versions of the Android Gradle Plugin produce broken AARs in this case too . The following direct local .aar file dependencies of the :base-kmp project caused this error: D:\projects\kotlin\Transtation-KMP\base-kmp\libs\monet.aar
Failed to check JDK distribution: 'jpackage.exe' is missing JDK distribution path: D:\Program Files\AndroidStudioStable\jbr
함께 제공된 jbr에 이 기능이 포함되어 있지 않다는 것을 알고 다른 JDK17의 위치로 JavaHome을 지정했습니다. 마침내 익숙한 설치 프로그램을 실행했습니다.
compose.desktop {
application {
javaHome = System.getenv("JDK_17")
}
}
하지만 설치 후 exe가 제대로 실행되지 않고 오류 등이 보고되지 않습니다.
지금은 여기까지이며 앞으로도 계속 업데이트할 예정입니다.
전반적인 경험
KMP+CMP는 처음 시도한 것이었고, 많은 시행착오를 겪었다고 말할 수 있습니다. 위에서 설명한 것 외에도 그 과정에서 여러 가지 경험을 했습니다. 그 경험은 다음과 같습니다.
Star
버전 카탈로그, 즉 toml을 통해 종속성을 구성하는 것은 이번이 처음 시도한 것이었습니다. 전반적으로 시작하기가 매우 쉬웠고 IDE의 지원도 꽤 괜찮았습니다. 동기화에 성공한 후 종속성 이름을 클릭하면 해당 toml 위치로 바로 이동하며, toml은 이름 변경 작업도 지원하므로 해당 build.gradle.kts 참조가 자동으로 변경됩니다. 해당 build.gradle.kts에 대한 참조가 자동으로 변경됩니다. 원래 이름으로 종속되어 있는 프로젝트도 toml 프로젝트로 변경하라는 메시지가 표시됩니다.
Bad
- 약간 누락된 자동 완성: 공통메인에서 전역으로 정의된 기대 클래스/객체를 참조할 때, 자동 완성 목록은 alt+Enter로 가져오지 않거나 가져올 수 없으므로 수동으로 모두 가져오거나 써야 하는데, 여전히 번거롭습니다!
- 미리보기 주석은 여러 플랫폼에서 작동하지 않으며, @Preview는 데스크톱에서는
androidx.compose.desktop.ui.tooling.preview.PreviewAndroid에서는androidx.compose.ui.tooling.preview.Preview. - 누락된 실제 선언을 추가하면 H에서 파일 이름 앞에 /가 붙는 버그가 있었는데, 삭제하지 않으면 결과 파일이 D 드라이브의 루트 디렉터리에 위치하게 되어 한동안 매우 혼란스러웠습니다. 다행히 H-Patch1에서 수정되었습니다.
| AS H | AS H-Patch1 |
|---|---|
- 레이아웃 인스펙터가 CMP를 지원하지 않는 것 같고 개별 컴포넌트를 표시할 수 없습니다.
Androi Studio H사용할 때 이 오류가 발생합니다.
kotlin {
compilerOptions {
freeCompilerArgs.add("-Xmulti-platform")
}
}
신고하지 마세요;
그런 다음 AS H-Patch 1 업그레이드하고 다시 노란색으로 레이블을 지정했습니다:
We didn't make it clear enough that expect/actual declarations are in Beta in pre-1.9.20 versions, because of that a lot of people used it in production code assuming that it's a stable feature. In 1.9.20, we want to fix it by introducing this warning
현재 예상/실제 클래스가 완벽하지 않고 때때로 복잡하며, 관계자들은 베타 기능이라는 점을 구체적으로 지적하여 사용하는 사람들이 이를 인지할 수 있도록 하려는 것입니다. 좋아요. 위의 지침에 따라 -Xexpect-actual-classes 추가하면 경고를 무시할 수 있습니다.
kotlin {
compilerOptions {
freeCompilerArgs.addAll("-Xmulti-platform", "-Xexpect-actual-classes")
}
}
- 예상/실제 클래스는 동시에 자동으로 이동하지 않습니다(예: CommonMain을 이동하는 경우 androidMain과 DesktopMain을 수동으로 이동해야 함).
Desktop Star의 알려진 문제
충돌 페이지
다음 페이지가 충돌합니다.
- TransProScreen
- 대화 번역 페이지
- 플러그인 페이지
오류를 다음과 같이 신고하세요:
java.lang.IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed. One of the common reasons is nesting layouts like LazyColumn and Column(Modifier.verticalScroll()). ...
이유는 모르겠지만 이 페이지들은 Android와 데스크톱에 대해 동일한 코드 세트를 공유하지만 Android 플랫폼에서는 잘 작동하고 데스크톱에서는 곧바로 충돌합니다. 더 이상한 점은 부모 레이아웃의 열은 스크롤할 수 없으며, LazyColumn의 높이를 제한해도(예: Modifier.height(400.dp)) ) 여전히 충돌이 발생한다는 것입니다. 데스크톱 작성에 문제가 있는 것 같지만 시간 제약으로 인해 아직 해결책을 찾지 못했습니다.
제대로 작동하지 않음
- 긴 번역 작업에 대한 메모를 변경할 수 없으며 해당 sqlite 코드가 올바르게 실행되지만 데이터베이스 내용은 변경되지 않습니다. SqlDelight에 문제가 있는 것일까요? 이슈를 제출했습니다: 참조하세요.
몇 가지 교훈
GPT는 매우 도움이 되었습니다. 원래 DAO에서 Sq 파일로 마이그레이션하고, build.gradle에서 build.gradle.kts로 마이그레이션하고, 데스크톱에서 많은 클래스를 구현하는 등 GPT에는 많은 작업이 필요합니다. 소스 코드를 제공하고 마이그레이션할 플랫폼을 알려주면 도움이 될 것입니다!
새로운 기술에 대해 인내심을 가져야 하며, 때로는 미치도록 혼란스러울 수 있으므로 조용히 시간을 갖고 천천히 다뤄야 합니다!
링크된 기사 중 많은 부분이 이미 설명되어 있으므로 반복하지 않겠습니다.
마지막으로 소스 코드를 다시 읽어보세요. 도움이 되었다면 Star를 환영합니다!



