在做悬浮托盘类的界面时,我第一次把“光晕 + 托盘缩放”的效果搭出来,几乎只花了一顿饭的工夫。用 SwiftUI 在视觉层铺一层模糊扩散,再用 scaleEffect 做放大,代码看上去干净又克制。结果真机一跑,展开动作和收起动作都像在抽帧,小气泡变大面板的过程没有“浮起来”,反而像卡在两层动画中间,整个交互的质感直接归零。
当时我先怀疑是托盘里放了太多动态内容,后来把内容清空只留下一个带光晕的圆角矩形,卡顿依旧。随后用 Instruments 把动画过程录下来,才发现问题根本不在主线程逻辑,而在 GPU 渲染热路径上。这里面的核心矛盾很有代表性:SwiftUI 的光晕效果本身需要比较重的像素处理,托盘缩放动画又会在每一帧把像素处理范围放大,两者叠加后,渲染成本不是加法,而是乘法。
这篇文章重点记录我从现象定位、性能剖析、方案选型到最终落地优化的完整过程,尤其是如何用预烘焙光晕纹理替换实时模糊,以及在缩放前后把特效层和内容层拆开处理的思路。如果你也在 SwiftUI 里做渐变发光、边缘泛光、浮出台面这类带光晕效果的动效,并且遇到了展开卡顿、拖动掉帧、旧设备不流畅的问题,这篇文章里的实测方法和排查路径可以直接拿来参考。
1. 光晕为什么是托盘缩放卡顿的根源:先把渲染成本拆开看
1.1 SwiftUI 光晕的常见写法,其实大部分都在做同一件事
在 SwiftUI 里做光晕,早期最省事的路径是叠加模糊:
swift复制ZStack {
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(Color.orange.opacity(0.35))
.blur(radius: 32)
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(Color.orange.opacity(0.15))
.blur(radius: 60)
}
看起来像两团发光的色块,但实际上这两层在做的事情非常纯粹:每个可见像素点都要从它周围的一个大范围里采样颜色,再按高斯权重混合,输出成新的颜色。blur 半径越大,采样半径越大,和屏幕上的可见面积相乘之后,需要处理的像素量会迅速上升。
光晕效果的核心就是模糊扩散和颜色叠加。不管你是透过 shadow 加阴影来模拟外发光,还是用 blur 做泛光,底层成本都跑不掉“多次采样”和“范围扩散”。问题在于,很多新手朋友会把光晕当作一个普通修饰符看待,觉着反正只有好几层背景,实际渲染性能往往被严重低估。
用图像处理的视角来看,光晕本质上是一个卷积操作。一帧画面里如果有 300×600 的区域在做模糊,GPU 就要对这个区域里的每个像素都执行一次卷积计算。而当托盘缩放动画运行时,这个需要模糊的区域还会跟着动画每帧放大或缩小,相当于把一部分像素面积动态拉大再执行重新计算,负担自然成倍增加。
1.2 托盘缩放为什么会把模糊成本“二次放大”
托盘缩放动画的场景和普通按钮点击动画不同,它通常有两种形态:一个很小的圆形气泡,和一个展开后的大面板。用户点按气泡后,气泡从小变大,面板从透明到可见,甚至会带一点弹性回弹。
在这个过程里,光晕层如果始终用实时模糊,那它就逃不过以下两个问题:
第一,模糊层自身的渲染尺寸在动画过程中被持续拉伸。假设气泡原始尺寸是 64×64,展开后最终尺寸是 320×240,那么可模糊区域被放大了五六倍甚至十倍以上。相当于原本需要做卷积的像素区间,从几千个点扩大到几万个点。GPU 单位时间内的计算负载并不是线性的,因为采样半径、采样数量和最终可见像素的面积都会叠加。
第二,托盘内部内容如果带有动态变化,比如更新进度、封面切换、文字滚动,实时模糊会让“需要重绘的区域”进一步蔓延。由于模糊结果依赖邻近像素的当前值,只要拖盘内有任何内容变化,光晕层就必须跟随变动的区域重新采样。托盘好看是好看,但它把所有内容变化都变成了模糊层的重绘压力。
还有一个很容易被忽视的细节:许多开发者在给整个托盘加缩放动效时,会写成这样:
swift复制withAnimation(.spring(response: 0.42, dampingFraction: 0.78)) {
isExpanded.toggle()
}
然后惯性地把整个视图结构用一个 scaleEffect 包起来。这个写法的直观效果很直接,但同时也会让光晕层跟随整个组合视图一起做逐帧缩放。SwiftUI 在组合视图缩放时通常需要维护图层树,如果光晕层本身又是带 blur/shadow 的背景效果,那么特效层就没有办法被离线复用。GPU 在缩放过程中要不断重新生成模糊结果,很多卡顿就是从这里冒出来的。
这个阶段的排查结论我认为比较明确:如果光晕只作为静态背景,即使尺寸很大,也不会造成持续的掉帧;真正吃掉性能的是动画过程中对光晕区域的“重复模糊”和“重复重绘”。所以优化的核心方向不是干掉光晕效果,而是想办法让光晕在一开始就生成好,动画过程里不依赖实时模糊。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先用 Instruments 把“看起来卡”翻译成可复现的问题
2.1 核心指标不是帧率数字,而是掉帧分布
做性能优化最忌讳的是“感觉卡就随便改”。很多 SwiftUI 项目越优化越卡,就是因为没有证据支撑改动方向。在真机上先把动画问题量化,后面每一步才有依据。
我习惯用 Instruments 里的 Core Animation 模板抓动画过程。操作方式并不复杂:单独连接一台真机,在 Instruments 里选择 Core Animation 录制模板,然后在 App 里反复触发展开和收起托盘动画。
需要注意,不要只看列表最上方的平均帧率。某些掉帧场景下平均帧率还能维持在 50 以上,但真正影响体验的是那些连续掉到 30 fps 以下的片段。Core Animation 模板会显示“瞬时段”和掉帧分布,观察动画启动的 0.2 秒到 0.6 秒区间,看是否出现明显的柱状低谷。
我这次实际测出来的结果很典型:CPU 的主线程占用率并不高,窗口期里 CPU 线程几乎没出现明显排队,但帧率就是上不去。这时候如果只盯着 Time Profiler 找 CPU 热点,会一无所获。真正的压力在 GPU 端,需要用 Metal System Trace 或观察 Core Animation 的渲染层耗时来做二次确认。
2.2 定位到光晕层占用了哪一段渲染管线
打开 Core Animation 模板后,可以进一步开启 Xcode 的“Color Offscreen-Rendered”和“Color Hits Green and Misses Red”调试选项。这里有个经验:SwiftUI 对离屏渲染的判定和 UIKit 不完全一样,但和光晕层相关的异常通常仍然能在这个调试开关下暴露出来。
比较有效的方法是先用一个开关控制光晕层是否存在。我当时的测试步骤是:
- 保留托盘主体和缩放动画,关闭光晕层,触发动画,采集一段数据。
- 打开光晕层,保持同样操作,再采集一段数据。
- 对比两段数据在高光区域中的渲染耗时差异。
这个对照测试很快就把问题锁定在光晕层,而不是托盘内容本身。光晕层开启后,渲染耗时显著上升,而且这个耗时上升和动画进度有明显相关性,越接近大尺寸展开阶段,耗时越高。
之后还可以配合 Time Profiler 看主线程之外的提交耗时。如果 Core Animation 的 Commit 阶段和 Render 阶段出现明显上升,通常说明视图层级里有比较复杂的渲染命令正在被反复提交。用 SwiftUI 做性能优化时,很多卡顿点并不是被某个函数占据,而是被绘制命令和渲染状态的切换拖累,这一点很容易被忽略。
3. 光晕方案选型:从 shadow、blur 到预烘焙纹理的横向比较
3.1 四个常用方案的对比
在定位到光晕层是卡顿主因之后,下一步面临的就是方案选择。我把当前 SwiftUI 里能落地的常规发光方案整理成了一张对比表,实际项目中可以先按这个表选择切入点。
| 方案 | 实现成本 | 渲染性能 | 视觉还原度 | 适用场景 |
|---|---|---|---|---|
| shadow 阴影叠加 | 很低 | 中,但多层时明显下降 | 边缘泛光还行 | 简单按钮、静态图标 |
| 实时 blur 模糊 | 低 | 低,动画场景尤其吃力 | 高,最像自然发光 | 小范围静态阴影、短动画 |
| 预烘焙光晕纹理 | 中 | 高,动画过程几乎没有额外负担 | 中高,需要控制细节 | 托盘缩放、大面积光晕、Tab 切换 |
| Metal shader 程序化光晕 | 高 | 很高 | 取决于效果复杂程度 | 固定形状的泛光、追求极致性能的场景 |
光晕强度随业务需求变化,不一定每个场景都需要用到预烘焙纹理。但结合托盘缩放的“浮出感”需求,实时 blur 在中小内存档位上的表现确实不够稳定,这是我在多个方案对比之后做出的选择。shadow 在一定场景下可以救急,但从泛光强度和柔和度来看,仍然不如以模糊图为基础的纹理更自然。
3.2 为什么没有直接全线切到 Metal shader
有经验的朋友看到这里可能会问:既然实时 blur 不行,为什么不直接上 Metal shader?这样不是彻底绕开模糊性能问题了吗?
原因在于 Metal shader 的定制成本比较高,尤其是想要做出一个符合真实发光质感、能随托盘形状变化、还能和颜色动态联动的效果,并非简单写一个径向渐变就能满足。一个托盘的光晕可能要覆盖圆角矩形、胶囊形、圆形等多种形状,还要在不同展开阶段有不同的聚焦效果,用程序化光晕去抽象这些形状变量,开发周期会拉长不少。
预烘焙纹理的思路更像是一个工程折中:预先用一次性的 Core Graphics / Core Image 处理,把复杂模糊固定成一张或多张光晕贴图。这样动画过程中实际渲染的还是普通 Image 加透明度变化,CPU 和 GPU 负担都能大幅下降。托盘缩放时,光晕不会每帧重新算。
4. 预烘焙光晕纹理的工程落地:摆脱逐帧模糊的核心路径
4.1 先理清楚“纹理里存什么”
这一步很关键:光晕贴图里保存的到底是什么。如果理解错了,后续用起来就会出现颜色失真、边缘发灰、多层叠加效果不对的问题。
我一般保存两层信息。光晕本身的主要成分是“带柔和边界的半透明亮度分布”,所以纹理里首先需要保留的就是发光形状的 alpha 渐变。为了让颜色在运行时能灵活调整,可以把纹理设计成白色发光底,颜色部分用 colorMultiply 叠加到 Image 上,这样一份光晕图就能复用于不同氛围色。
其次是光晕的扩散范围。如果只是简单地把一个圆角矩形原样存成一张图,是无法产生泛光的。必须在生成阶段把圆角矩形做一次足够强度的模糊,让轮廓向外扩散,形成有透明度的光边,这样才能在托盘底下形成那种“光源被挡在背后的感觉”。
托盘和光晕层的关系需要提前确定好。一个常见场景是展开后的面板是圆角矩形,但气泡态是圆形,这两种状态的光晕形状差异很大,所以我会针对每个形态分别生成一张等比例的光晕纹理。展开时使用大面板光晕图,收起时使用圆形光晕图,再根据缩放进度控制二者的可见度。
4.2 在运行时生成一张光晕纹理的可行代码
由于 SwiftUI 本身没有开放直接做模糊贴图的 API,只能借助 UIKit/Core Image 的能力。实现思路分成三步:第一步创建透明画布,用圆角路径填实白色;第二步对这张图做高斯模糊;第三步把模糊后的结果保存为可复用的 UIImage。
下面是一个在 iOS 里生成圆角矩形光晕纹理的参考实现:
swift复制import UIKit
import CoreImage
import CoreImage.CIFilterBuiltins
func makeGlowTexture(
size: CGSize,
cornerRadius: CGFloat,
blurRadius: CGFloat
) -> UIImage? {
let renderer = UIGraphicsImageRenderer(size: size)
let maskImage = renderer.image { context in
UIColor.white.setFill()
let path = UIBezierPath(
roundedRect: CGRect(origin: .zero, size: size),
cornerRadius: cornerRadius
)
path.fill()
}
guard let cgImage = maskImage.cgImage else { return nil }
let ciImage = CIImage(cgImage: cgImage).clampedToExtent()
let filter = CIFilter.gaussianBlur()
filter.inputImage = ciImage
filter.radius = blurRadius
let ciContext = CIContext(options: [.cacheIntermediates: false])
guard let outputImage = filter.outputImage,
let blurredCGImage = ciContext.createCGImage(
outputImage,
from: CGRect(origin: .zero, size: size)
) else {
return nil
}
return UIImage(cgImage: blurredCGImage)
}
有几个值得注意的细节。这里我把模糊图中央的“白色不透明区域”也保留了下来。在实际叠加时,光晕层放在托盘主体图层的下方,所以中央白色区域会被主体遮住,视觉上只露出托盘轮廓之外的柔和发光边缘。当然,如果托盘主体用了半透明毛玻璃材质,则需要把纹理中央部分透明化,以免让中央透出发光色块,影响面板内容的清晰度。
还有一点是 clampedToExtent() 的使用。CIGaussianBlur 在处理边缘时会出现边缘透明区域缩窄的问题,不加 clamp 会让光晕在接近纹理边界的地方被裁掉,导致边缘出现一条不自然的透明断带。加 clamp 之后可以尽量保证光晕向外扩散时保持连续性。
4.3 把光晕纹理叠回 SwiftUI
光晕纹理生成之后,可以把它封装成一个可复用的 SwiftUI 视图组件:
swift复制struct GlowBackground: View {
let glowImage: UIImage
var color: Color = .orange
var opacity: Double = 0.55
var body: some View {
Image(uiImage: glowImage)
.resizable()
.colorMultiply(color)
.opacity(opacity)
.blendMode(.plusLighter)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
使用方式是在托盘主体 ZStack 的最底层叠加光晕,然后给光晕层单独设置尺寸。要注意光晕纹理所占的可见范围要略大于托盘主体,通常我会在主体尺寸的基础上向外扩展一定的留白,扩展范围大约为模糊半径的两倍左右,这样光晕不会因为贴图边缘不够而在托盘侧面露馅。
有人会问:既然光晕是固定纹理,为什么动画过程中光晕看起来还能变亮、变色、变位置?这其实是通过 opacity、scaleEffect 和 colorMultiply 来控制的。这些操作都是 GPU 上的轻量级图像处理,和逐帧做模糊完全不是一个量级。运行时对同一个纹理做缩放、透明度和颜色叠加,性能开销非常低。
swift复制GlowBackground(glowImage: expandedGlowImage)
.color(glowColor)
.opacity(appearProgress * 0.5)
.frame(width: expandSize.width, height: expandSize.height)
.scaleEffect(glowScale)
4.4 在动画里引入“贴图切换”,避免贴图长时间被拉伸
用预烘焙纹理之后,还有一个细节值得实践:不要只用一张宽高比固定的光晕图去适配不同形态。
托盘从圆形气泡变成矩形面板,其轮廓差异非常大。如果用圆形光晕纹理缩放着去模仿矩形面板的光晕,中间和边缘的形状会始终对不上,用户在看到动画时会觉得光晕和托盘像是两层皮。
我的处理方法是先把尺寸跨度切分成几个区间。比如气泡阶段使用圆形光晕纹理,中间过渡阶段使用胶囊形光晕纹理,展开完成阶段使用大圆角矩形光晕纹理。进入区间时切换纹理,然后配合 0.08 到 0.12 秒的透明度交叉渐变,让视觉上感觉不到切换痕迹。因为在极短的时间内,两层光晕的透明度对消会让过渡看起来是平滑变形的,而实际只是透明度变化。
这里要理解:不是所有视觉动画都必须用底层数学变换来驱动,在性能敏感的场景里,用两张预烘焙贴图做透明度交叉混合反而更可靠。
5. 托盘缩放阶段的专项优化:别把动态内容和光晕绑在同一个容器里
5.1 修正缩放层级,把“整个复杂视图缩放”改成“关键图层缩放”
在托盘缩放动画中,很多卡顿并不只是光晕本身造成的。当整个 ZStack 同时包住光晕层、背景渐变层、文字层和图片层,再整体做 scaleEffect 时,每一帧都要重新计算这些图层的相对布局和绘制状态。即使不引入光晕,复杂层级下的整体缩放也可能掉帧。
优化思路是把托盘内容拆成两层。底下一层是托盘主体形状和光晕层,在动画里承担主要的缩放、放大和回弹效果;上面一层是内容信息,比如标题、按钮、封面图等,不随缩放的每一帧全量重绘,而是在托盘主体放大到接近完整尺寸后,再以淡入或轻微上移的方式出现。
这样处理之后,动画过程对复杂内容的处理时间明显减少。因为底下的主体形状比较简单,缩放的成本更低;上面的内容层不需要经历极端的小尺寸状态,也不会在小尺寸下被缩放出模糊锯齿或文字重排问题。
如果你的动画有连续的弹簧回弹,我更推荐用 SwiftUI 的 phaseAnimator 或者一次性 withAnimation 来驱动主体形状,而对内容层使用独立的透明度和位移动画。两个子动画的时间差可以很小,比如主体先走 20 毫秒,内容层再跟上,视觉上几乎感觉不到先后顺序,但绘制开销却能明显分开。
5.2 控制离屏渲染范围
SwiftUI 有两个和图层合成密切相关的修饰符:compositingGroup() 和 drawingGroup()。它们适合处理静态内容,但不适合在动态更新频繁的场景里无脑使用。
compositingGroup() 的作用是把子树先合成一层,再把某些特效施加到这一层上。如果托盘主体里有多个圆角矩形和阴影层,且它们之间没有相互裁剪关系,加一个 compositingGroup() 通常能减少中间过程的重复绘制。但要注意千万别把它包在动态进度条或会频繁刷新内容的视图外面,否则每次内容变化都会触发一整棵子树的重新合成。
drawingGroup(opaque: false) 的作用是让 SwiftUI 用离屏 buffer 去渲染子树,然后把这棵树作为一张位图提交给 GPU。这个能力在托盘主体不变化的情况下很有效。比如展开完成后的静态面板,可以用它避免层级关系里的多余混合。
我的用法是:把光晕层和托盘背景层单独剥离出来,当它们处于相对静止的状态时开启 drawingGroup,当它们需要发生形变和颜色转换时则尽量不开启,因为动态形变加离屏缓冲反而可能造成更高开销。
5.3 不要忘记处理遮罩边缘和裁剪关系
预烘焙光晕纹理之后,出现得最频繁的视觉瑕疵就是边缘发灰或边缘突然断裂。这个问题的根源往往不是纹理本身,而是托盘主体使用了 .mask 或 .clipShape 去遮罩视图内容,遮罩生效时把底下的光晕也一并裁掉了一部分。
SwiftUI 的遮罩只作用于目标视图,如果光晕层放进了同一个受遮罩约束的容器里,光晕层的边缘扩展就会在容器边界被切断。解决这个问题有两种方式:要么把光晕层放到遮罩容器的外面,让它独立于托盘主体的裁剪逻辑;要么给光晕层留足够的 padding 再单独对齐到托盘主体中央。
另外,圆角矩形光晕和普通矩形裁切不同,SwiftUI 的圆角半径容易产生边缘锯齿。我在预烘焙光晕纹理时,会刻意把光晕图的尺寸放大两倍,也就是用 2x 分辨率生成。这样在 Retina 屏幕上展示时,边缘过渡更细腻,消除掉一部分暗边和锯齿。
6. 低端设备上的持久稳定性:先分级处理,再谈更强方案
6.1 避免光晕成为低电量模式下的“隐形资源黑洞”
即便预烘焙光晕纹理已经大幅降低了成本,我还是会在代码里做一层分级控制。因为低端设备和高刷新率设备在 GPU 性能上的差异很大,同样的动画效果在 A17 芯片上可能很流畅,在更老机型的低电量模式下就可能出现偶发抖动。
我们需要充分了解目标用户,再决定光晕层的极限参数。我通常不会根据设备型号硬编码档位,而是优先读取系统能力,比如 ProcessInfo.processInfo.thermalState 和 UIAccessibility.isReduceTransparencyEnabled,再根据这些信息动态降低光晕的模糊半径、透明度或帧率。
swift复制var isLowPowerMode: Bool {
ProcessInfo.processInfo.thermalState == .serious
|| ProcessInfo.processInfo.thermalState == .critical
}
在低功耗状态下,直接把光晕层的 opacity 从 0.5 降到 0.2,同时减少一层叠加的色彩光晕,视觉上仍然能保留基本的发光感,但 GPU 负载会降低不少。用户开启“减弱透明度”辅助功能时,也应该走同样的降级逻辑。
6.2 形状比较规整时,可以考虑用 Metal shader 做参数化光晕
若应用形态比较固定,比如全程只有一个圆形气泡,也没有复杂纹理需求,那可以再进一步,使用 Metal shader 直接把光晕画出来。这样连预烘焙纹理的系统缓存和 Image 处理步骤都能去掉,渲染效率会更高。
以一个简单的径向光晕为例,shader 可以根据像素坐标与中心点的距离计算发光强度:
metal复制#include <metal_stdlib>
using namespace metal;
[[ stitchable ]] half4 glowFromCenter(
float2 position,
half4 currentColor,
float2 center,
float radius,
half4 glowColor
) {
float dist = distance(position, center);
float normalized = clamp(1.0 - dist / radius, 0.0, 1.0);
float alpha = normalized * normalized;
half4 glowLayer = half4(glowColor.r
