banner
Pi3

Pi3

记录学习、生活和成长
github
telegram
x
zhihu
email

快速入門Fyne框架:基礎篇

快速入門 Go 語言 Fyne UI 框架基礎篇,編寫一個簡單的 markdown 編輯器。

fyne_go.jpg

前言#

image.png

值得一提的是 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 框架編寫的:

  1. 密碼管理器系統設計與實現
  2. 基於 UDP 的多人在線聊天室
  3. 個人雲盤桌面客戶端

直到最近,開始學習 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.modgo.sum 是用作包管理的文件。

go run .

事實上,你不能直接運行 go run main.go,因為最後整個項目有很多 go 程序文件,需要將它們聯合編譯運行才是正確的,因此使用 go run . 十分方便,不需要手動指定所有的 go 程序文件。

出現這麼多程序文件的原因在於:Go 語言只要是同一個包 (如 package main),不管在什麼文件中都可以互相調用(大寫字母開頭可以被其他包訪問)

需要注意:第一次運行程序可能會等很久才會出現窗口,我一開始還以為是我配置錯誤了。所以需要耐心等待。

image.png


Fyne 默認字體不支持中文(新版本已支持),如果不能正確顯示中文,我們可以在 main() 函數中寫入以下代碼,通過更改主題樣式來設置中文字體。其中 NotoSansHans-Regular.ttf 可以替換為自己喜歡的字體。

customFont := fyne.NewStaticResource("NotoSansHans.ttf", loadFont("NotoSansHans-Regular.ttf"))
a.Settings().SetTheme(&myTheme{font: customFont})

同時在項目中創建 theme.goutil.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()
}

image.png

當然,這只是個佈局頁面,沒有任何功能。接下來我們需要使用一個結構體來存儲當前打開文件的配置選項,如當前文件路徑、文件名、左右編輯區和顯示區等等信息。我們將代碼重構為:

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 結構體中,以便其他方法可以訪問。
  • 設置 editOnChanged 事件,確保每當用戶修改輸入時,預覽區域會重新解析並更新。
  • 返回編輯和預覽小部件,供調用者使用。

createMenu 方法

  • 創建一個 "打開文件" 菜單項,並綁定到打開文件的函數。
  • 創建一個 "保存" 菜單項,並綁定到保存文件的函數。
  • 將 "保存" 菜單項保存到 cfg 結構體中,並將其初始狀態設置為禁用,防止用戶在沒有文件加載的情況下執行保存操作。
  • 創建一個 "另存為" 菜單項,並綁定到另存為的函數。
  • 創建一個 "文件" 菜單,將上述菜單項組合在一起。
  • 創建主菜單,將文件菜單設置為其內容。
  • 將主菜單應用到指定的窗口上,使其在應用中可用。

測試程序:

go mod tidy
go run .

image.png

image.png

對於打開文件 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

image.png

繼續修改文件,添加新的段落,並保存文件

image.png

關閉程序,新開一個空窗口,使用打開文件,選擇剛剛保存的文件並打開。

image.png

保存成功。

image.png

在測試的過程中,控制台會報一個錯誤,但並不影響運行:

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 輸入的名稱。

image.png

啟動速度很快,但是文件大小稍大,可能是因為 Fyne 將許多沒有用到的組件和資源一同打包進了軟件中。

如果需要減少軟件體積,我們可以使用手動編譯。

go build -ldflags "-s -w -H windowsgui" -o test.exe .
  • -ldflags "參數": 表示將引號裡面的參數傳給編譯器
  • -s:去掉符號信息
  • -w:去掉 DWARF 調試信息,不能使用 gdb 調試
  • -H windowsgui: 以 windows gui 形式打包,不帶 dos 窗口。

這樣一來,軟件的體積就縮小了一半。雖然這樣就不能直接設置軟件圖標和軟件信息,但是可以後期通過 Resource Hacker 加上。

image.png

如果還想進一步壓縮軟件體積,則可以使用 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.

可以看到軟件體積又被壓縮了一半。

image.png

後記#

以上就是快速入門 Go 語言 Fyne UI 框架的基礎篇,我們編寫一個簡單的 markdown 編輯器,實現了 markdown 編寫顯示,以及打開保存等功能。

關於這個 Fyne 框架的小項目,我更推薦去看原作者的視頻:Go 語言 + Fyne 快速上手教程,講的非常詳細和全面。

當然,這是一個非常簡單的程序,目的在於快速了解和入門 Fyne 框架。顯然這些 “皮毛” 在實際開發中是完全不夠用的,不過這會幫助我們更好的自學。

後續如果還需要深入去學習 Fyne 框架,我想官方的文檔手冊是一個很好的學習資源。如果還沒有學習 Go 語言的基礎語法,推薦學習 8 小時轉職 Golang 工程師Go 語言教程 | 菜鳥教程的教程,同時可以參考我所整理的筆記,Go 入門筆記 - Pi3's Notes

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。