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

相比较 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

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。