
앱 내에서 유저가 언어 설정을 할 수 있도록 다국어 기능을 구현해보자!
기본 언어 설정을 한국어로 하고 유저는 영어, 한국어 중 자유롭게 언어를 변경할 수 있음
설정 언어를 enum으로 관리하여 실수를 방지해보자.
enum class Language(val code: String) {
ENGLISH("en"),
KOREAN("ko")
}
앱을 처음 실행할 때 DataStore에 저장되어 있는 언어가 존재하면 해당 언어로 설정할 수 있도록 코드를 구현하려고 함. 따라서 언어 설정을 저장하는 함수, 언어 설정을 불러오는 함수를 구현해야 한다.
private val Context.dataStore by preferencesDataStore(name = "luxnine_prefs")
class LuxnineDataStore(context: Context) {
private val dataStore = context.dataStore
// 생략..
suspend fun setLanguage(language: Language) {
UserInfo.language = language
dataStore.edit { preferences ->
preferences[LANGUAGE] = language.code
}
}
suspend fun loadLanguage() {
val savedLanguage = dataStore.data.map { preferences ->
preferences[LANGUAGE] ?: "ko"
}.first()
UserInfo.language = Language.entries.find { it.code == savedLanguage } ?: Language.KOREAN
}
companion object {
// 생략..
val LANGUAGE = stringPreferencesKey("language")
}
}
현재 프로젝트 구조상 data 모듈에 존재하는 DatsStore를 feature 모듈에서 직접 사용할 수는 없다. 또한 뷰모델에서 Repository를 직접 주입받아 사용하지 않고 UseCase를 통해 간접적으로 필요한 함수만 사용하도록 제한하고 있기 때문에 언어 설정을 업데이트하는 UseCase와 언어 설정을 불러오는 UseCase를 각각 구현한다.
class LoadLanguageUseCase(
private val userRepository: UserRepository
) : UseCase<Unit, Unit>() {
override suspend fun invoke(request: Unit): Flow<Unit> {
return userRepository.loadLanguage()
}
}
class UpdateLanguageUseCase(
private val userRepository: UserRepository
): UseCase<Language, Unit>() {
override suspend fun invoke(request: Language): Flow<Unit> {
return userRepository.setLanguage(request)
}
}
여기서 두가지 선택지가 존재한다.
현재 UseCase는 모두 domain 모듈에 위치하고 있는데, 안드로이드 클린 아키텍처 원칙에 따르면 domain 모듈에는 어떠한 외부 라이브러리 없이 순수 코틀린 코드만 존재해야 한다.
현재 프로젝트에서는 이 원칙을 지키기 위해 위해 domain 모듈에 hilt 라이브러리 의존성을 추가하지 않았다. 이로 인해서 UseCase를 feature 모듈의 뷰모델에 주입하려면 따로 UseCaseModule을 구현하고 모든 UseCase에 대한 provide 함수를 구현해야 한다.
이것이 귀찮다면 원칙을 조금 위배하더라도 domain 모듈에 Hilt 라이브러리 의존성을 추가하고 UseCase에 @Inject construtor를 적용하여 Repository를 주입해도 된다. 하지만 나는 최대한 원칙을 준수하기 위해 domain 모듈에 Hilt를 추가하지 않고 UseCaseModule을 따로 구현하였다.
@Module
@InstallIn(SingletonComponent::class)
object UseCaseModule {
// 생략...
@Provides
fun provideLoadLanguageUseCase(
userRepository: UserRepository
): LoadLanguageUseCase {
return LoadLanguageUseCase(userRepository)
}
@Provides
fun provideUpdateLanguageUseCase(
userRepository: UserRepository
): UpdateLanguageUseCase {
return UpdateLanguageUseCase(userRepository)
}
}
@HiltAndroidApp
class LuxnineApplication: Application() {
override fun onCreate() {
super.onCreate()
// 생략..
val entryPoint = EntryPointAccessors.fromApplication(this, UseCaseEntryPoint::class.java)
val loadLanguageUseCase = entryPoint.loadLanguageUseCase()
CoroutineScope(Dispatchers.IO).launch {
loadLanguageUseCase(Unit).collect {
updateLanguage(UserInfo.language)
}
}
}
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface UseCaseEntryPoint {
fun loadLanguageUseCase(): LoadLanguageUseCase
}
Application 클래스에서 UseCase를 주입받기 위해서는 @Inject가 아니라, EntryPointAccessors를 활용해야 한다. 여기서 호출하고 있는 updateLanguage 함수는 앱 전역에서 사용해야 하는 공통 함수로 core 모듈의 CommonFunctions 파일에 존재한다. 코드는 다음과 같다.
fun Context.updateLanguage(language: Language) {
val locale = Locale(language.code)
Locale.setDefault(locale)
val config = resources.configuration
config.setLocale(locale)
config.setLayoutDirection(locale)
@Suppress("DEPRECATION")
resources.updateConfiguration(config, resources.displayMetrics)
}
@Suppress("DEPRECATION")를 사용하여 deprecated된 방식이지만 여전히 앱 전역의 언어를 변경하는 데 필요한 함수를 활용하였다. 이후에 새로운 방법을 찾는다면 변경해야 한다.
이렇게 앱을 처음 실행할 때 유저가 설정해 둔 언어가 존재한다면 해당 언어로 앱을 구성하도록 구현해 보았다. 이제는 앱을 실행시키고 앱 내부에서 언어를 변경하는 경우에 대비한 코드를 구현해야 한다.
현재 프로젝트는 모든 화면이 TopBar를 가지고 있는 구조이고 TopBar에서 언어를 변경하는 것이 가능하다. 따라서 TopBar에 언어 변경 콜백 함수를 전달하는 방식으로 구현하였다.
@Composable
fun TopBar(
// 생략..
updateLanguage: (String) -> Unit = {}
) {
// 생략..
}
언어 변경 버튼을 클릭하면 파라미터로 전달받은 함수를 호출하여 언어를 변경한다.
Scaffold(
topBar = {
TopBar(
// 생략...
updateLanguage = { language ->
context.updateLanguage(language)
val activity = context as? Activity
activity?.recreate()
}
)
},
) { innerPadding ->
실제 TopBar를 호출하는 Scaffold에서는 이런 방식으로 언어를 변경하면 된다. 여기서 호출하는 updateLanguage 함수는 Application 클래스에서 호출했던 함수와 동일한 함수이다.
액티비티를 재시작하는 이유는 updateLanguage 함수가 Context의 언어 설정을 변경하지만, 이미 로드된 화면은 변경된 Context를 반영하지 않기 때문이다. 따라서 변경된 언어를 반영하기 위해 새로고침을 해야 한다.

조금 어설픈 부분도 있지만 어쨌든 완성!