動的に変更する表示言語をComposeに反映する

# Androidの多言語対応と問題

Androidアプリ開発で多言語対応を行う場合は、言語ごとに `res/values*/strings.xml`ファイルを用意するだけで簡単に実装が可能です

- `res/values/strings.xml`：　デフォルトのLocale
- `res/values-${言語コード}/strings.xml`： 指定した言語のLocale

https://developer.android.com/training/basics/supporting-devices/languages?hl=ja

多くの場合は十分ですが、特別な要求があると問題が発生します。

- アプリの表示が即座には反映されない  
  設定アプリから言語を変更しても、起動中のアプリの表示にすぐには反映されません。アプリを再起動するかアプリ側で特別な対応が必要です。
- Androidシステムの言語設定に依存する  
  アプリ内の独自の表示言語を使いたい場合は対応できません。

# 完成物

システムの言語設定とは別にアプリ内部の表示言語を保持し、ユーザ操作により動的に変化する表示言語をUIに反映させます。UIはComposeで実装しています。


<!-- 動作様子のスクリーンショット動画 -->

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/a4858c18-5e9f-3968-71a8-a9a1daef623f.gif" width="300">

各言語の翻訳は ChatGPT に丸投げしましたので、悪しからず。


アプリのコードは[GitHubリポジトリで公開しています。](https://github.com/Seo-4d696b75/dynamic-text-compose)

# 実装と解説

主要ライブラリのバージョン

- compose-bom:2022.10.00
- hilt-android:2.44
- kotlinx-serialization-json:1.5.1

## 表示言語と翻訳ファイルの用意

アプリ独自の表示言語を`enum`で用意してあげます。

```kotlin
enum class DisplayLanguage(
    val code: String
) {
    English("en"), 
    Japanese("ja"), 
    French("fr"), 
    Thai("th"),
}
```

次にアプリで使用するテキストidの一覧も`enum`で用意します


```kotlin
@Suppress("EnumEntryName")
enum class TextId(
   @StringRes val xmlId: Int
) {
    app_bar_title(R.string.app_bar_title),
    home_title(R.string.home_title),
    // ...以下略...
}
```

:::note info
Previewの対応
Previewでは `res/values/strings.xml`のテキストを代わりに表示させるため、対応する`@StringRes`を指定しています。
:::


同時に対応する翻訳ファイルを`assets/text_${言語コード}.json`に用意します。
```json
{
  "app_bar_title": "SampleApp",
  "home_title": "Hello, Android!",
  ...以下略...
}
```

## リポジトリの実装

表示言語ごとのテキストを自前で管理します。

```kotlin
sealed interface TextCatalog {
    object Initializing : TextCatalog
    data class Data(
        val language: DisplayLanguage,
        val data: Map<TextId, String>,
    ) : TextCatalog
}
```

UI側で表示言語の変更を監視するため、リポジトリからは`Flow`で公開します。
```kotlin
interface DisplayLanguageRepository {
    val catalog: StateFlow<TextCatalog>
    fun onLanguageChanged(language: DisplayLanguage)
}
```

表示言語が変更されたら`assets/*`に用意した翻訳ファイルを読み出します。
```kotlin
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 では、コンポーズ可能な関数を新しいデータで再度呼び出します。すると、関数が再コンポーズされます。

https://developer.android.com/jetpack/compose/mental-model?hl=ja#recomposition

しかし、アプリ表示言語を引数で渡そうとすると、テキスト表示がある全てのComposableに引数のバケツリレーが必要となります。画面数を多い大規模なアプリとなれば実装は現実的ではありません。そこで `CompositionLocal`を利用し、アプリ表示言語の情報をComposable全体に暗黙的に流します。

https://developer.android.com/jetpack/compose/compositionlocal?hl=ja

表示言語の更新がUIに反映されるまでのデータの流れは以下のようになります。

![dynamic_text_data_flow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/440643/e598a9fa-e978-e34d-0e6c-6832d99be592.png)

## 表示言語のCompositionLocalを実装

```kotlin: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
}
```

:::note info
Compose + Hilt
ViewModelやActivity以外でDIする場合は、EntryPointを自前で用意する必要があります。
:::

:::note warn
collectAsStateWithLifecycle
ライフサイクルを考慮しないと、画面が表示されていない`Lifecycle.State.RESUMED`未満の状態でも表示言語の更新をcollect & Recompositionをトリガーし、場合によってはクラッシュします！
:::


定義した `LocalTextCatalog` を Composable全体に流すため、Themeの部分をProviderでラップします。

```kotlin:CompositionLocalのスコープ設定
@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 されます。

```kotlin:UI側からテキストを参照する
@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 からテキストを参照するとクラッシュします。初期表示の言語を設定する処理を追加します。

```kotlin
class BootUseCase @Inject constructor(
    private val displayLanguageRepository: DisplayLanguageRepository,
) {
    suspend operator fun invoke() {
        // その他の初期化処理
        delay(2000L)
        // 初期表示の言語は英語で固定する
        displayLanguageRepository.onLanguageChanged(DisplayLanguage.English)
    }
}
```

```kotlin
@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()
        }
    }
}
```

:::note warn
LoadingScreen
初期化処理が完了するまでの画面です。アプリ表示言語に依存するUIは描画できませんので、`stringResource(TextId)`関数は使えません。
:::

:::note info
初期化処理のタイミング
今回は実装の簡単のため`MainActivity`から呼び出しますが、スプラッシュ画面などアプリの実装状況に応じて適切に実行してください。
:::