ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

iOS 手搓轻量物理引擎:浮动气泡圆碰撞检测与网格分桶实战

iOS 手搓轻量物理引擎:浮动气泡圆碰撞检测与网格分桶实战

简介:这是一份面向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 // pt,10...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: ClosedRange<CGFloat> = 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 个以内
每个气泡一个UIView+draw交互方便同上,更重20 个以内
单画布draw(_:)+UIBezierPath一次绘制,图层只有一个每帧重绘,CPU 有开销200 个以内
单画布 +draw+ 预渲染UIImage贴图兼顾性能与观感颜色变化需要缓存多套500 个以内

我一般用第三种起步:一个BubbleCanvasView,states属性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里的泛型约束从具体类型换成Collidable,resolvePair里的字段访问一行都不用改。真正需要改的只有渲染层:BubbleCanvasView.draw里按类型画不同的形状,或者干脆改成「状态 + 贴图名」的组合,让调用方传进来。

参数面板更简单,因为config已经是var并且引擎每帧都读。运行时挂几个 slider,valueChanged里直接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、坐标转画布这三行写上,再开始画圆。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表