[Android] ReduxKotlin + Jetpack Compose + PreferenceDataStore + Hilt
目的
ReduxKotlin + Jetpack Compose + DataStore + Hiltを使用して
アプリを起動したことあるかどうかというフラグを保存し、それによってアプリ起動時の制御を変える実装をする(チュートリアル画面に遷移させるかどうかの制御)
他にnavigationが必要だが、省略する
ReduxKotlinについては以前紹介したので、それを参照して頂けると
実装
依存の追加
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は参照を渡すようにしなければならない
現状のバージョンだとスレッドに関連する問題が起きるので下記のように記述する必要がある
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