
简介这是一份面向iOS开发者的浮动泡泡功能示例工程围绕自定义视图绘制、动画驱动与碰撞反弹展开适合已掌握基础UIKit、希望练习Core Graphics绘图和基础物理检测的中级学习者参考。压缩包共24个文件、约7.34MB含m与h源码、storyboard界面、png素材、plist配置和Xcode工程文件并附mov演示视频便于对照梳理泡泡生成、定时刷新、路径动画及边缘碰撞等环节。当前已有701人学习下载。读者可从中获得可运行的SuspendedBubbleDemo结构、UIView绘图工具分类、NSTimer块封装以及碰撞检测与反弹处理思路示例借助贝塞尔路径判断泡泡是否碰到屏幕边缘或其他对象再调整变换属性模拟反弹可用于拆解浮动动画流程、复用计时与绘图逻辑并扩展到消息提示、天气气泡等交互场景工程结构清晰便于按模块查阅与二次开发。1. 从「泡泡叠在一起」说起浮动泡泡效果里最容易被低估的碰撞检测一个活动页要做浮动泡泡几十个半透明圆形从底部慢慢上浮互相碰一下弹开点一下爆掉。美术给的效果图很轻实现起来却容易返工——十有八九是因为一开始只画了圆、没做碰撞检测泡泡飘到中段全叠成一坨看起来像糊了一层色块。适合谁做想在 UIKit 页面里塞一个轻量、不引入整套游戏引擎的动效模块的 iOS 开发已经有 SpriteKit 或游戏引擎在跑的场景没必要看。不适合谁想要复杂刚体、旋转、关节约束的那还是老老实实上引擎。下面按「选型 → 目录 → 帧循环 → 碰撞 → 排查 → 二次开发」的顺序把这件事拆开讲代码是可以直接抄进工程跑的那种。2. 选型与工程结构用 CADisplayLink 手搓一套轻量物理引擎2.1 为什么不用 UIDynamicAnimator 和 SpriteKit系统自带的UIDynamicAnimator确实能做「多个圆形互相碰撞」这件事UICollisionBehavior的.items模式配上collisionMode再加个UIGravityBehavior二三十行就能让泡泡动起来。问题在于控制力浮力要按半径区分、横向要有一个正弦摆动、点击要能单独把某个泡泡摘出去、数量上到两百之后要根据位置做局部更新——这些需求落到UIDynamicBehavior体系里要么子类化一堆自定义行为要么在action闭包里改DynamicItem最后调试起来像个黑匣子你只能看到结果不对看不到是哪一步算错了。SpriteKit 稍好一点物理体、碰撞掩码都是现成的但它自带渲染循环和场景图把它塞进一个已经有UINavigationController、有列表、有转场动画的页面里层级和坐标系转换会额外增加维护成本包体积也会增加。为了几十个圆的碰撞检测引入整套引擎性价比不高。所以我一般选第三条路自己写。状态用纯struct存物理计算和渲染完全分离好处很直接——物理部分能写单元测试出问题能打印每一帧的位置速度还能做「固定 dt 回放」把偶发的穿墙问题复现出来。这条路的成本大约是四百行代码非常划算。2.2 目录分层Model / Physics / Render / Interaction 各管什么分层不是摆样子是为了让「我想改泡泡的样子」和「我想改泡泡的物理参数」这两件事互不干扰。工程目录大概长这样BubbleFloat/ ├── App/ │ └── DemoViewController.swift # 宿主页面只负责挂载、开关、传参 ├── BubbleKit/ │ ├── Model/ │ │ ├── BubbleState.swift # 单个气泡的纯数据无 UIKit 依赖 │ │ └── BubbleConfig.swift # 半径、浮力、弹性、阻尼等可调参数 │ ├── Physics/ │ │ ├── BubbleEngine.swift # 帧循环 积分 │ │ ├── CollisionResolver.swift # 圆-圆判定与冲量响应 │ │ └── SpatialGrid.swift # 均匀网格分桶 │ ├── Render/ │ │ ├── BubbleCanvasView.swift # 单画布绘制所有气泡 │ │ └── PopEmitter.swift # 爆裂粒子 │ └── Interaction/ │ └── BubbleGestureHandler.swift # 点击命中与拖拽 └── Tests/ └── CollisionTests.swift # 碰撞数学的单元测试各模块的边界用一句话说清楚模块职责明确不做的事Model描述「一个气泡此刻是什么状态」不碰UIView、不碰颜色对象Physics根据 dt 推进状态、解算碰撞不知道自己在哪个 view 上跑Render把状态数组画出来不做任何物理计算Interaction把触摸点翻译成索引不直接改位置只发指令给 Engine这种划分下BubbleEngine是唯一的胶水层它持有时钟、持有状态数组、每帧末把数组丢给BubbleCanvasView。写二次开发的时候你会感谢这个结构——换皮只动 Render换物理只动 Physics。2.3 气泡的数据结构八个字段撑起一套可测的物理状态先看状态本身。这里有个细节值得说质量用半径的平方乘密度而不是直接用半径。二维世界里质量正比于面积两个大小差一倍的泡泡相撞小泡泡应该被明显弹飞用半径做质量的话弹开幅度会不够夸张视觉上不像气泡更像两个一样重的球。struct BubbleState { let id: Int // 稳定标识用于分桶哈希和回放对齐 var position: CGPoint // 画布坐标单位 pt原点左上角 var velocity: CGVector // pt/s注意是「每秒」不是「每帧」 var radius: CGFloat // pt10...44 之间随机 var mass: CGFloat // 由半径推导mass radius^2 * density var hue: CGFloat // 0...1渲染时再转 UIColor var age: CGFloat 0 // 存活时长用于淡入和摆动相位 var isAlive: Bool true // 爆裂后置 false下一帧统一回收 static func make(id: Int, radius: CGFloat, in bounds: CGRect) - BubbleState { let density: CGFloat 0.02 return BubbleState( id: id, // 初始位置贴底x 轴随机避免一出生就互相重叠 position: CGPoint(x: .random(in: radius...max(radius, bounds.width - radius)), y: bounds.height radius), velocity: CGVector(dx: .random(in: -12...12), dy: -.random(in: 20...45)), radius: radius, mass: radius * radius * density, hue: .random(in: 0.45...0.75), age: 0, isAlive: true ) } }velocity的单位一定要是 pt/s。写成「每帧移动多少」是最常见的教训因为一旦你的渲染循环从 60Hz 变成 120Hz 的高刷屏所有东西的速度直接翻倍这种问题极难发现通常要等到真机测试才会暴露。用秒做单位物理计算就跟帧率解耦了。isAlive这个标记也别省。爆裂的时候不要去数组里remove(at:)标记一下、下一帧统一压缩数组可以避免「在遍历过程中修改数组」这类崩溃也让爆裂粒子和物理逻辑解耦。再看配置文件把所有能调的数值集中到一处参数默认值作用调大之后的效果buoyancy26向上加速度 pt/s²上升更快整体更「急」drag1.6阻尼系数按秒衰减横向摆动衰减更快运动更稳restitution0.72碰撞与撞墙的弹性接近 1 更弹接近 0 像撞进泥里swayAmplitude14侧向摆动加速度左右漂移更明显swayFrequency1.1摆动频率 Hz摆动更急促maxCount160同屏上限超过要留意帧率struct BubbleConfig { var buoyancy: CGFloat 26 var drag: CGFloat 1.6 var restitution: CGFloat 0.72 var swayAmplitude: CGFloat 14 var swayFrequency: CGFloat 1.1 var maxCount: Int 160 var radiusRange: ClosedRangeCGFloat 10...44 }把参数集中起来的好处是调手感的时候不用满工程找魔数坏处是容易手滑把drag调成负数泡泡会越飘越快一路飞出屏幕。加个断言更保险。3. 帧循环拆解一帧气泡从受力到落位的完整链路3.1 时钟CADisplayLink 的 dt 怎么算才不会炸CADisplayLink是这套东西的心脏。它跟着屏幕刷新回调回调里拿link.timestamp算出与上一帧的时间差这个差值就是 dt。看起来简单但有两处必须处理第一回调是以 target 为参数的CADisplayLink会强引用 target。如果 target 是self而self又强持有 link就构成循环引用页面退出后 link 继续跑还可能访问已经释放的 view。用[weak self]的 block 版本最省事。第二dt必须夹住。App 切后台、断点调试、系统弹窗打断都会让两帧之间隔上几秒这时如果直接用原始 dt位置积分一步就跨出去几百点泡泡直接穿墙飞走回来一片空白。final class BubbleEngine { private var displayLink: CADisplayLink? private var lastTimestamp: CFTimeInterval 0 private var bubbles: [BubbleState] [] var config BubbleConfig() func start() { guard displayLink nil else { return } let link CADisplayLink(target: WeakProxy(target: self), selector: #selector(step(_:))) // iOS 15上限锁 60。高刷屏跑满 120 对这种慢速动效没有收益只有耗电 link.preferredFrameRateRange CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60) // .common 模式列表滚动、弹窗弹出时都不停帧 link.add(to: .main, forMode: .common) displayLink link } func stop() { displayLink?.invalidate() displayLink nil lastTimestamp 0 // 重置下次 start 不会拿到一个陈旧的 dt } objc private func step(_ link: CADisplayLink) { // 第一帧没有参照只记时间不推进否则 dt 会是当前系统的 uptime guard lastTimestamp 0 else { lastTimestamp link.timestamp; return } var dt CGFloat(link.timestamp - lastTimestamp) lastTimestamp link.timestamp // 上限 1/30 秒宁可掉一帧的精度也不要一步跨出去 dt min(dt, 1.0 / 30.0) tick(dt: dt) } }说明几个参数preferredFrameRateRange的minimum给 30是允许系统在低电量或降频时主动降到 30 帧而不是卡在 60 反复跳变preferred给 60 是给 ProMotion 设备一个明确上限。WeakProxy是个几行的转发类把CADisplayLink的强引用挡在中间比在deinit里手动invalidate更不容易漏——当然两者一起做最稳。提示link.timestamp是上一次屏幕刷新的时间戳不是「现在」。所以同一帧内多次调用不会重复推进也不要用CACurrentMediaTime()代替它两者混用会导致 dt 出现负值。3.2 积分浮力、阻尼与横向摆动怎么写才不「假」拿到 dt 之后就是一帧的物理推进。这里用半隐式欧拉先更新速度再用新速度更新位置。相比显式欧拉它在同样的 dt 下更稳定数值发散得慢对我们这种「只是要好看」的场景完全够用。阻尼要特别小心。很多人写velocity * 0.98每帧衰减 2%在 60Hz 下每秒衰减到 0.3在 120Hz 下每秒衰减到 0.09手感直接差一倍。正确写法是按时间衰减private func tick(dt: CGFloat) { let c config for i in bubbles.indices where bubbles[i].isAlive { var b bubbles[i] b.age dt // 阻尼与帧率无关。exp(-k*dt) 保证 1 秒后的衰减量固定 let damping exp(-c.drag * dt) // 横向摆动给每个气泡一个固定的相位偏移否则整屏会同步左右晃非常假 let phase CGFloat(b.id % 7) * 0.9 let swayAccel sin(b.age * c.swayFrequency * 2 * .pi phase) * c.swayAmplitude // 先更新速度竖向是浮力减阻尼横向只有摆动和阻尼 b.velocity.dy b.velocity.dy * damping - c.buoyancy * dt b.velocity.dx b.velocity.dx * damping swayAccel * dt // 再用新速度更新位置 b.position.x b.velocity.dx * dt b.position.y b.velocity.dy * dt bubbles[i] b } resolver.resolve(bubbles, in: canvasView.bounds, config: c) recycleIfNeeded() canvasView.states bubbles // 只在帧末同步一次渲染 }几处参数说明swayAccel里乘了2 * .pi因为swayFrequency的单位是 Hz不乘的话周期会变成 6.28 秒摆动慢到看不出来。phase用id % 7而不是完全随机是为了让相位分布均匀又不至于每个气泡差异过大——完全随机的话会出现一批泡泡几乎同步、另一批完全静止的观感。c.buoyancy前面用减号因为画布坐标 y 轴向下向上是负方向。还有一个隐形收益速度单位是 pt/s所以整个方法不管在 30 帧还是 60 帧下跑泡泡走完一屏的时间是接近的只会因为积分误差有一点点偏差。这个特性在写自动化测试时特别好用。3.3 边界与回收反弹、夹位置、对象池边界处理看起来很机械但它是「抖动」问题的高发区。核心原则是先夹位置再改速度方向不要只改速度。func resolveBounds(_ bubbles: inout [BubbleState], in rect: CGRect, config: BubbleConfig) { for i in bubbles.indices where bubbles[i].isAlive { var b bubbles[i] // 左墙 if b.position.x - b.radius rect.minX { b.position.x rect.minX b.radius // 先夹回来 b.velocity.dx abs(b.velocity.dx) * config.restitution } else if b.position.x b.radius rect.maxX { b.position.x rect.maxX - b.radius b.velocity.dx -abs(b.velocity.dx) * config.restitution } // 顶部不反弹直接回收 if b.position.y b.radius rect.minY - 24 { b.isAlive false } bubbles[i] b } }注释里「先夹回来」这四个字很关键。如果只反转向量、不夹位置气泡会先陷进墙里半格下一帧再被反弹出来画面上就是贴着边抖。abs()的使用也是同理用当前速度乘以 -1 的话如果气泡已经因为某种原因朝外飞反转后还是朝外会直接穿出去用abs()强制朝向场内容错更好。越界回收用的是标记 压缩配合对象池复用private var pool: [BubbleState] [] private func recycleIfNeeded() { var kept: [BubbleState] [] kept.reserveCapacity(bubbles.count) for var b in bubbles { if b.isAlive { kept.append(b) } else { pool.append(b) // 留着复用避免反复分配 } } bubbles kept // 补足数量池子空了就新建 while bubbles.count config.maxCount { if let var reused pool.popLast() { reused.position CGPoint(x: .random(in: 0...canvasView.bounds.width), y: canvasView.bounds.height reused.radius) reused.velocity CGVector(dx: .random(in: -12...12), dy: -.random(in: 20...45)) reused.age 0 reused.isAlive true bubbles.append(reused) } else { let r CGFloat.random(in: config.radiusRange) bubbles.append(BubbleState.make(id: nextID(), radius: r, in: canvasView.bounds)) } } }对象池在这个量级一两百个其实省不了多少分配开销struct数组的分配很快。但对爆裂粒子那一层池子就是刚需了——每次点击新建十几个CALayer连点十几下渲染树的压力会上来。3.4 渲染单画布 draw(_:) 与每泡一个 Layer 的取舍渲染方案会影响最终能跑多少个气泡这个决策最好在动手写物理之前就定下来。方案优点缺点经验量级每个气泡一个CAShapeLayer写起来快能做独立动画图层数量暴涨离屏渲染风险30 个以内每个气泡一个UIViewdraw交互方便同上更重20 个以内单画布draw(_:)UIBezierPath一次绘制图层只有一个每帧重绘CPU 有开销200 个以内单画布 draw 预渲染UIImage贴图兼顾性能与观感颜色变化需要缓存多套500 个以内我一般用第三种起步一个BubbleCanvasViewstates属性didSet里setNeedsDisplay()draw(_:)里按顺序画圆每个圆填半透明渐变加一圈更亮的描边。之所以不用CAShapeLayer是因为两百个图层的光栅化开销比一次draw大得多而且每个图层都要单独设 frame、单独做颜色动画代码会变得很啰嗦。final class BubbleCanvasView: UIView { var states: [BubbleState] [] { didSet { setNeedsDisplay() } } override init(frame: CGRect) { super.init(frame: frame) backgroundColor .clear isOpaque false isUserInteractionEnabled true } override func draw(_ rect: CGRect) { guard let ctx UIGraphicsGetCurrentContext() else { return } for b in states where b.isAlive { let path UIBezierPath(ovalIn: CGRect(x: b.position.x - b.radius, y: b.position.y - b.radius, width: b.radius * 2, height: b.radius * 2)) // 半透明填充alpha 随 age 做淡入避免一出生就啪地出现 let alpha min(1, b.age / 0.6) * 0.35 ctx.setFillColor(UIColor(hue: b.hue, saturation: 0.55, brightness: 0.95, alpha: alpha).cgColor) ctx.addPath(path.cgPath) ctx.fillPath() // 高光描边比填充略亮气泡才有通透感 ctx.setStrokeColor(UIColor.white.withAlphaComponent(alpha * 1.8).cgColor) ctx.setLineWidth(1) ctx.addPath(path.cgPath) ctx.strokePath() } } }说明两点isOpaque false和backgroundColor .clear必须都设否则单画布会有一块不透明底色把下面的内容挡掉alpha乘 0.35 是填充的透明度气泡要靠叠加才有通透感单看一个其实很淡。如果屏幕上的气泡数量继续往上走就可以把这套绘制结果用UIGraphicsImageRenderer预渲染成几种固定尺寸的贴图draw里改成image.draw(at:)CPU 开销能再降一截。4. 碰撞检测与分离圆-圆判定、冲量响应与网格分桶4.1 相交判定先比平方距离别急着开根号圆和圆的相交判定简单到没有悬念两个圆心距离小于半径和就相交。但实现上有两个细节决定了性能上限一个是别开根号一个是别用CGPoint的distance方法。inline(__always) func intersects(_ a: BubbleState, _ b: BubbleState) - Bool { let dx a.position.x - b.position.x let dy a.position.y - b.position.y let r a.radius b.radius // 只比平方省一次 sqrt。碰撞检测里这一步的调用量是 n² 级别的 return dx * dx dy * dy r * r }inline(__always)不是玄学密集循环里这个函数每帧要调几千次内联能省掉函数调用开销实测能降低几个百分点的 CPU 占用。CGPoint的distance(to:)底层会走hypot分离出 dx/dy 自己算平方是更快的写法。真正要小心的是「距离恰好等于半径和」的情况。用还是会影响静止状态两个气泡刚好贴着时用会每帧都判定为碰撞并施加一次很小的冲量看着就像在微微震颤用则允许它们贴着共存。我一般用加一个极小的 epsilon 来避免浮点误差带来的漏判。4.2 冲量分离让两个泡泡按质量比弹开判定完只是知道「撞了」接下来两步才是重点改速度冲量响应和改位置穿透修正。func resolvePair(_ a: inout BubbleState, _ b: inout BubbleState, restitution: CGFloat) { let dx b.position.x - a.position.x let dy b.position.y - a.position.y let distSq dx * dx dy * dy let r a.radius b.radius guard distSq 0.0001, distSq r * r else { return } // 完全重合时跳过避免除以 0 let dist sqrt(distSq) let nx dx / dist // 碰撞法线从 a 指向 b let ny dy / dist // —— 第一步穿透修正无条件执行 —— // 把两个球沿法线推开质量大的少动、质量小的多动 let invMassA 1 / a.mass let invMassB 1 / b.mass let penetration r - dist let correction penetration / (invMassA invMassB) * 0.8 // 0.8 是松弛系数 a.position.x - nx * correction * invMassA a.position.y - ny * correction * invMassA b.position.x nx * correction * invMassB b.position.y ny * correction * invMassB // —— 第二步冲量响应只在「正在靠近」时执行 —— let rvx b.velocity.dx - a.velocity.dx let rvy b.velocity.dy - a.velocity.dy let velAlongNormal rvx * nx rvy * ny guard velAlongNormal 0 else { return } // 0 说明已经在分离不再补一脚 let j -(1 restitution) * velAlongNormal / (invMassA invMassB) a.velocity.dx - j * nx * invMassA a.velocity.dy - j * ny * invMassA b.velocity.dx j * nx * invMassB b.velocity.dy j * ny * invMassB }逐段解释。nx/ny是单位法线方向从 a 指向 b后续所有计算都在这个一维轴上做二维碰撞就退化成了两个球在一条线上的正碰公式也跟教科书一致j -(1 e) * v_rel·n / (1/m1 1/m2)。restitution就是弹性系数0.72 意味着碰撞后保留七成速度。松弛系数 0.8 是我调出来的经验值。取 1.0 的话穿透会被一次性完全消除两帧之内气泡就从重叠变成刚好相切位置跳变肉眼可见观感很生硬取 0.5 以下又会残留穿透泡泡看起来像陷进了对方身体里。0.8 是「两帧内基本分开、视觉上连续」的折中。velAlongNormal 0这个判断别省。如果不判断两个已经分开但仍在缓慢靠近的气泡会反复被施加冲量叠在一起原地抖动这是最典型的翻车现场。4.3 数量上到两百均匀网格分桶把 O(n²) 压下来两两检测的复杂度是 n(n-1)/2。100 个气泡是 4950 对每帧几千次平方距离计算现代设备完全扛得住到 300 个就是 44850 对加上冲量计算和内存访问主线程上跑到 60 帧就开始吃力了。分桶的思路很简单把画布切成网格每个气泡只和自己所在格子及相邻格子里的气泡比。网格边长取「两倍最大半径」时任意一对相交的气泡必然在同一个格子或相邻格子里。final class SpatialGrid { private let cellSize: CGFloat private var buckets: [Int64: [Int]] [:] init(cellSize: CGFloat) { // 边长 2 * 最大半径保证重叠的一对一定落在 3x3 邻域内 self.cellSize max(1, cellSize) } inline(__always) private func key(_ cx: Int, _ cy: Int) - Int64 { // 两个质数异或比结构体做 key 省掉一次哈希计算 Int64(cx) * 73_856_093 ^ Int64(cy) * 19_349_663 } func rebuild(_ bubbles: [BubbleState]) { buckets.removeAll(keepingCapacity: true) for (i, b) in bubbles.enumerated() where b.isAlive { let cx Int(b.position.x / cellSize) let cy Int(b.position.y / cellSize) buckets[key(cx, cy), default: []].append(i) } } /// 遍历所有可能相交的下标对每对只回调一次 func forEachPair(_ bubbles: [BubbleState], _ body: (Int, Int) - Void) { for (i, b) in bubbles.enumerated() where b.isAlive { let cx Int(b.position.x / cellSize) let cy Int(b.position.y / cellSize) // 只往「右、左下、下、右下、下下」五个方向找 // 保证 (i, j) 和 (j, i) 只出现一次 let offsets [(0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] for (ox, oy) in offsets { guard let bucket buckets[key(cx ox, cy oy)] else { continue } for j in bucket where j i { body(i, j) } } } } }offsets里的(0, 0)是当前格子其余四个是「右、左下、下、右下」。这五个方向覆盖了所有「比自己大」的邻居配合j i过滤每一对只会被访问一次。如果图省事写成九宫格全扫每对会被访问两次一次冲量变两次气泡弹开的力度会明显偏大看着像打了鸡血。驱动层变成这样func resolve(_ bubbles: inout [BubbleState], in rect: CGRect, config: BubbleConfig) { // 边界先处理避免气泡飞出去之后再算碰撞 resolveBounds(bubbles, in: rect, config: config) grid.rebuild(bubbles) grid.forEachPair(bubbles) { i, j in // 同一对可能被多个格子覆盖这里做一次快速剔除 guard intersects(bubbles[i], bubbles[j]) else { return } // 注意闭包里改 inout 数组的元素要小心用临时变量交换 var a bubbles[i] var b bubbles[j] resolvePair(a, b, restitution: config.restitution) bubbles[i] a bubbles[j] b } }闭包里直接改inout数组的元素在 Swift 里会有独占访问的坑必须先取出来、算完再写回。这种写法会增加几次拷贝但BubbleState只有几十字节值类型的拷贝成本可以忽略换来的是不会触发编译期或运行时的独占检查崩溃。还有一个顺序问题值得提如果分成「先解所有碰撞、再统一解边界」会出现气泡被邻居推着挤出墙的情况如果反过来边界处的气泡会被压着一小会儿再弹回来。我的做法是每一帧里先解边界解完一轮碰撞之后再补一次边界成本很低视觉上干净很多。200 个气泡时分桶能把每帧的检测对数从 19900 降到 800 左右差距非常明显。4.4 点击命中与爆裂reversed 遍历加对象池点击的命中测试和碰撞检测是同一套数学只是半径和变成了「气泡半径 手指容差」。手指的点击精度远不如我们想的高实际做的时候给一个 6pt 的容差用户的点击体验会好很多。objc private func handleTap(_ g: UITapGestureRecognizer) { let p g.location(in: canvasView) // 一定要转成画布坐标 let tolerance: CGFloat 6 // 反序遍历后绘制的气泡在视觉上层应该优先命中 for i in bubbles.indices.reversed() where bubbles[i].isAlive { let dx bubbles[i].position.x - p.x let dy bubbles[i].position.y - p.y let hitRadius bubbles[i].radius tolerance if dx * dx dy * dy hitRadius * hitRadius { pop(at: i) break // 一次点击只爆一个否则会爆掉一片 } } } private func pop(at index: Int) { let center bubbles[index].position let radius bubbles[index].radius bubbles[index].isAlive false // 只打标记不删数组 // 爆裂粒子走独立的轻量层跟着帧循环一起推进 emitter.burst(at: center, radius: radius, hue: bubbles[index].hue) UIImpactFeedbackGenerator(style: .light).impactOccurred() }bubbles.indices.reversed()这个写法有两个好处一是天然匹配「后画的在上面」这一视觉顺序二是reversed()返回的是反向索引视图不会产生数组拷贝。用enumerated().reversed()的话会先把整个数组包装一遍每帧都做一次没必要。break也别删。删掉之后手指按下去可能会一次命中三四个重叠的气泡连爆效果看着像卡了一下。真要做「连锁爆裂」应该是显式的一圈扩散查询而不是多个重叠的命中。5. 排查与避坑帧率、粘滞和手势上的 5 条血泪记录5.1 帧率与渲染的三条现象气泡在列表滚动或者弹窗弹出的时候全部静止等滑动结束才「跳」到新位置看起来像卡死了。原因CADisplayLink加在了.default的 runloop mode 上。滚动时主 runloop 切到UITrackingRunLoopMode.default的回调就不再执行。解决link.add(to: .main, forMode: .common)。.common是 mode 集合的别名滚动、弹窗、手势跟踪期间都能正常回调改动只有一行但这个问题几乎每次做动效都会遇到。现象从后台切回来或者断点停十几秒再继续屏幕上一片空白气泡全部消失了。原因dt没有夹上限。停顿期间的link.timestamp差值是秒级的位置积分一步跨出去几百点所有气泡的位置都越过顶部边界被判定为「出屏回收」。解决dt min(dt, 1.0 / 30.0)同时监听UIApplication.willEnterForegroundNotification把lastTimestamp重置为 0让下一帧重新走「只记时间不推进」的分支。两处都做才算把这个问题彻底解决。现象气泡加到一百多个帧率掉到 30 出头Instruments 里看到大量离屏渲染。原因每个气泡一个CAShapeLayer还都开了shadowOpacity。阴影本身就会触发离屏渲染图层一多GPU 直接饱和。解决换成单画布draw(_:)一个图层画完所有圆必须保留立体感的话用更亮的描边代替阴影或者把阴影预渲染进贴图。改动之后同样的气泡数量GPU 占用能降一大截。5.2 物理与交互的两条现象两个气泡撞上之后不分开贴着原地高频抖动看起来像在抽搐。原因只做了速度反射没做位置修正。两个球的速度变了但几何上仍然重叠下一帧继续判定为碰撞、继续施加冲量形成震荡。解决先做位置修正按质量比例推开再做冲量响应且冲量只在velAlongNormal 0正在靠近时施加。修完之后同一个场景立刻变得平滑这是最典型的一条经验。现象点击气泡没反应或者点击位置整体偏移了一截另外页面退出后偶尔崩溃在handleTap里。原因手势的location(in:)传了window或者父视图跟画布坐标系不一致尤其是画布有偏移、有 transform 的时候。崩溃则是手势回调里访问了已经释放的气泡数组——CADisplayLink还在跑或者手势没随着视图生命周期摘除。解决坐标系一律用location(in: canvasView)把「手势挂在谁身上」和「用谁的坐标」这两件事绑成一条规则。生命周期方面stop()里既invalidate()时钟也把gestureRecognizers一并移除别指望deinit兜底——有循环引用的时候deinit根本不会被调用。注意这五条里有四条在模拟器上都不容易复现高刷帧率、后台恢复、滚动停帧、内存压力真机测试不能省。6. 二次开发把气泡换成任意可碰撞体顺带留一套自检清单改这个模块最多的两个需求一是「气泡换成星星、头像、贴纸」二是「能不能调参数现场看效果」。前者靠抽象出接口就能解决后者靠把BubbleConfig变成可写属性。先做抽象。引擎其实只关心四件事位置、速度、半径、质量。把这四个抽成协议BubbleState遵守它物理部分就完全与「气泡」这个词脱钩了换成星星、爱心、小圆点都是一行初始化的事。protocol Collidable { var position: CGPoint { get set } var velocity: CGVector { get set } var radius: CGFloat { get } var mass: CGFloat { get } } extension BubbleState: Collidable {}CollisionResolver和SpatialGrid里的泛型约束从具体类型换成CollidableresolvePair里的字段访问一行都不用改。真正需要改的只有渲染层BubbleCanvasView.draw里按类型画不同的形状或者干脆改成「状态 贴图名」的组合让调用方传进来。参数面板更简单因为config已经是var并且引擎每帧都读。运行时挂几个 slidervalueChanged里直接engine.config.drag slider.value下一帧生效不需要重启循环。我一般还会加一个隐藏的手势三指长按画布来开关这个面板方便现场调手感。想改的东西动哪个文件注意点气泡外观BubbleCanvasView.draw换形状后radius的语义要跟着改物理手感BubbleConfig各字段drag必须大于 0碰撞规则CollisionResolver.resolvePair改完记得跑CollisionTests数量上限maxCount超过 200 建议先跑 Instruments交互方式BubbleGestureHandler坐标一律转到画布验证方法别偷懒。碰撞数学是纯函数最容易写测试构造两个位置已知、速度相同的圆跑一次resolvePair断言它们的距离恢复到r1 r2附近且质量小的一方速度变化更大。这条断言能挡住绝大多数「手滑把符号写反」的改动比肉眼盯着屏幕看靠谱得多。想要复现偶发的穿墙问题还可以给随机数加固定种子把气泡初始状态固定下来再用固定的 dt 序列跑一百帧逐帧打印位置有没有问题一目了然。一个我踩过的坑把preferredFrameRateRange的preferred设成UIScreen.main.maximumFramesPerSecond在高刷设备上跑到 120 帧气泡的摆动频率是全屏最高的观感反而更乱而且耗电明显。后来固定成 60配合按时间衰减的阻尼手感反而一致。从那以后我每次接这类动效需求都先把dt夹上限、deinit里invalidate、坐标转画布这三行写上再开始画圆。希望帮到你。本文还有配套的精品资源点击获取