思考并回答问题:
版权声明
这篇文章是我花6块钱在GitChat买的凉鞋的笔记,此处为了个人学习进行了一些整理。请移步凉鞋官网凉鞋的笔记进一步了解。
文章分两个部分,第一部分是原理,第二部分是实战。
第一部分:原理
ResKit中值得一说的Feature如下:
- 引用计数器
- 可以不传BundleName
当然也有很多不值得一提的Feature。
- 比如统一的加载API,一个ResLaoder.LoadSync API可以加载Resources/AssetBundle/PersistentDataPath/StreamingAssets,甚至是网络资源。这个很多开源方案都有实现。
- Simulation Mode,这个功能最早源自于Unity官方推出的AssetBundleManager,不过不维护了,被AssetBundleBrowser替代了。但是这个功能真的很方便就加入到ResKit里了。
- 对象池,是管理内存的一种手段,在ResKit中作用不是很大,但是是一个非常值得学习的优化工具。
接下来就按着以上顺序聊聊这些 Feature。
首先先看下ResKit示例代码。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18// 在加载AssetBundle资源之前,要进行一次(只需一次)初始化操作
ResMgr.Init();
// 申请一个加载器对象(从对象池中)
var loader = ResLoader.Allocate<ResLoader>();
// 加载prefab
var smObjPrefab = loader.LoadSync<GameObject>("smObj");
// 加载Bg
var bgTexture = loader.LoadSync<Texture2D>("Bg");
// 通过AssetBundleName加载logo
var logoTexture = loader.LoadSync<Texture2D>("hometextures", "logo");
// 当GameObject Destroy时候进行调用,这里进行一个资源回收操作,会将每个引用计数器减一。
loader.Recycle2Cache();
loader = null;
最先登场的是ResMgr。
ResMgr.Init()进行了读取配置操作。在游戏启动或者是热更新完成之后可以调用一次。
第二登场的是ResLoader。字如其意,就是资源加载器。用户大部分时间都是和它打交道。
ResLoader.LoadSync默认加载AssetBundle中的资源。
如果想加载Resources目录下的资源,代码如下所示:1
var someObjPrefab = loader.LoadSync<GameObject>("resources://someObj");
当然也可以扩展成支持加载网络资源等。
这里关键的一点是resources://这个前缀。和https://或http://类似,是一个路径,和www的file://的原理是一样的。
实现比较简单。核心代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30public static IRes Create(string assetName)
{
short assetType = 0;
if (assetName.StartsWith("resources://"))
{
assetType = ResType.Internal;
}
else if (assetName.StartsWith("NetImage:"))
{
assetType = ResType.NetImageRes;
}
else if (assetName.StartsWith("LocalImage:"))
{
assetType = ResType.LocalImageRes;
}
else
{
var data = ResDatas.Instance.GetAssetData(assetName);
if (data == null)
{
Log.E("Failed to Create Res. Not Find AssetData:" + assetName);
return null;
}
assetType = data.AssetType;
}
return Create(assetName, assetType);
}
这里除了支持resources://还支持网络图片NetImage:和本地图片LocalImage:的加载。
同样也可以支持自定义格式,通过特殊的前缀去决定资源如何加载、从哪里加载、异步还是同步等等,一个灵活的系提供应该如此。这里不多说,文章的下半部分的实战环节仔细讲解。
接下来说一个比较重要的概念,引用计数。
虽然在以上的代码中看不出来引用计数的影子,但是他是整个ResKit的核心。
先进行一个简单的入门。简易引用计数器。
在讲引用计数在ResKit中的应用之前,还要再重新介绍一次ResMgr。
在开头介绍了ResMgr.Init进行了读取配置操作。配置文件中则记录的是AssetBundle和Asset信息。
那ResMgr的职责是什么呢?
字如其意就是管理资源。
以笔者的习惯,称为XXXMgr或者YYYManager的都是一个容器,叫容器是因为里边维护了List或Dictionary等,用来对外提供查询API和进行一些简单的逻辑处理。
这里的ResMgr就是Res的容器。Res是一个类,它持有一个真正的资源对象(UnityEngine.Object)。有的Res是从Resources里加载进来的,在ResKit中叫做InternalRes。有的Res从AssetBundle里加载进来的,这里叫做AssetRes/AssetBundleRes。它们都继承一个共同的父类Res,之间的区别只是加载的方式不同。
代码如下:
InternalRes.cs
1 | public class InternalRes : Res |
AssetRes.cs
1 | public class AssetRes : Res |
这里每个Res都实现了引用计数器。那么在什么时候进行引用计数+1(Retain)操作呢?
ResMgr对ResLoader提供一个GetResAPI,当ResLoader调用它的时候会对获取的Res进行Retain操作。
这里了解下ResLoader加载一个资源的步骤就知道了。1
var smObjPrefab = loader.LoadSync<GameObject>("smObj");
以上这步操作做了一下事情:
- 1.判断loader里的List\
中有没有加载过Name为”smObj” 的Res,如果加载过直接返回这个资源。 - 2.接着通过GetRes这个API从ResMgr获取这个资源,获取之后对这个Res进行Retain操作,之后将Res保存到loader的List\
中,之后再返回给用户。 - 3.在ResMgr.GetRes中,先判判断容器中有没有该Res,如果没有就创建一个出来加入到容器中,再返回给ResLoader。
理解起来不难,代码如下:
ResLoader.cs
1 | public class ResLoader |
ResMgr.cs
1 | public class ResMgr |
到这里,总结下:
- ResLoader中有个ResList,用来缓存从ResMgr中获取的Res。也就是用户加载过的资源都会在ResLoader中缓存一次,这里缓存的当然只是这个对象,并不是真正的资源。用户可以同Res对象访问真实的资源。
- ResMgr中有个SharedLoadedReses,用来存储共享的Res。
- 所以ResMgr中的Res会共享给每个ResLoader。
- ResLoader是ResMgr 的一个分身,只不过每个分身从ResMgr获取资源,都会对资源进行 Retain 操作。
什么时候进行资源的Release操作呢?
就是当ResLoader调用Recycle2Cache时:
1 | public class ResLoader |
代码比较简单了。就是遍历List\
下面介绍下这种设计的优点。
ResKit中提倡每个需要动态加载的MonoBehaviour或者单元都申请一个ResLoader对象。当该MonoBehaviour进行OnDestroy时,调用Recycle2Cache。
比较理想的使用方式如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21public class UIHomePanel : MonoBehaivour
{
private ResLoader mResLoader = ResLoader.Allocate();
void Start()
{
var someObjPrefab = mResLoader.LoadSync<GameOBject>("someObj")
// 加载 Bg
var bgTexture = mResLoader.LoadSync<Texture2D>("Bg");
// 通过 AssetBundleName 加载 logo
var logoTexture = mResLoader.LoadSync<Texture2D>("hometextures","logo");
}
void OnDestroy()
{
mResLoader.Recycle2Cache();
mResLoader = null
}
}
在该Panel Destroy时,进行资源的引用计数减一操作。这里加载过的资源并不一定真正进行卸载,而是当引用计数减为0时才进行真正的卸载操作。这个好处非常明显了,就是减少大脑负荷,不用记住某个资源在那里加载过,需要在哪里进行真正的卸载。主要保证每个ResLoader的申请,都对应一个Recycle2Cache就好。
这就是引用计数器的力量。引用计数的应用先说到这里。
下面说说,不用传入AssetBundleName就能加载资源这个功能。
目前市面上大部分开源资源管理模块都不支持这个功能,当然笔者不是原创。原创是笔者的前同事,给出原创 git 链接:Qarth
我们一般的方案,每个从AssetBundle加载的资源,必须传入AssetBundleName。
例如:1
2ResourcesManager.LoadSync<Texture2D>("images","bg");
AssetBundleManager.LoadSync<Texture2D>("images","bg");
这是常见的解决方案。实现起来和原理也比较简单。而且它已经足够解决大部分问题了。
但是用的时候会有一点限制。
在一个项目的初期,美术的资源还没有全部给出。项目中美术资源相关的目录结构还不是最终版。这时候加载方式全部像上边一样写。
在整个项目周期中,目录结构发生了几次变化。每次变化资源就会被打进不同的AssetBundle中,导致都要更改一次加载资源相关的代码,可能改成如下:1
2ResourcesManager.LoadSync<Texture2D>("homes","bg");
AssetBundleManager.LoadSync<Texture2D>("homes","bg");
而字符串的更改往往比较麻烦,加载错误不会被编译器识别,只有当项目运行之后才可能发现。这就会造成大量人力浪费。
而ResKit初期就处于这个阶段。
当时的解决方案是代码生成。生成Bundle名字常量和各个资源常量。这样当一发生目录结构的改变,就会报出来很多编译错误。这样生成的编译错误一个一个地改就好了。
生成代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27namespace QAssetBundle
{
public class Assetobj_prefab
{
public const string BundleName = "ASSETOBJ_PREFAB";
public const string ASSETOBJ = "ASSETOBJ";
}
public class Gamelogic
{
public const string BundleName = "GAMELOGIC";
public const string BGLAYER = "BGLAYER";
public const string DEATHZONE = "DEATHZONE";
public const string LANDEFFECT = "LANDEFFECT";
public const string MAGNETITELAYER = "MAGNETITELAYER";
public const string PLAYER = "PLAYER";
public const string RAINPREFAB2D = "RAINPREFAB2D";
public const string STAGELAYER = "STAGELAYER";
public const string SUN = "SUN";
}
public class Gameplay_prefab
{
public const string BundleName = "GAMEPLAY_PREFAB";
public const string GAMEPLAY = "GAMEPLAY";
}
...
}
初期ResKit以这个方案减少了很多工作量,和项目风险。
关于只传入AssetName不传AssetBundleName支持的构想在很早就有了,只不过不敢确定到底可不可行,会不会造成性能上的瓶颈什么的,直到后来发现了Qarth,确定是可行的方案。
实现非常简单,只要生成一个配置表就够了。
配置文件如下:
AssetTable.json
1 | { |
有了这个文件,就可以根据AssetName查询对应的AssetBundleName就好了。
这样直到项目优化阶段会很少去进行代码的改动,毕竟项目开发的每一分钟都很宝贵。
使用这种方式会有一些限制,就是资源不能同名。同名时就会加载第一个查询到的资源和对应的AssetBundle。
当发生不同AssetBundle之间有相同资源时,只要传入AssetBundleName 就可以解决。这种操作在日常开发中占极少数。多语言包可能是一种常见的需求。
还有一种是不同类型的资源同名,比如prefab类型和Texture2D类型的资源使用了同一个名字,这里笔者想了几种解决方案:
1.可传入文件扩展名。根据扩展名决定加载哪个资源,比如:1
2AssetBundleManager.LoadSync<Texture2D>("bg.jpg");
AssetBundleManager.LoadSync<GameObject>("bg.prefab");
实现起来也是比较容易的事情。
2.根据传入泛型类型去判断,比如:1
2mResLoader.LoadSync<Texture2D>("bg"); // 加载纹理类型的,名为bg的资源。
mResLoader.LoadSync<GameObject>("bg"); // 加载GameObject 类型的,名为bg的资源。
实现也是比较简单。
以上两种都需要在生成配置阶段,对每个资源生成更多的信息。
还有一种是 Qarth 的方案。
就是在对每个 AssetBundle,都加入了激活和未激活两个状态。
示意代码如下。1
2
3
4
5
6
7ResMgr.ActivateAssetBundle("images");
mResLoader.LoadSync<Texture2D>("bg");
ResMgr.DectivateAssetBundle("images");
ResMgr.ActivateAssetBundle("home");
mResLoader.LoadSync<GameObject>("bg");
ResMgr.ActivateAssetBundle("home");
这也是一个非常不错的方案。
目前以上三种ResKit都没有支持,笔者也在纠结当中,也许三种都支持,也许只支持 1 ~ 2 种方式。可能会有更好的,随着 QFramework 发展慢慢来吧。
值得一说的Features都聊完了。接下来开始实践部分。
第二部分:实践
让我们先一切归零。下面笔者展示资源管理模块的演化过程。
**v0.0.1 Resources API入门**1 | public class UIHomePanel : MonoBehaviour |
代码很容易理解,就是打开UIHomePanel时动态加载背景资源,当关闭时进行资源的卸载操作。
如果随着需求增多,这个页面可能需要动态加载的资源也会增多。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30public class UIHomePanel: MonoBehaviour
{
private Texture2D mBgTexture = null;
private Texture2D mLogoTexture = null;
private Texture2D mBgEnTexture = null;
void Start()
{
mBgTexture = Resources.Load<Texture2D>("bg");
// do something
mLogoTexture = Resources.Load<Texture2D>("logo");
// do something
mBgEnTexture = Resources.Load<Texture2D>("bg_en");
// do something
}
void OnDestroy()
{
Resources.UnloadAsset(mBgTexture);
mBgTexture = null;
Resources.UnloadAsset(mLogoTexture);
mBgTexture = null;
Resources.UnloadAsset(mBgEnTexture);
mBgTexture = null;
}
}
这样代码量就增多了,成员变量的代码和需要卸载的代码随着资源加载数量正比例增长。这里要尽量避免这种类型的增长。因为声明那么多成员变量不是很好的事情。解决方案很简单,引入一个List,使用List来记录本页面加载过的资源。
**v0.0.1 引入List1 | public class UIHomePanel : MonoBehaviour |
这样加载和卸载相关的代码就固定了。
这时候又有一个问题,资源的重复加载和卸载。可能代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39public class UIHomePanel: MonoBehaviour
{
private List<UnityEngine.Object> mLoadedAssets = new List<UnityEngine.Object>();
void Start()
{
var bgTexture = Resources.Load<Texture2D>("bg");
mLoadedAssets.Add(bgTexture);
// do something
var logoTexture = Resources.Load<Texture2D>("logo");
mLoadedAssets.Add(logoTexture);
// do something
var bgEnTexture = Resources.Load<Texture2D>("bg_en");
mLoadedAssets.Add(bgEnTexture);
// do something
OtherFunction();
}
void OtherFunction()
{
// 重复加载了,也会导致重复卸载。
var logoTexture = Resources.Load<Texture2D>("logo");
mLoadedAssets.Add(logoTexture);
// do something
}
void OnDestroy()
{
mLoadedAssets.ForEach(loadedAsset => {
Resources.UnloadAsset(loadedAsset);
});
mLoadedAssets.Clear();
mLoadedAssets = null;
}
}
对于Resources这个API来说问题不大,但是AssetBundle就可能比较危险了,重复加载AssetBundle会导致闪退。所以比较危险了,还是要避免。
**v0.0.2 添加重复加载判断**1 | public class UIHomePanel: MonoBehaviour |
添加LoadAsset方法,带来了一个意外的好处,就是加载资源部分的代码也变得精简了。
这样就可以避免重复加载和卸载了,当然重复加载和卸载仅限于在UIHomePanel内。还是没法避免多个页面之间的对同一个资源的重复加载和卸载的。要解决这个问题会相对麻烦,我们分几个版本慢慢迭代。
第一个要做的就是,先把这套加载卸载策略封装好,总不能让每个加载资源的页面或者脚本都写一遍这套策略。
**v0.0.3 ResLoader**策略的复用有很多种,继承、封装成服务类对象等等。
封装成一个服务类对象会好搞一些,就是ResLoader。
1 | public class ResLoader |
而UIHomePanel则会变成如下:
1 | public class UIHomePanel: MonoBehaviour |
使用代码精简了很多。
ResLoader只是记录下当前页面或者脚本加载过的资源,这样不够用,还需要一个记录全局加载过资源的容器。
**v0.0.4 SharedLoadedAssets & Res**实现很简单,给ResLoader创建一个静态List\
ResLoader.cs
1 | public class ResLoader |
这个mSharedLoadedAssets有什么作用呢?
它相当于一个全局资源池,而ResLoader获取资源,都要从资源池中获取,何时进行加载和卸载取决于某个资源是否是第一次加载、某个资源卸载时是不是不被所有ResLoader引用。
所以资源的加载和卸载,应该取决于被引用的次数,第一次被引用就加载该资源,最后一次引用释放则进行卸载。
但是UnityEngine.Object没有提供引用计数功能。
所以需要在UnityEngine.Object基础上抽象一个类Res:
这个Res,要实现一个引用计数的功能。
而且为了未来分出来不同类型的资源(例如ResourcesRes和AssetBundleRes/AssetRes等),Res也要管理自己的加载卸载操作。
代码如下:
Res.cs
1 | public class Res |
对应的ResLoader变为如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56public class ResLoader
{
/// <summary>
/// 共享的
/// </summary>
public static List<Res> SharedLoadedReses = new List<Res>();
/// <summary>
/// 持有的
/// </summary>
private List<Res> mResList = new List<Res>();
public T LoadAsset<T>(string assetName) where T : Object
{
var loadedRes = mResList.Find(loadedAsset => loadedAsset.Name == assetName);
if (loadedRes != null)
{
return loadedRes as T;
}
loadedRes = SharedLoadedReses.Find(loadedAsset => loadedAsset.Name == assetName);
if (loadedRes != null)
{
loadedRes.Retain();
mResList.Add(loadedRes);
return loadedRes as T;
}
var asset = Resources.Load<T>(assetName);
loadedRes = new Res(asset);
SharedLoadedReses.Add(loadedRes);
loadedRes.Retain();
mResList.Add(loadedRes);
return asset;
}
public void UnloadAll()
{
foreach (var asset in mResList)
{
asset.Release();
}
mResList.Clear();
mResList = null;
}
}
ResLoader的代码,希望大家仔细研读下,尤其是LoadAsset方法。加载步骤在原理部分有简单讲过,这里不多说了。而UnloadAll,则是不是进行真正的卸载,而是对资源进行一次释放。
使用代码UIHomePanel.cs不变:
1 | public class UIHomePanel: MonoBehaviour |
这里在写其他的示例代码,加载相同的资源:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30public class UIOtherPanel : MonoBehaviour
{
ResLoader mResLoader = new ResLoader();
void Start()
{
var bgTexture = mResLoader.LoadAsset<Texture2D>("bg");
// do something
var logoTexture = mResLoader.LoadAsset<Texture2D>("logo");
// do something
var bgEnTexture = mResLoader.LoadAsset<Texture2D>("bg_en");
// do something
OtherFunction();
}
void OtherFunction()
{
var logoTexture = mResLoader.LoadAsset<Texture2D>("logo");
// do something
}
void OnDestroy()
{
mResLoader.UnloadAll();
mResLoader = null;
}
}
这样就算UIHomePanel和UIOtherPanel同时打开,也不会发生资源的重复加载或卸载了。
到这里一个基本的管理模型完成了,可以将版本号升级为v0.1.1,算是一个最小可执行版本(MVP)。不管在使用还是结构上都是一个非常好的方案。而ResKit就是以这个管理模型为基础,慢慢完善功能的,至今为止支持了非常多的项目。