QML 踩雷筆記

[2018-04-11] 這篇文章未來應該不會再變動了,QML 已經是我最厭惡的程式語言之一(詳情請見本文留言區),我想以後除非發瘋否則應該不會再嘗試第二次。
這篇文章是初學 QML 時寫的,很可能有謬誤。

沒想到網頁寫一寫莫名其妙就開始寫 QML 了。總之就是工作上有個新專案,最後是用 QML 開發,最大的優勢是前後端的開發可以分離。可以先把 UI 邏輯開發完,同時一邊跟客戶討論需求後,最後才進行後台業務邏輯實做。而在寫 UI 的過程幾乎不用寫 C++

雖然 KDE5 Plasma 桌面已經全面改用 QML 重寫、Blackberry、Ubuntu Phone 也都採用 QML 作為 GUI toolkit 使用者基數應該挺龐大的,然而目前寫了一個多月,實際在開發時還是踩到很多雷,而這些雷的原因也很雜,所以這裡來快速簡單的統整一下,希望對任何想學 QML 的新手能有一點點幫助,避免更多人成為孤獨的踩雷家。

QtQuick / QML / Controls / Controls2 的差異

QtQuick 與 QML

  • QtQuick 是一套新一代的跨平台 GUI framework,於 Qt 4.7 首次出現,裡面包含 QML 這個以 JS 為基礎的程式語言(QML 是 JS 的 superset)。

    QML 與 Controls

    QtQuick 提供了很多很低階的 GUI 元素,例如 Item, Rectangle, MouseArea, Text,然而這些元件過於低階,例如即使你只是要一個最常見的按鈕(例如 QPushButton)你也必須先花很多精力自己組合這些低階 GUI 元素,根本浪費開發者生命輪子滿地滾。

後來 Qt5.1 出現了Controls (又稱為Controls 1),就是幫你預先實做出了一些最常見的 GUI 設計元素,例如 Button, TextField, SpinBox, ComboBox,而且也保留了一些外觀的自訂能力。

Controls 1 與 Controls 2

  • Qt 5.4 時又另外又搞了一套 Controls 2,跟 Control 1 是互不相干互相獨立的,你可以在同一 App 中同時使用兩者。差別在於:
    • Controls 1 旨在使用 QML/QtQuick 實做出傳統 Qt QWidget 中的各種 GUI 元件,而且在各個 OS 上外觀風格看起來盡量與 native 相同。
    • Controls 2 旨在提供各平台完全一致的外觀,而不是系統 native 的 GUI 元件樣式,甚至從 Qt5.7 開始,Controls 2 自己內建了一套 Google 的 Material ,以及 Microsoft 的 Universal 樣式,讓你可以快速開發出看起來大致與 Android native UI 相同的 App。
      • 而且似乎是以 Android/iOS 等觸控螢幕為目標在設計: Controls 2 中的所有元件預設都不會去監控滑鼠的 hovering 事件(我猜也可能是為了效能?),也沒有任何 curosr 樣式(這點實在很糟),所以你要拿來開發跨傳統桌面平台的程式而且赫然發現他竟然都沒處理滑鼠 hovering 事件的話,你就得把所有 Controls 2 自己重包一次自訂自己要的效果…
    • 看看會不會哪天又出現 Controls 3(搬板凳吃雞排)。

千萬要記住

  • 千萬不要QWidget 時代的邏輯去寫 QML ,否則你、會、踩、到、雷。
  • 千萬不要拿 HTML/CSS/JS 的邏輯去寫 QML ,否則你、會、踩、到、雷。
  • 千萬不要拿 JS 的邏輯直接去寫 QML ,否則你、會、踩、到、雷。

可讀性與多人協作

QML 是個 MVC 三者摻在一起做撒尿牛丸的玩意,我發現他很容易寫出難以維護的東西。每次寫我都覺得很可怕,我已經見識過一堆連 Python 都能寫得醜到靠北的人了,這 QML…

總之要多人協作可能很需要 code style guide。

當然別人的 code 讀多了也發現,即使是 Python 都有人能寫得超難讀,就變數名這點來說好了,像變數全部小寫還無底線、亂自行發明會混淆的縮寫、或者意義不明或根本意義錯誤會誤導思考方向的變數名稱、以及 assign 了卻根本沒用到的變數等等。想到這種人如果來寫 QML 就覺得很可怕。

  • 可以把 component 當成 property type 傳入,就像寫 Qt/C++ 把 QWidget instance 的 reference/pointer 作為參數傳入另一個 QWidget 裡面,但不建議,因為不好維護。

    善用 Connection 增加可讀性

    謹慎使用 Signal.connect(),實在太容易寫完就忘記到底是在哪裡 .connect() 的了。

  • 大部分時候其實可以善用自定義 signal ,然後在一個適當的 scope 下(能夠同時看到 signal emitter 跟 receiver 的 scope)透過 Connection { ... } 來達成 component 間的互動、call function 等等,這樣維護會比較容易。

處理 Signal/Slot 的三種方式

在 QML 中,有三種方式來使用 Qt 很著名的 signal / slot 系統:

  • onXxxxxChanged
  • signalObject.connect()
  • Connections { target: 監控的對象(發射者); ... }

其使用方法官方文件寫得非常清楚易懂(難得不瑣碎的文件),自己去看。

qmldir

待補完,我自己也還沒弄得很清楚 orz @coldnew 大大快來救我…

Item 是三小

Item 是 QML 中所有 visible 元素的基礎 class。

就說其實底層是 C++了,就是所有你肉眼能看到的 QML 物件,都是繼承自 Item 這個 class。如果你寫過傳統 Qt GUI,Item 概念就跟 QWidget 很像。

不過跟傳統 QWidget 不同的地方是, Item 本身是看不到的(很弔鬼吧)、也不佔任何可見空間,但你可以拿他來封裝(encapsulate)其他可見 QML 元素。

如何封裝呢?可以看看稍後下面 Panel 的例子。

Component 是三小

在網路上討論 QML 話題時,大小寫很重要… QML 的 component 跟 QML 的 Component 指的是不同東西…因為 QML 裡面有種 component 就叫做 Component

所以為了避免混淆,我通常會寫成 Component { ... }

封裝好、可重用的物件就叫 component。

自訂 component 有很多種方法,最容易的就是直接建立 YourComponentName.qml 檔案。往後就可以在其他地方直接 YourComponentName { ... } 來使用。

#注意事項

  • 檔名就是 component 名稱,必須是大寫開頭的 CamelCase
  • 每個檔案(每個 component)都只能有一個 root Item。多寫會編譯不過。

另一種常見的自訂 component 方法是 inline component,就是不建立獨立的新的 .qml 檔,直接用 Component { ... } 這個鬼東西在一個 component 中定義另一個新的 component。入門建議先不要碰這裡,因為這用起來有點麻煩,有興趣的可以自己看文件(我雖然有用但我也還沒完全摸透,所以先擺者以後也許會補完這段。)

如何自訂「可塞入 child 的 Item」

例如內建的 Rectangle {} ,我們可以在裡面塞一堆有的沒的東西:

1
2
3
4
Rectangle {
Button { ...} // 塞一個東西
Label { ... } // 塞兩個東西
}

這要怎麼自己做呢?比如說,我想要用 QML 實做類似 Bootstrap 中的 panel

很簡單,塞進去的東西其實是被塞進 Item.data 這個 property 中,所以直接直接這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Panel.qml
Rectangle { // 封裝起來了,外界能存取的只有 root Item 中有定義的 property,例如 titleText
// 不過如果你需要讓外面能存取內部 Item,你可以用 alias。
id: root // root Item 建議一律取 root,方便好記不混淆。
default property alias innerObject: innerObject.data
property string titleText: "[Hello Untitled Title]"
color: "#fff"
Column {
anchors.fill: parent
Rectangle {
id: header
height: 10
width: parent.width
color: "#666"
Text { text: root.titleText; // 可以的話通常我不會省略 root。
color: "#fff" }
}

Rectangle {
id: innerObject
width: parent.width
// innerObject.data 會被插在這
}
}
}

以後就可以這樣用 Panel

1
2
3
4
5
6
7
8
9
10
Panel {
titleText: "Image Information" // 這是我們方才自行定義的 property
Column { /* .......*/ } // 這裡就會塞進 innerObject.data 中
}
Panel {
titleText: "Image Viewer"
Rectangle { /* .......*/ } // 塞進 innerObject.data
Rectangle { /* .......*/ } // 又塞進 innerObject.data
Rectangle { /* .......*/ } // 再塞一個 innerObject.data
}

避免使用 *Layout、(吐血)

熟悉 HTML / CSS 的話,嗯,你那堆概念在 QML 裡面完全不管用,別以為 QML 號稱給設計師好學習就真以為這是 CSS + JS 了,這他媽的完全不一樣啊。請務必把這些概念扔掉再來寫 QML!

寫了三個禮拜 QML 後,有人問我 HTML / CSS 相關問題,我竟然腦袋完全轉不過去,因為 QML 光是 position 跟 layout 的邏輯就跟 HTML / CSS 完完全全不一樣,就好像 「Java 跟 JavaScript 有什麼不一樣」之間的那種不一樣。

如果你寫過傳統 QWidget,你一定寫過像是 QVBoxLayout/QHBoxLayout/QGridLayout,還有應該也很熟悉 addSpacing() 等方法。如果你是這種人,那更要扔掉這些概念再來寫 QML!否則你、會、踩、到、雷!

  • QML 中,千萬別用什麼看起來很合理的 ColumnLayout/RowLayout/GridLayout,他們是惡魔!請改用 Column/Row/Grid
  • 永遠優先考慮使用 anchors,除非是出現重複性的排版(ex:一排按鈕、一排表單欄位)這種情況才該使用 Column/Row/Grid
    • 例如你如果要在視窗左側做一個 sidebar,請不要傻傻的用 Row { ... } ,那是 QWidget/QLayout 時代的邏輯。請用 anchors 來定位。

Column/Row/GridColumnLayout/RowLayout/GridLayout 的差異是,後者是前者的加強版。
前者雖然會根據包含的 Component 調整自身的尺寸,但他只管怎麼擺放 Component 的位置,並不會主動調整其中的 Component 的尺寸。後者的話,就可以在內部的 Componet 中使用 Layout.fillWidth: trueLayout.fillHeight: true 這兩個 attached properties 來自動調整尺寸。
除此之外, GridLayout 又比 Grid 多出了 Layout.colomnSpanLayout.rowSpan 這兩個 attached property,會寫 HTML table 語法的人應該很熟悉這個。
看起來很美好啦…但實際使用起來各種 bug 就是了。

我曾經因為 QML 的 Layout 浪費過將近一整天的時間在 debug,那次的狀況是不知道為啥當 ColumnLayout 內的 Item.height 改變時,Layout 自己的 height 竟然還蠢到不會自己調整,而且也找不到什麼方法強制它重新 render,後來發現全部改用 Column/Row/Grid 問題就全部解決,真是見鬼了。偏偏那天又睡很少,搞得一整天火氣都超大。

不要用 childrenRect

拿這貨來指定元素尺寸常常失敗,我目前完全搞不懂這貨是拿來幹麻的,如果你用這玩意遇到問題還是乖乖手動計算width / height,或者使用會自行調整 container 自身大小的 Row Column

我的經驗而已,如果有人明白為什麼、以及使用時機的話,請告訴我。

QML property 跟 JS variable

QML 雖然是 JS 的 superset,但千萬不要直接拿 JS 的想法去寫,否則你會踩到雷。

要牢牢記住:QML 的底層其實還是 C++,雖然這代表能夠透過 Qt MOC 來與 C++ 溝通、透過 C++ 來擴充 QML 的功能,但這同時也代表,QML 的 JS 對於 data type 的容錯是有限的,不能像 JS 那樣亂來。

舉例來說,我就踩過一個雷:

以下範例會用到一點點 HTML5 中的 Canvas 基本觀念,假設讀者已經知道。

假設我要一個 Canvas (這裡假設他的 idcanvas ),自訂兩個 property:

  1. 根據 strokeColor 這個 property 的值指定筆刷顏色。
  2. 然而,當 removeColor 這個 property 的值變成 true 時 ,讓筆刷顏色變成灰色 "#ddd"

來看範例 code。 ExampleCanvas.qml 如下:

1
2
3
4
5
6
7
8
9
10
11
Canvas {
id: canvas
property color strokeColor: "#f44" // stroke 的顏色預設為紅色 "#f44" ,type 是 color
property bool removeColor: false // removeColor 預設為 false,type 是 bool
onRemoveColorChanged: canvas.requestPaint() // 當 removeColor 的值改變時,讓 Canvas 重新 render。
onPaint: {
var ctx = getContext("2d"); // 拿到 Canvas 的 context
ctx.strokeStyle = removeColor ? "#ddd" : strokeColor
// 以下省略
}
}

然後這樣使用:

1
2
3
4
5
6
7
ExampleCanvas {
removeColor: true // 讓他一開始就是灰色
MouseArea { // 滑鼠點擊 ExampleCanvas 時切換顏色
anchors.fill: parent
onClicked: parent.removeColor = !parent.removeColor
}
}

看起來一切美好,然而實際執行的行為卻會是這樣:

  1. 一開始 ExampleCanvas 確實用灰色 "#ddd" 畫出了圖形
  2. 滑鼠第一次點擊 ExampleCanvas ,確實也變成了預設值紅色 "#f44"
  3. 但滑鼠再點擊一次,就再也變不回來了。

我後來發現問題是出在第 8 行,寫死的灰色 "#ddd" 那裡。

我猜測原因是 implicit type cast 的問題。QML 中你可以很簡單的用 "#f44" 這種包含了 RGB 色碼的 string 來指定顏色,他會 implicit cast 成 color 這個 QML type(C++ 內部實際所使用的則是 QColor ,有寫過 Qt/C++ 的一定很熟這玩意)。在絕大部分情況下這是完全沒有問題的。

然而使用 property color strokeColor: "#f44" 這種方法指定 color 就是 explicit cast 了,會直接讓 strokeColor 存真正的 color 而不是 string 而已。

上面的例子, strokeColor 裡面存的是真正的 color ,第 8 行的灰色#ddd卻只是string,推估問題就出在這了。我已經 assign ctx.strokeStylecolor ,再次 assign 的話如果是 string 他就忽略(實驗結果是這樣,我目前功力還不夠去看 C++ 怎麼實做這部份的行為)。

所以這個問題的解決方法就是,把灰色也用 color type 存成 property,然後第 8 行的 string 改成 color

請一定要讀一下 QML 中內建的基本 data type 一覽
還有一個不能算 type 的 type,叫做 alias (只能用在 property),這非常常用。可以去翻翻看 Controls 的原始碼看他怎麼使用 alias property。。


created date: 2016-10-12 21:41:08