快速入门Fyne框架:基础篇

Keywords: #技术 #Golang #Fyne
Table of Contents

快速入门 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 组件,如按钮、列表、输入框等,帮助开发者快速构建美观的应用界面。

相比较 Fet,我觉得 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