快速入门Fyne框架：基础篇

快速入门 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 框架编写的：

密码管理器系统设计与实现

基于 UDP 的多人在线聊天室

个人云盘桌面客户端

直到最近，开始学习 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 文件夹添加进环境变量中。

新建一个项目文件夹，初始化模块，名称随意或省略。

Copy
go mod init fyneTest01

在项目文件夹中，下载 Fyne 模块和帮助程序工具。

Copy
go get fyne.io/fyne/v2@latest
go install fyne.io/fyne/v2/cmd/fyne@latest

第一个 Fyne 程序

我们新建 main,go 文件，并在其中编写以下代码：

Copy
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! 只能在窗口关闭时打印。

随后打开当前文件夹终端，输入：

Copy
go mod tidy

这段指令是整理现有的依赖，它会更新 go.mod 并生成 go.sum，于是可以消除导入包的错误。go.mod 和 go.sum 是用作包管理的文件。

Copy
go run .

事实上，你不能直接运行 go run main.go，因为最后整个项目有很多 go 程序文件，需要将它们联合编译运行才是正确的，因此使用 go run . 十分方便，不需要手动指定所有的 go 程序文件。

出现这么多程序文件的原因在于：Go 语言只要是同一个包 （如 package main），不管在什么文件中都可以互相调用（大写字母开头可以被其他包访问）

需要注意：第一次运行程序可能会等很久才会出现窗口，我一开始还以为是我配置错误了。所以需要耐心等待。

Fyne 默认字体不支持中文（新版本已支持），如果不能正确显示中文，我们可以在 main() 函数中写入以下代码，通过更改主题样式来设置中文字体。其中 NotoSansHans-Regular.ttf 可以替换为自己喜欢的字体。

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

同时在项目中创建 theme.go 和 util.go 文件，写入以下代码：

theme.go

Copy
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

Copy
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 编辑页面，左边为输入区，右边为显示区域。

Copy
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()
}

当然，这只是一个布局页面，没有任何功能。接下来我们需要使用一个结构体来存储当前打开文件的配置选项，如当前文件路径、文件名、左右编辑区和显示区等等信息。我们将代码重构为：

Copy
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 布局。

Copy
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 结构体中，并将其初始状态设置为禁用，防止用户在没有文件加载的情况下执行保存操作。

创建一个 "另存为" 菜单项，并绑定到另存为的函数。

创建一个 "文件" 菜单，将上述菜单项组合在一起。

创建主菜单，将文件菜单设置为其内容。

将主菜单应用到指定的窗口上，使其在应用中可用。

测试程序：

Copy
go mod tidy
go run .

对于打开文件 cfg.openFunc(win))、保存 cfg.saveFunc(win))、另存为 cfg.saveAsFunc(win)) 方法这里还未实现，用空的匿名函数代替，我们在 config.go 中实现它们。

config.go

导入所需要的包：

Copy
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() 功能。

Copy
// 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 中添加一个全局过滤器。

Copy
// filter 用于过滤文件扩展名，只允许 .md 和 .MD 文件
var filter = storage.NewExtensionFileFilter([]string{".md", ".MD"})

接着，我们实现保存文件 saveFunc() 功能，只需将将编辑器中的文本转成字节写入文件中。

Copy
// 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() 功能。

Copy
// 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 中使用这三个功能函数：

Copy
open := fyne.NewMenuItem("打开文件", cfg.openFunc(win))
save := fyne.NewMenuItem("保存文件", cfg.saveFunc(win))
saveAs := fyne.NewMenuItem("另存为", cfg.saveAsFunc(win))

测试程序

测试程序：

Copy
go mod tidy
go run .

编写一段 markdown 文字，另存为 test.md：

继续修改文件，添加新的段落，并保存文件：

关闭程序，新开一个空窗口，使用打开文件，选择刚刚保存的文件并打开。

保存成功。

在测试的过程中，控制台会报一个错误，但并不影响运行：

Copy
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")

打包程序

执行以下代码：

Copy
go install fyne.io/fyne/v2/cmd/fyne@latest
go get fyne.io/fyne/v2/cmd/fyne

下载 fyne.exe 到 %GOROOT%\bin (添加进环境变量)

Copy
fyne package -os windows -icon Icon.png

-os：指定平台；-icon：指定软件图标

等待一段时间，就可以生成一个 exe 可执行文件。文件名为 go mod init 输入的名称。

启动速度很快，但是文件大小稍大，可能是因为 Fyne 将许多没有用到的组件和资源一同打包进了软件中。

如果需要减少软件体积，我们可以使用手动编译。

Copy
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%。

我们执行以下命令：

Copy
upx --best test.exe

输出结果：

Copy
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。