MyPal3(3)

音频播放

遇到的问题

复制代码
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进行了复杂的判定

  1. 语法与特性 (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); } } ``` #### \* 这里目前看起来能跑,我就不改了

相关推荐
lihihi1 小时前
P1650 [ICPC 2004 Shanghai R] 田忌赛马(同洛谷2587)
开发语言·算法·r语言
阿蒙Amon2 小时前
C#常用类库-详解Autofac
开发语言·c#
爱上妖精的尾巴2 小时前
8-18 WPS JS宏 正则表达式-边界匹配
开发语言·javascript·正则表达式·wps·jsa
格林威2 小时前
工业相机图像高速存储(C#版):内存映射文件方法,附堡盟相机C#实战代码!
开发语言·人工智能·数码相机·计算机视觉·c#·工业相机·堡盟相机
波波0072 小时前
每日一题:什么是强类型语言和弱类型语言?
开发语言
Ralph_Y2 小时前
正则表达式
开发语言·c++·正则表达式
Chan162 小时前
LeetCode 热题 100 | 矩阵
java·开发语言·数据结构·算法·spring·java-ee·intellij-idea
码农多耕地呗2 小时前
java字符串转Integer方法(正则表达式)
java·正则表达式
小二·2 小时前
Go 语言系统编程与云原生开发实战(第39篇)
开发语言·云原生·golang