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