Skip to content

持久化

UsePersisted 让一个值在组件重新挂载之后依然存在 —— 用户离开导航、组件卸载、用户导航回来,被捕获的状态仍在。它是「跨标签切换保留用户输入的内容」或「在用户打开侧边窗格时记住列表里选中的行」这类需求的正确 Hook。它不是「写入磁盘以便值能在应用重启后幸存」的正确 Hook;后者需要在 UseEffect 里搭一座磁盘桥,下文会讲。

// UsePersisted with explicit Window scope. The text outlives a re-mount
// of the component (e.g. navigation away and back) but is dropped when
// the host window closes. Replace PersistedScope.Window with
// PersistedScope.Application for process-lifetime persistence.
class NotesEditor : Component
{
    public override Element Render()
    {
        var (text, setText) = UsePersisted(
            "notes/body",
            initialValue: "",
            scope: PersistedScope.Window);

        return VStack(8,
            SubHeading("Notes"),
            TextBox(text, setText, placeholderText: "Start typing…")
                .AutomationName("Notes body")
                .Width(380),
            TextBlock($"{text.Length} characters").Opacity(0.6)
        ).Padding(16);
    }
}

笔记编辑器 —— UsePersisted 演示

查找每次挂载只发生一次:该 Hook 在具名作用域中查找 "notes/body",若存在就返回缓存值(否则返回提供的 initialValue),并在每次状态更新时把最新值写回该作用域。当组件卸载、稍后重新挂载时,第二次挂载会找到存储的值并跳过默认值。

参考

API 返回 说明
UsePersisted<T>(key, initialValue) (T, Action<T>) 默认使用 PersistedScope.Application
UsePersisted<T>(key, initialValue, scope) (T, Action<T>) 显式指定作用域 —— 新代码推荐这种。
PersistedScope.Window 枚举值 绑定到宿主窗口;卸载时丢弃。
PersistedScope.Application 枚举值 进程生命周期;跨窗口幸存。
IPersistedStateScope 接口 LRU 有界缓存;TryGet/Set/Remove
ApplicationPersistedScope.Default 静态 进程范围单例,4096 条目的 LRU。
ReactorWindow.PersistedScope 属性 PersistedScope.Window 解析到的逐窗口作用域。

该缓存仅在内存中。它由 LRU 限制(应用作用域默认 4096 条目),并注册了操作系统内存压力通知 —— 当宿主发出压力信号时,该作用域会收缩到容量的 25%。框架内部不做任何到磁盘的序列化。

作用域

PersistedScope.Window 把值绑定到组件所在的 ReactorWindow。同一个应用组件类的两个已打开窗口,在同一键下持有彼此独立的状态。窗口关闭时,整个 WindowPersistedScope 被释放,其条目随之丢弃。

PersistedScope.Application 在同一进程的多个窗口之间幸存。先后针对同一键打开的两个窗口,后者会看到前者的值。进程退出总是清除该状态。

// Survives a tab swap; dropped on window close.
var (filter, setFilter) = UsePersisted(
    "list/filter", "", PersistedScope.Window);

// Survives navigation across windows in this process.
var (token, setToken) = UsePersisted(
    "auth/token", "", PersistedScope.Application);

双参数重载 UsePersisted(key, initial) 出于向后兼容默认使用 PersistedScope.Application。分析器 REACTOR_PERSIST_001 现在会对它发出警告 —— 进程范围的状态会在共享同一键的窗口或标签之间串味,而这种问题直到两个窗口同时打开才会显形。随附的代码修复提供 PersistedScope.Window(推荐)或显式的 PersistedScope.Application;无论如何,新代码都应显式传入作用域。

带版本的结构迁移

当存于某个键下的结构发生变化时,绝不要复用同一个键。在键后缀里递增版本,并写一个一次性的迁移器,在应用启动时把 v1 载荷迁移到 v2:

// Versioned persisted shape. When the field set changes, bump the
// version and migrate forward. The reader matches on the stored shape
// and never trusts the cache to hold a current schema.
record NotesStateV1(string Body, DateTimeOffset LastEdit);
record NotesStateV2(string Body, DateTimeOffset LastEdit, string Title);

class VersionedNotesEditor : Component
{
    public override Element Render()
    {
        var initialState = UseMemo(
            () => new NotesStateV2("", DateTimeOffset.Now, ""),
            Array.Empty<object>());
        var (state, setState) = UsePersisted(
            "notes/state-v2",
            initialValue: initialState,
            scope: PersistedScope.Application);

        return VStack(8,
            TextBox(state.Title, t => setState(state with { Title = t }),
                placeholderText: "Title", header: "Title"),
            TextBox(state.Body, b => setState(state with { Body = b, LastEdit = DateTimeOffset.Now }),
                placeholderText: "Body", header: "Body")
        );
    }

    // One-shot migration from the v1 key to the v2 key. Run once at app
    // startup; thereafter the v1 key is empty and never consulted again.
    public static void MigrateOnce(IPersistedStateScope scope)
    {
        if (scope.TryGet<NotesStateV1>("notes/state-v1", out var v1))
        {
            scope.Set("notes/state-v2",
                new NotesStateV2(v1.Body, v1.LastEdit, Title: ""));
            scope.Remove("notes/state-v1");
        }
    }
}

有两个设计选择值得点出:

  • 版本由键承载,而不是载荷。 一个相信缓存里始终是当前 schema 的读取方,离一次运行时转换异常只差一次结构变更。按带版本的键读取,能让未知版本的查找干净地落空并回退到默认值。
  • 迁移一次,然后丢掉旧键。 迁移器的 Remove 调用释放了旧槽位,LRU 不会永远携带两份副本。请在应用启动时运行迁移器 —— 而不是在组件里 —— 这样它会在任何消费方看到半填充状态之前执行。

磁盘桥(跨进程持久化)

UsePersisted 不写磁盘。对于需要在进程重启后幸存的状态 —— 应用设置、最后打开的文档、窗口摆放 —— 把 UsePersisted(或 UseState)与一个把写入镜像到磁盘的 UseEffect 结合起来:

// Disk-backed bridge. UsePersisted alone is in-memory only — to outlive
// a process restart, mirror to disk in UseEffect. The state hook holds
// the live value; the effect writes it whenever it changes.
class PersistentSettings : Component
{
    private static readonly string SettingsPath = System.IO.Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
        "MyApp", "settings.json");

    public override Element Render()
    {
        // Seed from disk once via UseMemo; UsePersisted holds the live value
        // across re-mounts; the effect mirrors writes to disk.
        var initial = UseMemo(LoadFromDisk, Array.Empty<object>());
        var (settings, setSettings) = UsePersisted(
            "settings", initial, PersistedScope.Application);

        UseEffect(() =>
        {
            Directory.CreateDirectory(System.IO.Path.GetDirectoryName(SettingsPath)!);
            File.WriteAllText(SettingsPath, JsonSerializer.Serialize(settings, AppSettingsJsonContext.Default.AppSettings));
            return () => { };
        }, settings);

        return ToggleSwitch(settings.NotificationsOn,
            on => setSettings(settings with { NotificationsOn = on }),
            header: "Notifications");
    }

    private static AppSettings LoadFromDisk() =>
        File.Exists(SettingsPath)
            ? JsonSerializer.Deserialize(File.ReadAllText(SettingsPath), AppSettingsJsonContext.Default.AppSettings)
                ?? new AppSettings(NotificationsOn: true)
            : new AppSettings(NotificationsOn: true);
}

record AppSettings(bool NotificationsOn);

// JSON source-generated context so the disk read/write is trim- and
// NativeAOT-safe (no reflection-based JsonSerializer overloads).
[JsonSerializable(typeof(AppSettings))]
partial class AppSettingsJsonContext : JsonSerializerContext;

这个模式很小,但约束是实打实的:

  1. 只播种一次。 依赖数组为空的 UseMemo 只在首次渲染时从磁盘读取。每次渲染都重跑读取会与写入方产生竞态。
  2. 限流写入。 每一次按键都会更新 settings,进而触发副作用,进而写一个文件。对于文本框主体这类高频值,请在副作用内部做去抖(例如 await Task.Delay(500, ct),在无取消的出口处写入)。
  3. 处理畸形输入。 JsonSerializer.Deserialize 对空文件返回 null;示例回退到默认值。不要捕获并吞掉 JsonException —— 把它记下来;损坏的设置文件是一种值得被看见的真实失败模式。
  4. 卸载时取消。 UseEffect 的清理函数是取消任何进行中写入的地方,这样关闭中的窗口不会在磁盘上留下写了一半的文件。

异步资源一页深入讲解了取消模式;等这座桥变得不平凡时就去用它。

冲突解决

两个组件写入同一个键是编程错误,而不是框架特性 —— Microsoft.UI.Reactor(Reactor)不合并并发更新。后写者胜。如果你发现两个组件写同一个键,正确的做法是把状态提升到两者共享的上下文值上,而不是通过缓存来协调。缓存是卸载后幸存的设施,而不是组件间协调的设施。

对于跨进程磁盘桥的情况,「冲突」意味着第二个进程碰了同一个 JSON 文件。Reactor 不做锁定或仲裁;如果两个进程可能同时对同一个存储运行,请建立单写者模型(例如只有最近获得焦点的窗口才写),或者改用真正的数据库(Microsoft.Data.SqliteLiteDB)。

提示

在你有理由扩大范围之前,一直用 PersistedScope.Window 窗口作用域的状态有清晰的生命周期边界,不会误泄漏到第二个窗口里。应用作用域只对真正进程全局的事实才是正确默认值(认证令牌、功能开关、最近打开文件列表)。

按主题给键加前缀。 "notes/body" 优于 "body""prefs/theme" 优于 "theme"。应用作用域的缓存是进程范围的,所以两个不相关的主题撞在 "id" 上就是个待爆的 bug。键限制为 256 字符;校验器会拒绝 null、空或纯空白。

不要缓存大对象。 LRU 策略意味着单个 1MB 载荷可能淘汰掉成千上万个小值。把活的模型放在普通状态里,用 UsePersisted 记住它内部的位置(选中行 id、滚动偏移)—— 位置幸存下来,模型则从真相来源重新水合。

下一步

  • Hook —— 上一个:本 Hook 所组合的 UseState / UseMemo / UseEffect 形状。
  • 副作用 —— 下一个:驱动磁盘桥模式中 UseEffect 块的生命周期。
  • 窗口 —— PersistedScope.Window 实际在哪里解析;ReactorWindow.PersistedScope 如何接线。
  • 异步资源 —— 磁盘桥写入的取消与去抖。
  • 上下文 —— 当持久化是错误工具、你应该改为状态提升时。