[Side Project] 用 PySide2 (Qt Python) 實作 Prototype Pattern 與樂譜編輯器

最近讀書會剛好要報 Prototype Pattern,看了一下 Design Patterns: Elements of Reusable Object-Oriented Software 的 prototype 這章節。書中一開始動機說明一個五線譜的 GUI 編輯器,後面 sample code 卻是用像是 toy example 的迷宮,這樣真的兜不攏也看不太懂。

網路上找了一下也沒有人為了 prototype pattern 做五線譜的範例,剛好最近工作上會碰到 PySide2,於是我想想,乾脆來把五線譜的範例做出來順便練習一下 Qt 的 QGraphicsView 要怎麼使用,也順便展示 prototype pattern 的優缺點。

Side Project on GitHub

簡單的樂譜編輯器 GUI


Users 可以從左邊拖曳樂譜上的符號到右邊的畫板上,畫板 (canvas) 上的符號也可以事後再進行拖曳、旋轉。

就算簡單也該有的功能

為了讓範例簡單,我就只做了書中提到的 half note 和 whole note,不要加進一大堆其他符號來讓整個 example 複雜化。但相對地我們也不能弄得太簡單太像 toy example 看了沒感覺。

我就多做了幾個比較像 real project 的功能:

  • 拖曳的符號會像是磁鐵一樣 snap to lines/spaces 到最近的五線譜線上或空格(間) 內
  • 拖曳符號時我把預設有的圖示 (indicator) 隱藏,拖曳中 (dragging) 也看的到放開 (drop) 後的樣子
  • 我限制拖曳的符號只能在畫板 (canvas) 中移動,不給使用者拖曳到不見或卡在邊邊一半

Prototype Pattern 優點 1: 可以 Reuse Framework

不用 prototype pattern 我們會怎麼做創 object 的 framework? 我們可能就會創出一大堆平行化的 GraphicTool (橘色的部分) 用來造代表樂譜符號的 Graphic:


書中說 framework 不應該知道 application 任何細節才對,萬一今天 application 改成做遊戲地圖編輯器呢? 難道要創更多 GraphicTool? 那可能我們會先發瘋。

所以 prototype pattern 讓 application 能創出來的物件都統一實作 clone() 這個 function。Framework 要創東西就 call clone() 就對了,把當初 new 出來被 clone 的物件 (稱作 prototype) 和 clone() 的細節通通從 framework 那邊拉走。Framework 就會輕鬆很多:


如果我們今天想要 reuse framework 的 code,或甚至 framework 在 C++ 是 shared library,我們只要讓另外一個 application 都一樣繼續用 Graphic 這個 interface 實作 subclasses (橘色的部分) 就好:


Prototype Pattern 優點 2: Clone 比 New 還要快的話

按書中描述的架構做完之後我才想到一個問題,為什麼我們一定要用 clone() 的方式去新增一個 object 出來? 為何不 new 一個 object 再傳給真正要使用的人 (像是這邊的 framework) 就好?

去查了一下 Google 還真的找到也有人問類似的問題,我總結了一下原因發現也是要看狀況:

  • 當 clone "可以"比 new 還快的時候
    • 如果我們可以用 shallow copy 來 clone 的話,就有機會比 new 的速度還快
    • 甚至我們可以用 copy-on-write 技巧來 clone,使用方式洽當的話也可以比 new 的速度還快
  • 當 clone "一定"比 new 還快的時候:
    • 假設我們 new 東西需要 database 連線的話,clone 不需要重新與 database 連線幾乎一定會比較快
  • 當你沒辦法 new 的時候:
    • 假設要 clone 的 prototype 是用像是網路收到的訊息建立的,當初的資料 parse 完早就丟掉了,根本沒辦法再 new 一次,只好 clone 舊的 object

為了讓這個五線譜 example 也有這個優點,在這個 commit 中我就把原先 QGraphicsSvgItem 都要從頭再 create 一次,變成 SVG file 用 QSvgRenderer parse 一次就好,後面再創 QGraphicsSvgItem 的時候就 reuse 舊的 QSvgRenderer,就可以節省 parse SVG 要花的時間。

好險 QGraphicsSvgItem 有提供這個功能,不然我還真想不出來有什麼方法可以讓 clone 比 new 還快。至於你可能會問說為什麼不 clone QGraphicsSvgItem 就好? 因為我查過這個 QGraphicsSvgItem Qt 是沒有提供方法去 clone 的。

兩個優點一定都要有才能用 Prototype Pattern 嗎?

我覺得答案是否。只要任何一個優點適合用的話都可以考慮看看 prototype pattern。當然兩個優點都有的話更好。

兩個優點出發點不同,最後卻可以互相得到對方的優點,這也是書中沒有強調到的地方。

實作解析

QGraphicsItemGroup 與 QGraphicsItem 的關係

如果你把 Graphic class 的 self.debug 設成 True 的話,我額外用藍色的框畫出 QGraphicsItemGroup、用紅色的框畫出底下的 QGraphicsItem:


我從 QGraphicsItemGroup/QGraphicsItem 拿到 sceneBoundingRect() 後再膨脹了 3 pixels,這樣比較好看,不然藍框與紅框會疊在一起,五線譜的線的紅框也會和黑線重疊。

你看到這些音符的空白處這麼多是因為如果用圖片編輯軟體打開對應的 SVG 會發現比例就是這樣:


讓使用者感覺在拖曳音符的中心點

因為 QGraphicsGroup/QGraphicsItem 的座標都是以左上角為出發點,我們在 scene 上面的位移都是思考左上角的點要怎麼移動。但拖曳新圖片過程中如果讓使用者拖曳左上角的話感覺就很不舒服,所以我這邊簡單讓每個 Graphic class 自己知道要位移多少到音符的中心點,就因圖片而異了:


這個位移的校正只有從左邊 toolkit 拉新符號出來才會有,後續在 QGraphicsView 上移動我就沒做位移修正了,讓大家可以觀察到差異。

相關的位移校正 code 可以看 MyGraphicsView 中的 dragMoveEvent。

限制使用者移動 QGraphicsItem 在 scene 內部

在 MyGraphicsView 的 resizeEvent 中我隨時都讓 QGraphicsScene 和 QGraphicsView 大小一致。接著問題就來了: 我不想讓使用者把 QGraphicsGroup/QGraphicsItem 移動到看不見或被砍半,我就不想要看到這樣:


於是我就查到可以用 QGraphicsItem::itemChange 去防止這件事,詳細的做法我也是嘗試了好久才試出來:


itemChange 有點像通知你即將發生位置改變,但我們可以回傳一個位移過的座標來改變實際發生的位移。所以我這邊就是先算出 "如果乖乖位移的話的座標" 是不是超出 sceneRect,如果是就用一個 adjustment 移動回到邊界內,會像磁鐵一樣吸附到邊界上,上/下/左/右都各做一次就好。如果 sceneRect 大小比 QGraphicsGroup/QGraphicsItem 還小的話我就不管了,就看上/下/左/右的順序決定他最後會黏在哪一邊上。

但這邊實作就碰到很多陷阱:

第一個陷阱是 scene 的邊界要用 view.scene().sceneRect() 去拿到,他的背後含意和 view.sceneRect() 不一樣,如果我們刻意去設定 view.sceneRect() 他會和 view.scene().sceneRect() 脫鉤。

第二個陷阱是 QGraphicsItem 如果旋轉的話他的 boundingRect 不會改變大小,因為我設定旋轉是套用在 scene 的 transformation 身上,代表著原本的 local coordinates 並不會改變。所以我應該要去用 sceneBoundingRect 拿到 scene coordinates 並且在 scene coordinates 上做運算。如果我們硬要在 local coordinates 上做運算就還要考慮到各式各樣的旋轉以及縮放,code 會變超亂。

詳細的 code 可以參考 Graphic 中的 constrain_item_inside_scene

如何移動時黏 (Snap to Grid) 到五線譜上

也是利用 itemChange 來做到,其實很簡單:

  • 每一個可以被黏的 QGraphicsGroup (我設定如果五線譜被旋轉的話就不能被黏):
    • 把目前座標餵給 QGraphicsGroup,問他如果要黏的話要黏到哪個座標上
    • 如果被黏的座標是和目前 raw 座標最近的話,就選擇黏到這個 QGraphicsGroup 和他給的黏到的座標上
這邊我就不考慮有很多相鄰的 QGraphicsGroup 甚至重疊時是不是要考慮前後顯示 order 的問題,就是每一個 group 都測測看。

稍微麻煩一點的是要轉換 local coordinates <-> scene coordinates,因為我們問 group 要怎麼黏的時候想直接給他 group 的 local coordinates,這樣對 group 來說計算就很簡單不用扯到 scene。所以程式碼有這樣子的東西是代表:

  • group.mapFromScene(src_snap_point): src_snap_point 是 scene coordinates, 我們想轉換到特定的 group 的 local coordinates
  • group.mapToScene(local_dest_snap_point): local_dest_snap_point 是 group 說如果真的要黏就黏到這個 local coordinates,我們要轉成 scene coordinates 準備回傳給 itemChange 的 return value

詳細的 code 可以參考 Graphic 中的 snap_item_to_nearest_item

結語

在開發 C++ 的 Qt 時要查文件很方便,但我發現 PySide (Qt for Python) 的文件就沒那麼好看,有時候 Qt for Python 文件沒寫清楚的時候我還是會回到 C++ 的 Qt 文件看比較詳細的描述。甚至有看到 Qt for Python 那邊的 sample code 丟下去 Python 跑會錯誤的語法,看起來就是沒測過就寫出來的。

有時候 PySide 的 class 的 constructor 文件也沒寫清楚,就只能在 VSCode 裡面去看 PySide 原始碼,就會看到一堆被 @typing.overload 裝飾的 constructors 才知道要怎麼給 arguments。

但整體來說 Python 就不會像 C++ 一樣 build 要搞老半天,debugging 起來輕鬆很多。

這次練習後也是對 QGraphicsScene/QGraphicsItem 有滿多了解,就也知道那種 diagram 畫圖軟體是怎麼做到的了。

留言

此網誌的熱門文章

[試算表] 追蹤台股 Google Spreadsheet (未實現損益/已實現損益)

[Side Project] 互動式教學神經網路反向傳播 Interactive Computational Graph

[插件] 在 Chrome 網頁做區分大小寫的搜尋