文件读取
IDictionary vs Dictionary
在 C# 中,readonly 作用于引用类型时,仅保证"引用不可变(Reference Immutability)",而不保证"对象不可变(Object Immutability)"
如果真的想要集合不可变,那么可以使用
cs
public IReadOnlyDictionary<string, int> Scores => _scores;
总结:如果是需要频繁读取的如Cache,这些Cache只在内部使用切readonly,那么就使用Dictionary来避免虚函数调用的开销;如果是可能会被传递给不同的 Reader,或者被其他模块(如文件系统)引用的数据,那么就使用泛型,通过接口约束行为,确保模块之间的通信是标准化的,不依赖于某个具体的 .NET 内部类
"readonly 在 C# 中仅提供 '浅层只读'(Shallow Read-only) 语义。
对于引用类型,它约束的是变量持有的 引用地址(Reference) 不可被重新赋值,但并不限制对该引用所指向的 堆内存实例内部状态 的修改。
在复刻项目中,我使用 readonly 来修饰资源缓存,是为了利用其 '引用语义的原子性' ,确保系统在运行期间不会因为意外重置容器地址而导致资源引用断裂。如果需要实现严格的内容只读,我会选择使用 IReadOnlyDictionary 接口进行封装。"
为什么Cpk文件的读取使用ReadStruct,而其他文件的读取都需要自己手写逻辑?
-
CPK 文件 :它的索引表(Table)是非常规整的。每个槽位物理上就是 64 字节,不管这个槽位里存的是什么,这个"格子"的大小是不变的。
- 适用性:因为大小固定且成员固定,ReadStruct 可以像模具压铸一样,一下压出一个对象。
-
其他文件(以 .pol 模型为例):
-
数组长度未知:一个模型可能有 100 个顶点,另一个可能有 10,000 个。你无法在 C# 结构体中定义一个固定长度的数组来匹配这种动态变化。
-
链式依赖:你必须先读一个 int 拿到顶点数,然后才能决定后面要读多少字节。ReadStruct 无法处理这种"走一步看一步"的逻辑。
-
IBinaryReader
这里直接采用了作者的逻辑
https://blog.csdn.net/z2251226240z/article/details/158098225?spm=1001.2014.3001.5502
在传统的 C#(旧版本)中,接口只能定义"要做什么",不能写具体的代码实现。
但在 C# 8.0 之后,接口可以直接写逻辑。
解析二进制最基础的动作只有几个:ReadByte, ReadInt16, ReadSingle。
而像 ReadSingles(int count)(读一堆 float)这种高级动作,逻辑是通用的:就是写一个循环,不停地调用 ReadSingle。作者直接将这些基础动作以及Pal3原语的自定义读取包装进了接口
这样实现类就可以直接调用封装好的逻辑。
C#析构函数/终结器Finalize
终结器隐式调用对象基类上的 Finalize。 因此,对终结器的调用会隐式转换为以下代码:
cs
protected override void Finalize()
{
try
{
// Cleanup statements...
}
finally
{
base.Finalize();
}
}
这种设计意味着,对继承链(从派生程度最高到派生程度最低)中的所有实例以递归方式调用 Finalize 方法。
SafeBinaryreader
这里的SafeBinaryReader实际上是对C#自带的BinaryReader的封装
cs
private SafeBinaryReader() { }
public SafeBinaryReader(byte[] data)
: this (new MemoryStream(data)) { }
public SafeBinaryReader(Stream stream)
{
_reader = new BinaryReader(stream);
}
不允许进行new(),因为原生的Binaryreader必须持有一个流。
虽然如果有public的有参构造函数,C#就不会再自动生成无参的构造函数了,这里可能是为了严谨,我也在UnSafeBinaryReader里加上了。
cs
private void DisposeInternal(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
GC.SuppressFinalize(this);
}
_reader?.Dispose();
_disposedValue = true;
}
}
~SafeBinaryReader()
{
DisposeInternal(disposing: false);
}
public void Dispose()
{
DisposeInternal(disposing: true);
}
这里作者的析构函数(终结器)和Dispose使用的是使用的是 .NET 中最经典、最严谨的 "标准处置模式"
为什么要设置Finalize?
因为这里封装了Stream(这里是受托管资源)
一般来说,对于开发人员,C# 所需的内存管理比不面向带垃圾回收的运行时的语言要少。 这是因为 .NET 垃圾回收器会隐式管理对象的内存分配和释放。 但是,如果应用程序封装非托管的资源,例如窗口、文件和网络连接,则应使用终结器释放这些资源。 当对象符合终止条件时,垃圾回收器会运行对象的 Finalize 方法。
!!!不要从终结器访问托管对象成员。 在最终定型期间,可能已释放托管对象,使其不可用或处于无效状态。 仅直接从终结器访问非托管资源。因此,作者在这里使用了双重检查逻辑。
C#的垃圾回收机制是这样的:
程序员无法控制何时调用终结器,因为这由垃圾回收器决定。 垃圾回收器识别应用程序不再需要的对象。 如果它认为某个对象符合终止条件,则调用终结器(如果有),并回收用来存储此对象的内存。且不应使用空终结器。 如果类包含终结器,会在 Finalize 队列中创建一个条目。 此队列由垃圾回收器处理。 当 GC 处理队列时,它会调用每个终结器。 不必要的终结器(包括空的终结器、仅调用基类终结器的终结器,或者仅调用条件性发出的方法的终结器)会导致不必要的性能损失。
基于上述逻辑,作者这样写析构函数的意图就很明显了:
-
当 disposing == true 时:
-
由 Dispose() 调用(这里设计Dispose通过using让C#自动调用)
-
安全操作:可以安全地访问受托管对象(如这里的 _reader)。因为此时整个对象树都是完整的。
-
执行 GC.SuppressFinalize(this):告诉 GC:"我已经手动把活儿干完了,你不用再跑终结器了。"这能提升性能,因为有终结器的对象在 GC 中需要多走一轮回收流程(Finalization Queue)。
-
-
当 disposing == false 时:
-
由析构函数 ~SafeBinaryReader() 调用。
-
危险警报 :绝对不能访问其他受托管对象(如 _reader)。
-
原因 :当 GC 开始跑终结器时,_reader 所指向的那个对象可能已经被 GC 回收了。访问一个已被回收的对象会导致不可预知的崩溃。但是作者这里使用了_reader?.Dispose();来确保不会爆炸
-
此时只能释放 :非托管资源(如通过 C++ DLL 申请的裸指针、句柄、IntPtr 等)。
-
UnSafeBinaryreader
cs
public sealed unsafe class UnsafeBinaryReader : IBinaryReader
{
private GCHandle _handle;
private byte* _startPtr;
private byte* _dataPtr;
}
1. GCHandle 是什么?
GCHandle(Garbage Collection Handle,垃圾回收句柄)是 .NET Framework/Core 中一个用于与垃圾回收器(GC)交互 的结构体。它的核心作用是:允许你对托管对象(如你的 byte[] data 数组)的内存地址进行更精细的控制,防止垃圾回收器在你不希望的时候移动或回收这块内存。
在 .NET 中,垃圾回收器为了高效地管理内存,会压缩堆 。这意味着它会移动对象在内存中的位置来消除碎片。对于大多数代码来说,这是透明且无害的。但是,当你使用指针 (在 unsafe 代码块中)直接操作对象的内存时,如果 GC 在你操作的过程中移动了对象,你的指针就会指向错误的内存地址,导致程序崩溃或数据损坏。
GCHandle 就是解决这个问题的"钉子",它可以把对象"钉"在内存中的某个位置,告诉 GC:"别动这个对象!"
2. GCHandle 的类型
GCHandle 有几种类型(GCHandleType 枚举),最常用的两种是:
GCHandleType.Weak: 一个弱引用。不会阻止对象被 GC 回收。当对象被回收后,句柄的Target属性会变为null。常用于缓存。GCHandleType.Pinned: 固定对象 。这是最关键的一种。它告诉 GC**:"这个对象在内存中的位置是固定的,绝对不能移动!"**。这正是你代码中使用的类型。
还有其他类型如 Normal(普通强引用,不阻止移动)和 WeakTrackResurrection(跟踪复活的弱引用),但 Pinned 是用于 unsafe 指针操作的关键。
使用 GCHandle.Alloc 后,必须 在适当的时候(通常在类的 Dispose 方法或析构函数中)调用 _handle.Free() 来释放句柄。如果忘记释放,会导致内存泄漏,因为被钉住的对象无法被 GC 回收,它占用的内存永远不会被释放
Init
cs
public UnsafeBinaryReader(byte[] data)
{
Init(data);
}
public UnsafeBinaryReader(Stream stream)
{
if (!stream.CanSeek)
{
throw new ArgumentException("Stream must be seekable", nameof(stream));
}
Length = stream.Length;
if (Length == 0) return;
byte[] data = new byte[Length];
_ = stream.Read(data, 0, (int)Length);
Init(data);
}
private void Init(byte[] data)
{
Length = data.Length;
if (Length == 0) return;
_handle = GCHandle.Alloc(data, GCHandleType.Pinned);
fixed (byte* ptr = data)
{
_startPtr = ptr;
_dataPtr = ptr;
}
}
在初始化UnSafeBinaryReader时必须保证流是可以随机寻址的。
- 比如
FileStream(文件流)、MemoryStream(内存流)。你可以随时使用Seek方法跳到任何位置,也可以安全地获取Length属性。 - 如果
stream.CanSeek是false,意味着这个流是"不可寻址的"或"仅向前的"(forward-only)。比如NetworkStream(网络流)、CryptoStream(加密流)、从控制台读取的流等。这些流的数据就像水管里的水,流过就没了,你不能"倒带"或"快进"到中间某个点。
cs
_handle = GCHandle.Alloc(data, GCHandleType.Pinned);
这类似于C++从物理内存池中 malloc 了一块连续空间,并保证其虚拟地址不再改变
cs
fixed (byte* ptr = data)
{
_startPtr = ptr;
_dataPtr = ptr;
} // <--- 危险!
- 在 C# 中,fixed 语句块的作用域结束后,指针变量 ptr 的生命周期就结束了。虽然你已经使用了 GCHandle 锁定了内存,但在某些编译器优化下,离开 fixed 块后直接给类成员 _startPtr 赋值可能会导致非预期的行为。
*
- 修正方案:既然你已经用了 GCHandle,你应该直接从 Handle 中提取地址,而不需要 fixed 块:
cs
private void Init(byte[] data)
{
Length = data.Length;
if (Length == 0) return;
_handle = GCHandle.Alloc(data, GCHandleType.Pinned);
// 直接从 Handle 获取物理地址,这才是最稳健的写法
_startPtr = (byte*)_handle.AddrOfPinnedObject();
_dataPtr = _startPtr;
}
具体读取逻辑
对于基本类型:
cs
short value = *(short*)_dataPtr;
第一个 * 在这里是类型转换符的一部分 。它和 short 结合在一起 (short*),表示"将 _dataPtr 转换为一个指向 short 类型的指针"
第二个 * 是解引用操作符 。它的作用是:"获取指针所指向的内存地址上存储的实际值 "将其解释为一个 short 整数
对于自定义的struct(Pal3原语):
作者直接使用了和基本类型一样的强转逻辑,因为Pal3游戏原语(Vector,Color等)是一个全由同类型组成的"平铺型(Blittable)"结构体。
- GameBoxVector2 只有两个 float 成员(4 字节 + 4 字节)。不论系统的对齐步长(Pack)是 4 还是 8,两个 4 字节的 float 紧挨着放,正好占据 8 个字节,中间不会产生任何"空洞"(Padding)。
- 物理本质:在内存中,它的布局和磁盘上连续的两个 float 完全一致。因此,即便你不写标签,直接通过指针强转 *(GameBoxVector2*)ptr 也是物理安全的。
而CpkHeader和CpkTableEntity却不一样,其中存放着不同类型的元素且在内存中紧密排列,所以需要显式声明[StructLayout(LayoutKind.Sequential, Pack = 1)] 绝对禁止编译器进行任何自动优化
是否意味着 CPK 文件的读取也可以这样(指针强转)进行?
答案是:完全可以,而且这正是 UnsafeBinaryReader 追求的终极形态。
事实上,你之前看到的 CoreUtility.ReadStructInternal<T> 内部:
cs
return Marshal.PtrToStructure<T>((IntPtr)ptr);
这行代码在底层干的事情,跟 *(T*)ptr 的物理本质是一模一样的。
为什么作者针对 Vector 写了强转,而对 CPK 写了 ReadStruct?
-
性能微操 :*(GameBoxVector2*)ptr 是编译时确定的。CPU 只需要一条 MOV 指令就能把 8 字节抓进寄存器,速度是真正的"光速"。
-
逻辑解耦 :ReadStruct<T> 是运行时通用的。它能处理任何结构体,不需要为每个新结构体都手写一个具体的 ReadXXX 方法。
-
复刻建议:
-
对于高频、小尺寸 的数据(如顶点坐标、颜色),用你看到的这种显式指针强转。
-
对于低频、大尺寸 的元数据(如 Header、Table),用 ReadStruct<T> 以保持代码整洁。
-
对于数组:
直接拷贝内存
cs
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public GameBoxVector2[] ReadGameBoxVector2s(int count)
{
GameBoxVector2[] values = new GameBoxVector2[count];
fixed (GameBoxVector2* ptr = values)
{
Buffer.MemoryCopy(_dataPtr,
ptr,
count * sizeof(GameBoxVector2),
count * sizeof(GameBoxVector2));
}
_dataPtr += count * sizeof(GameBoxVector2);
return values;
}
Buffer.MemoryCopy 的工作原理:
fixed (GameBoxVector2* ptr = values):- 这是必须的 。因为
Buffer.MemoryCopy是一个操作原生内存的底层方法,它需要一个稳定的指针 指向目标数组values。 fixed语句会临时"钉住"数组,防止垃圾回收器(GC)在拷贝过程中移动它,并返回一个指向数组起始位置的指针ptr。
- 这是必须的 。因为
Buffer.MemoryCopy(_dataPtr, ptr, ...):- 这是 .NET 提供的高效内存拷贝工具。它的参数是:
_dataPtr:源内存地址(从流中读取的位置)。ptr:目标内存地址(新数组的起始位置)。count * sizeof(GameBoxVector2):要拷贝的字节数(第三个参数)。count * sizeof(GameBoxVector2):目标缓冲区的总大小(第四个参数,用于安全检查)。
- 这个方法会直接调用底层运行时(CLR)的实现,该实现通常是用汇编语言写的,专门为高速内存操作优化。
- 这是 .NET 提供的高效内存拷贝工具。它的参数是:
_dataPtr += count * sizeof(GameBoxVector2);:- 最后,手动将读取指针向后移动整个数组所占用的字节数,为下一次读取做好准备。
Finalize
这里的终结器与另一个Reader不同
cs
private void Dispose(bool disposing)
{
if (!_disposedValue)
{
if (disposing)
{
GC.SuppressFinalize(this);
}
if (Length != 0)
{
_handle.Free();
}
_disposedValue = true;
}
}
因为GCHandle是非托管资源,所以无论如何都要调用Free()释放。
IFileReader
cs
public interface IFileReader<out T>
{
public T Read(IBinaryReader reader, int codepage = 936);
public T Read(byte[] data, int codepage = 936)
{
#if ENABLE_IL2CPP || UNITY_EDITOR
using IBinaryReader reader = new UnsafeBinaryReader(data);
#else
using IBinaryReader reader = new SafeBinaryReader(data);
#endif
return Read(reader, codepage);
}
public T Read(Stream stream, int codepage = 936)
{
#if ENABLE_IL2CPP || UNITY_EDITOR
using IBinaryReader reader = new UnsafeBinaryReader(stream);
#else
using IBinaryReader reader = new SafeBinaryReader(stream);
#endif
return Read(reader, codepage);
}
}
泛型协变 (out T)
out T关键字表示这是一个协变泛型接口- 这意味着你可以将
IFileReader<Derived>赋值给IFileReader<Base>类型的变量 - 适用于只"产出"T而不"接收"T作为参数的场景
在 C++ 中,模板类是不具备这种性质的:
cpp
std::vector<Derived*> v1;
// std::vector<Base*> v2 = v1; // 编译错误!即使 Derived* 能转 Base*,容器也不能转。
C# 的 out 关键字解决了这种"泛型容器/接口"与"内部元素"之间多态性不匹配的问题。
在 Pal3.Unity 的架构里,out 的存在是为了配合 ServiceLocator(服务定位器):
在 GameResourceProvider 中有这样一行代码:
cs
var reader = ServiceLocator.Instance.Get<IFileReader<T>>();
依赖注入模式
- 通过
IBinaryReader参数而不是具体实现 - 遵循依赖倒置原则,高层模块不依赖于低层模块的具体实现
关于这里的ifdef:
https://blog.csdn.net/z2251226240z/article/details/158098225?spm=1001.2014.3001.5502
Json
JsonUtility:
- 优点 :API 极其简单,只有
ToJson()和FromJson()两个核心静态方法,上手零门槛。 - 缺点:功能限制导致在处理复杂数据时非常不便,需要编写大量"胶水代码"来适配。
cs
[System.Serializable]
public class PlayerData
{
public string playerName;
public int level;
public List<string> unlockedItems; // 支持 List
// public Dictionary<string, int> stats; // 不支持 Dictionary!
}
string json = JsonUtility.ToJson(playerData);
PlayerData data = JsonUtility.FromJson<PlayerData>(json);
Newtonsoft.Json:
- 优点 :API 丰富且符合直觉,
JsonConvert类提供了所有常用功能。 - 缺点:配置选项多,新手可能需要花一点时间学习常用特性。
cs
// 无需 [System.Serializable]
public class PlayerData
{
public string PlayerName { get; set; } // 支持属性
public int Level { get; set; }
public Dictionary<string, int> Stats { get; set; } // 完美支持字典
}
string json = JsonConvert.SerializeObject(playerData, Formatting.Indented);
PlayerData data = JsonConvert.DeserializeObject<PlayerData>(json);
// 动态解析示例
JObject jObject = JObject.Parse(json);
string name = (string)jObject["PlayerName"];
int level = (int)jObject["Level"];