做 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 放进一个ListSprite后面的播放逻辑只认这个 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.dbmacOS 有.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 ListSprite Sprites { get; private set; } new ListSprite(); public string RootDir { get { string root preferWritablePath ? Application.persistentDataPath : Application.streamingAssetsPath; return Path.Combine(root, subFolder); } } public Liststring Scan() { var result new Liststring(); 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 Liststring ScanDir(string dir) { var list new Liststring(); 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和21更小所以 img10 排在前面。这就是为什么很多人的轮播第二张跳到了第十张。解法是写一个自然排序比较器遇到连续数字就按数值比否则按字符比。这个工具类很短但每个做序列化的项目都该备一份。using System.Collections.Generic; public class NaturalComparer : IComparerstring { 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、persistentDataPathFile.ReadAllBytesTexture2D.LoadImage仅真实文件路径同步阻塞较高多一份字节数组persistentDataPath、桌面平台 StreamingAssets我默认选 UnityWebRequest理由是它把路径处理、平台差异、解码线程都吞掉了。代价是必须用协程组织代码不能写成同步函数。using System; using System.Collections; using System.Collections.Generic; using System.IO; using UnityEngine; using UnityEngine.Networking; public IEnumerator LoadAll(Actionint, int onProgress null) { Clear(); var files Scan(); var list new ListSprite(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, ActionSprite 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 BilinearBilinear 缩放平滑适合照片如果素材是像素风、需要锐利边缘改成 Point。pixelsPerUnit 100UI 里其实用不到但保持和项目一致避免以后复用同一张纹理做 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 在上。切换时把下一张塞进 nextImagealpha 从 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 ListImage dots new ListImage(); private void BuildDots(int count) { while (dots.Count count) { GameObject go Instantiate(dotPrefab, dotRoot); dots.Add(go.GetComponentImage()); } 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 上挂了 HorizontalLayoutGroupSetActive之后布局不会立刻更新第一帧会看到所有点挤在同一个位置第二帧才散开。手动强制重建一次视觉上就干净了。指示点的预制体我一般用一张 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 MB1920×1080RGBA324约 7.9 MB1920×1080RGB243约 5.9 MB2560×1440RGBA324约 14.1 MB3840×2160RGBA324约 31.6 MB1920×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 JPGSSDloadPerFrame 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首次读取目录很慢沙盒路径首次访问有开销加载期显示进度不要在主线程做同步 IOWebGL完全读不到本地目录浏览器沙盒不允许访问文件系统改用 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。第二种是缩略图条。指示点换成一行小缩略图每张缩略图