快速入门Fyne框架：进阶篇

快速入门 Go 语言 Fyne UI 框架进阶篇，自定义主题配置。

参考课程：Go 语言 + Fyne 快速上手教程

参考文档：Fyne.io

笔记仓库：InkkaPlum 频道的 Golang 及 Fyne 框架教程的代码和文字版教程。

前言

在上一篇笔记中，我们学习了如何快速使用 Fyne 框架编写一个简易的 markdown 编辑器。其中提到了几个问题：

Fyne 不支持热重载

自定义中文字体需要编写 theme.go 和 util.go

下面，我们同样通过 markdown 编辑器的例子来逐一解决这些问题。

热重载

可以通过 Air 工具来实现 Fyne 的热重载功能。此工具的开发者当初是因为 Gin 缺乏实时重载的功能而开发，那么也顺便解决了 Fyne 学习者的问题。

GitHub 项目地址：air: ☁️ Live reload for Go apps

配置非常简单：

安装 Air 工具

Copy
go install github.com/air-verse/air@latest

打开项目目录，运行以下命令，这会将具有默认设置的 .air.toml 配置文件初始化到当前目录。

Copy
air init

在这之后，你只需执行 air 命令，无需额外参数，它就能使用 .air.toml 文件中的配置了。

Copy
air

通过执行 air 指令替代原先的 go run . 指令启动项目，项目就可以支持热重载了。

自定义主题

主题接口

Fyne 的主题实际上就是一个接口 fyne.Theme：

Copy
type Theme interface {
Color(ThemeColorName, ThemeVariant) color.Color // 颜色
Font(TextStyle) Resource // 字体
Icon(ThemeIconName) Resource // 图标
Size(ThemeSizeName) float32 // 大小
}

要实现自定义的主题，我们只需要定义一个自定义主题并实现这个接口中的所有函数，即实现这个接口。

比如我定义了一个自定义主题 myTheme：

Copy
type myTheme struct {}

在这里，官方文档中提到，最好通过断言我们实现的这个接口，以便编译错误时更接近错误的位置。即通过以下代码实现断言：

Copy
var _ fyne.Theme = (*myTheme)(nil)

举个例子方便理解：比如当我们写了以上断言代码后，由于没有实现接口的全部函数，导致程序报错，这时程序会直接定位到 theme.go 中的错误。比如以下报错信息：

Copy
.\theme.go:29:20: cannot use (*myTheme)(nil) (value of type *myTheme) as fyne.Theme value in variable declaration: *myTheme does not implement fyne.Theme (missing method Size)

但是，如果我们没有写以上断言的代码后，程序由于没有实现接口而报错，程序也许会定位到 main.go 中，错误位置不够清晰，难以寻找到真实的错误原因。比如以下报错信息：

Copy
.\main.go:16:24: cannot use &myTheme{…} (value of type *myTheme) as fyne.Theme value in argument to a.Settings().SetTheme: *myTheme does not implement fyne.Theme (missing method Size)

因此，在这里使用断言代码，断言我们实现的接口的好处在于：提高代码的健壮性和可维护性。

自定义颜色

接下来，我们想要实现一个自定义的主题，就可以新建一个 theme.go 文件，在其中去实现 Fyne 定义的 Theme 接口。

比如我们想要自定义颜色，就去实现 Color 函数：

Copy
// 导入颜色包，这里需要从 "image/color" 导入
import "image/color"

// 定义一个名为 myTheme 的结构体，假设它实现了 fyne.Theme 接口
type myTheme struct {
// 这里可以定义其他与主题相关的字段
}

// Color 方法实现了 fyne.Theme 接口中的 Color 方法
// name 是颜色名称，variant 是主题变体
func (m myTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
// 检查颜色名称是否为背景色
if name == theme.ColorNameBackground {
// 如果是亮色主题
if variant == theme.VariantLight {
// 返回白色作为背景色
return color.RGBA{R: 254, G: 200, B: 216, A: 0xff}
}
// 否则暗色主题，返回黑色作为背景色
return color.RGBA{R: 149, G: 125, B: 173, A: 0xff}
}
// 对于其他颜色名称，调用默认主题的 Color 方法
return theme.DefaultTheme().Color(name, variant)
}

当然，前面提到过，要想使自定义主题有效，就必须要让我们的 myTheme 去实现 Theme 的所有方法。因此，即使我们只是设置一个主题颜色，我们仍然需要实现其他方法。如果不需要修改，则可直接返回默认值，但方法必须被实现！

Copy
func (m myTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}

func (m myTheme) Size(name fyne.ThemeSizeName) float32 {
return theme.DefaultTheme().Size(name)
}

func (m myTheme) Font(style fyne.TextStyle) fyne.Resource {
return theme.DefaultTheme().Font(style)
}

最后，只需要在 main.go 中添加以下代码，则可以将我们自定义的主题引入程序中。

Copy
app.Settings().SetTheme(&myTheme{})

这里没有设置输入框 theme.ColorNameInputBackground 的颜色，所以显示了默认的黑白颜色。

以上就是设置一个自定义背景颜色的代码和效果，如果我们想要同时设置其他控件的颜色，我们可以利用 Go 语言的 switch 语言来逐一判断并设置颜色。

Copy
// Color 方法实现了 fyne.Theme 接口中的 Color 方法
// m 是指向 myTheme 结构体的指针，允许方法修改 myTheme 的状态
// n 是颜色名称，v 是主题变体
func (m *myTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color {
switch n {
case theme.ColorNameBackground: // 背景色
return color.RGBA{190, 233, 255, 1}
case theme.ColorNameButton: // 按钮颜色
return color.RGBA{0, 122, 255, 255}
case theme.ColorNameDisabledButton: // 禁用按钮颜色
return color.RGBA{142, 142, 147, 255}
case theme.ColorNameHover: // 悬停时的颜色
return color.RGBA{230, 230, 230, 255}
case theme.ColorNameFocus: // 焦点时的颜色
return color.RGBA{255, 165, 0, 255}
case theme.ColorNameShadow: // 阴影颜色
return color.RGBA{0, 0, 0, 50}
default: // 其他没有匹配到的控件 设置为默认主题颜色
return theme.DefaultTheme().Color(n, v)
}
}

同理，要想自定义字体、图标和大小，也是利用相似的步骤，实现对应的方法即可。

Copy
func (m myTheme) Icon(name fyne.ThemeIconName) fyne.Resource {……}

func (m myTheme) Size(name fyne.ThemeSizeName) float32 {……}

func (m myTheme) Font(style fyne.TextStyle) fyne.Resource {……}

不过在自定义字体和图标之前，需要先要了解和学习一下关于 Fyne 的捆绑资源操作。

捆绑资源

bundle

我们都知道基于 Go 的应用程序通常构建为单个二进制可执行文件，这使得我们分发使用十分的方便，Fyne 应用程序也是如此。

但不幸的是 GUI 应用程序通常需要额外的资源来呈现用户界面，比如图片、字体、音频等等。为了应对这一挑战，Go 应用程序可以将资产捆绑到二进制文件本身中。

Fyne 工具包更喜欢使用 fyne bundle，因为它具有各种好处，我们将在下面探讨。

如果我们想要将一张图片捆绑到应用程序中，使得程序在运行时可以被使用，则我们可以执行下面的指令来捆绑资源：

Copy
fyne bundle -o bundled.go image.png

同时以上代码会在文件夹中生成一个 bundled.go 文件，其中直接包含了我们捆绑的图片的二进制数据，这样我们就可以在程序中使用图片，而不需要带上这张图片。

事实上，它的所有二进制信息已经在代码变量中了。比如下方 bundled.go 文件中的 StaticContent 就存放了图片 image.png 的所有二进制数据。

Copy
var resourceImagePng = &fyne.StaticResource{
StaticName: "image.png",
StaticContent: []byte{...}
}

不过需要注意的是，重复执行 fyne bundle -o bundled.go image.png 会相互覆盖。如果想捆绑多个资源，可以使用 -append 追加资源：

Copy
fyne bundle -o bundled.go image1.png
fyne bundle -o bundled.go -append image2.png

接着，就可以在程序中使用这些捆绑的资源。比如在 canvas 上加载一个图像：

Copy
img := canvas.NewImageFromResource(resourceImagePng)

注意这里的 resourceImagePng 资源名称是有规则的。比如我捆绑了 image1.png 和 image2.png 两张图片，则它们对应的资源名称就为 resourceImage1Png 和 resourceImage2Png。

默认命名规则为，文件名开头字母一定大写，文件后缀名开头一定大写，其他保持原样。即 resource<Name><Ext>

自定义图标

了解了 Fyne 的捆绑资源操作，接上一节，我们就可以通过绑定资源来自定义图标。

比如，我们捆绑了一张 icon.jpeg 图片，想要让它替换掉默认的 Home 图标。只需要在 theme.go 中修改实现 Icon 方法的代码：

Copy
func (m myTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
if name == theme.IconNameHome {
return resourceIconJpeg
}
return theme.DefaultTheme().Icon(name)
}

这样就可以将默认的 Home 图标，替换为自己的图片。在代码中同样使用 Home 图标即可。比如下面使用 Home 图标 theme.HomeIcon() 创建了一个图标按钮。

Copy
widget.NewButtonWithIcon("Home", theme.HomeIcon(), func() {})

Fyne 内置了一些常用的图标。在 主题图标 |Fyne.io 中可以查看所有图标。

自定义字体

对于自定义字体也可以是类似的方法。通过 fyne bundle 将字体文件绑定进 bundle.go，在 theme.go 中实现 Font 方法。

Copy
func (m myTheme) Font(style fyne.TextStyle) fyne.Resource {
return resourceNotoSansHansRegularTtf
}

在 main.go 中设置主题：

Copy
app.Settings().SetTheme(&myTheme{})

这样就完成了字体的引入，但是这样的方法也有缺点，就是体积非常大！NotoSansHans-Regular.ttf 字体体积为 8.5 MB，但是引入了 bundle.go 文件后，bundle.go 的文件体积就变为了 21.3 MB。

还有一种方法就是先在 theme.go 中的 myTheme 结构体中增加一个字体字段：

Copy
type myTheme struct {
font fyne.Resource
}

接着就是实现 Font 方法，这里只需要返回结构体中的 Font 字段的数据。

Copy
func (m *myTheme) Font(s fyne.TextStyle) fyne.Resource {
return m.font
}

具体的读取字体数据的步骤，我们可以新建一个 util.go 文件或者也可以直接写在 theme.go 中，在其中实现：

Copy
package main

import (
"os" // 导入 os 包，用于文件操作
"log" // 导入 log 包，用于日志记录
)

// loadFont 函数用于加载指定路径的字体文件
// 参数 fontPath 是字体文件的路径，返回值是字体文件的字节切片
func loadFont(fontPath string) []byte {
// 使用 os.ReadFile 读取字体文件
// fontData 存储读取到的字节数据，err 存储可能发生的错误
fontData, err := os.ReadFile(fontPath)
if err != nil {
// 如果读取字体文件时发生错误，记录错误信息并终止程序
log.Fatalf("无法加载字体文件: %v", err)
}
// 返回加载的字体文件数据
return fontData
}

最后就可以在 main.go 中引入这个字体，具体方法为：

Copy
// 使用 loadFont 函数加载指定路径的字体文件，并将其作为静态资源创建
customFont := fyne.NewStaticResource("NotoSansHans.ttf", loadFont("NotoSansHans-Regular.ttf"))
// 设置应用程序的主题为自定义主题，并将加载的字体应用于该主题
// myTheme 是自定义主题的结构体，其中包含字体信息
app.Settings().SetTheme(&myTheme{font: customFont})

这也是上一篇教程中引入中文字体的方法。这个方法没有将字体文件捆绑在程序中，而是在程序执行时读取字体文件，所以这种自定义字体的方式，需要带上字体文件一起分发，也不是很方便。

用同样的方法，我们可以分别设置字体的粗体、斜体和等宽体显示的样式：

Copy
type myTheme struct {
regular fyne.Resource
bold fyne.Resource
italic fyne.Resource
monospace fyne.Resource
}
func (m *myTheme) Font(s fyne.TextStyle) fyne.Resource {
if s.Monospace {
return m.monospace
}
if s.Bold {
return m.bold
}
if s.Italic {
return m.italic
}
return m.regular
}

利用类型的思路，还有更高阶全面的设置方法，同样要另外带上字体文件。

theme.go

Copy
type myTheme struct {
regular, bold, italic, boldItalic, monospace fyne.Resource
}

func (t *myTheme) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.Color {
return theme.DefaultTheme().Color(name, variant)
}

func (t *myTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}

func (m *myTheme) Font(style fyne.TextStyle) fyne.Resource {
if style.Monospace {
return m.monospace
}
if style.Bold {
if style.Italic {
return m.boldItalic
}
return m.bold
}
if style.Italic {
return m.italic
}
return m.regular
}

func (m *myTheme) Size(name fyne.ThemeSizeName) float32 {
return theme.DefaultTheme().Size(name)
}

func (t *myTheme) SetFonts(regularFontPath string, monoFontPath string) {
t.regular = theme.TextFont()
t.bold = theme.TextBoldFont()
t.italic = theme.TextItalicFont()
t.boldItalic = theme.TextBoldItalicFont()
t.monospace = theme.TextMonospaceFont()

if regularFontPath != "" {
t.regular = loadCustomFont(regularFontPath, "Regular", t.regular)
t.bold = loadCustomFont(regularFontPath, "Bold", t.bold)
t.italic = loadCustomFont(regularFontPath, "Italic", t.italic)
t.boldItalic = loadCustomFont(regularFontPath, "BoldItalic", t.boldItalic)
}
if monoFontPath != "" {
t.monospace = loadCustomFont(monoFontPath, "Regular", t.monospace)
} else {
t.monospace = t.regular
}
}

func loadCustomFont(env, variant string, fallback fyne.Resource) fyne.Resource {
variantPath := strings.Replace(env, "Regular", variant, -1)

res, err := fyne.LoadResourceFromPath(variantPath)
if err != nil {
fyne.LogError("Error loading specified font", err)
return fallback
}

return res
}

main.go

Copy
// 设置主题
t := &myTheme{}
t.SetFonts("./assets/font/Consolas-with-Yahei Regular Nerd Font.ttf", "")
// 注意"./assets/font"目录下有4个文件:
// Consolas-with-Yahei Bold Nerd Font.ttf
// Consolas-with-Yahei BoldItalic Nerd Font.ttf
// Consolas-with-Yahei Italic Nerd Font.ttf
// Consolas-with-Yahei Regular Nerd Font.ttf
app.Settings().SetTheme(t)