Share
Embed
Kotlin Multiplatform으로 Android/iOS/Desktop 번역기 만들기
Kotlin Multiplatform으로 Android/iOS/Desktop 번역기 만들기
2023 Devfest GDG Songdo x Incheon
Pangmoo
December 09, 2023
Transcript
191 slides
-
유광무 GDG Songdo Organizer GDSC TUK Lead ex 아우토크립트 안드로이드
개발 팀장 Incheon/Songdo kisa002 kisa002 firebase holykisa -
TRANSER • Android/iOS/Desktop 번역 유틸리티 ◦ Kotlin/Compose Multiplatform으로 개발된 오픈소스
◦ 개발 기간 1주일 ▪ KotlinContest 출품작 ▪ 대회 일정으로 인한 짧은 개발 기간 • 이번 Devfest 발표를 위해 ◦ Kotlin/Compose 최신 버전 마이그레이션 ◦ iOS 플랫폼 지원 https://github.com/kisa002/transer Git Repository -
프로젝트 구조 공통 로직 • 공통으로 사용할 로직 ◦ UI
/ API / DB / UseCase 등 • 정의된 로직들은 android, iOS, desktop 등 ◦ 다른 플랫폼에서 사용 가능 -
프로젝트 구조 플랫폼별 로직 • android, iOS, desktop 플랫폼별로 사용될
모듈 • common 모듈의 공통 로직을 사용하면서 ◦ 각 플랫폼 기능을 구현 -
공통 UI 만들기 • Shared 모듈 commonMain 패키지 • 환경설정
◦ presentation/preferences 배치 ◦ Preferences Screen/ViewModel 관리 • 컴포넌트 ◦ 재사용되는 컴포넌트 ◦ 공용은 물론, 각 플랫폼에서 독자적으로 호출가능한 컴포넌트 -
@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 = {} ) { ... } -
@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 = {} ) { ... } 상태는 아래로 -
@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 = {} ) { ... } 이벤트는 위로 -
공통 UI 만들기 환경설정 • 내부 UI 로직은 기존 Compose와
100 동일 • 자세한 설명은 생략하겠습니다. -
공통 모듈 만들기 API 통신 • API 라이브러리로 Ktor 채택
◦ Jetbrains에서 만든 Server/Client 프레임워크 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • Transer에서는 Serialization을 사용하였음 -
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 } } -
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 -
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 변환 설정 -
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 } } 로그 기능 -
공통 모듈 만들기 로컬 DB • 로컬 DB로 SQLDelight 채택
◦ CashApp에서 만든 오픈소스 라이브러리 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • SQLite, MySQL, PostgreSQL, HSQL 지원 -
// Build.Gradle.kts :shared sqldelight { database("TranserDatabase") { // DB이름 packageName
= "com.haeyum.shared" // 패키지 이름 } } -
공통 기능 만들기 로컬 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; -
공통 기능 만들기 로컬 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; -
공통 기능 만들기 로컬 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; -
공통 기능 만들기 로컬 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; -
공통 기능 만들기 로컬 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; -
공통 기능 만들기 로컬 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; -
공통 모듈 만들기 DI • DI 라이브러리로 Koin 채택 ◦
Kotlin, Kotlin Multiplatform DI 프레임워크 • Kotlin Multiplatform 지원 ◦ Android ◦ iOS ◦ Desktop ◦ … • Kotlin 2.0 Beta 지원 -
공통 모듈 만들기 DI • module ◦ Koin 모듈 생성
• single ◦ 한 번만 생성 • factory ◦ 호출마다 생성 • viewModel ◦ Android ViewModel • get ◦ 의존성 주입 • singleOf, factoryOf, viewModelOf ◦ Constructor DSL ◦ get 생략 가능 • bind ◦ 정의에 대한 바인딩 -
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 } } } } -
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 모듈 생성 -
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 싱글톤 정의 -
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 모듈 -
공통 기능 만들기 로컬 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 -
공통 기능 만들기 로컬 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 공통 모듈 주입 -
공통 기능 만들기 로컬 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 플랫폼별 모듈 주입 -
KMP 개발을 위한 알아두면 좋은 라이브러리 소개 / DI 프레임워크
찍먹하기 세션에서 만나뵐 수 있습니다. -
공통 기능 만들기 • 번역 • 최근 번역 • 저장된
번역 • 환경설정 • 초기화 -
공통 기능 만들기 • 번역 • 최근 번역 • 저장된
번역 • 환경설정 • 초기화 시간관계 상 번역과 환경설정만 이야기 -
공통 기능 만들기 환경설정 • 로컬 DB SQLDelight • 아키텍처
◦ DataSource/Repository/UseCase • ViewModel • Expect/Actual • Desktop/Android/iOS 플랫폼별 처리 -
// 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, ?, ?, ?, ?); -
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 ) } } -
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 ) } } -
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 -
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 ) } } -
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 빌드 시 자동 생성 -
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 -
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에서 가져오는 데이터클래스 원형 빌드 시 자동 생성 -
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에서 가져온 정보를 사용할 데이터클래스로 변환 -
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 ) } } -
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파일 기반 자동 생성되는 함수 -
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 ) ) } -
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 ) ) } -
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 ) ) } -
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 ) ) } -
class GetPreferencesUseCase(private val preferencesRepository: PreferencesRepository) { operator fun invoke(): Flow<Preferences?>
= preferencesRepository.getPreferences() } -
class SetPreferencesUseCase(private val preferencesRepository: PreferencesRepository) { suspend operator fun invoke(
sourceLanguage: Language, targetLanguage: Language, ) = preferencesRepository.setPreferences( sourceLanguage = sourceLanguage, targetLanguage = targetLanguage, ) } -
class PreferencesViewModel( private val getSupportedLanguagesUseCase: GetSupportedLanguagesUseCase, private val getPreferencesUseCase: GetPreferencesUseCase,
private val setPreferencesUseCase: SetPreferencesUseCase, private val clearDataUseCase: ClearDataUseCase ) : { ... } ViewModel() commonMain 모듈 -
class PreferencesViewModel( private val getSupportedLanguagesUseCase: GetSupportedLanguagesUseCase, private val getPreferencesUseCase: GetPreferencesUseCase,
private val setPreferencesUseCase: SetPreferencesUseCase, private val clearDataUseCase: ClearDataUseCase ) : { ... } ViewModel() 문제가 있는 코드이지만 우선 SKIP -
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) } -
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) } -
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) } -
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) } -
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) } } } } -
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) } } } } -
class PreferencesViewModel( private val getSupportedLanguagesUseCase: GetSupportedLanguagesUseCase, private val getPreferencesUseCase: GetPreferencesUseCase,
private val setPreferencesUseCase: SetPreferencesUseCase, private val clearDataUseCase: ClearDataUseCase ) : { ... } ViewModel은 안드로이드 의존이기에 공통 모듈에서 사용 불가능 ViewModel() -
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 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을 통해 구현됨 -
@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]"))) } ) } -
@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 주입 -
@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]"))) } ) } 플랫폼에 맞는 자유로운 커스텀 -
@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 상태 받아오기 -
@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 이벤트 전달 -
@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를 사용하여 메일 발송창 열기 -
@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) }, ) } } -
@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 주입 -
@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) }, ) } } -
@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 종속 -
@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 사용 -
@Composable fun PreferencesWindow( visible: Boolean, onChangeVisibleRequest: (Boolean) -> Unit, windowState:
WindowState = rememberWindowState( position = WindowPosition(BiasAlignment(0f, -.3f)), size = DpSize(width = 400.dp, height = 560.dp) ) ) { ... } -
@Composable fun PreferencesWindow( visible: Boolean, onChangeVisibleRequest: (Boolean) -> Unit, windowState:
WindowState = rememberWindowState( position = WindowPosition(BiasAlignment(0f, -.3f)), size = DpSize(width = 400.dp, height = 560.dp) ) ) { ... } 생성되는 위치 및 크기 등 윈도우 상태 -
@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에 따라 창 표시/미표시 -
@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 ) { ... } } } -
@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 ) { ... } } } -
@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 ) { ... } } } , // 제목 // 기본 스타일 해제 // 투명 // 창 크기 조절 윈도우 창 속성 설정 -
if (visible) { Window(...) { val preferencesViewModel by remember {
KoinJavaComponent.inject<PreferencesViewModel>(PreferencesViewModel::class.java) } PreferencesScreen( ... ) if (CurrentPlatform.isMac) { ... } DisposableEffect(Unit) { onDispose { preferencesViewModel.onCleared() } } } -
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 생성 -
if (visible) { Window(...) { val preferencesViewModel by remember {
KoinJavaComponent.inject<PreferencesViewModel>(PreferencesViewModel::class.java) } PreferencesScreen( ... ) if (CurrentPlatform.isMac) { ... } DisposableEffect(Unit) { onDispose { preferencesViewModel.onCleared() } } } 사라질 때 ViewModel 초기화 -
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가 존재하지 않습니다. -
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 타겟 -
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]")) } ) -
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 컴포넌트 커스텀 -
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을 받는다면 재구현이 필요 없음 -
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 활용한 메일 발송창 열기 -
if (visible) { Window(...) { val preferencesViewModel by remember {
KoinJavaComponent.inject<PreferencesViewModel>(PreferencesViewModel::class.java) } PreferencesScreen( ... ) if (CurrentPlatform.isMac) { ... } DisposableEffect(Unit) { onDispose { preferencesViewModel.onCleared() } } } -
if (visible) { Window(...) { val preferencesViewModel by remember {
KoinJavaComponent.inject<PreferencesViewModel>(PreferencesViewModel::class.java) } PreferencesScreen( ... ) if (CurrentPlatform.isMac) { ... } DisposableEffect(Unit) { onDispose { preferencesViewModel.onCleared() } } } -
if (visible) { Window(...) { PreferencesScreen( ... ) if (CurrentPlatform.isMac)
{ MenuBar { Menu("Window") { Item( text = "Close", onClick = { onChangeVisibleRequest(false) }, shortcut = KeyShortcut(Key.W, meta = true) ) } } } } -
if (visible) { Window(...) { PreferencesScreen( ... ) if (CurrentPlatform.isMac)
{ MenuBar { Menu("Window") { Item( text = "Close", onClick = { onChangeVisibleRequest(false) }, shortcut = KeyShortcut(Key.W, meta = true) ) } } } } 현재 플랫폼이 Mac인 경우에만 메뉴바 생성 -
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 -
if (visible) { Window(...) { PreferencesScreen( ... ) if (CurrentPlatform.isMac)
{ MenuBar { Menu("Window") { Item( text = "Close", onClick = { onChangeVisibleRequest(false) }, shortcut = KeyShortcut(Key.W, meta = true) ) } } } } -
if (visible) { Window(...) { PreferencesScreen( ... ) if (CurrentPlatform.isMac)
{ MenuBar { Menu("Window") { Item( text = "Close", onClick = { onChangeVisibleRequest(false) }, shortcut = KeyShortcut(Key.W, meta = true) ) } } } } 단축키 지정 -
fun main() { DesktopKoin.startKoin() val viewModel by inject<MainViewModel>(MainViewModel::class.java) application {
TranserTheme { ~~~ PreferencesWindow( visible = visiblePreferencesWindow, onChangeVisibleRequest = viewModel::setVisiblePreferencesWindow ) ~~~ } } } Desktop 모듈 Main.kt -
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() } -
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 분기 필요 -
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 요청 -
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) } } ... } -
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) } } ... } -
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 } } 상황에 맞는 번역 언어 설정 로직 -
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) } } ... } -
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 번역 요청 -
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 통신, 언어 변환 공식 등 코틀린 하나로. -
<?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> -
<?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> -
<?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를 통해 텍스트 공유 시 번역창 노출 -
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 { ... } } -
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 { ... } } 액티비티 투명 처리 -
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 { ... } } 넘겨받은 텍스트로 번역 요청 -
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 ) } } -
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 사용 -
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 = "") -
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 사용 -
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 = "") 번역 결과를 한번 더 가공해주는 확장 함수 -
fun String.decodeHtmlEntities() = replace("<", "<") .replace(">", ">") .replace("&", "&") .replace(""",
"\"") .replace("'", ”’”) .replace("'", "'") HTML Entity를 가공해주는 기능 Android/iOS/Desktop 모든 플랫폼에서 재구현없이 그대로 사용 -
val translatedText = query.transformLatest { query -> emit( if (query.isBlank()
|| (query.firstOrNull() == '>')) { ... } else { ... runCatching { translateUseCase(q = query).translatedText }.onFailure { exception -> ... }.getOrDefault("") } ) } -
val translatedText = query.transformLatest { query -> emit( if (query.isBlank()
|| (query.firstOrNull() == '>')) { ... } else { ... runCatching { translateUseCase(q = query).translatedText }.onFailure { exception -> ... }.getOrDefault("") } ) } 텍스트가 으로 시작 시 명령어이기에 번역x -
val translatedText = query.transformLatest { query -> emit( if (query.isBlank()
|| (query.firstOrNull() == '>')) { ... } else { ... runCatching { translateUseCase(q = query).translatedText }.onFailure { exception -> ... }.getOrDefault("") } ) } 마찬가지로 동일한 번역 UseCase 사용 -
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 } -
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 이벤트로 처리 -
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 } 키 이벤트에 따라 클립보드로 복사, 저장 등 처리 -
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 프로젝트와 상호운영성 제공 -
플랫폼 지식이 필요한가요? • Android ◦ 기존과 동일하게 개발 가능하다.
◦ 모듈화/의존성만 고려하면 큰 문제 없다. • Desktop ◦ 별다른 지식이 없어도 개발이 가능하다. ◦ AWT 경험이 있다면 조금 더 자유로운 개발이 가능하다. • iOS ◦ 기본적인 iOS, Xcode 지식을 알아야한다. ◦ KMP를 하는건지 iOS를 공부하는건지 라는 생각이 들 수 있다. -
레퍼런스는 충분한가요? • 당시에는 충분하지 않았습니다. ◦ SQLDelight 초기 설정부터
지옥이었다. ◦ 특히 Desktop의 경우 Memory 저장 내용만 있어 로컬에 저장하기 위해 많은 고생을 했다. GitHub 코드 검색해도 안 나왔음… • 지금은 조금 괜찮은 것 같습니다. ◦ 이제는 초기와 비교하면 KMP 관련 레퍼런스가 몇 배는 많아진 것 같다. ◦ 무엇보다 당시에는 Beta였다면 지금은 Stable이다. -
기존 컴포즈 UI를 그대로 사용할 수 있나요? • 운이 좋다면
가능합니다. ◦ Jetpack Compose에는 Android 의존성이 걸린 컴포저블이 존재한다. 만약 해당 컴포저블을 사용하였다면 해체해야 한다. ◦ 의존성 걸린 컴포저블이 없다면 그대로 사용 가능하다. • 지원되는 컴포저블이 늘어납니다. ◦ Compose Multiplatform 버전에 따라 사용가능한 요소도 늘어나고 있다. ◦ 한 예시로 기존에는 AlertDialog, DropdownMenu 사용이 불가능하였지만, 1.5.0으로 올라오면서 사용이 가능하다. -
유광무 GDG Songdo Organizer GDSC TUK Lead ex 아우토크립트 안드로이드
개발 팀장 Incheon/Songdo kisa002 kisa002 firebase holykisa