设置系统与首选项存储
设置系统是 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、账号管理等),这些设置需要:
- 类型安全:每个设置组是一个强类型 POCO(如
TSettings),UI 通过属性绑定直接读写; - AOT/裁剪友好:Watt Toolkit 发布时启用裁剪与 NativeAOT,反射式 JSON 序列化不可用,因此接口使用 C# 11 的
static abstract成员暴露每个具体设置类自带的JsonTypeInfo; - 热加载:设置文件可能被外部修改(多进程、用户手工编辑),框架通过
PhysicalFileProvider监视文件并触发IOptionsMonitor<T>.OnChange回调; - 低摩擦持久化:属性包装器在 setter 中自动触发保存,业务代码无需手写"读-改-写文件"样板。
关键概念
| 概念 | 说明 |
|---|---|
TSettings | 一个具体设置组类,实现 ISettings<TSettings>(CRTP 自引用约束),如通用设置、加速器设置等 |
Settings 目录 | 所有设置 JSON 文件的统一存放目录,位于 IOPath.AppDataDirectory 之下 |
OptionsMonitor | ISettings<TSettings> 内部的密封类,实现 IOptionsMonitor<TSettings> 与 IOptions<TSettings>,是运行时的设置实例容器 |
SettingsProperty | 将 TSettings 上的某个属性包装为可观察、可自动保存的动态属性(供 ViewModel 绑定) |
types 集合 | ISettings 维护的已注册设置类型集合,退出时 SaveAllSettingsAsync 对其并行保存 |
Architecture
设置系统整体分为四层:UI/ViewModel 层通过属性包装器读写设置;设置抽象层(BD.WTTS.Settings.Abstractions 命名空间)承载接口契约与保存调度;Options/DI 层将 OptionsMonitor 以 IOptionsMonitor<TSettings> 注入容器;存储层负责 JSON 文件的读写与文件系统监视。
架构说明:
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):
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 基接口中:
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)> 按设置类型缓存路径,避免每次保存重复拼接与校验:
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> 接口内的密封类,是设置实例的运行时容器。其构造流程集中体现了"构造即加载,加载失败回退默认值"的策略:
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
关键点:
- 三重回退:优先使用注入的
settings实例 → 尝试从文件反序列化 → 都失败时new()一个全默认实例。这保证 DI 解析IOptionsMonitor<TSettings>永不因文件损坏/缺失而抛出构造异常。 - 实现两个接口:同时实现
IOptions<T>与IOptionsMonitor<T>,让仅需一次性读取的代码与需要监视变更的代码都能注入使用,无需注册两个实例。 Get(name)忽略名称:命名选项(named options)语义被刻意压平 —— 本框架中一个设置类型只对应一个文件,不存在命名分组。
反序列化的"两段式"读取
文件内容的读取并非直接反序列化为 TSettings,而是先读成 JsonObject 再提取子节点:
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,实现真正的外部热更新:
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 的保存:
[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_____:
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<> 开放泛型解析实例后转发:
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:一次设置变更的端到端时序
时序说明:
- 步骤 1–3 是 UI 驱动路径:用户在设置页改值 → 包装器 setter 立即触发保存,无需显式"保存"按钮。
- 步骤 5 的反射桥接让非泛型上下文(如
SaveSettings(Type))也能复用同一份泛型写盘实现TrySave_____。 - 步骤 10–12 是外部修改路径:文件变化 →
Watch→ 回调内重新反序列化 → 默认值相等性检查 → 才真正通知监听者。两条路径通过notRead标志与默认值检查形成闭环互斥,避免"自己保存 → 自己触发监视 → 自己重置"的振荡。
序列化与 AOT 兼容
GetDefaultOptions 定义了设置 JSON 的统一外观(缩进、枚举字符串化、宽松转义):
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> 之间的适配层:
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.DirName | string 常量 | "Settings" | 设置子目录名,位于应用数据目录下 |
| 文件名 | string | 具体设置类的静态 Name | 最终文件为 {AppData}/Settings/{Name}.json |
appDataDirectory | string? | null → IOPath.AppDataDirectory | DirectoryExists/GetFilePath/OptionsMonitor 均可传入自定义目录,null 时回退全局默认 |
WriteIndented | bool | true | JSON 缩进输出,保证手工可编辑 |
DefaultIgnoreCondition | JsonIgnoreCondition | Never | 所有属性均写入(含默认值) |
IgnoreReadOnlyProperties | bool | true | 只读属性不落盘 |
IncludeFields | bool | false | 仅序列化属性,不序列化字段 |
| 枚举转换器 | JsonStringEnumConverter | 已注册 | 枚举以字符串形式存储 |
Encoder | JavaScriptEncoder | UnsafeRelaxedJsonEscaping | 中文等非 ASCII 字符不转义 |
notRead | bool | false | TrySave 的快速路径标志,true 时跳过读盘直接写内存实例 |
各具体设置组的业务字段(如加速器开关、UI 语言、Steam 相关选项)属于各设置模型自身定义,不在本页框架层范围内。
API Reference
以下 API 均定义于 BD.WTTS.Settings.Abstractions 命名空间(ISettings.cs)。
ISettings.TrySave(type, optionsMonitor, notRead) (static)
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)
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 指向设置目录 |
CurrentValue | TSettings IOptionsMonitor<TSettings>.CurrentValue | 当前内存实例(非快照,属性包装器直接在其上读改写) |
Value | TSettings IOptions<TSettings>.Value | 同 CurrentValue,适配 IOptions<T> 消费方 |
Get | TSettings Get(string? name) | 忽略 name,恒返回当前实例 |
OnChange | IDisposable? OnChange(Action<TSettings, string?> listener) | 文件监视订阅,返回 IDisposable 用于取消;回调前做默认值相等性过滤 |
AllowNullDeserialize | TSettings? 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
新增一个设置组的标准路径:
- 定义
MySettings : ISettings<MySettings>,实现静态Name(决定文件名)、JsonSerializerContext、JsonTypeInfo(由源生成器提供); - 保证类有无参构造
new()且为class(接口 CRTP 约束要求); - 通过 DI 注册
IOptionsMonitor<MySettings>→ISettings<MySettings>.OptionsMonitor(其构造即完成"读文件或建默认值"); - 类型进入
ISettings.types集合后,自动获得退出时的并行保存能力; - 在 ViewModel 中用
SettingsProperty包装需要在 UI 上双向绑定的属性,实现"改值即落盘"。
插件侧参照:加速器插件的 IProxySettings / GameAcceleratorSettings / ProxySettings(IProxySettings.cs、ProxySettings.cs)与 ASF 插件的 IASFSettings / ASFSettings(IASFSettings.cs、ASFSettings.cs)即按此模式接入本框架,具体字段含义见各插件页面。