AssetRipper 数据存储体系拆解:配置与元信息如何被管好
【免费下载链接】AssetRipperGUI application to analyze game files项目地址: https://gitcode.com/GitHub_Trending/as/AssetRipper
每次启动 AssetRipper,它都要记住上次的导入设置——脚本反编译开到哪一级、导出目录放在哪里。这份"记忆"没有走任何关系型数据库,而是由工具内置的 AssetRipper 数据存储层(Source/AssetRipper.Configuration/)承担。作为解析游戏文件的 Unity 资产提取工具,AssetRipper 在这里存放两类数据:单值配置项与列表形式的资产元信息。下文按一条配置数据的完整生命周期,讲清它从写入、序列化落盘到读回、查询的全过程。
先看场景:改了导入设置后,重启工具发生了什么
CoreConfiguration.cs 是这套存储的消费方,它只暴露两个容器:
public class CoreConfiguration { public SingletonDataStorage SingletonData { get; } = new(); public ListDataStorage ListData { get; } = new(); public ImportSettings ImportSettings { get => SingletonData.GetStoredValue<ImportSettings>(nameof(ImportSettings)); set => SingletonData.SetStoredValue(nameof(ImportSettings), value); } }构造函数里注册了默认值:以nameof(ImportSettings)为 key,放入一个JsonDataInstance<ImportSettings>,序列化器来自System.Text.Json的源生成上下文(ImportSettingsContext.Default.ImportSettings)。此后业务代码只操作ImportSettings属性,完全感知不到底下存的是什么格式;ResetToDefaultValues()则通过两个容器的Clear()一次性还原所有条目。
为什么配置层要分四层:先把每层职责划清楚
打开 AssetRipper.Configuration 会看到一小组类:DataEntry、DataInstance、DataSet、DataStorage<T>及各自的 String / Parsable / Json 变体。分层不是凑数量,每层只解决一个问题:
DataEntry是公共契约,只有一个Clear():重置自身而不删除。容器级的Clear()就是遍历所有条目逐个调用它。DataInstance表达"单个值"。抽象成员Text(可读写字符串)是它与外界的唯一接口;泛型子类DataInstance<T>持有Value和一个DataSerializer<T>,Text的 get/set 分别走序列化与反序列化。DataSet表达"一组值"。基类同时实现IEnumerable并暴露StringAccessor——一个IReadOnlyList<string>视图,把任意类型集合按"逐个转字符串"的方式读出来。泛型子类DataSet<T>内部就是一张List<T>加一个序列化器。DataStorage<T>(T : DataEntry)是索引层:一张Dictionary<string, T>,提供Keys、下标访问、TryGetValue/GetValue/Add/Clear。它不关心值的内部结构,只管按 key 找条目。
SingletonDataStorage和ListDataStorage只是把T钉死为DataInstance或DataSet,再加几个按类型取值的便捷方法。也就是说:值的形态(单值/列表)由子类决定,存储形态(字典)由基类统一,新增一种序列化格式不需要动存储层。
一条配置数据的一生:写入、落盘、读回、查询 📦
写入:两个容器钉死两种数据形态
DataStorage本体就是个带外壳的字典,没有索引、没有锁:
public class DataStorage<T> where T : DataEntry { protected readonly Dictionary<string, T> data = []; public bool TryGetValue<TValue>(string key, out TValue? value) where TValue : T => data.TryGetValue(key, out T? stored) ? (value = stored as TValue, value is not null) : (value = default, false); public TValue GetValue<TValue>(string key) where TValue : T => TryGetValue(key, out TValue? v) ? v : throw new KeyNotFoundException(); }SingletonDataStorage面向单值,额外提供TryGetStoredValue<T>/GetStoredValue<T>/SetStoredValue<T>:取出条目后做一次is DataInstance<T>类型检查,成立才返回Value。ListDataStorage面向列表,提供Add(key, List<string>)与Add<T>(key, List<T>)(要求T : IParsable<T>),分别包装成StringDataSet或ParsableDataSet<T>。
序列化落盘:三种序列化器都说"字符串"这门语言
DataSerializer<T>只有三个抽象方法:Serialize、Deserialize、CreateNew。项目里正好有三种实现,对应三种数据"方言":
| 序列化器 | 约束 | 行为 |
|---|---|---|
StringDataSerializer | 无 | 恒等变换,Deserialize直接返回原文,空值用"" |
ParsableDataSerializer<T> | T : IParsable<T>, new() | T.TryParse解析,失败回落到CreateNew() |
JsonDataSerializer<T> | T : new() | 基于JsonTypeInfo<T>做完整 JSON 序列化 |
关键设计是字符串作为通用货币:任何条目只要能产出并吃进一个string,就可以被同一套容器管理、写进同一份落盘文件、甚至在 GUI 里以文本形式展示。格式差异被完全关进序列化器内部。
反序列化读取:坏数据会被宽容地"降级"
JsonDataSerializer的读取路径是整套系统里最能体现取舍的地方:
public override T Deserialize(string text) { if (string.IsNullOrEmpty(text)) return CreateNew(); try { return JsonSerializer.Deserialize(text, typeInfo) ?? CreateNew(); } catch { return CreateNew(); // 坏数据静默回落为默认值 } }空文本、null结果、反序列化异常,全部退回CreateNew()的默认实例。ParsableDataSerializer的TryParse失败时同样回落。对"用户可能手改过配置文件"的场景这是合理的——工具宁愿丢一次修改也不能启动即崩;代价是错误被吞掉了,配置"看起来正常"其实是默认值。
查询:Try 与抛异常,两套入口对应两种调用姿态
读取侧的 API 呈明显的双轨设计:
TryGetValue/TryGetStoredValue:调用方不确定 key 是否存在(比如按 key 探测可选配置);GetValue/GetStoredValue:key 缺失属于编程错误,直接用KeyNotFoundException暴露。
列表数据则通过DataSet.Strings拿到StringAccessor视图,按IReadOnlyList<string>的方式索引、追加、清空,底层每个位置都经序列化器转换。
以 ImportSettings 为例走完全流程
把上面的组件串起来,ImportSettings的完整生命周期大致是:
// ① 首次启动:CoreConfiguration 构造时注册默认条目 var config = new CoreConfiguration(); // → SingletonData.Add("ImportSettings", // new JsonDataInstance<ImportSettings>(ImportSettingsContext.Default.ImportSettings)) // ② 用户在界面里调整脚本反编译级别 config.ImportSettings.ScriptContentLevel = ScriptContentLevel.Level1; // → SetStoredValue 把新对象写回 DataInstance<ImportSettings>.Value // ③ 落盘:上层取出 instance.Text(此时 Value 被序列化为 JSON 字符串)写入文件 // ④ 再次启动:从文件读回字符串,赋给 instance.Text // → Text 的 setter 触发 JsonDataSerializer.Deserialize,还原 Value // ⑤ 业务代码照旧只碰属性 bool scriptsOff = config.DisableScriptImport; // 内部读 Level0 判断注意 ③④ 两步里存储层只提供了Text这个字符串接口,真正读写文件的事由上层决定。这就是"存储与格式分离"的实际收益:换一种落盘格式(甚至手工编辑文本)都不需要碰DataStorage和CoreConfiguration。
列表侧同理:ListData.Add("README", [...])存入StringDataSet,需要按字符串处理时取GetValue<StringDataSet>(key).Strings,逐元素走的就是恒等序列化器,转换代价近乎为零。
这套设计的边界与代价
- 适用前提是单机单线程。
data是一张裸Dictionary,没有任何同步;两个线程同时Add或Clear会直接损坏容器。AssetRipper 的 GUI 流程是单线程驱动,这个假设成立,但把它移植到并发场景前必须自己加锁。 - 查询是 O(n) 起步的。没有二级索引,key 即唯一查找路径;对几十个配置 key 毫无压力,但拿它当"资产数据库"存上万条记录并不合适——AssetRipper 的主资产数据确实放在独立的 Assets/IO 模块里,配置层只放"小而慢变"的东西。
- 宽容解析是双刃剑。
catch { return CreateNew(); }保证工具永远能启动,却让"配置文件损坏"这种事件无声无息。若在自己的项目里照搬,至少应保留一条日志通道,否则排查问题时会怀疑人生。⚠️ Text的即时转换有成本。DataSet<T>每次经StringAccessor读一个元素都会完整跑一遍Serialize,对string或廉价ToString()的类型无所谓,对复杂对象则是每访问一次序列化一次——这类集合只适合展示,不适合热路径。
可迁移到自有项目的核心是两点:把"字符串"定为条目与外界的唯一契约,格式差异全部隔离在可替换的序列化器里;以及单值/列表/字典索引三个职责严格分层,扩展格式时存储层零改动。并发控制、错误上报、索引这些它没做的事,则需要在搬走之前按自己的场景补齐。
完整源码可从 Source/AssetRipper.Configuration/ 逐文件阅读,配合 Source/AssetRipper.Import/Configuration/ 下的CoreConfiguration能看到消费侧的真实用法。
【免费下载链接】AssetRipperGUI application to analyze game files项目地址: https://gitcode.com/GitHub_Trending/as/AssetRipper
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考