Jetpack ComposeでActivityを再起動せずにアプリのテーマを変更する方法

*この記事は韓国語から翻訳したものです。不十分な部分があれば、いつでもフィードバックをいただければありがたいです！ ([オリジナル記事](https://blog.chungjungsoo.dev/dev-posts/compose-theme-viewmodel/), 同じく私が作成しました。)*

一般的にAndroidアプリでテーマを変更すると、Activityを再起動する必要があります。しかし、いくつかのアプリを利用していた時、テーマを変更してもActivityを再起動しないアプリをいくつか発見し、興味を持って分析した後、好みに合わせて変更して実装してみました。


## 新しい発見

Android開発をしていると、Activityのライフサイクルで`onCreate`は無条件に1回だけ呼び出されるという事実を知っているはずです。そうやってアプリを開発してる時、`onCreate`が二回呼び出される現象を経験して、原因を調べてみると、ActivityでConfigurationの変更が起こる何かをしたことに気づきます。([参考](https://developer.android.com/guide/topics/resources/runtime-changes)) 

テーマの変更もConfigurationの変更に該当し、テーマが変更されるとActivityも再起動されます。以前のチームプロジェクトでチームメンバーがテーマを即座に変更する機能を実装していた時、テーマが変更されるたびに `recreate` のためにDialogが消えたり消えたりする問題を経験し、最終的にDialogの確認ボタンを押したらテーマが変更されるように修正した記憶があります。その時、チームメンバーがNow In Androidアプリでテーマを変更してもActivityが再起動しないことを発見して、私たちのプロジェクトと[比較する記事(韓国語です)](https://github.com/boostcampwm2023/and09-PriceGuard/wiki/%ED%85%8C%EB%A7%88-%EB%B3%80%EA%B2%BD-(compose-vs-xml))を簡単に書いていて、面白くて読んでみました。使ってたアプリの中でもNow In Androidと同じようにActivityを再起動せずにテーマを変更するアプリがあったので、この機会にこれを完全に分析して、個人プロジェクトに適用してみることにしました。


## コード分析

### Now In Android

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3896189/869b7e3e-9c56-1c2b-5096-247169ceb258.gif" width="300" height="600"/>


[Now In Android](https://github.com/android/nowinandroid)アプリはGoogleが公式にKotlinとComposeで作ったサンプルアプリです。Googleが推奨してるAndroidアプリアーキテクチャーやBest Practicesを紹介するため作られたそうです。上の画像を見ると、Radioボタンをクリックするとすぐにテーマが変更されることが確認できます。もし、Activityが再起動されたらDialogが再生成されるモーションが見えるはずです。Dialogを押したらどのようなことが起こるのかから見てみましょう。

[`nowinandroid/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt`](https://github.com/android/nowinandroid/blob/main/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt)

```kotlin
@Composable
fun SettingsDialog(
    onDismiss: () -> Unit,
    viewModel: SettingsViewModel = hiltViewModel(),
) {
    val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle()
    SettingsDialog(
        onDismiss = onDismiss,
        settingsUiState = settingsUiState,
        onChangeThemeBrand = viewModel::updateThemeBrand,
        onChangeDynamicColorPreference = viewModel::updateDynamicColorPreference,
        onChangeDarkThemeConfig = viewModel::updateDarkThemeConfig,
    )
}

// ...

@Composable
fun SettingsDialog(
    settingsUiState: SettingsUiState,
    supportDynamicColor: Boolean = supportsDynamicTheming(),
    onDismiss: () -> Unit,
    onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit,
    onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,
    onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,
) {
    // ...

    SettingsPanel(
        settings = settingsUiState.settings,
        supportDynamicColor = supportDynamicColor,
        onChangeThemeBrand = onChangeThemeBrand,
        onChangeDynamicColorPreference = onChangeDynamicColorPreference,
        onChangeDarkThemeConfig = onChangeDarkThemeConfig,
    )
}

// ... 

@Composable
private fun ColumnScope.SettingsPanel(
    settings: UserEditableSettings,
    supportDynamicColor: Boolean,
    onChangeThemeBrand: (themeBrand: ThemeBrand) -> Unit,
    onChangeDynamicColorPreference: (useDynamicColor: Boolean) -> Unit,
    onChangeDarkThemeConfig: (darkThemeConfig: DarkThemeConfig) -> Unit,
) {
    // ...

    Column(Modifier.selectableGroup()) {
        SettingsDialogThemeChooserRow(
            text = stringResource(string.feature_settings_dark_mode_config_system_default),
            selected = settings.darkThemeConfig == FOLLOW_SYSTEM,
            onClick = { onChangeDarkThemeConfig(FOLLOW_SYSTEM) },
        )
        SettingsDialogThemeChooserRow(
            text = stringResource(string.feature_settings_dark_mode_config_light),
            selected = settings.darkThemeConfig == LIGHT,
            onClick = { onChangeDarkThemeConfig(LIGHT) },
        )
        SettingsDialogThemeChooserRow(
            text = stringResource(string.feature_settings_dark_mode_config_dark),
            selected = settings.darkThemeConfig == DARK,
            onClick = { onChangeDarkThemeConfig(DARK) },
        )
    }
}

```

`SettingsDialogThemeChooserRow()`で設定値を変更する場合、`onChange...`関数を呼び出す。これは乗り越え、一番上にオーバーロードされたSettingsDialog関数からSettingsViewModelの関数を呼び出します。SettingsViewModelを見てみましょう。

[`nowinandroid/feature/settings/src/main/kotlin/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt`](https://github.com/android/nowinandroid/blob/main/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt)

```kotlin
@HiltViewModel
class SettingsViewModel @Inject constructor(
    private val userDataRepository: UserDataRepository,
) : ViewModel() {
    val settingsUiState: StateFlow<SettingsUiState> =
        userDataRepository.userData
            .map { userData ->
                Success(
                    settings = UserEditableSettings(
                        brand = userData.themeBrand,
                        useDynamicColor = userData.useDynamicColor,
                        darkThemeConfig = userData.darkThemeConfig,
                    ),
                )
            }
            .stateIn(
                scope = viewModelScope,
                started = WhileSubscribed(5.seconds.inWholeMilliseconds),
                initialValue = Loading,
            )

    fun updateThemeBrand(themeBrand: ThemeBrand) {
        viewModelScope.launch {
            userDataRepository.setThemeBrand(themeBrand)
        }
    }

    fun updateDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
        viewModelScope.launch {
            userDataRepository.setDarkThemeConfig(darkThemeConfig)
        }
    }

    fun updateDynamicColorPreference(useDynamicColor: Boolean) {
        viewModelScope.launch {
            userDataRepository.setDynamicColorPreference(useDynamicColor)
        }
    }
}
```


Radioの値が変更された時呼び出される関数を見ると、`UserDataRepository`でテーマの値を変更する関数です。そして `UserDataRepository` にある `userData` の値をサブスクリプションが可能な `SettingUiState` というStateFlowに変換することが確認できます。これは `SettingsDialog` 関数を見ると `collectAsStateWithLifecycle()` 関数で購読しています。

`UserDataRepository` にある `userData` とテーマ値設定関数は [`OfflineFirstUserRepository.kt`](https://github.com/android/nowinandroid/blob/main/core/data/src/main/kotlin/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt) ファイルを確認すると、DataStoreの値をすぐに修正して反映することも確認できます。

> まとめ： `SettingsDialog` Radioクリック -> `SettingsViewModel` でテーマ変更関数呼び出し -> `UserDataRepository` でDataSourceを使ってDataStoreに保存されたテーマの値を変更 -> `UserDataRepository` から値を吐き出す `userData` で変更された値を反映 -> UIに反映

それでは、本格的にこのテーマの値が変更されたら、アプリのテーマがどう変わるか確認してみましょう。アプリのテーマを初期設定するコードは[`MainActivity.kt`](https://github.com/android/nowinandroid/blob/main/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt)にあります。


```kotlin
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        // ...

        var uiState: MainActivityUiState by mutableStateOf(Loading)

        // Update the uiState
        lifecycleScope.launch {
            lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
                viewModel.uiState
                    .onEach { uiState = it }
                    .collect()
            }
        }

        // ...

        setContent {
            
            // ...

            CompositionLocalProvider(
                LocalAnalyticsHelper provides analyticsHelper,
                LocalTimeZone provides currentTimeZone,
            ) {
                NiaTheme(
                    darkTheme = darkTheme,
                    androidTheme = shouldUseAndroidTheme(uiState),
                    disableDynamicTheming = shouldDisableDynamicTheming(uiState),
                ) {
                    @OptIn(ExperimentalMaterial3AdaptiveApi::class)
                    NiaApp(appState)
                }
            }

            // ...
        }
    }
}
```


`MainActivity` でテーマを設定する際に `uiState` をベースに `NiaTheme` を設定します。この部分が一番上なので、uiStateの値が変わると、アプリが順番にrecompositionが行われることになります。`MainActivityUiState`を見てみましょう。([`MainActivityViewModel.kt`](https://github.com/android/nowinandroid/blob/main/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivityViewModel.kt))

```kotlin
@HiltViewModel
class MainActivityViewModel @Inject constructor(
    userDataRepository: UserDataRepository,
) : ViewModel() {
    val uiState: StateFlow<MainActivityUiState> = userDataRepository.userData.map {
        Success(it)
    }.stateIn(
        scope = viewModelScope,
        initialValue = Loading,
        started = SharingStarted.WhileSubscribed(5_000),
    )
}

sealed interface MainActivityUiState {
    data object Loading : MainActivityUiState
    data class Success(val userData: UserData) : MainActivityUiState
}
```

`MainActivityViewModel` も含まれています。`MainActivityUiState` の `Success` data classを見ると、おなじみのデータ型が一つあります。それが `UserData` です。先ほど、`UserData`にテーマ値があることを覚えているはずです。この値は `MainActivityViewModel` が生成される時 `uiState` で `UserDataRepository` の `UserData` をFlow形式で取得して、この値をライフサイクルに合わせて `MainActivity` でサブスクライブして反映しています。`NiaTheme` では最終的にテーマの値に基づいてColor Schemeを変更し、それを適用した `MaterialTheme` を返します。

> 最終的なまとめ： `SettingsDialog` Radioをクリック -> `SettingsViewModel` でテーマ変更関数を呼び出す -> `UserDataRepository` でDataSourceを使ってDataStoreに保存されたテーマの値を変更 -> `UserDataRepository` から値を吐き出す `userData` に変更された値を反映 -> これをサブスクライブしていた `MainActivity` の `MainActivityUiState.uiState` の値を変更 -> その値で `NiaTheme` で各Color Schemeの色を変更 -> UIに反映


レビュー: 各ファイルが分離されていて読みやすかったです。しかし、Now In Androidの特性上、モジュールが分離されすぎて、フォルダとファイル構造がアプリの規模に比べて複雑だと思いました。 そのため、必要なコードとファイルの位置を探すのが少し難しかったです。Android Studioでレポをクローンした後、インデックスが作成された状態で探したら少し楽になりました！


### Seal

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3896189/59dc7cd4-32f9-d492-08d7-5b224a27c4ab.gif" width="300" height="600"/>


[Seal](https://github.com/JunkFood02/Seal)は有名なYouTubeのダウンロードCLIツールである[yt-dlp](https://github.com/yt-dlp/yt-dlp)がサポートする全ての動画をAndroidでダウンロードできるようにMaterial3 Designで作られたアプリです。このアプリもJetpack Composeで作成され、テーマが変更された時、Activityの再起動は見られません。分析してみましょう。

[`app/src/main/java/com/junkfood/seal/ui/page/settings/appearance/AppearancePreferences.kt`](https://github.com/JunkFood02/Seal/blob/main/app/src/main/java/com/junkfood/seal/ui/page/settings/appearance/AppearancePreferences.kt)

```kotlin
// ...

val isDarkTheme = LocalDarkTheme.current.isDarkTheme()
PreferenceSwitchWithDivider(title = stringResource(id = R.string.dark_theme),
    icon = if (isDarkTheme) Icons.Outlined.DarkMode else Icons.Outlined.LightMode,
    isChecked = isDarkTheme,
    description = LocalDarkTheme.current.getDarkThemeDesc(),
    onChecked = { PreferenceUtil.modifyDarkThemePreference(if (isDarkTheme) OFF else ON) },
    onClick = { onNavigateTo(Route.DARK_THEME) })

// ...
```

Now In Androidのように値がどのように変わるか見てみましょう。`PreferenceSwitchWithDivider`というコンポーネントが上のGIFで見たトグルスイッチ項目です。このアプリも `PreferenceSwitch` が切り替わるたびにテーマがすぐに変わるのですが、 `onChecked` 時に呼び出される `PreferenceUtil.modifyDarkThemePreference()` 関数を見てみましょう。


[`app/src/main/java/com/junkfood/seal/util/PreferenceUtil.kt`](https://github.com/JunkFood02/Seal/blob/main/app/src/main/java/com/junkfood/seal/util/PreferenceUtil.kt)


```kotlin

private val kv: MMKV = MMKV.defaultMMKV()

object PreferenceUtil {

    // ...

    private val mutableAppSettingsStateFlow = MutableStateFlow(
        AppSettings(
            DarkThemePreference(
                darkThemeValue = kv.decodeInt(
                    DARK_THEME_VALUE, DarkThemePreference.FOLLOW_SYSTEM
                ), isHighContrastModeEnabled = kv.decodeBool(HIGH_CONTRAST, false)
            ),
            isDynamicColorEnabled = kv.decodeBool(
                DYNAMIC_COLOR, DynamicColors.isDynamicColorAvailable()
            ),
            seedColor = kv.decodeInt(THEME_COLOR, DEFAULT_SEED_COLOR),
            paletteStyleIndex = kv.decodeInt(PALETTE_STYLE, 0)
        )
    )
    val AppSettingsStateFlow = mutableAppSettingsStateFlow.asStateFlow()
    
    // ...

    fun modifyDarkThemePreference(
        darkThemeValue: Int = AppSettingsStateFlow.value.darkTheme.darkThemeValue,
        isHighContrastModeEnabled: Boolean = AppSettingsStateFlow.value.darkTheme.isHighContrastModeEnabled
    ) {
        applicationScope.launch(Dispatchers.IO) {
            mutableAppSettingsStateFlow.update {
                it.copy(
                    darkTheme = AppSettingsStateFlow.value.darkTheme.copy(
                        darkThemeValue = darkThemeValue,
                        isHighContrastModeEnabled = isHighContrastModeEnabled
                    )
                )
            }
            kv.encode(DARK_THEME_VALUE, darkThemeValue)
            kv.encode(HIGH_CONTRAST, isHighContrastModeEnabled)
        }
    }

    // ...
}
```

シングルトンであるPreferenceUtil Objectで`AppSettingsStateFlow`にテーマ関連の値を保存し、これをStateFlow形式で出力しています。一つ変わった点は、値を読み込んで保存する時、MMKVというオブジェクトを使うことです。確認してみると、[MMKV](https://github.com/Tencent/MMKV)はTencentが作ったKey-Value保存フレームワークで、値が即座にすごく速く反映され、suspend呼び出しが必要ありません。([パフォーマンス指標](https://github.com/Tencent/MMKV/wiki/android_benchmark)を見ると、速度がすごく速いです) WeChatで使ってるフレームワークだそうです。Starの数は多いですが、あまり馴染みのないフレームワークなので不思議でした。(確認してみたらSealの開発者も中国人だそうです) 

[`app/src/main/java/com/junkfood/seal/ui/common/CompositionLocals.kt`](https://github.com/JunkFood02/Seal/blob/main/app/src/main/java/com/junkfood/seal/ui/common/CompositionLocals.kt)


```kotlin
@Composable
fun SettingsProvider(windowWidthSizeClass: WindowWidthSizeClass, content: @Composable () -> Unit) {
    PreferenceUtil.AppSettingsStateFlow.collectAsState().value.run {
        CompositionLocalProvider(
            LocalDarkTheme provides darkTheme,
            LocalSeedColor provides seedColor,
            LocalPaletteStyleIndex provides paletteStyleIndex,
            LocalTonalPalettes provides if (isDynamicColorEnabled && Build.VERSION.SDK_INT >= 31) dynamicDarkColorScheme(
                LocalContext.current
            ).toTonalPalettes()
            else Color(seedColor).toTonalPalettes(
                paletteStyles.getOrElse(paletteStyleIndex) { PaletteStyle.TonalSpot }
            ),
            LocalWindowWidthState provides windowWidthSizeClass,
            LocalDynamicColorSwitch provides isDynamicColorEnabled,
            content = content
        )
    }
}
```

[`app/src/main/java/com/junkfood/seal/MainActivity.kt`](https://github.com/JunkFood02/Seal/blob/main/app/src/main/java/com/junkfood/seal/MainActivity.kt)

```kotlin

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        // ...

        setContent {
            // ...
            SettingsProvider(windowWidthSizeClass = windowSizeClass.widthSizeClass) {
                SealTheme(
                    darkTheme = LocalDarkTheme.current.isDarkTheme(),
                    isHighContrastModeEnabled = LocalDarkTheme.current.isHighContrastModeEnabled,
                    isDynamicColorEnabled = LocalDynamicColorSwitch.current,
                ) {
                    HomeEntry(
                        downloadViewModel = downloadViewModel,
                        cookiesViewModel = cookiesViewModel,
                        isUrlShared = isUrlSharingTriggered
                    )
                }
            }
        }
        // ...
    }
}
```

コード量が多くないし、同時に見た時、理解しやすそうなのでまとめて持ってきました。まず、テーマの値を`SettingsProvider`で`CompositionLocal`を利用して`LocalDarkTheme`及び`LocalDynamicColorSwitch`で提供しています。

Jetpack ComposeでComposableを作る時、再利用性とテストしやすさを考慮してStatelessなComposableを作ります。このようなComposableが多くなると、一般的には各ComposableのパラメータでUI Treeに沿って下へ下へと値が渡されます。しかし、よく使う値や最下位Treeで使われる場合は、TreeにDepthが深くなるとTreeに沿ってパラメータを一つ一つ設定する必要があり、面倒なことが発生します。これを簡単に処理できるようにするのが `CompositionLocal` です。`CompositionLocal`は`LocalDarkTheme`のように特定の値を提供し、`CompositionLocal`が提供されたTreeの範囲内では別にパラメータを設定することなくその値を使うことができるようにしてくれます。 (詳細は[公式ドキュメント](https://developer.android.com/develop/ui/compose/compositionlocal))

また、Sealのコードを見てみましょう。まず、先ほどテーマ関連の値を持ってた `AppSettingsStateFlow` をサブスクライブして、その値が変わるたびに `CompositionLocalProvider` で `LocalDarkTheme` と `LocalDynamicColorSwitch` の値も変更しています。このように `CompositionLocal` に提供された `Local...` で始まる値は `MainActivity` で `SealTheme` を包み込み、SealTheme でテーマの値を設定します。`SealTheme` はやはりテーマの値に応じて Color Scheme を設定し、 `MaterialTheme` を返します。

> 最終的なまとめ： `PreferenceSwitchWithDivider` トグルクリック -> `PreferenceUtil` でテーマ変更関数を呼び出す -> `MMKV` に保存された KV テーマの値を即座に変更 -> `AppSettingsStateFlow` の値を変更 - > `AppSettingsStateFlow` の値を変更する -> `AppSettingsStateFlow` を購読していた `CompositionLocalProvider` でテーマ関連の `CompositionLocal` の値を変更 -> `MainActivity` で `SealTheme` を包む `CompositionLocal` のテーマ値を変更して `SealTheme` のテーマの色を変更 -> RecompositionでUIに反映する。


レビュー: 実装方法がかなり簡単で、初めて見るライブラリを使うので不思議でした。(KVを読んで記録することをUI thread blockやANRなしで可能)しかし、中国のサードパーティライブラリに依存してるのが少し残念です。


### Read You


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3896189/ba20d6ce-0c71-b89a-d07d-e0fc809db276.gif" width="300" height="600"/>


最後は[Read You](https://github.com/Ashinch/ReadYou)というアプリです。Read YouはMaterial3デザインをベースに作られたRSSリーダーアプリです。同じくJetpack Composeで作成されました。コードを分析してみましょう。

[`app/src/main/java/me/ash/reader/ui/page/settings/color/ColorAndStylePage.kt`](https://github.com/Ashinch/ReadYou/blob/main/app/src/main/java/me/ash/reader/ui/page/settings/color/ColorAndStylePage.kt)

```kotlin
@Composable
fun ColorAndStylePage(
    navController: NavHostController,
) {
    val darkTheme = LocalDarkTheme.current
    val darkThemeNot = !darkTheme
    
    // ...

    SettingItem(
        title = stringResource(R.string.dark_theme),
        desc = darkTheme.toDesc(context),
        separatedActions = true,
        onClick = {
            navController.navigate(RouteName.DARK_THEME) {
                launchSingleTop = true
            }
        },
    ) {
        RYSwitch(
            activated = darkTheme.isDarkTheme()
        ) {
            darkThemeNot.put(context, scope)
        }
    }
}
```


すでに `LocalDarkTheme` 型の変数があることから `CompositionLocal` を使っていることがわかります。`darkTheme`変数のタイプを明示的に書いてないのでコードでは見えませんが、 `darkTheme` 変数は `DarkThemePreference` タイプです。これを見てみましょう。

[`app/src/main/java/me/ash/reader/infrastructure/preference/DarkThemePreference.kt`](https://github.com/Ashinch/ReadYou/blob/main/app/src/main/java/me/ash/reader/infrastructure/preference/DarkThemePreference.kt)

```kotlin
sealed class DarkThemePreference(val value: Int) : Preference() {
    object UseDeviceTheme : DarkThemePreference(0)
    object ON : DarkThemePreference(1)
    object OFF : DarkThemePreference(2)

    override fun put(context: Context, scope: CoroutineScope) {
        scope.launch {
            context.dataStore.put(
                DataStoreKeys.DarkTheme,
                value
            )
        }
    }

    // ...

    @Composable
    @ReadOnlyComposable
    fun isDarkTheme(): Boolean = when (this) {
        UseDeviceTheme -> isSystemInDarkTheme()
        ON -> true
        OFF -> false
    }

    // ...
}
```

[`app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt`](https://github.com/Ashinch/ReadYou/blob/main/app/src/main/java/me/ash/reader/ui/ext/DataStoreExt.kt)

```kotlin
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
```


不思議なことに `DarkThemePreference` は `Preference` 型を継承しています。そして、CoroutineScopeとContextを使ってPreferences DataStoreに値を保存しています。ユニークな構造ですが、とりあえず整理してみます、

> まとめ： `RYSwitch` トグルクリック -> `CompositionLocal` にある `LocalDarkTheme` の値を **読み込む** -> `DarkThemePreference` の `put()` 関数を使って DataStore にテーマの値を保存 -> 続行...

値を読み込む部分を強調しましたが、これは`LocalDarkTheme`の値を新しい変数として宣言して使ったからです。ここで宣言した変数の値を変えても `LocalDarkTheme` の値は変わりません。次に `CompositionLocal` の方を確認してみましょう。

[`app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt`](https://github.com/Ashinch/ReadYou/blob/main/app/src/main/java/me/ash/reader/infrastructure/preference/Settings.kt)

```kotlin
// ...
val LocalDarkTheme =
    compositionLocalOf<DarkThemePreference> { DarkThemePreference.default }
// ...

@Composable
fun SettingsProvider(
    content: @Composable () -> Unit,
) {
    val context = LocalContext.current
    val settings = remember {
        context.dataStore.data.map {
            Log.i("RLog", "AppTheme: ${it}")
            it.toSettings()
        }
    }.collectAsStateValue(initial = Settings())

    CompositionLocalProvider(
        // ...
        LocalDarkTheme provides settings.darkTheme,
        // ...
    ) {
        content()
    }
}
```

[`app/src/main/java/me/ash/reader/infrastructure/android/MainActivity.kt`](https://github.com/Ashinch/ReadYou/blob/main/app/src/main/java/me/ash/reader/infrastructure/android/MainActivity.kt)


```kotlin
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        // ...

        setContent {
            CompositionLocalProvider(
                LocalImageLoader provides imageLoader,
            ) {
                AccountSettingsProvider(accountDao) {
                    SettingsProvider { 
                        // ...
                        HomeEntry(subscribeViewModel = subscribeViewModel)
                    }
                }
            }
        }
    }
}

```

`SettingsProvider` で `CompositionLocal` を供給しています。ここで、Provider Composableがmemoryを使ってDataStoreの値を取得して`CompositionLocal`に提供しています。やはり `MainActivity` から `CompositionLocalProvider` で `LocalDarkTheme` を提供しています。変わった点はテーマのscopeがHomeEntryを囲むことなく、HomeEntryの中にテーマが入っていることです。そして、このテーマも`CompositionLocal`のテーマの値によってColor Schemeの値を変えて`MaterialTheme`を返し、その中にアプリの画面が入ります。


> 最終的なまとめ： `RYSwitch` トグルクリック -> `CompositionLocal` にある `LocalDarkTheme` の値を **読み込む** -> `DarkThemePreference` の `put()` 関数を使って DataStore にテーマの値を保存 -> `SettingsProvider` で DataStore の値をサブスクライブして、変更するたびに `CompositionLocal` に提供 - > `MainActivity` をクリックします-> `MainActivity` で `LocalDarkTheme` を受け取る -> `HomeEntry` で `CompositionLocal` のテーマ値を変更して `MaterialTheme` テーマの色を変更 -> RecompositionでUIに反映する。

レビュー: Sealとテーマ関連の実装方法は似ていますが、アプリ構造と値を読んで保存する部分のコードが難解でした。思いもしなかった実装方法(ContextとCoroutineScopeの使用位置)に驚いたし、個人的には値を保存して読み込む部分だけRepositoryパターンのように分離されたらもっと読みやすかったと思います。DataStoreの非同期作業が入るけど、コードが同期のように読めるように構成されてるのが不思議でした。


## 私が選んだ方法

結局、Activityを再起動せずにテーマを変更するためには、まず、UI Treeの下部で呼び出された関数を使ってどうにかして値を変更し、その変更された値がUI Treeの上部で持っているテーマの値を変えて順番にRecompositionが起こるように実装する必要があると判断しました。

私はここで`ThemeViewModel`というものを作って、テーマ関連値に対するロジックはここに入れて、`CompositionLocal`を通して`ThemeViewModel`にあるテーマ設定値を提供する方法を選択しました。このようにすると、テーマ変更ロジックは `ThemeViewModel` 一箇所で処理してコードを探して管理することができるので便利だと思いました。`ThemeViewModel`の中ではRepositoryパターンを使ってテーマ関連の値をDataStoreに保存して読み込むように実装しました。

```kotlin
@HiltViewModel
class ThemeViewModel @Inject constructor(private val settingRepository: SettingRepository) : ViewModel() {

    private val _themeSetting = MutableStateFlow(ThemeSetting())
    val themeSetting = _themeSetting.asStateFlow()

    init {
        fetchThemes()
    }

    private fun fetchThemes() {
        viewModelScope.launch {
            _themeSetting.update { settingRepository.fetchThemes() }
        }
    }

    fun updateDynamicTheme(theme: DynamicTheme) {
        _themeSetting.update { setting ->
            setting.copy(dynamicTheme = theme)
        }
        viewModelScope.launch {
            settingRepository.updateThemes(_themeSetting.value)
        }
    }

    fun updateThemeMode(theme: ThemeMode) {
        _themeSetting.update { setting ->
            setting.copy(themeMode = theme)
        }
        viewModelScope.launch {
            settingRepository.updateThemes(_themeSetting.value)
        }
    }
}

data class ThemeSetting(
    val dynamicTheme: DynamicTheme = DynamicTheme.OFF,
    val themeMode: ThemeMode = ThemeMode.SYSTEM
)
```

```kotlin
val LocalDynamicTheme = compositionLocalOf { DynamicTheme.OFF }
val LocalThemeMode = compositionLocalOf { ThemeMode.SYSTEM }
val LocalThemeViewModel = compositionLocalOf<ThemeViewModel> {
    error("CompositionLocal LocalThemeViewModel is not present")
}

@Composable
fun ThemeSettingProvider(
    themeViewModel: ThemeViewModel = hiltViewModel(),
    content: @Composable () -> Unit
) {
    themeViewModel.themeSetting.collectManagedState().value.run {
        CompositionLocalProvider(
            LocalThemeViewModel provides themeViewModel,
            LocalDynamicTheme provides dynamicTheme,
            LocalThemeMode provides themeMode,
            content = content
        )
    }
}
```

```kotlin
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    // ...
    override fun onCreate(savedInstanceState: Bundle?) {
        // ...
        setContent {
            // ...
            ThemeSettingProvider {
                GPTMobileTheme(
                    dynamicTheme = LocalDynamicTheme.current,
                    themeMode = LocalThemeMode.current
                ) {
                    SetupNavGraph(navController)
                }
            }
        }
    }
}
```


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3896189/0a43c283-0c18-8914-b2ec-b31ae4f4b39f.gif" width="300" height="600"/>


テーマ関連のコードはこれで終わりです。上で比較したプロジェクトよりコード量も少なく簡単だと思います。 また、テーマを変更したい時は、サブUI Treeのどこからでも `LocalThemeViewModel` を使ってテーマの値をすぐに変更することもできます。今後、様々な値を追加してアプリ関連の設定で拡張することも容易だと思います。

直接実装したアプリのソースコードは[ここ](https://github.com/Taewan-P/gpt_mobile)で確認することができます。アーキテクチャーパターンに決まった正解はありませんが、この方法も正しくないと思うかもしれません。(レポIssueまたはメールでフィードバックをいただければ幸いです).