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

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

Pangmoo
December 09, 2023

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

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

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