📱

[Android] ReduxKotlin + Jetpack Compose + PreferenceDataStore + Hilt

2024/04/18に公開

目的

ReduxKotlin + Jetpack Compose + DataStore + Hiltを使用して
アプリを起動したことあるかどうかというフラグを保存し、それによってアプリ起動時の制御を変える実装をする(チュートリアル画面に遷移させるかどうかの制御)

他にnavigationが必要だが、省略する

ReduxKotlinについては以前紹介したので、それを参照して頂けると
https://zenn.dev/giglancer/articles/f34be37dde7723

実装

依存の追加

toml

[versions]
# hilt
hilt-version = "2.50"
# redux
redux-version = "0.5.5"
redux-compose-version = "0.6.0"
# dataStore
data-store-version = "1.0.0"
ksp = "1.9.10-1.0.13"

[libraries]
# hilt
android-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt-version"}
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt-version"}
# redux
redux-threadsafe = { module = "org.reduxkotlin:redux-kotlin-threadsafe-jvm", version.ref = "redux-version"}
redux-compose = { module = "org.reduxkotlin:redux-kotlin-compose-jvm", version.ref = "redux-compose-version"}
# data-store
data-store = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "data-store-version"}

[bundles]
redux = ["redux-threadsafe", "redux-compose"]

[plugins]
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt-version"}
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }

project::build.gradle.kts

plugins {
    alias(libs.plugins.hilt) apply false
    alias(libs.plugins.ksp) apply false
}

app:buid.gradle.kts

plugins {
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)
}

dependencies {
    implementation(libs.android.hilt)
    ksp(libs.hilt.compiler)
    implementation(libs.bundles.redux)
    implementation(libs.data.store)
}

Preference DataStoreの実装

後々増えてもいいようにdata classで定義

// UserSettings
data class UserSettings(
    val isLaunchedTutorialScreen: Boolean
)
interface DataStoreRepository {

    val userSettingFlow: Flow<UserSettings>

    suspend fun updateUserSettings(isLaunchedTutorialScreen: Boolean)
}
class DataStoreRepositoryImpl @Inject constructor(private val dataStore: DataStore<Preferences>) : DataStoreRepository {

    companion object {
        val IS_LAUNCHED_TUTORIAL_SCREEN = booleanPreferencesKey("isLaunchedTutorialScreen")
    }

    override val userSettingFlow: Flow<UserSettings> = dataStore.data.map { preferences ->
        val isLaunchedTutorialScreen = preferences[IS_LAUNCHED_TUTORIAL_SCREEN] ?: false
        UserSettings(isLaunchedTutorialScreen)
    }

    override suspend fun updateUserSettings(isLaunchedTutorialScreen: Boolean) {
        dataStore.edit { preferences ->
            preferences[IS_LAUNCHED_TUTORIAL_SCREEN] = isLaunchedTutorialScreen
        }
    }
}

DI

事前準備

hiltを使えるようにする

@HiltAndroidApp
class AppApplication : Application()
@AndroidEntryPoint
class MainActivity : ComponentActivity()
....
<application
        android:name=".AppApplication"
....

Preference
dataStoreのDI

// DataStoreModule
const val USER_SETTINGS = "userSettings"
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = USER_SETTINGS)

@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
    @Provides
    fun provideDataStore(@ApplicationContext context: Context): DataStore<Preferences> {
        return context.dataStore
    }

    @Provides
    fun provideDataStoreRepository(dataStore: DataStore<Preferences>): DataStoreRepository {
        return DataStoreRepositoryImpl(dataStore)
    }
}

Redux実装

今回はスプラッシュ画面を独自に作ってその画面で既にアプリが過去に起動していなかったらチュートリアル画面に遷移し、そうではない場合はHome画面に遷移させる想定なので

SplashScreenState

ルートのState

SplashScreenAction

DataStoreMiddleWareAction

userSettingReducer

ルートreducer


DataStoreMiddleWare


Store

の順番で実装していく

SplashScreenState

data class SplashScreenState(
    val isLaunchedTutorialScreen: Boolean = false
)

ルートのState

data class State(
        val status: Status = Status.Initialize,
    val splashScreenState: SplashScreenState = SplashScreenState(),
)

Statusを

sealed class Status {
    data object Initialize : Status()

    data object Processing : Status()

    data object Success : Status()

    data object Error : Status()
}

SplashScreenAction

最初に保存されたデータを読み込むためのAction

sealed class SplashScreenAction {
    data object Load: SplashScreenAction()
}

DataStoreMiddleWareAction

UIでActionが投げられてMiddleWareでデータを取得した後Storeに変更を伝えるAction

sealed class DataStoreMiddleWareAction {
    data class ReduceSplashScreenState(val isLaunched: Boolean) : DataStoreMiddleWareAction()
}

userSettingReducer

実際に値を更新する

fun userSettingReducer(
    state: State,
    action: Any,
): State =
    when (action) {
        is DataStoreMiddleWareAction.ReduceSplashScreenState -> {
            state.copy(
                splashScreenState = state.splashScreenState.copy(
                    isLaunchedTutorialScreen = action.isLaunched
                )
            )
        }
        else -> state
    }

ルートreducer

fun reducer(
    state: State,
    action: Any,
): State =
    when (action) {
        is DataStoreMiddleWareAction -> userSettingReducer(state, action)
        else -> state
    }

DataStoreMiddleWare

Middleware<State>を継承したMiddleWare Classを作る

class DataStoreMiddleWare
    @Inject
    constructor(
        private val dataStoreRepository: DataStoreRepository
    ) : Middleware<State> {
        override fun invoke(store: TypedStore<State, Any>): (next: Dispatcher) -> (action: Any) -> Any {
            return { next ->
                { action ->
                    val coroutineScope = CoroutineScope(Dispatchers.IO)
                    when (action) {
                        is SplashScreenAction.Load -> {
                            coroutineScope.launch {
                                try {
                                    store.dispatch(StatusAction.Processing)

                                    dataStoreRepository.getUserSettingFlow.collect {
                                        val isLaunchedTutorialScreen = it.isLaunchedTutorialScreen
                                        store.dispatch(DataStoreMiddleWareAction.ReduceSplashScreenState(isLaunchedTutorialScreen))

                                        store.dispatch(StatusAction.Success)
                                    }
                                } catch (e: IOException) {
                                    store.dispatch(StatusAction.Error)
                                }
                            }
                        }
                        is チュートリアル画面のAction -> {
                            try {
                                coroutineScope.launch {
                                    dataStoreRepository.updateUserSettings(true)
                                }
                            } catch (e: IOException) {
                                store.dispatch(StatusAction.Error)
                            }
                        }
                        else -> next(action)
                    }
            }
        }
    }
}

Store

作ったルートのStateとReducer、Middlewareを設定する
Reducerは参照を渡すようにしなければならない

現状のバージョンだとスレッドに関連する問題が起きるので下記のように記述する必要がある
https://github.com/reduxkotlin/redux-kotlin/issues/77

class StoreProvider @Inject constructor(
    dataStoreMiddleWare: DataStoreMiddleWare,
){
    private fun <State> synchronizeStore(): StoreEnhancer<State> {
        return { storeCreator ->
            { reducer, initialState, en: Any? ->
                val store = storeCreator(reducer, initialState, en)
                val synchronizedStore = ThreadSafeStore(store)
                synchronizedStore
            }
        }
    }
    val store = createThreadSafeStore(
        ::reducer,
        State(),
        compose(
            applyMiddleware(
                dataStoreMiddleWare,
                roomMiddleWare
            ),
            synchronizeStore()
        )
    )

storeのDI

@Module
@InstallIn(SingletonComponent::class)
object StoreModule {
    @Provides
    @Singleton
    fun provideStore(
        dataStoreMiddleWare: DataStoreMiddleWare,
    ): StoreProvider {
        return StoreProvider(
            dataStoreMiddleWare,
        )
    }
}

UI実装

MainActivity
MainActivityでStore初期化してそのStoreをStoreProviderに渡す

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    @Inject lateinit var storeProvider: StoreProvider
    private val store: Store<State>
        get() = storeProvider.store

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Theme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    StoreProvider(store = store) {
                        App()
                    }
                }
            }
        }
    }
}

SplashScreen

必要なsplashScreenStateだけ定義する
dispatch(SplashScreenAction.Load)でActionを発行して起動済みかどうかのデータがあるが確認する
plashScreenState.isLaunchedTutorialScreenがtrueだったらHomeにそうではなかったらチュートリアル画面に遷移させる

@Composable
fun SplashScreen(
    modifier: Modifier = Modifier,
    navigateToTutorial: () -> Unit,
    navigateToHome: () -> Unit,
) {
    val splashScreenState by selectState<State, SplashScreenState> { splashScreenState }
    val status by selectState<State, Status> { status }
    val dispatch = rememberDispatcher()

    LaunchedEffect(Unit) {
        delay(500)
        dispatch(SplashScreenAction.Load)
    }

    LaunchedEffect(status) {
        if (status == Status.Success) {
            if (splashScreenState.isLaunchedTutorialScreen) {
                navigateToHome()
            } else {
                navigateToTutorial()
            }
        }
    }
    Column(
        modifier = modifier
            .background(color = Color.Black)
            .fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Image(
            modifier = Modifier.fillMaxWidth(),
            painter = painterResource(id = Splash.icon),
            contentDescription = "アプリアイコン"
        )
    }
}

Discussion