Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Kotlin Multiplatform으로 Android/iOS/Desktop 번역기 만들기

Avatar for Pangmoo Pangmoo
December 09, 2023

Kotlin Multiplatform으로 Android/iOS/Desktop 번역기 만들기

Kotlin Multiplatform으로 Android/iOS/Desktop 번역기 만들기
2023 Devfest GDG Songdo x Incheon

Avatar for Pangmoo

Pangmoo

December 09, 2023
Tweet

More Decks by Pangmoo

Other Decks in Programming

Transcript

  1. 유광무 GDG Songdo Organizer GDSC TUK Lead ex 아우토크립트 안드로이드

    개발 팀장 Incheon/Songdo kisa002 kisa002 firebase holykisa
  2. TRANSER • Android/iOS/Desktop 번역 유틸리티 ◦ Kotlin/Compose Multiplatform으로 개발된 오픈소스

    ◦ 개발 기간 1주일 ▪ KotlinContest 출품작 ▪ 대회 일정으로 인한 짧은 개발 기간 • 이번 Devfest 발표를 위해 ◦ Kotlin/Compose 최신 버전 마이그레이션 ◦ iOS 플랫폼 지원 https://github.com/kisa002/transer Git Repository
  3. 프로젝트 구조 공통 로직 • 공통으로 사용할 로직 ◦ UI

    / API / DB / UseCase 등 • 정의된 로직들은 android, iOS, desktop 등 ◦ 다른 플랫폼에서 사용 가능
  4. 프로젝트 구조 플랫폼별 로직 • android, iOS, desktop 플랫폼별로 사용될

    모듈 • common 모듈의 공통 로직을 사용하면서 ◦ 각 플랫폼 기능을 구현
  5. 공통 UI 만들기 • Shared 모듈 commonMain 패키지 • 환경설정

    ◦ presentation/preferences 배치 ◦ Preferences Screen/ViewModel 관리 • 컴포넌트 ◦ 재사용되는 컴포넌트 ◦ 공용은 물론, 각 플랫폼에서 독자적으로 호출가능한 컴포넌트
  6. @Composable fun PreferencesScreen( modifier: Modifier, header: @Composable () -> Unit,

    supportedLanguages: List<Language>, selectedSourceLanguage: String, selectedTargetLanguage: String, onSelectedSourceLanguage: (Language) -> Unit, onSelectedTargetLanguage: (Language) -> Unit, onClickClearData: () -> Unit, onClickContact: () -> Unit, onNotifyVisibleSelect: (Boolean) -> Unit = {} ) { ... }
  7. @Composable fun PreferencesScreen( modifier: Modifier, header: @Composable () -> Unit,

    supportedLanguages: List<Language>, selectedSourceLanguage: String, selectedTargetLanguage: String, onSelectedSourceLanguage: (Language) -> Unit, onSelectedTargetLanguage: (Language) -> Unit, onClickClearData: () -> Unit, onClickContact: () -> Unit, onNotifyVisibleSelect: (Boolean) -> Unit = {} ) { ... } 상태는 아래로
  8. @Composable fun PreferencesScreen( modifier: Modifier, header: @Composable () -> Unit,

    supportedLanguages: List<Language>, selectedSourceLanguage: String, selectedTargetLanguage: String, onSelectedSourceLanguage: (Language) -> Unit, onSelectedTargetLanguage: (Language) -> Unit, onClickClearData: () -> Unit, onClickContact: () -> Unit, onNotifyVisibleSelect: (Boolean) -> Unit = {} ) { ... } 이벤트는 위로
  9. 공통 UI 만들기 환경설정 • 내부 UI 로직은 기존 Compose와

    100 동일 • 자세한 설명은 생략하겠습니다.
  10. 공통 모듈 만들기 API 통신 • API 라이브러리로 Ktor 채택

    ◦ Jetbrains에서 만든 Server/Client 프레임워크 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • Transer에서는 Serialization을 사용하였음
  11. HttpClient(CIO) { // Response Convert install(ContentNegotiation) { // Json, XML,

    CBOR, ProtoBuf, ... json( Json { prettyPrint = true isLenient = true coerceInputValues = true } ) } // For Logging install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL } }
  12. HttpClient(CIO) { // Response Convert install(ContentNegotiation) { // Json, XML,

    CBOR, ProtoBuf, ... json( Json { prettyPrint = true isLenient = true coerceInputValues = true } ) } // For Logging install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL } } Coroutine based Input/Output
  13. HttpClient(CIO) { // Response Convert install(ContentNegotiation) { // Json, XML,

    CBOR, ProtoBuf, ... json( Json { prettyPrint = true isLenient = true coerceInputValues = true } ) } // For Logging install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL } } Response 변환 설정
  14. HttpClient(CIO) { // Response Convert install(ContentNegotiation) { // Json, XML,

    CBOR, ProtoBuf, ... json( Json { prettyPrint = true isLenient = true coerceInputValues = true } ) } // For Logging install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL } } 로그 기능
  15. 공통 모듈 만들기 로컬 DB • 로컬 DB로 SQLDelight 채택

    ◦ CashApp에서 만든 오픈소스 라이브러리 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • SQLite, MySQL, PostgreSQL, HSQL 지원
  16. 공통 기능 만들기 로컬 DB • 로컬 DB로 SQLDelight 채택

    ◦ CashApp에서 만든 오픈소스 라이브러리 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • SQLite, MySQL, PostgreSQL, HSQL 지원 CREATE TABLE recentTranslate ( idx INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, originalText TEXT NOT NULL, translatedText TEXT NOT NULL ); selectAll: SELECT * FROM recentTranslate ORDER BY idx DESC; insert: INSERT INTO recentTranslate (originalText, translatedText) VALUES (?, ?); deleteByIdx: DELETE FROM recentTranslate WHERE idx = ?; deleteByTranslatedText: DELETE FROM recentTranslate WHERE translatedText = ?; deleteAll: DELETE FROM recentTranslate;
  17. 공통 기능 만들기 로컬 DB • 로컬 DB로 SQLDelight 채택

    ◦ CashApp에서 만든 오픈소스 라이브러리 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • SQLite, MySQL, PostgreSQL, HSQL 지원 CREATE TABLE recentTranslate ( idx INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, originalText TEXT NOT NULL, translatedText TEXT NOT NULL ); selectAll: SELECT * FROM recentTranslate ORDER BY idx DESC; insert: INSERT INTO recentTranslate (originalText, translatedText) VALUES (?, ?); deleteByIdx: DELETE FROM recentTranslate WHERE idx = ?; deleteByTranslatedText: DELETE FROM recentTranslate WHERE translatedText = ?; deleteAll: DELETE FROM recentTranslate;
  18. 공통 기능 만들기 로컬 DB • 로컬 DB로 SQLDelight 채택

    ◦ CashApp에서 만든 오픈소스 라이브러리 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • SQLite, MySQL, PostgreSQL, HSQL 지원 CREATE TABLE recentTranslate ( idx INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, originalText TEXT NOT NULL, translatedText TEXT NOT NULL ); selectAll: SELECT * FROM recentTranslate ORDER BY idx DESC; insert: INSERT INTO recentTranslate (originalText, translatedText) VALUES (?, ?); deleteByIdx: DELETE FROM recentTranslate WHERE idx = ?; deleteByTranslatedText: DELETE FROM recentTranslate WHERE translatedText = ?; deleteAll: DELETE FROM recentTranslate;
  19. 공통 기능 만들기 로컬 DB • 로컬 DB로 SQLDelight 채택

    ◦ CashApp에서 만든 오픈소스 라이브러리 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • SQLite, MySQL, PostgreSQL, HSQL 지원 CREATE TABLE recentTranslate ( idx INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, originalText TEXT NOT NULL, translatedText TEXT NOT NULL ); selectAll: SELECT * FROM recentTranslate ORDER BY idx DESC; insert: INSERT INTO recentTranslate (originalText, translatedText) VALUES (?, ?); deleteByIdx: DELETE FROM recentTranslate WHERE idx = ?; deleteByTranslatedText: DELETE FROM recentTranslate WHERE translatedText = ?; deleteAll: DELETE FROM recentTranslate;
  20. 공통 기능 만들기 로컬 DB • 로컬 DB로 SQLDelight 채택

    ◦ CashApp에서 만든 오픈소스 라이브러리 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • SQLite, MySQL, PostgreSQL, HSQL 지원 CREATE TABLE recentTranslate ( idx INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, originalText TEXT NOT NULL, translatedText TEXT NOT NULL ); selectAll: SELECT * FROM recentTranslate ORDER BY idx DESC; insert: INSERT INTO recentTranslate (originalText, translatedText) VALUES (?, ?); deleteByIdx: DELETE FROM recentTranslate WHERE idx = ?; deleteByTranslatedText: DELETE FROM recentTranslate WHERE translatedText = ?; deleteAll: DELETE FROM recentTranslate;
  21. 공통 기능 만들기 로컬 DB • 로컬 DB로 SQLDelight 채택

    ◦ CashApp에서 만든 오픈소스 라이브러리 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • SQLite, MySQL, PostgreSQL, HSQL 지원 CREATE TABLE recentTranslate ( idx INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, originalText TEXT NOT NULL, translatedText TEXT NOT NULL ); selectAll: SELECT * FROM recentTranslate ORDER BY idx DESC; insert: INSERT INTO recentTranslate (originalText, translatedText) VALUES (?, ?); deleteByIdx: DELETE FROM recentTranslate WHERE idx = ?; deleteByTranslatedText: DELETE FROM recentTranslate WHERE translatedText = ?; deleteAll: DELETE FROM recentTranslate;
  22. 공통 모듈 만들기 DI • DI 라이브러리로 Koin 채택 ◦

    Kotlin, Kotlin Multiplatform DI 프레임워크 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • Kotlin 2.0 Beta 지원
  23. 공통 모듈 만들기 DI • module ◦ Koin 모듈 생성

    • single ◦ 한 번만 생성 • factory ◦ 호출마다 생성 • viewModel ◦ Android ViewModel • get ◦ 의존성 주입 • singleOf, factoryOf, viewModelOf ◦ Constructor DSL ◦ get 생략 가능 • bind ◦ 정의에 대한 바인딩
  24. val commonApiModule = module { single<HttpClient> { HttpClient(CIO) { install(ContentNegotiation)

    { json( Json { prettyPrint = true isLenient = true coerceInputValues = true } ) } install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL } } } }
  25. val commonApiModule = module { single<HttpClient> { HttpClient(CIO) { install(ContentNegotiation)

    { json( Json { prettyPrint = true isLenient = true coerceInputValues = true } ) } install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL } } } } DI 모듈 생성
  26. val commonApiModule = module { single<HttpClient> { HttpClient(CIO) { install(ContentNegotiation)

    { json( Json { prettyPrint = true isLenient = true coerceInputValues = true } ) } install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL } } } } HttpClient 싱글톤 정의
  27. val commonApiModule = module { single<HttpClient> { HttpClient(CIO) { install(ContentNegotiation)

    { json( Json { prettyPrint = true isLenient = true coerceInputValues = true } ) } install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL } } } } HttpClient 주입할 객체 API 모듈
  28. 공통 기능 만들기 로컬 DB • 플랫폼별 데이터베이스 생성 필요

    fun startKoin(context: Context) = org.koin.core.context.startKoin { androidLogger() androidContext(context) modules(commonApiModule, commonDataModule, coroutineScopesModule, mobileModule) } fun startKoin() = startKoin { modules(commonApiModule, commonDataModule, coroutineScopesModule, desktopModule) } Android Desktop
  29. 공통 기능 만들기 로컬 DB • 플랫폼별 데이터베이스 생성 필요

    fun startKoin(context: Context) = org.koin.core.context.startKoin { androidLogger() androidContext(context) modules(commonApiModule, commonDataModule, coroutineScopesModule, mobileModule) } fun startKoin() = startKoin { modules(commonApiModule, commonDataModule, coroutineScopesModule, desktopModule) } Android Desktop 공통 모듈 주입
  30. 공통 기능 만들기 로컬 DB • 플랫폼별 데이터베이스 생성 필요

    fun startKoin(context: Context) = org.koin.core.context.startKoin { androidLogger() androidContext(context) modules(commonApiModule, commonDataModule, coroutineScopesModule, mobileModule) } fun startKoin() = startKoin { modules(commonApiModule, commonDataModule, coroutineScopesModule, desktopModule) } Android Desktop 플랫폼별 모듈 주입
  31. KMP 개발을 위한 알아두면 좋은 라이브러리 소개 / DI 프레임워크

    찍먹하기 세션에서 만나뵐 수 있습니다.
  32. 공통 기능 만들기 • 번역 • 최근 번역 • 저장된

    번역 • 환경설정 • 초기화 시간관계 상 번역과 환경설정만 이야기
  33. 공통 기능 만들기 환경설정 • 로컬 DB SQLDelight • 아키텍처

    ◦ DataSource/Repository/UseCase • ViewModel • Expect/Actual • Desktop/Android/iOS 플랫폼별 처리
  34. // Preferences.sq CREATE TABLE preferences ( id INTEGER NOT NULL

    PRIMARY KEY DEFAULT 0, sourceLanguage TEXT NOT NULL, sourceName TEXT NOT NULL, targetLanguage TEXT NOT NULL, targetName TEXT NOT NULL ); select: SELECT sourceLanguage, sourceName, targetLanguage, targetName FROM preferences; set: REPLACE INTO preferences (id, sourceLanguage, sourceName, targetLanguage, targetName) VALUES (0, ?, ?, ?, ?);
  35. class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun

    getPreferences(): Flow<Preferences?> = database.preferencesQueries.select().asFlow().mapToOneOrNull().map { it?.let { Preferences(Language(it.sourceLanguage, it.sourceName), Language(it.targetLanguage, it.targetName)) } } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) { database.preferencesQueries.set( sourceLanguage = sourceLanguage.language, sourceName = sourceLanguage.name, targetLanguage = targetLanguage.language, targetName = targetLanguage.name ) } }
  36. class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun

    getPreferences(): Flow<Preferences?> = database.preferencesQueries.select().asFlow().mapToOneOrNull().map { it?.let { Preferences(Language(it.sourceLanguage, it.sourceName), Language(it.targetLanguage, it.targetName)) } } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) { database.preferencesQueries.set( sourceLanguage = sourceLanguage.language, sourceName = sourceLanguage.name, targetLanguage = targetLanguage.language, targetName = targetLanguage.name ) } }
  37. class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun

    getPreferences(): Flow<Preferences?> = database.preferencesQueries.select().asFlow().mapToOneOrNull().map { it?.let { Preferences(Language(it.sourceLanguage, it.sourceName), Language(it.targetLanguage, it.targetName)) } } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) { database.preferencesQueries.set( sourceLanguage = sourceLanguage.language, sourceName = sourceLanguage.name, targetLanguage = targetLanguage.language, targetName = targetLanguage.name ) } } SQLDelight Database
  38. class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun

    getPreferences(): Flow<Preferences?> = database.preferencesQueries.select().asFlow().mapToOneOrNull().map { it?.let { Preferences(Language(it.sourceLanguage, it.sourceName), Language(it.targetLanguage, it.targetName)) } } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) { database.preferencesQueries.set( sourceLanguage = sourceLanguage.language, sourceName = sourceLanguage.name, targetLanguage = targetLanguage.language, targetName = targetLanguage.name ) } }
  39. class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun

    getPreferences(): Flow<Preferences?> = database.preferencesQueries.select().asFlow().mapToOneOrNull().map { it?.let { Preferences(Language(it.sourceLanguage, it.sourceName), Language(it.targetLanguage, it.targetName)) } } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) { database.preferencesQueries.set( sourceLanguage = sourceLanguage.language, sourceName = sourceLanguage.name, targetLanguage = targetLanguage.language, targetName = targetLanguage.name ) } } Preferences.sq 파일에 정의한 select Query 빌드 시 자동 생성
  40. class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun

    getPreferences(): Flow<Preferences?> = database.preferencesQueries.select().asFlow().mapToOneOrNull().map { it?.let { Preferences(Language(it.sourceLanguage, it.sourceName), Language(it.targetLanguage, it.targetName)) } } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) { database.preferencesQueries.set( sourceLanguage = sourceLanguage.language, sourceName = sourceLanguage.name, targetLanguage = targetLanguage.language, targetName = targetLanguage.name ) } } SQLDelight에서 제공하는 Extension
  41. class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun

    getPreferences(): Flow<Preferences?> = database.preferencesQueries.select().asFlow().mapToOneOrNull().map { it?.let { Preferences(Language(it.sourceLanguage, it.sourceName), Language(it.targetLanguage, it.targetName)) } } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) { database.preferencesQueries.set( sourceLanguage = sourceLanguage.language, sourceName = sourceLanguage.name, targetLanguage = targetLanguage.language, targetName = targetLanguage.name ) } } public data class Select( public val sourceLanguage: String, public val sourceName: String, public val targetLanguage: String, public val targetName: String ) { public override fun toString(): String = """ |Select [ | sourceLanguage: $sourceLanguage | sourceName: $sourceName | targetLanguage: $targetLanguage | targetName: $targetName |] """.trimMargin() } DB에서 가져오는 데이터클래스 원형 빌드 시 자동 생성
  42. class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun

    getPreferences(): Flow<Preferences?> = database.preferencesQueries.select().asFlow().mapToOneOrNull().map { it?.let { Preferences(Language(it.sourceLanguage, it.sourceName), Language(it.targetLanguage, it.targetName)) } } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) { database.preferencesQueries.set( sourceLanguage = sourceLanguage.language, sourceName = sourceLanguage.name, targetLanguage = targetLanguage.language, targetName = targetLanguage.name ) } } DB에서 가져온 정보를 사용할 데이터클래스로 변환
  43. class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun

    getPreferences(): Flow<Preferences?> = database.preferencesQueries.select().asFlow().mapToOneOrNull().map { it?.let { Preferences(Language(it.sourceLanguage, it.sourceName), Language(it.targetLanguage, it.targetName)) } } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) { database.preferencesQueries.set( sourceLanguage = sourceLanguage.language, sourceName = sourceLanguage.name, targetLanguage = targetLanguage.language, targetName = targetLanguage.name ) } }
  44. class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun

    getPreferences(): Flow<Preferences?> = database.preferencesQueries.select().asFlow().mapToOneOrNull().map { it?.let { Preferences(Language(it.sourceLanguage, it.sourceName), Language(it.targetLanguage, it.targetName)) } } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) { database.preferencesQueries.set( sourceLanguage = sourceLanguage.language, sourceName = sourceLanguage.name, targetLanguage = targetLanguage.language, targetName = targetLanguage.name ) } } 동일하게 sq파일 기반 자동 생성되는 함수
  45. class PreferencesRepositoryImpl(private val preferencesDataSource: PreferencesDataSource) : PreferencesRepository { override fun

    getPreferences(): Flow<Preferences?> = preferencesDataSource.getPreferences().map { it?.toDomain() } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) = preferencesDataSource.setPreferences( sourceLanguage = com.haeyum.shared.data.model.languages.Language( sourceLanguage.language, sourceLanguage.name ), targetLanguage = com.haeyum.shared.data.model.languages.Language( targetLanguage.language, targetLanguage.name ) ) }
  46. class PreferencesRepositoryImpl(private val preferencesDataSource: PreferencesDataSource) : PreferencesRepository { override fun

    getPreferences(): Flow<Preferences?> = preferencesDataSource.getPreferences().map { it?.toDomain() } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) = preferencesDataSource.setPreferences( sourceLanguage = com.haeyum.shared.data.model.languages.Language( sourceLanguage.language, sourceLanguage.name ), targetLanguage = com.haeyum.shared.data.model.languages.Language( targetLanguage.language, targetLanguage.name ) ) }
  47. class PreferencesRepositoryImpl(private val preferencesDataSource: PreferencesDataSource) : PreferencesRepository { override fun

    getPreferences(): Flow<Preferences?> = preferencesDataSource.getPreferences().map { it?.toDomain() } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) = preferencesDataSource.setPreferences( sourceLanguage = com.haeyum.shared.data.model.languages.Language( sourceLanguage.language, sourceLanguage.name ), targetLanguage = com.haeyum.shared.data.model.languages.Language( targetLanguage.language, targetLanguage.name ) ) }
  48. class PreferencesRepositoryImpl(private val preferencesDataSource: PreferencesDataSource) : PreferencesRepository { override fun

    getPreferences(): Flow<Preferences?> = preferencesDataSource.getPreferences().map { it?.toDomain() } override suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) = preferencesDataSource.setPreferences( sourceLanguage = com.haeyum.shared.data.model.languages.Language( sourceLanguage.language, sourceLanguage.name ), targetLanguage = com.haeyum.shared.data.model.languages.Language( targetLanguage.language, targetLanguage.name ) ) }
  49. class SetPreferencesUseCase(private val preferencesRepository: PreferencesRepository) { suspend operator fun invoke(

    sourceLanguage: Language, targetLanguage: Language, ) = preferencesRepository.setPreferences( sourceLanguage = sourceLanguage, targetLanguage = targetLanguage, ) }
  50. class PreferencesViewModel( private val getSupportedLanguagesUseCase: GetSupportedLanguagesUseCase, private val getPreferencesUseCase: GetPreferencesUseCase,

    private val setPreferencesUseCase: SetPreferencesUseCase, private val clearDataUseCase: ClearDataUseCase ) : { ... } ViewModel() commonMain 모듈
  51. class PreferencesViewModel( private val getSupportedLanguagesUseCase: GetSupportedLanguagesUseCase, private val getPreferencesUseCase: GetPreferencesUseCase,

    private val setPreferencesUseCase: SetPreferencesUseCase, private val clearDataUseCase: ClearDataUseCase ) : { ... } ViewModel() 문제가 있는 코드이지만 우선 SKIP
  52. class PreferencesViewModel(...) : ViewModel() { val preferences = getPreferencesUseCase().shareIn( scope

    = viewModelScope, started = SharingStarted.Eagerly, replay = 1 ) val selectedSourceLanguage = preferences.filterNotNull().map { preferences -> preferences.sourceLanguage }.stateIn(scope = viewModelScope, started = SharingStarted.Eagerly, null) val selectedTargetLanguage = preferences.filterNotNull().map { preferences -> preferences.targetLanguage }.stateIn(scope = viewModelScope, started = SharingStarted.Eagerly, null) }
  53. class PreferencesViewModel(...) : ViewModel() { val preferences = getPreferencesUseCase().shareIn( scope

    = viewModelScope, started = SharingStarted.Eagerly, replay = 1 ) val selectedSourceLanguage = preferences.filterNotNull().map { preferences -> preferences.sourceLanguage }.stateIn(scope = viewModelScope, started = SharingStarted.Eagerly, null) val selectedTargetLanguage = preferences.filterNotNull().map { preferences -> preferences.targetLanguage }.stateIn(scope = viewModelScope, started = SharingStarted.Eagerly, null) }
  54. class PreferencesViewModel(...) : ViewModel() { val preferences = getPreferencesUseCase().shareIn( scope

    = viewModelScope, started = SharingStarted.Eagerly, replay = 1 ) val selectedSourceLanguage = preferences.filterNotNull().map { preferences -> preferences.sourceLanguage }.stateIn(scope = viewModelScope, started = SharingStarted.Eagerly, null) val selectedTargetLanguage = preferences.filterNotNull().map { preferences -> preferences.targetLanguage }.stateIn(scope = viewModelScope, started = SharingStarted.Eagerly, null) }
  55. class PreferencesViewModel(...) : ViewModel() { val preferences = getPreferencesUseCase().shareIn( scope

    = viewModelScope, started = SharingStarted.Eagerly, replay = 1 ) val selectedSourceLanguage = preferences.filterNotNull().map { preferences -> preferences.sourceLanguage }.stateIn(scope = viewModelScope, started = SharingStarted.Eagerly, null) val selectedTargetLanguage = preferences.filterNotNull().map { preferences -> preferences.targetLanguage }.stateIn(scope = viewModelScope, started = SharingStarted.Eagerly, null) }
  56. class PreferencesViewModel(...) : ViewModel() { fun setSelectedSourceLanguage(language: Language) { coroutineScope.launch

    { selectedTargetLanguage.value?.let { targetLanguage -> setPreferencesUseCase(sourceLanguage = language, targetLanguage = targetLanguage) } } } fun setSelectedTargetLanguage(language: Language) { coroutineScope.launch { selectedSourceLanguage.value?.let { sourceLanguage -> setPreferencesUseCase(sourceLanguage = sourceLanguage, targetLanguage = language) } } } }
  57. class PreferencesViewModel(...) : ViewModel() { fun setSelectedSourceLanguage(language: Language) { coroutineScope.launch

    { selectedTargetLanguage.value?.let { targetLanguage -> setPreferencesUseCase(sourceLanguage = language, targetLanguage = targetLanguage) } } } fun setSelectedTargetLanguage(language: Language) { coroutineScope.launch { selectedSourceLanguage.value?.let { sourceLanguage -> setPreferencesUseCase(sourceLanguage = sourceLanguage, targetLanguage = language) } } } }
  58. class PreferencesViewModel( private val getSupportedLanguagesUseCase: GetSupportedLanguagesUseCase, private val getPreferencesUseCase: GetPreferencesUseCase,

    private val setPreferencesUseCase: SetPreferencesUseCase, private val clearDataUseCase: ClearDataUseCase ) : { ... } ViewModel은 안드로이드 의존이기에 공통 모듈에서 사용 불가능 ViewModel()
  59. expect open class BaseViewModel() { val viewModelScope: CoroutineScope protected fun

    onCleared() } commonMain/BaseViewModel actual open class BaseViewModel actual constructor() { actual val viewModelScope: CoroutineScope = CoroutineScope(Dispatchers.Default) actual fun onCleared() { viewModelScope.cancel() } } desktopMain/BaseViewModel actual open class BaseViewModel : ViewModel() { actual val viewModelScope get() = (this as ViewModel).viewModelScope actual override fun onCleared() { super.onCleared() } } androidMain/BaseViewModel
  60. expect open class BaseViewModel() { val viewModelScope: CoroutineScope protected fun

    onCleared() } commonMain/BaseViewModel actual open class BaseViewModel actual constructor() { actual val viewModelScope: CoroutineScope = CoroutineScope(Dispatchers.Default) actual fun onCleared() { viewModelScope.cancel() } } desktopMain/BaseViewModel actual open class BaseViewModel : ViewModel() { actual val viewModelScope get() = (this as ViewModel).viewModelScope actual override fun onCleared() { super.onCleared() } } androidMain/BaseViewModel Expect로 선언한 클래스/함수/객체 등은 각 플랫폼 Android/iOS/Desktop 에서 Actual을 통해 구현됨
  61. @Composable fun AndroidPreferencesScreen( modifier: Modifier = Modifier, viewModel: PreferencesViewModel =

    koinViewModel() ) { val context = LocalContext.current PreferencesScreen( modifier = modifier.background(color = White), header = { Header(title = "Preferences") }, supportedLanguages = viewModel.supportedLanguages.collectAsState().value, selectedSourceLanguage = viewModel.selectedSourceLanguage.collectAsState().value?.name ?: "-", selectedTargetLanguage = viewModel.selectedTargetLanguage.collectAsState().value?.name ?: "-", onSelectedSourceLanguage = viewModel::setSelectedSourceLanguage, onSelectedTargetLanguage = viewModel::setSelectedTargetLanguage, onClickClearData = viewModel::clearData, onClickContact = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:[email protected]"))) } ) }
  62. @Composable fun AndroidPreferencesScreen( modifier: Modifier = Modifier, viewModel: PreferencesViewModel =

    koinViewModel() ) { val context = LocalContext.current PreferencesScreen( modifier = modifier.background(color = White), header = { Header(title = "Preferences") }, supportedLanguages = viewModel.supportedLanguages.collectAsState().value, selectedSourceLanguage = viewModel.selectedSourceLanguage.collectAsState().value?.name ?: "-", selectedTargetLanguage = viewModel.selectedTargetLanguage.collectAsState().value?.name ?: "-", onSelectedSourceLanguage = viewModel::setSelectedSourceLanguage, onSelectedTargetLanguage = viewModel::setSelectedTargetLanguage, onClickClearData = viewModel::clearData, onClickContact = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:[email protected]"))) } ) } koinViewModel을 통한 ViewModel 주입
  63. @Composable fun AndroidPreferencesScreen( modifier: Modifier = Modifier, viewModel: PreferencesViewModel =

    koinViewModel() ) { val context = LocalContext.current PreferencesScreen( modifier = modifier.background(color = White), header = { Header(title = "Preferences") }, supportedLanguages = viewModel.supportedLanguages.collectAsState().value, selectedSourceLanguage = viewModel.selectedSourceLanguage.collectAsState().value?.name ?: "-", selectedTargetLanguage = viewModel.selectedTargetLanguage.collectAsState().value?.name ?: "-", onSelectedSourceLanguage = viewModel::setSelectedSourceLanguage, onSelectedTargetLanguage = viewModel::setSelectedTargetLanguage, onClickClearData = viewModel::clearData, onClickContact = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:[email protected]"))) } ) } 플랫폼에 맞는 자유로운 커스텀
  64. @Composable fun AndroidPreferencesScreen( modifier: Modifier = Modifier, viewModel: PreferencesViewModel =

    koinViewModel() ) { val context = LocalContext.current PreferencesScreen( modifier = modifier.background(color = White), header = { Header(title = "Preferences") }, supportedLanguages = viewModel.supportedLanguages.collectAsState().value, selectedSourceLanguage = viewModel.selectedSourceLanguage.collectAsState().value?.name ?: "-", selectedTargetLanguage = viewModel.selectedTargetLanguage.collectAsState().value?.name ?: "-", onSelectedSourceLanguage = viewModel::setSelectedSourceLanguage, onSelectedTargetLanguage = viewModel::setSelectedTargetLanguage, onClickClearData = viewModel::clearData, onClickContact = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:[email protected]"))) } ) } ViewModel 상태 받아오기
  65. @Composable fun AndroidPreferencesScreen( modifier: Modifier = Modifier, viewModel: PreferencesViewModel =

    koinViewModel() ) { val context = LocalContext.current PreferencesScreen( modifier = modifier.background(color = White), header = { Header(title = "Preferences") }, supportedLanguages = viewModel.supportedLanguages.collectAsState().value, selectedSourceLanguage = viewModel.selectedSourceLanguage.collectAsState().value?.name ?: "-", selectedTargetLanguage = viewModel.selectedTargetLanguage.collectAsState().value?.name ?: "-", onSelectedSourceLanguage = viewModel::setSelectedSourceLanguage, onSelectedTargetLanguage = viewModel::setSelectedTargetLanguage, onClickClearData = viewModel::clearData, onClickContact = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:[email protected]"))) } ) } ViewModel 이벤트 전달
  66. @Composable fun AndroidPreferencesScreen( modifier: Modifier = Modifier, viewModel: PreferencesViewModel =

    koinViewModel() ) { val context = LocalContext.current PreferencesScreen( modifier = modifier.background(color = White), header = { Header(title = "Preferences") }, supportedLanguages = viewModel.supportedLanguages.collectAsState().value, selectedSourceLanguage = viewModel.selectedSourceLanguage.collectAsState().value?.name ?: "-", selectedTargetLanguage = viewModel.selectedTargetLanguage.collectAsState().value?.name ?: "-", onSelectedSourceLanguage = viewModel::setSelectedSourceLanguage, onSelectedTargetLanguage = viewModel::setSelectedTargetLanguage, onClickClearData = viewModel::clearData, onClickContact = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("mailto:[email protected]"))) } ) } startActivity를 사용하여 메일 발송창 열기
  67. iOS

  68. @Composable fun iOSPreferencesScreen(modifier: Modifier = Modifier) { val viewModel by

    produceState( initialValue = DIHelper().preferencesViewModel, ) { awaitDispose { value.onCleared() } } Box(modifier = modifier) { PreferencesScreen( header = { Header(title = "Preferences") }, ... onClickContact = { NSURL.URLWithString("mailto:[email protected]") ?.let(UIApplication.sharedApplication::openURL) ?: run(::println) }, ) } }
  69. @Composable fun iOSPreferencesScreen(modifier: Modifier = Modifier) { val viewModel by

    produceState( initialValue = DIHelper().preferencesViewModel, ) { awaitDispose { value.onCleared() } } Box(modifier = modifier) { PreferencesScreen( header = { Header(title = "Preferences") }, ... onClickContact = { NSURL.URLWithString("mailto:[email protected]") ?.let(UIApplication.sharedApplication::openURL) ?: run(::println) }, ) } } Koin을 통한 ViewModel 주입
  70. @Composable fun iOSPreferencesScreen(modifier: Modifier = Modifier) { val viewModel by

    produceState( initialValue = DIHelper().preferencesViewModel, ) { awaitDispose { value.onCleared() } } Box(modifier = modifier) { PreferencesScreen( header = { Header(title = "Preferences") }, ... onClickContact = { NSURL.URLWithString("mailto:[email protected]") ?.let(UIApplication.sharedApplication::openURL) ?: run(::println) }, ) } }
  71. @Composable fun iOSPreferencesScreen(modifier: Modifier = Modifier) { val viewModel by

    produceState( initialValue = DIHelper().preferencesViewModel, ) { awaitDispose { value.onCleared() } } Box(modifier = modifier) { PreferencesScreen( header = { Header(title = "Preferences") }, ... onClickContact = { NSURL.URLWithString("mailto:[email protected]") ?.let(UIApplication.sharedApplication::openURL) ?: run(::println) }, ) } } Android 동일 인자값 동일하게 커스텀 필요 없는 경우 Screen ViewModel 종속
  72. @Composable fun iOSPreferencesScreen(modifier: Modifier = Modifier) { val viewModel by

    produceState( initialValue = DIHelper().preferencesViewModel, ) { awaitDispose { value.onCleared() } } Box(modifier = modifier) { PreferencesScreen( header = { Header(title = "Preferences") }, ... onClickContact = { NSURL.URLWithString("mailto:[email protected]") ?.let(UIApplication.sharedApplication::openURL) ?: run(::println) }, ) } } 코틀린으로 Foundation Framework의 NSURL 사용
  73. @Composable fun PreferencesWindow( visible: Boolean, onChangeVisibleRequest: (Boolean) -> Unit, windowState:

    WindowState = rememberWindowState( position = WindowPosition(BiasAlignment(0f, -.3f)), size = DpSize(width = 400.dp, height = 560.dp) ) ) { ... }
  74. @Composable fun PreferencesWindow( visible: Boolean, onChangeVisibleRequest: (Boolean) -> Unit, windowState:

    WindowState = rememberWindowState( position = WindowPosition(BiasAlignment(0f, -.3f)), size = DpSize(width = 400.dp, height = 560.dp) ) ) { ... } 생성되는 위치 및 크기 등 윈도우 상태
  75. @Composable fun PreferencesWindow( visible: Boolean, onChangeVisibleRequest: (Boolean) -> Unit, windowState:

    WindowState = rememberWindowState( position = WindowPosition(BiasAlignment(0f, -.3f)), size = DpSize(width = 400.dp, height = 560.dp) ) ) { if (visible) { Window( onCloseRequest = { onChangeVisibleRequest(false) }, state = windowState, title = "Preferences", undecorated = true, transparent = true, resizable = false ) { ... } } } visible에 따라 창 표시/미표시
  76. @Composable fun PreferencesWindow( visible: Boolean, onChangeVisibleRequest: (Boolean) -> Unit, windowState:

    WindowState = rememberWindowState( position = WindowPosition(BiasAlignment(0f, -.3f)), size = DpSize(width = 400.dp, height = 560.dp) ) ) { if (visible) { Window( onCloseRequest = { onChangeVisibleRequest(false) }, state = windowState, title = "Preferences", undecorated = true, transparent = true, resizable = false ) { ... } } }
  77. @Composable fun PreferencesWindow( visible: Boolean, onChangeVisibleRequest: (Boolean) -> Unit, windowState:

    WindowState = rememberWindowState( position = WindowPosition(BiasAlignment(0f, -.3f)), size = DpSize(width = 400.dp, height = 560.dp) ) ) { if (visible) { Window( onCloseRequest = { onChangeVisibleRequest(false) }, state = windowState, title = "Preferences", undecorated = true, transparent = true, resizable = false ) { ... } } }
  78. @Composable fun PreferencesWindow( visible: Boolean, onChangeVisibleRequest: (Boolean) -> Unit, windowState:

    WindowState = rememberWindowState( position = WindowPosition(BiasAlignment(0f, -.3f)), size = DpSize(width = 400.dp, height = 560.dp) ) ) { if (visible) { Window( onCloseRequest = { onChangeVisibleRequest(false) }, state = windowState, title = "Preferences", undecorated = true, transparent = true, resizable = false ) { ... } } } , // 제목 // 기본 스타일 해제 // 투명 // 창 크기 조절 윈도우 창 속성 설정
  79. if (visible) { Window(...) { val preferencesViewModel by remember {

    KoinJavaComponent.inject<PreferencesViewModel>(PreferencesViewModel::class.java) } PreferencesScreen( ... ) if (CurrentPlatform.isMac) { ... } DisposableEffect(Unit) { onDispose { preferencesViewModel.onCleared() } } }
  80. if (visible) { Window(...) { val preferencesViewModel by remember {

    KoinJavaComponent.inject<PreferencesViewModel>(PreferencesViewModel::class.java) } PreferencesScreen( ... ) if (CurrentPlatform.isMac) { ... } DisposableEffect(Unit) { onDispose { preferencesViewModel.onCleared() } } } Koin Inject를 통한 ViewModel 생성
  81. if (visible) { Window(...) { val preferencesViewModel by remember {

    KoinJavaComponent.inject<PreferencesViewModel>(PreferencesViewModel::class.java) } PreferencesScreen( ... ) if (CurrentPlatform.isMac) { ... } DisposableEffect(Unit) { onDispose { preferencesViewModel.onCleared() } } } 사라질 때 ViewModel 초기화
  82. if (visible) { Window(...) { val preferencesViewModel by remember {

    KoinJavaComponent.inject<PreferencesViewModel>(PreferencesViewModel::class.java) } PreferencesScreen( ... ) if (CurrentPlatform.isMac) { ... } DisposableEffect(Unit) { onDispose { preferencesViewModel.onCleared() } } } Desktop에서는 Configuration Changes가 존재하지 않습니다.
  83. if (visible) { Window(...) { val preferencesViewModel by remember {

    KoinJavaComponent.inject<PreferencesViewModel>(PreferencesViewModel::class.java) } PreferencesScreen( ... ) if (CurrentPlatform.isMac) { ... } DisposableEffect(Unit) { onDispose { preferencesViewModel.onCleared() } } } 기본적인 ViewModel을 사용하는 예시이며, 안정적으로 사용을 원한다면 Decompose, moko mvvm 등 다른 방안이 있습니다. moko mvvm은 Android/iOS 타겟
  84. PreferencesScreen( modifier = ..., header = { Header( title =

    "Preferences", imageVector = Icons.Default.Close, onClick = { onChangeVisibleRequest(false) } ) }, supportedLanguages = preferencesViewModel.supportedLanguages.collectAsState().value, selectedSourceLanguage = preferencesViewModel.selectedSourceLanguage.collectAsState().value?.name ?: "-", selectedTargetLanguage = preferencesViewModel.selectedTargetLanguage.collectAsState().value?.name ?: "-", onSelectedSourceLanguage = preferencesViewModel::setSelectedSourceLanguage, onSelectedTargetLanguage = preferencesViewModel::setSelectedTargetLanguage, onClickClearData = preferencesViewModel::clearData, onClickContact = { Desktop.getDesktop().browse(URI("mailto:[email protected]")) } )
  85. PreferencesScreen( modifier = ..., header = { Header( title =

    "Preferences", imageVector = Icons.Default.Close, onClick = { onChangeVisibleRequest(false) } ) }, supportedLanguages = preferencesViewModel.supportedLanguages.collectAsState().value, selectedSourceLanguage = preferencesViewModel.selectedSourceLanguage.collectAsState().value?.name ?: "-", selectedTargetLanguage = preferencesViewModel.selectedTargetLanguage.collectAsState().value?.name ?: "-", onSelectedSourceLanguage = preferencesViewModel::setSelectedSourceLanguage, onSelectedTargetLanguage = preferencesViewModel::setSelectedTargetLanguage, onClickClearData = preferencesViewModel::clearData, onClickContact = { Desktop.getDesktop().browse(URI("mailto:[email protected]")) } ) 공통 Header 컴포넌트 커스텀
  86. PreferencesScreen( modifier = ..., header = { Header( title =

    "Preferences", imageVector = Icons.Default.Close, onClick = { onChangeVisibleRequest(false) } ) }, supportedLanguages = preferencesViewModel.supportedLanguages.collectAsState().value, selectedSourceLanguage = preferencesViewModel.selectedSourceLanguage.collectAsState().value?.name ?: "-", selectedTargetLanguage = preferencesViewModel.selectedTargetLanguage.collectAsState().value?.name ?: "-", onSelectedSourceLanguage = preferencesViewModel::setSelectedSourceLanguage, onSelectedTargetLanguage = preferencesViewModel::setSelectedTargetLanguage, onClickClearData = preferencesViewModel::clearData, onClickContact = { Desktop.getDesktop().browse(URI("mailto:[email protected]")) } ) 기존 Android/iOS 로직과 동일 Screen이 ViewModel을 받는다면 재구현이 필요 없음
  87. PreferencesScreen( modifier = ..., header = { Header( title =

    "Preferences", imageVector = Icons.Default.Close, onClick = { onChangeVisibleRequest(false) } ) }, supportedLanguages = preferencesViewModel.supportedLanguages.collectAsState().value, selectedSourceLanguage = preferencesViewModel.selectedSourceLanguage.collectAsState().value?.name ?: "-", selectedTargetLanguage = preferencesViewModel.selectedTargetLanguage.collectAsState().value?.name ?: "-", onSelectedSourceLanguage = preferencesViewModel::setSelectedSourceLanguage, onSelectedTargetLanguage = preferencesViewModel::setSelectedTargetLanguage, onClickClearData = preferencesViewModel::clearData, onClickContact = { Desktop.getDesktop().browse(URI("mailto:[email protected]")) } ) Desktop API 활용한 메일 발송창 열기
  88. if (visible) { Window(...) { val preferencesViewModel by remember {

    KoinJavaComponent.inject<PreferencesViewModel>(PreferencesViewModel::class.java) } PreferencesScreen( ... ) if (CurrentPlatform.isMac) { ... } DisposableEffect(Unit) { onDispose { preferencesViewModel.onCleared() } } }
  89. if (visible) { Window(...) { val preferencesViewModel by remember {

    KoinJavaComponent.inject<PreferencesViewModel>(PreferencesViewModel::class.java) } PreferencesScreen( ... ) if (CurrentPlatform.isMac) { ... } DisposableEffect(Unit) { onDispose { preferencesViewModel.onCleared() } } }
  90. if (visible) { Window(...) { PreferencesScreen( ... ) if (CurrentPlatform.isMac)

    { MenuBar { Menu("Window") { Item( text = "Close", onClick = { onChangeVisibleRequest(false) }, shortcut = KeyShortcut(Key.W, meta = true) ) } } } }
  91. if (visible) { Window(...) { PreferencesScreen( ... ) if (CurrentPlatform.isMac)

    { MenuBar { Menu("Window") { Item( text = "Close", onClick = { onChangeVisibleRequest(false) }, shortcut = KeyShortcut(Key.W, meta = true) ) } } } } 현재 플랫폼이 Mac인 경우에만 메뉴바 생성
  92. if (visible) { Window(...) { PreferencesScreen( ... ) if (CurrentPlatform.isMac)

    { MenuBar { Menu("Window") { Item( text = "Close", onClick = { onChangeVisibleRequest(false) }, shortcut = KeyShortcut(Key.W, meta = true) ) } } } } object CurrentPlatform { val isMac get() = System.getProperty("os.name").lowercase().contains("mac") val isWindows get() = System.getProperty("os.name").lowercase().contains("windows") } CurrentPlatform.kt
  93. if (visible) { Window(...) { PreferencesScreen( ... ) if (CurrentPlatform.isMac)

    { MenuBar { Menu("Window") { Item( text = "Close", onClick = { onChangeVisibleRequest(false) }, shortcut = KeyShortcut(Key.W, meta = true) ) } } } }
  94. if (visible) { Window(...) { PreferencesScreen( ... ) if (CurrentPlatform.isMac)

    { MenuBar { Menu("Window") { Item( text = "Close", onClick = { onChangeVisibleRequest(false) }, shortcut = KeyShortcut(Key.W, meta = true) ) } } } } 단축키 지정
  95. fun main() { DesktopKoin.startKoin() val viewModel by inject<MainViewModel>(MainViewModel::class.java) application {

    TranserTheme { ~~~ PreferencesWindow( visible = visiblePreferencesWindow, onChangeVisibleRequest = viewModel::setVisiblePreferencesWindow ) ~~~ } } } Desktop 모듈 Main.kt
  96. class TranslationDataSourceImpl(private val client: HttpClient) : TranslationDataSource { override suspend

    fun translate(q: String, target: String, source: String): TranslateResponse = client.get(ApiInfo.GOOGLE_TRANSLATE_API_URL) { parameter("q", q) parameter("source", source) parameter("target", target) parameter("key", ApiInfo.GOOGLE_TRANSLATE_API_KEY) }.body() override suspend fun detectLanguage(q: String): DetectionsResponse = client.get(ApiInfo.GOOGLE_TRANSLATE_API_DETECT_URL) { parameter("q", q) parameter("key", ApiInfo.GOOGLE_TRANSLATE_API_KEY) }.body() override suspend fun getLanguages(target: String): LanguagesResponse = client.get(ApiInfo.GOOGLE_TRANSLATE_API_LANGUAGES_URL) { parameter("target", target) parameter("key", ApiInfo.GOOGLE_TRANSLATE_API_KEY) }.body() }
  97. class TranslationDataSourceImpl(private val client: HttpClient) : TranslationDataSource { override suspend

    fun translate(q: String, target: String, source: String): TranslateResponse = client.get(ApiInfo.GOOGLE_TRANSLATE_API_URL) { parameter("q", q) parameter("source", source) parameter("target", target) parameter("key", ApiInfo.GOOGLE_TRANSLATE_API_KEY) }.body() override suspend fun detectLanguage(q: String): DetectionsResponse = client.get(ApiInfo.GOOGLE_TRANSLATE_API_DETECT_URL) { parameter("q", q) parameter("key", ApiInfo.GOOGLE_TRANSLATE_API_KEY) }.body() override suspend fun getLanguages(target: String): LanguagesResponse = client.get(ApiInfo.GOOGLE_TRANSLATE_API_LANGUAGES_URL) { parameter("target", target) parameter("key", ApiInfo.GOOGLE_TRANSLATE_API_KEY) }.body() } 공통 모듈 DI에서 만든 Ktor Client iOS 지원 시 Darwin 분기 필요
  98. class TranslationDataSourceImpl(private val client: HttpClient) : TranslationDataSource { override suspend

    fun translate(q: String, target: String, source: String): TranslateResponse = client.get(ApiInfo.GOOGLE_TRANSLATE_API_URL) { parameter("q", q) parameter("source", source) parameter("target", target) parameter("key", ApiInfo.GOOGLE_TRANSLATE_API_KEY) }.body() override suspend fun detectLanguage(q: String): DetectionsResponse = client.get(ApiInfo.GOOGLE_TRANSLATE_API_DETECT_URL) { parameter("q", q) parameter("key", ApiInfo.GOOGLE_TRANSLATE_API_KEY) }.body() override suspend fun getLanguages(target: String): LanguagesResponse = client.get(ApiInfo.GOOGLE_TRANSLATE_API_LANGUAGES_URL) { parameter("target", target) parameter("key", ApiInfo.GOOGLE_TRANSLATE_API_KEY) }.body() } Client 통해 API 요청
  99. class TranslateUseCase( private val translationRepository: TranslationRepository, private val detectLanguageUseCase: DetectLanguageUseCase,

    private val getPreferencesUseCase: GetPreferencesUseCase ) { suspend operator fun invoke(q: String) = detectLanguageUseCase(q).language.let { language -> val (source, target) = getPreferencesUseCase().firstOrNull() ?: throw NullPointerException("Preferences is null") makeSourceTargetPair(language, target.language, source.language).let { (target, source) -> translationRepository.translate(q = q, target = target, source = source) } } ... }
  100. class TranslateUseCase( private val translationRepository: TranslationRepository, private val detectLanguageUseCase: DetectLanguageUseCase,

    private val getPreferencesUseCase: GetPreferencesUseCase ) { suspend operator fun invoke(q: String) = detectLanguageUseCase(q).language.let { language -> val (source, target) = getPreferencesUseCase().firstOrNull() ?: throw NullPointerException("Preferences is null") makeSourceTargetPair(language, target.language, source.language).let { (target, source) -> translationRepository.translate(q = q, target = target, source = source) } } ... }
  101. class TranslateUseCase( private val translationRepository: TranslationRepository, private val detectLanguageUseCase: DetectLanguageUseCase,

    private val getPreferencesUseCase: GetPreferencesUseCase ) { ... private fun makeSourceTargetPair(language: String, target: String, source: String): Pair<String, String> = when(language) { "und" -> target to source target -> source to target !in listOf(target, source) -> source to language else -> target to source } } 상황에 맞는 번역 언어 설정 로직
  102. class TranslateUseCase( private val translationRepository: TranslationRepository, private val detectLanguageUseCase: DetectLanguageUseCase,

    private val getPreferencesUseCase: GetPreferencesUseCase ) { suspend operator fun invoke(q: String) = detectLanguageUseCase(q).language.let { language -> val (source, target) = getPreferencesUseCase().firstOrNull() ?: throw NullPointerException("Preferences is null") makeSourceTargetPair(language, target.language, source.language).let { (target, source) -> translationRepository.translate(q = q, target = target, source = source) } } ... }
  103. class TranslateUseCase( private val translationRepository: TranslationRepository, private val detectLanguageUseCase: DetectLanguageUseCase,

    private val getPreferencesUseCase: GetPreferencesUseCase ) { suspend operator fun invoke(q: String) = detectLanguageUseCase(q).language.let { language -> val (source, target) = getPreferencesUseCase().firstOrNull() ?: throw NullPointerException("Preferences is null") makeSourceTargetPair(language, target.language, source.language).let { (target, source) -> translationRepository.translate(q = q, target = target, source = source) } } ... } API 번역 요청
  104. class TranslateUseCase( private val translationRepository: TranslationRepository, private val detectLanguageUseCase: DetectLanguageUseCase,

    private val getPreferencesUseCase: GetPreferencesUseCase ) { suspend operator fun invoke(q: String) = detectLanguageUseCase(q).language.let { language -> val (source, target) = getPreferencesUseCase().firstOrNull() ?: throw NullPointerException("Preferences is null") makeSourceTargetPair(language, target.language, source.language).let { (target, source) -> translationRepository.translate(q = q, target = target, source = source) } } ... } Android/iOS/Desktop 등 여러 플랫폼에서 사용 가능한 번역 UseCase. API 통신, 언어 변환 공식 등 코틀린 하나로.
  105. <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.haeyum.transer"> <uses-permission android:name="android.permission.INTERNET" /> <application

    android:name=".TranserApp" ...> <activity android:name=".presentation.translation.TranslationActivity" android:exported="true" android:theme="@style/TransparentCompat"> <intent-filter> <action android:name="android.intent.action.PROCESS_TEXT" /> <category android:name="android.intent.category.DEFAULT" /> <data android:mimeType="text/plain" /> </intent-filter> </activity> </application> </manifest>
  106. <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.haeyum.transer"> <uses-permission android:name="android.permission.INTERNET" /> <application

    android:name=".TranserApp" ...> <activity android:name=".presentation.translation.TranslationActivity" android:exported="true" android:theme="@style/TransparentCompat"> <intent-filter> <action android:name="android.intent.action.PROCESS_TEXT" /> <category android:name="android.intent.category.DEFAULT" /> <data android:mimeType="text/plain" /> </intent-filter> </activity> </application> </manifest>
  107. <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.haeyum.transer"> <uses-permission android:name="android.permission.INTERNET" /> <application

    android:name=".TranserApp" ...> <activity android:name=".presentation.translation.TranslationActivity" android:exported="true" android:theme="@style/TransparentCompat"> <intent-filter> <action android:name="android.intent.action.PROCESS_TEXT" /> <category android:name="android.intent.category.DEFAULT" /> <data android:mimeType="text/plain" /> </intent-filter> </activity> </application> </manifest> Intent Filter를 통해 텍스트 공유 시 번역창 노출
  108. override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows( window.apply { setBackgroundDrawable(ColorDrawable(0))

    statusBarColor = 0x00000000 }, false ) viewModel.requestTranslation( intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT).toString() ) setContent { ... } }
  109. override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows( window.apply { setBackgroundDrawable(ColorDrawable(0))

    statusBarColor = 0x00000000 }, false ) viewModel.requestTranslation( intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT).toString() ) setContent { ... } } 액티비티 투명 처리
  110. override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows( window.apply { setBackgroundDrawable(ColorDrawable(0))

    statusBarColor = 0x00000000 }, false ) viewModel.requestTranslation( intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT).toString() ) setContent { ... } } 넘겨받은 텍스트로 번역 요청
  111. override fun onCreate(savedInstanceState: Bundle?) { ... setContent { TranslationScreen( onRequestOpen

    = { startActivity( Intent( this@TranslationActivity, MainActivity::class.java ).apply { addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) } ) }, onRequestFinish = ::finish, viewModel = viewModel ) } }
  112. override fun onCreate(savedInstanceState: Bundle?) { ... setContent { TranslationScreen( onRequestOpen

    = { startActivity( Intent( this@TranslationActivity, MainActivity::class.java ).apply { addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) } ) }, onRequestFinish = ::finish, viewModel = viewModel ) } } 커스텀을 고려하지 않기에 ViewModel 사용
  113. iOS

  114. val translatedText = text.transformLatest { text -> if (text.isNotEmpty()) {

    delay(700) runCatching { val (originalText, translatedText) = text to translateUseCase(text).translatedText.decodeHtmlEntities() emit(translatedText) ... }.onFailure { ... } } else { emit("") } }.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = "")
  115. val translatedText = text.transformLatest { text -> if (text.isNotEmpty()) {

    delay(700) runCatching { val (originalText, translatedText) = text to translateUseCase(text).translatedText.decodeHtmlEntities() emit(translatedText) ... }.onFailure { ... } } else { emit("") } }.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = "") Android와 동일한 번역 UseCase 사용
  116. val translatedText = text.transformLatest { text -> if (text.isNotEmpty()) {

    delay(700) runCatching { val (originalText, translatedText) = text to translateUseCase(text).translatedText.decodeHtmlEntities() emit(translatedText) ... }.onFailure { ... } } else { emit("") } }.stateIn(scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = "") 번역 결과를 한번 더 가공해주는 확장 함수
  117. fun String.decodeHtmlEntities() = replace("&lt;", "<") .replace("&gt;", ">") .replace("&amp;", "&") .replace("&quot;",

    "\"") .replace("&apos;", ”’”) .replace("&#39;", "'") HTML Entity를 가공해주는 기능 Android/iOS/Desktop 모든 플랫폼에서 재구현없이 그대로 사용
  118. val translatedText = query.transformLatest { query -> emit( if (query.isBlank()

    || (query.firstOrNull() == '>')) { ... } else { ... runCatching { translateUseCase(q = query).translatedText }.onFailure { exception -> ... }.getOrDefault("") } ) }
  119. val translatedText = query.transformLatest { query -> emit( if (query.isBlank()

    || (query.firstOrNull() == '>')) { ... } else { ... runCatching { translateUseCase(q = query).translatedText }.onFailure { exception -> ... }.getOrDefault("") } ) } 텍스트가 으로 시작 시 명령어이기에 번역x
  120. val translatedText = query.transformLatest { query -> emit( if (query.isBlank()

    || (query.firstOrNull() == '>')) { ... } else { ... runCatching { translateUseCase(q = query).translatedText }.onFailure { exception -> ... }.getOrDefault("") } ) } 마찬가지로 동일한 번역 UseCase 사용
  121. fun onPreviewKeyEvent(keyEvent: KeyEvent): Boolean { val keyEventId = (keyEvent.nativeKeyEvent as

    java.awt.event.KeyEvent).id val (isPressed, isTyped, isReleased) = listOf( keyEventId == java.awt.event.KeyEvent.KEY_PRESSED, keyEventId == java.awt.event.KeyEvent.KEY_TYPED, keyEventId == java.awt.event.KeyEvent.KEY_RELEASED ) when (keyEvent.key) { Key.Enter -> ... Key.DirectionUp -> ... Key.DirectionDown -> ... else -> return false } return true }
  122. fun onPreviewKeyEvent(keyEvent: KeyEvent): Boolean { val keyEventId = (keyEvent.nativeKeyEvent as

    java.awt.event.KeyEvent).id val (isPressed, isTyped, isReleased) = listOf( keyEventId == java.awt.event.KeyEvent.KEY_PRESSED, keyEventId == java.awt.event.KeyEvent.KEY_TYPED, keyEventId == java.awt.event.KeyEvent.KEY_RELEASED ) when (keyEvent.key) { Key.Enter -> ... Key.DirectionUp -> ... Key.DirectionDown -> ... else -> return false } return true } 키 이벤트의 경우 AWT 이벤트로 처리
  123. fun onPreviewKeyEvent(keyEvent: KeyEvent): Boolean { val keyEventId = (keyEvent.nativeKeyEvent as

    java.awt.event.KeyEvent).id val (isPressed, isTyped, isReleased) = listOf( keyEventId == java.awt.event.KeyEvent.KEY_PRESSED, keyEventId == java.awt.event.KeyEvent.KEY_TYPED, keyEventId == java.awt.event.KeyEvent.KEY_RELEASED ) when (keyEvent.key) { Key.Enter -> ... Key.DirectionUp -> ... Key.DirectionDown -> ... else -> return false } return true } 키 이벤트에 따라 클립보드로 복사, 저장 등 처리
  124. fun onPreviewKeyEvent(keyEvent: KeyEvent): Boolean { val keyEventId = (keyEvent.nativeKeyEvent as

    java.awt.event.KeyEvent).id val (isPressed, isTyped, isReleased) = listOf( keyEventId == java.awt.event.KeyEvent.KEY_PRESSED, keyEventId == java.awt.event.KeyEvent.KEY_TYPED, keyEventId == java.awt.event.KeyEvent.KEY_RELEASED ) when (keyEvent.key) { Key.Enter -> ... Key.DirectionUp -> ... Key.DirectionDown -> ... else -> return false } return true } 기존 AWT/SWING 프로젝트와 상호운영성 제공
  125. 플랫폼 지식이 필요한가요? • Android ◦ 기존과 동일하게 개발 가능하다.

    ◦ 모듈화/의존성만 고려하면 큰 문제 없다. • Desktop ◦ 별다른 지식이 없어도 개발이 가능하다. ◦ AWT 경험이 있다면 조금 더 자유로운 개발이 가능하다. • iOS ◦ 기본적인 iOS, Xcode 지식을 알아야한다. ◦ KMP를 하는건지 iOS를 공부하는건지 라는 생각이 들 수 있다.
  126. 레퍼런스는 충분한가요? • 당시에는 충분하지 않았습니다. ◦ SQLDelight 초기 설정부터

    지옥이었다. ◦ 특히 Desktop의 경우 Memory 저장 내용만 있어 로컬에 저장하기 위해 많은 고생을 했다. GitHub 코드 검색해도 안 나왔음… • 지금은 조금 괜찮은 것 같습니다. ◦ 이제는 초기와 비교하면 KMP 관련 레퍼런스가 몇 배는 많아진 것 같다. ◦ 무엇보다 당시에는 Beta였다면 지금은 Stable이다.
  127. 기존 컴포즈 UI를 그대로 사용할 수 있나요? • 운이 좋다면

    가능합니다. ◦ Jetpack Compose에는 Android 의존성이 걸린 컴포저블이 존재한다. 만약 해당 컴포저블을 사용하였다면 해체해야 한다. ◦ 의존성 걸린 컴포저블이 없다면 그대로 사용 가능하다. • 지원되는 컴포저블이 늘어납니다. ◦ Compose Multiplatform 버전에 따라 사용가능한 요소도 늘어나고 있다. ◦ 한 예시로 기존에는 AlertDialog, DropdownMenu 사용이 불가능하였지만, 1.5.0으로 올라오면서 사용이 가능하다.
  128. 유광무 GDG Songdo Organizer GDSC TUK Lead ex 아우토크립트 안드로이드

    개발 팀장 Incheon/Songdo kisa002 kisa002 firebase holykisa