Go-GUI-应用开发实用指南-全-

Go GUI 应用开发实用指南(全)

原文:zh.annas-archive.org/md5/0f23887348e58442e4ef6c801fd01f5a

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

自 2012 年 Go 编程语言的 1.0 版本发布以来,开发者们享受了 Go 语言的易读性、易学性和跨平台设计的生产力提升。全球各地的 Web 应用程序和系统工具都使用 Go 来快速交付可靠的性能。学习这门语言很容易,因为其集中式的文档和优秀的编程环境支持。然而,使用 Go 创建 图形用户界面GUIs)仍然非常新颖,并且还没有一个标准化的 UI 工具包。

这本关于使用 Go 编程 GUI 的 definitive guide 探讨了最流行的 GUI 包。它比较了每个工具包背后的愿景,以帮助您为您的项目选择正确的方案。每个工具包都进行了详细描述,概述了如何构建用户喜爱的美观、高性能的应用程序。代码示例和截图将帮助任何级别的 Go 开发者使用这些新兴技术来创建应用程序。

本书面向的对象

这本书是为对在桌面电脑及其它平台上构建原生图形应用程序感兴趣的 Go 开发者所写。假设读者对构建 Go 应用程序(基于命令行或 Web 应用程序)有一定的了解,但这并非必需。本书的第一部分探讨了图形用户界面(GUI)的历史,它对现代个人电脑发展的意义,以及它如何给软件开发者带来额外的挑战。

对尝试使用 Go 的 GUI 应用程序开发者来说,这本书也可能很有用。第二部分,使用现有小部件的工具包和第三部分,现代图形工具包,探讨了 Go 语言可用的各种框架,并展示了如何从基本原理快速开发应用程序。

此外,经验丰富的 Go 开发者可能会发现本书的最后一部分很有用。第四部分的章节 扩展和分发您的应用程序 涵盖了如何设计和构建符合用户期望的复杂图形界面,探讨了支持多种操作系统标准的不同方法,以及如何将 GUI 与网络资源和云服务良好集成。最后几章涵盖了使用 Go 进行 GUI 应用程序的最佳实践,并探讨了如何通过各种分发渠道打包和分发您的软件。

为了充分利用本书

本书假设读者对 Go 语言有基本了解。如果您还不熟悉其基本概念,请在开始阅读之前考虑运行在线教程 (tour.golang.org)。

为了最大限度地受益于后面的章节,如果您正在考虑一个特定的应用程序您正在开发或希望构建,那就很理想了。将本书中使用的框架和工具应用于特定项目将有助于您理解各种概念。此外,这将使您完成项目并为其分发做好准备。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择 SUPPORT 选项卡。

  3. 点击“代码下载与勘误”。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-GUI-Application-Development-in-Go/。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

在本书中使用了多种文本约定。

CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“将下载的WebStorm-10*.dmg磁盘映像文件作为系统中的另一个磁盘挂载。”

代码块按以下方式设置:

html, body, #map {
 height: 100%; 
 margin: 0;
 padding: 0
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都按以下方式编写:

$ mkdir css
$ cd css

粗体: 表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要注意事项如下所示。

小技巧如下所示。

联系我们

我们欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书籍标题,并通过customercare@packtpub.com给我们发邮件。

勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何非法副本,无论形式如何,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com与我们联系,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com.

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

想了解更多关于 Packt 的信息,请访问 packt.com.

第一部分:图形用户界面开发

多年来,图形用户界面(GUIs)一直是普通计算机用户与软件产品互动的标准方式。它们在桌面环境的熟悉背景下,为可能复杂的流程提供了直观的用户体验。经过 40 多年的发展,传统的图形应用程序正受到基于网络的软件的普遍性和现代智能手机及手持电脑上移动应用程序的出现所挑战。尽管有这些新趋势,但仍有许多理由说明为什么为桌面(和笔记本电脑)计算机构建原生图形应用程序可能是您产品的正确策略。

在本介绍性部分,我们将探讨桌面 GUI 的历史以及它是如何与技术创新同步发展的。我们将讨论为什么,尽管有新的替代方法,原生图形应用程序仍然是提供直观用户体验和可靠软件产品的绝佳方式。构建任何高质量的产品都需要努力,软件也不例外。我们将探讨团队在设计和支持多个操作系统的原生图形应用程序时可能面临挑战。在回顾了 GUI 的重要性及其可能带来的挑战后,我们将考察 Go 编程语言,并证明其设计非常适合创建适用于多个平台的现代原生图形应用程序。

本节将涵盖以下章节:

  • 第一章,原生图形应用程序的优势

  • 第二章,图形用户界面挑战

  • 第三章,Go 来拯救!

第一章:本地图形应用程序的好处

自从它们在 20 世纪 70 年代首次出现以来,很明显图形界面使与软件应用程序的工作变得更加容易。在早期,它们通常通过窗口、图标、菜单和指针WIMP)界面呈现。尽管这些界面在设计上跨平台和时间有所变化,但交互相对一致。

软件开发中的近期变化增加了对用户体验的理解,这侧重于创建即使是经验最少的计算机用户也能直观使用的应用程序。这与推动计算机交互向后 WIMP(窗口、图标、菜单和指针)方法的移动相结合,引发了一个问题:桌面计算机软件的下一步是什么?

本章将涵盖以下主题:

  • 图形用户界面(GUIs)的历史,涵盖桌面、网页和移动

  • 优秀集成和响应式应用程序界面的重要性

  • 用户对在线和离线本地应用程序的期望

  • 开发者构建本地图形应用程序的好处

图形应用程序的回归

"预测未来的最佳方式就是创造它。"

- 帕洛阿尔托研究中心的艾伦·凯

那是 1973 年,帕洛阿尔托研究中心施乐 PARC)刚刚完成了 Alto 计算机,这是第一个商业化的计算机 GUI 示例。尽管屏幕方向和缺乏颜色使其在现代人的眼中显得有些奇特,但它显然是一个图形界面,具有鼠标和键盘进行交互。尽管它花了另外七年才对公众普遍可用,但在 1981 年,作为施乐 Star,很明显这是某件大事的开始:

Dynabook 环境桌面(1976 年;在 Alto 上运行的 Smalltalk-76)。版权 SUMIM.ST,许可 CC BY-SA 4.0。

这是对计算机可用性的一次巨大飞跃——从标准文本模式计算机屏幕交互中迎来的一次欢迎的变化。图形界面不仅允许更高级的功能,而且对于想要入门的新手来说,学习起来也容易得多。尽管命令行界面在程序员和其他专家中仍然很受欢迎,但可以公平地说,如果没有 GUI,个人电脑就不会达到我们今天所知的这种普及程度:

传统的文本模式(命令行)界面在 20 世纪 80 年代中期仍然很常见

个人电脑

在施乐 Star 公开发布后的 10 年里,出现了许多图形平台,包括微软 Windows、苹果 Macintosh、X11(最初为 UNIX 计算机在麻省理工学院启动)和 DRI 的 GEM(主要用于 Atari ST)。尽管每个平台的背景都不同,但它们都拥有一个共同的目标,即为用户提供一个桌面环境,使用户能够同时与多个图形应用程序进行交互。

这是新兴的个人电脑PC)市场的一个定义性特征,并引领了一个全新的计算机软件世界:

图片

微软工作组 Windows 3.11。在此特别感谢微软的授权使用。

随着个人电脑(PC)变得更加强大,硬件的进步支持了更复杂的软件应用。更高分辨率的屏幕允许显示更多信息,可移动存储设备(如软盘、CD 和后来的 USB 闪存盘)使得在应用程序之间传输更大的数据集成为可能。曾经常见的简单界面和少量选项变得更为复杂和复杂。

默认的图形界面元素和布局需要扩展以保持同步。菜单变得更大,工具栏被引入以突出显示常见任务,内置的帮助系统变得必要,以帮助用户完成他们的任务。我们还看到平台开始展现自己的个性,这导致学习新软件时遇到额外的障碍。一个普通的现成软件产品通常附带的说明书比这本书还要长,解释如何与其各种功能交互。

从桌面到互联网

在 20 世纪 90 年代中期,万维网(将成为我们的全球通信平台)刚刚起步,个人电脑市场开始出现各种网络浏览器。这些浏览器最初以软件包的形式(在软盘上)分发,后来作为桌面环境的一部分(预装在新电脑上)。Mosaic、Netscape Navigator 和 Internet Explorer 相继出现,为早期采用者提供了进入新兴信息渠道的途径。在当时,主要是学术文本和参考资料;你需要知道在哪里寻找东西,并且与早期计算机使用类似,它并不特别直观。

然而,变得清晰的是,这种新媒体开始促进通信和信息交流的未来。人们开始意识到,成为该领域的主导技术将是至关重要的;于是,浏览器大战开始了。随着网络浏览器争夺首位,这项技术被嵌入到桌面平台上,作为一种快速提供精美内容的方式。最初,那些笨重的用户手册被转移到 HTML(网页语言)中,并捆绑在软件下载中,然后每个应用程序的功能越来越多地转移到线上。随着互联网连接在大多数家庭中变得司空见惯,我们看到了基于全网页的应用程序的兴起。

一个网络应用程序是指不需要在您的计算机上安装除互联网浏览器以外的任何软件的应用程序。它们总是直接从源头提供最新信息。这通常根据您的位置、偏好,甚至是在网络应用程序或合作伙伴公司的浏览历史中进行定制。此外,提供网络应用程序的公司可以随时对其进行改进;通常,在公司在实验中看到哪个版本的应用程序具有更好的用户体验后,会进行实验。以下插图展示了一个通过网络交付的应用程序的可能架构。

图片

简单的网络应用程序架构

随着基于网络的程序背后的技术的发展,它们成为了桌面软件的有力替代品。软件公司开始意识到,通过网站直接交付产品比传统的下载模式要容易得多。不仅如此,这也意味着一个产品几乎可以在任何计算机上运行。过去尝试创建一次编写、在任何地方运行的平台(如 Python 和 Java)在当时取得了巨大成功,但后来随着网络技术的复杂性达到一定水平,很明显,跨平台解释器所需的性能惩罚和分发开销使得网络应用程序在可能的情况下更具吸引力。

智能手机、应用程序和客户保留

很长一段时间,网站似乎是为交付软件产品而设的未来,直到智能手机的出现。一旦移动电话技术发展到可以在手掌中访问网站的程度,基于网络的程序的需求再次发生了变化。现在,开发者需要考虑如何在小屏幕上呈现有意义的内客。基于触摸屏的用户界面如何在以前假设鼠标和键盘的地方操作?当人们只有五分钟等待咖啡订单时,他们如何以有意义的方式进行互动?

在桌面浏览器和手机上提供单一应用程序,跨越众多不同的操作系统和设备,对开发者来说具有明显的优势,但也存在挑战。互联网是一个非常大的地方,你的产品很容易在噪音中迷失;你如何吸引新用户,以及如何确保现有客户持续回来?对此的一个主要回应是,为移动设备引入了原生应用(为特定平台设计和构建的应用程序)。iPhone 最初只提供基于 Web 的应用程序,但八个月内,苹果为开发者提供了构建原生应用程序的能力。这些应用程序为用户提供了一种更有意义的互动;它们是为运行的设备设计的,可以通过市场或应用商店轻松找到,一旦安装,就会在设备的主屏幕上持续提醒。

因此,我们进入了一个时代,我们的目标受众已经习惯了为他们的设备专门设计的软件。如果公司希望吸引并留住客户,那么一个精致的用户体验是必不可少的。等待页面加载或处理间歇性错误已经成为用户不再愿意忍受的小麻烦。对于软件交付的更高标准现在是一个被广泛理解的现象,但通过移动设备交付的软件质量提升尚未在桌面端得到体现。直到最近,浏览器仍然是王者;长列表的网站书签被用来替代期望通过商店交付并安装到计算机上的应用程序。然而,这种情况正在改变,我们将探讨如何通过精美的桌面应用程序提供优质用户体验。

原生性能

"用户真的很重视速度。"

- 玛丽莎·梅耶,谷歌副总裁

企业经常选择基于网站的方法的主要原因之一是避免为他们希望支持的多个平台构建许多产品。我们在移动应用程序开发中也看到了类似的方法:随着更多平台进入市场,开发原生应用程序成为许多企业负担不起的开支。他们选择基于 Web 的方法或混合应用,用户认为他们安装的是一个原生应用,实际上只是一个打包成下载的网站。虽然这可以满足简单应用程序的基本数据处理,但通常无法满足用户期望。此外,Web 浏览器的交互模式通常与周围的系统应用程序不同。如果用户期望应用程序以某种方式运行,那么嵌入式 Web 浏览器可能会提供一种令人困惑的体验。

通过网络技术(通过浏览器或下载的应用程序)交付大型应用的最大挑战是实现良好的性能。由于浏览器主要是为信息交换而设计的,它并不适合大量数据处理或复杂的图形表示。当通过网络浏览器交付时,其中许多可以由具有运行复杂计算能力并将摘要返回给用户的远程服务器执行。不幸的是,当你运行本地应用时,这不能被依赖,并且用户期望在他们的应用程序中立即获得结果(记住,这不仅仅是一个浏览器窗口,在等待时可以打开很多标签页进行浏览)。此外,回想一下基于网络交付的一个好处——不断更新软件而无需分发问题的机会?虽然这对开发来说可能很好,但你的客户可能不希望界面不断变化;他们希望控制何时(以及是否)更新他们的系统。

在那些需要大量计算或复杂图形显示的应用中,大多数网络应用很难达到用户期望的运行速度。本地应用,由于是为它们所运行的计算机编译的(并且已经预先下载,因此无需等待),目前是获得高性能的最佳方式。存在各种虚拟化技术,旨在通过单个应用程序提供接近本地性能(例如,Java),但这并不总是合适或足够的,并且常常出现副作用,如漫长的启动时间或巨大的下载量。既然你选择了阅读这本书,你可能已经意识到另一种方法:一种允许你编写单个应用程序,但又能将其编译成支持任何平台的高性能本地应用程序的语言。

集成用户体验

一致的用户体验对于用户能够快速上手软件至关重要。当软件被编程以匹配系统设计和布局,以及使用标准组件时,新用户更容易理解应用程序可能的工作方式,而无需查阅那些沉重的用户手册。大多数流行操作系统的图形用户界面都经过精心设计,使得为它们编写的应用程序会感觉自然。用户应该本能地识别设计语言,并立即知道如何完成大多数主要任务。精心设计的平台,如 macOS 或 Windows 10,提供了一套工具,确保使用它构建的应用程序对用户来说会立即熟悉。这包括诸如如何选择文件打开、复制粘贴复杂文件类型时应该发生什么,以及如果项目被拖放到其窗口中,应用程序应该如何响应等外围项目。这些功能中非常少的是提供给或正确利用基于 Web 或命令行应用程序的。

对于专业应用程序制作者来说,还需要考虑辅助技术。使用平台标准工具包构建的 GUI 与提供的(或补充的)辅助技术增强器(如屏幕阅读器或盲文设备)一起工作。网页和基于文本的应用程序通常需要更加努力才能支持这些技术。记住,您的网页或混合应用程序将在其上加载的每个平台可能都有非常不同的标准辅助技术行为。使用目标平台工具构建图形应用程序通常会使您的用户受益,无论他们是否直接使用您设计的界面,还是通过辅助选项使用。

可靠性和离线功能

优秀应用程序的一个好处是它们能够在在线和离线状态下工作,甚至可以处理不可靠的互联网连接。例如,允许撰写但不需要互联网直到你发布的博客应用程序,或者文档编辑器在您在线时下载您的工作并共享您所做的任何更改,与始终在线的任何 Web 应用程序相比,具有显著的优势。台式计算机甚至最新的智能手机都有显著的计算能力和存储能力,作为应用程序开发者,我们应该充分利用可用的资源。用户体验不仅限于设计和系统集成,还包括应用程序的响应性和工作流程。如果我们能够隐藏过程或技术的复杂性,让最终用户无法察觉,我们可能会发现他们经常回到该应用程序——即使他们的互联网连接目前不可用。

虽然缓存(为离线工作保留下载内容)是一个相对容易解决的问题,但同步(结合来自各个位置的所有更改)则不是。幸运的是,原生应用程序有工具可以帮助完成这项复杂的任务,无论是通过平台工具包(如苹果的 iCloud 的 CloudKit)还是通过使用第三方技术(如 Dropbox 的 API 或 Firebase 为 iOS 和 Android 提供的离线功能)。由于移动应用程序的流行度急剧上升,大多数开发都集中在那里,但这些技术同样适用于桌面上的原生应用程序。

网络技术持续在提供更高的可靠性和离线功能方面取得进展,但它们距离满足对原生图形应用程序的期望标准还有很长的路要走。

可维护性和测试

"机遇只青睐有准备的大脑"

- 路易·巴斯德

为了支持软件开发的速度、技术的演变以及用户对更多功能的期望,我们的软件必须具有良好的组织结构和高度的可维护性。你的团队中的任何一个人,或者你自己在未来的某个时刻,都应该能够轻松理解代码的工作方式,并快速进行所需的变化或添加。支持这种未来的发展需要一个组织良好的项目和投入时间来维护标准。

原生应用程序通常使用为它们构建的平台的单一种语言编写。这种限制意味着整个应用程序可以遵循标准的布局、命名和语义约定,这使得对软件的任何部分进行工作都更容易。模块化和代码重用更容易实现,因此项目内部不太可能出现重复或不完整更改的问题。现在已被广泛使用的测试驱动开发方法,不需要代码库中的单一语言就能很好地工作,但实现它的工具根据语言的不同而有所变化,每个项目只支持一个设置是有益的。

其他形式的图形应用程序(主要是基于网络的)使用多种语言的原因也是它们更难测试的原因:它们的界面是通过网络浏览器(或嵌入式 HTML 渲染器)呈现的,这在不同平台之间可能有很大的差异。无论硬件的年龄或使用的设备类型如何,人们都会期望你的应用程序快速加载并看起来正确。这意味着需要处理很多变化,并且每个变化都需要进行大量测试。与此相比,原生图形应用程序的目标设备是已知的,并且由用于开发的工具包完全支持。测试更容易、更快,因此可以快速且自信地进行更改。原生图形应用程序确实是制作美丽、响应式应用程序的最佳方式,这些应用程序将激发目标受众的喜悦。

摘要

在 20 世纪 70 年代初,随着第一个图形用户界面的出现,计算机变得更加易于访问,从那时起,开发者和设计师一直在寻找改进用户体验的方法。随着技术的进步,焦点从桌面应用程序转移到了基于 Web 的软件和移动应用。在每一次的发展变化中,我们都看到了使应用程序响应、可靠和吸引人的需求。在本章中,我们探讨了图形用户界面的历史以及原生应用程序如何继续提供最佳的用户体验。

通过使用原生技术创建高质量的图形应用程序,开发者能够提供更好的可靠性和更响应式的用户界面。确保应用程序能够无缝集成到操作系统中,同时在线和离线都能良好运行,将提供一致的流程,让您的用户保持满意。我们还看到,原生应用程序的结构和格式可以造福软件开发者和支持流程,确保产品质量更高。

在下一章中,我们将发现这些好处如何在图形应用程序中创造出来,以及它们可能带来的挑战。我们将比较各种处理这些复杂性的方法,并概述在设计现代原生图形用户界面时需要做出的某些决策。

第二章:图形用户界面挑战

在上一章中,我们探讨了图形用户界面的历史,了解了它们是如何演变的以及为什么它们可以比当代替代品提供更好的用户体验。不幸的是,尽管图形应用程序对最终用户有很多好处,但它们会给设计和构建它们的团队带来许多挑战。在本章中,我们将探讨团队在创建平均复杂度的图形应用程序的各个阶段可能面临的问题。

本章将涵盖以下主题:

  • 选择与操作系统或产品品牌相匹配的外观和感觉

  • 应用程序布局的不同方法和多个窗口

  • 并发和云服务集成的挑战

  • 在为多个平台开发图形应用程序时引入的 overheads

标准外观和感觉或应用程序主题

在设计你的图形应用程序时,一个早期的问题可能是关于视觉身份;应用程序是否应该符合操作系统的外观和感觉,或者它应该有自己的品牌?你希望为用户界面工作一个完整的主题,用户会认同它,还是希望利用用户操作系统的精心制作和普遍理解的界面元素?

就像我们在本章中会遇到的大多数问题一样,没有正确或错误的答案,你选择的任何路径都会有正面和负面的影响。完全使用标准组件可能会使开发更快,并且对用户来说更容易理解,但你如何使你的应用程序与其他应用程序区分开来?如果你从头开始设计完整的应用程序界面,那么你将为软件开发一个良好的品牌识别度,用户会认出它,但这可能需要他们更长的时间来学习,并且可能在目标平台上看起来不合适。

不同的设计方法通常适合不同类型的应用程序。游戏明显依赖于高度定制的图形界面,很少使用标准组件,但他们的用户,游戏社区,理解该类别的标准交互,因此不需要使用操作系统默认元素提供的常见视觉提示。实用应用程序(那些你在当前工作中快速完成任务时加载的应用程序)将受益于融入其中,这样操作它们时几乎不需要思考,并且不需要将任何身份与体验相关联:

图片

微软 Excel 使用系统组件和品牌身份的混合。经微软许可使用。

假设你已经决定你的视觉设计是适合标准化外观还是需要更定制化的方法,你需要考虑你将部署的平台。这是一个为单一操作系统设计的应用,还是为多个操作系统设计的?如果你的软件只能在 Windows 上运行,那么使用标准的外观和感觉显然是可行的,但如果你在寻找跨平台分发呢?macOS 看起来与 Windows 截然不同,而 Windows 又与平均的 Linux 桌面不同。你将针对哪个?或者,你选择在所有平台上使用相同的界面设计?

跨平台 GUI 设计的复杂性不是一个新问题,但在设计应用体验时,它确实需要一些思考。如果你有一个适用于你的品牌或应用的标准化界面设计,它是否能在不同的操作系统上一视同仁呢?或者,如果你旨在在每个平台上使用标准组件,你将如何确保一致的用户体验,以及你将投入多少时间到支持材料或客服台?

当 Java Swing 是跨平台图形应用的标准时,它们的做法是独特的:允许开发者针对标准 API 编写代码以构建 GUI,但提供使其在不同平台上有不同呈现模式的能力,以保持跨平台的一致性,或者与它运行的系统融为一体。这意味着同一个应用可以被配置为在所有操作系统上看起来都一样,或者适应当前的桌面环境。不幸的是,这种方法有其局限性,因为它最终提供的是一组最低的共同功能集。除非它是所有支持操作系统的特性,否则一个区域的高级集成在跨平台应用中是不可用的。

此外,用户界面设计在操作系统外观发生剧烈变化的情况下可能会显得过时(例如,微软 Windows 从 Vista 到 7 再到 10 的转变,每个版本都有相当独特的样子):

Java Swing 演示 - 顶部为跨平台外观(金属),底部为 macOS 系统外观

随着时间的推移,构建图形应用的方法越来越多,大多数编程语言都有很多选项。有些是为使用系统样式而设计的,有些则偏好自己的图形样式,而有些则留给开发者或用户偏好。因此,你必须做出选择:你希望你的应用融入标准操作系统的外观,还是你希望实现一个在每个系统上看起来都一样的品牌身份或设计?我们将在这本书的第二部分和第三部分中探讨这两种选项,即使用现有小部件的工具包现代图形工具包

图形用户界面和视觉层次结构

软件的图形语言和常见的视觉布局在消费软件产品的近期历史中已经发生了很大的变化,并且仍在不断发展。每个操作系统和图形工具包都专注于可用性,同时试图拥有独特的风格。这些原则推动了每个平台以略微不同的方向前进,这影响了我们所编写的软件和我们所呈现的内容。

多个文档

让我们先看看应用程序可以如何处理多个并发文档。这些界面都旨在同时处理多个文档的方式。无论是文字处理器、图像编辑器还是网页浏览器,都有许多方法可以做到这一点。操作系统通常有一个默认行为,鼓励应用程序开发者使用(有时通过推广最新的变化来提高可用性,有时通过在其各自的工具包中添加或删除 API)。这些界面偏好可能会随时间变化,但也可以围绕某些应用程序类别标准化。例如,在它们历史的早期,微软推广了 Windows 多文档界面(MDI)布局,这种布局在文字编辑器和 集成开发环境(IDE)中仍然很受欢迎:

使用 Microsoft Windows MDI 布局的示例应用程序

鼓励开发原生 macOS 应用程序的开发者为每个文档使用一个新窗口,但将它们分组在同一个应用程序下,这样用户只看到一个将它们分组的图标:

macOS 中多个文档作为单个应用程序的窗口加载

Chrome 网络浏览器决定将它们的标签显示集成到窗口标题栏中,这个空间通常只显示应用程序或加载文档的标题:

这张 Chrome 截图显示了加载网页标签显示的独特外观

在所有这些可能的方法中,哪一种适合您的应用程序?如果您必须处理多个文档,查看管理类似文件类型的应用程序或比较同一环境中各种应用程序如何处理窗口管理是值得的。

附加窗口

工具箱的位置和与常见功能区域相关的功能的分组也存在显著的差异。多年来,出现了许多迭代,如抽屉(从窗口滑出)和弹出对话框窗口(在上下文很重要但工具使用较少的地方仍然使用),但始终可见的工具栏或附加窗口仍然是最受欢迎的。

例如,为 Linux 和 Unix 桌面设计的应用程序通常使用单独的窗口为每个支持的工具面板呈现:

在 Gimp 这个流行的开源图像处理程序中,通常使用多个窗口

与通常在文档窗口的边缘定位控件的综合布局的 Windows 软件相比:

图片

然而,在 Microsoft Paint 中,工具被分组在文档的顶部。本图使用已获得 Microsoft 的许可。

这两种方法在单一平台上都提供了一致的用户体验,但对于针对多个操作系统的图形应用来说,考虑哪种方法最适合是很重要的。你的软件特别适合哪种方法?也许如果它的图形设计适应了运行平台的规范,人们使用你的应用会更容易。

视觉层次结构

基于网络的应用的演变遵循了不同的路径。从历史上看,这种媒介一直被用来展示大量的文本信息和学术文档。这通常以超链接内容的形式呈现,并且通常包括一个在导航区域中的流行链接列表,这有助于人们找到重要内容。虽然每个网站都有非常个性化的外观(颜色和排版趋势一路走来都有所变化),但这种内容分组在互联网上大体上一致。这相对于当时的桌面软件来说是一个巨大的转变,但一旦用户学会了与一个网站交互的方式,他们就可以相对容易地找到大多数网站的方法。

在通过网站提供的应用方面,这有一个很大的好处:标准化的布局或视觉层次结构意味着新的、独特的设计仍然可以被大多数互联网用户使用。此外,它们在任何操作系统或网络浏览器上看起来都一样。这种对用户的一致性使得设计师更容易将丰富的视觉或品牌应用到网络应用中,而不会降低用户体验。随着不断发展的层叠样式表(CSS)开放标准的普及,共享这些设计子集以及将布局细节与视觉样式和品牌分离变得更加容易。因此,出现了用于构建网站和应用的通用代码,类似于开发者熟悉的桌面工具包。但结合任何互联网连接计算机的一致性,这种标准方法开始使桌面应用的学习显得更加复杂:

图片图片

标准网页布局——在顶部,导航是一个侧边栏,

在下一张图片中,它是一个较短的行内区域

移动标准

移动应用程序提出了不同的设计挑战:如何在主要输入设备是触摸屏且手指可能会遮挡内容的情况下,在远小的屏幕上提供出色的用户体验。主要移动操作系统背后的公司(苹果、谷歌和微软)花费了许多年开发了一种视觉语言和标准交互,这些语言和交互为用户在日益复杂的应用程序中提供了流畅的流程。正如之前在 Web 应用程序中一样,对于用户快速学习和适应这些新平台来说,原生移动应用程序保持一致的行为方式非常重要。iOS、Android 和 Windows Phone 提供了标准 API,开发者可以使用这些 API 创建符合平台标准的应用程序。在每一个平台上,都有足够的定制选项来支持品牌识别,例如使用颜色、图标或应用中每个屏幕的内容。尽管移动平台的具体设计美学在过去几年中发生了变化,但精心设计的布局和工作流程方面始终保持一致。用户可以轻松地拿起最新的 iPhone,欣赏其新的设计,并且仍然完全熟悉应用程序的工作方式:

标准的 iOS 布局:顶部导航和底部操作

4 年前的类似 iOS 布局

设计应用程序 GUI 远不止设计工作流程和选择配色方案那么简单。你的应用程序是否会从现代应用程序 UX 中汲取灵感,还是针对那些更熟悉多年以来建立的桌面应用程序经典外观的用户?你将坚持使用单一平台及其标准的外观和感觉,还是对在多个操作系统上发布你的软件感兴趣?在我们查看不同的工具包之前,花些时间考虑这些选项,并确定哪种策略可能最适合你的应用程序。

并发和多线程

GUI 必须始终响应用户输入。虽然这主要是美学考虑,但操作系统也可能监控应用程序并强制无响应的用户界面退出。有效的事件处理使得这一点成为可能,这也是 GUI 的核心范式。事件处理器负责响应用户事件(如鼠标点击、手指轻触和键盘输入)、系统事件(如文件更改、网络可用性和应用程序状态),以及更新用户界面(如渲染内容、更改界面状态等)。任何阻止这项工作进行的因素都可能导致应用程序停止响应。在大多数图形工具包中,有一个负责事件处理和图形更新的单个线程(一个管理一组并发操作的任务)。在某些系统中,这是主线程(应用程序从中启动的地方),而在其他系统中,它是一个单独的线程或进程。了解您所使用的系统的语义很重要,因为通常只有图形或事件处理线程可以更改用户界面。

线程切换

不同的工具包和语言处理多线程和图形更新的方式差异很大。以下插图旨在强调此问题的复杂性,以防您不熟悉这些约束。具体细节有时会根据语言或版本而有所不同,但概念通常是统一的,否则使用 API 开发的软件将很难管理。

对于我们的第一个示例,考虑一个用 Java 编写的应用程序。它的惯例是图形和用户交互由单个事件调度线程处理。因此,您希望对用户界面进行的任何更改都需要使用SwingUtilities.invokeLater()推送到此线程:

SwingUtilities.invokeLater(new Runnable() {
    public void run() {
        button.SetText("Updated!");
    }
});

与苹果操作系统的交互方法略有不同。使用 AppKit 或 UIKit(分别用于桌面和移动应用程序)构建的应用程序在主线程上启动用户界面事件处理。这意味着在界面配置完成后,所有处理都必须在后台线程上处理,并且对用户界面的更改必须在主线程上执行。Objective-C 的块结构(用于封装单个行为)使这变得稍微容易一些,但代码仍然相当复杂:

dispatch_async(dispatch_get_main_queue(), ^{
    [button setTitle:@"Updated!" forState:UIControlStateNormal];
});

使用 GTK(支持为各种不同平台构建应用程序)的应用程序也有类似的限制。对于这些应用程序,图形更新必须在调用gtk_init()gtk_main()的线程上处理。对于这类应用程序,GLib提供的线程处理可以帮助管理应用程序中的多线程,但您必须在界面初始化代码中设置此配置:

...
gtk_init(&argc, &argv);
...
gdk_threads_enter();
gtk_main();
gdk_threads_leave();
...

然后,您可以使用 gdk 线程助手来管理后台更新,如下所示:

gdk_threads_enter();
gtk_button_set_label(GTK_BUTTON(label), "Updated!");
gdk_threads_leave();

避免复杂性

图形工具包在尽可能避免并发复杂性方面做了很多工作。例如,按钮的点击处理程序通常在控制图形更新的线程上运行;这意味着简单的用户反馈或显示用户操作的结果可以完成,而无需担心多线程复杂性。一个 Android 应用程序的简单回调函数可能如下所示:

button.setOnClickListener(new View.OnClickListener() {
    public void onClick(View v) {
        button.SetText("Updated!");
    }
});

然而,即使是简单的应用程序也不太可能长期避免这些复杂性。考虑一个简单的 RSS 新闻源应用程序;它所做的只是设置一个 GUI,从指定的 URL 加载新闻源的内容,并在用户界面中以列表的形式显示结果。为了保持响应性,图形界面必须在应用程序加载时呈现,在请求新闻源内容之前。随着内容的下载,它可以被解析并显示项目。然而,因为这是一个后台进程,它不允许简单地更改界面,例如添加列表项。相反,它必须识别要添加的项目,并将此信息传递回主线程(或事件调度线程)以向用户显示更新。这样的代码可能难以阅读,并且通常会引发调试复杂性,因为并发软件可能不会始终以相同的方式表现。在下一章中,我们将探讨 Go 处理并发的设计如何简化这一过程。

网络服务和云集成

网络服务和在线功能是当今大多数应用程序的核心部分。无论您是在处理从中央源下载的数据,在线协作存储的文档,还是仅仅想要分享您的创作,这很可能会通过互联网来完成。大多数图形工具包和 API 的核心都集中在小部件上——即用户界面的展示。尽管这有多种不同的原因(并且随着时间的推移而演变和扩展),但它主要反映了它们被创建的时期。例如,C 和 C++等编程语言是许多本地图形工具包的基础(尤其是那些针对多个平台的工具包),并且它们早于我们今天所知道的云服务和基于 Web 的 API。强大的网络服务和标准化的通信协议大大提高了基于 Web 应用程序的开发速度。相反,它们可能会使桌面上的本地图形应用程序更难,因为这些应用程序的核心语言或标准库缺乏支持。

通信

假设你选择的语言要么对连接到 HTTP 网络服务有良好的支持,要么已经确定了一个合适的库,那么从你的所需服务进行数据传输不会成为问题。然而,如果连接失败会怎样呢?虽然原生 GUI 应用程序通常位于桌面或笔记本电脑上,那里永久性网络连接很常见,但依赖这一点并不明智。随着远程工作的增加、咖啡店会议以及由 Wi-Fi 和蜂窝网络带来的更高流动性,任何现代应用程序都需要处理意外的网络条件。

在开发基于 Web 的应用程序时,可能不需要像用户可能已经在线那样勤奋。也可能互联网连接的失败对软件来说可能是一个致命的情况,所以在某些情况下,向用户显示的错误信息要求他们稍后再试可能是可以接受的。然而,用户对原生图形应用程序的期望要远高于此。智能手机及其所带的软件预计能够优雅地处理由网络条件或可用性的频繁变化引起的此类故障条件。那么,在这种情况下,我们能够做些什么来满足这种更高的期望呢?这可能需要一些规划;稍后再试的错误信息必须是最后的手段。

你的应用程序中有多少部分实际上需要一直(或在工作流程的特定点)保持互联网连接?(或者,是否有部分元素可以偶尔访问并本地存储(缓存)?)以及,是否可以接受出站通信在稍后而不是用户操作立即发生?在考虑网络连接及其真正需要的时候,富有创意是有帮助的。不久前,一个聊天客户端(如 IRC、ICQ、MSN 等)需要一直在线,如果连接停止响应,你就必须等待它重新连接。最近,期望已经发生了变化,新的聊天服务(如 Slack 和 Skype)允许你在离线时也能输入聊天或频道,消息会尽快送达。

在基于 Web 的互联世界中,一个额外的挑战是身份验证。基于旧密码和应用密钥的身份验证在大多数编程语言中工作得很好,但它们存在安全问题。最近采用的标准是 OAuth2,旨在确保用户知道当应用程序连接到受保护的服务时他们允许了什么。工作流程设计得很好,可以在 Web 浏览器中工作,但从一个原生应用程序内部,当请求权限时切换到 Web 浏览器是否是一个合理的用户体验?你通过在应用程序中嵌入 Web 视图来稍微改善流程吗?不幸的是,已经有人指出这也可能受到潜在的安全攻击,现在有一个文档专注于 OAuth2 集成到原生应用程序的最佳实践(IETF RFC 8252)。随着时间的推移,适应应用程序以实施这些建议将成为一项要求。

数据解析

建立与远程服务器(或从本地缓存加载数据)的连接后,接下来的挑战很可能是解析响应。复杂的字符串处理(由于它们的人读性设计,大多数基于 HTTP 的 API 都需要这种处理)并不是许多用于原生应用程序开发的较老编程语言的强项。已经开发了许多库来帮助完成这项任务,并且通常可以免费共享(使用开源软件许可),但如果编程语言没有很好的字符串处理能力,这仍然可能是一个非平凡的挑战。

可扩展标记语言XML)和JavaScript 对象表示法JSON)是互联网上传输数据的主要格式,它们在所有针对 Web 开发的语言中都得到了很好的支持(在 JavaScript 应用程序中 JSON 的使用是微不足道的,这应该不会让人感到惊讶)。正如其名称所暗示的,它们被设计用来传输面向对象或结构化数据,内容中应该包含足够的元数据,以便在客户端软件中无需复杂的反序列化代码即可重新创建对象。

标准组件

云服务与 Web 开发集成的一个巨大进步是添加了异步 JavaScript 和 XMLAJAX)功能。由于 JavaScript、XML 和 HTML 在现代浏览器中的普遍支持,Web 开发者可以配置用户界面的部分,使其在 Web 服务请求的结果下刷新。这种更新不需要数据解析或合并信息;服务器上的数据实际上可以替换用户界面(通常是 HTML 或 JavaScript 格式)的旧内容。

不幸的是,大多数本地应用程序工具包没有内置用于显示 Web 服务功能调用结果的组件。然而,随着时间的推移,流行的服务通常会发布帮助这些功能的库。如果提供 Web 服务的公司没有创建支持库或组件,通常情况下,可能已经有人独立创建并在线共享。支持外部模块或提供合适的包管理器的语言,通常能从这些类型的贡献中获得最大的好处。

为多个平台开发

除了本章前面描述的挑战之外,图形应用程序在针对多平台发行版时还会面临额外的复杂性。依赖性和包管理不在此节范围内,因为它们以相同的方式影响系统和网络应用程序,尽管系统应用程序很少需要处理打包资源(如图像和设计元素),而网络应用程序不太可能发布二进制包。本节概述了仅针对或对基于 GUI 的应用程序计划跨平台策略更具挑战性的主要挑战。虽然每个挑战都可以克服,但它们通常会在设计应用程序时引入额外的开发开销,这些开销应该被考虑在内。

跨平台 API

当为多个平台设计时,首先考虑的可能是外观和感觉(参见本章开头的标准外观和感觉或应用主题)。然而,考虑你的界面设计是否易于用户理解(是否应该与桌面小部件相匹配)也非常重要。对于高质量的图形应用程序,考虑它与用户环境的交互方式也很重要。例如,如果你的工作流程包括在当前界面之外打开网页,预期可能是在默认的网页浏览器中打开,这可能是操作系统配置的。你选择的工具包是否可以处理打开网页或其他由 URL 指定的文档类型?你是否需要为每个你希望支持的平台编写一些代码,以确保正确的行为发生?

为了从移动应用程序的最新发展中学习,我们应该看看“分享”功能。在 Android 上,以及最近在 iOS 上,一个应用程序可以启动分享操作,平台工具包将显示适当的视觉选择,以分享该类型的内容。然后用户将做出选择,注册处理该类型分享的应用程序将接收内容并请求任何进一步所需的信息。跨平台应用程序的原生开发者如何提供类似的功能?如果这对你的应用程序很重要,那么你可以寻找旨在提供此功能的语言或工具包,但你可能需要直接在你的代码中尝试实现它,或者与外部基于 Web 的服务合作以提供类似的经验。

图标和设计

大多数图形环境(如桌面、移动等)都有一个默认的图标集——那些用于显示文件类型、导航箭头和标准工具栏以帮助用户识别常见操作的图标。如果你的应用程序提供的不仅仅是简单的功能,那么可能需要在设计中添加一些图形元素——很可能是图标或符号来帮助用户。你的附加图标将与应用程序运行的环境提供的图标相匹配吗?如果你只支持一个平台,这可能不是问题,但当你追求跨平台解决方案时,这可能要困难得多:

图片

macOS 工具栏图标的默认样式

图片

Windows 使用的样式与 macOS 非常不同

在设计应用程序 GUI 时考虑这个挑战:你需要额外的图标或图形元素吗?这样做可能与系统样式不同,但它可能与用户的期望相符。

测试

确保应用程序质量良好的唯一方法是在你打算支持的每个平台上对其进行测试。这同样适用于系统应用程序(例如,可能旨在在 Linux 和 Mac 上运行)和 Web 应用程序(每个 Web 浏览器的行为可能略有不同)。然而,图形桌面环境中的差异可能很大,可能导致用户界面的许多不同版本。此外,设置这些平台中的每一个可能需要更多的计算机,或者一些复杂的多重启动设置。虚拟化在这里提供了一个很好的解决方案:在可能的情况下,你可以创建虚拟环境来模拟你需要测试的每个操作系统安装。

如果你包含 Linux 或类似的开源操作系统,请记住,用户可以自由选择不同的桌面环境,每个环境都有不同的外观和默认行为。例如,如果你支持 Ubuntu Linux,你可能需要测试默认环境(Unity),但也需要非常常见的 Gnome 替代方案。Linux 有许多不同的桌面环境可以考虑,包括 KDE 和 Xfce,它们也非常受欢迎,每个都有不同的外观和感觉,通常还有不同的工作流程需要考虑。

即使你打算只关注 Windows 和 macOS 的支持,你仍然需要考虑你将支持哪些版本。外观和感觉,甚至默认交互,都可能从一个大版本到另一个大版本发生变化,那么你将致力于适应这些细微差别,还是只为这些系统的最新版本提供出色的体验?请务必记录你将支持的操作系统和版本(如果你打算针对 Linux,甚至包括桌面配置),并在可能的情况下为每个这些设置一个测试环境。这将在长期内有所帮助!

打包和分发

为多个平台打包本机图形应用程序可能会引入额外的挑战。本机 GUI 通常需要适应当前平台,并且它需要包含包元数据,以便在用户的桌面上按预期集成。大多数图形应用程序还需要将许多资产嵌入到发布包中。此外,安装应用程序的语义在不同的操作系统中各不相同。例如,macOS 预期应用程序被打包成一个 ,可以从 下载 文件夹拖动(或移动)到 应用程序 文件夹。Windows 用户将期望一个可执行文件,下载后运行一次,或者一个可以设置所需组件的安装程序。你打算分发的平台可能会影响你的应用程序功能或可以打包的资源,我们希望使用单个代码库来简化维护。

近年来,我们看到了许多平台创建了 应用商店 或类似的应用,用户可以浏览适用于其计算机的应用程序。这提供了一些免费的市场营销和新的下载渠道,但给开发者增加了额外的负担。你的应用程序的截图和其他元数据至少是必需的,为了脱颖而出,你可能甚至需要创建一个展示软件操作的演示视频。这样的商店对用户来说安装变得非常简单,但通常会对开发者施加额外的限制。如果你打算为你的应用程序使用这些分发方法,请务必进行研究。

摘要

在本章中,我们讨论了原生图形应用程序开发者可能面临的各种附加复杂性,尤其是如果他们打算为多个操作系统构建应用程序的话。解决图形展示方面的挑战(视觉层次、系统外观或应用程序设计以及自定义图形元素)需要一些规划和调查——不仅是为了设计理想的应用程序,还要选择你将与之工作的约束或开销。

剩余的技术挑战——并发、Web 集成、打包和分发——将根据实现语言的不同而有所差异。如前所述,许多图形工具包是使用最初并未提供对这些考虑因素支持的编程语言创建的。一些提供了低级支持,开发者必须在此基础上构建以使他们的应用程序达到现代基于 GUI 的应用程序所期望的功能水平。幸运的是,Go 语言为许多这些挑战提供了优雅的解决方案。尽管该语言并非专为内置 GUI 的标准库而设计,但下一章我们将探讨为什么 Go 语言非常适合这类应用程序。

第三章:Go 来拯救!

在阅读了关于构建原生图形应用程序挑战的上一章后,你可能想知道这一切是否值得。希望你对用户将欣赏你设计的优质用户体验充满信心,并且你的团队将热衷于看到采用这种方法而不是网页应用程序或其他方法的益处。幸运的是,设计 Go 编程语言的 Google 团队理解了这些挑战,并决定应该做些什么来帮助开发者们在他们的追求中取得进展!

在本章中,我们将详细探讨 Go 语言,并了解其设计如何解决(或帮助解决)在 第二章 “图形用户界面挑战” 中讨论的各种挑战。特别是,我们将探讨以下主题:

  • 适用于任何应用程序的跨平台方法

  • 并发模型如何帮助创建可靠的应用程序

  • 内置支持与网络服务协同工作

  • 选择你的 GUI 的外观和感觉以及管理 GUI 代码

到本章结束时,你将熟悉 Go 语言如何支持 GUI 应用程序设计,并准备好开始使用为 Go 开发者提供的各种框架来编写真实示例。

简介

Go 是一种语言,它(类似于 C、C++、Lisp 以及许多其他语言)在它支持的每一个平台上编译成原生二进制文件。这对于图形应用程序来说非常重要,因为这是在主流计算机硬件上创建最响应和最平滑的用户界面的最佳方式。在撰写本文时,Go 运行的平台包括 Windows、macOS、Linux、Solaris 以及其他流行的基于 Unix 的操作系统(这实际上涵盖了所有桌面个人电脑)。与其它现代语言相比,Go 的一个突出特点是它的源代码可以在不进行任何修改或特殊适配的情况下,编译成它支持的每一个平台的原生代码。该语言还包含了一个庞大的 API 库,完全支持它所支持的每一个操作系统。这对于想要为多个操作系统编写高效应用程序的开发者来说是一个巨大的优势,因为他们无需为每个平台维护略有不同的版本。Go 还是一种 类型化 语言,这意味着每个变量、常量、函数参数和返回类型都必须有一个单一、已定义的类型。与一些较老的类型化语言不同,Go 通常能够推断类型,这有助于避免在源代码中重复信息。这些特性有助于创建一个非常适合开发的语言——那么,让我们看看一些真实的代码以及它是如何构建和运行的。我们将使用一个简单的 hello world 示例,我们将将其写入一个名为 main.go 的文件:

package main

import "fmt"

func main() {
    fmt.Println("Hello World!")
}

此示例展示了最基本的 Go 程序。第一行指示包名(在这里,main 表示该文件描述了一个可执行命令)。然后,我们有导入块,其中引用任何标准库包或外部代码。最后,有一个 main() 方法,这是任何 Go 程序的开始——该方法简单地使用 fmt 包将 Hello World! 打印到命令行。此方法没有提及返回类型(它将放在 main() 之后)——这意味着没有返回类型,例如 C 或 Java 程序中的 void。我们使用 go run main.go 命令运行此应用程序,如下所示:

图片

运行 main.go 输出消息然后退出

通常,每个 Go 文件旁边都会有一个测试文件,它会对主代码执行单元测试。让我们用一个简单的例子来演示。将以下代码输入到 main_test.go 中:

package main

import "testing"

func TestLogic(t *testing.T) {
    if true == false {
        t.Error("It's illogical")
    }
}

在我们运行此代码之前,你应该注意到与常规 Go 文件相比有两个重要差异。首先,导入列表包括 "testing"——这是编写任何测试方法所必需的。其次,这次方法名以 Test 开头,并包含一个 *testing.T 类型的单个参数。任何符合这些条件且在以 _test.go 结尾的文件中的方法都将被视为单元测试。现在让我们使用内置的测试运行器来运行测试:

图片

以详细模式运行 Go 测试

在这个命令中,-v 参数请求详细输出,显示正在运行的测试,以及结果中的 command-line-arguments 指示测试是在我们参数列表中指定的文件上运行的,而不是整个包。或者,键入 go test 将输出更少的信息,并运行当前包中的所有测试。

除了这些基本命令之外,Go 还附带了许多工具,可以帮助开发者编写和维护高质量的代码。以下是最常用的三个:

  • gofmt:这确保源代码按照 Go 规范格式化,如果请求(通过传递 -w)还可以重写你的文件。

  • godoc:这运行一个本地文档服务器来检查你的 API 将如何显示给其他开发者。

  • go vet:这检查代码中编译器无法检测到的常见编程错误。

你可能已经注意到,这些命令只是简单地运行,不需要编译——但如果 Go 是一种编译型语言,这是如何实现的呢?这是因为 run 命令实际上是一个构建应用程序然后运行的快捷方式。这使得运行应用程序的最新版本比通常的编译然后运行的方法要快得多,而且不会失去作为本地二进制文件的所有优势。在这种情况下,构建的应用程序在运行完成后将被丢弃。如果你想多次构建和运行,可以使用 build 命令,如下所示:

图片

构建可多次运行的二进制文件

如您所见,编译器已创建了一个与我们所输入代码的文件名相同的可执行文件。这个文件是由我们的源代码构建的本地应用程序,它可以像任何其他应用程序一样运行。请注意,这是一个本地应用程序,因此它不像 Java 应用程序那样可移植。它将在我们构建它的计算机上以及其他类似计算机上运行,但基于 Windows 计算机构建的应用程序不能直接在 macOS 上运行。源代码与这两个平台兼容,但二进制应用程序则不兼容。

应该指出的是,Go 语言还提供了垃圾回收功能,这有助于开发过程的简化。这意味着当我们创建的对象不再需要时,系统将确保它们占用的内存被释放。与不提供这种功能的C和其他(较老)编译语言相比,我们编写的代码更少,并且我们的应用程序泄漏内存的风险要低得多。现在我们了解了这种语言,让我们来探索支持跨平台方法的编译器功能,并看看如何为不同的操作系统构建这些示例。

适用于任何应用程序的跨平台

在介绍中,我们了解到以_test.go结尾的文件将自动作为测试阶段的一部分运行。Go 使用这种命名约定为编译器提供额外的功能,以提供为特定平台或计算机架构包含代码的能力。例如,名为main_windows.go的文件只有在构建 Microsoft Windows 时才会被包含在编译中,而main_darwin.go文件将仅针对 macOS(darwin 是底层操作系统的名称)进行编译。同样,计算机架构也可以用来条件性地包含源代码,因此名为main_arm.go的文件将仅作为 32 位 ARM 架构处理器的构建的一部分。

Go 还支持通过使用构建约束(也称为构建标记)对任意文件进行条件编译。这些操作在文件级别上确定一个文件是否应该包含在构建中。要使用此功能,需要在文件顶部的包声明之前放置一个注释(之后有一个重要的空白行):

// +build linux,!386

package myapp

基本构建标记与之前描述的用于文件命名的平台和架构字符串相匹配,并且它们可以组合和否定(使用!字符)。因此,当编译针对非 32 位处理器的 Linux 系统时(!386),前面的示例将被包含在内。通过向编译器传递自定义标记,该功能可以进一步扩展。这样,一个仅针对 Macintosh 提供高级功能的程序可以更新文件以读取以下内容:

// +build darwin,coolstuff

package myapp

这意味着,当为 macOS 计算机编译时,您可以通过添加一个额外的参数来调用编译器以启用此coolstuff功能,如下所示:go build -tags coolstuff main.go

这种级别的条件编译意味着代码不会显得杂乱或难以阅读——每个文件在构建时要么被包含,要么不被包含。通常,包含条件代码的文件将与包含替代实现的另一个文件配对,例如// +build !darwin !coolstuff,以提供前面额外功能的回退(如果不在 macOS 上或没有传递coolstuff标签,则会被编译)。有关构建约束计算方式的更多信息,请参阅文档:golang.org/pkg/go/build/

一个非常有用的编译器功能,在本地应用程序开发中非常有用(但应谨慎使用)是能够直接从 Go 代码中调用 C 代码,这被称为Cgo。以下示例演示了一个小的 Cgo 程序,通过导入"C"包,能够调用 C 代码。它还定义了一个小的内联函数,如果您在方法中需要多次调用 C 代码,这个函数可能会有所帮助:

package main

/*
#include <stdio.h>
#include <stdlib.h>

void print_hello(const char *name) {
    printf("Hello %s!\n", name);
}
*/
import "C"
import "unsafe"

func main() {
    cName := C.CString("World")
    C.print_hello(cName)
    C.free(unsafe.Pointer(cName))
}

当像正常的 Go 程序一样运行时,这将打印出您预期的消息:

图片

从 go 文件中运行 C 代码。

如您所见,内联 C 方法是注释的一部分,包括所需的导入,当放置在import "C"之前时,会被 Cgo 读取。请注意,Go 字符串不能直接传递给 C 代码,而必须通过"C"包转换为CString。从 C 代码中调用 Go 函数也是可能的,这被称为Cgo。Cgo 的完整解释超出了本书的范围,但更多信息可以在golang.org/cmd/cgo/的文档中找到。虽然这是一个非常强大的功能,但它会迅速导致平台特定的代码,因此除非绝对必要,否则不建议使用。

跨平台编译

我们迄今为止探索的编译器功能仅针对当前平台构建。这意味着当在 Linux 上开发时,编译器将创建(如果请求运行,则运行)一个本机 Linux 二进制文件(技术上称为 ELF)。如果在 macOS 上执行,结果将是一个针对 darwin(Mach-O 可执行文件)的本机二进制文件,而在 Windows 上则是一个针对 Windows 平台的本机二进制文件(PE32+)。如果开发者希望针对许多不同的平台,一个选项是为每个构建拥有不同的计算机,但这既昂贵又耗时。从开发者的计算机创建针对各种平台的本机二进制应用程序要方便得多——这被称为跨平台编译

使用 Go 工具链进行交叉编译很简单。您需要知道的是您希望编译的操作系统和架构。为了运行针对不同平台的构建,我们只需设置GOOSGOARCH环境变量(分别对应操作系统和架构)并调用"go build"。在这个例子中,我们通过为不同平台编译入门示例并使用 Unix 的file命令检查结果应用程序来阐述这个原则。如您从第一次调用中看到的那样,这个示例是在 64 位 Linux 计算机上执行的,然后我们分别为 32 位 Linux、Windows 和 macOS 进行构建:

图片

在一台计算机上为不同的平台构建

因此,您可以看到构建适用于任何平台的 Go 应用程序是多么简单。有了这些知识,我们可以在开发者的首选平台上创建图形应用程序,并将其交叉编译为许多最流行的操作系统,而无需任何自定义代码或构建配置。

标准库

编程语言的标准库是由语言运行时提供的 API 和功能集。例如,C 语言有一个非常小的标准库——作为一个底层语言,它支持的每个操作系统的功能数量是有限的。另一方面,Java 历史上以内存和启动时间消耗大而闻名,提供了一个庞大的标准库——包括在第二章中描述的 Swing GUI,图形用户界面挑战

标准库的大小通常是选择编程语言时需要权衡的一部分。对于性能良好的原生应用程序,启动速度快,内置 API 的数量通常较小。当使用高级语言进行构建时,开发者通常期望有很多支持特性和 API 包——这通常会在启动时间或性能上带来一定的代价。Go 语言试图提供一个完整的标准库,而不带来任何运行时惩罚。这是通过其跨平台编译和静态链接来管理的,它包括正在构建的原生二进制文件中使用的所有功能。这意味着文件可能比从 C 代码编译的程序要大,并且可能需要更长的时间来编译——但这些一次性成本(构建和下载)使得 Go 应用程序在所有平台上都能实现高性能。

Go 附带的标准库在许多领域都提供了强大的功能,包括密码学、图像处理、文本处理(包括 Unicode)、网络、HTML 模板和 Web 服务集成。您可以在golang.org/pkg/#stdlib上阅读完整的文档。

语言设计中的并发

在大多数主流编程语言中,并发和多线程可以增加复杂性,并使代码更难阅读。Go 的设计者决定从一开始就构建并发,使其易于管理许多执行线程,同时避免共享内存管理的困难。Go 不暴露传统的 threads,而是引入了 goroutines 的概念——这些类似于轻量级线程,但可以同时拥有数千个。共享内存通常是并发应用程序的主要通信机制,但在 Go 中,通信用于共享——这个内置特性被称为 channels。除了这些语言特性之外,Go 在标准库中有一个 sync 包,它提供了进一步并发管理的工具。

Goroutines

首先,让我们检查最基础的 Go 并发特性:goroutine。任何正常函数在被调用时,都会执行其内部的代码,并在遇到 return 时退出,或者函数退出——此时它将控制权返回给调用它的函数。Goroutine 是一种开始执行但立即将控制权返回给调用它的函数的函数——本质上为每次调用创建一个后台进程。任何函数都可以通过在调用前加上 go 前缀作为 goroutine 调用,如下所示:

package main

import (
    "fmt"
    "time"
)

func tick(message string) {
    for i := 0; i < 5; i++ {
        time.Sleep(10 * time.Millisecond)
        fmt.Println(message)
    }
}

func main() {
    go tick("goroutine")
    tick("function")
}

这个代码示例定义了一个 tick(string) 方法,它将每隔 10 毫秒输出请求的消息。main() 函数以两种不同的方式调用此代码:首先,它作为 goroutine 调用,然后作为正常的函数调用。如果这是作为两个连续的函数调用调用的,我们会在命令行中看到很多 "goroutine" 的副本,然后是多次 "function"。然而,goroutine 与以下代码并发执行,所以我们看到的是这个输出:

使用 goroutine 的并发输出

你看到的内容可能略有不同,因为顺序看起来有点随机。你应该看到的是,每一对输出行之间要么是 "goroutine""function",并且它们之间有一个小的时间间隔。每一对顺序将取决于调度器,但你可以清楚地看到,tick 函数的两次调用是同时运行的(即并发)。Goroutines 并不仅限于这样的简单示例,但它们占用相同的地址空间(就像正常的函数调用一样),因此它们可以访问共享的内存区域。当多个线程可以写入同一内存区域时,通常需要同步来确保正确操作。为了提供更好的语义以这种方式进行通信,Go 语言有一个名为 channels 的特性。

Channels

Go 中共享数据的哲学是不要通过共享内存来通信;相反,通过通信来共享内存。通道是支持这种方法的语言结构——它们允许通过在 goroutine 之间正确通信来共享数据,而不是共享公共数据。这是 Go 避免竞态条件(即一个线程写入数据,而其他线程读取相同的数据)的主要方式。通道在 Go 中用于各种模式——它们可以传递 goroutine 的结果(或在不同程序之间传递数据),在数据更改时提供更新,甚至可以发出进程应该结束的信号。

通道,就像 Go 中所有的变量和常量一样,需要有一个类型。通道的类型决定了可以通过它传递的数据。如果只想发送true/false这样的信息,类型可以是bool;如果您希望传递更多信息,例如数据更改通知,则可以是自定义的struct数据类型。在这个通道的例子中,我们使用了一个简单的字符串通道,它被读取多次,而 goroutine 继续向其中写入:

package main

import "fmt"

func say(words string, to chan string) {
    fmt.Println("Speaking:", words)
    to <- words
}

func talk(to chan string) {
    say("Hello", to)
    say("Everyone", to)
    say("My name is...", to)
    fmt.Println("Never mind")
}

func listen(to chan string) {
    heard := <-to
    fmt.Println("I heard:", heard)}

func main() {
    chat := make(chan string)

    go talk(chat)

    listen(chat)
    listen(chat)
    fmt.Println("Bye")
}

运行此示例将演示每次向通道写入(在say中),它必须等待通道被读取(在listen中),然后才能再次写入。您还可以看到talk协程从未完成消息,因为我们没有读取它等待写入的所有数据:

通过简单通道进行通信

默认情况下,向通道写入将阻塞,直到有代码准备好从另一端读取,同样,读取将阻塞,直到向通道写入数据,此时程序流程将继续。这种行为可以通过使用带缓冲的通道来改变——如果通道的缓冲区大小为 5,则可以写入 5 次而不阻塞;同样,从该通道读取可能会在阻塞之前返回 5 个值(在没有数据可用时,读取通道将始终阻塞)。如果我们更新前面的示例以创建一个大小为 3 的带缓冲通道(通过使用make(chan string, 3)),我们会看到完整的信息被写入,并且talk方法完成:

向通道添加缓冲

这个简单的例子说明了您如何在 goroutine 之间安全地通信,但让我们通过包含一些额外的功能来查看一些更实际的例子。例如,每次配置结构体更改时,都可以通过通道传递,这样应用程序就可以相应地做出反应:

go func() {
    for {
        config := <-configManager
        myWidget.applyConfiguration(config)}
}()

为了能够管理多个 goroutine 之间的并发和通信,语言对 select 关键字进行了增强,它提供了在多个通道上等待的能力。这意味着你不需要为每个阻塞通道创建一个 goroutine。以下示例说明了后台函数如何处理一些复杂的计算(在这种情况下,square),并将结果反馈给主函数,同时等待信号以完成处理:

package main

import "fmt"

func square(c, quit chan int) {
    sq := 2
    for {
        select {
        case c <- sq:
            sq*=sq
        case <-quit:
            fmt.Println("quitting")
            return
        }
    }
}

func main() {
    c := make(chan int)
    quit := make(chan int)
    go square(c, quit)

    func() {
        for i := 0; i < 5; i++ {
            fmt.Println("Square", <-c)
        }
        quit <- 1
    }()
}

运行此示例将输出计算结果,直到进程被信号通知停止:

图片

读取计算通道直到收到退出信号

最后,Go 中的通道可以被写入者关闭;这意味着从通道读取的函数可能停止接收新值。为了避免这种死锁情况,通道的读取者可以检测通道是否已关闭。检查通道状态的语法是读取一个可选的第二个参数,val, ok := <-ch,其中 val 是读取的值,ok 表示通道没有被关闭。此外,还增加了一个新的 range 关键字,它将迭代通道的值,直到它关闭。以下示例包括一个 download() 函数,该函数模拟下载数据并更新完成百分比。进程达到逻辑结论,因此 main 函数可以完成。你可以看到如何确保进度条在程序的其他部分继续运行时保持更新:

package main

import "fmt"

func download(file string, c chan int) {
    fmt.Println("Downloading", file)

    c <- 10
    c <- 40
    c <- 65
    c <- 100

    close(c)
}

func main() {
    c := make(chan int)
    go download("myfile.jpg", c)

    for i := range c {
        fmt.Printf("Progress %d%%...\n", i)
    }
    fmt.Println("Download complete")
}

运行此示例将展示模拟下载的进度,并在进程完成后返回。简单的 range 关键字用于避免直接处理通道关闭条件:

图片

在通道范围内迭代

有时候,你需要超越并发原语并处理特殊情况。这正是标准库中的 sync 包所提供的。

sync

Go 标准库的 sync 包提供了语言本身不包括的额外同步功能。它在并发管理方面的补充包括 Mutex、WaitGroup 和 Once,我们将简要介绍。

当你想确保互斥访问——也就是说,你只想让一个 goroutine 一次访问一块数据(以避免潜在的冲突)时,会使用 Mutex。关键方法是 Lock()Unlock(),它们包围了不应该并发执行的代码段。如果第二个 goroutine 尝试进入该段,它将阻塞,直到锁被释放:

var vals map[string]string
var lock sync.Mutex

func Get(key string) string {
    lock.Lock()
    defer lock.Unlock()
    return vals[key]
}

func Set(key, value string) {
    lock.Lock()
    vals[key] = value
    lock.Unlock()
}

在前面的例子中,我们有一个vals映射,我们想要共享,因此必须确保线程安全。我们添加sync.Mutex来保护访问并确保在使用映射之前获得锁。注意,在Get方法中,我们使用defer关键字来确保代码在方法退出时被调用——这避免了在返回之前需要访问映射、存储值和解锁的需要(使代码更整洁)。

WaitGroup很有用,如果你想要创建多个后台活动,然后等待它们全部完成。例如,这个代码片段创建了一个下载方法,它接受一个额外的参数,即它所属的组。每个下载实例在下载开始时增加组计数器(Add(1)),在下载结束时清除它(Done())。调用函数设置一个等待组,然后调用Wait(),这将返回一旦所有下载完成:

func Download(url string, group *sync.WaitGroup) {
    group.Add(1)
    http.Get(url)
    group.Done()
}

func main() {
    ...
    var group sync.WaitGroup
    go download("http://example.com/image1.png", group)
    go download("http://example.com/image2.png", group)
    group.Wait()
    fmt.Println("Done")
    ...
}

最后一个例子,Once,相当直观——它允许代码只执行一次。调用它的Do(func())方法将导致传递的函数不会被调用超过一次。如果你试图实现一个懒加载的单例模式,这很有帮助,如下面的代码所示:

var instance *myStruct
var once sync.Once

func GetInstance() *myStruct {
    once.Do(func() {
        instance = &myStruct{}
    })

    return instance
}

完整文档可在golang.org/pkg/sync/找到;然而,建议尽可能使用 channel 构造,而不是这些功能中的大多数。

标准包含的 Web 服务

作为一种现代编程语言,Go 提供了广泛的 HTTP 客户端、服务器和标准编码处理程序的支持,包括 JSON 和 XML。结合内置的字符串和映射功能,这消除了许多与 Web 服务一起工作的障碍。除此之外,Go 中结构体的格式允许额外的tags,可以为字段提供元数据。encoding/jsonencoding/xml包都利用这一点来理解如何正确地编码和解码这些结构体的实例。以下示例通过连接到 Web 服务、访问 JSON 响应并将其解码为结构体来演示这些功能,然后像使用任何其他结构体一样使用它:

package main

import "encoding/json"
import "fmt"
import "io/ioutil"
import "net/http"

type Person struct {
    Title     string `json:"title,omitempty"`
    Firstname string `json:"firstname"`
    Surname   string `json:"surname"`

    Username string `json:"username"`
    Password string `json:"-"`
}

func readFromURL(url string) ([]byte, error) {
    var body []byte
    resp, err := http.Get(url)
    if err != nil {
        return body, err
    }

    defer resp.Body.Close()
    return ioutil.ReadAll(resp.Body)
}

func main() {
    person := &Person{
        "",
        "John",
        "Doe",
        "someuser",
        "somepassword",
    }
    fmt.Println("Struct:", person)

    data, _ := json.MarshalIndent(person, "", "  ")
    fmt.Println("JSON:", string(data))

    fmt.Println("Downloading...")
    data, _ = readFromURL("http://echo.jsontest.com/title/Sir/" +
        "firstname/Anthony/surname/Other/username/anon123/")
    fmt.Println("Download:", string(data))

    person = &Person{}
    json.Unmarshal(data, person)
    fmt.Println("Decoded:", person)
}

在前面的示例代码中,你可以看到带有前缀"json:"的结构体标签的使用。这些为管理这些对象编码和解码的"encoding/json"包提供了提示。我们可以运行这个示例,并看到将结构体转换为 JSON 然后再转换回来的输出:

图片

对 HTTP 请求进行 JSON 编码和解码

注意到标记为 omitempty 的零值字段未包含在 JSON 输出中,同样地,标记为 "-"(表示不包含)的密码字段在编码数据时也被忽略。从测试网络服务下载数据后,它被直接 marshaled 到 Person 结构体的实例中,缺失的字段保留其零值。这一切都是使用语言的内置特性和标准库功能实现的。这要归功于 Go 在处理网络服务方面的简便性。

选择外观和感觉

如 第二章 中所讨论的,图形用户界面挑战,图形应用程序有许多方法,让开发者可以在原生与混合(打包的 Web 应用)之间选择,让设计师可以从系统外观和感觉、多平台小部件集或甚至自定义设计中进行选择。您所做的选择可能会受到您的应用程序需求和目标平台的影响——性能是否重要,您是否希望实现跨平台部署?Go 没有标准的图形工具包,这种省略导致开发者认为它不是用于编码 GUI 应用的语言。然而,正如我们在本章中所看到的,它非常适合图形应用程序开发。这引出了一个问题:在 Go 中构建 GUI 应用程序有哪些工具包可用?

简而言之:很多。您可以在网上看到主要、目前维护的工具包列表,请访问 awesome-go.com/#gui——可能有一个选项适合大多数用例。由于我们正在寻找构建性能出色的高性能应用程序,我们将跳过依赖于捆绑网络内容以创建混合应用程序的项目。这仍然留下了一个庞大的列表!有些工具包提供对系统组件的直接访问,以实现真正的原生体验,有些则提供抽象的 API 以实现相同平台的外观和感觉。其他工具包提供他们自己的用户界面渲染,这在所有支持的平台上都是一致的(类似于 Java Swing 的 Metal 外观和感觉)。

除了是 GUI 开发的优秀语言外,Go 还为我们提供了选择适合我们应用程序的正确 GUI 工具包的机会。第二部分,使用现有小部件的工具包(第四章、5 章、6 章和 7 章)和第三部分,现代图形工具包(第八章、9 章和 10 章),介绍了每个主要选项,并展示了如何使用每个工具包开始构建美观的图形应用程序。第二部分,使用现有小部件的工具包,专注于构建与操作系统外观和感觉相匹配的应用程序,而第三部分,现代图形工具包,则是为那些追求更现代外观并旨在跨多个平台保持一致性的应用程序而设计的。

摘要

在本章中,我们探讨了 Go 语言在开发图形应用程序方面的优势。它处理并发的架构使得 GUI 所需的线程类型易于管理。通道,作为主要的线程通信特性,学习起来有点困难,但通过一些基本示例,我们看到了如何轻松避免常见的并发问题。Go 的“一次编写,到处编译”的理念意味着开发者可以轻松地编译相同的代码,并使用提供的工具从单一代码库在大多数常见平台上交付原生应用程序。作为一种现代语言,它旨在在互联的世界中运行,其对网络通信和 Web 服务的支持非常出色——我们运行了示例,展示了如何轻松地将对象转换为常见的 Web 格式。

探索了 Go 语言适用于图形应用开发的多种方式后,我们也反思了可供选择的众多工具包。尽管 Go 语言没有标准用户界面,但仍有许多可能性可以用来构建外观出色的图形应用程序。在第四章,Walk - 构建图形窗口应用程序,第五章,andlabs UI - 跨平台原生 UI,第六章,Go-GTK - 多平台与 GTK,第七章,Go-Qt - 多平台与 Qt,第八章,Shiny - 实验性 Go GUI API,第九章,nk - Nuklear for Go,以及第十章,Fyne - 基于 Material Design 的 GUI中,我们将探讨不同的工具包以及如何开始构建你的第一个基于 Go 的 GUI。我们首先从探索如何构建传统用户界面开始,从第四章,Walk - 构建图形窗口应用程序中的 Microsoft Windows 应用程序开始。

第二部分:使用现有小部件的工具包

首先,让我们看看 Go 的图形应用工具包,这些工具包使用现有的小部件。这些工具包可以帮助你构建与操作系统外观和感觉相匹配的应用程序,如果你想要构建一个用户能立即熟悉的应用程序,这是一个很好的选择。然而,这种方法可能有一个缺点,那就是你需要为每个你想要支持的平台进行大量的测试和潜在的自定义。我们探索的 API 具有不同的抽象级别和平台集成度,因此,根据你的应用程序需求,所需的工作量可能会有所不同。

本节涵盖了三种创建与操作系统匹配的 GUI 的不同方法。首先,我们将看看 Walk,这是一个专门用于创建 Windows 应用程序的 Go API。这是创建 Microsoft Windows 桌面应用程序的最直接方式,但它显然不能轻易地移植到其他平台。之后,我们将切换到 andlabs/ui,这是一个针对 Windows、macOS、Linux 和其他小部件集的抽象。该章节中构建的应用程序将使用本地平台小部件,同时可移植到许多平台。然后,我们将看看可主题化的小部件集,这些小部件集在多个平台上表现相同,并加载与本地用户界面相匹配的主题。在这个类别中,我们将使用 GTK+和 Qt 工具包的 Go 绑定来设置应用程序。

本节将涵盖以下章节:

  • 第四章,Walk – 构建图形 Windows 应用程序

  • 第五章,andlabs UI – 跨平台原生 UI

  • 第六章,Go-GTK – 使用 GTK 的多平台

  • 第七章, Go-Qt – 使用 Qt 的多平台

因此,让我们直接深入,使用 Walk API 构建我们的第一个 Windows 应用程序。如果你正在为不同的操作系统开发,那么这一章可能不太相关,你可以跳转到第五章,andlabs UI - 跨平台原生 UI,我们将继续使用 andlabs UI。

第四章:Walk - 构建图形窗口应用程序

Walk 是 Go 编程语言的 Windows GUI 工具包——它的目的是使我们能够使用 Go 构建 Windows 的原生桌面 GUI 应用程序。它是基于同一作者编写的 win 包构建的,该包是 Windows API 的直接 Go 绑定。主要的 API 设计旨在使与Windows APIWinAPI)的工作既容易又符合 Go 设计原则。

在本章中,我们将涵盖以下主题:

  • 背景 和 目标

  • 开始使用 Walk

  • 声明式 API 的好处

  • 构建用户界面

  • 在跨平台应用程序中使用 Walk

让我们开始探索 Walk 项目及其为 Windows 平台开发的 Go 应用程序的解决方案的背景。

背景 和 目标

Walk 项目是 Go 最古老的 GUI 工具包之一,始于 2010 年 9 月。这个名字代表Windows 应用程序库套件,反映了其支持为 Microsoft 平台构建 GUI 应用程序的目的。其项目主页在 GitHub 上,您可以查看最新的发展和讨论:github.com/lxn/walk

项目 API 受到了 Qt 框架的 Qt Widgets 模块的启发(将在第七章,Go-Qt - 多平台与 QT中介绍)。Qt Widgets 是一组标准用户界面特性,用于创建具有熟悉外观和感觉的图形应用程序。通过紧密匹配 Qt 设计,可以在准备基于 Walk 的用户界面时使用一些 Qt 工具,例如 UI 设计器。Walk 目前支持最常用的控件,这意味着它可能不适合每个应用程序。在撰写本文时,第二章,图形用户界面挑战中描述的多文档界面MDI)和可停靠的工具窗口不支持——尽管作者指出这是一个开源项目,欢迎贡献。

Walk 为构建 Windows 桌面应用程序提供了一个出色的 API。尽管它不提供某些工具包提供的主题或呈现样式选择,但使用它的应用程序看起来与其它 Windows 应用程序完全一样。项目的一个目标是在没有任何额外依赖或复杂的设置的情况下工作,这意味着它是一个很好的起点——您将在下一节中看到。

开始使用 Walk

现在我们已经对 Walk 库有了一些了解,让我们看看它的实际应用。以下步骤旨在使用 Walk API 创建一个简单的应用程序,以验证一切是否正常工作。如果您在这些步骤中遇到任何问题,请考虑查看附录,安装细节,并在安装 Go中的Microsoft Windows部分进行操作。

设置

在我们开始使用 Walk 编写 GUI 之前,我们需要安装库——这意味着 Go 将能够编译我们编写的代码,并且任何已安装的开发环境在编写代码时都将能够提供建议。只需在命令提示符中执行 go get github.com/lxn/walk 即可。此命令将从 %GOPATH%/src 下载并安装 Walk 库,以便在应用程序中使用。如果你没有手动设置 GOPATH 环境变量,不要担心,因为 Go 安装程序已经为你设置了一个默认值(通常是 %HOMEDRIVE%%HOMEPATH%/go)。

代码

现在让我们编写一些代码!首先,创建一个新的目录来存放这段代码——由于 Walk 二进制文件的创建方式(参见以下代码),我们需要在目录级别进行构建,而不是单个文件,因此拥有一个干净的工作空间是很好的。将以下代码复制到一个名为 hello.go 的文件中:

package main

import (
   "github.com/lxn/walk"
   . "github.com/lxn/walk/declarative"
)

func main() {
   MainWindow{
      Title: "Hello",
      Layout: VBox{},
      Children: []Widget{
         Label{Text: "Hello World!"},
         PushButton{
            Text: "Quit",
            OnClicked: func() {
               walk.App().Exit(0)
            },
         },
      },
   }.Run()
}

在前面的代码中,你可以看到两个不同的 Walk 导入——我们稍后会详细讨论这个问题。在 main() 函数中,我们设置了一个简单的窗口,其中包含一个 VBox 布局中的两个项目:一个 Label 和一个 PushButton,当点击时会退出应用程序。接下来,我们需要创建一个额外的文件,名为 hello.exe.manifest,内容如下(此清单文件在构建过程中是必需的):

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly  manifestVersion="1.0" >
   <assemblyIdentity version="1.0.0.0" processorArchitecture="*" name="HelloWorld" type="win32"/>
   <dependency>
      <dependentAssembly>
         <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
      </dependentAssembly>
   </dependency>
   <asmv3:application>
      <asmv3:windowsSettings >
         <dpiAware>true</dpiAware>
      </asmv3:windowsSettings>
   </asmv3:application>
</assembly>

此清单文件是必需的,用于告知 Windows 运行时我们正在使用 Common Controls 框架版本 6.0.0.0(或更高版本),这是 Walk API 所必需的。

构建

保存这两个文件后,你可以构建应用程序。由于 Walk 应用程序的性质(特别是清单文件中描述的 Windows API),需要额外一步来准备目录。Walk 应用程序需要一个将嵌入我们构建的可执行文件的清单文件。为此,我们需要从 github.com/akavel/rsrc 下载 rsrc 工具,该工具将嵌入所需的元数据。然后,我们使用 -manifest 参数运行 rsrc.exe 命令以生成嵌入文件,如下所示:

图片

rsrc 工具生成 .syso 文件以嵌入

该步骤将创建一个 .syso 文件,该文件将自动包含在下一步中。现在我们可以实际运行 go build 命令。在命令行中,我们添加一个额外的 ldflag 参数,设置为 "-H windowsgui",这告诉编译器输出一个 GUI 应用程序,而不是命令行应用程序。虽然没有这个参数也可以正常工作,但当你从常规图标点击启动应用程序时,你的应用程序后面会显示一个命令行窗口:

图片

再次运行 go build 将嵌入 .syso 文件

运行

上一步构建的 hello world 应用程序可以通过两种方式执行:要么从命令行运行,要么通过文件管理器中的图标点击:

图片

hello 应用程序图标

在当前目录下,你应该在你的文件管理器中看到一个类似于前面的图标。或者,返回到命令提示符,并简单地从项目目录中输入hello.exe命令。使用任何一种方法,你现在都应该能在你的桌面上看到这个应用程序正在运行(你可能需要寻找,因为它是一个非常小的窗口):

基于 Walk 的 Hello World

声明式 API 的好处

如代码示例所示,Walk API 分为两个显著的包:github.com/lxn/walkgithub.com/lxn/walk/declarative。声明式 API 是使用 Walk 开发应用程序 GUI 的首选方法,因为它提供了更好的抽象层,并且更符合习惯用法。声明式 API 的实现还提供了各种标准指标和默认值,有助于用最少的代码创建标准用户界面。该包通常使用.前缀导入,这样 GUI 代码就可以避免重复使用declarative.前缀。

与原生 API 相比

使用原生 API(原生 winAPI 的 Go 绑定)是可能的,但在大多数情况下,这会更冗长,因为你是在直接与低级 API 工作。以这种方式编码无法利用由高级声明式 API 处理的标准化指标或配置,该 API 旨在更好地适应现代编程语言。为了说明这种差异,以下是在我们仅使用原生 API 时前面示例的样子:

package main

import (
   "log"

   "github.com/lxn/walk"
)

var marginSize = 9

func buildWindow() (*walk.MainWindow, error) {
   win, err := walk.NewMainWindowWithName("Hello")
   if err != nil {
      return nil, err
   }
   layout := walk.NewVBoxLayout()
   layout.SetMargins(walk.Margins{marginSize, marginSize, marginSize, marginSize})
   layout.SetSpacing(marginSize)
   win.SetLayout(layout)

   label, err := walk.NewLabel(win)
   if err != nil {
      return win, err
   }
   label.SetText("Hello World!")

   button, err := walk.NewPushButton(win)
   if err != nil {
      return win, err
   }
   button.SetText("Quit")
   button.Clicked().Attach(func() {
      walk.App().Exit(0)
   })

   return win, nil
}

func main() {
   win, err := buildWindow()
   if err != nil {
      log.Fatalln(err)
   }

   win.SetVisible(true)
   win.Run()
}

这段代码可以像前面的示例一样编译,运行时看起来完全一样。显然,为了获得相同的结果,需要更多的代码,而且阅读起来也更困难,而且没有特别的收益。在使用声明式 API 时,这种替代示例中的错误处理是隐式处理的。抛开 Go 语法的差异,应该很明显,这个例子中使用的原生 API 调用是直接操作 WinAPI 中的小部件。实际上,通过NewLabel()NewPushButton()NewMainWindowWithName()创建的每个对象都是 WinAPI(由github.com/lxn/win提供)的 Go 绑定的轻量级包装器。

有很多情况下使用这种原生 API 是有用的;最常见的是,当你需要控制细节或处理现有小部件的更改时,例如在事件处理代码中。声明式 API 旨在轻松定义应用程序用户界面,但它通常不足以管理复杂 GUI 的工作流程。因此,通常会将这两个 API 一起使用——在适当的时候使用每个 API 的力量。

使用两种 API 以获得灵活性

理解声明式 API 和本地 API 之间的区别非常重要,因为任何应用程序都可能需要同时使用这两种 API。使用声明式语法非常适合简洁地描述用户界面,但要对图形元素进行运行时操作,则需要引用代码包装的本地小部件之一。为了建立这种联系,每个声明式类型都有一个AssignTo字段,它通常传递一个指向var的指针,而var本身是一个指向表示本地类型的对象的指针。这意味着在用户界面构建阶段,声明式 API 解析器可以创建本地小部件,并在您的代码中设置指针以供以后使用。让我们看看这个功能在实际中的应用:

package main

import (
   "fmt"

   "github.com/lxn/walk"
   . "github.com/lxn/walk/declarative"
)

func main() {
   var message *walk.Label
   var userName *walk.TextEdit

   MainWindow{
      Title: "Hello",
      Layout: VBox{},
      Children: []Widget{
         Label{
            AssignTo: &message,
            Text: "Hello World!",
         },
         TextEdit{
            AssignTo: &userName,
            OnTextChanged: func() {
               welcome := fmt.Sprintf("Hello %s!", userName.Text())
               message.SetText(welcome)
            },
         },
         PushButton{
            Text: "Quit",
            OnClicked: func() {
               walk.App().Exit(0)
            },
         },
      },
   }.Run()
}

上述代码可以像之前的hello world示例一样编译(如果您为这个示例创建了一个新项目,别忘了包含和处理清单)。当运行此示例时,您应该看到以下界面,并额外有一个文本输入字段。当您在输入框中键入时,欢迎信息将改变,例如,在这个截图中输入了John Doe

图片

带有姓名输入的 hello world

您会注意到messageuserName变量不是由应用程序代码直接初始化的,而是在分配给OnTextChanged函数的函数被调用时,它们持有对已实例化小部件的有效引用。使用这种方法,我们可以在使用声明式 API 提供的易于阅读的 UI 定义的同时,获得本地 API 包装器提供的访问类型。

构建用户界面

在了解了 Walk API 的设计和利用方法之后,让我们继续探讨一个现实世界的例子。在这本书中,我们将为每个探索的工具包(在第 4、5、6、7、8、9、10 章)构建相同的用户界面,这将是一个名为 GoMail 的简单电子邮件应用程序。由于 Walk 和 Qt 小部件之间的紧密关系,我们可以快速开始使用 Qt Creator 中包含的 UI Builder 设计用户界面(文档可在doc.qt.io/qtcreator/creator-using-qt-designer.html找到)。

基本应用程序将由两个窗口组成:主电子邮件浏览器和一个用于编写新电子邮件的辅助窗口。主窗口将包含一个列表或树视图,显示我们收到的电子邮件,一个较大的面板用于显示当前选定的电子邮件内容,以及一个菜单和工具栏,用于访问电子邮件应用程序的各种功能:

Qt Designer 中的主要电子邮件窗口

要撰写新电子邮件,我们将显示一个次要窗口,该窗口将要求输入发送电子邮件的各种详细信息。打开新窗口将允许用户在撰写新要发送的电子邮件的同时继续阅读电子邮件。撰写窗口还将有发送或丢弃正在编写的电子邮件的按钮:

正在设计的附加撰写窗口

样式

使用 Walk 构建的应用程序由原生 Windows 组件组成,因此样式由微软提供的实现设置(这是由 Common Controls 部分提供的 ComCtl32.dll)。Walk 所需的版本(版本 6.0)增加了对视觉样式的支持;这是提供应用程序使用当前运行的 Windows 桌面版本的正确视觉样式的系统。

此功能从 Windows XP 开始可用,但从 Vista 开始成为标准功能:

Windows 7 默认主题(Aero – 经微软许可使用)

Windows 8 默认主题(称为 Windows,经微软许可使用)

上述图片展示了简单应用程序如何适应不同版本的 Windows 主题。这些插图使用的是默认主题,但用户可以在他们的桌面上应用额外的自定义设置,这些设置也将适用于使用 Walk 构建的应用程序。

布局

Walk 布局(如它们所受启发的 Qt Widget 布局)基于有限的基于网格的变体。已实现的布局列表包括以下内容:

  • GridLayout: 元素以常规网格布局

  • VBoxLayout: 元素被放置在单列中

  • HBoxLayout: 元素在一行中对齐

如果你已经探索了 Qt UI Builder 或熟悉 Qt,你可能期望有一个第四个布局 FormLayout,但目前 Walk 中没有这个布局。然而,可以通过使用两列 GridLayout 并应用所需的对齐属性来模拟它。

除了标准布局外,还有各种控件(其中一些在最终界面中不可见),有助于分组 UI 元素并提供更满意的布局。以下是最常用的这些控件:

  • Splitter: 在两个子控件之间放置一个可拖动的分隔条

  • Spacer: 用于创建视觉填充,以便项目可以收缩而不是填充空间

  • 分隔符: 在工具栏或菜单等界面元素之间提供视觉分隔

  • ScrollView: 提供可滚动内容的标准控件

  • GroupBox: 带有边框和可选标题的视觉控件容器

  • Composite: 用于逻辑分组项的控件容器

让我们开始通过创建一些使用声明式 API 的 Go 代码来实现我们的电子邮件应用用户界面。我们从一个设置了合适的MinSizeMainWindow开始,并添加一个HSplitter来存放我们的内容。TreeView用于在分割器的左侧列出电子邮件(作为Children列表中的第一个项目),在右侧(列表中的第二个项目)是一个设置为使用Grid布局的Composite——这是最接近我们设计的表单布局。在组内,我们添加了许多子Label实例,我们将在这里显示电子邮件详情(将在“与 GUI 通信”部分更新):

MainWindow{
   Title:   "GoMail",
   Layout:  HBox{},
   MinSize: Size{600, 400},
   Children: []Widget{
      HSplitter{
         Children: []Widget{
            TreeView{},
            Composite{
               Layout: Grid{Columns: 3},
               Children: []Widget{
                  Label{
                     Text:       "subject",
                     Font:       Font{Bold: true},
                     ColumnSpan: 3,
                  },
                  Label{
                     Text: "From",
                     Font: Font{Bold: true},
                  },
                  Label{
                     Text:       "email",
                     ColumnSpan: 2,
                  },
                  Label{
                     Text: "To",
                     Font: Font{Bold: true},
                  },
                  Label{
                     Text:       "email",
                     ColumnSpan: 2,
                  },
                  Label{
                     Text: "Date",
                     Font: Font{Bold: true},
                  },
                  Label{
                     Text:       "email",
                     ColumnSpan: 2,
                  },
                  TextEdit{
                     Text:       "email content",
                     ReadOnly:   true,
                     ColumnSpan: 3,
                  },
               },
            },
         },
      },
   },
}

上述代码可以通过替换上一个“hello world”示例中的MainWindow,重新编译,然后再次运行来执行。如果你设置了一个新项目,请记住包括清单文件,并再次运行rsrc!运行时,它应该看起来像以下截图,在 Windows 10 上拍摄:

图片

使用 Walk 的声明式 API 的基本电子邮件界面

接下来,我们将创建一个具有类似布局的Dialog,用LineEditTextEdit替换Label实例,以输入新电子邮件的详细信息。最后,我们添加另一个具有HBox布局的Composite,其中包含用于CancelSendPushButton实例,以及一个HSpacer来完成布局:

Dialog{
   Title:   "New GoMail",
   Layout:  Grid{Columns: 3},
   MinSize: Size{400, 320},
   Children: []Widget{
      Composite{
         Layout: Grid{Columns: 3},
         Children: []Widget{
            LineEdit{
               Text:       "subject",
               Font:       Font{Bold: true},
               ColumnSpan: 3,
            },
            Label{
               Text: "To",
               Font: Font{Bold: true},
            },
            LineEdit{
               Text:       "email",
               ColumnSpan: 2,
            },
            TextEdit{
               Text:       "email content",
               ColumnSpan: 3,
            },
            Composite{
               Layout:     HBox{},
               ColumnSpan: 3,
               Children: []Widget{
                  HSpacer{},
                  PushButton{Text: "Cancel"},
                  PushButton{Text: "Send"},
               },
            },
   },
}

如果你想测试这段代码,最简单的方法是将Dialog替换为MainWindow,然后像主布局一样运行它(别忘了在继续之前将其改回)。

一旦我们有了事件处理,它就会像对话框一样打开,这就是为什么在先前的列表中它不是一个MainWindow。运行代码应该产生以下截图:

图片

使用 Walk 的声明式 API 创建撰写电子邮件视图

完成主界面功能布局代码所需的所有内容就是这些。接下来,让我们添加菜单、工具栏,并为已定义的按钮设置操作。

工具栏和菜单

使用声明式 API 添加菜单和工具栏非常简单。MainWindow结构体有一个Menu字段(它是一个MenuItem切片)和一个ToolBar字段(它接受一个包含Items字段的ToolBar结构体,该字段用于MenuItem列表)。列表中的每个项目都是一个Action、一个Separator或另一个与我们在早期创建的设计相匹配的Menu

声明式 API 中的每个Action都期望一个用于菜单显示的Text字符串。工具栏也使用此内容作为工具提示,并在样式设置为ToolBarButtonTextOnly时用于显示。一个Image字段允许你设置工具栏的图标,如果你想要引用安装的图像或与你的应用一起分发的图标。最重要的是OnTriggered字段,它应该设置为在按钮或菜单项被点击时执行的func()

以下代码用于设置我们在布局部分创建的MainWindow上的菜单:

MenuItems: []MenuItem{
   Menu{
      Text: "File",
      Items: []MenuItem{
         Action{
            Text: "New",
         },
         Action{
            Text: "Reply",
         },
         Action{
            Text: "Reply All",
         },
         Separator{},
         Action{
            Text: "Delete",
         },
         Separator{},
         Action{
            Text: "Quit",
         },
      },
   },
   Menu{
      Text:  "Edit",
      Items: []MenuItem{
         Action{
            Text: "Cut",
         },
         Action{
            Text: "Copy",
         },
         Action{
            Text: "Paste",
         },
      },
   },
   Menu{
      Text: "Help",
   },
},

工具栏的代码几乎完全相同,因此省略了细节,但您可以通过ToolBar字段将其添加到MainWindow中,如下所示:

ToolBar: ToolBar{
   Items: []MenuItem{
      Action{
         Text: "New",
      },

// full listing omitted but is available in the book's example code

   },
   ButtonStyle: ToolBarButtonTextOnly,
},

添加的代码的结果应该是一个类似于以下截图的窗口:

截图

添加了菜单和工具栏的主要电子邮件界面

如果新按钮的代码没有为您工作,请不要担心——完整的应用程序源代码可在github.com/PacktPublishing/Hands-On-GUI-Application-Development-in-Go下载。在用户界面代码完成之前,我们应该添加一些代码,这将帮助我们导航应用程序。最简单的是文件菜单中的退出项。只需将以下代码添加到前面的Quit操作中:

OnTriggered: func() {
   walk.App().Exit(0)
},

我们的编辑对话框的打开稍微复杂一些,因为对话框需要知道它从哪个父窗口加载。为此,创建一个名为window的本地变量,其类型为*walk.MainWindow,并使用以下行将其分配给MainWindow声明性 API:

AssignTo:  &window,

然后,您可以在New操作处理程序中引用它,其中NewCompose是一个创建电子邮件编辑窗口的函数:

OnTriggered: func() {
   NewCompose().Run(window)
},

最后,我们应该为我们的编辑对话框中的按钮设置默认行为。为此,我们需要声明两个*walk.PushButton变量,分别分配给CancelSend按钮。然后通过CancelButtonDefaultButton字段将这些变量传递给对话框定义,我们就能获得适当的行为:

DefaultButton: &send,
CancelButton:  &cancel,

现在,让我们将取消按钮设置为关闭对话框——您需要创建一个walk.Dialog变量来AssignTo声明性 API,就像主窗口一样。完成这些步骤后,无论是点击取消按钮还是按Esc键,都应该关闭编辑窗口:

OnClicked: func() {
   dialog.Cancel()
},

与 GUI 通信

为了填充用户界面,我们需要定义一个数据模型并加载一些测试数据。在本书的代码中,有一个客户端包包含一个数据模型和一些测试数据,用于模拟电子邮件服务器。我们将通过将github.com/PacktPublishing/Hands-On-GUI-Application-Development-in-Go/client包导入到本项目的 Go 文件中来使用该包。我们不会在本章中详细介绍该包的细节,但我们将引用其定义的client.EmailServerclient.EmailMessage类型。电子邮件消息的定义如下——字段名称在加载 UI 中的电子邮件详细信息时将很有用:

type EmailMessage struct {
   Subject, Content string
   To, From         Email
   Date             time.Time
}

视图模型

为了与 Walk 用户界面通信,我们需要定义另一个数据模型。这个视图模型旨在以声明性 API 能够理解的方式传递信息。我们将创建一个名为EmailClientModel的类型,它将处理将我们的客户端代码中的数据转换为我们的用户界面定义。创建一个新文件model.go,在那里你可以开始定义这些模型。代码的第一部分允许设置电子邮件服务器,从而使电子邮件列表相应更新。

为了简洁,省略了walk.TreeModel的琐碎方法——你可以在本书的完整代码列表中找到它们:

type EmailClientModel struct {
   Server *client.EmailServer

   root walk.TreeItem

   itemsResetPublisher  walk.TreeItemEventPublisher
   itemChangedPublisher walk.TreeItemEventPublisher
}

// TreeModel methods omitted - see full code listing

func (e *EmailClientModel) SetServer(s *client.EmailServer) {
   e.Server = s

   e.root = NewInboxList(s.ListMessages())
   e.itemsResetPublisher.Publish(e.root)
}

func NewEmailClientModel() *EmailClientModel{
   return &EmailClientModel{}
}

这个模型的电子邮件列表需要将我们的电子邮件列表作为树中的项目来表示,而不是客户端代码返回的简单列表。为了支持这一点,我们需要另一个类型,EmailModel,它实现了walk.TreeItem接口。在这里,我们又省略了琐碎的细节——每个电子邮件项目永远不会包含子元素,因此我们可以忽略这种复杂性:

type EmailModel struct {
   email  *client.EmailMessage
   parent walk.TreeItem
}

// TreeItem functions omitted - see full code listing

我们希望将我们的电子邮件分组在Inbox标题下,因此我们需要构建根节点,然后在其内部填充电子邮件列表。为此,我们定义了一个额外的类型InboxList,它也实现了walk.TreeItem接口,但这次它将允许访问它所持有的子列表(电子邮件)。我们还需要编写一个方法,用于从消息列表(客户端代码将提供给我们)构建收件箱列表。看看这个代码片段中的最终方法是如何为每条消息创建EmailModel实例并将它们添加到收件箱列表中的:

type InboxList struct {
   emails []walk.TreeItem
}

func (i *InboxList) Text() string {
   return "Inbox"
}

func NewInboxList(l []*client.EmailMessage) *InboxList {
   list := &InboxList{}

   for _, item := range l {
      list.emails = append(list.emails, &EmailModel{item, list})
   }

   return list
}

详细视图

现在我们已经构建了数据模型,让我们显示加载的数据。从电子邮件详细视图开始,我们将使用 Walk 的声明性 API,DataBinder。这允许我们避免在每次加载新消息时手动设置每个标签上的数据。为了正确工作,我们还需要创建一个walk.DataBinder来分配——这将处理实际的绑定:

emailDetail *walk.DataBinder

然后,我们可以更新显示电子邮件信息的Composite小部件,以使用这种数据绑定。让我们也通过DataSource字段设置默认内容。这些信息将来自模型,我们将在稍后初始化它:

DataBinder: DataBinder{
   AssignTo: &emailDetail,
   DataSource: model.Server.CurrentMessage(),
},

然后,每个项目只需将其静态Text字段更改为适当的Bind()调用;参数将是视图模型部分中描述的client.EmailMessage类型上的字段名称:

Text:       Bind("Subject"),

对于Date字段,我们无法直接绑定time.Time类型,因此使用DateString()辅助函数代替:

Text:       Bind("DateString"),

最后,让我们创建一个辅助方法,允许我们更新当前绑定的电子邮件消息:

func (g *GoMailUIBrowse) SetMessage(email *client.EmailMessage) {
   g.emailDetail.SetDataSource(email)
   g.emailDetail.Reset()
}

列表视图

我们电子邮件列表的大部分工作是在前面的模型代码中完成的——现在我们需要将其连接到用户界面。以下代码设置了一个walk.TreeView类,我们用它来跟踪当前项,并将其分配给声明性的TreeView。之后,设置模型,然后传递一个函数,当当前项发生变化时会通知我们:

emailList *walk.TreeView

TreeView{
   AssignTo: &g.emailList,
   Model: model,
   OnCurrentItemChanged: func() {
      item := g.emailList.CurrentItem()

      if email, ok := item.(*EmailModel); ok {
         g.SetMessage(email.email)
      }
   },
},

所有这些准备就绪后,应用程序将使用model.Server.CurrentMessage()通过DataBinder的默认DataSource从当前电子邮件消息加载电子邮件详情。当点击主列表时,传递给OnCurrentItemChanged的函数会检查该项是否为EmailModel,如果是,则更新详细视图。最后,我们需要设置前面代码中使用的模型,如下所示:

model := NewEmailClientModel()
model.SetServer(client.NewTestServer())

此模型用于设置列表内容,也用于设置详细视图的默认内容。构建并运行后,应用程序现在应该看起来像一个完整(尽管是基本的)的电子邮件客户端:

图片

加载了一些测试数据的我们的电子邮件界面

背景处理

所有使用 Walk 的用户界面代码都必须在主线程上运行;这是处理小部件的 winAPI 的限制。这意味着任何在后台的工作必须在运行任何 UI 代码之前更改线程。这是通过在walk.Window上使用Synchronize()函数来完成的。它接受一个函数作为参数,并确保包含的代码将被适当地运行。

为了处理当新电子邮件到达时的更新,我们创建了一个新的函数incomingEmail(),该函数将更新我们的电子邮件列表模型。此函数将导致将电子邮件添加到模型中,这将发生在主线程上,以便用户界面可以更新以反映新数据:

func (g *GoMailUIBrowse) incomingEmail(email *client.EmailMessage, model *EmailClientModel) {
   g.window.Synchronize(func() {
      model.AddEmail(email)
   })
}

为了支持此更改,我们需要更新EmailClientModel以添加此新的AddEmail()函数。该函数将向列表添加一个项并发布数据重置事件:

func (e *EmailClientModel) AddEmail(email *client.EmailMessage) {
   e.root.Add(email)
   e.itemsResetPublisher.Publish(e.root)
}

这反过来需要一个在InboxList类型中的Add()函数,我们创建它以向模型提供数据:

func (i *InboxList) Add(email *client.EmailMessage) {
   i.emails = append(i.emails, &EmailModel{email, i})
}

最后,我们需要监听Incoming()服务器通道,它将每个新电子邮件传递到我们的应用程序。由于此通道读取将阻塞,直到收到电子邮件,因此必须在单独的 goroutine 中运行——这就是为什么需要背景处理。当电子邮件到达时,我们只需调用我们刚刚创建的函数,传递新的email和一个对model的引用,我们应该刷新它:

server := client.NewTestServer()
model.SetServer(server)

go func() {
   incoming := server.Incoming()
   for email = range incoming {
      g.incomingEmail(email, model)
   }
}()

在此代码到位后,您将看到当新电子邮件到达时电子邮件列表更新。然后可以点击电子邮件以查看详情。

在跨平台应用程序中漫步

Walk 显然是一个针对 Microsoft Windows 平台创建图形用户界面的库——但这并不意味着使用 Walk 构建你的应用程序会限制你只能在 Windows 上使用。通过在第三章中探索的技术,“Go to the Rescue!”,我们可以设置 Windows 的代码在为该平台构建时条件性地包含,并引入其他可能为其他平台提供用户界面的文件。

第一步是更新我们迄今为止构建的文件,以便仅在 Windows 上构建。我们使用构建约束注释格式来完成此操作(如果你愿意,你也可以在这一步使用文件命名):

// +build windows

package main

...

然后,我们引入了一个新的文件,用于处理我们在不同平台上的回退情况。对于这个简单的项目,我们将它称为 nonwindows.go,因为其内容将在任何非 Windows 计算机上运行。在这个文件中,我们放置了一小段代码,如果应用程序在任何不受支持的平台上启动,它将打印失败消息并退出。请注意,这里的构建约束被设置为在任何非 Windows 平台上编译;这也将更新以匹配你的项目可能有的任何回退情况:

// +build !windows

package main

import "log"

func NewMailUIBrowse() {
   log.Fatalln("GoMail with Walk only works on windows")
}

注意 NewMailUIBrowse() 函数的名称——这是我们用于加载和运行主 GoMail 浏览界面的通用方法名。你可能需要更新之前用于运行应用程序的方法的名称。最可能的是,你使用了 main(),但我们需要提供一个包含该方法的新的 main.go 文件。这个新文件是项目中唯一没有构建约束的文件。它将编译为任何平台,并且在运行时,它将执行为目标平台编译的 NewMailUIBrowse() 方法:

package main

func main() {
   NewMailUIBrowse()
}

如果我们切换到另一个操作系统,比如 macOS,并现在编译代码,应该没有编译错误。运行应用程序将显示一个简单的错误消息,并立即退出。显然,这段代码可以比仅仅显示错误消息做更多有意义的事情:

图片

因此,你可以看到我们如何使用 Walk 开发特定于 Windows 的用户界面。作为多平台策略的一部分,这可以帮助确保你的受众在 Windows 上的平台集成度更高,或者你可能希望为你的应用程序的某些部分提供特定于平台的实现。无论原因如何,你可以看到在用 Go 进行跨平台应用程序构建时包含多个平台特定替代方案是多么容易。

摘要

在本章中,我们首先通过查看 Windows 图形应用程序开发的 Walk API 来开始对 GUI 工具包的探索。我们了解了如何运行基于 Go 的 Windows 应用程序,并学习了 Walk 项目是如何构建为独立的声明性 API 和本地 API 的。我们还看到了每个 API 提供的不同好处以及它们如何最佳地组合以创建一个简单的应用程序。

由于 Walk 设计深受 Qt 项目(我们将在第七章,Go-Qt - 多平台与 Qt)的启发,我们得以利用 Qt Creator 的界面设计功能来模拟一个基本的电子邮件应用程序,然后使用声明性 API 构建它。这个电子邮件应用程序是一个设计,可以用于每个工具包探索章节。为了支持示例应用程序,我们导入了这个书中源代码提供的另一个包,它提供了一些数据模型和测试数据。通过结合我们的 UI 代码、电子邮件客户端库以及 Walk 工具包的数据绑定功能,我们能够创建一个简单的电子邮件应用程序,该程序可以在 Windows 平台上使用系统提供的控件原生运行。通过一些小的调整,展示了这可以成为更广泛的跨平台策略的一部分,其中每个平台的图形表示由不同的工具包提供。

在下一章中,我们将把重点转移到提供跨多个平台原生外观和感觉的代码。我们将特别关注 andlabs UI——一个旨在提供与当前操作系统相匹配的外观和感觉的 GUI 工具包。如果用于 Windows,它将与 Walk 相似,但它也可以适应不同的桌面平台,而只需编写一次用户界面代码。

第五章:andlabs UI - 跨平台原生 UI

就像我们在上一章中探索的 Walk API 一样,andlabs UI 旨在在操作系统原生小部件之上创建 Go API,但与 Walk 不同,andlabs UI 项目支持单个 API 的多个操作系统。这意味着使用该 API 创建的图形应用程序可以使用相同的源代码在 Windows、macOS 和 Linux 上编译和运行。

在本章中,我们将探讨与操作系统外观和感觉相匹配的跨平台原生应用程序。特别是,我们将涵盖以下主题:

  • 背景和历史

  • 开始使用 andlabs UI

  • 多平台通用的 API

  • 构建用户界面

  • 多个原生 GUI 的挑战

在我们开始探讨使用原生小部件工具包的跨平台 API 的益处和复杂性之前,让我们更深入地了解一下项目的背景。

背景和历史

andlabs UI 项目是为了提供一个简单易用的方法来使用 Go 创建原生图形应用程序。API 是最小化的,因为它旨在只提供创建 GUI 程序所必需的内容。核心是一个 C 库,它隐藏了平台特定的 API,允许主库管理 Go GUI API 的惯用考虑因素。最近,这个 C 库(libui)被移动到一个单独的项目中,为了方便开发者,它被包含在 Go 项目中。

项目中包含的可用小部件的演示——当在 Linux 计算机上运行时,它将看起来像以下截图:

图片

andlabs UI 的小部件演示

作为平台原生实现,andlabs UI 中的小部件在每个操作系统上看起来都不同。在 Windows 和 macOS 上,库使用原生小部件集,而在 Linux 上则使用 GTK+库。这种方法创建的应用程序与当前计算机上的其他软件保持一致,因此应该对用户来说很容易理解。这种方法功能强大,具有实质性的好处,但可能给应用程序开发者带来复杂性。我们将在本章中探讨这种方法的益处和挑战,但首先让我们从一个简单的hello world应用程序开始运行。

开始使用 andlabs UI

andlabs UI 在大多数平台上都很容易开始使用,但具体细节因系统而异。由于链接到许多不同的操作系统的原生小部件工具包,可能会有一些隐藏的复杂性,尤其是在开发基于 Linux 的应用程序时。在我们能够构建第一个基于 andlabs 的应用程序 GUI 之前,有一些设置是必需的。我们需要准备当前的开发环境以与原生小部件一起工作。

先决条件

作为利用每个平台原生控件的 API,Windows、macOS 和 Linux 的先决条件各不相同。在此部分需要安装的任何包都将由您开发的应用程序的用户所需要。还需要确保 CGo 运行(Go 代码调用 C 函数的能力在第三章,Go 来拯救!中有所说明),这可能需要安装额外的构建工具。

Microsoft windows

Windows 上使用的原生控件是通用控件——与我们在第四章,Walk - 构建图形化窗口应用程序中详细探讨的 Walk 库所使用的相同。由于它们是操作系统的原生控件,因此在使用 Windows Vista 或更高版本时不需要安装。如果您想支持更早的版本(回溯到 Windows XP),如果安装至少 ComCtl32.dll 版本 6.0,则是有可能的。

Andlabs UI,像本书中介绍的其他许多工具包一样,需要 CGo 的存在来利用原生库。在完整的开发系统中,这可能是已经设置好的。如果您不确定,或者想要回顾如何设置 Cgo 依赖项,请检查附录中的设置 CGo部分,安装详情

macOS

在为 macOS 开发时,直接使用原生控件。因为这些控件由操作系统为 macOS 的每个最新版本提供,所以不需要额外的库。

CGo 支持对于 andlabs UI 是必需的,这需要安装 XCode 命令行工具。如果您还没有设置好,请检查附录中的设置 CGo部分,安装详情

Linux

在 Linux 上,andlabs UI 使用 GTK+ 控件库(我们将在第六章,Go-GTK - 多平台与 GTK中详细探讨)因此,必须在您的计算机上安装此库。如果您已安装 Gnome 桌面或使用 GTK+ 的其他应用程序(如 Gimp),库已安装。如果没有,您将需要使用系统包管理器安装此依赖项。

虽然这是一个简单的任务,但包名在不同的系统之间可能会有所不同——它可能被称为 gtk3-devellibgtk-3-devgtk3。按照常规方式安装它,您就准备好设置 andlabs UI 库了。

要在 Linux 上启用 CGo,这是 andlabs UI 所必需的,您必须安装一个编译器(gcc 或 clang)。这通常已经安装在开发 Linux 安装中,但如果您不确定,可以遵循附录中的设置 CGo部分,安装详情

设置

andlabs UI 的设置非常简单——它只需要你使用 Go 工具获取库。你只需要执行go get github.com/andlabs/ui。在 Windows、macOS 和 Linux 上,这完全一样,前提是你已经安装并运行了 Go(如果没有,请查看附录中的安装 Go部分,安装细节)。如果你遇到错误,首先检查你的 Go 安装是否是最新的——这些问题通常很快就会得到修复——并且你已经按照描述设置了 CGo。

重新构建 UI 库(解决方案)

andlabs UI 构建所基于的 libui 库与主库一起打包,但有时这会过时或没有为你的电脑的精确配置编译。如果你看到错误,例如relocation R_X86_64_32S against '.rodata' can not be used when making a shared object,这些说明将有所帮助。如果你在安装时没有看到错误,请跳过这些提示!

以下命令将为你的电脑重新构建 libui 文件。它假设使用 Linux bash shell,因为这种情况最有可能发生在 Linux 电脑上。这不会是使用你构建的应用程序的人所需要的——只是用于设置你的开发环境。libui 项目是从 GitHub 下载的,并使用标准的 cmake 工具构建。务必指定-DBUILD_SHARED_LIBS=OFF参数,因为我们必须构建一个静态库以嵌入 Go 库中:

图片

如果打包版本不起作用,重新构建 libui

命令设计为无需任何环境配置即可工作,但你需要安装 cmake——如果你的系统包管理器找不到它,它将能够安装。一旦构建完成,生成的库out/libui.a应该移动到 UI 项目中并适当地重命名。

代码

现在库已经安装,是时候编写一些代码了。以下示例是 andlabs UI 的hello world示例,我们在第四章,Walk - 构建图形化窗口应用程序中使用过。首先,将以下代码输入到一个新文件中,命名为hello.go

package main

import "github.com/andlabs/ui"

func main() {
        err := ui.Main(func() {
                window := ui.NewWindow("Hello", 100, 50, false)
                window.SetMargined(true)
                window.OnClosing(func(*ui.Window) bool {
                        ui.Quit()
                        return true
                })

                button := ui.NewButton("Quit")
                button.OnClicked(func(*ui.Button) {
                        ui.Quit()
                })
                box := ui.NewVerticalBox()
                box.Append(ui.NewLabel("Hello World!"), false)
                box.Append(button, false)

                window.SetChild(box)
                window.Show()
        })
        if err != nil {
                panic(err)
        }
}

这段代码相当简单,但还有一些事情我们应该讨论,所以让我们一步步来。通常情况下,对于一个简单的图形化 Go 应用程序,我们在定义main()函数之前使用main包并导入工具库。然后我们调用 andlabs UI 应用程序的主入口点ui.Main(),它接受一个函数来构建和显示应用程序的 GUI。如果发生错误,我们将二进制文件导致 panic,因为接口无法加载。

在我们的用户界面代码中,我们首先使用ui.NewWindow()设置一个窗口,带有标题和默认大小,最后一个参数表示窗口是否应该有菜单栏。我们打开默认边距(填充),并通过调用ui.Quit()分配一个关闭函数以退出应用程序。接下来,使用ui.NewButton()创建一个新的按钮,标签为Quit,点击它也会退出应用程序。这些组件使用ui.NewVerticalBox()容器进行布局。一个Hello World!标签和一个Quit按钮都被添加。ui.BoxAppend()方法接受一个布尔参数stretchy——如果设置为true,则组件将扩展以填充可用空间。最后,我们使用SetChild()设置窗口的内容,并使用Show()显示它。

构建

构建这个示例应用非常简单。例如,在下面的屏幕截图中,我们正在 Linux 计算机上运行一个终端,并简单地执行go build hello.go。这会创建一个可以直接运行的可执行文件,无需安装 Go 工具:

图片

为当前 Linux 环境构建

在 Windows 计算机上构建(只要 gcc 在命令行路径中——参见前面提到的先决条件部分)与在 Linux 或 macOS 上构建一样简单:

图片

在 Windows 上构建 hello world 应用程序

在这些示例中,我们是在它们将要运行的平台构建应用程序。与 andlabs UI 相比,交叉编译是 Go 工具链的优势之一,但更为复杂。

运行

应用程序可以通过命令行(Linux 或 macOS 上的./hello,Windows 上的hello.exe)运行,或者简单地通过双击系统文件浏览器中的文件图标。无论哪种方式,结果都应该是出现一个熟悉的hello world窗口。这将在多个操作系统上看起来非常相似,但外观和感觉会有所不同:

在 Windows 上这与 Walk 相同:

图片

Andlabs UI 在 Linux 上的 hello world:

图片

在 macOS 上运行的 hello world:

图片

多平台通用的 API

andlabs UI 项目提供了一个通用的 API,它封装了 Windows、Linux 和 macOS 上的操作系统原生小部件。由于这种方法,它主要限于最低共同分母级别的功能,但考虑到这些工具包的相似性,生成的 API 出人意料地丰富。

所有小部件都继承自ui.Control接口,该接口定义了所有控件必须实现的Show()Hide()Enable()Disable()方法(具有明显的预期行为)。此外,它还定义了LibuiControl()Handle()方法,分别提供对低级 libui 和操作系统小部件的指针。这些方法的使用通常不推荐,因此在本章中未涉及。

与第四章中受 Qt 启发的 Walk API 相比,andlabs UI 的布局功能似乎有限,因为管理 GUI 视觉流的控件较少。本地控件(尽管广泛相似)编程方式不同,并且不一定与相同的顶级布局定义兼容。您将在下一节中看到,容器通常被设置为期望一个子控件,该子控件使用ui.Box控件进行布局。在许多其他工具包中可能被视为容器的小部件,在 andlabs UI 中作为单个控件管理(例如ui.RadioButtons),以便可以内部处理操作系统特定的实现。

控件

andlabs UI 中定义的所有小部件都实现了Control接口,因此可以通过SetChild()(除ui.Window之外)显示、隐藏、启用或禁用,并将它们设置为窗口的内容。出于明显的原因,窗口不能是任何其他ui.Control的子控件。窗口的show()hide()定义将由操作系统或小部件工具包设置,禁用窗口内容的方式也是如此。

Box

很可能任何窗口的内容都将设置为 Box——这是因为它是唯一一个提供将多个控件组合在一起方式的控件。这是一个没有可见容器的控件,这是 withinlabs UI 中的基本布局机制。您可以使用ui.NewHorizontalBox()ui.NewVerticalBox()创建一个新的 Box,它以线性排列的方式水平或垂直布局其子控件。在水平排列中,子项将具有相同的高度(这将与最高子项所需的高度相匹配),而在垂直(堆叠)配置中,它们的宽度将相同。

向 Box 添加子控件的方法是调用Append()函数,该函数接受一个ui.Control子控件参数和一个bool可伸缩参数。子控件将被添加到组件列表中,而可伸缩参数决定了如何填充可用空间。当可伸缩参数为true时,项目将扩展以填充额外空间;如果为false,则观察最小尺寸。如果有多个组件的可伸缩性被打开,则额外空间将在它们之间平均分配。

如果小部件之间留有一定的空间,通常会对用户界面提供更好的视觉流程。提供了一个合适的方法,SetPadded(),它将在 Box 中的子小部件之间设置标准空间。这个大小由小部件工具包的标准度量设置,并且会因平台而异。以这种方式应用的填充放置在子组件之间——对于外部(周围)空间,您应该设置边距。边距在嵌入子控件的控件中可用——在本章中被称为“容器”。

容器

容器,或允许我们嵌入另一个控件的控件,通常通过其类型定义中存在的 SetChild()SetMargined() 函数来识别。由于这些控件相互嵌入,通常希望围绕内容有边距——这是 ui.Box 中填充的外部等效物。这可以通过使用 SetMargined(true) 来开启,并且将在子控件周围引入系统定义的边距大小。

以下容器是作为 andlabs UI 的一部分定义的:

  • Window 控件描述了一个应用程序窗口,并且是 andlabs UI 图形应用程序的主要入口点。主要内容是通过 SetChild() 设置的。如果是一个简单的内容窗口,则可能需要开启边距,如果您正在添加进一步的容器控件,则可能不需要开启。

  • Group 定义了一个围绕子小部件(通过 SetChild() 分配)的框架,并带有标题(通过 ui.NewGroup() 传递)。组控件的外观在不同系统之间可能会有所不同;在某些系统上,它可能是一个围绕子小部件的框,而在其他系统上则可能不可见。与窗口控件一样,在决定是否启用边距之前,您应该考虑子内容。

  • Tab 与其他控件略有不同,因为它可能包含多个子控件——但一次只能看到一个。由于存在多个子控件,添加子控件的方法是 Append(string, Control)——第一个参数是要在标签上显示的标题,第二个参数是此新标签的子控件。为了适应多个子控件,边距控件也进行了调整——您需要调用 SetMargined(int, bool),其中第一个参数是标签索引,后者是用于打开或关闭边距的常规参数。

那就是管理其他控件的所有控件,让我们看看构成 andlabs UI 应用程序的主要小部件的详细信息。

小部件

剩余的小部件对任何桌面图形应用程序的开发者或实际上使用它们的人来说都很熟悉。以下是每个小部件的功能或限制的快速概述:

  • Button: 一个带有标签的标准 pushbutton,并具有 onClicked 回调

    • Checkbox: 一个可切换的条目,可以是勾选的或未勾选的;在更改时将触发 onToggled 回调
  • Combobox: 一个提供字符串列表以供选择的控件

  • DateTimePicker: 一个用于输入日期和/或时间的字段——配置是通过不同的构造函数设置的

  • Entry: 一个单行文本输入控件,可以是只读的;它支持用于更改事件的 onChanged 处理程序

  • Label: 一个简单的只读文本组件,用于注释用户界面

  • ProgressBar: 一个水平条,用于指示进度;值范围从 0 到 100

  • RadioButtons: 一个用于展示选项列表的控件,如复选框,但只能选择一个

  • Separator: 一个水平或垂直线,用于在视觉上分隔其他控件

  • Slider:一个水平条,可以通过移动指示器在设定的最小值和最大整数值之间选择

  • Spinbox:一个输入框,可以通过上下按钮选择介于最小值和最大值之间的整数

在这个列表中一个明显的遗漏是菜单或工具栏小部件;在撰写本文时,它们不包括在 andlabs UI 工具包中。接下来,我们将查看一个可能的解决方案,通过访问底层的 libui 来处理菜单(不幸的是,这不会适用于工具栏)。

菜单

在撰写本文时,andlabs UI 没有公开菜单 API(尽管ui.NewWindow()接受一个hasMenubar参数)。目前有一个项目正在进行中,旨在正确公开菜单功能到 Go API,但到目前为止,它仅在你与底层的 libui C 代码一起工作时才可用。C 库中定义的菜单可以通过添加一些 CGo 代码从 Go 项目中访问,例如以下代码:

/*
void onMenuNewClicked(uiMenuItem *item, uiWindow *w, void *data) {
   void menuNewClicked(void);
   menuNewClicked();
}

int onQuit(void *data) {
   return 1;
}

void loadMenu() {
   uiMenu *menu;
   uiMenuItem *item;

   menu = uiNewMenu("File");
   item = uiMenuAppendItem(menu, "New");
   uiMenuItemOnClicked(item, onMenuNewClicked, NULL);
   uiMenuAppendSeparator(menu);
   item = uiMenuAppendQuitItem(menu);
   uiOnShouldQuit(onQuit, NULL);

   menu = uiNewMenu("Help");
   item = uiMenuAppendItem(menu, "About");
}
*/
import "C"

代码片段为“新建”菜单项设置了一个点击处理程序,并为“退出”菜单项(由于 macOS 以不同的方式处理退出菜单项,因此它是一个特殊项)设置了一个退出处理程序。然后我们有一个loadMenu()函数,它设置了一个文件菜单,子项被添加到其中,有一个分隔符,以及一个目前为空的“帮助”菜单。

要正确编译此代码,需要cfuncs.go文件知道头文件和 C 库存储的位置。在运行此代码之前,请确保CFLAGSLDFLAGS显示了正确的位置。虽然构建菜单的代码并不复杂,但 CGo 配置和链接相当复杂,因此可能不推荐这样做:

图片

启动菜单示例

结果应该类似于这张截图,它是在 Linux 计算机上拍摄的:

图片

andlabs libui 菜单

在本书的代码仓库中有一个完整的菜单项目。不幸的是,它不是一个跨平台项目,可能无法在所有操作系统或 Go 版本上正确执行。

面积和绘图

ui.Area小部件呈现一个类似于画布的控制元素——一个可以使用路径和其他绘图原语绘制的表面。在撰写本文时,这些 API 都是ui包的一部分,但它们可能很快就会移动到ui/draw,以便将它们与主要控件 API 分开。一个区域可以是它占据的空间的大小,也可以更大,在这种情况下,它将嵌入到一个可滚动的控件中。所需的行为基于是否调用ui.NewArea(handler)ui.NewScrollingArea(handler, width, height)(其中 width 和 height 是期望的内容大小)。

面积背后的逻辑是 ui.AreaHandler,它是面积构造函数的任意一个的第一个参数。它的 Draw(*ui.Area, *ui.AreaDrawParams) 函数在工具包需要重新绘制面积时被调用,第一个参数是它注册的面积,第二个提供了上下文,例如要填充的剪辑矩形。除了绘制面积的内容外,处理器还负责处理鼠标和键盘事件,当鼠标事件发生时调用 MouseEvent(*ui.Area, *ui.AreaMouseEvent),对于任何键盘事件调用 KeyEvent(*ui.Area, *ui.AreaKeyEvent)

为了更仔细地查看绘图功能,让我们运行一段小代码。在这个例子中,我们创建了一个新的 ui.AreaHandler 类型(命名为 areaHandler),它实现了接口中所有必需的函数。唯一感兴趣的方法是 Draw() 调用,它包含在这里:

func (areaHandler) Draw(a *ui.Area, dp *ui.AreaDrawParams) {
   p := ui.NewPath(ui.Winding)
   p.NewFigure(10, 10)
   p.LineTo(dp.ClipWidth - 10, 10)
   p.LineTo(dp.ClipWidth - 10, dp.ClipHeight - 10)
   p.LineTo(10, dp.ClipHeight - 10)
   p.CloseFigure()
   p.End()

   dp.Context.Fill(p, &ui.Brush{Type:ui.Solid, R:.75, G:.25, B:0, A:1})
   dp.Context.Stroke(p, &ui.Brush{Type:ui.Solid, R:.25, G:.25, B:.75, A:.5},
      &ui.StrokeParams{Thickness: 4, Dashes: []float64{10, 6}, Cap:ui.RoundCap})
   p.Free()
}

此代码分为两部分:首先我们设置一个 ui.Path,然后我们使用路径进行绘制。路径(命名为 p)被设置为在正在绘制的剪辑区域内部 10 像素处——这样做是为了演示画布背景(在每次 Draw() 调用之前都会清除绘图区域)。接下来,我们使用这个路径在绘图上下文 (dp.Context) 中进行 Fill()Stroke()Fill() 调用指定了一个不透明的橙色画笔(前述代码中的 A 代表 alpha)。然后,我们使用相同的路径调用 Stroke()(这将绘制一个围绕填充框的线)。我们要求一个四像素宽的虚线,带有圆形端点——这次使用半透明的蓝色颜色。

要将此绘制到屏幕上,我们需要配置一个窗口,使其具有一个 ui.Area 控制器,该控制器扩展以填充窗口,如下所示:

func main() {
   err := ui.Main(func() {
      window := ui.NewWindow("Draw", 200, 150, false)
      window.SetMargined(false)
      window.OnClosing(func(*ui.Window) bool {
         ui.Quit()
         return true
      })

      handler := new(areaHandler)
      box := ui.NewVerticalBox()
      box.Append(ui.NewArea(handler), true)

      window.SetChild(box)
      window.Show()
   })
   if err != nil {
      panic(err)
   }
}

如果你将这些放在一起(或运行 chapter5/draw 示例),你应该会看到以下截图类似的内容:

Andlabs UI 绘图函数

注意透明蓝色是如何勾勒出橙色填充的矩形,并且也显示了矩形和下面的背景。如果我们颠倒 Fill()Stroke() 调用的顺序,橙色矩形将完全覆盖虚线轮廓的一半。

构建用户界面

现在我们已经了解了 andlabs UI 的 API 功能,让我们看看如何构建一个具有一定复杂性的图形应用程序。对于本节,我们将遵循在 第四章 中介绍的 "GoMail" 应用程序的设计,即 Walk - 构建图形窗口应用程序。所提出的设计是使用 Qt Creator 工具创建的,虽然它非常适合使用 Walk 库开发应用程序,但并不是所有 GUI 工具包的直接选择。andlabs UI 的多平台方法使用原生小部件意味着某些组件不可用,但可以通过组合简单的小部件来创建更复杂的组件。

考虑到这一点,让我们快速看一下不同平台的样式功能可能会如何影响我们正在构建的应用程序。在探索样式之后,我们将开始实现应用程序的基本布局,并添加控件和功能来展示用户界面能力。

样式

基于 andlabs-UI 的应用程序样式是平台特定的,通常由操作系统设置。一些支持基于用户的自定义,这可能会微妙或极大地影响应用程序的外观和感觉——因此在应用程序设计和测试期间考虑可能的变体是很重要的。

当在 Microsoft Windows 上运行时,使用的工具包是通用控件(在第四章[3b8f1272-2158-4744-945f-3258b5c4f61c.xhtml]中讨论,构建图形窗口应用程序)。基本上,控件在 Windows 的不同版本中看起来会有所不同,这有助于应用程序与不断发展的桌面外观和感觉相融合。Windows 中的大多数用户自定义选项都集中在较新的(“通用”)应用程序上,但可能会在使用通用控件(因此与 andlabs UI 相关)构建的应用程序中显示一些颜色变化。在测试你的应用程序布局和设计时,务必考虑你打算支持的 Windows 版本。

苹果公司也在随着时间的推移不断进化他们的 macOS 小部件工具包的外观和感觉,尽管大多数最新版本(自 2007 年发布的 OS X 10.5 以来)在组件的布局和尺寸上保持大体一致。在 macOS 环境中运行 andlabs UI 的应用程序应该在整个支持的版本中保持相当一致——除非用户在 2018 年底发布的 macOS Mojave 中启用了新的“深色模式”。随着这个新的用户配置选项的出现,用户界面可能会以亮色(默认)或深色模式呈现,以匹配用户的偏好。应用程序设计师应考虑这一点,并确保他们的内容在两种配置中都能良好展示。

在 andlabs UI 中使用的组件能够正确适应这种新样式,但自定义内容可能不行。目前还没有 API 来检测正在使用哪种颜色模式,因此最简单的方法是限制你的界面使用标准控件,或者选择一个在两种模式下都看起来合适的颜色方案:

图片

macOS 浅色和深色模式的并排比较(版权所有:IDG UK via MacWorld)

在 Linux 中,andlabs UI 工具包建立在 GTK+小部件集之上,该小部件集旨在允许主题和样式调整。虽然这些类型的主题不能实质性地改变组件的布局,但它们可以显著影响主题提供的尺寸、填充和着色,从而影响应用程序用户界面的流动和尺寸。这对于希望在其程序中支持固有灵活性的软件开发者来说可能是一个挑战。GTK+有超过一千个主题,许多可以在 Gnome Look 网站上找到:www.gnome-look.org/browse/cat/135/ord/top/。GTK+主题在第六章,Go-GTK - 多平台与 GTK中进一步探讨,我们更深入地研究了 GTK+工具包。

这两个截图比较了一个流行的浅色和深色主题——显然它们可以改变不仅仅是配色方案:

图片 1 图片 2

GTK+ SuperFlat 和 Vertex 主题比较

这些平台主题和配置选项中的每一个都可能影响最终应用程序的外观和感觉。如果您计划支持这些视觉风格,最佳策略是避免自定义控件,绘制功能并让原生控件适当地适应。如果您的应用程序需要自定义内容或渲染,选择一个适用于许多不同主题或样式的调色板将非常重要。

布局

andlabs UI 中的布局由水平和垂直的盒子组成,每个盒子都包含一个子元素列表,这些元素可以是可拉伸的或静态的。水平盒子在一个单独的行上布局,盒子内的每个控件都有相同的高度(即与最高元素的高度相匹配)。在垂直盒子中,控件以单列布局,每个元素都有相同的宽度(即最宽项的宽度)。如果容器比容纳项目所需的最小尺寸大,任何额外的空间都将由任何被附加为可拉伸的元素共享——如果没有可拉伸的元素,项目将保持左对齐或顶对齐。

为了在元素组之间提供视觉分隔,我们可以使用ui.Separator控件,它在水平或垂直方向上绘制一条细线——记得在盒子布局中将其标记为不可拉伸。如果您希望在布局中引入空间而不使用视觉线,可以创建一个空白标签(使用ui.Label("")),并将其附加到盒子时将可拉伸参数设置为true

主要电子邮件窗口

我们电子邮件客户端的主要布局框content是一个使用ui.NewHorizontalBox()创建的水平框,其中包含左侧的电子邮件列表(第一个要附加的项目)、一个垂直的ui.Separator以及右侧的详细视图(因为它是要附加的项目列表项)。电子邮件列表由一个名为inboxui.Group组成,包括Inbox标题;请注意,我们的标题标签后面跟着一系列空格——这有助于在我们的应用程序中创建更宽敞的布局。在这个布局中,我们有一个垂直的ui.Box,其中每个电子邮件都有一个ui.Label

由于没有可用的网格布局,详细视图由各种框组成。你可以看到meta框是两个垂直ui.Box子实例的水平布局:第一个包含标签的垂直框,第二个是稍后将要填充的值的列表——填充将提供它们之间的合适间隔。

hello world示例一样,我们创建了一个带有GoMail标题、请求的大小,并将hasMenu参数设置为false的窗口。在示例的末尾,我们设置窗口的内容并调用Show()

window := ui.NewWindow("GoMail", 600, 400, false)
window.SetMargined(true)
window.OnClosing(func(*ui.Window) bool {
   ui.Quit()
   return true
})

list := ui.NewVerticalBox()
list.Append(ui.NewLabel("email1"), false)
list.Append(ui.NewLabel("email2"), false)
inbox := ui.NewGroup("Inbox")
inbox.SetChild(list)

subject := ui.NewLabel("subject")
content := ui.NewLabel("content")
labels := ui.NewVerticalBox()
labels.Append(ui.NewLabel("From "), false)
labels.Append(ui.NewLabel("To "), false)
labels.Append(ui.NewLabel("Date "), false)

values := ui.NewVerticalBox()
from := ui.NewLabel("email")
values.Append(from, false)
to := ui.NewLabel("email")
values.Append(to, false)
date := ui.NewLabel("date")
values.Append(date, false)

meta := ui.NewHorizontalBox()
meta.SetPadded(true)
meta.Append(labels, false)
meta.Append(values, true)

detail := ui.NewVerticalBox()
detail.SetPadded(true)
detail.Append(subject, false)
detail.Append(meta, false)
detail.Append(ui.NewHorizontalSeparator(), false)
detail.Append(content, true)

content := ui.NewHorizontalBox()
content.SetPadded(true)
content.Append(inbox, false)
content.Append(ui.NewVerticalSeparator(), false)
content.Append(detail, true)

window.SetChild(content)
window.Show()

通过将那段代码放入我们在hello world应用程序中使用的相同main()包装器中,我们可以运行这个用户界面以查看布局是如何工作的。你应该会看到以下截图:

主要电子邮件浏览器布局

如你所见,我们无法使用 Walk 示例中的拆分器,但使用ui.Separator模拟了那种外观。虽然代码相同,但它们在不同操作系统上可能表现不同,如下所示 macOS 上扩展的垂直ui.Box

在 macOS 上,布局不同,但随着我们添加内容将会改进

左侧的树形或列表组件在这个阶段只是一个简单的标签集合,因为没有提供标准的列表组件。最后,我们没有将标签加粗。这是可能的,但只能通过使用 draw API,这将显著复杂化代码。此外,使用绘图可能导致用户界面的某些部分与加载的平台主题不同;为此,我们坚持使用标准的ui.Label组件。在先前的屏幕截图中,你可以看到在这个阶段不同平台具有非常不同的布局——随着我们添加更多内容,这将趋于一致。

电子邮件编写对话框

我们编写的对话框窗口布局稍微简单一些:一个名为 layout 的垂直框管理着输入元素附加的控件堆栈。我们需要创建另一个水平排列的框,将“收件人”标签放置在输入字段之前;确保开启填充以提供一些间隔。每个文本输入框都是使用 ui.NewEntry() 创建的,它创建了一个简单的单行输入字段。不幸的是,在撰写本文时,还没有多行输入字段——在当前阶段,这个限制没有明显的解决方案。UI 库的下一个版本将有一个新的 ui.MultilineEntry,这将提供这个功能。

编写布局的最后一个是第二个水平框,buttonBox,它使用熟悉的空标签技巧使取消和发送按钮在可用空间内右对齐:

window := ui.NewWindow("New GoMail", 400, 320, false)
window.SetMargined(true)
window.OnClosing(func(*ui.Window) bool {
   return true
})

subject := ui.NewEntry()
subject.SetText("subject")

toBox := ui.NewHorizontalBox()
toBox.setPadded(true)
toBox.Append(ui.NewLabel("To"), false)
to := ui.NewEntry()
to.SetText("email")
toBox.Append(to, true)

content := ui.NewEntry()
content.SetText("email content")

buttonBox := ui.NewHorizontalBox()
buttonBox.SetPadded(true)
buttonBox.Append(ui.NewLabel(""), true)
buttonBox.Append(ui.NewButton("Cancel"), false)
buttonBox.Append(ui.NewButton("Send"), false)

layout := ui.NewVerticalBox()
layout.SetPadded(true)
layout.Append(subject, false)
layout.Append(toBox, false)
layout.Append(content, true)
layout.Append(buttonBox, false)

window.SetChild(layout)
window.Show()

如前述代码所示,它使用了与主电子邮件浏览器代码相同的 ui.NewWindow() —这是因为 andlabs UI 不区分窗口类型。各种对话框窗口确实存在,但它们是为特定目的预定义的,因此对于我们的自定义对话框,我们将使用普通窗口。因此,你可以通过使用与之前的代码示例相同的 main() 方法轻松测试此代码。一旦运行,你应该会看到类似于以下截图的内容:

电子邮件编写窗口:

图片

macOS 上的电子邮件编写:

图片

在 Windows 计算机上加载:

图片

工具栏和菜单

很遗憾,目前 andlabs UI 中没有 API 支持菜单或工具栏功能。相反,我们将通过使用水平框、按钮和分隔符来模拟工具栏,这应该会提供所需的效果。由于分隔符可以非常细,我们在其两侧添加了额外的空间:

toolbar := ui.NewHorizontalBox()
toolbar.Append(ui.NewButton("New"), false)
toolbar.Append(ui.NewButton("Reply"), false)
toolbar.Append(ui.NewButton("Reply All"), false)

toolbar.Append(ui.NewLabel(" "), false)
toolbar.Append(ui.NewVerticalSeparator(), false)
toolbar.Append(ui.NewLabel(" "), false)
toolbar.Append(ui.NewButton("Delete"), false)
toolbar.Append(ui.NewLabel(" "), false)
toolbar.Append(ui.NewVerticalSeparator(), false)
toolbar.Append(ui.NewLabel(" "), false)

toolbar.Append(ui.NewButton("Cut"), false)
toolbar.Append(ui.NewButton("Copy"), false)
toolbar.Append(ui.NewButton("Paste"), false)

如你所见,我们指定了没有任何按钮、分隔符或间隔应该扩展,因此按钮将左对齐在栏上。如果你希望按钮展开,可以通过传递 truestretchy 参数来改变这种行为,例如,在附加空的 ui.Label 控件时。

我们需要将此添加到窗口中——添加了一个新的垂直框,称为 layout,并将之前的内容打包在工具栏下方。为了在工具栏和主要内容之间提供一些间隔,我们调用了 SetPadded(true)。请注意,工具栏和空间(垂直方向)不会拉伸,但内容布局会:

layout := ui.NewVerticalBox()
layout.SetPadding(true)
layout.Append(buildToolbar(), false)
layout.Append(content, true)

window.SetChild(layout)

通过将此代码与前面描述的主要布局相结合,你应该会得到一个接近我们在第四章,“漫步 - 构建图形窗口应用程序”中设计的电子邮件用户界面的应用程序:

图片

添加了一个按钮框来模拟工具栏

注意外观如何在不同的操作系统之间有所不同——以下是在 Microsoft Windows 上运行的:

图片

在 Windows 上添加我们的工具栏框

与 GUI 通信

现在基本布局已经编码完成,我们将添加功能以展示从模拟电子邮件服务器获取的一些数据。与 Walk 示例一样,我们将从github.com/PacktPublishing/Hands-On-GUI-Application-Development-in-Go/client包中加载模型定义和测试电子邮件服务器。

首先,让我们编写代码将模型内容加载到用户界面中。我们将创建一个SetEmail(EmailMessage)函数,该函数将电子邮件的内容设置到用户界面中。为了帮助将client.Emailtime.Time转换为string,我们将使用辅助函数ToEmailString()DateString()。此函数将在界面加载期间调用,并且每次更改所选电子邮件时也会调用:

func (m *mainUI) setEmail(e *client.EmailMessage) {
   m.subject.SetText(e.Subject)
   m.to.SetText(e.ToEmailString())
   m.from.SetText(e.FromEmailString())
   m.date.SetText(e.DateString())
   m.content.SetText(e.Content)
}

接下来,我们应该更新电子邮件列表。在列表中不再使用两个虚拟电子邮件,而是创建一个新的方法,该方法将遍历所有电子邮件并为每个电子邮件添加一个条目。为了能够在点击时设置电子邮件内容,我们必须从ui.Label移动到ui.Button(没有其他 andlabs UI 标准控件有OnClicked回调)。正如你所见,我们为每个添加的按钮设置了一个新函数,该函数通过调用setEmail()函数来设置显示的电子邮件。captured变量是必需的,以避免在每次迭代中重新定义 for 循环的email

func (m *mainUI) listEmails(list []*client.EmailMessage) {
   for _, email := range list {
      item := ui.NewButton(email.Subject)
      captured := email
      item.OnClicked(func(*ui.Button) {
         m.SetEmail(captured)
      })
      m.list.Append(item, false)
   }
}

为了在加载时调用这些新功能,我们需要更新main()方法。首先,使用client.NewTestServer()创建一个新的服务器,然后使用从服务器获取的适当信息调用我们编写的函数:

func main() {
   server := client.NewTestServer()
   err := ui.Main(func() {
      main := new(mainUI)
      window := main.buildUI()

      main.listEmails(server.ListMessages())
      main.setEmail(server.CurrentMessage())
      window.Show()
   })
   if err != nil {
      panic(err)
   }
}

主视图的最后一步是在用户点击新建按钮时打开撰写窗口。这可以通过另一个OnClicked处理程序轻松完成,该处理程序构建并显示二级ui.Window

compose := ui.NewButton("New")
compose.OnClicked(func(*ui.Button) {
   compose := &composeUI{}
   compose.buildUI().Show()
})

在我们能够发送电子邮件之前,我们需要从撰写用户界面中的控件构造一个电子邮件。这个新的CreateMessage()函数简单地收集用户输入的信息,并将其封装在一个新的client.EmailMessage中,该消息已准备好发送:

func (c *composeUI) createMessage() *client.EmailMessage {
   email := &client.EmailMessage{}

   email.Subject = c.subject.Text()
   email.To = client.Email(c.to.Text())
   email.Content = c.content.Text()
   email.Date = time.Now()

   return email
}

最后,我们希望取消和发送按钮能按预期工作。两个按钮都应该关闭撰写窗口,但发送按钮应首先尝试发送电子邮件。我们在 UI 代码中已经创建的buttonBox上为这些按钮添加了简单的OnClicked处理程序:

cancel := ui.NewButton("Cancel")
cancel.OnClicked(func(*ui.Button) {
   window.Hide()
})
buttonBox.Append(cancel, false)
send := ui.NewButton("Send")
send.OnClicked(func(*ui.Button) {
   email := c.createMessage()
   c.server.Send(email)

   window.Hide()
})
buttonBox.Append(send, false)

一旦将所有这些代码组合在一起,你就可以运行它,应该会看到一个看起来像这些屏幕截图的应用程序:

  • 加载测试数据后运行的 GoMail 接口在 Linux 上:

图片

  • 使用不同主题(Minwaita)的相同界面:

图片

  • 在 macOS 上运行的完成后的 GoMail 界面:

图片

  • 在 macOS 暗黑模式下运行:

图片

  • 在 Windows 10 上运行的 GoMail 接口:

图片

后台处理

如您从 andlabs UI 代码的第一行(ui.Main(func() { ... }))中可能已经意识到的,在构建此 API 时需要考虑多线程。这是因为它集成的大多数工具包都要求所有图形更新都在同一线程上执行(通常是主应用程序线程)。andlabs UI 通过内部管理线程并提供辅助方法来管理这些复杂性。

由于这种设计,任何在 ui.Main() 设置之外的用户界面更新(或在该处创建的控件上的回调)都必须以函数的形式传递给 ui.QueueMain() 方法,就像初始设置一样。这允许 andlabs UI 代码在当前框架的适当线程上处理更新。以下代码说明了标签文本如何作为某些后台处理的结果而更改:

ui.QueueMain(func () {
   label.SetText("background")
})

回调函数如 OnClicked()OnClosing() 也像 ui.QueueMain() 函数一样接受一个 func() 参数。此代码将在正确的线程上自动执行,因此无需担心额外的复杂性。

示例

为了查看后台线程的影响,我们将向 GoMail 应用程序添加另一个功能——当新电子邮件到达时更新用户界面。为了启用此功能,我们必须监听 client.EmailServer 类型定义的 Incoming 通道。

首先,我们创建了一个处理传入电子邮件的函数。这只是一个对新的方法 appendEmail(*client.EmailMessage) 的包装,该方法负责将新项目添加到电子邮件列表中。但必须创建一个包装的 func() 并将其传递给 ui.QueueMain,以便代码在正确的线程上执行:

func (m *mainUI) incomingEmail(email *client.EmailMessage) {
   ui.QueueMain(func() {
      m.appendEmail(email)
   })
}

然后我们在 main() 方法中添加一些额外的代码来监听来自 client.EmailServer 的传入电子邮件。以下代码从服务器模型请求传入通道,然后遍历通过该通道通信的任何电子邮件,触发任何到达的处理程序:

go func() {
   incoming := server.Incoming()
   for email := range incoming {
      main.incomingEmail(email)
   }
}()

在这些更新运行的同时,相同的客户端将在 10 秒后触发一个新的电子邮件出现。使用 Go,并发处理很简单,前面的代码显示了 andlabs UI 如何使我们能够从处理用户界面的并发中受益。

多个原生 GUI 的挑战

在本章中,我们看到了如何使用单个代码库创建可以在多个平台上与原生小部件工具包协同工作的应用程序。这是一种非常强大的快速开发与平台风格一致、提供熟悉用户体验的图形应用程序的方法。然而,这种方法也可能会对你的项目带来挑战,你可能需要克服这些挑战。

一致的风格

虽然在决定适应本地工具包时,一致的风格可能并不明显很重要,但在风格和应用程序设计中涉及许多参数。你们的设计团队或产品专家是否有定义的标准或方法,希望应用于所有应用程序和平台?是否应该将品牌指南包含在界面设计中?

品牌风格

由于 andlabs UI 是一个旨在提供对标准小部件抽象的工具包(因此使用当前平台的视觉和感觉),定制选项有限。引入自定义元素的唯一方式是 ui.Area 小部件和我们所探索的绘图功能。这使公司字体或标志可以在界面中的某个位置绘制(据说在稍后的版本中将支持加载图像)。

如果您正在寻找进一步定制或主题化您正在构建的应用程序的能力,那么 andlabs UI 可能不是您项目的正确解决方案。可能更好的是探索 GTK+ 或 Qt(我们在第六章 Go-GTK - 多平台与 GTK 和第七章 Go-Qt - 多平台与 QT 中进行了介绍),或者跳转到第三部分(第八章 Shiny - 实验性的 Go GUI API,第九章 nk - Nuklear for Go 和第十章 Fyne - 基于 Material Design 的 GUI),并了解其他图形应用程序设计的途径。

用户体验

使用单个 API 并不能保证在多个操作系统上的一致性。所使用的工具包可能有不同的布局默认值,例如不同的填充或对齐。The andlabs UI(以及其底层的 andlabs libui)API 被设计为提供尽可能接近操作系统默认值的程序。如果您有关于用户界面外观(除了风格之外)的具体要求,例如布局或对齐,您可能需要编写特殊的代码。使用 Go 的构建标签方法,为不同的操作系统加载不同的代码,您可以使您的代码在不同平台上略有不同地表现。

如果我们查看之前的 hello world 示例,我们可以更新代码以调整不同平台上的退出按钮布局。在这里,我们将为 macOS 加载一个右对齐的按钮,但其他系统则保持全宽。

创建退出按钮的代码已从 hello.go 中移除,并替换为调用新的 layoutQuit() 函数的行:

button := layoutQuit()

在一个名为custom_other.go的新文件中,我们将之前的按钮定义移动到一个新的layoutQuit()函数中。此外,在顶部添加了一个条件编译注释,以确保此文件不包括 macOS(darwin)。请注意,文本也已更改为退出,以说明平台如何进行适配:

// +build !darwin

package main

import "github.com/andlabs/ui"

func layoutQuit() ui.Control {
   button := ui.NewButton("Exit")
   button.OnClicked(func(*ui.Button) 
   {
      ui.Quit()
   })

   return button
}

这一切都很直接;然后我们添加另一个名为custom_darwin.go的文件,在那里我们定义了替代行为。在这个文件中,我们不需要构建定义,因为文件名为我们提供了这个信息。在这个实现中,我们创建了一个带有退出按钮并使用一个空的可拉伸ui.Label向右填充的水平ui.Box,如下所示:

package main

import "github.com/andlabs/ui"

func layoutQuit() ui.Control {
   button := ui.NewButton("Quit")
   button.OnClicked(func(*ui.Button) {
      ui.Quit()
   })

   box := ui.NewHorizontalBox()
   box.Append(ui.NewLabel(""), true)
   box.Append(button, false)

   return box
}

使用这种方法,您可以将用户界面适配为在特定平台上略有不同。如果您需要在不同的系统上使用不同的小部件布局,这是一个有用的方法:

图片 1 图片 2

更新的 hello 应用程序(左侧)和 macOS 的布局(右侧)

测试

由于多平台抽象的各个方面(包括风格的差异),测试 andlabs UI 应用程序可能需要很长时间。此外,由于设计上的原因,该应用程序在 Windows、macOS 和 Linux 上看起来和工作方式略有不同,可能还会受到额外的用户自定义的影响。这三个平台都提供了一些用户选项来更改用户界面——Windows 允许调整颜色,macOS 类似地有高亮颜色,最近还增加了暗黑模式,而 GTK+(Linux 的实现)提供完整的主题支持。

测试以这种方式构建的应用程序的一部分是决定您将支持哪些平台和变体。您的 Windows 用户是否都在 Windows 的最新版本上,还是您将确保应用程序与较旧的控件样式兼容?在 macOS 上,您是否检查您的界面在暗黑和浅色模式下是否易于阅读?对于 Linux,您是否支持(因此测试)各种不同的主题?

非常推荐您为可能影响应用程序外观的每个系统变体配置一个测试环境。幸运的是,虚拟机使这变得更容易——您不再需要一排电脑或复杂的多启动配置。如果您可以在单独的虚拟机镜像中加载和设置这些配置,那么应该可以测试所有这些潜在变体。请注意,macOS 许可要求它在 Macintosh 计算机上运行——即使是在虚拟化环境中。

当然,这种跨平台方法的影响可能更为深远——操作系统还有许多不可见的不同之处。重要的是要加载并完全运行您的应用程序以检查所有功能,仅仅查看用户界面是不够的,以满足一个坚实的测试策略。

跨平台编译

由于 libui 是针对本地小部件 API 构建的,因此交叉编译比简单的 Go 应用程序更复杂。除了为当前计算机上的 andlabs UI 构建应用程序所需的开发工具外,你还需要访问小部件库定义以成功进行交叉编译。在某些情况下,这意味着简单的库安装,在其他情况下,可能需要安装操作系统的软件开发套件SDK)。让我们看看每个目标平台的详细信息。

正常的 Go 交叉编译一样,我们首先设置环境变量GOOS(可选的GOARCH),以定义构建的目标平台。为了与 libui 一起工作,我们需要使用CGO_ENABLED=1重新启用 CGo(默认情况下交叉编译时是禁用的)。简单地使用此设置执行构建可能会因为缺少库或 SDK 而失败,如下所示:

图片

在 macOS 上为 Linux 构建失败

图片

一台 Linux 计算机编译 macOS 失败

让我们来看看如何为各种配置启用交叉编译。

在 macOS 或 Windows 上为 Linux 构建

要能够为 Linux 进行交叉编译,主要要求是 GTK+库,它为 Linux 上的 andlabs UI 提供小部件。由于操作系统没有标准的包管理器,安装这个库会稍微困难一些,但如果你遵循这里描述的步骤,应该可以完成。这个过程还涉及到安装交叉编译工具链,就像本节中的其他示例一样。设置交叉编译的详细信息可以在附录中找到,即附录的交叉编译设置部分。主要步骤在此处概述,以供快速参考。

macOS

要为 macOS 进行交叉编译,我们需要安装一个包管理器。最简单且最完整的是 Homebrew——你可以从brew.sh/安装它。Linux 编译推荐的工具链是musl-cross,它位于FiloSottile/musl-cross/musl-cross包中。安装 Homebrew 后,在你的终端窗口中执行以下命令:

  1. brew install gtk+3

  2. export HOMEBREW_BUILD_FROM_SOURCE=1

  3. brew install FiloSottile/musl-cross/musl-cross

完成这些后,你应该可以通过设置GOOS=linuxGOARCH=amd64CGO_ENABLED=1CC=x86_64-linux-musl-gcc环境变量(CXX=x86_64-linux-musl-g++)来为 Linux 构建。然后你可以像平常一样构建,结果将是一个 Linux 可执行文件而不是 macOS:

图片

在 macOS 上构建 Linux 可执行文件

Windows

使用 Windows 进行交叉编译稍微复杂一些,因为没有标准的包管理器。推荐的方法是安装 Cygwin(从 cygwin.com/install.html)。然后安装 gtk3 和 linux-gcc(交叉编译器)软件包。从那里开始,按照前面为 macOS 概述的说明进行,但使用 CC=linux-gccCXX=linux-g++

在 Linux 或 macOS 上为 Windows 构建

从其他平台构建 Windows 需要安装 mingw(类似于我们在 Windows 上安装的以支持 CGo)。设置交叉编译的详细信息可以在 附录 的 使用 CGo 交叉编译 Windows 部分,交叉编译器设置 中找到。

主要步骤在此处概述,以便快速参考:

使用您的包管理器(macOS 上的 Homebrew 和 Linux 上的各种),安装 mingw 软件包,该软件包通常命名为 mingw-w64-clangw64-mingw。如果您找不到此软件包,可以直接使用 github.com/tpoechtrager/wclang 中的说明进行安装。

安装完成后,我们需要设置适当的构建标志——特别是 CC=x86_64-w64-mingw32-clang(用于 C 工具链)和 CXX=x86_64-w64-mingw32-g++(用于 C++ 需求)。假设您还设置了 CGO_ENABLED=1GOOS=windows,则可以构建 Windows 可执行文件。查看生成的 hello.exe 文件,您可以看到它是一个 MS Windows 可执行文件:

使用 mingw(x86_64-w64-mingw32-clang),我们在 Linux 上构建了一个 Windows 原生 UI 应用程序

当从 macOS 构建时,可以使用 Homebrew 安装 mingw-w64 软件包。

在 Linux 或 Windows 上为 macOS 构建

为 macOS 进行交叉编译需要 macOS SDK 可用于链接。当从 Linux 或 Windows 计算机构建时,我们必须下载并安装 SDK,并更新我们的构建工具链以使用它。最简单的方法是使用 osxcross 工具。有关此设置的详细信息,请参阅 附录 中的 使用 CGo 交叉编译 macOS 部分,交叉编译器设置。此处概述了主要步骤,以便快速参考,使用 Linux 终端——一旦安装了 cygwin 或 mingw 终端,Windows 设置类似。

首先,我们需要下载 macOS SDK,它包含在 Xcode 中。从苹果下载网站 developer.apple.com/download/more/?name=Xcode%207.3 下载 XCode.dmg(推荐使用 7.3.1 版本用于 osxcross)。接下来,从 github.com/tpoechtrager/osxcross 安装 osxcross 工具(完整的安装细节可在该网址或附录中找到)。完成安装后,将提取 macOS SDK 并创建用于构建这些已安装 API 的编译工具链。

现在我们准备构建。除了之前的环境变量外,我们添加了CC=o32-clang,之后我们的构建命令应该能够成功执行。在这里,你可以看到我们的 Linux 计算机成功创建了一个名为hello的 macOS 64 位 Mach-O 可执行文件:

图片

使用 osxcross(o32-clang),我们在 Linux 上构建了一个 macOS 本机 UI 应用程序。

Windows 的过程类似,详细内容可以在附录的使用 CGo 为 macOS 交叉编译部分找到,交叉编译器设置

一个更好的解决方案

这些步骤很复杂且可能很脆弱。由于这些挑战,创建了一个新的项目来帮助跨编译 andlabs UI 应用程序。你可以通过访问项目主页了解更多信息,并比较以下详细说明的过程:github.com/magJ/go-ui-crossbuild/

摘要

在本章中,我们探讨了 andlabs UI 工具包,它提供了一个单一的 API,用于使用运行操作系统的本地小部件构建图形 Go 应用程序。我们逐步介绍了如何在 macOS、Windows 和 Linux 上设置构建 andlabs UI 应用程序,并展示了如何从单个 Go 源文件在各个系统上运行一个简单的hello world应用程序。然后我们详细研究了用于构建应用程序的小部件 API 和用于自定义渲染的绘图 API。

带着这些知识,我们回顾了第四章,Walk - 构建图形 Windows 应用程序中的 GoMail 应用程序,并再次使用 andlabs UI 库构建用户界面。尽管当前版本有一些限制,但我们能够模拟一些缺失的小部件,几乎完全重现了应用程序。当然,好处是我们可以从相同的源代码在 Windows、Linux 和 macOS 上运行 GUI。

测试使用具有可变用户界面的库构建的应用程序,并确保其尽可能保持一致性,可能取决于你的应用程序设计而变得困难。此外,由于 Go 提供的简单交叉编译方式在 andlabs UI 中由于它使用特定于操作系统的小部件 API 实现而变得相当困难。我们探讨了如何在这些限制下工作并构建适用于不同平台的应用程序。

在接下来的两个章节中,我们将调查通过 Go API 提供的现有跨平台小部件库。GTK+(我们在 andlabs UI 中看到它被用于 Linux)和 QT 都提供了一个标准的 widget 集合,这对现有桌面应用程序的用户来说会感觉熟悉。我们将在下一章中详细探讨 GTK+。

第六章:Go-GTK - 使用 GTK+ 的多平台

我们在 第四章,Walk - 构建图形窗口应用程序 和 第五章,andlabs UI - 跨平台原生 UI 中探讨了直接连接到操作系统原生小部件集的工具包(仅适用于 Windows 的 Walk 和适用于 Windows、macOS 和 Linux 的 andlabs UI)。在本章和下一章(第七章,Go-Qt - 使用 Qt 的多平台)中,我们将查看旨在看起来类似于传统原生小部件同时为多平台分发而构建的小部件工具包。在每一章中,我们将使用一个流行的 Go 绑定,它提供了对底层 API 大多数功能的访问。

在本章中,我们将探索 Go-GTK,这是最流行的 GTK+ 小部件库的 Go 绑定。我们将涵盖以下内容:

  • GTK+ 背景知识

  • 开始使用 Go-GTK

  • 信号和命名空间

  • 示例应用程序

  • 主题设计

到本章结束时,你将熟悉 GTK+ 和 Go-GTK 库,通过探索一些示例应用程序。我们将构建 GoMail 应用程序的新版本,并将其与之前使用 Walk 和 andlabs UI 构建的版本进行比较。

GTK+ 背景知识

GTK+,或称为 GNU 图像处理程序GIMP)工具包(一个流行的跨平台图像编辑器),是一个用于创建图形应用程序的跨平台 API。该项目旨在提供一套完整的 GUI 小部件,支持从小型图形工具到大型应用程序套件:

图片

使用 GTK+ 的 GIMP,在 Windows Vista 上显示;版权所有 GTK+ 团队

自其创建以来,该工具包的采用率迅速扩大,得益于其开源许可,它支持在商业和免费应用程序中 alike 的使用。虽然 1.0 版本(于 1998 年发布)主要是为了支持 GIMP 应用程序的功能,但在一年后的 1.2 版本中,工具包的目标是更广泛的受众。2002 年,发布了 2.0 版本,这使得 GTK+ 成为 Gnome Linux 桌面的官方工具包。这个功能齐全的发布极大地扩大了采用率,成为跨平台开发中最受欢迎的控件集之一——2.x 版本的次要发布在 2018 年仍然非常受欢迎。2011 年,发布了 3.0 版本,其中包含了许多更改,其中最引人注目的是基于 级联样式表CSS)的新主题引擎,这对于大多数网页开发者来说都很熟悉。尽管 CSS 更容易创建主题,但新方法也受到了批评,许多发行商继续提供 2.24 版本,尽管它已经超过七年了。

Go 语言的一个优点是它为在多个平台上表现一致的应用程序提供了一个单一的 API。GTK+(以及下一章中将要介绍的 Qt)是一个采用类似方法来启用 GUI 应用程序开发的 API。通过将 Go 语言与这两个 API 结合,我们可以创建出(根据用户的主题设置)在所有支持的操作系统(Windows、macOS、Linux 以及许多 Unix 发行版)上看起来和表现都一样的应用程序。在本章中我们使用的 Go 绑定是由 Yasuhiro Matsumoto 创建的,该项目有一长串的维护者名单。它专注于 GTK2 支持,并旨在提供完整的 API 绑定,但当前许多功能尚不可用。正如您在本章中将要看到的,目前可用的绑定支持了大多数应用程序的需求,因此它们目标的局部完成不会影响大多数开发者对这一 API 的使用。

开始使用 Go-GTK

要开始使用 Go-GTK,需要在您的系统上安装 GTK+库(如果尚未安装),设置 CGo,并下载 Go 绑定。使用 Go-GTK 构建的应用程序的用户需要在他们的计算机上安装 GTK+库,因此“安装 GTK+”部分可能需要包含在您的文档中。

先决条件

针对 GTK+库进行编译将需要设置 CGo;如果尚未完成,您可以查阅附录,安装细节

安装 GTK+

使用包管理器安装 GTK+库是最简单的方法,因为它还会配置您的开发环境。

macOS

对于 macOS,建议使用 Homebrew 进行安装。如果您之前没有设置 Homebrew,可以简单地遵循https://brew.sh上的说明。一旦安装了 Homebrew,只需打开一个终端并运行brew install gtk+即可。

Windows

Windows 没有标准包管理器来处理 GTK+之类的工具,但MSYS项目旨在解决这个问题。使用已安装的MSYS Mingw-w64终端(如果您遵循了 CGo 设置说明,则已安装),我们可以安装额外的库。通过执行以下命令,正确的库应该可以正常运行:

pacman -S mingw-w64-x86_64-gtk2

这将安装 GTK+库及其所有依赖项。本章中的示例需要从 MSYS 终端运行,即使已经构建完成。

Linux

在 Linux 安装中,有很大可能性您已经安装了 GTK+ 2,因为许多应用程序都使用这个小部件集。如果没有(或者您不确定),那么您的系统包管理器将负责安装;只需查找名为gtk2gtk的包。如果您的发行版将开发头文件与运行时库分开,您可能需要安装额外的gtk2-devgtk-dev包。

安装 Go-GTK

一旦 Go 运行并且 GTK+依赖项安装完毕,你可以简单地使用go get github.com/mattn/go-gtk,然后使用go get github.com/mattn/go-pointergo-gtk项目依赖于它。安装好之后,我们就准备好构建一个测试应用程序。

构建

使用 Go-GTK 的基本欢迎世界应用程序与之前我们看到的类似:我们创建一个窗口,添加一个垂直框,并附加一个标签和一个按钮。下面的代码示例应该很简单,但我们将更详细地查看一些具体细节:

package main

import "github.com/mattn/go-gtk/gtk"

func main() {
   gtk.Init(nil)
   window := gtk.NewWindow(gtk.WINDOW_TOPLEVEL)
   window.SetTitle("Hello")

   quit := gtk.NewButton()
   quit.SetLabel("Quit")
   quit.Clicked(func() {
      gtk.MainQuit()
   })

   vbox := gtk.NewVBox(false, 3)
   vbox.Add(gtk.NewLabel("Hello World!"))
   vbox.Add(quit)

   window.Add(vbox)
   window.SetBorderWidth(3)
   window.ShowAll()
   gtk.Main()
}

首先,我们导入github.com/mattn/go-gtk/gtk包作为主要的 GTK 命名空间。Go-GTK项目被分割成多个命名空间,我们将在本章后面进一步探讨。接下来,使用gtk.NewWindow()创建窗口——请注意,这个函数的参数是window类型,而不是它的标题(标题将在SetTitle()中设置)。使用gtk.NewButton()创建Quit按钮,并使用SetLabel()设置文本,然后我们添加使用Clicked()函数的代码来退出,传递一个匿名函数。

布局由使用gtk.NewVBox(bool, int)创建的垂直框管理。这个消息的参数首先是一个均匀的bool标志(确定所有子组件是否应该具有相同的大小),其次是用于间距int值(这指定了在每个子元素之间放置的填充量)。

最后,使用Add()在窗口上设置内容,并使用SetBorderWidth(3)设置与 VBox 中间距一致的填充。调用ShowAll()将窗口及其内容设置为显示(因为小部件默认是隐藏的),调用gtk.Main()运行应用程序以渲染并响应用户输入。

你可以使用标准的go build hello.go命令构建这个程序,它应该为你的操作系统创建一个可运行的文件:

图片

使用 Go-GTK 构建欢迎世界示例

运行

你可以通过双击文件图标或使用 Go 工具(使用go run hello.go)从命令行运行构建的文件。无论以何种方式启动,你应该看到类似以下截图的内容:

Go-GTK 欢迎世界:

图片

Go-GTK 在 macOS 上:

图片

Go-GTK 默认 Windows 外观:

图片

你可以看到,就像和 andlabs UI 一样,我们能够在许多操作系统上运行这个单个文件。这里的区别是应用程序看起来几乎完全相同。这就是使用像 GTK+这样的工具包的好处,这也是你为什么可能考虑为你的下一个应用程序使用 Go-GTK 的原因。

在我们查看更完整的应用程序用户界面之前,我们应该调查一些 Go-GTK API 的细节。

信号和命名空间

GTK+是一个事件驱动的工具包;这意味着除非发射了一个事件并且注册了一个回调来接收它,否则不会发生任何事情。GTK+中的事件通过信号实现,为信号注册回调称为连接。信号包括大多数涉及 GUI 行为和通信的事件,包括按钮点击事件或窗口生命周期。

信号

你注意到在我们的 hello world 示例中,Quit按钮会退出应用程序,但关闭窗口却没有吗?这是因为我们没有将任何回调连接到窗口销毁信号。我们可以通过添加以下行来修复这种情况:

window.Connect("destroy", func() {
   gtk.MainQuit()
})

此代码将提供的匿名函数连接到windowdestroy信号。当信号被发射时,函数被调用,应用程序现在将正确退出。由于gtk.MainQuit()函数不接受任何参数,我们可以更简洁地写成如下:

window.Connect("destroy", gtk.MainQuit)

但等等,按钮点击是如何工作的呢?这是因为我们在button组件上使用了Clicked()函数。这是一个便利函数,它会为你设置信号连接(并且使代码更整洁!)!如果你查看Button.Clicked()函数的源代码,你会看到发生了什么:

func (v *Button) Clicked(onclick interface{}, datas ...interface{}) int {
   return v.Connect("clicked", onclick, datas...)
}

因此,你可以看到,并不总是需要手动连接这些连接,因为Go-GTK提供了许多像这样的便利方法。

传递数据

之前的所有示例都使用了一个不带参数的函数。虽然这通常足够了,但向您的信号处理函数传递更多信息可能会有所帮助。这可以通过连接功能(通过Clicked()便利函数镜像)轻松完成,它允许发送额外的参数。在函数引用之后,您可以传递额外的数据参数,这些参数将可用于执行回调的函数。

我们可以通过创建一个新的按钮并将此按钮及其功能传递给信号连接来证明这一点:

button := gtk.NewButton()
button.SetLabel(label)
button.Clicked(clicked, button)

在回调函数中,我们将函数签名更新为接受一个*glib.CallbackContext参数。此参数包含在连接信号时指定的数据。可以使用上下文的Data()函数调用访问数据。

转换返回数据的类型很方便,但记住在断言新类型时要小心,因为错误类型会导致你的程序崩溃:

func clicked(ctx *glib.CallbackContext) {
   button := ctx.Data().(*gtk.Button)
   log.Println("Button clicked was:", button.GetLabel())
}

在一个简单的示例中,我们创建三个具有相同回调函数的按钮,我们可以看到这个数据参数如何使我们避免创建不必要的函数:

    

多个按钮;处理多个按钮点击回调的点击函数

如你所注意到的,之前的代码片段提到了一个新的包,glib。让我们看看Go-GTK项目包含的不同包以及你何时可能想要使用它们。

命名空间

Go-GTK项目包含多个命名空间,用于组织代码并使开发者更容易找到他们需要的内容。这些子项目或包反映了 GTK+主项目中的命名,因此熟悉这些内容的可以跳过这一部分。到目前为止的大多数示例都使用了gtk,这显然是构建用户界面时需要使用的主要包,但正如我们之前看到的,glib也可能很重要(对于与小部件无关的事情)。

让我们看看每个命名空间覆盖的内容,并看看在应用程序开发中它们可能在哪里有用:

gdk GDK 代表 GIMP 绘图工具包;它是 GTK+的低级组件,负责处理每个受支持平台上的渲染细节。这提供了操作系统细节的抽象,因此允许 GTK+的其他区域实现平台无关性。如果你的应用程序需要绘制任何自定义元素,这个包将非常有用。
gdkpixbuf Pixbuf 指的是包含用于渲染图像的像素数据的内存缓冲区。这个包提供了一些管理可以加载到 Go-GTK 应用程序中的图像的便利函数。值得注意的是gdkpixbuf.NewPixbufFromData函数,它与make_inline_pixbuf工具结合使用,允许加载嵌入到应用程序中的图像。
gio gio代表 GTK+应用程序的输入/输出抽象。它提供了一个一致的 API 来访问本地和远程文件。
glib glib是所有 GTK+功能和应用程序的支持库。它实现了面向对象的系统以及各种数据结构和实用工具。由于 Go 语言原生定义了许多这些,Go-GTK 中的 glib 包负责将 Go 结构转换为 glib(C)结构。这是处理线程管理和消息传递的地方,但大多数这些功能都被库的高级函数隐藏了。
gtk GTK+库中小部件的主要命名空间。正如我们已经看到的,它提供了一个跨平台的工具包来创建图形应用程序,这是由这里列出的其他包实现的。
pango Pango是一个字体渲染库,为 GTK+应用程序提供高质量的文本符号。你不太可能需要直接调用这些 API;它主要用于 GTK+内部渲染文本。

在查看Go-GTK(Go-GTK)中的主要包(并看到应用程序可能只需要使用gtkglibgdk)之后,我们将看到这些包是如何在一个更大的应用程序中结合在一起的。

示例应用

是时候再次整理 GoMail 应用程序的设计,并使其适应 GTK+小部件了。由于 andlabs UI 应用程序(在 Linux 上运行)使用 GTK+,因此从那里开始似乎是合理的。然而,这次我们不受最低共同分母设计约束的限制,andlabs 的本地跨平台设计绕过了这个限制,所以让我们从头开始,看看 GTK+能做什么。

布局

使用基于 GTK+的应用程序的基本布局使用熟悉的垂直和水平盒模型。Go-GTK(作为此 API 的直接绑定)公开了相同的功能。我们使用垂直盒定位菜单和工具栏在主内容上方来布局 GoMail 主窗口。然后,我们的主要内容是一个使用gtk.NewHPaned()创建的水平分割窗格(其中H指的是水平布局,而不是条形方向,条形方向是垂直的)。在查看细节之前,以下是主窗口的基本布局代码。为了简洁起见,省略了工具栏和菜单创建代码,但可以在示例代码仓库中找到:

package main

import "github.com/mattn/go-gtk/gtk"

const padding = 3

func main() {
   gtk.Init(nil)
   window := gtk.NewWindow(gtk.WINDOW_TOPLEVEL)
   window.SetTitle("GoMail")
   window.Connect("destroy", func() {
      gtk.MainQuit()
   })

   list := gtk.NewTreeView()
   list.AppendColumn(gtk.NewTreeViewColumnWithAttributes("Inbox", gtk.NewCellRendererText(), "text", 0))
   meta := gtk.NewHBox(false, padding)

   labels := gtk.NewVBox(true, padding)
   labels.Add(gtk.NewLabel("To"))
   labels.Add(gtk.NewLabel("From"))
   labels.Add(gtk.NewLabel("Date"))
   values := gtk.NewVBox(true, padding)
   values.Add(gtk.NewLabel("email"))
   values.Add(gtk.NewLabel("email"))
   values.Add(gtk.NewLabel("date"))
   meta.Add(labels)
   meta.Add(values)

   content := gtk.NewTextView()
   content.GetBuffer().SetText("email content")
   content.SetEditable(false)

   detail := gtk.NewVBox(false, padding)
   detail.PackStart(gtk.NewLabel("subject"), false, true, 0)
   detail.PackStart(meta, false, true, 0)
   detail.Add(content)

   split := gtk.NewHPaned()
   split.Add1(list)
   split.Add2(detail)

   vbox := gtk.NewVBox(false, padding)
   vbox.PackStart(buildMenu(), false, true, 0)
   vbox.PackStart(buildToolbar(), false, true, 0)
   vbox.Add(split)

   window.Add(vbox)
   window.SetBorderWidth(padding)
   window.Resize(600, 400)
   window.ShowAll()
   gtk.Main()
}

在此代码中有两点值得注意。首先是在文件顶部定义的padding常量。盒模型没有定义标准间距,因此每次布局需要一些视觉填充时,我们都会传递这个常量。第二个重要的教训是Add(IWidget)PackStart(IWidget, bool, bool, uint)方法在盒子上的区别。Add方法简单地将小部件追加到容器中(gtk.Box继承自gtk.Container),这将导致子项扩展以填充可用空间。对于菜单栏和工具栏,我们不希望垂直扩展,因此我们使用PackStart方法,这允许对行为有更多的控制。第一个布尔参数控制扩展;通过传递false,我们指示容器该小部件不应占用任何空闲空间。

第二个布尔参数控制填充,并确定小部件是否应在任何空间计算完成后填充任何可用空间,因此传递true指定我们的工具栏应占满全宽。在gtk.VBox中,扩展参数指的是垂直拉伸,而填充应用于水平。

我们还需要向列表视图添加一些内容,这需要创建一个模型来表示我们将要展示的内容。由于内容将是一个没有父/子关系的单列,我们可以使用gtk.ListStore,而不是更复杂的gtk.TreeStore。将内容设置到模型中的方法是使用迭代器并将值应用于数据表的每一行。对于此布局的目的,我们将email1email2添加到视图的 0th(第一)列:

   model := gtk.NewListStore(gtk.TYPE_STRING)
   list.SetModel(model)

   var iter gtk.TreeIter
   model.Append(&iter)
   model.SetValue(&iter, 0, "email1")
   model.Append(&iter)
   model.SetValue(&iter, 0, "email2")

工具栏 API 简单易用,通过利用 GTK+ 中包含的库存图标,为许多常见操作提供标准图标。由于我们的一些按钮是非标准的(例如“回复”和“全部回复”),我们将工具栏样式设置为显示图标和标签;稍后,我们可以添加一些自定义图标。每个项目都可以使用 OnClicked() 函数或通过连接 clicked 信号来设置其操作:

   toolbar := gtk.NewToolbar()
   toolbar.SetStyle(gtk.TOOLBAR_BOTH)
   item := gtk.NewToolButtonFromStock(gtk.STOCK_NEW)
   item.OnClicked(showCompose)
   toolbar.Add(item)

其余的图标可以类似地添加。菜单代码稍微复杂一些;每个下拉菜单(无论是子菜单还是主菜单)都需要使用 gtk.NewMenu() 创建,并按所示添加其项目。然后,每个顶级菜单需要创建一个新的菜单项(例如,gtk.NewMenuItemWithLabel()),并使用 SetSubmenu() 连接菜单。构建的菜单可以随后附加到菜单栏:

menubar := gtk.NewMenuBar()
fileMenu := gtk.NewMenuItemWithLabel("File")

menu := gtk.NewMenu()
item := gtk.NewMenuItemWithLabel("New")
item.Connect("activate", showCompose)
menu.Append(item)

fileMenu.SetSubmenu(menu)
menubar.Append(fileMenu)

在所有这些代码到位(以及工具栏和菜单中的几个更多项目)之后,我们有一个基本的布局,应该看起来很熟悉。如您所见,我们已经开始从更大的小部件工具包的附加功能中受益,包括标准图标、更完整的样式和布局:

图片

在我们进行任何样式调整之前,使用 Go-GTK 的 GoMail 的基本布局

使用 label.SetAlignment(0, 0) 可以设置左对齐,从而改善布局,尤其是电子邮件详情面板的布局。通过使用 pango 库的标记功能,可以将标签内容设置为粗体;只需调用 label.SetMarkup(fmt.Sprintf("<b>%s</b>", label.GetText())) 即可。前面的代码主要关注基本布局,因此这些调整被省略了。附加的细节包含在示例代码仓库中,完成的界面可以在关于主题的后续部分中查看。

撰写布局

显示撰写对话框的代码现在应该非常熟悉了。窗口被创建为 gtk.WINDOW_TOPLEVEL,因为 Go-GTK 只允许选择顶级或弹出(即浮动内容),而不是对话框等子窗口。我们设置了一个销毁函数,它会关闭窗口而不是退出应用程序。

剩余的布局代码是每个项目的常规垂直框,对于 to 标签,它位于输入字段左侧的水平框:

func buildCompose() {
   window := gtk.NewWindow(gtk.WINDOW_TOPLEVEL)
   window.SetTitle("New GoMail")
   window.Connect("destroy", func() {
      window.Destroy()
   })

   vbox := gtk.NewVBox(false, padding)
   subject := gtk.NewEntry()
   subject.SetText("subject")
   vbox.PackStart(subject, false, true, 0)
   toBox := gtk.NewHBox(false, padding)
   toBox.PackStart(gtk.NewLabel("To"), false, true, 0)
   email := gtk.NewEntry()
   email.SetText("email")
   toBox.Add(email)
   vbox.PackStart(toBox, false, true, 0)

   content := gtk.NewTextView()
   content.GetBuffer().SetText("email content")
   content.SetEditable(true)
   vbox.Add(content)

   buttonBox := gtk.NewHBox(false, padding)
   buttonBox.PackEnd(gtk.NewButtonWithLabel("Cancel"), false, true, 0)
   buttonBox.PackEnd(gtk.NewButtonWithLabel("Send"), false, true, 0)
   vbox.PackEnd(buttonBox, false, true, 0)

   window.Add(vbox)
   window.SetBorderWidth(padding)
   window.Resize(400, 320)
   window.ShowAll()
}

如您通过 buttonBox 所见,我们已经使用了之前描述的 PackEnd() 函数,将按钮在撰写窗口的底部右对齐。我们还使用了主窗口的 padding 定义,为我们的小部件提供一致的间距。运行前面的代码应该会加载一个类似于这样的窗口:

图片

使用 Go-GTK 的 GoMail 撰写窗口

现在我们已经准备好了基本的布局和输入字段,让我们通过我们的测试电子邮件服务器连接到一些内容。

信号

在传统的 GTK+ 应用程序中,使用内置的信号处理能力是可能的,甚至是被推荐的。可以创建一个新的信号,然后在适当的时候由应用程序发出;组件可以连接到这个信号并做出相应的响应。然而,创建信号的能力并没有通过 Go-GTK API 暴露出来,因此我们将使用类似于前例的回调。

为了加载我们的测试服务器,我们首先更新 main() 函数以设置服务器并将其传递给用户界面创建代码。然后,我们设置内容以显示测试服务器的当前消息:

func main() {
   server := client.NewTestServer()
   main := new(mainUI)
   main.showMain(server)
   main.setEmail(server.CurrentMessage())

   gtk.Main()
}

这使用了新的辅助函数,该函数将设置电子邮件详细面板的内容。我们将在稍后的列表选择代码中调用此函数:

func (m *mainUI) setEmail(message *client.EmailMessage) {
   m.subject.SetText(message.Subject)
   m.to.SetText(message.ToEmailString())
   m.from.SetText(message.FromEmailString())
   m.date.SetText(message.DateString())

   m.content.GetBuffer().SetText(message.Content)
}

为了设置电子邮件列表的内容,我们在创建应用程序结构体时存储迭代器和模型,以便以后可以引用。以下辅助函数处理将项目预加到电子邮件列表中的细节。这个函数在 server.ListMessages() 中的每个消息上被调用,以设置初始列表:

func (m *mainUI) prependEmail(message *client.EmailMessage) {
   m.listModel.Prepend(&m.listIter)
   m.listModel.SetValue(&m.listIter, 0, message.Subject)
}

与用户界面基本通信的最后一部分是处理树视图中项目的选择。为了处理这一点,我们的应用程序必须实现 gtk.GtkTreeSelecter,它有一个单一的 Select() 函数。以下实现将满足我们的需求。首先,请注意,这可以用于选择和取消选择,因此我们需要检查项目当前是否未被选中。然后,我们使用回调调用时指定的路径来确定被点击的行。这个行号用于从消息的服务器列表中获取电子邮件。然后,我们可以调用我们的有用函数 setEmail()

func (m *mainUI) Select(selection *gtk.TreeSelection, model *gtk.TreeModel, path *gtk.TreePath, selected bool) bool {
   if selected { // already selected, just return
      return true
   }

   row := path.GetIndices()[0]
   email := m.server.ListMessages()[row]

   m.setEmail(email)
   return true
}

为了使选择处理程序被调用,我们必须在创建 gtk.ListView 时注册它:

var selecter gtk.GtkTreeSelecter
selecter = mainUI
list.GetSelection().SetSelectFunction(&selecter)

现在,用户界面应该已经完成。我们需要处理新电子邮件到达时的后台更新。

线程处理

在我们能够正确处理 Go-GTK(或任何 GTK+ 实现)的背景处理之前,我们必须正确初始化底层库(glibgdk)的线程处理部分。这些行应该在应用程序的 main() 函数开始时输入:

glib.ThreadInit(nil)
gdk.ThreadsInit()
gdk.ThreadsEnter()
gtk.Init(nil)

一旦设置了线程处理,我们就可以编写将与用户界面通信的后台代码。此代码必须在创建应用程序时相同的线程上执行。为了确保这一点,我们在要执行的代码周围使用辅助函数 gdk.ThreadsEnter()gdk.ThreadsLeave()。为了使我们的应用程序在消息到达时将新消息添加到电子邮件列表的末尾,在调用 gtk.Main() 以启动应用程序之前立即添加以下代码:

   go func() {
      for email := range server.Incoming() {   
gdk.ThreadsEnter()
         main.prependEmail(email)
         gdk.ThreadsLeave()
      }
   }()

这完成了我们在 Go-GTK 中实现 GoMail 应用程序的工作,但如何为不同的平台编译应用程序呢?

跨平台编译

为 Go-GTK 基于的应用程序编译针对额外平台需要安装额外的 C 编译器,以便 CGo 可以创建必要的二进制输出。如果您还没有这样做,完成此步骤的步骤在附录,交叉编译设置中。您还需要安装 GTK+,这显然已经是既定事实。由于 andlabs UI 使用 GTK+库针对某些目标平台,第四章的构建图形窗口应用程序部分已经详细说明了,所以我们在这里不再重复。由于 andlabs UI 使用 GTK+库针对某些目标平台,这个过程是相同的。请确保设置适当的GOOSGOARCHCGO_ENABLEDCCCXX环境变量。

在我们结束对工具包的探索之前,我们应该看看它提供的主题功能带来的好处。

主题

使用基于 GTK+(或 Qt)的 API 的一个大优点是,小部件集可以主题化。用户可以安装任意数量的主题(或编写自己的主题)来控制应用程序的外观。虽然这可能会增加测试开销,但它们将在所有平台上表现相同,因此负担有所减轻。

让我们看看这里展示的 GoMail 应用程序应用的一些不同主题,首先是名为Clearlooks的出色浅色主题。

  • Linux 上的 Clearlooks 主题:

图片

  • 在 Clearlooks 中撰写:

图片

在 Windows 上,默认主题看起来更像标准小部件,尽管用户可以加载任何其他 GTK+主题。注意,默认图标也有所不同,更符合操作系统标准。

  • Windows 默认主题:

图片

  • 使用 Windows 撰写:

图片

还有许多深色主题;Arc Dark非常受欢迎。

  • 在 Linux 上运行的 Arc Dark 主题:

图片

  • Ark Dark 撰写窗口:

图片

许多主题是为怀旧而设计的,包括这个基于 20 世纪 90 年代彩色桌面环境的 CDE 主题。

  • 运行 CDE 主题以获得复古外观:

图片

  • 在 CDE 主题中撰写:

图片

如您所见,用户界面元素的色彩可以有很大差异,但布局大体上一致。如果您查看按钮(撰写窗口中的发送取消按钮),某些边缘的圆角也有所不同。使用 Go-GTK 构建的应用程序应该能够与任何加载的主题良好工作,但在质量保证过程中检查各种不同的配置是明智的。

摘要

在本章中,我们探讨了 GTK+工具包的细节以及它是如何通过 go-GTK 提供给 Go 的。我们研究了如何在 macOS、Windows 和 Linux 上设置它,以及这些平台上的应用程序看起来和行为方式完全相同。我们探讨了 API 设计、其各种组件以及其事件驱动模型是如何向开发者公开的。

然后,我们回到了第四章的 GoMail 应用程序,“Walk - 构建图形窗口应用程序”,以及第五章,“andlabs UI - 跨平台原生 UI”,使用 Go-GTK 库重新构建它。由于 API 提供了对大多数 GTK+功能的访问,我们发现该应用程序看起来比在第五章中使用的 andlabs UI 内由 Linux 驱动程序创建的基于 GTK+的应用程序更完整。我们在应用程序中实现了一些基本的线程和信号处理,以处理用户输入和后台事件。最后,我们探讨了强大的 GTK+主题引擎如何样式化创建的应用程序用户界面。

到现在为止,你应该已经熟悉了 Go-GTK 库,以及它是如何利用底层的 GTK+工具包,并允许使用 Go 快速开发 GUI 应用程序。这些应用程序将与操作系统标准的界面和感觉不同,但接近标准应用程序设计,因此应该对大多数用户来说都很熟悉。如果界面小部件设计或 API 并不是你想要的,那么请阅读下一章,我们将探讨 GTK 的替代方案,即 Qt 框架。

第七章:Go-Qt - 使用 Qt 的多平台

与我们在上一章中探讨的 Go-GTK 库类似,therecipe 的 qt 允许你使用单个 Go 代码库编写跨平台的图形应用程序。它利用 Qt,这是一个为快速将应用程序交付到桌面和嵌入式计算环境而设计的多平台应用程序框架。像 GTK+一样,它旨在绘制用户熟悉的控件,但不依赖于操作系统提供的工具包。此外,Qt 为移动和嵌入式设备提供了不同的外观,用户期望不同的展示风格。所有这些都在框架内部控制,这样开发者就可以专注于开发单个应用程序。

在本章中,我们将探讨最广泛采用的 Go 语言 Qt 绑定 recipe/qt 的细节。我们将涵盖以下内容:

  • Qt 框架的历史和目标

  • API 是如何设计和桥接到 Go 的

  • 使用 therecipe/qt 库创建应用程序

  • Qt 应用程序的主题功能

到本章结束时,你将熟悉 Qt 框架的功能及其对许多不同平台的支持。通过探索一些示例应用程序和我们的 GoMail 应用程序,你将了解 recipe 的 Go 绑定如何为 Go 开发提供对这些功能的访问。你还应该了解 Qt 框架是否适合你的下一个应用程序。

Qt 背景

Qt 框架是由名为 Trolltech 的公司在 1991 年创建的(现在称为 Qt 公司)。KDE Linux 桌面是基于 Qt 的,Qt 的流行度的增加可能是 Qt 开发变得更加普遍的关键原因。作为一个部分针对嵌入式设备的平台,使用 Qt 的典型开发者与 GTK+框架的开发者不同。此外,由于他们的商业支持,可用的工具和支持得到了更好的发展。

Qt 框架以两种不同的发行版发布,一个是商业版,另一个是开源版(称为双重许可)。这样,他们可以免费支持符合开源规范的应用程序,同时为封闭源代码的商业项目提供无限制的使用。在 2000 年之前(2.2 版本的发布),免费分发的源代码在多种许可证下,一些团体认为这些许可证与常见的开源倡议不兼容。对于 2.2 版本的发布,它被改为 GPL 许可证,这解决了关于该团体对真正开源自由的承诺的任何疑虑。2007 年,发布了 Qt 4.5,他们为更喜欢更宽松许可证的开发者添加了 LGPL 作为选项。

在 2011 年,诺基亚公司成立了 Qt 项目,旨在开放 Qt 库的开发路线图。Qt 最大的市场是在嵌入式设备上,如汽车和家电,这项技术被特斯拉和奔驰等大型公司所采用:

Scribus 是一个用 Qt 编写的流行桌面出版应用程序(图片版权:Henrik Hüttemann)

therecipe 的 Go 绑定(其真实名称未与项目相关联),以及许多贡献者,旨在将 Qt API 及其庞大的支持平台列表带到 Go 语言中。该项目支持为 Windows、macOS 和 Linux 桌面计算机以及 Android、iOS 和许多其他移动和嵌入式设备构建应用程序。

开始使用 therecipe/qt

要开始探索 Qt 和 Go 的绑定,我们将构建一个简单的 hello world 应用程序。为了能够做到这一点,我们首先需要安装 recipe/qt,它依赖于各种必须首先设置的前提条件。

前提条件

与 Go-GTK 一样,我们将依赖于一个本地库,这要求我们设置 CGo 功能并安装适用于当前平台的 Qt 库。

准备 CGo

Qt Go 绑定,就像本书中介绍的其他许多工具包一样,需要 CGo 的存在来利用本地库。在一个完整的开发系统中,这可能是已经设置好的。如果您不确定或想回顾如何设置 CGo 依赖项,请查阅附录 设置 CGo

安装 Qt

Qt 网站 (www.qt.io/download) 提供了各种安装方法,包括任何拥有 Qt 账户的人都可以使用的定制在线安装程序(注册 Qt 账户是免费的)。通常,Qt 安装包括 Qt Creator(项目 IDE)、GUI 设计器、其他工具和示例。访问前面的网站将自动检测您的系统并建议最合适的下载(这通常是最佳选项)。

请注意,Qt 安装可能相当大。如果您硬盘上没有至少 40 GB 的空间,您需要在安装之前腾出一些空间。

一些操作系统将 Qt 库和工具作为其包管理器的一部分提供,这通常提供了一种更轻量级的安装方式,可以自动保持更新。然而,此选项并不提供 Qt 开发工具的完整功能集,本章中讨论的 Qt 绑定默认使用在线安装程序提供的标准 Qt 安装。

macOS

在 Apple macOS 上,最佳安装方法是使用 Qt 下载网站提供的安装程序应用程序。访问 www.qt.io/download 并下载 macOS 安装程序。下载完成后,打开包并运行程序;这将安装所选的编译器、工具和支持应用程序。如果在安装过程中遇到任何错误,第一步应该是检查您的 Xcode 安装是否完整且最新(更多信息,请参阅 安装详情 附录)。

Windows

在 Windows 上安装比我们之前查看的一些工具包更简单,因为 Qt 安装程序捆绑了 mingw 包,用于设置大多数编译需求(尽管在绑定阶段仍建议您设置自己的编译器)。要安装它,请访问之前列出的下载页面并访问 Windows 安装程序。运行下载的可执行文件并按照屏幕上的说明操作。建议安装到默认位置。一旦完成,您就可以设置绑定了。

Linux

使用来自 www.qt.io 的在线安装程序是最简单的方法,尽管也可能通过您的系统包管理器进行安装(如果您想尝试包管理器方法,请首先阅读 Qt Linux 文档,见 github.com/therecipe/qt/wiki/Installation-on-Linux)。在大多数 Linux 平台上,Qt 下载网站将正确检测平台并提供一个简单的运行安装程序。下载文件后,您应该使其可执行,然后运行它:

图片

在 Linux 上,您需要使安装文件可执行并运行它

这将启动安装程序,就像在 macOS 上一样;从这里开始,按照屏幕上的说明完成安装。

许可证 / Qt 账户

当来到登录屏幕时,如果您有 Qt 账户详情,请输入。如果您符合他们的开源许可证(GPL 或 LGPL),您可以跳过此步骤——要这样做,请确保电子邮件和密码字段为空。

安装 qt(绑定)

要使用 qt(Go Qt 绑定),我们需要下载项目和其依赖项,然后运行一个设置脚本以配置和编译库。如果使用 Windows,建议使用附录中描述的 MSYS2 终端。

如果您将 Qt 下载安装到了非默认位置,请确保将 QT_DIR 环境变量设置为所选位置。

首先,应使用 go 工具安装库及其依赖项,通过运行 go get github.com/sirupsen/logrusgo get github.com/therecipe/qt

下载完成后,我们需要运行 qtsetup 工具,该工具包含在 qt 项目中;因此,在 cmd/qtsetup 文件夹中,执行 go run main.go。使用 Linux 终端,它应该看起来像这样:

图片

执行 therecipe/qt 绑定的 qtsetup 脚本

一旦此过程完成,绑定应该可以使用了。如果您遇到错误,那么可能是因为 Qt 工具没有正确安装,或者位置被自定义了,而您忘记设置 QT_DIR 环境变量。

构建

要使用 Go 构建我们的第一个 qt 应用程序,让我们再创建一个 Hello World 应用程序。与之前的示例一样,我们将在单个应用程序窗口内使用一个简单的垂直框布局。以下代码应该足以加载你的第一个应用程序:

package main

import (
   "os"

   "github.com/therecipe/qt/widgets"
)

func main() {
   app := widgets.NewQApplication(len(os.Args), os.Args)

   window := widgets.NewQMainWindow(nil, 0)
   window.SetWindowTitle("Hello World")

   widget := widgets.NewQWidget(window, 0)
   widget.SetLayout(widgets.NewQVBoxLayout())
   window.SetCentralWidget(widget)

   label := widgets.NewQLabel2("Hello World!", window, 0)
   widget.Layout().AddWidget(label)

   button := widgets.NewQPushButton2("Quit", window)
   button.ConnectClicked(func(bool) {
      app.QuitDefault()
   })
   widget.Layout().AddWidget(button)

   window.Show()
   widgets.QApplication_Exec()
}

让我们从这段代码中注意一些细节。您会看到每个小部件构造函数通常接受两个参数,每个参数都是父小部件和一个 flags 参数。传递给这些值的附加类型通常会在函数名中添加一个注释,表明有额外的参数。例如,widgets.NewQLabel2(title, parent, flags) 等同于 widgets.NewQLabel(parent, flags).SetTitle(title)。此外,您会看到布局是通过 SetLayout(layout) 应用到新的 widgets.QWidget 上的,并且通过 window.SetCentralWidget(widget) 设置为窗口内容。

要加载显示并运行应用程序,我们调用 window.Show() 然后调用 widgets.QApplication_Exec()。此文件以通常的方式构建,使用 go build hello.go

图片

构建很简单,但输出文件相当大

由于 Qt 框架的大小,构建的文件相当大。在为特定发行版打包时,这将显著减少。这个主题将在第十四章“分发你的应用程序”中深入探讨。

运行

构建阶段的输出是一个可以在当前计算机上执行的二进制文件,无论是在命令行上还是在文件管理器中双击。此外,您还可以直接使用 go run hello.go 来执行它——无论哪种方式,您都应该看到一个简单的窗口,如图所示:

图片

在 Linux 上运行 qt Hello

图片

在 macOS 上运行

在这个阶段,二进制文件可以在安装了 Qt 的具有相同架构的计算机上执行。我们将在本章后面讨论更广泛的分发。在此之前,让我们更深入地了解 Qt API 和 qt 绑定的运作方式。

对象模型和事件处理

Qt 框架是用 C++ 语言编写的,因此其架构对之前使用过 C++ 编码的人来说非常熟悉。需要注意的是,Go 不是一个完整的面向对象语言,因此它不能直接匹配这些功能。特别是,我们应该关注继承,因为它对 Qt 对象模型非常重要。

继承

Qt API 是一个完全面向对象的模型,大量使用了继承模型。虽然 Go 并不完全支持传统方式的对象继承,但其组合方法非常强大,并且在其位置上运行良好。结果是,你可能不会注意到任何区别!这只有在你想实现一个自定义小部件时才会出现,而这超出了本章的范围。

内存管理

正如你在前面的示例中所注意到的,每个小部件都期望将父对象传递给构造函数。这使得 Qt 框架能够处理小部件树的清理和内存释放。QObject(它是所有 Qt API 的基对象)跟踪其子对象,因此当被移除时,它也可以移除其子对象。这使得创建和删除复杂的 widget 层次结构更容易正确处理。为了使用这个功能,你应该始终记得将父对象传递给小部件的构造函数(以 New... 开头的 Go 函数),尽管传递 nil 可能看起来像是在工作。

信号和槽

Qt 与 GTK+ 类似,是一个事件驱动的框架,并广泛使用信号来处理事件管理和数据通信。在 Qt 中,这个概念被分为信号和槽;信号是事件发生时将生成的内容,槽是可以接收信号的内容。将槽设置为接收信号的动作称为连接,这会导致当连接的信号被调用时,槽函数将被调用。在 Qt 中,这些是类型化的事件,意味着每个信号都有一个与其关联的类型参数列表。当信号被定义时,这个类型被设置,任何希望连接到信号的槽都需要有相同的类型。

在 qt Go 代码中,信号和槽使用类似于 func(string) signal:"mySignal" 和 `func(string) `slot:"mySlot" 的结构标签来定义,这些标签为 Go 类型系统提供元数据,就像我们在第三章“Go to the Rescue!”中的 JSON 示例一样。给定一个定义这些属性的 s 结构,我们可以设置一个函数,当 mySignal 触发时执行以下代码:

s.ConnectMySignal(
   func(msg string) {
      log.Println("Signalled message", msg)
   }
)

信号和槽是 Qt Designer 生成的用户界面的动力源泉,也是处理多线程应用程序的推荐方式。一个信号可能从后台线程触发,用户界面代码可以将这个信号连接到自己的槽——本质上是在监听信号。当信号触发时,任何相关数据(信号的参数)将从一条线程传递到另一条线程,以便在 GUI 更新中安全使用。在许多方面,这与我们在第三章“Go to the Rescue!”中广泛讨论的 Go 通道的工作方式相似。

由于 Qt 是对 Qt API 的轻量级绑定,Go 特定的文档很少,但你可以在官方文档中找到更多关于 Qt 设计和所有可用类的信息,官方文档的网址是 doc.qt.io/qt-5/classes.html

既然我们已经了解了 Qt 应用程序和 qt Go 实现的设置,让我们通过回到我们的 GoMail 示例来探索一个更完整的应用程序。

示例应用程序

为了查看一个更完整的应用程序,我们将重新审视 GoMail 应用程序的原始设计——毕竟,它们最初是用 Qt Designer 创建的。我们将重新创建第四章“构建图形窗口应用程序”中生成的确切布局,并在进行过程中解释实现方式。

布局

在我们的 GoMail 示例中,我们首次拥有了一个工具包,它提供了所有所需的布局来匹配我们在第二部分“使用现有小部件的工具包”开始时设计的用户界面。这或许并不令人惊讶,因为它使用了 Qt 工具创建,但这是一个探索 Qt 提供的更完整布局集以及通过 qt 绑定提供的机会。最有用的布局如下:

布局 描述
框布局现在非常熟悉;它将小部件布局在水平或垂直框中。因此,它使用widgets.NewQVBoxLayout()widgets.NewQVBoxLayout()相应地创建。
表单 这是一个便利的布局,基本上是一个两列网格,其中左侧列的所有小部件都是标签。它根据 Qt Creator 中看到的设计进行了相应的样式化。
网格 这种布局代表了一种灵活的网格布局,使得单元格不必都强制保持相同的大小,而是行和列会根据网格中打包项目的最小尺寸进行伸缩以适应。
间隔 虽然间隔项本身不是布局,但它可以在布局中用来创建视觉空间。使用widgets.NewQSpacerItem(width, height, hPolicy, vPolicy)构造,可以使用这个有用的类添加各种不同类型的间隔。
堆叠 堆叠布局将所有子对象设置为包含小部件的全尺寸,但确保一次只能看到一个。可以使用SetCurrentWidget()SetCurrentIndex()函数来控制哪个子对象可见。这对于实现标签面板或分页控件非常有用。

利用这些知识,我们可以使用纯 Qt 小部件重新创建 GoMail 浏览界面。现在,大部分代码都很熟悉,但也有不少显著的不同之处。首先,你可以看到布局(如之前所列)通常设置在 widgets.QWidget 上,而不是为它们自己的目的创建全新的小部件。这种方法意味着可以保持不同小部件的数量较低,但也导致一些功能被附加到布局上而不是小部件上。例如,我们设置在 detail 小部件上的 widgets.NewQFormLayout() 是为了布局表单组件,因此具有添加行(例如 form.AddRow3)的辅助函数。要使用这些函数,我们必须保持对布局(本代码中的 form 变量)的引用以进行操作。你还可以看到 AddWidget() 是在 widget.Layout() 上而不是直接在 widget 上调用的。

这个片段包含了创建我们基本布局的大部分代码。一些工具栏和菜单代码(相当重复)已被省略,但可以在伴随本书的代码仓库中找到。我们首先导入并创建一个基本的菜单栏框架:

package main

import (
   "github.com/therecipe/qt/core"
   "github.com/therecipe/qt/gui"
   "github.com/therecipe/qt/widgets"
   "os"
)

func buildMenu() *widgets.QMenuBar {
   menu := widgets.NewQMenuBar(nil)

   file := widgets.NewQMenu2("File", menu)
   ...
   menu.AddMenu(file)

   ...

   return menu
}

同样,我们可以使用内置图标创建一个新的工具栏:

func buildToolbar() *widgets.QToolBar {
   toolbar := widgets.NewQToolBar("tools", nil)
   toolbar.SetToolButtonStyle(core.Qt__ToolButtonTextUnderIcon)
   toolbar.AddAction2(gui.QIcon_FromTheme2("document-new", nil), "New")

   ...

   return toolbar
}

最后,我们布局窗口的主要内容:

func main() {
   widgets.NewQApplication(len(os.Args), os.Args)

   window := widgets.NewQMainWindow(nil, 0)
   window.SetWindowTitle("GoMail")

   widget := widgets.NewQWidget(window, 0)
   widget.SetLayout(widgets.NewQVBoxLayout())
   window.SetMinimumSize2(600, 400)
   window.SetCentralWidget(widget)

   window.SetMenuBar(buildMenu())
   widget.Layout().AddWidget(buildToolbar())

   list := widgets.NewQTreeView(window)
   list.SetModel(core.NewQStringListModel2([]string{"email1", "email2"}, widget))

   detail := widgets.NewQWidget(window, 0)
   form := widgets.NewQFormLayout(detail)
   detail.SetLayout(form)
   form.AddRow5(widgets.NewQLabel2("subject", detail, 0))
   form.AddRow3("From", widgets.NewQLabel2("email", detail, 0))
   form.AddRow3("To", widgets.NewQLabel2("email", detail, 0))
   form.AddRow3("Date", widgets.NewQLabel2("date", detail, 0))
   form.AddRow5(widgets.NewQLabel2("content", detail, 0))

   splitter := widgets.NewQSplitter(window)
   splitter.AddWidget(list)
   splitter.AddWidget(detail)
   widget.Layout().AddWidget(splitter)

   window.Show()
   widgets.QApplication_Exec()
}

之前的代码结构与上一章类似(因为 GTK+ 和 Qt API 有很多相似之处),尽管命名会让你想起第四章(3b8f1272-2158-4744-945f-3258b5c4f61c.xhtml),构建图形窗口应用程序,以及 Walk 示例。显然,由于 Walk 主要基于 Qt,命名通常是相同的,但这里使用的 Qt API 不提供相同的声明性语法,因此必须使用基于函数的构造函数来创建。

本例介绍了两个新的 Qt 包,coregui。如你所见,我们使用 core 包与数据模型(许多更复杂的小部件都使用)一起使用。gui 包提供了有助于使用户界面更具吸引力的辅助功能;在这种情况下,我们使用 gui.QIcon_FromTheme2 函数查找标准图标。在一个更完整的应用程序中,我们可以提供备选图标,以完成回复和全部回复的工具栏按钮:

图片

使用 Qt 的 GoMail 应用程序的完整布局

如从这张截图所示,一个 Qt 应用程序甚至可以用最基础的代码看起来也很精致。你可能注意到了电子邮件列表上方的数字 1 而不是收件箱;这是由于用于此布局示例的 core.QStringListModel 的限制,应在我们的完整实现中解决。

编写布局

GoMail 的撰写布局甚至更简单:我们再次使用widgets.QFormLayout,尽管To字段是唯一包含标签的行。对于这个更简单的窗口,我们创建widgets.QDialog并将布局直接设置在对话框小部件上。为了在屏幕底部添加按钮,我们使用一个新的widgets.QWidget,其布局设置为widgets.NewQHBoxLayout()以水平排列按钮。为了管理右对齐,我们在按钮之前首先包含widgets.NewQSpacerItem()在按钮框中。最后请注意,我们在send按钮上调用SetDefault(true),使其成为默认操作:

package main

import "github.com/therecipe/qt/widgets"

func showCompose() {
   dialog := widgets.NewQDialog(nil, 0)
   dialog.SetModal(false)
   dialog.SetWindowTitle("New GoMail")

   form := widgets.NewQFormLayout(dialog)
   dialog.SetLayout(form)
   dialog.SetMinimumSize2(400, 320)

   form.AddRow5(widgets.NewQLineEdit2("subject", dialog))
   form.AddRow3("To", widgets.NewQLineEdit2("email", dialog))
   form.AddRow5(widgets.NewQTextEdit2("content", dialog))

   buttons := widgets.NewQWidget(dialog, 0)
   buttons.SetLayout(widgets.NewQHBoxLayout())
   buttons.Layout().AddItem(widgets.NewQSpacerItem(0, 0, widgets.QSizePolicy__Expanding, 0))
   buttons.Layout().AddWidget(widgets.NewQPushButton2("Cancel", buttons))
   send := widgets.NewQPushButton2("Send", buttons)
   send.SetDefault(true)
   buttons.Layout().AddWidget(send)
   form.AddRow5(buttons)

   dialog.Show()
}

从前面的代码中,我们得到以下期望的结果——一个简单且熟悉的撰写对话框窗口:

图片

使用 Qt 小部件的电子邮件撰写对话框

现在布局已经完成,让我们将我们的测试电子邮件服务器连接起来,以显示一些电子邮件数据。

信号

为了完成 GoMail 示例的交互,我们将使用 Qt 中的标准信号和槽。首先,我们需要设置我们的测试电子邮件服务器的一个实例并加载数据。我们添加一个setMessage(*client.EmailMessage)函数来设置标签的内容,这个函数可以在我们的 GUI 加载时和电子邮件列表被点击时调用:

func (m *mainUI) setMessage(message *client.EmailMessage) {
   m.subject.SetText(message.Subject)
   m.to.SetText(message.ToEmailString())
   m.from.SetText(message.FromEmailString())
   m.date.SetText(message.DateString())

   m.content.SetText(message.Content)
}

处理点击电子邮件列表的代码看起来像以下片段。我们创建一个匿名函数并将其连接到selectionChanged信号。记住在查找所选行号之前检查是否没有选中的索引:

list.ConnectSelectionChanged(func(selected *core.QItemSelection, _ *core.QItemSelection) {
   if len(selected.Indexes()) == 0 {
      return
   }

   row := selected.Indexes()[0].Row()
   m.setMessage(m.server.ListMessages()[row])
})

接下来,我们需要更新我们的工具栏和菜单,以便在点击“新建”时打开撰写对话框。要连接的信号是triggered;我们需要将showCompose()包裹在一个匿名函数中,因为信号类型传递一个bool标志(用于检查状态),而我们想忽略这个标志。代码对于工具栏和菜单是相同的:

new := file.AddAction("New")
new.ConnectTriggered(func(_ bool){showCompose()})

相似的代码用于处理按钮点击,它发送一个clicked信号;我们的撰写对话框c将连接一个匿名函数来撰写电子邮件,发送它,并在点击“发送”时隐藏对话框:

send.ConnectClicked(func(_ bool) {
   email := c.createEmail()
   c.server.Send(email)
   c.dialog.Close()
})

线程处理

如前所述的点击处理程序所示,复杂应用程序的多线程方面由 Qt 中的信号-槽设计处理。在槽中执行的代码将在正确的线程上运行以进行图形更新。此外,任何作为信号定义一部分传递的数据都可以以线程安全的方式访问。我们将利用这个特性来处理我们的后台电子邮件通知。

为了设置这个,我们创建一个新的自定义信号。这是由qtmoc工具实现的,该工具与 recipe/qt 绑定一起提供。我们将更新我们的mainUI结构定义,使其继承自core.QObject(这是一个要求),然后定义一个带有signal标记的匿名函数,该标记定义了信号的名字:

core.QObject
_ func(message *client.EmailMessage) `signal:"newMail"`

一旦设置好,你应该在当前目录下运行 qtmoc 工具;这将生成包括 ConnectNewMail()NewMail() 方法(分别连接方法和信号触发器)以及一个新的构造函数。一旦完成,我们必须更新我们的代码以使用新生成的构造函数(如果你的信号没有触发槽,那么这个步骤可能被遗漏了):

main := NewMainUI(nil)

我们随后添加新的代码来将 prependEmail(client.EmailMessage) 连接到 newMail 信号。一旦连接成功,我们就监听 server.Incoming() 通道,并且每次有消息到达时,我们使用生成的 NewMail(client.EmailMessage) 函数发送信号:

main.ConnectNewMail(main.prependEmail)
go func() {
   for email := range main.server.Incoming() {
      main.NewMail(email)
   }
}()

在此代码到位后,我们的后台代码将触发适当的处理程序,并且所有更新都将在正确的线程上发生,以便立即更新屏幕。

跨编译

为其他桌面平台编译基于 Qt 的应用程序目前不支持与其他示例相同的方式。然而,有一个替代方法,即使用 Docker 作为部署方法。使用这种方式设置工具和运行构建超出了本章的范围,但你可以在 github.com/therecipe/qt/wiki/Deploying-Application 上了解更多关于部署 Qt 应用程序的信息。

主题化

Qt 主题(在 Qt 术语中称为 Style)可以像前一章中的 GTK 应用程序一样进行调整。在 Qt 5 之前,当前主题可以通过标准设置应用程序进行配置,但在 Qt 5 中,目标是与当前桌面兼容——因此,应用程序样式将适应以融合。可以在每个应用程序的基础上覆盖这些设置。由于我们的应用程序在 QApplication 构造函数中传递了命令行参数(widgets.NewQApplication(len(os.Args), os.Args)),我们继承了某些有用的选项,例如 -style=OtherStyle

另一个可能对应用程序非常有用的默认参数是 -reverse。此选项将告诉所有布局以从右到左的方向工作,而不是默认的从左到右:

带有反向布局的 GoMail

摘要

在本章中,我们探讨了流行的 Qt 工具包,其历史以及我们如何使用它用 Go 构建吸引人的图形应用程序。我们看到了创建一个在许多支持的平台上工作方式相同的 GUI 是多么容易。

通过探索我们的 GoMail 应用程序,我们发现强大的布局和内置的标准图标有助于快速构建吸引人的用户界面。therecipe 的 Qt 绑定提供的工具允许我们创建自定义信号来处理我们的后台处理并避免多线程问题。我们将在第十四章 Distributing Your Application 中进一步探讨这些 Go 应用的多操作系统分发。

在第三部分“现代图形工具包”中,我们告别了那些使用标准小部件集的熟悉工具包。我们将探讨各种小部件工具包,这些工具包要么是为了跨平台交付而从头设计的,要么是为了与 Go 编程语言完美匹配。

第三部分:现代图形工具包

在第二部分的四个章节中,使用现有小部件的工具包,我们探讨了各种图形工具包,这些工具包为 Go 应用程序提供了与现有小部件集交互的不同方式。这些工具包为原生小部件(如 Windows 中的 CommonControls 或 macOS 中的 Cocoa 小部件)或现有跨平台工具包(GTK+和 Qt)提供了 Go API。这些小部件集经过测试,通常由商业公司支持,并建立了开发者工具来支持其功能(尽管并非所有功能都可用于 Go 绑定)。使用这些 GUI API 构建的应用程序的外观将根据它们运行的平台而有所不同。这可能,也可能不是期望的行为。

在第三部分,现代图形工具包,将探讨专门为 Go 语言设计的图形工具包。作为它们设计的一部分,每个工具包都旨在在其支持的各个操作系统上看起来和感觉相同。这样做意味着在一定程度上打破了传统的小部件设计和布局,而且大多数这些 API 都抓住了机会,从更现代的来源中汲取灵感来设计它们的解决方案。

作为专门为 Go 语言设计的库,它们与标准库很好地配合,使得它们易于学习和与其他 Go 包集成。作为跨平台工具包的一部分,它们还旨在简化跨编译应用程序的复杂性,以便更容易分发。使用这些工具包构建的应用程序通常体积较小,意味着下载时间快,并且由于加载的代码比更成熟的跨平台解决方案少,因此可以预期它们运行得很快。

本节将涵盖以下章节:

  • 第八章,Shiny – 实验性的 Go GUI API

  • 第九章,nk – Nuklear for Go

  • 第十章,Fyne – 基于 Material Design 的 GUI

第八章:Shiny - 实验性 Go GUI API

Shiny 是一个从头开始设计和纯 Go 编写的实验性 GUI 库。它被创建来探索为 Go 语言构建跨平台 GUI 时可能实现的内容。它不是 Go 语言的官方 GUI 工具包(尽管它是由谷歌的开发者创建的),但它为大多数受支持的 Go 平台上的图形应用程序提供了一个坚实的基础。

本章探讨了如何使用 Shiny 项目构建跨平台的图形应用程序,无需 C 库或预安装的依赖项。本章将涵盖以下主题:

  • Shiny 项目的原理及其小部件的设计

  • 工具包是如何构建以支持多个平台而无需外部驱动程序或本地库的

  • 构建一个可以轻松交叉编译到不同系统的基本图形应用程序

  • 使用 Shiny 创建一个完整的应用程序

在本章结束时,你应该对这一实验性新 API 了如指掌。

Shiny 的背景和愿景

Shiny 项目是为了理解如何创建一个符合 Go 语法的图形应用程序工具包而创建的。因此,它的 API 和方法应该与 Go 语言语义和标准库相匹配,其依赖项应该是纯 Go 库或现有系统例程,并且它应该提供一种现代的方法来开发应用程序 GUI。正如我们从本书的第二部分中看到的工具包绑定所知,这一切大多只有在从头开始时才有可能。它位于golang.org/x/exp/shiny存储库中——这是 Go 库的一个实验性扩展。

该项目最初是由 Go 开发者 Nigel Tao 发起的一项调查,他一直在从事golang.org/x/mobile(Shiny 所依赖的项目),因为他想看到桌面应用程序由新的 API 支持。经过大量开发后,建议将其添加为golang.org存储库中的实验性项目,并于 2015 年获得批准。预计在未来某个时刻,golang.org/x/mobilegolang.org/x/exp/shiny之间的共性将体现在一个独立的项目中,而移动和桌面特定的部分将保留在其各自的项目中。

近年来,该项目的开发速度有所放缓,但它仍然是构建图形应用程序的强大基础。目前尚不清楚该项目是否会复兴,或者将成为其他项目的基础。无论如何,它是一个优秀的低级图形 API,适用于 Go 语言,因此我们将深入研究其细节,并开始构建一个示例应用程序。

设计和受支持的平台

Shiny 项目被设计成确保小部件代码和底层渲染代码之间有良好的分离。它还基于这样的理解:图形驱动程序可能对多个平台都很有用,并且随着时间的推移可能会被更改或添加。

架构

Shiny API 被分为两层,底层处理图形缓冲区和渲染,高层则存放小部件和布局代码。每一层都有明确的职责,它们的分离有助于保持 API 的整洁。

底层

Shiny API 的底层负责为每个支持的平台创建渲染上下文。它还负责处理来自键盘和其他外围设备输入。图形展示的主要概念是 缓冲区纹理窗口

  • 缓冲区:缓冲区指的是内存中像素数据的一个数组。这可能是加载的图像、绘图画布或任何需要在应用程序中展示的其他图形数据。

  • 纹理:纹理是对图形状态的快照的引用,它已准备好进行渲染。它将不可用于应用程序。纹理可以立即渲染(例如当前小部件状态)或存储并在将来多次渲染(例如图像)。

  • 窗口:窗口是应用程序图形输出的位置。在应用了某些变换(由驱动程序确定)后,纹理将被渲染到窗口中。

在正常的应用程序流程中,图形用户界面的代码将更新小部件或布局状态——导致缓冲区内容更新。然后,该缓冲区将被上传到纹理中,以便由驱动程序进行绘制。纹理随后将被渲染到应用程序窗口,可能通过驱动程序或底层平台的图形实现中的变换。如果你熟悉 OpenGL 的工作方式,那么这些过程将看起来非常熟悉——这不是巧合,因为这种方法已被充分验证,Shiny 驱动程序之一就使用了 OpenGL API。对于大多数应用程序开发者来说,纹理的存在可能不可见或不重要,但在优化代码时考虑这个过程可能会有所帮助。

驱动程序还处理用户交互,将它们封装为mouse.Eventkey.Event结构(在x/mobile/event中定义)。一个widget.Widget可以注册接收这些事件,使用过滤器来确定哪些与该对象相关,并将它们标记为已处理。或者,应用程序可以直接从screen.Window访问事件队列,调用NextEvent()将等待另一个事件发生。采用这种方法的程序应该意识到 Shiny 可以生成大量的事件(参见本章后面的入门部分中的示例)。当处理事件时,Shiny 项目包括一个强大的手势包,它允许你根据gesture.Event进行过滤,该事件描述了比底层数据更多的基于意图的信息。有用的事件类型包括gesture.TypeDraggesture.TypeIsLongPressgesture.TypeIsDoublePress(事件类型可以通过手势事件上的Event.Type访问)。

高层

高层 API 专注于小部件和图形用户界面的整体布局和行为。在这个级别工作,开发者不会期望处理缓冲区和事件,而是处理按钮、文本区域和布局等高级概念。在这个层(在widget包内)定义的类型和函数旨在从高层次易于理解,并包括大多数开发者都熟悉的图形用户界面概念。

Shiny 小部件(在本章后面的小部件和材料设计部分中进一步详细说明)都是用纯 Go 编写的,并封装了任何小部件逻辑(如输入处理),以及渲染(通过实现node.PaintBase()node.Paint()函数)。这使得用户界面代码可以完全与驱动程序分离,以便更好地测试,并促进所有支持的操作系统的统一性。

支持的平台

Shiny 项目目前支持 Windows、macOS、Linux、DragonFly BSD 和 OpenBSD。其他使用 X11 的 BSD 发行版或 Unix 系统(参见以下关于驱动程序的讨论)可能可以工作,但在此时尚未官方支持。

如果现有的驱动程序能够运行,支持操作系统的代码相对较轻量。例如,如果在一个尚未得到支持的平台上安装了 OpenGL,那么你可能能够添加操作系统特定的代码来连接它。在这种情况下,需要为特定平台创建一个 Go 文件来打开窗口,并处理 OpenGL 窗口的任何平台特定输入或设备设置。

在一个现有驱动程序当前无法工作的平台上,添加支持将是一项巨大的工作。除了窗口和用户交互代码外,还需要从头编写图形渲染器和表示层,或者提供一个 API 桥接到现有的一个。这种实现必须处理主 Shiny 代码使用的完整绘图原语和变换集(此类列表超出了本书的范围)。

当前包含的驱动程序

在撰写本文时,Shiny 有三个完整的驱动程序(winglx11),并且这些驱动程序必须实现 Shiny 工具包的所有输入和输出功能。驱动程序输出部分需要定义一个合适的screen.Texture提供者,以便将缓冲区上传并准备好进行渲染,并处理渲染过程。在输入方面,驱动程序必须处理鼠标和键盘事件,并将它们转换为golang.org/x/mobile类型,然后由 Shiny 事件处理代码进行过滤。每个驱动程序的详细信息如下:

  • gl:最常用的驱动程序,基于跨平台的 OpenGL 构建,它利用这个标准 API 进行图形显示。许多操作系统提供此功能,但应注意的是,这可能在所有设备上都不受支持。

  • win:win 驱动程序专门为 Microsoft Windows 操作系统构建,无需 OpenGL API 即可工作。渲染由图形设备接口GDI)提供。

  • x11:X11 驱动程序为 Linux 和 Unix 上的标准图形桌面平台提供支持。它直接与XServer通信,并使用共享内存SHM)扩展来有效地通信图像数据。

在这些驱动程序之间,至少为本书前面描述的工具包支持的操作系统提供了至少一个渲染定义,可能还有更多。这些细节在日常使用 Shiny 编程时不应成为问题,但了解未来的可能扩展是有帮助的。

开始使用 Shiny

遵循 Shiny 的设计原则,不依赖于任何本地库或系统依赖项,因此使用 Shiny 没有先决条件。因此,我们可以直接开始安装库并看到它的实际应用。

设置

安装 Shiny 库就像安装来自golang.org/x/exp/shiny的 Go 文件及其x/mobilex/image依赖项一样简单。由于这些都是顶级项目,您可能会看到有关没有 Go 文件的警告——您可以忽略这个警告,因为 API 将被安装:

获取 x/exp/shiny 将下载包内容

不需要额外的库或系统配置。

示例

在我们开始构建应用程序之前,让我们加载一个示例项目来检查 Shiny 是否已安装并且工作正常。该项目提供了各种示例项目——我们将检查名为basic的项目。只需切换到examples/basic目录并运行main.go

图片

启动基本的 Shiny 示例

启动后,你应该看到以下窗口,以及(如前所述)由应用程序触发的事件输出。窗口的大小可能因你运行的操作系统而异,这是由于驱动程序的默认值:

图片

一个相当不寻常的示例应用

正如你所见,这个示例应用与其他我们探索的工具包不同。这代表了 Shiny 项目的主要焦点,即作为一个技术演示。

交叉编译

作为旨在完全用 Go 编写的项目,其目标之一是使其交叉编译到不同平台与为当前操作系统构建一样简单。在 Shiny 驱动程序是纯 Go(如当前windowsx11,由 Linux 和 BSD 使用)的操作系统的情况下,为特定操作系统编译就像使用GOOS参数一样简单,如第三章中所述,Go 来拯救!

图片

从 Linux 命令提示符编译 Linux 和 Windows 的二进制文件

提供硬件加速渲染(由 macOS 和 Linux 使用)的gl驱动程序依赖于一个当前没有 CGO 就无法获得的系统 API,因此交叉编译更具挑战性。通过工具包设计,如果 CGO 不可用,可以使用x11驱动程序为 Unix 目标平台,因此 Linux 或 BSD 仍然可以进行交叉编译。

注意,为 Linux 交叉编译 Shiny 应用程序将导致应用程序没有启用图形加速。这可以通过使用CGO_ENABLED=1并安装各种库来解决,但它很容易被遗忘,因此建议设置一个专门的 Linux 构建环境。

这意味着,从 macOS 出发,我们可以通过简单地设置适当的GOOS变量来交叉编译 Linux 和 Windows 的可执行文件,正如预期的那样:

图片

为 macOS 交叉编译

对于 Windows 和 Linux(以及一些 BSD 版本),所有交叉编译都不需要 CGO,因此我们只需要关注 macOS 作为一个特殊情况。为了成功为 macOS 交叉编译,我们必须在我们的构建中添加CGO_ENABLED=1,这将使构建过程寻找所需的系统库。显然,这些库通常不可用,因此我们必须设置我们的开发环境以提供所需的 API。

设置用于 macOS 交叉编译的 clang 二进制文件和所需 API 包的过程可能很复杂,但如果你已经完成了第五章,andlabs UI - 跨平台原生 UIs,那么这些设置已经完成。如果你直接跳到了这一章,那么你可能需要遵循附录,跨平台编译设置,使用 cgo 进行 macOS 交叉编译部分中的步骤。一旦完成,你应该会有一个名为o32-clang的新编译器可用,它能够链接到 macOS Foundation API。

为了构建应用程序,我们现在设置GOOSCGO_ENABLED标志,就像之前一样,但还通过额外的CC环境变量指定要使用的编译器,将其设置为o32-clang。完成此配置后,我们可以从 Linux 终端构建 macOS Shiny 应用程序:

图片

从 Linux 终端构建 macOS 应用程序

以这种方式构建的应用程序将具有完整的 OpenGL 加速,就像它们是在 macOS 计算机上直接构建的一样。

现在我们已经看到了使用 Shiny 构建的所有细节,让我们来探索这些应用程序是如何设计的。

小部件和材料设计

在我们开始一个简单的应用程序之前,我们需要更多地了解 Shiny 小部件及其视觉设计对开发的影响。我们之前探讨的其他工具箱在使用 API 时不需要这种理解,但 Shiny 中高级 API 的实验性状态意味着即使是“hello world”应用程序也需要对工具箱的工作方式有一定的了解。

在我们深入了解 Shiny 提供的小部件及其使用方法之前,让我们先看看 Shiny 项目的整体设计和图标设计。这种设计方法与我们在本书中之前探讨的工具箱有所不同,但对于任何 Android 应用开发者或 Google 产品套件的用户来说,应该都很熟悉。

设计

如果你对“材料设计”这个概念还不熟悉,可以这样定义:

...一个灵活的指南、组件和工具系统,支持用户界面设计的最佳实践"

-material.io

这些设计原则对于拥有 Android 智能手机或平板电脑的人来说都很熟悉,并且与微软为最近发布的支持平板电脑和触摸屏用户输入的 Windows 操作系统所采用的用户界面设计有些相似。这种方法旨在帮助开发者快速创建美观的应用程序,并简化开发者和设计师之间的沟通。这种设计语言也有助于在应用程序试图通过自己的品牌设计脱颖而出的世界中,促进一致的用户体验。

利用材料设计原则的应用程序可能不会与所有其他应用程序完全相同,但将具有足够的相似性,用户应该不会难以理解其工作方式。颜色、布局和导航可能因界面而异,只要它们遵循设定的指南。材料设计包括一些标准调色板和创建自定义调色板的建议,以满足您的需求。布局和导航小部件也有标准实现,但可以根据上下文进行扩展和使用。这些标准小部件被称为材料组件,它们是为 Android、iOS、Flutter 和网页创建的——Shiny 是将它们带到桌面的一种潜在方法:

展示材料设计的 Android 应用程序。图片版权:Google。

在线有许多工具可以帮助您了解和采用材料设计原则。它们可以在材料设计网站上找到,网址为material.io。让我们探索 Shiny API 核心的一些细节。

图标

材料设计项目创建了一套标准的图标,这些图标可以免费在任何应用程序中使用。将这些清晰、简洁的图标集成到用户界面中,可以添加简单易懂的提示,与其它应用程序保持一致,并可以减少最终用户界面中过多的文本。Shiny 将最常用的图标打包在materialdesign/icons包中,并且可以通过 API 通过名称引用。要查看名称和图标的列表,可以运行 Shiny 的IconGallery示例(如图中所示):

Shiny 包括矢量图形的材料图标集

图标可以通过在node.PaintBase()方法中创建一个iconvg.Rasterizer实例来绘制,并为node.PaintBaseContext调用iconvg.Decode()来解码图标引用(这些图标以紧凑的 IconVG 格式存储)。本章后面(或 Shiny 示例代码中)可以找到此代码的示例。

主题

材料设计中的一个核心概念是调色板——虽然它不强制规定应用程序可以使用的颜色,但它对颜色选择和搭配有非常明确的规则。鼓励设计师从标准调色板(用于大多数用户界面元素)中选择一个主色(用于突出显示和强调),以及一个辅助色(用于辅助色和强调色),以相互补充。对于每种颜色,都有标准的浅色和深色变体,这可以为应用程序界面增加深度。您可以通过material.io/tools/color上的在线颜色工具来探索这些。

Shiny 使用的调色板遵循这种方法;可用的颜色有:

  • theme.Foreground:主题的标准前景色——用于文本和图标

  • theme.Background:容器标准背景颜色

  • theme.Neutral:较小区域的后景色,应与背景区分开来

  • theme.Light:中性颜色的较浅版本

  • theme.Dark:中性颜色的较深版本

  • theme.Accent:次要调色板中的主颜色,用于突出显示关键元素

在 Shiny API 中,颜色是通过theme.Color类型而不是 golang 的color.Color类型传递的。这确保了使用的颜色来自主题调色板。从主题颜色类型中,您可以调用Color()函数来获取标准颜色类型或调用Uniform()函数来获取image.Uniform,这在Paint()函数中用于绘制填充矩形。

应用程序可以选择使用内置主题(theme.Default)或提供自己的主题。任何实现了theme.Theme类型的类型都可以用于 Shiny GUI 的渲染。

小部件

作为更专注于底层跨平台图形渲染能力的项目,Shiny 工具包不附带许多标准小部件。已经投入了大量工作来设置构建块,以便应用程序可以提供自己的小部件,但如果您想使用内置类型,widget包提供的列表如下:

  • 流:这是一个容器,其子小部件沿水平或垂直轴(在Flow.AxisNewFlow()中设置)排列。

  • 弹性:实际上,在flex子包中,这是一个根据 CSS flexbox 算法排列其子小部件的容器。与widget.Flow一样,布局参数是在flex.Flex容器上设置的。

  • 图像:此小部件在屏幕上渲染 golang 的image.Image。其尺寸是单独指定给图像的。

  • 标签:这是一个简单的显示带有主题颜色(例如,theme.Foreground)的文本行的工具。

  • 填充器:一个包含子小部件并带有指定空间围绕水平或垂直维度(或两者)显示的不可见小部件。

  • 工作表:工作表提供了所有其他小部件将绘制其上的缓冲区。任何不是工作表子小部件的小部件可能不会被渲染。如果内容应独立移动,则需要多个工作表,例如滚动视图。

  • 尺寸调整器:尺寸调整器是一个包含子小部件但覆盖其尺寸的不可见小部件。这可以用来指定与现有小部件默认尺寸不同的自然尺寸。

  • 空间:一个占据可用空间的不可见小部件。放置在两个小部件之间,它们将变为左对齐和右对齐,或者通过在widget.Space小部件的任一侧放置小部件,它将变为居中对齐。

  • 文本:一个多行文本小部件,用于显示比widget.Label更复杂的文本。

  • 均匀:这是一个简单的绘制来自主题调色板(例如,theme.Background)的实色矩形的工具。

此外,还有一个名为glwidget.GL的高级小部件,它将嵌入式系统的 OpenGLGLESframebuffer渲染到 Shiny 应用程序中。这对于传统应用程序来说并不常见,但它是一个很好的附加功能,以支持这一功能。

尽管前面的列表很长,但你可能会注意到它并没有提供我们在其他章节中使用过的所有小部件。因此,我们将构建一个不同的示例应用程序。这次,一个更适合工具包功能的应用程序。在此之前,让我们回到开始并创建一个 hello world 应用程序。

开始继续

现在我们已经探索了 Shiny 的设计方式和当前的限制,我们可以实现我们的第一个图形应用程序并看到它运行。

代码

让我们从编写一个简单的hello world窗口开始,就像前面的章节一样。由于当前工具包的低级性质,这段代码比前面的例子要复杂一些。除了定义窗口、标签和按钮外,我们还需要设置背景层并测量包含窗口的最小尺寸:

package main

import (
   "golang.org/x/exp/shiny/driver"
   "golang.org/x/exp/shiny/screen"
   "golang.org/x/exp/shiny/widget"
   "golang.org/x/exp/shiny/widget/theme"

   "log"
)

func main() {
   driver.Main(func(s screen.Screen) {
      label := widget.NewLabel("Hello World!")
      button := newButton("Quit",
         func() {
            log.Println("To quit close this window")
         })

      w := widget.NewFlow(widget.AxisVertical, label, button)
      sheet := widget.NewSheet(widget.NewUniform(theme.Neutral, w))

      w.Measure(theme.Default, 0, 0)
      if err := widget.RunWindow(s, sheet, &widget.RunWindowOptions{
         NewWindowOptions: screen.NewWindowOptions{
            Title: "Hello",
            Width: w.MeasuredSize.X,
            Height: w.MeasuredSize.Y,
         },
      }); err != nil {
         log.Fatal(err)
      }
   })
}

在前面的代码中,你可以看到流布局(widget.NewFlow())、背景层(widget.NewSheet())和测量初始化(w.Measure())。在 Shiny 中,widget.Sheet是任何小部件下面的必需项,这样它们才能正确绘制。在简单的应用程序中,一个单独的层应该足够,但在更复杂的用户界面中,其中项目可以独立移动(即,滚动),可能需要额外的层。

支持代码

如你所注意到的,前面的代码有两个问题,都与Quit按钮有关。首先,被调用的func()实际上并没有退出应用程序。这是 Shiny 生命周期代码的一个当前限制。可以通过自定义生命周期来解决这个问题,但由于需要大量代码,这并不推荐。其次,你可能注意到newButton()是一个局部函数,而不是widget包的一部分。工具包列表中目前缺少的一个标准按钮,因此,我们必须自己定义一个。这可以通过添加以下描述的代码来完成:

首先,我们定义自定义节点;它必须首先继承自node.LeafEmbed。我们添加了包含文本标签的字段和当它被点击时应该调用的onClick函数。我们还应该添加一个方便的方法来构建按钮。这需要设置node.Embed.Wrapper字段,因为这个字段不应该为nil

type button struct {
   node.LeafEmbed

   label   string
   onClick func()
}

func NewButton(label string, onClick func()) *button {
   b := &button {label: label, onClick: onClick}
   b.Wrapper = b

   return b
}

为了定义按钮占据的合适区域,我们需要实现Measure()函数。这将更新一个用于界面布局的缓存大小(node.Embed.MeasuredSize):

const buttonPad = 4

func (b *button) Measure(t *theme.Theme, widthHint, heightHint int) {
   face := t.AcquireFontFace(theme.FontFaceOptions{})
   defer t.ReleaseFontFace(theme.FontFaceOptions{}, face)

   b.MeasuredSize.X = font.MeasureString(face, b.label).Ceil() + 2*buttonPad
   b.MeasuredSize.Y = face.Metrics().Ascent.Ceil() + face.Metrics().Descent.Ceil() + 2*buttonPad
}

要在屏幕上显示内容(这实际上是将画到前面提到的底层 widget.Sheet 上),我们添加一个 PaintBase() 函数。对于我们的按钮,我们将绘制一个 theme.Foreground 颜色的矩形作为基础,并使用 theme.Background 颜色作为文本的颜色(这样我们的按钮就能从其他文本中脱颖而出)。请注意,在实际绘制之前,我们从对象中移除 node.MarkNeedsPaintBase 标记,这样它就不会在下一个界面重绘时被重新绘制:

func (b *button) PaintBase(ctx *node.PaintBaseContext, origin image.Point) error {
   b.Marks.UnmarkNeedsPaintBase()
   face := ctx.Theme.AcquireFontFace(theme.FontFaceOptions{})
   defer ctx.Theme.ReleaseFontFace(theme.FontFaceOptions{}, face)

   draw.Draw(ctx.Dst, b.Rect.Add(origin).Inset(buttonPad), theme.Foreground.Uniform(ctx.Theme), image.Point{}, draw.Src)
   d := font.Drawer{
      Dst: ctx.Dst,
      Src: theme.Background.Uniform(ctx.Theme),
      Face: face,
      Dot: fixed.Point26_6{X: fixed.I(b.Rect.Min.X + buttonPad), Y: fixed.I(b.Rect.Min.Y + face.Metrics().Ascent.Ceil() + buttonPad)},
   }
   d.DrawString(b.label)

   return nil
}

最后,按钮需要一个点击处理程序。我们可以实现 OnInputEvent() 函数,以便 Shiny 可以向按钮发送事件。在这里,我们检查事件是否为 gesture.Event,如果是,则检查其类型是否为 gesture.TypeTap。如果满足这些条件,并且我们已注册 onClick 处理程序,则调用 b.onClick()

func (b *button) OnInputEvent(e interface{}, origin image.Point) node.EventHandled {
   if ev, ok := e.(gesture.Event); ok {
      if ev.Type == gesture.TypeTap && b.onClick != nil {
         b.onClick()
      }

      return node.Handled
   }

   return node.NotHandled
}

这就完成了使用 Shiny 实现一个“Hello World”图形应用程序所需的代码(完整代码在本书的代码库中)。现在,让我们构建并运行该应用程序。

构建 & 运行

由于 Shiny 没有原生依赖项,构建我们的 Shiny “Hello World” 应用程序非常简单——我们可以直接构建或运行 hello.go 文件。此外,由于许多平台驱动程序没有使用 CGo 编写,我们可以轻松地为这些操作系统进行交叉编译。以下屏幕截图显示了为 Linux 构建然后为 Windows 构建而没有额外设置的情况:

图片

构建我们的 hello 应用程序并在没有 CGo 的情况下运行它很容易

注意,为 macOS 构建将需要一些额外的设置,因为它的驱动程序使用 CGo(如 入门 中先前所述的 交叉编译 部分中描述的)。

无论你如何构建或运行应用程序,你应该看到一个小的窗口,类似于以下内容:

图片

使用 Shiny 的“Hello World”

我们可以完善我们应用程序的视觉效果,但我们将继续进行一个更大的应用程序,以展示 Shiny 的功能。

构建用户界面

为了探索 Shiny 工具包的功能,我们将构建另一个完整的图形应用程序。由于 Shiny 的发展主要集中在图形 API 的底层,因此像 GoMail 这样的应用程序将涉及创建许多自定义小部件。相反,我们将查看一个更具图形导向的应用程序——图像查看器。

设计

为了了解图像查看器应该如何看起来,我们将制作一个粗略的设计,以便我们遵循。在线工具 Balsamiq (balsamiq.com) 是快速创建适合此目的的线框的好方法。看看下面的导出。它包括顶部的导航栏、左侧的目录列表和右侧的全尺寸图像视图:

图片

使用 Balsamiq 工具制作的 GoImages 应用程序原型

显然,这张图片的细节水平远低于我们在第四章,“走——构建图形窗口应用”(回到第二部分,“使用现有小部件的工具包”)中使用的图形设计工具,但这是故意的。本书第三部分,“现代图形工具包”中的每个工具包都有一个非常不同的外观,由其主题定义和通过使用粗略的设计来设置,我们可以为每个工具包构建一个使用最佳实践的实现。

布局

要开始,我们将实现布局。为我们的应用设置布局的最简单方法是使用水平和垂直流布局。在创建布局之前,我们应该定义那些将被包含的小部件。目前这些由makeBar()makeList()中创建的占位符表示——每个都简单地创建一个标签来显示其目的。我们还希望确保项目根据我们的设计进行填充。为了使用 Shiny 实现这一点,我们使用widget.NewPadder()和一个定义的单位padSize。我们还定义了一个spaceSize,稍后用于中央填充:

package main

import (
  "golang.org/x/exp/shiny/driver"
  "golang.org/x/exp/shiny/screen"
  "golang.org/x/exp/shiny/unit"
  "golang.org/x/exp/shiny/widget"
  "golang.org/x/exp/shiny/widget/node"
  "golang.org/x/exp/shiny/widget/theme"

  "image"
  "log"
  "os"

  _ "image/jpeg"
)

var padSize = unit.DIPs(20)
var spaceSize = unit.DIPs(10)

func makeBar() node.Node {
   bar := widget.NewUniform(theme.Neutral,
      widget.NewPadder(widget.AxisBoth, padSize,
         widget.NewLabel("Navigation")))

   return widget.WithLayoutData(bar,
      widget.FlowLayoutData{ExpandAlong: true, ExpandAcross: true})
}

func makeList() node.Node {
   return widget.NewUniform(theme.Background, widget.NewLabel("File list"))
}

要在我们的布局中显示图片,我们可以使用widget.Image,但首先我们需要从文件系统加载一张图片——一个辅助函数loadImage()被定义来处理这个应用。当加载图片时,别忘了导入适当的解码器(在这个例子中,image/jpeg):

func loadImage(name string) image.Image {
   reader, err := os.Open(name)
   if err != nil {
      log.Fatal(err)
   }
   defer reader.Close()

   image, _, err := image.Decode(reader)
   if err != nil {
      log.Fatal(err)
   }

   return image
}

准备就绪后,我们就可以实现布局了。主方法构建了小部件树并创建widget.Sheet来管理它们的渲染。这被传递给widget.RunWindow()以显示内容和运行应用。主要的布局元素是body(水平流)和container(垂直流,包含导航和主体)。注意在文件列表和图片查看器之间传递了一个nil子小部件给widget.NewPadder(),以近似小部件间距。你还可以看到sheet的子小部件实际上是一个使用widget.NewUniform()创建的theme.Background着色矩形——这有助于确保如果有小部件未绘制其部分区域,我们有一个一致的后台颜色。然后容器通过成为均匀的子小部件来填充空间:

func main() {
   driver.Main(func(s screen.Screen) {
      image := loadImage("shiny-hall.jpg")

      body := widget.NewFlow(widget.AxisHorizontal, makeList(),
         widget.NewPadder(widget.AxisHorizontal, spaceSize, nil),
         widget.NewImage(image, image.Bounds()))
      container := widget.NewFlow(widget.AxisVertical, makeBar(),
                      widget.NewPadder(widget.AxisBoth, padSize, body))
      sheet := widget.NewSheet(widget.NewUniform(theme.Background, container))

      container.Measure(theme.Default, 0, 0)
      if err := widget.RunWindow(s, sheet, &widget.RunWindowOptions{
         NewWindowOptions: screen.NewWindowOptions{
            Title:  "GoImages",
            Width:  container.MeasuredSize.X,
            Height: container.MeasuredSize.Y,
         },
      }); err != nil {
         log.Fatal(err)
      }
   })
}

运行前面的代码应该会显示一个窗口,其中包含以下内容,这与我们之前设计的布局大致匹配。随着我们进入本章,我们将向每个区域添加内容并完善界面的每个部分:

图片

带有导航栏和文件列表占位符的 GoImages 布局

导航

在我们的设计中创建导航栏时,水平流布局是完成这项工作的正确工具。我们可以使用widget.Spacer在按钮和标签之间创建间隔,并确保文件名在可用空间中居中。我们添加了一个辅助方法expandSpace(),它将创建一个新的间隔,该间隔将沿着流布局轴扩展。我们还定义了previousImage()nextImage()函数,这些函数将在按钮被按下时执行:

func previousImage() {}

func nextImage() {}

func expandSpace() node.Node {
   return widget.WithLayoutData(widget.NewSpace(),
      widget.FlowLayoutData{ExpandAlong: true, ExpandAcross: true, AlongWeight:1})
}

定义了这些函数后,我们可以布局导航栏。我们定义了prevnextname项,并将它们添加到一个包含expandSpace()元素的widget.AxisHoriontal流容器中,以分隔项目。为了创建按钮,我们使用本章前面(由于 Shiny 小部件 API 没有定义标准按钮)提到的相同的newButton()函数。我们使用theme.Neutral作为此部分的背景容器,并将整个栏设置为沿水平轴扩展:

func makeBar() node.Node {
   prev := newButton("Previous", previousImage)
   next := newButton("Next", nextImage)
   name := widget.NewLabel("Filename")

   flow := widget.NewFlow(widget.AxisHorizontal, prev, expandSpace(),
      widget.NewPadder(widget.AxisBoth, padSize, name), expandSpace(), next)

   bar := widget.NewUniform(theme.Neutral, flow)

   return widget.WithLayoutData(bar,
      widget.FlowLayoutData{ExpandAlong: true, ExpandAcross: true})
}

上述代码应更新导航栏,如下所示。因为我们自己定义了按钮,所以如果需要,可以使用边框样式进行自定义(完整的代码列表可在本书的代码仓库中找到):

图片

更新后的导航栏,按钮左对齐和右对齐

文件列表

由于 Shiny 没有定义列表小部件,我们将使用另一个垂直流容器来构建一个。列表中的每个项目都将是一个自定义单元格小部件,它将在左侧显示一个图标,并将文件名文本左对齐在剩余的空间中。首先,我们将更新我们的makeList()函数以添加一些虚拟数据。每个项目都是一个新单元格,使用makeCell()(稍后定义)创建。项目使用widget.NewFlow()在垂直轴上作为列表布局:

func makeList(dir string) node.Node {
   parent := makeCell(dir, nil)
   cell1 := makeCell("Filename 1", loadImage("shiny-hall.jpg"))
   cell2 := makeCell("Filename 2", loadImage("shiny-hall.jpg"))
   cell3 := makeCell("Filename 3", loadImage("shiny-hall.jpg"))
   return widget.NewFlow(widget.AxisVertical, parent, cell1, cell2, cell3)
}

如您所见,列表中的第一项是我们目录的名称,它需要一个不同的图标。我们可以使用iconvg包从 Shiny 图标库中加载一个标准图标,具体来说,使用iconvg.Rasterizericonvg.Decode()。使用以下辅助函数,我们可以将icons.FileFolder图标加载到图像中,以便可以使用与从文件系统加载的图像相同的函数进行绘制:

func loadDirIcon() image.Image {
   var raster iconvg.Rasterizer
   bounds := image.Rect(0, 0, iconSize, iconSize)
   icon := image.NewRGBA(bounds)
   raster.SetDstImage(icon, bounds, draw.Over)

   iconvg.Decode(&raster, icons.FileFolder, nil)
   return icon
}

我们布局代码的最后部分是makeCell()函数。在这种情况下,它是一个简单的包装,用于创建cell小部件。当此函数传递一个nil图标时,它将使用上面的辅助程序设置目录图标。当传递一个图标时,它创建一个onClick函数,该函数将在主视图中加载图像:

func makeCell(name string, icon image.Image) node.Node {
   var onClick func()
   if icon == nil {
      icon = loadDirIcon()
   } else {
      onClick = func() {chooseImage(icon)}
   }

   return newCell(icon, name, onClick)
}

我们单元格小部件的细节与之前创建的按钮非常相似,因此大部分代码被省略了。下一个摘录显示了它的PaintBase()函数,该函数将图标和文本绘制到屏幕上。它计算图像的比率,以便可以在单元格中正确绘制。然后,文本就像按钮代码一样绘制,但与绘制的图像之间有一个空格。

为了使这起作用,还需要一个简单的scaleImage()函数,它使用draw.ApproxBiLinear将图形缩放到适合的尺寸,以获得合理的性能:


func (c *cell) PaintBase(ctx *node.PaintBaseContext, origin image.Point) error {
   c.Marks.UnmarkNeedsPaintBase()
   face := ctx.Theme.AcquireFontFace(theme.FontFaceOptions{})
   defer ctx.Theme.ReleaseFontFace(theme.FontFaceOptions{}, face)

   ratio := float32(c.icon.Bounds().Max.Y)/float32(c.icon.Bounds().Max.X)
   if c.icon.Bounds().Max.Y > c.icon.Bounds().Max.X {
      ratio = float32(c.icon.Bounds().Max.X)/float32(c.icon.Bounds().Max.Y)
   }
   scaled := scaleImage(c.icon, iconSize, int(float32(iconSize)*ratio))

   draw.Draw(ctx.Dst, c.Rect.Add(origin), scaled, image.Point{}, draw.Over)
   d := font.Drawer{
      Dst:  ctx.Dst,
      Src:  theme.Foreground.Uniform(ctx.Theme),
      Face: face,
      Dot:  fixed.Point26_6{X: fixed.I(c.Rect.Min.X + origin.X + iconSize + space),
         Y: fixed.I(c.Rect.Min.Y + origin.Y + face.Metrics().Ascent.Ceil())},
   }
   d.DrawString(c.label)

   return nil
}

func scaleImage(src image.Image, width, height int) image.Image {
   ret := image.NewRGBA(image.Rect(0, 0, width, height))

   draw.ApproxBiLinear.Scale(ret, ret.Bounds(), src, src.Bounds(), draw.Src, nil)

   return ret
}

所有这些代码组合在一起,创建了一个带有图像预览的文件列表,如下面的截图所示:

左侧完成的文件列表,包含占位符内容

图像视图

widget.Image类型以加载时的相同分辨率在缓冲区中绘制图像(源图像中的一个像素与屏幕上的一个像素匹配)。对于图像查看器,我们需要将其缩放以适应可用空间。为此,我们创建了一个名为scaledImage的新自定义小部件。代码与 Shiny 图像小部件非常相似,但PaintBase()函数更复杂。

此函数计算imgWidthimgHeight以适应当前小部件的边界并保持源图像的宽高比。然后使用之前定义的scaleImage()辅助函数缩放图像,以便以正确的分辨率绘制。最后,计算offset以确保图像在可用空间中居中:

func (w *scaledImage) PaintBase(ctx *node.PaintBaseContext, origin image.Point) error {
   w.Marks.UnmarkNeedsPaintBase()
   if w.Src == nil {
      return nil
   }

   wRect := w.Rect.Add(origin)
   ratio := float32(w.Src.Bounds().Max.X)/float32(w.Src.Bounds().Max.Y)
   width := wRect.Max.X - wRect.Min.X
   height := wRect.Max.Y - wRect.Min.Y

   imgWidth := int(math.Min(float64(width), float64(w.Src.Bounds().Max.X)))
   imgHeight := int(float32(imgWidth)/ratio)

   if imgHeight > height {
      imgHeight = int(math.Min(float64(height), float64(w.Src.Bounds().Max.Y)))
      imgWidth = int(float32(imgHeight)*ratio)
   }

   scaled := scaleImage(w.Src, imgWidth, imgHeight)
   offset := image.Point{(imgWidth-width)/2, (imgHeight-height)/2}

   draw.Draw(ctx.Dst, wRect, scaled, offset, draw.Over)
   return nil
}

为了避免前述计算留下的空白空间,让我们添加一个在许多其他图像应用程序中常见的棋盘图案。为了实现这一点,我们创建了一个名为checkerImage的自定义图像类型,它简单地根据规则的棋盘图案从At()函数返回像素。由于图像是有限的,我们需要添加一个resize()函数,以便图像可以扩展以填充空间:

var checkers = &checkerImage{}

type checkerImage struct {
   bounds image.Rectangle
}

func (c *checkerImage) resize(width, height int) {
   c.bounds = image.Rectangle{image.Pt(0, 0), image.Pt(width, height)}
}

func (c *checkerImage) ColorModel() color.Model {
   return color.RGBAModel
}

func (c *checkerImage) Bounds() image.Rectangle {
   return c.bounds
}

func (c *checkerImage) At(x, y int) color.Color {
   xr := x/10
   yr := y/10

   if xr%2 == yr%2 {
      return color.RGBA{0xc0, 0xc0, 0xc0, 0xff}
   } else {
      return color.RGBA{0x99, 0x99, 0x99, 0xff}
   }
}

要包含棋盘图案,我们只需更新scaledImagePaintBase()函数的末尾。在绘制图像本身之前,我们将棋盘图案设置为扩展到正确的尺寸,并将其绘制到背景上。棋盘是用draw.Src模式绘制的,然后使用draw.Over模式在顶部绘制图像:

func (w *scaledImage) PaintBase(ctx *node.PaintBaseContext, origin image.Point) error {

   ...

   checkers.resize(width, height)
   draw.Draw(ctx.Dst, wRect, checkers, checkers.Bounds().Min, draw.Src)
   draw.Draw(ctx.Dst, wRect, scaled, offset, draw.Over)
   return nil
}

在所有这些代码到位后,我们有一个更新的应用程序,它正确地填充了我们设计的布局,并缩放和定位了占位符图像以适应可用空间:

界面更新以显示以正确宽高比居中的图像

这就是我们的图形代码的大部分内容。接下来,我们将添加必要的代码来从本地文件系统加载实际内容。

与 GUI 通信

现在我们已经有一个运行的用户界面,我们需要加载一些实际数据并显示它。我们通过获取请求目录的图像文件列表并更新用户界面以列出这些文件而不是占位符信息来开始这个任务。记住,在这个阶段,添加额外的图像导入,以便我们可以解码我们将在新的getImageList()函数中过滤的所有图像:

import (
   _ "image/jpeg"
   _ "image/png"
   _ "image/gif"
)

var names []string

func getImageList(dir string) []string {
   files, _ := ioutil.ReadDir(dir)

   for _, file := range files {
      if file.IsDir() {
         continue
      }

      ext := strings.ToLower(filepath.Ext(file.Name()))
      if ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" {
         names = append(names, file.Name())
      }
   }

   return names
}

列表显示了一个相当简单的算法,用于检查目录中的每个项目,如果文件名看起来像我们支持的图像文件,则将其添加到 names 列表中。简单的文件扩展名检查应该足以满足我们的目的。我们将这些文件名添加到全局列表中,以便在用户界面中稍后使用。

一旦我们有一个支持文件的列表,我们就可以更新现有的 makeList() 函数。新版本遍历 files 列表并为每个项目添加一个新单元格。makeCell() 函数不需要进行任何额外的工作即可使用新内容,但我们确实传递了数组索引,以便在按钮处理程序中稍后使用。我们还保存了在选中时用于显示的内存中加载的 images

var images []image.Image

func makeList(dir string, files []string) node.Node {
   parent := makeCell(-1, filepath.Base(dir), nil)
   children := []node.Node{parent}

   for idx, name := range files {
      img := loadImage(path.Join(dir, name))
      cell := makeCell(idx, name, img)
      children = append(children, cell)
      images = append(images, img)
   }

   return widget.NewFlow(widget.AxisVertical, children...)
}

要更新显示的主图像,我们需要为我们的 scaledImage 小部件添加一个新功能。这个新的 SetImage() 函数设置要显示的图像引用,并标记小部件进行绘制。更新 node.MarkNeedsPaintBase 标记意味着小部件将在下一次图形绘制事件发生时重新绘制(我们将在稍后更详细地讨论绘制事件):

func (w *scaledImage) SetImage(img image.Image) {
   w.Src = img
   w.Mark(node.MarkNeedsPaintBase)
}

要使用这个新功能,我们需要更新我们的 chooseImage() 代码以设置选定的图像。我们还需要存储对创建以调用此函数的 scaledImage 小部件的引用:

var view *scaledImage

func chooseImage(idx int) {
   view.SetImage(images[idx])
}

当图像更改时,我们还需要将正确的文件名设置到图像上方的标签中。为此,我们将添加对 widget.Label 对象的引用并设置其 Text 字段。更新此属性后,我们还需要设置 node.MarkNeedsMeasureLayout 标志,因为文本可能具有与之前内容不同的尺寸。我们使用 names 数组和传递给 chooseImage() 的索引变量来查找内容。这也可以通过创建一个使用新对象类型存储图像、名称和元数据的单个列表来完成,但使用多个索引列表的方法在较小的代码示例中更容易解释:

var name *widget.Label
var index = 0

func chooseImage(idx int) {
   index = idx
   view.SetImage(images[idx])

   name.Text = names[idx]
   name.Mark(node.MarkNeedsMeasureLayout)
   name.Mark(node.MarkNeedsPaintBase)
}

我们还需要填写由标题按钮调用的空 previousImage()nextImage() 函数。添加了一个名为 changeImage() 的简单辅助函数,用于根据当前图像的偏移量(1-1)处理图像切换。每个按钮回调都使用适当的偏移量调用此函数:

func changeImage(offset int) {
   newidx := index + offset
   if newidx < 0 || newidx >= len(images) {
      return
   }

   chooseImage(newidx)
}

func previousImage() {
   changeImage(-1)
}

func nextImage() {
   changeImage(1)
}

在此基础上,main() 函数可以包含对 chooseImage(0) 的调用,以加载目录中找到的第一个图像。当然,在这样做之前,你应该检查至少存在一个图像。

最后的更改是在应用程序加载时确定要显示图像的目录。之前的main()函数被重命名为loadUI()(它接受一个目录参数传递给getImageList()makeList())。创建了一个新的主函数,它解析命令行参数,允许用户指定一个目录。如果传递了意外的参数(或指定了--help),以下代码将打印出有用的使用提示;如果没有找到参数,它将显示当前工作目录(使用os.Getwd()):

func main() {
   dir, _ := os.Getwd()

   flag.Usage = func() {
      fmt.Println("goimages takes a single, optional, directory parameter")
   }
   flag.Parse()

   if len(flag.Args()) > 1 {
      flag.Usage()
      os.Exit(2)
   } else if len(flag.Args()) == 1 {
      dir = flag.Args()[0]

      if _, err := ioutil.ReadDir(dir); os.IsNotExist(err) {
         fmt.Println("Directory", dir, "does not exist or could not be read")
         os.Exit(1)
      }
   }
   loadUI(dir)
}

通过这些修改,我们创建了一个完整的图像查看器应用程序,可以显示整个图像目录的缩略图和一张大图像视图。通过点击列表中的项目,或使用“下一张”和“上一张”按钮,可以在可用的图像之间切换。虽然这样可以工作,但在大型目录中加载可能会非常慢。接下来,我们将探讨如何改进这一点:

在 macOS 上运行的完成的 GoImages 应用程序

背景处理

图像处理,即使是加载要查看的图像,也是一个 CPU 密集型任务,所以如果我们打开一个包含大量图片的目录,应用程序加载将非常慢。我们可以通过将图像加载移动到后台工作,在我们加载用户界面时进行修复这个延迟。幸运的是,使用 Go 创建新的线程进行异步处理非常简单(如我们在第三章,Go to the Rescue!中探讨的那样),但我们还需要确保用户界面相应地更新。

为了延迟图像的加载直到有处理能力可用,我们可以用替换的asyncImage类型替换loadImage()的使用,该类型将处理繁重的工作。主图像加载代码将被移动到一个私有的load()函数中,该函数由newAsyncImage()调用,使用go img.load()启动,因此它在后台开始:

type asyncImage struct {
   path     string
   img      image.Image
   callback func(image.Image)
}

func (a *asyncImage) load() {
   reader, err := os.Open(a.path)
   if err != nil {
      log.Fatal(err)
   }
   defer reader.Close()

   a.img, _, err = image.Decode(reader)
   if err != nil {
      log.Fatal(err)
   }

   a.callback(a.img)
}

func newAsyncImage(path string, loaded func(image.Image)) *asyncImage {
   img := &asyncImage{path: path, callback:loaded}
   go img.load()

   return img
}

通过定义异步图像加载器,我们可以用asyncImage替换image.Image的使用。需要记住的重要一点是,img字段中的图像在load()函数完成之前将是nil。确保任何使用图像的代码在处理之前检查nil数据。我们首先更新的是makeCell()函数,使其不再接受图像参数。相反,我们传递一个loaded回调函数,以便在图像加载后设置图像。我们将makeList()更新为用以下代码替换单元格创建代码:

   cell := makeCell(idx, name)
   i := idx
   img := newAsyncImage(path.Join(dir, name), func(img image.Image) {
      cell.icon.SetImage(img)
      if i == index {
         view.SetImage(img)
      }
   })

此代码将确保在图像加载完成后显示缩略图,同时如果图像是当前选择,它还会更新主图像view

如果你在这个时候运行应用程序,你会注意到一些图像被加载,而其他可能没有被加载。这是因为我们没有通知 Shiny 需要重新绘制。应用于小部件以强制它们重新绘制的标记实际上并没有触发界面的绘制;它只是标记它们需要在下一次重新绘制时进行绘制:

图片

在后台加载图像时的部分渲染

没有简单的方法来通知 Shiny 刷新用户界面,因此我们将创建一个refresh()函数以方便使用。当文件名的文本更新以及在不同(或懒加载)的图像被设置在scaledImage小部件上时,应该调用此函数:

func chooseImage(idx int, img image.Image) {
   ...

   name.Mark(node.MarkNeedsPaintBase)
   refresh(name)
}

func (w *scaledImage) SetImage(img image.Image) {
   w.Src = img
   w.Mark(node.MarkNeedsPaintBase)

   refresh(w)
}

func refresh(_ node.Node) {
   // Ideally we should refresh but this requires a reference to the window
   // win.Send(paint.Event{})
}

不幸的是,在这个阶段,没有大量的额外代码,我们无法进一步进行。这是我们所使用的推荐widget.RunWindow()函数的限制。我们需要发送绘图事件到窗口的引用在 Shiny 包外部不可用。为了解决这个问题,有必要在screen.Screen实例上使用NewWindow()函数,该实例被传递到driver.Main()函数中——但是这样做意味着完全重新实现事件循环,这是一项大量工作。

我们在设置主图像时没有注意到这个问题,是因为当应用程序接收用户事件(鼠标移动等)时,其事件循环会运行。每次循环迭代完成时,用户界面都会重新绘制。发送之前展示的paint.Event也会导致这种情况发生。因此,如果用户当前正在与 GUI 交互(即使只是将鼠标移到上面),则界面将在后台图像加载后更新。如果需要,将实现替换生命周期以解决这个问题,留作读者的练习。

摘要

在本章中,我们探讨了我们的第一个现代小部件工具包 Shiny,它专门为 Go 语言设计。我们探讨了其设计原则以及它是如何在不需要外部依赖的情况下支持跨平台图形应用程序开发的。我们还看到,其设计利用了 Go 语言的一些强大功能,如并发和标准库。

Shiny 背后的图形设计原则为桌面应用程序 GUI 提供了一种新的解释,这对于使用相同材料设计方法的 Android 移动操作系统用户来说将非常熟悉。在探索其图形功能时,我们发现当前的小部件集还处于早期阶段,因此尚未准备好支持我们在前几章中创建的 GoMail 应用程序。为了探索 Shiny 工具包的功能,我们反而开发了一个图像查看器应用程序,它更适合当前的功能集。我们看到了 Shiny 的渲染能力是多么强大,但也意识到在创建丰富的应用程序用户界面方面存在一些挑战。

在下一章中,我们将探讨另一个采用现代方法处理小部件工具包的工具包。Nuklear 还帮助开发者创建跨平台的图形用户界面,但它采用嵌入式用户界面方法。我们将探索这个名为nk的库的 Go 绑定。

第九章:nk - Nuklear for Go

Nuklear 是一个专注于图形界面的轻量级小部件库。它提供了一个丰富的工具包,可以在所有支持的平台上以相同的方式渲染。最初是为嵌入式系统设计的,它避免了应用程序生命周期、窗口和用户交互的复杂性,以保持其 API 焦点和完全平台无关。其实施没有依赖关系,通过避免特定平台的渲染库或操作系统驱动程序来实现这一点。

本章将涵盖以下主题:

  • Nuklear 项目的设计和目的

  • 使用 Nuklear 和 Go 绑定进行设置,nk

  • 如何创建渲染上下文并使用工具包小部件

  • 使用 nk 构建 complete application

到本章结束时,你将创建一个使用 OpenGL 后端且可在所有主流桌面操作系统上运行的基于 nk 的应用程序。

Nuklear 的背景和设计

Nuklear 是为构建嵌入式应用程序和游戏图形用户界面而设计的。它旨在轻量级且完全平台无关。它通过将窗口管理、操作系统特定的方法和甚至渲染驱动程序留给单独的模块或使用库的应用程序来管理这一点。许多这些功能都由附加模块提供;由于 Nuklear 的流行,有众多渲染驱动程序可供选择(一些是针对特定操作系统的,而另一些可以在多个平台上工作)。Nuklear 已在公共领域内提供,这也使其成为嵌入商业软件中的有吸引力的选择。

Nuklear 提供了许多小部件、布局和功能,用于创建丰富的应用程序 GUI,这些 GUI 还可以根据应用程序设计进行皮肤化。以下截图是标准界面设计的示例:更多内容可以在项目网站上的画廊部分找到:github.com/vurtun/nuklear#gallery

项目网站上的 Nuklear 小部件截图(版权:Micha Mettke)

除了与其他我们探索过的工具包相比的差异之外,还有一个更大的区别——Nuklear 是一个 即时模式 GUI 工具包。相比之下,我们在本书中使用的其他工具包都是 保留模式 用户界面。在保留模式 API 中,开发者通过创建按钮和输入框等对象、将它们排列在布局中,然后工具包将绘制这些功能到屏幕上来描述应用程序 GUI。当发生事件时,工具包将更改项目的状态,结果图形变化将在屏幕上反映出来,可选地将更改发送到应用程序代码。

当使用即时模式库时,没有状态被保留。应用程序开发者不会创建按钮和控件以供以后使用;相反,这些控件在渲染过程中被定义,纯粹是为了下一次图形更新。乍一看,这似乎效率不高,但实际上这与图形渲染管道的工作方式非常匹配,因此可以更加高效。它也是一个更节省内存的过程,因为没有在内存中代表整个应用程序 GUI 的额外结构。这个决定的主要影响,正如我们稍后将会看到的,是如何布局创建 GUI 的代码以及如何处理事件。与其进一步描述,你可以在本章后面的部分(在“入门 nk”的代码部分)看到它的实际操作。

渲染和平台支持

Nuklear 库灵活性的核心是其模块化设计。库不会将内容渲染到屏幕上,也不会管理用户输入;此类功能由伴随库的模块提供。应用程序通常会利用 Nuklear 库的核心功能来处理小部件和布局,以及其中一个渲染模块来控制窗口的打开、渲染和处理用户输入。

渲染模块

Nuklear 项目包括许多渲染模块,为各种不同的环境或操作系统提供支持。在撰写本文时,你可以从以下后端中进行选择:

  • Windows:

    • 图形设备接口GDI

    • GDI+

    • Direct3DD3D

  • Linux 或 Unix:

    • X11

    • X11 OpenGL

  • 游戏开发:

    • Allegro
  • 跨平台开发:

    • 简单直接媒体层SDL

    • 简单快速多媒体库SFML

    • 图形库框架GLFW

一些渲染器是 3D 加速的,而另一些则不是,一些专注于嵌入式和低功耗设备,而另一些则针对桌面或智能手机类型的设备。由于 GLFW 模块支持大多数桌面操作系统(以及一些智能手机),我们将使用此模块来编写下一章。如果你选择使用不同的模块,那么应用程序生命周期代码应该进行适配,但我们将探索的 nk 代码保持不变。

支持的平台

由于工具包提供了如此广泛的渲染器,Nuklear 为各种不同的操作系统提供了卓越的覆盖范围。它支持 Windows、Linux、macOS、BSD、iPhone 和 Android,为多个平台提供的支持比我们探索的其他库更好。通过选择跨平台的 GLFW Nuklear 模块,我们略微减少了可能支持的平台数量,但它仍然支持 Windows、macOS 和 Linux 桌面应用程序以及 Android 移动应用程序。

GLFW 库有 Go 绑定,可以与 OpenGL Go 绑定一起使用(两者都是由同一作者编写的)。对于大多数平台,它们不依赖于安装任何外部包或库。这对于快速使用 nk 来说是一个巨大的好处,因为我们不需要安装额外的包或配置我们的开发环境。所以,让我们开始吧。

开始使用 nk

要使用 GLFW 和 Go-GL,我们需要链接到一些 C API;然而,这些在大多数系统上不是外部库。唯一的原生依赖项是 OpenGL 原生库(通常是操作系统的一部分),任何中间库都嵌入在 Go 项目中,这意味着我们只需要准备 CGo。

预先条件

由于我们将为 nk 使用需要访问原生 C API 的渲染器,我们需要 CGo 正确运行,以便我们的应用程序能够构建。在大多数平台上,这意味着安装一个兼容的 C 编译器。这只是一个开发依赖项,我们构建的应用程序的用户(除了需要一个兼容 OpenGL 的系统之外)不需要安装。如果你已经完成了这本书的前几章,那么你可能已经设置了这些。如果没有,或者你不确定,那么请遵循 设置 CGo 部分的 附录 中 安装细节 部分的步骤。

一些平台将需要安装额外的开发文件,以便正确编译代码的操作系统特定部分。macOS 和 Windows 开发者可以跳过这一部分,因为 CGo 的开发环境已经提供了所有所需的内容。Linux 或 Android 开发者可能需要采取以下额外步骤。

Linux

要在 Linux 上使用 nk,我们需要确保安装一些额外的开发头文件。由于 GLFW 依赖于 Xorg 进行窗口管理和输入处理,我们需要能够编译其库。如果你的发行版将开发头文件与库分开打包,你需要确保它们已安装,以便编译成功。所需的包在 Debian 或 Ubuntu 上称为 xorg-dev,在 Arch Linux 上称为 xorg-server-devel,在 Fedora 或 CentOS 上称为 xorg-x11-server-devel

因此,以下任一命令都应正确安装开发依赖项:

  • 对于 Debian 或 Ubuntu,请使用以下命令:

    sudo apt-get install xorg-dev

  • 对于 Arch Linux(这可能会已经安装),请使用以下命令:

    sudo pacman -S xorg-server-devel

  • 对于 Fedora 或 CentOS,请使用以下命令:

    sudo yum install xorg-x11-server-devel

如果找不到 sudo 命令,请尝试 su -c。一旦安装了这个库,你应该能够遵循下一节的设置步骤并运行一个 nk 示例应用程序。

macOS 和 Windows

一旦你在计算机上设置了 Go 和 CGo,就没有其他预先条件了,你可以跳到以下 设置 部分。

Android

要使用 nk 构建 Android 应用程序,需要一些额外的步骤。移动应用程序的开发不在此书的范围之内,但对于好奇的人来说,这些步骤包括在内,以帮助您开始。首先,您必须安装 Android 软件开发工具包SDK)和本地开发工具包NDK)。最简单的方法可能是安装 Android Studio(可在developer.android.com/studio/获取)并使用内置的 SDK Manager(在 SDK 工具下)安装NDK包:

确保安装了最新版本的 NDK 包,以便进行 Android nk 开发

完成以下步骤以完成nk-android构建,这将导致一个完整的发展环境。构建工具链需要设置ANDROID_HOMENDK环境变量,并正确更新您的PATH。您可能不需要设置所有这些环境变量,因为如果您已经完成了之前的 Android 项目,它们可能已经配置好了。更多文档可在github.com/golang-ui/nuklear#android-demo找到:

设置 Android 环境和构建 nk-android 工具链

这应该为使用 nk 和 Go 进行 Android 开发准备您的桌面。本章的其余部分专注于桌面开发,但如果工具运行正确,您应该能够将说明适应基于 Android NDK 的部署。

现在您的平台准备工作已经完成,让我们设置 nk 并运行我们的第一个示例应用程序。

设置

nk包设置为从 Go 使用 Nuklear,就像使用标准go工具安装github.com/golang-ui/nuklear/nk包一样简单:

如果您已经安装并运行了 CGo,安装 nk 就非常直接

现在您已经安装了库,让我们运行一个示例来看看 nk 的实际应用。

示例

Go Nuklear 绑定项目提供了一个示例应用程序来演示一些小部件;我们可以使用这个来快速检查一切是否正常工作。在完成之前的设置步骤后,运行演示就像安装 Go 项目并运行它一样简单。代码位于github.com/golang-ui/nuklearcmd/nk-example子项目中;我们可以使用go install下载并安装示例,然后使用nk-example运行:

一旦设置好 CGo,安装和运行 nuklear 示例就非常简单

运行前面的命令应该在您的屏幕上出现以下示例窗口。此示例展示了 Nuklear 工具包提供的一些小部件,包括嵌入的窗口:

在 Linux 上运行的 nk-example 应用程序

代码

要开始我们的第一个 nk 应用程序,我们需要编写一定数量的设置代码。Nuklear 专注于提供图形工具包 API,而不是操作系统特定的代码,例如管理窗口和用户输入。为了避免必须自己编写所有这些代码,我们将使用 glfw Go 绑定来创建并显示我们的应用程序窗口。以下代码将设置应用程序窗口并显示它(没有任何内容)。我们还需要调用 runtime.LockOSThread(),因为此设置代码必须在主线程上执行:

package main

import "runtime"
import "github.com/go-gl/glfw/v3.2/glfw"
import "github.com/go-gl/gl/v3.2-core/gl"

func init() {
   runtime.LockOSThread()
}

func main() {
   glfw.Init()
   win, _ := glfw.CreateWindow(120, 80, "Hello World", nil, nil)
   win.MakeContextCurrent()
   gl.Init()

   ...
}

在初始化 glfw 之后,我们需要创建一个窗口,这由 glfw.CreateWindow() 为我们处理。我们在前三个参数中指定窗口大小和标题。第四个参数用于全屏窗口;通过传递一个 *glfw.Monitor 引用,我们请求一个窗口,该窗口在其默认视频模式下填充指定的监视器。最后一个参数与 上下文共享 相关,传递一个现有的 *glfw.Window 引用请求这个新窗口共享相同的图形上下文以重用纹理和其他资源。然后我们使新窗口成为当前窗口,以便其上下文在下面的代码中使用。请注意,窗口可能不会完全符合请求的参数(精确的窗口大小或监视器模式可能不受支持),因此创建后检查这些值而不是假设结果是很重要的。

我们必须做的其他设置是创建一个 OpenGL 上下文,Nuklear 代码可以利用它。为此任务,我们将导入 go-gl 库(由与 glfw Go 绑定相同的作者编写)。我们初始化 OpenGL 库,准备使用由 glfw 创建的窗口的上下文。

此外,nk 包需要初始化,并且我们需要设置一个默认字体。幸运的是,Nuklear 提供了一个标准字体包,但我们需要运行一些代码来将其设置为默认字体(或者为我们的应用程序加载一个自定义字体):

import "github.com/golang-ui/nuklear/nk"

func main() {
   ...

   ctx := nk.NkPlatformInit(win, nk.PlatformInstallCallbacks)
   atlas := nk.NewFontAtlas()
   nk.NkFontStashBegin(&atlas)
   font := nk.NkFontAtlasAddDefault(atlas, 14, nil)
   nk.NkFontStashEnd()
   nk.NkStyleSetFont(ctx, font.Handle())

   ...
}

所有设置完成后,窗口看起来仍然和之前一样,因为我们还没有渲染任何内容。要实际运行一个 Nuklear 应用程序,我们需要添加一个处理事件管理和 GUI 刷新的运行循环。下面的代码并不是最简单的可能的事件循环(可以使用for !win.ShouldClose() { ... },但那样会消耗整个 CPU!),但它对于简洁性来说效率是合理的。它设置了一个循环,该循环将检查任何事件,然后每秒刷新用户界面 30 次。下面的代码块完成了我们的基本 nk main() 函数:

import "time"

func main() {
   ...

   quit := make(chan struct{}, 1)
   ticker := time.NewTicker(time.Second / 30)
   for {
      select {
      case < -quit:
         nk.NkPlatformShutdown()
         glfw.Terminate()
         ticker.Stop()
         return
      case<-ticker.C:
         if win.ShouldClose() {
            close(quit)
            continue
         }
         glfw.PollEvents()
         draw(win, ctx)
      }
   }
}

上述代码将运行我们的应用程序,但我们还没有定义用户界面。在上述代码中调用draw()函数的秘密在于此,因此我们现在应该实现它。让我们将方法分为两部分来看:首先,GUI 布局,其次,实际渲染。为了设置我们的界面,我们创建一个新的框架(想象一下视频的单个快照),它将在用户界面的下一次刷新时被绘制。在调用nk.NkPlatformNewFrame()之后,我们可以设置我们的界面;在nk.NkBegin()nk.NkEnd()之间的任何代码都将是我们刚刚开始的框架的 UI 更新的一部分。我们可以通过检查返回的update变量来找出是否需要重新绘制;如果它是0,则没有发生变化,我们可以跳过 UI 代码。

if update > 0 { ... }块内部,我们布局应用程序界面,每行包含一个单元格。在第一行(使用nk.NkLayoutRowStatic()创建),我们添加了一个包含文本Hello World!nk.NkLabel。在第二行,我们使用nk.NkButtonLabel()创建了一个退出按钮。由于这是一个即时模式用户界面,我们不会保留按钮的引用以检查其状态,也不会传递点击处理程序;我们只需检查小部件绘制函数的返回值。如果按钮已被点击,则返回的值将大于0;因此,我们可以放置内联代码,告诉窗口关闭,从而关闭应用程序:

const pad = 8

func draw(win *glfw.Window, ctx *nk.Context) {
   // Define GUI
   nk.NkPlatformNewFrame()
   width, height := win.GetSize()
   bounds := nk.NkRect(0, 0, float32(width), float32(height))
   update := nk.NkBegin(ctx, "", bounds, nk.WindowNoScrollbar)

   if update > 0 {
      cellWidth := int32(width-pad*2)
      cellHeight := float32(height-pad*2) / 2.0
      nk.NkLayoutRowStatic(ctx, cellHeight, cellWidth, 1)
      {
         nk.NkLabel(ctx, "Hello World!", nk.TextCentered)
      }
      nk.NkLayoutRowStatic(ctx, cellHeight, cellWidth, 1)
      {
         if nk.NkButtonLabel(ctx, "Quit") > 0 {
            win.SetShouldClose(true)
         }
      }
   }
   nk.NkEnd(ctx)

   ...
}

最后,在draw()函数的末尾,我们需要要求我们的 OpenGL 视口渲染创建的用户界面。为此,我们使用gl.Viewport()设置 OpenGL 视口——正如你所见,我们使用实际窗口大小的宽度和高度参数,而不是假设我们在代码开始时请求的大小是正确的。一旦视口设置好,我们就清除它并设置一个背景颜色(使用gl.ClearColor())。主要的渲染工作由nk.NkPlatformRender()处理,它接受我们之前定义的框架并将其绘制到当前图形上下文中。此函数要求我们指定顶点和元素缓冲区的大小。我们传递的数字将足够大,以满足我们的演示目的。

最后,我们通过调用win.SwapBuffers()来显示内容。由于glfw.Window双缓冲,我们一直在绘制一个当前不在屏幕上的后缓冲区。通过调用交换,我们将后缓冲区移动到屏幕上,并将之前显示的前缓冲区设置为隐藏,以便绘制下一帧:

func draw(win *glfw.Window, ctx *nk.Context) {
   ...

   // Draw to viewport
   gl.Viewport(0, 0, int32(width), int32(height))
   gl.Clear(gl.COLOR_BUFFER_BIT)
   gl.ClearColor(0x10, 0x10, 0x10, 0xff)
   nk.NkPlatformRender(nk.AntiAliasingOn, 4096, 1024)
   win.SwapBuffers()
}

这样就应该完成了我们的Hello World应用程序的代码。虽然设置了很多,但 UI 定义代码相对简洁,因此构建更复杂的界面不会增加太多工作量。

构建 和 运行

简单地构建或运行hello.go,你将看到预期的 Hello World 窗口。点击退出按钮将告诉窗口关闭,从而退出应用程序:

使用 nk 的 Hello World

交叉编译

将基于 nk 的应用程序编译到不同的操作系统可能是一个复杂的过程,因为它需要使用 CGo 与本地 OpenGL 库进行通信。然而,如果你已经完成了第五章,andlabs UI - 跨平台原生 UI,或者第八章,Shiny - 实验性 Go GUI API,那么这些应该已经设置好了。如果你直接跳到了这一章,那么你可能需要遵循附录中的步骤,在交叉编译设置中。一旦完成,你应该会有新的编译器可用(macOS 上命名为o32-clang,Windows 上命名为x86_64-w64-mingw32-gcc),它们能够分别链接到 macOS Foundation API 和 Windows 系统调用。

要构建应用程序,我们现在设置与之前相同的GOOSCGO_ENABLED标志,但还通过额外的CC环境变量指定要使用的编译器,将其设置为 Darwin OS 的o32-clang或 Windows 的x86_64-w64-mingw32-gcc。完成此配置后,我们可以从 Linux 终端构建我们的 nk 应用程序用于 macOS 和 Windows:

从 Linux 终端编译 Linux、macOS 和 Windows

现在我们已经构建了第一个 nk 应用程序,让我们进一步了解底层 Nuklear 库支持构建应用程序 GUI 的功能。

控件、布局和皮肤

由于 Nuklear 库专注于应用程序工具包的控件方面,其在该领域的功能与更成熟的应用程序库相当。正如你将在下面看到的,有一个很长的控件列表可以包含在任何 Nuklear 应用程序中。由于 nk 绑定公开了所有库功能,这些功能对 nk 应用程序也是可用的。

GUI 功能分为三个广泛领域:控件(主要用户界面元素)、绘制(直接绘制到画布上)和布局(在屏幕上排列元素)。在本节中,我们将依次查看每个领域,从主要控件开始。

控件

Nuklear 控件(以及展示它们的 nk API)在许多方面应该是熟悉的。合理的命名使得在您最喜欢的 IDE 中编程时可以发现许多这些功能,但让我们探索主要的控件及其功能:

控件名称 描述
NkButtonLabel 一个标准的按钮控件,API 报告了它何时被点击。参见NkButtonImage(使用图像而不是文本标签)和NkButtonImageLabel(包含两者)。
NkCheckboxLabel 复选框显示一个熟悉的框,旁边是标签,可以是勾选的或未勾选的。API 报告其值何时发生变化。
NkColorPicker 这是一个特殊的按钮,用于打开颜色选择器。此表单返回当前选定的颜色,或者您可以使用 NkColorPick,它会在值更改时报告。
NkComboBox 这是一个下拉选择组合框容器。其中每个项目可以包含文本、图像或两者(请参阅以 NkComboItem 开头的 API)。
NkGroup(Begin/End) 这为界面中的控件添加分组。一个分组有一个标题,如果需要,还有一个滚动条。要手动控制滚动行为,您可以使用 NkGroupScrolledBegin。在开始和结束之间声明的控件将被包含。开始函数返回 > 0 如果内容应该被绘制。
NkImage 这在界面中显示一个简单的图像。
NkMenubar(Begin/End) 要向用户界面添加菜单栏,这需要使用以 NkMenuNkMenuItem 开头的各种 API。开始函数返回 > 0 如果内容应该被绘制。
NkPopup(Begin/End) 这将在当前内容上显示一个弹出窗口;在开始和结束之间声明的控件将被包含。开始函数返回 > 0 如果内容应该被绘制。
NkRadioLabel 单选选择类似于组合框,但提供多个可能的值,每个值都使用此函数添加。返回值指示指定的项目是否已被选中。
NkSlider(Int/Float) NkSlider 函数添加一个具有指定最小值、最大值和当前值的滑动条。API 报告值何时已更改。另一种格式 NkSlide(Int/Float) 返回当前值。
NkTexteditString 这是一个文本输入控件。此函数需要一个缓冲区来编辑;这可以通过 NkEditStringZeroTerminated() 更容易地设置。还有许多以 NkTextedit 开头的有用 API,可以用来管理文本内容。
NkTree(Push/Pop) 树形控件可用于允许用户界面的部分被展开和折叠,或用于在屏幕上显示基于树的 数据。以 NkTreePush 开头的函数标记新树部分的开始,而 NkTreePop 结束该部分(或树的根)。名为 TreeNodeTreeType 标记用户界面样式树,而 TreeTab 用于数据样式树。
Window (NkBegin, NkEnd) 窗口(NkBeginNkEnd)是必需的,以包含 Nuklear 中的所有小部件(在此作用域之外声明的任何内容都将被忽略或导致错误)。窗口通过 NkBeginNkBeginTitled 声明,并通过 NkEnd 标记为完成。提供了各种窗口管理函数,它们以 NkWindow 开头。

如您所见,这些部件中的许多都很直接。更复杂的部件具有随时间变得熟悉的打开和关闭其定义的语义。这是由于 API 的即时模式性质及其设计不保留任何状态。常见的语义是当容器需要绘制时返回大于 0 的值。同样,对用户事件做出响应的项目在激活或更改时将返回非零值。

现在我们已经探索了可用的部件,让我们看看我们如何在我们 GUI 中排列元素。

布局

Nuklear 的布局系统遵循简单的行列方法。为了布局部件,每个项目都应该位于一行内;当根据行配置中设置的参数添加部件时,会隐式创建列。当向已满的行添加部件时,将自动创建一个新的行,其参数与上一个相同。可以通过更改参数或在不填满剩余列的情况下完成前一行的布局来开始一个新的行。基本的布局由这里描述的 NkLayoutRow API 控制;还有一个有用的基于模板的布局在 NkLayoutRowTemplate 中,我们将在之后探索。最后,NkLayoutSpace 允许直接设置部件的位置和大小——我们将最后探索这一点。

NkLayoutRow

布局的简单方法是从使用 NkLayoutRowDynamic()NkLayoutRowStatic() 开始新的一行。这两个函数都指定了行中的单元格数量。两者之间的区别在于,动态行分配将分割单元格之间的所有空间,并在窗口或容器大小改变时调整它们的大小。使用静态排列时,所有单元格的大小将保持不变,无论容器大小如何。在开始一行后添加的部件将附加到该行,直到它填满;如果进一步添加部件,则将为新部件创建一个新的行。这会一直持续到调用 NkLayoutRowEnd() 或使用这些替代函数之一开始不同的行配置。

通过使用 NkLayoutRowBegin() 函数开始一行,可以增加一些控制;这指定了行高和列数,但并不指定列的大小。在通过调用 NkLayoutRowPush() 将部件附加到行之前,会向行中添加单元格;这为下一个单元格设置大小或比例,并且应该随后声明一个部件来填充该单元格。这种类型的行也应该通过调用 NkLayoutRowEnd() 来结束。

最后,可以直接调用 NkLayoutRow 来设置后续行的参数,静态或动态大小,以及指定高度或比例和指定列数。

NkLayoutRowTemplate

使用模板机制是一种更强大的排列行的方法。通过调用NkLayoutRowTemplateBegin(),可以为所有后续行设置模板。列的大小是通过三种模板函数之一定义的。首先,NkLayoutRowTemplatePushStatic()指定该列中的小部件应具有固定宽度。NkLayoutRowTemplatePushDynamic(),类似于没有模板的动态分配定义,将行宽分配给动态列(如果没有可用空间,这可能是0)。最后,还有一个额外的调用NkLayoutRowTemplatePushVariable()函数;这将确保小部件获得它们所需的最小空间,并将占用任何额外的空间(或者平均分配给其他可变宽度列)。

在模板指定的末尾,您必须调用NkLayoutRowTemplateEnd();这将指示添加的任何小部件将开始创建声明模板之后的布局行。与之前一样,如果有更多小部件适合一行,则将自动创建新行,并且小部件将开始添加到新行。与之前指定行中列数的常规行布局函数不同,使用此方法将在行中添加与模板定义中项目数量一样多的小部件。

NkLayoutSpace

最后,空间布局提供了对 Nuklear 应用程序中项目位置和大小的完全控制。布局的起始和结束方式与之前的基于行的布局相同;使用NkLayoutSpaceBegin()开始基于空间布局,使用NkLayoutSpaceEnd()完成布局。在您希望添加到界面中的每个小部件之前,调用NkLayoutSpacePush()函数,传递NkRect,它指定下一个要添加的小部件的大小和位置。

除了布局控制函数外,还有许多使用NkLayoutSpace API 前缀的辅助函数。最有用的是NkLayoutSpaceBounds()——如果在空间布局中调用,它将返回可用于工作的总空间。如果您希望右对齐或底对齐或在小部件中居中定位,这很重要。

这些就是 nk API 提供的所有布局选项;现在让我们看看库的绘图功能。

绘图

绘图 API 提供了一个相当标准的二维2D)矢量图形库,主要用于高级小部件。由于它是公共 API 的一部分,因此也可以在您的应用程序中使用它们。

命令队列

要绘制应用程序的定制区域,我们的应用程序将直接与 Nuklear 绘制命令队列(用于渲染用户界面帧的绘制项列表)交互,因此建议小心操作。您可以通过使用NkWindowGetCanvas()函数来获取访问nk.CommandBuffer的权限,这是每个绘制命令所需的。重要的是,这只能在窗口活动时调用(在NkBegin之后和NkEnd之前)。位置值需要了解其他加载的控件和布局,这可能会很快变得复杂——在通常空白的窗口中使用这些命令绘制是最简单的,这样可以避免覆盖其他控件。

绘制函数

如果您想直接使用这些绘制命令,可以使用以下方法:

描边函数 填充函数 注意事项
NkStrokeLine() 使用指定颜色绘制单个线段。
NkStrokeCurve() 使用指定颜色绘制单个曲线段。
NkStrokeRect() NkFillRect()NkFillRectMultiColor() 使用指定颜色(或颜色组合)绘制矩形(或正方形)轮廓,或实心矩形。要绘制轮廓填充的矩形,请先调用NkFillRect(),然后使用相同坐标调用NkStrokeRect()NkFillRectMultiColor()是快速在矩形中绘制渐变的方法。
NkStrokeCircle() NkFillCircle() 使用指定颜色绘制圆(或椭圆)轮廓或填充。
NkStrokeArc() NkFillArc() 在指定颜色中围绕中心点绘制轮廓或填充圆弧。
NkStrokeTriangle() NkFillTriangle() 使用指定颜色绘制三角形轮廓或实心三角形。
NkStrokePolyline() 使用指定颜色绘制一系列线段。
NkStrokePolygon() NkFillPolygon() 使用定义边界的点列表绘制轮廓或填充形状。
NkDrawImage() 在指定的矩形和背景颜色中绘制图像。
NkDrawText() 使用指定的背景和前景颜色绘制文本字符串。

现在我们已经探索了所有的小部件和绘图功能,我们可以直接开始构建一个完整的应用程序。然而,Nuklear 还有一个很酷的功能值得我们关注:使用皮肤化来改变界面设计。

皮肤化

除了为包含的组件定义自己的样式外,Nuklear 还支持皮肤化——加载主题以改变应用程序的外观。这是一个强大的功能——与我们在 GTK+和 Qt 中看到的主题非常相似,但由应用程序而不是最终用户选择。任何希望为他们的应用程序设置皮肤化的 nk 应用程序开发者可能会发现这并不容易做到——这是由于大多数配置都是通过底层的 Nuklear API 从 C 结构中暴露出来的方式。虽然这些元素大多可以通过 Go API 绑定获得,但它需要大量的指针转换和不安全的赋值,这可能会影响应用程序的稳定性。然而,通过 CGo,在应用程序中包含一些 C 代码是可能的。

以下 C 代码是从 Nuklear 皮肤化示例中提取的,以防开发者希望在他们的应用程序中包含自定义皮肤并且愿意在 Go 代码中嵌入 C。该示例使用单个纹理图像,该图像定义了所有定义主题的不同图像。首先,必须将纹理加载到当前 OpenGL 上下文中,然后识别加载纹理中的各个区域,如下所示:

   glEnable(GL_TEXTURE_2D);
   media.skin = image_load("../skins/gwen.png");
   media.check = nk_subimage_id(media.skin, 512,512, nk_rect(464,32,15,15));
   media.check_cursor = nk_subimage_id(media.skin, 512,512, nk_rect(450,34,11,11));

   ...

上述代码片段仅指定了两个子纹理,而在实际使用中会有更多。在纹理加载后,我们定义一个与主题匹配的样式结构指针(在这里,我们正在皮肤化复选框)。然后,将此指针的值设置为加载的样式配置的位置(这就是为什么在纯 Go 代码中很难重新创建)。对于结构中的每个字段,应设置适当的图像或颜色:

 {struct nk_style_toggle *toggle;
 toggle = &ctx.style.checkbox;
 toggle->normal = nk_style_item_image(media.check);
 toggle->hover = nk_style_item_image(media.check);
 toggle->active = nk_style_item_image(media.check);
 toggle->cursor_normal = nk_style_item_image(media.check_cursor);
 toggle->cursor_hover = nk_style_item_image(media.check_cursor);
 toggle->text_normal = nk_rgb(95,95,95);
 toggle->text_hover = nk_rgb(95,95,95);
 toggle->text_active = nk_rgb(95,95,95);}

应该将相同的技巧应用于所有将在皮肤化应用程序中使用的组件。这是一项大量工作,甚至工具包的作者也警告说,由于耗时性,现在应避免这样做!以下是Gwen皮肤的样式纹理以及加载了此主题的应用程序的截图:

图片 1 图片 2

Gwen 皮肤用于演示 Nuklear 皮肤功能(左);Gwen 皮肤的实际应用(右)

完整的实现可以在github.com/vurtun/nuklear/blob/master/example/skinning.c的示例存储库中找到。

构建用户界面

让我们再次回到我们的 GoMail 应用程序,尝试使用 nk API。Nuklear 是一个功能丰富的成熟工具包,因此它应该能够像之前的示例一样构建用户界面。当我们构建用户界面时,我们将看到即时模式工具包是如何不同的,包括代码的排列方式和事件处理的管理方式。

我们可以先复制 hello world 应用程序,这样我们就不必重新编写所有的设置代码和生命周期管理。由于这个应用程序将包含更多的图形元素,我们需要增加使用NkPlatformRender()设置的缓冲区大小。在这个例子中,用以下行替换原始行。在你的应用程序中,这个值可能需要更高——如果数字太低,你可能注意到当弹出窗口和菜单项出现时,图形元素不会显示或消失:

nk.NkPlatformRender(nk.AntiAliasingOn, 512 * 1024, 128 * 1024)

布局

我们将从基本的应用布局开始;首先,我们将更新我们的draw()函数,以便调用一个单独的drawLayout()函数,在那里我们将添加我们的新代码。这个新函数需要传递窗口的高度以正确填充垂直空间,正如你稍后将会看到的:

func draw(win *glfw.Window, ctx *nk.Context) {
   nk.NkPlatformNewFrame()
   width, height := win.GetSize()
   bounds := nk.NkRect(0, 0, float32(width), float32(height))
   update := nk.NkBegin(ctx, "", bounds, nk.WindowNoScrollbar)

   if update > 0 {
      drawLayout(win, ctx, height)
   }
   nk.NkEnd(ctx)

   gl.Viewport(0, 0, int32(width), int32(height))
   gl.Clear(gl.COLOR_BUFFER_BIT)
   gl.ClearColor(0x10, 0x10, 0x10, 0xff)
   nk.NkPlatformRender(nk.AntiAliasingOn, 512 * 1024, 128 * 1024)
}

上述代码对于使用 nk 绘制窗口来说是相当标准的。让我们直接进入我们的新布局代码。

主要电子邮件窗口

我们从一个简单的布局函数drawLayout()开始。这将设置类似于我们在第四章,Walk - 构建图形窗口应用程序中创建的 GoMail 设计的基本应用程序布局。代码的开始为菜单和工具栏留出空间,这些空间将扩展以填充窗口的宽度。然后我们开始使用NkLayoutRowTemplateBegin()启动一个模板布局,以便在左侧有一个固定大小的列用于我们的电子邮件列表,以及一个更宽的、可变宽度的列,当窗口调整大小时将扩展:

func drawLayout(win *glfw.Window, ctx *nk.Context, height int) {
   toolbarHeight := float32(36)
   nk.NkLayoutRowDynamic(ctx, toolbarHeight, 1)
   nk.NkLabel(ctx, "Toolbar", nk.TextAlignLeft)

   nk.NkLayoutRowTemplateBegin(ctx, float32(height)-toolbarHeight)
   nk.NkLayoutRowTemplatePushStatic(ctx, 80)
   nk.NkLayoutRowTemplatePushVariable(ctx, 320)
   nk.NkLayoutRowTemplateEnd(ctx)

   nk.NkGroupBegin(ctx, "Inbox", 1)
   nk.NkLayoutRowDynamic(ctx,0, 1)
   nk.NkLabel(ctx, "Item1", nk.TextAlignLeft)
   nk.NkLabel(ctx, "Item2", nk.TextAlignLeft)
   nk.NkLabel(ctx, "Item3", nk.TextAlignLeft)
   nk.NkGroupEnd(ctx)

   ...

注意,虽然我们布局的宽度可以自动调整,但高度并不那么灵活。在这个例子中,我们传递窗口的高度并从中减去我们分配给工具栏的高度。这个总数传递给我们的模板布局,以便它扩展到剩余的窗口高度。

在主布局的第一列中,我们添加一个名为"Inbox"的新组用于我们的电子邮件列表,并添加三个简单的标签项来表示加载的列表。接下来,我们添加另一个将占用模板布局第二个空间的组。此代码设置了一组一列和两列的行,将显示电子邮件内容。

我们打开组并使用NkLayoutRowDynamic()设置一个简单的动态行,包含一个列,并在该单元格中插入NkLabel主题。接下来,我们添加另一个模板布局,以便我们可以有一个窄的、固定宽度的列用于标签,以及一个可变宽度的列用于值。之后,NkLabel可以插入标签和值以形成一个网格。最后,我们开始另一个单列动态行用于主要电子邮件内容:

   ...

   nk.NkGroupBegin(ctx, "Content", 1)
   nk.NkLayoutRowDynamic(ctx,0, 1)
   nk.NkLabel(ctx, "Subject", nk.TextAlignLeft)
   nk.NkLayoutRowTemplateBegin(ctx, 0)
   nk.NkLayoutRowTemplatePushStatic(ctx, 50)
   nk.NkLayoutRowTemplatePushVariable(ctx, 320)
   nk.NkLayoutRowTemplateEnd(ctx)
   nk.NkLabel(ctx, "From", nk.TextAlignRight)
   nk.NkLabel(ctx, "email", nk.TextAlignLeft)
   nk.NkLabel(ctx, "To", nk.TextAlignRight)
   nk.NkLabel(ctx, "email", nk.TextAlignLeft)
   nk.NkLabel(ctx, "Date", nk.TextAlignRight)
   nk.NkLabel(ctx, "date", nk.TextAlignLeft)
   nk.NkLayoutRowDynamic(ctx,0, 1)
   nk.NkLabel(ctx, "Content", nk.TextAlignLeft)
   nk.NkGroupEnd(ctx)
}

运行上述代码,以及从我们的Hello world示例中必要的样板代码,应该显示一个窗口,看起来很像以下这样:

使用 nk API 创建的基本 GoMail 布局

电子邮件编写对话框

要开始我们的 compose 窗口布局,我们创建一个新的 drawComposeLayout() 函数(用于测试,我们可以调用它而不是从 draw() 函数中调用 drawLayout())。在我们能够添加电子邮件编写 UI 将使用的文本编辑小部件之前,我们需要创建缓冲区来管理它们将编辑的内容。记住,这是一个即时模式工具包,因此,为了记住任何状态,我们必须提供数据存储。这就是 compose 窗口将存储新电子邮件的主题、电子邮件地址和内容的地方:

var composeSubject = make([]byte, 512, 512)
var composeEmail = make([]byte, 512, 512)
var composeContent = make([]byte, 4096, 4096)

对于用户来说,提供提示(通常称为占位符)也很有帮助——为了做到这一点,我们需要在绘制循环开始之前将一些数据复制到缓冲区中:

copy(composeSubject[:], "subject")
copy(composeEmail[:], "email")
copy(composeContent[:], "content")

现在,让我们看看电子邮件编写窗口的布局。布局与我们之前布局代码中电子邮件显示组的布局相似,设置了一个动态行用于主题小部件,然后是一个行模板用于 To 标签和电子邮件地址输入。这次,我们不是使用 NkLabel(),而是使用 NkEditStringZeroTerminated() 创建一个文本输入小部件,并带有多个参数。nk.EditBox|nk.EditSelectable|nk.EditClipboard 标志告诉 Nuklear 我们正在设置一个可以选中文本并与系统剪贴板交互的编辑框。我们还需要告诉小部件它应该编辑哪个缓冲区(在这种情况下是 composeSubject)以及字符的最大数量应该是多少(我们将其设置为缓冲区的长度 int32(len(composeSubject)))。然后,对于电子邮件和内容输入小部件,我们重复这个过程:

func drawComposeLayout(ctx *nk.Context, height int) {
   nk.NkLayoutRowDynamic(ctx,0, 1)
   nk.NkEditStringZeroTerminated(ctx, nk.EditBox|nk.EditSelectable|nk.EditClipboard,
      composeSubject, int32(len(composeSubject)), nil)
   nk.NkLayoutRowTemplateBegin(ctx, 0)
   nk.NkLayoutRowTemplatePushStatic(ctx, 25)
   nk.NkLayoutRowTemplatePushVariable(ctx, 320)
   nk.NkLayoutRowTemplateEnd(ctx)
   nk.NkLabel(ctx, "To", nk.TextAlignRight)
   nk.NkEditStringZeroTerminated(ctx, nk.EditBox|nk.EditSelectable|nk.EditClipboard,
      composeEmail, int32(len(composeEmail)), nil)
   nk.NkLayoutRowDynamic(ctx, float32(height-114), 1)
   nk.NkEditStringZeroTerminated(ctx, nk.EditBox|nk.EditSelectable|nk.EditClipboard,
      composeContent, int32(len(composeContent)), nil)

   ...

最后,我们需要将按钮添加到屏幕底部——我们为此使用另一个行模板。在这个布局中,变量空间被设置为行的大小减去我们按钮的大小,这样按钮就会对齐到右边。我们在第一个单元格中插入一个空的 NkLabel 作为填充物。两个 NkButtonLabel() 函数调用设置了布局右下角的按钮:

   ...

   nk.NkLayoutRowTemplateBegin(ctx, 0)
   nk.NkLayoutRowTemplatePushVariable(ctx, 234)
   nk.NkLayoutRowTemplatePushStatic(ctx, 64)
   nk.NkLayoutRowTemplatePushStatic(ctx, 64)
   nk.NkLayoutRowTemplateEnd(ctx)
   nk.NkLabel(ctx, "", nk.TextAlignLeft)
   nk.NkButtonLabel(ctx, "Cancel")
   nk.NkButtonLabel(ctx, "Send")
}

创建了布局代码后,我们可以显示窗口并看到一个类似于以下截图的电子邮件 Compose 窗口:

使用 nk 工具包的基本 Compose 布局

工具栏和菜单

使用 NkMenubarBegin()NkMenuBeginLabel()NkMenuItemLabel() 等函数,可以在 nk 窗口中添加菜单。设置菜单的难点在于我们还需要为栏和其项目添加适当的布局。重要的是(实际上,是强制性的),栏必须在一个 y=0 的布局中,所以我们立即使用 NkLayoutRowBegin() 添加一个新的行布局,并使用动态大小。然后,我们使用 NLayoutRowPush() 推送这个布局的单元格大小。

使用NkMenuBeginLabel()打开一个菜单项,我们必须检查这个函数的返回值——0表示菜单被隐藏。如果它返回非零值,那么我们应该在栏下方布局菜单。我们使用NkLayoutRowDynamic()开始一个新的动态行布局,使用单个列来包含每个菜单项。然后使用NkMenuItemLabel()添加每个菜单项,并使用适当的label字符串。这个函数的返回值表示项目是否被点击。如果我们得到非零值,那么我们应该执行该操作——如所示

Quit项。最后,如果菜单已打开,我们必须再次使用NkMenuEnd()关闭它:

   nk.NkMenubarBegin(ctx)
   nk.NkLayoutRowBegin(ctx, nk.LayoutStaticRow, 25, 3)
   nk.NkLayoutRowPush(ctx, 45)
   if nk.NkMenuBeginLabel(ctx, "File", nk.TextAlignLeft, nk.NkVec2(120, 200)) > 0 {
      nk.NkLayoutRowDynamic(ctx, 25, 1)
      nk.NkMenuItemLabel(ctx, "New", nk.TextAlignLeft)
      if nk.NkMenuItemLabel(ctx, "Quit", nk.TextAlignLeft) > 0 {
         win.SetShouldClose(true)
      }

      nk.NkMenuEnd(ctx)
   }

   ...

进一步的菜单(例如EditHelp)可以通过使用NkMenuBeginLabel()开始另一个块来简单地添加。对于完整的列表,你可以查看本书的代码库:chapter9/gomail

添加工具栏不如使用Nuklear工具包没有直接的工具栏支持那么直接。我们将通过在栏中添加一行固定大小且左对齐的按钮来模拟。为此,我们打开一个新的静态行布局,指定按钮的期望大小为单元格宽度(以及正确的列数)。然后我们使用NkButtonLabel()添加每个按钮,传递一个按钮标签。理想情况下,我们会使用NkButtonImage(),但没有标准的工具栏图标可用。我们可以自己打包所需的图标并加载图像,但目前从 Go 代码中加载图像的支持很少;有一个提议要添加NkImageFromRgba(),但在撰写本文时,这还不存在。实现图像加载超出了本章的范围:

   ...

   toolbarHeight := float32(24)
   nk.NkLayoutRowStatic(ctx, toolbarHeight, 78, 7)
   nk.NkButtonLabel(ctx, "New")
   nk.NkButtonLabel(ctx, "Reply")
   nk.NkButtonLabel(ctx, "Reply All")

   nk.NkButtonLabel(ctx, "Delete")

   nk.NkButtonLabel(ctx, "Cut")
   nk.NkButtonLabel(ctx, "Copy")
   nk.NkButtonLabel(ctx, "Paste")

   ...

这些按钮返回的,是一个int类型的值,就像前面的菜单项一样,表示它是否被点击。我们将在下一节中添加按钮处理,与 GUI 通信。有了这段代码,我们看到一个完整的电子邮件浏览窗口用户界面:

图片

我们编辑窗口的完成布局

与 GUI 通信

现在我们已经完成了所有布局,我们需要连接数据源并处理适当的交互事件。我们首先导入之前示例中使用的client电子邮件包。一旦导入,我们设置一个新的测试服务器并缓存当前消息(这将通过点击一个项目而稍后更改)。如前所述,我们必须在应用程序代码中而不是用户界面中保存所有状态:

import "github.com/PacktPublishing/Hands-On-GUI-Application-Development-in-Go/client"

var server = client.NewTestServer()
var current = server.CurrentMessage()

更新电子邮件列表组就像在for循环中包装标签创建,该循环遍历server.ListMessages()的范围:

nk.NkGroupBegin(ctx, "Inbox", 1)
nk.NkLayoutRowDynamic(ctx,0, 1)
for _, email := range server.ListMessages() {
   nk.NkLabel(ctx, email.Subject, nk.TextAlignLeft)
}
nk.NkGroupEnd(ctx)

内容是从我们保存为currentclient.EmailMessage加载的,如下所示:

   nk.NkLabel(ctx, ui.current.Subject, nk.TextAlignLeft)
   ...
   nk.NkLabel(ctx, "From", nk.TextAlignRight)
   nk.NkLabel(ctx, string(ui.current.From), nk.TextAlignLeft)
   nk.NkLabel(ctx, "To", nk.TextAlignRight)
   nk.NkLabel(ctx, string(ui.current.To), nk.TextAlignLeft)
   nk.NkLabel(ctx, "Date", nk.TextAlignRight)
   nk.NkLabel(ctx, ui.current.DateString(), nk.TextAlignLeft)
   ...
   nk.NkLabel(ctx, ui.current.Content, nk.TextAlignLeft)

对于主界面,最后的交互是菜单和工具栏按钮;当项目被激活时,每个相关功能都会返回> 0。我们可以像之前处理Quit项一样给菜单项添加点击处理程序:

   if nk.NkMenuItemLabel(ctx, "Quit", nk.TextAlignLeft) > 0 {
      win.SetShouldClose(true)
   }

同样的模式也可以用于工具栏按钮。对于“新建”按钮,我们设置一个撰写窗口在点击时出现。由于我们需要在本地维护所有状态,你将看到这里的按钮点击正在设置一个composeUI实例(一个用于撰写状态的定制类型);这将在以下内容中使用,以决定是否应该打开撰写窗口:

   if nk.NkButtonLabel(ctx, "New") > 0 {
      compose = newComposeUI(this)
   }

由于 Nuklear 后端通常不支持多个原生操作系统窗口,我们需要在 GoMail 主用户界面中加载我们的撰写窗口。在主界面布局代码运行后,我们可以插入一个新的检查,检查我们之前设置的compose值。当这个值为nil时,我们没有撰写窗口可以显示,但当它被设置后,我们将在第一个窗口内创建第二个窗口:

   ...
   nk.NkEnd(ctx)

   if compose != nil {
      drawCompose(ctx)
   }

   ...

上述代码在主窗口(由NkEnd()标记)之后执行。如果设置了撰写状态,我们需要调用新的drawCompose()函数:

func (ui *mainUI) drawCompose(ctx *nk.Context) {
   bounds := nk.NkRect(20, 20, 400, 320)
   update := nk.NkBegin(ctx, "Compose", bounds, nk.WindowNoScrollbar | nk.WindowBorder | nk.WindowTitle | nk.WindowMovable | nk.WindowMinimizable)

   if update > 0 {
      compose.drawLayout(ctx, 296)
   }

   nk.NkEnd(ctx)
}

这个新函数设置一个子窗口,然后调用我们之前定义的drawComposeLayout()——现在重命名为drawLayout(),在新的composeUI类型中。我们需要将撰写状态(我们之前声明的数据缓冲区)封装在单独的类型中;这允许我们跟踪多个撰写窗口中做出的更改(因为撰写窗口没有状态)。

要根据列表中选定的项目更改电子邮件,我们可以将NkLabel更改为NkSelectableLabel。此小部件接受一个额外的参数,用于指定是否选中,如果将选择更改为指定项目,则将返回非零值。更新列表代码应如下所示(需要一些额外的代码将bool转换为int32):

   for _, email := range ui.server.ListMessages() {
      var selected int32
      if email == ui.current {
         selected = 1
      }
      if nk.NkSelectableLabel(ctx, email.Subject, nk.TextAlignLeft, &selected) > 0 {
         ui.current = email
      }
   }

在将所有数据加载并从“新建”工具栏或菜单项打开撰写窗口后,我们看到如下内容:

我们完成的 GoMail 应用,nk 显示一个撰写窗口

背景处理

立即模式用户界面工具包的一个好处是没有隐藏状态。当我们看到新电子邮件到达时,我们不需要将更改通知给列表小部件(或等效的)以指示它添加新行。只要模型数据在事件发生时更新,就没有额外的工作要做。我们的 nk 代码将在下一帧自动添加新数据,因此用户界面将相应刷新。

这也意味着我们不需要在我们的用户界面代码中处理多个线程的复杂性。如果你确保任何模型数据是线程安全的(使用标准的 Go 工具),那么用户界面将始终在主线程上刷新。所有渲染代码必须在同一线程上运行是一个要求,但由于工具包的设计方式,这不太可能成为问题。

摘要

在本章中,我们探讨了三个工具包中的第二个,这些工具包旨在摆脱我们在第二部分使用现有小部件的工具包中探讨的传统工具包。Nuklear 项目主要针对嵌入式应用,但我们看到,在许多方面,它也适合桌面应用。其定制的小部件设计意味着应用将在所有支持的操作系统上看起来完全相同,这个列表比 Shiny 更长——包括用于移动开发的 Android。

我们探讨了 Nuklear 框架的设计以及它与提供实际绘图和用户输入实现的各个后端之间的交互。我们检查了主要的 API 功能,包括其绘图能力、它包含的控件以及它为构建用户界面提供的布局算法。然后,我们通过使用 nk API 和功能创建一个完整的应用程序,实现了在第四章“Walk - 构建图形窗口应用程序”和第七章“Go-Qt - 使用 Qt 的多平台”中创建的相同 GoMail 项目,从第四章到第七章。在处理即时模式 GUI 框架时有许多不同之处,但在许多方面,实现我们的基本应用程序更容易。

在下一章中,我们将探讨Fyne,这是我们将在详细探讨的最后一个工具包。与 Shiny 一样,它是一个受材料设计启发的控件库,但与 Nuklear 类似,它的重点是提供完整的控件工具包。

第十章:Fyne - 基于 Material Design 的 GUI

Fyne 是一个 UI 工具包和应用 API,旨在易于使用。其界面设计遵循材料设计原则,提供跨平台的图形,在所有支持的平台上都看起来相同。本章探讨了如何使用 Fyne 编写多平台的图形应用程序。

我们将涵盖以下主题:

  • Fyne 项目的愿景和设计

  • 构建基于 Fyne 的简单多平台应用程序

  • API 设计和 Fyne 提供的小部件

  • 如何使用 Fyne 创建完整的应用程序

到本章结束时,你应该对 Fyne 项目的雄心壮志有所了解,并且将使用工具包构建了多个跨平台的图形应用程序。

Fyne 的背景和愿景

Fyne 项目是由本书的作者 Andrew Williams 创建的,以回应人们对现有图形工具包和应用 API 复杂性的日益批评。它旨在易于使用,并选择了 Go 语言,因为它具有强大的简洁性。就像我们在 第八章 中探索的 Shiny 项目一样,Shiny – Experimental Go GUI API,其 API 从为 Go 语言专门创建中受益。

与 第三部分 中其他小部件工具包一样,现代图形工具包,它便于构建在所有平台上看起来相同的图形应用程序,而不是采用操作系统的外观和感觉。

"Fyne 的 API 旨在为开发美观、易用和轻量级的桌面和超越桌面应用程序提供最佳选择。"

-github.com/fyne-io/fyne/wiki/Vision

工具包最初是使用 Enlightenment Foundation Libraries (EFL: enlightenment.org/about-efl) 构建的,以促进跨平台渲染。从那时起,Fyne 已经转向基于 OpenGL 的驱动程序,就像上一章中的 nk 包一样。这使得设置更加简单,并且意味着使用 Fyne 创建的应用程序没有运行时依赖。在我们详细检查工具包之前,让我们看看如何设置一个简单的 Fyne 应用程序。

开始使用 Fyne

在我们开始构建 Fyne 应用程序之前,我们将逐步进行安装并运行一个示例应用程序。对于大多数系统,设置就像使用标准的 Go 工具安装 fyne.io/fyne 包一样简单。然而,对于某些系统,存在需要检查的开发前提条件,所以让我们从这里开始。

前提条件

对于大多数平台,开始使用 Fyne 无需安装任何要求。在 macOS 和 Windows 上,工具包使用内置的 OpenGL 功能,因此你可以直接跳到以下 设置 部分(然而,如果你是第一次在 macOS 上开发,请检查以下说明)。如果你在与 Linux(或其他 Unix 系统)一起工作,那么可能需要安装一些系统头文件。

Linux

要在 Linux 上编译,你需要安装 Xorg 和 GL(mesa 或类似)头文件(运行应用程序不需要这些)。具体要求会因系统而异,但最常见的要求如下:

  • Debian / Ubuntu:

    libgl1-mesa-devxorg-dev

  • Fedora / CentOS:

    libX11-devel, libXcursor-devel, libXrandr-devel, libXinerama-devel, mesa-libGL-devel, 和 libXi-devel

  • Arch Linux:

    mesa

在开发计算机上,这些软件包可能已经安装,但如果你在本章的后面部分遇到编译错误,首先应该检查这些软件包或它们在你系统中的等效软件包是否正确安装。

macOS

对于在 macOS 上开发,你必须安装 Xcode 命令行工具。如果你之前已经使用过 C 或 CGo,那么这可能是已经设置好的;如果没有,那么你可能需要执行 xcode-select --install 命令:

图片

如果你还没有安装命令行工具,那么 xcode-select 将显示此提示

设置

使用 go get 命令下载 Fyne API 以进行设置非常简单。项目通过其基本导入名称访问,fyne.io/fyne

图片

一旦设置好 CGo,安装 Fyne 就变得简单

示例

Fyne 工具包内置了一个示例应用程序,可以用来探索其功能和资产。我们可以使用这个应用程序来验证设置是否正常。只需使用 Go 工具安装应用程序,然后使用 fyne_demo 命令运行它:

图片

从项目仓库安装并运行 fyne_demo 命令

运行演示应用程序将打开一个窗口,该窗口提供各种选项以供探索。如果我们点击几个项目,将打开额外的窗口,你应该看到如下所示的内容:

图片

Fyne 示例应用程序中展示的一些功能

代码

使用 Fyne 的基本 Hello World 应用程序相当简洁,因为应用程序设置封装在一个单独的调用中。由 app 子包提供的入口点 app.New() 设置了一个新的应用程序,我们使用它来打开一个新窗口。widget 子包定义了我们可以添加到新窗口的各种小部件:

package main

import "fyne.io/fyne/app"
import "fyne.io/fyne/widget"

func main() {
   app := app.New()

   win := app.NewWindow("Hello World")
   win.SetContent(widget.NewVBox(
      widget.NewLabel("Hello World!"),
      widget.NewButton("Quit", func() {
         app.Quit()
      }),
   ))

   win.ShowAndRun()
}

如前一个代码块所示,新创建的 fyne.Window 将其内容设置为一个新的 widget.VBox,它提供了基本的布局。在这个布局中,我们使用 widget.NewLabel() 添加了一个 Hello World 标签,并使用 widget.NewButton() 添加了一个退出按钮。按钮的第二个参数是 func(),当按钮被点击时会调用这个函数。

最后,我们在创建的窗口上调用 ShowAndRun()。这个函数将显示窗口并启动应用程序 event 循环。它是 win.Show(); app.Run() 的简写。

构建 和 运行

这个简单应用程序可以直接使用 go run hello.go 运行,或者使用 go build hello.go 构建,然后运行编译后的二进制文件:

图片

在任何支持的系统上直接编译或运行的效果相同

运行代码应该会生成一个看起来像下面的简单应用程序。点击退出按钮或关闭窗口将退出应用程序:

图片

在 macOS 上运行 Hello World

跨平台编译

由于依赖于 CGo,为除你正在开发的平台之外的平台编译,不幸的是,并不像设置 GOOS 环境变量那样简单。为不同的平台构建需要为目标操作系统安装 C 编译器。如果你一直在阅读前面的章节,那么这可能已经设置好了,如果没有,那么这个过程在 附录 2 中有文档说明,交叉编译器设置

一旦安装了适当的编译器,构建过程就通过设置 GOOSCGO_ENABLEDCC 环境变量来配置。你可能还需要更新你的路径——建议将其添加到你的终端或 shell 配置中:

图片

在 macOS 和 Windows 上从 Linux 构建是一个使用正确编译器的例子

现在我们已经探讨了如何启动和运行以及为多个平台编译的细节,让我们更深入地了解 Fyne 的设计和组织方式。

渲染和矢量图形

Fyne 小部件(类似于第九章 [48b682de-d742-4c7b-b9a8-2926a76d7cb8.xhtml] 中 Nuklear 库中的那些)由简单的图形对象组成,渲染驱动程序负责绘制这些对象。驱动程序作为包的一部分包含在内,因此不需要额外的设置即可启动应用程序。类似于我们在第八章 [9e373c53-f82e-4bf2-ba31-7a59c22d9791.xhtml] 中探索的 Shiny 工具包(Shiny – Experimental Go GUI API),图标都是基于矢量的,Fyne 使用矢量图形来创建可缩放的用户界面,以适应设备屏幕的密度。

矢量图形

向量图形指的是由线条和形状组成的图像,而不是像素集合(称为位图图形)。虽然这些图像加载可能较慢,但它们非常适合在任何比例下绘制完美的图像。随着计算机屏幕和智能手机像素密度的持续增加,以每英寸点数(DPI)衡量,生产在所有设备上看起来都好的位图图形变得越来越困难。例如,iOS 平台历史上通过要求相同内容的不同分辨率的多个文件来解决这个问题——如Icon.pngIcon@2x.pngIcon@3x.png(例如,分别为 60 x 60、120 x 120 或 180 x 180 像素)——以便可以使用最适合屏幕的图像。使用向量图标时,您将提供一个单独的图像,Icon.svg可缩放矢量图形),它可以绘制出所需的精确分辨率,以获得清晰的图像。

Fyne 工具包在整个工具包中使用向量图形,以便使用它构建的应用程序可以适当地缩放以适应任何计算机屏幕。当应用程序启动时,它会计算当前屏幕的像素密度(DPI)并设置适当的缩放比例。此外,当 Fyne 窗口移动到具有不同分辨率的屏幕时,内容(以及包含它的窗口)将相应地调整大小。这意味着当在笔记本电脑(通常是高分辨率屏幕)上运行的应用程序移动到外部显示器(通常是低分辨率)时,窗口将调整到更少的像素数量,以尝试保持用户的一致大小。如果您希望覆盖自动检测到的缩放比例,那么在启动应用程序之前设置FYNE_SCALE环境变量是可能的。

设置比例值的示例——注意清晰的文本和图标:

图片

FYNE_SCALE=0.5

图片

FYNE_SCALE=2.5

在某些情况下,使用位图图像而不是向量图像可能是合适的。如果您想要绘制与可用空间中可见像素数量完全相同的像素,这通常是有帮助的。这种情况的一个例子可以在图像处理程序中找到,或者在绘制复杂计算的结果时。对于这些情况,Fyne API(使用canvas.NewRaster()创建)中存在一种图像类型,它提供了这种功能。Fyne 提供的示例之一是分形查看器,其中每个像素都是使用位图图像功能计算和绘制的:

图片

为输出设备逐像素计算出的曼德布罗特分形。观察细节水平

驾驶员

Fyne 中的驱动程序负责渲染文本、画布对象和图像,以及处理窗口管理和用户输入。驱动程序还必须处理任何后台的线程管理。通过采用这种设计,背景进程或异步事件可以在没有任何图形工具包中常见的线程管理代码的情况下更新用户界面。

Fyne 的默认驱动程序使用 Go-GL 和 Go GLFW 绑定,这意味着它具有与我们在上一章中工作的示例相同的依赖项,即nk – Nuklear for Go。如果你的电脑和目标客户电脑支持 OpenGL(这包括所有最新的桌面电脑、大多数笔记本电脑、智能手机和平板电脑等),那么你不需要任何额外的库或支持包。安装适当的 Go 开发者工具(参见入门 Fyne中的先决条件,前面已讨论)就是你所需要的,并且你的应用程序用户没有运行时要求。

如果你希望为旧电脑或没有 OpenGL 支持电脑构建,可以使用替代的efl驱动程序。这个驱动程序使用 Enlightenment Foundation Libraries 以跨平台的方式处理渲染、窗口管理和用户输入。它们在广泛平台上的多年开发(除了桌面平台,它们还支持 Playstation、Tizen、Samsung Gear 手表和各种机顶盒)意味着应用程序可以在更广泛的设备上运行。要使用此驱动程序运行,只需在任意的 go 构建或运行命令中添加-tags efl,例如go run -tags efl hello.go。虽然这个驱动程序确实提供了更好的多平台支持,但它也要求在开发者的电脑和目标设备上都安装了 EFL 库。因此,当使用 Fyne 时,这通常不是首选的方法。

支持的平台

尽管不同的 Fyne 驱动程序可能支持不同的平台,但核心工具包仅支持一组标准的操作系统。在撰写本文时,这包括 macOS、Windows、Linux 和 BSD 变体。任何特定操作系统的代码都理解应用程序应该如何在这些目标上运行。与第三部分中的其他工具包不同,现代图形工具包,Fyne 旨在为应用程序及其图形界面提供 API。例如,app.OpenURL()允许应用程序在每个支持的系统上使用默认浏览器启动外部文档。

现在我们已经探讨了 Fyne 项目背景,以及其设计和操作系统支持,让我们来探索它为应用程序开发者提供的 API。

画布、小部件和布局

Fyne API 被划分为多个子包,用于基本绘图定义、容器布局、高级小部件和主题描述。在本节中,我们将逐一查看。这些包提供了从应用程序开发人员的角度来看有用的实现细节,并且它们通常实现通用接口。这些接口定义位于层次结构的顶层,包括如 fyne.CanvasObject(任何可以添加到画布的对象都实现了它)、fyne.Container(描述多个对象如何分组和布局)和 fyne.Resource(表示嵌入的应用程序资源,如图标或字体)。此外,还有一些数学和几何实用工具以及事件和文本处理的定义。

我们将不会介绍其他一些额外的包,包括 dialog(用于常见对话框窗口的有用类)、driver(驱动程序从这里加载)和 test(提供有用的测试设施)。让我们探索其他更常用的包。

Canvas (绘图)

canvas 包包含 Fyne 理解的所有基本绘图对象的定义。这些类型中的每一个都定义了代表配置的多个字段,例如颜色、大小和可见性。这些是 Fyne 驱动程序将遍历的对象,将每个对象绘制出来以创建渲染的用户界面:

Circle 这是一个由边界左上角到右下角的矩形定义的圆形或椭圆。它可以由 NewCircle()&Circle{} 创建。在大多数应用程序中并不常用。
Image 一张图像可能是一个从文件(使用 NewImageFromFile())或嵌入的资源加载的矢量或位图图像,或者它可能是一个动态生成的图像以填充可用空间(使用 NewRaster()func(w, h int) *image.Image 回调)。
Line 这是一个从一点绘制到另一点的简单线条。除非绘制图表,否则并不常用。
Rectangle 是小部件的基本构建块,矩形以指定的颜色绘制一个区域。可以通过 NewRectangle()&Rectangle{} 创建。
Text 文本画布原语以指定的颜色和对齐方式在屏幕上绘制单个字符串。它不处理任何特殊字符或格式。可以直接使用 &Text{} 或使用辅助 NewText() 函数创建。

上述列表构成了 Fyne 画布的原始绘图元素。接下来,我们将查看如何使用布局在容器内定位它们。

Layout

Fyne 中的多个对象被分组在 fyne.Container 类型中,其子对象通过 fyne.Layout 进行布局。提供了各种标准布局,具体细节如下表所示。布局提供两个功能:首先,它管理一系列 fyne.CanvasObject 对象的大小和位置;其次,它必须定义容纳所有它安排的对象所需的最小大小:

BorderLayout 边界布局将特定的画布对象放置在容器的顶部、底部、左侧和右侧边缘。容器中的任何其他对象将填充中央空间。
BoxLayout 箱式布局是垂直或水平(使用NewVBoxLayout()NewHBoxLayout()函数创建)。它将项目排列在列表中,每个项目都位于其最小高度(垂直)或宽度(水平),另一个维度将扩展到容器边缘。箱式布局还可以包含一个填充器,它将扩展以填充可用空间(通常使用NewSpacer()创建)。
FixedGridLayout 固定网格布局指定每个单元格的大小,然后在可用空间内按行排列它们。当下一个小部件扩展到容器宽度之外时,将创建新的一行。
GridLayout 网格布局具有指定的列数,每个子对象将占据容器宽度的适当比例。高度以类似的方式定义,取决于子画布对象的数量。例如,如果有五个对象分布在两列中,将有三行等高。
MaxLayout 这是最简单的布局。每个项目都设置为相同的大小以填充可用空间。请注意,以正确的顺序指定容器对象(第一个将被绘制在后续任何项目下方)。例如,一个按钮可能只是一个矩形,文本位于上方,两者都应等比例扩展。

还可以通过实现fyne.Layout接口来编写自定义布局。MinSize()函数应确定所需的大小(可能尊重子对象上的MinSize()函数)并且Layout()函数在子对象上调用Move()Resize()以配置显示以进行渲染。

虽然容器和布局很有用,但我们将大部分时间将花费在高级小部件定义上,所以让我们看看有哪些可用。

小部件

Fyne 小部件分为两部分:行为,这是主 API 暴露的内容,以及渲染器,它控制小部件的外观。除非你正在构建自定义小部件,否则不建议访问渲染功能(隐藏在widget.Renderer()实用函数后面)。如果需要,用户界面的定制应使用theme包管理(参考下一节)。

所有小部件都可以使用它们的构造函数(如NewButton("text", callback))或使用初始化语法创建,例如&Button{Text: "text", OnTapped: callback}。如果使用后者,则还可以在初始化小部件后立即设置字段,直到它首次渲染。小部件显示后,应使用设置函数(如SetText())来确保 GUI 更新以反映更改。小部件字段仍然很有用——如果您想一次性更新多个属性,可以将适当的字段设置为在单个刷新中应用。只需确保在应用更改后调用widget.Refresh(myObject)即可。

写作时的完整小部件列表如下:

Box 这是一个简单的使用layout.BoxLayout来排列子对象在水平或垂直列表中的小部件。
Button 基本按钮包含文本和/或图标,并在被轻触时调用传递的func()
Check 复选框小部件显示在复选框旁边的标签,并在切换时触发func(bool)回调。
Entry 用于单行或多行输入的文本输入小部件。
Form 表单小部件布局一个简单的数据表单,其中标签在一列,输入小部件在另一列。设置OnSubmitOnCancel回调字段将包括在附加行上的适当按钮。
Group 子对象的视觉分组。在项目周围绘制一条线,并在它们上方绘制一个标题标签。
Icon 用于绘制主题图标的简单小部件。使用图标资源(参考以下部分的主题)创建它,它将适应当前主题配置。
Label 这是一个简单的文本小部件,使用当前主题文本颜色绘制,并在颜色更改时更新。
PasswordEntry 与前面的Entry小部件相同,但文本以*字符隐藏。
TabContainer 与标准容器类似,但可以显示不同的内容。每个子容器都与一个标签按钮相关联,当按下时,将显示适当的内容。
Toolbar 工具栏小部件显示一行图标按钮,可选地用NewToolbarSpacer()(一个不可见的空间)或NewToolbarSeparator()(一条细线以显示分组)分隔。

实现自己的小部件是可能的——它们需要做的只是实现fyne.Widget接口。除了基本的fyne.CanvasObject函数外,小部件还必须定义一个返回fyne.WidgetRenderer实例的CreateRenderer()函数。小部件渲染器类似于容器对象,但它还具有背景颜色,并且应该反映当前主题(如果更改主题,将调用所有小部件的所需ApplyTheme()函数)。正如我们多次提到的,现在让我们进一步探索 Fyne 主题提供的内容。

主题

theme包是一个受材料设计启发的用户界面实现。它提供了显示 Fyne 用户界面所需的色彩方案、图标、字体和间距信息:

图片

“基准”材料设计色彩方案。Fyne 默认使用蓝色/灰色变体

小部件广泛使用主题包以匹配当前设置。例如,按钮将被着色为theme.ButtonColor()(除非它是主按钮,在这种情况下为theme.PrimaryColor()),而标签文本为theme.TextColor()。Fyne 还打包了一个标准字体,可以使用theme.TextFont()(及其变体)访问,但这些通常不需要。相反,请在文本对象或标签上使用fyne.TextStyle属性。然而,theme.TextSize()theme.Padding()是匹配自定义小部件用户界面风格的有用方式。

Fyne 主题还提供了一组可以在任何应用程序中使用的材料设计图标,例如,theme.ContentPasteIcon()。从主题加载的图标在使用任何标准小部件时将适应新的主题加载。这些图标与工具包捆绑在一起,不需要安装或与应用程序一起发送任何额外项目。

每次使用主题方法时,重要的是要意识到结果可能会随时间而改变——可能会加载新的主题或用户可能会更改配置。为了正确处理这种情况,您应该实现fyne.ThemedObject,它需要一个函数ApplyTheme()。在这个函数内部,您应该重新应用任何访问过的基于主题的值。这种功能由小部件自动处理,因此通常不需要应用程序处理主题更改。

打包的主题

Fyne 工具包提供了两个主题以匹配用户的偏好——浅色主题和深色主题。要更改应用程序的主题,可以将环境变量FYNE_THEME设置为lightdark。如果您正在实现自定义小部件,建议至少使用这些两个主题进行测试:

图片

默认的深色主题

图片

可选的浅色主题

在撰写本文时,Fyne 不提供下载用户创建的自定义主题的功能,但未来可能会有所改变。然而,应用程序可以使用自己的主题进行显示。在实现fyne.Theme接口后,您应使用app.Settings().SetTheme()将类型的实例传递给应用程序配置。

构建用户界面

要进一步探索 Fyne 工具包,让我们构建第四章中设计的 GoMail 应用程序的最新版本,Walk – 构建图形窗口应用程序。我们将从设置基本应用程序布局开始。

布局

使用 Fyne 创建复杂布局的情况是将多个容器组合起来,每个容器都使用提供的布局之一。我们有可能编写自己的布局来使用单个容器设置界面,但在这个探索中,我们将仅使用内置组件。让我们从创建主应用程序窗口开始。

主要电子邮件窗口

要加载 Fyne 应用程序的第一个窗口,我们必须使用app.New()创建一个新的应用程序实例。之后,我们可以在该应用程序对象上调用NewWindow()函数。返回的fyne.Window对象允许我们控制屏幕上的窗口并设置其内容:

import "fyne.io/fyne/app"

func main() {
   mailApp := app.New()
   browse := mailApp.NewWindow("GoMail")

   ...
}

接下来,我们将为我们的 GUI 创建所需的控件。这首先是通过添加控件导入行开始的,然后我们在之前创建的main()函数中添加声明。使用widget.NewToolbar()添加了一个工具栏(我们稍后会向其中添加项目)。对于左侧的电子邮件列表,我们使用widget.NewGroup()创建一个新的带标题的组,标题为Inbox。我们向这个组添加了占位符标签,使用widget.NewLabel()

然后,我们为电子邮件的内容和主题创建新的标签以显示。我们使用fyne.TextStyle声明设置主题标签的文本。最后,我们使用widget.NewForm()设置我们的电子邮件元数据的网格布局。表单小部件符合我们的设计,即列出带有描述小部件的粗体文本标签的行。对于表单,我们添加了收件人发件人日期项,如下所示:

import "fyne.io/fyne/widget"

func main() {
   ...

   toolbar := widget.NewToolbar()
   list := widget.NewGroup("Inbox",
      widget.NewLabel("Item1"),
      widget.NewLabel("Item2"),
      widget.NewLabel("Item3"),
   )
   content := widget.NewLabel("Content")
   subject := widget.NewLabel("subject")
   subject.TextStyle = fyne.TextStyle{Bold:true}

   meta := widget.NewForm()
   meta.Append("To", widget.NewLabel("email"))
   meta.Append("From", widget.NewLabel("email"))
   meta.Append("Date", widget.NewLabel("date"))

   ...
}

现在我们已经定义了所有控件,我们需要适当地布局它们。在 Fyne 中,我们通常使用fyne.Container对象,并可选地传递一个布局来控制其设置方式。还有一些辅助控件提供了更易于使用的 API,例如在下一节中使用的widget.NewVBox()(它设置了一个容器,其中项目按垂直列表排列)。

在此代码片段中的两个容器中,我们使用的是BorderLayout。在调用layout.NewBorderLayout()时,我们传递了应该放置在布局顶部、底部、左侧和右侧的对象(如果它们要留空,则为nil)。任何包含在容器中但未在特定位置列出的项目将被排列以填充布局的中心,占据所有剩余空间。记住,要放置在边框部分的项目也应作为后续参数传递给fyne.NewContainerWithLayout()函数,因为这控制了将在容器内绘制的对象。请参阅以下部分,了解如何将subjectbox传递给布局以及容器,因为我们希望它们由布局定位并由容器绘制。

在第一个容器(detail)中,我们将 subject 标签设置为沿顶部拉伸,并将包含我们的元数据和内容的 box 在容器内左对齐。下一个容器(container)是我们的整体应用程序布局,它将 toolbar 放在顶部,将电子邮件 list 放在左侧,并将 detail 容器填充布局的剩余空间(因为它没有指定为边框参数):

import "fyne.io/fyne"
import "fyne.io/fyne/layout"

func main() {
   ...

   box := widget.NewVBox(meta, content)
   detail := fyne.NewContainerWithLayout(
      layout.NewBorderLayout(subject, nil, box, nil),
      subject, box)
   container := fyne.NewContainerWithLayout(
      layout.NewBorderLayout(toolbar, nil, list, nil),
      toolbar, list, detail)

   ...
}

在定义了所有容器和布局之后,我们需要通过设置其内容并可选地指定大小来完成窗口。你可能不需要在窗口上调用 Resize() 函数——它的默认大小将适合所有小部件和容器在其最小尺寸。

最后,我们在窗口上调用 ShowAndRun(),这将使窗口出现并启动应用程序的主循环。任何后续的窗口只需简单地调用 Show()(因为应用程序应该只启动一次):

   ...

   browse.SetContent(container)
   browse.Resize(fyne.NewSize(600, 400))
   browse.ShowAndRun()
}

运行前面的代码(可以在本书的源代码仓库中找到)应该会产生一个类似于以下窗口的窗口:

图片

使用 Fyne 的基本应用程序布局。顶部的栏是一个空的工具栏

撰写对话框

要启动我们的次要窗口,即撰写对话框,我们可以使用 Fyne 中的自定义对话框功能(使用 dialog.ShowCustom() 创建)。然而,Fyne 中的所有对话框窗口都是固定大小的,而我们希望撰写窗口是灵活的。因此,我们将创建一个新的窗口,就像我们的 main() 函数中一样,使用 app.NewWindow()。为此,我们需要将应用程序实例传递给一个新的 ShowCompose() 函数(因为窗口是从应用程序对象创建的):

func ShowCompose(app fyne.App) {
   compose := app.NewWindow("GoMail Compose")

   ...
}

接下来,我们创建用于撰写窗口的小部件。我们将为每个文本输入组件使用 widget.NewEntry()。对于多行消息小部件,我们可以将 Entry.MultiLine 设置为 true,但相反,我们使用 widget.NewMultiLineEntry() 辅助函数。在每个实例中,我们使用 Entry.SetPlaceHolder() 设置一个占位符值(在用户输入自己的文本之前将显示为提示)。

使用 widget.NewButton() 创建了两个新的按钮,一个带有 "Send" 标签,另一个带有 "Cancel"。我们保留了对 send 按钮的引用,以便我们可以将 Button.Style 设置为 widget.PrimaryButton。这突出了按钮作为窗口的默认操作。最后,我们使用 widget.NewHBox() 创建了一个新的水平框用于按钮栏。在这个框中,我们首先添加了一个填充物以使按钮右对齐(使用 layout.NewSpacer()),然后包括了取消和发送按钮:

func ShowCompose(app fyne.App) {
   ...

   subject := widget.NewEntry()
   subject.SetPlaceHolder("subject")
   toLabel := widget.NewLabel("To")
   to := widget.NewEntry()
   to.SetPlaceHolder("email")

   message := widget.NewMultiLineEntry()
   message.SetPlaceHolder("content")

   send := widget.NewButton("Send", func() {})
   send.Style = widget.PrimaryButton
   buttons := widget.NewHBox(
      layout.NewSpacer(),
      widget.NewButton("Cancel", func() {
         compose.Close()
      }),
      send)

   ...
}

最后,我们设置窗口的布局。再次强调,这是一个非平凡的布局,因为 Fyne 的布局选项很简单。我们使用layout.NewBorderLayout()来指定哪些组件应该拉伸,哪些应该放置在其周围。top布局将主题放置在其顶部边缘,并将to字段与扩展的toLabel左对齐。第二个布局contentmessage编辑器放置在中心,top布局在其上方,buttons栏在其下方。

然后,我们设置新compose窗口的内容,将其设置为默认大小(大于布局计算的minSize()),并调用Show()。记住,这次我们不使用ShowAndRun(),因为应用程序已经运行:

func ShowCompose(app fyne.App) {
   ...

   top := fyne.NewContainerWithLayout(
      layout.NewBorderLayout(subject, nil, toLabel, nil),
      subject, toLabel, to)

   content := fyne.NewContainerWithLayout(
      layout.NewBorderLayout(top, buttons, nil, nil),
      top, message, buttons)

   compose.SetContent(content)
   compose.Resize(fyne.NewSize(400, 320))
   compose.Show()
}

尽管我们还没有compose按钮,但此代码可以从main()函数立即调用,在browse.ShowAndRun()之前进行测试(记得之后删除此行)。结果应该类似于以下内容:

图片

我们使用基本的 Fyne 组件创建的 compose 对话框

工具栏和菜单

不幸的是,Fyne 没有菜单栏支持(尽管在以下项目问题中提出了建议:github.com/fyne-io/fyne/issues/41)。我们也不能轻易地从更简单的组件中创建一个,因为目前没有对弹出小部件的支持。因此,我们只需添加一个工具栏(如一些之前的示例所示)。

使用 Fyne 内置的图标(来自材料设计项目),我们可以快速创建一个吸引人的工具栏。为了设置工具栏,我们将创建一个新的函数buildToolbar(),该函数将创建工具栏并添加项目。我们传递应用程序实例,以便Compose项目可以将其传递到我们之前创建的ShowCompose()函数。

工具栏构建函数接受一个ToolbarItem对象列表(任何实现widget.ToolbarItem的控件或类型)。在创建工具栏后,也可以调用Append()Prepend()。对于应在工具栏中出现的每个项目,我们使用widget.NewToolbarAction()传递一个操作项。工具栏操作接受一个fyne.Resource参数(图标)和一个当项目被点击时调用的func()。对于资源,我们使用主题 API 来访问框架中打包的标准图标。此外,我们添加一个分隔符来分组操作,使用widget.NewToolbarSeparator()

func buildToolbar(app fyne.App) *widget.Toolbar {
   return widget.NewToolbar(
      widget.NewToolbarAction(theme.MailComposeIcon(), func() {
         ShowCompose(app)
      }),
      widget.NewToolbarAction(theme.MailReplyIcon(), func() {
      }),
      widget.NewToolbarAction(theme.MailReplyAllIcon(), func() {
      }),
      widget.NewToolbarSeparator(),
      widget.NewToolbarAction(theme.DeleteIcon(), func() {
      }),
      widget.NewToolbarAction(theme.CutIcon(), func() {
      }),
      widget.NewToolbarAction(theme.CopyIcon(), func() {
      }),
      widget.NewToolbarAction(theme.PasteIcon(), func() {
      }),
   )
}

要使用这个新方法,我们更新了main()方法中的工具栏创建代码,使其简单地读取toolbar := buildToolbar(mailApp)。这些更改到位后,我们会在主窗口顶部看到使用材料设计图标的完整工具栏,如下所示:

图片

内置的 Fyne 工具栏为许多操作提供了默认图标

与 GUI 通信

设置用户界面以显示真实数据并执行适当的交互,就像设置文本值和填写点击处理程序一样简单。首先,我们将添加两个辅助方法。

加载电子邮件

第一个新函数,setMessage(),将简单地调用 SetText() 对每个 widget.Label 元素。这需要保存之前在本节中创建的 tofromdatesubjectcontent 标签小部件的引用。它们的内容可以使用以下 SetText() 函数更新:


func setMessage(email *client.EmailMessage) {
   subject.SetText(email.Subject)

   to.SetText(email.ToEmailString())
   from.SetText(email.FromEmailString())
   date.SetText(email.DateString())

   content.SetText(email.Content)
}

我们还将创建另一个辅助函数,addEmail(),用于向列表中添加新电子邮件。这与我们最初添加到 widget.Group 中的 widget.Labels 列表不同——我们正在使用按钮来利用它们内置的点击处理功能。在此函数中创建的按钮将标签设置为电子邮件主题,就像之前一样,并在被点击时调用新的 setMessage() 函数:

func addEmail(email *client.EmailMessage) fyne.CanvasObject {
   return widget.NewButton(email.Subject, func() {
      setMessage(email)
   })
}

然后,列表代码更新为在加载用户界面时调用新的 addEmail() 函数:

list := widget.NewGroup("Inbox")
for _, email := range server.ListMessages() {
   list.Append(addEmail(email))
}

这些是我们需要实现以使浏览器界面功能性的唯一更改。现在,让我们向编写窗口添加适当的处理代码。

发送电子邮件

为了完成编写视图的工作,我们需要更新按钮的回调。对于取消按钮,只需要在窗口对象上调用 Close()。在发送按钮的点击处理程序中,我们将构建一个新的电子邮件并使用服务器对象的 Send() 函数发送它。client.NewMessage() 函数处理电子邮件对象的创建。我们只需要使用每个输入的 Entry.Text 字段来访问当前状态:

send := widget.NewButton("Send", func() {
   email := client.NewMessage(subject.Text, content.Text,
      client.Email(to.Text), "", time.Now())
   server.Send(email)
   compose.Close()
})
send.Style = widget.PrimaryButton
buttons := widget.NewHBox(
   layout.NewSpacer(),
   widget.NewButton("Cancel", func() {
      compose.Close()
   }),
   send)

在此代码到位后,应用程序应该与之前构建的示例完全一样地运行。尽管编写窗口看起来没有变化,但我们的电子邮件浏览器窗口现在有一些真实数据,看起来应该像这样:

在 Fyne 默认深色主题下的完成 GoMail 接口

由于 Fyne 提供了两种内置主题,我们还可以看到如果用户更喜欢浅色主题,应用程序看起来会是什么样子。通过将 FYNE_THEME 环境变量设置为 "light",我们可以加载替代主题,如下所示:

您可以在环境中设置 FYNE_THEME 或将其传递给运行命令

设置正确的主题值将导致应用程序加载一个浅色版本:

我们带有浅色 Fyne 主题的 GoMail 接口

在我们完成此应用程序之前,我们还应该涵盖后台处理部分——处理新电子邮件到达的情况。

后台处理

使用 Fyne 进行后台处理以更新用户界面不需要任何特殊的线程处理代码。你可以在任何 goroutine 中执行完整的图形和控件命令——工具包将负责任何系统线程管理。

要将新收到的电子邮件添加到我们应用程序中的列表,我们只需要为新的 client.EmailMessage 调用 addEmail() 并将其传递给 list.Prepend() 函数。代码就像以下这样:

go func() {
   for email := range server.Incoming() {
      list.Prepend(addEmail(email))
   }
}()

这样就完成了我们的基本 GoMail 应用程序。鉴于 Fyne 项目与我们在第八章中探讨的 Shiny 工具包相似,即 Shiny – Experimental Go GUI API,让我们也看看我们如何重新构建图像查看器应用程序。

构建图像查看器

由于 Fyne 工具包包括类似于 Shiny 项目的画布 API 和图像处理,因此与我们在第八章中创建的图像查看器应用程序进行比较也是有意义的,即 Shiny – Experimental Go GUI API。让我们像往常一样,从基本的应用程序布局开始。

布局

由于我们将使用画布 API、控件和布局,我们需要首先导入大多数 Fyne 子包。除了 canvas,其中我们获取基本的图像 API,我们还将使用 theme 包来访问图标,并使用 app 包来启动我们的应用程序。我们不需要导入图像库,如 image/jpeg,因为 Fyne 图像控件会为我们导入它们:

import (
   "fyne.io/fyne"
   "fyne.io/fyne/app"
   "fyne.io/fyne/canvas"
   "fyne.io/fyne/layout"
   "fyne.io/fyne/theme"
   "fyne.io/fyne/widget"
)

与任何 Fyne 应用程序一样,我们首先使用 app.New() 创建一个应用程序,然后通过调用 NewWindow() 并提供一个合适的标题来为应用程序创建一个窗口:

func main() {
   imageApp := app.New()
   win := imageApp.NewWindow("GoImages")

   ...
}

接下来,我们将创建主布局的控件。为了实现一个视觉上独特的导航栏,让我们像在 GoMail 应用程序中那样使用工具栏。除了标准图标按钮外,我们还添加了一个空格(使用 widget.NewToolbarSpacer()),以便第二个按钮在栏中右对齐。我们稍后会回到导航,添加文件名显示和功能。

接下来,我们使用 widget.Group 控件来视觉上分组文件列表(如果更喜欢无边框的外观,我们可以使用 widget.Box 控件)。在组中,我们添加各种标签,它们将作为文件占位符。最后,我们加载图像视图以显示占位符文件。canvas.NewImageFromFile() 函数为我们处理所有图像加载,如下面的代码块所示:

func main() {
   ...

   navBar := widget.NewToolbar(
      widget.NewToolbarAction(theme.NavigateBackIcon(), func() {}),
      widget.NewToolbarSpacer(),
      widget.NewToolbarAction(theme.NavigateNextIcon(), func() {}))
   fileList := widget.NewGroup("directory",
      widget.NewLabel("Image 1"),
      widget.NewLabel("Image 2"),
      widget.NewLabel("Image 3"))
   image := canvas.NewImageFromFile("shiny-hall.jpg")

   ...
}

对于这个应用程序,简单的 layout.BorderLayout 将提供我们需要的精确布局。我们创建一个新的布局,其中 navBar 在顶部,fileList 在左侧。容器还包括 image,它将被拉伸以填充剩余的空间:

func main() {
   ...

   container := fyne.NewContainerWithLayout(
      layout.NewBorderLayout(navBar, nil, fileList, nil),
      navBar, fileList, image,
   )

   ...
}

最后,我们将此容器设置为窗口的内容,将整个窗口的大小调整为大于计算出的最小尺寸,并显示它。和之前一样,我们使用ShowAndRun()作为运行应用程序的快捷方式:

func main() {
   ...

   win.SetContent(container)
   win.Resize(fyne.NewSize(640, 480))

   win.ShowAndRun()
}

在所有这些代码到位后,可以运行示例。你应该会看到一个与以下非常相似的窗口(假设你正在使用默认的深色主题):

图片

使用默认 Fyne 小部件的基本图像查看器布局

导航

为了完成导航栏,我们还需要在栏的中间显示文件名。正如你可能已经注意到的,没有工具栏小部件允许显示文本,但我们可以创建自己的。工具栏中的每个项目都实现了widget.ToolbarItem接口,因此我们可以创建一个遵循此模式的新类型。通过实现ToolbarObject()(该接口仅要求一个函数),我们可以返回适当的标签以显示:

type toolbarLabel struct {
}

func (t *toolbarLabel) ToolbarObject() fyne.CanvasObject {
   return widget.NewLabel("filename")
}

当我们更新导航栏时,我们应该创建占位符函数来处理“上一个”(左箭头)和“下一个”(右箭头)按钮的点击。空参数列表与widget.Button回调的函数类型相匹配,因此这些函数很简单如下:

func previousImage() {}

func nextImage() {}

最后,我们将导航栏的创建更新为使用我们创建的新toolbarLabel类型。通过添加第二个间隔小部件,我们要求布局将标签居中,同时保留下一个按钮的右对齐:

navBar := widget.NewToolbar(
   widget.NewToolbarAction(theme.NavigateBackIcon(), previousImage),
   widget.NewToolbarSpacer(),
   &toolbarLabel{},
   widget.NewToolbarSpacer(),
   widget.NewToolbarAction(theme.NavigateNextIcon(), nextImage))

在这些更改到位后,运行代码应该导致以下更新的导航栏。我们将在稍后返回此设置正确的文件名,但现在,我们将继续处理界面左侧的文件列表:

图片

使用自定义工具栏组件创建的导航栏

文件列表

由于 Fyne 列表小部件不支持图标和文本的组合,我们需要从基本组件构建一个。在文件组中,我们将每个项目更新为调用一个新函数makeRow(),该函数将在稍后定义。我们向此函数传递文件名,以便它可以加载图像并显示合适的标题:

fileList := widget.NewGroup("directory",
   makeRow("shiny-hall.jpg"),
   makeRow("shiny-hall.jpg"),
   makeRow("shiny-hall.jpg"))

新的makeRow()函数将返回一个包含图像预览和标题文本的水平框小部件。预览图像使用canvas.NewImageFromFile()加载,并使用SetMinSize()设置一个合适的大小。为了在尺寸上保持一致,使用theme.IconInlineSize()作为高度,以及 50%更大的宽度——假设大多数图片是横向的。最后,这在一个水平框中返回,以及一个新的标签小部件,使用widget.NewHBox()

func makeRow(text string, file string) fyne.CanvasObject {
   preview := canvas.NewImageFromFile(file)
   iconHeight := theme.IconInlineSize()
   preview.SetMinSize(fyne.NewSize(int(float32(iconHeight)*1.5), iconHeight))

   return widget.NewHBox(preview, widget.NewLabel(text))
}

在这些更改到位后,你应该会看到每个文件名前都有图标预览的相同界面。在我们完成布局之前,让我们润色一下图像视图,看看我们如何保持图像的宽高比:

图片

添加到界面的占位符文件和图像缩略图

图像视图

要完成图像查看器布局,我们需要查看主图像视图。Fyne 中图像的默认行为是它们将扩展以填充可用空间(这是canvas.ImageFillStretch模式)。然而,我们希望图像保持其宽高比,同时保持在查看区域范围内。我们还将添加一个背景图案,就像在第八章的 Shiny 示例中做的那样,Shiny – 实验性 Go GUI API

首先,我们为背景图案创建一个新的图像。Fyne 提供了一个名为canvas.NewRasterWithPixels()的辅助方法,用于创建一个动态绘制的图像。它接受一个参数,即返回请求像素的color.Color值的像素计算函数。其参数是x, y, width, height(所有int变量)。这意味着我们可以仅使用xy坐标,或者我们可以根据宽度和高度值(指定每个轴上的像素数)进行计算。

在我们的检查器图案实现中,我们简单地返回浅灰色或深灰色以形成正方形图案。这些块的大小为 10 x 10 像素,我们计算像素坐标位于哪个正方形内,如下所示:

func checkerColor(x, y, _, _ int) color.Color {
   xr := x/10
   yr := y/10

   if xr%2 == yr%2 {
      return color.RGBA{0xc0, 0xc0, 0xc0, 0xff}
   } else {
      return color.RGBA{0x99, 0x99, 0x99, 0xff}
   }
}

检查器图案图像是通过将我们的checkerColor函数传递给canvas.NewRasterWithPixels()函数创建的。现在,这个变量可以像任何其他canvas.Image类型一样使用:

checkers := canvas.NewRasterWithPixels(checkerColor)

此外,主图像视图应设置为在可用空间内保持其宽高比。为此,我们将image变量的FillMode字段设置为canvas.ImageFillContain。类似于 CSS3 定义,这将使图像在空间内以最大缩放尺寸居中:

image := canvas.NewImageFromFile("shiny-hall.jpg")
image.FillMode = canvas.ImageFillContain

最后,我们将检查器图案图像添加到我们的布局中。通过将其传递到主图像对象之前,我们指定它在绘制顺序中位于较低层,因此被设置为背景。请注意,任何未明确指定在边框位置中的项目都将调整大小以填充剩余空间。这样,我们的图像视图就在背景之上绘制,并且两者都被设置为填充边框小部件内的空间:

container := fyne.NewContainerWithLayout(
   layout.NewBorderLayout(navBar, nil, fileList, nil),
   navBar, fileList, checkers, image,
)

使用这些更改更新代码将产生完成的图像查看器布局,其外观应如下所示:

图片

在棋盘图案上居中图像

与 GUI 进行通信

要添加处理 GUI 更新和响应用户事件的代码,我们需要保存对已创建的一些小部件的引用;主要是widget.Label工具栏和主视图canvas.Image。通过存储这些引用,我们可以在以后更新它们的内容。

此外,我们将在访问的目录中添加一个 []string 列表 images,并保存当前图像的 int index,以便我们可以计算上一个和下一个。一旦这些创建完成,我们就可以填写 previousImage()nextImage() 函数的内容,以调用一个新的 chooseImage() 函数来更新显示:

var images []string
var index int

var image *canvas.Image
var label *widget.Label

func previousImage() {
   if index == 0 {
      return
   }

   chooseImage(index-1)
}

func nextImage() {
   if index == len(images)-1 {
      return
   }

   chooseImage(index+1)
}

chooseImage() 函数从稍后将要加载的图像列表中访问文件路径,并使用这些信息来更新我们的用户界面。从 path,我们调用 label.SetText() 来显示文件名,然后设置 image.File 以更新主图像显示的路径:

func chooseImage(id int) {
   path := images[id]
   label.SetText(filepath.Base(path))
   image.File = path
   canvas.Refresh(image)
   index = id
}

为了最简单地实现点击处理行为以从列表中选择图像,我们将从 widget.Label 更改为 widget.Button 项目。由于按钮具有不同的颜色背景,我们应该使用 layout.BorderLayout 来整理显示,以便按钮填充可用空间。最后,因为按钮比标签高,我们更新 minSize() 预览代码,使其相对于按钮的最小高度,而不是之前由主题定义的行内图标大小:

func makeRow(id int, path string) fyne.CanvasObject {
   filename := filepath.Base(path)
   button := widget.NewButton(filename, func() {
      chooseImage(id)
   })

   preview := canvas.NewImageFromFile(path)
   iconHeight := button.MinSize().Height
   preview.SetMinSize(fyne.NewSize(int(float32(iconHeight)*1.5),
      iconHeight))

   return fyne.NewContainerWithLayout(
      layout.NewBorderLayout(nil, nil, preview, nil),
      preview, button)
}

接下来,我们需要添加一个 getImageList() 函数,该函数将访问目录中的图像列表。此函数的内容与第八章 Shiny – Experimental Go GUI API 中相同的函数相同,因此为了简洁起见在此省略。有了这个,我们可以更新我们的 makeList() 函数,它现在接受一个 dir 参数,以加载图像文件列表并使用 makeRow() 创建新行,以及填充我们存储的 images 列表:

func makeList(dir string) *widget.Group {
   files := getImageList(dir)
   group := widget.NewGroup(filepath.Base(dir))

   for idx, name := range files {
      path := filepath.Join(dir, name)
      images = append(images, path)

      group.Append(makeRow(idx, path))
   }

   return group
}

然后,我们更新 main() 函数中 fileList 的创建,以传递要加载的目录路径:

fileList := makeList(dirpath)

与之前的 GoImages 代码一样,我们可以使用内置的 flag 处理来允许用户指定要显示的目录。代码在此列出,我们可以通过将前面的 dirpath 变量设置为 parseArgs() 的结果来调用它(如果您添加此代码,请记住导入 flagfmtos 包):

func parseArgs() string {
   dir, _ := os.Getwd()

   flag.Usage = func() {
      fmt.Println("goimages takes a single, optional, directory parameter")
   }
   flag.Parse()

   if len(flag.Args()) > 1 {
      flag.Usage()
      os.Exit(2)
   } else if len(flag.Args()) == 1 {
      dir = flag.Args()[0]

      if _, err := ioutil.ReadDir(dir); os.IsNotExist(err) {
         fmt.Println("Directory", dir, "does not exist or could not be read")
         os.Exit(1)
      }
   }

   return dir
}

更新所有前面的代码应该会导致我们的完整图像查看器应用程序。如果您想访问完整的代码,可以从本书的源代码仓库在 GitHub 上下载:

我们完成的图像查看器显示了壁纸目录

与之前的 GoMail 示例一样,我们可以通过在命令行环境中指定 FYNE_THEME=light 来使用浅色主题加载此界面:

与使用 Fyne 浅色主题的相同应用程序和目录

背景处理

使用 Fyne 时,许多图像处理已经在多个线程上进行了处理,但这可能对于图像密集型应用来说还不够。在这个 GoImages 应用中,在用户界面显示之前,有大量图像正在被加载。我们可以更新图像处理,以便让 GUI 显示得更快。为此,我们再次创建一个新的 asyncImage 类型,在显示之前在后台线程上加载图像。与 Shiny 直接将图像传递给渲染器不同,在这里,我们向 canvas.Image 对象提供它们,因此代码略有不同。

我们首先创建基本的 asyncImage 类型——其主要工作在 load() 函数中,该函数将在后台线程上运行。loadPath() 函数设置要加载的文件的路径并开始后台处理。请注意,一旦我们更改了图像数据,我们需要调用 canvas.Refresh() 来确保界面更新——由于 Fyne 会为我们处理,因此不需要任何线程处理代码:

type asyncImage struct {
   path   string
   image  *canvas.Image
   pixels image.Image
}

func (a *asyncImage) load() {
   if a.path == "" {
      return
   }
   reader, err := os.Open(a.path)
   if err != nil {
      log.Fatal(err)
   }
   defer reader.Close()

   a.pixels, _, err = image.Decode(reader)
   if err != nil {
      log.Fatal(err)
   }

   canvas.Refresh(a.image)
}

func (a *asyncImage) loadPath(path string) {
   a.path = path
   go a.load()
}

由于这个异步图像加载器将提供原始图像数据给图像小部件,我们还需要实现 image.Image API。在每个方法中,我们检查 pixels 变量是否已设置(在图像加载之前将是 nil),返回适当的值或合理的回退:


func (a *asyncImage) ColorModel() color.Model {
   if a.pixels == nil {
      return color.RGBAModel
   }

   return a.pixels.ColorModel()
}

func (a *asyncImage) Bounds() image.Rectangle {
   if a.pixels == nil {
      return image.ZR
   }

   return a.pixels.Bounds()
}

func (a *asyncImage) At(x, y int) color.Color {
   if a.pixels == nil {
      return color.Transparent
   }

   return a.pixels.At(x, y)
}

最后,我们的 asyncImage 类型将受益于一个便利的构造函数来设置将要渲染的 image 小部件。我们还开始在后台线程上加载第一个图像文件,path

func newAsyncImage(path string) *asyncImage {
   async := &asyncImage{}
   async.image = canvas.NewImageFromImage(async)
   async.loadPath(path)

   return async
}

为了完成异步图像加载器的使用,我们更新了 chooseImage() 函数以设置新的路径。通过这个更改,应用程序将在后台线程上加载所有图像,而不是在主循环上。Go 将适当地分布到我们的处理器上,以利用可用的 CPU:

func chooseImage(id int) {
   path := images[id]
   label.SetText(filepath.Base(path))
   async.loadPath(path)
   index = id
}

运行这个新版本的应用程序将加载得更快。你也会看到,随着每个文件的加载完成,图像将出现。通过在加载每个图像后使用简单的 canvas.Refresh() 调用,我们确保用户界面适当地更新。

摘要

在本章中,我们探讨了本书要探索的最后一种工具包,Fyne。我们学习了它是如何专门为 Go 创建的,以便于构建图形应用程序。我们很快设置了工具包,并探讨了如何构建在 macOS、Windows 和 Linux 上运行完全相同的应用程序。

我们探讨了 Fyne 工具包的架构及其使用矢量图形提供可伸缩的图形界面。通过学习 layoutcanvaswidget 包的功能,我们看到了如何快速构建基本用户界面。我们还看到了 Fyne 提供的两个不同的主题,浅色深色,它们将根据用户设置或环境变量来使用。

应用这一知识,我们构建了 GoMail 应用的第六版,其中包含了内置的材料设计图标,并避免了任何线程处理复杂性。我们还通过重新构建第八章中设计的 GoImages 应用,即“Shiny – 实验性 Go GUI API”,探索了图像 API 和后台处理能力。

现在我们已经探索了可用的主要工具包,我们将转向第四部分,“增长和分发您的应用”。在这本书的最后部分,我们将关注所有图形应用都适用的主题,无论使用哪种工具包。我们将探讨有助于润色和分发完整图形用户界面的主题,从第十一章开始,*“导航和多窗口”。

第四部分:增长和分发你的应用程序

在第二部分“使用现有小部件的工具包”和第三部分“现代图形工具包”中,我们详细研究了使用 Go 语言构建图形应用程序最流行的工具包。每个框架都有不同的背景和愿景,并且许多在支持的平台上有所不同。你可能已经对哪个 API 最适合你的下一个应用程序有了强烈的想法,但无论你计划使用哪种技术,在构建和管理不断增长或复杂的图形应用程序时,还有许多其他事情需要考虑。

在本节中,我们将探讨与早期章节中探索的比那些更实质性的 GUI 相关的各种主题。我们将涵盖在设计更复杂的图形界面时需要考虑的内容,以及如何在不同的平台上管理它们。当应用程序连接到现代云和分布式服务时,并发和网络编程通常是一个挑战,因此我们将探讨如何使用 Go 语言和标准库将这些集成到应用程序中。

在本节的末尾,我们将转向管理随着增长而发展的代码和应用程序。我们将介绍开发图形应用程序的最佳实践以及它们如何应用于 Go 语言。最后,我们将为我们的应用程序准备分发,探讨跨平台开发的益处如何在我们要部署软件时导致复杂化。

本节中的章节如下:

  • 第十一章“导航和多窗口”

  • 第十二章“并发、网络和云服务”

  • 第十三章“Go GUI 开发的最佳实践”

  • 第十四章“分发你的应用程序”

第十一章:导航和多个窗口

在过去的七个章节中,我们探讨了如何使用不同的工具包和技术构建相对简单的图形用户界面。在每个例子中,我们都看到了小部件和 API 设计的优势,但也看到了在选择工具包时经常面临的挑战。在本章中,我们将重点转向更复杂 GUI 的计划和实施——无论选择哪个工具包和技术,都会遇到的挑战。

在本章中,我们将涵盖以下主题:

  • 规划更复杂 GUI 的工作流程

  • 窗口管理和通知,以提供干净的用户体验

  • 操作系统特定的细节以及如何适应跨平台应用程序

在本章结束时,你将检查更复杂图形应用程序中的应用流程和导航的更大图景问题。你将思考你的 GUI 如何适应当前平台的流程,以及如何适当地通知或吸引用户。因此,让我们从查看复杂应用程序的导航开始。

规划应用程序布局

规划大型图形应用程序可能是一项艰巨的任务,如果你在考虑你软件的不同用户以及它将在哪些不同的设备上使用。或者,如果你的雄心可以通过使用标准设计应用程序(如 Qt Creator 或 Glade for GTK+)来实现,这些应用程序可以从拖放界面生成代码,那么它可能看起来相当直接。不出所料,为你的应用程序界面创造一个出色的用户体验可能需要一段时间来探索、规划和设计以获得最佳结果。最大的复杂性可能是导航或应用程序的整体布局——我们将首先查看跨多个平台的布局技术。

标准布局

我们所探讨的每个工具包都提供标准的布局组件,有助于以整洁、标准化的方式组织小部件和界面元素。然而,当你考虑这些布局的命名时(例如,VBox、Border 和 Frame),它们通常描述的是细粒度控制,而不是更高层次的设计概念。对于本节,我们需要稍微退后一步,思考应用程序的整体流程,应用程序最常用的部分是什么,以及用户首次加载用户界面时应该看到什么。

如第二章“图形用户界面挑战”中所述,桌面应用程序已经围绕一个相当常见的布局进行标准化:菜单、工具栏、调色板和内容。随着应用程序变得更加复杂,人们试图将更多功能和特性放入这个空间,以便用户能够访问这些越来越强大的应用程序的全部功能集。随着智能手机和移动应用程序的普及,关于如何更好地利用屏幕空间以及如何利用有限的资源创造出色的用户体验的讨论也很多。

关于屏幕使用、内容可读性及相关主题的研究现在在网页设计方面也很普遍。例如,如何眼动扫描影响 UX 设计中的视觉层次这类主题常常出现在那些希望提高网站可用性或用户保留率的推荐阅读列表上。考虑到这一点,作为桌面或跨平台应用程序的创作者,我们可能需要更加仔细地思考我们的软件是如何呈现的,以及传统方法是否适合我们的特定用例。

一些 GUI 工具包开始提供更高层次的布局,这些布局反映意图而不是静态视觉布局。例如,当空间可用时,Apple 的 iOS 中的UISplitViewController会并排显示列表视图和详细视图,或者当屏幕较小时(当列表被点击时,详细视图会滑动出来):

图片

用于 iPad 设置应用的 iOS 分割视图

(图片版权:Apple)

图片

iPhone 上的相同分割视图

(图片版权:Apple)

在可能的情况下,应在您的应用程序中使用这些元素,以便您的界面根据当前设备进行适当配置,但可能需要进一步的定制。让我们看看影响更复杂布局设计的一些因素。

设备形态因素

虽然主要考虑的是移动和现代便携式设备,但应用程序布局应考虑到设备的物理设计。桌面上的视觉和交互技术在过去 25 年中基本保持一致,导致标准化了界面设计——但这也在发生变化。现在,计算机更常见地配备超高分辨率屏幕,其中许多也是触摸表面。如果您的方法是跨平台应用程序开发,包括移动设备,您需要考虑屏幕尺寸以及设备的方向。

当然,屏幕并不是唯一需要考虑的因素;现在输入设备的变化也很常见。鼠标和键盘可能是用户输入的主要配置,但许多便携式电脑现在都有平板模式,其中触摸输入可以替代鼠标,并且可以出现虚拟键盘进行文字输入。如果你想要支持这种配置,那么你的布局需要在屏幕的一部分(可能是底部边缘)被虚拟键盘覆盖时仍然有效。更重要的是,基于手指的输入(通常称为多点触控)比点击和点选的方法要丰富得多;你的应用程序是否旨在支持捏合缩放触摸旋转?如果你打算支持这些类型的特性,那么请确保你选择的框架支持输入手势。每个工具包都在快速发展,因此,为了避免出版时过时,本书中省略了这些细节。

除了完整的设备功能外,传统的桌面应用程序可以通过许多用户操作进行移动和调整大小或方向——你的设计是否旨在适应这些配置的变化?当在平板设备上并排显示应用程序时,你会看到当正确实现时,不同的布局可以有多么强大。

响应式或自适应设计

为了能够适应广泛的设备,通常不可能使用单一的用户界面设计。因此,可能需要对当前环境进行某种形式的适应。关于适应屏幕和设备功能的变化,有两种主要的思想流派:响应式和自适应设计。两者都旨在提供一个适合当前设备的适当用户体验。虽然这些原则目前主要指的是网络应用程序设计,但它们也可以应用于原生应用程序,尤其是当设计针对多个平台时。

当遵循适应性原则时,应用程序设计师会选择一个有限的设备配置集来设计,通常是一个移动设备(可能具有多个方向),一个平板设备,和一个常规桌面。通过为这些类别设计,为特定设备设计所花费的时间将减少,只需关注这些定义的配置。当专注于少数几种变化时,设计可以针对这些不同的用例进行优化,从而创造一个非常流畅的体验。当以这种方式实施时,应用程序将检测其正在运行的设备类别,并加载相应的布局(有时这由框架自动处理)。这种方法已被苹果的 iOS SDK(用户界面为 iPhone、iPhone Plus 和 iPad)所证实,正确的用户界面将被加载,单个应用程序可以以任何配置执行。适应性方法的局限性在于,中间设备,即比预期小或大的设备(或具有不寻常的配置),可能不会以用户期望的方式看起来或运行。

相比之下,响应式设计旨在定义一个单一的用户界面,使其能够响应当前设备的配置。以这种方式创建的布局通常会有某些触发值或拐点,这些值或拐点决定了元素的可见性或始终应显示的项目(在 CSS 中,这通常是通过媒体查询来实现的)。响应式技术在设计旨在为各种不同设备提供良好体验的网站中越来越受欢迎;它可能无法提供适应性设计所追求的完美用户界面,但它确实为从最小、最不具能力的设备到完整的桌面体验的每一款设备都提供了支持。这种方法很好地映射到跨平台方法,这可能是使用 Go 开发应用程序 GUI 的团队意图的一部分,因为我们通常不知道我们的软件将在哪些设备上运行。

本书探讨的工具包为响应式布局提供了一定程度的支持。根据可用空间和屏幕布局来布局内容的网格是一个良好的起点。一些工具包正在进一步探索这个领域,通过提供能够正确适应当前设备的语义布局:

图片

三种不同尺寸的可能响应式布局示例

自定义布局

您的应用程序可能具有与标准布局不同的要求,或者比工具包的 API 支持的更复杂。尽可能使用标准布局是推荐的,但这并不意味着必须放弃您理想的应用程序设计。每个工具包都以某种方式支持自定义布局,您可以利用它来填补可用标准布局的空白。以这种方式实现自定义布局时,基本方法是设置父容器内每个组件的固定位置。为了更好地适应可用的屏幕空间,请记住考虑界面的当前宽度和高度;使用比率或百分比而不是绝对值(例如,列表组件是屏幕宽度的 25%或内容列是可用空间的 1/3)通常更容易实现。

在更高级的工具包中,还有一个基于约束的布局可用,它根据配置的方程而不是硬编码的值来布局组件。约束布局的标准算法名为 Cassowary,并在overconstrained.io/上进行了全面文档记录。基本原理是每个布局值都可以定义为形式为item1.attr1 = item2.attr2 × multiplier + constant的方程的结果(例如,button.top = content.bottom × 1.0 + 25)。基于约束的布局(也称为自动布局)在 iOS 和 Android 中是标准的,但在桌面工具包中并不常见。现代 GUI 通常包括这项功能作为标准,但在 Qt 和 GTK 等其他工具包中使用此功能将需要集成第三方项目(因为这些目前无法通过 Go 绑定获得,因此集成超出了本章的范围):

图片

使用 Xcode 向标签添加约束(图片版权:Big Nerd Ranch, LLC)

导航您的应用程序

在复杂的应用程序中实现一致且易于遵循的导航是困难的,但做得好可以显著提高用户体验。典型的应用程序有一个核心功能集——这些功能应该始终容易访问——以及一个更大的辅助工具和功能集,这些工具和功能使用频率较低。在核心功能和附加功能之间保持平衡是许多应用程序尚未解决的问题。过于拥挤的工具栏和非常长的菜单是我们经常遇到的问题:

图片

在 Microsoft Office 2007 中,菜单、工具栏、快捷方式和下拉列表都位于文档上方

寻找限制屏幕上各种选项的方法应该会导致用户困惑减少。应用设计师的责任是创建一个清晰且易于使用的界面,而不是展示所有选项并期望用户学习如何导航。调整用户界面以关注基本或最有用功能的方法有很多;我们将在以下章节中探讨它们。

渐进式披露

在较大的应用程序(如之前所示),可能无法轻松地在最小用户界面中展示所有可用功能。在这种情况下,应用设计师将面临一个新的挑战,即为新用户提供平滑的学习曲线。如何设计一个应用程序的用户界面,既能满足复杂的功能集,又易于产品新手学习?

渐进式披露是一种帮助集中用户注意力并促进学习复杂系统的技术。这是通过隐藏对完成当前任务不必要的功能或数据来实现的。通常,这是通过从可见的基本功能集开始,并在用户探索超出基本范围的区域时扩展来实现的。扩展可用功能的触发器可能包括访问新的菜单项、高级按钮,或者简单地使用应用程序一段时间或添加足够的数据。

示例 1 – Microsoft Edge

流行网络浏览器的一个标准功能是它们包含的开发者工具,但这不是核心功能。当构建新的网络浏览器 Edge 时,微软决定专注于核心功能,将更高级的功能,如开发者工具,隐藏在普通用户之外。

如果通过单个菜单项(...菜单,然后按 F12 开发者工具)或F12键盘快捷键启用,开发者功能将被激活。从那时起,浏览器将在所有预期位置展示开发者功能,创建一个功能更丰富的应用程序功能集:

默认 Edge 上下文菜单

F12 或开发者工具菜单项:

在启用开发者工具后

示例 2 – Skyscanner 航班搜索

Skyscanner 服务的主要功能(www.skyscanner.net/)是搜索众多航班,根据价格、时间、位置等标准进行匹配。在(最初看起来最小的)移动应用中找到合适的旅程后,用户通常会通过预订流程购买他们的航班。

如果用户还没有准备好购买,应用程序用户可以将航班组合收藏起来以备后用。完成此操作后,应用程序中会出现一个菜单,用户可以从中返回收藏的搜索。如果这些搜索被访问多次,则会出现另一个新功能,称为关注的航班,允许更高级的假日规划者监控搜索价格的变动。

菜单和工具栏

当您希望向应用程序用户提供许多项目时,菜单或工具栏是方便的选择,但应谨慎且适度地使用。将太多选项添加到工具栏可能会增加类似于微软 Office 的杂乱无章的功能区用户界面,如之前所示。同样,为每个功能添加菜单项可能会导致认知过载,因为用户会花费太多时间试图找到项目或记住它们的位置。

工具栏

工具栏非常适合用于日常操作。例如,在我们的电子邮件应用程序中,回复将是一个常用的操作,同样还有新建和删除。通过将工具栏项目按相似性分组(例如,回复和全部回复或剪切、复制和粘贴),可以帮助用户快速找到您的快捷方式。按使用频率对这些组进行排序意味着,如果用户界面小于预期,则最有用的功能仍然可见。为了提供无法放入屏幕的工具的访问权限,您可以使用溢出项(其中弹出不可见项目的列表),使工具栏可滚动,或在不同的地方提供访问权限,例如菜单或第二行。

工具栏在大多数设备形态中都很受欢迎,从小型手机到大型桌面应用程序。主要区别是可包含的项目数量。如果您在响应式或自适应布局中包含工具栏,您可能需要考虑比最左侧可见更复杂的策略。例如,在电子邮件应用程序中,如果只能显示五个项目,那么删除全部回复以便显示删除可能是一个好方法;用户可以在按下回复后,在撰写窗口中选择全部回复。

许多应用程序选择允许工具栏中的项目重新排列,这是某些图形工具包提供的一项功能(例如,苹果 iOS 设备的 UIKit 和桌面应用程序的 AppKit):

图片

macOS 中自定义工具栏的示例用户界面(图片版权:苹果)

菜单

菜单栏传统上被认为是放置应用程序中所有其他缺失功能访问的最佳位置。随着菜单标题中项目数量的不断增加,应用程序菜单很快就会变得难以导航。这通常被认为是由于平均人类在短期记忆中可以保持的项目数量。乔治·米勒在 1956 年的实验表明,通常可以保持在工作记忆中的项目数量在五到九之间(七加减二——米勒的“神奇数字”)。更近期的估计显示,这个数字通常更低,接近四到五个项目。

考虑到这一点,我们可以理解为什么保持菜单数量少且简短很重要——在导航的每一步,应该不超过九个选项,如果可能的话,则更少。

菜单的呈现方式因平台而异,标准的是窗口顶部或屏幕顶部,在桌面应用中最为常见,而在移动布局中,屏幕左上角的图标(被称为汉堡菜单)最为普遍。无论布局如何,你都会注意到顶级列表大约有五个项目,如果需要更多选项,下拉菜单不会比这更长。如果你发现你的菜单变得过长,也许可以考虑任务特定的工具栏或其他与上下文相关的快捷方式分组,可能从单个菜单项打开。

无论你选择哪种设计来提供功能,请记住用户的主要焦点或当前上下文。

你不希望它们变得如此复杂,以至于每一步都需要进行大量的思考:

图片

(版权所有:Dylan Beattie)Visual Studio 的所有视图和工具栏都已开启

多个窗口

帮助你的应用程序更容易导航的另一种方法是将内容分割成多个窗口。每个窗口都将是你应用程序的不同视图,其中可以展示适合当前上下文的适当工具栏或菜单。正如我们的 GoMail 示例所示,我们在单独的窗口中编写新消息;这允许我们将与编辑相关的项目分组在输入字段附近,并允许我们同时草拟多个消息,而不会使我们的电子邮件浏览窗口变得杂乱。

当然,不同的平台上的多个窗口不一定具有相同的语义。如果我们在一个智能手机设备上采取同样的方法,将合成窗口作为现有应用的叠加显示是很常见的。用户是否可以在该模式和浏览窗口之间切换,可能取决于操作系统的设计,或者我们可能决定,在较小的设备上运行时,合成窗口应该是一个模态窗口(即,它阻止对父窗口的访问。这一点将在稍后进一步讨论)。

我们在第二章,图形用户界面挑战中看到的一种另一种方法是为主应用程序的焦点提供多个窗口。这些外围窗口通常是工具栏或主窗口中内容详细信息/控制的详细信息,在基于内容创建的应用程序中尤其有帮助。随着内容扩展以填充整个主窗口,工具栏和上下文操作已被放置在单独的窗口中,以保持用户专注。这种布局和导航通常在专业应用程序中采用,其中用户对领域非常了解。这种方法的附加复杂性可能导致产品早期用户感到困惑,因此作为应用程序的设计师和开发者,我们应该注意我们在用户界面中呈现的窗口数量或不同的布局和上下文。

窗口类型和保持整洁

管理多个窗口对于任何应用程序都将成为必要,无论是通过像前面那样的布局设计的一部分,还是为了向用户展示重要信息以吸引他们的注意或接收他们的输入。对于您应用程序中显示的每个窗口,了解其外观是否应该立即吸引他们的注意,支持屏幕上已有的内容,或者只是可以稍后关注的事情,这一点很重要。了解显示的每个窗口的意图将有助于支持而不是阻碍用户的工作流程,并保持您应用程序的用户体验整洁。

标准对话框

在应用程序流程中显示额外窗口的最常见原因是请求用户输入或确认,或提醒他们(通常是意外的)事件。这些是标准交互,因此通常最有效的方法是尽可能使用所使用的工具包提供的对话框窗口。使用提供的 API 通常将提供最一致的用户体验,并且几乎肯定会导致您的应用程序中的代码更少。

工具包提供的标准对话框类型通常包括文件处理(打开和保存)、进度(当用户必须等待时)、消息(显示警告或错误)以及确认对话框(立即提问)。在更高级的工具包中,您还可以期待找到用于颜色选择、字体选择、文档打印甚至标准关于窗口的对话框 API。以下 API 是开始使用本书前面提到的某些工具包的绝佳起点(如果它不是默认的,则包括命名空间):

遍历 andlabs UI GoGTK qt Fyne
打开 ShowOpen OpenFile NewFileChooserDialog widgets.NewQFileDialog
保存 ShowSave SaveFile NewFileChooserDialog widgets.NewQFileDialog
进度 widgets.NewQProgressDialog dialog.NewProgress
消息 MsgBox MsgBoxError NewMessageDialog widgets.NewQMessageBox widgets.NewErrorMessage dialog.ShowInformation dialog.ShowError
确认 widgets.NewQMessageBox dialog.ShowConfirmation
输入 widgets.NewQInputDialog
颜色 widgets.NewQColorDialog
字体 FontSelection widgets.NewQFontDialog
打印 printsupport.NewQPrintDialog
关于 NewAboutDialog
自定义 NewDialog NewDialog widgets.NewQDialog dialog.ShowCustom

有时显示一个小型的选择或确认窗口,而不在先前的列表中(要么是因为你的需求不同,要么是因为工具包尚未实现该功能)。这可以通过创建一个新窗口,打包内容并显示它来实现,但推荐的方法是使用自定义对话框 API。显示对话框而不是标准窗口允许工具包将窗口配置为最佳效果。这通常涉及将其设置为不可调整大小的、最顶层的模态窗口(意味着用户在对话框关闭之前不能与下面的窗口交互)。

模态窗口

如前所述,模态窗口是一种阻止用户向其上方的窗口输入的窗口。这通常意味着它被放置在父窗口的中心,下面的窗口将被禁用或变灰,将用户交互集中在新的对话框窗口上。这是对话框窗口的典型特征,因为它们设计为仅在用户无法继续当前任务(直到信息、确认或进度完成)时出现(此时对话框消失,控制权返回)。

为了以这种方式运行,模态窗口通常会被传递一个父窗口,该窗口应该被遮挡。输入将被强制导向新的模态窗口(根据桌面环境和配置,如果父窗口当前不是最顶层应用程序,则可能不会这样)以与新的界面交互。不同的平台可以使用各种样式来显示模态窗口;一些看起来像普通窗口(通常禁用最大化和小化按钮),其他则将内容嵌入到当前窗口中,还有一些(如 macOS)可以从父窗口的标题栏中显示它们。使用内置的 API 进行自定义对话框将意味着这些视觉风格在你的应用程序中是一致的。

然而,可能存在某种原因,使得你的应用程序需要在不同的工作流程中打开一个新窗口以获取焦点。在这些情况下,工具包通常允许直接设置窗口的模态。这可能是一个强大的功能,但请确保考虑是否没有更好的 API 来管理此流程,或者是否自定义对话框窗口可能更适合。

窗口提示

窗口的属性(在许多系统中被称为提示)允许应用程序向操作系统指示某些期望的属性,这将控制窗口的展示方式。由于各种原因(包括用户体验甚至安全性),在大多数情况下,应用程序不能强制规定窗口如何以及何时展示;因此,重要的是要记住,这些提示可能不会被强制执行。

尺寸

最常见的提示设置与大小相关——即用户界面应占用的最小和可能的最大尺寸。最小尺寸几乎在所有图形系统中都得到支持;每个图形系统都会尽量允许应用程序使用它声称需要的空间。但是,请确保尺寸合理,因为,尤其是当针对多种不同的平台类型时,最小尺寸不应大于可用的屏幕!为了避免这种情况,操作系统可能会显示比请求的更小的应用程序窗口,这样用户就不会遇到访问屏幕外用户界面部分的问题。仅出于这个原因,建议设置的最低尺寸确实是 GUI 能够正确运行的最低尺寸。

为了确保应用程序在首次加载时比例正确,通常设置一个默认或首选的大小(通常通过SetDefaultSize()SetSize()Resize()实现)。这意味着,尽可能多的情况下,应用程序将以一个合理的尺寸加载,但如果屏幕不够大,则可能更小。在我们的新 GoMail电子邮件编写窗口的例子中,我们可以设置一个良好的默认值,以便在保持合理的最小尺寸(可能由工具包计算得出)的同时,允许访问所有输入字段。

此外,为您的窗口设置一个最大尺寸可能也有帮助;虽然不像最小或默认尺寸那样常用,但这可能很有帮助。最常见的情况是,如果您希望窗口保持较小(例如工具箱或信息窗口)或仅向一个方向扩展(仅允许高度调整,或将最小和最大宽度设置为相同的值)。在展示自定义对话框时,将最小和最大尺寸设置为相同的值也很有帮助,因此请求窗口为固定大小。一些工具包通过提供SetFixedSize()函数使这变得更容易。

其他提示

可以在窗口上设置许多其他类型的属性或提示,以帮助用户在应用程序中导航。根据工具包的不同,可能可以设置窗口类型。这通常在显示对话框窗口时自动处理(因为它们可以具有特殊属性),但对于其他类型的窗口创建可能不会处理。请检查您选择的工具包中的窗口 API,以查看您是否可以在创建工具箱窗口或信息面板等应属于父窗口的窗口时设置窗口类型。

最有指导意义的窗口提示是在任务栏、应用程序切换器以及可能窗口边框中显示的图标。在某些系统中,默认图标是应用程序的图标,而在其他系统中则是特定于窗口的图像。通常,将相同图标设置在您的应用程序窗口和应用程序图标上是一个好主意,这可能由您使用的工具包处理。为窗口设置不同的图标应仅保留在窗口服务于与主窗口不同目的时。为了避免用户混淆,与主图标不同的窗口图标应通过样式或内容表明它们与哪个主图标相关。设置窗口图标通常是通过调用window.SetIcon()widget.SetWindowIcon()(对于顶级小部件)来实现的。在某些情况下,application.SetDefaultIcon()可能允许您通过单个调用为所有窗口设置图标。

设置应用程序图标是平台特定的,并在第十四章,分发您的应用程序中进一步探讨。重要的是要意识到,一些系统允许加载自定义图标主题。在这种情况下,自定义应用程序或窗口图标可能对用户来说不太熟悉,因此您可能考虑在应用程序本身中包含一些品牌元素。

通知和任务状态

如我们之前所见,大多数工具包 API 提供方便的方式来打开对话框窗口,显示信息、进度报告和错误以吸引用户的注意。然而,权力越大,责任越大;除非真的有必要,否则不要打断工作流程。想想看,你不得不等待或关闭的许多“文件下载完成!”或“请等待下载更新...”对话框窗口,你就会明白正确的方向。

那么,解决方案是什么?让我们看看如何以不太妨碍的方式向用户呈现非关键警报或后台进度。

小型警报

应向用户呈现的许多消息可能并不重要,因此可能不需要中断他们的流程。信息可能不足以证明显示另一个窗口,或者可能是基于时间的,因此如果应用程序不活跃,当用户回来时可能就不再相关。大多数操作系统中的更好方法是通知区域。

最初作为系统托盘的一部分呈现,通知以气泡或呼出区域的形式显示,如果用户准备好分心,则可以吸引他们的注意力;否则,可以忽略。更现代的呈现方式是在通知区域(可能并不总是可见)中放置所有应用程序通知。这提供了一种方法,可以在更方便的时间对所有消息进行分组处理。通常显示预览,因此保持消息简短是一个好主意。

可以使用我们讨论过的许多框架的内置 API 来创建通知,例如 Qt 中的NewQSystemTrayIcon().ShowMessage()或 Walk 中的NewNotifyIcon().ShowMessage()。使用这些函数通常会在当前平台的默认通知区域显示通知,从而提供一致的用户体验。然而,这意味着即使在旨在跨多个平台完全一致的用户界面工具包中,此代码的行为也可能不同,因此在编写文档时务必测试所有支持的平台,并牢记这些差异:

图片

通知出现在 Windows 10 的操作中心(图片版权:微软)

背景进度

Qt、Fyne 和其他提供的进度对话框窗口非常适合显示用户还需要等待多长时间才能完成关键过程(例如打开大文件、缓冲电影或从网站加载数据)。它们不适合报告用户在应用程序中继续任务时可以运行的任务的进度。为此,我们应该考虑在应用程序或屏幕的其他位置显示信息。

许多系统开始添加在标准区域报告任务进度的支持,例如前面描述的通知空间。不幸的是,这还没有在足够的操作系统上普及,以至于我们探索的工具包可以提供支持;因此,我们必须使用另一种方法。对于此类后台进程,有两种常见的策略来传达任务进度,这可能取决于您是否期望同时运行多个任务。

对于可能运行单个后台任务的应用程序(例如运行构建的 IDE 或与服务器同步的任务列表),传统的做法是在应用程序的某个位置嵌入进度条。这将显示在后台任务开始时,并在完成后消失。这样的视觉提示通常位于状态栏或其他用户通常寻找辅助信息的信息区域。

如果您的应用程序经常需要运行多个后台事件,例如文件下载或图像转换,那么在任务运行时通常会看到一个新窗口或工具面板出现。这个用户界面新增功能通常会列出所有正在进行的任务及其进度,并在完成后隐藏它们。由于这些对当前工作流程不是关键,因此很重要的一点是不要在当前工作之上显示这些元素——实际上,一些应用程序(如苹果的 Safari 网络浏览器)甚至不会显示此窗口,除非用户请求有关下载进度的详细信息:

图片

Safari 中的下载列表默认情况下不可见,但如果请求将提供详细信息(图片版权:苹果)

平台特定考虑事项

尽管大多数小部件工具包在它们支持的平台上工作方式相似,但在操作系统行为上仍有一些差异。这些差异可能是由于希望与竞争对手区分开来,或者认为他们的方法提供了更好的用户体验。这些区别应该被考虑进去。在本节中,我们将探讨平台方法中的一些显著差异。

窗口分组

在 Windows 10 的任务栏和大多数 macOS 版本中,一个应用程序的所有窗口都聚集在单个图标下。这使得用户界面不那么杂乱,但也意味着打开许多窗口的应用程序可能更难导航,因为没有简单的图标点击来显示特定窗口。再加上 macOS 和 Ubuntu Linux(或使用 Gnome 桌面的其他发行版)在任务切换器中将窗口聚集在单个图标下的行为(键盘Alt + Tab)。在 Gnome 中,你可以通过按向下箭头键或使用鼠标来探索图标下的窗口,但在 macOS 上,你需要使用替代的键盘组合(Alt *+~)来循环应用程序的窗口。

这种将窗口分组的趋势可能会影响展示多个窗口的应用程序的设计;如果你每次启动应用程序时都打开三个窗口,那么;在打开三个文档之后,你可能会打开九个窗口。在这种情况下最常见的方法是只打开一次支持窗口,使它们的工具或信息假设当前文档的上下文(可能是最顶层的窗口)。然而,这可能会增加你应用程序的复杂性,因为它需要与其他已经打开的软件实例进行通信。

应用程序实例

尽管在现代任务切换器中有些隐藏,但可以注意到一些操作系统更倾向于只打开一个应用程序实例,而不是打开多个(例如,每个文档一个)。在为 macOS 开发应用程序时,建议在任何时候只运行一个副本-尝试运行同一应用程序的第二个实例通常会导致原始窗口被带到前台。如果你的应用程序旨在支持并发实例语义不同的平台,应该花时间决定你的应用程序应该如何表现。你的设计是否会在所有平台上以相同的方式工作,还是适应当前的环境?

要根据平台更改行为,可以在运行时检测操作系统,但通常操作系统是决定性因素,因此您可以使用在第三章中讨论的 Go 内置构建标签,Go to the Rescue!。例如,我们可以有两个不同的文件来控制应用程序的打开方式:launch_darwin.go 将在为 macOS 编译时使用,而 launch_other.go 将在其他平台上运行。设置此示例可能如下所示。

首先,我们创建一个处理标准机制(称为 launch_other.go)的文件;打开文件或新文档将创建一个带有适当文档的窗口并显示它:

// +build !darwin
package main

type app struct {
}

func (a *app) openFile(file string) {
   newWindow(openDocument(file)).Show()
}

func (a *app) openBlank() {
   newWindow(newDocument()).Show()
}

然后,我们为 macOS 创建一个版本(命名为 launch_darwin.go),它首先检查是否有正在运行的实例。如果找到,我们将调用一些 RPC远程过程调用)函数在运行的应用程序中打开文件,否则我们像以前一样加载窗口:

package main

import (
   "log"
   "os"
)

type app struct {
}

func (a *app) openFile(file string) {
   running := getFirstInstance(a)
   if running != nil {
      log.Println("Found running app, opening document", file)
      running.openFile(file)
      os.Exit(0)
   } else {
      newWindow(openDocument(file)).Show()
   }
}

func (a *app) openBlank() {
   running := getFirstInstance(a)
   if running != nil {
      log.Println("Found running app, opening blank document")
      running.openBlank()
      os.Exit(0)
   } else {
      newWindow(newDocument()).Show()
   }
}

启动此应用程序的主函数可能主要是解析命令行参数以确定是否传递了文件名,如下所示:

func main() {
   app := &app{}

   if len(os.Args) <= 1 {
      app.openBlank()
   } else {
      app.openFile(os.Args[1])
   }
}

getFirstInstance() 和 RPC 代码的详细信息超出了本章的范围,但可以在本书的代码仓库中的 chapter11/singleapp 文件夹中找到。这种模型可能被某些工具包支持,但也有项目旨在使其更容易,例如 github.com/marcsauter/single

额外功能

在某些情况下,可能无法在您的应用程序或工具包支持的所有平台上找到等效的行为或用户界面元素。在这些情况下,您可能会发现工具包提供了特定于操作系统的扩展,这些扩展可以在您的应用程序代码中使用。如果您使用这些特定平台的项,您需要确保您的代码在其他目标系统上仍然能够正确运行。这通常由构建约束来处理,如前面的示例,其中某些实现可能没有或减少了功能以匹配最低的共同基数。

这样的平台扩展是 Qt Windows Extras,它提供了任务栏图标进度 API 和提供 跳转列表(来自应用程序图标的快捷方式集)的方法。该项目可以在 doc.qt.io/qt-5/qtwinextras-index.html 找到。

摘要

在本章中,我们探讨了设计和编程更复杂图形应用程序的技术。在查看复杂布局和深层导航结构的原则时,我们比较了常见应用程序如何处理这些挑战,并注意到了要避免的复杂用户界面类型。为了在这些应用程序中提供更丰富、一致的用户体验,我们研究了提供标准对话框窗口并允许开发者配置应用程序窗口以与应用程序工作流程保持一致的 API。

后台任务进度和系统通知应该是可见的,但不能打断用户的操作流程。我们探讨了如何使用工具包小部件和常见技术来提供这种平衡的通信。为了进一步与当前操作系统集成,我们探讨了桌面平台之间的差异以及它们提供的附加功能,以便应用程序可以在保持单一代码库的同时进行跨平台开发。

在下一章中,我们将关注 Go 提供的后台操作和网络功能,以及如何在图形应用程序中有效地使用它们。我们将回到我们的 GoMail 示例,并利用云服务来扩展它们,以提供更丰富的用户体验。

第十二章:并发、网络和云服务

到目前为止,我们一直专注于设计和构建应用程序的图形元素。大多数现代软件如果没有与互联网服务和网络功能稳固的连接就不完整。如果未正确管理,向远程服务添加依赖项可能会影响应用程序的稳定性。并发也是管理与远程服务交互的关键部分;我们需要添加更高级的任务处理来管理这些不同的通信渠道。

在本章中,我们将探讨以下主题:

  • 线程处理和管理用户界面

  • 在你的应用程序中包含远程资源

  • 连接到云服务并处理错误情况

  • 在网络断开时维护用户体验

到本章结束时,你应该能够将远程资源和云服务集成到你的应用程序中。你还将看到如何维护响应式用户界面,尽管这种新功能依赖于可能不可靠或不稳定的网络连接。随着应用程序更新以管理来自多个来源的数据,长时间运行的后台过程和它们可能引起的通信挑战将完全解决。

并发、线程和 GUI 更新

Goroutines 是运行并发操作和后台任务的非常强大的工具,尤其是如果它们是短运行的。随着我们将更多的应用程序逻辑和数据处理移到后台进程,我们需要添加适当的保障措施来确保错误得到处理,并且用户界面保持最新。

管理长时间运行的过程

通常创建 goroutine 是为了在后台任务完成的同时继续代码流程。如果这些任务开始用于应用程序关键任务或处理重要数据,尤其是如果这些任务可能需要很长时间,我们需要更仔细地管理它们。主要考虑的是如何在应用程序退出时优雅地关闭后台任务。这可能看起来不是必需的,对于某些任务可能确实不是,但如果过程涉及数据完整性,我们希望确保提前终止不会引起问题。

信号关闭

为了演示这个问题,让我们从一个简单的 goroutine 演示开始;我们将启动三个线程来打印进度。对于每个线程,我们将打印Started然后是.,直到线程停止,此时将打印Ended

package main

import (
   "fmt"
   "time"
)

func tick() {
   fmt.Println("Started")

   for _ = range time.NewTicker(time.Second).C {
      fmt.Print(".")
   }

   fmt.Println("Ended")
}

func main() {
   go tick()
   go tick()
   go tick()

   time.Sleep(5 * time.Second)
}

如果你运行此代码,你将看到以下输出。线程按预期启动并计时,在5秒超时后,程序退出。没有看到Ended消息:

图片

未终止 goroutine 的输出

如您从这个简单的演示中看到的,goroutines 并没有优雅地终止;它们只是停止运行。如果我们正在编写复杂的数据,向远程服务器发送消息,或等待重要响应,这可能会导致数据损坏或其他意外结果。让我们看看如何在应用程序终止时如何向 goroutines 发出停止信号。

我们首先创建一个简单的名为stop的通道,并将其传递给每个 goroutine。当应用程序准备退出时,我们将向每个线程发出信号,以便它们可以通过关闭此通道来完成工作。我们更新 tick 函数以检查这个新通道是否已关闭,如果是,它将退出。为了在应用程序退出之前允许代码完成,我们必须在main()的末尾添加一个新的暂停,以便进行清理。更新的代码如下所示:

package main

import (
   "fmt"
   "time"
)

func tickUntil(stop chan(struct{})) {
   fmt.Println("Started")

   ticker := time.NewTicker(time.Second).C
   for {
      select {
      case <-ticker:
         fmt.Print(".")
      case <-stop:
         fmt.Println("Ended")
         return
      }
   }
}

func main() {
   stop := make(chan(struct{}))

   go tickUntil(stop)
   go tickUntil(stop)
   go tickUntil(stop)

   time.Sleep(5 * time.Second)
   close(stop)

   time.Sleep(10 * time.Millisecond)
}

运行此代码应显示以下输出,这正是我们最初所寻找的:

图片

使用信号通道,我们的线程可以在程序退出前结束

检查完成情况

上述示例在技术上可行,但依赖于定时器等待线程完成并不可靠。如果线程需要等待响应或正在进行长时间的计算,如果定时器超时,我们仍然有可能出现数据损坏。解决方案是在清理完成后让 goroutine 发出信号。这可以通过sync.WaitGroup或使用另一个通道来实现。

对于我们的完成线程示例,我们创建sync.WaitGroup,并将其传递给每个 tick 线程。在我们启动 goroutine 之前,我们使用wg.Add(1)增加等待线程的数量。一旦每个线程完成,它们就会使用wg.Done()标记。然后,我们的应用程序可以自由地立即在退出前调用wg.Wait(),安全地知道它不会提前终止任何分组的后台进程。

以下代码演示了如何向多个 goroutines 发出信号和等待:

package main

import (
   "fmt"
   "sync"
   "time"
)

func tickAndEnd(stop chan (struct{}), wg *sync.WaitGroup) {
   wg.Add(1)
   go func() {
      fmt.Println("Started")

      ticker := time.NewTicker(time.Second).C
      for {
         select {
         case <-ticker:
            fmt.Print(".")
         case <-stop:
            fmt.Println("Ended")
            wg.Done()
            return
         }
      }
   }()
}

func main() {
   stop := make(chan (struct{}))
   wg := &sync.WaitGroup{}

   tickAndEnd(stop, wg)
   tickAndEnd(stop, wg)
   tickAndEnd(stop, wg)

   time.Sleep(5 * time.Second)
   close(stop)

   wg.Wait()
}

这个输出几乎与上一个版本完全相同,但线程结束的具体时间略有不同:

图片

等待 goroutines 完成而不是等待固定的时间

通过通道进行通信

正如我们在前面的章节中看到的,goroutines 提供了强大而简单的并发操作。大多数这些示例都是生成输出或响应用户请求,但长时间运行的过程通常会产生需要由应用程序利用的数据。在这个例子中,我们看到如何使用通道有效地从多个线程收集数据以聚合和报告。

我们的示例是一个简单的工具,可以获取目录的磁盘使用情况。对于这个目录内的每个元素,我们将启动一个 goroutine(dirSize()),它会计算目录及其包含的文件使用的空间。这个函数通过通道返回结果,这样应用程序就可以在信息可用时使用它:

package main

import (
   "fmt"
   "os"
   "path/filepath"
)

type sizeInfo struct {
   name string
   size int64
}

func dirSize(path string, result chan sizeInfo) {
   var size int64

   filepath.Walk(path, func(_ string, file os.FileInfo, err error) error {
      if err == nil {
         size += file.Size()
      }

      return nil
   })

   result <- sizeInfo{filepath.Base(path), size}
}

reportUsage()函数中,我们为指定目录中报告的每个文件启动尽可能多的 goroutine。然后代码在 goroutine 完成时打印使用结果,使用for info := range result,并在每个结果返回时终止(if results == len(files) {break}),在我们退出之前添加一个简单的总数:

func reportUsage(path string) {
   f, _ := os.Open(path)
   files, _ := f.Readdir(-1)
   f.Close()

   result := make(chan sizeInfo)
   for _, file := range files {
      go dirSize(filepath.Join(path, file.Name()), result)
   }

   var total int64
   results := 0
   for info := range result {
      total += info.size
      fmt.Printf("%s:\t%d\n", info.name, info.size)

      results++
      if results == len(files) {
         break
      }
   }
   fmt.Printf("\nTotal:\t\t%d\n", total)
}

最后,我们添加一个main()函数,它简单地解析参数以初始化reportUsage()函数。如果没有指定参数,我们将为os.Getwd()报告的当前目录报告:

func main() {
   path, _ := os.Getwd()

   if len(os.Args) == 2 {
      path = os.Args[1]
   }

   fmt.Println("Scanning", path)
   reportUsage(path)
}

运行此示例可能立即返回,但如果你在大型目录上调用它,可能需要一些时间才能完成。通过这样做,你可以看到每个打印的输出都会在相关的 goroutine 完成时立即出现,而总数总是最后出现。前面的列表中没有包括在结果截图(可在本书的代码库中找到)中看到的一些样板数字格式化:

图片

报告目录的使用情况;通常较小的项目先出现,因为它们计算得更快

来自 goroutine 的图形更新

与图形界面(在大多数框架中)通信意味着正确管理线程。在先前的示例中,我们可以在dirSize()方法中更新 GUI,例如添加一行到表格中。从理论上讲,这样就可以避免需要通道和传递回reportUsage()函数的结构。然而,更改线程是一个(相对)缓慢的过程,这取决于其他应用程序活动,而且我们应尝试将我们的逻辑和数据处理与用户界面代码分离。这样做将使以后重用代码更容易,并且如果我们的需求发生变化,还可能更容易更改工具包。

我们的设计是将大部分用户交互处理在一个单独的函数中,这意味着我们的实际目录使用代码与用户界面完全分离。让我们更新前面的示例,以生成图形输出。这次我们将使用 Go-GTK,因为它的线程处理非常明确:

func gtkReportUsage(path string, list *gtk.ListStore, totalLabel *gtk.Label) {
   f, _ := os.Open(path)
   files, _ := f.Readdir(-1)
   f.Close()

   result := make(chan sizeInfo)
   for _, file := range files {
      go dirSize(filepath.Join(path, file.Name()), result)
   }

   var total int64
   results := 0
   for info := range result {
      var listIter gtk.TreeIter
      total += info.size

      gdk.ThreadsEnter()
      list.Append(&listIter)
      list.SetValue(&listIter, 0, info.name)
      list.SetValue(&listIter, 1, formatSize(info.size))
      gdk.ThreadsLeave()

      results++
      if results == len(files) {
         break
      }
   }

   gdk.ThreadsEnter()
   totalLabel.SetText(fmt.Sprintf("Total: %s", formatSize(total)))
   gdk.ThreadsLeave()
}

注意,我们的替代使用报告方法有两个gdk.ThreadsEnter()gdk.ThreadsLeave()实例;每次我们更新用户界面时,我们必须切换到gdk主线程。正如之前的 Go-GTK 示例中那样,我们还需要更新主方法以正确初始化线程处理:

func main() {
   glib.ThreadInit(nil)
   gdk.ThreadsInit()
   gdk.ThreadsEnter()
   gtk.Init(nil)

   window := gtk.NewWindow(gtk.WINDOW_TOPLEVEL)

...

   gtk.Main()
}

为了简洁起见,本章省略了完整的用户界面创建,但可以在本书的源代码中找到(在chapter12/goroutines/gtkdiskusage.go中)。大多数图形工具包都要求后台进程在更新用户界面时切换到主线程或图形线程。一些,如 Fyne,没有这个要求,您可以在示例的替代版本中看到这一点(本书的代码库中也可用,在chapter12/goroutines/fynediskusage.go)。我们不是在线程处理代码中包装 GUI 调用,而是简单地从后台代码调用list.Append()label.SetText(),界面将相应更新:

图片

用于磁盘使用示例的 GTK 界面

图片

使用 Fyne 的相同磁盘使用示例

网络资源和缓存

访问远程资源,无论是在本地网络中还是在互联网上的服务器上,在大多数应用程序中都可能扮演一定的角色。不幸的是,这也可能是许多潜在问题的来源:响应缓慢、数据意外或根本没有数据。让我们看看一些我们可以采取的方法,即使在需要使用网络和集成云服务的情况下,也能创建一个健壮的应用程序。

加载远程资源

在 Go 中访问资源通常是通过字节流进行的,无论是本地(用于嵌入式资源或文件系统访问)还是远程(用于 HTTP 请求和远程服务器上的数据)。由于读取本地和远程数据的方法相似,我们可以在大多数使用本地或嵌入式资源的地方加载远程资源。

图像

遵循基于流的架构,Go 的image包可以从流中解码图像。通过连接到远程流并从请求中读取字节,我们可以轻松地从 Web 服务器渲染图像。以下代码使用 Fyne 的canvas.NewImageFromImage()函数来渲染从golang.org/doc/gopher/frontpage.png URL 使用image.Decode()加载的 Go 解码图像:

package main

import (
   "image"
   _ "image/png"
   "io"
   "log"
   "net/http"

   "fyne.io/fyne"
   "fyne.io/fyne/app"
   "fyne.io/fyne/canvas"
)

func readStream(url string) io.ReadCloser {
   res, err := http.Get(url)
   if err != nil || res.StatusCode != 200 {
      log.Fatal("Error reading URL", err)
   }

   return res.Body
}

func remoteImage(url string) image.Image {
   stream := readStream(url)
   defer stream.Close()

   m, _, err := image.Decode(stream)
   if err != nil {
      log.Fatal("Error reading image", err)
   }

   return m
}

func main() {
   app := app.New()
   w := app.NewWindow("Remote Image")

   img := canvas.NewImageFromImage(remoteImage("https://golang.org/doc/gopher/frontpage.png"))
   img.SetMinSize(fyne.Size{180, 250})
   w.SetContent(img)
   w.ShowAndRun()
}

如您所预期,此应用程序打开一个窗口,其中包含加载的图像作为其内容:

图片

从互联网上加载文件

但这仅在互联网连接表现正常时才适用,即使如此,也可能比用户预期的加载时间更长。在我们探讨改进策略之前,让我们看看如何从 Web 服务下载的数据执行相同的操作。

JSON

要探索如何与来自网络服务的远程数据一起工作,我们将从第三章的示例开始,Go to the Rescue!。代码被精简并更新,以使用为前面的图片示例创建的相同readStream()函数。生成的代码非常基础,但展示了我们可以如何轻松地使用内置的 Go 功能将 JSON 数据解码到结构体中:

type Person struct {
   Title     string `json:"title,omitempty"`
   Firstname string `json:"firstname"`
   Surname   string `json:"surname"`

   Username string `json:"username"`
   Password string `json:"-"`
}

func main() {
   fmt.Println("Downloading...")
   stream := remote.ReadStream("http://echo.jsontest.com/title/Sir/" +
      "firstname/Tom/surname/Jones/username/singer1/")
   defer stream.Close()

   person := &Person{}
   json.NewDecoder(stream).Decode(person)
   fmt.Println("Decoded:", person)
}

使用单一方法来加载我们的资源使我们能够在一个中央位置放置更健壮的错误处理。在我们做出这些改进之前,如果请求失败(例如没有互联网或服务器错误),我们的应用程序将会崩溃:

图片

没有网络连接时的图片加载失败

图片

离线时也无法访问 JSON

虽然这些错误可以得到更好的处理,但我们仍然没有下载任何内容。图片加载失败可能无关紧要,但连接存在但缺少 JSON 数据的情况可能会降低我们应用程序的功能。我们应该努力改善连接不存在或响应不正确的情况。

缓存资源数据

当网络连接缓慢或不稳定时,我们提供更好体验的第一种方法是为我们的远程资源实现缓存机制。这样一来,应用程序的一次在线运行就足以防御连接问题,因为它会填充缓存数据。额外的好处是,在应用程序的重复运行中,加载这些资源会更快。

在前面的图片示例的基础上,我们实现了一个新的函数,cacheStream(),我们将用它来代替readStream()。一个名为cacheFileName()的辅助函数根据url参数确定用于缓存的文件位置。每次我们使用此函数请求 URL 时,它都会尝试从该位置加载缓存的副本;如果存在,则直接返回该位置的io.ReadCloser。如果缓存文件不存在,则我们使用原始的readStream()函数将内容下载到缓存文件中,然后像以前一样返回缓存文件的流:

func cacheFileName(u string) string {
   id, _ := url.Parse(u)
   file := filepath.Base(id.Path)
   return path.Join("/tmp/", fmt.Sprintf("%s:%s", id.Hostname(), file))
}

func cacheStream(url string) io.ReadCloser {
   cacheFile := cacheFileName(url)
   if _, err := os.Stat(cacheFile); !os.IsNotExist(err) {
      fmt.Println("Found cached file at", cacheFile)
      file, _ := os.Open(cacheFile)
      return file
   }

   fmt.Println("No cache found, downloading")
   stream := readStream(url)
   writer, _ := os.Create(cacheFile)
   io.Copy(writer, stream)
   stream.Close()
   writer.Close()

   fmt.Println("Saved to", cacheFile)
   stream, _ = os.Open(cacheFile)
   return stream
}

这种实现只是说明了如何做到这一点;如果要在生产应用程序中使用,你需要使用更好的缓存位置并处理潜在的线程问题。

在 Go 1.11 版本中,有一个新的os.UserCacheDir()函数。然而,在依赖新功能之前等待一段时间通常是明智的,因为并非所有人都会升级。

基于流的这种方法的优点是,我们可以用它来处理除了图片之外的资产。就像图片示例一样,我们可以更新我们的 JSON 代码,用cacheStream()代替readStream(),然后我们的数据将下载一次,然后由缓存代码从本地文件读取:

图片

缓存远程图像意味着应用程序具有更好的弹性

图片

通过缓存 JSON,即使网络失败,我们的应用程序也能正常运行

这些示例应该有助于在您的应用程序中处理远程资源,但它们相对简单。我们如何处理更复杂的云服务?

连接到云服务

有许多框架和库旨在帮助您在 Go 中使用云服务。然而,如果您询问 Go 社区哪个最好,他们可能会建议您坚持使用内置包。对于来自 C 或 Java(或许多在互联网连接应用程序变得普遍之前创建的语言)的人来说,这样做可能看起来很奇怪,但 Go 的标准库非常强大。我们将探讨如何使用提供的工具,并在不添加额外依赖项的情况下将基于云服务的功能添加到我们的代码中。

编码

要开始查看这个,我们将回到在 client 包中定义的 EmailMessage 模型,该模型在之前的章节中已导入。github.com/PacktPublishing/Hands-On-GUI-Application-Development-in-Go/tree/master/client。通过向此对象添加简单的提示,我们可以轻松地在 JSON 和 XML 格式之间进行序列化和反序列化。

JSON

由于 JSON 中的约定是映射键为小写,我们在结构体中添加了 json:"subject" 形式的提示,告诉 json 包如何处理结构体内的字段名称。更新后的定义应如下代码所示:

type EmailMessage struct {
   Subject string    `json:"subject"`
   Content string    `json:"content"`
   To      Email     `json:"to"`
   From    Email     `json:"from"`
   Date    time.Time `json:"sent"`
}

为了便于测试,我们还在定义中添加了一个 String() 函数,以便稍后更容易进行调试:

func (e *EmailMessage) String() string {
   format := "EmailMessage{\n  To:%s\n  From:%s\n  Subject:%s\n  Date:%s\n}"
   return fmt.Sprintf(format, e.To, e.From, e.Subject, e.Date.String())
}

一旦设置好,我们就可以添加一些代码来演示其用法。首先,让我们构建一个新的 EmailMessage 对象并将其编码为 JSON。编码非常简单,如下所示。我们创建一个新的 json.Encoder 实例(它将输出到标准输出),设置缩进值(以提高可读性),并要求它将我们的结构体编码:

fmt.Println("To JSON:")
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", "  ")
encoder.Encode(email)

从 JSON 解码结构体也很简单。我们连接到一个 URL,使用本章前面(为了简洁省略了 URL)中的代码打开一个流,并延迟关闭该流。然后,从这个流中创建一个新的 json.Decoder 实例,并要求它将数据解码到电子邮件结构体中。然后,我们将数据(使用前面的有用 String() 函数)输出以查看结果:

stream := readStream(urlOmitted)
defer stream.Close()

email := &EmailMessage{}
json.NewDecoder(stream).Decode(email)
fmt.Println("Downloaded:", email)

运行所有这些将产生一些非常易于阅读的输出,显示我们已经成功创建了、编码了,然后解码了结构体的 JSON 数据:

图片

从结构体和 WebService 获取的 JSON 数据

XML

与 XML 一起工作与 JSON 非常相似。事实上,由于 XML 和 Go 共享将公共变量名首字母大写的语义,所需的映射注解更少,因此结构体只需要一个映射标签:

type EmailMessage struct {
   Subject string
   Content string
   To      Email
   From    Email
   Date    time.Time `xml:"Sent"`
}

编码和解码几乎相同;显然,我们需要创建xml.Encoderxml.Decoder而不是 JSON 的对应版本。唯一的另一个区别是设置缩进的方法调用(仅用于美化打印):

fmt.Println("To XML:")
encoder := xml.NewEncoder(os.Stdout)
encoder.Indent("", "  ")
encoder.Encode(email)

此外,我们可以使用一个网络服务为我们提供 XML 进行解码(为了简洁,这里省略了 URL,但可以在本书的源代码库中找到):

stream := readStream(urlOmitted)
defer stream.Close()

email := &EmailMessage{}
xml.NewDecoder(stream).Decode(email)
fmt.Println("Downloaded:", email)

执行所有前面的代码将给出与 JSON 示例相似的输出,但在编码时格式不同。注意,变量名以大写字母开头,这在 XML 中很常见:

与 Web 服务通信时,同样可以轻松地使用 XML 数据。

认证 – OAuth 2.0

认证通常是访问网络服务的必要条件——不一定适用于整个 API,但肯定需要访问受保护的用户数据。目前大多数基于 Web 的认证都使用 OAuth 2.0,这是一个框架,允许应用程序在用户授权后获得对用户数据的部分访问权限。认证需要在第一次访问资源时显示一个网页来解释请求。作为一个基于 GUI 的应用程序,这个工作流程通常通过嵌入的浏览器窗口来展示,以隐藏访问网页进行权限请求的复杂性。不幸的是,这种功能并没有集成在我们所讨论的许多工具包中,所以我们将会简单地打开一个外部 Web 浏览器来展示这个工作流程。这仅适用于首次使用,之后,授权的访问应该可以在应用程序运行之间被记住。

为了演示认证,我们将进一步扩展每个 GoMail 示例中使用的客户端代码。我们将扩展它以从 Gmail API 中读取并下载消息。为此,你需要有一个 Gmail 账户,并在 Google 开发者控制台中创建了一个项目并启用了 API 访问,这将生成CLIENT_IDCLIENT_SECRET。首先,我们将创建一个新的函数authStream(),它将接受一个string类型的 URL 参数,并返回一个类似于之前的readStream()cacheStream()函数的io.ReadCloser流。

第一次请求

为了返回一个经过认证的流,我们需要检查是否需要认证(HTTP 请求上的状态码 401 意味着正是如此)。如果我们已经认证过,那么请求将正常完成,我们只需返回请求体。如果需要认证,那么我们必须通过在正确的 URL 加载一个网络浏览器来启动这个过程,请求用户的权限;这个过程由一个辅助函数openBrowser()完成,该函数可以在本书的源代码库中找到。

当浏览器窗口打开时,用户将被告知正在请求的权限,如果他们接受,页面将重定向到回调 URL。我们需要设置一个简单的本地 Web 服务器来处理这个重定向。为此,我们在/oauth/callback路径上注册一个处理程序,并等待在端口 19999 上的连接。

服务器启动后,函数将阻塞,直到我们稍后关闭它:

func authStream(url string) io.ReadCloser {
   ret, err := client.Get(url)

   if err == nil && ret.StatusCode != 401 {
      return ret.Body
   }

   fmt.Println("Requesting authorization")
   openbrowser(conf.AuthCodeURL("state", oauth2.AccessTypeOffline))

   http.HandleFunc("/oauth/callback", callbackHandler)
   server = &http.Server{Addr: ":19999", Handler: nil}
   server.ListenAndServe()

   return retReader
}

回调处理程序相对简单。它负责从重定向中提取授权码,并使用此代码从发送一次性代码的服务器请求一个可重复使用的令牌(这由conf.Exchange()处理)。交换完成后,我们再次尝试连接到最初指定的 URL;如果我们成功,则设置返回流,如果不成功,则失败并返回适当的错误。无论结果如何,我们都会提示用户关闭浏览器窗口(因为网页安全规定这不能自动完成)。在我们将此内容返回给用户后,我们将关闭服务器。这会将控制权交回原始的authStream()函数,该函数将返回新认证的请求流:

func callbackHandler(w http.ResponseWriter, r *http.Request) {
   queryParts, _ := url.ParseQuery(r.URL.RawQuery)

   authCode := queryParts["code"][0]
   tok, err := conf.Exchange(ctx, authCode)
   if err != nil {
      log.Fatal(err)
   }
   client = conf.Client(ctx, tok)

   ret, err := client.Get("https://www.googleapis.com/gmail/v1/users/me/messages")
   if err != nil {
      fmt.Fprint(w, "<p><strong>Authentication Failed</strong></p>")
      fmt.Fprintf(w, "<p style=\"color: red\">%s</p>", err.Error())
      fmt.Fprint(w, "<p>Please close this window and try again.</p>")
      log.Fatal(err)
   } else {
      fmt.Fprint(w, "<p><strong>Authentication Completed</strong></p>")
      fmt.Fprint(w, "<p>Please close this window.</p>")

      retReader = ret.Body
   }

   server.Shutdown(context.Background())
}

这个谜题的最后一步是设置 OAuth2 配置和上下文。我们将从 Gmail API 请求只读认证范围,并指定我们的本地服务器作为回调 URL。为了正确运行,您需要提供CLIENT_IDCLIENT_SECRET的值。大部分配置都由golang.org/x/oauth2/google包中的google.Endpoint定义提供帮助:

func setupOAuth() {
   // Your credentials should be obtained from the Google Developer Console
   // (https://console.developers.google.com).
   conf = &oauth2.Config{
      ClientID:     "CLIENT_ID",
      ClientSecret: "CLIENT_SECRET",
      Scopes:       []string{"https://www.googleapis.com/auth/gmail.readonly"},
      Endpoint:     google.Endpoint,
      RedirectURL:  "http://127.0.0.1:19999/oauth/callback",
   }
   ctx = context.WithValue(context.Background(), oauth2.HTTPClient, client)
}

存储令牌

对于重复请求,我们可以通过重新使用我们收到的令牌来避免让用户再次经历权限工作流程。conf.Exchange()返回的令牌可以持久化并用于后续请求。此令牌包含一个用于刷新令牌的引用,这意味着即使令牌已过期,应用程序也可以自动请求新的令牌。

为了存储和检索令牌,我们将使用已经设置在oauth2.Token类型上的 JSON 序列化。当令牌最初颁发时,我们将将其保存到文件中(这可以是数据库或任何其他应用程序可以访问的持久化存储)。因为我们使用的client对象是共享的;我们不需要为每个请求重新加载令牌。相反,我们可以在应用程序下次启动时简单地加载它。这意味着,在第二次启动示例时,您将立即看到结果,而无需再次进行权限请求。

因此,我们更新callbackHandler()函数以存储令牌,如果它成功返回:

   if err != nil {
      log.Fatal(err)
   }
   saveToken(tok)
   client = conf.Client(ctx, tok)

在我们应用程序的main()函数中,我们在设置 OAuth 配置后立即添加令牌加载行:

func main() {
   setupOAuth()
   token = loadToken()

...
}

这将存储的令牌(如果有的话)加载到全局变量中,以便稍后由 authStream() 函数访问,该函数应更新以检查令牌是否已加载:

func authStream(url string) io.ReadCloser {
   if token != nil {
      fmt.Println("Reusing stored token")
      client = conf.Client(ctx, token)
   }
   ret, err := client.Get(url)

...
}

在源代码库中有简单的 saveToken()loadToken() 实现。为了测试目的,将内容打印到系统输出并复制粘贴到 loadToken() 函数中,以便在下次运行之前,这已经足够了。有了所有这些,我们可以实现一个简单的请求,该请求将计算用户收件箱中的消息数量。此函数请求需要身份验证的 Gmail API,并计算结果 JSON 消息列表中的项目数量:

func countMessages() {
   in := authStream("https://www.googleapis.com/gmail/v1/users/me/messages")
   defer in.Close()

   var content interface{}
   decoder := json.NewDecoder(in)
   decoder.Decode(&content)

   if body, ok := content.(map[string]interface{}); ok {
      list := body["messages"].([]interface{})
      fmt.Println(len(list), "messages found")
   }
}

当我们运行两次时,我们可以看到第一次请求需要浏览器打开并确认授权。在第二次运行中,该令牌被重用,并且返回相同的内容,而不会中断用户:

第一次请求 OAuth2 授权时将打开浏览器窗口;重复调用使用我们保存的令牌

发布数据

将数据发布到网络服务应该和将 Get() 函数调用改为 Post() 一样简单,但通常会有一些复杂情况。考虑我们的电子邮件示例和连接到 Gmail 的任务。API 很简单,我们可以轻松地发出请求,但数据必须适当地格式化。当发送到邮件服务器时,电子邮件具有复杂的编码,我们需要实现这一点才能与 API 一起工作。Gmail 服务需要一个 RFC 2822 编码的电子邮件(Go 标准库不提供),然后进行 base64url 编码(标准库可以处理这一点)。在我们能够发布任何电子邮件之前,我们需要向我们的 EmailMessage 类型添加一个编码器,如下所示:

func (e *EmailMessage) ToGMailEncoding() string {
   m := mime.NewMultipartMessage("alternative", "")
   m.SetHeader("Subject", mime.EncodeWord(e.Subject))
   m.SetHeader("From", mime.EncodeWord("Demo") + " <" + string(e.From) + ">")
   m.SetHeader("To", mime.EncodeWord("Demo") + " <" + string(e.To) + ">")
   plain := mime.NewTextMessage(qprintable.UnixTextEncoding, bytes.NewBufferString(e.Content))
   plain.SetHeader("Content-Type", "text/plain")
   m.AddPart(plain)

   var buf bytes.Buffer
   io.Copy(&buf, m)
   return base64.URLEncoding.EncodeToString(buf.Bytes())
}

此代码使用了外部库 github.com/sloonz/go-mime-message,并且为了方便起见,我们使用了 mime 名称;在这些示例中我们没有记录人们的名字,如果您愿意,可以省略该部分。为了实现发送电子邮件,我们可以查看 Google 文档 developers.google.com/gmail/api/v1/reference/users/messages/send,以了解我们需要传递一个与 raw 键关联的编码数据的 JSON 有效负载。一个简单的方法应该能够打包并发送到 API:

func postMessage(msg *EmailMessage) {
   raw := msg.ToGMailEncoding()
   body := fmt.Sprintf("{\"raw\": \"%s\"}", raw)

   ret := authPost("https://www.googleapis.com/gmail/v1/users/me/messages/send",
      "application/json", strings.NewReader(body))
   io.Copy(os.Stdout, ret)
   ret.Close()
}

对于这段代码,我们还需要一个额外的函数,authPost()。这个函数将对我们的 URL 进行认证发送,将内容类型和请求体作为第二个和第三个参数传递。这种方法可以将 URL、内容类型和有效负载保存下来,以便在需要授权工作流程时重新提交,但通常对于 HTTP POST 请求来说,这既不明智也不可行,所以我们简单地重新使用之前在 authStream() 函数中生成的令牌。如果你确实重新使用了这个令牌,那么你需要记得更新代码以请求额外的权限;更新的作用域应如下所示:

   Scopes: []string{"https://www.googleapis.com/auth/gmail.readonly",
      "https://www.googleapis.com/auth/gmail.compose"},

通过这个更改,将颁发新的令牌,并且,在放置了前面的代码之后,我们可以执行一个简单的方法,使用之前列出的 postMessage() 函数发送电子邮件:

func main() {
   setupOAuth()
   token = loadToken()

   msg := &EmailMessage{
      "GoMail Test Email",
      "This is a test email sent from a Go example",
      "test@example.com",
      "me@example.com",
      time.Now()}
   postMessage(msg)
}

之前的 postMessage() 函数输出了有用的调试信息,但显然可以将其关闭,并且可以通过更合适的方式处理电子邮件发送失败:

图片

来自我们电子邮件发送示例的调试信息;Gmail 返回消息和线程 ID 以及标签信息

GUI 集成

在之前的 并发、线程和 GUI 更新 部分,我们探讨了线程管理以及如何从后台任务更新用户界面。现在我们正在使用真实的云服务,让我们看看我们可能需要处理的额外复杂性。

来信消息

用于模拟连接到电子邮件服务器的客户端 API 包括 Incoming() 函数,该函数返回一个 EmailMessage 对象的通道。每当有新电子邮件到达时,都会向该通道发送一条新消息,我们也可以使用这个相同的模型来连接真实的电子邮件服务器。电子邮件消息抽象用于传达标准结构,因此新的电子邮件连接(如前面的 Gmail 示例)需要做的只是将传入的数据打包到 EmailMessage 结构体中,并将其添加到通道中。

这意味着我们唯一需要做的额外工作就是更新服务器连接包中的代码,以监视新消息,并在检测到新消息时添加一些 JSON 解码。所有这些都可以在不更改我们 GoMail 示例应用程序 GUI 的任何一行代码的情况下完成。实际上,为了激活真实的 Gmail 账户而不是我们的测试服务器 "我们需要将 client.NewTestServer() 函数调用更改为 client.NewGmailServer()(有关此 Gmail 提供者的完整描述可在最后的 附录,将 GoMail 连接到真实电子邮件服务器)。

活动通知

对于用户来说,知道应用程序何时在后台执行代码可能很有帮助。例如,如果你想在应用程序中包含一个检查电子邮件的按钮,那么指示电子邮件正在被检查可能有助于用户知道按下按钮不会做任何事情。作为开发者,这种情况最常见于 IDE 状态栏,指示构建正在进行或打包任务正在运行。如果在笔记本电脑上工作,风扇开始旋转,了解后台正在执行什么操作是有用的,这样任何潜在的问题都可以暂时搁置。

为了支持这种界面更新,我们需要追踪任务何时开始和结束。根据视觉设计的类型,我们可以使用两种策略:后台任务的简单计数器或正在运行的任务列表。前者更容易实现,但后者能够向应用程序用户报告更多信息。如果你只是使用旋转器或无限进度条,那么第一种策略将很好地工作。然而,如果你想要添加一个显示当前运行任务的进度条,那么你需要选择第二种策略。

旋转器

一个旋转器(或其他简单的活动指示器)可以是有助于可视化是否有后台活动。如果后台任务的数量非零,它将是可见的。为了追踪这一点,我们可以在应用程序中实现一个简单的计数器,并使用StartTask()StopTask()函数更新它。然后,一个监听器或通道会通知用户界面元素正在运行的任务数量已更改,以便它可以通过显示或隐藏可视化元素来更新 GUI。

在一个基于云的应用程序中,如果后台任务正在使用网络连接,这将带来额外的优势:我们可以将这些任务追踪功能调用插入到网络请求代码中。例如,我们可以更新readStream()以调用StartTask(),这样所有的后台任务都会增加计数器。为了表示任务已结束,我们将返回一个包装器到流中,这样当调用Close()时,它就可以正确地调用StopTask()

状态面板

一个状态面板,用于显示当前(或最近的)任务,将需要我们在任务开始时追踪任务名称。为了准确显示哪些任务仍在运行,我们还需要追踪哪些任务结束(否则,一个在长时间运行的任务之后开始并停止的快速任务将无法正确更新状态显示)。

一个示例实现是,适当的开始函数返回一个任务引用,然后直接停止,例如,task := StartTask("我的任务名称"),稍后使用task.Stop()停止。还需要一个类似的监听器或通道,但这次数据是任务引用而不是后台任务的数量。

离线时保持一致的用户体验

在现代图形应用程序中,良好的用户体验显然取决于优秀的设计和高质量,但处理网络和服务故障也同样重要。在本章的网络资源和缓存部分,我们介绍了缓存服务器响应以提高容错性和加快应用程序加载速度,但这只是为出色的离线支持制定更大策略的一小部分。

缓存响应

本章前面引入的响应缓存代码可以应用于几乎所有的 HTTP 请求,但我们只使用了它来处理 HTTP GET。在许多不同的 HTTP 请求类型中,只有三种被认为是可缓存的(GET、HEAD 和 POST),而 HEAD 请求不返回主体,因此在我们的应用程序中并不有用。POST 方法表示正在执行的操作,所以在我们这个上下文(以及大多数其他上下文)中,知道它是否完成比保存它引起的响应更重要(请参阅下一节的排队操作)。要了解更多关于 HTTP 请求类型的信息,请参阅en.wikipedia.org/wiki/Hypertext_Transfer_Protocol

此外,可能不适合缓存每个 GET 请求的响应。虽然 HTTP 是无状态协议,但你正在与之通信的服务器可能正在跟踪可能影响你请求响应的状态。如果你的应用程序知道请求的响应将是时间敏感的,你可以确保它跳过缓存或在该缓存条目上设置超时。不幸的是,可能并不总是能够提前知道这一点;这就是 HTTP 头部(以及 HEAD 方法)可以发挥作用的地方。通过检查响应的头部,你可能看到 Last-Modified 或 ETag 元数据(通过发出 HEAD 请求,你可以访问这些信息而不需要发送完整的响应数据)。如果 Last-Modified 头部包含一个早于你的缓存条目创建时间的日期,那么你的缓存仍然可以使用,否则你应该删除缓存的项并用新的请求替换它。使用 ETag 通常更高效,因为它不需要任何日期解析,但你需要为每个缓存的响应存储适当的标签。这些元数据用作响应内容的唯一标识符,如果数据以任何方式更改,ETag 也会更改(此时你应该像之前提到的那样重置缓存)。

如果实现完整的 HTTP 缓存,还有其他一些头部信息需要注意,最值得注意的是Cache-Control。如果这个值设置为no-cacheno-store(或包括这些值的组合),服务器表明响应不得被缓存。这可能是内容针对特定请求和时间的一个指示,或者有其他原因导致再次发出相同的请求会返回不同的响应体。

在妥善处理所有这些考虑因素之后,管理响应缓存的代码比本章前面所展示的要复杂得多,这也是为什么存在各种 Go 包来管理细节。在您最喜欢的搜索引擎中搜索“golang http cache”可能会返回最相关的结果。

队列操作

其他 HTTP 方法,如 POST、PUT 或 DELETE,通常表示用户操作,确认其已被传达是主要要求。在这些情况下,缓存没有帮助;如果再次请求,缓存可能会阻止我们的操作到达服务器。因此,通常不会缓存这些请求。此外,如果我们打算构建一个健壮的应用程序,我们需要为失败的请求做出计划。在这些情况下,服务器可能已经或未收到我们的请求,操作可能已经或未处理。

应对这一挑战的通常方法是为出站响应构建一个队列。将请求添加到此类队列可以是“发射并忘记”,用户不关心请求何时完成,或者添加一个回调,以便在完成时传达适当的通知(例如,“邮件已发送”)。使用 Go 构建此类队列有很好的文档支持;对多线程、通道和等待组的支持使其相对简单,所以我们不会深入探讨如何执行。然而,重要的是确定请求是成功还是失败。

如果一个 HTTP POST 请求(例如)超时或返回 500(或更高)的错误,我们必须假设它失败了。重新发出相同的请求是安全的,因为如果它第一次成功完成,重新发出一个相同的 POST 请求不应该引起任何额外的状态变化。从 400 到 499 的响应代码意味着请求有误,重试不会解决问题。在这些情况下,可能需要通知用户失败(并且代码应该以某种方式记录错误到你的团队)。

注意不要盲目地将状态码 200(OK)视为成功;许多协议在成功的 HTTP 响应体中传达某些失败条件。请确保阅读您所使用的 API 文档,了解如何检查额外的错误。例如,一个典型的graphql响应可能会返回 HTTP 状态码 200,但内部可能已经失败;是否在后台重试或向应用程序用户传达错误将取决于服务和遇到的错误。在以下示例中,服务器响应有用地指出重试可能有助于解决问题:

{
  "errors" => [
    {
      "message" => "Temporary storage failure",
      "retry" => true,
      "path" => ["user", "add"],
    }
  ]
}

离线启动

上述策略有助于处理间歇性互联网连接,或者在线一段时间后继续以离线模式使用应用程序。但如果您的应用程序从一开始就被设计为离线工作呢?如果不需要立即登录,那么您可能能够支持初始离线状态;这可能不是电子邮件客户端的理想选择,但对于协作文档平台或娱乐系统来说可能是有预期的。如果没有网络可用,我们如何利用我们已经探索过的技术来提供出色的首次使用体验呢?

解决这个问题的最简单方法可能是将数据与应用程序捆绑在一起,以便在没有最近缓存的可用数据时用作缓存。以这种方式,应用程序可以尝试使用本地缓存(如果存在),否则回退到应用程序数据,如果两者都不可用,则尝试建立远程连接,如下面的典型函数所示:

func cacheFallbackStream(url string) io.ReadCloser {
   stream := cacheStream(url)
   if stream != nil {
      return stream
   }

   stream = resourceStream(url)
   if stream != nil {
      return stream
   }

   return readStream(url)
}

在这个例子中,我们在之前重新使用了cacheStream()readStream()函数,并使用了一个名为resourceStream()的新(假设的)函数,该函数将编码 URL,在应用程序中查找一些捆绑的资源,并在找到时返回一个流。另一种方法是,在应用程序首次运行时提取所有已打包的缓存资源并设置本地缓存,然后后续代码可以像之前一样简单地使用cacheStream()。有关捆绑资源以进行分发的更多信息,请参阅第十四章,分发您的应用程序

当然,无论您使用哪种策略,都务必考虑数据保持最新程度的重要性;将旧缓存捆绑到应用程序中是否是您数据的良好策略?您是否希望定期更新此信息的地方副本?如果数据必须尽可能新鲜,那么前面的函数可能需要更改,以便在可能的情况下尝试resourceStream()cacheStream()之前尝试readStream(),并尝试进行实时请求。如果您采取这种方法,请务必考虑超时和其他失败条件,并适当地处理用户期望。

摘要

在本章中,我们探讨了开发具有长时间运行的后台线程和依赖远程资源或网络服务的丰富应用程序的一些更复杂方面。我们首先回顾了 goroutines 和线程处理的基础知识,然后研究了如何设计后台进程以最小化某些图形工具包所需的代码开销。

本章讨论的大部分复杂性涉及与远程资源和网络服务的工作。我们看到了如何实现缓存策略以及它们如何在网络条件不佳时帮助创建一个更具弹性的应用程序。我们还探讨了请求的认证(使用常见的 OAuth2 工作流程),并将 GoMail 示例连接到一个真实的 Gmail 账户以读取和发送电子邮件。

所有这些主题都有助于将健壮性构建到应用程序中,并在所需资源不可用的情况下保持高质量的用户体验。在下一章,第十三章,Go GUI 开发的最佳实践中,我们将关注点从用户体验转移到优秀的源代码,并探讨使用 Go 进行 GUI 开发的最佳实践。我们还将介绍如何设置代码以方便开发和协作,以及沿途将帮助的工具和流程。

第十三章:Go GUI 开发的最佳实践

Go 语言有明确的格式化、文档和代码结构实践。您可以在许多地方找到这些参考,例如,golang.org/doc/effective_go.html#formattinggithub.com/golang/go/wiki/CodeReviewComments。此外,有一个强大的社区推动编写惯用的 Go 语言,例如 dmitri.shuralyov.com/idiomatic-go。许多这些设计决策都编码在工具如gofmtgolint中,这使得人们学习并维护标准化代码变得容易。

在本章中,我们将超越这些代码标准和常见约定,专注于使基于 GUI 的应用程序更容易维护和扩展的最佳实践方面。我们将涵盖以下主题:

  • 关注点的分离

  • 驱动 UI 开发的测试

  • GUI 的持续集成

  • 管理平台特定性

在应用程序中添加图形元素通常会使其测试变得更加困难。在本章中,我们将看到,通过正确的准备和结构,我们可以克服这些挑战,并使我们的代码更加健壮和易于更改。让我们首先看看如何为可维护性构建基于 GUI 的应用程序的结构。

关注点的分离

这个概念与罗伯特·C·马丁在其面向对象设计原则中引入的单一责任原则密切相关 (butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod),该原则指出:

“一个类应该只有一个,且仅有一个,改变的理由。”

–罗伯特·C·马丁

在这方面,关注点责任具有更广泛的范围,通常影响您的应用程序的设计和架构,而不是单个类或接口。在图形应用程序中,分离关注点是必要的,以便正确地将易于测试的逻辑与处理用户交互的表示代码分离。

通过分离应用程序的关注点,我们可以更容易地测试子组件并验证我们软件的有效性,甚至不需要运行应用程序。这样做,我们创建了更健壮的应用程序,这些应用程序可以随着时间的推移适应需求或技术的变化。例如,您为应用程序选择图形工具包时,不应将其纳入或影响您的业务逻辑设计。考虑我们在前几章中构建的 GoMail 示例;我们能够使用不同的工具包来显示我们的电子邮件,但管理访问它们的代码从未更改。这样,我们在不影响相关区域的情况下,保持了软件的开放性,易于更改。

建议的应用结构

在规划应用程序的开发时,考虑如何将核心关注点分离以保持灵活性。以下建议的结构应提供一些灵感:|

project/ 项目结构的根目录。此包应定义其余项目使用的接口和实用函数。这些文件不应依赖于任何子包。
project/logic/ 此包将包含大部分应用程序逻辑。应仔细考虑哪些函数和类型被暴露,因为它们将形成其余应用程序所依赖的 API。随着你将应用程序的关注点分离,可能会有多个包含应用程序逻辑的包。可能更倾向于使用特定领域的术语来代替 logic
project/storage/ 大多数应用程序都将依赖于某种类型的数据源。此包将定义一个或多个可能的数据源。它们将符合顶级项目中的接口,以便在项目中的包之间传递数据访问。
project/gui/ 此包是唯一导入图形工具包的地方。它负责加载应用程序的 GUI 并响应用户事件。它可能会访问由应用程序运行器设置的存储包提供的数据。
project/cmd/appname/ Go 语言中应用程序二进制的约定是它们位于 cmd/appname 子包中。该目录的实际包将是 main,它将包含加载和运行其他包中定义的主要应用程序所需的最低限度的代码。它可能会初始化一个存储系统,加载应用程序逻辑,并指示图形界面进行加载。

在为这些包中的每一个编写测试时,它们将关注当前包的功能。logic 包应该有非常高的单元测试覆盖率,而 storage 包可能更多地依赖于集成测试(关于不同类型测试的复习,请参阅 www.atlassian.com/continuous-delivery/software-testing/types-of-software-testing)。通常被认为最难测试的 gui 包可以在其测试中直接导入逻辑包,但可能不应该包含主存储包以验证其功能。您可以在 medium.com/@benbjohnson/standard-package-layout-7cdbc8391fc1 上了解更多关于推荐包结构的建议。|

遵循合理的结构将极大地帮助使你的应用程序可测试,正如许多开发者可能已经意识到的。然而,测试应用程序的图形部分通常要困难得多。从一开始就设计你的应用程序以便进行单元测试,通常会得到一个组织得更好的代码库,并自然地导致代码更容易理解和修改。让我们看看测试驱动开发TDD)能教给我们关于构建图形界面的哪些东西。

测试驱动 UI 开发

自动测试用户界面或前端软件所需付出的努力常常被争论为代价过高,因为它在避免未来错误方面所提供的价值。然而,这很大程度上源于所使用的工具包或甚至所选择的表现技术。如果没有开发工具或图形 API 对测试的全面支持,确实很难在没有巨大努力的情况下创建简单的单元测试。正如在基于 Web 的环境(以及一些本地测试框架)中经常看到的那样,唯一剩下的可能性就是运行应用程序并执行测试脚本,这些脚本将执行验证。它们通常会控制用户输入,模拟鼠标动作和键盘敲击,并监控被测试应用程序的结果行为。然而,如果你的应用程序和 GUI 工具包在设计时考虑了测试(例如,使用关注点分离),那么使用更少的开销进行自动化测试应该是可能的。

设计为可测试

在设置项目 UI 代码中的组件(如图gui子包所示)时,应小心定义具有单一责任和清晰 API 的类型和类。这样做将使使用标准的 Go 测试工具加载和测试单个组件变得更容易。如果可以测试较小的组件,我们就可以避免启动整个应用程序和所需的测试运行器,从而使测试过程更快。当测试套件运行得快时,它可以更频繁地运行并更容易扩展,从而导致更高的测试覆盖率和更大的软件质量信心。

为了一个实际的例子,让我们看看 GoMail 的撰写对话框及其发送按钮。显然,对话框在发送之前应该执行各种验证,如果验证通过,则发送电子邮件。验证可以通过正常的单元测试轻松测试,但验证发送按钮是否正确发送新电子邮件则需要测试用户界面。在下面的示例中,我们将加载撰写窗口,输入一些数据,并模拟按下发送按钮。通过使用 GoMail 示例中使用的测试电子邮件服务器,我们可以检查用户界面是否已发送电子邮件,而无需与真实的电子邮件服务器通信。

示例应用程序测试

我们回到第十章的 GoMail 代码,Fyne – 基于 Material-design 的 GUI,并创建一个新文件,compose_test.go。由于测试在同一个包中,我们可以测试内部函数定义,而不是依赖于导出的 API——只要应用程序足够小,不需要单独的包或库,这种情况在 UI 代码中很常见。我们首先添加测试导入;testing 是 go test 代码所必需的,而 github.com/stretchr/testify/assert 提供了有用的断言功能。我们还导入了为我们的 GoMail 示例创建的客户端电子邮件库,最后是 Fyne 测试包,fyne.io/fyne/test

package main

import (
   "testing"

   "fyne.io/fyne/test"

   "github.com/PacktPublishing/Hands-On-GUI-Application-Development-in-Go/client"
   "github.com/stretchr/testify/assert"
)

现在,我们可以添加一个测试方法,使用推荐的命名模式 Test<type>_<function>();通常,函数名会是函数名,但在这里我们指的是按钮标题或其动作。在函数的第一部分,我们通过调用 newCompose() 并传递一个测试应用程序(由 test.NewApp() 返回)来设置测试的编辑窗口。然后我们为测试准备状态——我们记录服务器发件箱的大小,并设置一个 OnClosed 处理器,当窗口关闭时会报告。最后,我们使用 test.Type() 模拟在 compose.to 字段中输入电子邮件地址:

func TestCompose_Send(t *testing.T) {
   server := client.NewTestServer()
   compose := newCompose(test.NewApp(), server)
   ui := compose.loadUI()

   pending := len(server.Outbox)
   closed := false
   ui.SetOnClosed(func() {
      closed = true
   })
   address := "test@example.com"
   test.Type(compose.to, address)

   ...
}

一旦设置代码完成,我们就可以实现主要的测试。这首先是通过使用 test.Tap() 点击 compose.send 按钮,这将导致发送电子邮件。我们首先验证在电子邮件发送完成后窗口是否已 关闭(我们添加的 OnClosed 处理器记录了这一点)。然后我们检查 server.Outbox 中的电子邮件比之前多一个。

如果这些测试通过,我们将进行最终检查。发送的电子邮件是从发件箱中提取出来的,这样我们可以检查其内容。通过一个最终的断言,我们验证电子邮件地址与我们输入到 To 输入框中的内容相匹配:

func TestCompose_Send(t *testing.T) {
   ...

   test.Tap(compose.send)
   assert.True(t, closed)
   assert.Equal(t, pending + 1, len(server.Outbox))

   email := server.Outbox[len(server.Outbox)-1]
   assert.Equal(t, address, email.ToEmailString())
}

运行前面的测试将在内存中加载用户界面,执行设置代码,并运行测试,然后退出并显示结果。我们使用 -v 运行以下测试,以便查看每个运行的测试,而不仅仅是总结。您会注意到以这种方式进行测试花费的时间非常少(go test 报告测试耗时为 0.00 秒,总耗时为 0.004 秒);因此,可以定期运行更多测试以验证应用程序的行为:

图片

运行用户界面测试花费的时间非常少

当运行测试时,您可能会注意到这个测试不会在您的计算机屏幕上显示任何窗口。这是许多 GUI 工具包测试框架的设计特性——对于测试目的,不显示应用程序运行要快得多。这通常被称为 无头 模式,当作为 持续集成 过程的一部分运行自动化测试时非常有用。

GUI 的持续集成

持续集成(定期将团队的工作进行中的代码合并以自动测试)已成为软件开发团队的常态。将此过程添加到您的团队工作流程中已被证明可以更早地突出显示开发过程中的问题,这有助于更快地修复问题,并最终产生高质量的软件。这部分的关键是自动化测试,这些测试会测试整个源代码,包括图形用户界面。

GUI 测试自动化的方法

将代码组织成逻辑组件以进行开发和测试是很重要的。使用框架测试功能(或外部支持库),较小的组件可以通过简单的测试更容易地得到验证。Go 语言内置的测试支持意味着测试覆盖率正在提高;事实上,流行的 Go 库列表awesome-go.com要求库至少有 80%的测试覆盖率!GUI 工具包,尤其是第三部分中讨论的新工具包,需要满足这些期望,并允许使用它们的开发者也能做到这一点。

如果您选择的框架不提供必要的支持,仍然可以自动化功能测试。这种技术涉及从测试脚本中运行应用程序,然后在该主机计算机上执行模拟用户操作。这并不是理想的做法,因为它要求应用程序在屏幕上可见,并且测试脚本需要控制键盘和鼠标——但比没有 GUI 测试要好。为了克服这种不便,可以在其中运行应用程序的虚拟帧缓冲区(一个离屏显示区域)。这种技术基本上创建了一个不可见的屏幕,应用程序可以将其绘制。这些方法通常由商业持续集成服务器支持,但设置它们超出了本书的范围。

避免外部依赖

在测试应用程序或其部分时,需要注意的一点是可能涉及外部系统。文件浏览器可能依赖于网络连接来完成一些工作,或者即时通讯应用将需要一个服务器来处理发送和接收消息。如果您的代码已经精心组织以分离其关注点,您已经使用了接口来定义不同组件之间的交互。如果采取这种方法,我们可以使用依赖注入来为不应包含在自动化测试中的应用程序区域提供替代实现。

“将复杂问题分解成更小的模块并实现这些模块的主要目标之一是依赖。一个高度依赖于底层技术或平台的模块可重用性较低,并且使软件的更改变得复杂和昂贵。”

best-practice-software-engineering.ifs.tuwien.ac.at/patterns/dependency_injection.html

当代码正确地与它所依赖的组件解耦时,就有可能为测试加载应用程序的不同版本。以这种方式,我们可以避免依赖任何外部系统或对数据存储造成永久性更改。让我们看看一个简单的例子,定义了一个Storage 接口,它将被用来从磁盘读取和写入文件:

type Storage interface {
   Read(name string) string
   Write(name, content string)
}

有一个应用程序运行器调用一个永久存储,并使用它来写入然后读取一个文件:

func runApp(storage Storage) {
   log.Println("Writing README.txt")
   storage.Write("README.txt", "overwrite")

   log.Println("Reading README.txt")
   log.Println(storage.Read("README.txt"))
}

func main() {
   runApp(NewPermanentStorage())
}

显然,这个应用程序将会覆盖现有的README.txt文件中的内容,将其替换为overwrite的内容。如果我们假设,例如,这是期望的行为,我们可能不希望这个外部系统(磁盘)受到我们的测试的影响。因为我们已经设计存储来符合接口,我们的测试代码可以包含一个不同的存储系统,我们可以在测试中使用它,如下所示:

type testStorage struct {
   items map[string]string
}

func (t *testStorage) Read(name string) string {
   return t.items[name]
}

func (t *testStorage) Write(name, content string) {
   t.items[name] = content
}

func newTestStorage() Storage {
   store := &testStorage{}
   store.items = make(map[string]string)
   return store
}

在此添加之后,我们可以测试应用程序的runApp函数,而不会覆盖真实文件的风险:

import (
   "testing"

   "github.com/stretchr/testify/assert"
)

func TestMain_RunApp(t *testing.T) {
   testStore := newTestStorage()
   runApp(testStore)

   newFile := testStore.Read("README.txt")
   assert.Equal(t, "overwrite", newFile)
}

当运行这个测试时,你会看到我们得到了预期的结果,同时也应该注意到没有任何实际文件被更改。这个示例中的代码也包含在书的源代码仓库中的chapter13/ci文件夹里:

图片

确认我们的 TestMain_RunApp 测试成功完成,没有写入我们的磁盘

管理平台特定性

在第三章,Go to the Rescue!中,我们看到了 Go 编译器内置了对基于环境变量和构建标签的源文件条件包含的支持。随着应用程序功能的增加,尤其是从平台集成角度来看,你选择的工具包可能无法提供你所需的所有功能。当这种情况发生时,代码需要更新以处理特定平台的功能。为此,我们将使用条件构建的变体——使用命名良好的文件而不是构建标签(如第十一章,Navigation and Multiple Windows中使用的那样)。这在项目级别上更容易阅读,并且应该清楚地表明哪些文件将被编译用于哪个平台。

让我们创建一个简单的例子:我们想要显示一个通知,但我们的代码只能在 macOS(darwin)上这样做。我们将在notification_darwin.go文件中设置一个简单的notify()函数,以实现我们想要的功能:

package main

import (
   "log"
   "os/exec"
)

func notify(title, content string) {
   cmd := exec.Command("osascript", "-e", "display notification \""+content+
      "\" with title \""+title+"\"")
   err := cmd.Run()

   if err != nil {
      log.Printf("Error showing notification: %v", err)
   }
}

这个简单的函数调用了osascript工具,这是一个与 macOS 捆绑的命令行应用程序,允许执行系统脚本。由于此文件以名称_darwin.go结尾,它只有在为 macOS 构建时才会被编译。为了在其他平台上正确编译,我们需要创建另一个将被加载的文件,我们将称之为notification_other.go

// +build !darwin

package main

import "log"

func notify(title, content string) {
   log.Println("Notifications not supported")
}

在这个文件中,我们必须指定构建条件,因为没有为所有其他平台指定特殊的文件名格式;这里,// +build !darwin表示该文件将包含在任何除 macOS 以外的平台上。我们在这个文件中提供的简单方法只是记录该功能不受支持。最后,我们创建了一个名为main.go的简单应用程序启动器,它将调用notify()函数:

package main

func main() {
   notify("Email", "A new email arrived")
}

在 macOS 上运行此代码将导致出现预期的通知:

我们简单的通知出现在 macOS 上

在任何其他操作系统上,它将记录回退错误信息:

当在 Linux(或 Windows 或其他操作系统)上运行时,我们只看到日志消息

我们可以以一种对学习源代码的人来说清晰易懂的方式处理特定平台的代码。另一位开发者可以选择添加一个notification_windows.go文件来支持 Windows 上的通知。只要他们也更新了notification_other.go中的构建规则,应用程序将继续按预期工作,但会添加基于 Windows 的通知。这种方法的优点是,它不需要对现有代码进行任何修改来添加这个新功能。

摘要

在本章中,我们探讨了使用 Go 编写的基于 GUI 的应用程序的一些管理和技巧。通过仔细规划应用程序的模块及其交互方式,我们看到了我们可以使其更容易测试和维护。由于更高的测试覆盖率是提高软件应用程序质量的因素之一,我们探讨了如何使用这些技术来测试我们的图形代码,这是一个众所周知难以处理的话题。我们逐步分析了为简单 GUI 应用程序编写测试代码的示例,该代码可以自动运行。

从这些基本概念出发,我们探讨了如何为常规自动化测试准备应用程序,以不断检查代码中的错误(称为持续集成)。通过利用良好的模块化代码库,我们可以避免在测试软件时依赖外部服务或产生意外的副作用。我们看到了依赖注入如何提高我们的测试可靠性和加快反馈过程。最后,我们看到了如何将我们的知识应用于处理图形应用程序中的操作系统特定功能。

在接下来的,也是最后一章中,我们将探讨开发过程的最后一步:打包和共享编译后的应用程序。我们将研究每个平台可用的各种选项,以及这些渠道如何有助于,或复杂化,我们的跨平台策略。

第十四章:分发您的应用程序

到现在为止,您应该已经熟悉了如何使用 Go 语言构建应用程序的图形用户界面。构建图形应用程序的任何旅程的最后一步是分发。打包和发布您完成的产品可能具有挑战性,尤其是如果您正在向多个平台发布,我们将在本章中探讨这些细节。

尽管 Go 语言以及我们在本书中迄今为止使用的库使得为多个平台编写软件变得容易,但不同操作系统要求原生图形应用程序以不同格式存在的事实是无法回避的。对于开发者来说,往往很容易忘记这一点,因为 Go 工具以在不同系统上一致的方式从源代码构建。为了准备应用程序发布,我们将探讨以下主题:

  • 为我们的应用程序准备元数据和图标

  • 将资源捆绑以适应 Go 的单个二进制分发

  • 为不同的操作系统打包完成的应用程序

  • 上传到平台市场和应用商店

到本章结束时,您应该能够打包和分发准备与目标受众分享的图形应用程序。您将完成创建应用程序包的步骤,这些包可以像您每个分发平台上的用户期望的那样下载或安装。我们首先收集您完成任何系统市场分发的所有必要信息。

元数据和图标

在我们开始创建应用程序发布的技术细节之前,有一些先决条件需要考虑。应用程序的名称可能已经确定,但您是否有一个出色的描述?您知道如何以吸引潜在用户注意的方式阐述您软件的关键特性吗?您(或您的设计团队)是否创建了一个令人难忘且能体现其功能的优秀应用程序图标?

如果您不会通过如应用商店这样的管理渠道进行分发,您应该考虑您的应用程序如何被目标受众发现。关于搜索引擎优化SEO)和越来越多的关于应用商店优化ASO)的讨论和信息在网上有很多,所以我们不会在这里详细介绍。在当前的软件环境中,显而易见的是,易于发现和记忆现在比以往任何时候都更加重要。

应用程序图标

选择您的图标可能是准备发布应用程序最重要的单个部分。它需要令人难忘,并激发一些关于软件用途的想法。一个优秀的图标在显示大或小尺寸时都应该看起来很好,并且通常应避免或仅用于设计的不重要方面。确保您的图标以高分辨率创建;1024 x 1024 像素是图标在各种设备上看起来出色的最低要求。同时,考虑透明度的使用也很重要——根据您希望分发的平台,这可能或可能不被推荐。大多数桌面系统允许使用形状图标,但并非所有都允许半透明区域。

花些时间查看您预期应用程序将在其中使用的每个操作系统或桌面环境中的流行或常见图标。您能否成功地将您的图标风格与它们匹配?这些系统的用户是否期望特定的形状或风格?可能最好,或者必要的是,为不同的平台创建不同的图形版本。这样做没有问题,并且可以通过传递不同的图标给我们在稍后工作的构建工具来适应。

描述您的应用程序

在这个开发阶段,为所创建的软件准备一些营销材料并不罕见。这是考虑如何最好地吸引新用户描述的时候。无论是通过网络搜索引擎还是应用程序市场,您使用的文本对于说服任何人安装您的应用程序至关重要。除了应用程序的名称和其主要功能外,请确保您考虑它如何为您的用户带来好处。您预计他们在搜索您构建的解决方案时将尝试完成哪些任务?不用担心使这段文字很长,但请尽量包括这些重要观点。

无论您打算通过在线商店还是简单的网站发布您的应用程序,在继续到发布流程之前,确保您已经完成了元数据是明智的。

打包资源

Go 应用程序被设计为从单个二进制文件运行。这意味着它们可以轻松分发,并且不依赖于安装脚本。不幸的是,这种好处给开发者带来了成本——我们不能像网络或移动应用开发者那样(在我们开发过程中所做的那样)依赖资源紧邻我们的应用程序。为了确保我们的应用程序符合这种设计,我们必须将任何所需的资产嵌入到应用程序的二进制文件中。这包括字体、图像以及任何其他应用程序正确运行所需的静态内容。

go-bindata

基于 GUI 的应用程序并非唯一需要解决这个挑战的,因此已经有许多解决方案可用。最常用的工具被称为 go-bindata,可在 github.com/jteeuwen/go-bindata 获取。这是一个简单的实用工具,可以将静态文件转换为 Go 源代码,以便它们可以被编译到应用程序中。这种方法是最容易工作的,因为嵌入的资源成为源代码的一部分,因此将与项目中的其余部分一起检出和构建。不幸的是,该包已不再维护,尽管它在社区中仍然被广泛使用。虽然存在新的、积极维护的版本,但在此时的受欢迎程度较低。

要使用这个资源打包器,我们使用 go get -u github.com/jteeuwen/go-bindata/... 从 GitHub 安装它,然后运行 go-bindata 命令,传入 asset 目录的名称:

图片

运行 go-bindata 会创建一个名为 bindata.go 的新文件

通过包含生成的 Go 文件,我们可以访问到许多新导出的方法。其中最重要的方法是 Asset()MustAsset(),它们各自接受从捆绑目录中 asset 文件的名称。第一个方法查找资源,如果找到则返回数据,否则返回错误。后者返回数据,但如果找不到指定的资源则会引发恐慌。有了这个新功能,我们可以从代码中加载所需的资源,如下所示:

data, err := Asset("shiny-hall.jpg")

您还可以使用 AssetNames() 命令获取可用资源的列表,或使用 AssetInfo() 命令获取有关资源的更多信息。

packr

由托管在 github.com/gobuffalo/packr 的 packr 项目提供了一种替代方法。这个实用工具在 go-bindata 和其他类似工具的基础上提供了额外的功能——在开发过程中能够直接从文件系统中加载资源。这种灵活性可以加快处理多个资源的工作,因为您不再需要在每次更改后重新生成打包的源代码。然而,这种灵活性需要稍微改变工作流程,并且在构建用于安装或发布的应用程序时,必须使用 packr 命令代替 go 命令。

要使用这种捆绑技术,我们需要安装 packr 工具(使用 go get -u github.com/gobuffalo/packr/packr),这将确保库也被安装。在我们可以运行构建之前,我们需要编写查找我们资源的代码,如下所示:

package main

import "log"
import "github.com/gobuffalo/packr"

func main() {
   box := packr.NewBox("./data")
   data, err := box.Find("shiny-hall.jpg")

   log.Println("datLen", len(data), "err", err)
}

保存此代码后,我们可以像平常一样运行,例如,go run main.go,它将从文件系统加载资源。当我们想要安装应用程序或构建用于发布时,我们必须使用 packr 命令,例如 packr install。此命令将找到我们引用的所有资源目录,将它们捆绑到 Go 代码中,然后构建包括附加代码的应用程序:

图片

我们可以像平常一样运行代码,但在构建时必须使用 packr。

如前述截图所示,在开发过程中没有额外的步骤,它的工作方式与任何其他 Go 代码一样。当构建用于发布的应用程序时,我们使用 packr 命令在构建过程中将 assets 捆绑到可执行文件中。

rsrc

如果你的应用程序是使用 Walk 或其他特定于 Windows 的工具包构建的,你可能需要考虑 rsrc 工具。rsrc 用于将清单文件和图标文件捆绑到可执行文件中。这个过程涉及运行工具以生成一个 .syso 文件,然后在运行 go build 时将其编译到最终的二进制输出中。这是在 第四章 中描述的相同过程,Walk – 构建图形 Windows 应用程序,用于嵌入应用程序的清单文件。我们还在本章的后面使用此工具嵌入应用程序图标以供 Windows 分发。

要将图标打包到应用程序中,你可以运行 rsrc -ico myicon.ico,anothericon.ico,然后重新构建你的应用程序。以这种方式嵌入的资源可以使用 walk.NewIconFromResource("myicon.ico") 访问。如果你专门为 Windows 编写应用程序,这是一种嵌入图标资源的有用方法。如果你打算与多个目标平台一起工作,它可能不太有用,因为你的 macOS 或 Linux 可执行文件将无法访问这些图标。

fyne bundle

之前提到的工具可以与任何 Go GUI 框架一起使用,但如果你正在使用 Fyne 工具包,你可以利用它自己的捆绑工具(在 Fyne 项目的一部分 fyne 命令中)。在使用基于 Fyne 的应用程序时使用此特定工具的好处是它会为每个嵌入的资源生成 fyne.Resource 定义。这使得将资产传递到各种 Fyne API 上变得更加容易。fyne bundle 的过程与 go-bindata 类似——运行此实用程序将资产从文件系统转换为 Go 源代码,然后可以在构建过程中将其编译到应用程序中。最大的区别是我们如何引用这些资产,即通过声明的变量而不是查找系统。

bundle 命令是 fyne 可执行文件的一部分,它将嵌入的文件作为其主要参数。它将结果打印到系统输出,因此我们使用控制台重定向(>)将生成的 Go 源代码发送到合适的文件:

图片

Fyne 打包命令输出到 stdout,因此我们将输出重定向到 Go 文件

一旦文件生成(或附加到现有文件中),我们可以使用创建的符号(*fyne.StaticResource 类型,实现了 fyne.Resource 接口)来引用它。这可以像使用任何其他资源一样使用,因此我们可以以下这种方式将其加载为图像:

image := canvas.NewImageFromResource(resourceShinyHallJpg)

生成的变量名可能不适合您的使用,但可以使用额外的命令参数进行更改。例如,如果您想导出这个新符号,可以使用 -name ShinyHall 将名称大写。要打包目录,您可以选择传递目录名而不是文件名,或者使用额外的 -append 参数重复运行命令。

构建发布版本

现在您的代码已经完成,您已经准备好了所有元数据,并且已经嵌入资产文件,现在是时候实际构建发布版本了。我们将按以下三个阶段来查看这个过程:

  1. 决定要发布哪些平台并设置工具

  2. 构建发布版本的二进制文件

  3. 准备您将要分发的实际软件包

假设您将分发到多个操作系统,在您能够构建所有发布版本的二进制文件之前,可能需要经过一些准备阶段。

准备工作

如果您是为了使用 Walk 框架并因此仅针对 Windows 操作系统进行分发而进入这一章节,您可以跳过这个准备步骤,因为您不需要任何额外的编译工具。本书中我们探索的所有其他工具包都需要 CGo 来实现某些功能。启用 CGo 的 C 编译器应该已经为您的开发平台设置好了,但还需要进行额外的工作以启用对其他平台的交叉编译。如果您为每个目标平台都有一个单独的操作系统安装,则可以跳过此步骤。

对于本节,我们假设在首选操作系统上进行开发,因此需要为额外的目标平台进行交叉编译。这样做将需要为每个目标平台安装一个 GCC 兼容的编译器(例如,基于 Linux 的开发者可能需要安装 Windows 和 macOS 的编译器)。

编译器安装

每个额外的平台(与当前操作系统不同)都需要安装一个 C 编译器。本书中推荐的使用如下(以及与 CGo 一起使用的 CC 环境变量):

目标平台 CC= 下载 备注
macOS (darwin) o32-clang github.com/tpoechtrager/osxcross/ 您还需要 macOS SDK(参见 osxcross 文档)
Windows x86_64-w64-mingw32-gcc 在 macOS 上: brew.sh/ 在 Linux 上: 使用您的包管理器 macOS: 安装 mingw-w64 软件包 Linux: 包名可能不同
Linux/BSD gcc-linux 在 macOS 上: brew.sh/ 在 Windows 上: cygwin.com/install.html macOS: 安装 FiloSottile/musl-cross/musl-cross Windows: 安装 gcc-linux 软件包

安装交叉编译工具可能很复杂——请务必阅读每个下载页面的文档,并在安装后检查您的环境配置。

如何安装和设置这些编译器的详细信息可以在 附录 2,交叉编译器设置 中找到。

构建

在安装了所有适当的编译器和库之后,我们可以继续到构建阶段。对于每个目标操作系统,您需要设置正确的环境变量并执行这些步骤。建议先为某个平台构建,然后完成下表中列出的包装步骤,再切换到下一个配置。这是因为一个平台的发布二进制文件可能会覆盖另一个(例如,当编译时,macOS 和 Linux 的二进制文件具有相同的名称)。请注意,当为当前平台构建发布版本时,可以省略 CGO_ENABLEDCC 环境变量:

目标平台 GOOS= CGO_ENABLED= CC= 备注
macOS darwin 1 o32-clang 确保您已准备好 SDK 并将其复制到 osxcross 的 tarballs/ 目录。
Windows windows 1 x86_64-w64-mingw32-gcc 如果您更改 GOARCH,可能需要不同的 CC 或编译器。
Linux linux 1 gcc-linux 在 macOS 上,您可能需要使用 x86_64-linux-musl-gcc 作为 CC。

在设置适当的这些环境变量后,我们可以执行构建命令。您可能还想设置 GOARCH 以指定不同的 CPU 架构,但这超出了本章的范围。如果您使用 Packr 资产打包器,则需要使用 packr build 命令,否则可以执行 go build 命令。这通常会比正常运行或构建花费更长的时间,因为可能需要在编译您的应用程序之前为新的目标平台构建所有应用程序依赖项。

要查看实际操作,我们可以打开 Linux 终端并为每个构建设置环境。当为当前平台编译时,我们不需要指定 CGO_ENABLEDCC 变量,因为这些变量有正确的默认值。每次构建后,我们都有一个为所需平台生成的单个应用程序二进制文件,我们将将其放在一边并在下一步中使用(例如,包装):

图片

为 Linux、macOS 和 Windows 编译带有 CGo

一旦编译了二进制文件,我们就可以分发应用程序——这将是命令行工具或网络应用程序的正常流程。然而,对于基于 GUI 的应用程序,用户将期望有视觉元素,例如图标,以及与平台应用程序启动器的集成。这些信息在打包阶段添加,并且因平台和工具包而异。

打包

要完成创建图形应用程序的发布文件,我们必须在刚刚编译的二进制文件旁边添加额外的图像和元数据。这些需要以特定格式存在,每种操作系统的格式都不同。

Linux

Linux 上应用程序包的格式因发行版而异(.rpm.deb.tar.gz是常见的格式),但它们都需要相同的资产,我们现在将构建这些资产。除了编译的应用程序外,我们还需要一个icon文件和一个desktop entry文件(由 FreeDesktop.org 定义的标准:standards.freedesktop.org/desktop-entry-spec/desktop-entry-spec-latest.html)。icon文件是支持格式之一(PNG、XPM 和 SVG)的简单图像文件。对于位图图标,建议使用 PNG,如果您的图标是矢量图,则应使用 SVG 格式。

创建元数据文件

.desktop文件是一个具有分组的标准键值格式简单文本文件。有一些键是必需的,还有很多可选的(我们在这里不会介绍)。一个基本的桌面条目文件可能看起来像这样:

[Desktop Entry]
Type=Application
Name=My Application
Exec=myapp %f
Icon=myapp.png
Categories=Utility

将内容保存到合适命名的文件中,例如与编译的二进制文件相邻的myapp.desktop。此文件概述了基本的应用程序信息——它描述的可执行文件类型(应用程序)以及用于显示的名称(如果需要,您可以使用Comment添加附加信息)。名称可以进行本地化(以不同语言显示)——为此,请使用Name[fr]=French Name格式。然后我们指定可执行文件(这可以是文件名,在这种情况下,将查找位置,或者指向已安装二进制文件的绝对路径)。%f参数表示可执行文件可以接受单个文件参数,这对于诸如将文件拖放到应用程序图标上之类的操作很有用(如果不支持命令参数,则省略此参数)。我们需要指定icon参数以告诉系统如何找到此应用程序的图标(在这种情况下,它将在主题图标路径中查找myapp.png)。可选地,我们可以指定此应用程序应出现在其中的类别——省略此元素可能意味着图标不会出现在系统菜单中。支持的所有类别完整列表可在网上找到,地址为specifications.freedesktop.org/menu-spec/latest/apa.html#main-category-registry

为了使用户在安装软件后更容易找到它,请确保选择一个清晰的名字并为您的应用程序设置正确的类别。您可以使用 Comment 属性提供更多信息,但这可能并不总是显示。

在某些系统中,类别不仅仅是菜单分组。例如,设置 类别中的项目可能被放置在控制面板而不是主应用程序列表中。

软件包发布

Linux 软件包以两种方式之一分发:作为源代码或作为二进制(编译)软件包。当以源代码形式提供时,会包含一个 makefile 或类似的文件,以指导编译器如何创建可执行文件。由于 Go 的标准结构和构建工具,所以在这种情况下我们不需要包含 makefile。作为开发者,我们只需为当前项目调用 go install 或为尚未下载的项目调用 go get。任何熟悉 Go 的人都会知道这个过程,所以在这种情况下不需要构建信息。

然而,我们是为普通用户分发软件包,而不是为开发者。为了使其工作,我们可以使用特定于发行版的软件包打包(我们将在本章后面讨论)或构建一个适用于任何 Linux 系统的软件包。为了做到后者,我们可以准备一个结构化的软件包,可以简单地展开到用户的系统中。非系统软件包的标准安装位置是 /usr/local,因此我们从该位置开始放置我们的文件(我们在当前目录中镜像这个结构)。预期的文件树应如下所示(hicolor 是查找图标的备用主题名称):

文件路径 描述
usr/local/share/applications/myapp.desktop Desktop Entry 元数据
usr/local/share/icons/hicolor/512x512/apps/myapp.png 应用程序图标(512 x 512 像素的位图图像)
usr/local/share/icons/hicolor/scalable/apps/myapp.svg 应用程序图标(矢量图像)
usr/local/bin/myapp 可执行文件(来自 go build)

在所有这些文件都放在正确的文件夹中后,我们可以使用 tar 工具构建一个应用程序包。创建包含此内容的新文件的完整命令是 tar -cf myapp.tar.gz usr,如下面的截图所示:

图片

打包我们的 usr/local 目录结构的内容

生成的软件包可以共享用于安装,接收者应从文件系统的根目录使用 sudo tar -xf myapp.tar.gz 进行解压。在这个例子中,我们传递了额外的 -C / 参数以避免需要更改目录,如下面的截图所示:

图片

安装软件包后,我们可以从 $PATH 中运行它

这种软件包格式适用于所有 Linux 发行版,但为软件包管理器打包是额外的工作。我们将在本章后面讨论发行版工具。

macOS

要在 macOS 上分发的应用程序还需要特定的目录结构和相关的元数据。这些文件不会像前面展示的 Linux 示例那样安装,而是从我们创建的目录中运行。这种格式称为 应用程序包,需要我们创建一些元数据文件来描述应用程序。

创建元数据文件

macOS 应用程序的主要元数据文件称为 Info.plist,就像 Linux 的桌面条目一样,它是一个结构化文本文件。对于 Go 项目,最好直接编辑文本,而不是使用已安装的 Xcode 工具。此文件包含一个键值对列表,描述了我们构建的应用程序。重要的是不要更改 CFBundlePackageTypeCFBundleInfoDictionaryVersion 的值,因为这些值将文件标识为 macOS 的应用程序。

定制的主要键是 CFBundleExecutable,它设置可执行文件名;CFBundleName,用于应用程序的人类可见名称;以及 CFBundleIconFile,用于指定图标文件名。设置 CFBundleIdentifier 的合理值很重要,因为这唯一地标识了此应用程序,以及 CFBundleShortVersionString,它指定了包含的应用程序版本。将这些值全部放入 plist 格式,你应该有一个类似以下文件:

<!DOCTYPE plist PUBLIC \"-//Apple Computer//DTD PLIST 1.0//EN\" 
      \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
<plist version=\"1.0\">
   <dict>
      <key>CFBundleExecutable</key>
      <string>myapp</string>
      <key>CFBundleIdentifier</key>
      <string>com.example.myapp</string>
      <key>CFBundleName</key>
      <string>MyApp</string>
      <key>CFBundleIconFile</key>
      <string>myapp.icns</string>
      <key>CFBundleShortVersionString</key>
      <string>1.0</string>
      <key>CFBundleInfoDictionaryVersion</key>
      <string>6.0</string>
      <key>CFBundlePackageType</key>
      <string>APPL</string>
   </dict>
</plist>

一定要设置一个全局唯一的 CFBundleIdentifier 值——通常通过使用前面展示的逆向域名格式来实现。此配置用于将文件类型与您的应用程序关联起来,并且 App Store 不会接受未正确设置此值的应用程序。

在创建 macOS 软件包时,还需要额外的一步,即图标必须是 ICNS 格式。ICNS 文件包含许多不同尺寸的图标,这样 macOS 就可以在各种分辨率下显示清晰的图形。有许多图形工具可以操作这些文件(网上搜索 create icns file),但 XCode 命令行工具包括 iconutil,这是一个简单的实用程序,可以从一组图标创建这些文件。

当调用 iconutil 时,我们使用 -c icns 参数指定它应该转换为 icns 格式,并使用 -o <filename> 提供输出文件名。最后一个参数是 iconset 输入——一个包含适当命名文件的目录,这些文件将被包含在内。对于我们的 1024 x 1024 像素的图标,我们称之为 icon_512x512@2x.png,但建议提供多个不同分辨率的版本。运行该命令将创建我们应用程序图标所需的 .icns 文件,如下所示:

iconutil -c icns -o Chapter14.app/Contents/Resources/chapter14.icns chapter14.iconset/

打包发布

现在元数据已经创建,我们可以创建 macOS 应用程序包所需的目录结构。文件的位置很重要,如下所示:

文件 pathDescription

myapp.app/Contents/Info.plist 前面概述的应用程序元数据
myapp.app/Contents/Resources/myapp.icns ICNS 格式的应用程序图标
myapp.app/Contents/MacOS/myapp 应用程序的可执行文件

在创建这些目录并将文件移动到正确的位置后,你就拥有了一个完整的应用程序包。可以通过双击图标来执行它,并且可以以这种状态进行分发。安装过程是将此图标拖动到计算机的 Applications 文件夹中,如下所示:

图片

创建 .app 目录结构和添加元数据可以创建一个 macOS 应用程序

在 Finder 中查看结果,我们看到新目录被识别为应用程序,其 .app 扩展名被隐藏,图标与我们之前设置的一样。你可以像其他任何应用程序一样启动、安装或删除此应用程序:

图片

我们的 .app 目录显示为它所描述的应用程序

Windows

Windows 中的应用程序元数据嵌入到可执行文件中,而不是在附加文件中。为此,我们创建元数据文件,然后使用资源工具将它们包含在最终的执行文件中。

创建元数据文件

要包含应用程序元数据,我们创建一个类似于 第三章,“Go to the Rescue!” 和 第四章,“Walk – Building Graphical Windows Applications” 中使用的应用程序清单文件,当时我们使用 Common Controls 小部件集(通过 Walk 和 andlabs UI)构建应用程序。assemblyIdentity 实例的内容用于确定有关可执行文件的元数据。对于平台无关的 GUI,文件应如下所示:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly  manifestVersion="1.0" >
   <assemblyIdentity version="1.0.0.0" processorArchitecture="*" name="Chapter14" type="win32"/>
</assembly>

然而,如果你使用 Walk、andlabs UI 或其他需要在清单文件中列出依赖项的工具集,请不要删除 <dependency> 部分。

要添加可能对用户有用的更多元数据(例如,产品名称和版本),你需要手动设置额外的值。来自 github.com/josephspurrier/goversioninfo/ 的 goversioninfo 工具是添加这些值的简单方法。请注意,你只能写入 .syso 文件一次,因为再次运行这些工具将覆盖之前的内容。

为了准备我们的图标以供 Windows 使用,它必须被转换成 .ico 文件(微软图标格式)。虽然 Windows 中没有预安装的图标转换工具,但有许多付费应用程序可以工作。如果你更喜欢免费解决方案,有一些网站提供免费图像转换服务。如果你的开发平台是 Linux 或 macOS,你可以安装 icotool,它支持 .ico 格式。

打包发布

为了打包发布的数据,我们将此元数据嵌入到应用程序二进制文件中。这样做需要创建一个二进制资源文件(以.syso结尾),它将封装清单和图标文件。最简单的方法是使用第四章,Walk – 构建图形 Windows 应用程序中使用的rsrc工具。如果您尚未安装rsrc,可以使用go get github.com/akavel/rsrc进行安装。我们告诉工具在哪里找到清单和图标文件,它将在当前目录中输出一个rsrc.syso文件,如下所示:

rsrc -manifest myapp.exe.manifest -ico myapp.ico

如果您正在为 64 位目标编译,您需要指定一个额外的-arch amd64参数。重要的是生成的资源文件与您正在编译的应用程序具有相同的架构(i386 或 amd64)。

现在元数据已经打包到资源文件中,我们必须重新构建我们的项目。确保这次,您添加了-ldflags="-H windowsgui"参数,否则生成的应用程序在启动时将显示终端窗口:

go build -ldflags="-H windowsgui"

一旦构建完成,我们将创建一个包含图标和元数据的 Windows 可执行文件。现在您可以从命令行或通过双击图标来启动它:

在生成资源包之后构建应用程序将自动包含数据

在这里,我们看到 Windows 资源管理器中的相同目录。在下面的屏幕截图中,应用程序位于左侧,使用我们包含的图标显示:

我们嵌入图标的 Windows 应用程序

这些过程很慢,并且可能容易出错。为了避免手动执行此过程,我们将探讨可以自动化此过程的工具。

跨平台打包工具

如您所见,每个操作系统都需要非常不同的打包方式。此外,通常使过程变得简单的工具通常是平台特定的,这使得从单个系统构建变得更加困难。GUI 应用程序的发行是一个 Go 工具也缺乏的领域。Go 语言非常适合快速创建跨平台软件,但它并不是为了处理图形应用程序打包的复杂性而设计的。

在第二部分,使用现有小部件的工具包中,我们探讨了非常成熟的工具包,但它们要么不是为 Go 编写的,要么不是为跨平台设计的,因此它们不提供我们可以使用的工具。第三部分,现代图形工具包则研究了更专注于提供图形功能而不是应用程序生命周期的工具包,因此也不提供合适的工具。

fyne 打包

一个例外是 Fyne 项目,因为它旨在提供全应用生命周期的 API,因此工具支持在多个平台上分发完整的桌面应用程序。虽然该项目仍处于早期阶段,但它确实有一个可以帮助应用打包的工具(即使你尚未在代码中使用 Fyne)。fyne package 命令旨在生成并打包应用程序在 macOS、Linux 或 Windows 上分发的所有必需元数据。使用 -os <platform> 参数(使用 "darwin"、"linux" 或 "windows" 之一)将在当前目录中创建一个完整打包的应用程序。在执行此命令之前,应用程序应该已经编译为发布版本。

例如,我们可以使用 fyne package -os linux 从 Linux 计算机创建 macOS 应用程序包。有许多其他参数可以更改应用程序的内容,其中最有用的是 -icon <filename> 参数(这是必需的)。如果你之前没有使用 Fyne,那么应该使用以下命令安装:go get fyne.io/fyne/cmd/fyne

图片

在 Linux 上使用 "fyne package" 构建 macOS 应用程序包

如前述截图所示,该工具从 Linux 终端生成了一个 .app 目录结构(它定义了一个 macOS 应用程序)。我们为 GOOS 环境和 fyne package-os 参数使用了相同的平台名称。建议为单个平台构建并打包,然后再更改目标操作系统,以避免输出包中可能出现的错误。

在本节中构建的应用程序可以立即分发。上传到网站或以其他方式共享文件都可以,但我们希望为用户提供一个完全无缝的过程。让我们通过查看直接向最终用户交付桌面应用程序的各种分发渠道来结束本章。

分发到平台市场

大多数操作系统现在都有一个中心位置用于发现和安装应用程序。苹果创建了 Mac App Store,Windows 有微软商店,每个 Linux 发行版都有自己的首选包管理器。在平台市场中列出(并由其托管)的应用程序可以显著增加你预期的用户数量,并降低相关的托管成本。当与精心准备的元数据(如本章开头所述)相结合时,市场可以轻松成为你最大的分发渠道。如何将这些目录中的应用程序包含在内,每个平台都有其特定的方法,因此我们将依次查看每个平台的过程。

Mac App Store

Mac App Store 是苹果著名的 iOS App Store 的桌面版本。它提供了成千上万的应用程序可供购买、下载或赠送给他人。此外,还有精选内容,包括各种类别中最受欢迎的应用程序列表,以及员工精选和推荐软件。如果有人购买,苹果还提供教育折扣以及免费提供给家庭成员的副本。您还可以使用礼品卡兑换购买应用程序或订阅的费用。不幸的是,Mac App Store 不能在线浏览,因为它需要预安装在兼容 Mac 电脑上的 App Store 软件。

除了安装开发工具之外,您还需要注册苹果开发者计划。如果您还不是会员,可以在此网站上注册:developer.apple.com/programs/enroll/。开发资源可以免费访问,但访问代码签名工具需要支付年度订阅费用,这些工具是发布软件到其任何 App Store 所必需的。

打包

提交应用程序的打包由 XCode 工具(您应该已经安装了)管理。该过程针对使用 XCode 构建的应用程序提交进行了优化,因为它不支持 Go,所以我们有一些手动步骤需要完成。

我们之前创建的应用程序包(针对 macOS 分发)在我们可以上传到 App Store 之前必须进行签名。代码签名是一个复杂的设置过程,因此在本描述中,我们假设您已经安装了分发证书。您需要记下证书的名称(使用“密钥链访问”查找您的开发者证书),然后在以下命令中使用该名称:

codesign -s "CertificateName" /path/to/MyApp.app

生成的应用程序包已准备好上传到 App Store Connect 网站进行验证。

上传

App Store 应用程序通过 App Store Connect 网站(在appstoreconnect.apple.com/)进行管理。使用您的苹果开发者账户登录并创建一个新的应用程序(如果您还没有这样做)。这是您添加将在商店中显示的元数据的地方——务必仔细检查信息,因为一些数据发布后无法更改。精心挑选的描述和截图将有助于您的应用程序更容易被发现。在此应用程序定义中,您需要开始准备一个新的版本,包括适当的版本号和相关支持信息。您可能会注意到您还不能选择构建版本——为了启用此功能,我们首先需要上传编译包。

应用程序加载工具是上传新构建的最简单方法:打开应用并使用您的 Apple ID 登录。登录后,您将被要求选择要上传的应用程序;选择匹配的应用程序并继续上传。完成后,构建将出现在 App Store Connect 网站上(您可能需要刷新页面)。如果您更喜欢用于管理进度的命令行工具,可以使用xcrun altool,它提供相同的功能。一旦您选择了这个新构建,您就可以点击“提交审核”按钮开始审核流程。

审核过程

一旦应用程序提交审核,它将经过一系列自动的代码检查。此过程验证应用程序不包含元数据或代码签名中的明显错误,并执行代码分析以确保您没有使用苹果公司专有的或受限制的 API。假设这些自动检查通过,则应用程序将被发送给 App Store 审核团队的一员进行最终接受。

审核团队会检查您的应用程序的质量、可靠性、是否符合人类界面指南HIG - developer.apple.com/app-store/review/),以及它是否符合商店收录的其他标准。此过程通常需要一两天,但新应用程序的首次发布可能需要更长的时间。一旦过程完成,您的软件将在 App Store 上可供购买或下载。在您的第一周分发中,它甚至可能被包括在“新功能和值得注意”部分。

微软商店

微软商店是查找和安装所有当前 Windows、Windows Phone 和 Xbox 设备的软件、应用程序和游戏的官方位置。除了提供托管和搜索功能外,它还处理非免费软件的支付,并支持折扣和优惠券。您可以通过在线浏览微软商店的内容(在www.microsoft.com/store/apps)或使用它支持的每个系统上的商店应用程序。

要将应用程序提交到微软商店,您需要一个微软账户(如果您已登录 Windows、Xbox 或 Office 365,您可能已经有了)。您还必须开始年度订阅以访问开发者门户的相关部分。您可以在appdev.microsoft.com/StorePortals登录和注册。

打包

创建用于上传到微软商店的应用程序包所需的工具包含在 Windows 软件开发工具包中。如果您尚未安装,可以从developer.microsoft.com/en-us/windows/downloads下载,无论是作为 Visual Studio 的一部分还是作为单独的包。

要将应用程序上传到商店,我们必须创建一个.appx文件(一个应用程序包)。这需要一个额外的清单文件,名为AppxManifest.xml,其中包含正在打包的应用程序的元数据。其内容在微软网站上进行了文档化,网址为docs.microsoft.com/en-us/uwp/schemas/appxpackage/how-to-create-a-basic-package-manifest。生成的清单文件应在Applications部分至少包含一个Application元素。

从我们的源文件创建软件包时,我们使用MakeAppx.exe命令,如下面的代码片段所示。通过使用/d参数,我们可以指定要打包的文件目录 - 如果您只想使用文件的一部分,您可以使用映射文件并通过/f参数指定它:

MakeAppx.exe bundle /d sourcedir /p myapp.appx

一旦创建.appx文件,它必须进行签名。可以使用SignTool.exe命令对应用程序包进行签名。设置用于应用程序签名的证书超出了本章的范围,但微软开发者门户上的文档将指导您完成此过程。请确保清单文件中列出的发布者与您创建用于签名包的证书相匹配。

上传

完成的软件包应上传到开发者门户中的“软件包”页面。在准备上传时,请确保您的所有应用程序元数据都已添加到正确的位置,以便人们可以轻松找到您的软件。

一旦软件包上传,它将检查各种可能导致其无法发布的错误。如果您遇到任何警告,您需要从门户中删除已上传的构建版本并修复问题。一旦解决,您需要重新打包、重新签名并上传新的软件包以进行重新测试。

审查

一旦您的软件包上传并通过初步验证,它将被添加到队列中等待审查。微软的工作人员将审查您的应用程序的正确性和适用性,并验证其质量是否足够高,可以包含在商店中。假设所有这些检查都通过,他们将发布它以便在您在提交过程中指定的设备上分发。

Linux 软件包管理器

多年来,Linux 发行版在处理软件包分发方面享有良好声誉。桌面系统可能有一个图形化的软件包管理应用程序,它提供易于搜索的索引,包含数千个软件包。最近,各种应用程序被创建出来,以帮助软件发现(使查找新软件包更容易)。

例如 Discover(更多关于此的信息可以在 userbase.kde.org/Discover 找到)这样的应用可以在大多数系统上使用,通过配合许多不同的包管理器工作。其他一些,如 Ubuntu 软件中心,旨在通过类别、评分和其他增强元数据使特定系统的应用程序查找更加容易。

尽管有数百种不同的 Linux 发行版,但只需要少数几种打包格式来支持它们。在本节中,我们将探讨三种最流行的格式:DebianRed HatTarball。一旦为系统创建了软件包,应用程序开发者就可以将其提交到包列表中。然而,由于 Linux 是一个开源系统,你可能发现现有的包维护者可能很乐意为你做这件事!

Debian (.deb)

Debian 的分发与我们在前面创建的 .tar.gz 分发非常相似,只是增加了特定的元数据,这使得 Debian 工具能够搜索和正确安装软件。Debian 的打包在他们的网站上详细描述(wiki.debian.org/HowToPackageForDebian),但基本过程是添加元数据(Debian 化),构建符合其文件系统布局的软件包,然后(可选)对软件包进行签名,以便用户知道他们可以信任内容。

打包

提供了 dh_make 命令来自动创建所需的元数据文件,并在现有的 Linux 打包目录结构中运行它将添加必要的文件。一旦运行,你应该检查 debian/ 目录中的所有文件,根据需要更新信息。一旦添加了元数据,debuild 命令将为我们的软件创建一个 Debian 软件包。创建后,你应该使用 lintian 命令检查软件包是否存在许多常见错误。

虽然不是必需的,但你可以使用 debsigs 工具对软件包进行签名。这会创建一个签名的软件包,它提供了加密证明,表明软件包包含开发者所期望的内容。如前所述,设置证书和签名是复杂的过程,本书不涉及。

分发

一旦你的软件包准备就绪,你可以直接将其文件分发给其他 Debian 用户。然而,目标是将其包含在软件包列表中。为此,需要开始于在他们的错误跟踪器中提交“打包意图”(bugs.debian.org/cgi-bin/pkgreport.cgi?pkg=wnpp;dist=unstable))。要完成这个过程,你需要为你的软件包找到一个赞助者或成为 Debian 开发者。无论哪种情况,你都需要联系开发社区以了解更多信息;详细信息可以在 wiki.debian.org/DebianMentorsFaq 找到。

Red Hat (.rpm)

RPM 文件与 Debian 软件包非常相似,但具有不同的元数据集。同样,有一些标准工具可以帮助创建这些文件,并创建最终的软件包。首先,你应该在你的 Linux 发行版中安装 rpmrpm-build 软件包,它应该包括必要的命令。

打包

首先,我们必须创建一个 .spec 文件来描述这个软件包。rpmdev-newspec 命令可以从模板选项中提供一个,帮助你开始。根据你的应用程序信息更新内容。这个文件将包含关于你的软件的所有元数据(包括其源位置、许可证和作者)。spec 文件还提供了组装软件包所需的构建信息,以及安装脚本和更多内容。

一旦元数据完成,就可以使用 rpmbuild 命令构建软件包。第一次尝试不成功是正常的。阅读输出并根据需要更新 .spec 文件,然后重新运行构建命令。完成后的输出将是你的完成 .rpm 文件,准备进行测试。此时,你可以通过手动安装(在兼容的 Linux 计算机上)并验证软件是否按预期工作来测试该文件。

分发

现在软件包已经准备好了,可以通过你的网站进行分发——RPM 文件可以相对容易地下载和安装。Linux 发行版的包含是一个复杂的过程,每个变种的细节都不同。CentOS、Fedora 和 Red Hat Enterprise Linux 都使用 RPM 软件包格式,许多其他发行版也是如此,但提交过程并没有很好地记录。Fedora 项目有全面的文档,你可以在 fedoraproject.org/wiki/Package_Review_Process 上参考。对于大多数软件包查询,最好的方法是联系当前的发行版维护者。

打包文件 (.tar.gz)

打包文件是对应用程序的二进制(和基于源)分发的通用打包。我们在本章前面构建的 .tar.gz 软件包是一个可能的打包文件分发的例子。在 Linux 中,大多数应用程序是开源的,这意味着打包通常是从源代码而不是从编译输出进行的。我们可以使用二进制发布版,就像我们构建的那样,但请记住,一些发行版可能会根据它们自己的政策反对包含这样的软件包。

Arch Linux

Arch Linux 的包管理器 pacman 依赖于 PKGBUILD 文件来了解如何定位和安装软件包。PKGBUILD 文件是一种特定的 shell 脚本格式(命令行可执行文本文件),它描述了包的元数据、必须首先安装的任何依赖项以及包的安装过程。PKGBUILD 文件的完整详情可以在 wiki.archlinux.org/index.php/PKGBUILD 找到。一个有效的文件必须至少包含 pkgnamepkgverpkgrelarch 变量。

创建包的过程是在创建 PKGBUILD 文件后运行 makepkg 命令。这将执行脚本文件中定义的步骤并准备生成的包。建议使用 namcap 命令检查完成的包文件,以确保没有犯下某些常见的错误。

新创建的包可以提交到 Arch 用户仓库,其他 Arch Linux 用户将能够安装它。随着时间的推移,值得注意的包可以从该位置提升到官方仓库。有关提交您的包的详细信息可以在 wiki.archlinux.org/index.php/Arch_User_Repository#Submitting_packages 找到。

Gentoo Linux

Gentoo Linux 是(并非唯一)基于源的分发版。这意味着包系统仅包含描述如何下载和安装软件的指令文件。此文件称为 ebuild 文件,类似于之前的 PKGBUILD 文件,包含有关元数据、依赖项和构建指令的信息。与 Arch Linux 不同,此描述文件是完整的分发版 – 打包者不使用元数据文件构建二进制分发包。

将新包提交给 Gentoo Linux 的过程(一旦创建了 ebuild 文件)可以在他们的社区文档 wiki.gentoo.org/wiki/Submitting_ebuilds 中找到。与其他发行版一样,您需要成为开发社区的一部分才能自己添加包,但您可以说服现有的开发者为您维护该包。

其他

许多其他 Linux 发行版使用类似的打包系统,但在此章节中提及所有这些会过于冗长。每个网站都会提供有关如何完成包并将其提交到其应用程序列表的信息。

容器

应用程序分发的越来越流行的方法是应用容器化。这种方法意味着每个应用程序都被打包成一个容器,类似于 Docker 和其他工具为基于服务器的软件所做的那样。应用程序容器是一个模拟文件系统的单个文件,应用程序被安装在其中。应用程序可以从网站或通过包管理器下载,并且不需要安装即可运行。相同的容器文件可以在任何 Linux 发行版上运行;甚至还有一些容器格式旨在支持多个操作系统。AppImage、Snap 和 Zero Install 是一些流行的格式,每个都有其特定的优势或目标受众。如果您想为您的应用程序分发单个 Linux 软件包,并且不太关心将其包含在平台软件列表中,这种方法可能适合您。

摘要

在本章的最后,我们探讨了如何打包和分发基于 Go 的图形应用程序。与命令行或系统工具的分发不同,交付 GUI 应用程序的过程需要额外的元数据和打包。图形应用程序的用户期望以特定方式找到和安装软件,这取决于他们的操作系统。我们首先探讨了良好元数据的重要性以及如何选择一个图标和描述来吸引潜在用户尝试我们的新软件。然后我们学习了如何将资源打包到基于 Go 的应用程序中。由于该语言是为单二进制分发而设计的,我们必须在分发之前将所有支持文件合并到可执行文件中。

为不同平台打包可能会很复杂,因此我们介绍了构建原生外观的图形包所需的步骤,包括 macOS、Windows 和 Linux。每个包都有自己的元数据格式和包结构,但我们能够为每种特定格式打包我们的简单应用程序。这一步骤产生的包可以通过网站或其他文件共享机制进行分发。然而,我们希望将应用程序分发到用户期望找到的地方——系统应用商店。因此,我们完成了为每个平台市场准备和分发包的过程。Windows 和 macOS 商店为应用程序在发布后赚取收入提供了机会,而 Linux 软件列表将有助于提高我们的软件包的可见性。

通过学习使用 Go 语言构建 GUI 的各种方法并探索可用的工具包,我们已经完成了从零到有、从有到完整的图形应用程序的构建。希望你在学习过程中学到了很多,并且成功创建了原本想要构建的应用程序——仅使用 Go 语言来支持高效、可维护且美观的用户界面。

第十五章:安装详情

为了运行本书中的代码示例,你需要安装 Go 编译器和 C 编译器(以支持 Cgo)。如果其中任何一个没有设置,这个附录将指导你完成安装。

安装 Go

作为一种相对较新的编程语言,Go 并没有预安装在许多操作系统上。本节将指导任何尚未设置的用户如何设置它。

Microsoft Windows

在 Windows 上配置开发环境可能很复杂,因为默认情况下没有安装很多工具。因此,有多个选项可以使用外部工具和包(如 MSYS、MinGW 和 Ubuntu 子系统)进行设置,但探索这些内容超出了本书的范围。幸运的是,可以在不需要许多额外开发工具的情况下开始开发 Go 应用程序。

Git

首先,如果你还没有这样做,你需要下载并安装 Git。下载可在git-scm.com/download/win找到,当你访问该页面时,它应该会自动开始。运行下载的文件,设置程序将开始(如果出现通知表示此程序未经验证,请点击“仍然安装”按钮)。对于大多数用户,默认选项应该可以工作——确保选中“从 Windows 命令提示符使用 Git”以避免以后更多的麻烦。

完成这些后,打开一个命令提示符窗口(如果没有快捷方式,可以从开始菜单搜索 cmd)并输入 git --version——你应该会看到以下类似的输出:

图片

通过检查版本来测试 Git 是否已安装

Go

接下来,你应该安装 Go——可以在golang.org/dl/找到。在这个页面上,选择推荐的 Microsoft Windows 下载(文件名将以 .msi 结尾)。与 Git 安装一样,你需要运行下载的文件,并可能确认你想要继续安装未经验证的程序。再次提醒,默认值应该合适——如果你更改了任何配置,请确保适当地更新以下行。

安装程序完成后,返回你的命令提示符并输入 go version,它应该输出版本号然后退出:

图片

通过检查版本来测试 Go 是否已安装

环境

如果前面的安装成功,那么你的环境应该已经正确配置。如果你在安装过程中做了某些更改,你可能需要调整你的环境配置:

图片

检查我们的 %GOPATH%/bin 是否出现在 %PATH% 中

在前面的输出中,您可以看到 Git\binGo\bin%GOPATH% 已包含在您的 %PATH% 环境中,以便查找可执行文件。如果情况不是这样,您可能需要注销或重启以使设置生效。

Apple macOS

许多开发者工具(包括 Git)都是作为 XCode 软件包的一部分安装的。如果您还没有安装 Xcode 用于其他开发工作,您可以从 Mac App Store 中免费下载。安装完成后,您还应设置命令行工具——为此,请转到 Xcode 菜单并选择首选项,然后下载和安装命令行工具。

如果您不确定是否已经安装了这些,那么请打开终端应用程序并执行 xcode-select——如果已安装,它将正常执行;如果没有,您将被提示运行安装:

如果未安装开发者工具,将显示安装对话框

除了这些工具之外,您还需要安装 Go。您可以从 golang.org/dl/ 获取下载包——点击 Apple macOS 的特色下载链接并运行下载的安装程序包。您可能需要关闭任何打开的终端窗口以更新您的环境变量。

Linux

在 Linux 上设置先决软件通常只需要安装您发行版的正确软件包。git 软件包将提供版本控制工具,而 Go 语言应包含在 gogolang 软件包中。安装这些软件包将提供运行本书中示例所需的必要命令。您可能需要将 ~/go/bin 添加到您的 PATH 环境变量中,以便能够运行 Go 安装的工具。

设置 Cgo

要使用本书中探索的大多数库和功能,您还需要使用 Cgo(Go 内置的 C 语言桥接器)。Cgo 需要操作系统可用的一个 C 编译器和一些相关工具。本节概述了如何设置它们。

Microsoft Windows

要在 Windows 上使 Cgo 正常工作,您需要安装 gcc(或兼容的)编译器。如果已经安装了 Visual Studio,那么您可能已经有了一个 C 编译器。对于那些还没有安装的人来说,本节将指导您在命令行上配置构建环境。根据我的经验,最简单的方法是下载并安装 MSYS2(一个软件发行版)并为 Windows 构建一个平台。使用 MSYS2,我们可以安装 mingw-w64 软件包,这些软件包提供了一个名为 mingw 的针对 Windows 的 gcc 项目的更新版。

www.msys2.org/下载安装程序——根据你的计算机架构选择 32 位(i686)或 64 位(x86_64)版本。下载完成后,运行此安装程序,它将下载基本包到你的计算机上,包括包管理器(pacman)。完成后,它将启动 MSYS 命令提示符,该提示符将用于任何需要 Cgo 的项目。你需要更新PATH环境变量以使用现有的 Go 和 Git 安装:

图片

MSYS 控制台提供了访问许多额外包的方式

一旦设置了命令行,包管理器就被用来安装 C 编译器和工具链以及pkg-config(Cgo 用来查找包的工具):

pacman -S mingw-w64-x86_64-toolchain mingw-w64-x86_64-pkg-config

一旦完成,你将能够从 MSYS 命令行执行gccpkg-config——这些工具对于 Cgo 的正常设置是必不可少的。以下输出可能会显示错误,但它表明工具已被找到:

图片

MSYS 上的 Pacman 提供了我们需要的包

Apple macOS

要在 macOS 上启用 Cgo 支持,你需要 Xcode 发行版中包含的开发工具。如果你之前在你的 Macintosh 计算机上做过开发工作,或者如果你遵循了安装 Git 工具的先前说明,那么你已经有这个安装了。如果你已经安装了 Xcode 但之前没有使用过命令行工具,那么你可以通过以下命令从终端窗口安装这些工具:

xcode-select --install

Linux

Linux 上的 Cgo 需要gcc的存在,这通常在 Linux 环境中由其他包安装。如果从终端执行gcc时出现错误,例如gcc: command not found,那么你需要从你的系统包管理器安装gcc包。

第十六章:交叉编译器设置

当构建需要访问原生 API 的应用程序时,我们可以使用 CGo。尽管对于常规开发来说并不困难,但这确实使得交叉编译变得更加复杂。对于您想要为每个目标平台构建的,都必须有一个 C 编译器知道如何创建原生二进制文件。本附录概述了设置本书记载中之前提到的每个组合所需的交叉编译目标步骤。

大多数 Go 应用程序不需要为交叉编译设置此环境,因为 Go 编译器被设计为支持所有平台。如果生成的应用程序(或它们使用的工具包)通过 CGo 链接到操作系统库,则需要额外的步骤。

使用 CGo 为 macOS 进行交叉编译

当为 macOS 进行交叉编译时,需要安装苹果的 SDK(软件开发工具包)以及一个合适的编译器。Windows(使用 MSYS2——在之前的 附录,安装详情)和 Linux 的说明几乎相同;主要工作是安装 macOS SDK。

从 Linux 或 Windows 到 macOS

为了准备交叉编译到 Darwin,我们必须安装 macOS SDK 和一个可以使用的构建工具链。最简单的方法是使用 osxcross 项目。以下示例展示了如何下载和安装 SDK 和工具,以便在没有使用 Macintosh 计算机的情况下为 macOS 构建应用程序。此说明使用 Linux,但对于使用 MSYS2 或 Cygwin 命令提示符的 Windows 开发者来说,过程是相同的。

我们将使用 clang 而不是 gcc,因为它的设计更便携。为了使此过程正常工作,您需要使用您的包管理器安装 clangcmakelibxml2-dev

  • 在 Linux 上使用:pacman -S clang cmake libxml2-dev(或 apt-getyum,具体取决于您的发行版)

  • 在 Windows 上使用:pacman -S mingw-w64-x86_64-clang mingw-w64-x86_64-cmake mingw-w64-x86_64-libxml2

接下来,我们需要下载 macOS SDK,它包含在 Xcode 中。如果您还没有 Apple 开发者账户,您需要注册并同意他们的条款和条件。使用此账户,登录到 developer.apple.com/download/more/?name=Xcode%207.3 下载 XCode.dmg(推荐使用 osxcross 的 7.3.1 版本)。

然后,我们可以安装 osxcross 工具——首先使用 git clone https://github.com/tpoechtrager/osxcross.git 下载它,然后切换到下载的目录。使用这些工具,我们可以使用提供的包工具 ./tools/gen_sdk_package_darling_dmg.sh <path to Xcode.dmg> 从下载的 Xcode.dmg 文件中提取 macOS SDK。生成的 MacOSX10.11.sdk.tar.xz 文件应该复制到 tarballs/ 目录。

最后,通过执行 ./build.sh 来构建 osxcross 编译器扩展。之后,应该会创建一个名为 target/bin/ 的新目录,你需要将其添加到你的 PATH 环境变量中。现在可以通过设置环境变量 CC=o32-clang 来在 CGo 构建中使用编译器。关于此过程以及如何适应其他平台的更多详细信息,可以在 osxcross 项目网站上找到,网址为 github.com/tpoechtrager/osxcross

使用 CGo 为 Windows 进行交叉编译

从其他平台为 Windows 构建需要安装 mingw 工具链(类似于我们在 Windows 上安装以支持 CGo 的工具链)。这应该在你的包管理器中可用,名称类似于 mingw-w64-clangw64-mingw,但如果不可用,你可以直接使用 github.com/tpoechtrager/wclang 上的说明进行安装。

从 macOS 到 Windows

要在 macOS 上安装软件包,建议使用 Homebrew 包管理器。你可能已经从本书的早期章节中安装了它(例如,在设置 GTK+ 库时),如果没有,你可以从 brew.sh 下载它。一旦设置了 Homebrew,就可以使用 brew install mingw-w64 来安装编译器软件包。

安装完成后,可以通过设置 CC=x86_64-w64-mingw32-gcc(用于 C 工具链)和 CXX=x86_64-w64-mingw32-g++(用于 C++ 需求)来使用编译器与 CGo 一起。

从 Linux 到 Windows

在 Linux 上安装只需在发行版的列表中找到正确的软件包即可。例如,对于 Debian 或 Ubuntu,你会执行 sudo apt-get install gcc-mingw-w64

安装完成后,可以通过设置 CC=x86_64-w64-mingw32-gcc(用于 C 工具链)和 CXX=x86_64-w64-mingw32-g++(用于 C++ 需求)来使用编译器与 CGo 一起。

使用 CGo 为 Linux 进行交叉编译

要为 Linux 进行交叉编译,我们需要一个可以构建 Linux 二进制文件的 GCC 或兼容编译器。在 macOS 上,最简单的平台是 musl-cross(musl 有许多其他优点,你可以在 www.etalabs.net/compare_libcs.html 上了解更多)。在 Windows 上,linux-gcc 软件包将是合适的。让我们逐一处理这些步骤。

从 macOS 到 Linux

要为 Linux 交叉编译安装依赖项,我们将再次使用 Homebrew 包管理器——请参阅前面的章节或 brew.sh/ 以获取安装说明。使用 Homebrew,我们将通过打开终端并执行以下命令来安装适当的软件包(HOMEBREW_BUILD_FROM_SOURCE 变量解决了 musl-cross 依赖于可能过时的库版本的问题):

  • export HOMEBREW_BUILD_FROM_SOURCE=1

  • brew install FiloSottile/musl-cross/musl-cross

安装完成后(这可能需要一些时间,因为它需要从源代码构建完整的编译器工具链),你应该能够为 Linux 进行构建。为此,你需要设置环境变量,CC=x86_64-linux-musl-gccCXX=x86_64-linux-musl-g++

从 Windows 到 Linux

使用 MSYS2,我们可以安装gcc包以提供 Linux 的交叉编译:

pacman -S gcc

安装完成后,我们可以通过设置环境变量 CC=gcc 来告诉我们的 Go 编译器使用 gcc。现在,按照你当前示例中的说明进行编译应该会成功,例如以下内容:

GOOS=linux CGO_ENABLED=1 CC=gcc go build

在这个阶段,你可能可能会看到由于缺少头文件而导致的额外错误。为了解决这个问题,你需要搜索并安装所需的库。例如,如果你的错误表明 SDL 找不到,那么你会使用 pacman -Ss sdl 来搜索要安装的正确包。如果你找不到合适的包,你可能需要安装 Cygwin www.cygwin.com/(因为它有一个更大的包库)或者 Windows 子系统(WSL)docs.microsoft.com/en-us/windows/wsl/(因为它将完整的 Linux 发行版带到你的 Windows 桌面)。

第十七章:GUI 工具包比较

在这本书中,我们探讨了 Go 语言的七个流行 GUI 工具包。建议在决定在项目中使用哪个工具包之前,阅读每一章并了解每个工具包的优点和潜在缺点。为了快速参考,以下表格应有助于根据多个重要因素缩小选项范围:

名称 许可证 多平台 活跃状态 主题 原生小部件 惯用性 交叉编译 丰富性
Walk BSD
andlabs UI MIT !¹ -
Go-GTK LGPL² !³ !
qt LGPL !³ ✔⁵
Shiny BSD !
nk MIT ! -
Fyne BSD !

¹ andlabs UI 的顶层、声明性层是为 Go 开发设计的;然而,它暴露了来自底层库的 C 语言惯用语。

² go-GTK库有多种许可证,但包含 GTK+小部件需要 LGPL。

³ 使用主题功能,可以获得看起来像原生的用户界面。

⁴ 交叉编译应该是可能的,但需要为每个目标系统编译许多库。

⁵ 支持交叉编译的方式是通过使用容器,而不是直接在开发计算机上。

⁶ 主题可以从代码中设置,但没有用户可安装的主题集合。

上述表格应有助于选择要使用的 GUI 技术。没有单一的最佳选项,选择应基于您最重要的标准。以下是对每个标题的说明:

许可证:每个工具包都可在开源许可证下获得,允许开源或闭源和商业使用。然而,作为一个静态编译的语言,使用 LGPL 时会有一些复杂性。如果与 LGPL 项目静态链接,您的代码应分布在同一许可证下,或者您需要提供一个不包含静态链接库的替代编译输出。在这种情况下,最简单的选项可能是将工具包作为动态编译库,用户在您的应用程序可以运行之前必须安装它。一些工具包可在单独的商业许可证下获得,可以通过付费来避免许可问题。

多平台支持:勾选标记表示工具包至少支持 Linux、macOS 和 Windows。一些选项提供对更多平台的支持。

活跃状态:项目是否处于活跃维护状态?活跃的项目并不保证商业支持的可获得性。

主题:勾选标记表示用户可安装的主题,可以自定义应用程序的外观。一些工具包支持使用代码设置主题,允许应用程序开发者更改外观和感觉。

原生组件: 框架是否使用原生系统组件?勾选表示应用程序将使用系统组件。某些工具包通过安装特殊主题,使得组件看起来像系统组件。

惯用性: 工具包是否构建得与 Go 语言惯用性相匹配?这可能对每个项目都不重要,但它可以提高开发速度和维护的便捷性。使用惯用性工具包的应用程序也更容易调试。

交叉编译: 对于 Go 应用程序来说,交叉编译通常很重要。由于与底层图形库一起工作的复杂性,并非所有工具包都完全支持这一点。

丰富性: 这是对组件选择完整性的衡量。向上箭头表示可以使用提供的功能构建一个完整的应用程序。水平条(既不向上也不向下)表示大多数应用程序应该是可能的,但可能需要使用可用的组件构建复杂组件。向下箭头表示组件集最小——简单或中等复杂度的应用程序是可能的,但许多组件需要从提供的基本组件构建。

第十八章:将 GoMail 连接到真实电子邮件服务器

在本书的许多章节中,我们探讨了构建基于 Go 的电子邮件应用GoMail的方法。所有这些示例都使用了虚拟电子邮件服务器——client包中的某些代码,这使得我们能够在不需要管理服务器通信的情况下构建邮件客户端的 GUI 部分。在本附录的最后部分,我们将逐步添加代码以连接到真实电子邮件服务器。

在第十二章的探索基础上,并发、网络和云服务(特别是认证—OAuth 2.0示例),我们将使用 Gmail 公共 API 和 Go 语言的内置功能来实现这一点。

下载 Gmail 凭证

第十二章并发、网络和云服务中,我们仅使用标准库编写了 OAuth2 处理程序和 Gmail 集成。对于这次最后的代码探索,我们将使用 Google 为与 Gmail 服务器交互而创建的有用库。要使用此库,我们需要以不同的格式(credentials.json)的客户端凭证。要访问此文件,请登录您的 Google 账户,并转到 Go 快速入门页面developers.google.com/gmail/api/quickstart/go。在此处,您需要点击“启用 Gmail API”,然后下载“客户端配置”。这将下载我们将在下一节创建服务器提供者中初始化库所需的credentials.json文件。

下载凭证文件后,您需要使用go get -u google.golang.org/api/gmail/v1go get -u golang.org/x/oauth2/google安装两个必需的库。然后,您就可以添加代码来连接到 Gmail 并访问您的电子邮件了。

创建服务器提供者

以下代码概述了位于本书仓库client包中的gmail.go文件的包含内容。如果您想直接尝试此功能,只需将您的credentials.json文件复制到当前目录,然后跳到下一节更新示例以使用 Gmail

我们首先通过复制来自 Google 的 Gmail 快速入门 Go 文件中的getClient()getTokenFromWeb()tokenFromFile()saveToken()函数来添加必要的 OAuth2 设置和令牌存储。这些函数与之前创建的 OAuth2 代码非常相似,但与 Google 库配合得更好。

下载收件箱消息

接下来,我们需要从已保存的凭证文件(在当前目录中)设置客户端。我们添加了一个新函数来解析数据,设置身份验证,并使用以下代码配置 *gmail.Service

func setupService() *gmail.Service {
   b, err := ioutil.ReadFile("credentials.json")
   if err != nil {
      log.Fatalf("Unable to read client secret file: %v", err)
   }

   config, err := google.ConfigFromJSON(b, gmail.GmailReadonlyScope,
      gmail.GmailComposeScope)
   if err != nil {
      log.Fatalf("Unable to parse client secret file to config: %v", err)
   }
   client := getClient(config)

   srv, err := gmail.New(client)
   if err != nil {
      log.Fatalf("Unable to retrieve Gmail client: %v", err)
   }

   return srv
}

从此函数返回的服务将用于对 Gmail API 的每次后续调用,因为它包含身份验证配置和凭证。接下来,我们需要通过下载用户收件箱中的所有消息来准备电子邮件列表。使用 INBOX 标签 ID 来过滤未存档的消息。此函数请求消息列表并遍历元数据以启动每个消息的完整下载。对于完整实现,我们需要添加分页支持(响应包含 nextPageToken,它指示更多数据何时可用),但此示例将处理多达 100 条消息:

func downloadMessages(srv *gmail.Service) {
   req := srv.Users.Messages.List(user)
   req.LabelIds("INBOX")
   resp, err := req.Do()
   if err != nil {
      log.Fatalf("Unable to retrieve Inbox items: %v", err)
   }

   var emails []*EmailMessage
   for _, message := range resp.Messages {
      email := downloadMessage(srv, message)
      emails = append(emails, email)
   }
}

要下载每条单独的消息,我们需要实现之前提到的 downloadMessage() 函数。对于指定的消息,我们使用 Gmail Go API 下载完整内容。从结果数据中,我们从消息头中提取所需信息。除了解析 Date 头之外,我们还需要解码消息正文,它是以序列化、Base64 编码的格式:

func downloadMessage(srv *gmail.Service, message *gmail.Message) *EmailMessage {
   mail, err := srv.Users.Messages.Get(user, message.Id).Do()
   if err != nil {
      log.Fatalf("Unable to retrieve message payload: %v", err)
   }

   var subject string
   var to, from Email
   var date time.Time

   content := decodeBody(mail.Payload)
   for _, header := range mail.Payload.Headers {
      switch header.Name {
      case "Subject":
         subject = header.Value
      case "To":
         to = Email(header.Value)
      case "From":
         from = Email(header.Value)
      case "Date":
         value := strings.Replace(header.Value, "(UTC)", "", -1)
         date, err = time.Parse("Mon, _2 Jan 2006 15:04:05 -0700",
            strings.TrimSpace(value))
         if err != nil {
            log.Println("Error: Could not parse date", value)
            date = time.Now()
         } else {
            log.Println("date", header.Value)
         }
      }
   }

   return NewMessage(subject, content, to, from, date)
}

decodeBody() 函数如下所示。对于纯文本电子邮件,内容位于 Body.Data 字段。对于多部分消息(其中正文为空),我们访问多个部分中的第一个并对其进行解码。解码 Base64 内容由标准库解码器处理:

func decodeBody(payload *gmail.MessagePart) string {
   data := payload.Body.Data
   if data == "" {
      data = payload.Parts[0].Body.Data
   }
   content, err := base64.StdEncoding.DecodeString(data)
   if err != nil {
      fmt.Println("Failed to decode body", err)
   }

   return string(content)
}

准备此代码的最终步骤是完成 EmailServer 接口方法。ListMessages() 函数将返回 downloadMessages() 的结果,我们可以设置 CurrentMessage() 以返回列表顶部的电子邮件。完整实现见本书的代码库。

发送消息

要发送消息,我们必须将数据打包成原始格式以通过 API 发送。我们将重新使用 第十二章 中的 Post 示例的 ToGMailEncoding() 函数,并发、网络和云服务。在编码电子邮件之前,我们设置适当的 "From" 电子邮件地址(务必使用您登录的电子邮件地址或已注册的别名)和发送时的当前日期。编码后,我们将数据设置为 gmail.Message 类型的 Raw 字段,并将其传递给 Gmail 的 Send() 函数:

func (g *gMailServer) Send(email *EmailMessage) {
   email.From = "YOUR EMAIL ADDRESS"
   email.Date = time.Now()

   data := email.ToGMailEncoding()
   msg := &gmail.Message{Raw:data}

   srv.Users.Messages.Send(user, msg).Do()
}

这段最小代码足以实现发送消息。所有艰苦的工作都由之前的设置代码完成——它提供了 srv 对象。

监听新消息

尽管谷歌提供了使用推送消息的能力,但设置非常复杂——因此,我们将改为轮询新消息。每 10 秒,我们应该下载任何到达的新消息。为此,我们可以使用历史 API,该 API 返回自历史特定点(使用StartHistoryId()设置)之后出现的任何消息。HistoryId是一个按时间顺序标记消息到达顺序的数字。在我们可以使用历史 API 之前,我们需要一个有效的HistoryId——我们可以通过在downloadMessage()函数中添加以下行来实现:

g.history = uint64(math.Max(float64(g.history), float64(mail.HistoryId)))

一旦我们有一个历史点来查询,我们需要一个新的函数,可以下载从这个时间点以来的任何消息。以下代码与前面代码中的downloadMessages()类似,但只会下载新消息:

func (g *gMailServer) downloadNewMessages(srv *gmail.Service) []*EmailMessage{
   req := srv.Users.History.List(g.user)
   req.StartHistoryId(g.history)
   req.LabelId("INBOX")
   resp, err := req.Do()
   if err != nil {
      log.Fatalf("Unable to retrieve Inbox items: %v", err)
   }

   var emails []*EmailMessage
   for _, history := range resp.History {
      for _, message := range history.Messages {
         email := downloadMessage(srv, message)
         emails = append(emails, email)
      }
   }

   return emails
}

为了完成功能,我们更新我们的Incoming()方法,使其设置通道并启动一个线程来轮询新消息。每10秒,我们将下载任何出现的新消息并将每个消息传递到创建的in通道:

func (g *gMailServer) Incoming() chan *EmailMessage {
   in := make(chan *EmailMessage)

   go func() {
      for {
         time.Sleep(10 * time.Second)

         for _, email := range downloadNewMessages(srv) {
            g.emails = append([]*EmailMessage{email}, g.emails...)
            in <- email
         }
      }
   }()

   return in
}

完整的代码可以在本书代码仓库的client包中找到。让我们看看如何在之前的示例中使用这个新的电子邮件服务器。

更新示例以使用 Gmail

在 GoMail 示例应用中的任何一个,你都需要编辑main.go中的主要服务器设置。将服务器初始化更改为将client.NewTestServer()更改为client.NewGMailServer()。在放置了credentials.json文件后,运行此新代码将获得连接到你的 Gmail 账户以读取和发送电子邮件。请注意,对于此示例,你需要从命令行运行并遵循 OAuth2 设置步骤。为了提供更好的用户体验,你可以提供一个更复杂的getTokenFromWeb()函数实现。

posted @ 2025-09-05 09:28  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报