Androidの多言語対応と問題
Androidアプリ開発で多言語対応を行う場合は、言語ごとに res/values*/strings.xml
ファイルを用意するだけで簡単に実装が可能です
-
res/values/strings.xml
: デフォルトのLocale -
res/values-${言語コード}/strings.xml
: 指定した言語のLocale
多くの場合は十分ですが、特別な要求があると問題が発生します。
- アプリの表示が即座には反映されない
設定アプリから言語を変更しても、起動中のアプリの表示にすぐには反映されません。アプリを再起動するかアプリ側で特別な対応が必要です。 - Androidシステムの言語設定に依存する
アプリ内の独自の表示言語を使いたい場合は対応できません。
完成物
システムの言語設定とは別にアプリ内部の表示言語を保持し、ユーザ操作により動的に変化する表示言語をUIに反映させます。UIはComposeで実装しています。
各言語の翻訳は ChatGPT に丸投げしましたので、悪しからず。
アプリのコードはGitHubリポジトリで公開しています。
実装と解説
主要ライブラリのバージョン
- compose-bom:2022.10.00
- hilt-android:2.44
- kotlinx-serialization-json:1.5.1
表示言語と翻訳ファイルの用意
アプリ独自の表示言語をenum
で用意してあげます。
enum class DisplayLanguage(
val code: String
) {
English("en"),
Japanese("ja"),
French("fr"),
Thai("th"),
}
次にアプリで使用するテキストidの一覧もenum
で用意します
@Suppress("EnumEntryName")
enum class TextId(
@StringRes val xmlId: Int
) {
app_bar_title(R.string.app_bar_title),
home_title(R.string.home_title),
// ...以下略...
}
Previewの対応
Previewでは res/values/strings.xml
のテキストを代わりに表示させるため、対応する@StringRes
を指定しています。
同時に対応する翻訳ファイルをassets/text_${言語コード}.json
に用意します。
{
"app_bar_title": "SampleApp",
"home_title": "Hello, Android!",
...以下略...
}
リポジトリの実装
表示言語ごとのテキストを自前で管理します。
sealed interface TextCatalog {
object Initializing : TextCatalog
data class Data(
val language: DisplayLanguage,
val data: Map<TextId, String>,
) : TextCatalog
}
UI側で表示言語の変更を監視するため、リポジトリからはFlow
で公開します。
interface DisplayLanguageRepository {
val catalog: StateFlow<TextCatalog>
fun onLanguageChanged(language: DisplayLanguage)
}
表示言語が変更されたらassets/*
に用意した翻訳ファイルを読み出します。
class DisplayLanguageRepositoryImpl @Inject constructor(
@ApplicationContext private val context: Context,
) : DisplayLanguageRepository {
private val _catalog = MutableStateFlow<TextCatalog>(TextCatalog.Initializing)
override val catalog = _catalog.asStateFlow()
@OptIn(ExperimentalSerializationApi::class)
override fun onLanguageChanged(language: DisplayLanguage) {
val fileName = "text_${language.code}.json"
val serializer = MapSerializer(String.serializer(), String.serializer())
val map = context
.assets
.open(fileName)
.use {
Json.decodeFromStream(serializer, it)
}
.entries
.associate { entry ->
val id = TextId.values().first { it.name == entry.key }
id to entry.value
}
_catalog.update {
TextCatalog.Data(
language = language,
data = map,
)
}
}
}
Composeに表示言語の変更を反映する
原則、Composeの表示を変更するには新しい引数をComposableに渡してRecompositionをトリガーするしかありません。
Compose では、コンポーズ可能な関数を新しいデータで再度呼び出します。すると、関数が再コンポーズされます。
しかし、アプリ表示言語を引数で渡そうとすると、テキスト表示がある全てのComposableに引数のバケツリレーが必要となります。画面数を多い大規模なアプリとなれば実装は現実的ではありません。そこで CompositionLocal
を利用し、アプリ表示言語の情報をComposable全体に暗黙的に流します。
表示言語の更新がUIに反映されるまでのデータの流れは以下のようになります。
表示言語のCompositionLocalを実装
val LocalTextCatalog = compositionLocalOf<TextCatalog>(
policy = neverEqualPolicy(),
) {
TextCatalog.Initializing
}
@Composable
fun rememberTextCatalog(): State<TextCatalog> {
return if (LocalInspectionMode.current) {
// Previewの場合は res/values/strings.xml を参照するため適当な値で固定
rememberUpdatedState(newValue = TextCatalog.Initializing)
} else {
val context = LocalContext.current.applicationContext
val entry = remember(context) {
EntryPoints.get(context, ThemeEntryPoint::class.java)
}
entry.displayLanguageRepository.catalog.collectAsStateWithLifecycle(
minActiveState = Lifecycle.State.RESUMED,
)
}
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ThemeEntryPoint {
val displayLanguageRepository: DisplayLanguageRepository
}
Compose + Hilt
ViewModelやActivity以外でDIする場合は、EntryPointを自前で用意する必要があります。
collectAsStateWithLifecycle
ライフサイクルを考慮しないと、画面が表示されていないLifecycle.State.RESUMED
未満の状態でも表示言語の更新をcollect & Recompositionをトリガーし、場合によってはクラッシュします!
定義した LocalTextCatalog
を Composable全体に流すため、Themeの部分をProviderでラップします。
@Composable
fun YourTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
// ...中略...(プロジェクトの自動生成コードのまま)
val textCatalog by rememberTextCatalog()
CompositionLocalProvider(
LocalTextCatalog provides textCatalog,
) {
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
}
表示言語の変更を考慮したテキストの参照
アプリ独自の表現言語に応じたテキストを取得する関数stringResource
を実装します。内部でLocalTextCatalog.current
を参照するため、表示言語が更新されると関数stringResource
の使用箇所が Recompose されます。
@Composable
fun stringResource(id: TextId): String {
return if (LocalInspectionMode.current) {
// Previewでは `res/values/strings.xml`のテキストを代わりに表示させます
androidx.compose.ui.res.stringResource(id = id.xmlId)
} else {
when (val catalog = LocalTextCatalog.current) {
TextCatalog.Initializing -> throw IllegalStateException("not initialized yet")
is TextCatalog.Data -> catalog.data[id]
?: throw IllegalArgumentException("string not found. id: $id")
}
}
}
初期化処理の実装
アプリ起動時はTextCatalog.Initializing
であり、まだ表示言語ごとのテキストを読み込んでいません。このまま Compose からテキストを参照するとクラッシュします。初期表示の言語を設定する処理を追加します。
class BootUseCase @Inject constructor(
private val displayLanguageRepository: DisplayLanguageRepository,
) {
suspend operator fun invoke() {
// その他の初期化処理
delay(2000L)
// 初期表示の言語は英語で固定する
displayLanguageRepository.onLanguageChanged(DisplayLanguage.English)
}
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject
lateinit var bootUseCase: BootUseCase
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
YourTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
when (LocalTextCatalog.current) {
TextCatalog.Initializing -> LoadingScreen()
is TextCatalog.Data -> MainScreen()
}
}
}
}
lifecycleScope.launch {
bootUseCase()
}
}
}
LoadingScreen
初期化処理が完了するまでの画面です。アプリ表示言語に依存するUIは描画できませんので、stringResource(TextId)
関数は使えません。
初期化処理のタイミング
今回は実装の簡単のためMainActivity
から呼び出しますが、スプラッシュ画面などアプリの実装状況に応じて適切に実行してください。