首页 / 资讯中心 / 文章详情

Unity从StreamingAssets及外部路径加载音频图片的完整指南

Unity从StreamingAssets及外部路径加载音频图片的完整指南 ★ FEATURED ARTICLE
做Unity项目尤其想做点“有内容”的功能十有八九会碰上这么个需求从外部加载一张图片、一段音频。比如活动海报、用户头像、下载好的语音包、背景音乐全都不是预先打进包里的而是在运行期间才确定的资源。这时候问题就来了——Unity里资源到底是该怎么读如果只是把资源塞进工程里那很简单Resources.Load或者直接拖个引用就行完全不折腾。麻烦的恰恰是那些“运行期间才能确定”的文件用户刚下载的一张图、服务器推送的一段音频、放在某个固定目录下的素材包。这时怎么把它们变成Texture2D、AudioClip就成了绕不开的坎。这篇文章专门把这件事讲透Unity里怎么从StreamingAssets文件夹读取音频和图片以及怎么从外部目录比如本地磁盘、Android存储读。文章不会用什么奇技淫巧全部基于Unity官方接口的常规操作但我会把平台差异、异步加载、资源释放这些容易踩坑的地方都过一遍。适合哪些人看刚入坑Unity没多久、被WWW/UnityWebRequest搞得头大的人或者在做热更新、编辑器工具、用户本地素材导入这类功能需要一份完整参考的人。1. 先把需求理清楚为什么音频和图片不能直接放进工程里1.1 静态引用、Resources和AssetBundle各自什么问题正常的Unity开发流程里资源进入项目无非三种习惯直接拖引用。把图片拖到Image的Source Image上把音频拖到AudioSource的clip上。这最方便但缺点也很明显一旦打成AssetBundle或者做热更新这种引用关系就废了而且资源散落在各个场景里管理起来很痛苦。放Resources文件夹。这是很多新手的第一选择因为Resources.LoadT(path)足够简单。但Resources文件夹里的所有东西都会无条件打进主包包体变大不说而且一旦打出去的包不能做内容替换发布之后想换张图只能重新发版。对于运营类、内容更新类的项目这基本等于自杀。用AssetBundle。灵活、可热更但流程重打AB、管理Manifest、加载依赖、处理卸载一套下来工程不小。如果你的需求就是 “加载一两个外部文件”搞AB完全是杀鸡用牛刀。这就是StreamingAssets和外部路径的用武之地。它们的位置介于“打包资源”和“运行期动态文件”之间既能随包体发布又保留了对普通文件系统的可读性非常适合放初始化的配置文件、首包语音、内置示例图片这类东西。1.2 那StreamingAssets到底算个什么东西简单说StreamingAssets文件夹在构建时会被原封不动地拷贝到目标平台的某个特定位置Unity不会对里面的文件做任何压缩、格式转换或资源打包处理。也就是说你在编辑器里放进Assets/StreamingAssets的文件发布后仍然以原始文件的形式存在于包里。听起来很美但它的定位是“只读”。你没法在运行时往里面写文件因为它在手机安装目录里不是你的私有存储区。所以它适合放“随包发布但运行期要读取”的内容不适合做下载落地目录。还有一个容易被忽视的点StreamingAssets在不同平台上的真实路径不一样读取方式也不一样。桌面平台上你可以像普通文件一样用File.ReadAllBytes直接读但Android上它被埋在APK里用普通文件API是读不出来的。这一点我在第2节会重点说坑了一大半人。1.3 外部路径和StreamingAssets到底啥区别外部路径指的是Application.persistentDataPath、Application.temporaryCachePath以及Android、iOS上用户可访问的存储目录。这类目录的特点是可写、可删、可改、运行期生成。它最适合放动态内容下载的音频、生成的截图、用户编辑的配置。如果你只是需要一个“随包素材 可能被替换”的发布方案我的建议是首包资源放StreamingAssets下载资源和用户内容放persistentDataPath。两个路径分开管理加载的API可以共用一套后面代码里我会做统一封装让上层调起来完全无感。2. 平台路径差异一半的坑都在这里2.1 不同平台下StreamingAssets的真实路径先说结论你在代码里尽量不要手动拼路径而是用Application.streamingAssetsPath。这个属性在每个平台返回的值都不一样平台返回的路径形式能否用File API编辑器工程目录下Assets/StreamingAssets可以Windows可执行文件同目录下的StreamingAssets可以macOS.app包内的Contents/Resources/Data/StreamingAssets可以Linux可执行文件旁的StreamingAssets可以iOSApplication/.../Data/Raw可以Androidjar:file:///.../base.apk!/assets/...不可以Android那行最扎眼它返回的不再是一个普通文件路径而是jar:开头的URL形式。因为Android的APK本身是一个zip压缩包StreamingAssets里的内容被打包进了APK的assets目录。普通文件API找的是真实磁盘路径自然找不到这个“虚拟路径”。所以处理Android平台的StreamingAssets正确姿势是走UnityWebRequest这类能识别URL的API。这也解释了为什么我后面所有加载方法都要基于UnityWebRequest来封装而不是直接File.ReadAllBytes。2.2 Android上看到jar协议不要慌很多人在Android真机上发现Directory.GetFiles(Application.streamingAssetsPath)返回空或者报错会一脸懵。其实原因就一条这不是一个文件系统路径而是一个压缩包内部的资源访问地址。Unity官方给的方案就是用UnityWebRequest加载它会自动识别jar:协议从APK内部解开assets内容。实际开发中还有个变通方案通过AndroidJavaClass调用Java层的AssetManager.open()拿到输入流。但除非你在写原生插件否则真没必要UnityWebRequest已经够用了。补充一点如果你的Android包开启了Split Application Binary也就是APK加OBBStreamingAssets可能会被放进OBB文件里同样不是普通路径依然需要走UnityWebRequest。所以凡是StreamingAssets内容无脑统一用UnityWebRequest就对了别想着某平台能走捷径真走捷径后面改起来都是泪。2.3 外部目录不是随便拿个路径就能用的外部目录最常用的是Application.persistentDataPath。这个名字在不同平台映射的位置也完全不一样iOS应用沙盒的Documents目录Android应用私有目录下的files即/data/data/包名/filesWindows%USERPROFILE%/AppData/LocalLow/公司名/项目名这个目录最大的好处是不需要申请任何权限读写自由iOS和Android都认。建议所有需要下载、保存、动态生成的资源都默认放这里。相对难处理的是Android公共存储目录。Android 10开始引入了分区存储机制app不能随便往公共目录写东西了写图片、多媒体文件需要按类型申请权限还要用MediaStore这类原生接口去操作。在Unity里想直接写公共目录经常会在权限上翻车。所以我的建议很简单除非确实需要让用户通过文件管理器看到文件否则一律用persistentDataPath省掉一堆权限适配的麻烦。3. 实操从StreamingAssets和外部目录加载音频3.1 音频格式与解码不是给了个文件就能放的Unity加载音频文件时需要知道文件的编码格式Android和iOS对不同格式的支持并不完全一样。常见用法是编辑器、Windows、macOS下放MP3、WAV、OGG都没问题Android上MP3是没问题的OGG一般也支持iOS上原生支持m4a、mp3、wav、aac等。因此你在UnityWebRequestMultimedia.GetAudioClip(url, audioType)里要指定AudioType。举个例子加载一个.mp3文件audioType必须是AudioType.MPEG加载wav就是AudioType.WAVogg是AudioType.OGGVORBIS。还有个偷懒方法传AudioType.UNKNOWN让Unity自动探测。实测下来大多数情况都能识别但在某些平台对某些编码可能会识别失败。所以如果文件来源是项目自控的尽量明确指定类型别依赖自动探测。3.2 UnityWebRequestMultimedia是官方正确姿势直接上代码下面这个封装就是“从任意路径加载AudioClip”的最小可用版本using System; using System.Collections; using UnityEngine; using UnityEngine.Networking; public static class AudioLoader { /// summary /// 加载音频。path 可以是 StreamingAssets 路径、persistentDataPath 路径也可以是 http/https 链接。 /// /summary public static IEnumerator LoadAudio(string path, AudioType audioType, ActionAudioClip onSuccess, Actionstring onError null) { using (UnityWebRequest request UnityWebRequestMultimedia.GetAudioClip(path, audioType)) { yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { DownloadHandlerAudioClip handler request.downloadHandler as DownloadHandlerAudioClip; AudioClip clip handler.audioClip; if (clip ! null) { // 给个名字方便排查 clip.name System.IO.Path.GetFileNameWithoutExtension(path); onSuccess?.Invoke(clip); } else { onError?.Invoke(解析出的 AudioClip 为空); } } else { onError?.Invoke(request.error); } } } }使用方式// 从 StreamingAssets 加载音频 StartCoroutine(AudioLoader.LoadAudio( Path.Combine(Application.streamingAssetsPath, bgm.mp3), AudioType.MPEG, clip { audioSource.clip clip; audioSource.Play(); }, error Debug.LogError(error)));这个封装的重点就一个用using包住UnityWebRequest确保请求和下载处理器在协程结束之后被释放掉。注意持有返回的clip比如赋值给AudioSource因为如果随后没有任何引用它可能被GC回收。3.3 流式加载和全量加载怎么选DownloadHandlerAudioClip初始化时可以传入一个streamAudio参数true表示流式加载边下载边播放false表示必须等整个文件下载完才能得到可播放的AudioClip。对大音频文件比如50MB的语音包、整章剧情音乐流式加载能明显降低启动等待和内存峰值。代码这样写public static IEnumerator LoadAudioStreaming(string path, AudioType audioType, ActionAudioClip onSuccess) { DownloadHandlerAudioClip handler new DownloadHandlerAudioClip(path, audioType); handler.streamAudio true; using (UnityWebRequest request new UnityWebRequest(path, UnityWebRequest.kHttpVerbGET)) { request.downloadHandler handler; yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { onSuccess?.Invoke(handler.audioClip); } } }注意事项流式加载得到的AudioClip必须在播放时由AudioSource持续消耗数据别把它的引用到处乱存也别主动Dispose它否则播放会中断。3.4 从外部目录加载音频代码完全一样因为UnityWebRequest本身能识别绝对路径所以不管是StreamingAssets还是persistentDataPathpath参数只是源不同而已。唯一要留神的是Windows路径里带反斜杠某些情况下构造URL会出问题。建议统一把反斜杠替换成正斜杠再传给UnityWebRequeststring normalizedPath path.Replace(\\, /); // 如果是Windows本地文件有的版本需要 file:// 前缀 #if UNITY_STANDALONE_WIN if (!normalizedPath.StartsWith(file://) !normalizedPath.StartsWith(http)) normalizedPath file:// normalizedPath; #endif这么做以后基本能做到“传入什么路径就加载什么路径”上层业务只关心拼接路径不关心底层用的哪种协议。4. 实操从外部和StreamingAssets加载图片4.1 图片解码字节数组和Texture2D图片和音频最大的区别是图片本身是文件PNG、JPG但在Unity UI里只能显示Sprite在3D里只能显示Texture2D。所以加载图片的本质是把文件字节解码成Texture2D再将Texture2D转成Sprite。Unity官方提供了一个专门方法UnityWebRequestTexture.GetTexture(url)它返回一个DownloadHandlerTexture里面的texture就是解码好的Texture2Dusing System; using System.Collections; using UnityEngine; using UnityEngine.Networking; public static class TextureLoader { public static IEnumerator LoadTexture(string path, ActionTexture2D onSuccess, Actionstring onError null) { using (UnityWebRequest request UnityWebRequestTexture.GetTexture(path)) { yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { DownloadHandlerTexture handler request.downloadHandler as DownloadHandlerTexture; Texture2D texture handler.texture; if (texture ! null) { texture.name System.IO.Path.GetFileNameWithoutExtension(path); onSuccess?.Invoke(texture); } else { onError?.Invoke(解析出的 Texture 为空); } } else { onError?.Invoke(request.error); } } } }4.2 从字节数组直接解码的Plan B如果场景特殊比如你已经通过其他渠道拿到了byte[]自己下载、从存档读、从服务器接口返回没必要再绕一圈UnityWebRequest。用Texture2D.LoadImage就行了public static Texture2D TextureFromBytes(byte[] bytes) { Texture2D texture new Texture2D(2, 2, TextureFormat.RGBA32, false); if (texture.LoadImage(bytes)) return texture; else { UnityEngine.Object.Destroy(texture); return null; } }LoadImage支持JPG和PNG还支持WebP的某些变体。注意一点默认mipmap参数为false做UI展示时完全够用但如果你要拿去做3D贴图建议创建时开启mipmap。TextureFromBytes的优点是可控性高适合已经拿到字节流、不想再发起一次网络请求的场景。缺点是不能流式加载大图会有明显的阻塞不过在StartCoroutine里用yield return null分帧处理就能缓解。4.3 把Texture2D变成UI能用的SpriteUI的Image.sprite只能接收Sprite所以拿到Texture2D之后还要转一下public static Sprite SpriteFromTexture(Texture2D texture, float pixelsPerUnit 100f) { return Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), pixelsPerUnit, 0, SpriteMeshType.FullRect, Vector4.zero); }重点说一下pixelsPerUnit。UI默认是100也就是说如果你的图片是100×100像素显示出来会是1个单位大小在Canvas的默认Scale下大概是1厘米。如果你加载了一张2000px的活动海报直接设置默认值的话UI里会大得离谱。建议按需传参或者直接把pixelsPerUnit设为纹理的实际宽度。4.4 图片内存管理大图是个坑加载一张4K图片解码后在内存里占多大以RGBA32格式计算4096 × 4096 × 4 64MB。注意这只是解码后的Texture2D还不算Sprite的开销。如果你用Texture2D.LoadImage时没设置mipmap默认是不生成mipmap链的这反而能省不少内存。但是如果你用UnityWebRequestTexture.GetTexture它是默认生成mipmap的这就意味着内存直接翻倍。所以在加载外部图片时我一般会加一个最大尺寸检查如果图片尺寸超过2048×2048就先等比缩到目标大小再返回。缩放的代码不复杂但值得单独封装public static Texture2D ResizeTexture(Texture2D source, int maxSize 2048) { if (source.width maxSize source.height maxSize) return source; float scale Mathf.Min((float)maxSize / source.width, (float)maxSize / source.height); int width Mathf.Max(1, Mathf.RoundToInt(source.width * scale)); int height Mathf.Max(1, Mathf.RoundToInt(source.height * scale)); RenderTexture rt RenderTexture.GetTemporary(width, height, 0); rt.wrapMode TextureWrapMode.Clamp; rt.filterMode FilterMode.Bilinear; Graphics.Blit(source, rt); Texture2D result new Texture2D(width, height, TextureFormat.RGBA32, false); RenderTexture.active rt; result.ReadPixels(new Rect(0, 0, width, height), 0, 0); result.Apply(false); RenderTexture.active null; RenderTexture.ReleaseTemporary(rt); if (result ! source) UnityEngine.Object.Destroy(source); return result; }这个函数看着长核心逻辑就三行拿RenderTexture当画布把原图Blit上去再读回来。因为是用GPU处理所以性能很好比在CPU上逐像素缩放快几个数量级。5. 一个可以直接抄的通用加载器5.1 为什么要写成统一封装与其在业务代码里到处写UnityWebRequest不如抽一个静态类出来统一管理。好处有三个路径统一转换、错误统一上报、卸载统一管理。下面给出一套完整的实现包括加载音频、加载图片、从StreamingAssets还是外部目录读取上层只传文件路径。5.2 完整核心代码实现using System; using System.IO; using System.Collections; using UnityEngine; using UnityEngine.Networking; public static class MediaLoader { /// summary 统一的路径拼接传入文件名返回该文件在StreamingAssets下的可访问路径 /summary public static string StreamingAssetPath(string fileName) { return UriString(Path.Combine(Application.streamingAssetsPath, fileName)); } /// summary 统一的外部文件路径persistentDataPath 子目录 文件名 /summary public static string ExternalFilePath(string fileName, string subDir ) { string dir Path.Combine(Application.persistentDataPath, subDir); if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); return dir Path.DirectorySeparatorChar fileName; } /// summary 统一把路径转成 UnityWebRequest 可识别的 URI /summary private static string UriString(string path) { path path.Replace(\\, /); if (path.StartsWith(jar:)) return path; if (path.StartsWith(file://)) return path; #if UNITY_STANDALONE_WIN return file:/// path; #else return path; #endif } // 音频 public static IEnumerator LoadAudio(string path, AudioType audioType, ActionAudioClip onSuccess, Actionstring onError null) { string url UriString(path); AudioClip clip null; using (UnityWebRequest request UnityWebRequestMultimedia.GetAudioClip(url, audioType)) { yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { clip (request.downloadHandler as DownloadHandlerAudioClip).audioClip; if (clip ! null) { clip.name Path.GetFileNameWithoutExtension(path); onSuccess?.Invoke(clip); } else onError?.Invoke(AudioClip is null); } else onError?.Invoke(request.error); } } // 图片 public static IEnumerator LoadTexture(string path, ActionTexture2D onSuccess, Actionstring onError null) { string url UriString(path); Texture2D texture null; using (UnityWebRequest request UnityWebRequestTexture.GetTexture(url)) { yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { texture (request.downloadHandler as DownloadHandlerTexture).texture; if (texture ! null) { texture.name Path.GetFileNameWithoutExtension(path); onSuccess?.Invoke(texture); } else onError?.Invoke(Texture is null); } else onError?.Invoke(request.error); } } /// summary 从文件字节流创建Sprite非UnityWebRequest方案 /summary public static Sprite LoadSpriteFromBytes(byte[] bytes, float pixelsPerUnit 100f) { Texture2D texture new Texture2D(2, 2, TextureFormat.RGBA32, false); if (!texture.LoadImage(bytes)) { UnityEngine.Object.Destroy(texture); return null; } return Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f), pixelsPerUnit); } /// summary 外部目录的显式路径加载图片无需借助UnityWebRequest直接用File接口 /summary public static IEnumerator LoadTextureFromExternalFile(string fullPath, ActionTexture2D onSuccess, Actionstring onError null) { if (!File.Exists(fullPath)) { onError?.Invoke(File not exists: fullPath); yield break; } byte[] bytes null; // 大文件的话这里可以做分帧读取但大多数图片几MB以内直接读即可 bytes File.ReadAllBytes(fullPath); if (bytes null || bytes.Length 0) { onError?.Invoke(read bytes failed); yield break; } Texture2D texture new Texture2D(2, 2, TextureFormat.RGBA32, false); if (texture.LoadImage(bytes)) { onSuccess?.Invoke(texture); } else { UnityEngine.Object.Destroy(texture); onError?.Invoke(LoadImage failed, maybe not a png/jpg?); } } }使用示例// 加载StreamingAssets下的图片 StartCoroutine(MediaLoader.LoadTexture( MediaLoader.StreamingAssetPath(poster.png), tex { uiImage.sprite MediaLoader.SpriteFromTexture(tex); }, err Debug.LogError(err))); // 加载外部下载的音频 StartCoroutine(MediaLoader.LoadAudio( MediaLoader.ExternalFilePath(voice1.mp3, downloads), AudioType.MPEG, clip { audioSource.clip clip; audioSource.Play(); }, err Debug.LogError(err)));这套封装基本覆盖了常见的读取音频、图片场景业务层只需要关心“文件名放在哪”不需要管它是StreamingAssets还是persistentDataPath。5.3 Android外部目录权限与URI处理别踩公共目录的坑上面代码里我特意不给“Android公共目录”提供支持就是怕你掉进权限泥潭。如果你真的必须读用户从系统相册选择的图片先判断一下应用目标版本Android 13及以上用系统照片选择器不需要权限Android 12及以下用Permission.RequestUserPermission(Permission.ReadExternalStorage)。但Unity的权限API在Android 10以后有变化最稳妥的方案依然是绕开公共目录让用户先把文件拷贝到persistentDataPath再读取。还有个小坑Android真机上如果直接从相册路径读取拿到的可能是content://风格的URI而不是file://路径UnityWebRequest对这个不友好。要么用原生代码转成文件路径要么直接让用户下载到应用私有目录。不管怎样优先级永远是persistentDataPath优先公共目录只读不写。5.4 协程的安全使用与错误处理上面所有方法都返回IEnumerator在MonoBehaviour里用StartCoroutine调。如果你用的是非MonoBehaviour管理器自己搞个CoroutineRunner组件挂到某个场景对象上。别在静态类里直接StartCoroutine因为静态类不继承MonoBehaviour。如果你用的是异步编程风格可以绕过协程用UnityWebRequest.SendWebRequest()搭配async/await需要引入UnityEngine.Networking和.ConfigureAwait辅助但注意Unity主线程限制UnityWebRequest回调会自动回到主线线程所以UI操作没问题。不过协程方案依然是最稳妥、最容易理解的新手也建议先彻底搞清楚协程再说。6. 常见问题与排查记录6.1 问题速查表现象常见原因解决办法Android上StreamingAssets用File.ReadAllBytes报错StreamingAssets在APK内不是真实文件系统路径改用UnityWebRequest加载iOS上加载StreamingAssets正常Android却失败Android平台路径带jar:协议路径处理方式不同统一走UnityWebRequest不要特判加载的图片发紫或显示异常纹理内存被错误释放或格式不兼容检查是否误用了Destroy(renderTexture)导致引用失效确认图片为PNG/JPG大图加载后内存暴涨默认mipmap开启RGBA32格式占用巨大设置mipmap:false加载前做尺寸限制音频加载成功但播放没声音AudioType指定错误或流式加载被提前销毁尝试AudioType.UNKNOWN不要提前Dispose handlerWindows打包后StreamingAssets路径失效打包后路径和编辑器不同用Application.streamingAssetsPath别写死路径Android 10无法写公共目录分区存储限制改到persistentDataPath下6.2 资源释放到底怎么把握调试Unity项目最烦就是内存只增不减。加载进来的Texture2D、AudioClip如果不用了记得主动Destroy。但注意如果是UI上正在显示用的SpriteDestroy它对应的Texture就会导致UI显示异常。所以正确的姿势是先卸载旧的再加载新的或者进了新页面先缓存旧资源引用等切换完成后统一清理。UnityWebRequest里的using已经帮你释放了请求和下载处理器。但DownloadHandlerTexture的texture属性返回的Texture2D是独立的Unity对象它不会因为handler释放而自动销毁除非你自己Destroy。这句话我反复说是因为真有人把handler.texture当临时变量用销毁handler后发现Texture变成“丢失”状态。6.3 调试时最好用的三板斧第一把所有加载失败都打日志格式统一成[MediaLoader] load fail: error | path。太多人连载入失败都不知道白查半天。第二把路径打出来看。Unity编辑器里用Debug.Log(Application.streamingAssetsPath)看一眼实际路径再去资源管理器里比对能解决90%的“路径不存在”问题。第三别在真机上盲改。先在编辑器的StreamingAssets目录造一个同结构的假文件用模拟路径跑通整条链路再上真机测Android效率高得多。最后再说点平时文档里搜不到的体会我在实际项目里被这个需求反复折腾过最大的教训就是别想着给每个平台写一套加载逻辑。UnityWebRequest几乎解决了所有平台差异真正需要分开处理的也就是Android那个jar协议和Windows的file前缀。与其到处特判不如在最外层做一层路径归一化让业务代码永远只面对“文件名 目录类型”两个参数。另外如果你做的是那种长期迭代、频繁换图换语音的项目强烈建议给加载好的资源加一层缓存别每次打开界面都重新从磁盘解码。一个最简单的Dictionarystring, Texture2D缓存能省下的IO时间杯水车薪但省下的卡顿和用户差评就很值了。音频同理但缓存AudioClip时注意控制数量因为每条AudioClip都比纹理更占内存缓存列表加个容量上限超了按时间戳淘汰这是我能想到的性价比最高的优化。最后还想提醒一条这篇里所有代码虽然能直接复制过去用但不建议当成黑盒不管。等你哪天真碰上诡异问题比如某个机型加载失败、某个文件格式偏门、某种权限弹窗没弹出来你会后悔没把底层机制看透。Unity这工具最值钱的经验都是在踩坑之后积累的。
阅读完成 · 觉得有帮助?
咨询建站