一個拖拽框背後的高中數學

不少時候一個看似簡單的 bug 背後,均可能有着徹底在做者意料以外的成因。這時候一路排查、調試和給出 fix 的過程每每能夠至關神奇。最近我就遇到了個這樣的問題,在此分享一下 :)算法

緣起

最近我在維護一個用於平面設計的編輯器項目。在編輯器的畫布上,圖片是支持拖拽、旋轉和裁切的,像這樣:瀏覽器

drag-normal

爲了保證圖片裁切後始終可見,咱們須要限制用戶的拖拽範圍。對於普通的圖片,下面這種邊界限制顯然很容易實現:緩存

trivial-limit

可是一旦圖片存在旋轉角度,這時的行爲就顯得很詭異了:bash

current

這顯然不是預期的行爲,那麼該如何修復這個 bug 呢?編輯器

瓶頸 1:祖傳代碼

排查這個問題時,首先須要面對的是已有的代碼實現。以前的代碼雖然有相似的拖拽限制邏輯,但它所實現的 UI 交互和新版的需求有所不一樣(例如在拖拽時,我所須要移動的是圖片,而舊版實現中移動的是裁剪框),故而沒有辦法直接複用。但若是現成的代碼拿來改改就能解決問題,那何苦本身從新搞一套呢?本着這個再普通不過的想法,我首先嚐試的是搞懂現有代碼的實現ide

不讀不知道,一讀嚇一跳。爲了這個拖拽限制,現有的代碼庫中刨除掉各類膠水代碼,還有 150+ 行代碼直接與這個限制的計算相關。爲何會這麼複雜呢?這個實現的步驟大體是這樣:函數

  • 對 0~90 度旋轉,依次判斷圖片左、上、右、下四條邊是否與裁切框的左、上、右、下相交。
  • 對 90~180 度旋轉,依次判斷圖片上、右、下、左四條邊是否與裁切框的左、上、右、下相交。
  • 對 180~270 度旋轉,依次判斷圖片右、下、左、上四條邊是否與裁切框的左、上、右、下相交。
  • 對 270~360 度旋轉,依次判斷圖片下、左、上、右四條邊是否與裁切框的左、上、右、下相交。

這個實現確實能夠說很符合直覺。然而同時,這個算法就有了 4 x 4 = 16 個可能的分支出口,每一個出口裏都有一系列類似但有區別的三角函數計算。雖然通過抽象封裝,最後代碼中只有四個用於實際計算的函數,但這個複雜度已經使得我很難經過小修小補的方式將它適配到新的交互方式上了。所以我決定花一些時間,思考如何重寫工具

瓶頸 2:高中數學

既然決定了重寫,那麼核心的算法顯然就能夠另起爐竈重來了。和上面很是直接的這種直覺比起來,我在觀察了這套交互以後,找到了另外一種偷懶的直覺:只要你把屏幕傾斜一下,那麼旋轉後的狀況就能夠化歸爲沒有旋轉時的狀況了呀!這也就是說,在代碼實現上,旋轉後是有可能直接複用不存在旋轉時的簡單邏輯的。聽起來是否是省心了不少呢?優化

光有 idea 是不行的,把它實現出來纔有意義。對於把屏幕傾斜一下這個 idea,它可以如何落實到代碼實現上呢?高中數學的座標系概念給了我靈感:一個點的位置,在多個不一樣的座標系中能夠有不一樣的表示。這樣一來對於旋轉後的圖片,只要咱們將座標系隨之旋轉,那麼在旋轉後的座標系中,計算拖拽限制應當就不是一件難事了。這套新思路能夠總結爲這樣的算法:ui

  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 的原型實現,這還有助於放大問題與提供乾淨的復現環境。
  • 注意你以爲絕不起眼的角落,整個執行鏈路上的代碼都值得歸入考慮。
  • 不少技術問題一路鑽到底就能獲得答案。我也能夠選擇擱置這個優化,但這樣就錯過了一個鍛鍊的機會 :)

限於個人水平,這段調試經歷顯得有些曲折。但願對感興趣的同窗有所幫助~

相關文章
相關標籤/搜索