App 状态和用户状态
在日常项目开发中,我们通常都会维护一些状态,比如 App 级别的全局状态有用户是否已登录、是否同意了隐私协议、是否完成新手引导等。或者是用户级别的状态,比如首页是单列还是双列展示、是否开启深色模式、某些功能开关等。 这些状态的特点是不复杂,但是访问和修改频繁,并且需要立马反应到 UI 上。
直接使用 DataStore 的问题
SharedPreference 实际上是可以的,但无论是使用 commit 或 apply 都容易引发 ANR,非常在意这些的话是不建议直接使用的。
原本这些状态我都是通过 DataStore 进行的,但是这种以 Flow + 协程为核心的异步存储的方案其实体验不是很好,没有指哪打哪的那种流畅感。
举个很常见的场景,在设置页修改首页布局样式,会出现两种情况。
- 确保状态更新后再退出设置页面,UI 逻辑上是安全的,但用户会明显感觉到操作有点“卡”,像是被强行拦了一下。
- 修改完直接退出设置页,返回首页发现布局没变,得重进一次页面才能生效。
本质原因是 UI 状态直接依赖持久化结果,而不是依赖一个内存中的“实时状态源”。
通过订阅 datastore flow 的方案能一定程度上解决问题,但实际上真的非常不好用,代码逻辑也比较抽象,编写代码时脑子里要一直维持着 flow 流转的消耗,维护代码也可能不小心碰了哪个源头导致页面更新不正常,非常费神。
从前端 zustand 框架得到的思路
如果把问题换个角度来看,其实这些状态更像是:
- 一个全局可访问的状态容器
- UI 需要实时响应它的变化
- 持久化只是“顺带做的事”
在前端生态里,这类问题已经被大量讨论过了。 比如 Zustand 这种状态管理框架,本身就是以内存状态为核心,而持久化只是一个插件能力,甚至持久化也只是乐观的,不保证持久化成功。
所以我们可以仿照 Zustand 自己编写一个,满足以下几个要求即可
- 状态可以被单独监听
- 状态更新是同步生效的
- 状态会自动持久化到本地,内存先更新,存储慢慢写,失败就不管了。
多端状态同步, 这个光靠客户端基本搞不定,涉及冲突、版本、回滚等问题,实现成本太高,这里就不展开了
Android 端实现
Flutter 端基本可以完全参照 Android,而且更加简单,所以只提供 Android 端的实现思路。
全局只需要一个 AppStore
我们使用 serialization+datastore 作为本地存储的框架,直接将整个状态序列化成 json 存储 json。
@Serializable
enum class HomeLayout { SingleColumn, DoubleColumn }
@Serializable
data class UserState(
val homeLayout: HomeLayout = HomeLayout.SingleColumn,
val darkMode: Boolean = false
)
@Serializable
data class AppState(
val userId: String? = null,
val token: String? = null,
val agreedPrivacy: Boolean = false,
val deviceId: String = UUID.randomUUID().toString(),
// per-user states (key=userId, "0" for guest)
val userStates: Map<String, UserState> = mapOf("0" to UserState())
) {
val isLoggedIn: Boolean get() = !userId.isNullOrBlank() && !token.isNullOrBlank()
fun currentUserKey(): String = userId?.takeIf { it.isNotBlank() } ?: "0"
fun currentUserState(): UserState = userStates[currentUserKey()] ?: UserState()
}
object AppStore {
private val json: Json = Json {
isLenient = true
coerceInputValues = true
encodeDefaults = true
ignoreUnknownKeys = true
}
private val _state = MutableStateFlow(AppState())
val state: StateFlow<AppState> = _state.asStateFlow()
private val readyDeferred = CompletableDeferred<Unit>()
suspend fun awaitReady() = readyDeferred.await()
private lateinit var scope: CoroutineScope
private lateinit var dataStore: DataStore<AppState>
@OptIn(ExperimentalSerializationApi::class)
fun init(context: Context) {
val appContext = context.applicationContext
scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
dataStore = DataStoreFactory.create(
serializer = object : Serializer<AppState> {
override val defaultValue: AppState = AppState()
override suspend fun readFrom(input: InputStream): AppState {
return runCatching {
json.decodeFromStream<AppState>(input)
}.getOrDefault(defaultValue)
}
override suspend fun writeTo(t: AppState, output: OutputStream) {
json.encodeToStream(t, output)
}
},
produceFile = { appContext.filesDir.resolve("app_state.json") }
)
scope.launch(Dispatchers.IO) {
val restored = dataStore.data.first()
withContext(Dispatchers.Main.immediate) {
_state.value = restored.ensureBucket(restored.currentUserKey())
if (!readyDeferred.isCompleted) readyDeferred.complete(Unit)
}
}
}
// 确保用户桶存在
private fun AppState.ensureBucket(userKey: String): AppState {
return if (userStates.containsKey(userKey)) this
else copy(userStates = userStates + (userKey to UserState()))
}
// ---- selectors ----
fun <T> select(mapper: (AppState) -> T): Flow<T> =
state.map(mapper).distinctUntilChanged()
fun darkModeFlow(): Flow<Boolean> =
select { it.currentUserState().darkMode }
fun homeLayoutFlow(): Flow<HomeLayout> =
select { it.currentUserState().homeLayout }
// ---- internal update ----
private fun update(reducer: (AppState) -> AppState) {
check(readyDeferred.isCompleted) {
"AppStore is not ready yet. Call awaitReady() before using write APIs."
}
val newState = reducer(_state.value)
_state.value = newState
scope.launch(Dispatchers.IO) {
dataStore.updateData { newState }
}
}
private fun updateUserState(reducer: (UserState) -> UserState) = update { s ->
val key = s.currentUserKey()
val cur = s.userStates[key] ?: UserState()
val next = reducer(cur)
if (next == cur) s
else s.copy(userStates = s.userStates + (key to next))
}
fun login(userId: String, token: String) = update { s ->
val withAuth = s.copy(userId = userId, token = token)
withAuth.ensureBucket(userId)
}
fun logout() =
update { it.copy(userId = null, token = null) }
// ---- user-level (per userId, including guest "0") ----
fun setDarkMode(enabled: Boolean) =
updateUserState { it.copy(darkMode = enabled) }
fun toggleDarkMode() =
updateUserState { it.copy(darkMode = !it.darkMode) }
fun setHomeLayout(layout: HomeLayout) =
updateUserState { it.copy(homeLayout = layout) }
}
正确的初始化
在 Application 中初始化
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
AppStore.init(this)
}
}
AppStore 初始化是异步从本地磁盘读取状态的,如果初始化尚未完成,读取到的状态将会是默认值。 所以最好在启动页 awaitReady(),这种情况最好配合 SplashScreen 使用,例如:
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
class LauncherActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// 1. 安装 SplashScreen(必须在 super.onCreate 之前或紧跟之前)
val splashScreen = installSplashScreen()
// 2. 在 AppStore ready 前,持续显示 Splash
splashScreen.setKeepOnScreenCondition { !AppStore.isReady }
super.onCreate(savedInstanceState)
// 3. 等待状态恢复后做路由
lifecycleScope.launch {
AppStore.awaitReady()
val next = if (AppStore.state.value.isLoggedIn) {
Intent(this@LauncherActivity, MainActivity::class.java)
} else {
Intent(this@LauncherActivity, LoginActivity::class.java)
}
startActivity(next)
finish()
}
}
}