Repository Wiki
BeyondDimension/SteamTools

设置系统与首选项存储

设置系统是 Watt Toolkit(SteamTools)客户端的核心基础设施,基于 .NET 泛型选项模型(IOptionsMonitor<T>)与 System.Text.Json 源生成序列化构建,负责所有应用首选项(通用设置、UI 设置、Steam 设置、各插件设置等)的加载、反序列化、运行时变更监视与落盘保存。设置以 JSON 文件形式存储在应用数据目录的 Settings 子目录中,并通过 DI 容器以 IOptionsMonitor<TSettings> 形式注入到 ViewModel 与服务层。

Purpose and Scope

本页面覆盖设置系统的核心框架层实现,即位于 src/BD.WTTS.Client/Settings/Infrastructure/ 目录下的基础设施代码:

  • ISettings / ISettings<TSettings> 抽象接口(静态抽象成员、路径解析、保存调度、反序列化)
  • OptionsMonitor 内部密封类(加载 + 文件监视 + 变更通知)
  • JsonTypeInfoResolver(AOT/裁剪友好的序列化类型解析)
  • SettingsProperty / SettingsStructPropertyBase 属性包装器(读-改-写保存语义)

以下内容有意留给兄弟页面,本页不做展开:

  • 具体某个设置模型(如加速器插件 GameAcceleratorSettings、ProxySettings,ASF 插件 ASFSettings)的字段含义 — 见对应插件页面
  • 设置页 UI 层(Settings_General.axaml、SettingsPage.axaml 等 Avalonia 视图)— 见 UI 层相关页面
  • 通用配置/启动流程(Startup、DI 容器装配全貌)— 见核心框架其它页面

Overview

设计目标

客户端的设置横跨几十个功能模块(通用、外观、网络加速、ASF、账号管理等),这些设置需要:

  1. 类型安全:每个设置组是一个强类型 POCO(如 TSettings),UI 通过属性绑定直接读写;
  2. AOT/裁剪友好:Watt Toolkit 发布时启用裁剪与 NativeAOT,反射式 JSON 序列化不可用,因此接口使用 C# 11 的 static abstract 成员暴露每个具体设置类自带的 JsonTypeInfo;
  3. 热加载:设置文件可能被外部修改(多进程、用户手工编辑),框架通过 PhysicalFileProvider 监视文件并触发 IOptionsMonitor<T>.OnChange 回调;
  4. 低摩擦持久化:属性包装器在 setter 中自动触发保存,业务代码无需手写"读-改-写文件"样板。

关键概念

概念说明
TSettings一个具体设置组类,实现 ISettings<TSettings>(CRTP 自引用约束),如通用设置、加速器设置等
Settings 目录所有设置 JSON 文件的统一存放目录,位于 IOPath.AppDataDirectory 之下
OptionsMonitorISettings<TSettings> 内部的密封类,实现 IOptionsMonitor<TSettings> 与 IOptions<TSettings>,是运行时的设置实例容器
SettingsProperty将 TSettings 上的某个属性包装为可观察、可自动保存的动态属性(供 ViewModel 绑定)
types 集合ISettings 维护的已注册设置类型集合,退出时 SaveAllSettingsAsync 对其并行保存

Architecture

设置系统整体分为四层:UI/ViewModel 层通过属性包装器读写设置;设置抽象层(BD.WTTS.Settings.Abstractions 命名空间)承载接口契约与保存调度;Options/DI 层将 OptionsMonitor 以 IOptionsMonitor<TSettings> 注入容器;存储层负责 JSON 文件的读写与文件系统监视。

Loading diagram...

架构说明:

  • ISettings<TSettings> 是整个体系的契约核心。它不要求实例成员,而是通过 static abstract 成员(Name、JsonSerializerContext、JsonTypeInfo)让每个具体设置类型自带序列化元数据 —— 这是为裁剪/AOT 场景刻意选择的设计,避免运行时反射生成序列化代码。
  • OptionsMonitor 同时实现 IOptionsMonitor<TSettings> 和 IOptions<TSettings>,因此任何依赖标准 Options 模式的服务都能透明消费设置;它内部持有 PhysicalFileProvider 监视设置文件。
  • 保存路径 SettingsProperty.Save() → ISettings.TrySave(type, monitor, notRead: true) 中 notRead: true 表示"跳过重新读取文件、直接写入内存中当前实例",防止保存动作与文件监视回调互相触发回环。
  • SaveAllSettingsAsync 在应用退出等时机对 types 集合中所有已注册设置类型并行保存。

类型契约(CRTP + 静态抽象成员)

ISettings<TSettings> 使用自引用泛型约束(Curiously Recurring Template Pattern):

csharp
1public interface ISettings<TSettings> : ISettings where TSettings : class, ISettings<TSettings>, new() 2{ 3 static new abstract JsonTypeInfo<TSettings> JsonTypeInfo { get; } 4 5 /// <summary> 6 /// 从 UTF8 Json 流中反序列化实例 7 /// </summary> 8 [MethodImpl(MethodImplOptions.AggressiveInlining)] 9 static TSettings Deserialize(Stream utf8Json) 10 => SJsonSerializer.Deserialize(utf8Json, TSettings.JsonTypeInfo) ?? new();

Source: ISettings.cs

这一约束保证了:(1) 具体设置类有无参构造(new()),可安全创建默认实例;(2) TSettings.JsonTypeInfo 在编译期即可绑定到该具体类型的源生成元数据,Deserialize 在缺失/空 JSON 时回退到 new() 默认值。

存储布局与路径解析

所有设置统一存放于应用数据目录下的 Settings 子目录,目录常量与路径解析逻辑位于 ISettings 基接口中:

csharp
1protected const string DirName = "Settings"; 2 3[MethodImpl(MethodImplOptions.AggressiveInlining)] 4internal static bool DirectoryExists(string? appDataDirectory = null) 5{ 6 var settingsDirPath = Path.Combine(appDataDirectory ?? IOPath.AppDataDirectory, DirName); 7 if (!Directory.Exists(settingsDirPath)) 8 { 9 Directory.CreateDirectory(settingsDirPath); 10 return false; 11 } 12 return true; 13}

Source: ISettings.cs

最终文件路径为 {AppDataDirectory}/Settings/{name}.json(FileEx.JSON 即 .json 后缀)。GetFilePath 使用 ConcurrentDictionary<Type, (string Name, string FilePath)> 按设置类型缓存路径,避免每次保存重复拼接与校验:

csharp
1private static readonly ConcurrentDictionary<Type, (string Name, string FilePath)> cacheFilePaths = new(); 2 3[MethodImpl(MethodImplOptions.AggressiveInlining)] 4internal static string GetFilePath(Type type, string name, string? appDataDirectory = null) 5{ 6 if (cacheFilePaths.TryGetValue(type, out var result)) 7 { 8 return result.FilePath; 9 } 10 else 11 { 12 var settingsFilePath = Path.Combine( 13 appDataDirectory ?? IOPath.AppDataDirectory, 14 DirName, 15 name + FileEx.JSON); 16 17 cacheFilePaths.TryAdd(type, (name, settingsFilePath)); 18 return settingsFilePath; 19 } 20}

Source: ISettings.cs

设计意图:路径缓存以 Type 为键,意味着同一个设置类型在进程生命周期内只会对应一个文件;而 name 参数(即具体设置类的 Name 静态属性)决定文件名。将目录创建与存在性检查合并进 DirectoryExists,使得首次保存时无需调用方显式建目录。

OptionsMonitor:加载、持有与热更新

OptionsMonitor 是 ISettings<TSettings> 接口内的密封类,是设置实例的运行时容器。其构造流程集中体现了"构造即加载,加载失败回退默认值"的策略:

csharp
1sealed class OptionsMonitor : IOptionsMonitor<TSettings>, IOptions<TSettings> 2{ 3 TSettings settings; 4 readonly string settingsFileName; 5 readonly string settingsFilePath; 6 readonly PhysicalFileProvider fileProvider; 7 8 public OptionsMonitor(string settingsFilePath, TSettings? settings = default) 9 { 10 this.settingsFilePath = settingsFilePath; 11 var settingsDirPath = Path.GetDirectoryName(settingsFilePath); 12 settingsDirPath.ThrowIsNull(); 13 this.settings = settings ?? ISettings.Deserialize<TSettings>(settingsFilePath) ?? new(); 14 fileProvider = new(settingsDirPath); 15 settingsFileName = Path.GetFileName(settingsFilePath); 16 } 17 18 TSettings IOptionsMonitor<TSettings>.CurrentValue => settings; 19 20 TSettings IOptions<TSettings>.Value => settings; 21 22 TSettings IOptionsMonitor<TSettings>.Get(string? name) => settings;

Source: ISettings.cs

关键点:

  1. 三重回退:优先使用注入的 settings 实例 → 尝试从文件反序列化 → 都失败时 new() 一个全默认实例。这保证 DI 解析 IOptionsMonitor<TSettings> 永不因文件损坏/缺失而抛出构造异常。
  2. 实现两个接口:同时实现 IOptions<T> 与 IOptionsMonitor<T>,让仅需一次性读取的代码与需要监视变更的代码都能注入使用,无需注册两个实例。
  3. Get(name) 忽略名称:命名选项(named options)语义被刻意压平 —— 本框架中一个设置类型只对应一个文件,不存在命名分组。

反序列化的"两段式"读取

文件内容的读取并非直接反序列化为 TSettings,而是先读成 JsonObject 再提取子节点:

csharp
1[MethodImpl(MethodImplOptions.AggressiveInlining)] 2static TSettings? Deserialize<TSettings>(string settingsFilePath) where TSettings : ISettings 3{ 4 JsonObject? jobj; 5 var options = ISettings.GetDefaultOptions(); 6 using var readStream = new FileStream( 7 settingsFilePath, 8 FileMode.Open, 9 FileAccess.Read, 10 FileShare.ReadWrite | FileShare.Delete); 11 jobj = SJsonSerializer.Deserialize<JsonObject>(readStream, options); 12 if (jobj != null) 13 { 14 var jnode = jobj[TSettings.Name]; 15 if (jnode != null) 16 { 17 options = ISettings.GetDefaultOptions(); 18 options.TypeInfoResolver = ISettings.JsonTypeInfoResolver.Instance; 19 var settingsByRead = SJsonSerializer.Deserialize<TSettings>(jnode, options); 20 return settingsByRead; 21 } 22 } 23 return default; 24}

Source: ISettings.cs

设计意图:

  • 顶层 JSON 是一个以 TSettings.Name 为键的容器对象(例如 { "GeneralSettings": { ... } }),这一层间接性允许多个设置类型共存于同一文件格式约定下,也便于版本迁移时按名提取。
  • FileShare.ReadWrite | FileShare.Delete 允许与其它进程的写入并发读取,避免读写互斥死锁。
  • 第二段反序列化时挂载 ISettings.JsonTypeInfoResolver.Instance,把源生成的 JsonTypeInfo<TSettings> 接入运行时解析链。

文件监视与 OnChange 回调

OnChange 通过 ChangeToken.OnChange 挂接 PhysicalFileProvider.Watch,实现真正的外部热更新:

csharp
1IDisposable? IOptionsMonitor<TSettings>.OnChange(Action<TSettings, string?> listener) 2 => ChangeToken.OnChange(() => fileProvider.Watch(settingsFileName), () => 3{ 4 var settings_ = AllowNullDeserialize(); 5 if (settings_ != null) 6 { 7 // 监听到的设置模型实例,如果和 new 一个空的数据一样的,就是默认值则忽略 8 var newSettingsData = Serializable.SMP2(settings); 9 var emptySettingsData = Serializable.SMP2(new TSettings()); 10 if (newSettingsData.SequenceEqual(emptySettingsData))

Source: ISettings.cs

其中的 AllowNullDeserialize 将文件读取包裹在 try/catch 中,把任何 IO/JSON 异常吞掉并返回 null(见 ISettings.cs)。注释揭示了一条重要防御逻辑:当监视事件读到的是与 new TSettings() 序列化结果完全一致的"默认值"时直接忽略 —— 防止监视回调被自身保存动作(写入默认状态文件)或瞬时空文件误触发,造成运行时设置被意外重置。

(本节代码覆盖至源文件第 240 行,OnChange 回调后续分支的完整实现未在本次采集范围内展开。)

保存管线:属性变更 → 落盘

SettingsProperty / SettingsStructPropertyBase

SettingsProperty 是把设置类上的属性变成"可自动保存属性"的包装器,ViewModel 中大量使用(引用类型与值类型属性各有对应基类)。其核心语义是:读取时取监视器当前值,写入时更新实例并立即触发一次 notRead: true 的保存:

csharp
[MethodImpl(MethodImplOptions.AggressiveInlining)] public void Save() => ISettings.TrySave(typeof(TSettings), monitor, true);

Source: SettingsProperty.cs

同样的 Save() => ISettings.TrySave(typeof(TSettings), monitor, true) 模式也出现在 SettingsStructPropertyBase 中(见 SettingsStructPropertyBase.cs)。setter 内部在赋值后调用 Save()(见 SettingsProperty.cs 的 setter 上下文)。

设计意图:notRead: true(即传入 bool.TrueString)是防回环关键 —— 保存时不重新读取磁盘文件,直接把内存中 monitor 持有的当前实例写盘,从而不会触发"读→发现是默认值→重置内存"的误路径。

反射桥接:TrySave 与 SettingsExtensions

由于 ISettings 基接口不携带 TSettings 类型参数,TrySave 通过反射构造泛型调用,桥接到 SettingsExtensions.TrySave_____:

csharp
1static ISettings() 2{ 3 var trySaveMethod = typeof(SettingsExtensions).GetMethod(nameof(SettingsExtensions.TrySave_____)); 4 TrySaveMethod = trySaveMethod.ThrowIsNull(); 5} 6 7[MethodImpl(MethodImplOptions.AggressiveInlining)] 8static void TrySave([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors | DynamicallyAccessedMemberTypes.PublicProperties)] Type type, object optionsMonitor, bool notRead = false) 9 => TrySaveMethod.MakeGenericMethod(type).Invoke(null, new object?[] { 10 optionsMonitor, 11 notRead ? bool.TrueString : null, 12 });

Source: ISettings.cs

TrySave_____(五个下划线命名)即实际写盘实现,其第二个参数为 string?,null 表示正常"读取-合并-保存"路径,bool.TrueString 表示 notRead 快速路径。TrySave 方法标注了 DynamicallyAccessedMembers,把反射目标(公共构造与公共属性)保留给裁剪器,防止 AOT 裁剪时方法被裁掉。

单类型保存与全量并行保存

对单个设置类型,SaveSettings 从 DI 容器按 IOptionsMonitor<> 开放泛型解析实例后转发:

csharp
1[MethodImpl(MethodImplOptions.AggressiveInlining)] 2private static void SaveSettings([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] Type type) 3{ 4 try 5 { 6 var optionsType = typeof(IOptionsMonitor<>).MakeGenericType(type); 7 var options = Ioc.Get_Nullable(optionsType); 8 if (options != null) 9 TrySave(type, options); 10 } 11 catch (Exception ex) 12 { 13 Startup.GlobalExceptionHandler.Handler(ex, nameof(SaveSettings)); 14 } 15} 16 17[MethodImpl(MethodImplOptions.AggressiveInlining)] 18static async Task SaveAllSettingsAsync() 19{ 20 try 21 { 22 await Parallel.ForEachAsync(types, async ([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] type, _) => await Task.Run(() => 23 { 24 SaveSettings(type); 25 }).ConfigureAwait(false)).ConfigureAwait(false); 26 } 27 catch (Exception ex) 28 { 29 Startup.GlobalExceptionHandler.Handler(ex, nameof(SaveAllSettingsAsync)); 30 } 31}

Source: ISettings.cs

设计意图:SaveAllSettingsAsync 使用 Parallel.ForEachAsync 并行遍历 types(HashSet<Type>,注册于 ISettings,见 ISettings.cs),并在每个任务里再包一层 Task.Run 把同步写盘移出当前同步上下文。所有异常都被引导到全局的 Startup.GlobalExceptionHandler,保存失败永不中断应用流程 —— 这是首选项持久化的容错底线。

Core Flow:一次设置变更的端到端时序

Loading diagram...

时序说明:

  1. 步骤 1–3 是 UI 驱动路径:用户在设置页改值 → 包装器 setter 立即触发保存,无需显式"保存"按钮。
  2. 步骤 5 的反射桥接让非泛型上下文(如 SaveSettings(Type))也能复用同一份泛型写盘实现 TrySave_____。
  3. 步骤 10–12 是外部修改路径:文件变化 → Watch → 回调内重新反序列化 → 默认值相等性检查 → 才真正通知监听者。两条路径通过 notRead 标志与默认值检查形成闭环互斥,避免"自己保存 → 自己触发监视 → 自己重置"的振荡。

序列化与 AOT 兼容

GetDefaultOptions 定义了设置 JSON 的统一外观(缩进、枚举字符串化、宽松转义):

csharp
1static JsonSerializerOptions GetDefaultOptions() 2{ 3 var o = new JsonSerializerOptions() 4 { 5 DefaultIgnoreCondition = JsonIgnoreCondition.Never, 6 IgnoreReadOnlyFields = false, 7 IgnoreReadOnlyProperties = true, 8 IncludeFields = false, 9 WriteIndented = true, 10 Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, 11 }; 12 o.Converters.Add(new JsonStringEnumConverter()); 13 return o; 14}

Source: ISettings.cs

设计意图:

  • WriteIndented = true + UnsafeRelaxedJsonEscaping:设置文件需要人类可读、可手工编辑(中文等多语言字符不转义为 \uXXXX),这直接支撑了"用户手工改文件 → 热更新生效"的使用方式。
  • IgnoreReadOnlyProperties = true:只写属性不落盘,避免计算属性污染文件。
  • JsonStringEnumConverter:枚举以字符串存储,新增枚举值或调整顺序时旧文件仍可读。

JsonTypeInfoResolver

密封类 JsonTypeInfoResolver 实现 IJsonTypeInfoResolver,作为反射式 DefaultJsonTypeInfoResolver 与源生成 JsonTypeInfo<TSettings> 之间的适配层:

csharp
1[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] 2sealed class JsonTypeInfoResolver : IJsonTypeInfoResolver 3{ 4 readonly DefaultJsonTypeInfoResolver resolver = new(); 5 6 static readonly Lazy<JsonTypeInfoResolver> instance = new(() => new()); 7 8 public static IJsonTypeInfoResolver Instance => instance.Value; 9 10 JsonTypeInfoResolver() { } 11 12 JsonTypeInfo? IJsonTypeInfoResolver.GetTypeInfo(Type type, JsonSerializerOptions options) 13 { 14 if (types.Contains(type)) 15 { 16 try 17 { 18 if (typeof(JsonTypeInfoResolver) 19 .GetMethod(nameof(GetJsonTypeInfo), BindingFlags.NonPublic | BindingFlags.Static) 20 ?.MakeGenericMethod(type).Invoke(null, null) is JsonTypeInfo jsonTypeInfo) 21 { 22 if (jsonTypeInfo.Options == options) 23 return jsonTypeInfo; 24 if (Activator.CreateInstance(jsonTypeInfo.GetType(), options) is JsonTypeInfo jsonTypeInfo1) 25 return jsonTypeInfo1; 26 } 27 } 28 catch 29 { 30 31 } 32 } 33 return resolver.GetTypeInfo(type, options); 34 } 35}

Source: ISettings.cs

其解析优先级是:已注册的设置类型 → 用源生成的 TSettings.JsonTypeInfo(必要时以当前 options 重新构造一个实例,因为 JsonTypeInfo 与其 Options 绑定);未注册类型 → 回退到默认反射解析器。任何异常被静默吞掉后落到反射回退路径,确保序列化永不因解析器问题失败。Lazy 单例保证 resolver 本身只初始化一次。

Configuration Options

设置系统本身的配置面很小,因为它面向的是"约定优于配置"的统一布局:

项类型默认值 / 来源说明
ISettings.DirNamestring 常量"Settings"设置子目录名,位于应用数据目录下
文件名string具体设置类的静态 Name最终文件为 {AppData}/Settings/{Name}.json
appDataDirectorystring?null → IOPath.AppDataDirectoryDirectoryExists/GetFilePath/OptionsMonitor 均可传入自定义目录,null 时回退全局默认
WriteIndentedbooltrueJSON 缩进输出,保证手工可编辑
DefaultIgnoreConditionJsonIgnoreConditionNever所有属性均写入(含默认值)
IgnoreReadOnlyPropertiesbooltrue只读属性不落盘
IncludeFieldsboolfalse仅序列化属性,不序列化字段
枚举转换器JsonStringEnumConverter已注册枚举以字符串形式存储
EncoderJavaScriptEncoderUnsafeRelaxedJsonEscaping中文等非 ASCII 字符不转义
notReadboolfalseTrySave 的快速路径标志,true 时跳过读盘直接写内存实例

各具体设置组的业务字段(如加速器开关、UI 语言、Steam 相关选项)属于各设置模型自身定义,不在本页框架层范围内。

API Reference

以下 API 均定义于 BD.WTTS.Settings.Abstractions 命名空间(ISettings.cs)。

ISettings.TrySave(type, optionsMonitor, notRead) (static)

csharp
static void TrySave([DynamicallyAccessedMembers(...)] Type type, object optionsMonitor, bool notRead = false)
  • 参数:
    • type (Type):目标设置类型,须为已注册的 ISettings<TSettings> 实现
    • optionsMonitor (object):该类型的 IOptionsMonitor<TSettings> 实例(装箱传入以支持非泛型上下文)
    • notRead (bool, 默认 false):true 表示跳过重新读取文件,直接序列化监视器内存实例落盘
  • 说明:通过缓存的 MethodInfo 反射调用 SettingsExtensions.TrySave_____ 完成实际写盘。反射目标通过 DynamicallyAccessedMembers 标注保留给裁剪器。

ISettings.GetFilePath(type, name, appDataDirectory) (internal static)

  • 参数:type 设置类型(缓存键)、name 文件名主干(不含扩展名)、appDataDirectory 可选根目录
  • 返回:string,形如 {dir}/Settings/{name}.json;结果按 Type 缓存于 ConcurrentDictionary

ISettings.DirectoryExists(appDataDirectory) (internal static)

  • 返回:bool,目录已存在返回 true;不存在时创建并返回 false(即"首次调用负责建目录")

ISettings.SaveAllSettingsAsync() (static)

  • 返回:Task
  • 行为:Parallel.ForEachAsync 并行保存 types 集合中全部已注册设置类型;异常统一交由 Startup.GlobalExceptionHandler 处理,不向外抛

ISettings<TSettings>.Deserialize(utf8Json) (static)

csharp
static TSettings Deserialize(Stream utf8Json) => SJsonSerializer.Deserialize(utf8Json, TSettings.JsonTypeInfo) ?? new();
  • 参数:utf8Json (Stream):UTF-8 JSON 流
  • 返回:TSettings,流为空/反序列化为 null 时返回 new() 默认实例
  • ** Throws**:底层的 System.Text.Json 异常(未在此层捕获;OptionsMonitor.AllowNullDeserialize 会将其吞掉返回 null)

OptionsMonitor (sealed class)

成员签名说明
构造函数OptionsMonitor(string settingsFilePath, TSettings? settings = default)构造即加载;文件缺失/损坏回退 new();同时创建 PhysicalFileProvider 指向设置目录
CurrentValueTSettings IOptionsMonitor<TSettings>.CurrentValue当前内存实例(非快照,属性包装器直接在其上读改写)
ValueTSettings IOptions<TSettings>.Value同 CurrentValue,适配 IOptions<T> 消费方
GetTSettings Get(string? name)忽略 name,恒返回当前实例
OnChangeIDisposable? OnChange(Action<TSettings, string?> listener)文件监视订阅,返回 IDisposable 用于取消;回调前做默认值相等性过滤
AllowNullDeserializeTSettings? AllowNullDeserialize() (private)容错重读文件,任何异常吞掉返回 null

Failure Modes, Edge Cases & Concurrency

文件缺失 / 损坏 / JSON 非法:OptionsMonitor 构造函数中的 ?? new() 与 AllowNullDeserialize 的空 catch 块构成两级容错 —— 加载阶段失败不会导致 DI 解析失败,监视阶段失败不会让回调抛异常。应用总能以默认设置启动。

保存回环抑制:SettingsProperty.Save() 传入 notRead: true,写盘动作不回读文件;同时 OnChange 回调里做 Serializable.SMP2(settings) 与 Serializable.SMP2(new TSettings()) 的序列化比对,读到的内容等于全默认值时直接忽略。双重机制防止"保存触发监视、监视又重置内存"的振荡。

并发读文件:FileStream 以 FileShare.ReadWrite | FileShare.Delete 打开(见 ISettings.cs),允许读时其它句柄写入或删除,避免多进程场景下的互斥死锁。

并行保存的线程安全:SaveAllSettingsAsync 对不同设置类型并行写盘;路径缓存用 ConcurrentDictionary;已注册类型集合为 HashSet<Type>。异常在类型粒度上捕获,单个类型保存失败不影响其它类型。

AOT / 裁剪风险点:TrySave 与 JsonTypeInfoResolver 的反射调用若缺少 DynamicallyAccessedMembers 标注或根集不含具体设置类型,裁剪器可能移除所需成员。代码通过显式标注缓解此问题(另见源码中 IL2070 警告抑制注释,见 ISettings.cs)。

Current.Value 非快照语义:IOptions<T>.Value 通常被理解为快照,但此处直接返回可变实例引用。属性包装器依赖这一非快照语义在同一实例上读改写;若消费方缓存了该引用并长期持有,其看到的是"活引用"而非构建时快照 —— 使用时需知悉。

Performance & Operational Notes

  • 热路径零反射:每次属性保存都走 TrySave → MakeGenericMethod(type).Invoke,但 MethodInfo 在静态构造函数中一次性获取并缓存于 TrySaveMethod(见 ISettings.cs),单次保存只承担一次 Invoke 的轻量开销。
  • 路径缓存:ConcurrentDictionary 缓存后的 GetFilePath 是 O(1) 字典查找,MethodImplOptions.AggressiveInlining 进一步压平调用。
  • PhysicalFileProvider 监视开销:每个 OptionsMonitor 持有一个独立的 PhysicalFileProvider 监视设置目录;文件系统事件有平台差异(Linux 上轮询间隔、Windows 上 ReadDirectoryChangesW),高灵敏写入场景下事件可能合并。
  • 退出时机:SaveAllSettingsAsync 是全量落盘入口(应用退出时调用),并行执行缩短关停时间;每个类型的保存各自容错,不会因单个文件锁死而阻塞整体退出。

Extension Points

新增一个设置组的标准路径:

  1. 定义 MySettings : ISettings<MySettings>,实现静态 Name(决定文件名)、JsonSerializerContext、JsonTypeInfo(由源生成器提供);
  2. 保证类有无参构造 new() 且为 class(接口 CRTP 约束要求);
  3. 通过 DI 注册 IOptionsMonitor<MySettings> → ISettings<MySettings>.OptionsMonitor(其构造即完成"读文件或建默认值");
  4. 类型进入 ISettings.types 集合后,自动获得退出时的并行保存能力;
  5. 在 ViewModel 中用 SettingsProperty 包装需要在 UI 上双向绑定的属性,实现"改值即落盘"。

插件侧参照:加速器插件的 IProxySettings / GameAcceleratorSettings / ProxySettings(IProxySettings.cs、ProxySettings.cs)与 ASF 插件的 IASFSettings / ASFSettings(IASFSettings.cs、ASFSettings.cs)即按此模式接入本框架,具体字段含义见各插件页面。

Sources

(1 files)