音频播放
遇到的问题
复制代码
if (audioSource.isPlaying)
{
audioSource.Stop();
audioSource.clip.Destroy();
}
作者直接把已经存进audioClipCache里的Clip Destory()掉了
因为这是在游戏资源查看器这个场景中,对于几十mb的BGM,如果我们一直在测试不同的mp3,那么堆内存??很快就会爆炸
GameResourceProvider
GameResourceProvider是一个应该被注册在ServiceLocator的服务
这里使用了依赖注入的方式(显示注入),而不是使用从ServiceLocator中获取的服务进行注入(隐式注入),而且
如果使用隐式注入,那么new GameResourceProvider()就是一个"黑盒",暗地里去全局单例里"偷"东西。如果你在一个新场景里想用它,结果忘了提前向 ServiceLocator 注册 ICpkFileSystem,它就会在运行时突然崩溃。应该在GameResourceInitializer中使用ServiceLocator中获取的服务来显示构造他,这样就保证不会因为漏注册了服务而导致初始化失败。
作者还加入了卫语句Requires.IsNotNull,保证传入的一定非空。如果过没有这段卫语句,那么游戏可能在运行到一半时因为GameResourceProvider无法提供资源而崩溃。
IsNotNull
为了方便,直接写在CoreUtilities里了。
cs
复制代码
public static T IsNotNull<T>([NotNull] this T instance, string paramName)
where T : class
{
if (typeof(T) == typeof(object) ||
typeof(T).IsSubclassOf(typeof(object)))
{
if (ReferenceEquals(null, instance))
{
throw new ArgumentNullException(paramName);
}
}
else if (instance == null)
{
throw new ArgumentNullException(paramName);
}
return instance;
}
因为在 Unity 中,所有的 UnityEngine.Object(如 GameObject, MonoBehaviour, Texture2D 等)在底层都包含两部分:C# 的壳子 和 C++ 的内核数据 。
当你在 Unity 中调用 Destroy(gameObject) 时,C++ 的内核被销毁了,但 C# 的壳子(内存引用)还在,直到 GC 回收它。
为了让开发者方便,Unity 重载了 == 运算符 。如果你写 gameObject == null,Unity 底层会去检查 C++ 的内核是否还在。如果 C++ 对象死了,它就返回 true,哪怕此时 C# 引用根本不是真正的 null。
所以这里使用了反射typeof和ReferenceEquals进行了复杂的判定
语法与特性 (Attributes)
NotNull\]:这是 静态分析特性 (Static Analysis Attribute)。它告诉编译器(以及 Rider/VS 的分析引擎):"如果这个方法执行完没报错,那么传出来的这个变量绝对不是空"。这能有效消除后续代码中烦人的"可能为 null"的警告。
where T : class:泛型约束。限制 T 必须是引用类型,因为值类型(如 int, struct)在 C# 中不可能为 null。
2. 底层逻辑:处理 Unity 的"伪空" (Fake Null)
代码中区分了两种检查方式:
ReferenceEquals(null, instance):这是 物理地址检查。判断托管堆(Managed Heap)里的指针是否真的指向地址 0。(在纯 C# 中,一个引用要么指向内存地址,要么是 null(0x0))
instance == null:Unity 重写了 == 运算符。对于继承自 UnityEngine.Object 的类,即使 C# 对象还在,引用不是 0x0,如果底层的 C++ 原生对象被销毁了,== null 也会返回 true。(当销毁一个物体时,C++ 层的内存被释放了。但 C# 层的那个"壳"对象还在,并没有被 GC 立即回收)
架构价值:这个方法同时兼容了标准的 .NET 对象和 Unity 的特殊对象,确保在资源加载(如加载 CPK)时,传入的组件或路径对象是物理和逻辑双重健康的。
但在现代的C#中有更加简洁的写法:
```cs
public static T IsNotNull([NotNull] T instance, string paramName) where T : class
{
// C# 现代语法:'is null' 强制执行底层引用比对,忽略 Unity Fake Null,无需反射,性能极高
if (instance is null)
{
throw new ArgumentNullException(paramName);
}
return instance;
}
```
*** ** * ** ***
#### CpkConstants
```cs
public static class CpkConstants
{
public const char DirectorySeparatorChar = '\\';
public const string FileExtension = ".cpk";
}
```
* **struct 的语义**:它代表一个\*\*"数据载体"\*\*(Data Container)。比如 Vector3,Color,或者我们前面写的 CpkHeader。它生来就是为了在栈上分配内存,传递数值的。
* **static class 的语义**:它代表一个\*\*"无状态的工具箱"\*\*(Utility/Constant Container)。它明确告诉读代码的人:"我只是个收纳盒,用来装一些全局配置、常量或静态工具函数,我没有生命周期,也不占用实例内存。"
这里和C++不同,如果只是纯数据类应该使用static class而不是struct,代表不可继承不可实例化
*** ** * ** ***
#### C# 8.0 的新语法:Using Declaration(using声明)
```cs
using UnityWebRequest request = ...;
```
这是 C# 8.0 引入的 **Using Declaration**。它等同于 C++ 中的本地堆栈对象(RAII)。
* **作用域** :该对象会在**当前代码块(Scope)结束时**才调用 Dispose()。
* **C++**:类似于C#using语法using{}
```cpp
{
UnityWebRequest request = ...; // 构造
// 执行逻辑...
} // 这里离开作用域,自动调用析构函数
```
**异步状态机**
yield return 或 await 的时候,函数不是已经"返回"了吗?那变量是不是应该被销毁了?
**不,这就是 C# 编译器的黑魔法。**
当你把函数标记为 IEnumerator(协程)或 async Task 时,编译器会将整个函数重构为一个**状态机类**:
1. 函数里的所有局部变量(包括 request)都会被提升为这个**类的成员变量(Field)**。
2. using 的逻辑会被包装在状态机的 Dispose 或特定的状态转换中。
**执行流程推演:**
1. 代码执行 request.SendWebRequest()。
2. 遇到 yield return(或 await),函数**挂起**。此时,状态机对象依然活在堆内存中,request 成员变量也活得好好的。
3. 等到请求完成,Unity 引擎唤醒状态机,继续执行后续代码。
4. 只有当代码执行到函数的**最后一个大括号 }**,或者遇到 yield break / return 时,编译器生成的代码才会调用 request.Dispose()。
**为什么音频加载需要 using?**
UnityWebRequest 内部持有了指向 Native C++ 层的指针和缓冲区。如果不及时 Dispose,即使 C# 对象被 GC 回收了,底层的 Native 内存可能还会残留几个 GC 周期。在频繁切换 BGM 的 **Resource Viewer** 中,这可能导致内存波峰。因此,使用 using 确保 **"确定性析构"** 是非常专业的做法。
*** ** * ** ***
### LoadMp3
原作者的实现思路
[https://blog.csdn.net/z2251226240z/article/details/158350184?spm=1001.2014.3001.5502](https://blog.csdn.net/z2251226240z/article/details/158350184?spm=1001.2014.3001.5502 "https://blog.csdn.net/z2251226240z/article/details/158350184?spm=1001.2014.3001.5502")
这里我使用了Task进行重构
```cs
private async Task LoadMp3(string fileVirtualPath)
{
nowPlayingTextUI.text = "* Now Playing: " + fileVirtualPath.GetFileName(CpkConstants.DirectorySeparatorChar);
// 将虚拟的Cpk处理成Unity持久区路径
// C:\Users\zhan\AppData\LocalLow\DefaultCompany\PAL3\CacheData\music\music
string musicCachePath = _gameResourceProvider.GetMusicFilePathInCacheFolder(fileVirtualPath);
try
{
await LoadMp3AudioClipTaskAsync(fileVirtualPath, musicCachePath);
}
catch (Exception e)
{
Debug.LogError($"[GameResourceViewer] 播放音乐失败: {e.Message}");
nowPlayingTextUI.text = "Play Error!";
return false;
}
return true;
}
private async Task LoadMp3AudioClipTaskAsync(string fileVirtualPath, string musicCachePath)
{
await _gameResourceProvider.ExtractAndMoveMp3FileToCacheFolderAsync(fileVirtualPath, musicCachePath);
AudioClip clip = await _gameResourceProvider.LoadAudioClipTaskAsync(musicCachePath, AudioType.MPEG, streamAudio: true);
if (clip != null)
{
if (audioSource.isPlaying)
{
audioSource.Stop();
Destroy(audioSource.clip);
}
audioSource.clip = clip;
audioSource.volume = 0.5f;
audioSource.loop = true;
audioSource.Play();
}
}
```
fileVirtualPath这里是用CpkFileSystem中的BatchSearch搜索到的相对路径
```cs
public async Task ExtractAndMoveMp3FileToCacheFolderAsync(string musicFileVirtualPath,
string musicFileCachePath)
{
if (File.Exists(musicFileCachePath)) return;
Debug.Log($"[GameResourceProvider] 正在提取 MP3 到缓存: {musicFileCachePath}");
try
{
await Task.Run(() =>
{
new DirectoryInfo(Path.GetDirectoryName(musicFileCachePath) ?? string.Empty).Create();
File.WriteAllBytes(musicFileCachePath,_cpkFileSystem.ReadAllBytes(musicFileVirtualPath));
});
}
catch (Exception e)
{
Debug.LogError($"[GameResourceProvider] 提取 MP3 失败: {musicFileVirtualPath}. 错误: {e.Message}");
// 删除残缺文件,防止下次读取失败
if (File.Exists(musicFileCachePath)) File.Delete(musicFileCachePath);
throw; // 必须向上抛出
}
}
public async Task LoadAudioClipTaskAsync(string musicCachePath, AudioType audioType, bool streamAudio)
{
string cacheKey = musicCachePath.ToLower();
if (_audioClipCache.TryGetValue(cacheKey,out AudioClip audioClip) &&
_audioClipCache[cacheKey] != null)
{
return audioClip;
}
return await LoadAudioClipAsyncInternal(musicCachePath, audioType, streamAudio);
}
private async Task LoadAudioClipAsyncInternal(string musicCachePath, AudioType audioType, bool streamAudio)
{
if (musicCachePath.StartsWith("/")) musicCachePath = musicCachePath[1..];
string url = "file:///" + musicCachePath;
// 创建请求
using UnityWebRequest request = UnityWebRequestMultimedia.GetAudioClip(url, audioType);
// 获取 Handler 引用
var handler = (DownloadHandlerAudioClip)request.downloadHandler;
handler.streamAudio = streamAudio;
if (!streamAudio)
{
handler.compressed = true;
}
// 异步等待资源加载完成
await request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"[GameResourceProvider] 无法加载音频: {url} | 错误: {request.error}");
return null;
}
try
{
AudioClip clip = DownloadHandlerAudioClip.GetContent(request);
return clip;
}
catch (Exception e)
{
Debug.LogException(e);
return null;
}
}
```
游戏运行时使用的AudioManager也是使用的这套缓存逻辑
值得一提的是ExtractAndMoveMp3FileToCacheFolderAsync中的throw,代表向上传递异常,即虽然处理了异常,但是程序并不能继续进行下去,如果没有throw向上抛出
```cs
AudioClip clip = await _gameResourceProvider.LoadAudioClipTaskAsync(musicCachePath, AudioType.MPEG, streamAudio: true);
```
那么依然会继续执行Load的任务,一直执行到如下代码段
```cs
if (musicCachePath.StartsWith("/")) musicCachePath = musicCachePath[1..];
string url = "file:///" + musicCachePath;
// 创建请求
using UnityWebRequest request = UnityWebRequestMultimedia.GetAudioClip(url, audioType);
```
这里LoadAudioClipAsyncInternal没有做任何检查,默认传进来的musicCachePath是有效的,但是我们在发现ExtractAndMoveMp3FileToCacheFolderAsync的异常时,直接把解析错误的文件给删除了,这里就读不到对应的音频文件了。
所以应该使用throw通过使用Task包装异常的责任链一直向上传递,最终会被LoadMp3中
```cs
try
{
await LoadMp3AudioClipTaskAsync(fileVirtualPath, musicCachePath);
}
catch (Exception e)
{
Debug.LogError($"[GameResourceViewer] 播放音乐失败: {e.Message}");
nowPlayingTextUI.text = "Play Error!";
return false;
}
```
捕获
*** ** * ** ***
#### RandMp3
```cs
private async void RandMp3()
{
while(!await LoadMp3(_mp3Files[Random.Next(0, _mp3Files.Count)]));
}
```
**对于 async Task**:如果方法内部抛出异常,异常会被封装在返回的 Task 对象中。调用者可以通过 await 来捕获它,或者通过 Task.Exception 观察它。但是async void没有
对于async void的函数,不受任何 CancellationToken 或 Task 句柄控制,所以无法在其中捕获异常,即使使用trycatch。
async void的唯一合法场景是作为委托函数注册到按钮的监听器上,正确的做法:
```cs
// 合法写法:作为 UI 按钮的点击响应
public async void OnClickRandomButton()
{
try
{
await RandMp3Async(); // 内部逻辑依然封装在 Task 里
}
catch(Exception ex)
{
Debug.LogError(ex);
}
}
```
#### \*
这里目前看起来能跑,我就不改了