Unity本地目录图片轮播:动态扫描、自然排序与纹理释放

发布时间:2026/10/28 19:58:14
Unity本地目录图片轮播:动态扫描、自然排序与纹理释放

做 Unity 客户端这几年,同一个需求我至少遇到三回:UI 上要摆一个图片轮播,图片不塞进 Resources,而是放在本地某个目录里,现场的人随时往里丢图、删图,程序下次启动就按新数量跑。需求的字面意思很简单,难点全在“图片数量可任意删减”这几个字上。写死数组的方案,运营删掉两张图,索引就越界;指示点数量对不上;图片顺序按字符串排序变成 1、10、11、2;旧纹理没释放,切了几十次内存一路涨。

这篇就把我这套做法完整拆开:Unity 自动从本地目录扫描图片、解码成 Sprite、按自然顺序轮播,图片数量可以随便增删,不写一行资源清单,也不需要在 Editor 里手动拖任何东西。适合三种人看:刚接触 Unity UI 和协程的新手,能照着代码直接跑通;有半年左右经验、想搞清楚纹理内存和异步加载细节的开发者;以及给运营、美术交付过工具、被“能不能加几张图”折磨过的同行。

下面所有代码我都按可以直接粘进工程跑通的标准写,目录结构、平台差异、参数取值都给了理由,遇到需要拍脑袋的地方我会说明我为什么这么选。

1. 需求拆解:把“写死数组”换成“目录即配置”

1.1 固定数组方案的三处硬伤

最常见的写法是这样:脚本上挂一个public Sprite[] images,在 Inspector 里一张张拖进去,然后按index递增取模循环。这种写法在 demo 阶段很舒服,一旦进入交付阶段就会连着出问题。

第一处硬伤是维护成本。图片数量一变,就得回到 Editor 重新拖数组,改完还要重新出包。如果这个轮播是给现场展厅用的,每换一次素材就要发一次版本,交付周期直接被素材拖着走。

第二处硬伤是索引安全。很多人只做了index = (index + 1) % images.Length,看起来没问题,但如果数组里有空引用——比如美术改了图名导致引用丢失——或者数量被改成 0,取模就会抛除零异常。

第三处硬伤是顺序不可控。即便改成Resources.LoadAll,它返回的顺序也不保证跟你在文件夹里看到的一致,实测下来经常是按内部资源名排的,出现 1、10、2 这种顺序,运营一眼就能看出不对。

这三处问题指向同一个结论:图片的“清单”不该写进代码,也不该写进序列化数据,它应该由文件系统本身来定义。目录里有几张图,就播几张;删掉几张,下次启动自然少几张。

1.2 目录即配置:数量由文件决定

换个思路,把轮播当成一个“读取目录、生成序列、循环播放”的管道。启动时扫一遍目录,过滤出图片文件,按自然顺序排好,逐张加载成 Sprite 放进一个List<Sprite>,后面的播放逻辑只认这个 List 的Count。

这样一来,数量删减就变成了零成本操作:删文件、重启、自动生效。指示点数量跟着Count走,索引用取模加一次防御性 clamp,数量为 0 的时候走空态分支,根本不会触发越界。

我一般还会额外支持两种边界情况,很多教程会漏掉:数量为 1 的时候关掉动画和自动轮播,只显示一张静态图,避免无意义的淡入淡出;数量为 0 的时候不隐藏整个组件,而是显示一张内置的占位图,这样现场排查时能一眼看出是“图没放进去”还是“UI 没加载出来”。

1.3 三种资源位置的选型对比

Unity 能放本地图片的地方就三个,选错了后面全是坑。先把差异摊开:

位置运行时路径运行时可写入能否用 File IO 直接读典型用途
Resources 目录无(走 Resources.Load)否否数量固定、体积极小的内置资源
StreamingAssetsApplication.streamingAssetsPath否PC 可以,Android/iOS 不行随包发布、允许用户替换的素材
persistentDataPathApplication.persistentDataPath是是(全平台)用户自行增删、热更新素材

这三条路的差别,核心在“打包后文件在哪”。StreamingAssets 里的东西在桌面平台是真实文件,路径能直接读;到了 Android,它被塞进 apk 的 assets 里,路径变成jar:file:///...!/assets这种虚拟地址,Directory.Exists和File.ReadAllBytes全部失效;iOS 上虽然路径形态不同,但同样不能按普通文件处理。

persistentDataPath 刚好相反,它在所有平台都是真实可写的目录。代价是它初始为空,第一次运行得从 StreamingAssets 里拷一份默认素材过去,或者干脆允许空目录、显示占位图。

我这套代码的策略是:优先读 persistentDataPath,目录不存在时回退到 StreamingAssets;加载统一走 UnityWebRequest,绕开平台分支。下一节展开。

2. 整体方案:加载层、数据层、表现层三层分离

2.1 分层职责与文件结构

代码我拆成三个文件,各自只干一件事:

  • NaturalComparer.cs:只负责字符串的自然排序比较,纯工具类,不依赖 MonoBehaviour。
  • LocalImageLoader.cs:扫描目录、过滤扩展名、排序、异步加载、生成 Sprite、负责释放。对外只暴露Sprites列表和LoadAll协程。
  • ImageCarousel.cs:只认 Sprite 列表,管播放节奏、切换动画、指示点、手势。

为什么要拆这么细?因为轮播这件事的变化点其实很多:今天是淡入淡出,明天可能改成左右滑动;今天是指示点,明天可能改成缩略图条。把这些变化都压在同一个脚本里,改一处就得读五百行。拆开之后,换动画只需要动ImageCarousel里的一个协程,加载逻辑一行都不用碰。

目录结构我建议这样:

Assets/ Scripts/ Carousel/ NaturalComparer.cs LocalImageLoader.cs ImageCarousel.cs StreamingAssets/ CarouselImages/ 01.jpg 02.jpg 03.jpg Resources/ CarouselFallback.png

注意CarouselImages下的文件名用了两位补零。补零不是强制的,但如果能保证位数一致,排序逻辑会简单很多——这也是我下一节要讲的“自然排序”之外的第二道保险。

2.2 轮播状态机与数据流

虽然代码里不一定显式写一个状态枚举,但脑子里得有这张表,否则协程嵌套一深就乱:

状态进入条件主要动作退出条件
Idle脚本 Start显示占位图扫描开始
Loading调用 LoadAll逐张异步加载,更新进度全部加载完成或失败
Playing加载完成且数量大于 0计时、推进索引计时到点
Transitioning触发切换到某张播放淡入动画动画时长走完
Empty加载完成但数量为 0显示占位图并打警告手动触发重载

数据流是单向的:文件系统 → 字符串路径列表 → Sprite 列表 → 当前索引 → UI 表现。只要保证每一层都只被上一层喂数据,就不会出现“UI 显示的和索引不一致”这种鬼问题。

有个细节值得单独说:Transitioning期间不能再次触发切换。我的做法是在淡入协程里用一个bool isSwitching做闸门,动画期间自动播放的计时器直接跳过累加。否则帧率一抖动,两次切换的协程会叠在一起,画面会出现两个半透明图层打架。

2.3 为什么不用 Resources.Load 一把梭

新手最常问的就是:既然有Resources.LoadAll,为什么还要自己扫目录?

三个原因。第一,Resources目录里的资源在打包时会全部进包,且无法在运行期增减,跟“数量可任意删减”的需求直接冲突。第二,Resources.LoadAll的返回顺序没有文档保证,实测经常不是文件名顺序,你要稳定顺序还得自己排。第三,Resources 目录的所有资源会被打进一张总表里,项目后期这个表变大之后,启动时的索引加载会明显变慢,而且没有好的排查手段。

它也不是完全没用——我上面建议的占位图就放在 Resources 里,只有一张,开销可以忽略。这就是典型的“该用就用、别滥用”。

3. 核心实现一:目录扫描、自然排序与纹理解码

3.1 扫描与过滤:哪些文件才算数

扫描本身很简单,Directory.GetFiles拿到全部文件,按扩展名过滤。但有几个坑必须提前躲开。

第一个坑是缩略图和隐藏文件。Windows 打开文件夹时可能生成Thumbs.db,macOS 有.DS_Store,某些同步工具还会生成.png.tmp之类的临时文件。所以过滤不能只看“包含 png”,要看“扩展名精确等于”。

第二个坑是格式支持范围。Unity 的纹理解码走的是内置解码器,稳定支持 PNG、JPG,对 BMP、TGA 要看版本,WebP 和 GIF 基本不支持(GIF 只会显示第一帧或者直接失败)。这个结论必须写进给运营的交付说明里,否则一定会收到“我放了个 webp 为什么是白图”的反馈。

第三个坑是子目录。SearchOption.AllDirectories会递归进子目录,看起来方便,但现场很容易出现“回收站”目录、“备份”目录,结果素材翻倍。我默认只用TopDirectoryOnly,需要分组的话用文件名前缀解决。

using System; using System.Collections.Generic; using System.IO; using UnityEngine; public class LocalImageLoader : MonoBehaviour { [Header("相对根目录的子文件夹名")] public string subFolder = "CarouselImages"; [Header("优先读取可写目录,找不到再回退 StreamingAssets")] public bool preferWritablePath = true; [Header("每帧最多加载几张,控制卡顿")] public int loadPerFrame = 1; private static readonly string[] Extensions = { ".jpg", ".jpeg", ".png" }; public List<Sprite> Sprites { get; private set; } = new List<Sprite>(); public string RootDir { get { string root = preferWritablePath ? Application.persistentDataPath : Application.streamingAssetsPath; return Path.Combine(root, subFolder); } } public List<string> Scan() { var result = new List<string>(); if (!Directory.Exists(RootDir)) { if (preferWritablePath) { string fallback = Path.Combine(Application.streamingAssetsPath, subFolder); if (Directory.Exists(fallback)) return ScanDir(fallback); } return result; } return ScanDir(RootDir); } private List<string> ScanDir(string dir) { var list = new List<string>(); foreach (var file in Directory.GetFiles(dir, "*", SearchOption.TopDirectoryOnly)) { string ext = Path.GetExtension(file).ToLowerInvariant(); if (Array.IndexOf(Extensions, ext) >= 0) list.Add(file); } list.Sort((a, b) => NaturalComparer.Instance.Compare( Path.GetFileName(a), Path.GetFileName(b))); return list; } }

Directory.Exists在 Android 的 StreamingAssets 上返回 false,这是平台限制,不是 bug。所以如果你要上 Android 并且素材要能替换,正确做法是首次启动时把 StreamingAssets 里的素材复制到 persistentDataPath,之后全走可写目录。复制这块用UnityWebRequest逐文件读、File.WriteAllBytes写,逻辑不复杂,但要注意首次复制要放在加载流程之前,并且用 PlayerPrefs 记一个版本号,避免每次启动都拷一遍。

3.2 自然排序:img2 必须排在 img10 前面

字符串默认比较是按字符逐位比的,"img10"和"img2"比,第一个不同字符是'1'和'2','1'更小,所以 img10 排在前面。这就是为什么很多人的轮播第二张跳到了第十张。

解法是写一个自然排序比较器:遇到连续数字就按数值比,否则按字符比。这个工具类很短,但每个做序列化的项目都该备一份。

using System.Collections.Generic; public class NaturalComparer : IComparer<string> { public static readonly NaturalComparer Instance = new NaturalComparer(); private static bool IsDigit(char c) => c >= '0' && c <= '9'; public int Compare(string a, string b) { if (a == b) return 0; if (a == null) return -1; if (b == null) return 1; int i = 0, j = 0; while (i < a.Length && j < b.Length) { if (IsDigit(a[i]) && IsDigit(b[j])) { int si = i, sj = j; while (i < a.Length && IsDigit(a[i])) i++; while (j < b.Length && IsDigit(b[j])) j++; string na = a.Substring(si, i - si).TrimStart('0'); string nb = b.Substring(sj, j - sj).TrimStart('0'); if (na.Length != nb.Length) return na.Length - nb.Length; int cmp = string.CompareOrdinal(na, nb); if (cmp != 0) return cmp; } else { if (a[i] != b[j]) return a[i].CompareTo(b[j]); i++; j++; } } return (a.Length - i) - (b.Length - j); } }

这里有两个容易写错的地方。第一,判断数字别用char.IsDigit,它会把全角数字和部分 Unicode 数字也判成 true,前导零处理就乱套了,老老实实用字符区间判断。第二,数值比较前必须TrimStart('0'),否则"10"和"010"长度不同会先被判出大小,结果反而错。比较完长度再按序号比,这样01、1、001都能正确等价。

再补一句实操建议:文件名统一用01、02两位补零,即使排序器写错了,字符串比较也能得到正确结果。这是成本最低的防御措施。

3.3 两条加载路径:UnityWebRequest 与 File.ReadAllBytes

读取本地图片有两条路,各有适用场景,我把对比列出来:

方式跨平台是否阻塞主线程内存峰值适用路径
UnityWebRequestTexture.GetTexture全平台统一异步(解码在子线程,上传纹理在主线程)较低StreamingAssets、persistentDataPath
File.ReadAllBytes+Texture2D.LoadImage仅真实文件路径同步阻塞较高(多一份字节数组)persistentDataPath、桌面平台 StreamingAssets

我默认选 UnityWebRequest,理由是它把路径处理、平台差异、解码线程都吞掉了。代价是必须用协程组织代码,不能写成同步函数。

using System; using System.Collections; using System.Collections.Generic; using System.IO; using UnityEngine; using UnityEngine.Networking; public IEnumerator LoadAll(Action<int, int> onProgress = null) { Clear(); var files = Scan(); var list = new List<Sprite>(files.Count); int finished = 0; int frameCounter = 0; int perFrame = Mathf.Max(1, loadPerFrame); foreach (var path in files) { yield return LoadOne(path, sp => { if (sp != null) list.Add(sp); else Debug.LogWarning($"[Carousel] 跳过无法加载的文件:{path}"); }); finished++; onProgress?.Invoke(finished, files.Count); if (++frameCounter >= perFrame) { frameCounter = 0; yield return null; } } Sprites = list; Debug.Log($"[Carousel] 扫描到 {files.Count} 个文件,成功加载 {list.Count} 张"); } private IEnumerator LoadOne(string path, Action<Sprite> onDone) { string url = ToUrl(path); using (UnityWebRequest req = UnityWebRequestTexture.GetTexture(url, true)) { yield return req.SendWebRequest(); #if UNITY_2020_2_OR_NEWER if (req.result != UnityWebRequest.Result.Success) #else if (req.isNetworkError || req.isHttpError) #endif { onDone?.Invoke(null); yield break; } Texture2D tex = DownloadHandlerTexture.GetContent(req); tex.wrapMode = TextureWrapMode.Clamp; tex.filterMode = FilterMode.Bilinear; tex.anisoLevel = 1; Sprite sp = Sprite.Create( tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f, 0, SpriteMeshType.FullRect); sp.name = Path.GetFileNameWithoutExtension(path); onDone?.Invoke(sp); } } private static string ToUrl(string path) { if (path.Contains("://")) return path; return new Uri(path).AbsoluteUri; }

ToUrl那段看着不起眼,其实是整个加载流程里最容易翻车的地方。Unity 在 Android 上返回的streamingAssetsPath本身就带jar:file://前缀,如果无脑拼"file://" + path,就变成file://jar:file:///...,直接失败。用path.Contains("://")判断一次就能绕开。桌面平台用new Uri(path).AbsoluteUri会自动处理盘符和反斜杠,比手拼字符串稳。

3.4 纹理参数与 Sprite 创建的四个关键设置

UnityWebRequestTexture.GetTexture(url, true)第二个参数是nonReadable,设成 true 表示不保留 CPU 侧的可读副本。对轮播这种只负责显示的场景,一定要设 true,它能省掉大约一半的运行时内存。很多人不知道这个参数,加载几十张大图后内存直接翻倍。

Sprite.Create那串参数我按重要性说明:

  • wrapMode = Clamp:默认是 Repeat。图片如果被缩放到比原图小、或者 UI 上有轻微缩放,Repeat 模式下边缘会出现一圈相邻边的像素,看起来像“串色”。Clamp 是显示单张图的唯一正确选择。
  • filterMode = Bilinear:Bilinear 缩放平滑,适合照片;如果素材是像素风、需要锐利边缘,改成 Point。
  • pixelsPerUnit = 100:UI 里其实用不到,但保持和项目一致,避免以后复用同一张纹理做 3D 显示时尺寸突然不对。
  • SpriteMeshType.FullRect:默认是 Tight,会按 alpha 裁掉透明边。对轮播来说,Tight 会让不同图片的显示尺寸不一致,画面跳一下。FullRect 是矩形,尺寸稳定。
  • nonReadable + GetContent:注意拿到的是解压后的 RGBA32 或 RGB24 纹理,原始 JPG 的压缩基本没用上。所以“把图压小再放进去”对运行时内存几乎没帮助,真正有效的是降低分辨率。

最后一个设置容易被忽略:Texture2D的mipmapCount。GetTexture默认不生成 mipmap,这对 UI 场景是好事。如果你后来发现图片缩小后闪烁,别急着加 mipmap,先检查是不是filterMode设成了 Point。

3.5 分帧加载与加载期占位

20 张 1920x1080 的图,如果一帧内全部加载完,主线程会卡死一两秒。所以上面代码里加了loadPerFrame,默认每帧加载一张,中间yield return null让一帧。

这个值怎么定?我的经验是按单张耗时倒推。单张 1920x1080 JPG 在 PC 上从读文件到纹理上传大约 10 到 25 毫秒,也就是一张图就可能吃掉一帧的预算。所以loadPerFrame = 1比较稳。如果素材是 800x600 的小图,单张 3 到 5 毫秒,可以设成 3 到 4,加载 20 张只要五六帧。

加载期间 UI 不能空着。我的做法是让ImageCarousel先把占位图显示出来,加个进度文字,等LoadAll完成再切到第一张。如果加载失败、一张都没有,占位图就留在那里,同时在 Console 打一条明确的警告。

// 加载期占位示例 placeholderImage.gameObject.SetActive(true); yield return loader.LoadAll((done, total) => { progressText.text = $"{done}/{total}"; }); placeholderImage.gameObject.SetActive(loader.Sprites.Count == 0);

进度回调不要每张都string.Format,会产生大量临时字符串。数量多的话加一个“只有数字变化时才刷新文本”的判断。

4. 核心实现二:轮播控制器与动态指示点

4.1 自动播放的时间基准与索引推进

时间基准只有一个原则:用Time.unscaledDeltaTime。UI 轮播经常出现在弹窗里,如果用的是Time.deltaTime,一旦游戏时间被暂停(比如打开设置面板把Time.timeScale设成 0),轮播就停了,用户会以为界面卡死。

索引推进我不用(index + 1) % Count直接算,而是写了个Wrap方法做双重取模,这样即使传进来负数索引也不会翻车:

private int Wrap(int i, int count) { if (count <= 0) return 0; int r = i % count; return r < 0 ? r + count : r; }

为什么考虑负数?因为后面要做左右滑动切换,index - 1在 index 为 0 时是 -1,如果直接去取列表会抛异常。双重取模是防御性写法,成本几乎为零。

自动播放的循环我写成这样:

private IEnumerator AutoPlayRoutine() { while (true) { if (!isSwitching && !isDragging && Count > 1) { timer += Time.unscaledDeltaTime; if (timer >= interval) { timer = 0f; yield return SwitchTo(index + 1, true); } } else { timer = 0f; } yield return null; } }

注意Count > 1这个条件。数量为 1 的时候不需要自动播放,让计时器空转就行。

4.2 切换动画:交叉淡入与位移两套实现

我准备了两套动画,交叉淡入适合单张大图全屏轮播,位移适合有边框、要做“卡片滑动”感觉的场景。

交叉淡入需要两个 Image 叠在一起,名字叫currentImage和nextImage,层级上 nextImage 在上。切换时把下一张塞进 nextImage,alpha 从 0 过渡到 1,动画结束后再把 sprite 换到 currentImage、nextImage 置透明。这样任何时刻都只有一次 alpha 混合,性能可控。

public float fadeDuration = 0.35f; private IEnumerator CrossFadeRoutine(int targetIndex) { Sprite sp = Sprites[targetIndex]; if (sp == null) yield break; nextImage.transform.SetAsLastSibling(); nextImage.sprite = sp; nextImage.color = new Color(1f, 1f, 1f, 0f); nextImage.gameObject.SetActive(true); float t = 0f; while (t < fadeDuration) { t += Time.unscaledDeltaTime; float k = Mathf.Clamp01(t / fadeDuration); float e = k * k * (3f - 2f * k); // smoothstep,起止更柔和 nextImage.color = new Color(1f, 1f, 1f, e); yield return null; } currentImage.sprite = sp; currentImage.color = Color.white; nextImage.color = new Color(1f, 1f, 1f, 0f); nextImage.gameObject.SetActive(false); index = targetIndex; RefreshDots(index); }

淡入时长我用 0.35 秒。这个数字试出来的:低于 0.2 秒视觉上像硬切,高于 0.6 秒节奏拖沓,尤其是停留时间只有 3 秒的时候,动画占了五分之一会让人烦。缓动函数用 smoothstep,比线性插值顺眼很多,代码量就多一行。

位移版本用RectMask2D裁出一个视口,两张图并排放,靠改anchoredPosition平移。核心是用同一个缓动曲线,位移 800 像素在 0.35 秒内完成,速度约 2300 像素每秒,观感比较干脆。要注意位移结束后必须把两张图的 anchoredPosition 各自归位,否则下一次滑动方向会乱。

两套方案共同的坑是ScrollRect冲突。如果你的轮播外层套了 ScrollRect,交叉淡入方案要关掉 ScrollRect 的横向滚动,否则手指滑动会同时触发两者。

4.3 指示点动态生成与数量联动

指示点最忌讳的做法是“每次切图都销毁重建”,会持续产生 GC,而且 LayoutGroup 每帧重排,性能很难看。正确做法是池化加复用:数量不够就 Instantiate 补,数量多余就 SetActive(false),全程不销毁。

public RectTransform dotRoot; public GameObject dotPrefab; public Color normalColor = new Color(1f, 1f, 1f, 0.35f); public Color activeColor = Color.white; private readonly List<Image> dots = new List<Image>(); private void BuildDots(int count) { while (dots.Count < count) { GameObject go = Instantiate(dotPrefab, dotRoot); dots.Add(go.GetComponent<Image>()); } for (int i = 0; i < dots.Count; i++) dots[i].gameObject.SetActive(i < count); LayoutRebuilder.ForceRebuildLayoutImmediate(dotRoot); } private void RefreshDots(int cur) { for (int i = 0; i < dots.Count; i++) { if (!dots[i].gameObject.activeSelf) continue; bool on = (i == cur); dots[i].color = on ? activeColor : normalColor; dots[i].rectTransform.localScale = on ? Vector3.one * 1.25f : Vector3.one; } }

ForceRebuildLayoutImmediate这一句是必需的。如果 dotRoot 上挂了 HorizontalLayoutGroup,SetActive之后布局不会立刻更新,第一帧会看到所有点挤在同一个位置,第二帧才散开。手动强制重建一次,视觉上就干净了。

指示点的预制体我一般用一张 8x8 的圆形 Sprite,加 Image 组件、勾掉 Raycast Target。临时没图的时候可以用 Unity 内置 UI 资源里的 Knob 顶一下,但正式版本一定让美术出一张干净的圆,Knob 带内阴影,放到纯色背景上很难看。

图片数量多的时候,指示点会挤成一排。我的处理是超过 8 个就只显示当前点附近的 5 个,两端的点缩小到 0.5 倍作为“还有更多”的暗示。这个逻辑写起来也就二十行,但体验提升很直观。

4.4 手动滑动、点击跳转与自动播放的抢权重置

用户主动操作时,自动播放必须让路,而且是“让路 + 重置计时”,不能只是暂停。否则用户刚滑到第 5 张,计时器还差 0.2 秒,立刻又被自动切走,体验极差。

实现上用三个标记:isDragging在BeginDrag时置 true、EndDrag时置 false 并清零timer;点击指示点跳转时,先停掉自动播放协程,跳完再重新启动;左右滑动用ScrollRect.onValueChanged判断偏移量是否过阈值,过阈值才切图,避免手抖误触。

还有一个不那么显眼的问题:快速连点。用户连续点五下指示点,如果每次都启动一个淡入协程,五个协程会并行跑,alpha 互相覆盖,画面会疯狂闪烁。解法是维护一个Coroutine currentTransition,新的切换开始前先StopCoroutine掉旧的,并且立刻把两个 Image 的颜色恢复到确定状态。

5. 内存与性能:纹理占用的算法和实测

5.1 一张图到底吃多少内存

纹理内存有精确公式:宽 × 高 × 每像素字节数 × (1 + mipmap 系数)。不生成 mipmap 时系数为 0,也就是直接乘。每像素字节数取决于纹理格式,Unity 运行时从 JPG/PNG 解码出来的是 RGBA32 或 RGB24。

分辨率格式每像素字节单张占用
1280×720RGBA324约 3.5 MB
1920×1080RGBA324约 7.9 MB
1920×1080RGB243约 5.9 MB
2560×1440RGBA324约 14.1 MB
3840×2160RGBA324约 31.6 MB
1920×1080ASTC 6×60.44约 0.9 MB

看这张表就能明白为什么“压缩图片文件”对运行时没用:你把 5 MB 的 PNG 压成 800 KB 的 JPG,进内存还是 7.9 MB,省的是包体和加载时间,不是显存。

真正的杠杆是分辨率和格式。手机端如果只显示在 1080 宽的屏幕上,素材就没必要做成 4K。我一般按“UI 实际显示宽度 × 1.5”定素材分辨率上限,再多就是浪费。

5.2 降采样、压缩与释放时机

运行时降采样最省事的做法是在加载后调用一次Texture2D.ReScaled或者手动Graphics.Blit,但注意这两个操作都要求原纹理可读,也就是nonReadable必须设 false,反而多占一份内存。所以“先加载大图再缩放”这条路是负收益。

更实际的方案是源头控制加缓存策略。我通常做两件事:

第一,加载前先读图片尺寸(不用完整解码,读文件头就行),超过阈值直接跳过并打警告。Unity 没有现成的读尺寸 API,但有取巧办法:Texture2D.LoadImage之后立刻判断,超了就Destroy掉。虽然不完美,但能兜住“运营放了一张 8K 图”的极端情况。

第二,释放要成对。销毁顺序必须是先 Sprite 后 Texture,因为 Sprite 内部持有 Texture 的引用:

public void Clear() { foreach (var sp in Sprites) { if (sp == null) continue; Texture2D tex = sp.texture; Destroy(sp); if (tex != null) Destroy(tex); } Sprites.Clear(); }

反过来的话,Sprite 里会留下一个已销毁的纹理引用,再访问就报MissingReferenceException。这个错误我在别人的项目里见过至少两次。

还有一个隐蔽的内存泄漏点:切换动画里的nextImage.sprite。如果每张图都新建 Sprite 而不复用,来回切几十次之后场景里会堆一堆孤儿对象。上面代码是复用同一个 Sprite 对象、只换引用,所以不会泄漏,但RefreshDots里不要给每个点新建材质。

5.3 实测数据与优化取舍

某次在桌面平台跑的参考数据,20 张 1920×1080 JPG,SSD,loadPerFrame = 1:

阶段耗时说明
目录扫描 + 排序3 毫秒20 个文件,可以忽略
单张读文件 + 解码8 到 15 毫秒JPG 复杂度影响较大
纹理上传显卡3 到 6 毫秒首帧会有一次 GPU 上传
总计(分帧后)约 0.5 秒,无感知每帧只卡一帧的时长

如果素材换成 4K,单张解码会到 40 到 60 毫秒,一帧内完成会让帧率明显掉一下。这时候loadPerFrame = 1就不够用了,得在加载期间给一个全屏遮罩或者进度条,让用户对“正在加载”有预期。

手机端的实测差异更大,中低端机型解码同一张 1920×1080 大概要 20 到 35 毫秒,所以手机上我基本固定每帧一张,并且加载期不播放任何其他动画。

6. 常见问题与排查速查表

6.1 加载成功但显示异常的几类

图片能读出来但显示不对,这类问题占了我调试时间的一半。我把遇到过的情况整理成表:

现象大概率原因处理方式
显示纯白方块纹理创建成功但 Image 的 Sprite 为空,或 alpha 为 0检查赋值顺序,确认color.a是 1
显示粉红色用了自定义 Shader 但材质不兼容UI 场景直接用默认 UI/Default
图片上下颠倒不做处理的话 Unity 会自动翻转,手工 LoadImage 时需要自己翻用 UnityWebRequest 就不存在这个问题
图片被拉伸变形Image 的尺寸和原图比例不一致开preserveAspect或加 AspectRatioFitter
图片边缘有彩色噪点wrapMode是 Repeat改成 Clamp
播放时闪一下黑切换瞬间两个 Image 都透明先给 nextImage 赋值再启动淡入

“闪黑”这个问题特别常见。原因是在淡入的第一帧,nextImage.color被设成 alpha 0,而currentImage如果此时还没赋值(比如首帧切换),画面上就什么都没有。解法是首帧切换不做动画,直接硬切,从第二张开始才走淡入。

6.2 平台差异踩坑清单

平台差异主要有三块,我在表里列清楚:

平台现象根因处理方式
AndroidDirectory.Exists返回 falseStreamingAssets 在 apk 内,不是真实目录走 persistentDataPath,或首次启动复制
Android路径拼接后加载失败jar:file://前缀被二次拼接判断Contains("://")后直接返回
iOS首次读取目录很慢沙盒路径首次访问有开销加载期显示进度,不要在主线程做同步 IO
WebGL完全读不到本地目录浏览器沙盒不允许访问文件系统改用 StreamingAssets + UnityWebRequest
桌面路径含中文加载失败URL 转义不一致目录和文件名统一用英文加数字

WebGL 要单独说一句。浏览器环境不存在“本地目录”这个概念,Directory.GetFiles直接抛异常。要支持 WebGL,只能把图片清单在打包时导成一个 JSON,运行期按清单一个个用 UnityWebRequest 拉。多维护一步,但没别的办法。

6.3 增删图片后不生效的排查路径

“我明明删了两张图,怎么还是显示原来那么多张”,这是运营最常反馈的问题。排查按这个顺序走:

第一步,确认目录。Debug.Log(RootDir)打印出来的路径,直接复制到资源管理器里打开看看,图片是不是真的在那里。很多时候是因为工程里改了subFolder,实际读的是另一个目录。

第二步,确认缓存。如果第一次运行把 StreamingAssets 复制到了 persistentDataPath,那之后读的都是副本,改原目录当然不生效。要么改副本,要么加一个版本号机制强制重拷。

第三步,确认场景对象。有些项目里轮播是常驻对象,切场景不销毁,重新进场景时不会重新执行Start,自然拿的还是旧数据。这种情况要提供一个手动重载入口,比如在设置页加个“刷新素材”按钮,或者干脆在OnEnable里重新扫描。

第四步,确认构建产物。Editor 里改了 StreamingAssets 的图,出包之后包体里是新的;但如果只跑了旧的构建产物,看到的当然是旧图。这类低级错误我承认自己犯过。

调试期我一般会加一个快捷键重载:

private void Update() { if (Input.GetKeyDown(KeyCode.R)) { StopAllCoroutines(); StartCoroutine(ReloadAll()); } }

只在 Editor 或开发包里加,正式包记得用宏关掉。

7. 我那几处踩坑换来的经验

7.1 给运营和美术的交付约定

技术上做得再稳,交付环节没约定好一样出问题。我现在的做法是随包附带一份很短的说明,只写四条:图片放哪个目录;只支持 jpg 和 png;文件名用两位数字开头,比如01.jpg、02.png;单张建议不超过 1920 宽。这四条看起来废话,但能挡掉九成的沟通成本。

另外我会在加载完成后做一次数量校验:如果实际加载成功的数量比扫描到的文件数量少,就在界面上打一条不显眼的提示(比如右下角小字)。这样现场的人自己就能发现“有张图格式不对”,不用等我远程排查。

还有一点是关于路径的。目录名和文件名我强制要求用英文加数字,不用中文、不用空格。原因是 URL 转义在不同平台表现不完全一致,中文路径在桌面上可能正常、到手机上就 404。这个约定写在文档第一行,比事后调试省事得多。

7.2 可以继续往下扩的几个方向

这套骨架其实很适合往上加东西。视频轮播是最直接的一种:把LocalImageLoader里的扩展名过滤加上.mp4,然后换成VideoPlayer组件,播放逻辑几乎不用改,因为状态机是一样的。要注意的是视频纹理的内存占用比图片高一个量级(RenderTexture尺寸要单独控制),手机上建议限制到 720p。

第二种是缩略图条。指示点换成一行小缩略图,每张缩略图