快速入門 Go 語言 Fyne UI 框架基礎篇,編寫一個簡單的 markdown 編輯器。
- 參考課程:Go 語言 + Fyne 快速上手教程
- 參考文檔:Fyne.io
- 課程作者筆記倉庫:InkkaPlum 頻道的 Golang 及 Fyne 框架教程的代碼和文字版教程。
前言#
值得一提的是 Go 語言 + Fyne 快速上手教程 的視頻作者是一位高中生 Up 主,技能範圍覆蓋:算法 & 數據結構解題、前端 + 後端 Node 開發問題 + Go Web / 綜合開發以及 Unity PHP,掌握英語、日語和俄語。可以說是年輕有為,作為準研究生的我也是自愧不如,敬佩不已。
看完他的很多視頻,對我來說受益匪淺,本次的 Fyne 系列視頻教程也亦是如此。
對於像我這樣剛學完 Go 的基礎語法的人來說,可以通過做一個小項目來鞏固知識,同時快速入門一個新的 UI 框架。
對於準備學習 Go 語言的人來說,這是一個很好的入門視頻,你不用擔心聽不懂,因為 Up 主講的非常細致,小到 Go 語言的指針和引用類型、結構體和接口,大到 Go 語言的 goroutine,都有講到。看完這個視頻,再去學習基礎語法也是一個不錯的路徑,可以更好地入門 Go。
Fyne 框架#
在接觸 Go 語言和 Fyne 框架之前,我編寫的所有桌面端程序都是使用 Python 和 Flet 框架。
使用 Python 是因為 Python 簡單易上手,一直到現在 Python 都是我平時寫程序的首選語言,雖然我也在大學課堂中學過 C 和 Java,成績也不錯,但是總是覺得沒有 Python 寫起來順手。
使用 Flet 框架,也是因為它簡單易上手,它允許使用 Python 構建網頁、桌面和移動應用程序,而無需具備前端開發經驗。我只是閱讀它的文檔,就可以輕易上手開發出一些桌面端程序,也給我了很好的開發體驗。
比如我之前寫的幾個小項目,都是使用 Python + Flet 框架編寫的:
直到最近,開始學習 Go 語言,開始接觸了 Fyne 框架,我發現它和 Flet 其實是一種類型的跨平台框架,但是由於 Go 語言沒有 Python 那麼簡潔,再加上 Go 語言結構體 + 接口的特性,所以寫起來沒有那麼方便,但是其 API 函數接口設計的很直觀簡單,看到函數名就知道是什麼功能。
Fyne 同樣是跨平台的框架,支持 Windows、macOS、Linux、Android 和 iOS,能夠讓開發者一次編寫,處處運行。
Fyne 也提供了豐富的現代化 UI 組件,如按鈕、列表、輸入框等,幫助開發者快速構建美觀的應用界面。
相較於 Flet,我覺得 Fyne 的 API 設計更簡潔直觀些,易於上手,適合新手和經驗豐富的開發者。並且它封裝了更多的功能,甚至是 markdown 的顯示渲染。
同時,由於是用 Go 編寫,Fyne 利用 Go 的並發特性,能夠提供良好的性能,尤其在處理高並發任務時表現優秀。這是 Python 所不能比擬的。
不過,Fyne 是沒有熱重載的,這一點比較可惜,而 Flet 則支持熱重載。
總之,Fyne 和 Flet 有很多相似之處,對於我來說十分好上手。Fyne 以它的輕量、性能、資源消耗低著稱。而 Flet 背靠 Python,擁有簡潔的語法,方便開發。
配置環境#
Go 語言的配置不用多說,可以參考Go 語言入門 1:Go 簡介
Fyne 則需要一個如 MinGW-w 64 的 C 編譯器,它需要 C 編譯器來處理與系統圖形驅動程序和其他底層系統組件的必要交互。 下載地址
找到 x86_64-win32-sjlj
並下載,解壓即可。最後將其中的 bin
文件夾添加進環境變量中。
新建一個項目文件夾,初始化模塊,名稱隨意或省略。
go mod init fyneTest01
在項目文件夾中,下載 Fyne 模塊和幫助程序工具。
go get fyne.io/fyne/v2@latest
go install fyne.io/fyne/v2/cmd/fyne@latest
第一個 Fyne 程序#
我們新建 main,go
文件,並在其中編寫以下代碼:
package main
import (
"fmt" // 導入 fmt 包,用於格式化 I/O
"fyne.io/fyne/v2/app" // 導入 Fyne 的 app 包,用於創建應用
"fyne.io/fyne/v2/widget" // 導入 Fyne 的 widget 包,用於創建 UI 組件
)
func main() {
// 創建一個新的 Fyne 應用
a := app.New()
// 創建一個新的窗口,標題為 "test app"
w := a.NewWindow("test app")
// 設置窗口內容為一個標籤,標籤顯示 "Hello Fyne!"
w.SetContent(widget.NewLabel("Hello Fyne!"))
// 顯示窗口並開始運行應用程序
w.ShowAndRun()
// 應用程序運行後,打印 "Hello, World!" 到控制台
fmt.Println("Hello, World!")
}
注意,w.ShowAndRun ()
可以寫成 w.Show()
和 w.Run()
兩段,同時 w.Run()
會開啟一個事件循環,將主程序阻塞,因此最後的 Hello, World!
只能在窗口關閉時打印。
隨後打開當前文件夾終端,輸入:
go mod tidy
這段指令是整理現有的依賴,它會更新 go.mod
並生成 go.sum
,於是可以消除導入包的錯誤。go.mod
和 go.sum
是用作包管理的文件。
go run .
事實上,你不能直接運行 go run main.go
,因為最後整個項目有很多 go 程序文件,需要將它們聯合編譯運行才是正確的,因此使用 go run .
十分方便,不需要手動指定所有的 go 程序文件。
出現這麼多程序文件的原因在於:Go 語言只要是同一個包 (如 package main
),不管在什麼文件中都可以互相調用(大寫字母開頭可以被其他包訪問)
需要注意:第一次運行程序可能會等很久才會出現窗口,我一開始還以為是我配置錯誤了。所以需要耐心等待。
Fyne 默認字體不支持中文(新版本已支持),如果不能正確顯示中文,我們可以在 main()
函數中寫入以下代碼,通過更改主題樣式來設置中文字體。其中 NotoSansHans-Regular.ttf
可以替換為自己喜歡的字體。
customFont := fyne.NewStaticResource("NotoSansHans.ttf", loadFont("NotoSansHans-Regular.ttf"))
a.Settings().SetTheme(&myTheme{font: customFont})
同時在項目中創建 theme.go
和 util.go
文件,寫入以下代碼:
theme.go
package main
import (
"fyne.io/fyne/v2" // 導入 Fyne GUI 工具包
"fyne.io/fyne/v2/theme" // 導入默認主題包
"image/color" // 導入圖像/顏色包以處理顏色
)
// myTheme 結構體將保存自定義主題設置
type myTheme struct {
font fyne.Resource // 自定義字體資源
}
// Font 方法根據提供的文本樣式返回字體資源
func (m *myTheme) Font(s fyne.TextStyle) fyne.Resource {
return m.font // 返回在 myTheme 中定義的自定義字體
}
// Color 方法根據給定的主題顏色名稱和變體返回顏色
func (m *myTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color {
// 委託給默認主題以獲取顏色
return theme.DefaultTheme().Color(n, v)
}
// Icon 方法根據給定的主題圖標名稱返回圖標資源
func (m *myTheme) Icon(n fyne.ThemeIconName) fyne.Resource {
// 委託給默認主題以獲取圖標資源
return theme.DefaultTheme().Icon(n)
}
// Size 方法根據給定的主題大小名稱返回大小
func (m *myTheme) Size(n fyne.ThemeSizeName) float32 {
// 委託給默認主題以獲取大小
return theme.DefaultTheme().Size(n)
}
util.go
package main
import (
"os" // 導入 os 包,用於與操作系統交互
"log" // 導入 log 包,用於記錄日誌
)
// loadFont 函數用於加載指定路徑的字體文件
// 參數 fontPath: 字體文件的路徑
// 返回值: 字體文件的字節切片
func loadFont(fontPath string) []byte {
// 使用 os.ReadFile 函數讀取字體文件
fontData, err := os.ReadFile(fontPath)
if err != nil {
// 如果讀取過程中發生錯誤,使用 log.Fatalf 記錄錯誤信息並終止程序
log.Fatalf("無法加載字體文件: %v", err)
}
// 返回讀取到的字體數據
return fontData
}
實現 markdown 編輯器#
main.go#
首先,我們在 main.go
中編寫一個大致的編輯器框架。模仿 vscode 的 markdown 編輯頁面,左邊為輸入區,右邊為顯示區。
package main
import (
"fyne.io/fyne/v2" // 導入 Fyne GUI 工具包
"fyne.io/fyne/v2/app" // 導入 Fyne 應用程序模塊
"fyne.io/fyne/v2/container" // 導入 Fyne 容器模塊,用於佈局管理
"fyne.io/fyne/v2/widget" // 導入 Fyne 控件模塊,用於創建各種用戶界面控件
)
// main 函數是應用程序的入口點
func main() {
// 創建一個新的 Fyne 應用程序實例
a := app.New()
// 創建一個新的窗口,標題為 "Markdown編輯器"
w := a.NewWindow("Markdown編輯器")
// 創建一個多行輸入框,用於編輯 Markdown 文本
edit := widget.NewMultiLineEntry()
// 創建一個富文本控件,用於顯示 Markdown 預覽
preview := widget.NewRichText()
// 將編輯框和預覽框放入一個水平分割容器中
w.SetContent(container.NewHSplit(edit, preview))
// 設置窗口的初始大小為 800x600 像素
w.Resize(fyne.NewSize(800, 600))
// 將窗口居中顯示在屏幕上
w.CenterOnScreen()
// 顯示窗口並運行應用程序的主事件循環
w.ShowAndRun()
}
當然,這只是個佈局頁面,沒有任何功能。接下來我們需要使用一個結構體來存儲當前打開文件的配置選項,如當前文件路徑、文件名、左右編輯區和顯示區等等信息。我們將代碼重構為:
package main
import (
"fyne.io/fyne/v2" // 導入 Fyne 框架的主包
"fyne.io/fyne/v2/app" // 導入 Fyne 框架的應用程序包
"fyne.io/fyne/v2/container"// 導入 Fyne 的容器包
"fyne.io/fyne/v2/storage" // 導入 Fyne 的存儲包
"fyne.io/fyne/v2/widget" // 導入 Fyne 的小部件包
)
// config 結構體用於存儲應用程序的配置信息
type config struct {
EditWidget *widget.Entry // 編輯區域,使用 Entry 小部件
PreviewWidget *widget.RichText // 預覽區域,使用 RichText 小部件
CurrentFile fyne.URI // 當前打開的文件 URI
MenuItem *fyne.MenuItem // 菜單項
BaseTitle string // 窗口標題的基本字符串
}
var cfg config // 聲明一個全局變量 cfg,類型為 config
func main() {
a := app.New() // 創建一個新的 Fyne 應用程序實例
// 創建新的窗口,標題為 "Markdown編輯器"
w := a.NewWindow("Markdown編輯器")
cfg.BaseTitle = "Markdown編輯器" // 設置基本標題
// 創建編輯區域和預覽區域的 UI
edit, preview := cfg.makeUI()
// 創建菜單
cfg.createMenu(w)
// 設置窗口內容為水平分割的編輯區域和預覽區域
w.SetContent(container.NewHSplit(edit, preview))
// 設置窗口的初始大小
w.Resize(fyne.Size{Width: 800, Height: 600})
// 窗口居中顯示在屏幕上
w.CenterOnScreen()
// 顯示窗口並運行應用程序
w.ShowAndRun()
}
其中 cfg.makeUI()
和 cfg.createMenu(w)
方法還未實現,為了讓代碼結構更規範,在 main.go
程序中,我們只實現整個軟件的全局佈局配置。
具體的 UI 佈局排列,比如創建編輯區域和預覽區域和創建菜單功能我們放置在 ui.go
文件中。
而像菜單中的打開文件、保存文件以及另存為等功能,我們放在 config.go
中實現。
ui.go#
所以,接下來我們新建一個 ui.go
文件,在其中編寫具體的 UI 佈局。
package main
import (
"fyne.io/fyne/v2" // 導入 Fyne 框架的核心包
"fyne.io/fyne/v2/widget" // 導入 Fyne 的小部件包
)
// makeUI 方法用於創建編輯和預覽區域的用戶界面
func (cfg *config) makeUI() (*widget.Entry, *widget.RichText) {
// 創建一個多行文本輸入框用於編輯
edit := widget.NewMultiLineEntry()
// 創建一個富文本小部件用於Markdown預覽
preview := widget.NewRichTextFromMarkdown("")
// 將創建的編輯和預覽小部件保存到 config 結構體中
cfg.EditWidget = edit
cfg.PreviewWidget = preview
// 當編輯內容改變時,解析Markdown並更新預覽
edit.OnChanged = preview.ParseMarkdown
// 返回編輯和預覽小部件
return edit, preview
}
// createMenu 方法用於創建窗口的菜單
func (cfg *config) createMenu(win fyne.Window) {
// 創建 "打開..." 菜單項,並指定點擊時調用的函數
open := fyne.NewMenuItem("打開文件", func() {})
// 創建 "保存" 菜單項,並指定點擊時調用的函數
save := fyne.NewMenuItem("保存", func() {})
// 將保存菜單項存儲到 config 結構體中,並禁用它
cfg.MenuItem = save
cfg.MenuItem.Disabled = true // 空文件不能保存
// 創建 "另存為..." 菜單項,並指定點擊時調用的函數
saveAs := fyne.NewMenuItem("另存為", func() {})
// 創建文件菜單,將上述菜單項添加到文件菜單中
fileMenu := fyne.NewMenu("文件", open, save, saveAs)
// 創建主菜單,並將文件菜單作為其內容
menu := fyne.NewMainMenu(fileMenu)
// 將創建的主菜單設置為窗口的菜單
win.SetMainMenu(menu)
}
makeUI
方法:
- 創建一個多行文本輸入框
edit
,用於用戶輸入 Markdown 文本。 - 創建一個富文本小部件
preview
,利用 Fyne 自帶的NewRichTextFromMarkdown("")
方法來顯示 Markdown 的渲染結果。 - 將創建的輸入框和預覽小部件保存到
cfg
結構體中,以便其他方法可以訪問。 - 設置
edit
的OnChanged
事件,確保每當用戶修改輸入時,預覽區域會重新解析並更新。 - 返回編輯和預覽小部件,供調用者使用。
createMenu
方法:
- 創建一個 "打開文件" 菜單項,並綁定到打開文件的函數。
- 創建一個 "保存" 菜單項,並綁定到保存文件的函數。
- 將 "保存" 菜單項保存到
cfg
結構體中,並將其初始狀態設置為禁用,防止用戶在沒有文件加載的情況下執行保存操作。 - 創建一個 "另存為" 菜單項,並綁定到另存為的函數。
- 創建一個 "文件" 菜單,將上述菜單項組合在一起。
- 創建主菜單,將文件菜單設置為其內容。
- 將主菜單應用到指定的窗口上,使其在應用中可用。
測試程序:
go mod tidy
go run .
對於打開文件 cfg.openFunc(win))
、保存 cfg.saveFunc(win))
、另存為 cfg.saveAsFunc(win))
方法這裡還未實現,用空的匿名函數代替,我們在 config.go
中實現它們。
config.go#
導入所需要的包:
package main
import (
"io" // 導入 io,用於讀取文件內容
"strings" // 導入 strings,用於字符串操作
"fyne.io/fyne/v2" // 導入 Fyne 框架的核心包
"fyne.io/fyne/v2/dialog" // 導入 Fyne 的對話框包
"fyne.io/fyne/v2/storage" // 導入 Fyne 的存儲包
)
由於菜單項需要返回一個回調函數,因此這個三個功能都是返回一個函數。
實現打開文件 openFunc()
功能。
// openFunc 方法返回一個打開文件的函數
func (cfg *config) openFunc(win fyne.Window) func() {
return func() {
// 創建文件打開對話框
openDialog := dialog.NewFileOpen(func(read fyne.URIReadCloser, err error) {
// 錯誤處理:如果發生錯誤,則顯示錯誤對話框
if err != nil {
dialog.ShowError(err, win)
return
}
// 如果沒有選擇文件,直接返回
if read == nil {
return
}
// 讀取文件內容
data, err := io.ReadAll(read)
if err != nil {
dialog.ShowError(err, win)
return
}
// 確保文件在讀取後關閉
defer read.Close()
// 將讀取的內容設置到編輯器中
cfg.EditWidget.SetText(string(data))
// 更新當前文件的 URI
cfg.CurrentFile = read.URI()
// 更新窗口標題,包含當前文件名
win.SetTitle(cfg.BaseTitle + "-" + read.URI().Name())
cfg.MenuItem.Disabled = false // 啟用保存菜單項
}, win)
// 設置文件過濾器
openDialog.SetFilter(filter)
openDialog.Show() // 顯示打開對話框
}
}
- 創建一個新的打開文件對話框。
- 處理用戶選擇文件時的邏輯,包括錯誤處理。
- 讀取選定文件的內容並將其設置到編輯器中。
- 更新當前文件的 URI 和窗口標題,並啟用保存菜單項。
openDialog.SetFilter(filter)
是設置過濾器,我們只能打開文件後綴為 .md
或者 .MD
的文件,因此我們還需在 main.go
中添加一個全局過濾器。
// filter 用於過濾文件擴展名,只允許 .md 和 .MD 文件
var filter = storage.NewExtensionFileFilter([]string{".md", ".MD"})
接著,我們實現保存文件saveFunc()
功能,只需將將編輯器中的文本轉成字節寫入文件中。
// saveFunc 方法返回一個保存當前文件的函數
func (cfg *config) saveFunc(win fyne.Window) func() {
return func() {
// 檢查當前是否有打開的文件
if cfg.CurrentFile != nil {
// 獲取當前文件的寫入器
write, err := storage.Writer(cfg.CurrentFile)
if err != nil {
dialog.ShowError(err, win)
return
}
// 將編輯器中的文本寫入文件
write.Write([]byte(cfg.EditWidget.Text))
// 確保文件在寫入後關閉
defer write.Close()
}
}
}
- 檢查當前是否有打開的文件,如果有,則將編輯器的內容寫入該文件。
- 處理文件寫入的邏輯,包括錯誤處理和確保文件正確關閉。
最後,我們實現另存為saveAsFunc()
功能。
// saveAsFunc 方法返回一個保存文件的函數
func (cfg *config) saveAsFunc(win fyne.Window) func() {
return func() {
// 創建文件保存對話框
saveDialog := dialog.NewFileSave(func(write fyne.URIWriteCloser, err error) {
// 錯誤處理:如果發生錯誤,則顯示錯誤對話框
if err != nil {
dialog.ShowError(err, win)
return
}
// 如果沒有選擇文件,直接返回
if write == nil {
return
}
// 檢查文件擴展名是否為 .md
if !strings.HasSuffix(strings.ToLower(write.URI().String()), ".md") {
dialog.ShowInformation("錯誤", "必須是.md擴展名", win)
return
}
// 將編輯器中的文本寫入文件
write.Write([]uint8(cfg.EditWidget.Text))
// 更新當前文件的 URI
cfg.CurrentFile = write.URI()
// 確保文件在寫入後關閉
defer write.Close()
// 更新窗口標題,包含當前文件名
win.SetTitle(cfg.BaseTitle + "-" + write.URI().Name())
cfg.MenuItem.Disabled = false // 啟用保存菜單項
}, win)
// 設置默認文件名和過濾器
saveDialog.SetFileName("未命名.md")
saveDialog.SetFilter(filter)
saveDialog.Show() // 顯示保存對話框
}
}
- 創建一個新的保存文件對話框。
- 處理用戶選擇文件時的邏輯,包括錯誤處理和文件擴展名檢查。
- 將編輯器中的文本寫入用戶選擇的
.md
文件。 - 更新當前文件的 URI 和窗口標題,並啟用保存菜單項。
在 ui.go
中使用這三個功能函數:
open := fyne.NewMenuItem("打開文件", cfg.openFunc(win))
save := fyne.NewMenuItem("保存文件", cfg.saveFunc(win))
saveAs := fyne.NewMenuItem("另存為", cfg.saveAsFunc(win))
測試程序#
測試程序:
go mod tidy
go run .
編寫一段 markdown 文字,另存為 test.md
:
繼續修改文件,添加新的段落,並保存文件:
關閉程序,新開一個空窗口,使用打開文件,選擇剛剛保存的文件並打開。
保存成功。
在測試的過程中,控制台會報一個錯誤,但並不影響運行:
Fyne error: Preferences API requires a unique ID, use app.NewWithID() or the FyneApp.toml ID field
這個錯誤提示說明你的 Fyne 應用程序沒有提供唯一的應用程序 ID,我們將 main.go
中創建一個應用程序的方法從 a := app.New()
改為使用 a := app.NewWithID()
方法,並向其中傳入一個唯一的應用 ID 即可。比如 a := app.NewWithID("01")
打包程序#
執行以下代碼:
go install fyne.io/fyne/v2/cmd/fyne@latest
go get fyne.io/fyne/v2/cmd/fyne
下載 fyne.exe
到 %GOROOT%\bin
(添加進環境變量)
fyne package -os windows -icon Icon.png
-os
:指定平台;-icon
:指定軟件圖標
等待一段時間,就可以生成一個 exe 可執行文件。文件名為 go mod init
輸入的名稱。
啟動速度很快,但是文件大小稍大,可能是因為 Fyne 將許多沒有用到的組件和資源一同打包進了軟件中。
如果需要減少軟件體積,我們可以使用手動編譯。
go build -ldflags "-s -w -H windowsgui" -o test.exe .
-ldflags "參數"
: 表示將引號裡面的參數傳給編譯器-s
:去掉符號信息-w
:去掉 DWARF 調試信息,不能使用 gdb 調試-H windowsgui
: 以 windows gui 形式打包,不帶 dos 窗口。
這樣一來,軟件的體積就縮小了一半。雖然這樣就不能直接設置軟件圖標和軟件信息,但是可以後期通過 Resource Hacker 加上。
如果還想進一步壓縮軟件體積,則可以使用 UPX 工具。UPX 是一種高級可執行文件壓縮器。UPX 通常會將程序和 DLL 的文件大小減少約 50%-70%。
我們執行以下命令:
upx --best test.exe
輸出結果:
Ultimate Packer for eXecutables
Copyright (C) 1996 - 2024
UPX 4.2.4 Markus Oberhumer, Laszlo Molnar & John Reiser May 9th 2024
File size Ratio Format Name
-------------------- ------ ----------- -----------
22947328 -> 10285568 44.82% win64/pe test.exe
Packed 1 file.
可以看到軟件體積又被壓縮了一半。
後記#
以上就是快速入門 Go 語言 Fyne UI 框架的基礎篇,我們編寫一個簡單的 markdown 編輯器,實現了 markdown 編寫顯示,以及打開保存等功能。
關於這個 Fyne 框架的小項目,我更推薦去看原作者的視頻:Go 語言 + Fyne 快速上手教程,講的非常詳細和全面。
當然,這是一個非常簡單的程序,目的在於快速了解和入門 Fyne 框架。顯然這些 “皮毛” 在實際開發中是完全不夠用的,不過這會幫助我們更好的自學。
後續如果還需要深入去學習 Fyne 框架,我想官方的文檔手冊是一個很好的學習資源。如果還沒有學習 Go 語言的基礎語法,推薦學習 8 小時轉職 Golang 工程師和 Go 語言教程 | 菜鳥教程的教程,同時可以參考我所整理的筆記,Go 入門筆記 - Pi3's Notes。