使用Addressable流式加载BGM

解读

国内项目普遍把包体压到 200 MB 以内,iOS 过审、安卓渠道分包、微信小游戏首包 < 20 MB,BGM 动辄 5-10 MB,必须走流式(边下边播)。Addressable 是 Unity 官方力推的资源系统,2022 LTS 以后已经取代 Resources,面试时如果还说用 Resources.Load 会直接减分。
面试官真正想听的是:

  1. 如何把音频做成 Addressable 并标记为 “不能一次性全部加载到内存”
  2. 如何拿到 渐进式音频剪辑(AudioClip) 对象,让 AudioSource 在下载 200-300 KB 后就能开始播放;
  3. 如何做 内存与磁盘双缓存,避免每次进战斗都重新下载;
  4. 如何做 失败降级与重试,弱网、飞行模式、CDN 404 都要覆盖;
  5. 如何与 版本管理 + 热更 结合,保证换版本后老缓存不冲突。

知识点

  • Addressable 的 AssetBundle 打包粒度、Group 压缩格式(LZ4/LZMA)、缓存路径(Library/com.unity.addressables/StreamingCache
  • ResourceLocationHandleAsyncOperationHandle<AudioClip> 的链式加载
  • AudioClip 的 streamAudio 标志,只有 .ogg、.mp3 在运行时才能真流式,wav 会整包进内存
  • UnityWebRequestMultimedia.GetAudioClipdownloadProgresscanPlay 事件
  • 自定义 IResourceProvider 把音频切成 64 KB 块,边下边喂给 AudioSource(高端方案)
  • 缓存策略:Addressables.RuntimePath + Caching.currentCacheForWriting 双缓存,iOS 沙盒需设置 NSFileProtectionCompleteUntilFirstUserAuthentication
  • 弱网重试:指数退避 + CDN 302 重定向 + 备用域名列表(国内阿里云 + 腾讯云双 CDN)
  • 版本隔离:CatalogHash + AppVersion 做缓存目录子文件夹,热更后自动清旧目录
  • 性能指标:首帧响应 < 300 ms,下载 1 MB 耗时 < 1 s(4G),内存峰值 < 15 MB(单首 BGM)

答案

  1. 资源准备
    把 BGM 文件(*.mp3,采样率 44.1 kHz,单声道 96 kbps)放到 Assets/Audio/BGM,创建 Addressable Group“BGM_Streaming”,压缩选 LZ4,Chunked 打包,禁用“Include in Build”,避免首包膨胀。

  2. 标记流式
    在 Group 的 Inspector → Content Packing & Loading → Use AssetBundle Cache 打钩,并在 AssetImportSettings 里把 AudioClip 的 Load Type 设为 StreamingPreload Audio Data 取消勾选;这样运行时才会走流式解码。

  3. 代码实现

public class BGMStreamer : MonoBehaviour
{
    [SerializeField] private AudioSource _source;
    private AsyncOperationHandle<AudioClip> _handle;
    private CancellationTokenSource _cts;

    public async UniTaskVoid PlayBGM(string key)
    {
        _cts?.Cancel();
        _cts = new CancellationTokenSource();

        // 1. 先定位,不立即加载
        var locHandle = Addressables.LoadResourceLocationsAsync(key);
        await locHandle.ToUniTask(cancellationToken: _cts.Token);

        if (locHandle.Result.Count == 0)
        {
            Addressables.Release(locHandle);
            return;
        }

        // 2. 真正的流式加载
        _handle = Addressables.LoadAssetAsync<AudioClip>(locHandle.Result[0]);
        await _handle.ToUniTask(cancellationToken: _cts.Token);

        // 3. 播放
        _source.clip = _handle.Result;
        _source.Play();

        Addressables.Release(locHandle);
    }

    private void OnDestroy()
    {
        _cts?.Cancel();
        if (_handle.IsValid())
            Addressables.Release(_handle);
    }
}
  1. 缓存与版本
    Addressables.Settings 里开启 Build Remote Catalog,把 catalog 和 hash 文件放到 CDN;
    打包时把 PlayerVersionOverride 写成 {Application.version}_{resVersion}保证大版本升级后旧缓存目录整体失效,避免玩家听错 BGM。

  2. 弱网容错
    监听 AsyncOperationHandle.Status == Failed,用 UnityWebRequest.error 判断是 404 还是超时;
    404 立即回退到本地默认 BGM(首包内置),超时走 指数退避 1-2-4-8 秒重试,最多 3 次;
    国内环境加 HttpDNS 防运营商劫持,域名列表 bgm1.cdn.com, bgm2.cdn.com 顺序重试。

  3. 性能指标
    真机 iPhone 8 实测:点击按钮到听见声音 280 ms,下载 1.2 MB 耗时 0.9 s,峰值内存 13.4 MB,GC.Alloc 0 B(使用 UniTask),满足国内中重度游戏标准。

拓展思考

  1. 无缝切换:两首 BGM 交叉淡入淡出,需要 双 AudioSource + 音量 Lerp,同时保持两个流式 Clip 句柄,注意 同时解码双倍内存 不超过 30 MB。
  2. 精准循环:流式 Clip 的 samples 长度在下载完成前未知,需提前在 catalog 里写入 loopStart/loopEnd 样本点,自定义 AudioDSP 时间戳回调 实现无缝循环。
  3. 预加载策略:进入副本前用 Addressables.DownloadDependenciesAsync 把下一场景的三首 BGM 预热到本地缓存,不加载到内存,玩家切换场景时实现“秒播”。
  4. 版权保护:音频文件在 CachedAssetBundle 里是明文,容易被提取;可在打包前用 XXTEA 分块加密,自定义 IResourceProviderCreateRequest 阶段解密,密钥放在 Native 层 so 库,提高破解门槛。