Slide 1

Slide 1 text

KMP로 Android/iOS/Desktop 번역기 만들기 Incheon/Songdo

Slide 2

Slide 2 text

유광무 GDG Songdo Organizer GDSC TUK Lead ex 아우토크립트 안드로이드 개발 팀장 Incheon/Songdo kisa002 kisa002 firebase holykisa

Slide 3

Slide 3 text

Kotlin/Compose Multiplatform Incheon/Songdo

Slide 4

Slide 4 text

Kotlin Multiplatform이란?

Slide 5

Slide 5 text

이전 발표들을 참고해주세요.

Slide 6

Slide 6 text

이전 발표 참고 Youtube 코틀린 멀티플랫폼 검색 시 간단한 10분 소개 영상

Slide 7

Slide 7 text

이전 발표 참고

Slide 8

Slide 8 text

GitHub 메인 페이지 README 발표 자료 참고 이전 발표 참고

Slide 9

Slide 9 text

No content

Slide 10

Slide 10 text

더 알아보기 아래 세션 참고 fun HelloKMP : GladToMeetYou

Slide 11

Slide 11 text

프로젝트 소개 Incheon/Songdo

Slide 12

Slide 12 text

TRANSER ● Android/iOS/Desktop 번역 유틸리티 ○ Kotlin/Compose Multiplatform으로 개발된 오픈소스 ○ 개발 기간 1주일 ■ KotlinContest 출품작 ■ 대회 일정으로 인한 짧은 개발 기간 ● 이번 Devfest 발표를 위해 ○ Kotlin/Compose 최신 버전 마이그레이션 ○ iOS 플랫폼 지원 https://github.com/kisa002/transer Git Repository

Slide 13

Slide 13 text

플랫폼별 기능

Slide 14

Slide 14 text

플랫폼별 기능 온보딩 Desktop

Slide 15

Slide 15 text

플랫폼별 기능 번역 Desktop Android iOS

Slide 16

Slide 16 text

플랫폼별 기능 최근 번역 Desktop Mobile

Slide 17

Slide 17 text

플랫폼별 기능 저장 Desktop Mobile

Slide 18

Slide 18 text

공통 기능

Slide 19

Slide 19 text

Desktop 공통 기능 테마 Mobile

Slide 20

Slide 20 text

공통 기능 환경설정 Desktop Mobile

Slide 21

Slide 21 text

프로젝트 구조

Slide 22

Slide 22 text

프로젝트 구조 ● Bob s Clean Architecture ● MVVM Design Pattern

Slide 23

Slide 23 text

프로젝트 구조 공통 로직 ● 공통으로 사용할 로직 ○ UI / API / DB / UseCase 등 ● 정의된 로직들은 android, iOS, desktop 등 ○ 다른 플랫폼에서 사용 가능

Slide 24

Slide 24 text

프로젝트 구조 플랫폼별 로직 ● android, iOS, desktop 플랫폼별로 사용될 모듈 ● common 모듈의 공통 로직을 사용하면서 ○ 각 플랫폼 기능을 구현

Slide 25

Slide 25 text

No content

Slide 26

Slide 26 text

공통 UI 만들기

Slide 27

Slide 27 text

공통 UI 만들기 ● Shared 모듈 commonMain 패키지 ● 환경설정 ○ presentation/preferences 배치 ○ Preferences Screen/ViewModel 관리 ● 컴포넌트 ○ 재사용되는 컴포넌트 ○ 공용은 물론, 각 플랫폼에서 독자적으로 호출가능한 컴포넌트

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

@Composable fun PreferencesScreen( modifier: Modifier, header: @Composable () -> Unit, supportedLanguages: List, selectedSourceLanguage: String, selectedTargetLanguage: String, onSelectedSourceLanguage: (Language) -> Unit, onSelectedTargetLanguage: (Language) -> Unit, onClickClearData: () -> Unit, onClickContact: () -> Unit, onNotifyVisibleSelect: (Boolean) -> Unit = {} ) { ... } 이벤트는 위로

Slide 31

Slide 31 text

공통 UI 만들기 환경설정 ● 내부 UI 로직은 기존 Compose와 100 동일 ● 자세한 설명은 생략하겠습니다.

Slide 32

Slide 32 text

공통 모듈 만들기

Slide 33

Slide 33 text

공통 모듈 만들기 API 통신 로컬 DB DI

Slide 34

Slide 34 text

공통 모듈 만들기 API 통신 ● API 라이브러리로 Ktor 채택 ○ Jetbrains에서 만든 Server/Client 프레임워크 ● Kotlin Multiplatform 지원 ○ Android ○ iOS ○ Desktop ○ … ● Transer에서는 Serialization을 사용하였음

Slide 35

Slide 35 text

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 } }

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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 변환 설정

Slide 38

Slide 38 text

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 } } 로그 기능

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

공통 모듈 만들기 로컬 DB

Slide 41

Slide 41 text

// Build.Gradle.kts :shared sqldelight { database("TranserDatabase") { // DB이름 packageName = "com.haeyum.shared" // 패키지 이름 } }

Slide 42

Slide 42 text

공통 기능 만들기 로컬 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;

Slide 43

Slide 43 text

공통 기능 만들기 로컬 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;

Slide 44

Slide 44 text

공통 기능 만들기 로컬 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;

Slide 45

Slide 45 text

공통 기능 만들기 로컬 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;

Slide 46

Slide 46 text

공통 기능 만들기 로컬 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;

Slide 47

Slide 47 text

공통 기능 만들기 로컬 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;

Slide 48

Slide 48 text

공통 모듈 만들기 DI ● DI 라이브러리로 Koin 채택 ○ Kotlin, Kotlin Multiplatform DI 프레임워크 ● Kotlin Multiplatform 지원 ○ Android ○ iOS ○ Desktop ○ … ● Kotlin 2.0 Beta 지원

Slide 49

Slide 49 text

공통 모듈 만들기 DI ● module ○ Koin 모듈 생성 ● single ○ 한 번만 생성 ● factory ○ 호출마다 생성 ● viewModel ○ Android ViewModel ● get ○ 의존성 주입 ● singleOf, factoryOf, viewModelOf ○ Constructor DSL ○ get 생략 가능 ● bind ○ 정의에 대한 바인딩

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

val commonApiModule = module { single { HttpClient(CIO) { install(ContentNegotiation) { json( Json { prettyPrint = true isLenient = true coerceInputValues = true } ) } install(Logging) { logger = Logger.DEFAULT level = LogLevel.ALL } } } } HttpClient 주입할 객체 API 모듈

Slide 54

Slide 54 text

공통 기능 만들기 로컬 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

Slide 55

Slide 55 text

공통 기능 만들기 로컬 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 공통 모듈 주입

Slide 56

Slide 56 text

공통 기능 만들기 로컬 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 플랫폼별 모듈 주입

Slide 57

Slide 57 text

앞서나온 Koin, SQLDelight 등 자세한 설명

Slide 58

Slide 58 text

다양한 KMP 라이브러리를 듣고 싶다면?

Slide 59

Slide 59 text

KMP 개발을 위한 알아두면 좋은 라이브러리 소개 / DI 프레임워크 찍먹하기 세션에서 만나뵐 수 있습니다.

Slide 60

Slide 60 text

공통 기능 만들기 ● 번역 ● 최근 번역 ● 저장된 번역 ● 환경설정 ● 초기화

Slide 61

Slide 61 text

공통 기능 만들기 ● 번역 ● 최근 번역 ● 저장된 번역 ● 환경설정 ● 초기화 시간관계 상 번역과 환경설정만 이야기

Slide 62

Slide 62 text

공통 기능 만들기 환경설정 ● 로컬 DB SQLDelight ● 아키텍처 ○ DataSource/Repository/UseCase ● ViewModel ● Expect/Actual ● Desktop/Android/iOS 플랫폼별 처리

Slide 63

Slide 63 text

DB Table 만들기

Slide 64

Slide 64 text

// 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, ?, ?, ?, ?);

Slide 65

Slide 65 text

DataSource 만들기

Slide 66

Slide 66 text

interface PreferencesDataSource { fun getPreferences(): Flow suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) }

Slide 67

Slide 67 text

class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun getPreferences(): Flow = 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 ) } }

Slide 68

Slide 68 text

class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun getPreferences(): Flow = 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 ) } }

Slide 69

Slide 69 text

class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun getPreferences(): Flow = 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

Slide 70

Slide 70 text

class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun getPreferences(): Flow = 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 ) } }

Slide 71

Slide 71 text

class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun getPreferences(): Flow = 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 빌드 시 자동 생성

Slide 72

Slide 72 text

class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun getPreferences(): Flow = 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

Slide 73

Slide 73 text

class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun getPreferences(): Flow = 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에서 가져오는 데이터클래스 원형 빌드 시 자동 생성

Slide 74

Slide 74 text

class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun getPreferences(): Flow = 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에서 가져온 정보를 사용할 데이터클래스로 변환

Slide 75

Slide 75 text

class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun getPreferences(): Flow = 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 ) } }

Slide 76

Slide 76 text

class PreferencesDataSourceImpl(private val database: TranserDatabase) : PreferencesDataSource { override fun getPreferences(): Flow = 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파일 기반 자동 생성되는 함수

Slide 77

Slide 77 text

Repository 만들기

Slide 78

Slide 78 text

interface PreferencesRepository { fun getPreferences(): Flow suspend fun setPreferences(sourceLanguage: Language, targetLanguage: Language) }

Slide 79

Slide 79 text

class PreferencesRepositoryImpl(private val preferencesDataSource: PreferencesDataSource) : PreferencesRepository { override fun getPreferences(): Flow = 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 ) ) }

Slide 80

Slide 80 text

class PreferencesRepositoryImpl(private val preferencesDataSource: PreferencesDataSource) : PreferencesRepository { override fun getPreferences(): Flow = 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 ) ) }

Slide 81

Slide 81 text

class PreferencesRepositoryImpl(private val preferencesDataSource: PreferencesDataSource) : PreferencesRepository { override fun getPreferences(): Flow = 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 ) ) }

Slide 82

Slide 82 text

class PreferencesRepositoryImpl(private val preferencesDataSource: PreferencesDataSource) : PreferencesRepository { override fun getPreferences(): Flow = 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 ) ) }

Slide 83

Slide 83 text

UseCase 만들기

Slide 84

Slide 84 text

class GetPreferencesUseCase(private val preferencesRepository: PreferencesRepository) { operator fun invoke(): Flow = preferencesRepository.getPreferences() }

Slide 85

Slide 85 text

class SetPreferencesUseCase(private val preferencesRepository: PreferencesRepository) { suspend operator fun invoke( sourceLanguage: Language, targetLanguage: Language, ) = preferencesRepository.setPreferences( sourceLanguage = sourceLanguage, targetLanguage = targetLanguage, ) }

Slide 86

Slide 86 text

이처럼 관심사/의존성이 잘 분리되었다면 기존 아키텍처 그대로 활용 가능

Slide 87

Slide 87 text

ViewModel 만들기

Slide 88

Slide 88 text

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

Slide 89

Slide 89 text

class PreferencesViewModel( private val getSupportedLanguagesUseCase: GetSupportedLanguagesUseCase, private val getPreferencesUseCase: GetPreferencesUseCase, private val setPreferencesUseCase: SetPreferencesUseCase, private val clearDataUseCase: ClearDataUseCase ) : { ... } ViewModel() 문제가 있는 코드이지만 우선 SKIP

Slide 90

Slide 90 text

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) }

Slide 91

Slide 91 text

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) }

Slide 92

Slide 92 text

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) }

Slide 93

Slide 93 text

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) }

Slide 94

Slide 94 text

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) } } } }

Slide 95

Slide 95 text

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) } } } }

Slide 96

Slide 96 text

여기까지는 기존 Android 개발과 동일

Slide 97

Slide 97 text

class PreferencesViewModel( private val getSupportedLanguagesUseCase: GetSupportedLanguagesUseCase, private val getPreferencesUseCase: GetPreferencesUseCase, private val setPreferencesUseCase: SetPreferencesUseCase, private val clearDataUseCase: ClearDataUseCase ) : { ... } ViewModel은 안드로이드 의존이기에 공통 모듈에서 사용 불가능 ViewModel()

Slide 98

Slide 98 text

Expect/Actual

Slide 99

Slide 99 text

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

Slide 100

Slide 100 text

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을 통해 구현됨

Slide 101

Slide 101 text

플랫폼별 환경설정 사용하기

Slide 102

Slide 102 text

Android

Slide 103

Slide 103 text

@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]"))) } ) }

Slide 104

Slide 104 text

@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 주입

Slide 105

Slide 105 text

@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]"))) } ) } 플랫폼에 맞는 자유로운 커스텀

Slide 106

Slide 106 text

@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 상태 받아오기

Slide 107

Slide 107 text

@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 이벤트 전달

Slide 108

Slide 108 text

@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를 사용하여 메일 발송창 열기

Slide 109

Slide 109 text

Android Result

Slide 110

Slide 110 text

iOS

Slide 111

Slide 111 text

@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) }, ) } }

Slide 112

Slide 112 text

@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 주입

Slide 113

Slide 113 text

@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) }, ) } }

Slide 114

Slide 114 text

@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 종속

Slide 115

Slide 115 text

@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 사용

Slide 116

Slide 116 text

iOS Result

Slide 117

Slide 117 text

Desktop

Slide 118

Slide 118 text

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

Slide 119

Slide 119 text

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

Slide 120

Slide 120 text

@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에 따라 창 표시/미표시

Slide 121

Slide 121 text

@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 ) { ... } } }

Slide 122

Slide 122 text

@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 ) { ... } } }

Slide 123

Slide 123 text

@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 ) { ... } } } , // 제목 // 기본 스타일 해제 // 투명 // 창 크기 조절 윈도우 창 속성 설정

Slide 124

Slide 124 text

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

Slide 125

Slide 125 text

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

Slide 126

Slide 126 text

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

Slide 127

Slide 127 text

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

Slide 128

Slide 128 text

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

Slide 129

Slide 129 text

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]")) } )

Slide 130

Slide 130 text

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 컴포넌트 커스텀

Slide 131

Slide 131 text

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을 받는다면 재구현이 필요 없음

Slide 132

Slide 132 text

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 활용한 메일 발송창 열기

Slide 133

Slide 133 text

이처럼 Android Compose와 사용이 동일합니다.

Slide 134

Slide 134 text

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

Slide 135

Slide 135 text

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

Slide 136

Slide 136 text

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

Slide 137

Slide 137 text

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

Slide 138

Slide 138 text

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

Slide 139

Slide 139 text

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

Slide 140

Slide 140 text

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

Slide 141

Slide 141 text

fun main() { DesktopKoin.startKoin() val viewModel by inject(MainViewModel::class.java) application { TranserTheme { ~~~ PreferencesWindow( visible = visiblePreferencesWindow, onChangeVisibleRequest = viewModel::setVisiblePreferencesWindow ) ~~~ } } } Desktop 모듈 Main.kt

Slide 142

Slide 142 text

Desktop Result

Slide 143

Slide 143 text

공통 번역 기능 만들기

Slide 144

Slide 144 text

공통 기능 만들기 번역 기능 ● API 통신

Slide 145

Slide 145 text

API 연결

Slide 146

Slide 146 text

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() }

Slide 147

Slide 147 text

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 분기 필요

Slide 148

Slide 148 text

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 요청

Slide 149

Slide 149 text

Repository 및 기타 UseCase 설명 생략

Slide 150

Slide 150 text

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) } } ... }

Slide 151

Slide 151 text

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) } } ... }

Slide 152

Slide 152 text

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

Slide 153

Slide 153 text

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) } } ... }

Slide 154

Slide 154 text

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 번역 요청

Slide 155

Slide 155 text

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 통신, 언어 변환 공식 등 코틀린 하나로.

Slide 156

Slide 156 text

플랫폼별 번역 시나리오

Slide 157

Slide 157 text

Android

Slide 158

Slide 158 text

Slide 159

Slide 159 text

Slide 160

Slide 160 text

Intent Filter를 통해 텍스트 공유 시 번역창 노출

Slide 161

Slide 161 text

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 { ... } }

Slide 162

Slide 162 text

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 { ... } } 액티비티 투명 처리

Slide 163

Slide 163 text

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 { ... } } 넘겨받은 텍스트로 번역 요청

Slide 164

Slide 164 text

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 ) } }

Slide 165

Slide 165 text

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 사용

Slide 166

Slide 166 text

Android Result

Slide 167

Slide 167 text

iOS

Slide 168

Slide 168 text

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 = "")

Slide 169

Slide 169 text

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 사용

Slide 170

Slide 170 text

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 = "") 번역 결과를 한번 더 가공해주는 확장 함수

Slide 171

Slide 171 text

fun String.decodeHtmlEntities() = replace("<", "<") .replace(">", ">") .replace("&", "&") .replace(""", "\"") .replace("'", ”’”) .replace("'", "'") HTML Entity를 가공해주는 기능 Android/iOS/Desktop 모든 플랫폼에서 재구현없이 그대로 사용

Slide 172

Slide 172 text

iOS Result

Slide 173

Slide 173 text

Desktop

Slide 174

Slide 174 text

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

Slide 175

Slide 175 text

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

Slide 176

Slide 176 text

val translatedText = query.transformLatest { query -> emit( if (query.isBlank() || (query.firstOrNull() == '>')) { ... } else { ... runCatching { translateUseCase(q = query).translatedText }.onFailure { exception -> ... }.getOrDefault("") } ) } 마찬가지로 동일한 번역 UseCase 사용

Slide 177

Slide 177 text

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 }

Slide 178

Slide 178 text

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 이벤트로 처리

Slide 179

Slide 179 text

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 } 키 이벤트에 따라 클립보드로 복사, 저장 등 처리

Slide 180

Slide 180 text

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 프로젝트와 상호운영성 제공

Slide 181

Slide 181 text

Desktop Result

Slide 182

Slide 182 text

미리 준비해본 질문과 답변 Incheon/Songdo

Slide 183

Slide 183 text

플랫폼 지식이 필요한가요?

Slide 184

Slide 184 text

플랫폼 지식이 필요한가요? ● Android ○ 기존과 동일하게 개발 가능하다. ○ 모듈화/의존성만 고려하면 큰 문제 없다. ● Desktop ○ 별다른 지식이 없어도 개발이 가능하다. ○ AWT 경험이 있다면 조금 더 자유로운 개발이 가능하다. ● iOS ○ 기본적인 iOS, Xcode 지식을 알아야한다. ○ KMP를 하는건지 iOS를 공부하는건지 라는 생각이 들 수 있다.

Slide 185

Slide 185 text

레퍼런스는 충분한가요?

Slide 186

Slide 186 text

레퍼런스는 충분한가요? ● 당시에는 충분하지 않았습니다. ○ SQLDelight 초기 설정부터 지옥이었다. ○ 특히 Desktop의 경우 Memory 저장 내용만 있어 로컬에 저장하기 위해 많은 고생을 했다. GitHub 코드 검색해도 안 나왔음… ● 지금은 조금 괜찮은 것 같습니다. ○ 이제는 초기와 비교하면 KMP 관련 레퍼런스가 몇 배는 많아진 것 같다. ○ 무엇보다 당시에는 Beta였다면 지금은 Stable이다.

Slide 187

Slide 187 text

기존 컴포즈 UI를 그대로 사용할 수 있나요?

Slide 188

Slide 188 text

기존 컴포즈 UI를 그대로 사용할 수 있나요? ● 운이 좋다면 가능합니다. ○ Jetpack Compose에는 Android 의존성이 걸린 컴포저블이 존재한다. 만약 해당 컴포저블을 사용하였다면 해체해야 한다. ○ 의존성 걸린 컴포저블이 없다면 그대로 사용 가능하다. ● 지원되는 컴포저블이 늘어납니다. ○ Compose Multiplatform 버전에 따라 사용가능한 요소도 늘어나고 있다. ○ 한 예시로 기존에는 AlertDialog, DropdownMenu 사용이 불가능하였지만, 1.5.0으로 올라오면서 사용이 가능하다.

Slide 189

Slide 189 text

Kotlin Multiplatform

Slide 190

Slide 190 text

지금 바로 개척자의 길을 걸어보세요

Slide 191

Slide 191 text

유광무 GDG Songdo Organizer GDSC TUK Lead ex 아우토크립트 안드로이드 개발 팀장 Incheon/Songdo kisa002 kisa002 firebase holykisa