Unity | 资源热更(YooAsset & AB)
YooAsset 资源管理框架。
目录
(1)Unity(我用的版本是2020.3.8)导入AssetBundle Browser
一、AssetBundle
AB包可以存储绝大部分Unity资源但无法直接存储C#脚本,所以代码的热更新需要使用Lua或者存储编译后的DLL文件。
AB包不能重复进行加载,当AB包已经加载进内存后必须卸载后才能重新加载。
多个资源分布在不同的AB包可能会出现一个预制体的贴图等部分资源不在同一个包下,直接加载会出现部分资源丢失的情况,即AB包之间是存在依赖关系的,在加载当前AB包时需要一并加载其所依赖的包。
打包完成后,会自动生成一个主包(主包名称随平台不同而不同),主包的manifest下会存储有版本号、校验码(CRC)、所有其它包的相关信息(名称、依赖关系)。
1. 插件AssetBundle Browser 打AB包
(1)Unity(我用的版本是2020.3.8)导入AssetBundle Browser
从github下载插件,将下载后的安装包解压到Unity工程的Packages文件夹下,如果报错,删除Tests即可。
(2)设置Prefab
(3)AssetBundleBrowser面板
正确获取到并安装完插件后,通过 Windows/AssetBundle Browser下打开AB包管理面板 一共有三个面板。利用AssetBundleBrowser打包时,我们用的是Build面板,设置好之后点击[build]即可。
-
Configure面板 :能查看当前AB包及其内部资源的基本情况(大小,资源,依赖情况等)。
-
Build面板:负责AssetBundle打包的相关设置。
-
Inspect面板:用来查看已经打包后的AB包文件的一些详细情况(大小,资源路径等)。
压缩选项:
- 无压缩(None):
这个选项意味着资源不会被压缩,它们将以原始大小存储在AssetBundle中。这种方式可以使得加载资源的速度最快,因为不需要解压,但是AssetBundle的大小会很大。
- 标准压缩(Standard Compression LZMA):
这使用LZMA算法对AssetBundle进行压缩。压缩比较高,可以显著减小AssetBundle的大小,但是加载资源时需要全部解压,这会增加CPU的负担和加载时间。
- 块压缩(Chunk-Based Compression LZ4):
推荐,这种方法提供了较快的解压速度和较小的体积增加。它是一种平衡加载速度和文件大小的好方法。LZ4压缩允许你在运行时按块解压资源,这意味着你可以更快地开始使用资源,而不需要等待整个AssetBundle解压完成。
打包结果:
build后会包含下方三个文件:
- AB包资源文件
- AB包资源文件.manifest:提供了资源信息、依赖关系、版本信息等
- 同输出文件夹名称的AB包及manifest文件:我们称之为主包。它提供了AB包的名称及AB包之间的依赖关系。
2.代码打AB包
通过代码打AB包,不需要设置prefab的AssetBundle名称。
private static void BuildAssetBundles(string tempDir, string outputDir, BuildTarget target)
{
Debug.Log("AB outputDir:"+ outputDir);
Directory.CreateDirectory(tempDir);
Directory.CreateDirectory(outputDir);
List<AssetBundleBuild> abs = new List<AssetBundleBuild>();
{
var prefabAssets = new List<string>();
string testPrefab = $"{Application.dataPath}/Prefabs/Cube.prefab";
prefabAssets.Add(testPrefab);
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
abs.Add(new AssetBundleBuild
{
assetBundleName = "prefabs",
assetNames = prefabAssets.Select(s => ToRelativeAssetPath(s)).ToArray(),
});
}
BuildPipeline.BuildAssetBundles(outputDir, abs.ToArray(), BuildAssetBundleOptions.None, target);
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
}
public static string ToRelativeAssetPath(string s)
{
return s.Substring(s.IndexOf("Assets/"));
}
3.加载资源
AssetBundle assetBundle = AssetBundle.LoadFromFile(System.IO.Path.Combine(Application.streamingAssetsPath, "myprefab"));
GameObject obj = assetBundle.LoadAsset<GameObject>("Cube");
GameObject.Instantiate(obj);
注意:AB包不能重复加载,否则报错:The AssetBundle 'StreamingAssets\myprefab' can't be loaded because another AssetBundle with the same files is already loaded.
4.AB包依赖
默认情况下,会自动把依赖资源打包到主体的AB包中。但如果人为的将依赖资源和主体分开打包到两个AB包中,这个时候如果只加载自己的 AB 包,通过它创建对象会出现资源丢失的情况。解决办法:这种时候需要把依赖的AB包一起加载了才能正常。
那对于大型游戏,如何知道主体依赖的资源在哪个AB包呢?通过主包可以获取依赖关系。
//加载主包
AssetBundle mainBundle = AssetBundle.LoadFromFile(System.IO.Path.Combine(Application.streamingAssetsPath, "StandaloneWindows"));
//加载主包的manifest
AssetBundleManifest assetBundleManifest = mainBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest");
//获取AB包的依赖包
string[] str = assetBundleManifest.GetAllDependencies("cube");
for (int i = 0; i < str.Length; i++)
{
Debug.Log(str[i]); //mat
//加载依赖包
AssetBundle assetBundle1 = AssetBundle.LoadFromFile(System.IO.Path.Combine(Application.streamingAssetsPath, str[i]));
}
二、YooAsset
AssetBundle 的管理和使用相对复杂,需要处理依赖关系、版本控制、加载策略等问题。因此,很多开发者会选择使用如 YooAsset 这样的资源管理框架来简化 AssetBundle 的使用,自动处理这些复杂的任务。
1. 准备工作
(1)官方文档
(2) 通过Packages清单安装YooAsset
直接修改Packages文件夹下的清单文件manifest.json:
{
"dependencies": {
"com.tuyoogame.yooasset": "2.1.0",
......
},
"scopedRegistries": [
{
"name": "package.openupm.cn",
"url": "https://package.openupm.cn",
"scopes": [
"com.tuyoogame.yooasset"
]
}
]
}
unity2020.3.8出现以下报错,报错代码注释掉即可,貌似DownloadHandlerAssetBundle下还没有 autoLoadAssetBundle这个变量,而且支持的版本这个值默认false。
2.全局配置
Project窗体内右键 -> Create -> YooAsset -> Create YooAsset Setting可创建YooAssetSettings全局配置文件,该文件需放在Resources文件夹下:
- Manifest File Name : 清单文件名称
- DefaultYooFolderName:Yoo文件夹名称
3.资源配置
Project窗体内右键 -> Create -> YooAsset -> Create AssetBundle Collector Setting可创建该文件,需要注意的是一个工程只能有一个AssetBundleCollectorSetting。
重点关注Enable Addressable及Pack Rule打包规则:
(1)Enable Addressable
启用可寻址资源定位系统。启用后加载资源时可以不写全路径,只根据资源名称即可加载:
YooAssets.LoadSceneAsync("scene_home");
(2)Pack Rule打包规则
规则可以自定义扩展。下面是内置规则:
- PackSeparately 以文件路径作为资源包名,每个资源文件单独打包(比如场景文件)。
- PackDirectory 以文件所在的文件夹路径作为资源包名,该文件夹下所有文件打进一个资源包。
- PackTopDirectory 以收集器下顶级文件夹为资源包名,该文件夹下所有文件打进一个资源包。
- PackCollector 以收集器路径作为资源包名,收集的所有文件打进一个资源包。
- PackGroup 以分组名称作为资源包名,收集的所有文件打进一个资源包。
- PackRawFile 目录下的资源文件会被处理为原生资源包(比如dll.byte)。
4.资源构建
(1)Build Mode
- ForceRebuild:强制构建模式,会删除指定构建平台下的所有构建记录,重新构建所有资源包。
- IncrementalBuild:增量构建模式,以上一次构建结果为基础,对于发生变化的资源进行增量构建。
(2)Encryption
加密类列表。Build时选的加密类型要和加载资源时的解密类型一致,否则加载资源时会报错。比如Build时选择的加密方式为FileOffsetEncryption,则文件解密服务接口也需要为FileOffset类型:
{
...
var initParameters = new HostPlayModeParameters();//HostPlayModeParameters继承自 InitializeParameters
initParameters.BuildinQueryServices = new GameQueryServices();//内置资源查询服务接口
initParameters.DecryptionServices = new FileOffsetDecryption();//如果资源包在构建的时候有加密,需要提供实现IDecryptionServices接口的实例类。
...
}
/// <summary>
/// 资源文件偏移加载解密类
/// </summary>
private class FileOffsetDecryption : IDecryptionServices
{
/// <summary>
/// 同步方式获取解密的资源包对象
/// 注意:加载流对象在资源包对象释放的时候会自动释放
/// </summary>
AssetBundle IDecryptionServices.LoadAssetBundle(DecryptFileInfo fileInfo, out Stream managedStream)
{
managedStream = null;
return AssetBundle.LoadFromFile(fileInfo.FileLoadPath, fileInfo.ConentCRC, GetFileOffset());
}
/// <summary>
/// 异步方式获取解密的资源包对象
/// 注意:加载流对象在资源包对象释放的时候会自动释放
/// </summary>
AssetBundleCreateRequest IDecryptionServices.LoadAssetBundleAsync(DecryptFileInfo fileInfo, out Stream managedStream)
{
managedStream = null;
return AssetBundle.LoadFromFileAsync(fileInfo.FileLoadPath, fileInfo.ConentCRC, GetFileOffset());
}
private static ulong GetFileOffset()
{
return 32;
}
}
5.YooAsset运行时
以官方提供的Space Shooter这个Demo为例,重新编写YooAsset相关的流程。先确保Demo能正常运行:
- Space Shooter在导入完成后,打开YooAsset->AssetBundle Collector窗口。
- 点击修复按钮,然后点击Save按钮保存配置,最后关闭窗口。
- 找到Boot.scene场景启动游戏。
打开Boot.cs文件,注释掉 IEnumerator Start方法,在TestLoad方法中一步一步进行YooAsset资源的加载:
private void Start()
{
StartCoroutine(TestLoad());
}
IEnumerator TestLoad()
{
...
}
(1)初始化
IEnumerator TestLoad()
{
// 初始化资源系统
YooAssets.Initialize();
// 创建默认的资源包
var package = YooAssets.CreatePackage("DefaultPackage");
// 设置该资源包为默认的资源包,可以使用YooAssets相关加载接口加载该资源包内容。
YooAssets.SetDefaultPackage(package);
...
}
(2)资源系统的运行模式
...
if (PlayMode == EPlayMode.EditorSimulateMode)
{
var initParameters = new EditorSimulateModeParameters();//EditorSimulateModeParameters继承自InitializeParameters
string simulateManifestFilePath = EditorSimulateModeHelper.SimulateBuild(EDefaultBuildPipeline.BuiltinBuildPipeline.ToString(), "DefaultPackage");
initParameters.SimulateManifestFilePath = simulateManifestFilePath;
yield return package.InitializeAsync(initParameters);
}
else if (PlayMode == EPlayMode.OfflinePlayMode)
{
var initParameters = new OfflinePlayModeParameters();//OfflinePlayModeParameters继承自InitializeParameters
initParameters.DecryptionServices = new FileOffsetDecryption();//需要补充这个
yield return package.InitializeAsync(initParameters);
}
else if (PlayMode == EPlayMode.HostPlayMode)
{
// 注意:GameQueryServices.cs 太空战机的脚本类,详细见StreamingAssetsHelper.cs
string defaultHostServer = "https://static0.xesimg.com/project-yooasset/0130Offset";
string fallbackHostServer = "https://static0.xesimg.com/project-yooasset/0130Offset";
var initParameters = new HostPlayModeParameters();//HostPlayModeParameters继承自InitializeParameters
initParameters.BuildinQueryServices = new GameQueryServices();//内置资源查询服务接口
initParameters.DecryptionServices = new FileOffsetDecryption();//如果资源包在构建的时候有加密,需要提供实现IDecryptionServices接口的实例类。
initParameters.RemoteServices = new RemoteServices(defaultHostServer, fallbackHostServer);//远端服务器查询服务接口
var initOperation = package.InitializeAsync(initParameters);
yield return initOperation;
if (initOperation.Status == EOperationStatus.Succeed)
{
Debug.Log("资源包初始化成功!");
}
else
{
Debug.LogError($"资源包初始化失败:{initOperation.Error}");
}
}
else if (PlayMode == EPlayMode.WebPlayMode)
{
string defaultHostServer = "http://127.0.0.1/CDN/WebGL/V1.0";
string fallbackHostServer = "http://127.0.0.1/CDN/WebGL/V1.0";
var initParameters = new WebPlayModeParameters();//WebPlayModeParameters继承自InitializeParameters
initParameters.BuildinQueryServices = new GameQueryServices();
initParameters.RemoteServices = new RemoteServices(defaultHostServer, fallbackHostServer);
var initOperation = package.InitializeAsync(initParameters);
yield return initOperation;
if (initOperation.Status == EOperationStatus.Succeed)
{
Debug.Log("资源包初始化成功!");
}
else
{
Debug.LogError($"资源包初始化失败:{initOperation.Error}");
}
}
补充两个类:
/// <summary>
/// 资源文件偏移加载解密类
/// </summary>
private class FileOffsetDecryption : IDecryptionServices
{
/// <summary>
/// 同步方式获取解密的资源包对象
/// 注意:加载流对象在资源包对象释放的时候会自动释放
/// </summary>
AssetBundle IDecryptionServices.LoadAssetBundle(DecryptFileInfo fileInfo, out Stream managedStream)
{
managedStream = null;
return AssetBundle.LoadFromFile(fileInfo.FileLoadPath, fileInfo.ConentCRC, GetFileOffset());
}
/// <summary>
/// 异步方式获取解密的资源包对象
/// 注意:加载流对象在资源包对象释放的时候会自动释放
/// </summary>
AssetBundleCreateRequest IDecryptionServices.LoadAssetBundleAsync(DecryptFileInfo fileInfo, out Stream managedStream)
{
managedStream = null;
return AssetBundle.LoadFromFileAsync(fileInfo.FileLoadPath, fileInfo.ConentCRC, GetFileOffset());
}
private static ulong GetFileOffset()
{
return 32;
}
}
/// <summary>
/// 远端资源地址查询服务类
/// </summary>
private class RemoteServices : IRemoteServices
{
private readonly string _defaultHostServer;
private readonly string _fallbackHostServer;
public RemoteServices(string defaultHostServer, string fallbackHostServer)
{
_defaultHostServer = defaultHostServer;
_fallbackHostServer = fallbackHostServer;
}
string IRemoteServices.GetRemoteMainURL(string fileName)
{
return $"{_defaultHostServer}/{fileName}";
}
string IRemoteServices.GetRemoteFallbackURL(string fileName)
{
return $"{_fallbackHostServer}/{fileName}";
}
}
(3)获取资源版本
...
var package = YooAssets.GetPackage("DefaultPackage");
var operation = package.UpdatePackageVersionAsync();
yield return operation;
if (operation.Status == EOperationStatus.Succeed)
{
//更新成功
string packageVersion = operation.PackageVersion;
Debug.Log($"Updated package Version : {packageVersion}");
}
else
{
//更新失败
Debug.LogError(operation.Error);
}
(4)更新资源清单
对于联机运行模式,在获取到资源版本号之后,就可以利用UpdatePackageManifestAsync更新资源清单了:
...
bool savePackageVersion = true;
var package = YooAssets.GetPackage("DefaultPackage");
var operation = package.UpdatePackageManifestAsync(packageVersion, savePackageVersion);
yield return operation;
if (operation.Status == EOperationStatus.Succeed)
{
//更新成功
}
else
{
//更新失败
Debug.LogError(operation.Error);
}
(5)资源包下载
IEnumerator Download()
{
int downloadingMaxNum = 10;
int failedTryAgain = 3;
var package = YooAssets.GetPackage("DefaultPackage");
//创建资源下载器,下载所有资源
var downloader = package.CreateResourceDownloader(downloadingMaxNum, failedTryAgain);
//没有需要下载的资源
if (downloader.TotalDownloadCount == 0)
{
Debug.Log("TotalDownloadCount = 0");
yield break;
}
//需要下载的文件总数和总大小
int totalDownloadCount = downloader.TotalDownloadCount;
long totalDownloadBytes = downloader.TotalDownloadBytes;
//注册回调方法
downloader.OnDownloadErrorCallback = OnDownloadErrorFunction;
downloader.OnDownloadProgressCallback = OnDownloadProgressUpdateFunction;
downloader.OnDownloadOverCallback = OnDownloadOverFunction;
downloader.OnStartDownloadFileCallback = OnStartDownloadFileFunction;
//开启下载
downloader.BeginDownload();
yield return downloader;
//检测下载结果
if (downloader.Status == EOperationStatus.Succeed)
{
Debug.Log("Finish");
}
else
{
Debug.Log("DownLoad Failed");
}
}
private void OnStartDownloadFileFunction(string fileName, long sizeBytes)
{
Debug.Log("fileName:" + fileName + ",sizeBytes:" + sizeBytes);
}
private void OnDownloadOverFunction(bool isSucceed)
{
Debug.Log("isSucceed");
}
private void OnDownloadProgressUpdateFunction(int totalDownloadCount, int currentDownloadCount, long totalDownloadBytes, long currentDownloadBytes)
{
Debug.Log("totalDownloadCount:" + totalDownloadCount + ",currentDownloadCount" + currentDownloadCount + ",totalDownloadBytes:" + totalDownloadBytes + ",currentDownloadBytes" + currentDownloadBytes);
}
private void OnDownloadErrorFunction(string fileName, string error)
{
Debug.Log("DownloadError:" + fileName + ",error:" + error);
}
(6)资源加载
//加载场景,启用可寻址功能(Enable Addressable)后,不用写全路径,直接写资源名称即可
string location = "scene_home";
var sceneMode = UnityEngine.SceneManagement.LoadSceneMode.Single;
bool suspendLoad = false;
SceneHandle handle = package.LoadSceneAsync(location, sceneMode, suspendLoad);
yield return handle;
Debug.Log($"Scene name is {handle.SceneObject.name}");
//加载预制体
AssetHandle handle0 = package.LoadAssetAsync<GameObject>("UIHome");//不用加后缀
yield return handle0;
GameObject go = handle0.InstantiateSync();
Debug.Log($"Prefab name is {go.name}");
//加载音频
AssetHandle handle1 = package.LoadAssetAsync<AudioClip>("music_background");
yield return handle1;
AudioClip audioClip = handle1.AssetObject as AudioClip;
GameObject audio = new GameObject("AudioSource");
audio.AddComponent<AudioSource>().clip= audioClip;
audio.GetComponent<AudioSource>().Play();
(7)完整TestLoad
IEnumerator TestLoad()
{
//0.别忘初始化项目中这两个事件相关的类
// 游戏管理器 注册场景事件
GameManager.Instance.Behaviour = this;
// 初始化事件系统
UniEvent.Initalize();
//1.初始化
Debug.Log("1. 初始化");
// 初始化资源系统
YooAssets.Initialize();
// 创建默认的资源包
var package = YooAssets.CreatePackage("DefaultPackage");
// 设置该资源包为默认的资源包,可以使用YooAssets相关加载接口加载该资源包内容。
YooAssets.SetDefaultPackage(package);
//2.资源系统的运行模式
Debug.Log("2. 资源系统的运行模式");
if (PlayMode == EPlayMode.EditorSimulateMode)
{
var initParameters = new EditorSimulateModeParameters();//EditorSimulateModeParameters继承自InitializeParameters
string simulateManifestFilePath = EditorSimulateModeHelper.SimulateBuild(EDefaultBuildPipeline.BuiltinBuildPipeline.ToString(), "DefaultPackage");
initParameters.SimulateManifestFilePath = simulateManifestFilePath;
yield return package.InitializeAsync(initParameters);
}
else if (PlayMode == EPlayMode.OfflinePlayMode)
{
var initParameters = new OfflinePlayModeParameters();//OfflinePlayModeParameters继承自InitializeParameters
initParameters.DecryptionServices = new FileOffsetDecryption();//需要补充这个
yield return package.InitializeAsync(initParameters);
}
else if (PlayMode == EPlayMode.HostPlayMode)
{
// 注意:GameQueryServices.cs 太空战机的脚本类,详细见StreamingAssetsHelper.cs
string defaultHostServer = "https://static0.xesimg.com/project-yooasset/0130Offset";
string fallbackHostServer = "https://static0.xesimg.com/project-yooasset/0130Offset";
var initParameters = new HostPlayModeParameters();//HostPlayModeParameters继承自InitializeParameters
initParameters.BuildinQueryServices = new GameQueryServices();//内置资源查询服务接口
initParameters.DecryptionServices = new FileOffsetDecryption();//如果资源包在构建的时候有加密,需要提供实现IDecryptionServices接口的实例类。
initParameters.RemoteServices = new RemoteServices(defaultHostServer, fallbackHostServer);//远端服务器查询服务接口
var initOperation = package.InitializeAsync(initParameters);
yield return initOperation;
if (initOperation.Status == EOperationStatus.Succeed)
{
Debug.Log("资源包初始化成功!");
}
else
{
Debug.LogError($"资源包初始化失败:{initOperation.Error}");
}
}
else if (PlayMode == EPlayMode.WebPlayMode)
{
string defaultHostServer = "http://127.0.0.1/CDN/WebGL/V1.0";
string fallbackHostServer = "http://127.0.0.1/CDN/WebGL/V1.0";
var initParameters = new WebPlayModeParameters();//WebPlayModeParameters继承自InitializeParameters
initParameters.BuildinQueryServices = new GameQueryServices();
initParameters.RemoteServices = new RemoteServices(defaultHostServer, fallbackHostServer);
var initOperation = package.InitializeAsync(initParameters);
yield return initOperation;
if (initOperation.Status == EOperationStatus.Succeed)
{
Debug.Log("资源包初始化成功!");
}
else
{
Debug.LogError($"资源包初始化失败:{initOperation.Error}");
}
}
//3.获取资源版本:UpdatePackageVersionAsync
Debug.Log("3. 获取资源版本");
var operation = package.UpdatePackageVersionAsync();
yield return operation;
if (operation.Status == EOperationStatus.Succeed)
{
string packageVersion = operation.PackageVersion;
Debug.Log($"Updated package Version : {packageVersion}");
//4.更新资源清单:对于联机运行模式,在获取到资源版本号之后,就可以更新资源清单了:UpdatePackageManifestAsync
//联机运行模式
//通过传入的清单版本,优先比对当前激活清单的版本,如果相同就直接返回成功。如果有差异就从缓存里去查找匹配的清单,如果缓存里不存在,就去远端下载并保存到沙盒里。最后加载沙盒内匹配的清单文件。
Debug.Log("4. 更新资源清单");
bool savePackageVersion = true;
var operation2 = package.UpdatePackageManifestAsync(packageVersion, savePackageVersion);
yield return operation2;
if (operation2.Status == EOperationStatus.Succeed)
{
//5.资源包下载
Debug.Log("5. 资源包下载");
yield return Download();
//6.加载场景,启用可寻址功能(Enable Addressable)后,不用写全路径,直接写资源名称即可
//YooAssets.LoadSceneAsync("scene_home");
string location = "scene_home";
var sceneMode = UnityEngine.SceneManagement.LoadSceneMode.Single;
bool suspendLoad = false;
SceneHandle handle = package.LoadSceneAsync(location, sceneMode, suspendLoad);
yield return handle;
Debug.Log($"Scene name is {handle.SceneObject.name}");
//7.加载预制体
AssetHandle handle0 = package.LoadAssetAsync<GameObject>("UIHome");//不用加后缀
yield return handle0;
GameObject go = handle0.InstantiateSync();
Debug.Log($"Prefab name is {go.name}");
//8.加载音频
AssetHandle handle1 = package.LoadAssetAsync<AudioClip>("music_background");
yield return handle1;
AudioClip audioClip = handle1.AssetObject as AudioClip;
GameObject audio = new GameObject("AudioSource");
audio.AddComponent<AudioSource>().clip= audioClip;
audio.GetComponent<AudioSource>().Play();
//9.资源释放
handle0.Release();
package.UnloadUnusedAssets();
}
else
{
//更新失败
Debug.LogError(operation.Error);
}
}
else
{
//更新失败
Debug.LogError(operation.Error);
}
}
6.YooAsset测试
无论是通过增量构建还是强制构建,都会生成一个以Build Version命名的文件夹,我们把这个文件夹统称为补丁包。补丁包里包含了游戏运行需要的所有资源,我们可以无脑的将补丁包内容覆盖到CDN目录下。
Host Play Mode下,YooAsset资源加载顺序是:先检查StreamingAsset目录,再检查同Library目录的的Yoo缓存目录(_data),最后才Host服务器下载。
Offline Play Mode下:检查StreamingAsset目录。
三、参考资料
更多推荐
所有评论(0)