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。

  1. 底层逻辑:处理 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<T>([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

这里我使用了Task进行重构

cs 复制代码
private async Task<bool> 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<AudioClip> 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<AudioClip> 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);
    }
}

*

这里目前看起来能跑,我就不改了

相关推荐
Solis程序员18 小时前
亿级流量设计之布隆过滤器原理、优缺点及主流替代方案
java
划水的code搬运工小李18 小时前
下载CSDN到PDF
开发语言·pdf·swift
不负岁月无痕18 小时前
STL-- C++ stack_queue _priority_queue类 模拟实现
开发语言·c++
半个烧饼不加肉18 小时前
JS 底层探究--上下文
开发语言·javascript·ecmascript
小满Autumn18 小时前
依赖注入设计模式速查手册
开发语言·c#·wpf·mvvm·依赖注入
selt79119 小时前
Redisson 源码深度分析
java·c++·redis·lua
装不满的克莱因瓶19 小时前
Servlet 到 Spring MVC 架构演进:Java Web 开发二十年技术变迁史
java·spring·servlet·架构·springmvc
周末也要写八哥19 小时前
浅谈:C++中cpp 14 ~ cpp 17
开发语言·c++·算法
不会C语言的男孩19 小时前
C++ Primer 第13章:拷贝控制
开发语言·c++