【文件格式】
文件可以分为文本文件、图片文件、音频文件、视频文件等等,我们常见的这些文件都有行业内的标准格式,其意味着按照一定的规则和规范去保存读取文件,可以获取我们想要的数据。
有些软件会有自己的文件格式,会按照其自己设计的规则和规范去保存读取文件。
我们自己在做开发时,也需要保存和读取一些文件,但我们需要保存的数据一般会比较简单,很少去做设计格式。
例如,要保存一系列角色和怪物的当前血量。为了读取时能区分是角色和怪物,可以先保存角色个数。读取时先读取角色个数n,随后读取n个血量数据,这些都是角色的,随后读取的是怪物的。
个数n就是文件头,血量是文件数据。
一般来说,任何文件都可以分为文件头和文件数据两个部分,文件头用于描述文件数据。如果要保存的数据很复杂,可以进一步将文件头分为文件头(描述整个文件的,例如版本号)和数据头(描述数据部分的)、将文件数据分为主要数据段和次要数据段等。
如果复杂些,例如还需要保存当前的魔力值,那么需要区分当前的值是魔力值还是血量。如果是自己做开发,自己读写,可以默认先是血量,随后的值是魔力值。如果要保存角色的更多属性,那么要保存的不能是单一的数值,而是不同数值组成的数据结构了。
如果是软件,那么采用默认的方式就不合适,随着版本的迭代,要保存的数据会发生变化,必须在文件头中加入版本号和其他信息以描述要保存的数据。要保存的数据越复杂,文件头也就越复杂。
尽管更加复杂,但只要明白保存读取的规则和规范(即文件格式),那么和我们自定义的保存读取的主要逻辑在本质上没有区别。
行业标准格式更为复杂,其会被多个软件使用,可以看看场景的文件格式了解下文件是怎么样构成的:
PNG文件解读(2):PNG格式文件结构与数据结构解读—解码PNG数据-腾讯云开发者社区-腾讯云
MP3文件结构解析(超详细)-CSDN博客
AVI文件格式详解-CSDN博客
【AssetBundle格式】
详细的请看下Unity 的AssetBundle解析视频和AssetStudio读取Bundle的源码,这里只做简要介绍
- AssetBundleHeader
- AssetBundleFileHeader
- StorageBlock[] m_BlocksInfo
- Node[] m_DirectoryInfo
- SerializedFile
- SerializedFileHeader 文件头
- MetaData
- Types 每个对象的类型是什么
- TypeTree
- Objects 有多少对象,分别是什么,多大,在哪开始读取
- ScriptTypes 如果有mono脚本的话,脚本的类型是什么
- Externals 如果包里的对象引用了其他AB里的资产,分别在哪里可以找到
- RefTypes
- Types 每个对象的类型是什么
- Object(第一个Object类型为AssetBundle)
- 一系列的其他Object (包内Object的数据)
- ResourceFile
下面是相关的数据结构
// AssetBundle文件头结构
public class AssetBundleFileHeader {public string signature;public uint version;public string unityVersion;public string unityRevision;public long size;public uint compressedBlocksInfoSize;public uint uncompressedBlocksInfoSize;public ArchiveFlags flags;
};public class StorageBlock{public uint compressedSize;public uint uncompressedSize;public StorageBlockFlags flags;}public class Node{public long offset;public long size;public uint flags;public string path;}public class SerializedFileHeader{public uint m_MetadataSize;public long m_FileSize;public SerializedFileFormatVersion m_Version;public long m_DataOffset;public byte m_Endianess;public byte[] m_Reserved;}public class SerializedType{public int classID;//unity有个classId的映射表public bool m_IsStrippedType;public short m_ScriptTypeIndex = -1;public TypeTree m_Type;public byte[] m_ScriptID; //Hash128public byte[] m_OldTypeHash; //Hash128public int[] m_TypeDependencies;public string m_KlassName;public string m_NameSpace;public string m_AsmName;}public class TypeTree{public List<TypeTreeNode> m_Nodes;public byte[] m_StringBuffer;}public class TypeTreeNode{public string m_Type;public string m_Name;public int m_ByteSize;public int m_Index;public int m_TypeFlags; //m_IsArraypublic int m_Version;public int m_MetaFlag;public int m_Level;public uint m_TypeStrOffset;public uint m_NameStrOffset;public ulong m_RefTypeHash;
}public class ObjectInfo{public long byteStart;//相对于SerializedFileHeader的偏移public uint byteSize;public int typeID;public int classID;public ushort isDestroyed;public byte stripped;public long m_PathID;public SerializedType serializedType;}public class LocalSerializedObjectIdentifier{public int localSerializedFileIndex;public long localIdentifierInFile;}public class FileIdentifier{public Guid guid;public int type; //enum { kNonAssetType = 0, kDeprecatedCachedAssetType = 1, kSerializedAssetType = 2, kMetaAssetType = 3 };public string pathName;//custompublic string fileName;}public class AssetBundle : NamedObject{public PPtr<Object>[] m_PreloadTable;public KeyValuePair<string, AssetInfo>[] m_Container;
}public class AssetInfo{public int preloadIndex;public int preloadSize;public PPtr<Object> asset;
}
我们要关心的核心问题是:如何从Bundle中加载需要的Asset
- 业务上层会传入一个资源路径
- 游戏中的资源加载模块会根据资源路径得到其所在的Bundle路径
- 资源加载模块会调用Unity接口去加载Bundle
- 先将整个文件头加载到内存中,其以SerializedFile 格式存储在内存中(注意保存文件时也即磁盘上的数据结构和内存中的数据结构不一定一致)
- 根据传入的路径从Bundle内的Container中找到该Asset对应的AssetInfo
- 从AssetInfo中拿到PreloadIndex,其是PreloadTable中的索引,PreloadSize是长度,结合两者可以知道该Asset包含了哪些Object,并获取到ObjectInfo(也叫ObjectHeader)
- 从ObjectInfo中拿到FileID和PathID,PathID是Object在AssetBundle内的标识,如果FileID为0,表明Object在该Bundle内,如果FileID不为0,则说明需要的Object在其他AssetBundle中
- 从Exteranls中根据FileID找到对应的FileIdentifier,拿到AssetBundle的名字,再根据PathID找到Object 。
- 在内存中会有其他转换,例如每个SerializedFile都会有个SerializedFileIndex。FileID和SerializedFileIndex不过是同一个Bundle在磁盘和内存上的不同标识而已
- 从ObjectInfo中拿到byteStart和byteSize即可知道Object数据在整个Bundle文件中的位置,并实现读取
- 依次将Asset内的所有Object数据读取到内存中
- 根据读取的Object数据反序列化得到想要加载的资源
【AssetBundle的加载和卸载】
先看一张图
加载时AssetBundleHeader、SerializedFileHeader、MetaData的数据会进入到内存中,也即途中的AssetBundle内存镜像:
- AssetBundle.LoadFromFile(path):同步加载,path为本地路径
- AssetBundle.LoadFromFileAsync(path):异步加载,path为本地路径
- AssetBundle.LoadFromMemory(byte[] binary):从字节数组加载,binary为目标ab二进制流
- AssetBundle.LoadFromMemoryAsync(byte[] binary):从字节数组异步加载,binary为目标ab二进制流
- UnityWebRequest.GetAssetBundle(string uri):url为ab文件路径,可为本地,也可为云端,
加载Asset时会从Bundle中加载一系列的Object生成对应的Asset,每个Asset会根据FileID和PathID生成唯一的标识ID:
assetBundle.LoadAsset<T>(name):
T为目标资产类型,name为资产名称,会返回一个T实例- assetBundle.LoadAsset(name,type):name为资产名,type为资产类型
assetBundle.LoadAllAssets<T>()
:T为目标资产类型,会返回一个assetBundle中所有T类型资产数组- assetBundle.LoadAllAssets():加载assetBundle中所有资产,返回一个assetBundle中所有资产数组
实例化时,会生成一份新的Asset,其有一个对应的InstanceID,并引用原来的Asset。
【AssetBundle解析工具】
unity官方的WebExtract和Binary2Text
WebExtract路径:
cd进入路径后 输入AssetBundle路径即可
Binary2Text路径:
AssetStudio
【AssetBundle优化】
IO优化:先了解下读取文件的详细流程
这里说的都是很通用的IO优化方法,不光是读取AssetBundle,读取其他文件也一样:
- 缓存文件句柄
- 打开关闭文件是一个耗时的操作,上层要卸载Bundle时,可以先缓存文件句柄,而不是立即关闭释放
- Object数据重排
- IO中很大一部分时间消耗是寻找磁道和磁头移动的时间(即寻道时间),为了减少这个时间,需要数据顺序排列,这样可以顺序读取,类似CPU读取数组比List快。在加载Asset时会读取多个Object的数据,如果保证传入的流的Pos是顺序增加或减少,而不是来回横跳,那么可以大幅度减少IO时间。
- 无锁多线程
- 一般来说一个成熟软件的读取文件操作会在单独的IO线程中执行,但会涉及到很多加锁操作,可以考虑改成无锁的,这个难度比较大
- 组合IO请求
- 一次大的IO请求比多次小的IO请求好,如果多个小的IO请求的位置大致是连续的,即使中间有部分数据可能不是需要的,也可以组合成一次大的IO请求
大小和内存优化
- 剔除重复数据
- 保证每个Asset只在一个Bundle内
- 字符串优化(内存优化,很大一部分是去如何优化字符串)
- string转Id
- 例如所有的AssetInfo中的assetName转为int,加载资源时也传入Id
- 所有标识改为用Id,int64改为int32
- string转Id
- 合并重复数据,例如TypeTree合并成一份
- 精简冗余数据
- 会建立各种映射关系,可以梳理下简化映射,例如PersistentManager里的remapper
- 有些数据在加载到内存后可能不会用了,从原有的数据结构中拆开,然后释放,例如各种版本信息
- 动态Buffer,读取文件时一般会有个Buffer缓存数据,读Object时的Buffer是FileCacherRead,可以根据需要动态调整,或者共用Buffer
【参考】
AssetBundle研究报告 | BLOG
Unity如何把一个对象从内存序列化到磁盘 | 矩阵·空间
AssetBundle热更新完整工作流与知识点解析 | 登峰造极者,殊途亦同归。