App 状态和用户状态

在日常项目开发中,我们通常都会维护一些状态,比如 App 级别的全局状态有用户是否已登录、是否同意了隐私协议、是否完成新手引导等。或者是用户级别的状态,比如首页是单列还是双列展示、是否开启深色模式、某些功能开关等。 这些状态的特点是不复杂,但是访问和修改频繁,并且需要立马反应到 UI 上。

直接使用 DataStore 的问题

SharedPreference 实际上是可以的,但无论是使用 commit 或 apply 都容易引发 ANR,非常在意这些的话是不建议直接使用的。

原本这些状态我都是通过 DataStore 进行的,但是这种以 Flow + 协程为核心的异步存储的方案其实体验不是很好,没有指哪打哪的那种流畅感。

举个很常见的场景,在设置页修改首页布局样式,会出现两种情况。

  1. 确保状态更新后再退出设置页面,UI 逻辑上是安全的,但用户会明显感觉到操作有点“卡”,像是被强行拦了一下。
  2. 修改完直接退出设置页,返回首页发现布局没变,得重进一次页面才能生效。

本质原因是 UI 状态直接依赖持久化结果,而不是依赖一个内存中的“实时状态源”。

通过订阅 datastore flow 的方案能一定程度上解决问题,但实际上真的非常不好用,代码逻辑也比较抽象,编写代码时脑子里要一直维持着 flow 流转的消耗,维护代码也可能不小心碰了哪个源头导致页面更新不正常,非常费神。

从前端 zustand 框架得到的思路

如果把问题换个角度来看,其实这些状态更像是:

  • 一个全局可访问的状态容器
  • UI 需要实时响应它的变化
  • 持久化只是“顺带做的事”

在前端生态里,这类问题已经被大量讨论过了。 比如 Zustand 这种状态管理框架,本身就是以内存状态为核心,而持久化只是一个插件能力,甚至持久化也只是乐观的,不保证持久化成功。

所以我们可以仿照 Zustand 自己编写一个,满足以下几个要求即可

  1. 状态可以被单独监听
  2. 状态更新是同步生效的
  3. 状态会自动持久化到本地,内存先更新,存储慢慢写,失败就不管了。
  4. 多端状态同步, 这个光靠客户端基本搞不定,涉及冲突、版本、回滚等问题,实现成本太高,这里就不展开了

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()
        }
    }
}