概述
VFS(Virtual Files System 虚拟文件系统)是我的Unity开发框架TinaX Framework中的资源管理模块。
开源以来,渐渐也有些人开始关注和使用这个项目,也渐渐的,我们也发现了TinaX的一些问题和局限性。
于是,大概在几个月前,全新一代的TinaX开始从新开发,而本文所要介绍的,就是重构后的VFS模块。
开始
我们目前的案例,基于Unity 2019.3版本,由于使用了一些Unity和C#的新特性,它可能不支持较旧的版本。目前新TinaX仍在开发中,计划推荐用于生产环境的最低版本是Unity 2019.4 LTS.
我们从一个简单的案例开始说起,首先,我们做了这样一个简单的UI,我们称其为UI 1:

并制作了另一个简单的prefab(也是个UI,姑且称之为UI 2)

并编写一个特别简单的代码,挂在在UI 1的Canvas上。
using UnityEngine; using UnityEngine.UI; public class HelloMeow : MonoBehaviour { GameObject my_prefab; GameObject my_ui_go; Sprite my_img; public void OnGUI_BtnClicked() { //load prefab string prefab_path = "prefabs/ui_prefab"; my_prefab = Resources.Load<GameObject>(prefab_path); my_ui_go = Instantiate(my_prefab, this.transform); //绑定按钮 my_ui_go.transform .Find("btn_close") .GetComponent<Button>() .onClick.AddListener(() => { GameObject.Destroy(my_ui_go); my_ui_go = null; my_prefab = null; if(my_img != null) { Resources.UnloadAsset(my_img); my_img = null; } }); my_ui_go.transform .Find("btn_show_img") .GetComponent<Button>() .onClick.AddListener(() => { string img_path = "imgs/myImg"; my_img = Resources.Load<Sprite>(img_path); my_ui_go.transform.Find("my_img").GetComponent<Image>().sprite = my_img; }); } }
功能很明了,当我们点击UI 1上唯一的按钮时,它会加载prefab中的UI2,并且为UI 2上的两个按钮绑定点击事件。(分别是关闭UI2和显示一张图)

目前,整个功能使用Unity的Resources类进行资源的加载。接下来,我们就把Resources的部分拿掉,换成我们本文要介绍的TinaX VFS。
开始使用TinaX
新的TinaX和目前文档上是使用方法几乎完全不一样了,所以我们还得从头来罗嗦几句。
首先,安装TinaX
目前作为正式版本的TinaX使用 unitypackage
文件导入工程来使用。

但新的TinaX就不一样了,它不放在Assets目录下(强迫症福利),而是使用Unity的新的包管理工具UPM来导入工程。这样做的好处之一是,TinaX的巨多代码不在Assembly-CSharp
项目下,不会影响开发者自己代码的编译速度。
TinaX.Core
首先我们来安装TinaX.Core, 它是新TinaX中的核心组件。它的地址是 https://github.com/yomunsam/tinax.core
TinaX.Core依赖两个第三方库:“UniRx”和“UniTask”,由于目前这两个库的作者比没有官方的UPM包,于是我自己做了一个,按照ReadMe上的依赖说明,复制地址即可。(以后官方有自己的UPM地址的话,可以无缝切换,或者要是愿意的话,你也可以制作自己的UPM库,只要名字一样就能识别)

打开Unity的UPM窗口,点击左上角的加号,选择使用“git”方式依此导入两个依赖和TinaX.Core即可



注意:使用git方式添加包,需要你的电脑已经安装好git工具。如果没有相关工具的话,也可以直接在GitHub页面点击“Download”之后,用UPM的本地导入选项。
TinaX.VFS
在新TinaX的设计中,所有的功能模块(Services)都是独立的UPM包,地址是: https://github.com/yomunsam/tinax.vfs
使用同样的方式安装即可,vfs的依赖和core部分是一样的,不需要重复添加。
在代码中启用VFS
把TinaX.VFS相关的包加入到项目之后,我们就回到刚刚的代码上了。首先,我们需要在代码中启动它。
与现在作为正式版本的TinaX不同的是,新的TinaX不会自己启动(因为有开发者吐槽这个),改成了需要自己在代码中启动。当然,如果还是喜欢把TinaX导入工程之后,什么都不用管,它自己就启动的话,计划中后续我们会单独做一个包来实现这个功能。
我们来看启动部分的代码
using TinaX; //TinaX的主要命名空间 using TinaX.VFSKit; //VFS的命名空间 using UnityEngine; namespace Nekonya.Example { public class HelloMeowTinaX : MonoBehaviour { private IXCore core; private async void Start() { core = XCore.New() //实例化TinaX.Core .RegisterServiceProvider(new VFSProvider()) //向TinaX.Core注册VFS服务 .OnServicesStartException((serviceName, exception) => { //启动框架中各个服务时报错的回调 Debug.Log("在启动框架的时候出错了,报错的服务是:" + serviceName); Debug.LogException(exception); }); await core.RunAsync(); //启动框架 } } }
从启动的代码我们看出,它使用了async/await的语法糖。实际上在新的TinaX框架中,一切的设计都是优先异步的,包括后面介绍的VFS的各种逻辑也是一样。
把资源从Resources中拿出来
本来我们使用Resources类进行资源管理时,所有希望能在代码中加载出来的资源,都得放在“Resources”文件夹中,这样管理起来很不方便。
那么首先,我们就把我们的资源从Resources文件夹中拿出来,放在我们自己喜欢的目录下。


配置VFS
在菜单“TinaX -> VFS -> VFS Dashboard”选项中,我们可以打开VFS的配置面板。

在目前正式版本的VFS中,所有功能的配置都是放在一个用第三方库Odin写的统一窗口里的。虽然实现起来方便,效果也挺好,但开源的东西里面依赖一个付费的库,总觉得有点怪怪的。

于是在新的TinaX中,咱一咬牙一跺脚,所有的UI都是直接徒手写出来的。(这样也带来了一些好处:就是布局可以很自由,甚至大部分UI还是跟随系统中英文多语言的)

首次打开配置面板,它会提醒我们需要创建一个配置文件,点击“Create”即可。
整个VFS面板可以大致分为两个部分:“全局设置”和“资源组”设置。

首先我们勾选左上角的“启用VFS”选项,然后我们来看看这个“资源组”。
资源组是新VFS中新加入的功能,它设计理念部分参考了Unity官方最近弄出的Addressables(可寻址资产系统)。通过资源组的分组,我们可以给不同的资源设置不同的管理规则。(比Addressable玩法更多)
在面板中间的列表中,我们可以看到配置文件中默认给了我们一个叫“common”的资源组,我们先就都用这个组就可以了。点击列表中的“common”组,我们发现面板右边显示出了common这个组相关的选项。(这部分基本和目前版本的VFS类似)。

在新的VFS中,我们可以单独把某个资源添加进白名单,就像Addressables一样,这样它就可以被我们在代码中加载了。

但是在这儿我们先不这么干,还是把整个目录添加进白名单

至此,VFS的配置工作就告一段落了。
注意:
1. 不同的组的文件白名单范围应该是相互独立的,即一个文件或目录不允许同时出现在多个不同的组中。推荐的最佳实践是如果没有什么一定要分组的需求,就都用common组就好了。
2. VFS要求配置中至少有一个非扩展组(什么是扩展组以后再说)。
新的代码实现
之前管理UI的代码,我们重新改成VFS的实现后,如下:
using TinaX; //TinaX的主要命名空间 using TinaX.VFSKit; //VFS的命名空间 using UnityEngine; using UnityEngine.UI; namespace Nekonya.Example { public class HelloMeowTinaX : MonoBehaviour { private IXCore core; private IAsset my_prefab_asset; GameObject my_ui_go; Sprite my_img; private async void Start() { core = XCore.New() //实例化TinaX.Core .RegisterServiceProvider(new VFSProvider()) //向TinaX.Core注册VFS服务 .OnServicesStartException((serviceName, exception) => { //启动框架中各个服务时报错的回调 Debug.Log("在启动框架的时候出错了,报错的服务是:" + serviceName); Debug.LogException(exception); }); await core.RunAsync(); //启动框架 } public async void OnGUI_BtnClicked() { IVFS vfs = core.GetService<IVFS>(); //从core中获取vfs服务的接口,core中的服务是可以被依赖注入的,这个以后再说。 //load prefab string prefab_path = "Assets/MyApp/prefabs/ui_prefab.prefab"; //VFS一直以来的特点之一就是,加载路径就是编辑器下的资源路径,不管什么时候,直接右键复制路径粘贴过来就行了。 my_prefab_asset = await vfs.LoadAssetAsync<GameObject>(prefab_path); //"LoadAsset"系列的方法加载得到的是“IAsset”接口,这是新VFS中新增的东西,类似隔壁“XAsset”工具里的加载方式 my_ui_go = Instantiate(my_prefab_asset.Get<GameObject>(), this.transform); //绑定按钮 my_ui_go.transform .Find("btn_close") .GetComponent<Button>() .onClick.AddListener(() => { GameObject.Destroy(my_ui_go); //因为VFS底层是AssetBundle包,所以和Unity的Resources不同:即使是GameObject类型也得调用资源释放接口 my_prefab_asset.Release(); //使用IAsset接口得到的资源,可以直接在原接口中释放 my_prefab_asset = null; my_ui_go = null; if(my_img != null) { vfs.Release(my_img); //也可以调用vfs的方法,直接传递原本的资源对象进行释放。 } }); my_ui_go.transform .Find("btn_show_img") .GetComponent<Button>() .onClick.AddListener(() => { string img_path = "Assets/MyApp/imgs/myImg.jpg"; vfs.LoadAsync<Sprite>(img_path, (img,err)=> { /* * 讨论IAsset接口这种模式的时候,有人觉得这样加载资源比较麻烦, * 所以我们还是提供了原来一样的直接加载的方式, * 这里得到的"img"就是Sprite类型,而非IAsset. * 两种方式的内部实现完全一致,没有优劣。 * * 如果不喜欢 async/await 的话,我们也依然可以使用回调的方式来加载资源。 */ my_img = img; my_ui_go.transform.Find("my_img").GetComponent<Image>().sprite = img; }); }); } } }
总结一下代码:
首先,相较于Resources和其他资源框架开发者的加载方式,VFS一直以来的一大特点就是,它在代码中直接使用”Assets/xxx/xxx.xxx”这种原始工程中的路径来加载资源,在runtime中也是用这种路径。而没有使用相对某个文件夹的路径或者别名之类的方法。
当然我不是说针对Addressables,我个人认为在较大规模的项目里,尤其是整个包大部分资源都得要热更新的项目,使用别名来管理资源是挺添乱的事。当然你们要是喜欢,其实可以直接在VFS上封装一层自己实现别名加载(TinaX 5.x开始的UI加载其实就是用的别名,实现起来很简单)。
然后,相较于之前版本的TinaX VFS,新的VFS中资源加载的方法最大的变化应该就是多了Task异步方法和IAsset接口。
IAsset 接口是参考一个叫“XAsset”挺棒的Unity资源框架弄出来的,使用”LoadAsset”或者”LoadAssetAsync”方法加载资源会得到一个IAsset接口。我们可以通过IAsset接口加载资源和释放资源。
IAsset my_asset = vfs.LoadAsset<Sprite>("Assets/xxx/xx.png"); Sprite my_img = my_asset.Get<Sprite>(); // 或 Sprite my_img = my_asset.Get() as Sprite; // 或 Sprite my_img = my_asset.Asset as Sprite; my_asset.Release(); //释放资源
之前和一些开发者讨论过这事,确实有不少人喜欢用这种方式来加载资源。
当然,也有人会觉得这么写挺麻烦的,多了一个步骤,所以我们依然可以不用IAsset,使用”Load”或者”LoadAsync”方法加载资源,就会直接得到自己想要的资源类型。
Sprite my_img = vfs.Load<Sprite>("Assets/xxx/xx.png"); vfs.Release(my_img); //释放资源
这两种方法的底层实现上是一样的,没有性能差异。
而为了配合新TinaX的异步优先,全局跑Task的设计风格,新VFS也是从内到外设计了Task的异步方法:
IAsset my_text = await vfs.LoadAssetAsync<TextAsset>("Assets/xxx/xxx.txt"); Sprite my_img = await vfs.LoadAsync<Sprite>("Assets/xx/xx.png");
当然,使用async/await的方法来写异步逻辑之后,是会有开发者不习惯的,胡乱写起来甚至可以把整个编辑器卡死,于是我们依然可以使用很常见的回调方式来异步加载资源:
vfs.LoadAssetAsync<TextAsset>("Assets/xxx/xxx.txt",asset => { //异步加载回调 }); vfs.LoadAssetAsync<TextAsset>("Assets/xxx/xxx.txt", (asset,err) => { if(err != null) { //出错了 } }); try { vfs.LoadAsync<Sprite>("Assets/xx/xx.png", (img) => { //异步加载回调 }); } catch { /*出错了*/ }
那么,lua怎么办呢,新的VFS依然有这方面的考虑,给每种加载方法都提供了没有泛型,不需要捕获异常,使用回调而非await 的方法。并且计划中,重做lua部分的时候也会提供针对lua风格的方法。
vfs.LoadAsync("Assets/xx/xx.png", typeof(Sprite), (img,err) => { if(err != null) { //xxxxx } else { //xxxxx } });
最后,新TinaX在设计上是异步优先的,但是我们依然在VFS中保留了同步加载的方法,要注意的是,同步加载不是直接卡死线程去等待异步方法,而是独立的加载逻辑,这意味着:
- 同步方法无法加载远程位置的资源,如果一个资源不存在于本地的话,它不会去尝试从服务器下载,而是直接抛异常。
- 如果同步加载”文件A”时,发现之前有个异步方法也加载了“文件A”,并且异步方法加载的文件A正在加载过程中的话,同步加载的方法并不会等待异步方法加载的结果,而是会重新加载一份,两份“文件A”会有相互独立的资源引用计数。这种设定是处于线程安全性优先的考虑。
挺卡的?
介绍完VFS的基础功能,我们可能也发现了新的问题:刚刚案例中加载的UI,没什么感觉,但是换成模型资源、音频资源这类比较大的东西之后,点运行之后会明显感觉编辑器卡了一下,怎么回事呢?不是异步加载么?
实际上,为了开发时候调试工作的流畅连贯,我们在编辑器下运行游戏的时候,VFS并没有真正的去加载AssetBundle,(甚至都没有去打包AssetBundle),而是在用UnityEditor.AssetDatabase类进行同步加载,并延迟到下一帧再返回结果来模拟异步。
这可不仅仅是卡一下这么简单,这样也就意味着,有些只能在真实加载AssetBundle时候出现的问题,比如业务逻辑内存泄漏,在编辑器下模拟加载的时候是没法发现的。
那怎么办呢?我们就真的去打个包,用真正的AssetBundle来加载试试呗。
打包
打包的操作挺简单的,在之前说的那个VFS的配置面板,顶部工具栏的右上角有个小按钮,点开就是了。


直接点一下“Build”,等读条完成,打包就结束了。(如果不勾选“清空资源输出目录”的选项的话,第二次开始的打包操作会很快)
然后我们在神奇面板的左上角的“编辑器下的资源加载方式”选择“从资源构建结果目录中加载AssetBundle”。

这个资源构建结果目录是在Assets目录外面的,所以它不需要像导入StreamingAssets那样每次读条,很省时间。在工程很大的时候,每次导入StreamingAssets的时候读条都会超久的。
然后,我们再次运行,它就已经从AssetBundle加载资源了。
PS:在编辑器下使用UnityEditor.AssetDatabase
进行模拟资源加载的时候,启动TinaX时可以在Console观察到一行醒目的提示。(如果你系统语言不是中文的话,看到的提示不一样,反正大概就这么个意思。)

PS2 : VFS配置面板的各项Runtime相关的设置,也是打包资源的一部分。这就意味着,在真实加载AssetBundle的时候,调整配置面板中的设置项是无效的,必须重新打包才会生效。
PS3: 有从代码中构建资源以及打补丁的接口,方便开发者自己搞DevOps之类的东西。
接下来,我们来说点成年人都很感兴趣的东西。
热更新
新的VFS给我们提供了两种资源热更的玩法。
- 母包+补丁包
- WebVFS
补丁包
补丁包是一种常见且有效的热更方。 (科普见:https://www.yomunchan.moe/archives/367 )
新VFS会在每次打包时,记录该次打包的原始Assets的hash记录,并以此对比资源变动情况来生成补丁包。
据我所知,有不少公司是采用记录AssetBundle的hash信息来对比资源变动的,我们没有采用这种方法的原因是:
- Unity有的时候,不同的设备打包相同的资源,得到的AssetBundle的hash是不同的。
- 对assetbundle的加密或混淆处理会影响hash。
如果是个规模较大的公司,它可以搞个专门的打包设备,甚至把每次打包的assetbundle结果都直接上传进SVN,直接用SVN记录来生成补丁等方法来解决这些问题。不过讲真,这些方案有点“贵”,我们这里考虑了独立开发者等情况,确实不能直接定死用这种方案。
在TinaX6.5及之前版本,我们的解决方法就是不解决:反正开发者你把补丁打过来咱能给你加载起来,但是这个补丁怎么来咱不管。
在新的VFS中,我们给了一套相对完整的方案,基本开箱即用,有特殊需求的话基本也能在其基础上自己扩充。
废话不多说,我们来看东西,刚刚我们打了一次assetbundle包,现在我们用它来做热更新相关的工作。
首先,我们在神奇面板的顶部,找到“版本管理器”,打开,

然后,创建分支,

创建完之后,选择分支,点击创建版本。


点击保存之后,我们刚刚打包的资源的信息就会被记录下来。如果不勾选“保存二进制文件”的话,记录的就都只是些数据,很小,可以随意放在git或者svn里。
然后,版本管理工具会写出一个版本标记文件到我们刚刚打包的那堆assetbundle资源的目录里。如果我们有把刚刚的资源导入进StreamingAssets的话,只要StreamingAssets中的资源和“构建结果保存目录”里的资源是一致的话,这个版本标记文件也会自动写道StreamingAssets里。不需要手动操心什么。
这儿要注意的是,我们保存的这个版本记录,和它对应的那一次资源构建,和它写出的版本标记文件是一一对应的。如果这时候再执行一次资源构建,那么之前写出的版本标记就无效了,你得重新记录版本。
现在我们有了一个带有版本信息的assetbundle资源,我们把它导入到StreamingAssets里就可以打出真机包来了。VFS也给了个小工具方便我们管理StreamingAssets目录里的资源。

出了真机包(母包)之后,我们试着在工程里改点东西,做一些变动,然后再次构建资源。然后再次记录版本。

这时候我们就得说说“保存二进制文件”这个选项了,如果我们要制作版本1相对于版本0的补丁,我们就得有版本1的所有assetbundle文件,对吧。
如果版本1中没有保存二进制文件,制作补丁的时候,VFS回去检查“存放资源构建结果”里的文件和这次版本记录是否对应,如果对应的话,就可以直接用构建结果那边的文件来制作补丁。而如果并不对应的话,那就只能使用该版本所保存下来的的文件来制作补丁了。
而如果没有勾选“保存二进制文件”,并且存放资源构建结果的目录里的资源和这个版本记录也不对应的话,那就没法制作补丁了,会报错。
而制作补丁的过程也很简单,选中刚才记录的版本1,点击“制作补丁”,

在弹出的窗口中选择补丁的“目标版本”(也就是母包的版本)

点击制作补丁,选择保存位置之后就得到了补丁。

补丁默认会使用版本号命名,但是无所谓,你可以改成任何你喜欢的命名。然后补丁的本质是个zip压缩包,开发者也可以再次基础之上二次处理,比如把它加密什么的。
打补丁的时机,是交给开发者自己决定的,因为这个没法统一,有的项目要在登录后更新,有的项目要在刚启动就更新,所以VFS没法干这事,只能交给开发者自己搞。
下载好补丁之后,直接调用vfs的方法就完事了:
vfs.InstallPatch("c:/xx/xx/xx.xpk");
PS: 打补丁的全过程有开放代码接口,可以给开发者自己搭建devops之类的时候用。
WebVFS
WebVFS是一个很好玩的功能,其实TinaX6.5时候重构资源系统就是想做这个功能来着的,结果后来搞炸了,不过在新的TinaX中,可以玩起来了。
和Addressables比较像,它的原理是直接把一个个assetbundle包放在服务器上,而不是补丁包。
只有当资源被调用加载且本地没有缓存的时候,它才会去从服务器下载对应的assetbundle包。这也就是所,WebVFS的资源必须是异步加载的。
使用WebVFS,你可以实现把游戏母包做到非常小,几十甚至十几MiB,然后就“边下边玩”,不需要推补丁,也不需要读条。
启用WebVFS功能也挺简单,在神奇的配置面板里把这个勾勾给点上。

然后在资源组的设置中把组的处理模式改成“Local Or Remote”或者“Remote Only”,这个组就成了WebVFS的组。

WebVFS组没有版本的概念,所以也没法打补丁。一个项目中可以同时存在普通的组和WebVFS组。
在面板左上角Profile中,我们可以更灵活的定义一些WebVFS的细节(关于Profile具体有啥用这个我们能单开一篇聊)

在神奇面板的右上角,我们也能找到一个方便我们调试的文件服务器。

更多
嘛,新VFS的相关内容大概就先介绍到这儿了,我一开始都没以为能写这么长。
关于VFS怎么加密assetbundle,怎么自定义更多东西之类的玩法,有机会我们后续开篇再说,
(⊙﹏⊙)