首页/新闻资讯/正文详情

iOS浮动泡泡交互:定步长循环与网格分桶碰撞检测

发布时间:2026/11/5 5:40:10 来源:云帆数科 栏目:资讯中心
iOS浮动泡泡交互:定步长循环与网格分桶碰撞检测
简介面向iOS开发者的一份浮动泡泡功能实现示例围绕自定义视图绘制、位移动画与边缘反弹等常见交互需求展开适合想练习Core Graphics绘图、CADisplayLink/NSTimer定时刷新及UIBezierPath碰撞检测的中级学习者参考。压缩包共24个文件包含Objective-C源码、Storyboard界面、图片素材、工程配置与一段mov演示视频整体约7.34MB可直接放入Xcode对照运行和调试。已有701人学习下载说明其在同类动画与碰撞示例中具备一定参考价值。通过示例工程读者可以梳理泡泡视图的绘制流程、动画驱动方式、碰撞判断与反弹处理思路也能借助演示视频观察实际运行效果并基于现有结构改造成天气气泡、消息提示等场景。1. 浮动泡泡不是「随便飘」从一次点击错位说起浮动泡泡这个交互真正容易出问题的地方是碰撞检测动画反而是次要的。需求描述通常只有一句「页面上飘着几十个泡泡点一下破掉泡泡之间还要互相弹开」真动手就会遇到很多麻烦泡泡飘到状态栏下面点不中、快速拖拽时互相穿透、数量上到 40 个之后帧率从 60 掉到 30 出头、退到后台再回来所有泡泡堆在角落里。这套东西本质上是把一个小型二维刚体系统放进视图层里只不过外观是圆的、行为是软的、用户还希望它看起来像玄学。它适合两类人一类要在引导页、活动页、弹层里加轻量互动另一类想找一个复杂度适中但足够完整的场景把定步长循环、空间划分、冲量响应这套东西在 iOS 上真正跑通一次。下面按「先做物理底座再做碰撞检测最后做视觉和排错」的顺序拆开说。2. 泡泡的物理底座用 CADisplayLink 搭一个帧率无关的定步长循环先把运动这块固定下来。泡泡的运动一旦交给系统动画后面所有碰撞、拖拽、反弹都会变得没法控制因为拿不到中间状态。所以第一步是建一个不依赖 UIKit 动画系统的小循环每帧拿到每个泡泡的精确圆心和速度再在这个基础上叠加物理规则。2.1 为什么不用 UIView.animate 和 UIDynamicAnimatorUIView.animate是目标驱动终点驱动的你告诉它「从 A 走到 B用 0.3 秒」中间过程由 Core Animation 在渲染服务里插值你的代码完全拿不到 t0.17s 那一刻泡泡在哪里。做纯展示够用但这套泡泡要响应手指、要互相弹就必须每帧都知道真实位置所以这条路走不通。UIDynamicAnimator看上去很对口——它有UICollisionBehavior、UIAttachmentBehavior还能加重力。真用过就知道两个问题一是碰撞回调里你没法做自定义的位置修正两个泡泡叠在一起时它只会给你一个UICollisionBehaviorDelegate通知重叠量多少、往哪个方向推、推多少全是黑匣子二是拖拽需要「用户接管」的语义被手指按住的泡泡应该变成无限质量推得动别人、别人推不动它这一点UIDynamicAnimator表达起来很别扭。自己写一个循环代码量大概两三百行换来的是每个参数都可控。注意CADisplayLink在高刷新率屏幕上默认会跟着屏幕跑。同一份物理常数在 60Hz 上调好到 120Hz 设备上如果还是每帧乘一次阻尼速度衰减会翻倍手感直接变成「黏住了」。要么显式把preferredFrameRateRange锁到 60要么把阻尼写成按时间归一的形式二选一别两个都不做。2.2 泡泡的状态结构与一帧的推进顺序状态先定义清楚。泡泡是圆所以只需要圆心、半径、速度三个量质量用面积近似这样半径大的泡泡被撞时位移更小看起来更「重」。import UIKit struct Bubble { let id: Int var center: CGPoint // UIKit 坐标原点左上y 向下为正 var velocity: CGVector // 单位point / second var radius: CGFloat var isDragging false // 被手指按住时当作无限质量 // 面积近似质量半径大 惯性大 被撞时让位少 var mass: CGFloat { max(radius * radius, 1) } }循环本身要解决三件事拿时间差、把时间差限制在合理范围内、用弱引用代理打破循环引用。final class DisplayLinkProxy { weak var target: BubbleWorld? objc func tick(_ link: CADisplayLink) { target?.step(link) } } final class BubbleWorld { private(set) var bubbles: [Bubble] [] private var link: CADisplayLink? private let proxy DisplayLinkProxy() private var lastTime: CFTimeInterval 0 var bounds: CGRect .zero var gravity CGVector(dx: 0, dy: 0) // 漂浮模式给 0下落模式给 900 左右 var dampingPerSecond: CGFloat 0.35 // 每秒速度保留比例 var restitution: CGFloat 0.86 // 反弹恢复系数 func start(in host: UIView) { proxy.target self let link CADisplayLink(target: proxy, selector: #selector(DisplayLinkProxy.tick(_:))) link.preferredFrameRateRange CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60) // .common 模式手指按住或滚动时循环不暂停否则拖拽时泡泡会「冻住」 link.add(to: .main, forMode: .common) self.link link lastTime 0 } func stop() { link?.invalidate() link nil lastTime 0 } objc func step(_ link: CADisplayLink) { guard lastTime 0 else { lastTime link.timestamp; return } // 关键切后台、卡顿之后 timestamp 会跳必须夹住否则一帧位移几百个点 let dt min(link.timestamp - lastTime, 1.0 / 30.0) lastTime link.timestamp integrate(dt) resolveCollisions() } private func integrate(_ dt: CGFloat) { // 按时间归一同样的阻尼系数60Hz 和 120Hz 下衰减速率一致 let keep pow(dampingPerSecond, dt) for i in bubbles.indices where !bubbles[i].isDragging { bubbles[i].velocity.dx (bubbles[i].velocity.dx gravity.dx * dt) * keep bubbles[i].velocity.dy (bubbles[i].velocity.dy gravity.dy * dt) * keep bubbles[i].center.x bubbles[i].velocity.dx * dt bubbles[i].center.y bubbles[i].velocity.dy * dt bounceOnWalls(index: i) } } private func bounceOnWalls(index i: Int) { let b bubbles[i] if b.center.x - b.radius bounds.minX { bubbles[i].center.x bounds.minX b.radius bubbles[i].velocity.dx abs(b.velocity.dx) * restitution } else if b.center.x b.radius bounds.maxX { bubbles[i].center.x bounds.maxX - b.radius bubbles[i].velocity.dx -abs(b.velocity.dx) * restitution } if b.center.y - b.radius bounds.minY { bubbles[i].center.y bounds.minY b.radius bubbles[i].velocity.dy abs(b.velocity.dy) * restitution } else if b.center.y b.radius bounds.maxY { bubbles[i].center.y bounds.maxY - b.radius bubbles[i].velocity.dy -abs(b.velocity.dy) * restitution } } }逻辑说明step里先算dt再夹断是整个循环里最容易被忽略、也最容易导致异常现象的一行。integrate只处理未被拖拽的泡泡被手指按住的泡泡位置由手势直接写速度积分跳过但边界夹取仍然要做的——否则拖到屏幕外松手泡泡就再也回不来了。bounceOnWalls里先修正位置再翻转速度顺序不能反先翻转速度的话泡泡可能还在墙外下一帧又被判定一次碰撞速度符号来回抖视觉上就是贴着边「嗡嗡」震动。参数说明参数含义建议起点调大之后dampingPerSecond每秒速度保留比例0.30 ~ 0.40越接近 1 越「滑」像冰面0.1 以下像糖浆restitution碰撞恢复系数0.80 ~ 0.90超过 1.0 会自己越弹越快直接飞出屏幕gravity.dy竖直加速度pt/s²漂浮 0下落 800 ~ 1000越大下落越「重」但也越容易穿透dt上限单帧最大步长1/30 秒放大会让卡顿帧变成瞬移radius泡泡半径24 ~ 40 pt越小数量可以越多但命中越难2.3 漂浮和下落是两套参数不要混着调很多实现里泡泡「飘」得不自然问题不在算法在参数组合错了。真正好看的漂浮感来自重力为 0、阻尼很小0.6 以上、速度上限压得比较低400 pt/s 以内再叠一个每帧随机的小扰动。如果加了重力又用小阻尼泡泡会全部沉到底部挤成一排看起来像在解压。扰动别每帧都加会抖。常见做法是每隔 0.4~0.8 秒给一个持续若干帧的小加速度用Timer或者在第 2 章那个循环里挂一个累加器累加器超过阈值才注入一次private var driftAccumulator: CGFloat 0 private func injectDrift(_ dt: CGFloat) { driftAccumulator dt guard driftAccumulator 0.6 else { return } driftAccumulator 0 for i in bubbles.indices where !bubbles[i].isDragging { // 幅度控制在 60 pt/s 以内太大就变成抽搐而不是漂 bubbles[i].velocity.dx .random(in: -60...60) bubbles[i].velocity.dy .random(in: -60...60) } }这里有个血泪经验扰动幅度是按「速度」加的不是按「位置」。按位置加随机偏移会让泡泡每帧的位置不连续肉眼看到的是高频抖动而不是缓慢漂移。3. 碰撞检测落地圆-圆判定、网格分桶与穿透修正运动有了接下来是这套东西的核心——碰撞检测。圆和圆的判定数学上非常简单麻烦的是两件事数量上去之后的两两比较开销以及碰撞之后怎么让两个泡泡「分开得自然」。这两件事决定了泡泡多了之后是优雅互推还是一团乱麻。3.1 圆-圆判定只比较平方距离并且防住零向量判定本身不需要开方两个圆相交的充要条件是圆心距小于半径和等价于平方距离小于半径和的平方。开方在几十个泡泡的量级上不是瓶颈但没有必要顺手省掉。inline(__always) func overlaps(_ a: Bubble, _ b: Bubble) - Bool { let dx b.center.x - a.center.x let dy b.center.y - a.center.y let r a.radius b.radius return dx * dx dy * dy r * r }真正要注意的是「两个泡泡完全重合」的情况。这时候dx dy 0后面归一化算出来的法线是0/0 NaNNaN 一旦写进center泡泡就从屏幕上消失或者跑到 (0,0) 去贴着左上角。这个异常现象第一次遇到会以为是布局问题查半天坐标转换实际上是数学边界没处理。判定为重叠之后如果距离平方小于一个极小值比如1e-6直接给一个固定法线(1, 0)让它们沿水平方向分开。另外一个容易忽略的点overlaps里判断的是正好相切时不算碰撞。如果写成两个泡泡飘着飘着会因为浮点误差一直处于「刚好接触」状态每帧都触发一次冲量计算速度符号反复翻转视觉上是两个泡泡黏在一起高频震动。3.2 网格分桶格子边长取最大直径用 i j 去重40 个泡泡两两比较是 780 次这个量级本身还好但配上sqrt、inout数组访问和每帧重建临时数组在 60 帧的预算里16.7ms就占到可见比例了。上到 150 个泡泡两两比较是 11175 次直接掉帧。空间划分选网格而不是四叉树理由很实际泡泡大小接近、分布均匀、每帧重建。四叉树的插入和查询分支多在几十到一两百个对象的量级上不一定比直接算网格快代码还长三倍。格子边长取2 * maxRadius最大泡泡的直径。原因是只有圆心距小于r1 r2 ≤ 2 * maxRadius才可能碰撞取这个值之后只需要检查当前格子和周围 8 个格子也就是 3×3 邻域不会漏。struct GridKey: Hashable { let x: Int let y: Int } final class CollisionGrid { private var buckets: [GridKey: [Int]] [:] private var cell: CGFloat 1 func rebuild(_ bubbles: [Bubble]) { buckets.removeAll(keepingCapacity: true) // 格子边长随最大半径走避免某些泡泡特别大时漏判 cell max((bubbles.map(\.radius).max() ?? 20) * 2, 1) buckets.reserveCapacity(bubbles.count) for (i, b) in bubbles.enumerated() { let key GridKey(x: Int(floor(b.center.x / cell)), y: Int(floor(b.center.y / cell))) buckets[key, default: []].append(i) } } /// 遍历 3x3 邻域用 i j 去重每对只回调一次 func forEachPair(_ bubbles: [Bubble], _ body: (Int, Int) - Void) { for (key, list) in buckets { for dx in -1...1 { for dy in -1...1 { let neighbor GridKey(x: key.x dx, y: key.y dy) guard let other buckets[neighbor] else { continue } for i in list { for j in other where i j { body(i, j) } } } } } } }逻辑说明i j这一行就是全部的去重逻辑。3×3 邻域会重复访问同一对泡泡最多 9 次加上这个整数比较之后每对只剩一次回调成本比维护一个SetPair低得多。rebuild用removeAll(keepingCapacity: true)复用字典容量避免每帧重新分配——这一行在 Instruments 里的差别是肉眼可见的。参数说明cell是唯一要调的参数。取值低于最大直径会漏判相邻格子覆盖不到取值过大退化成 O(n²)。经验值是最大直径的 1.0~1.2 倍直接取2 * maxRadius就够。3.3 位置修正与冲量先分开再改速度顺序不能反两个泡泡重叠了要做两件事把它们沿法线方向推开消除穿透再根据相对速度算一个冲量改变运动。顺序必须是先位置、后速度反过来也能跑但会出现「速度已经反弹了位置还叠着」的一帧如果这一帧被截图或者被手势读到就会看到泡泡边缘互相咬住。extension BubbleWorld { func resolveCollisions() { grid.rebuild(bubbles) grid.forEachPair(bubbles) { i, j in guard overlaps(bubbles[i], bubbles[j]) else { return } resolve(bubbles[i], bubbles[j]) } } private func resolve(_ a: inout Bubble, _ b: inout Bubble) { var dx b.center.x - a.center.x var dy b.center.y - a.center.y var distSq dx * dx dy * dy let rSum a.radius b.radius guard distSq rSum * rSum else { return } // 完全重合时给一个确定法线避免后面的除法产出 NaN if distSq 1e-6 { dx 1; dy 0; distSq 1 } let dist sqrt(distSq) let nx dx / dist, ny dy / dist let overlap rSum - dist // 被按住的泡泡当成无限质量推得动别人别人推不动它 let totalMass (a.isDragging ? 0 : a.mass) (b.isDragging ? 0 : b.mass) guard totalMass 0 else { return } let aShare a.isDragging ? 0 : b.mass / totalMass let bShare b.isDragging ? 0 : a.mass / totalMass a.center.x - nx * overlap * aShare a.center.y - ny * overlap * aShare b.center.x nx * overlap * bShare b.center.y ny * overlap * bShare // 相对速度在法线上的投影 0 说明两个球正在分开不用再推 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 } let invA a.isDragging ? 0 : 1 / a.mass let invB b.isDragging ? 0 : 1 / b.mass guard invA invB 0 else { return } let e restitution let impulse -(1 e) * velAlongNormal / (invA invB) a.velocity.dx - impulse * nx * invA a.velocity.dy - impulse * ny * invA b.velocity.dx impulse * nx * invB b.velocity.dy impulse * ny * invB } }逻辑说明位置修正按质量反比分配也就是轻的让位多、重的让位少和直觉一致。冲量用标准的-(1e) * v_n / (1/m1 1/m2)只作用在法线方向切向速度不动——这就是为什么泡泡擦着边过的时候不会被「拽住」。velAlongNormal 0那个 guard 是关键不加的话两个刚被推开的泡泡下一帧还会被判为重叠然后被反向再推一次表现为高频抖动。参数说明restitution在 0.8 左右时两个泡泡相撞后会明显弹开一段再分开调到 0.5 以下会变成「撞上去黏住慢慢滑开」看起来像液体而不是泡泡。如果发现两个泡泡在角落反复弹跳停不下来把restitution降到 0.7 以下通常能解决。3.4 穿透与子步高速甩出时的隧道效应手指快速甩出去的泡泡单帧位移可能超过 40 pt。60 帧下单帧 16.7ms速度 2600 pt/s 意味着每帧走 43 pt如果泡泡半径只有 24它会在两帧之间直接跨过另一个泡泡——判定那一刻两者并不重叠于是完全忽略看起来就是「穿过去了」。解决办法是子步把这一帧拆成几小步每步做完积分就做一次碰撞。全部帧都切子步太浪费只在检测到高速运动时切。private func substepCount(for dt: CGFloat) - Int { let maxSpeed bubbles.reduce(CGFloat.zero) { max($0, hypot($1.velocity.dx, $1.velocity.dy)) } let minRadius bubbles.map(\.radius).min() ?? 24 // 单帧位移超过半个最小半径就必须切子步 return maxSpeed * dt minRadius * 0.5 ? 3 : 1 }把step里的integrate(dt)换成循环调用integrate(dt / n)加resolveCollisions()n 取substepCount。三步已经能覆盖绝大多数场景再往上收益递减因为冲量本身也有上限。光有子步还不够速度本身也要封顶。不加限速的话一次猛甩能让泡泡速度到 5000 pt/s冲量算出来也是巨量的两个泡泡撞一下像台球开球整个屏幕全乱。上限一般取 2500~3000 pt/s在甩出的回调里夹断就行。4. 视觉与手势渐变图层、命中判定与甩出惯性的手感映射物理跑通了接下来是「看起来像泡泡」和「摸起来跟手」。这两件事的坑不在算法在图层层级和坐标换算上尤其是每帧更新位置时的隐式动画这个坑几乎每个人都会踩一次。4.1 泡泡外观渐变层加圆形遮罩位置用 position 而不是 frame每个泡泡用三个图层拼一个宿主CALayer承载位置和尺寸一个CAGradientLayer做渐变一个CAShapeLayer做圆形遮罩再叠一个半透明白色椭圆当高光。不要用UIView承载40 个UIView的 layout 开销没有必要。func makeBubbleLayer(radius: CGFloat, colors: [UIColor]) - CALayer { let size CGSize(width: radius * 2, height: radius * 2) let host CALayer() host.bounds CGRect(origin: .zero, size: size) host.cornerRadius radius let gradient CAGradientLayer() gradient.frame host.bounds gradient.colors colors.map(\.cgColor) gradient.startPoint CGPoint(x: 0.15, y: 0.0) // 左上到右下模拟球面受光 gradient.endPoint CGPoint(x: 0.85, y: 1.0) let mask CAShapeLayer() mask.path UIBezierPath(ovalIn: host.bounds).cgPath gradient.mask mask // mask 的路径坐标要和 gradient.bounds 对齐 host.addSublayer(gradient) // 高光偏左上、透明度很低的小椭圆这一步决定了「像不像球」 let highlight CAShapeLayer() let hRect CGRect(x: radius * 0.40, y: radius * 0.26, width: radius * 0.80, height: radius * 0.58) highlight.path UIBezierPath(ovalIn: hRect).cgPath highlight.fillColor UIColor.white.withAlphaComponent(0.28).cgColor host.addSublayer(highlight) return host }逻辑说明host.cornerRadius其实可以省掉外层已经被遮罩住留着是为了后续想加边框时不至于溢出。高光的位置比例是相对半径算的这样不同尺寸的泡泡高光位置一致看起来才像同一个光源照出来的。每帧更新位置的时候必须关掉隐式动画。CALayer的属性变化默认带 0.25 秒的隐式动画你每帧改一次position它每次都插值结果就是泡泡永远慢半拍、拖着一条残影。正确写法是把整批更新包在一个事务里CATransaction.begin() CATransaction.setDisableActions(true) // 关掉隐式动画 for (i, bubble) in world.bubbles.enumerated() where i layers.count { layers[i].position bubble.center // 用 position不要用 frame } CATransaction.commit()用position而不是frame的原因改frame会触发一次 layout 计算走 CPU改position只是改图层的变换交给合成器。40 个泡泡下这个差别在 Time Profiler 里能看出来。注意不要在运动的泡泡上开shouldRasterize。光栅化缓存对静态内容有效泡泡每帧都在动缓存每帧都失效等于每帧多做一次离屏渲染帧率反而更低。4.2 命中判定从上层往下找并且放宽一点半径手势挂在一个透明的、覆盖整个区域的容器上不要给每个泡泡加手势。命中判定自己算从后往前找后添加的图层在上面并且把半径乘一个系数放宽因为手指没那么精确。objc private func handlePan(_ gesture: UIPanGestureRecognizer) { // location(in: canvas) 给的是 canvas 坐标系和泡泡的 center 同一套坐标系 let point gesture.location(in: canvas) switch gesture.state { case .began: let slop: CGFloat 1.15 // 手指误差补偿别超过 1.3否则会误抓住旁边那个 draggingIndex world.bubbles.indices.reversed().first { i in let b world.bubbles[i] let dx point.x - b.center.x let dy point.y - b.center.y let r b.radius * slop return dx * dx dy * dy r * r } if let i draggingIndex { world.bubbles[i].isDragging true // 抓起瞬间把速度清零否则松手前的残余速度会和拖拽打架 world.bubbles[i].velocity .zero } case .changed: guard let i draggingIndex else { return } world.bubbles[i].center point world.resolveCollisions() // 立刻跑一次避免高速划过时一帧的重叠 case .ended, .cancelled, .failed: guard let i draggingIndex else { return } applyFling(velocity: gesture.velocity(in: canvas), index: i) world.bubbles[i].isDragging false draggingIndex nil default: break } }逻辑说明location(in: canvas)和泡泡的center必须是同一个坐标系。如果泡泡图层的容器视图做过缩放、平移这里就必须用convert(_:to:)转换否则抓取位置会整体偏移——这个偏移在视觉上表现为「抓不住」但代码里没有任何报错只能靠肉眼对比。.changed里直接写center然后立刻跑一次碰撞是为了处理手指快速划过的情况如果等到下一帧step才处理那一帧里被拖的泡泡已经叠在别人身上画面会出现一次明显的穿模松手之后又被弹开用户会觉得「手感发黏」。4.3 甩出惯性手势速度到初速度的映射与三段限幅gesture.velocity(in:)返回的是 pt/s量级通常比泡泡正常运动速度大很多人手一甩轻松 2000~4000 pt/s。直接赋值会让泡泡像炮弹一样飞出去撞上边界反弹后满屏乱窜。需要三段限幅缩放系数、绝对值上限、以及一个「低速不甩」的死区。private func applyFling(velocity v: CGPoint, index i: Int) { let scale: CGFloat 0.55 // 手感系数 let cap: CGFloat 2600 // 单轴速度上限 pt/s let deadZone: CGFloat 120 // 低于这个速度视为「放下」而不是「甩出」 var dx v.x * scale var dy v.y * scale if hypot(dx, dy) deadZone { dx 0; dy 0 } world.bubbles[i].velocity CGVector( dx: max(-cap, min(cap, dx)), dy: max(-cap, min(cap, dy)) ) }逻辑说明scale控制「跟手程度」0.3 会觉得甩不动0.9 会觉得太滑。cap是防止穿透的第一道防线理论上应该小于「最小半径 / 单帧时长」的两倍否则子步也救不回来。deadZone是为了避免用户在拖动结束时手指自然的一点位移被识别成甩出导致每次放下泡泡都会轻轻弹一下。参数说明三个值要一起调。如果发现「甩出去之后总是贴边来回弹很久」先降restitution再降cap如果发现「甩不动」先加scale别急着加cap。4.4 拖拽和碰撞的时序谁先算谁后算一帧里的事务顺序建议固定成手势写入位置 → 位置积分 → 重建网格 → 碰撞求解 → 更新图层位置。手势事件由 UIKit 在 runloop 里投递和CADisplayLink的触发顺序不一定固定所以不要在两边都改位置。被拖拽的泡泡在integrate里被where !isDragging跳过了速度积分这是对的但它的位置完全由手势写如果手势事件比CADisplayLink稀疏比如 120Hz 屏幕上手指采样只有 60~90Hz泡泡的位置在帧之间就是台阶状的。视觉上不明显但加上碰撞之后可能出现轻微抖动。常见做法是在step里对拖拽中的泡泡做一次插值平滑或者在changed里用setNeedsLayout触发额外的一次位置刷新代价很小。5. 避坑与排查五个真实翻车现场下面这五个问题是这个功能从能跑到能被测试同学放过之间的全部距离。每条都按「看到什么 → 为什么 → 怎么改」写遇到同类现象可以直接对照。5.1 泡泡在安全区附近点不中或者被裁掉一半现象泡泡飘到顶部状态栏下方一点点一半看不见点击也没反应换到刘海屏机型上更明显底部 Home 条附近也有同样问题。原因物理世界用的bounds是整个 view 的 bounds没有扣safeAreaInsets。泡泡可以飘到状态栏下面而手势容器又被裁切或者被系统手势区域盖住事件传不进来。解决初始化时把world.bounds设成扣除安全区之后的矩形并在viewDidLayoutSubviews里重新设置一次同时把所有越界的泡泡拉回来。override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() let inset view.safeAreaInsets let newBounds view.bounds.inset(by: inset) guard newBounds ! world.bounds else { return } world.bounds newBounds world.clampAllInside() // 把越界泡泡的 center 拉回边界内并把速度朝内翻 }5.2 数量一上去帧率断崖式下跌现象10 个泡泡稳稳 60 帧40 个掉到 30 出头Instruments 的 Time本文还有配套的精品资源点击获取

相关推荐

蓝色生日祝福HTML模板:原生三件套实现与二次开发指南
蓝色生日祝福HTML模板:原生三件套实现与二次开发指南

简介:这是一份面向Web开发初学者与需要快速制作生日祝福页面的用户提供的HTML网页模板,以蓝色主题和“兄弟”生日场景为设计方向,解决从零搭建祝福页面耗时、样式与交互难以兼顾的问题。压缩包共36个文件,约9.62MB,包含… · 2026/11/5 5:40:10

人工智能导论实验六模块:搜索、博弈与神经网络工程化落地
人工智能导论实验六模块:搜索、博弈与神经网络工程化落地

简介:面向山东大学软件学院2022级人工智能导论课程的实验资料包,对应软工22级7班的四次课程实验,适合正在修读该课程、需要复盘实验过程或自查实现细节的学生参考,也可作为入门神经网络实践的自学素材。压缩包共74个文件、约185.7… · 2026/11/5 5:39:10

MongoDB图书管理系统实战:原子扣减、聚合统计与索引优化
MongoDB图书管理系统实战:原子扣减、聚合统计与索引优化

简介:面向初学MongoDb与Java桌面开发者的图书管理系统课程设计包,围绕图书信息增删改查这一典型场景,提供可直接运行参考的MongoDb入门实践,适合课程设计、数据库入门与Java项目练手。压缩包共38个文件,约1.3MB&#x… · 2026/11/5 5:39:10

Go安全测试工具工程化:并发超时、字节解析与限速审计
Go安全测试工具工程化:并发超时、字节解析与限速审计

1. 先把“黑帽子”这三个字摆到台面上:Go 在安全测试里真正吃香的三件事前阵子帮一个做内部资产盘点的团队看代码,他们用某个脚本语言写的探测工具,扫完一个 /16 的网段要跑将近四个小时,中间还因为网络抖动卡死在某个地址上&… · 2026/11/5 8:23:06

校园网POST自动认证:抓包还原、断线重连与守护进程
校园网POST自动认证:抓包还原、断线重连与守护进程

校园网认证这件事,说小很小,说烦是真烦。每天打开电脑,浏览器弹出一个认证页面,输入账号密码,点登录,一天掉线三五次,每次都得重来一遍。如果手上还有一台常年开着的开发机、一台树莓派、几台虚… · 2026/11/5 8:23:06

IT运维实战:故障排查与应急处置手册
IT运维实战:故障排查与应急处置手册

1. 项目概述:IT运维人员的实战急救手册在机房凌晨三点刺耳的报警声中,在CEO会议室投影仪罢工的尴尬时刻,在财务系统突然崩溃的月末关账日——这些场景构成了IT运维人员的日常战场。"IT疑难杂症诊疗室"不是教科书式的理论百科&#… · 2026/11/5 8:23:06

Android中国象棋源码:规则引擎与Alpha-Beta搜索落地
Android中国象棋源码:规则引擎与Alpha-Beta搜索落地

简介:这份 Android 中国象棋源码基于 Java 与 Android Studio 构建,面向希望借助完整可运行项目入门移动游戏开发的初学者与进阶者,覆盖棋盘绘制、棋子走子规则、触摸事件响应、胜负判定与 AI 对弈等核心环节,可用于课程设计、自学… · 2026/11/5 8:23:06

O2O系统中台架构设计与实践指南
O2O系统中台架构设计与实践指南

1. 同城O2O系统与中台架构的必然结合 去年我参与改造某连锁超市的O2O系统时,发现他们前端有7个业务App(会员/配送/团购等),后端却用了12套重复的库存服务。每次促销活动,技术团队都要通宵改代码。这让我深刻意识到&… · 2026/11/5 8:23:06

K210部署20类视觉模型:从YOLO训练到kmodel量化落地
K210部署20类视觉模型:从YOLO训练到kmodel量化落地

简介:面向K210边缘计算入门与实战的20类分类模型工程,基于Maix Dock开发板与MaixPy环境,借助NNCase将YOLO的tflite模型转换为板端可运行的kmodel,适合希望打通模型转换、部署与推理验证链路的开发者与学习者。压缩包共20个文件&am… · 2026/11/5 8:22:05

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38

多智能体协作系统架构设计与实践优化
多智能体协作系统架构设计与实践优化

1. 多智能体协作系统的时代背景2017年Transformer架构的诞生彻底改变了人工智能的发展轨迹。当行业还沉浸在单一大模型带来的性能突破时,Anthropic的研究团队已经将目光投向了更前沿的领域——如何让多个智能体通过协作产生超越单体能力的系统智能。这就像交响乐团中… · 2026/11/5 0:00:39

大模型系统性入门实战指南:从原理到部署微调的全链路路线
大模型系统性入门实战指南:从原理到部署微调的全链路路线

提到大模型的系统性入门,我先说个结论:这事真不难,难的是资料太散、路线太多,今天这个框架明天那个工具,新人很容易在“收藏夹吃灰”和“教程看到一半放弃”之间反复横跳。市面上关于大模型的内容少说有几十万篇&#… · 2026/11/5 0:00:39

鸿蒙文件系统沙箱机制解析与开发实践
鸿蒙文件系统沙箱机制解析与开发实践

1. 鸿蒙文件系统沙箱机制深度解析作为一名从Android转向鸿蒙开发的工程师,我深刻体会到文件系统访问是迁移过程中最先遇到的"拦路虎"。在Android上运行良好的文件读写代码,在鸿蒙真机环境却频频出现文件写入后神秘消失或直接抛出权限拒绝错误的… · 2026/11/5 0:00:39

双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 3:16:49

STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 6:59:29

DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/4 7:02:38

了解更多?预约专属演示

我们的顾问将为您一对一讲解产品与方案

企业微信二维码