1. 六个脚本到底解决了哪些实际痛点
做Unity UI动效这行的朋友应该都有体会,项目做到中后期,真正耗时间的往往不是写Shader或者调Timeline,而是那些重复、琐碎、又不得不做的杂活。比如改一个按钮的缩放曲线,得反复切进Play模式看效果;美术给了一批序列帧,得手动一张张压缩;图集打出来发现超了2048,又得回头翻是哪张贴图惹的祸。这些事单看每件都不大,但一天下来能吃掉你三四个小时。
这套脚本就是冲着这些场景去的。六个工具分别对应六个高频痛点:自动保存解决的是Unity编辑器崩溃丢进度的问题;图片压缩解决的是批量处理贴图导入设置的问题;图集估算解决的是打包前预判图集尺寸的问题;动画末帧与偏移解决的是UI动效首尾帧对齐和位移偏差的问题。全部基于Editor脚本实现,不依赖任何第三方插件,拖进工程就能用。
适合谁参考?如果你正在做Unity UI动效、界面交互,或者团队里只有你一个TA要兼顾工具链,这套东西能直接省掉你造轮子的时间。哪怕你只是刚接触Editor扩展开发,这几个脚本的写法也足够典型,拿来当学习模板完全够格。
2. 自动保存脚本:别等崩溃了才后悔
2.1 为什么Unity自带的自动保存不够用
Unity其实有自动保存功能,在Preferences里可以设置保存间隔。但实际用下来有几个问题:第一,它只在编辑器失去焦点时才触发,如果你一直盯着Scene视图调动画,它可能半小时都不存一次;第二,它保存的是场景文件,但Prefab的修改、ScriptableObject的改动不一定能覆盖到;第三,保存时没有任何提示,你根本不知道它什么时候存了、存了什么。
我自己的习惯是写一个独立的自动保存脚本,逻辑很简单:定时器 + 脏标记检测。核心思路是每隔固定时间检查当前场景和打开的资源是否有未保存的修改,如果有就执行保存,同时在Console里打一条带时间戳的日志。这样你随时能翻日志确认保存记录,心里有底。
2.2 脚本实现的关键细节
先看核心代码结构:
[InitializeOnLoad] public static class AutoSaveTool { private static double _nextSaveTime; private const double SaveInterval = 300.0; // 5分钟 static AutoSaveTool() { _nextSaveTime = EditorApplication.timeSinceStartup + SaveInterval; EditorApplication.update += OnEditorUpdate; } private static void OnEditorUpdate() { if (EditorApplication.timeSinceStartup < _nextSaveTime) return; _nextSaveTime = EditorApplication.timeSinceStartup + SaveInterval; if (!EditorApplication.isPlaying && !EditorApplication.isCompiling) { SaveModifiedAssets(); } } }这里有几个点值得展开说。EditorApplication.timeSinceStartup返回的是编辑器启动以来的秒数,用double存,比DateTime.Now更适合做编辑器内的计时,因为它不受系统时间调整影响。InitializeOnLoad特性保证脚本在编辑器加载时自动初始化,不需要你手动挂载。
保存逻辑里我加了两道判断:isPlaying和isCompiling。运行时不保存是因为Play模式下的修改本来就不会持久化,保存反而可能把运行时状态写进场景;编译中不保存是因为此时资源数据库正在刷新,强行保存可能触发死锁。这两个坑我都踩过,尤其是编译中保存导致编辑器卡死那次,直接丢了半小时的Prefab调整。
2.3 保存范围与日志输出
保存范围我建议分三档:场景文件、Prefab、其他资源。场景用EditorSceneManager.SaveOpenScenes(),Prefab和资源用AssetDatabase.SaveAssets()。但SaveAssets会把所有脏资源都存一遍,如果工程大可能会卡顿,所以更精细的做法是遍历AssetDatabase.GetAllAssetPaths()配合EditorUtility.IsDirty筛选。
日志输出别用Debug.Log,用Debug.LogFormat带上时间戳和保存数量,方便回溯:
Debug.LogFormat("[AutoSave] {0} 已保存 {1} 个资源,耗时 {2:F2}ms", DateTime.Now.ToString("HH:mm:ss"), savedCount, elapsedMs);注意:自动保存间隔别设太短,5分钟是底线。设成1分钟的话,每次保存都会打断你的操作节奏,而且频繁写磁盘对SSD寿命也有影响。我试过2分钟,结果一天下来保存了200多次,Console刷得没法看。
3. 图片压缩脚本:批量处理导入设置的省力方案
3.1 手动改导入设置有多折磨
美术给过来的贴图,命名规范、尺寸规范往往参差不齐。一张2048x2048的UI底图,如果按默认设置导入,在移动端能占掉8MB内存。你得手动选中、改Texture Type、改Max Size、改Compression、点Apply,一张一张来。一批50张图,光改设置就得半小时,还容易漏。
这个脚本的思路是:遍历指定文件夹下的所有贴图,按预设规则批量修改TextureImporter的设置。规则可以按文件夹名、文件名前缀、或者尺寸阈值来区分。比如UI/Atlas/下的图统一设成Sprite、Max Size 1024、压缩格式ASTC 6x6;UI/Background/下的设成Max Size 2048、ASTC 8x8。
3.2 压缩格式的选择逻辑
压缩格式这块得说清楚,因为选错了画质和体积都遭殃。移动端主流是ASTC,但不同block size差别很大:
| 格式 | 适用场景 | 体积比 | 画质 |
|---|---|---|---|
| ASTC 4x4 | 高质量UI、图标 | 1.0x | 最高 |
| ASTC 6x6 | 一般UI、按钮 | 0.44x | 良好 |
| ASTC 8x8 | 背景、大图 | 0.25x | 可接受 |
| ETC2 | 兼容老设备 | 0.5x | 一般 |
我的经验是:UI图标和文字用ASTC 4x4或6x6,背景大图用8x8。如果项目要上微信小游戏,还得考虑平台是否支持ASTC,不支持的话退回ETC2。脚本里可以做一个平台判断,根据EditorUserBuildSettings.activeBuildTarget自动切换格式。
3.3 批量处理脚本的实操写法
核心代码大概长这样:
[MenuItem("Tools/UI/批量压缩贴图")] public static void CompressTextures() { var guids = AssetDatabase.FindAssets("t:Texture2D", new[] { "Assets/UI" }); foreach (var guid in guids) { var path = AssetDatabase.GUIDToAssetPath(guid); var importer = AssetImporter.GetAtPath(path) as TextureImporter; if (importer == null) continue; importer.textureType = TextureImporterType.Sprite; importer.maxTextureSize = GetMaxSizeByPath(path); importer.textureCompression = TextureImporterCompression.Compressed; var settings = new TextureImporterPlatformSettings { name = "Android", overridden = true, format = TextureImporterFormat.ASTC_6x6, maxTextureSize = GetMaxSizeByPath(path) }; importer.SetPlatformTextureSettings(settings); importer.SaveAndReimport(); } AssetDatabase.Refresh(); }SaveAndReimport这步不能省,否则设置改了但资源没重新导入,图集里还是旧数据。但要注意,批量Reimport很耗时,50张图可能要跑两三分钟,建议放在午休或者下班前跑。
实操心得:跑批量压缩之前,先
git commit一下。因为Reimport会改变贴图的meta文件,万一规则设错了想回滚,没有版本控制就只能手动改回来。我有次把Max Size全设成了512,结果所有UI都糊了,还好有git。
4. 图集估算脚本:打包前预判尺寸避免返工
4.1 图集超尺寸的代价
Unity的Sprite Atlas有个硬限制:单张图集最大尺寸取决于平台,移动端通常2048x2048。如果打包时发现某张图集超了,Unity会自动缩放到下一个2的幂次,但这个过程会重新排列所有Sprite,可能导致引用丢失或者显示错位。更麻烦的是,你往往是在出包前最后一刻才发现这个问题,回头改图集分组、重新打图、重新测试,一晚上就没了。
图集估算脚本的作用就是在打包前跑一遍,算出每个图集的实际占用面积,提前告诉你哪张会超。原理不复杂:遍历图集包含的所有Sprite,读取它们的rect尺寸,累加面积,再考虑Unity的图集打包算法(通常是矩形装箱)带来的空隙率,估算出最终尺寸。
4.2 估算算法的实现要点
Unity的图集打包用的是类似MaxRects的算法,但具体实现没开源。我们做估算不需要完全精确,只要误差在10%以内就够用了。我的做法是:总面积乘以1.3的系数(经验值,覆盖空隙和边距),然后向上取到最近的2的幂次。
public static Vector2Int EstimateAtlasSize(SpriteAtlas atlas) { var sprites = new Sprite[atlas.spriteCount]; atlas.GetSprites(sprites); long totalArea = 0; foreach (var sprite in sprites) { if (sprite == null) continue; totalArea += (long)(sprite.rect.width * sprite.rect.height); } // 1.3为经验空隙系数,可根据项目实际情况调整 long estimatedArea = (long)(totalArea * 1.3f); int side = Mathf.NextPowerOfTwo((int)Mathf.Sqrt(estimatedArea)); return new Vector2Int(side, side); }Mathf.NextPowerOfTwo是Unity自带的方法,返回大于等于输入的最小2的幂次。比如输入1500返回2048,输入2100返回4096。这样你一眼就能看出哪张图集会超2048。
4.3 输出报告与阈值告警
光算出来还不够,得让结果一目了然。我习惯在Console里用表格形式输出,超标的用红色标记:
Debug.LogFormat("<color={0}>图集 {1}: 估算尺寸 {2}x{2},Sprite数量 {3}</color>", side > 2048 ? "red" : "green", atlas.name, side, sprites.Length);如果项目里图集很多,还可以生成一份CSV报告存到工程根目录,方便用Excel打开排序。这个脚本我一般放在打包流程的第一步,跑完确认没问题再执行Build,能省掉大量返工时间。
注意:
atlas.GetSprites在编辑器下返回的是Sprite引用,如果图集还没打包(比如刚创建),可能返回空数组。这时候需要先调用atlas.Pack或者手动触发一次打包。另外,图集的includeInBuild选项如果关了,估算结果可能不准,因为运行时不会包含这些Sprite。
5. 动画末帧与偏移脚本:UI动效对齐的自动化方案
5.1 首尾帧不对齐有多常见
做UI动效的时候,经常遇到一个问题:动画播完了,UI元素的位置和动画开始前对不上。比如一个弹窗从缩放0.8弹到1.0,理论上末帧应该是1.0,但实际可能因为关键帧插值、曲线设置、或者父节点偏移,导致末帧是0.98或者1.02。这种偏差在单个动画里不明显,但多个动画串联时就会累积,最后UI错位。
还有一种情况是序列帧动画,美术导出的图片序列首尾帧有偏移,播放时会有跳动感。手动一帧帧对太费时间,这个脚本就是自动检测并修正这些偏差。
5.2 末帧对齐的实现逻辑
对于Transform动画,思路是:记录动画开始前的初始值,播放到末帧,比较当前值和初始值的差异,如果超过阈值就自动修正关键帧。核心是AnimationClip的曲线操作:
public static void AlignLastFrame(AnimationClip clip, Transform target) { var binding = EditorCurveBinding.FloatCurve( AnimationUtility.GetAnimatableBindingPath(target), typeof(Transform), "m_LocalPosition.x"); var curve = AnimationUtility.GetEditorCurve(clip, binding); if (curve == null || curve.length == 0) return; var lastKey = curve[curve.length - 1]; float startValue = curve[0].value; if (Mathf.Abs(lastKey.value - startValue) > 0.001f) { lastKey.value = startValue; curve.MoveKey(curve.length - 1, lastKey); AnimationUtility.SetEditorCurve(clip, binding, curve); } }这里用EditorCurveBinding.FloatCurve来定位具体的属性曲线,GetAnimatableBindingPath是自定义方法,用来获取Transform在动画层级中的路径。阈值设0.001是因为浮点数精度问题,小于这个值的差异可以忽略。
5.3 序列帧偏移的检测与修正
序列帧的偏移检测更直接:读取所有帧的Sprite,比较它们的rect位置和pivot。如果首帧和末帧的pivot不一致,说明有偏移。修正方式有两种:一是调整Sprite的pivot,二是调整动画关键帧的Position。我一般选后者,因为改pivot会影响其他用到这张图的动画。
public static void FixSpriteOffset(AnimationClip clip, Sprite[] frames) { if (frames.Length < 2) return; var firstPivot = frames[0].pivot; var lastPivot = frames[frames.Length - 1].pivot; var offset = (lastPivot - firstPivot) / frames[0].pixelsPerUnit; if (offset.sqrMagnitude > 0.0001f) { // 在末帧的Position曲线上补偿偏移 var binding = EditorCurveBinding.FloatCurve("", typeof(Transform), "m_LocalPosition.x"); var curve = AnimationUtility.GetEditorCurve(clip, binding); if (curve != null && curve.length > 0) { var lastKey = curve[curve.length - 1]; lastKey.value -= offset.x; curve.MoveKey(curve.length - 1, lastKey); AnimationUtility.SetEditorCurve(clip, binding, curve); } } }pixelsPerUnit是Sprite的像素密度,用来把像素偏移转换成世界单位。这个值在Sprite导入设置里可以改,默认是100。如果美术给的序列帧pivot不统一,最好让他们在导出前统一设成BottomCenter或者Center,能从源头避免这个问题。
实操心得:跑完修正脚本后,一定要在Animation窗口里手动播一遍确认。因为脚本改的是曲线数据,有时候会因为曲线插值模式(比如Bezier)导致修正后的末帧仍然有微小偏差。如果偏差在0.01以内,可以接受;超过的话,把末帧的插值模式改成Constant或者Linear再试。
6. 六个脚本的协同工作流与踩坑记录
6.1 建议的使用顺序
这六个脚本单独用都有效果,但组合起来用效率更高。我自己的流程是这样的:每天开始工作前,自动保存脚本已经在后台跑着;美术给新图后,先跑图片压缩脚本批量处理导入设置;UI动效做完后,跑动画末帧脚本检查对齐;准备打包前,跑图集估算脚本确认没有超尺寸的图集。整个流程下来,原本需要大半天的杂活能压缩到一两个小时。
如果团队里有CI流程,可以把图集估算和图片压缩挂到构建前的脚本里,每次出包自动跑一遍,彻底杜绝人为遗漏。自动保存和动画末帧脚本更适合本地开发时用,因为涉及实时交互,放CI里意义不大。
6.2 常见问题速查
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 自动保存后Console报错 | 保存时资源正在被其他进程占用 | 加try-catch,跳过被占用的资源 |
| 图片压缩后画质明显下降 | 压缩格式选得太激进 | 检查ASTC block size,UI图标至少用6x6 |
| 图集估算结果偏小 | 空隙系数设低了 | 把1.3调到1.5,或者手动加10%余量 |
| 动画末帧修正无效 | 曲线插值模式导致 | 把末帧插值改成Constant再修正 |
| 脚本菜单不显示 | Editor脚本没放在Editor文件夹 | 确保脚本在Assets/Editor/目录下 |
6.3 几个容易忽略的细节
第一个是脚本的命名空间。如果你项目里已经有Tools菜单,新脚本的MenuItem路径要避开冲突,比如用Tools/UI动效/而不是Tools/。我见过有人直接写Tools/压缩,结果和另一个脚本的Tools/压缩重名,菜单里只显示一个,排查了半天。
第二个是Undo操作的支持。批量修改资源的时候,最好用Undo.RecordObject记录一下,这样用户按Ctrl+Z能撤销。虽然Reimport操作本身不好撤销,但至少让用户知道脚本改了什么。我一般会在修改前弹一个确认框,列出将要影响的资源数量,让用户决定是否继续。
第三个是性能问题。AssetDatabase.FindAssets在大型工程里可能很慢,如果只处理特定文件夹,把搜索路径缩小能快很多。另外,SaveAndReimport是同步操作,批量处理时会阻塞编辑器,建议在循环里加EditorUtility.DisplayProgressBar显示进度,至少让用户知道还要等多久。
这套脚本我前后迭代了大概半年,从最开始只有自动保存,到后来慢慢加上其他五个。每个脚本的代码量都不大,单个文件基本在200行以内,但省下来的时间是真金白银。如果你也在做Unity UI动效,建议先从自动保存和图集估算这两个开始用,见效最快。图片压缩和动画末帧脚本可以根据项目实际情况调整参数,别照搬我的默认值,因为不同项目的贴图规范和动画风格差别很大。