iOS端基于WKWebView实现的视频播放器,在开启画中画(Picture in Picture,简称PiP)之后,用户可以自由拖拽小窗口。为了保证窗口不会被拖出屏幕可视区域,开发者通常需要在拖拽回调中做边界碰撞检测,把窗口坐标钳制在合法范围内。听起来很简单,但实际项目中经常会遇到诡异的bug:窗口明明已经贴到屏幕右边缘,却始终差那么零点几像素;或者松手后窗口在边缘附近轻微抖动;又或者某些机型上窗口莫名其妙跑出边界一个点。这些问题的共同根源,往往不是逻辑写错了,而是浮点精度在作怪。

本文就从浮点数的底层表示说起,逐步分析画中画拖拽场景中容易踩坑的几何计算点,并给出基于epsilon容差的鲁棒比较方案,帮助大家把这些隐蔽的边界bug一次性清理干净。
浮点精度问题为什么在拖拽边界处集中爆发
Swift和Objective-C中常用的CGFloat在64位设备上本质是Double类型,采用IEEE 754二进制浮点格式存储。二进制浮点无法精确表示绝大多数十进制小数,比如0.1在二进制里是无限循环的,存储时会被截断成一个近似值。平时这种误差小到可以忽略,但拖拽过程中坐标会经过大量累加运算:手势的translation、view的center、屏幕边界值、像素与point的换算系数,每个环节都引入一点误差,累加后可能达到0.0000001甚至更大的量级。
问题在于边界检测往往使用严格的比较运算。比如判断窗口右边缘是否超出屏幕宽度,代码可能写成windowFrame.maxX > screenBounds.width。如果windowFrame.maxX经过一系列浮点运算后得到392.99999999999994,而屏幕宽度是393.0,这个条件判定为不越界,窗口就会被放行,实际上用户视觉上窗口已经贴边了,松手后回弹动画再一修正,就出现了肉眼可见的抖动。
更麻烦的是WKWebView场景下的特殊性。网页内的视频进入画中画后,如果需要自定义控制窗口位置,坐标可能还要经过JavaScript与原生层的桥接传递,evaluateJavaScript传回来的数值会被序列化成字符串再解析,多一道转换就多一分精度损耗。此外WKWebView自身渲染层是独立的,其内部viewport坐标与原生UIKit坐标之间的缩放换算(涉及contentScaleFactor)也会产生非整数中间值。多重因素叠加,边界比较就成了误差的高发地带。
epsilon比较:给浮点判断加上合理的容差
解决思路很直接:不要用精确相等或严格大于小于来判断浮点数,而是引入一个极小的容差值epsilon,认为两个数的差值落在epsilon以内就视为相等。标准的写法如下:
let epsilon: CGFloat = 0.0001
/// 判断两个浮点数是否近似相等
func isAlmostEqual(_ a: CGFloat, _ b: CGFloat, tolerance: CGFloat = 0.0001) -> Bool {
return abs(a - b) < tolerance
}
/// a是否大于等于b(考虑浮点误差)
func isGreaterThanOrEqual(_ a: CGFloat, _ b: CGFloat, tolerance: CGFloat = 0.0001) -> Bool {
return a > b - tolerance
}
/// a是否小于等于b(考虑浮点误差)
func isLessThanOrEqual(_ a: CGFloat, _ b: CGFloat, tolerance: CGFloat = 0.0001) -> Bool {
return a < b + tolerance
}epsilon取多少合适没有绝对答案,取决于坐标的量级和业务容忍度。画中画窗口坐标通常在0到500这个量级,取0.0001到0.01之间都比较安全。要注意一个常见误区:epsilon不能取太大,比如取0.5,那么窗口在边界0.4个point以内的合法位置也会被判定为越界,导致窗口无法贴近边缘,出现吸附不上的现象。
更严谨一点,对于大数值应该使用相对误差比较而非绝对误差。绝对误差比较在数值很大时会失效,因为浮点数的绝对精度随数值增大而降低,1000000.0附近的相邻可表示浮点数间距已经远大于0.0001。相对误差比较的写法是:
/// 相对误差比较,适用于大数值场景
func isAlmostEqualRelative(_ a: CGFloat, _ b: CGFloat, relTol: CGFloat = 1e-9) -> Bool {
let diff = abs(a - b)
let largest = max(abs(a), abs(b))
return diff <= relTol * largest
}
/// 结合绝对误差与相对误差,兼顾接近零的特殊情况
func isAlmostEqualHybrid(_ a: CGFloat, _ b: CGFloat,
absTol: CGFloat = 1e-6,
relTol: CGFloat = 1e-9) -> Bool {
let diff = abs(a - b)
if diff <= absTol { return true }
let largest = max(abs(a), abs(b))
return diff <= relTol * largest
}在画中画拖拽这个具体场景里,坐标量级不大,绝对误差比较(第一种写法)已经够用。但如果你的窗口逻辑涉及视频时间轴、缩放系数等大范围数值的混合计算,建议直接上混合比较,一劳永逸。另外提醒一点,Swift标准库从某个版本开始为浮点数提供了isAlmostEqual相关API的讨论,社区也有成熟的第三方实现,但自己封装这几个十几行的方法足够应付绝大多数场景,没必要引入依赖。
画中画拖拽场景的鲁棒几何计算实践
有了epsilon工具,接下来看画中画窗口拖拽中三个最容易出问题的几何计算点:坐标钳位、矩形相交检测和边界吸附。先看坐标钳位,这是拖拽的核心逻辑,把窗口中心点限制在合法区域内:
/// 将窗口位置钳制在屏幕安全区内
func clampWindowPosition(_ center: CGPoint,
windowSize: CGSize,
screenBounds: CGRect,
safeInset: CGFloat = 8.0) -> CGPoint {
let halfW = windowSize.width / 2
let halfH = windowSize.height / 2
// 最小与最大中心点坐标
let minX = screenBounds.minX + safeInset + halfW
let maxX = screenBounds.maxX - safeInset - halfW
let minY = screenBounds.minY + safeInset + halfH
let maxY = screenBounds.maxY - safeInset - halfH
// 鲁棒钳位:先判断是否已经越出边界(含容差),避免反复微调造成抖动
var x = center.x
var y = center.y
if isGreaterThanOrEqual(minX, maxX) {
// 屏幕宽度不足以容纳窗口加边距,取中间值,避免除零或极端值
x = (minX + maxX) / 2
} else if x < minX {
x = minX
} else if isGreaterThan(x, maxX) {
x = maxX
}
if isGreaterThanOrEqual(minY, maxY) {
y = (minY + maxY) / 2
} else if y < minY {
y = minY
} else if isGreaterThan(y, maxY) {
y = maxY
}
// 对结果做一次量化,消除拖拽累积的微小误差
return CGPoint(x: quantize(x), y: quantize(y))
}
/// 是否严格大于(含容差)
func isGreaterThan(_ a: CGFloat, _ b: CGFloat, tolerance: CGFloat = 0.0001) -> Bool {
return a > b + tolerance
}
/// 将坐标量化到0.001精度,抹平浮点尾差
func quantize(_ value: CGFloat, step: CGFloat = 0.001) -> CGFloat {
return (value / step).rounded() * step
}这段代码里有两个关键细节。第一是退化保护:当屏幕尺寸减去边距后小于窗口尺寸时,minX会大于maxX,直接钳位会造成窗口左右来回弹跳,这里取中间值兜底。第二是量化(quantize):把最终坐标四舍五入到固定精度,相当于把392.99999999999994规整成393.0,从源头上切断误差向下传递的链条,这也是很多团队忽略的一步。
第二个易错点是矩形相交检测,常用于判断画中画窗口是否与其他悬浮视图重叠。CGRect自带的intersects方法是精确比较,在边界贴合的场景同样会出问题,可以封装一个带容差的版本:
/// 带容差的矩形相交检测
func rectsIntersect(_ a: CGRect, _ b: CGRect, tolerance: CGFloat = 0.0001) -> Bool {
let horizontalOverlap = min(a.maxX, b.maxX) - max(a.minX, b.minX)
let verticalOverlap = min(a.maxY, b.maxY) - max(a.minY, b.minY)
return horizontalOverlap > tolerance && verticalOverlap > tolerance
}第三个点是松手后的边界吸附动画。常见需求是窗口松手后自动吸附到最近的左右边缘。吸附决策同样受浮点误差影响:如果窗口中心恰好处于屏幕中线附近,两个方向的距离几乎相等,直接比较center.x < midX可能因为千分位的误差导致两次松手吸附方向不一致,用户体验上就是吸附行为随机。稳妥做法是给吸附决策也加一个死区(dead zone),中线附近一个小区间内的位置统一按固定方向吸附,或者记录上一次吸附方向保持一致性:
/// 计算松手后应吸附到的目标点
func snapTarget(for center: CGPoint, screenBounds: CGRect, windowSize: CGSize) -> CGPoint {
let midX = screenBounds.midX
let halfW = windowSize.width / 2
let edgeInset: CGFloat = 8.0
let deadZone: CGFloat = 0.5 // 中线附近0.5pt视为模糊区域
let goesLeft: Bool
if abs(center.x - midX) <= deadZone {
// 模糊区域:根据窗口在中线的哪一侧且更接近哪边决定
goesLeft = center.x <= midX
} else {
goesLeft = center.x < midX
}
let targetX = goesLeft
? screenBounds.minX + edgeInset + halfW
: screenBounds.maxX - edgeInset - halfW
// 目标Y轴做上下边界钳位
let targetY = min(max(center.y,
screenBounds.minY + edgeInset + windowSize.height / 2),
screenBounds.maxY - edgeInset - windowSize.height / 2)
return CGPoint(x: quantize(targetX), y: quantize(targetY))
}最后再强调一下工程上的整体策略。epsilon比较属于防御性编程,能解决精度判断的问题,但不能替代合理的计算顺序。写坐标相关代码时尽量减少中间运算环节,优先使用系统提供的完整API(如CGRect.inset(by:))而不是手工拆分计算,把钳位和量化收敛到统一的出口函数,避免散落在多个手势回调里各自为政。做到这些,WKWebView视频画中画窗口的拖拽体验就能做到贴边精准、吸附稳定,不再出现那种调了半天复现不出来的灵异边界bug。