一个拖拽框背后的高中数学

5,756 阅读9分钟

很多时候一个看似简单的 bug 背后,都可能有着完全在作者意料之外的成因。这时候一路排查、调试和给出 fix 的过程往往可以相当神奇。最近我就遇到了个这样的问题,在此分享一下 :)

缘起

最近我在维护一个用于平面设计的编辑器项目。在编辑器的画布上,图片是支持拖拽、旋转和裁切的,像这样:

drag-normal

为了保证图片裁切后始终可见,我们需要限制用户的拖拽范围。对于普通的图片,下面这种边界限制显然很容易实现:

trivial-limit

但是一旦图片存在旋转角度,这时的行为就显得很诡异了:

current

这显然不是预期的行为,那么该如何修复这个 bug 呢?

瓶颈 1:祖传代码

排查这个问题时,首先需要面对的是已有的代码实现。之前的代码虽然有类似的拖拽限制逻辑,但它所实现的 UI 交互和新版的需求有所不同(例如在拖拽时,我所需要移动的是图片,而旧版实现中移动的是裁剪框),故而没有办法直接复用。但如果现成的代码拿来改改就能解决问题,那何苦自己重新搞一套呢?本着这个再普通不过的想法,我首先尝试的是搞懂现有代码的实现

不读不知道,一读吓一跳。为了这个拖拽限制,现有的代码库中刨除掉各种胶水代码,还有 150+ 行代码直接与这个限制的计算相关。为什么会这么复杂呢?这个实现的步骤大致是这样:

  • 对 0~90 度旋转,依次判断图片左、上、右、下四条边是否与裁切框的左、上、右、下相交。
  • 对 90~180 度旋转,依次判断图片上、右、下、左四条边是否与裁切框的左、上、右、下相交。
  • 对 180~270 度旋转,依次判断图片右、下、左、上四条边是否与裁切框的左、上、右、下相交。
  • 对 270~360 度旋转,依次判断图片下、左、上、右四条边是否与裁切框的左、上、右、下相交。

这个实现确实可以说很符合直觉。然而同时,这个算法就有了 4 x 4 = 16 个可能的分支出口,每个出口里都有一系列相似但有区别的三角函数计算。虽然经过抽象封装,最后代码中只有四个用于实际计算的函数,但这个复杂度已经使得我很难通过小修小补的方式将它适配到新的交互方式上了。因此我决定花一些时间,思考如何重写

瓶颈 2:高中数学

既然决定了重写,那么核心的算法显然就可以另起炉灶重来了。和上面非常直接的这种直觉比起来,我在观察了这套交互之后,找到了另一种偷懒的直觉:只要你把屏幕倾斜一下,那么旋转后的情况就可以化归为没有旋转时的情况了呀!这也就是说,在代码实现上,旋转后是有可能直接复用不存在旋转时的简单逻辑的。听起来是不是省心了很多呢?

光有 idea 是不行的,把它实现出来才有意义。对于把屏幕倾斜一下这个 idea,它能够如何落实到代码实现上呢?高中数学的坐标系概念给了我灵感:一个点的位置,在多个不同的坐标系中可以有不同的表示。这样一来对于旋转后的图片,只要我们将坐标系随之旋转,那么在旋转后的坐标系中,计算拖拽限制应当就不是一件难事了。这套新思路可以总结为这样的算法:

  1. 当图片矩形存在旋转角 θ 时,我们将拖拽事件的 dx 和 dy 偏移量映射到和原始坐标系夹角 θ 的新直角坐标系上。
  2. 使用新坐标系上的偏移量 dx' 和 dy',复用现有代码计算限制。
  3. 将添加了限制的 dx' 和 dy' 变换回 dx 和 dy,使用这两个校正后的偏移量来移动元素即可。

听起来「映射」和「变换」也不是件容易的事,而且我也不确定这个算法是否是正确的。如果吭哧吭哧实现完发现不能用,那么时间显然就浪费了。所以该怎么验证这个想法呢?我想到了个简单的方式:取特殊值

旋转角为任意角度的时候,变换的公式需要推导。但是如果刚好旋转了 90 度或 180 度,这时的变换就十分简单,像这样:

// 正变换
x' = y
y' = -x

// 逆变换
x = -y'
y = -x'

这显然非常容易通过小修小改现有代码的方式来实现。而实现后,对于旋转 90 度的图片,拖拽限制就这样神奇地改变了。这个尝试给了我很大的信心,因此我开始尝试推导一般情形下的变换,先根据直觉写出这个公式:

x' = xcosθ + ysinθ
y' = xsinθ + ycosθ

然后我试图据此求出

x = ?x' + ?y'
y = ?x' + ?y'

这个方程比较难直接通过高中数学暴力算出来,我尝试通过矩阵的变换来计算它,也就是求下面这个变换矩阵的逆矩阵:

| cosθ sinθ | 
| sinθ cosθ |

但是在套用现成的矩阵变换公式的时候,这个矩阵的行列式可能为零,而这时候逆矩阵不存在……我对此感到匪夷所思,于是厚着脸皮请教了正在 T 大数学系读博的敏神,敏锐的敏神一眼就指出了问题:变换矩阵里有个值应该是负的……果然毕业以后太多东西都还回去了啊[捂脸]

改了改以后答案变成了这样(求忽略不支持 LaTeX 的丑陋写法):

| x' | = |  cosθ sinθ | | x |
| y' |   | -sinθ cosθ | | y |

| x | = | cosθ -sinθ | | x' |
| y |   | sinθ  cosθ | | y' |

把这个正逆变换的公式应用到现在的代码上,就得到了这样的效果:

rotate-poc

看起来好像大功告成了啊!可惜这还不是终点……

瓶颈 3:一步之遥

本来问题似乎已经解决了,但是在合并代码前的自测时,却发现旋转后的拖拽限制可能会出现一个莫名其妙的固定偏移量:

outer-offset

这就让人头大了……算法看起来是正确的,一般情况和若干特殊情况下的效果也是正确的,但是少数情形下却有这么大的误差,实在是非常诡异。我依次排查了新加入的代码和用于获得偏移量的胶水代码,都没有找到问题所在。因为这个 bug,我不得不暂时放下了这个重构,优先处理一些其它的细节需求。

有意思的是,即便放下了一个问题,对它的思考说不定也在默默地继续。某次浴室沉思的时候,我想到了一个被忽略的地方:我一直不愿意改动的「简单逻辑限制」代码

我们一开始就提到过没有旋转时的拖拽限制非常好写,就像 OpenGL 里面「掐头去尾」的 clamp 函数:

const clamp = (x, lower, upper) => Math.max(lower, Math.min(x, upper))

dx = clamp(dx, minLeft, maxLeft)
dy = clamp(dy, minTop, maxTop)

这个 clamp 本身是正确的,因此我也一直认为这段代码是正确无误的。但考虑了「旋转」这个因素后,lefttop 的来源是否值得信任呢?它们是在屏幕坐标系下的偏移量,求出它们的代码非常简单,大概这样:

minLeft = rect.left - rect.width
maxTop = rect.top + rect.height
// ...

一个元素在浏览器内的位置,是相对于屏幕左上角的。但上文中的变换公式中,位置是相对于拖拽框中心点的。考虑这一因素之后,这几个变量的有效性就存疑了。对此我的尝试是:基于两个矩形中心点之间的距离去计算拖拽限制,而非直接利用现成的偏移量。由于中心点的间距抹除了初始位置对计算的影响,那么偏移量就应当是可以消除的。重构之后的代码用 centerDeltaXcenterDeltaY 替代了上面的中间变量,得到的效果如下所示:

fixed

于是,最麻烦的 bug 就这样修复了~这个改进的收益还是有的:150+ 行的代码被优化到了 10+ 行的量级,代码执行路径上的分支也从 16 个优化到了 0 个。最后的版本如下所示:

// 在高阶计算函数中缓存 sin 与 cos
const rotateVector = utils.getVectorRotator(element.rotate);
const { minLeft, maxLeft, minTop, maxTop, centerDeltaX, centerDeltaY } = element.$getDragLimit();
// 变换至旋转后坐标系
// 带 _ 后缀的变量处于旋转后参考系中
const [dx_, dy_] = rotateVector(dx, dy);
// 最终偏移量 deltaX = 拖拽事件偏移量 dx + 两矩形中心点距离 centerDeltaX
const [centerDeltaX_, centerDeltaY_] = rotateVector(centerDeltaX, centerDeltaY);
const clampedDeltaX_ = utils.clamp(centerDeltaX_ + dx_, minLeft, maxLeft);
const clampedDeltaY_ = utils.clamp(centerDeltaY_ + dy_, minTop, maxTop);
// 将修正后偏移量反变换回原始坐标系
const [clampedDeltaX, clampedDeltaY] = rotateVector(clampedDeltaX_, clampedDeltaY_, true);
[dx, dy] = [clampedDeltaX - centerDeltaX, clampedDeltaY - centerDeltaY];
[left, top] = [drag.left + dx, drag.top + dy];

总结

到此为止,一段折腾的故事终于告一段落了。虽然这个需求未必是我们日常开发中可能遇到的,但调试过程中的一些总结感觉还是有些参考价值的:

  • 直觉还是很重要。譬如敏神的直觉就可以直接指出我在关键的地方少了个负号(致谢致谢),而写工程代码的直觉,可能也就是尽可能地依赖现有的工具找捷径解决问题吧 XD
  • 对复杂的问题,把代码逻辑梳理正确比起瞎改变量然后保存反复尝试,要靠谱得多。
  • 重新实现一套逻辑显得很麻烦的时候,可以使用特殊的输入输出来给出 POC 的原型实现,这还有助于放大问题与提供干净的复现环境。
  • 注意你觉得毫不起眼的角落,整个执行链路上的代码都值得纳入考虑。
  • 很多技术问题一路钻到底就能得到答案。我也可以选择搁置这个优化,但这样就错过了一个锻炼的机会 :)

限于我的水平,这段调试经历显得有些曲折。希望对感兴趣的同学有所帮助~