Fyre-跨平台-GUI-应用构建指南-全-
Fyre 跨平台 GUI 应用构建指南(全)
原文:
zh.annas-archive.org/md5/ceefdd89e585c59c20db6a7760dc11f1
译者:飞龙
前言
图形用户界面的开发有着悠久的历史,导致今天形成了复杂的地形。有许多不同的方法来构建应用程序,每种方法都有其自身的优势和劣势。在这个现代世界中,我们日常生活中有如此多的不同设备,似乎没有构建许多不同的应用程序或 GUI 就无法触及整个受众。所需的技术通常是平台特定的,许多现有的技术已经跨越了几十年,留下了可以减缓新开发者和经验丰富团队步伐的遗产。
与 Go 编程语言旨在使跨所有操作系统的软件开发更容易一样,Fyne 工具包旨在以平台无关的方式赋予图形应用程序创建能力。本指南旨在帮助任何经验水平的软件工程师学习使用 Fyne 工具包构建应用程序的 API 和流程。从你的第一行 Fyne 代码到将应用程序部署到全球市场,示例和截图将引导你完成每一步。
本书面向对象
这本书既是为对构建原生图形应用程序感兴趣的 Go 开发者所写,也是为那些拥有特定平台 GUI 经验并寻求跨平台解决方案的人所写。假设读者对构建 Go 应用程序有一定的了解,但这并非必需。本书以 GUI 的历史开篇,为不熟悉此领域的人提供简要介绍,然后介绍了 Fyne 项目及其愿景和目标,旨在解决桌面和移动设备原生应用程序开发者面临的多项挑战。
通过代码片段和实例,各个级别的开发者都应能够成功构建他们的第一个 Fyne 应用程序。除了在您的计算机和移动设备上运行这些应用程序外,您还将了解准备和上传过程,以便将应用程序部署到应用商店和市场。
本书涵盖内容
第一章,GUI 工具包和跨平台开发简史,简要回顾了图形应用程序背后的历史以及用于开发它们的工具包是如何随着时间的推移而演变的。我们探讨了不同的跨平台开发方法以及为什么它很重要。到本章结束时,你将熟悉 GUI 工具包在跨平台应用程序开发中的优势和挑战。
第二章,根据 Fyne 展望未来,介绍了 Fyne 工具包及其支持所有操作系统的方法,以及受 Material Design 启发的用户界面外观和感觉。在本章中,我们将探讨 Fyne 工具包的愿景以及它是如何基于 Go 语言创建一个易于使用、跨平台的 GUI 工具包的。阅读本章后,您将了解 Fyne 旨在解决书中第一章概述的挑战,以及它如何旨在塑造未来的应用程序开发。
第三章,窗口、画布和绘图,介绍了 Fyne 工具包渲染层背后的主要 API。我们将了解如何绘制对象以及如何使用容器将它们组合起来以创建更复杂的输出。本章还涵盖了加载应用程序和管理其窗口所需的 API。我们通过一个使用图形元素和动画创建简单跨平台游戏的示例来完成本章。
第四章,布局和文件处理,在上一章中元素的手动放置基础上进行了扩展。我们将检查提供的标准布局以及它们如何组合形成复杂的用户界面结构,以及如何构建自己的布局。此外,还将涵盖文件系统抽象,它为传统文件系统和更复杂的移动数据共享方法提供标准文件访问。我们将应用所有这些知识,在章节末尾创建一个图像浏览应用程序。
第五章,小部件库和主题,介绍了 Fyne 工具包中最大的包——其小部件库。在本章中,我们将探索可用的主要小部件以及如何在构建应用程序 GUI 时使用它们。我们将了解通过主题选择如何影响它们的标准化外观和感觉,以及如何使用内置主题渲染用户偏好,如浅色或深色模式。本章通过逐步创建一个使用之前探索的许多小部件的任务管理应用程序来完成。
第六章,数据绑定和存储,探讨了帮助在 Fyne 应用程序中高效管理数据和存储的 API。我们将了解前一章中看到的小部件如何绑定到数据元素,从而避免设置和管理它们内容所需的大量代码。此外,还将展示应用程序如何管理用户偏好,以及它们如何通过数据绑定直接连接到小部件值。本章的概念通过创建一个帮助跟踪您水消耗的健康应用程序来应用。
第七章,构建自定义小部件和主题,展示了具有特定要求的应用程序如何在前几章探索的坚实基础之上构建。我们将研究开发者可以如何自定义和扩展现有小部件,或者构建完全定制的组件。我们还将看到如何加载自定义主题,以赋予应用程序更多的品牌识别度或添加自定义字体和图标。使用这些功能,我们将构建一个即时通讯用户界面,该界面显示独特的风格和自定义小部件。
第八章,项目结构和最佳实践,基于对 Go 语言有良好记录的最佳实践。我们将了解如何组织项目以保持代码整洁,并随着项目的增长促进更易于维护。我们还将探讨在用 Fyne 构建图形用户界面时,单元测试和测试驱动开发是如何成为可能并受到鼓励的。此外,在极少数需要特定平台代码的情况下,我们将看到应用程序如何调整其行为以适应不同的目标平台。
第九章,资源打包和发布准备,解释了将图形应用程序打包的过程比从命令行访问的简单二进制文件要复杂得多。在本章中,我们将了解允许 Fyne 应用与其他桌面和移动应用程序融合所需的元数据。我们还将逐步介绍打包应用程序的过程,以便它们可以被共享和安装,正如最终用户期望的平台原生应用程序那样。
第十章,分发 – 应用商店及其他,面对跨平台应用程序开发的最终挑战——分发。我们将看到应用程序是如何为公开发布做准备,以及如何与许多应用商店要求的额外数据和代码签名打包。我们将通过上传过程结束本书,包括苹果、谷歌和微软商店的上传,以及向 Unix 系统的分发。
附录 A**,开发者工具安装,包含了管理其他章节所需软件的平台特定步骤。本章将帮助新开发者安装构建本书示例所需的编译器和工具。
附录 B**,移动构建工具安装,提供了设置构建移动设备 Fyne 应用所需额外工具的文档。遵循这些步骤后,可以在本地创建 Android 和 iOS 构建。或者,开发者可以选择使用在 附录 C 中介绍的 fyne-cross,进行交叉编译。
附录 C**,交叉编译概述了设置不同操作系统构建所需的具体平台安装和配置。遵循这些步骤,开发者将能够仅使用一台计算机编译他们的 Fyne 应用程序。它还展示了如何为不希望自己详细管理工具的开发者设置 Fyne 交叉编译解决方案 fyne-cross。
要充分利用本书
本书假定您对 Go 语言有基本了解。如果您还不熟悉语法或概念,请在开始阅读之前考虑运行在线教程(tour.golang.org)。
要运行示例,您至少需要安装 Go 语言的 1.12 版本,以及适用于您的计算机的 C 编译器(由 Fyne 库代码要求)。如果这些都没有安装,您可以在附录 A,开发者工具安装中找到详细的步骤。我们将逐步介绍安装 Fyne 库及其支持工具的过程,这些工具在本书的整个过程中都是必需的:
要开发应用程序,您需要一个装有之前描述的开发工具的 Windows、macOS 或 Linux 计算机。要测试移动设备上的应用程序,您还需要安装 Android SDK 和/或 Xcode(用于 iOS/iPadOS)环境。有关更多信息,请参阅附录 B,移动构建工具的安装。为了进一步测试移动构建,建议您准备一个合适的移动设备。
要从本书中获得更多收益,如果您心中有一个您想要构建的应用程序项目,那就再好不过了。这样做将帮助您在不同的环境中练习示例代码,以更好地理解小部件和 API 功能。此外,这也意味着本书结尾的应用程序商店上传过程将导致您的应用程序版本发布!
如果您正在使用本书的数字版,我们建议您通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您避免与代码复制粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 在github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne
下载本书的示例代码文件。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有来自我们丰富的书籍和视频目录中的其他代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
下载彩色图像
我们还提供包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781800563162_ColorImages.pdf
使用的约定
本书使用了多种文本约定。
文本中的代码
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“使用基于方法的更新,SetText()
和 SetIcon()
调用将刷新小部件,可能触发前面的问题。”
代码块设置如下:
const (
serverKeyDevelopment = "DEVELOPMENT_KEY"
serverKeyProduction = "PRODUCTION_KEY"
)
任何命令行输入或输出都应按以下方式编写:
$ fyne release -appVersion 1.0 -appBuild 1 -certificate "CertificateName" -profile "ProfileName"
当命令与 GitHub 仓库(如上所述,以及每章的顶部)中的文件或数据相关时,命令提示符将以目录名开头,如下所示:
Chapter03/window$ go run main.go
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“您也可以在 fyne_demo 中的 设置 菜单中找到 设置 面板。”
小贴士或重要提示
显示如下。
联系我们
欢迎读者反馈。
customercare@packtpub.com
。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问 www.packtpub.com/support/errata,选择您的书籍,点击勘误表提交表单链接,并输入详细信息。
copyright@packt.com
并附上材料链接。
如果您想成为一名作者:如果您在某个领域有专业知识,并且对撰写或参与一本书籍感兴趣,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
有关 Packt 的更多信息,请访问 packt.com。
第一部分:为什么选择 Fyne?存在的理由和未来的愿景
自从它们近 50 年前被发明以来,图形用户界面(GUIs)一直是与软件产品交互的标准方式。在这段时间里,它们已经发展演变,传统的图形应用程序正受到基于网络的软件的普遍性和智能手机及手持电脑的新交互方法的挑战。尽管有这些新趋势,仍然有许多理由说明为什么构建本地的图形应用程序可能是你产品的正确策略——尤其是如果它能够部署到所有可用的平台。
在本节中,我们将看到 GUI 及其编程所使用的图形工具包是如何演变的。我们将探讨这些技术多年来的优缺点以及它们如何被用于跨平台开发。我们将了解 Fyne 项目,其背景和愿景,以及它旨在成为 GUI 开发不断变化需求的理想解决方案。
本节将涵盖以下主题:
-
第一章, GUI 工具包和跨平台开发的简要历史
-
第二章, 根据菲恩的未来
我们从对桌面计算机和传统 GUI 的历史简短回顾开始。
第一章:第一章:GUI 工具包和跨平台开发简史
本书旨在探讨如何轻松构建健壮且美观的图形应用程序,这些应用程序将在所有操作系统和设备上运行良好。在我们开始查看如何实现这一点的细节之前,考虑这些设备的历史以及过去 50 年图形工具包的格局非常重要。我们从回顾 GUI 应用程序的起点以及它们走了多远开始。
在本章中,您将重新了解图形用户界面(GUI),同时了解支持应用程序开发的工具包以及它们如何提供不同的跨平台开发方法。我们将探讨编写本地 GUI 以实现响应式用户体验和平台集成的优势。完成本章后,您应该熟悉图形工具包的起源和挑战,以及在这一旅程中采取的不同方法。
在本章中,我们将涵盖以下主题,以提供 GUI 工具包和跨平台开发的简要历史:
-
GUI 工具包的来源
-
它们是如何随着时间的推移而适应(或保持不变)的
-
跨平台开发的历史方法
理解图形用户界面的历史
在 1973 年,帕洛阿尔托研究中心(施乐帕洛阿尔托研究中心)完成了 Alto 计算机,这是第一个图形桌面计算机的商业实例。在大多数当代历史中,这是我们所理解的 GUI 的第一个实例。尽管屏幕方向和缺乏颜色使其对现代眼睛来说有些奇特,但它显然是可以识别的,并且包括许多关键组件,以及用于交互的鼠标和键盘。尽管它直到 1981 年作为Xerox Star才对公众普遍可用,但它显然是一个巨大的进步:
图 1.1 – Dynabook 环境桌面(1976 年;在 Alto 上运行的 Smalltalk-76)。版权 SUMIM.ST,许可 CC BY-SA 4.0
这种发展是计算机可用性的一次巨大飞跃。在此之前,所有交互都是通过文本模式的计算机屏幕和键盘或其他文本输入设备进行的。图形界面对于想要入门的新手来说更容易学习,并且允许更快地发现高级功能。尽管命令行界面仍然受到程序员和其他专业用户的欢迎,但 GUI 是桌面计算机兴起的最主要因素。
桌面计算机的普及
用户体验友好的图形环境的引入促进了台式计算机的使用显著增长。在 Alto 计算机时代,全球大约有 48,000 台台式计算机。到 2001 年,这个数字急剧增加到超过 12 亿 5 千万台个人电脑发货(en.wikipedia.org/wiki/History_of_personal_computers#Market_size
)。2002 年,行业庆祝了 10 亿台电脑发货(news.bbc.co.uk/1/hi/sci/tech/2077986.stm
),尽管最近数字有所下降(参见本章后面的智能手机和移动应用部分),2018 年报道的发货量不到 3 亿台(venturebeat.com/2019/01/10/gartner-and-idc-hp-and-lenovo-shipped-the-most-pcs-in-2018-but-total-numbers-fell/
)。
随着这些设备进入消费者手中,硬件变得更加强大,我们开始看到创建吸引人的用户界面的重点,以及建立或匹配时尚趋势的趋势。以下是一些重要的微软 Windows 操作系统版本:
所有屏幕截图的版权属于微软。每个图像都是在获得许可的情况下使用的。
如您在前面的屏幕截图中所见,桌面环境的每一次主要修订都为按钮、字体和其他用户界面元素带来了新的样式。这一切都由工具包控制,代表了我们在本章后面将要探讨的可用性和风格选择上的进化。
当微软在 GUI 方面取得进展时,也有许多竞争对手,其中一些可能看起来很熟悉,而另一些则有自己的独特风格;例如,以下是一些流行的系统:
1985-2015 年各种操作系统的桌面截图。每个图像都是在公平使用政策下获得必要许可后使用的。
如您从 1985 年到 2015 年各种操作系统的桌面截图中所见,外观和感觉发生了显著变化,同时保持了某种熟悉感。这些桌面系统都是为了运行多个应用程序窗口而设计的,通常围绕文档编辑、文件管理和实用程序应用。多年来,还出现了额外的软件,如游戏、照片管理和音乐播放器,但最普遍的网页浏览器直到 20 世纪 90 年代末才变得常见。互联网接入的加入开启了一个新的计算时代的转变。
转向网络
随着可靠互联网连接的日益普及,我们从服务器上获取的信息量开始增加,从你最喜欢的搜索引擎中的“浏览器大战”到了解更多)。
万维网最初由蒂姆·伯纳斯-李爵士在 1981 年提出,开发始于 CERN(home.cern
)项目(代号ENQUIRE)。早期的万维网在 1993 年向公众开放。作为一个任何人都可以添加的分布式系统,设计创新甚至比我们之前看到的桌面操作系统还要迅速。设计和可用性的趋势迅速赶上、超越并开始引领传统的软件开发:
网站设计趋势(通过 Web 设计博物馆);版权属于各自所有者
互联网最初是一个提供数据访问的项目,源于对在不同计算机上获取信息可能多么困难的挫败感。最初只是简单的信息检索很快变成了更复杂信息的精致展示,然后开始成为提交或操作信息的地方。
一个简单的数据访问平台迅速发展成为一个更全面的平台,不久之后,它就成为一个完整的应用程序平台。事实上,由于基于标准的方法(由万维网联盟(W3C)监督),这成为了第一个真正跨平台开发机会之一。一个基于网络的程序可以一次性开发并供所有计算机使用——这比之前为多个平台开发的大幅进步。
通过基于网络的解决方案交付应用程序的额外好处是,你可以支持多种类型的应用程序访问底层数据或功能。一个历史上曾为用户可见的网站提供动力的基于网络的API(应用程序编程接口)也可以由其他设备使用。这种设计允许传统软件访问与基于网络的交付系统相同的数据,并有助于发展支持多种不同类型软件的通用架构——包括更近期的基于移动的应用程序。
智能手机和移动应用
在 2007 年,苹果的史蒂夫·乔布斯推出了 iPhone,这是移动计算概念的一个全新设计。尽管在此事件之前已经存在便携式智能手机设备多年,但引入了一个光滑的新用户界面、触摸屏输入和用于显示视频和网页内容的大屏幕,对市场产生了重大影响。竞争对手(现有和新创建的)现在正在竞相创造最适合消费者口袋的最佳用户体验。尽管早期设备宣称可以轻松浏览任何网站,但开发者很快就将内容调整为更适合这些小屏幕的展示——通常关注移动时重要的信息。
为了满足用户在这些更有限的(由硬件或互联网连接性)设备上对更复杂和更快体验的需求,诞生了移动应用程序的概念。这些小型的软件专门为某种类型的移动电话(Android、iPhone 和其他)设计,并通过平台的商店或市场提供。与之前出现的基于网络的解决方案相比,它们具有很大的优势,因为它们可以安装在设备上,所以运行得更快,并且专门为特定的硬件开发,从而创造更好的用户体验,并允许访问设备的高级功能(如位置检测、指纹传感器和蓝牙)。
这些原生应用提供了终极的用户体验。这些应用可以非常快(因为它们安装在设备上),适应用户(通过访问本地设置和数据),还可以与操作系统功能(如日历、语音控制和尖端硬件传感器)交互,所有这些在通过网页应用提供时都是不可能实现的。然而,它们对开发者来说有一个缺点——不仅每个平台看起来都不同,这意味着设计可能需要调整,而且它们是分别分发的,通常需要不同的编程语言来开发。现在,软件公司不再需要用一个单一的应用程序来覆盖整个世界,而是至少需要三个不同的应用程序来通过客户喜欢的设备接触他们的客户:
![iPhone 和 Android 设备展示它们的相似之处和不同之处]
![图片 04.jpg]
iPhone 和 Android 设备展示它们的相似之处和不同之处
我们将在稍后回到为多个不同的硬件平台开发时的挑战,但首先,我们将探索支撑本节中我们看到的各种技术的图形工具包。
探索 GUI 工具包的演变
图形用户界面(GUIs)必须像任何其他计算机程序一样进行编程,就像库是为了提供标准组件而创建的一样,GUI 工具包存在是为了支持构建应用程序的图形元素。由于存在许多不同的原因,存在许多工具包——维基百科维护着一个包含近 50 个不同项目的列表,并且还在增长,可以在en.wikipedia.org/wiki/List_of_widget_toolkits
和en.wikipedia.org/wiki/List_of_platform-independent_GUI_libraries
找到。为了理解大量选项的意义,我们将它们分为类别,首先考虑那些为特定操作系统构建的工具包。
特定平台工具包
每个图形操作系统或桌面环境都有其独特的外观和编程风格,因此传统上为每个平台创建了一个图形工具包。Windows 有 WinAPI(以及 WinForms 和基础类),Atari 使用 GEM 进行编程,而 BeOS 使用 Be API。为苹果产品开发的应用程序使用了各种工具包,但自从 macOS X 以来,它被称为 Cocoa(桌面使用 AppKit,移动设备使用 UIKit)。Android 设备使用自己的工具包,而其他移动平台也探索了其他选项。
Unix 和 Linux 操作系统的故事更为复杂。尽管 Motif 工具包是第一个之一,但其设计提供多种选择的事实意味着没有一种真正的外观或库。在 1980 年代,在 Motif 创建之前,有一个名为 OpenLook 的项目,旨在为 Unix 系统提供标准的界面外观和感觉。尽管有众多不同的设计和工具包可供选择,Unix 的主要贡献者决定统一将有助于其与 Windows 和其他桌面平台竞争。因此,在 1993 年,他们选择了 Motif 作为未来的开发。
桌面环境设计的常见特征是它经常更新,正如你可以在本章前面看到的 Microsoft Windows 的截图中所见。无论是因为时尚的变化还是可用性的进步,这些变化都是预期的,而 Motif 系统没有适应这些变化,因此创建了新的项目作为替代品。在 90 年代后期,GTK+和 Qt 项目启动,提供了更现代、更精致的用户界面。此外,Java 平台在 1995 年推出了AWT(抽象窗口工具包),所有这些都不是特定平台的,为跨平台 GUI 库开辟了新的世界。
跨平台工具包
在前面的子节中提到的工具包都是为了特定的平台开发的。它们随着操作系统设计的发展而发展,通常使用制造商首选的编程语言进行开发。这些挑战使得创建一个能在所有平台上工作的单一应用程序变得困难(如果不是不可能的话)。因此,创建跨平台工具包的举措需要采取不同的方法,因此开发者开始设计一个可以在不依赖于特定平台的情况下用任何支持的操作系统的编译语言编写的库。
在 20 世纪 90 年代中期,当 GTK+和 Qt 被创建时,它们分别选择了 C 和 C++(一种从 C 派生出的面向对象的语言)。这两种语言在大多数操作系统上都有广泛的采用,并且已经与一些其他工具包一起使用,从而降低了学习门槛。然而,Java 的方法却更为广泛——创建一种全新的语言,使其能够在所有这些平台上工作,并交付一个基于此的图形工具包。
操作系统和计算机制造商拥有影响技术的市场力量,随着新语言的可用性,他们能够迫使开发者走向相同的新方向(例如,苹果转向 Swift,微软转向 C#,谷歌将他们的应用程序迁移到 Dart)。然而,围绕跨平台技术构建的大型开源社区通常对其构建的语言忠诚,因此通常不会接受这样大的变化。因此,在这些项目中,某些方面可能会被遗弃,并鼓励开发者寻找新的方向,例如网络技术。
混合应用程序
如本章前面所述,万维网为向多个操作系统的用户提供应用程序提供了一个有吸引力的平台,同时也提供了一种构建一次应用程序并在任何计算机上运行的方法。网络浏览器提供了一个高度可定制的画布,因此,使用层叠样式表(CSS),任何基于超文本标记语言(HTML)的应用程序都可以被设计成任何设计。这种好处为网络应用程序开发带来了很多人气,甚至影响了某些原生工具包扩展其主题功能以模仿这一点。
如本章前面所述,网站是为了信息传输而设计的——最初是只读的,后来也可以发送和编辑数据。要从这里过渡到完全交互式应用程序,需要额外的编程能力,而这正是JavaScript的作用。自其创建以来,JavaScript 在流行度和复杂性方面都得到了增长——现在有多个包管理器来处理可用于任何 JavaScript 应用程序的数千个可用包。在这些库中,许多是用于处理应用程序交互和布局的图形工具包,就像传统的 GUI 工具包一样。在这些工具包中,目前最受欢迎的是 React、Vue.js 和 JQuery,尽管这个列表经常变化。
在开发此类基于 JS 的 Web 应用程序时,重点是用户界面(即前端),而完整的应用程序可能包含业务逻辑和算法,这些算法可能是 Web 应用程序的后端基础设施的一部分。从历史上看,这些复杂应用程序的独立部分使用不同的编程语言创建。这在大型基础设施的每个领域都有不同的要求是有意义的。然而,对于较小的应用程序或为了降低技术复杂性,使用相同的语言处理所有部分是有用的——因此创建了 Node.js 来支持应用程序的基于 JavaScript 的后端元素。
尽管通过 Web 分发具有诸多好处,但许多公司仍然希望提供一种传统的应用程序,该应用程序可以下载并安装(原因将在下一节中探讨)。为了平衡开发速度和 Web 应用程序的其他优势与开发者熟悉的传统应用程序包,创造了一种新的应用程序类型,被称为混合应用。这些新应用在一个标准容器中加载,就像系统上的其他任何应用程序一样,在常规窗口中加载自定义 Web 应用程序。Electron、Ionic 和 React Native 都是在这个领域工作的项目,提供基于 Web 的应用程序框架,具有不同级别的系统访问权限。
在跨平台开发图形工具包的演变过程中,我们不能忽视 Web 浏览器的普遍存在。尽管它有存在于大多数现代计算机上的好处,但它可能不是构建您产品的正确解决方案——让我们看看这些方法如何不同。
比较原生图形应用与 Web UI
尽管基于 Web 的应用程序可以提供诸多好处,但每个技术选择都意味着在某些领域做出权衡,因此让我们看看一些可能影响您决定是否构建原生应用程序或基于 Web 的混合应用程序的常见问题。
开发速度与交付
选择网络技术来构建你的应用程序的一个主要原因是开发速度。以这种方式开发的本性意味着你可以在网页浏览器中实时预览你的工作。基于浏览器的编辑器的可用性还意味着设计团队可以在不太多代码经验的情况下调整用户界面。你的 Web 应用程序的大部分内容也可以用于混合应用程序(或反之亦然),以提供高度的重用和最小的工作量来支持桌面和移动交付。
关于速度的权衡在运行时被发现——因为应用程序需要网页视图来运行代码,这会影响应用程序加载和执行的速度。每次加载混合应用程序时,它都会在窗口内创建一个网页浏览器的小版本,像网页一样加载代码并开始执行捆绑的 JavaScript。对于大多数用户来说,这可能不会慢到令人沮丧,但与本地编译的应用程序相比,可能会有明显的差异。根据选择的框架,这种模式通常也需要大量的内存——实际上,Electron 因需要大量 RAM 而闻名,最简单的应用程序仅为了显示“Hello World”就需要近 70 MB。
基于网络技术构建的应用程序的实际执行速度也可能明显较慢。由于抽象层的存在,基于网络的程序通常需要更多的时间和 CPU 周期来完成与编译的本地应用程序相同的操作(尽管像WebGL和WASM(即Web Assembly)这样的技术正在尝试改善这一点)。因此,如果你的应用程序可能需要大量的 CPU 资源,或者有很多动画图形,你可能希望基准测试不同的方法,以确定哪些平台能够满足你对应用程序响应速度的要求。
另一个考虑因素可能是自动更新——你是否希望你的应用程序始终运行最新版本?一些基于网络的工具包提供了下载应用程序更新并动态加载新版本的功能,而无需用户担心。这可能是一个很大的好处,但如果你的客户期望软件每天都能正常工作,直到他们选择更新它,这可能会令人沮丧。有些人也担心这种性质的应用程序可能会“回家”报告——也就是说,在更新过程中向中央服务器报告应用程序的使用情况和位置。
视觉风格
选择基于网络技术应用程序开发的另一个主要决策点可能是表现层(CSS)的强大功能。通过结合图像资源和样式表代码,几乎可以创建任何所需的视觉风格。对于希望为其应用程序提供完全定制外观的开发人员(或他们团队的设计师)来说,这可能是一个很好的选择。然而,值得考虑的是,你的用户将如何使用该应用程序,以及完全定制的样式是否会以任何方式阻碍可用性。
如果应用程序旨在匹配当前系统的用户界面风格,这种完全定制的优势可能会变得具有挑战性。由于渲染无限灵活,开发人员当然可以在某些系统上运行时对风格进行微调,使其看起来微妙(或显著)不同。这种调整可能需要意想不到的大量额外努力——因为每个平台在一段时间后都可能具有不同的风格。试图匹配系统风格但未能做到的 GUI 比明显遵循其自身风格指南的 GUI 更具排斥性。
因此,如果你希望与其他系统中的应用程序融为一体,可能最好避免使用混合应用程序。网络技术确实提供了一个快速开发、适应性强的跨平台应用程序平台,但这种方法也存在一些限制,也应予以考虑。
技术限制
使用网络技术构建的应用程序,即使是使用混合框架构建的看似系统应用程序,都在沙盒中运行。这意味着它们在访问设备和系统功能方面存在某些限制。允许访问底层功能的 JavaScript API 不断扩展以克服这些限制,但如果你的应用程序可以从非标准外围设备或集成到特定操作系统功能中受益,那么网络用户界面可能不是你的正确选择。
通信端口、不包括在典型网络 API 中的外围设备以及进程管理是应用程序的一些低级元素,这些元素默认情况下不会得到支持。此外,与桌面环境系统托盘、搜索功能和一些高级文件管理进行交互可能从 JavaScript 代码中难以访问。为了弥合这一差距,一些混合工具包允许编写和加载本地代码作为库来访问这些功能。然而,此类扩展需要用平台自己的语言(通常是 C 或 C++)编写,然后为每个支持的平台进行编译。这不仅增加了应用程序分发的复杂性;也可能损害使用网络工具提供的单一代码库应用程序设计技术。
与此相比,其他跨平台开发方法通常提供对所有支持操作系统的抽象,这样应用程序就可以一次构建,但在支持缺失的情况下,它们提供了一种直接访问底层功能的方法。这通常以语言桥接或从高级语言中加载系统库的形式出现。这可能需要使用不同的编程语言进行构建,如前面所述,但在 Web 沙盒之外的跨平台技术中,这通常不会大幅增加分发复杂性。此外,与基于嵌入式 Web 浏览器的工具包相比,找到不支持原生跨平台工具包的设备的情况要罕见得多。
如果本节中提到的某些 Web 技术跨平台开发的限制可能会影响您的应用程序,或者如果您更愿意不使用 HTML 和 JavaScript 进行编码,那么原生工具包可能是正确的选择,我们将在下一节中探讨。
跨平台原生工具包的选项
如本章前面所述,跨平台工具包的概念并不新颖——实际上,它们可以追溯到 20 世纪 90 年代中期,当时 GUI 发展的历史还不到 10 年。重要的是要理解,即使在原生的跨平台工具包中,也存在不同的方法,各有其优势和劣势。
视觉风格
跨平台工具包可以分为两种不同的视觉方法——在运行时匹配系统外观和感觉的愿望,与提供一致外观的愿望,这种外观将在所有环境中保持一致。Qt 和 GTK+工具包最初都有自己的视觉风格,增加了通过视觉主题进行控制的能力。随着时间的推移,它们开发了特定操作系统的主题,使它们能够匹配系统上其他应用程序的设计。相比之下,Java AWT 库被创建为一个代码级别的抽象,这意味着程序将使用操作系统小部件进行渲染,尽管应用程序是为没有特定平台编写的。有趣的是,在 1998 年,Sun(Java 的创造者)推出了 Swing 工具包,它提供了一种全新的外观和感觉,这种外观和感觉将在所有平台上保持一致。随着 AWT 的逐步淘汰,这个替换用户界面库逐渐获得了人气。在有趣的反转中,Sun 引入了操作系统外观类似的主题,开发者可以选择在他们的应用程序中启用(与 GTK+和 Qt 不同,这并不是用户的首选)。
编译与解释
定义工具包的另一个常见因素是编程语言的选择——这些可以区分编译成应用程序二进制文件的语言与作为源代码分发并在运行时解释的语言。这种区别通常与静态类型语言与动态类型语言相关联,这影响编程风格和关联 API 的设计。在静态类型编译语言中,所有变量都在编译时定义和检查。它们的类型(即它们包含或引用的内容类型)在定义中设置且不会改变。这种方法通常可以早期捕获编程错误,并可能导致健壮的代码,但可能会受到导致应用程序开发速度较慢的批评。编译的应用程序通常可以直接在构建它的计算机上运行,这意味着它不需要与应用程序一起安装支持技术。然而,为了实现这一点,应用程序通常需要为每个支持的平台进行编译——这再次导致开发时间更长,这次是为了分发任务。
相比之下,解释性语言通常被认为支持更快的开发和更快的交付。通过允许变量用于不同类型的内容,应用程序的源代码可以更短,在编程阶段出现的复杂性也更少。相反,这种简化的严格性意味着通常需要有一个更稳固的测试基础设施(通过单元测试或自动用户界面测试)来确保软件的正确性。以这种方式分发应用程序将需要安装运行时环境,以便代码可以执行(就像我们在讨论混合应用程序时看到的嵌入式网页浏览器)。对于某些操作系统,可能已经安装了解释器,而对于其他操作系统,可能需要用户安装。有趣的是,有一些编译器可用于解释性语言,允许它们像任何其他二进制应用程序一样分发,尽管存在先前的权衡,即应用程序必须为每个目标平台单独构建。
解释性选项
由于构建 GUI 的流行,每个主要的解释性语言都有自己的首选工具包。Java 运行时包括自己的图形例程,这意味着它可以包含定制的用户界面,即 Swing。最近,JavaFX 库是在相同的图形代码之上构建的。如前所述,Java 还包括 AWT 库,它委托给系统组件。
其他流行的编程语言运行时没有附带相同的图形组件,因此它们通常依赖于底层库。TkInter 是 Python 的标准 GUI 库,基于 Tk 库,而 Ruby 语言没有推荐标准库。语言绑定允许程序员使用解释语言创建应用程序,同时使用底层的现有小部件工具包,如 Tk、GTK+ 或 Qt,这些绑定非常受欢迎。使用这种方法,可用的选项太多,无法一一列举,但它们通常有一个缺点,即不是为语言设计的,因此编程起来可能没有专门为该语言构建的选项直观。
编译选项(基于 C)
如本章前面所示,GUI 开发有着悠久的历史,最流行的工具包最初都是许多年前设计的。GTK+ 和 Qt 都非常受欢迎,但它们分别是为 C 和 C++ 语言设计的。虽然这并不妨碍它们成为有效的选择,但它们对于现代程序员来说可能显得有些过时。可能部分原因就在于此,这些 GUI 框架已经为几乎所有的编程语言提供了语言绑定。然而,这也意味着你需要了解一些底层系统的工作原理,以便在开发中发挥最大效用。例如,C 库中的内存管理是一个复杂且需要手动完成的任务,大多数开发者都不愿意去担心。此外,基于 C++ 的库在底层可能有一个不同的线程模型,这可能与高级语言的方法不兼容。
对于设计了几十年的工具包,还有一个额外的考虑因素,那就是它们可能不适合现代图形计算设备的格局。屏幕,无论是在桌面、笔记本电脑还是移动设备上,现在都有各种各样的尺寸和像素密度。如果代码是基于像素大小来构建的假设,那么这可能会带来挑战,因为以前是这样的。96 每英寸点数(DPI)是一个常见的假设,这意味着高度为 96 像素的东西在显示时大约为一英寸。然而,根据当前设备,它可能从一英寸到 3/16 英寸(只有预期大小的 20%)不等,因此了解这将对应用程序设计产生什么影响非常重要。较老的工具包通常基于像素测量,其中显示被简化为一定数量的像素(1、2、3 或 4)来表示每个源像素的大小(这将是 1、4、9 或 16 像素来显示一个正方形)。如果应用程序没有仔细适应设备,使用这种方式的工具包可能会导致像素化输出。
编译选项(其他语言)
部分原因是旧有的基于 C/C++的 GUI 工具包的遗留问题,部分原因是新的编程语言相对于旧语言带来的好处,大多数较新的编译型语言也都有与之配套的工具包。这些更现代的工具包通常由于它们较新的设计,能够更有效地处理今天各种设备。通过转向基于矢量图像的设计,像素或位图设计带来的挑战正在被克服。矢量图像可以在任何像素密度的屏幕上以更高的质量输出。它们通过独立于输出像素定义图像元素,如线条、矩形和曲线,来实现这一点;它们仅在确定可用像素数量后才会绘制,从而在大多数输出设备上产生更高的质量。
正如我们之前所看到的,操作系统制造商经常规定使用的编程语言,并在需要时推动新版本或编程语言的采用。我们可以从苹果最近将其 UIKit 和 AppKit 框架的最新版本迁移到 Swift 这一举措中看到这一点(这种语言主要用于苹果设备,但这一趋势仍然值得关注)。微软的开发平台目前正朝着 C#的方向发展,之前曾使用过 C、C++和 Visual Basic。谷歌正在将精力投入到 Dart 编程语言和建立在它之上的 Flutter 工具包中。
其他语言,如 Go,没有官方的 GUI 工具包或小部件库。在这些情况下,我们可以看到各种项目出现,以不同的方式填补这一空白。在 Go 语言中,最活跃的项目是 andlabs UI、Fyne 和 Gio。andlabs 项目旨在使用当前系统样式——实际上,它通过简单的 Go API 包装代码来显示它们,就像之前讨论的 Java AWT 一样。Gio 是一个即时模式的 GUI 工具包,旨在尽可能地为应用程序开发者提供控制权,要求应用程序管理渲染和事件处理。Fyne 项目旨在提供一个简单易学且易于扩展的 API,无需担心渲染过程——这通常被称为保留模式,因为小部件状态由库管理。
摘要
在本章中,我们探讨了图形应用程序的历史以及推动它们发展的工具包。我们看到,在过去的半个世纪里,许多事情都发生了变化,但许多方面仍然保持不变。通过展示图形设计和技术能力的发展趋势,我们清楚地认识到,尽管这些技术在最终用户眼中正在适应和改进,但它们在拥抱开发者期望的改进速度方面可能较为缓慢。我们了解到,有众多不同的方法来支持跨平台工作的 GUI 创建,但同时也存在一些缺点。
在下一章中,我们将更深入地了解 Fyne 工具包的愿景和设计,以及为什么项目团队认为这为在任意平台上构建健壮且性能卓越的图形应用程序提供了最简单的方法。
第二章:第二章:根据 Fyne 的未来
Fyne 工具包的设计基于这样一个前提,即解决第一章中提出的许多挑战的最好方法是对 GUI 工具包设计采取全新的方法。它旨在结合现代编程语言、Material Design 风格和简单的 API 的优点。
在本章中,我们将探讨 Fyne 项目的背景和目标,包括以下内容:
-
Fyne 及其团队的目标愿景
-
现代编程语言如何实现全新的方法?
-
它如何解决跨平台、原生应用开发的复杂性?
技术要求
在本章中,我们将使用 Go 代码的示例,因此您需要安装 Go 编译器——请参阅golang.org/doc/install
中的说明。我们还将探讨如何将 C API 进行桥接,因此您还需要安装一个 C 编译器。C 的安装因系统而异。您可以在附录 A中找到详细信息,开发者工具安装。
本章的完整源代码可以在github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne/tree/master/Chapter02
找到。
从一张白纸开始
通过 GUI 开发的历史,我们看到大多数最受欢迎的工具包都是基于 C 或 C++ 语言代码。这些项目有着悠久的历史、庞大的社区和无数的开发小时数,使它们成为今天的模样。尽管它们是衡量所有其他工具包的标准,但它们存在缺点,这主要是因为它们建立在旧的设计决策之上。在本节中,我们反思为什么从头开始创建跨平台应用程序的更好体验。
为现代设备设计
我们今天使用的设备类型与 1980 年代和 1990 年代相比,既大不相同又更加多样化,当时最常用的工具包正在设计和构建中。今天,图形应用程序可以在台式计算机、笔记本电脑或上网本、移动设备或平板电脑、智能手机或手表形态、甚至机顶盒或智能电视上运行。这些设备类别都有不同的用户界面范式——为使用鼠标和键盘的台式计算机设计的 API 并不一定很好地适应基于触摸屏的输入、多指手势或作为主要输入设备的遥控器。为现代设备设计的输入 API 将能够提供对底层细节的适当抽象,从而关注用户的意图。通过采用这种方法,应用程序可以更好地适应消费者手中的各种设备。
随着设备尺寸的多样性,屏幕尺寸和类型也呈现出广泛的变化。现在的手机屏幕比以往任何时候都要大,但它们仍然相对较小。然而,它们包含的像素数量巨大,当我们将它们贴近脸部时,会呈现出平滑的外观。另一方面,电视屏幕非常大,但由于通常观察的距离较远,分辨率较低。即使是台式机的屏幕也发生了变化——平均尺寸增大,屏幕变得更宽,但最大的变化是像素密度呈天文数字般增长,高端显示器的像素数量是廉价设备的十倍。处理这种多样化的输出设备需要分辨率无关的渲染,这与图形工具包的起源有很大的不同,在图形工具包中,像素的大小是可以假设的。为了解决这些问题,一些工具包简单地为每个输出像素使用一个乘数。这种方法通过使用更多的像素来显示相同的原始分辨率,从而避免了应用程序变得难以阅读,但为了避免渲染出现严重像素化,需要更新应用程序。
随着智能手机的复杂性不断增长,消费者现在期望应用程序能够适应他们的位置、偏好和行为。这很大程度上得益于许多传感器的加入,每个传感器都有平台特定的 API。在良好的现代工具包中,对平台特定性的简单抽象可以显著减少开发者准备软件以适应不同平台所需的时间。
并行处理和 Web 服务
计算技术在过去的 50 年里取得了长足的进步,不仅是在机房和服务器机架中,也在我们的桌面上和口袋里。人们常说,我们现在口袋里的计算能力比人类首次登月时还要强大。实际上,如果考虑到智能手机的功能,这个数字可能高达 1 万倍。要容纳这么大的计算能力需要现代编程技术——这不仅仅是运行相同的代码速度更快。一个主要因素是,现代操作系统和其上的任何应用程序都能够同时计算许多事情——但要有效地做到这一点,需要理解并行处理的代码。进行这种调整很简单;代码必须分成足够独立的组件,以便能够同时运行。引入这种能力意味着内存不再由代码的单一部分控制,因此可能会出现意外的结果(通常称为竞态条件)。使用旧工具解决这些复杂问题可能非常复杂且容易出错。
到目前为止,几乎所有 GUI 工具包(包括苹果和微软的最新版本)都要求用户界面或输出图形的更改由主线程处理,这是任何应用程序能力的一个单一部分。这个限制要求应用程序开发者进行仔细的协调,并可能限制应用程序图形组件可用的计算能力。
应用程序中背景线程最常见的一个用途将是与远程资源通信。网络服务或网络资源不像本地计算机上的数据或与应用程序捆绑的数据那样快速访问,因此应用程序必须在处理这些较慢请求的同时管理用户输入和图形更新。大多数图形工具包和 API 的核心都专注于小部件——向用户展示界面的方式。许多工具包的设计早于我们今天所知道的云服务和基于 Web 的 API。强大的网络服务和用于通信的标准协议大大提高了基于 Web 应用程序的开发速度。相反,它们可能会使桌面上的本地图形应用程序更难,因为核心语言或标准库缺乏支持。将这些功能纳入现代编程语言正在改变这一点,现代图形工具包应该为开发者提供类似的好处。
为任何设备构建
在考虑设备的硬件特性和连接性质时,我们需要考虑软件如何部署到这些设备上。每个操作系统都期望应用程序以不同的格式打包和安装,并且通常有不同方式来发现和下载软件。尽管 C 和 C++语言在大多数平台上都能工作,但要为与你正在工作的不同计算机编译可能会非常复杂。对于桌面和笔记本电脑应用程序来说,这种限制并不是问题,因为公司只需购买不同类型的计算机来运行编译和打包。然而,对于移动和嵌入式设备来说,情况就复杂得多,因为它们无法从源代码编译自己的应用程序。
最近设计的编程语言包括从单一开发计算机构建不同系统的能力。尽管解释型语言一直都在做这件事,但现在你的编译应用程序(更快,无需预安装运行时环境)可以为任何设备进行交叉编译。除了这个功能之外,GUI 工具包必须支持准备不同格式的应用程序包,这些包可以打包二进制资产、资源和其他元数据以进行分发。虽然可以手动执行这些任务,但一个经过深思熟虑的开发者体验应该确保这实际上是自动的。
此外,许多分发平台,如应用商店和软件市场,要求一个认证过程,以确保其提供的应用程序的真实性。设置这些加密步骤通常很耗时,可能会成为希望将其作品提供给公众的新应用程序开发者的障碍。重新思考工具包及其提供的实用工具将允许在编译和打包挑战的同时解决此问题。
最佳实践已经发展
在评估用于开发应用程序的工具包时,可以观察到的最后一个因素可能是其最佳实践是否最新。长期以来,人们认为图形应用程序的自动化测试几乎是不可能的,这种观点在很大程度上是由于测试驱动开发(TDD)和持续集成(CI)在遗留工具包或它们使用的编程语言设计时并不常见。一个希望学习或在一个专业软件工程师团队中的开发者可能会期望现代工具支持或甚至鼓励这些实践。
C 语言(及其许多衍生语言)常因对字符串类型的处理能力弱而受到批评。事实上,正是这种缺陷导致了众多高度可见的软件漏洞和公开数据泄露。尽管并非所有较老的编程语言都存在这个问题,但它们几乎都只支持使用拉丁字母的简单字符串(尽管一些有附加库尝试解决这个问题)。在这种限制下,编写一个能够轻松适应软件应支持的国际世界中各种常见语言的程序是困难的。Unicode标准是处理国际化文本的通用方法,但这种多字节(使用多个字节来表示一个字母或符号)格式在引入到未设计为理解它的软件时可能会引起问题。用户和开发者现在都期望支持这些复杂的编码,因此较老工具包的缺点继续增加。
正如你所见,在开发或使用图形工具包时存在许多挑战,如果我们从头开始,这些问题是可以克服的。因此,Fyne 工具包决定这样做,但这样做需要选择使用哪种编程语言。正如我们所知,Go 被选为 Fyne——在下一节中,我们将探讨为什么它被认为是一个克服所面临挑战的好选择。
Go 语言非常适合这个挑战
在前一节中,我们看到了许多原因说明图形工具包根植于过时的基础,以及它们所使用的语言可能限制了它们适应的机会。许多制造商正在认识到这个问题,并转向新的语言以寻找解决方案,甚至完全避免过去的挑战。苹果公司正在将所有开发迁移到 Swift 语言,尽管苹果支持的软件仅设计在他们的设备上运行。其他公司,如 Facebook,正在寻找方法将更现代的基于网络的工具适应,以创建适用于手机和桌面的原生应用程序。
无论是特定平台的技术方法还是源自解释型互联网技术的语言,都无法真正创造出令人愉悦的开发体验。我们正在寻找一个能够产生性能优异且健壮的跨平台应用程序的开发平台——这是现代应用程序开发的万能药。作为本书的作者,以及 Fyne 项目的开发者,我相信 Go 可能是支撑这种跨平台图形应用程序革命的语言。
引用 Go 经常被问及的关于这个话题的问题,请参阅 golang.org/doc/faq
。
Go 通过尝试将解释型、动态类型语言的编程便捷性与静态类型、编译型语言的效率和安全性相结合来解决这些问题。它还旨在成为现代语言,支持网络和多核计算。
在本节中,我们将探讨为什么 Go 编程语言非常适合支持新一代的图形用户界面编程。
简单的跨平台代码
Go 是一种语言(类似于 C、C++、Swift 以及许多其他语言),它可以在每个它支持的平台上编译成原生二进制文件。这对于图形应用程序来说非常重要,因为这是在主流计算机硬件上创建最响应和最流畅的用户界面的最佳方式。与其他流行的 GUI 开发者语言相比,Go 的突出之处在于它能够在编译时支持大量操作系统,而无需任何修改或特殊适应,就可以在每个平台上生成原生代码。这意味着基于 Go 的项目可以在任何计算机上构建,用于其他任何计算机,使用标准工具,无需复杂的构建配置或额外安装的开发者包。在撰写本文时,Go 运行的平台包括 Windows、macOS、Linux、Solaris 以及其他流行的基于 Unix 的操作系统(这基本上是所有桌面个人计算机),以及 iOS、Android 和其他基于 Linux 的移动设备(甚至通过 TinyGo 在微型嵌入式计算机上)。
Go 是一种类型化语言,这意味着每个变量、常量、函数参数和返回类型都必须有一个单一、已定义的类型——这导致默认情况下代码更加健壮。与一些较老的类型化语言不同,Go 通常能够推断类型,这有助于避免源代码中的信息重复(实际上,Go 的设计原则之一就是避免重复)。这些特性帮助创建了一个既快速又易于开发的语言,同时创建的软件与传统用于原生图形应用的编程语言一样稳固。
除了易于学习和易于阅读之外,Go 语言还提供了关于代码风格、文档和测试的良好沟通标准。这种标准化使得开发者更容易理解不同的项目,并减少了集成库和学习新 API 所需的时间。除了记录这些标准之外,Go 开发工具包括可以检查你的代码是否符合这些指南的实用工具。在许多情况下,它们甚至可以自动更新你的源代码文件以符合这些指南。很自然地,支持 Go 的开发环境也鼓励遵循这些指南,这使得任何加入你项目的人的学习曲线更加平缓。
除了所有 API 的源代码和文档的标准格式外,Go 语言及其社区支持和鼓励在应用程序和库中进行单元测试。编译器内置了通常与动态语言相关联的测试功能,这些语言需要此类检查以确保正确性。在已经强大的语言中加入有效的测试功能,可以验证代码行为,并使得其他人更容易修改代码。实际上,在流行的 Go 语言库集合中,被列入的要求之一是你的代码满足 80%的单元测试代码覆盖率指标(参见github.com/avelino/awesome-go
及其贡献指南)。
标准库
编程语言的标准库是指由语言运行时提供的 API 集合和功能。例如,C 语言有一个非常小的标准库——作为一种为多种不同设备设计的底层语言,它能够支持的每个操作系统的功能数量是有限的。另一方面,Java 语言因其内存和启动时间较重而闻名,提供了一个庞大的标准库——包括在第一章中描述的 Swing GUI,GUI 工具包和跨平台开发简史。这是所有语言在决定是优先考虑较小的内存还是大量内置功能时都需要做出的权衡。
幸运的是,Go 语言做出了聪明的平衡,使其能够包含一个庞大的 API 库,完全支持其目标操作系统中的每一个。为此,它使用了构建标签,允许只包含当前(或目标)操作系统所需的代码。这对于想要为多个操作系统编写高效应用程序的开发者来说是一个巨大的优势,他们不需要为每个平台维护略有不同的版本,也不需要忍受缓慢的加载时间或大量的内存需求。
Go 的标准库包含了涵盖许多领域的强大功能,包括密码学、图像处理、文本处理(包括 Unicode)、网络、并发和网络服务集成(我们将在后续章节中介绍)。您可以在golang.org/pkg/#stdlib
上阅读完整的文档。
并发
如前文在 并行性和网络服务 部分所述,现代编程语言需要处理并发问题。不幸的是,使用 API 来管理多线程可能会增加复杂性,并使代码更难以阅读。Go 的设计者决定从一开始就将并发性纳入其中,使得管理许多执行线程变得容易,同时避免了共享内存管理的困难。基于没有这种内置并发意识的语言的 GUI 工具包已经传播了这样的观点:图形例程必须在特定的线程上执行。通过从更好的语言开始,我们可以避免这样的限制。
Go 不暴露传统的线程,而是引入了goroutines的概念——这些类似于轻量级线程,但增强了以支持同时运行数千个。应用程序之间通过共享内存进行通信是很常见的,但这引入了称为竞争条件的问题,需要更多的代码来管理访问。为了避免这种情况,Go 提供了通道——一种在执行线程之间通信而不引起相同问题的机制。在这个模型中,语言管理从 goroutine 到另一个 goroutine 的安全信息传输,使多线程代码整洁且易于理解。
网络服务
作为一种现代编程语言,Go 提供了对 HTTP 客户端、服务器和标准编码处理器的广泛支持,包括 JSON 和 XML。对于来自 C 编程背景的 GUI 开发者来说,这是一个重大的改进——当语言或其工具包被创建时,网络服务和远程资源并不常见。
由于语言级别对网络通信中使用的文本编码的支持,可以直接从 HTTP 请求中加载数据结构。这种便利性对于基于 Web 的语言(如 JavaScript 和 PHP)来说可能是标准的,但在严格类型化的语言中不使用第三方代码就能实现这一点是罕见的额外优势。
尽管这些特性使 Go 成为构建可以以高级别描述并彻底测试的复杂应用程序的优秀语言,但在需要时,也可以对操作系统执行特定平台请求。
高级系统访问
即使拥有功能齐全的编程语言,有时也可能会需要访问低级组件或特定平台的 API。无论是为了发送通知、从自定义设备读取数据,还是简单地调用当前操作系统的特定函数,有时可能需要访问编程语言或其标准库中未包含的功能。为了解决这一挑战,Go 提供了三条途径来处理程序的特定平台方面——syscall、CGo 和构建标签。
构建标签
在 Go 中,你可以根据一些称为构建标签的条件参数,在文件级别为特定操作系统包含部分代码。这有助于在应用程序将在某些系统上运行时调整行为,但在控制下一节中展示的特定平台 syscall 或 CGo 代码的使用方面更有益。
即使你的应用程序没有使用特定于操作系统的调用,利用条件编译也可能很有帮助。在简单情况下,Go 代码可以检查应用程序正在运行的系统以执行略微不同的代码(例如,通过检查os.GOOS
的值,一个函数可以返回不同的值)。然而,对于更复杂的行为变化,将特定平台代码放入以特定约定命名的单独文件中(例如,*_windows.go
在为 Microsoft Windows 构建时将被包含)或通过在文件顶部使用特殊注释(例如,// +build linux,darwin
在 Linux 或 macOS 是目标平台时包含文件)可能很有用。
Syscall
Go 标准库提供的包之一是os
和net
,以提供高级抽象,因此在使用 syscall 之前,请务必检查你所需的功能是否由其他包提供。
对这个包的典型调用可能是请求 Windows 注册表键的详细信息,或者加载 DLL(Windows 计算机上的系统库)以访问 Go 未提供的功能。在 Linux 计算机上,如果允许,你可以使用此功能读取或写入特定的内存区域,例如连接的设备。系统调用是一个非常复杂的程序,在可能的情况下,通常调用 C 函数会更简单——我们将在下一节中看到如何使用 CGo 来实现这一点。
CGo
如果您的应用程序或您依赖的一个库出于特定原因是用 C 语言(或其衍生语言,如 C++或 Objective-C)编写的,并且您无法将其迁移到 Go,那么 CGo 是一个无价的功能。使用此功能,可以直接包含 C 代码或调用其他基于 C 的功能。重要的是要小心使用此功能——从一种语言跳转到另一种语言会有一些性能影响——但更重要的是,您需要记住关于内存管理和线程的方式,这是 Go 通常会为您管理的。
以下源代码展示了在同一文件中 C 和 Go 代码的示例,其中我们将 Go 字符串转换为 C 字符串(或者更严格地说,转换为[]byte
)并将其传递给 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 run cgo.go
Hello World!
$
在代码片段中,您可以看到 C 代码包含在import "C"
行上面的注释中。此代码可以位于单独的.c
文件中,甚至可以在构建计算机上的库中(并且 Go 编译器将使用pkgconfig
来查找所需的头文件)。当与之前的代码中的条件构建(如构建标签)结合使用时,您可以看到,在需要时可以访问特定平台的功能或遗留代码。
本节考虑了 Go 语言及其设计如何非常适合构建现代 GUI 工具包。接下来,我们将探讨 Material Design 如何为 Fyne 应用程序提供出色的美学。
使用 Material Design 看起来很棒
任何 GUI 工具包的关键部分都可能影响开发者的选择,以及构建的应用程序的用户吸引力,那就是整体设计语言。这种审美选择体现在颜色、字体、布局甚至图标设计中。其中一些选择很明显,而另一些则更为微妙,但结合在一起,就能产生一个可识别的应用程序外观和感觉。
新的跨平台工具包通常创建自己的设计,例如 Java 的 Swing 或 GTK+和 Qt 工具包。这些通常设计得与当时软件的外观相匹配——您可以在这些工具包中识别出 20 世纪 90 年代的桌面应用程序设计。在当前的环境下,移动应用程序的可用性和设计原则正在被适应和部署到其他领域,为传统应用程序带来了软件设计的新时代。由于在这个领域的工作,Material Design 项目非常适合旨在具有通用跨平台吸引力的应用程序设计。
材料设计的官方网站(https://material.io
)对材料的描述如下:
材料是一个灵活的系统,包括指南、组件和工具,支持用户界面设计的最佳实践。由开源代码支持,Material 简化了设计师和开发者之间的协作,并帮助团队快速构建美观的产品。
Google 首次于 2014 年发布了基于他们之前为网站开发的新设计语言的 Material Design 指南。此后,它已被应用于其不同的网络属性,并已成为 Android 操作系统的视觉设计。这种适应性意味着组件设计已经确定了适用于所有平台的工作区域,同时也确定了可能需要为桌面计算机进行轻微调整的区域,从而减少了 Fyne 或其他使用其设计语言的跨平台工具包所需的工作。
标准组件的布局和功能因平台而异,尽管在主网站上提供了明确的推荐,但实现方式在material.io
上有文档记录。更普遍的是,材料应用中使用的调色板和图标图形。
调色板
材料设计颜色围绕标准调色板进行设计。主色用于提高某些元素的重要性,例如默认按钮或焦点输入。辅助的次级颜色(可选)保留用于强调重要项目,例如浮动按钮或选中文本。Material Design 项目提供了一个基线颜色主题,可用于任何应用程序。开发者也可以选择自己的主色和次级颜色,以匹配其品牌身份或首选的美学。以下是基线调色板:
![图 2.1 – 材料设计“基准”调色板
![图片 B16820_02_01.jpg]
图 2.1 – 材料设计“基准”调色板
材料主题的醒目颜色有助于应用程序拥有干净的设计,同时传达意义和重要性。同样,材料设计提供了一套干净、清晰的图标,应在可能的情况下使用。
标准图标
材料图标设计得既令人愉悦又精美,涵盖了大多数现代软件的常见交互和标准项目。它们包括标准硬件、文件类型、内容操作和用户动作等主题。除了标准图标集外,还有社区提交的附加组件库,对于采用较少标准操作的应用程序可能很有用。在下面的屏幕截图中,我们可以看到一些材料设计图标:
![图 2.2 – 材料设计图标的小选集
![图片 B16820_02_02.jpg]
图 2.2 – 材料设计图标的小选集
通过遵循材料设计规范,使用 Fyne 工具包构建的任何应用程序都将从一开始就干净、易于使用,同时支持品牌身份和定制。
我们已经看到了基于 Fyne 的应用程序对最终用户的外观,但对于开发者来说,API 设计同样重要。让我们看看团队如何旨在保持这种简洁和精心制作的设计。
为简洁性和可维护性设计 API
令人愉悦的用户体验是任何应用程序工具包的重要目标,但 Fyne 旨在使开发体验也变得愉快。为此,API 必须精心设计,既简单易学,又可扩展以支持更复杂的应用程序。项目的模块化方法旨在支持这一点,同时每一步都易于测试。
语义 API
API(或应用程序编程接口)通常被定义为控制对特性和数据访问的一组函数和过程。然而,在较高层次上,Fyne 工具包旨在提供一种语义 API,一组定义意图而不是特性和功能的函数。通过采取这种方法,工具包能够将意义与表现分离。
例如,我们可以考虑一个简单的按钮——当屏幕上有许多按钮时,你可能希望其中一个能够突出显示,显得更重要。在一个专注于表现或样式的 API 中,你可能设置按钮颜色;在 Flutter GUI 工具包中,这会显示如下:
FlatButton(color:Colors.cyan, child: Text("Tap Me"))
相比之下,采用语义方法的 API 将允许开发者通过按钮类型或意图字段来指示预期的差异,如下面的 Fyne 片段所示:
widget.Button{Text: "Tap Me", Importance: widget. HighImportance}
采用这种方法可以提供一个一致的 API,它描述了预期的结果而不是可能暗示这些结果的特性。它还允许当前主题确保一致的视觉风格,并避免开发者定义的代码创建难以阅读或不吸引人的图形选择。
模块化
当构建一个旨在跨多个不同的操作系统和计算机无缝工作的强大工具包时,采用模块化方法非常重要。这样做可以确保库的任何元素都不会对代码的其他区域做出错误的假设。不小心暴露关键区域(如图形驱动程序)的所有内部细节,可能会限制小部件只能在单个操作系统或特定图形模式下正确运行。这种技术在软件工程中被称为关注点分离。
在 Go 语言中,模块被称为包,并且它们在项目根目录下以层次结构组织。为了允许系统的不同部分进行通信,项目通常定义一组接口类型,这些类型描述了功能,以及某些代码可能选择遵守的依赖关系。通过加载实现这些接口的代码,应用程序或库可以将代码的独立元素组合在一起以创建完整的解决方案。每个区域只知道每个接口公开声明的功能,可以隐藏所有内部细节。这允许通过更小的部分构建和测试复杂的软件,如果出现问题,这要容易得多。
在 Fyne 中,包的使用可以在许多地方看到,最显著的是Driver
和Widget
接口定义的实现。Fyne 工具包中使用驱动程序使得应用程序能够在许多不同类型的计算机上运行,而无需了解或意外利用单个设备的特定细节。当应用程序启动时,正确的驱动程序将被加载来处理当前计算机运行的特定细节。正如您将在第五章“小部件库和主题”中看到的那样,Fyne 内部的各种小部件(以及确实可以由应用程序开发者添加的自定义小部件)都实现了Widget
接口。所有小部件必须实现的行为为驱动程序和图形代码提供了有关其外观的信息,这意味着图形代码不需要了解小部件的任何内部细节就能在应用程序窗口中绘制它。这使得小部件开发者能够避免影响图形代码,并且确实允许其他开发者在工具包代码之外添加自定义小部件。
模块化方法的一个其他好处是代码可以在不启动标准应用程序或显示任何窗口的情况下执行。这可能不是面向用户的代码的常见要求,但对于支持应用程序的高效测试来说非常重要。
可测试
自动测试图形用户界面代码长期以来一直被认为是完整测试套件中最困难的一步,如果你将智能手机或移动设备添加到你的支持平台中,这会变得更加困难。每个操作系统可能需要不同的方法,可能还需要编写和维护特定的代码来运行测试套件。正如前文所述,Fyne 工具包采用的模块化方法通过不需要显示应用程序来执行测试脚本,从而承诺更容易地进行测试。
由于其模块化设计,基于 Fyne 的应用程序的绘图组件是一个小细节。小部件的主要逻辑和行为与图形输出完全分开定义。这种方法允许所有元素通过自动化交互更快、更可靠地进行测试,比加载应用程序然后使用控制鼠标和键盘(或触摸屏)硬件的测试运行器点击按钮的替代工具更快、更可靠。以下代码片段显示了如何在简单的单元测试中测试Button
和Entry
小部件:
func TestButton_Tapped(t *testing.T) {
tapped := false
button := widget.NewButton("Hi", func() {
tapped = true
})
test.Tap(button)
if !tapped {
t.Errorf("Button was not tapped")
}
}
func TestEntry_Typed(t *testing.T) {
entry := widget.NewEntry()
test.Type(entry, "Hi")
if entry.Text != "Hi" {
t.Errorf("Text was not updated")
}
}
如你所见,这两个简单的单元测试(使用标准的 Go 测试结构)能够测试在标准用户交互发生时Button
和Entry
小部件的行为是否符合预期。测试辅助函数Tap
和Type
被提供以执行这些操作,以及test
包中的各种其他实用工具。通过这种方式构建测试套件,你可以每秒执行数千个 GUI 测试,而无需加载窗口或连接到特定的设备。实际上,这个功能支持图形应用程序的真正 TDD(测试驱动开发)。这种方法意味着在实现之前,可以设计和理解应用程序代码,从而产生更健壮的软件和更好的模块解耦,使得更多的开发者可以并行工作在一个项目上。
Fyne 工具包将确保所有元素都将正确地显示在应用程序将分发的任何设备上。其驱动程序和小部件都经过与本章前面所述相同的测试严格性。然而,有时有必要测试小部件或屏幕的实际渲染输出。在这种情况下,test
包提供了更多可以帮助你开发的实用工具。尽管这些工具在屏幕上不可见,但 Fyne 测试代码将计算输出将如何显示,并通过Capture()
函数将其保存到图像中。然后,测试辅助函数AssertImageMatches
能够将此与之前保存或由设计师创建的特定输出进行比较:
func TestButton_Render(t *testing.T) {
button := widget.NewButton("Hi", func() {})
window := test.NewWindow(button)
test.AssertImageMatches(t, "button.png", window.Canvas(). Capture())
}
这个代码示例确实包含了一些关于Window
和Canvas
的细节,这些内容我们将在第三章“窗口、画布和绘图”中进行讲解,但你可以看到其整体上的简洁性。代码定义了一个小部件(在这种情况下是一个Button
小部件),然后将其添加到一个测试窗口中,该窗口随后被捕获并与一个预先存在的图像文件进行比较。测试窗口不会显示在屏幕上,甚至不会与操作系统通信——它完全加载到内存中,以模拟绘图过程。
我们已经看到良好的模块化和测试如何导致更健壮的应用程序,但这里描述的是工具包设计——开发者是否可以为自己的目的扩展功能?
可扩展性
Fyne 工具包的核心小部件被设计成健壮、易于使用且经过良好测试,但工具包不能包含所有可能类型的小部件。因此,工具包也需要是可扩展的,支持包含核心项目未定义的小部件——无论是作为附加库还是允许应用程序添加它们自己的自定义用户界面元素。
Fyne 项目允许以两种不同的方式包含小部件——开发者可以扩展现有的小部件(保持主要渲染的一致性,但添加新功能)或者添加他们自己的小部件。如前文在模块化部分所述,任何实现Widget
接口的代码都将被解释为接口组件,并可以在任何 Fyne 应用程序中使用。在本书的后续部分,我们还将看到如何扩展现有的小部件以添加新功能或调整行为以适应特定应用程序。
由于模块化代码库基于界面设计,Fyne 应用程序可以以许多其他方式扩展。通过实现Layout
接口,应用程序可以定义其组件的位置和大小,或者,使用 URI 接口,它可以连接到不同类型的数据资源(更多信息,请参阅第四章,布局和文件处理)。
如您所见,工具包及其 API 的设计与它所包含的功能一样重要。为了完成本章,让我们回顾一下 Fyne 项目的整体愿景。
对未来的展望
Fyne 项目是在对现有图形工具包和应用 API 的复杂性日益增长的批评以及它们无法适应现代设备和最佳实践的背景下创建的。该项目旨在易于使用,并选择了 Go 语言,因为它具有强大的简洁性。
Fyne 项目维基上的愿景声明(github.com/fyne-io/fyne/wiki/Vision
)如下所述:
Fyne 的 API 旨在为开发美观、易用且轻量级的桌面和更多平台上的应用程序提供最佳方案。
由于设备类型和平台特定工具包的数量比我们最近看到的要多,要在所有平台上提供出色的原生应用程序体验变得更加困难,成本也更高。Fyne 工具包定位为解决这些挑战的解决方案,同时将现代移动应用程序的设计和用户体验学习带到所有设备上。
美观的应用程序
Fyne 旨在支持构建在所有平台上看起来一致的图形应用程序,而不是采用操作系统的外观和感觉。其 API 确保所有应用程序都提供精致的用户体验并渲染美观的应用程序 GUI。遵循 Material Design 指南,基于 Fyne 的应用程序将让 Android 用户感到熟悉,并与 Windows 10 的扁平用户界面美学相匹配。对于用户界面通常采用不同风格的操作系统,用户仍会为清晰的视觉设计、捆绑的图标和干净的字体感到高兴。
由于许多操作系统现在提供浅色和深色模式的选择,工具包需要相应地适应以满足用户期望。所有 Fyne 应用程序都包括浅色和深色主题,除非开发者覆盖设置,否则它将匹配当前的系统配置。当最终用户更改系统主题时,任何正在运行的 Fyne 应用程序都将更新以反映配置更改。这将在以下图像中显示,我们看到浅色和深色 Fyne 主题的外观:
容易学习
除了保持简洁、简单的设计外,Fyne 团队还希望确保每个人都能学会构建图形应用程序。为此,入门门槛需要低——通过简单的安装和设置,以及文档和教程的 readily 可用,以支持甚至是最没有经验的开发者。
对于之前没有使用 Go 语言的开发者来说,可以从在线导游(tour.golang.org/
)开始,然后转到 Fyne 导游,它向开发者介绍了 GUI 开发的概念以及如何开始这个项目(tour.fyne.io/
)。更多文档可以在主要开发者网站上找到,包括入门提示、代码教程和完整的 API 参考(developer.fyne.io/
)。
平台无关性
跨平台工具包在为不同平台构建时具有不同的复杂程度。有些需要不同的构建过程,而另一些则会根据设备类型加载不同的用户界面。Fyne 旨在使这些不同的目标更容易使用,并追求平台无关性,这意味着代码不需要了解它正在运行的设备。
对于许多初创公司来说,平台无关性在移动应用的世界中代表了一种难以达到的乌托邦。
这句话来自alleywatch.com.
基于 Fyne 的应用程序代码不需要适应系统特定性,并且可以使用相同的工具集编译到任何设备上。正如我们将在本书后面看到的那样,有时需要调整以适应特定的操作系统或设备。在这些情况下,有 Go 和 Fyne API 可用以帮助。对于许多应用程序,将有可能避免任何系统特定的定制,并且过程中唯一变化的是应用程序的发行。
分布到所有平台
当你的应用准备发布时,需要打包并上传到一个中央位置,让用户能够找到它。不幸的是,每个操作系统都使用不同的打包格式,每个供应商都有自己的应用商店或市场。我们将在本书中多次使用的fyne
命令行工具能够创建所有必需格式的应用程序包。应用打包后,可以在本地安装,与朋友分享,或者上传到网站进行分发。
大多数系统现在都在转向应用商店或市场模式,其中应用在制造商提供的位置可用,包括截图、免费广告和管理的安装。这里的挑战之一是每个商店都不同,每个平台的应用认证和上传过程也不同。Fyne 工具在发布过程中也提供帮助——尽可能简化流程,并确保所有商店的开发者体验一致。
摘要
在本章中,我们看到了如何设计一个新的图形工具包可以克服现有方法面临的许多挑战。我们探讨了 Fyne 的背景和愿景,它旨在解决这些困难,以及它如何支持在所有流行的桌面和移动设备上创建美观且性能良好的应用。我们介绍了材料设计,并看到了它如何将现代可用性原则和设计经验带到桌面以及更远的地方。通过使用fyne
构建工具,我们看到了应用可以构建和分发到任何设备或应用商店,而无需任何平台特定的代码。
在下一章中,我们将探讨 Fyne 应用程序的基础知识,并看到其绘图能力如何使我们能够构建一个简单的游戏。
进一步阅读
要了解更多关于本章介绍的一些主题,你可以访问以下网站:
-
Go 编程语言:
golang.org/
-
Go 语言中的 C 绑定:
golang.org/cmd/cgo/
-
材料设计系统:
material.io
-
Fyne 工具包开发者文档:
developer.fyne.io/
第二部分:Fyne 应用程序的组件
Fyne 工具包包含许多相互构建的组件包。要提供一个完整的跨平台小部件工具包,需要许多启用区域,包括图形应用程序编程接口(APIs)、文件处理和布局管理。除此之外,Fyne 还提供了高级功能,如数据绑定和存储抽象,这些功能使得构建复杂的应用和工作流程变得容易。
要了解 Fyne 的全部功能,我们将探索工具包的所有领域、其包和其功能。本节中的每一章都包含一个示例应用程序,该应用程序探讨了这些概念,包括详细的步骤和完整的源代码。
本节将涵盖以下主题:
-
第三章,画布、绘图和动画
-
第四章,布局和文件处理
-
第五章,小部件库和主题
-
第六章,数据绑定和存储
-
第七章,构建自定义小部件和主题
我们从探索为整个工具包提供动力的画布和图形 API 开始本节。
第三章:第三章:窗口、画布和绘图
我们已经探讨了图形应用程序开发的基础,并看到了从现代语言中的新设计开始如何导致更简单的开发。从现在开始,我们将更详细地探讨 Fyne 工具包旨在为所有开发者提供易于使用的 API 来构建跨平台应用程序。
在本章中,我们将研究 Fyne 应用程序的结构,它如何绘制对象,以及它们如何在容器中进行缩放和操作——以及动画。
在本章中,我们将涵盖以下主题:
-
如何构建 Fyne 应用程序及其结构以及如何开始制作您的第一个应用程序
-
探索画布包和可以绘制的对象类型
-
可扩展元素如何创建一个干净的用户界面
-
与位图和像素渲染一起工作
-
元素和属性的动画
到本章结束时,您将了解这些功能如何组合在一起创建一个图形应用程序,这将通过一个简单的游戏进行演示。
技术要求
在本章中,我们将编写我们的第一个 Fyne 代码,包括构建一个完整的应用程序。为此,您需要安装 Go 编译器以及一个代码编辑器应用程序。您可以从 golang.org/dl/
的主页下载 Go。代码编辑器的选择通常是用户偏好的问题,但微软的 Visual Studio Code 和 JetBrain 的 GoLand 应用程序都强烈推荐。
由于 Fyne 在内部使用一些操作系统 API,因此您还需要安装一个 C 编译器。Linux 上的开发者可能已经安装了一个;macOS 用户可以从 Mac App Store 简单地安装 Xcode。基于 Windows 的开发者需要安装一个编译器,例如 MSYS2、TDM-GCC 或 Cygwin——更多详细信息可以在 附录 A – 开发者工具安装 中找到。
本章的完整源代码可以在本书的 GitHub 仓库中找到,网址为 github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne/tree/master/Chapter03
。
Fyne 应用程序的解剖结构
正如我们在 第二章 《根据 Fyne 的未来》 中所看到的,工具包抓住了从头开始的机会,摒弃了旧工具包中的一些有时令人困惑的限制。因此,API 需要定义构建图形应用程序所涉及的所有内容。在本节中,我们将探讨运行基于 Fyne 的应用程序和产生屏幕上可见组件的主要概念,从应用程序本身开始。
应用程序
在fyne.App
接口中定义的应用程序,模拟了基于 Fyne 的应用程序的功能。每个使用 Fyne 的应用程序通常会在其main()
函数内部创建并运行一个单独的fyne.App
实例。由于图形应用程序的工作方式,它们必须从主函数启动,而不是通过 goroutine 或其他后台线程。
要创建应用程序实例,我们利用 Fyne 中的app
包,可以使用fyne.io/fyne/app
导入它。这个包包含了所有逻辑和驱动设置代码,允许应用程序了解其运行的平台并适当地配置自己。我们调用的函数名为New()
,它将返回我们将在整个代码中使用的应用程序实例。要运行应用程序,我们随后调用Run()
,应用程序将启动。
带着这些知识,我们可以运行我们的第一个 Fyne 应用程序;然而,如果不先要求它显示某些内容,很难知道它是否在正常工作!因此,我们现在将学习如何在运行第一个示例之前显示一个窗口。
窗口
窗口定义了屏幕上应用程序控制区域。在桌面环境中,这通常会在与已安装的其他应用程序匹配的窗口边框内显示。你通常可以移动和调整窗口大小,并在完成后关闭它。
在移动和其他设备上,这个概念可能定义得不太明确。例如,在 Android 和 iOS 智能手机上,应用程序窗口将占据整个屏幕,并且不会显示窗口边框。要切换应用程序,你会使用操作系统定义的手势或按下一个标准按钮,其他应用程序将出现,允许你移动。此外,平板电脑——例如 iPadOS、Android 或 Windows——将允许应用程序在屏幕的一部分显示,可能通过一个分隔符来分隔,允许用户改变使用的空间量。
在所有这些不同的展示模式中,显示的内容仍然由fyne.Window
接口来模拟。
要在屏幕上显示内容,我们需要创建一个新的窗口并显示它。一旦定义了窗口,我们就可以运行应用程序来查看结果。让我们逐步分析我们的第一个应用程序的代码!
-
我们打开一个新的文件,
main.go
,并将其定义为main
包:package main
-
然后我们需要添加任何导入——在这个例子中,我们只是使用
app
包,所以以下就足够了:import "fyne.io/fyne/app"
-
要定义一个可运行的程序,我们创建一个
main()
方法。在这个函数中,我们将使用之前看到的New()
函数创建一个新的应用程序实例:func main() { a := app.New() ... }
-
此外,在这个方法中,我们调用
NewWindow(string)
(在fyne.App
中定义),这允许我们创建用于显示的窗口。我们传递一个字符串参数来设置标题(例如,如果操作系统在窗口边框或应用程序切换器中显示标题,则使用它)。将以下代码放置在前面代码片段中的...
出现的位置:w := a.NewWindow("Hello")
-
一旦我们创建了一个窗口,我们可以使用
Show()
函数来显示它。在显示窗口之后,我们还需要在应用程序上调用Run()
函数。由于通常同时进行这两项操作,有一个辅助函数ShowAndRun()
,我们可以在显示应用程序的第一个窗口时使用它:w.ShowAndRun()
-
在此代码到位后,我们可以保存文件并像其他 Go 应用程序一样运行其内容:
Chapter03/window$ go run main.go
-
你应该在屏幕上看到一个窗口出现。根据你的操作系统,这可能会是一个非常小的窗口,因为我们没有添加任何内容。下面的截图是在 macOS 计算机上调整空窗口大小后拍摄的:
图 3.1 – 我们的第一个窗口
如您从图 3.1中的窗口中看到的,窗口中没有内容,因为我们没有设置任何内容。窗口的背景不仅仅是黑色(或旧图形内存中的随机颜色)——这怎么可能呢?原因是窗口包含一个Canvas
,这就是管理我们绘制的内容的东西。
画布
每个fyne.Window
的内容都是一个fyne.Canvas
。尽管画布内部的工作方式取决于当前系统和 Fyne 内部驱动程序包中的某些复杂代码,但它对开发者和我们的应用程序最终用户来说看起来完全一样。这个平台无关的渲染画布负责所有组合在一起创建图形输出并最终完成应用程序界面的绘制操作。
在每个画布中至少包含一个fyne.CanvasObject
。这些对象,正如我们在理解 CanvasObject 和画布包部分将看到的,定义了可以绘制到画布上的操作类型。为了设置窗口的内容,我们可以使用Window.SetContent(fyne.CanvasObject)
。这个函数将内容传递到画布,告诉它绘制这个对象,并且调整窗口大小以足够大以显示它。
当然,这仅设置内容为单个元素;我们通常希望包含许多元素,这正是Container
类型提供的。
容器
fyne.Container
扩展了简单的fyne.CanvasObject
类型,包括管理多个子对象。容器负责控制它包含的每个元素的大小和位置。图 3.2显示了画布包含一个容器,该容器将三个CanvasObject元素堆叠在左侧,并在右侧还有一个额外的Container。这个第二个容器负责三个进一步元素,它将它们排列成一行:
![图 3.2 – 包含各种容器和 CanvasObject 元素的画布
![img/Figure_3.3_B16820.jpg]
图 3.2 – 包含各种容器和 CanvasObject 元素的画布
容器通常将定位子对象的工作委托给fyne.Layout
。这些布局算法将在第四章**,布局和文件处理中进一步探讨。在当前章节中,我们将使用不带布局的容器——这些被称为手动布局,并使用container.NewWithoutLayout(elements)
调用,其中elements
参数是容器将要展示的fyne.CanvasObject
类型列表。我们将在组合元素部分进一步探讨手动布局。
现在我们已经看到了应用程序的定义以及它如何处理图形元素的表现,我们应该看看 Fyne 支持哪些绘图功能,以及如何使用它们。
理解 CanvasObject 和画布包
CanvasObject
定义只是一个 Go 接口,它描述了一个可以定位、调整大小并添加到 Fyne 画布中的元素。该类型不包含任何关于如何绘制的相关信息——这些信息由canvas
包内的具体类型提供。这些类型定义了易于理解的图形原语,例如Text
和Line
。
在学习如何使用这些元素之前,我们应该看看它们在 Fyne 演示应用程序中的样子。
画布演示
在我们查看如何在窗口中显示形状的代码之前,我们应该看看这些功能的实际演示。使用内置的 Fyne 演示应用程序,我们可以看到canvas
包支持什么。如果您还没有这样做,可以使用以下命令安装并运行演示应用程序:
$ go get fyne.io/fyne/cmd/fyne_demo
$ ~/go/bin/fyne_demo
在运行演示时,点击左侧导航面板上的画布项。你应该看到以下屏幕:
![图 3.3 - fyne_demo 应用程序展示各种画布原语
![img/Figure_3.7_B16820.jpg]
图 3.3 - fyne_demo 应用程序展示各种画布原语
如图 3.3所示,该窗口展示了 Fyne 所知的几种画布类型。这里绘制的类型按以下列表顺序命名(从左到右,从上到下):
-
图片
-
矩形
-
线
-
圆形
-
文本
-
光栅
-
线性渐变
-
径向渐变
这些元素都可以包含在我们的应用程序中,正如我们接下来将要探索的。
向我们的窗口添加对象
上一演示图中的每个元素,以及随后添加到canvas
包中的任何新项目,都可以直接使用它们的NewXxx()
构造函数创建。从这个构造函数返回的对象可以直接传递到窗口或对象容器中。
为了演示这一点,让我们向空窗口添加一些文本内容。在之前使用的import
语句中添加image/color
和fyne.io/fyne/canvas
之后,我们可以将主函数更改为以下内容:
func main() {
a := app.New()
w := a.NewWindow("Hello")
w.SetContent(canvas.NewText("This works!",
color.Black))
w.ShowAndRun()
}
如您所见,这次更改仅添加了一行——传递给 w.SetContent
的 canvas.NewText
。文本构造函数接受两个参数,要显示的文本和要使用的颜色。如果您运行此代码,您将看到窗口现在包含文本 This works!,并且大小正好适合显示:
图 3.4 – 显示文本内容
如您所见,显示画布元素就像知道您想显示哪些内容一样简单。让我们看看一个稍微复杂一点的例子,使用容器中的多个画布元素。
元素组合
为了展示我们如何使用容器来显示多个项目并创建更吸引人的输出,我们将使用 canvas.Circle
和 canvas.Rectangle
元素在 fyne.Container
内复制一个道路标志。让我们看看如何进行:
-
首先,我们将为这段代码创建一个新函数,命名为
makeSign()
。它应该返回a fyne.CanvasObject
(所有图形元素都实现此接口)。我们代码的其余部分将放入此函数中:func makeSign() fyne.CanvasObject { ... }
-
然后,我们将使用
canvas.NewCircle()
创建一个标志的背景,并保存对其的引用,以便我们稍后可以使用它。我们传递的颜色是鲜艳的红色——红色通道的255
(最大值)。绿色和蓝色值是0
,alpha 通道(颜色看起来有多不透明)也设置为最大值,因此它是完全可见的:bg := canvas.NewCircle(color.NRGBA{255, 0, 0, 255})
-
然后,我们给这个圆添加一个白色边框。
StrokeWidth
属性控制边框有多宽(默认为0
或隐藏),我们将StrokeColor
设置为白色以形成一个外圈白色:bg.StrokeColor = color.White bg.StrokeWidth = 5
-
接下来,我们将绘制横跨中心的条形;这只是一个白色矩形:
bar := canvas.NewRectangle(color.White)
-
要组合这两个元素,我们定义一个容器。重要的是要首先传递带有圆圈(
bg
)的参数,然后是矩形(bar
),因为它们将按此顺序绘制:c := container.New(bg, bar)
-
接下来,我们必须定位这些元素。我们将指定标志是
100
x100
。因为它有边框,我们将从两侧向内缩进10
x10
,以便它在 120 x 120 大小的窗口中居中:bg.Resize(fyne.NewSize(100, 100)) bg.Move(fyne.NewPos(10, 10))
-
为了定位
bar
,我们将它设置为80
x20
。为了将其定位在 60, 60 的中心点,我们将它移动到20
,50
:bar.Resize(fyne.NewSize(80, 20)) bar.Move(fyne.NewPos(20, 50))
在此代码到位后,我们通过返回我们制作的容器来结束函数:
return c
这完成了我们标志的定义。要显示它,我们调用 makeSign()
函数并将其传递给 SetContent()
。我们还可以关闭窗口上的默认填充,因为我们的内容没有达到容器的边缘。因为我们正在使用手动布局,所以我们也需要调整窗口大小以显示我们定位的项目:
w.SetContent(makeSign())
w.SetPadded(false)
w.Resize(fyne.NewSize(120, 120))
在此代码到位后,您可以用通常的方式运行应用程序,但这次我们将强制使用深色主题,以便我们的白色边框更加突出(我们将在 第五章,小部件库和主题)中更详细地了解主题):
Chapter03/canvas$ FYNE_THEME=dark go run main.go
您应该看到一个带有清晰标志的窗口,背景颜色较深:
![图 3.5 – 由圆形和矩形创建的我们的路标]
图 3.5 – 由圆形和矩形创建的我们的路标
注意:手动布局不会自动调整大小
当使用手动布局,如本例中所述,当窗口大小改变时,它不会进行缩放。这种功能是通过使用布局算法实现的,我们将在第四章,布局和文件处理中探讨。
在本节中,我们探讨了画布元素及其绘制方法。它们创建出清晰、干净的输出,但我们没有探讨它是如何工作的。在下一节中,我们将探讨可缩放渲染是什么以及它是如何创建如此高质量的输出的。
可缩放绘图原语
如您从上一个示例中可能已经意识到的,我们迄今为止渲染的所有项目都是矢量图形。这意味着它们是由线条、曲线和高层次参数描述的,而不是像素集合。正因为如此,这些组件被称为可缩放的(就像在可缩放矢量图形(SVG)文件中一样),这意味着它们可以在任何比例下绘制。Fyne 工具包是一个可缩放的工具包,这意味着 Fyne 应用程序可以在任何比例下绘制并以高质量渲染。
让我们更详细地看看文本组件,例如。我们像以前一样定义一个简单的文本组件:
w.SetContent(canvas.NewText("Text", color.Black))
然后,我们可以将这一行代码放入我们在本章第一部分“Fyne 应用程序结构”中编写的标准main()
函数中,然后运行它。输出将如预期的那样——以正常大小绘制文本——但如果使用FYNE_SCALE
覆盖首选比例,我们可以看到如果用户想要更大的文本,应用程序将看起来如何:
![图 3.6 – 使用 FYNE_SCALE=1(左)和 3.5(右)渲染的 canvas.Text]
图 3.6 – 使用 FYNE_SCALE=1(左)和 3.5(右)渲染的 canvas.Text
以这种方式缩放 Fyne 应用程序不仅会改变字体大小,还会缩放用户界面的每个元素。这包括所有标准图形、小部件和布局。标准主题还提供了一套图标(我们将在第五章,小部件库和主题中进一步探讨),这些图标也是可缩放的。我们可以通过使用主题资源和图标小部件类型来看到这一点:
w.SetContent(widget.NewIcon(theme.ContentCopyIcon()))
通过将前面的行添加到同一个main
函数中,我们可以看到图标将如何缩放以匹配前一个图中展示的文本:
图 3.7 – 使用 FYNE_SCALE=1(左)和 3.5(右)渲染的 widget.Icon
元素的大小和位置将根据画布比例进行缩放。我们现在可以看看这个坐标系是如何工作的。
坐标系
如您在之前的组合元素部分中看到的,有时需要在用户界面内定位或调整元素的大小。对于可伸缩的输出,这样做可能会有些复杂,因为我们不是用像素来衡量内容的。因此,Fyne 使用一个与设备无关的坐标系,这可能是 Android 开发者所熟悉的。
在 Fyne 中,1 x 1 的大小(写作fyne.NewSize(1, 1)
)可能代表多于(或少于)1 个输出像素。如果缩放比例是 3(如许多现代智能手机那样),那么 1 x 1 的正方形可能会使用九个输出像素。由于工具包是为可伸缩输出设计的,所以结果不会是像素化的输出,就像它可能使用简单的将每个像素大小乘以的老式图形工具包那样。渲染的输出将继续看起来清晰,就像我们在图 3.6和图 3.7中看到的那样。
当与不同像素密度的显示设备一起工作时,完全可伸缩的用户界面具有巨大的好处,它允许用户为所有应用程序组件选择首选的缩放级别。然而,有时我们需要与非可伸缩的元素一起工作,例如基于像素的位图图像,或者我们的应用程序可能需要使用所有可用的像素进行高清图形输出。我们将在下一节中探讨这个问题。
像素输出 – 渲染图像
由于前一部分中概述的原因,建议您使用可伸缩的图形(通常是 SVG 文件)来制作图标和其他基于图像的用户界面组件。有时需要与位图图形(由像素集合定义而不是图形特征)一起工作。如果您正在加载和管理图像,或者您想使用所有可用的像素显示详细的图形元素,那么本节包含有关如何进行的重要信息。
图像
Fyne 中的图像内容定义了通常会根据分配给它的空间拉伸或缩小的图形内容。将具有像素尺寸定义的位图图像加载到可伸缩的输出中可能不会提供预期的结果。以像素定义的大小渲染的输出将取决于输出设备的缩放比例或用户的首选项。因此,Fyne 通常不会为正在加载的图像设置最小尺寸。
图像可以从文件、资源、数据流等加载。每个文件都应该有一个唯一的名称(或路径),这使得通过缓存进行性能改进成为可能。从文件系统加载图像应通过 Fyne 的storage
API 进行,以避免任何特定平台的代码或假设(这在第四章,布局和文件处理中详细探讨)。如果需要,您可以使用storage.NewFileURI
来获取文件路径的引用。例如,要从文件路径加载图像,您将调用以下代码:
img := canvas.NewImageFromURI(storage.NewFileURI(path))
要定义加载的图像在您的应用程序中的显示方式,您可以在canvas.Image
对象中设置ImageFill
字段。它将是以下值之一:
-
canvas.ImageFillStretch
: 默认值。这将调整图像尺寸以匹配图像对象大小,调整宽高比(宽度和高度值的比率),这可能导致图像看起来被压扁或拉伸。 -
canvas.ImageFillContain
: 此填充选项将保留图像的宽高比,以确保图像不会被扭曲,并以尽可能大的尺寸绘制,使其适合图像大小。这通常会在两个边缘上留下空间,以便图像在可用空间中居中。 -
canvas.ImageFillOriginal
: 在原始填充模式下,图像将使用图像中的每个像素显示一个输出像素。尽管这似乎是理想的,但重要的是要注意,由于像素密度的变化,其可见大小将根据设备而变化。使用此值还将确保为绘制所需数量的像素保留足够的空间。如果图像可能比可用空间大,请确保将其包裹在滚动容器中(在第第五章**,小部件库和主题)中讨论)。
如填充模式中所述,图像的输出大小不能通过查看图像文件来确定,因此您的应用程序可能需要指定图像的大小。通常,这将由布局控制——图像将根据布局类型扩展。另一种方法是调用SetMinSize()
以确保图像永远不会小于指定的(与像素无关)值。如果您已使用ImageFillOriginal
,则此步骤将已自动完成。
如果使用的是小图像,但它们占据了很大的空间,它们可能看起来像素化,这取决于它们被缩放的程度。建议您使用包含足够像素/细节的图像,这样在显示时它们会被缩小(显示得更小),而不是放大;然而,如果您想让输出看起来像素化(或复古),可以指定使用基于像素的缩放来增强这种外观:
Image.ScaleMode = canvas.ImageScalePixels
注意,这里描述的像素化输出不适用于图像文件为.svg
的情况。当加载可缩放图像文件时,它将始终被重新绘制到请求的大小,确保每次输出都是高质量的。
图像不是绘制位图内容的唯一方式;我们还可以使用Raster
类型包含更多动态创建的像素内容。
光栅
在某些情况下,应用程序可能希望使用所有可用的像素来显示内容,以便显示高细节,例如在显示波形或 3D 渲染时。在这些情况下,我们使用Raster
小部件。这是设计用来显示基于像素的动态计算输出,而不是从文件中加载。
光栅输出将根据其在占据空间中可用的像素数量动态确定要显示的内容。每次空间调整大小时,都会要求小部件重新绘制自己。这些内容请求由生成器函数处理。
在本例中,我们将探讨如何显示棋盘图案:
-
首先,我们声明一个生成器函数——这将接受请求的像素的
x
、y
参数以及整个区域的宽度和高度参数(以像素为单位),并返回一个颜色值,如图所示:func generate(x, y, w, h int) color.Color { ... }
-
我们接下来想确定我们的像素的颜色。以下计算将在
x
、y
坐标位于左上角的 20 x 20 像素方块或任何奇数行的方块,以及下一行的相反方块时返回白色。对于这些像素,我们指定白色颜色:if (x/20)%2 == (y/20)%2 { return color.White }
-
对于任何其他像素,我们将返回黑色:
return color.Black
-
生成器函数定义后,我们可以创建一个新的
raster
小部件,该小部件将使用它来着色输出像素:w.SetContent(canvas.NewRasterWithPixels(generate))
-
通过在先前的示例中重用相同的应用程序启动代码,我们可以加载应用程序并显示其窗口:
Chapter03/raster$ go run main.go
-
这将显示以下图中的结果:
图 3.8 - 使用 FYNE_SCALE=1
(左侧)和 3.5
(右侧)渲染的 canvas.Raster
无论你改变比例还是调整窗口大小,你都会看到图案以相同的大小重复,始终使用宽度和高度为 20 像素的正方形。在我们完成本节之前,我们还应该看看如何在画布上处理渐变。
渐变
正如我们在 图 3.3 中所看到的,Fyne 画布也能够显示渐变。与上一节中的光栅类似,渐变将使用所有可用的像素来显示,以实现每个设备可能的最佳输出。然而,添加渐变比管理光栅内容要简单得多。
有两种类型的渐变:线性渐变和径向渐变。
线性渐变
LinearGradient
显示从一种颜色到另一种颜色的均匀过渡,通常以水平或垂直方式呈现。垂直渐变从区域顶部的起始颜色到底部的结束颜色改变颜色;每一行的像素将具有相同的颜色,从而创建一个从顶部到底部过渡的渐变区域。水平渐变执行相同的操作,但起始颜色在区域的左侧,结束颜色在右侧,这意味着每一列的像素将具有相同的颜色。
例如,以下行将分别使用提供的便利构造函数创建从白色到黑色的水平垂直渐变:
canvas.NewHorizontalGradient(color.White, color.Black)
canvas.NewVerticalGradient(color.White, color.Black)
通过将这些传递给 Window.SetContent
,就像我们在其他示例中所做的那样,你可以看到以下结果,左侧有一个水平渐变,右侧有一个垂直渐变:
图 3.9 – 水平和垂直渐变
也可以指定线性渐变的精确角度。NewLinearGradient
构造函数接受第三个参数,即用于定位的角度(以度为单位)。垂直渐变在 0
度,水平渐变在 270
度(相当于逆时针旋转 90
度)。因此,水平渐变辅助函数的使用也可以写成如下所示:
canvas.NewLinearGradient(color.White, color.Black, 270)
有时,然而,需要形成一个曲线的渐变;为此,我们使用径向渐变。
径向渐变
径向渐变是指起始颜色位于区域的中心(尽管可以使用 CenterOffsetX
和 CenterOffsetY
来偏移),并逐渐过渡到区域的边缘的结束颜色。渐变绘制的方式是,结束颜色在从渐变中心出发的水平线和垂直线范围内完全显示。这意味着此渐变占据的区域角落将位于渐变计算之外;因此,将结束颜色设置为 color.Transparent
可能很有用。我们设置了一个类似于 LinearGradient
示例的从白色到黑色的渐变,如下所示:
canvas.NewRadialGradient(color.White, color.Black)
当将此代码放置在窗口的内容中时,将生成以下图像:
![图 3.10 – 从白色到黑色的径向渐变
图 3.10 – 从白色到黑色的径向渐变
我们已经看到了我们可以输出的各种方式,但也可以使内容动起来,从而使你的应用程序看起来更具交互性。我们将在下一节中了解如何做到这一点。
绘制属性的动画
Fyne 的 canvas
包还包括处理对象和属性动画的功能。使用这些 API 将有助于你管理平滑的过渡,以获得更好的用户体验。
动画过渡
在 Fyne 中,最基本的动画是一个将在每个图形帧上被调用的函数。一旦启动,它将根据 Duration
字段指定的时长运行。可以使用以下方式创建基本动画:
anim := fyne.NewAnimation(time.Duration, func(float32))
从这个构造函数返回的动画可以通过调用 anim.Start()
来启动。当动画启动时,其结束时间将基于经过的时间长度来计算。传入的回调函数将在每次图形更新时执行。此函数的 float32
参数在开始时为 0.0
,在结束前立即为 1.0
;每次中间调用都将在这两个值之间。
为了提供更具体的说明,我们可以设置一个位置动画。这是 canvas
包提供的有用动画之一。它,像许多其他动画一样,需要两个额外的参数:动画的 start
和 end
值。在这种情况下,它期望一个 start
和 end
fyne.Position
。请注意,callback
函数将提供当前的位置值,而不是 float32
偏移 参数。我们创建一个新的位置动画,它将持续一秒钟:
start := fyne.NewPos(10, 10)
end := fyne.NewPos(90, 10)
anim := canvas.NewPositionAnimation(start, end,
time.Second, callback)
callback
函数负责将位置值应用于图形对象。在这种情况下,我们将创建一个将在窗口中移动的文本对象:
text := canvas.NewText("Hi", color.Black)
callback := func(p fyne.Position) {
text.Move(p)
canvas.Refresh(text)
}
然后,我们只需使用 Start()
方法启动此动画即可:
anim.Start()
这些动画将只运行一次,但也可以要求它们循环。
循环动画
任何动画都可以设置为重复——这意味着在时间持续时间过后,它将从开始处再次开始。要请求此行为,将 Animation
结构体上的 RepeatCount
字段设置为 fyne.AnimationRepeatForever
:
anim.RepeatCount = fyne.AnimationRepeatForever
将 RepeatCount
设置为大于 0
的任何数字将指定此动画应重复的次数。
anim.Start()
在重复动画开始后,它将一直运行,直到手动停止(使用 Animation.Stop()
)或达到 RepeatCount
中指定的重复次数。
有许多更多的动画 API 可以用来控制图形和过渡。您可以在 canvas
包中查找 NewXxxAnimation()
构造函数来找到更多。
现在我们已经探索了 Fyne 工具包的图形功能,我们将将其组合在一个小型游戏应用程序中。
实现一个简单的游戏
在本书的第一个示例应用程序中,我们将通过构建 蛇游戏 的图形元素来了解画布元素是如何结合在一起的(关于这款游戏的历史,请参阅维基百科条目en.wikipedia.org/wiki/Snake_(video_game_genre)
)。这个游戏的主要元素是蛇角色,用户将控制它在屏幕上移动。我们将从一排矩形开始构建蛇,并添加动画元素使其生动起来。让我们先绘制初始屏幕。
在屏幕上绘制蛇
要开始显示游戏画布的工作,我们将创建一个由 10 个绿色方块组成的简单蛇。让我们开始:
-
首先,我们将创建一个设置函数,该函数将构建游戏屏幕。我们将调用此函数
setupGame
并创建一个空列表,我们将填充它。此方法返回的容器没有布局,这样我们就可以稍后为视觉元素手动布局:func setupGame() *fyne.Container { var segments []fyne.CanvasObject ... return container.NewWithoutLayout(segments...) }
-
为了设置图形元素,我们将遍历一个包含 10 个元素的循环(
i
从0
到9
),并为每个位置创建一个新的Rectangle
。这些元素都是 10 x 10 的大小,并使用Move
函数一个接一个地放置。我们将它们全部添加到之前创建的段切片中。这完成了我们的设置代码:for i := 0; i < 10; i++ { r := canvas.NewRectangle(&color.RGBA{G: 0x66, A: 0xff}) r.Resize(fyne.NewSize(10, 10)) r.Move(fyne.NewPos(90, float32(50+i*10))) segments = append(segments, r) }
之前提到的颜色规范使用的是十六进制格式,其中
0xff
是通道的最大值,缺失的通道(如本代码中的红色和蓝色)默认为0
。结果是中等亮度的绿色。 -
使用图形设置代码,我们可以将其包裹在通常的应用程序加载代码中,这次将
setupGame()
的结果传递给SetContent
函数。由于这个游戏没有动态大小,我们将调用SetFixedSize(true)
以确保窗口不能调整大小:func main() { a := app.New() w := a.NewWindow("Snake") w.SetContent(setupGame()) w.Resize(fyne.NewSize(200, 200)) w.SetFixedSize(true) w.ShowAndRun() }
-
现在,我们可以像往常一样构建和运行代码:
Chapter03/example$ go run main.go
-
你将看到以下结果:
![图 3.11 – 窗口中绘制的一个简单蛇
图 3.11 – 窗口中绘制的一个简单蛇
接下来,我们将通过一些简单的移动代码使蛇活跃起来。
添加计时器来移动蛇
下一步是添加一些游戏动作。我们将从一个简单的计时器开始,它将蛇重新定位在屏幕上:
-
为了帮助管理游戏状态,我们将定义一个新的类型来存储每个蛇段
x
、y
值,命名为snakePart
。然后我们创建一个包含所有元素的切片,这就是我们在蛇在屏幕上移动时将更新的内容。我们还将定义一个游戏变量,当需要刷新屏幕时我们将使用它:type snakePart struct { x, y float32 } var ( snakeParts []snakePart game *fyne.Container )
-
在
setupGame
内部,我们需要创建蛇段表示,每个矩形一个。将以下行添加到循环中将设置状态:seg := snakePart{9, float32(5 + i)} snakeParts = append(snakeParts, seg)
-
为了确保每次移动蛇时游戏都会刷新,我们需要移动矩形并调用
Refresh()
。我们创建一个新的函数,该函数将根据更新的蛇段信息更新我们之前创建的矩形。我们称这个函数为refreshGame()
:func refreshGame() { for i, seg := range snakeParts { rect := game.Objects[i] rect.Move(fyne.NewPos(seg.x*10, seg.y*10)) } game.Refresh() }
-
为了运行主游戏循环,我们需要一个额外的函数来使用计时器移动蛇。我们称这个函数为
runGame
。此代码等待 250 毫秒,然后移动蛇。为了移动它,我们复制每个元素的位置,从比它前进一步的那个元素的位置开始,从尾部到头部工作。最后,代码将头部移动到新的位置,在这种情况下是屏幕的上方(通过使用snakeParts[0].y--
)。请参考以下函数:func runGame() { for { time.Sleep(time.Millisecond * 250) for i := len(snakeParts) - 1; i >= 1; i-- { snakeParts[i] = snakeParts[i-1] } snakeParts[0].y-- refreshGame() } }
-
为了启动游戏计时器,我们需要更新
main()
函数。它必须分配game
变量,以便我们稍后可以刷新它,并且它将启动一个新的 goroutine 执行runGame
代码。我们通过更改SetContent
和ShowAndRun
调用来这样做:game = setupGame() w.SetContent(game) go runGame() w.ShowAndRun()
-
运行更新后的代码最初将显示相同的屏幕,但绿色形状随后将向上移动屏幕,直到它离开窗口:
Chapter03/example$ go run main.go
在放置了绘制和基本运动代码之后,我们希望能够控制游戏,我们将在下一节中探讨这一点。
使用按键控制方向
要控制蛇的方向,我们需要处理一些键盘事件。不幸的是,这将是针对具有硬件键盘的桌面或移动设备;要添加触摸屏控制,需要使用小部件(如按钮),我们将在第五章**,小部件库和主题中不探讨这些内容:
-
首先,我们定义一个新的类型(
moveType
),它将用于描述下一个移动方向。我们使用 Go 内置指令iota
,它在其他语言中类似于enum
。然后定义move
变量来保存下一个移动方向:type moveType int const ( moveUp moveType = iota moveDown moveLeft moveRight ) var move = moveUp
-
接下来,我们将从按键事件转换为我们刚刚定义的运动类型。创建一个新的
keyTyped
函数,如下所示,该函数将执行键盘映射:func keyTyped(e *fyne.KeyEvent) { switch e.Name { case fyne.KeyUp: move = moveUp case fyne.KeyDown: move = moveDown case fyne.KeyLeft: move = moveLeft case fyne.KeyRight: move = moveRight } }
-
为了触发按键事件,我们必须指定这个键处理程序应该用于当前窗口。我们通过在窗口的画布上使用
SetOnKeyTyped()
函数来完成此操作:w.Canvas().SetOnTypedKey(keyTyped)
-
要使蛇根据这些事件移动,我们需要更新
runGame()
函数以应用正确的移动。在snakeParts[0].y--
(在refreshGame()
之前)的行替换以下代码,该代码将为每次新移动定位头部:switch move { case moveUp: snakeParts[0].y-- case moveDown: snakeParts[0].y++ case moveLeft: snakeParts[0].x-- case moveRight: snakeParts[0].x++ } refreshGame()
-
我们现在可以运行更新的代码示例来测试键盘处理:
Chapter03/example$ go run main.go
-
在应用程序加载后,您按下左键然后按下下键,您应该看到以下内容:
![Figure 3.12 – 蛇可以在屏幕上移动
Figure 3.12 – 蛇可以在屏幕上移动
尽管现在从技术上讲这是一个动画游戏,但我们仍然可以使它更加流畅。使用动画 API 将允许我们绘制更平滑的运动。
动画运动
上一节中我们的运行循环创建的运动为游戏提供了基础运动,但它并不非常平滑。在本节的最后,我们将通过使用动画 API 来改进运动。我们将为头部段创建一个新的矩形,该矩形将移动到蛇动画的前面,并将尾巴平滑地移动到新位置。其余元素可以保持固定。让我们看看这是如何实现的:
-
我们首先定义一个新的矩形,代表移动的头部段:
var head *canvas.Rectangle
-
我们通过在
setupGame
函数中添加以下代码来设置此功能:head = canvas.NewRectangle(&color.RGBA{G: 0x66, A: 0xff}) head.Resize(fyne.NewSize(10, 10)) head.Move(fyne.NewPos(snakeParts[0].x*10, snakeParts[0].y*10)) segments = append(segments, head)
-
要在蛇当前身体位置之前开始绘制头部,我们需要在
runGame
函数的顶部添加以下代码,以便在蛇到达该位置之前计算下一个运动段:nextPart := snakePart{snakeParts[0].x, snakeParts[0].y - 1}
-
我们在
runGame
函数中的for
循环内设置动画,在计时器暂停之前。首先,我们计算头部位置及其新位置,然后设置一个新的动画来实现这个过渡:oldPos := fyne.NewPos(snakeParts[0].x*10, snakeParts[0].y*10) newPos := fyne.NewPos(nextPart.x*10, next Part.y*10) canvas.NewPositionAnimation(oldPos, newPos, time.Millisecond*250, func(p fyne.Position) { head.Move(p) canvas.Refresh(head) }).Start()
-
我们还创建另一个动画,将尾巴过渡到新的位置,如下所示:
end := len(snakeParts) - 1 canvas.NewPositionAnimation( fyne.NewPos(snakeParts[end].x*10, snakeParts[end].y*10), fyne.NewPos(snakeParts[end-1].x*10, snakeParts[end-1].y*10), time.Millisecond*250, func(p fyne.Position) { tail := game.Objects[end] tail.Move(p) canvas.Refresh(tail) }).Start()
-
在我们的游戏循环中的
time.Sleep
之后,我们需要使用新的nextPart
变量来设置新的头部位置,如下所示:snakeParts[0] = nextPart refreshGame()
-
在刷新行之后,我们需要更新运动计算来设置
nextPart
以便为下一次移动做准备:switch move { case moveUp: nextPart = snakePart{nextPart.x, nextPart.y - 1} case moveDown: nextPart = snakePart{nextPart.x, nextPart.y + 1} case moveLeft: nextPart = snakePart{nextPart.x - 1, nextPart.y} case moveRight: nextPart = snakePart{nextPart.x + 1, nextPart.y} }
-
运行更新后的代码,你会看到这种行为,但沿着路径有一个平滑的过渡:
Chapter03/example$ go run main.go
对于这个示例的完整代码,你可以使用本书的 GitHub 仓库:github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne/tree/master/Chapter03/example
。
尽管可以给这个示例添加更多功能,但我们已经探索了构建完整游戏所需的所有应用程序和画布操作。
摘要
在本章中,我们开始了使用 Fyne 工具包的旅程,探索了它是如何组织的以及一个 Fyne 应用程序是如何运行的。我们看到了它是如何使用矢量图形在任何分辨率下创建高质量输出的,这使得它能够在桌面电脑、智能手机等设备上很好地缩放。
我们探索了 canvas 包的功能,并看到了它是如何被用来将单个元素绘制到屏幕上的。通过使用fyne.Container
结合这些图形原语,我们能够绘制更复杂的输出到我们的窗口中。我们还看到了动画 API 是如何被用来显示对象大小、位置和其他属性的过渡效果。
为了将这些知识结合起来,我们构建了一个小型的蛇游戏,该游戏将元素显示到屏幕上并根据用户输入进行动画处理。尽管我们可以给这个游戏添加更多功能和图形润色,但我们将继续探讨其他主题。
在下一章中,我们将探讨布局算法如何管理窗口的内容以及构建图像浏览器应用程序所需的最佳文件处理实践。
第四章:第四章:布局和文件处理
在上一章中,我们学习了 Fyne 工具包的主要绘图方面是如何组织的,以及一个应用程序如何可以直接在窗口画布上与CanvasObject
项目交互。这些信息足以设置一个小游戏,但一旦应用程序需要展示大量信息或需要用户输入和工作流程,它们通常需要更复杂的用户界面设计。在本章中,我们将探讨应用程序用户界面的结构,包括以下内容:
-
使用内置布局算法排列
Container
项目 -
创建自定义布局算法
-
以跨所有平台(桌面和移动)的方式处理文件
基于这些知识,我们将构建一个用于浏览照片的应用程序。让我们开始吧!
技术要求
本章的要求与第三章“Windows、Canvas 和绘图”相同,即需要安装 Fyne 工具包。有关更多信息,请参阅上一章。
本章的完整源代码可以在github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne/tree/master/Chapter04
找到。
布局容器
如我们在上一章所见,Fyne 画布由CanvasObject
、Container
和Widget
项目组成(尽管Container
和Widget
项目本身也是CanvasObject
项目!)。为了能够显示多个元素,我们必须使用Container
类型,它将多个CanvasObject
项目(这些项目也可以是Widget
项目或额外的Container
项目)组合在一起。为了管理容器内每个项目的尺寸和位置,我们使用Layout
的实现,该实现通过container.New(layout, items)
构造函数在创建容器时传递给容器。
应用程序可能有多种方式来布局其组件,在本节中,我们将探讨实现这些不同方式的方法。然而,布局并不总是必需的,因此我们首先将探讨在什么情况下可能不需要使用布局,以及如何手动处理尺寸和位置。
手动布局
在我们探索布局算法之前,可以不使用布局来管理容器——这被称为container.NewWithoutLayout(items)
。
当使用没有布局的容器时,开发者必须手动使用Move()
和Resize()
方法定位和调整容器内所有元素的尺寸。在这种模式下,开发者负责根据容器的当前尺寸调整位置和尺寸。
让我们看一下以下代码:
square := canvas.NewRectangle(color.Black)
circle := canvas.NewCircle(color.Transparent)
circle.StrokeColor = &color.Gray{128}
circle.StrokeWidth = 5
box := container.NewWithoutLayout()(square, circle)
square.Move(fyne.NewPos(10, 10))
square.Resize(fyne.NewSize(90, 90))
circle.Move(fyne.NewPos(70, 70))
circle.Resize(fyne.NewSize(120, 120))
box.Resize(fyne.NewSize(200, 200))
我们刚才看到的代码设置了一个Rectangle
项目和一个Circle
项目,在容器内部调整它们的大小到大约一半,然后将它们定位以有少量重叠。您可以从以下图中看到,元素是按照它们传递给容器的顺序绘制的:
![Figure 4.1 – Manual layout in a container
![img/Figure_4.1_B16820.jpg]
图 4.1 – 容器中的手动布局
一旦设置,这些大小和位置将不会改变,除非我们添加更多代码来修改它们的位置。
重要提示
注意,没有发布任何调整大小的事件,因此如果您想自动调整容器大小,您应该考虑构建一个自定义布局,这在本章后面的提供自定义布局部分有描述。
使用布局管理器
从本质上讲,布局管理器与我们在前面看到的手动移动和调整大小代码相同,不同之处在于它操作的是CanvasObject
项目列表(那些是Container
的子项)。布局管理器有两个职责:
-
要控制每个元素的大小和位置
-
确定容器应接受的最小大小
当Container
项目被调整大小时,它所使用的布局将被要求重新定位所有子组件。布局算法可以选择根据新大小缩放元素,或者重新定位元素。或者,它可能决定根据可用空间是更高还是更宽来流动元素或调整布局。通过这种方式,在容器上设置布局可以提供基于屏幕大小甚至设备方向的响应式用户界面。
当我们布局界面组件时,通常希望通过插入一些清晰的空间来分隔元素。这被称为填充,在 Fyne 中,您可以使用theme.Padding()
函数找到标准填充大小。您可以在第五章,小部件库和主题中找到更多关于theme
包的信息。下一节中列出的标准布局都包括元素之间的标准填充。请注意,通常,容器布局不会在外侧使用填充,因为这将由父容器或顶级容器的窗口画布提供。
使用布局的容器可以使用container.New
函数创建:
container.New(layout, items)
当使用具有布局的容器时,通常不需要调用Resize
,就像我们之前做的那样,因为它将初始大小设置为至少最小大小。
隐藏的对象
在选择布局或自己编写布局代码时,还需要考虑的一个额外因素是对象可能并不总是可见的。一个CanvasObject
项目可能因为以下两个原因而隐藏:
-
有一个开发者对该对象调用了
Hide()
方法。 -
它位于一个同样调用了
Hide()
的Container
项目内部。
通常,布局算法在计算最小尺寸或布局元素时将跳过隐藏的元素。我们接下来将要看到的每个标准布局都将跳过隐藏的元素,而不是在那些项目本应出现的地方留下空白空间。
我们已经看到了布局是如何在安排应用程序组件中起作用的。为了使构建复杂用户界面尽可能简单,有标准布局可供使用,这些布局涵盖了大多数常见的用户界面排列。
标准布局
由于有许多标准布局算法,Fyne 工具包在layout
包中包含了一系列标准实现。通过导入此包,您可以将这些布局应用于应用程序中的任何Container
:
import "fyne.io/fyne/layout"
本节将详细检查每个布局。尽管容器只能有一个布局,但嵌套在彼此内部的容器数量没有限制,因此我们将在本节末尾探讨不同布局的组合。
MaxLayout
MaxLayout(或最大布局)是所有内置布局算法中最简单的一个。其目的是确保容器中的所有子元素都占用该容器的全部空间:
图 4.2 – MaxLayout 在容器中
这通常用于将一个元素放置在另一个元素之上,例如文本项在背景色矩形之上。当使用此布局时,列出容器元素的正确顺序很重要;每个元素都将覆盖另一个元素,因此列表中的最后一个项目将绘制在最上面:
myContainer := container.New(layout.NewMaxLayout(), …)
CenterLayout
CenterLayout在需要将指定最小尺寸的项在可用空间中水平和垂直居中时非常有用:
图 4.3 – CenterLayout 在项目周围添加空间
与MaxLayout
类似,容器内的每个元素都将绘制在之前元素的上方,但大小将设置为每个元素的最小值,而不是填充可用空间:
myContainer := container.New(layout.NewCenterLayout(), …)
PaddedLayout
PaddedLayout有助于您通过主题定义的填充值插入内容。内容元素将通过所有侧面的标准填充在容器中居中,如图所示:
图 4.4 – PaddedLayout 在项目周围添加小空间
与MaxLayout
类似,容器内的每个元素都将绘制在之前元素的上方,所有元素大小相同,但在此情况下略小于容器:
myContainer := container.New(layout.NewPaddedLayout(), …)
BoxLayout
框布局有两种类型,HBoxLayout
(水平布局——用于按行排列项目)和VBoxLayout
(垂直布局——用于按列表排列项目)。每个框布局都遵循类似的算法:它创建一个线性流,其中元素被打包(水平或垂直),同时保持一致的高度或宽度。
水平框中列出的项目将宽度设置为每个元素的最小值,但将共享相同的高度,这是所有元素最小高度的最大值:
图 4.5 – HBoxLayout 在一行中对齐三个元素
垂直框中的所有项目都具有相同的宽度(所有最小宽度中的最大值),同时缩小到每个元素的最小高度:
图 4.6 – VBoxLayout 在列中堆叠元素
这种方法允许不同大小的项目看起来统一,同时不会在容器中浪费任何空间。每个这些的语法如下:
myContainer := container.New(layout.NewHBoxLayout(), …)
myContainer := container.New(layout.NewVBoxLayout(), …)
FormLayout
FormLayout由表单小部件使用,但当你希望在容器中为项目添加标签时,它也可以单独使用。应该添加偶数个元素;每对中的第一个将位于左侧,尽可能窄。剩余的水平空间将由每对中的第二个占据:
图 4.7 – FormLayout 配对项目进行标签
这里是一个使用FormLayout
的例子(假设要添加的参数数量是偶数):
myContainer := container.New(layout.NewFormLayout(), …)
GridLayout
基本的GridLayout旨在将容器划分为与容器中子元素数量相等的等空间。
对于具有两列和三个子元素容器的例子,将创建第二行,但不会完全填满:
图 4.8 – 两列 GridLayout 中的三个元素
在创建网格布局时,开发者将指定要使用的列数或行数,项目将相应地排列。在每一行或每一列的末尾,布局将换行并创建一个新的布局。行数(或列数)将取决于元素的数量。例如,让我们看一下以下插图:
myContainer := container.New(layout.NewGridLayoutWithColumns(2), …)
myContainer := container.New(layout.NewGridLayoutWithRows(2), …)
网格布局有一个额外的模式,可以帮助适应不同的输出设备。在移动设备中,当纵向持有时,通常在单列中显示项目;当横向持有时,在单行中显示。要启用此功能,请使用NewAdaptiveGridLayout
;此构造函数的参数表示您希望在垂直排列中拥有的行数或水平排列时的列数。当移动设备旋转时,此布局将重新排列其Container
,如下所示:
myContainer := container.New(layout.NewAdaptiveGridLayout(3), …)
GridWrapLayout
使用网格的另一种变体是当你希望元素在容器大小调整时自动流动到新行(例如,文件管理器或图像缩略图列表)。在这种情况下,Fyne 提供了一个网格包裹布局。在一个包裹的网格中,每个子元素都会调整到指定的大小,然后它们将按行排列,直到下一个项目无法适应,此时将为后续元素创建一个新行。
例如,这里是一个比指定大小三项更宽的网格包裹容器:
图 4.9 – GridWrapLayout 中的固定元素
GridWrapLayout 和 MinSize
需要注意的是,这种布局与其他所有布局不同,它不会检查每个项目的MinSize
。因此,开发者应小心确保它足够大,或者包含的元素将截断其元素(如文本)以避免溢出。
这里是一个使用网格包裹布局的示例:
myContainer := container.New(layout.NewGridWrapLayout(fyne. NewSize(120, 120), …)
BorderLayout
在安排应用程序布局中最常用的布局可能是BorderLayout。这种布局算法将指定元素排列在容器的顶部、底部、左侧和右侧边缘。顶部和底部的项目将调整到最小高度,但水平拉伸,左侧和右侧的项目将压缩到最小宽度并垂直扩展。容器中未指定属于任何边缘的任何元素将调整大小以填充边框内的可用空间。这通常用于将工具栏放置在顶部,页脚放置在底部,以及文件列表放置在左侧。任何你希望留空的边缘应使用 nil 代替:
图 4.10 – 设置了顶部和左侧区域的 BorderLayout
BorderLayout 参数
注意,对于BorderLayout
,某些元素必须指定两次——布局参数指定元素应放置的位置,但项目列表控制容器中将显示什么。如果你发现某个项目没有显示,请确保它在两个地方都进行了指定。
以下代码显示了如何设置带有header
在顶部并将files
定位在content
左侧的边框容器:
myContainer := container.New(layout.NewBorderLayout(header, nil, files, nil), header, files, content)
组合布局
要构建更复杂的应用程序结构,你将需要在用户界面中使用多个布局。由于每个容器只有一个布局,我们通过嵌套不同的容器来实现这一点。这可以按需多次进行。例如,看看以下图示:
图 4.11 – 具有不同布局的多个容器
对于前面的插图,我们使用了一个容器,其中左侧面板使用VBoxLayout
,顶部使用HBoxLayout
,中央容器使用GridWrapLayout
,所有这些都位于BorderLayout
内部,如下所示:
top := container.New(layout.NewHBoxLayout(), ...)
left := container.New(layout.NewVBoxLayout(), ...)
content := container.New(layout.NewGridWrapLayout(fyne. NewSize(40, 40)), ...)
combined := container.New(layout.NewBorderLayout(top, nil, left, nil), top, left, content)
使用容器包
所有的前例都使用了内置的 Layout
类型来配置 fyne.Container
的内容。为了帮助管理更复杂的布局配置(我们将在 第五章,小部件库和主题)中看到更多),container
包中有很多有用的构造函数。例如,我们可以使用 container.NewBorder(...)
而不是 container.New(layout.NewBorderLayout(…)…)
,这可以使代码更清晰。
提供自定义布局
如果标准布局或它们的组合无法满足你的用户界面需求,你可以构建一个自定义布局并将其传递给容器。
任何实现了 fyne.Layout
接口的数据类型都可以用作 Container
布局。这个接口只需要实现两个方法,如下所示:
// Layout defines how CanvasObjects may be laid out in a // specified Size.
type Layout interface {
// Layout will manipulate the listed CanvasObjects Size // and Position to fit within the specified size.
Layout([]CanvasObject, Size)
// MinSize calculates the smallest size that will fit the // listed
// CanvasObjects using this Layout algorithm.
MinSize(objects []CanvasObject) Size
}
如你所见,这个接口将早期描述的布局管理器需要确定容器的最小尺寸以及处理容器中每个元素的定位进行了编码。由于容器的内容可能会随时改变,因此传递给 Layout
或 MinSize
的 CanvasObject
元素切片可能会改变。因此,自定义布局应避免缓存对单个元素的引用。在某些情况下(例如我们之前看到的 BorderLayout
),布局可能会明确持有对象引用。如果你的布局以这种方式工作,重要的是要记住,项目可能不在布局的对象切片中存在。
大多数布局在计算最小尺寸或布局时也应跳过隐藏元素。然而,有一些例外,尤其是如果元素可能会经常显示和隐藏。例如,一次只显示一个内容元素的基于标签的布局,如果隐藏的元素比当前可见的元素大,可能会导致窗口扩展。在这种情况下,如果布局在 MinSize
代码中考虑隐藏元素,即使它们没有在 Layout
中定位,这对用户来说也是有益的。
我们将创建一个编写自定义布局的简短示例:
-
这个类型,命名为
diagonal
,将容器中的项目定位在对角线上,从左上角到右下角。我们首先实现MinSize()
来返回所有可见对象的总和(这样就有空间以对角线显示它们):type diagonal struct{} func (d *diagonal) MinSize(items []fyne.CanvasObject) fyne.Size { total := fyne.NewSize(0, 0) for _, obj := range items { if !obj.Visible() { continue } total = total.Add(obj.MinSize()) } return total }
-
然后我们添加
Layout()
方法,它负责实际定位每个对象。在这个版本中,我们简单地声明一个topLeft
变量,并定位每个可见对象,每次定位和调整元素大小时都会向该值添加:func (d *diagonal) Layout(items []fyne.CanvasObject, size fyne.Size) { topLeft := fyne.NewPos(0, 0) for _, obj := range items { if !obj.Visible() { continue } size := obj.MinSize() obj.Move(topLeft) obj.Resize(size) topLeft = topLeft.Add(fyne.NewPos(size.Width, size.Height)) } }
-
要将此布局应用于容器,你只需使用以下方法:
item1 := canvas.NewRectangle(color.Black) item1.SetMinSize(fyne.NewSize(35, 35)) item2 := canvas.NewRectangle(&color.Gray{128}) item2.SetMinSize(fyne.NewSize(35, 35)) item3 := canvas.NewRectangle(color.Black) item3.SetMinSize(fyne.NewSize(35, 35)) myContainer := container.New(&diagonal{}, item1, item2, item3)
我们得到的结果是:
![图 4.12 – 简单的对角线布局
图 4.12 – 简单的对角线布局
我们看到的例子设置了一个静态布局。为每个项目设置的最低大小确定了Container
的最低大小,它不再扩展。这个布局的改进版本将计算额外空间(容器MinSize()
与传递给Layout()
函数的size
参数之间的差异)。更新后的Layout()
函数看起来如下所示:
func (d *diagonal) Layout(items []fyne.CanvasObject, size fyne.Size) {
topLeft := fyne.NewPos(0, 0)
visibleCount := 0
for _, obj := range items {
if !obj.Visible() {
continue
}
visibleCount++
}
min := d.MinSize(items)
extraX := (size.Width - min.Width)/visibleCount
extraY := (size.Height - min.Height)/visibleCount
for _, obj := range items {
if !obj.Visible() {
continue
}
size := obj.MinSize()
size = size.Add(fyne.NewSize(extraX, extraY))
obj.Move(topLeft)
obj.Resize(size)
topLeft = topLeft.Add(fyne.NewPos(size.Width,
size.Height))
}
}
运行代码后,我们得到以下结果:
![Figure 4.13 – 对角布局扩展以填充空间
Figure 4.13 – 对角布局扩展以填充空间
使用这个更高级的代码,我们不再需要为所有项目使用最低大小来控制容器(尽管项目通常会有一个最低大小)。实际上,我们可以只调整容器的大小(或应用程序窗口)如下所示:
item1 := canvas.NewRectangle(color.Black)
item2 := canvas.NewRectangle(&color.Gray{128})
item3 := canvas.NewRectangle(color.Black)
myContainer := canvas.New(&diagonal{}, item1, item2, item3)
myContainer.Resize(fyne.NewSize(120, 120))
现在我们已经探讨了如何布局应用程序的基础知识,我们可以开始考虑在一个真实的应用程序中实现这一点。我们将要探索的例子是一个图像浏览应用程序,它将布局图像及其元数据。然而,在我们能够做到这一点之前,我们需要了解跨平台环境下的文件处理。如果应用程序开发者假设用户将有一个与他们的开发系统匹配的文件系统或结构,那么在其他设备上可能不起作用,因此了解如何做好这一点对于确保应用程序在所有设备上都能良好运行是至关重要的。
跨平台文件处理
Go 标准库在其支持的平台上对文件处理提供了出色的支持。os
包允许访问文件系统(文件和目录),以及如filepath
之类的实用程序包,这些包有助于使用当前操作系统的语义解析和管理位置。虽然这些操作在大多数设备上可能很有用,但它们并不很好地扩展到非桌面设备,在这些设备上,传统的文件系统并不是最终用户所面对的。
以移动设备为例。iOS 和 Android 在内部都有传统的文件系统,但文件系统并不完全对设备用户开放,也不是文件数据的唯一来源。应用程序通常只能访问其自己的沙盒目录——不允许在此空间外读取和写入文件——在 iOS 上,您甚至可能需要请求特殊权限才能访问它。除此之外,用户现在还期望能够从其他来源打开数据。例如,像 Dropbox 这样的文件共享应用程序可能为用户提供文件来源,用户可能希望将其传递到您的应用程序中,但使用标准文件处理无法访问这些数据。
由于这些原因,Fyne 工具包包括一个简单的存储抽象,允许您的应用程序处理来自任何来源的数据,同时为您管理权限和安全考虑。这种交互使用URI的概念来替换传统的文件路径,允许应用程序在没有直接访问文件和目录的情况下运行。
URI
文件处理抽象的核心是fyne.URI
(此处为http://
或https://
。URI 可能表示文件系统对象(它将以file://
开头),来自另一个应用程序的数据流(它可能以content://
开头),或远程资源(例如,sftp://
用于安全文件传输协议连接)。
与os.File
类型类似,fyne.URI
是对资源的引用,尽管它不会保持该资源打开,因此可以在应用程序中传递而不会出现问题。可以使用String()
方法访问此 URI 的底层字符串表示。如果你希望将 URI 引用存储起来以供以后使用,例如在配置文件或数据库中,请使用此方法。如果你有一个 URI 字符串表示,可以使用storage
包中的实用工具访问原始 URI 对象,如下所示:
uriString := "file:///home/user/file.txt"
myUri := storage.NewURI(uriString)
读取和写入
当你不确定文件存储在哪里时访问文件比传统的os.Open()
要复杂一些;然而,Fyne 的storage
包提供了处理这种功能的方法。数据访问的两个主要函数是OpenFileFromURI
和SaveFileToURI
,如包中的摘录所示:
// OpenFileFromURI loads a file read stream from a resource // identifier.
func OpenFileFromURI(uri fyne.URI) (fyne.URIReadCloser, error) {
return fyne.CurrentApp().Driver().FileReaderForURI(uri)
}
// SaveFileToURI loads a file write stream to a resource // identifier.
func SaveFileToURI(uri fyne.URI) (fyne.URIWriteCloser, error) {
return fyne.CurrentApp().Driver().FileWriterForURI(uri)
}
这些函数都接受一个 URI(如前述代码所述)作为位置,在操作成功时返回URIReadCloser
或URIWriteCloser
,如果操作失败则返回error
。
如其名称所示,这些返回类型实现了带有URI()
函数的io.ReadCloser
和io.WriteCloser
,以查询原始资源标识符。你可能不认识这些io
接口,但你可能已经通过os.File
使用过它们。这种相似性意味着你可以在许多需要传递文件进行读取操作的地方使用URIReadCloser
,或者在写入数据时使用URIWriteCloser
。
如果你正在自行处理读取或写入操作,记得在完成后调用Close()
方法(就像任何io.Closer
流一样)。这通常通过在检查任何错误后调用defer reader.Close()
来确保。以下代码展示了从 URI 读取文件的简单示例:
uri := storage.NewURI("file:///home/user/file.txt")
read, err := storage.OpenFileFromURI(uri)
if err != nil {
log.Println("Unable to open file \""+uri. String()+"\"", err)
return
}
defer read.Close()
data, err := ioutil.ReadAll(read)
if err != nil {
log.Println("Unable to read text", err)
return
}
log.Println("Loaded data:", string(data))
用户文件选择
应用程序打开文件的最常见方式,至少最初,是提示用户选择他们希望打开的文件。标准的文件打开对话框可用于提供此功能。应用程序可以调用dialog.ShowFileOpen
,这将要求用户选择一个文件(可选的文件过滤器)。所选文件将通过回调函数以URIReadCloser
的形式返回,如前所述。如果你希望存储所选文件的引用,可以使用URI()
方法返回标识符。以下代码展示了这一操作:
dialog.ShowFileOpen(func(reader fyne.URIReadCloser, err error) {
if err != nil { // there was an error - tell user
dialog.ShowError(err, win)
return
}
if reader == nil { // user cancelled
return
}
// we have a URIReadCloser - handle reading the file
// (remember to call Close())
fileOpened(reader)
}, win)
类似地,还有 dialog.ShowFileSave
来启动文件写入工作流程,例如常见的 另存为 功能。有关对话框包的更多信息,请参阅 第五章 的 对话框 部分,小部件库和主题。
ListableURI
在某些应用程序中,可能需要打开包含其他资源的资源(就像文件目录一样)。对于这些情况,还有一个类型,fyne.ListableURI
,它提供了一个返回 URI
项切片的 List()
方法。这可以与 dialog.ShowDirectoryOpen
结合使用,它将返回用户选择的 ListableURI
位置。
让我们看看一个例子:
dialog.ShowFolderOpen(func(dir fyne.ListableURI, err error) {
if err != nil { // there was an error - tell user
dialog.ShowError(err, win)
return
}
if dir == nil { // user cancelled
return
}
log.Println("Listing dir", dir.Name())
for _, item := range dir.List() {
log.Println("Item name", item.Name())
}
}, win)
如此例所示,一旦用户做出选择,ListableURI
就会被传递到我们的代码中。然后我们可以使用 range List()
遍历目录或集合中每个项目的 URI。如果您已经有了目录的名称,则可以使用 storage.ListerForURI(storage.NewFileURI(dirPath))
。
让我们将布局和文件处理付诸实践。现在,我们将构建一个简单的图像浏览应用程序,使用到目前为止我们所看到的一切。
实现图像浏览器应用程序
此应用程序将加载包含一些图像的目录,在窗口底部的状态栏中提供内容摘要,并使用大部分空间来显示每个图像。图像将以缩略图(图像的小版本)形式加载,我们将在每个缩略图下方显示图像信息。
创建布局
为了开始这个示例,我们将创建应用程序的布局以及将在中央网格中显示的图像项。让我们详细理解这些操作:
-
首先,我们设置图像项。我们希望图像名称位于图像下方。虽然这可以通过手动定位实现,但如果使用
BorderLayout
,项目对尺寸变化的响应将更加灵活。我们将在bottom
位置创建一个canvas.Text
元素,并使用canvas.Rectangle
来表示我们稍后要加载的图像:func makeImageItem() fyne.CanvasObject { label := canvas.NewText("label", color.Gray{128}) label.Alignment = fyne.TextAlignCenter img := canvas.NewRectangle(color.Black) return container.NewBorder(nil, label, nil, nil, img) }
-
对于主应用程序,我们需要创建一个网格来包含图像缩略图以及稍后定位的状态面板。对于图像网格,我们将使用
GridWrapLayout
。这种网格布局将所有元素的大小调整为指定的尺寸,并且随着可用空间的增加,可见项目的数量也会增加。在这种情况下,用户可以通过增加窗口大小来看到更多图像。 -
由于我们尚未加载目录,我们将伪造项目数量(通过遍历
{1, 2, 3}
硬编码为三个)。我们为每个项目调用makeImageItem
创建一个项目列表。然后,在size
参数(每个项目使用的尺寸——这是网格包装布局特有的行为)之后,将此列表传递给NewGridWrap
。func makeImageGrid() fyne.CanvasObject { items := []fyne.CanvasObject{} for range []int{1, 2, 3} { img := makeImageItem() items = append(items, img) } cellSize := fyne.NewSize(160, 120) return container.NewGridWrap(cellSize, items...) }
-
首先,我们将只为状态创建一个文本占位符,用于布局应用程序:
func makeStatus() fyne.CanvasObject { return canvas.NewText("status", color.Gray{128}) }
-
最后,我们将再次使用
BorderLayout
创建一个新的容器来安排状态栏在其余内容下方。通过将图像网格放置在BorderLayout
的中央空间,它将填充任何可用空间:func makeUI() fyne.CanvasObject { status := makeStatus() content := makeImageGrid() return container.NewBorder(nil, status, nil, nil, content) }
-
要完成应用程序,我们只需要一个简短的
main()
函数来加载 Fyne 应用程序并创建一个窗口,然后我们将将其调整到大于最小尺寸,以便图像网格布局可以扩展到多列:func main() { a := app.New() w := a.NewWindow("Image Browser") w.SetContent(makeUI()) w.Resize(fyne.NewSize(480, 360)) w.ShowAndRun() }
-
我们现在要做的就是运行组合代码:
Chapter04/example$ go run main.go
-
运行此代码将显示以下窗口,准备加载一些真实数据和图像:
图 4.14 – 我们照片应用的基本布局
列出目录
在我们能够加载图像之前,我们需要确定应用程序启动时加载的是哪个目录。让我们看看完成这一点的步骤:
-
从
main()
函数中,我们将调用一个新的startDirectory
(解析应用标志或回退到当前工作目录)并将其传递给makeUI()
函数。目录路径通过调用ListerForURI
和NewFileURI:
转换为ListableURI
:func startDirectory() fyne.ListableURI { flag.Parse() if len(flag.Args()) < 1 { cwd, _ := os.Getwd() list, _ := storage.ListerForURI( storage.NewFileURI(cwd)) return list } dir, err := filepath.Abs(flag.Arg(0)) if errr != nil { log.Println("Could not find directory", dir cwd, _ := os.Getwd list, _ := storage.ListerForURI( storage.NewFileURI(cwd)) return list } list, _ := storage.ListerForURI(storage.NewURI(dir)) return list }
-
一旦
ListableURI
被传递到makeUI
,我们就可以使用dir.List()
并在遍历 URIs 之前过滤图像文件。一个新的函数filterImages
将接受目录列表并返回图像 URI 的切片。为此,一个小的isImage()
函数将帮助过滤:func isImage(file fyne.URI) bool { ext := strings.ToLower(file.Extension()) return ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" } func filterImages(files []fyne.URI) []fyne.URI { images := []fyne.URI{} for _, file := range files { if isImage(file) { images = append(images, file) } } return images }
-
使用代表图像的
fyne.URI
切片,我们可以更新状态和图像网格创建函数,以及更新图像标签以在每个图像占位符下使用URI.Name()
:func makeImageGrid(images []fyne.URI) fyne.CanvasObject { items := []fyne.CanvasObject{} for range images { img := makeImageItem() items = append(items, img) } cellSize := fyne.NewSize(160, 120) return container.NewGridWrap(cellSize, items...) } func makeStatus(dir fyne.ListableURI, images []fyne.URI) fyne.CanvasObject { status := fmt.Sprintf("Directory %s, %d items", dir.Name(), len(images)) return canvas.NewText(status, color.Gray{128}) } func makeUI(dir fyne.ListableURI) fyne.CanvasObject { list, err := dir.List() if err != nil { log.Println("Error listing directory", err) } images := filterImages(list) status := makeStatus(dir, images) content := makeImageGrid(images) return container.NewBorder( (nil, status, nil, nil, content) }
加载图像
现在我们来看一下将图像加载到我们应用程序中的步骤:
-
首先,我们创建一个简单的图像加载方法,它接受一个 URI 并返回
*canvas.Image
。然后,新的loadImage
函数将用于代替占位符矩形:func loadImage(u fyne.URI) fyne.CanvasObject { read, err := storage.OpenFileFromURI(u) if err != nil { log.Println("Error opening image", err) return canvas.NewRectangle(color.Black) } res, err := storage.LoadResourceFromURI(read.URI()) if err != nil { log.Println("Error reading image", err) return canvas.NewRectangle(color.Black) } img := canvas.NewImageFromResource(res) img.FillMode = canvas.ImageFillContain return img }
-
makeImage
函数应该更新为按照以下方式传递URI
项目:func makeImageItem(u fyne.URI) fyne.CanvasObject {
-
然后可以将
makeImageItem
函数内部创建图像的线条替换为创建的图像:img := loadImage(u)
-
在
loadImage
函数中,在返回canvas.Image
之前,我们将FillMode
从默认值(canvas.ImageFillStretch
)更改为canvas.ImageFillContain
,这样图像的宽高比将被尊重,并且图像将适合可用空间:
图 4.15 – 加载到布局中的图像和名称
这段代码按预期工作,如图所示,但它可能比较慢。我们在继续用户界面加载之前就加载了图像。这并不利于用户体验,所以让我们通过使用后台图像加载来改善这种情况。
加速应用加载
为了避免图像大小减慢用户界面的加载速度,我们需要在图像加载之前完成应用程序 UI 的构建。这被称为异步(或后台)加载,如果您的应用程序需要使用大量资源,这可能非常强大。
在后台加载所有图像的最简单方法就是启动许多 goroutine。但是,当显示大目录时,这可能会变得非常慢。相反,我们将使用单个图像加载 goroutine,一次处理一张图像。(作为一个练习,如果你感到好奇,你可以将其扩展到一次处理八张或更多的图像。)
现在我们来看看如何做到这一点:
-
为了跟踪图像加载,我们将创建一个新的类型
bgImageLoad
,它将引用要加载的图像的 URI 以及它应该加载到的*canvas.Image
项目。我们还需要创建一个通道(我们将它命名为loads
),它将排队要加载的项目。我们在1024
项处缓冲它,这代表了一个大目录——处理无界文件数量的实现可能需要我们更聪明一些:type bgImageLoad struct { uri fyne.URI img *canvas.Image } var loads = make(chan bgImageLoad, 1024)
-
在加载此更新版本的图像时,我们将创建一个空的 Fyne
*canvas.Image
,稍后将在其中加载图像。然后我们排队此图像URI
的详细信息,以便在 goroutine 能够处理它时加载:func loadImage(u fyne.URI) fyne.CanvasObject { img := canvas.NewImageFromResource(nil) img.FillMode = canvas.ImageFillContain loads <- bgImageLoad{u, img} return img }
-
我们将图像加载代码移动到新的
doLoadImage
函数中,该函数将在后台运行。在这个版本中,我们想要做所有图像加载的慢速部分;因此,我们加载并解码图像,将其转换为用于显示的内存中的图像,并让用户界面对更新、调整大小等更加响应。新的函数
doLoadImages
将遍历所有添加到通道中的项目,并逐个调用doLoadImage
来加载它们。图像加载代码将在加载原始数据后刷新图像CanvasObject
,因此每个项目都会在加载时出现:func doLoadImage(u fyne.URI, img *canvas.Image) { read, err := storage.OpenFileFromURI(u) if err != nil { log.Println("Error opening image", err) return } defer read.Close() raw, _, err := image.Decode(read) if err != nil { log.Println("Error decoding image", err) return } img.Image = scaleImage(raw) img.Refresh() } func doLoadImages() { for load := range loads { doLoadImage(load.uri, load.img) } }
-
为了确保图像已加载,我们在
main()
函数中启动doLoadImages
作为 goroutine:func main() { ... go doLoadImages() w.ShowAndRun() }
-
最后,在前面提到的代码中,我们引用了
scaleImage
。这意味着我们显示的每一张图像都是全尺寸图像的小版本。当浏览的目录包含非常大的图像时,这是必要的。工具包将尝试将非常大的图像画得很小,这可能会非常慢。相反,我们将图像的大小减小,以适应每个网格单元中可用的空间。我们使用了较大的数字(单元格大小的两倍),这样高密度显示仍然可以给出良好的效果。 -
以下代码片段使用了有用的
github.com/nfnt/resize
包来缩放图像。尽管 Go 中的image
包通常很有帮助,但它不包含高效的缩放例程。我们使用这个库并请求Lanczos3
插值,这在上采样图像时提供了速度和质量之间的平衡:func scaleImage(img image.Image) image.Image { return resize.Thumbnail(320, 240, img, resize.Lanczos3) }
resize.Thumbnail
函数创建一个适合指定大小的小图像,这对于我们的目的非常理想,因此我们可以避免担心宽高比和计算。
使用更新的代码将为即使是包含非常大的图像的大目录创建快速加载和响应式的用户界面。这就是我们想要的:当使用全尺寸图像时可能较慢的调整大小操作,现在变得更快了!
为图像元素创建自定义布局
在此示例中,标签所占用的空间可能有点浪费,因此让我们创建一个自定义布局,该布局将文本写入每个图像的底部边缘。我们将使用半透明背景使文本更易于阅读,并使用小渐变从文本背景到图像进行混合。
要构建自定义布局,我们需要定义一个类型(在本例中为itemLayout
),该类型实现了fyne.Layout
接口中的MinSize
和Layout
函数。由于背景、渐变和文本都有特殊的位置,我们将保存对这些画布对象的引用,以便稍后进行排列:
type itemLayout struct {
bg, text, gradient fyne.CanvasObject
}
func (i *itemLayout) MinSize(_ []fyne.CanvasObject) fyne.Size {
return fyne.NewSize(160, 120)
}
func (i *itemLayout) Layout(objs []fyne.CanvasObject, size fyne.Size) {
textHeight := float32(22)
for _, o := range objs {
if o == i.text {
o.Move(fyne.NewPos(0, size.Height-textHeight))
o.Resize(fyne.NewSize(size.Width, textHeight))
} else if o == i.bg {
o.Move(fyne.NewPos(0, size.Height-textHeight))
o.Resize(fyne.NewSize(size.Width, textHeight))
} else if o == i.gradient {
o.Move(fyne.NewPos(0, size.Height- (textHeight*1.5)))
o.Resize(fyne.NewSize(size.Width, textHeight/2))
} else {
o.Move(fyne.NewPos(0, 0))
o.Resize(size)
}
}
}
此代码将确保我们的容器中的每个元素都定位在正确的位置。text
和bg
与gradient
底部对齐,而任何其他元素(在本例中为我们的图像缩略图)将定位在布局请求填充的填充大小。
要使用此布局,我们更新makeImageItem
函数,使其使用&itemLayout
作为容器布局。在这个构造函数中,我们传递一个新的canvas.Rectangle
和canvas.Gradient
,用于实现之前描述的效果。在将图像传递给文本背景之前,并将文本元素最后传递给NewContainerWithLayout
,这是设置这些元素绘制顺序的重要步骤:
func makeImageItem(u fyne.URI) fyne.CanvasObject {
label := canvas.NewText(u.Name(), color.Gray{128})
label.Alignment = fyne.TextAlignCenter
bgColor := &color.NRGBA{R: 255, G: 255, B: 255, A: 224}
bg := canvas.NewRectangle(bgColor)
fade := canvas.NewLinearGradient(color.Transparent, bgColor, 0)
return container.New(
&itemLayout{text: label, bg: bg, gradient: fade},
loadImage(u), bg, fade, label)
}
经过这些更改后,我们可以再次运行代码,看看我们的新布局如何使每个图像预览在相同的空间内更大,同时给应用程序增添一点风采:
![Figure 4.16 – 自定义图像及其标签的布局
Figure 4.16 – 自定义图像及其标签的布局
最后,你可能已经注意到,包含许多图像的目录会强制窗口扩展,因此你可能希望向网格容器添加滚动。为此,我们将使用之前提到的容器包中的一个辅助工具,在图像网格容器周围添加container.Scroll
。这只需要将makeUI
函数的content
创建行替换为以下内容:
content := container.NewScroll(makeImageGrid(images))
更改目录
除了加载指定的目录外,我们可能希望在应用程序打开后允许用户打开不同的目录。为此,我们将使用Window
上的SetMainMenu
函数,该函数设置了一个结构来填充菜单栏。
使用fyne
包中的NewMainMenu
、NewMenu
和NewMenuItem
辅助函数,我们设置了一个结构,定义了点击时chooseDirectory
的行为(我们也传递了当前窗口,以便可以从该函数中显示对话框)。以下代码被添加到main()
函数中,在Window.ShowAndRun()
之前:
w.SetMainMenu(fyne.NewMainMenu(fyne.NewMenu("File",
fyne.NewMenuItem("Open Directory...", func() {
chooseDirectory(w)
}))))
为了支持这个菜单操作,我们需要创建chooseDirectory
函数。这将调用dialog.ShowDirectoryOpen
,提示用户在他们的电脑上选择一个目录。这个功能与之前我们探索的ShowFileOpen
调用类似,只是回调中返回的参数是ListableURI
而不是URIReadCloser
。使用这个参数(在检查任何错误之后),我们可以调用makeUI
并使用这个新位置来更新我们整个应用程序的用户界面:
func chooseDirectory(w fyne.Window) {
dialog.ShowFolderOpen(func(dir fyne.ListableURI, err error) {
if err != nil {
dialog.ShowError(err, w)
return
}
w.SetContent(makeUI(dir)) // this re-loads our // application
}, w)
}
如果我们正在构建一个更复杂的应用程序,那么仅仅调用Window.SetContent
并不是最高效的方法。在这种情况下,我们会保存对主fyne.Container
的引用,并只更新图像网格而不是整个应用程序。然而,我们应用程序的最终版本应该看起来像以下截图:
图 4.17 – 添加主菜单
注意,当在 macOS 上运行时,默认行为是在桌面菜单栏中显示菜单——这可以通过使用no_native_menus
构建标签来覆盖,如下所示:
$ go run -tags no_native_menus main.go Images/Desktop
摘要
本章详细介绍了布局的工作原理,工具包中所有内置布局的详细信息以及何时使用它们。我们还看到了如何轻松地组合多个布局,并创建了我们自己的自定义布局,为我们的图片浏览应用程序增添了一些风采。
我们还探讨了如何使用URI
和ListableURI
类型来调整文件处理代码以跨所有平台工作。利用这些知识,我们的图片浏览应用程序现在与所有桌面和移动平台兼容。通过了解如何布局应用程序并避免对传统文件系统的假设,你现在可以确保你的应用程序在任何支持的平台上都能正确运行,包括移动、桌面以及更多。
虽然我们只使用画布原语和布局创建了一个完整的应用程序,但使用widget
包可以构建更复杂的应用程序,我们将在下一章中探讨。
第五章:第五章:小部件库和主题
Fyne 工具包的大部分是它的标准小部件库,它提供简单的视觉元素,管理用户输入,并处理应用程序工作流程。这些小部件处理信息和用户输入的显示方式,以及用于组织用户界面和管理标准工作流程的容器选项。Fyne 工具包附带的主题支持浅色和深色版本,两者都支持用户颜色偏好,同时适应所有用户界面元素,使其在两种模式下都看起来很棒。
在本章中,我们将探讨 Fyne 工具包中可用的所有小部件以及如何使用它们。我们将涵盖以下主题:
-
探索 Fyne Widget API 的设计
-
介绍基本小部件
-
使用集合小部件进行分组
-
使用容器小部件添加结构
-
使用常用对话框
到本章结束时,你将熟悉所有 Fyne 小部件以及主题功能如何控制它们的外观。通过组合小部件和容器,你将构建你的第一个基于 Fyne 的完整图形用户界面。
技术要求
本章的要求与 第三章,“窗口、画布和绘图”相同 - 即,你必须安装 Fyne 工具包并有一个工作的 Go 和 C 编译器。有关更多信息,请参阅上一章。
本章的完整源代码可以在github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne/tree/master/Chapter05
找到。
探索 Widget API 的设计
正如我们在 第二章,“根据 Fyne 的未来”中描述的,其 API 被设计用来传达语义意义,而不是功能列表。随后是 Widget 定义,我们添加了描述行为的 API 并隐藏了渲染的细节。所有小部件都必须实现的接口仅仅是基本 CanvasObject
对象(在第 第三章,“窗口、画布和绘图”中介绍)的扩展,它添加了一个 CreateRenderer()
方法。它在源代码中定义如下:
// Widget defines the standard behaviors of any widget.
// This extends the CanvasObject - a widget behaves in
// the same basic way but will encapsulate many child
// objects to create the rendered widget.
type Widget interface {
CanvasObject
CreateRenderer() WidgetRenderer
}
Fyne 使用新的 CreateRenderer()
方法来确定小部件的外观。没有公共 API 可以访问当前渲染器 - 相反,状态是在 Widget
中设置的,每个渲染器都会刷新其输出以匹配此状态。这种设计强烈鼓励 API 专注于行为和意图,而不是直接操作图形输出(这可能导致不一致或无法使用的应用程序)。
专注于行为
通过在组件状态和其视觉表示之间强制分离,每个Widget
被迫公开一个 API,该 API 描述了行为或意图,而不是视觉属性或内部细节。这对于继续语义 API 的设计原则很重要,这导致了一个更简洁的 API,该 API 专注于预期结果而不是图形调整。
通常,状态的变化需要通过某种方式的WidgetRender
更新来反映。图形表示如何变化由渲染器控制,并且通过调用Widget.Refresh()
来触发。这种刷新通常由小部件代码处理;例如,Button.SetText()
函数的代码如下:
// SetText allows the button label to be changed
func (b *Button) SetText(text string) {
b.Text = text
b.Refresh()
}
Refresh()
调用将排队一个请求,要求更新小部件的渲染,这将导致下一次屏幕更新时的图形更新。在某些小部件上,调用Refresh
函数可能会引起大量的计算。如果你有很多更改要应用到小部件上,你可能不希望它在每一行后都刷新。为了帮助解决这个问题,也存在一种直接的字段访问方法,确保它不需要频繁调用。
方法与字段访问
如前所述,刷新小部件,尤其是复杂的小部件,可能很耗时,如果必须执行许多更新,用户可能会注意到轻微的延迟。如果开发者希望更新小部件的多个方面,那么(尽管可能性不大)可能一个更改会在视觉重绘之前应用,而其他更改则发生在之后。尽管将发生另一次绘制,但可能会注意到它们没有一起改变。考虑以下代码:
func updateMyButton(b *widget.Button) {
b.SetText("sometext")
b.SetIcon(someResource)
}
当使用基于方法更新时,SetText()
和SetIcon()
调用将刷新小部件,可能会引起之前提到的轻微延迟。建议仅在需要时调用Refresh()
;为了实现这一点,开发者可以直接访问小部件状态并手动刷新对象。这被称为基于字段的访问,因为我们直接更改小部件导出的字段。例如,我们可以将前面的代码重写如下:
func updateMyButton(b *widget.Button) {
b.Text = "sometext"
b.Icon = someResource
b.Refresh()
}
通过采用这种方法,我们确保Refresh()
只被调用一次,这样所有更改的元素将同时重新绘制。这为用户提供了更平滑的结果,并且将导致更低的 CPU 使用率。
渲染小部件
之前提到的CreateRenderer()
方法将返回一个新的渲染器实例,该实例定义了小部件如何在屏幕上呈现。工具包负责调用此方法,并且当小部件可见时,它将缓存结果。开发者不应直接调用此方法,因为结果将与显示的内容没有关联。
渲染器的确切生命周期是变化的,它是小部件的可见性、其父级的可见性以及窗口当前是否显示的组合。小部件的 WidgetRenderer
定义在其应用程序生命周期中可能被卸载。如果小部件在以后日期再次变得可见,将请求一个新的实例。如果不再需要渲染器,则将调用其 Destroy()
方法。小部件渲染器的完整定义如下,并已从 API 文档中提取:
// WidgetRenderer defines the behavior of a widget’s
// implementation. This is returned from a widget’s main
// object through the CreateRenderer() function.
type WidgetRenderer interface {
Layout(Size)
MinSize() Size
Refresh()
Objects() []CanvasObject
Destroy()
}
在 WidgetRenderer
定义中的前两种方法(Layout()
和 MinSize()
)应该来自 第四章,布局和文件处理 – 它们定义了一个容器布局。在这种情况下,容器是这个小部件,被控制的对象是用于渲染小部件的视觉组件 – 它们是从 Objects()
方法返回的。当需要重新绘制可见小部件时,会内部调用 WidgetRenderer
的 Refresh()
方法。
Objects()
方法的调用返回一个列表,其中包含渲染它所描述的小部件所需的每个 CanvasObject
。这是从 canvas
包(如 Text
、Rectangle
和 Line
)中的项目切片。这些项目将使用之前描述的布局方法进行排列,以创建最终的小部件展示。每个元素的颜色匹配或与当前主题融合是很重要的。当调用 Refresh()
函数时,它可能是对主题更改的响应,因此在该代码中应相应地更新任何自定义值。
现在我们已经了解了小部件的工作原理,让我们看看工具包中内置了什么。
介绍基本小部件
在 Fyne(或任何 GUI 工具包)中最常用的包可能是 widget
包。它包含所有对大多数图形应用程序有用的标准小部件。该集合分为基本小部件(用于简单的数据显示或用户输入)和集合小部件(List
、Table
和 Tree
),用于显示大量或更复杂的数据。在本节中,我们将按字母顺序逐步查看基本小部件,以了解它们的样式以及如何将它们添加到应用程序中。
手风琴
手风琴小部件用于通过显示和隐藏项目来将大量内容适应到小区域,这样每次只能看到一个子元素。每个项目都有一个标题按钮,用于显示或隐藏其下的内容。这可以在以下图像中看到,它显示了浅色和深色主题中的手风琴小部件:
图 5.1 – 一个展开项目 B 的手风琴小部件,显示在浅色和深色主题中
要创建一个Accordion
小部件,您必须传递一个AccordionItem
对象列表,该列表指定了折叠中每个元素的标题和详细信息。您还可以可选地指定Open
值,如果设置为true
,则默认显示内容。用于创建前面Accordion
图像的代码块如下:
acc := widget.NewAccordion(
widget.NewAccordionItem("A", widget.NewLabel("Hidden")),
widget.NewAccordionItem("B", widget.NewLabel("Shown item")),
widget.NewAccordionItem("C", widget.NewLabel("End")),
)
acc.Items[1].Open = true
默认情况下,Accordion
小部件一次只显示一个项目。要允许显示任意数量的项目,可以将MultiOpen
字段设置为true
。
按钮
Button
小部件提供了一个标准的按下按钮,可以通过鼠标点击(或在触摸屏上使用轻触手势)来激活。按钮可以包含文本或图标内容,或两者兼而有之。Button
构造函数还接受一个匿名func
,当按钮被轻触时执行。
您可以使用widget.NewButton
或widget.NewButtonWithIcon
构造函数来创建一个按钮。建议尽可能使用内置的主题图标(有关更多信息,请参阅本章后面的主题部分),如下所示:
widget.NewButtonWithIcon("Cancel", theme.CancelIcon(), func() {})
每个主题中的按钮如图所示:
![图 5.2 – 在亮暗主题中显示其图标的按钮
![img/Figure_5.2_B16820.jpg]
图 5.2 – 在亮暗主题中显示其图标的按钮
大多数按钮外观相同,但如果您需要让某个按钮突出,可以通过设置Button.Importance = widget.HighImportance
来将其设置为高重要性样式,如图所示:
![图 5.3 – 在亮暗主题中的高重要性按钮
![img/Figure_5.3_B16820.jpg]
图 5.3 – 在亮暗主题中的高重要性按钮
用于表示高重要性小部件的颜色将根据主题而变化,甚至可以由用户设置。Button.Importance
的另一个可能值是widget.LowImportance
,这会减少显示的视觉影响,例如,通过移除前面两个图像中显示的阴影。
卡片
当用户界面的元素相互关联时,将它们分组在一起可能很有用。当一组项目需要标题或当许多不同的数据元素想要比简单列表更大的预览时,这很有帮助。可以将一组Card
小部件添加到具有网格布局的Container
中,以安排显示不同内容(如搜索结果或媒体项目的预览)的类似项目。
您可以使用NewCard
构造函数创建一个卡片小部件,该函数接受一个标题和副标题字符串,以及一个CanvasObject
内容参数。您还可以在构建后或通过手动创建结构体来指定Image
字段,如下面的代码块所示:
widget.NewCard("Card Title", "Subtitle",
widget.NewLabel("Content"))
&widget.Card(Title: "Card Title",
Subtitle: "Subtitle",
Image: canvas.NewImageFromResource(theme.FyneIcon()))
您可以在以下图像中看到前面代码使用亮暗主题:
![图 5.4 – 卡片小部件在亮暗模式下的标题和图像
![img/Figure_5.4_B16820.jpg]
图 5.4 – 卡片小部件在亮暗模式下的标题和图像
在前面的代码中使用的所有字段都是可选的。对于任何标题的空字符串都将将其从显示中移除,以及空图像或内容也将将其移除(你可以在前面的图像中看到缺失的内容)。
复选
复选框功能由Check
小部件提供。它有两个状态:选中状态和未选中状态(默认)。构造函数接受一个回调函数func(bool)
,该函数将在选中状态更改时被调用,传递当前状态(true
表示选中)。此代码如下:
widget.NewCheck("Check", func(bool) {})
您还可以通过调用SetChecked
方法并传递true
(或传递false
以将其更改回未选中)来手动设置选中状态:
check.SetChecked(true)
在以下图像中显示了每个主题中不同的选中状态:
![Figure 5.5 – 亮色主题的复选框被选中,而暗色主题的复选框未被选中
![img/Figure_5.5_B16820.jpg]
图 5.5 – 亮色主题的复选框被选中,而暗色主题的复选框未被选中
选中指示器将适应当前主题,就像文本一样。
条目
文本输入主要通过Entry
小部件添加,它提供自由文本输入。可以通过设置Placeholder
向字段添加内容提示,并且可以直接使用Text
字段或SetText()
方法设置文本内容。可以使用以下构造函数之一创建条目。第一个是普通文本字段,第二个是密码输入,第三个用于多行输入(默认为 3 行):
widget.NewEntry()
widget.NewPasswordEntry()
widget.NewMultilineEntry()
以下图像显示了使用默认主题的标准和密码输入小部件:
![Figure 5.6 – 亮色和暗色主题下的 Entry 和 PasswordEntry 小部件
![img/Figure_5.6_B16820.jpg]
图 5.6 – 亮色和暗色主题下的 Entry 和 PasswordEntry 小部件
条目小部件还支持验证,可用于向用户提供关于当前文本的反馈。不同的状态可以在以下图像中看到:
![Figure 5.7 – 使用亮色主题的成功和失败验证状态
![img/Figure_5.7_B16820.jpg]
图 5.7 – 使用亮色主题的成功和失败验证状态
在输入时,当内容有效时,具有验证器集的条目将提供正面反馈。当用户停止编辑输入时,无效内容将显示警告。
文件图标
除了使用静态图标(参见本节后面的图标部分)之外,工具包还可以显示不同类型文件的适当图标。FileIcon
小部件被创建出来,通过加载标准图标资源并在其中显示文件扩展名,使这项常见任务变得更容易。图标的大小与标准的widget.Icon
相匹配,其图像和文本将更新以反映指定的 URI。可以通过指定文件资源的 URI 来创建FileIcon
小部件,如下所示:
file := storage.NewFileURI("images/myimage.png")
widget.NewFileIcon(file)
以下代码将根据当前主题渲染如下:
图 5.8 – 显示 PNG 图像符号和扩展的 Fileicon 小部件
FileIcon
小部件将根据文件类型变化其外观,设置合适的图标。
表单
表单小部件用于标记和布局各种输入元素。表单的每一行通常包含一个带有标签的输入元素。如果表单设置了 OnSubmit
或 OnCancel
函数,那么它将自动生成一个包含相应操作按钮的额外行。
您可以通过将 FormItem
对象的列表传递给 NewForm()
构造函数来创建一个表单,如下所示:
form := widget.NewForm(
widget.NewFormItem("Username", widget.NewEntry()),
widget.NewFormItem("Password", widget.NewPasswordEntry()),
)
form.OnCancel = func() {
fmt.Println("Cancelled")
}
form.OnSubmit = func() {
fmt.Println("Form submitted")
}
您还可以调用 Form.Append()
来稍后添加元素。表单的外观将根据当前主题如下所示:
图 5.9 – 在浅色和深色主题中显示的登录表单
将输入元素分组到表单中还有一个额外的好处,那就是它可以帮助确保只有经过验证的输入才会被提交。如果任何小部件设置了验证器,那么表单将只允许在所有验证器都通过后提交。
超链接
在某些情况下,提供可点击的 URL 字符串以在用户的浏览器中打开网页可能会有所帮助。为此用例,有一个 Hyperlink
小部件,它接受一个 URL
参数,当它被点击时打开页面。要创建超链接小部件,我们可能需要解析一个 URL,如下面的代码片段所示:
href, _ := url.Parse("https://fyne.io")
widget.NewHyperlink("fyne.io", href)
小部件的外观如下:
图 5.10 – 使用浅色和深色主题的超链接小部件
图标
虽然您可以直接将 canvas.Image
添加到用户界面中,但有时保持一致的大小可能很有用。Icon
小部件提供了这一点,加载标准资源并将其最小尺寸设置为主题定义的图标大小。图标还将更新以匹配当前主题。我们可以使用以下代码创建一个显示标准粘贴图像的图标:
widget.NewIcon(theme.ContentPasteIcon())
以下代码将根据当前主题进行渲染:
图 5.11 – 在浅色和深色主题中显示粘贴图标的图标小部件
接下来,我们将继续介绍标签小部件。
标签
通常建议使用 Label
小部件来显示文本。虽然可以使用 canvas.Text
,但正如我们在 第三章 中看到的,Windows、Canvas 和绘图,这些元素使用开发者定义的颜色 – 在大多数用户界面中,文本匹配当前主题更可取,这正是 Label
小部件所处理的:
widget.NewLabel("Text label")
每个主题中的标签小部件看起来如下:
图 5.12 – 浅色和深色主题中的文本标签
此小部件还支持额外的文本格式化,如换行、自动换行和截断。包含换行符(\n
)的字符串将被拆分为第二行并完全可见,而将Label.Wrapping
设置为fyne.TextWrapWord
(或fyne.TextWrapBreak
)将自动在小部件宽度需要时添加新行。将值设置为fyne.TextWrapTruncate
将简单地从显示中移除溢出的文本。
弹出菜单
PopUpMenu
小部件对于显示上下文菜单或允许用户在内置小部件无法提供所需功能时从选项中选择非常有用。要以这种方式创建菜单,你需要一个fyne.Menu
数据结构来描述可用的选项(这是我们用于第三章,窗口、画布和绘图,主菜单的相同结构)。在这种情况下,菜单的标题可以是空的,因为它将不会显示。
一旦定义了菜单,ShowPopUpMenuAtPosition
实用函数就是向用户显示此菜单的最简单方法。此函数接受窗口中的绝对位置并在左上角显示菜单。与其他弹出元素一样,可以通过在显示的内容外轻触来关闭它,从而将用户返回到之前的状态。你可以使用以下代码来完成此操作:
menu := fyne.NewMenu("", fyne.NewMenuItem("An item", func() {}))
pos := fyne.NewPosition(20, 20)
widget.ShowPopUpMenuAtPosition(menu, myWindow.Canvas(), pos)
上述代码将在窗口当前内容上方创建一个新菜单。结果如下所示:
图 5.13 – 在浅色和深色主题中显示的 PopUpMenu
也可以直接在这里使用Menu
小部件。可以使用widget.NewMenu
构造函数渲染菜单,而不会像PopUpMenu
那样创建覆盖层。
ProgressBar
如果一个应用程序需要指示一个过程将需要一些时间来执行,那么你可以使用ProgressBar
小部件。有两种变体:widget.ProgressBar
和widget.ProgressBarInfinite
。常规进度条显示从Min
到Max
(默认0到1)的当前Value
,开发者负责在过程进行时设置值。当使用无限进度条时,没有内在值,因此输出渲染一个动画,表示活动(值的改变)在未定义的时间内。我们可以使用以下任一行来创建进度条:
bar1 := widget.NewProgressBar()
bar2 := widget.NewProgressBarInfinite()
这两个版本的进度条看起来如下,左侧是bar1
,右侧是bar2
:
图 5.14 – 在浅色和深色主题中显示的标准和无限进度条
如果您希望为常规进度小部件添加不同的文本叠加,则可以使用TextFormatter
字段,设置一个返回字符串值的函数。这可以根据小部件的状态或类似加载中...
的字符串进行格式化。
单选按钮组
单选按钮组小部件是请求用户输入的最常见方式。结果与下一个讨论的选择
小部件类似,但所有选项都是可见的。RadioGroup
小部件可以通过指定将作为选项列出的字符串值片段来创建。第二个参数是一个回调函数,每次选择改变时都会执行,将新值传递给函数:
widget.NewRadioGroup([]string{"Item 1", "Item 2"}, func(s string) {
fmt.Println("Selected", s)
})
单选按钮组小部件在不同主题中的外观如下:
![图 5.15 – 在浅色和深色主题中显示的带有两个选项的单选按钮组,顶部被选中]
![图片 5.15 – B16820.jpg]
图 5.15– 在浅色和深色主题中显示的带有两个选项的单选按钮组,顶部被选中
选择
与前面的单选按钮组
小部件类似,选择
小部件允许用户从列表中选择一个项目。当选项列表很长或可用空间较小的时候,更常用选择
。此小部件以显示当前值的按钮形式出现。当触摸时,将显示一个弹出菜单,列出可用的选项:
widget.NewSelect([]string{"Item 1", "Item 2"}, func(s string) {
fmt.Println("Selected", s)
})
选择
小部件在不同主题中的外观如下:
![图 5.16 – 在浅色和深色主题中显示的选择小部件]
![图片 5.16 – B16820.jpg]
图 5.16 – 在浅色和深色主题中显示的选择小部件
接下来是选择条目
小部件。
选择条目
选择条目
与之前描述的选择
小部件类似,不同之处在于它还允许用户定义选项。这可以通过提供一个带有选择样式下拉图标的Entry
小部件来实现,该图标列出指定的选项。因为当前值可以在每次按键时改变,所以此小部件的回调配置与Entry
类似,而不是选择
——它不是作为构造函数中的选择更改函数传递,而是可以在小部件的OnChanged
字段上设置:
widget.NewSelectEntry([]string{"Item 1", "Item 2"})
选择条目
小部件的外观如下:
![图 5.17 – 在浅色和深色主题中添加输入前的选择条目小部件]
![图片 5.17 – B16820.jpg]
图 5.17 – 在浅色和深色主题中添加输入前的选择条目小部件
接下来是滑动条
小部件。
滑动条
滑动条
小部件可用于在范围内输入一个数字,尤其是在用户可能不知道确切数字时。例如,当指定亮度或音量时,这可能很有用——数字对最终用户来说并不重要,但它有一个从最小值
到最大值
的明确范围。
可以通过构造函数指定Min
和Max
值来创建Slider
小部件。它的默认值将被设置为最小值,并且可以通过设置Slider.Value
来更改。还可能指定一个Step
值,它定义了每个有效值之间的距离。如果没有定义步长,则允许在最小值和最大值之间的任何浮点值。通过指定Step
,例如,您可以只接受整数值。在这种模式下,当用户滑动小部件时,滑块可能会从一个有效值跳到另一个有效值:
widget.NewSlider(0, 100)
前面的代码将创建一个简单的滑动条小部件,设置为最小值,如下面的图像所示:
图 5.18 – 在浅色和深色主题中显示的滑动条小部件在最小值时的样子
TextGrid
尽管前面提到的Label
小部件提供了一些文本格式化功能,但有些应用程序需要按字符应用样式。对于代码编辑器中的语法高亮显示或显示在控制台输出上的错误行,有TextGrid
小部件。
在TextGrid
小部件内部,内容被分割成字符串表示中的每个字符,并且每个字符都应用了一个TextGridStyle
。这种样式允许为每个字符或网格中的单元格指定前景和背景颜色。此外,网格的每一行都可以指定一个样式。这个样式将用于任何没有指定自己样式的单元格。如果一个单元格有字符和行样式可用,这两个样式将会合并,使得单元格上设置的前景色将采用行的背景色;除非字符样式指定了两者。
尽管允许设置特定颜色的样式存在,但还有许多语义样式定义允许代码注释意图而不是绝对颜色。最常用的样式之一是TextGridStyleWhitespace
,它使用主题定义以柔和的颜色显示字符。使用内置样式,开发者可以将颜色定义委托给当前主题,为每个意图定义颜色。
TextGrid
小部件还提供了技术文本显示的常用功能,包括ShowLineNumbers
,它会在每一行的开头显示行号。ShowWhitespace
也可以设置为 true,以在视觉上指示其他不可见的空白字符,如制表符、空格和换行符。以下代码示例说明了您可以在TextGrid
中控制文本的一些方法:
grid := widget.NewTextGridFromString(
"TextGrid\n Content ")
grid.SetStyleRange(0, 4, 0, 7,
&widget.CustomTextGridStyle{BGColor:
&color.NRGBA{R: 64, G: 64, B: 192, A: 128}})
grid.Rows[1].Style = &widget.CustomTextGridStyle{BGColor:
&color.NRGBA{R: 64, G: 192, B: 64, A: 128}}
grid.ShowLineNumbers = true
grid.ShowWhitespace = true
下面的图像显示了前面代码的结果。在这里,我们可以看到背景样式已经应用于Grid中使用的 4 个字母的所有单元格,并且第二行(索引 1)应用了行样式。它们还启用了行号和空白选项:
![图 5.19 – 使用 TextGrid 在浅色和深色主题下展示的样式化内容
![img/Figure_5.19_B16820.jpg]
图 5.19 – 使用 TextGrid 在浅色和深色主题下展示的样式化内容
可以使用 SetStyle
方法为单元格分配样式。然而,当需要将样式应用于许多符文时,开发者可以使用更高效的 SetStyleRange
工具方法。SetRowStyle
方法可以帮助设置行样式,如前图所示。
工具栏
如果应用程序中有许多经常访问的功能,Toolbar
小部件可以是一种高效展示这些选项的方法。工具栏的主要元素是 ToolbarAction
项目,它们是简单的图标,当点击时,会执行传递给 NewToolbarAction
的函数参数。要分组操作元素,可以使用 ToolbarSeparator
,它在其左右两侧创建一个视觉分隔符。此外,可以使用 ToolbarSpacer
类型在操作之间创建一个间隙。这将扩展,导致其后的元素右对齐。使用一个空格将显示其左侧的项和其右侧的项。使用两个空格意味着空格之间的元素将在工具栏中居中。
要构建包含四个操作元素和一个分隔符的工具栏,我们可以使用以下代码:
widget.NewToolbar(
widget.NewToolbarAction(theme.MailComposeIcon(),
func() {}),
widget.NewToolbarSeparator(),
widget.NewToolbarSpacer(),
widget.NewToolbarAction(theme.ContentCutIcon(),
func() {}),
widget.NewToolbarAction(theme.ContentCopyIcon(),
func() {}),
widget.NewToolbarAction(theme.ContentPasteIcon(),
func() {}),
)
前面的代码片段会产生以下结果。以下图像展示了它在浅色和深色主题下的样子:
![图 5.20 – 在浅色和深色主题下具有一些可能图标的工具栏小部件
![img/Figure_5.20_B16820.jpg]
图 5.20 – 在浅色和深色主题下具有一些可能图标的工具栏小部件
我们迄今为止所探讨的小部件相当标准,可以通过简单的构造函数创建,或者通过直接初始化结构体。其中一些需要回调函数,该函数可以用来通知我们何时发生了动作。
在下一节中,我们将查看一些更复杂的小部件,这些小部件旨在管理数千个子小部件。为此,我们将学习它们如何使用更多的函数参数查询大型数据集并高效地显示数据子集。
使用集合小部件进行分组
在本节中,我们将查看旨在高效包含主要小部件的小部件。上一节中提到的某些小部件也执行此操作,例如 Form
和 Toolbar
,但集合小部件可以支持数千个项目(尽管它们并不是一次性都可见)。这些小部件通常用于显示大量选项或导航复杂的数据集。
由于集合小部件只显示大量数据的要求,它们被设计成一次只显示可能小部件的一小部分。为此,并保持出色的性能,它们有一个缓存机制,这使得它们的 API 比我们之前看到的小部件要复杂一些。
回调函数
这些小部件中的每一个都依赖于多个回调函数。这些函数中的第一个将提供有关小部件将要显示的数据维度(有关数据的更完整讨论,请参阅第六章,数据绑定和存储)的信息。第二个负责创建稍后将要显示的视觉元素,而第三个将数据中的项加载到先前创建的元素中。
缓存
集合小部件的性能关键在于它们如何缓存其中的重复图形元素。在集合小部件构造函数中引用的模板对象将在用户滚动小部件时被重用,以保持性能并跟上用户操作。
列表
小部件(以及其他集合小部件)维护一个最近使用的模板元素的内部缓存,这些元素将在下一行变为可见之前应用新数据。优化数据检索以使任何接近已可见项的项能够快速加载是应用程序开发者的工作。当我们探索可用的各种集合小部件时,我们将看到这些概念的应用。首先,我们将查看列表小部件。
列表
列表
小部件用于显示具有相似外观的垂直项目列表。一旦创建小部件,就可以加载相关数据,因此如果数据加载缓慢或显示复杂,这可能很有帮助。小部件将只加载和显示可见的元素,从而快速显示大型数据集的元素。
回调
每个集合小部件都使用回调函数来理解数据、加载模板项并在数据加载时更新它。让我们更详细地看看这些:
-
理解数据 -
长度
回调:列表
的第一个回调函数是长度
回调,它返回数据中的项目数量。这告诉小部件它需要管理多少行。如果数据集中添加了更多项,此值可以更新,并且下次列表刷新时,它将相应地调整。 -
加载模板 –
CreateItem
回调:第二个回调函数用于生成一个可重复使用的图形元素,用于加载数据。这被称为CanvasObject
,可以是任何类型的Widget
、Container
或来自canvas
包的项。小部件将根据屏幕上可见的项数多次调用此函数。在这个阶段,它们应该只包含占位符值。例如,在下面的图像中,每一行包含一个图标和一个标签,因此返回的模板可能是一个具有水平框布局的容器,以及默认图标和标签中的占位符文本。尽管用户永远不会看到占位符值,但它们很重要,因为模板的大小配置了List
组件。模板项的高度将用于每一行的高度,以便当它乘以前一个长度的结果时,回调将确定列表组件的整体滚动高度。此外,模板宽度将指定List
组件的最小宽度。 -
用数据填充模板 –
UpdateItem
回调:回调三是用于将数据应用于模板单元格。它接收两个参数:要使用的数据项的索引和之前配置的CanvasObject
模板。此回调的目的是使用在指定索引处应使用的数据配置项。所使用的模板将与第二个参数的返回对象相同,以便可以适当地进行类型转换。
每个集合小部件都有之前描述的模式的变体,正如我们将在本章后面的 表格 和 树 部分中看到的那样。
选择
集合小部件的一个额外功能是它们允许选择一个元素(通过点击)。在列表界面中,所选元素通过前导边缘的标记表示,如前图所示。要通知何时选择了一个项,你可以在 OnSelected
字段上设置一个 func(ListItemID)
回调,这将通知你哪个数据集中的项被选中。创建列表的基本代码如下:
widget.NewList(
func() int { return 3 },
func() fyne.CanvasObject {
icon := widget.NewIcon(theme.FileIcon())
label := widget.NewLabel("List item x")
return container.NewHBox(icon, label)
},
func(index ListItemID, template fyne.CanvasObject) {
cont := template.(*fyne.Container)
label := cont.Objects[1].(*widget.Label)
label.SetText(fmt.Sprintf("List item %v", index))
})
代码示例将在第二次点击后生成以下输出:
![图 5.21 – 在浅色和深色主题中选中的列表集合小部件]
图 5.21 – 在浅色和深色主题中选中的列表集合小部件
表格
Table
小部件是 List
小部件的二维版本,旨在显示具有行和列维度的大型数据集。它使用与 List
和 Tree
小部件相同的缓存和回调系统。
回调
Table
小部件的回调与 List
的回调类似,但数据标识符传递一个行和列 int
来索引数据结构。这意味着 Length
回调现在返回 (int, int)
。
在Table
版本中设置新图形模板的回调(称为CreateCell
)不接收任何参数,仅返回一个fyne.CanvasObject
,该对象将被缓存以用于显示。此模板用于确定所有单元格的默认大小,因此请确保它有一个合理的最小尺寸。与List
一样,您在此返回的元素将不会呈现给用户,但将用于测量和配置整体布局。
最后必需的回调是UpdateCell
,用于将数据应用于模板元素。在Table
小部件中,此函数传递一个数据标识符(TableCellID
,其中包含一个Row
和Col
整型),它索引要应用的数据,以及CanvasObject
模板。开发人员应在模板中填充由标识符指定的适当数据。与其他集合小部件一样,建议尽可能加载相关数据,以便当用户滚动或展开元素时,任何需要很长时间才能加载的数据都准备好显示。
选择
Table
小部件支持选择单元格,如以下图像所示,在首部和边框处有标记。要通知何时选择了项目,可以在OnSelected
字段上设置func(TableCellID)
回调。这将通过传递标识行和列来通知您选择了数据集中的哪个项目。创建新表格的基本代码如下:
widget.NewTable(
func() (int, int) { return 3, 3 },
func() fyne.CanvasObject {
return widget.NewLabel("Cell 0, 0")
},
func(id TableCellID, template fyne.CanvasObject) {
label := template.(*widget.Label)
label.SetText(fmt.Sprintf("Cell %d, %d", id.Row+1, id.Col+1))
})
假设单元格 2, 1 被点击以获得选择,前面的代码将生成以下输出:
图 5.22 – 表格集合小部件在浅色和深色主题下的选择
树形结构
Tree
小部件与List
小部件非常相似,但增加了每个元素可以展开以显示其他项目的功能。这种展开用于显示层次结构,例如目录和文件、类别和项目,或者具有父子关系的其他数据。
回调
Tree
小部件的回调与List
和Table
类似,但由于其更复杂的数据结构,简单的Length
回调被ChildUIDs
和IsBranch
所取代。第一个回调将返回一个TreeNodeID
值(可以使用常规字符串)的切片,这些值包含指定节点(作为唯一的TreeNodeID
传入)下存在的每个项目的唯一标识符。第二个回调在传递唯一 ID 时对每个元素进行调用。如果它可以包含更多节点,则应返回true
,否则返回false
。
在 Tree
版本(称为 CreateNode
)中设置新图形模板的回调函数接受一个布尔参数,表示这是一个分支(true
,可以展开)还是叶子(false
,这是树的末尾)。如果你想在树中的分支和叶子元素中使用不同的样式,这很有用。与 List
一样,你在这里返回的元素将不会展示给用户,而是用于测量和配置整体布局。
最后必需的回调是 UpdateNode
。这个回调用于将数据应用到模板元素上。在 Tree
小部件中,这个函数传递唯一的 TreeNodeID
标识符、表示这是分支还是叶子模板的 bool
以及 CanvasObject
模板。开发者应使用标识符指定的适当数据填充此模板。与其他收集小部件一样,建议尽可能加载相关数据,以便当用户滚动或展开元素时,加载缓慢的数据可以准备好显示。
此外,关于管理内容所需的回调,Tree
小部件允许开发者设置 OnBranchOpened
和 OnBranchClosed
回调,以便他们可以跟踪树的状态变化。这两个函数都是 func(TreeNodeID)
类型,其中参数是数据项的唯一标识符。
选择
Tree
小部件也支持选中的节点。这通过前导边缘的标记表示,如下所示。要通知何时选中了某个项目,你可以在 OnSelected
字段上设置 func(TreeNodeID)
回调。这将通知你哪个数据集的项目被选中,同时传递唯一标识符。显示树所需的基本代码如下。第一个回调返回每个级别的子节点的唯一 ID:
func(uid TreeNodeID) []string {
switch uid {
case "":
return []string{"cars", "trains"}
case "cars":
return []string{"ford", "tesla"}
case "trains":
return []string{"rocket", "tgv"}
}
return ""
},
func(uid TreeNodeID) bool {
return uid == "" || uid == "cars" || uid == "trains"
},
func(_ bool) fyne.CanvasObject {
return widget.NewLabel("Template")
},
func(uid TreeNodeID, _ bool, template fyne.CanvasObject) {
label := template.(*widget.Label)
label.SetText(strings.Title(uid))
})
上述代码将在应用中显示一个树形结构。一旦第二个元素被展开,这个树形结构将如下所示:
![图 5.23 – 展开分支的树形小部件,显示在浅色和深色主题中
![img/Figure_5.23_B16820.jpg]
图 5.23 – 展开分支的树形小部件,显示在浅色和深色主题中
本节中展示的三个收集小部件为展示大量或复杂数据提供了有用的功能。API 比标准小部件稍微复杂一些,但这允许向用户展示大量数据集;例如,通过滚动数据库中的数千条记录或显示大型文件树的部分。
我们可以使用一系列容器小部件来构建更复杂的用户界面设计并导航应用程序。我们将在下一节中了解这些内容。
使用容器小部件添加结构
在第三章,“窗口、画布和绘图”中,我们学习了如何使用 Container
在画布内组合多个对象。使用我们在第四章“布局和文件处理”中探索的布局,可以自动根据某些规则排列每个 CanvasObject
。然而,有时应用程序可能希望项目根据用户交互出现和消失,或者具有超出其大小和位置之外的可视属性。容器小部件可以提供这些更丰富的行为。这些结构小部件可以在 container
包中找到,包括滚动、分组以及隐藏和显示内容的变体。让我们按字母顺序探索每个选项(选项)。
AppTabs
AppTabs
容器用于控制应用程序的大面积区域,其中内容应根据当前活动进行切换。例如,这可以用于将许多图形元素放入一个小应用程序用户界面中,当一次只有子部分有用时。
标签容器中的每个标签可以包含文本和/或图标(无论使用哪种组合,所有项目都应保持一致)。每个标签都有一个相关的 CanvasObject
(通常是一个容器),当标签被选中时将显示。这些是通过传递给 NewAppTabs
构造函数的 TabItem
对象创建的。要创建带有图标和标签的两个标签,您将使用以下代码:
container.NewAppTabs(
container.NewTabItemWithIcon("Tab1", theme.HomeIcon(), tab1Screen),
container.NewTabItemWithIcon("Tab2", theme.MailSendIcon(), tab2Screen))
上述代码将渲染为以下容器之一:
图 5.24 – 在 AppTabs 小部件中使用亮色和暗色主题的带有文本和图标的两个标签页
前面的图片显示了标签页的默认方向。然而,标签容器可以在四个边缘中的任何一个显示标签。SetTabLocation()
函数接受 TabLocation
类型中的一个;即,TabLocationTop
、TabLocationBottom
、TabLocationLeading
(通常是左侧)或 TabLocationTrailing
(在内容之后 – 通常在右侧)。下面的图片显示了标签的位置如何改变图标布局:
图 5.25 – 底部的标签容器,具有左侧和右侧位置
当在移动设备上运行应用程序时,在纵向模式下,预期标签位于顶部或底部。因此,位置将相应地适应 – 前导设置将在顶部显示标签,而跟随设置将标签设置在底部。如果设备旋转,则标签将移动到左侧或右侧边缘 – 为内容留出更多空间。在横向模式下,任何请求顶部位置的标签将显示在前导(左侧)边缘;底部设置将移动到跟随(右侧)边缘。
滚动
大多数需要滚动内容的部件都包含此功能。然而,如果您想为内容添加滚动功能,可以使用 Scroll
容器。通过将其他元素包裹在滚动容器中,您可以在水平和垂直维度上添加滚动条。在水平和垂直维度上滚动的构造函数是 container.NewScroll()
。如果您只想水平滚动,也可以调用 NewHScroll()
,或者如果您只想垂直滚动(例如列表),则可以调用 NewVScroll()
。以下图像显示了简单标签内容的完整滚动:
图 5.26 – 在浅色和深色主题下显示滚动条和阴影的滚动容器
如您所见,滚动容器的最小尺寸变得非常小 – 只有 32x32。如果您使用水平滚动条,则其最小高度将适合内容,而如果您使用垂直滚动条(如列表),则宽度将适应以适合内容。
分割
Split
容器为我们提供了一种整洁的方式来分隔应用程序的两个部分,当用户能够改变每个部分可用的空间量时。这可以是水平分割或垂直分割。水平分割容器显示两个元素并排,它们之间有一个分割栏。垂直分割将元素堆叠在一起,中间有一个分割栏:
right := container.NewVSplit(
widget.NewLabel("Top"), widget.NewLabel("Bottom"))
container.NewHSplit(widget.NewLabel("Line1/nLine2"), right)
在以下图像中,您可以看到左侧(前导)侧有 Line1\nLine2
的水平分割容器,右侧(跟随)侧有一个包含 Top
和 Bottom
的垂直分割:
注意
在水平模式下,前导位置(第一个参数)通常是左侧,而在垂直模式下,它将在顶部。
图 5.27 – 使用浅色和深色主题的水平模式和垂直模式下的 Split 小部件
分割容器允许拖动条来改变分配给分割每侧的大小。容器的最小大小将是两个内容(加上分割)的总和,除非它位于具有更多空间的父容器中,否则条将不可拖动。当有更多空间可用时,拖动条将改变额外空间分配的位置。
开发者还可以通过Offset
字段直接手动指定比例。0.0
的值表示分割应尽可能靠左(或向上),而1.0
的值表示应完全靠右(或底部对齐)。如果想要保存用户的偏好设置,可以在应用程序运行时查询此值。
除了将标准小部件组合在一起以形成清晰和逻辑的用户界面外,有时显示临时信息或请求用户输入也是有用的。对于开发人员或设计师不希望在主界面中包含这些情况,我们可以使用一组标准弹出对话框。我们将在下一节中探讨这一点。
使用常用对话框
在用户使用应用程序的过程中,你经常会需要中断流程来展示信息、请求用户确认,或者选择文件或其他输入元素。为此目的,工具包通常提供对话框窗口,Fyne 也是如此。与打开新窗口不同,对话框将出现在当前窗口的现有内容之上(这在所有平台上都工作得很好,因为并非所有平台都能很好地管理多个窗口应用程序)。
每个对话框都有自己的构造函数(dialog.NewXxx()
形式),用于创建稍后通过Show()
显示的对话框。它们还提供了一个辅助函数来创建并显示它(dialog.ShowXxx()
形式)。所有这些函数的最后一个参数是它们应该显示的窗口。所有对话框也支持在对话框关闭时设置回调。这可以通过SetOnClosed()
方法进行配置。
在本节中,我们在学习如何为应用程序构建自定义对话框之前,查看了一下可用的不同对话框辅助工具(按字母顺序排列)。尽管这些工具将加载当前应用程序主题,但我们只为每个示例展示了一张图片。
ColorPicker
Fyne 为应用程序提供了一种标准对话框,用于选择颜色。此功能将展示一组标准颜色、最近选择的颜色列表,以及一个高级区域,可以通过值滑块选择特定颜色,编辑红、绿和蓝(RGB)或色调、饱和度和亮度(HSL)的通道值,或者直接输入 RGB 十六进制颜色表示法。
可以通过调用dialog.NewColorPicker()
然后使用Show()
或简单地调用dialog.ShowColorPicker()
来创建颜色选择器。构造函数的参数是要在顶部显示的标题和消息,一个用于颜色选择的回调函数,以及要显示其中的父窗口:
dialog.ShowColorPicker("Pick a Color", "",
func(value color.Color) {
fmt.Println("Chose:", value)
},
win)
上述代码将加载选择器,如下所示:
图 5.28 – 简单颜色选择器对话框
上一张图片显示了默认的简单颜色选择器。如果您希望开发者有更多的控制权,则可以提供高级功能。
确认
确认对话框允许您要求用户确认一个操作。除了提供此确认的标题和内容外,开发者还可以传递一个回调函数,当用户做出决定时将被调用,参数为 false 表示否定回答,为 true 表示确认。像所有对话框一样,最后一个参数是父窗口:
dialog.ShowConfirm("Please Confirm", "Are you sure..?",
func(value bool) {
fmt.Println("Chose:", value)
}, win)
如果当前加载了浅色主题,确认对话框将如下所示:
图 5.29 – 使用浅色主题的确认对话框
重要的是要记住,显示对话框并不会停止加载它的代码。用户的决定将通过回调来传达;其余的代码将继续不间断地执行。
文件选择
正如我们在第四章,“布局和文件处理”中看到的,dialog
包可以帮助进行文件选择——选择要打开的文件或保存内容的位置。打开fyne.URIReadCloser
或fyne.URIWriteCloser
类型时可能会出现错误,因为这些操作可能因多种原因而失败:
dialog.ShowFileOpen(func(read fyne.URIReadCloser, err error) {
fmt.Println("User chose:", read.URI().String(), err)
}, win) {
打开文件对话框如下所示:
图 5.30 – 使用文件对话框选择要打开或保存的文件
文件对话框默认显示用户的主目录。这可以通过调用SetLocation
方法来更改。由于这是一个跨平台 API,起始位置是一个URI
而不是字符串路径。这也意味着文件对话框可以用来显示远程文件系统和其他文件数据源的内容。
以类似的方式,应用程序可以使用dialog.ShowFileSave
方法询问写入文件的位置。也可以使用dialog.ShowFolderOpen
来提示文件夹选择而不是文件。
表单
Form
对话框通过请求输入值来扩展确认的简单前提,除了确认结果外。Form
对话框可以包含各种小部件,就像在 介绍基本小部件 部分中的 Form
小部件一样。构造函数与确认对话框类似,但它接受一个额外的 *widget.FormItem
值切片来指定内容:
dialog.ShowForm( "Form Input", "Enter", "Cancel",
[]*widget.FormItem{
widget.NewFormItem("Enter a string...", widget. NewEntry())},
func(bool) {}, win)
结果将是一个包含 widget.Entry
字段的对话框,如下面的图像所示:
![Figure 5.31 – 使用 Entry 对话框请求用户输入值
![img/Figure_5.31_B16820.jpg]
图 5.31 – 使用 Entry 对话框请求用户输入值
信息
在命令行应用程序中,信息通常会写入标准输出或标准错误(通常用于错误消息)。然而,图形应用程序通常不会从命令行运行,因此用户应该看到的消息需要以不同的方式呈现。对话框包可以帮助完成这项任务。
信息对话框可以用于在信息的重要性足够高,以至于用户应该被打断查看时呈现标准消息。调用 dialog.ShowInformation
函数来呈现此对话框,并接受标题和消息参数。如果要呈现的信息是错误,则可以使用 dialog.ShowError
辅助函数,因为它接受错误类型,并将信息提取出来以显示:
dialog.ShowInformation("Some Information",
"This is a thing to know", win)
err := errors.New("a dummy error message")
dialog.ShowError(err, win)
信息对话框将呈现如下:
![Figure 5.32 – 浅色主题下的信息对话框
![img/Figure_5.32_B16820.jpg]
图 5.32 – 浅色主题下的信息对话框
在这些之后,我们将转向自定义对话框。
自定义对话框
尽管前面的对话框应该涵盖了您可能希望使用弹出对话框中断用户流程的大部分原因,但您的应用程序可能还有其他要求。为了支持这一点,您可以将任何内容插入到自定义对话框中,以确保整体布局一致。
要构建一个自定义对话框,必须将一个新的参数及其内容传递给构造函数。任何 Fyne 小部件或 CanvasObject
都可以用于自定义对话框,这包括容器以提供更复杂的内容。为了说明这一点,我们将使用 TextGrid
组件:
content := widget.NewTextGrid()
content.SetText("Custom content")
content.SetStyleRange(0, 7, 0, 14,
widget.TextGridStyleWhitespace)
dialog.ShowCustom("Custom Dialog", "Cancel", content, win)
上述代码将生成一个自定义对话框,如下所示:
![Figure 5.33 – 显示自定义内容(一个 TextGrid)
![img/Figure_5.33_B16820.jpg]
图 5.33 – 显示自定义内容(一个 TextGrid)
还有一个 ShowCustomConfirm()
版本,它提供了一个 func(bool)
回调,以通知开发者哪个按钮被点击。
通过探索各种小部件和对话框,我们已经看到了标准主题的外观,以及提供了浅色和深色版本。接下来,我们将探讨主题由什么组成以及如何管理和自定义它们。
理解主题
Fyne 工具包中的主题实现了 Material Design 的色彩方案、图标和大小/填充值。主题 API 的设计旨在确保应用程序感觉一致,并提供良好的用户体验,同时允许开发者传达身份和定制。所有 Fyne 应用程序都可以使用内置主题以浅色或深色模式显示。我们将在下一节中详细探讨这些内容。
内置主题
由于越来越多的操作系统支持浅色与深色桌面配色,Fyne 主题规范支持浅色和深色变体。默认情况下,每个应用程序都会附带一个内置主题,提供浅色和深色变体。这个主题在本书前面的 介绍基本小部件 部分中已经广泛介绍,但要了解这一切是如何结合在一起的,请查看以下 Fyne 演示应用程序的截图,该应用程序展示了小部件:
![Figure 5.34 – 默认主题下的浅色变体小部件集合
Figure 5.34 – 默认主题下的小部件集合 – 浅色变体
上一张截图显示了浅色主题下的控件演示。下一张截图显示了相同的控件,但使用了内置的深色主题:
![Figure 5.35 – 使用深色主题的各种小部件
Figure 5.35 – 使用深色主题的各种小部件
如您所见,正在使用的主要颜色(主色 – 在这种情况下为蓝色)已被选择,因为它与浅色和深色主题的背景颜色对比鲜明。当使用此模型时,主题可以更改主色,同时继续支持浅色和深色用户偏好。
在大多数操作系统上,Fyne 会自动选择与当前用户偏好最匹配的主题变体。用户可以选择特定的版本,我们将在下一节中看到。
用户设置
如我们之前提到的,基于 Fyne 的应用程序通常会检测用户对浅色或深色主题的偏好,并相应地加载。可以通过使用 Fyne 设置应用程序或使用环境变量来设置加载哪个主题的偏好。
fyne_settings 应用程序,它可以配置所有基于 Fyne 的应用程序,可以运行来管理用户的设置。这包括他们的主题变体(浅色或深色),以及他们将使用的基色。使用此界面所做的任何更改都将保存以供将来使用,并将立即应用于所有打开的应用程序。您还可以从 fyne_demo 中的 设置 菜单找到 设置 面板:
![Figure 5.36 – fyne_settings 应用程序
Figure 5.36 – fyne_settings 应用程序
如果您希望临时应用一个主题,或者希望一个应用程序使用不同的主题,使用环境变量可能会有所帮助。可以将 FYNE_THEME
环境变量设置为 light 或 dark 以指定应使用哪个变体。您可以使用类似的方式,通过使用 FYNE_SCALE
环境变量来覆盖默认的界面缩放。在这里,1.0
是标准值;较小的数字加载较小的内容,而较大的数字加载较大的内容。
包含的图标
如本章前面的一些小部件所示(例如,Button
和 AppTabs
),主题包包括来自材料设计集合的许多图标(完整官方集合可在 material.io/resources/icons/
找到)。
由于基于 Fyne 的应用程序的所有元素都设计为适应不同类型的显示和用户偏好,因此图像应该是基于矢量的而不是位图的。这意味着在非常小或非常大的尺寸显示时,将计算显示的确切像素以实现最佳显示,而不是从原始图像中乘以(或减少)像素数量。
幸运的是,材料设计图像以矢量格式提供,内置图标都是 可缩放矢量图形(SVG)格式。这也意味着图标可以轻松适应各种颜色,因为应用程序运行时,从而确保它们可以适应主颜色和当前主题:
图 5.37 – 材料设计图标的选择
由于图标集是免费提供的并且非常受欢迎,因此很容易下载额外的图标并将它们添加到您的应用程序中,知道它们将与工具包的整体美学相匹配。
应用程序覆盖
希望偏离默认(或用户选择)主题的应用程序开发人员也在 Fyne 主题 API 中得到了照顾。在为您的应用程序指定主题之前请谨慎行事——这可能会让您的应用程序用户感到惊讶。要使用内置主题之一,但覆盖用户或系统设置有关是否使用浅色或深色变体的设置,您可以在当前的 App
实例上调用 SetTheme()
,如下所示:
fyne.CurrentApp().Settings().SetTheme(theme.DarkTheme())
或者,要强制应用程序使用内置的浅色主题,请使用以下代码:
fyne.CurrentApp().Settings().SetTheme(theme.LightTheme())
此 API 更常用于设置自定义应用程序主题。创建自定义主题的详细信息将在 第七章,构建自定义小部件和主题 中介绍。一旦创建了主题,就可以使用 SetTheme()
函数加载它,并将其应用于当前应用程序。以下截图显示了一个偏离标准样式的自定义主题:
图 5.38 – 基于 Fyne 的 BBC Micro 模拟器 GUI
现在我们已经探讨了 Fyne 工具包的主要小部件和主题功能的细节,让我们构建一个简单的应用程序,将其中许多元素组合在一起。
实现任务列表应用程序
为了探索上一节中列出的某些小部件以及它们如何组合成一个简单的应用程序,我们将构建一个小型任务列表。此应用程序将根据完成或不完整状态显示任务列表,并允许用户编辑每个项目的详细信息。
设计 GUI
首先,我们将拼凑一个任务应用程序的基本用户界面。它将在应用程序的左侧包含任务列表,并在右侧包含编辑任务的组件集合。在此之上,我们将添加一个用于其他操作的工具栏。让我们开始吧:
-
任务列表将是一个
List
小部件,当用户选择一个项目时,它会通知用户。List
小部件将包含用于此模拟的静态内容。在这里,我们将告诉列表存在一定数量的项目(例如,5
),以便它创建正确数量的项目来显示。每次列表调用CreateItem
回调时,我们都会创建一个新的复选框项目。目前,我们将第三个(UpdateItem
)方法留空,以便它只显示模板值。此代码将在简单的makeUI
方法中创建,如下所示:func makeUI() fyne.CanvasObject { todos := widget.NewList(func() int { return 5 }, func() fyne.CanvasObject { return widget.NewCheck("TODO Item x", func(bool) {}) }, func(int, fyne.CanvasObject) {}) ... }
-
接下来,我们将创建允许我们编辑任务项的小部件。让我们创建一个
Form
小部件,它将包含我们需要的项目并提供标签。我们将使用widget.NewFormItem
为每个项目创建一个新的行,传递string
标签和小部件的内容作为参数。这些都是标准小部件,但我们传递的回调目前都是空的。我们将在稍后返回这些小部件以完成它们的功能。以下代码位于我们在上一部分开始的makeUI
函数内部:details := widget.NewForm( widget.NewFormItem("Title", widget.NewEntry()), widget.NewFormItem("Description", widget.NewMultiLineEntry()), widget.NewFormItem("Category", widget.NewSelect([]string{"Home"}, func(string) {})), widget.NewFormItem("Priority", widget.NewRadioGroup([]string{"Low", "Mid", "High"}, func(string){})), widget.NewFormItem("Due", widget.NewEntry()), widget.NewFormItem("Completion", widget.NewSlider(0, 100)), )
-
我们将添加到界面中的最后一个组件是一个工具栏,它将提供访问添加任务功能的权限。为此,我们将使用
ToolbarAction
项目创建一个widget.Toolbar
:toolbar := widget.NewToolbar( widget.NewToolbarAction(theme.ContentAddIcon(), func() {}), )
-
为了将这些界面元素组合在一起,我们将使用
Border
布局创建一个新的容器。工具栏将被设置为顶部项目,任务项将位于容器的左侧。我们的表单将通过传递一个未指定为位于边框上的组件来填充剩余空间。此容器将从makeUI
函数返回,以便它可以用于显示我们的应用程序窗口:return container.NewBorder( toolbar, nil, todos, nil, details)
-
要运行我们的应用程序,我们只需添加常规的启动代码,该代码创建一个窗口并设置我们的内容。我们不需要指定此窗口的大小,因为内容将自然地压缩到合理的最小尺寸:
func main() { a := app.New() w := a.NewWindow("TODO List") w.SetContent(makeUI()) w.ShowAndRun() }
-
运行我们迄今为止创建的所有代码将给我们一个很好的印象,了解应用程序的外观:
Chapter05$ go run .
当使用 Fyne 浅色主题(通过用户偏好选项或设置FYNE_THEME="light"
)时,应用程序应该如下所示:
Figure 5.39 – Our task list GUI skeleton
在我们可以完成这个应用程序的功能之前,我们需要定义一个数据结构,它将保存我们正在编辑的任务信息。
定义数据
为了使我们的应用程序正常工作,我们需要创建一个数据结构来管理我们正在编辑的信息。让我们开始吧:
-
首先,我们必须定义一个
task
数据结构 – 这只是简单地列出与我们在上一节中设计匹配的各种字段。不同的字段将存储在不同的类型中 – 例如,Entry
小部件映射到string
,我们的复选框映射到bool
。我们将添加以下代码到一个名为data.go
的新文件中:type task struct { title, description string done bool category string priority int due *time.Time completion float64 }
如你所见,我们为我们的完成
Slider
的值使用了float64
,并且我们将把日期输入转换为time.Time
格式。 -
由于我们将存储许多任务,我们可以简单地创建一个任务指针的切片,但通过定义一个新的类型,我们可以将某些函数与有用的其他函数关联起来。该类型只是包装了
[]*task
切片,它将存储数据:type taskList struct { tasks []*task }
-
由于我们将根据
done
状态显示任务列表,我们应该添加两个辅助方法,根据该字段的值返回这些子列表:func (l *taskList) remaining() []*task { var items []*task for _, task := range l.tasks { if !task.done { items = append(items, task) } } return items } func (l *taskList) done() []*task { var items []*task for _, task := range l.tasks { if task.done { items = append(items, task) } } return items }
-
我们还将定义一些常量,有助于管理我们数据中的不同优先级。参考以下代码片段:
const ( lowPriority = 0 midPriority = 1 highPriority = 2 )
-
在编写数据处理代码时,编写测试也很重要。如果你在连接到用户界面之前添加这些测试,那么错误可以更快地暴露出来。这意味着当我们添加图形测试时,发现的问题应该与我们的用户界面代码中的错误有关。创建一个名为
data_test.go
的新文件,并添加以下测试:func TestTaskList_Remaining(t *testing.T) { item := &task{title: "Remain"} list := &taskList{tasks: []*task{item}} remain := list.remaining() assert.Equal(t, 1, len(remain)) done := list.done() assert.Equal(t, 0, len(done)) } func TestTaskList_Done(t *testing.T) { item := &task{title: "Done", done: true} list := &taskList{tasks: []*task{item}} remain := list.remaining() assert.Equal(t, 0, len(remain)) done := list.done() assert.Equal(t, 1, len(done)) }
可以向这个项目添加更多测试 – 你可以在本书的代码仓库中找到它们,位于
Chapter05
文件夹内,网址为github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne/tree/master/Chapter05
。 -
在本章中,我们没有探讨数据存储,所以我们只需将任务列表保存在内存中。你将在第六章中了解更多关于数据和偏好存储的信息,数据绑定和存储。由于我们的数据将在每次应用程序运行时重置,我们应该创建另一个函数,用一些将在应用程序启动时加载的内容填充数据结构,如下所示:
func dummyData() *taskList { return &taskList{ tasks: []*task{ {title: "Nearly done", description: `You can tick my checkbox and I will be marked as done and disappear`}, {title: "Functions", description: `Tap the plus icon above to add a new task, or tap the minus icon to remove this one`}, }} }
现在我们已经定义了数据结构和基本函数,我们可以将其连接到用户界面并完成功能。
选择任务
更新小部件内容的最简单方法是保留对已构造实例的引用。我们将为多个元素执行此操作,因此我们应该创建一个新的类型来处理用户界面的各种元素。创建这个结构体意味着我们可以避免大量的全局变量,这应该有助于保持代码整洁。让我们开始吧:
-
创建一个新的结构体,并将其命名为
taskApp
,如下所示:type taskApp struct { data *taskList visible []*task tasks *widget.List // more will be added here }
该类型包括对
*taskList
数据结构的引用,它将保存我们的数据,并定义了一个*task
类型的切片,表示当前可见的任务(调用taskList.remaining()
或taskList.done()
的结果)。 -
现在,我们可以将我们的
makeUI
函数作为taskApp
类型的成员方法,使其签名变为func (a *taskApp) makeUI() fyne.CanvasObject
。这样做使我们能够通过a.data
访问我们之前定义的数据结构。然而,我们将使用存储在visible
中的任务列表来填充我们的列表,因为它可能包含已完成或未完成的项,具体取决于其当前状态。 -
设置列表小部件的代码现在可以使用以下代码进行更新。我们将将其引用存储在
a.tasks
中,而不是原始的todos
变量(这样我们就可以稍后引用它);别忘了将makeUI
返回的todos
引用也改为使用a.tasks
。我们的Length
回调函数的结果只是简单地返回a.visible
切片中的项目数量。尽管CreateItem
回调(中间参数)不需要更改,但我们确实提供了一个最终回调的实现;即UpdateItem
。这个新函数从指定的索引(i
)获取任务,并使用task.title
来设置Check
小部件的文本:a.tasks = widget.NewList(func() int { return len(a.visible) }, func() fyne.CanvasObject { return widget.NewCheck("TODO item x", func(bool) {}) }, func(i int, c fyne.CanvasObject) { check := c.(*widget.Check) check.Text = a.visible[i].title check.Refresh() })
-
要看到这些更改的实际效果,我们需要设置数据源。为此,我们必须在调用
SetContent
之前添加一行来创建我们的虚拟数据并构建新的taskApp
结构体,如下所示:data := dummyData() tasks:= taskApp{data: data, visible: data. remaining()} w.SetContent(tasks.makeUI())
执行这些代码更改将更新应用程序,使其反映主列表中的任务标题,如下面的屏幕截图所示:
![Figure 5.40 – Showing real task titles
![img/Figure_5.40_B16820.jpg]
图 5.40 – 显示真实任务标题
接下来,我们需要填写窗口右侧的详细信息。
填写详细信息
要填充应用程序的数据区域,我们需要跟踪当前任务及其应填充的小部件。让我们开始:
-
要做到这一点,我们需要在
taskApp
结构体中添加一个current
字段。之后,我们需要保存对每个我们为初始布局测试添加的输入元素的引用,这将需要在taskApp
中添加更多字段:type taskApp struct { data *taskList visible []*task current *task tasks *widget.List title, description, due *widget.Entry category *widget.Select priority *widget.Radio completion *widget.Slider }
-
在这些准备工作就绪后,我们可以在
makeUI
中的details
设置中完成替换,使其看起来如下所示:a.title = widget.NewEntry() a.description = widget.NewMultiLineEntry() a.category = widget.NewSelect([]string{"Home"}, func(string) {}) a.priority = widget.NewRadio( []string{"Low", "Mid", "High"}, func(string) {}) a.due = widget.NewEntry() a.completion = widget.NewSlider(0, 100) details := widget.NewForm( widget.NewFormItem("Title", a.title), widget.NewFormItem("Description", a.description), widget.NewFormItem("Category", a.category), widget.NewFormItem("Priority", a.priority), widget.NewFormItem("Due", a.due), widget.NewFormItem("Completion", a.completion), )
-
一旦完成设置代码,我们可以添加一个名为
setTask
的新函数。这将用于更新当前任务并刷新我们在前面的代码块中创建的详细元素:func (a *taskApp) setTask(t *task) { a.current = t a.title.SetText(t.title) a.description.SetText(t.description) a.category.SetSelected(t.category) if t.priority == midPriority { a.priority.SetSelected("Mid") } else if t.priority == highPriority { a.priority.SetSelected("High") } else { a.priority.SetSelected("Low") } a.due.SetText(formatDate(t.due)) a.completion.Value = t.completion a.completion.Refresh() }
-
为了支持这段代码,我们还将定义
formatDate
函数,它将我们的日期转换为字符串值。如果可选的date
是nil
,则返回空字符串,否则使用dateFormat
常量进行格式化:const dateFormat = "02 Jan 06 15:04" func formatDate(date *time.Time) string { if date == nil { return "" } return date.Format(dateFormat) }
-
在这段代码到位后,我们可以设置要在显示上呈现的第一个任务。当然,在假设可以显示一个项目之前,我们应该检查是否有任何任务。以下代码在
main
函数中更新:w.SetContent(ui.makeUI()) if len(data.remaining()) > 0 { ui.setTask(data.remaining()[0]) }
-
为了在用户浏览时更新我们的用户界面,我们需要添加的最后一段代码是
List.OnSelected
。这将允许我们在列表被点击时更新显示的详细信息。一旦创建了我们的List
,它被设置为从a.tasks
加载,只需简单地添加以下行:a.tasks.OnSelected = func(id int) { a.setTask(a.visible[id]) }
在所有代码到位后,我们就有一个完整的应用程序,如下面的截图所示:
![图 5.41 – 完整的用户界面
图 5.41 – 完整的用户界面
所示的所有代码都将与当前主题一起工作,这意味着当我们使用标准暗色主题时,我们可以看到相同的内容:
图 5.42 – 标准暗色主题下的任务用户界面
接下来,我们将处理当用户编辑任何数据时如何保存详细信息。
编辑内容
当每个输入小部件被编辑时,我们应该更新数据集。对于大多数输入来说,这是微不足道的,因为我们只需设置 OnChanged
回调,以便在数据更改时得到通知。让我们开始吧:
-
在每个回调中,我们必须确保当前有一个任务被选中(以防所有任务都被删除),然后设置适当的字段。
Title
的回调如下。注意,我们还调用Refresh()
来更新任务列表,因为标题的变化应该反映在列表中:a.title.OnChanged = func(text string) { if a.current == nil { return } a.current.title = text a.tasks.Refresh() // refresh list of titles }
大多数其他回调都很相似,所以它们已被省略在此描述中 – 完整的代码可在本书的 GitHub 仓库中找到。
-
优先级回调更新稍微复杂一些,因为我们正在将字符串表示的选择转换为数字字段。请注意,回调是一个传递给构造函数的函数:
a.priority = widget.NewRadio([]string{"Low", "Mid", "High"}, func(pri string) { if a.current == nil { return } if pri == "Mid" { a.current.priority = midPriority } else if pri == "High" { a.current.priority = highPriority } else { a.current.priority = lowPriority } })
-
最后,我们将查看输入小部件,因为我们应该添加对日期格式的验证。为此,我们将
Validator
回调设置为向用户提供关于输入状态的反馈。首先,我们必须创建一个新的验证器,它可以检查日期格式,它简单地具有Validate(string)
error
函数签名(这意味着它实现了fyne.StringValidator
):func dateValidator(text string) error { _, err := time.Parse(dateFormat, text) return err }
-
在设置了验证器之后,我们只需将其设置为
OnChanged
回调。在这个回调中,我们需要重新解析日期以获取输入的适当日期(如果输入为空,则跳过此步骤):a.due.Validator = dateValidator a.due.OnChanged = func(str string) { if a.current == nil { return } if str == "" { a.current.due = nil } else { date, err := time.Parse(dateFormat, str) if err != nil { a.current.due = &date } } }
这是我们展示和编辑任务所需的所有代码。接下来,我们将学习如何标记任务为完成并保持列表更新。
标记任务为完成
我们接下来要添加的功能是标记任务为完成。让我们开始:
-
因为我们在列表内部,所以需要在
UpdateItem
回调中设置回调为List
,以便能够标记正确的项目为完成:check.OnChanged = func(done bool) { a.visible[i].done = done a.refreshData() }
-
在这里,我们需要使用一个有用的
refreshData()
函数来更新数据列表(通过重新计算剩余的内容),然后要求task
小部件刷新:func (a *taskApp) refreshData() { // hide done a.visible = a.data.remaining() a.tasks.Refresh() }
到目前为止,它功能正常。然而,当点击
Check
文本时,它将任务标记为完成,而不是选择它进行编辑。为了改进这一点,我们可以将文本组件移动到一个单独的Label
小部件中,这将允许鼠标点击通过到列表选择逻辑。 -
要做到这一点,我们将在模板函数中使用
container.NewHBox
返回一个Check
和一个Label
。在应用更新回调中的内容时,我们需要从Container.Objects[]
字段中提取小部件。否则,代码与之前类似。最终的列表实现如下:a.tasks = widget.NewList( func() int { return len(a.visible) }, func() fyne.CanvasObject { return container.NewHBox(widget.NewCheck("", func(bool) {}), widget.NewLabel("TODO Item x")) }, func(i int, c fyne.CanvasObject) { task := a.visible[i] box := c.(*fyne.Container) check := box.Objects[0].(*widget.Check) check.Checked = task.done check.OnChanged = func(done bool) { task.done = done a.refreshData() } label := box.Objects[1].(*widget.Label) label.SetText(task.title) })
最后,我们将在工具栏中实现 add
按钮。
创建一个新任务
在本节中,我们将更新数据代码,以便我们可以添加新的任务。让我们开始吧:
-
首先,我们将创建一个新的
add()
函数,带有task
参数,并将其添加到列表的顶部:func (l *taskList) add(t *task) { l.tasks = append([]*task{t}, l.tasks...) }
-
由于数据函数通常很容易测试,我们将在
data_test.go
中添加另一个单元测试:func TestTaskList_Add(t *testing.T) { list := &taskList{} list.add(&task{title: "First"}) assert.Equal(t, 1, len(list.tasks)) list.add(&task{title: "Next"}) assert.Equal(t, 2, len(list.tasks)) assert.Equal(t, "Next", list.tasks[0].title) }
高度建议对整个用户界面进行单元测试,但这超出了本例的范围——我们将在 第八章,项目结构和最佳实践中回到这个话题。
-
要完成添加任务的功能,我们必须在
NewToolbarAction()
函数中填写回调,该函数是我们第一次设置用户界面时调用的。此代码简单地创建一个新的task
,带有标题New task
,将其添加到数据中,然后重用我们为隐藏已完成任务创建的相同的refreshData()
函数:widget.NewToolbarAction(theme.ContentAddIcon(), func() { task := &task{title: "New task"} a.data.add(task) a.refreshData() }),
上述代码结束了我们的任务应用示例。我们还可以添加更多功能,但我们将将其留给你作为练习来完成。
概述
在本章中,我们学习了 Fyne 小部件 API 的设计,并查看了一系列标准小部件。我们看到了容器和集合小部件如何帮助我们组织和管理工作界面组件。对话框包也被探索,以展示我们如何将其与我们的应用程序一起使用,以实现常见活动的标准组件。
我们还看到了如何在工具包中实现主题,以及它们如何应用于所有小部件组件。本章演示了标准主题的浅色和深色变体,并展示了应用程序可以为自定义外观和感觉提供自己的主题。
通过构建一个任务跟踪应用程序,我们了解了内置小部件的使用情况,如何将它们布局在各种容器中,以及如何跟踪用户交互来管理一些内存中的数据。在下一章中,我们将探讨数据绑定和存储 API,这些可以帮助我们管理更复杂的数据需求。
第六章:第六章:数据绑定和存储
在上一章中,我们了解到小部件可以通过应用程序代码手动控制。当我们开始查看更复杂的应用程序时,开发者通常希望显示或操作动态数据源。Fyne 工具包提供了数据和存储 API,可以自动化很多这项工作。
在本章中,我们将探讨在 Fyne 工具包内处理数据的方式。我们将涵盖以下主题:
-
将数据绑定到小部件
-
适配数据类型以显示
-
绑定复杂的数据类型
-
使用 Preferences API 存储数据
到本章结束时,我们将知道如何利用数据绑定和存储 API 创建一个帮助跟踪水消耗的应用程序。它将在本地设备上存储信息,并使用 API 探索如何最小化管理数据所需的编码。
技术要求
本章的要求与第三章,“Windows、Canvas 和绘图”相同,即您必须安装 Fyne 工具包并确保 Go 和 C 编译器正常工作。更多信息,请参阅上一章。
本章的完整源代码可以在github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne/tree/master/Chapter06
找到。
将数据绑定到小部件
当我们在第五章,“小部件库和主题”中探讨小部件时,我们看到了如何收集和展示信息。我们查看的每个小部件都是手动配置的,访问用户输入的数据(例如使用 Entry
小部件)需要查询小部件的状态的代码。fyne.io/fyne/data/binding
包提供了支持自动将小部件连接到数据源以更高效地处理这些操作的功能。
在以下章节中,我们将探讨数据绑定是什么,为什么它如此有用,以及它是如何在 Fyne API 中设计的。
理解数据绑定
数据绑定有许多不同的方法,定义可能因您所使用的工具包而异。一般来说,数据绑定允许图形界面组件的显示由独立的数据源控制。此外,它确保图形表示始终与更改保持最新,并且用户界面中的用户操作更改会同步回原始数据。
您可以在docs.microsoft.com/en-us/dotnet/desktop/wpf/data/data-binding-overview
中看到 .NET 的实现方法。Android 也提供了类似的功能,相关文档可在developer.android.com/topic/libraries/data-binding
找到。
尽管每个工具包都采取了不同的方法,但任何成功的数据绑定的基本原理都是基于独立数据对象的状态自动更新图形输出。这被称为 单向 绑定,通常称为 单向数据流。当信息来自外部源时,这对于数据显示是足够的。然而,如果你的应用程序将修改数据,则这并不足够。完整的数据绑定(双向 或 双向)确保在保持显示数据更新的同时,如果用户通过某些交互式小部件更改了表示,它也会更新源数据。
在 Fyne 工具包中完成的所有数据绑定都是双向的,这意味着每个建立的连接都可以读取或写入其连接到的数据。仅用于显示目的的小部件将不会使用写入数据的能力,但输入小部件,如输入框或滑块,会将数据更改推送到连接的绑定。每个数据绑定都有一个底层的数据源。我们将在下一节中查看支持的数据类型。
支持的数据类型
就像主要的 Go 语言一样,Fyne 数据 API 中的所有值都是强类型的——这意味着数据绑定具有特定的值类型。每个值都匹配一个原始 Go 类型,确保编译器可以检查这些值是否以正确的方式使用。
在撰写本文时,Fyne 数据绑定支持以下类型:
-
Bool
: 布尔值可以是true
或false
。此类型使用bool
作为其内部存储。 -
Float
: 浮点数值,这使用float64
类型进行内部存储。 -
Int
: 带有正数和负数的整数值,使用int
类型存储。 -
Rune
: 单个 Unicode 字符的表示,由rune
类型支持。 -
String
: Gostring
类型的可绑定版本。 -
List
: 与slice
或array
类似的映射,可以包含一个由int
偏移量索引的单个类型的集合。 -
Map
:map
原始类型的可绑定版本,可以按string
键索引存储多种类型。 -
Struct
: 与map
类似的数据绑定,其中键代表开发者定义的struct
类型的导出元素。
你使用的值类型可能由源数据决定,或者(如果你没有绑定到现有的数据源)你使用的输出小部件。我们将在本节的后面部分探讨小部件连接,但了解我们可以在需要时转换类型是有用的。我们将在 适应显示数据类型 部分后面探讨这一点。
当你拥有更复杂的数据,例如列表或结构体时,你仍然可以使用数据绑定 API,但需要更高级的类型,这将在后面的 绑定复杂数据类型 部分讨论。
现在我们已经了解了可用的数据类型,让我们看看如何通过绑定来读取和写入数据。
创建、读取和写入绑定数据
绑定 API 提供的基本数据类型(Bool
、Float
、Int
、Rune
和 String
)都提供了两个构造函数:一个用于从 Go 的零值创建新变量(使用 New...()
名称),另一个用于绑定到现有变量(命名为 Bind...()
)。它还提供了 Get()
和 Set()
函数来管理数据访问。我们将使用 Float
类型作为示例,在下一部分详细探讨这些内容。
NewFloat() Float
使用 New...
构造函数创建数据绑定将创建一个具有零值标准的新数据项。返回的对象实现了 DataItem
接口,从而实现了绑定。我们将在本章后面的 监听变化 部分更详细地探讨这一点。
BindFloat(*float64) Float
Bind...
构造函数使用原始值的指针创建一个可绑定数据项。使用此函数,我们可以设置非零的默认值。此外,原始变量仍可用于在不支持数据绑定的地方获取和设置数据。在以下代码中,我们正在创建一个新的数据绑定到默认值为 0.5
的浮点值:
f := 0.5
data := binding.BindFloat(&f)
返回的 data
变量是一个新的 Float
类型数据绑定,初始化为 0.5
。如果此绑定被更改,它将新值写回 f
变量。
保持与源变量的同步
如果你正在绑定到数据绑定构造函数外部存在的变量,例如在之前的示例中,它也会导致绑定中的更改更新原始变量。然而,如果你直接更新变量,则必须通知数据绑定。调用 data.Reload()
通知数据绑定值已更改,以便它可以传播更改事件。
Get() (float64, error)
要获取数据绑定的当前状态,只需调用 Get()
。它将返回 Go 的原始数据类型之一的内容。对于 Float
绑定,这将是一个 float64
。如果你访问此值时发生错误,则第二个参数将返回错误。虽然在这个阶段不太可能发生,但你将看到如何组合绑定来创建更复杂的情况。此调用不会产生任何额外操作。
Set(float64) error
要更新绑定的值,你必须调用其 Set(val)
函数,同时传递新值(与 Get()
返回值一样,这是一个原始数据类型)。由于值已更改(如果新值与当前状态不同),数据绑定将通知所有已注册的代码。如果你设置值时发生错误,则不会触发绑定通知,并将返回错误。
接下来,我们将探讨如何请求对正在发生的变化的更新。
监听变化
数据绑定中的一个关键概念是,当值发生变化时,任何绑定到它的项目将自动更新。为此,我们需要一个机制来通知我们变化。在我们学习小部件如何自动执行此操作之前,我们将探讨此实现的实现,以便我们可以创建自己的代码,通过数据绑定保持最新。
使用DataItem
接口添加监听器
任何数据绑定的核心功能是DataItem
接口。前面显示的每个绑定数据类型(在支持的数据类型部分)也实现了此接口。DataItem
的定义允许我们添加和移除监听器。每当数据项发生变化时,这些监听器都会收到通知:
type DataItem interface {
AddListener(DataListener)
RemoveListener(DataListener)
}
定义显示了如何向任何数据项添加和移除监听器;这就是小部件如何得知数据何时发生变化。
当前值
当向DataItem
添加监听器时,它将立即调用当前存储的值。这使得任何使用绑定的小部件或程序片段可以正确初始化,而无需额外的代码。
在本节中,我们了解到监听器是DataListener
类型。我们将在下一节中更详细地探讨此类型。
创建一个DataListener
每个DataItem
的监听者都必须实现DataListener
接口。通过构造一个符合此定义的类型,我们可以向我们所依赖的数据项中添加监听器,并在之后移除。它被定义为如下:
type DataListener interface {
DataChanged()
}
如你所见,接口定义了一个单一的功能,DataChanged()
。在大多数情况下,使用此代码的代码将只想直接提供该函数。对于这些情况,我们可以使用一个有用的构造函数,它接受一个简单的函数并返回一个DataListener
的实例。生成的监听器可以用作AddListener
和RemoveListener
函数的参数。当数据更新时,它将调用指定的函数:
func NewDataListener(fn func()) DataListener
利用这些知识,我们可以创建一个非常简单的数据绑定并监视其值的变化。我们所需做的只是使用数据绑定构造函数之一,并调用AddListener
,传递我们创建的监听器:
func main() {
val := binding.NewString()
callback := binding.NewDataListener(func() {
str, _ := val.Get()
fmt.Println("String changed to:", str)
})
val.AddListener(callback)
}
通过运行前面的代码,你会看到回调立即被触发,使用当前的字符串值(在这种情况下,空字符串,""
)。
根据你的计算机速度,你可能在main()
函数的末尾需要设置一个时间延迟,因为应用程序可能在数据绑定处理之前退出(例如,time.Sleep(time.Millisecond*100)
):
Chapter06$ go run listen.go
String changed to:
如前所述的代码所示,创建数据绑定并在数据更改时接收通知非常简单。我们也可以通过调用val.Set("new data")
来触发更改,回调将再次触发。
尽管前面的代码很有用,但在 Fyne 中数据绑定的主要用途是连接小部件到数据,而无需编写将复制信息并监视更改的代码。接下来,我们将学习标准小部件如何与数据绑定一起操作。
使用标准小部件的数据
正如我们在前面的部分中所看到的,数据绑定 API 允许我们创建在内容更改时提供通知的值。正如你所期望的,Fyne 工具包中提供的 widgets 理解绑定,并且可以连接到它们以显示或更新数据。让我们探索如何设置它:
-
我们首先在
package main
中打开一个新文件,并创建一个makeUI()
函数,就像我们之前做的那样。这次,我们将通过声明一个新的浮点数作为我们的数据绑定来开始这个方法。使用NewFloat()
将创建一个新的绑定,默认值为0.0
:func makeUI() fyne.CanvasObject { f := binding.NewFloat() ... }
-
接下来,我们将创建一个绑定到这个数据源的
ProgressBar
小部件。这个进度条简单地显示一个值,它是我们刚刚创建的Float
数据类型,我们必须使用NewProgressBarWithData()
构造函数:prog := widget.NewProgressBarWithData(f)
-
为了操作数据绑定,我们还想包括一个
Slider
。正如你在 小部件库和主题 中所看到的,这个小部件将显示一个值,使用其当前位置,并允许通过滑动来更改值。为了建立这种连接,我们将使用NewSliderWithData()
,它接受额外的参数;即可以发送到数据绑定的允许的最小和最大值。然后我们必须设置Slider.Step
值(滑块上每个步骤之间的间隔)为0.01
,以便我们可以引起精细粒度的数据变化:slide := widget.NewSliderWithData(0, 1, f) slide.Step = 0.01
-
除了前面的一对一和双向绑定之外,我们还可以使用一种只写的一对一绑定的版本。我们不需要以与这里连接最后两个小部件相同的方式将数据连接到小部件(因为没有必要为数据更改设置监听器)。相反,我们可以简单地创建一个
Button
,当被点击时将写入数据绑定。在这里,我们将使用熟悉的NewButton()
构造函数,并传递一个点击处理程序,该处理程序将值设置为0.5
:btn := widget.NewButton("Set to 0.5", func() { _ = f.Set(0.5) })
-
在我们的
makeUI
函数中的最后一行将返回一个容器,该容器将这些元素打包。在这种情况下,我们将使用container.NewVBox
,它将每个元素对齐在彼此的顶部:return container.NewVBox(prog, slide, btn)
-
为了完成这个示例,我们只需要创建一个常规的
main()
函数,该函数创建一个新的App
实例并打开一个标题为Widget Binding
的窗口。我们的makeUI
函数中的内容将被添加,并且相应的窗口将出现。请参考以下代码:func main() { a := app.New() w := a.NewWindow("Widget Binding") w.SetContent(makeUI()) w.ShowAndRun() }
-
你现在可以运行这个示例来查看这些小部件在所有绑定到同一数据源时的行为:
Chapter06$ go run widget.go
-
当应用程序出现时,滑块和进度条将位于零位置。通过拖动滑块,你将更新滑块和进度条所绑定到的浮点数。结果,你会注意到进度条在移动。如果你按下设置为 0.5按钮,它将值设置为0.5,其他小部件也将更新到中间位置,如以下截图所示:
![Figure 6.1 - 数据绑定设置为 0.5]
图 6.1 - 数据绑定设置为 0.5
在本节中,我们看到了数据绑定在处理动态数据时的好处,以及强类型 API 的约束。接下来,我们将探讨如何在不同类型之间进行转换,以便数据能够适应更广泛的各种小部件的使用。
适应显示数据类型
在上一节中我们讨论的示例中,我们了解到可以将相同的值绑定到不同的小部件上,因为Slider
和ProgressBar
都期望它们的数据为Float
类型。然而,这些数据类型并不总是完美匹配。有时,我们需要进行转换以连接到我们希望使用的小部件。在本节中,我们将探讨如何进行转换,从如何在先前的示例中包含一个标签开始。
将类型格式化为字符串
在许多应用程序中,使用Label
或其他基于字符串的显示来包含其他格式的信息是很常见的,例如int
或float64
。使用数据绑定,这并无不同,因此binding
包提供了可以简化这种适应的转换函数。
为了延续先前的示例,我们可以包含一个显示float64
值的Label
,但要做到这一点,我们需要一个String
绑定而不是Float
。为了获得这个,我们可以使用绑定包中的FloatToString
函数。像所有绑定转换函数一样,它接受一个参数,即源绑定,并返回一个正确类型的新绑定:
strBind := binding.FloatToString(f)
label := widget.NewLabelWithData(strBind)
使用我们刚刚获得的字符串绑定,我们可以创建一个Label
,如前一个代码片段所示。这种方法将使用默认格式渲染值。对于Float
绑定,这将使用"%f"
格式字符串,在用法上类似于fmt.Printf
。如果我们想指定自己的格式,可能包括一些前导文本,我们可以使用ToStringWithFormat
表示法来做到这一点:
strBind := binding.FloatToStringWithFormat(f,
"Value is: %0.2f")
label := widget.NewLabelWithData(strBind)
当使用此更新后的代码时,输出标签将包含类似于Value
is:
0.50
的文本,这对你应用的用户来说可能更有意义。在指定格式时,请确保包含源类型的适当格式字符串,例如Float
类型的%f
或Int
类型的%d
。
虽然显示非字符串类型的值是迄今为止最常见的转换需求,但我们可能需要访问存储在字符串中的某些数字类型的变量。我们将在下一节中探讨这一点。
从字符串类型解析值
在某些情况下,你正在处理的数据可能不是你希望用于小部件的所需格式。当手动管理转换时,你可以将其作为你自己的代码的一部分来完成——简单地提取数据,转换它,然后将解析后的信息提供给小部件。然而,在使用数据绑定时,我们希望保持与源数据的直接连接,这意味着转换需要在数据绑定的链中发生。
就像在上一节中一样,也有一些方便的方法可以帮助你完成这项工作;它们的名字通常包含短语 StringTo
。让我们通过一个例子来操作,这个例子中有一个包含 int
数字的 string
值。我们不希望在转换中包含手动代码,因此我们需要在链中包含一个转换,如下所示:
val := "5"
strBind := binding.BindString(&val)
intBind := binding.StringToInt(strBind)
因此,我们有了 binding.Int
,它正在读取底层的 string
数据源,现在可以在需要 Int
作为数据源的小部件中使用。
这是一个简单的例子。通常,当数字以字符串形式存储时,是因为该值包含一些非数字元素,例如一个包含尾随 %
符号的百分比。当字符串中有额外的格式化时,我们仍然可以通过使用转换的 StringToIntWithFormat
版本来达到相同的结果,如下所示:
val := "5%"
strBind := binding.BindString(&val)
intBind := binding.StringToIntWithFormat(strBind, "%d%%")
注意,在格式字符串中,我们需要使用两个百分号来捕获百分号符号(%%
)。这是由于格式字符串的工作方式,但除此之外,这个例子相当简单。调用 intBind.Get()
将返回 5
(没有错误),如果 strBind
的值改为 25%
,那么 IntBind.Get()
将返回 25
,正如预期的那样。
在本节中,我们成功地使用了数据绑定转换函数将源数据转换为另一种类型的数据,以便在用户界面中使用。接下来,我们将学习如何确保输出(对于双向绑定)的变化传播到原始数据。
通过转换传播变化
在上一节提供的示例中,我们查看复杂的数据绑定链,以便将源数据转换为显示目的。这使得在不需要编写复杂转换代码的情况下使用各种与数据绑定连接的小部件变得容易得多。然而,我们需要确保呈现的数据中的变化被推回到数据源。
好消息是,在将类型格式化为字符串和从字符串类型解析值部分中展示的转换是全双工数据绑定。这意味着我们不需要添加任何额外的代码来传播数据的变化。如果建立了IntToString
连接,那么源Int
的变化不仅会在输出String
中产生新的值,而且对String
调用Set()
将导致解析后的整数被推回到原始的Int
绑定。当然,这取决于字符串是否正确格式化,设置无效值将不会传播变化,而是返回错误。
本节中我们探索的类型被标准小部件用于显示和管理动态数据。然而,在许多应用程序中,我们拥有更复杂的数据。在下一节中,我们将探讨如何将此类数据绑定到我们的应用程序中。
绑定复杂的数据类型
我们在数据绑定探索中使用的类型到目前为止仅限于 Go 原始类型的映射。这意味着它们代表具有单个元素的简单变量。对于许多应用程序,显示更复杂的数据,如列表、映射或自定义结构体将是必要的。在本节中,我们将探讨如何实现这一点,从DataList
类型开始。
使用数据列表
无论您是想使用数据绑定将数据呈现给widget.List
或widget.RadioGroup
,还是您正在使用将传递给您的自定义小部件的绑定来建模数据,数据列表的概念都将很重要。数据绑定 API 将DataList
定义为一个提供额外Length
和GetItem(int)
函数的绑定,如下所示:
type DataList interface {
DataItem
GetItem(int) DataItem
Length() int
}
这个通用定义意味着列表可以封装一个数据类型——GetItem()
返回的DataItem
实例可以是String
、Int
,甚至是另一个DataList
。你可以使用之前的接口实现DataList
,或者可以使用 Fyne 提供的基于类型的列表之一。Length
方法将始终返回列表中的项目数量,而GetItem
可以用来访问指定索引处的数据绑定(因为列表是有序的)。
列表长度的变化(通过append
、insert
或remove
)将在注册在DataList
上的任何监听器上触发回调。如果列表中某个项目的值发生变化,它将触发单个项目的更改监听器,而不是包含该列表。正因为如此,当监听器调用我们的代码时,我们可以巧妙地最小化 UI 变化的影响。在接下来的章节中,我们将使用StringList
来管理在List
小部件中展示的多个项目时,我们将看到这个过程是如何工作的。
创建一个列表
尽管列表可以包含许多不同类型的项,但它们通常都会包含相同类型的所有项,例如 String
或 Float
。为此,有一些有用的构造函数,例如 NewFloatList
和 BindStringList
。您可以使用 New...List
方法创建一个没有任何内容的空列表,或者您可以使用 Bind...List
函数将绑定到 Go 代码中的现有切片。例如,一个空字符串列表可能看起来如下所示:
strings := binding.NewStringList()
fmt.Println("String list length:", strings.Length())
strings.Append("astring")
fmt.Println("String list length:", strings.Length())
val, _ := strings.GetValue(0)
fmt.Println("String at 0:", val)
运行此代码将显示以下输出:
String list length: 0
String list length: 1
String at 0: astring
在这里,您可以看到标准列表提供了额外的 GetValue
和 SetValue
方法,以便像标准单一绑定一样访问值。还有匹配的 Get
和 Set
方法,允许您更改整个列表。这使我们能够使用原始类型在已知列表类型时访问和操作数据。我们还可以使用数据绑定的存储切片以相同的方式进行操作:
src := []string{"one"}
strings := binding.BindStringList(&src)
fmt.Println("String list length:", strings.Length())
strings.Append("astring")
fmt.Println("String list length:", strings.Length())
val, _ := strings.GetValue(0)
fmt.Println("String at 0:", val)
运行此代码将产生以下输出:
String list length: 1
String list length: 2
String at 0: one
现在我们已经创建了一个列表绑定,我们可以使用它来在使用 DataList
绑定的小部件中显示数据,例如 widget.List
。
显示列表
我们通常使用数据列表的最常见方式是通过 List
小部件。当使用数据绑定来管理列表小部件时,它将自动添加与数据长度相匹配的行。当长度变化时,它会增长或缩小。此外,列表中的数据项可以绑定到列表中的项,这意味着如果数据值被更新,列表将自动更新。请参考以下代码:
l := widget.NewListWithData(strings,
func() fyne.CanvasObject {
return widget.NewLabel("placeholder")
},
func(item binding.DataItem, obj fyne.CanvasObject) {
text := obj.(*widget.Label)
text.Bind(item.(binding.String))
})
如您所见,我们使用 NewListWithData
构造函数在 List
小部件中设置数据绑定。第一个参数 DataList
替换了在常规 List
构造函数中使用的 Length
函数。第二个参数,设置模板项的函数保持不变。最后一个参数的回调用于更新项,与常规列表类似,但第一个参数现在是一个 DataItem
而不是 ListItemID
。我们可以通过调用 Bind()
并传递已转换为 binding.String
强类型的 DataItem
来保持 Label
的 Text
值最新。以这种方式应用绑定将实现标准 API,因此我们不必担心缓存如何影响此功能。
管理不同类型的列表是可能的,只要你在每次访问时小心地将 DataItem
转换为正确的绑定类型。这超出了本章的范围。在下一节中,你将了解如何将这种技术应用于数据映射,它通常包含许多不同类型的值。
使用数据映射
在数据绑定的上下文中,数据映射与 Go 的 map
类型非常相似,其中 string
键映射到 DataItem
类型;例如,map[string]binding.DataItem
。DataMap
的定义与 DataList
类似,但它使用 string
键来标识子元素,而不是使用 int
ID。它不使用返回列表长度的 Length()
方法,而是需要一个 Keys()
函数,该函数返回一个包含数据集中所有键的字符串切片。要找到数据中的项目数量,你可以简单地调用 len(DataMap.Keys())
:
type DataMap interface {
DataItem
GetItem(string) (DataItem, error)
Keys() []string
}
当向 DataMap
添加或从其中移除项目时,其监听器将被触发。如果某个项目的值发生变化,它将触发单个项目的更改监听器,而不是所有 DataMap 监听器(如前一小节中提到的列表中的项目)。由于映射通常包含许多不同类型的数据,因此使用标准 API 创建映射与创建列表略有不同,我们现在将看到这一点。当然,任何实现了 DataMap
接口类型的都可以用于映射数据绑定,但通常使用提供的实现会更简单。
在接下来的几节中,我们将通过创建一个 DataMap
并设置其值来探索如何使用它,然后再描述它们如何用于显示。
创建数据映射
与 DataList
不同,DataMap
在 Fyne 中没有特定类型的实现。由于映射通常包含许多不同类型的数据,因此只有一个映射实现将 string
键映射到 interface{}
值。这可以通过在内存中创建一个新的映射,或者通过使用 map[string]interface{}
类型签名绑定到现有的 map
来创建。首先,让我们看看如何创建一个新的映射;以下代码使用 binding.NewUntypedMap()
构造函数创建一个新的映射并添加一些值:
values := binding.NewUntypedMap()
fmt.Println("Map size:", len(values.Keys()))
"_ = values.SetValue("akey", 5)
fmt.Println("Map size:", len(values.Keys()))
val, _ := values.GetValue("akey")
fmt.Println("Value at akey:", val)
执行前面的代码将产生以下输出:
Map size: 0
Map size: 1
Value at akey: 5
我们可以再次执行相同的操作,但这次通过使用现有的数据源,利用 binding.BindUntypedMap()
来实现,如下所示:
src := map[string]interface{}{"akey": "before"}
values := binding.BindUntypedMap(&src)
fmt.Println("Map size:", len(values.Keys()))
_ = values.SetValue("newkey", 5)
fmt.Println("Map size:", len(values.Keys()))
val, _ := values.GetValue("akey")
fmt.Println("Value at akey:", val)
执行前面的代码将产生以下输出:
Map size: 1
Map size: 2
Value at akey: before
之前的示例没有检查数据类型,但如果你确定一个键的类型,你可以执行类似于列表中的类型断言。例如 values.GetItem("key").(Float)
会返回一个与存储在指定键处的 float64 类型的 float 数据绑定。现在让我们看看如何使用它来显示。
显示映射
在编写本文时,没有内置的小部件使用 DataMap
绑定类型。它主要的作用是允许你的应用程序在单个绑定类型中维护多个数据绑定。
预计 Form
和 Table
小部件将在不久的将来添加对数据绑定的支持。你可以在开发者网站上查看最新的 API 更新,网址为 developer.fyne.io/api/widget.html
。
在我们离开本节之前,我们将探索一个最后的技巧——将自定义 struct
映射到 DataMap
。
将结构体映射到数据绑定
在填充 DataMap
中的数据时,常见的方法之一是使用 struct
类型。在这种情况下,有一组字段,其中名称映射到值(正如我们在本书中已经多次看到的)。正如你所看到的,这个定义与我们在上一节中看到的映射非常匹配。为了节省大量的手动代码,数据绑定 API 提供了一种简单的方法,可以从现有的结构变量自动创建 DataMap
。
就像任何其他的 binding.Bind...
方法一样,我们传递一个变量的指针,它返回绑定后的数据类型。在这种情况下,Fyne 代码将使用每个 struct
字段的名称作为映射的键,值将被设置为该 struct
的变量。为了能够在一个 struct
中绑定一个字段,它必须是一个导出字段(名称必须以大写字母开头)。以下代码演示了这一原则:
type person struct {
Name string
Age int
}
src := person{Name: "John Doe", Age: 21}
values := binding.BindStruct(&src)
fmt.Println("Map size:", len(values.Keys()))
name, _ := values.GetValue("Name")
fmt.Println("Value for Name:", name)
age, _ := values.GetValue("Age")
fmt.Println("Value for Age:", age)
运行前面的代码将产生以下输出:
Map size: 2
Value for name: John Doe
Value for age: 21
正如你所看到的,键和值都按预期工作,但有一个好处,即小部件可以观察对数据的更改并保持其显示更新。
现在我们已经探讨了数据绑定如何轻松处理动态数据的显示方式,我们将看看如何使用 Preferences API 存储用户生成数据。
使用 Preferences API 存储数据
对于应用程序来说,存储大量信息是一个常见的需求,例如用户配置选项、当前输入字段的内容以及打开文件的历史记录。使用文件来存储这些信息将需要额外的代码来格式化信息以便存储;使用数据库将需要额外的服务器或应用程序的依赖项。为了帮助解决这个问题,Fyne 提供了一个类似于 iOS 和 Android 开发者使用的 Preferences API。
存储为 Fyne 预设的数据可以通过任何使用特定字符串标识符(称为 键)的应用程序代码访问。每个存储的 值 都有特定的类型,因此开发者不需要处理任何转换或类型检查。任何时间数据发生变化,它都会被保存以供将来使用。
在本节中,我们将学习如何使用 Preferences API 管理数据,并了解我们如何避免手动管理用户数据。
获取和设置值
每个支持的类型(见下一节)都提供了函数,以便我们可以获取和设置该类型的数据。我们将通过使用字符串来探索这一点。要使用字符串读取或写入数据,我们可以使用 String()
函数;要写入一个值,我们可以使用 SetString()
。
要访问首选项,我们可以使用App.Preferences()
。如果您无法访问加载应用程序的App
实例,可以使用fyne.CurrentApp().Preferences()
代替,它将返回相同的引用。然而,还有一个额外要求——每个应用程序必须声明一个唯一的标识符,用于存储。为此,我们必须将app.New()
构造函数更改为app.NewWithID()
,传递一个合适的唯一标识符。通常,唯一 ID 将是反向 DNS 格式,并且必须与您在分发时使用的标识符匹配(有关更多详细信息,请参阅第九章,打包资源和准备发布)。例如,您可以使用com.example.myapp
进行测试目的。
以下代码片段设置了一个具有(相对)唯一标识符的应用程序并访问标准首选项:
func main() {
a := app.NewWithID("com.example.preferences")
key := "demokey"
a.Preferences().SetString(key, "somevalue")
val := a.Preferences().String(key)
fmt.Println("Value is:", val)
}
我们可以将前面的代码插入到常规的main()
函数中(对于更完整的列表,请参阅实现水消耗跟踪器部分)。请注意,我们使用单个string
值作为键——这有助于我们避免在每次访问时输入键时出错。运行它将产生以下输出:
Value is: somevalue
这个快速示例演示了字符串访问,但我们还可以使用其他类型。我们将在下一节中查看其中的一些。
支持的类型
使用所展示的字符串方法,可以存储任何类型的数据,但 API 旨在帮助我们避免以这种方式格式化和解析数据的复杂性。因此,Fyne 首选项代码支持不同类型。在撰写本文时,支持的类型如下:
-
bool
:存储简单的布尔值(true
或false
)。 -
float
:需要浮点数值的数字可以使用float
存储。 -
int
:对于整数,使用int
函数。 -
string
:正如我们之前所使用的,简单的string
值。
这些类型遵循我们在前面的代码中看到的相同命名约定。例如,您可以使用SetInt()
设置整数或使用Bool()
获取布尔值。
通过使用 Go 语义,如果没有之前存储任何项,返回的值将具有零值。使用回退值可以设置不同的默认值。
回退值
对于那些属性默认值不应是 Go 定义的标准零值(0)的情况,每个Get...
函数都有一个WithFallback
版本;例如,StringWithFallback()
。
如果我们将前面的示例中的代码更改为仅使用获取和回退方法,我们可以看到它们是如何工作的:
key := "anotherkey"
val := a.Preferences().String(key)
fmt.Println("Value is:", val)
val = a.Preferences().StringWithFallback(key, "missing")
fmt.Println("Value is:", val)
运行此版本的代码将产生以下输出:
Value is:
Value is: missing
使用这些方法,我们可以处理具有合理默认值的数据,并保存应用程序未来运行的更改。有时,我们可能需要删除旧数据;我们也可以做到这一点。
删除旧数据
存储用户数据很重要,但能够在请求时删除它也同样重要。为此,首选项 API 提供了一个名为RemoveValue
的最终方法,它将执行此操作。
通过将以下代码添加到我们之前的示例末尾,设置的值将被清除,这意味着在下次运行时,如果应用再次启动,你将看到默认值:
fmt.Println("Removing")
a.Preferences().RemoveValue(key)
val = a.Preferences().String(key)
fmt.Println("Value is:", val)
之前的代码在完成时也会打印出值,确保项目已被从首选项中移除。将本节中的所有代码一起运行将产生以下输出:
Chapter06$ go run preferences.go
Value is:
Value is: missing
Value is: somevalue
Removing
Value is:
通过这样,我们已经看到了如何轻松地存储和访问在应用中使用的数据元素。然而,当我们将它与我们本章开头看到的绑定数据 API 结合使用时,首选项 API 变得更加强大。
首选项绑定
在本章前面我们关注的binding
包中,我们可以创建连接到首选项存储而不是常规变量的数据绑定。这为我们提供了巨大的好处,即每次值设置触发时,它都会被存储,当应用再次启动时,将加载之前的值。
要访问此功能,我们可以使用以BindPreference
开头命名的函数,例如BindPreferenceString()
。本节前面列出了支持的首选项 API 的每种类型都有一个函数。这些方法都接受一个字符串参数,这是我们之前代码片段中使用的键字符串。希望继续使用首选项 API 的代码可以像以前一样继续使用,但使用这些数据绑定确保新值直接推送到绑定连接的小部件。从首选项绑定返回的绑定使用与其他数据绑定 API 相同的类型,因此你可以通过Get()
方法获取首选项项的string
值,正如你所期望的那样:
data := binding.BindPreferenceString("demokey", a.Preferences())
val, _ = data.Get()
fmt.Println("Bound value:", val)
之前的代码将通过数据绑定框架访问相同的首选项值,这使得保持小部件与用户首选项同步变得容易。输出将如下所示:
Chapter06$ go run preferences.go
Bound value: somevalue
这些绑定也可以像之前的定义一样链式连接,这意味着你可以通过以下方式获取一个整数首选项值的String
绑定:
binding.IntToString(binding.BindPreferenceInt("mykey", p))
也可能存在多个小部件,连接到多个数据绑定,所有这些小部件都读取和写入相同的首选项值。如果你使用相同的键为多个绑定创建首选项,那么当值发生变化时,它们都将保持最新。我们将在下面的示例中看到这一点。
通过这样,我们已经探讨了数据绑定和偏好 API,以及它们如何单独或共同大大减少管理应用数据所需的代码量。让我们利用这些知识来实现一个示例应用,帮助我们追踪日常的水消耗。
实现水消耗追踪器
在本章中我们探讨的 API 对于大多数应用都有帮助。为了了解我们如何将偏好存储添加到简单的应用中,我们将探讨另一个示例项目。这次,我们将创建一个可以追踪一周内水消耗的追踪器。
构建用户界面
在我们开始使用数据绑定 API 之前,我们将构建基本用户界面。目标是把今天的总数以大字体显示在窗口的顶部,日期显示在下面。我们将接着添加用于向当前总数添加水的控件。在这之下,我们将添加另一个部分,显示当前周的数据。让我们开始吧:
-
我们将像往常一样,首先定义一个
makeUI
函数,该函数构建用户界面。首先,我们将定义一个用于显示总数的标签,将其字体设置为42
点,居中对齐,并使用theme
主色调:func makeUI() fyne.CanvasObject { label := canvas.NewText("0ml", theme.PrimaryColor()) label.TextSize = 42 label.Alignment = fyne.TextAlignCenter
-
现在,我们需要创建另一个用于显示日期的常规标签:
date := widget.NewLabel("Mon 9 Nov 2020") date.Alignment = fyne.TextAlignCenter
-
接下来的几个元素添加了控制功能,这些功能支持向当前的水消耗值添加数值。这可以是一个简单地添加特定数字(例如,250 ml)的按钮,但为了更灵活,我们将允许用户指定一个数值。为此,我们将创建一个预先填充为
250
的Entry
字段。然后,我们将在其后添加一个辅助的ml
标签,并定义一个新的按钮,标签为Add
,稍后将对这个按钮进行操作:amount := widget.NewEntry() amount.SetText("250") input := container.NewBorder(nil, nil, nil, widget.NewLabel("ml"), amount) add := widget.NewButton("Add", func() {})
-
在定义历史布局之前,我们定义了一个有用的
historyLabel
函数。这个函数将创建一个新的标签,简单地包含0ml
并将其右对齐。这里的数据将稍后添加。func historyLabel() fyne.CanvasObject { num := widget.NewLabel("0ml") num.Alignment = fyne.TextAlignTrailing return num }
-
我们将要添加的最后内容元素是历史信息。在这种情况下,我们可以使用一个两列的网格容器来构建这个。在左侧,我们将显示一周中的某一天,而在右侧,我们将显示一个用于值的标签:
history := container.NewGridWithColumns(2, widget.NewLabel("Monday"), historyLabel(), widget.NewLabel("Tuesday"), historyLabel(), widget.NewLabel("Wednesday"), historyLabel(), widget.NewLabel("Thursday"), historyLabel(), widget.NewLabel("Friday"), historyLabel(), widget.NewLabel("Saturday"), historyLabel(), widget.NewLabel("Sunday"), historyLabel(), )
-
为了从这个函数返回界面构建的结果,我们可以创建一个新的垂直框。在这个框内,我们必须堆叠总数
label
、date
、一个对齐input
和add
按钮的水平网格,以及最后包含历史内容的Card
小部件,以及一个标题。return container.NewVBox(label, date, container.NewGridWithColumns(2, input, add), widget.NewCard("History", "Totals this week", history)) }
-
为了使这个示例运行,我们必须创建典型的
main()
函数。这次,我们希望它创建一个标题为Water Tracker
的窗口,并将makeUI()
返回的内容显示出来:func main() { a := app.New() w := a.NewWindow("Water Tracker") w.SetContent(makeUI()) w.ShowAndRun() }
-
我们现在可以从命令行以通常的方式运行这个示例:
Chapter06/example$ go run main.go
运行前面的命令应该会显示以下界面(当使用浅色主题时):
图 6.2 – 空的用户界面
这个界面看起来很合适,但它目前还没有做任何事情。在下一节中,我们将通过将total
标签绑定到一个可以使用添加按钮增加的值来开始创建一些功能。
将数据绑定到 UI
为了开始使用户界面具有功能,我们将使用数据绑定,以便应用程序的标题部分管理一个代表一天内消耗了多少水的单个整数值。按照以下步骤操作:
-
首先,我们需要声明一个新的绑定变量,其类型为
binding.Int
。我们必须在makeUI
函数的开始处添加以下行:total := binding.NewInt()
-
接下来,我们将添加按钮点击处理的实现。为了执行数字增加,我们必须用以下函数替换旧的
func() {}
函数:func() { inc, err := strconv.Atoi(amount.Text) if err != nil { log.Println("Failed to parse integer:" + amount.Text) return } current, _ = total.Get() _ = total.Set(current + inc) }
这将通过调用
Get()
来解析amount.
Text
字段中的整数并将其添加到总数中。它是通过调用Get()
找到当前值,然后调用Set()
并应用增量来完成的。 -
接下来,我们想在标签中显示整数值,使用基于我们已创建的
Int
函数的String
绑定:totalStr := binding.IntToStringWithFormat(total, "%dml")
-
现在我们已经使用
canvas.Text
定义了我们的大号彩色文本,我们必须绑定这些值。然而,没有有用的WithData
构造函数,因此我们必须手动应用绑定值。让我们创建一个新的DataListener
,当值发生变化时将被调用。在回调内部,我们设置文本并请求刷新:totalStr.AddListener(binding.NewDataListener( func() { label.Text, _ = totalStr.Get() label.Refresh() }))
-
经过这些更改后,我们可以运行应用程序以查看实际效果:
Chapter06/example$ go run main.go
当加载时,用户界面看起来是一样的,但当我们点击添加按钮时,总数值将更新,增加的数值等于输入字段中的数值:
图 6.3 – 显示一个绑定值
如果你退出这个版本的程序并再次打开它,你会注意到值被忘记了。我们可以通过使用偏好设置 API 来修复这个问题,我们现在就会这么做。
使用偏好设置存储
为了在应用程序启动之间记住值,我们可以使用偏好设置 API。这允许我们存储值,而无需管理文件访问或数据库。在本节中,我们将把我们的数据绑定连接到偏好设置 API,以便在数据更改时自动记住数据。按照以下步骤操作:
-
要能够存储偏好设置,我们需要为每个数据项分配一个键。在这个例子中,每个数字代表一天的总数,因此我们将使用日期来识别存储的项目。我们需要一个新的函数
dateKey
,它将从任何给定的时间格式化一个字符串:func dateKey(t time.Time) string { return t.Format("2006-01-02") // YYYY-MM-DD }
-
为了能够使用偏好设置 API,我们需要确保我们的应用程序有一个唯一的标识符。为此,我们将使用
app.NewWithID
而不是app.New
。ID 应该是全局唯一的。然后,我们需要检索应用程序的Preferences
实例并将其传递到我们的makeUI
函数中,以便它可以在绑定中使用:a := app.NewWithID("com.example.watertracker") pref := a.Preferences() w.SetContent(makeUI(pref)
-
接下来,我们将更新
Int
绑定,使其能够访问偏好设置而不是在内存中创建一个新的值。我们将更新makeUI
,使其接受一个偏好设置实例,然后更改我们的绑定创建,使其使用BindPreferenceInt
。这个构造函数需要它需要访问的值的键(这是由我们创建的dateKey
函数生成的)以及要使用的偏好设置实例:func makeUI(p fyne.Preferences) fyne.CanvasObject { total := binding.BindPreferenceInt( dateKey(time.Now()), p)
-
由于我们正在完成 app 的顶部部分,我们也应该设置正确的日期。只需更改标签构造函数,使其传递一个格式化的日期字符串,如下所示:
date := widget.NewLabel(time.Now().Format( "Mon Jan 2 2006"))
如果你再次运行 app,你会看到相同的界面(带有更正的日期显示)。如果你向今天的总数添加一些值然后重新启动 app,你会注意到这个值被记住了。
这样,我们就完成了 app 的基本功能,但历史字段仍然是空的。基于之前的功能,我们可以显示之前几天的存储值。
添加历史记录
在主数据绑定代码工作正常后,我们可以将类似的功能添加到历史面板中。我们将访问每周每一天的偏好设置值,并更新historyLabel
函数以访问它。让我们看看如何:
-
历史面板最复杂的部分是日期处理。我们需要找出代表一周开始的那一天。由于一天中的时间通常并不重要(此代码中没有考虑夏令时),我们只需操作日期部分。使用
Time.Weekday()
,我们可以找出是星期几,然后从这里开始。Go 的time
包期望星期日是一周的第一天,因此我们需要通过减去 6 天来找到星期一。对于其他所有日期,我们减去从星期一开始的天数(24 * time.Hour
)。以下代码将使我们能够找到第一个历史元素的日期:func dateForMonday() time.Time { day := time.Now().Weekday() if day == time.Sunday { return time.Now().Add(-1 * time.Hour * 24 * 6) } daysSinceMonday := time.Duration(day - 1) dayLength := time.Hour * 24 return time.Now().Add(-1 * dayLength * daysSinceMonday) // Monday is day 1 }
-
接下来,我们必须更新
historyLabel
函数,添加所讨论的那天的日期,以及要使用的偏好设置引用。基于此,我们可以生成我们的dateKey
,就像我们为总存储所做的那样,并将一个Int
值绑定到偏好设置上。然后,我们创建另一个使用相同格式的字符串转换,但在这个例子中,我们可以简单地使用NewLabelWithData
构造函数:func historyLabel(date time.Time, p fyne.Preferences) fyne.CanvasObject { data := binding.BindPreferenceInt(dateKey(date), p) str := binding.IntToStringWithFormat(data, "%dml") num := widget.NewLabelWithData(str) num.Alignment = fyne.TextAlignTrailing return num }
-
现在,我们可以更新历史面板的详细信息。首先,我们计算当前周开始时的星期一的日期,然后设置一个用于天长度的辅助器,这并不属于 Go
time
包的一部分。对于每个历史元素,我们通过自周一开始增加天数来传递每天的日期:weekStart := dateForMonday() dayLength := time.Hour * 24 history := container.NewGridWithColumns(2, widget.NewLabel("Monday"), historyLabel(weekStart, p), widget.NewLabel("Tuesday"), historyLabel(weekStart.Add(dayLength), p), widget.NewLabel("Wednesday"), historyLabel(weekStart.Add(dayLength*2), p), widget.NewLabel("Thursday"), historyLabel(weekStart.Add(dayLength*3), p), widget.NewLabel("Friday"), historyLabel(weekStart.Add(dayLength*4), p), widget.NewLabel("Saturday"), historyLabel(weekStart.Add(dayLength*5), p), widget.NewLabel("Sunday"), historyLabel(weekStart.Add(dayLength*6), p), )
-
通过运行这个最终版本的 app,我们将看到它更新了历史面板中的今天值:
Chapter06/example$ go run main.go
更新后的代码将加载我们之前拥有的相同界面,但现在,你会看到按下添加按钮将更新历史面板中的当前日期:
![图 6.4 – 我们完成的水追踪 app
图 6.4 – 我们完成的水量追踪应用
你将看到这个应用程序在一周内的历史记录,如前一个截图所示。如果你想编辑数据,你可以直接修改首选项文件。首选项文件的位置取决于你的操作系统。更多信息请参阅developer.fyne.io/tutorial/preferences-api
。
摘要
在本章中,我们探讨了 Fyne 中可用于管理和存储数据的各种 API。我们探讨了数据绑定的概念,并了解了它如何帮助保持用户界面更新,同时减少我们需要编写的代码量。
我们随后查看了 Preferences API,它允许我们在应用启动之间持久化用户数据。当与数据绑定代码结合使用时,这不会带来额外的复杂性。通过利用这些功能,我们实现了一个示例应用,用于管理跟踪用水量的数据,并将其存储在我们的本地设备上,以便第二天使用。
有了这些,我们已经涵盖了 Fyne 工具包中最常见的标准小部件和功能。有时,一个应用程序可能需要包含在内的小部件或功能。为了支持这一点,工具包允许我们扩展内置组件。我们将在下一章中探讨这一点。
第七章:第七章:构建自定义小部件和主题
在前几章中,我们看到了许多作为 Fyne 工具包一部分的功能。然而,许多应用程序将受益于作为标准不包括的组件或功能。为了能够支持易于使用的工具包 API 并同时支持附加功能,Fyne 工具包提供了在常规小部件旁边使用自定义代码的能力。
在本章中,我们将探讨如何在 Fyne 应用程序中使用自定义数据,以及如何通过代码或加载自定义主题来添加自定义样式。
在本章中,我们将涵盖以下主题:
-
扩展现有小部件
-
从头创建组件
-
添加自定义主题
在本章结束时,我们将看到如何利用自定义小部件和主题功能来创建一个展示可用于各种即时通讯协议的对话历史的应用程序。它将展示如何使新小部件补充标准集,以及如何使自定义主题为应用程序增添一些个性。
技术要求
本章与第三章、“Windows、Canvas 和绘图”的要求相同:您需要安装 Fyne 工具包并拥有一个有效的 Go 和 C 编译器。更多信息,请参阅上一章。
本章的完整源代码可以在github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne/tree/master/Chapter07
找到。
扩展现有小部件
我们在第五章“小部件库和主题”中探讨的标准小部件都具有最小的 API 来定义常用功能。为了支持添加更高级的功能,应用程序开发者可以扩展每个 Fyne 小部件。在本节中,我们将看到如何通过覆盖现有功能或添加新行为来增强小部件。
如以下图所示,扩展小部件以及自定义小部件都可以与标准小部件和画布对象一起包含在容器中:
![图 7.1 – 扩展和自定义小部件可以与标准元素一起使用
![img/Figure_7.1_B16820.jpg]
图 7.1 – 扩展和自定义小部件可以与标准元素一起使用
扩展小部件将嵌入现有小部件并提供替换或增强的功能。我们将在本章后面看到,自定义小部件实现了完整的Widget
接口,因此不受标准小部件设计的限制。
构建自定义小部件提供了更多的灵活性;然而,它需要更多的代码。相反,我们将从学习如何扩展现有小部件以添加我们自己的功能开始。一旦我们理解了如何扩展现有小部件,我们将在“从头创建组件”部分了解更多关于自定义组件的内容。
覆盖小部件函数
我们将探索如何扩展现有小部件的第一种方式是通过覆盖它的一些功能。这通常是通过嵌入一个小部件并创建一个与 Fyne 调用它的签名相同的方法来完成的,本质上是用我们自己的方法替换了内置方法。在采取这种方法时,通常希望在处理了我们的覆盖之后执行原始功能。为此,我们的扩展小部件可以从我们自己的方法中调用原始方法。
为了说明这一点,我们将创建一个扩展的 Entry
小部件,当 submitEntry
被触发时执行一个动作,并将代码放入 submitentry.go
中,如下所示:
-
我们首先创建一个新的结构体,它将定义我们的自定义类型,命名为
submitEntry
。在里面,我们添加一个匿名字段,widget.Entry
,这意味着我们将继承Entry
小部件的所有字段和功能。注意,这并不是一个指针:type submitEntry struct { widget.Entry }
-
接下来,我们创建一个构造函数,
newSubmitEntry
。这一步不是严格必要的,但我们必须调用ExtendBaseWidget()
,因此这样的函数通常是最佳方法。我们需要将新小部件作为参数传递给ExtendBaseWidget
,以便工具包代码知道我们正在提供对嵌入小部件的替换:func newSubmitEntry() *submitEntry { e := &submitEntry{} e.ExtendBaseWidget(e) return e }
-
然后,我们可以添加我们自己的、覆盖的功能。在这种情况下,我们替换了
TypedKey
方法,该方法在按下键(物理或虚拟)以触发事件时被调用。如果我们想拦截字符,我们会使用TypedRune
。在我们的方法中,我们检查键是否为KeyReturn
,如果是,我们执行一个自定义动作。如果按下任何其他键,我们调用嵌入的Entry
小部件的TypedKey
函数(传递相同的KeyEvent
),这确保了我们的小部件继续作为文本输入工作:func (s *submitEntry) TypedKey(k *fyne.KeyEvent) { if k.Name == fyne.KeyReturn { log.Println("Submit data", s.Text) s.SetText("") return } s.Entry.TypedKey(k) }
-
最后,我们创建了一个常规的
main
函数。在这种情况下,我们只需将内容设置为我们的新submitEntry
小部件:func main() { a := app.New() w := a.NewWindow("Submit Entry") w.SetContent(newSubmitEntry()) w.ShowAndRun() }
-
我们现在可以使用
go run
命令运行示例:Chapter07$ go run submitentry.go
你将看到一个包含看起来像常规输入小部件的窗口,但如果你在输入时按下 回车 键,你将在控制台看到一个日志消息,内容将被清除:
图 7.2 – submitEntry
结构体看起来像是一个常规的 widget.Entry
我们已经看到了如何覆盖小部件的现有函数,但也可以添加新功能,正如我们将在下一节学习如何创建可点击图标时看到的那样。
添加新行为
开发者可以通过在嵌入类型周围实现新的处理程序来扩展现有小部件的另一种方式是添加新功能。在这个例子中,我们将创建一个可点击图标。这个扩展的widget.Icon
将像按钮一样只有一个图标,但它不包括常规按钮的边框或点击动画(在某些情况下可能不希望有)。我们从这个例子开始创建tapicon.go
,并按描述进行操作:
-
我们再次从一个自定义结构体开始,该结构体嵌入现有的小部件,这次是一个
widget.Icon
。与之前一样,这不应该是一个指针类型。在这个结构体中,我们还将包括一个func()
来存储当图标被点击时应运行的回调:type tapIcon struct { widget.Icon tap func() }
-
我们再次创建一个构造函数,主要是为了确保调用
ExtendBaseWidget
。我们将传递一个fyne.Resource
到这个函数中,该资源指定要显示的图标,以及一个func()
,当图标被点击时将调用它。资源被传递到原始图标中,因为它仍然将处理渲染:func newTapIcon(res fyne.Resource, fn func()) *tapIcon { i := &tapIcon{tap: fn} i.Resource = res i.ExtendBaseWidget(i) return i }
-
要添加可点击功能,我们只需实现
fyne.Tappable
接口,该接口需要一个方法,即Tapped()
,它接受一个*PointEvent
参数。我们只需在这个函数内部执行之前保存的回调,只要设置了回调即可:func (t *tapIcon) Tapped(_ *fyne.PointEvent) { if t.tap == nil { return } t.tap() }
-
对于这个演示,我们将创建一个基本的用户界面,其中包含我们的三个
tapIcon
实例,模拟来自应用程序的首页、后退和下一项导航元素。为此,我们创建一个新的makeUI
函数,将它们在水平框中排列:func makeUI() fyne.CanvasObject { return container.NewHBox( newTapIcon(theme.HomeIcon(), func() { log.Println("Go home") }), newTapIcon(theme.NavigateBackIcon(), func() { log.Println("Go back") }), newTapIcon(theme.NavigateNextIcon(), func() { log.Println("Go forward") }), ) }
-
为了完成这个示例,我们创建一个新的
main
函数,该函数将窗口内容设置为makeUI()
调用的结果:func main() { a := app.New() w := a.NewWindow("Navigate") w.SetContent(makeUI()) w.ShowAndRun() }
-
现在,我们可以运行整个示例,以查看操作中的结果小部件:
Chapter07$ go run tapicon.go
当应用程序运行时,您将看到一个类似于以下窗口,它渲染图标。您可以点击它们,并在这样做时看到日志输出。我们只是重新创建了没有额外边框、填充和点击动画的按钮小部件:
图 7.3 – 水平框中的可点击图标
我们已经探讨了扩展现有小部件的一些不同方法,但有时我们想要创建一些完全新的东西。在这些情况下,我们可以从头开始构建一个小部件,这就是我们接下来要做的。
从头创建组件
与我们在上一节中通过扩展现有小部件来构建新组件不同,我们可以从头开始构建。任何实现了fyne.Widget
接口的组件都可以用作 Fyne 应用程序中的小部件。为了简化开发,有一个widget.BaseWidget
定义,我们可以从它继承。让我们首先定义一个新的小部件的行为——三态复选框。
定义小部件行为
Fyne 小部件的 API 基于行为而不是外观。因此,为了开始我们的小部件开发,我们将定义我们的三态复选框可以采取的状态以及用户如何与之交互。我们将创建threestate.go
并开始编码:
-
首先,我们必须定义一个新类型
CheckState
,它将保存我们新复选框小部件的三个不同状态。由于我们正在构建一个可重用组件,导出所需的类型,如CheckState
及其定义的各种状态,是一个好主意。iota
的使用定义了第一个索引,后续状态将从该值递增:type CheckState int const ( CheckOff CheckState = iota CheckOn CheckIndeterminate )
-
然后我们定义新组件的核心,将其命名为
ThreeStateCheck
,并设置它从widget.BaseWidget
继承基本小部件行为。使用BaseWidget
是可选的,但它可以节省一些编码工作。我们添加一个名为State
的字段,它将保存检查小部件的当前状态:type ThreeStateCheck struct { widget.BaseWidget State CheckState }
-
接下来,我们为这个新类型创建一个构造函数。与之前的示例一样,我们需要调用
ExtendBaseWidget
;在这种情况下,我们继承的基本功能已经正确设置:func NewThreeStateCheck() *ThreeStateCheck { c := &ThreeStateCheck{} c.ExtendBaseWidget(c) return c }
-
此类型行为的最后一个元素是其对触摸事件的响应能力。我们设置了一个
Tapped
处理程序,就像我们在上一节中对可触摸图标所做的那样。这次,我们将旋转此小部件的三个状态,如果上一个状态是CheckIndeterminate
,则将其包裹到CheckOff
:func (c *ThreeStateCheck) Tapped(_ *fyne.PointEvent) { if c.State == CheckIndeterminate { c.State = CheckOff } else { c.State++ } c.Refresh() }
这就是我们定义这个新小部件行为所需编写的全部内容。然而,因为它是一个新组件(而不是现有小部件的扩展),我们必须定义它的渲染方式,这将在接下来的内容中完成。
实现渲染细节
为了使新小部件完整,它还必须定义其渲染方式。这需要一个实现fyne.WidgetRenderer
的新类型,正如我们将在下面实现的那样。这个新类型必须从小部件实现中的CreateRenderer
函数返回,正如你将在下面的代码中看到的。这个渲染器将使用三个复选框图标之一——两个内置在 Fyne 主题中,第三个我们将在此代码库中提供。请注意,像这样的额外资源应该捆绑以供分发,这将在第九章中详细讨论,捆绑资源和准备发布部分,在捆绑资产节中:
-
要开始渲染器定义,我们创建一个名为
threeStateRender
的新类型;这个类型不应该被导出,因为渲染器细节是私有的。这将持有它所渲染的ThreeStateCheck
的引用,以及一个将显示我们检查小部件所使用的三个图标之一的canvas.Image
:type threeStateRender struct { check *ThreeStateCheck img *canvas.Image }
-
就像我们定义容器布局一样,我们需要定义小部件渲染器元素的尺寸和位置。在这个例子中,我们将简单地指定我们的复选框图标应设置为
theme.IconInlineSize
,以与其他小部件保持一致。我们将其定义为我们的MinSize
并在需要定义Layout
时使用相同的值来调整小部件的尺寸:func (t *threeStateRender) MinSize() fyne.Size { return fyne.NewSize(theme.IconInlineSize(), theme.IconInlineSize()) } func (t *threeStateRender) Layout(_ fyne.Size) { t.img.Resize(t.MinSize()) }
-
为了完成我们的渲染器,我们还必须定义额外的方法:
Destroy
方法(当此渲染器不再需要时调用),Objects
方法(返回图形元素列表),以及Refresh
方法(如果状态发生变化时调用)。这些方法相对简单——大多数都是空的,而Refresh
方法只是简单地调用一个新定义的updateImage
方法,该方法将在下一步定义:func (t *threeStateRender) Destroy() { } func (t *threeStateRender) Objects() []fyne.CanvasObject { return []fyne.CanvasObject{t.img} } func (t *threeStateRender) Refresh() { t.updateImage() }
-
确保小部件是最新的关键是选择当前状态的正确图像。我们通过一个新的
updateImage
方法来完成这项工作,如下面的代码所示:func (t *threeStateRender) updateImage() { switch t.check.State { case CheckOn: t.img.Resource = theme.CheckButtonCheckedIcon() case CheckIndeterminate: res, _ := fyne.LoadResourceFromPath( "indeterminate_check_box-24px.svg") t.img.Resource = theme.NewThemedResource(res) default: t.img.Resource = theme.CheckButtonIcon() } t.img.Refresh() }
它只是检查状态并选择一个要显示的资源。在正常状态下,我们可以使用主题内置的图标,但对我们新的不确定状态,我们必须加载自己的资源。正如我们在此节前面提到的,资产应该被打包,但我们将更详细地探讨这一点,在第九章,打包资源和准备发布。
-
编写
WidgetRenderer
的最后一部分是从我们创建的小部件上定义的CreateRenderer
方法返回它:func (c *ThreeStateCheck) CreateRenderer() fyne.WidgetRenderer { r := &threeStateRender{check: c, img: &canvas.Image{}} r.updateImage() return r }
在这个方法中,我们设置渲染器并传递一个 canvas.Image
实例,它可以使用该实例来显示。然后我们调用之前定义的 updateImage
方法,以确保初始状态正确渲染:
-
要运行这个演示,我们只需要添加一个常规的
main
函数。这次,我们将内容设置为单个三态复选框,使用NewThreeStateCheck()
:func main() { a := app.New() w := a.NewWindow("Three State") w.SetContent(NewThreeStateCheck()) w.ShowAndRun() }
-
我们现在可以使用
go run
命令像往常一样运行代码:Chapter07$ go run threestate.go
运行应用程序将显示图 7.4 中的第一个窗口。点击图标将使复选框旋转到图中所示的三个状态:
![图 7.4 – 我们自定义复选框的三个状态]
图 7.4 – 我们自定义复选框的三个状态
我们现在已经探讨了扩展小部件以添加新行为和创建全新组件的各种方法。正如您从本章到目前为止的图中可以看到,它们看起来都很相似,因为它们尊重当前的主题。在下一节中,我们将看到如何使用自定义主题进行全局样式更改。
添加自定义主题
为了给应用程序带来一些个性风格或品牌识别,定义一个自定义主题可能很有用。这是一个强大的功能,但应谨慎使用。颜色的选择可能会显著影响文本元素和图标的可读性。此外,Fyne 应用程序的用户可以自由选择亮色和暗色模式,因此您的主题应尽可能反映这一选择。
开发者可以通过两种方式创建自定义主题——要么从头开始定义一个新主题并实现Theme
接口,要么从标准主题继承。每种方法都有其优点,因此我们将探讨两种方法,首先从创建全新的主题开始。
实现主题接口
Fyne 中的所有主题都是通过实现fyne.Theme
接口(就像任何小部件都将实现fyne.Widget
一样)来提供的。该接口要求我们提供四个方法,用于查找主题的详细信息。这些方法如下:
type Theme interface {
Color(ThemeColorName, ThemeVariant) color.Color
Font(TextStyle) Resource
Icon(ThemeIconName) Resource
Size(ThemeSizeName) float32
}
如您所猜,这些方法返回主题提供的颜色、大小、图标和字体。四个签名各不相同,但含义如下:
-
颜色
: 颜色查找使用两个参数:第一个是请求的颜色名称(我们将在稍后进行更多探讨)和第二个是ThemeVariant
。在撰写本文时,有两种变体:theme.VariantLight
和theme.VariantDark
。这允许主题适应亮色和暗色模式,尽管这样做是可选的。 -
大小
: 此查找仅接受大小名称参数。它是ThemeSizeName
常量之一,例如theme.SizeNamePadding
或theme.SizeNameText
。 -
字体
: 此查找以TextStyle
作为查找参数。主题可以选择为各种样式返回哪种字体,例如TextStyle{Bold: true}
用于粗体。主题还应检查Italic
和Monospaced
字段。未来版本可能会添加其他选项。 -
图标
: 最后一种方法允许主题在需要时提供自定义图标。参数是图标资源的名称,返回的资源可以是 PNG、JPEG 或 SVG 格式的图像。通常建议您在返回 SVG 文件时使用theme.NewThemedResource
,以便它能够适应主题变体。
颜色
方法是这些方法中最复杂的,因为它预期(但不要求)根据传入的ThemeVariant
返回不同的值。大多数主题颜色在用户从亮色模式切换到暗色模式时可能会发生变化;您可以在标准主题中看到这一点。然而,并非所有颜色都会变化。由于用户可以挑选他们喜欢的首选颜色,因此通常在两种模式下保持这种一致性。
为了管理ThemeColourName
和ThemeSizeName
的各种值,theme
包提供了常量集合,命名为theme.ColorNameXxx
、theme.SizeNameXxx
和theme.IconNameXxx
。一个完整的主题应该为这些常量中的每一个返回一个合适的值。在撰写本文时,大小常量是SizeNamePadding
、SizeNameScrollBar
、SizeNameScrollBarSmall
、SizeNameText
和SizeNameInlineIcon
。
颜色列表很长,并且随着新主题功能的添加可能会增长。目前所需列表如下:
-
ColorNameBackground
-
ColorNameButton
-
ColorNameDisabledButton
-
ColorNameDisabled
-
ColorNameFocus
-
ColorNameForeground
-
ColorNameHover
-
ColorNamePlaceHolder
-
ColorNamePrimary
-
ColorNameScrollBar
-
ColorNameShadow
虽然建议为列出的每种颜色和大小实现一个返回值(该值适应请求的变体),但随着时间的推移可能会添加新的项目,你的主题可能不知道。为了帮助适应这些情况,可以指定主题扩展了内置的主题(它将始终有合适的颜色可用)。我们将在下一节中探讨这一点。
提供标准主题的定制
在某些情况下,应用程序开发者可能希望只修改主题的某些颜色元素,例如引入他们自己的主色以匹配他们的公司品牌。为了支持这一点,你可以实现一个部分主题,并要求它将任何未定义的项目委托给作为 Fyne 一部分提供的默认主题。为此,你可以部分实现一个主题,并调用theme.DefaultTheme()
方法来提供标准值。这也可以用来在应用程序中更改字体,例如,同时保留颜色为标准。
让我们看看一个简单的主题定制,它希望所有文本都使用等宽字体,并且希望这些文本是橙色。我们将在一个新的文件theme.go
中开始,如下所示:
-
要实现
Theme
接口,我们需要定义一个新的类型。现在我们将使用一个空的struct
:type myTheme struct { }
-
要始终使用等宽字体,我们可以实现
Font
函数并返回任何请求的默认字体资源:func (t *myTheme) Font(fyne.TextStyle) fyne.Resource { return theme.DefaultTextMonospaceFont() }
-
然后,我们希望指定文本应该是橙色。为此,我们实现
Color
方法,并在名称为Colors.Text
时返回这个自定义值。由于我们不提供浅色和深色不同的值,我们可以忽略ThemeVariant
参数。通过将所有其他颜色委托给theme.DefaultTheme()
,我们将指定应使用默认主题的值:func (t *myTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color { if n == theme.ColorNameForeground { return &color.NRGBA{0xff, 0xc1, 0x07, 0xff} } return theme.DefaultTheme().Color(n, v) }
-
我们没有大小考虑,但我们必须实现这个方法。我们只需简单地返回默认主题的值,以便使用当前的大小:
func (t *myTheme) Size(n fyne.ThemeSizeName) int { return theme.DefaultTheme().Size(n) }
-
类似地,我们需要提供一个空的
Icon
函数,它将返回默认主题的图标。func (t *myTheme) Icon(n fyne.ThemeIconName) fyne.Resource { return theme.DefaultTheme().Icon(n) }
-
为了能够展示主题,我们创建了一个简单的界面,包含一个
Label
、Entry
和一个Button
。下面的makeUI
函数将这些元素以垂直框的形式返回:func makeUI() fyne.CanvasObject { return container.NewVBox( widget.NewLabel("Hello there"), widget.NewEntry(), widget.NewButton("Tap me", func() {}), ) }
-
最后,我们创建一个
main
函数来加载并运行我们的应用程序。注意这次我们调用了App.Settings().SetTheme
,这将设置一个新主题,如下面的代码所示:func main() { a := app.New() a.Settings().SetTheme(&myTheme{}) w := a.NewWindow("Theme") w.SetContent(makeUI()) w.ShowAndRun() }
-
我们现在可以像往常一样运行此应用程序,或者指定深色主题,如下所示:
Chapter07$ FYNE_THEME=dark go run theme.go
现在我们可以看到我们自定义主题的结果。所有文本都是等宽的,并且是明亮的橙色!
![图 7.5 – 使用自定义主题
图 7.5 – 使用自定义主题
现在我们已经看到了如何通过特定的应用程序主题和自定义小部件来增强 Fyne 应用程序界面,我们将在一个示例应用程序中将它们结合起来。这次,我们将构建一个聊天应用界面。
实现聊天应用用户界面
图形应用程序的一个常见例子,尤其是在移动环境中,是消息应用。尽管现在有许多消息应用,但它们通常共享彩色文本框的设计,这些文本框可以滚动回过去。它们也通常是左对齐或右对齐(带有一些填充以强调),以区分传入的消息和发出的消息。在这个例子中,我们将实现消息组件以这种方式显示文本,并应用自定义主题以赋予应用一个身份。
创建消息小部件
我们从用于显示单个消息的消息小部件开始。每条消息都将有一个文本正文和发送消息的人的名字。使用发送者的名字,我们可以确定这条消息是发出的。首先,我们定义一个自定义的Widget
类型,它将在新的文件message.go
中保存这些数据:
-
要创建一个自定义小部件,我们定义一个新的类型,命名为
message
,它扩展了widget.BaseWidget
。我们添加了自己的字段,text
和from
,它们将保存我们的小部件状态:type message struct { widget.BaseWidget text, from string }
-
我们还将在这个例子中使用一些常量值——
myName
是我们将用于发出消息的名字。显然,在一个真正的应用中,这将是用户设置。messageIndent
是一个值,它决定了在我们的消息输出中会出现多少左或右的空间,以对齐传入和发出的消息:const ( myName = "Me" messageIndent = 20 )
-
与本章前面的示例一样,我们创建了一个有用的构造函数,用于设置自定义小部件并确保调用
ExtendBaseWidget
:func newMessage(text, name string) *message { m := &message{text: text, from: name} m.ExtendBaseWidget(m) return m }
-
在我们的自定义消息小部件中,大部分工作都与它的定位和样式相关,因此渲染器是我们必须做大部分工作的地方。我们首先定义一个自定义渲染器类型,
messageRender
。没有标准的渲染器类型可以扩展,但我们将想要保存它所渲染的message
小部件的引用(以防我们需要读取其状态)。我们还添加了Rectangle
作为背景和将显示我们的文本的Label
:type messageRender struct { msg *message bg *canvas.Rectangle txt *widget.Label }
-
Widget
(或任何CanvasObject
)的一个重要部分是知道其最小尺寸。这决定了布局如何在屏幕上打包内容。我们的尺寸因使用换行文本而变得复杂——可用宽度将改变高度。我们创建了一个辅助方法messageMinSize
,它将返回可用宽度下的实际最小尺寸,然后我们从其中减去messageIndent
以创建最终显示中的间隙(使其更清晰地区分传入和发出的消息):func (r *messageRender) messageMinSize(s fyne.Size) fyne.Size { fitSize := s.Subtract(fyne.NewSize(messageIndent, 0)) r.txt.Resize(fitSize.Max(r.txt.MinSize())) return r.txt.MinSize() }
-
现在我们知道了文本所需的空间,我们可以实现
MinSize
方法。我们将messageIndent
添加到宽度中,以便保留水平空间:func (r *messageRender) MinSize() fyne.Size { itemSize := r.messageMinSize(r.msg.Size()) return itemSize.Add(fyne.NewSize(messageIndent, 0)) }
-
我们渲染器的主要逻辑是
Layout
方法。它必须在Widget
内调整文本和背景矩形的尺寸和位置。所有位置都是相对于我们的小部件的左上角位置:func (r *messageRender) Layout(s fyne.Size) { itemSize := r.messageMinSize(s) itemSize = itemSize.Max(fyne.NewSize( s.Width-messageIndent, s.Height)) bgPos := fyne.NewPos(0, 0) if r.msg.from == myName { r.txt.Alignment = fyne.TextAlignTrailing r.bg.FillColor = theme.PrimaryColorNamed( theme.ColorBlue) bgPos = fyne.NewPos(s.Width-itemSize.Width, 0) } else { r.txt.Alignment = fyne.TextAlignLeading r.bg.FillColor = theme.PrimaryColorNamed( theme.ColorGreen) } r.txt.Move(bgPos) r.bg.Resize(itemSize) r.bg.Move(bgPos) }
在计算文本内容加上填充的完整大小后,我们设置此组件的图形细节。如果是发出的消息,我们将内容右对齐并设置为
蓝色
;否则,我们将其设置为绿色。然后,将计算出的尺寸和位置应用于元素。 -
为了完成渲染器,我们必须实现剩余的方法。这些方法大多为空,因为此示例没有使用动态数据。
Objects
方法返回应该按绘制顺序包含的每个元素,因此背景必须在文本之前:func (r *messageRender) BackgroundColor() color.Color { return color.Transparent } func (r *messageRender) Destroy() { } func (r *messageRender) Objects() []fyne.CanvasObject { return []fyne.CanvasObject{r.bg, r.txt} } func (r *messageRender) Refresh() { }
-
完成此小部件的最后一个函数是将
Widget
与WidgetRenderer
链接起来的方法。我们传入将要绘制的画布对象,以避免稍后重新创建它们:func (m *message) CreateRenderer() fyne.WidgetRenderer { text := widget.NewLabel(m.text) text.Wrapping = fyne.TextWrapWord return &messageRender{msg: m, bg: &canvas.Rectangle{}, txt: text} }
这完成了自定义组件,但在我们可以测试它之前,我们需要创建将使用它们的用户界面。我们首先创建一个消息小部件列表。
列出消息
要创建用户界面的其余部分,我们将创建一个新的文件main.go
,并添加标准组件。首先,我们创建一个消息列表:
-
使用我们之前创建的
newMessage
函数,创建一个消息列表非常简单。我们只需创建一个VBox
容器,并传递一个使用该辅助函数创建的message
小部件列表。显然,在一个完整的应用程序中,这将使用某种外部数据源:func loadMessages() *fyne.Container { return container.NewVBox( newMessage("Hi there, how are you doing?", "Jim"), newMessage("Yeah good thanks, you?", myName), newMessage("Not bad thanks. Weekend!", "Jim"), newMessage("Want to visit the cinema?", "Jim"), newMessage("Great idea, what's showing?", myName), ) }
-
我们可以实施一个简单的
main
函数来展示我们到目前为止的进度。这将有助于稍后当完整用户界面准备好运行时。对于这个版本,我们只需将窗口内容设置为从loadMessages()
返回的列表。我们给窗口一个合理的尺寸并显示它:func main() { a := app.New() w := a.NewWindow("Messages") w.SetContent(loadMessages()) w.Resize(fyne.NewSize(160, 280)) w.ShowAndRun() }
-
我们现在可以运行消息列表来查看当前的工作:
Chapter07/example$ go run .
结果是一个按发送者对齐的消息列表,并显示适当的颜色。这可以在以下图中看到:
![Figure 7.6 – Our messaging list in the default theme
![img/Figure_7.6_B16820.jpg]
图 7.6 – 默认主题下的我们的消息列表
这完成了消息列表(我们将在下一节中添加一个滚动容器)。我们还应该添加一个输入部分来发送新的消息,我们也将这样做。
完成用户界面
消息列表看起来很棒,但我们希望能够发送新的消息。让我们看看如何实现这一点:
-
为了添加剩余的用户界面元素,我们创建了一个新的函数
makeUI
,并首先添加loadMessages
列表:func makeUI() fyne.CanvasObject { list := loadMessages() … }
-
然后,我们创建一个包含用于捕获文本的
Entry
和用于发送消息的Send
按钮的页脚:msg := widget.NewEntry() send := widget.NewButtonWithIcon("", theme.MailSendIcon(), func() { list.Add(newMessage(msg.Text, myName)) msg.SetText("") }) input := container.NewBorder(nil, nil, nil, send, msg)
从前面的代码中,我们可以看到,通过在本章早期使用
submitEntry
,您也可以支持在按回车键时发送消息的功能。按钮点击处理程序将创建一个新的消息小部件并将其添加到列表中,这将刷新。然后它将重置我们的Entry
中的文本。我们返回一个名为input
的Container
,适当地定位这些元素。 -
最后,对于这个函数,我们返回一个新的
Border
容器,将输入行定位在底部,并使用剩余的空间显示消息列表。我们还添加了一个Scroll
容器围绕列表,以便它可以包含比屏幕上显示更多的数据:return container.NewBorder(nil, input, nil, nil, container.NewVScroll(list))
-
要使用这段新代码,我们更新
main
函数,将其从调用loadMessages()
改为调用makeUI()
:w.SetContent(makeUI())
-
现在,我们可以再次运行应用程序以查看完整的界面,如下所示:
Chapter07/example$ go run .
-
这次,我们可以使用末尾的输入框来添加一条新消息。在图 7.7 中,我们添加了一条如何...的消息:
![图 7.7 – 默认主题下的我们的消息应用
![img/Figure_7.7_B16820.jpg]
图 7.7 – 默认主题下的我们的消息应用
这完成了我们的应用程序功能,但我们可以通过应用自定义主题使其更加有趣。
使用自定义主题添加一些风格
Fyne 应用程序的默认外观设计得既清晰又吸引人,但某些应用程序可能希望应用更多的身份或设计风格。我们将为我们的消息应用做这件事,从一个新的文件theme.go
开始:
-
我们首先定义一个新的类型来表示我们的主题。它不需要管理任何状态或从其他结构体继承,因此我们创建了一个空的结构体:
type myTheme struct { }
-
这个自定义主题的主要目的是为某些元素返回不同的
Color
。我们首先调整Background
颜色以显示蓝色灰度的浅色或深色版本,这取决于当前用户的设置(称为ThemeVariant
)。在这个函数的末尾返回默认主题的查找,以表明我们没有为其他颜色提供自定义选项:func (m *myTheme) Color(n fyne.ThemeColorName, v fyne.ThemeVariant) color.Color { switch n { case theme.ColorNameBackground: if v == theme.VariantLight { return &color.NRGBA{0xcf, 0xd8, 0xdc, 0xff} } return &color.NRGBA{0x45, 0x5A, 0x64, 0xff} } return theme.DefaultTheme().Color(n, v) }
-
我们还将为聚焦元素提供自定义颜色。我们将以下代码插入到上一段代码段中的
switch
语句中。显然,如果您愿意,可以提供更多自定义选项:case theme.ColorNameFocus: return &color.NRGBA{0xff, 0xc1, 0x07, 0xff}
-
在此示例中,我们不希望提供自定义大小值或字体,但如果你想要的话可以返回自定义值(如之前在“添加自定义主题”部分所示)。我们需要实现这些方法,但我们将返回默认主题查找,以便使用标准值:
func (m *myTheme) Size(n fyne.ThemeSizeName) int { return theme.DefaultTheme().Size(n) } func (m *myTheme) Font(n fyne.TextStyle) fyne.Resource { return theme.DefaultTheme().Font(n) } func (m *myTheme) Icon(n fyne.ThemeIconName) fyne.Resource { return theme.DefaultTheme().Icon(n) }
-
要将此主题应用到我们的应用中,我们使用
App.Settings.SetTheme()
。这应该在main()
函数调用ShowAndRun()
之前执行:a.Settings().SetTheme(&myTheme{})
-
再次运行此代码,我们可以看到完成的工作:
Chapter07/example$ go run .
-
我们将看到自定义主题被加载。在图 7.8 中,它以
FYNE_THEME=dark
运行,以表明我们的自定义主题适用于亮暗模式:
图 7.8 – 使用我们刚刚编写的暗黑模式主题
我们看到了如何实现自定义小部件和主题来构建一个吸引人的消息用户界面。实际通过你喜欢的聊天协议发送和接收消息的任务留给读者作为练习!
摘要
在本章中,我们看到了如何以各种方式偏离标准组件和内置主题。我们探讨了现有小部件如何扩展和适应,以及如何从头开始构建我们自己的组件。我们还看到了如何创建自定义主题,以及我们如何通过主题扩展将我们的自定义修改应用到默认主题中。
借助这些知识,我们创建了一个混合了标准和自定义组件的应用程序。我们通过小部件的渲染器添加了一些视觉增强,但还通过定义自定义主题创建了进一步的定制。通过本章中的代码,我们学习了如何自定义单个元素和小部件,以及如何使用主题 API 在自定义和标准小部件上应用视觉更改。
这使我们结束了对 Fyne 工具包 API 及其功能的探索。在接下来的章节中,我们将看到如何创建和管理 GUI 应用程序,以及最佳实践如何帮助构建易于维护的健壮软件。我们还将了解到应用程序可以准备分发,甚至可以上传到平台应用商店和市场。在下一章中,我们将探讨项目结构方面的最佳实践,以及如何保持不断增长的 GUI 应用程序的健壮性和可维护性。
第三部分:打包和发行
在探索工具包并使用 Fyne 构建我们的第一个应用程序之后,我们看到了构建跨多平台工作的应用程序是多么容易。现在,是时候看看如何管理我们的应用程序并为发布做准备。由于每个发行系统对元数据和打包格式的需求不同,这可能会让人感到 daunting,但接下来的部分将引导您了解细节。到这些章节结束时,您将打包好您的应用程序,像在桌面和手机上安装原生应用程序一样安装它们,并将它们上传到各个应用商店。
本节将涵盖以下主题:
-
第八章, 项目结构和最佳实践
-
第九章, 资源打包和发布准备
-
第十章, 发行 – 应用商店及其他
我们从这个部分开始,回顾如何组织我们的应用程序,然后继续讨论打包和发行。
第八章:第八章:项目结构和最佳实践
Go 语言附带了一套被广泛理解的最佳实践,如风格、文档和代码结构。通常,当应用程序开始添加图形用户界面(GUI)元素时,这些最佳实践可能会丢失。测试单个组件并保持类型之间的清晰分离有助于我们保持代码的整洁,使其随着时间的推移更容易维护。这些概念也可以在 GUI 代码中遵循,并得到像 Fyne 这样的工具包的支持。
在本章中,我们将探讨这些概念如何应用于图形应用程序开发,以及我们如何从中学习,使我们的 GUI 随着时间的推移更容易管理。我们将涵盖以下主题:
-
组织一个结构良好的项目
-
理解关注点的分离
-
使用测试驱动开发并编写整个应用程序 GUI 的测试
-
管理特定平台的代码
让我们开始吧!
技术要求
本章的要求与第三章、“Windows、Canvas 和绘图”相同;也就是说,你必须安装 Fyne 工具包,并且 Go 和 C 编译器正在运行。更多信息,请参阅该章节。
本章的完整源代码可以在github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne/tree/master/Chapter08
找到。
本章的一些部分涉及管理特定平台的代码,因此如果你有两个不同的操作系统可供使用,可能会很有帮助。
组织你的项目
Go 语言的设计原则之一是你可以从简单开始,随着项目的增长,逐步构建更多的结构。遵循这个原则,你可以在为项目创建的目录中简单地启动一个 GUI 项目,其中包含一个main.go
文件。这最初将包含你的整个应用程序,从其main()
函数开始。
从简单开始
一旦你的用户界面从最基本的形式发展起来,将其拆分到一个新文件中是一个好主意,例如命名为ui.go
。以这种方式拆分代码可以使代码更清晰,区分哪些代码仅仅是启动应用程序(main()
函数和辅助函数)与实际构建用户界面的代码。
到目前为止,你应该开始考虑添加单元测试(如果你还没有添加的话!)。这些测试将存在于一个文件中,与你的代码一起,以_test.go
结尾——例如,ui_test.go
。测试所有代码是一个好习惯,并且对于你添加的每个新函数或类型,都会有新的测试来确保代码随着时间的推移正确运行。对于主函数没有测试文件是正常的,因为它的目的只是连接应用程序的组件并启动它。一个已经通过非常基础的阶段的项目可能包含以下文件:
project/
main.go
ui.go
ui_test.go
这种结构在应用程序需要添加一些自定义类型时效果很好。我们将在下一节中探讨这个问题。
添加新类型
由于 Fyne 应用程序的用户界面代码主要关注行为,因此通常会将应用程序的不同区域拆分为单独的区域,每个区域定义自己的类型。每个类型将定义它所代表的数据(或数据访问),以及可以操作这些信息的各种方法。这些应用程序代码的部分可能是简单的类型定义,其中可能有一个名为makeUI()
或类似名称的 UI 创建函数。以下是一个示例:
func (t *myType) makeUI() fyne.CanvasObject { … }
或者,它们可能是自定义小部件,在这种情况下,类型扩展CanvasObject
,因此可以将其传递到更广泛的 GUI 结构中。在任一情况下,这些类型都值得有一个新的文件,例如mytype.go
,以及它们自己的测试,在mytype_test.go
中。例如,一个不断增长的应用程序可能具有以下结构:
project/
editor.go
editor_test.go
files.go
files_test.go
main.go
status.go
status_test.go
ui.go
ui_test.go
这是一个简单应用程序的好结构,但一旦代码库增长,特别是如果它包含不是 GUI 部分的库或支持功能,你可能会考虑使用多个包。那么会发生什么呢?让我们看看。
将代码拆分为包
当你需要将一些复杂的代码,例如数据访问或复杂计算的管理代码,与将要显示它的用户界面代码分离时,包就非常有用。在这些情况下,一个单独的包允许你保持清晰的分离并独立测试这些元素(参见本章后面的理解关注点分离部分)。如果你希望这个子包 API 对公众可用,那么你可以只为它创建一个新的文件夹(例如,project/mylib
)。或者,你也可以选择将许多子包分组在标准的pkg
目录下(即,project/pkg/mylib
)。
然而,如果你希望将其 API 作为此项目的内部细节,你可以使用特殊的internal
包作为其父包(即,project/internal/pkg/mylib
)。
如果 GUI 代码也使用了内部结构,那么保持代码整洁也是有帮助的,这样项目的顶部就会包含更少的文件。通常,人们会使用project/internal/app/
来实现这一点。因此,一个包含storage
和cache
内部库的应用程序可能看起来像这样:
project/
internal/
app/
editor.go
editor_test.go
files.go
files_test.go
status.go
status_test.go
ui.go
ui_test.go
pkg/
storage/
storage.go
storage_test.go
cache/
cache.go
cache_test.go
main.go
使用此模型,库包都是自包含的,应用程序包可以依赖它们来运行。main.go
文件可能依赖于所有这些包来准备应用程序并启动其 GUI。
上述结构对于单个应用程序存储库来说效果很好。以下命令将直接安装它(请注意,这个命令不应该包含方案前缀,如http://
):
$ go get projectURL
然而,在某些情况下,你需要在项目中使用多个可执行文件。我们将在下一节中探讨这个问题。
多个可执行文件
无论项目主要是库还是包含多个可执行文件的应用程序,都有一个名为cmd
的标准目录,可以包含多个子目录,每个子目录对应一个可执行文件。cmd
中的每个包都将包含它应该编译到的应用程序的名称,尽管Go
包始终是main
,这样它就可以被执行。
因此,如果你的项目主要是库,但包含了mylib_gui
和mylib_config
可执行文件,那么你的结构将如下所示(省略任何内部代码):
project/
cmd/
mylib_gui/
main.go
mylib_config/
main.go
lib.go
通过使用此格式,某人可以通过以下命令(不使用http://
)来依赖你的库:
import "projectURL"
他们也可以选择使用以下命令安装 GUI 二进制文件:
$ go get projectURL/cmd/mylib_gui
这种灵活性允许仓库在保持整洁结构的同时拥有多个用途。当然,internal/pkg/
或internal/app/
中可能会有很多通用代码,以实现这一点。
上述示例只是例子——每个应用程序都会有不同的需求,可能希望偏离这些常见的布局。为了帮助开发者理解一个应用程序,其main()
函数应该位于项目的根目录或cmd/appname/
子目录中。同样,库的主要 API 应该可以从项目的根目录或pkg/libname/
子目录导入。遵循这些提示将使你的应用程序或库对新熟悉推荐 Go 项目布局的开发者更容易上手。
在本节中,我们学习了如何将应用程序拆分为多个部分。尽管如此,这为什么很重要可能并不清楚。在下一节中,我们将探讨关注点分离,这将展示这种方法如何帮助我们保持代码的整洁和未来可维护性。
理解关注点分离
如我们本章前面所提到的,以及我们在第二章中讨论 Fyne 工具包 API 原则时,在设计简洁且易于维护的 API部分,关注点分离的概念如果我们要保持代码库的整洁性是非常重要的。它使我们能够将相关的代码放在一起,而不用担心在做出更改时破坏其他区域。
这个概念与罗伯特·C·马丁在其面向对象设计原则(www.butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod
)文章中引入的单一职责原则密切相关。在这里,他提出了以下观点:
“一个类应该只有一个,并且只有一个,改变的理由。”
–罗伯特·C·马丁
在这方面,关注点比责任有更广泛的范围,因为它们通常影响您应用程序的设计和架构,而不是单个类或接口。在图形应用程序中,如果您希望正确地将易于测试的逻辑与处理用户交互的表示代码分离,关注点的分离是必不可少的。通过分离应用程序的关注点,我们更容易测试子组件并验证我们软件的有效性,甚至不需要运行应用程序。这样做,我们创建了更健壮的应用程序,可以适应随时间变化的需求或技术。
例如,Fyne 小部件和 API 不应纳入或影响您的业务逻辑设计。即使是专注于行为的图形 API,如 Fyne 工具包,也只应由应用程序的表示层(项目根目录或internal/app
包中的项目)引用。正因为如此,一个健壮的应用程序才被分割成多个区域。每个支持库都将独立于表示层或工具包的功能运行。这样,我们保持软件的开放性,使其在不影响相关区域的情况下易于更改。
在下一节中,我们将学习提供的测试实用工具如何帮助简化单元测试的创建。我们可以通过将代码分割成更小的组件来实现这一点。这对于验证我们应用程序表示代码的行为非常有用。
测试驱动开发
自动测试用户界面或前端软件所需的工作量通常被认为对于避免未来错误的价值太低。然而,这很大程度上取决于所使用的工具包或甚至选择的表现技术。如果没有开发工具或图形 API 对测试的全面支持,在没有投入大量努力的情况下创建简单的单元测试可能会很困难。
Fyne 工具包的设计原则之一是应用程序的 GUI 应该像其余代码一样易于测试。这部分得益于 API 的设计,但这一点通过我们可以提供的测试实用工具得到了进一步加强。我们将在本节稍后探讨这一点。通过以下方法,我们将学习 Fyne 应用程序如何遵循测试驱动开发(TDD),即使是对于用户界面组件。
设计用于测试
Fyne 工具包的模块化设计允许为不同的系统或目的加载不同的驱动程序。这种方法主要支持在任何操作系统上工作的 Fyne 应用程序,无需开发者修改他们的应用程序。这种方法的一个额外好处是,可以将应用程序加载到测试运行时中执行各种检查,而无需在屏幕上显示。这大大提高了测试运行的速度,同时也使你的测试更加可靠(因为用户交互不能干扰测试过程)。
通过导入fyne.io/fyne/test
包,我们自动创建了一个内存中的应用程序,该应用程序能够创建包含实际应用程序 GUI 的虚拟窗口。这些窗口支持与常规窗口相同的 API,因此你的代码可以像之前一样运行。每个图形元素都可以通过编程方式与之交互和测试,以确认其行为和状态,甚至验证其渲染输出。
在接下来的两个例子中,我们将学习如何测试用户界面组件的行为,然后如何验证它是否正确渲染,所有这些都不需要将 GUI 显示在屏幕上。
测试我们的 GUI 逻辑
要测试应用程序的功能,我们必须定义一个非常简单的 GUI。它将有一个Hello World!
标签,后面跟着一个我们可以用来指定我们名字的输入小部件。最后的组件——一个简单的按钮——将被触发,根据输入更新问候语。以下是它是如何工作的:
-
首先,我们定义一个简单的
struct
,称为greeter
,它将包含对这些对象的引用。在这个例子中,我们将编写以下代码到ui.go
中:type greeter struct { greeting *widget.Label name *widget.Entry updateGreeting *widget.Button }
-
由于我们在这个例子中没有创建自定义小部件,我们将定义一个名为
makeUI
的小方法,该方法将构建代表此应用程序的小部件。在这种情况下,这是一个简单的垂直框容器,它结合了我们根据前面的描述创建的所有小部件。我们创建每个小部件,将它们分配给greeter
类型的变量,然后返回一个将它们打包在一起的垂直框:func (g *greeter) makeUI() fyne.CanvasObject { g.greeting = widget.NewLabel("Hello World!") g.name = widget.NewEntry() g.name.PlaceHolder = "Enter name" g.updateGreeting = widget.NewButton("Welcome", g.setGreeting) return container.NewVBox(g.greeting, g.name, g.up dateGreeting) }
-
要执行更新,当按钮被点击时,我们需要一个额外的函数
setGreeting
,该函数将使用fmt.Sprintf
格式化一个替换字符串。它将当前name
输入小部件的内容传递进去,以使问候语个性化。这看起来如下所示:func (g *greeter) setGreeting() { text := fmt.Sprintf("Hello %s!", g.name.Text) g.greeting.SetText(text) }
-
最后,我们创建一个简单的
main
函数,该函数将加载问候语,在窗口中显示它,并运行应用程序:func main() { a := app.New() w := a.NewWindow("Hello!") g := &greeter{} w.SetContent(g.makeUI()) w.ShowAndRun() }
你可以通过简单地运行它来验证应用程序是否正确工作,如下所示:
$ go run ui.go
在本节中,我们将关注我们必须执行的测试,因此让我们编写一个单元测试来验证我们之前定义的行为:
-
首先,创建一个测试文件,该文件将与代码一起提供;即
ui_test.go
。在这里,我们定义了一个具有标准签名的测试。它必须以Test
开头,并接受一个参数;即一个testing.T
指针:func TestGreeter_UpdateGreeting(t *testing.T) { ... }
-
在这个函数中,我们将创建我们问候器的全新实例并请求创建界面组件。一旦运行完成,我们断言初始状态是正确的。在这里,我们使用由
stretchr
提供的 testify 项目的assert
包(更多信息可以在github.com/stretchr/testify
找到)。这将使用github.com/stretchr/testify/assert
导入路径,应该添加到文件顶部。通过添加以下代码,您可以设置用户界面以便进行测试并执行其第一次断言:
g := &greeter{} g.makeUI() assert.Equal(t, "Hello World!", g.greeting.Text) assert.Equal(t, "", g.name.Text)
-
编写此测试的最后一步是执行用户步骤并检查结果变化。我们使用 Fyne 的
test
包来模拟用户在输入小部件中输入并点击按钮以确认。之后,我们确认问候文本已被更新:test.Type(g.name, "Joe") test.Tap(g.updateGreeting) assert.Equal(t, "Hello Joe!", g.greeting.Text)
使用这段测试代码,我们可以确保用户界面运行正确。它可以像其他任何 go 测试一样简单地执行:
$ go test .
通过这样,我们知道应用运行正确。然而,验证输出是否按预期渲染可能会有所帮助。让我们编写一个新的测试来做到这一点。
验证输出是否被渲染
在大多数情况下,应用程序可以通过行为测试来测试其正确性,就像我们之前看到的那样。然而,有时实际看到将要渲染的内容以检查结果是有用的。如果您的应用程序包含自定义绘图代码或复杂的布局,这可能很合适。
在本节中,我们将创建一个新的测试,类似于我们在上一节中创建的测试,但在这个情况下,我们将使用另一个test
实用工具来测试渲染输出。让我们看看这是如何工作的:
-
创建一个新的方法,如下面的代码所示。一旦设置了
greeter
类型,将g.makeUI()
传递给test.NewWindow()
。这将创建一个内存中的窗口,我们可以用它来捕获输出,如下所示:func TestGreeter_Render(t *testing.T) { g := &greeter{} w := test.NewWindow(g.makeUI()) }
-
创建测试窗口后,我们可以使用
w.Canvas().Capture()
来获取其内容。这个函数将返回一个图像,界面渲染得就像它在真实窗口中运行一样。现在,我们可以使用
AssertImageMatches
测试实用工具,它要求测试比较此图像与命名文件,如下所示:test.AssertImageMatches(t, "default.png", w.Canvas().Capture())
-
这段代码将比较默认的外观。现在,我们可以再次模拟用户操作,并将新状态与另一个具有适当名称的图像文件进行比较:
test.Type(g.name, "Joe") test.Tap(g.updateGreeting) test.AssertImageMatches(t, "typed_joe.png", w.Canvas().Capture())
您可以像之前的行测试一样运行这些测试,尽管这次测试将失败,因为与之比较的图像不存在。您将在testdata/failed/
目录内找到两个新文件。
您应该查看这些文件以了解正在绘制的内容。如果您认为输出是正确的,那么这些文件可以被移动到testdata/
目录。在第二次运行这些测试时,您将看到它们都按预期通过。以下截图显示了typed_joe.png
文件的外观:
图 8.1 – 测试我们的用户界面代码生成的图像
由于对工具包设计进行更改会导致它们失败,因此此类测试可能很脆弱。然而,它们可以帮助突出显示代码更改导致意外图形变化的情况。因此,当在测试代码中适当使用时,这种方法可以成为验证过程的有价值补充。
本节中我们探讨的测试有助于验证应用程序代码的正确性,并且应该定期运行。确保这一点的最佳方法是让服务器自动运行测试。我们将在下一节探讨这一点。
GUI 的持续集成
持续集成(CI – 以常规方式合并团队的工作进度代码,以便自动测试)已成为软件开发团队的常态。将此流程添加到团队工作流程中已被证明可以更早地突出显示开发过程中的问题,从而更快地修复问题,并最终产生高质量的软件。
这个关键部分包括自动化测试整个源代码,包括 GUI。强烈建议不仅包括整个应用程序代码的常规编译,而且在每次提交时都要运行单元测试。这样做可以帮助你快速识别损坏或行为上的意外变化。为此目的,有各种 CI 工具可用,尽管查看它们超出了本书的范围。当你配置自动化流程时,这些工具很有帮助,因为它们确保本节中探索的测试等成为你常规测试和验收检查的一部分。
我们已经看到,定期进行测试很重要,但如果我们希望为不同的平台编写略有不同的代码,情况会有所改变。在某个时候,大多数应用程序可能需要基于操作系统的系统调用。接下来,我们将探讨如何在保持易于理解的良好代码结构的同时完成这项工作。
管理特定平台的代码
在第二章“根据 Fyne 的未来”中,我们了解到 Go 编译器支持基于环境变量和构建标签的源文件条件包含。随着应用程序添加更多功能,尤其是在平台集成方面,工具包可能无法提供你所需的所有功能。当这种情况发生时,代码需要更新以处理特定平台的功能。为此,我们将使用条件构建的变体——使用命名良好的文件而不是构建标签。这在项目级别上更容易阅读,并且应该清楚地表明哪些文件将编译为哪个平台。
让我们创建一个简单的示例:我们想要大声朗读文本,但我们的代码只能在 macOS (Darwin) 上这样做。我们将在 say_darwin.go
文件中设置一个简单的 say()
函数,来完成我们想要的功能:
package main
import (
"log"
"os/exec"
)
func say(text string) {
cmd := exec.Command("say", text)
err := cmd.Run()
if err != nil {
log.Println("Error saying text", err)
}
}
这个简单的函数调用了内置的 say 工具,这是一个与 macOS 打包的命令行应用程序,允许文本被大声朗读。由于这个文件以 _darwin.go
结尾,它只会在我们为 macOS 构建时编译。为了在其他平台上正确编译,我们需要创建另一个将被加载的文件。我们将把这个文件命名为 say_other.go
:
// +build !darwin
package main
import (
"log"
"runtime"
)
func say(_ string) {
log.Println("Say support is not available for",
runtime.GOOS)
}
在此文件中,我们必须指定构建条件,因为没有为所有其他平台指定特殊的文件名格式。在这里,// +build !darwin
表示该文件将包含在任何非 macOS 平台上。我们将在此文件中提供的简单方法只是记录该功能不受支持。最后,我们必须创建一个简单的应用程序启动器,命名为 main.go
,它将调用 say()
函数:
package main
func main() {
say("Hello world!")
}
运行此代码(使用 go run .
)将在 macOS 计算机上运行时大声朗读 Hello world!
。在其他操作系统上,它将打印一个错误,表明该功能不可用:
Chapter08/say> go run .
2020/10/01 16:46:32 Say support is not available for linux
Chapter08/say>
我们可以以这种方式处理特定平台的代码,使得任何学习当前项目并首次阅读其代码的人都能清楚地理解。另一位开发者可以决定添加一个 say_windows.go
文件来支持在 Windows 上读取文本。只要他们也更新 say_other.go
中的构建规则,应用程序将继续按预期工作,但增加了基于 Windows 的文本读取功能。这种方法的优点是,它不需要我们修改任何现有代码来简单地添加这个新功能。
摘要
在本章中,我们探讨了使用 Go 编写的基于 GUI 的应用程序的一些管理和技巧。通过仔细规划应用程序的模块及其交互方式,我们看到了我们可以使任何应用程序更容易测试和维护。由于更高的测试覆盖率是提高软件应用程序质量的一个因素,我们研究了如何使用这些技术来测试我们的图形代码,这是一个众所周知难以处理的话题。我们通过一个示例来编写一个简单的 GUI 应用程序的测试代码,该代码可以自动运行。
当需要适应特定的操作系统时,我们需要了解我们的代码如何适应。通过适当的抽象或编写特定平台的代码,并通过通用回退来切换,我们可以使我们的应用程序易于维护,尽管存在操作系统差异。
在整本书中,我们一直在命令行旁边运行示例代码,与它们的源代码并列。这意味着我们能够包含当前目录中存在的文件——但是当我们开始分发我们的应用程序时,这将不再可能。
在下一章中,我们将探讨如何将这些额外资源(例如图片和数据文件)包含到我们的应用程序中,为它们的发布做准备。
第九章:第九章:资源打包和准备发布
Go 应用程序以其构建简单的应用程序二进制文件而闻名,这使得它们易于安装。然而,图形应用程序所需的附加数据可能会使这一过程变得具有挑战性,并导致了复杂的包格式以及安装器的引入。Fyne 提供了一种替代解决方案,允许应用程序在大多数平台上再次以单个文件的形式分发。
完成应用程序的打包需要元数据和额外的构建步骤来准备分发文件。这一步骤允许应用程序与系统原生应用程序一起安装到本地系统或开发设备上,我们将在本章中研究这一点。
我们将逐步介绍应用程序在运行时所需的各个文件。我们将涵盖以下主题:
-
如何在您的应用程序中包含额外的文件
-
检查常见的用户体验(UX)错误以改进您的 GUI
-
准备分发应用程序
-
在您的计算机或开发移动设备上安装
在本章的结尾,您将在您的计算机和智能手机上安装应用程序进行实际测试。
技术要求
本章与第三章,Windows、画布和绘图的要求相同:需要安装 Fyne 工具包,并且 Go 和 C 编译器正常工作。有关更多信息,请参阅该章节。
对于部署到 Android 设备,您需要安装 Android SDK 和 NDK(请参阅附录 B,移动构建工具的安装)。要为 iOS 设备构建,您还需要在您的 Macintosh 计算机上安装 Xcode(由于许可原因,需要 Mac)。
本章的完整源代码可以在github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne/tree/master/Chapter09
找到。
资产打包
Go 应用程序设计为从单个二进制文件运行。这意味着它们可以轻松分发,并且不依赖于安装脚本。不幸的是,这种好处给开发者带来了成本——我们不能像网络或移动应用程序开发者那样(以及我们在开发过程中所做的那样)依赖于应用程序中找到的资源。为了确保我们的应用程序符合这种设计,我们必须将任何所需的资产嵌入到应用程序二进制文件中。这包括字体、图像以及任何其他应用程序正常运行所需的静态内容。
Fyne 工具包为使用 Fyne 构建的任何应用程序提供了打包资源的工具。使用此工具的好处是它为每个嵌入的资源生成fyne.Resource
定义,这使得将嵌入的资产传递到各种 Fyne API 变得容易。实际上,这个打包工具是项目fyne
命令行工具中的一个命令,本书中的许多示例都使用了这个命令。该命令可以通过单个go get
命令安装,如下所示:
Chapter09/bundle$ go get fyne.io/fyne/cmd/fyne
bundle
命令简单地将文件系统中的资产转换为 Go 源代码,然后可以将其编译成应用程序。这意味着编译后的应用程序将包含资产,因此当应用程序运行时不需要依赖文件系统中的资产。bundle
命令是fyne
可执行文件的一部分,它将嵌入的文件作为其主要参数。它将结果打印到系统输出,因此我们使用控制台重定向(>
)将生成的 Go 源代码发送到合适的文件,如下面的代码片段所示:
Chapter09/bundle$ ls
data
Chapter09/bundle$ fyne bundle data/demo.svg > bundled.go
Chapter09/bundle$ ls
bundled.go data
Chapter09/bundle$
文件生成后,我们可以使用创建的符号(类型为*fyne.StaticResource
,实现了fyne.Resource
接口)来引用它。这可以像任何其他资源一样使用,因此我们可以以下面的方式将其加载为图像:
image := canvas.NewImageFromResource(resourceDemoSvg)
生成的变量名可能不适合您的使用,但可以通过添加额外的命令参数来更改。例如,如果您想导出这个新符号,可以通过添加-name Demo
参数来指定一个以大写字母开头的简单名称,如下所示:
Chapter09/bundle$ fyne bundle -name Demo data/demo.svg > bundled.go
前面的命令管理单个资产的包含,但大多数应用程序需要包含许多资产。让我们看看如何添加多个资源。
包含多个资产
在前面的示例中,我们打包了一个包含所有必需头文件的单一文件,以便将打包文件变成一个完整的 Go 源文件。要以这种方式打包多个文件,我们需要为每个资产创建一个新的打包文件。这可能不是最佳选择,因此打包工具包括一个-append
参数,可以用来向同一个打包文件添加更多资产。
要打包第二个文件,我们使用这个新参数并将控制台重定向符号更改为追加版本(>>
)。例如,我们可以将demo2.svg
添加到同一个打包输出中:
Chapter09/bundle$ fyne bundle data/demo.svg > bundled.go
Chapter09/bundle$ fyne bundle -append data/demo2.svg >> bundled.go
生成的bundle.go
文件将包含两个定义,resourceDemoSvg
和resourceDemo2Svg
。
以这种方式,您可以嵌入许多资源,但需要为每个资源添加额外的命令,这可能会很耗时且容易出错。相反,我们可以使用单个命令将目录中的所有资产打包在一起。为此,我们只需使用目录路径而不是文件名,使用与第一个打包相同的语法。以下目录打包的结果将生成与之前显示的两个文件命令相同的输出:
Chapter09/bundle$ fyne bundle data > bundled.go
如您所见,使用单个命令嵌入大量数据可以非常强大。生成的文件(bundle.go
)应该添加到您的版本控制中,这样其他开发者就不必运行此命令。
为了支持这一点,当资产位于单独的目录中时,简单的配置效果最好。因此,除了在 第八章 中讨论的文件结构,项目结构和最佳实践,通常还会在将利用嵌入式资产的代码旁边添加 data
目录。
然而,当资产更新时,开发者可能不会记得要使用的命令,因此我们将简要地看看如何自动化这个过程。
自动化捆绑命令
Go 编译器有一个有用的 generate
子命令,可以用来处理资源,例如我们在这个部分捆绑的资产。为了使用这个工具,我们在我们的源文件之一(而不是生成的文件,因为这个文件将被覆盖)中添加了一个 //go:generate
头文件。
在这个简单的示例中,我们添加了一个名为 main.go
的新文件,它仅仅是为了包含这个头文件(通常情况下,已经存在一个文件)。在这个文件中,我们在包名之前添加了一行头文件,告诉 Go 如何生成我们的资源。
//go:generate fyne bundle -o bundled.go data
package main
你可以看到,我们之前调用命令的方式发生了变化——添加了一个 -o
参数,后面跟着我们想要输出的名称。这是引入的,因为在 generate
命令中,我们不能使用之前使用的命令行重定向工具。该参数具有相同的效果——它指定了输出应该发送到哪个文件。因此,当我们运行 go generate
时,我们会看到与手动捆绑数据目录相同的结果。如下所示:
Chapter09/bundle$ ls
data main.go
Chapter09/bundle$ go generate
Chapter09/bundle$ ls
bundled.go data main.go
使用前面的工具,我们已经准备了一个没有资产文件的应用程序工作,这使得它更容易分发。在我们开始打包之前,我们还应该检查 Fyne 工具包是否有其他对我们应用程序的建议。
检查 UI 提示
由于 Fyne 是基于 Material Design 原则构建的,因此可以参考其关于如何使用某些组件的推荐,以及如何组合元素以实现良好的用户体验。
Fyne 工具包中内置了 提示 的概念。这些是关于应用程序如何进行更改以提供改进的用户界面的建议。
我们将通过创建一个简单的示例标签容器应用程序来开始探索这些提示可以提供什么。这个代码片段将把两个标签加载到标签容器中(makeTabs()
函数)。然后我们包含一个 main()
函数,该函数将加载一个新的应用程序,创建一个窗口,并将标签设置为它的内容。然后函数以通常的方式运行我们的应用程序,如下所示:
package main
import (
"fyne.io/fyne/app"
"fyne.io/fyne/container"
"fyne.io/fyne/theme"
"fyne.io/fyne/widget"
)
func makeTabs() *container.AppTabs {
return container.NewAppTabs(
container.NewTabItemWithIcon("Home",theme.HomeIcon(),
widget.NewLabel("Tab 1")),
container.NewTabItem("JustText",
widget.NewLabel("Tab 2")),
)
}
func main() {
a := app.New()
w := a.NewWindow("Tabs hints")
w.SetContent(makeTabs())
w.ShowAndRun()
}
有了这段代码,我们可以像平常一样运行它。然而,这次我们将传递额外的 -tags hints
参数来启用建议:
Chapter09/hints$ go run -tags hints .
当运行时,您将看到如图图 9.1所示的应用程序:
![Figure 9.1 – The Tabs app looks like it’s working correctly
![img/Figure_9.1_B16820.jpg]
图 9.1 – 标签应用程序看起来运行正确
在应用程序的外观方面,实际上并没有太多惊喜,但如果您检查打印到命令行中的应用程序输出,您将注意到很多我们之前没有看到的内容。您可能会看到以下类似的内容(它可能基于使用的 Fyne 版本而有所不同):
Chapter09/hints$ go run -tags hints .
2020/10/07 14:06:08 Fyne hint: Applications should be
created with a unique ID using app.NewWithID()
2020/10/07 14:06:08 Created at:
.../Chapter09/hints/main.go:18
2020/10/07 14:06:08 Fyne hint: TabContainer items should all
have the same type of content (text, icons or both)
2020/10/07 14:06:08 Created at:
.../Chapter09/hints/main.go:11
如您在前面的输出中看到的,有两行被标记为Fyne 提示
。这些每一行都是新建议的开始。每个实例之后的行有助于显示提示在代码中的位置(为了清晰起见,路径部分已被删除)。前面的提示告诉我们以下信息:
-
我们的应用程序缺少某些功能正常工作所需的唯一 ID – 如果您阅读了第六章,数据绑定和存储,您可能已经了解了
appID
。我们将在本章后面的元数据、图标和应用程序 ID部分进一步讨论这个问题。 -
我们创建的标签容器混合了多种标签样式;一个有图标,另一个没有。我们可以通过向
JustText
标签添加图标或删除Home
标签图标来解决这个问题。
如您所见,检查您应用程序的提示可能会有所帮助。通过这样做建议的小改动可以导致用户体验的改善,或者解决尚未遇到的问题。
当我们的应用程序现在准备好打包时,我们需要考虑它在应用名称、图标和其他元数据方面的展示方式。
选择元数据、图标和应用程序 ID
在我们开始创建应用程序发布的技术细节之前,有一些先决条件需要考虑。应用程序的名称现在可能已经确定,但您有出色的描述吗?您知道如何以吸引潜在用户注意的方式阐述您软件的关键特性吗?您(或您的设计团队)是否创建了一个令人难忘且能体现其功能的优秀应用程序图标?
如果您不会通过管理渠道分发您的应用程序,例如应用商店或平台包管理器,您应该考虑您的目标受众将如何发现应用程序。关于搜索引擎优化(SEO)的讨论和信息在网上有很多,关于应用商店优化(ASO)的内容也在不断增加,所以我们在这里不会详细介绍。在当前的软件环境中,很明显,您应用程序的可发现性和记忆性现在比以往任何时候都更重要。最重要的三个方面是应用程序的图标和描述,以及它在每个商店中使用的唯一标识符。我们将首先探讨应用程序图标的细节。
应用程序图标
选择图标可能是准备发布应用程序最重要的单个部分。它需要让人印象深刻,并且能够唤起一些关于软件用途的想法。一个优秀的图标在显示为大或小尺寸时都应该看起来很好,而且通常应该避免使用过多的细节,或者只用于设计的不重要方面。确保你的图标以高分辨率创建;使用矢量格式是可取的(例如,SVG),但如果你正在使用位图格式(例如,PNG),那么 1024 x 1024 像素是图标在广泛设备上看起来很棒的最小要求。同时,考虑透明度的使用也很重要——根据你希望分发的平台,这可能或可能不被推荐。大多数桌面系统允许使用形状图标,但并非所有都允许半透明区域,iOS 则完全不允许透明度,而 Android 则鼓励使用透明度。
花些时间查看你预期应用程序将在其中使用的每个操作系统或桌面环境中的流行或常见图标。你能否成功地匹配你的图标风格与它们中的每一个?这些系统的用户是否期望特定的形状或风格?可能最好,或者必要的是,为不同的平台创建不同的图形版本。这样做没有问题,并且可以通过在后面章节中学习的构建命令传递不同的图标来实现。
本章后面的打包命令允许指定图标;然而,如果你想为你的应用程序设置一个默认图标,只需将其命名为Icon.png
或Icon.svg
。
描述你的应用程序
在这个发展阶段,你可能已经开始吸引你的受众并了解他们喜欢软件的哪些方面,以及目标用户是谁。如果没有,那么不要害怕——只需注意,现在是考虑如何让你的描述和支持材料最能吸引新用户的时候。无论是通过网络搜索引擎还是应用市场,你使用的文本对于说服任何人安装你的应用程序至关重要。除了应用程序的名称和其主要功能外,确保你考虑了它如何为你的用户带来好处。你期望他们在寻找你构建的解决方案时尝试完成哪些任务?不用担心让这段文字太长,但尽量包括这些重要观点。
如何具体发布您的应用程序将在第十章中进一步讨论,分发 – 应用商店及其他,但无论您打算通过在线商店还是简单的网站发布应用程序,在继续发布流程之前,确保您已经完成了元数据是很明智的。我们在这里准备的信息将嵌入我们创建的包中,并且它与本章及以后将使用的分发元数据保持一致性非常重要。用户的信任可能会迅速丧失,例如,如果应用程序图标与预览不匹配,可能会引起担忧。请记住,描述应与名称和图标相匹配,以便用户安装后能快速识别。
应用标识符(appID)
正如我们在 检查 UI 提示 部分中看到的,每个 Fyne 应用程序在某个时候都需要一个唯一标识符。如果您已经使用了首选项或存储 API,那么这可能已经设置好了;然而,如果您还没有,那么您需要在此时选择 app ID,因为它对于在许多操作系统上打包您的应用程序是必需的。
应用标识符用于唯一识别此软件;除了是全球唯一的,它永远不能改变。意外更改它可能会导致用户丢失数据,也可能意味着不会向现有软件用户发送更新,所以现在选择一个,并确保它保持一致。
选择您独特 ID 的常规方案是使用反向 DNS 表示法。这种格式对与 Java 或 Android 包或苹果的 统一类型标识符(UTI)工作过的开发者来说很熟悉。该格式基于每个开发者、公司或产品都有一个网站或主页地址,可以用作其工作的命名空间的假设。当应用这种分组时,可以使用附加信息来识别软件组件,使其成为全球唯一标识符。反向 DNS 的 反向 部分对于排序和搜索很有用,这就是为什么它在软件组件管理中变得流行。
通用格式如下:
<extension>.<domain name>.<optional categories>.<app name>
因此,按照这种格式,一个名为 myco.com
的公司,在其 productivity
软件类别中发布名为 tasks
的产品时,可能会使用以下 app ID:
com.myco.productivity.tasks
在初始反向域名之后的字符串内容可以是您选择的任何内容;添加类别或另一个标识符很常见。然而,不建议添加版本号,因为这个字符串必须在其软件生命周期内保持相同,以避免之前描述的一些潜在问题。
如果你没有为你的应用程序创建网站,你可以选择使用它存储的位置。即使你将来移动位置,这也没有关系,因为这只是一个标识符——如果你确实移动了存储库位置,请确保保持它不变。例如,存储在 GitHub 上的用户dummyUser
的教程应用程序可能采用以下应用程序 ID——请注意,域名中有一个第三元素以保持全球唯一性:
com.github.dummyUser.tutorial
现在我们已经整理好元数据,我们可以开始打包我们的应用程序,然后将其安装到我们的测试设备上。
打包应用程序(桌面和移动)
要整合前面章节中准备好的元数据,我们需要执行打包阶段。这将从标准的 Go 应用程序二进制文件中提取所需数据,并根据操作系统的具体情况进行附加或嵌入。由于每个平台都需要不同的数据格式并产生不同的结果文件结构,我们再次使用fyne
工具来处理细节。
为你的当前计算机打包
要从 Fyne 项目创建包,我们使用fyne package
命令。默认情况下,这将创建适用于当前操作系统的应用程序包或可执行文件。在 macOS 上运行时,这将创建一个.app
包;在 Windows 上,它将是一个.exe
文件(带有附加的元数据);在 Linux 上,它创建一个.tar.gz
文件,可以用来安装应用程序。
你也可以使用-os
参数为不同的系统构建,我们将在本章后面探讨这一点。
在打包之前,确认你的应用程序使用go build
命令成功构建是一个好主意。当你的应用程序准备就绪时,只需执行fyne package
命令,它将处理你的应用程序和元数据以创建适合平台的输出。例如,在 macOS 计算机上你会看到以下内容:
Chapter09/package$ ls
Icon.png main.go
Chapter09/package$ go build .
Chapter09/package$ ls
Icon.png main.go package
Chapter09/package$ fyne package
Chapter09/package$ ls
Icon.png main.go package package.app
你可以看到go build
命令创建了一个常规的二进制文件,而fyne package
创建了一个应用程序包。在 macOS Finder 中打开时,你可以看到图标是如何应用到输出应用程序包上的:
图 9.2 – macOS 构建的文件图标
如果你在 Linux 计算机上运行相同的命令,你会看到以下内容:
Chapter09/package$ ls
Icon.png main.go
Chapter09/package$ go build .
Chapter09/package$ ls
Icon.png main.go package
Chapter09/package$ fyne package
Chapter09/package$ ls
Icon.png main.go package package.tar.gz
要了解如何安装我们刚刚构建的应用程序,你可以跳转到安装你的应用程序部分。然而,如果你想要为移动设备准备构建,请继续阅读,因为我们将接下来进行这一操作。
为移动设备打包
由于移动应用程序不能在设备上创建,它们必须从桌面计算机打包,然后安装到移动设备上。我们使用与前面章节相同的工具,并添加-os
参数指定ios
或android
作为目标系统。
由于移动应用程序需要应用 ID 来构建,我们还必须传递与本章前面应用程序标识符(appID)部分中讨论的唯一标识符一起的appID
参数。
在为 iOS 或 Android 设备打包之前,您需要安装 Xcode 或 Android 开发者工具(在附录 B**,移动构建工具的安装中更详细地讨论)。
在 macOS 计算机上安装了 Xcode(由于苹果的许可限制),您可以使用以下命令构建 iOS 应用程序:
$ fyne package -os ios -appID com.example.myapp .
要构建 Android 应用程序包(.apk
),请使用以下命令:
$ fyne package -os android -appID com.example.myapp .
现在您已经准备好了应用程序包或二进制文件,我们将看到如何在您的桌面和移动设备上简单地安装您的应用程序。
安装您的应用程序
如果您只想在计算机或开发设备上安装桌面应用程序,则可以使用有用的install
子命令。install
工具有两种模式,首先是在当前计算机上安装,其次是在已设置为开发模式的移动设备上安装。
在您当前的计算机上安装
要将您的应用程序安装到您当前的计算机并使其系统范围内可用,您可以简单地执行以下操作:
$ fyne install -icon myapp.png
图标文件是将应用程序安装到桌面所需的最小元数据。如果您想避免每次都传递-icon
参数,只需将文件重命名为Icon.png
,它将默认使用。一旦应用程序安装完成,您将在计算机的程序列表中看到它,并显示适当的图标。
在移动设备上安装
在这个阶段,如果设备已设置为开发模式,我们可以将应用程序安装到移动设备上。
注意
开发配置可能很复杂,并且超出了本书的范围。
您可以在help.apple.com/xcode/mac/current/#/dev5a825a1ca
上阅读有关 iOS 设备的更多信息。
对于 Android 设备,您可以在developer.android.com/studio/debug/dev-options
上阅读文档。
使用已启用开发的移动设备,可以通过传递-os
参数作为android
或ios
来使用相同的安装命令进行安装。例如,要将生成的.apk
文件安装到您的 Android 设备上,请使用以下命令:
$ fyne install -os android -appID com.example.myid -icon myapp.png
如您所见,对于移动应用程序安装,我们需要额外的appID
元数据值。这个值被传递到我们在上一节中探讨的package
命令中。如果包是最新的,这个值可能不是必需的,但通常出于谨慎考虑最好还是传递它。
因此,你可以看到在当前计算机或连接的移动设备上安装应用程序非常简单。为了实现这一点,Fyne 工具实际上进行了交叉编译(这意味着为不同类型的计算机编译)。现在让我们更详细地了解一下它是如何工作的。
轻松交叉编译
能够编译不同于当前计算机的操作系统或架构的能力被称为 交叉编译。我们在上一节中看到它被用于从桌面计算机打包和安装移动应用。通过交叉编译,我们还可以从一台计算机为其他类型的桌面构建应用程序,例如使用 Windows 构建 macOS 应用程序。
这可以通过两种方式完成。首先,我们将看到熟悉特定平台编译的开发者如何使用他们的常规工具为多个平台构建。之后,我们将探讨 fyne-cross
工具以及它是如何通过使用 Docker 镜像来管理编译而隐藏所有复杂性的。
使用已安装的工具链
当采用传统的交叉编译方法时,计算机将需要为开发者想要支持的每个平台和架构添加一个额外的 工具链。这提供了编译图形和系统集成代码的能力,通常包括一个 C 编译器和链接器。每个工具链的安装方式取决于当前的操作系统以及目标工具链。各种安装的详细信息可在 , 交叉编译 中找到。
在安装了工具链之后,构建过程就像常规的 Go 交叉编译一样,你需要指定 GOOS
和 GOARCH
环境变量来指定目标操作系统和架构。然而,我们还需要指定 CGO_ENABLED=1
(以便启用 C 集成)以及一个指定要使用哪个工具链编译器的 CC
环境变量。
最常用编译器和要使用的 CC
环境变量的简要总结如下(更多信息请参阅 , 交叉编译):
表 9.1 – 各桌面平台下载、备注和 CC 环境
在安装了适当的编译器和库之后,我们可以继续到构建阶段。对于每个目标操作系统,你需要设置正确的环境变量并运行这些步骤。建议先为单个平台构建,然后在更改到下一个配置之前,为每个平台完成包装步骤。这是因为一个平台的发布二进制文件可能会覆盖另一个(例如,当编译时,macOS 和 Linux 的二进制文件具有相同的名称)。
要查看这是如何工作的,我们将在 macOS 计算机上启动一个终端,并编译和打包当前 macOS 系统的应用程序,然后是 Windows 和 Linux。你可以使用任何项目;以下示例使用本章早些时候的打包示例。让我们看看结果如何:
-
首先,我们检查应用程序是否正在为当前系统正确构建。对于我们的 macOS 主机计算机,这将创建一个
package.app
文件,因为我们正在构建的应用程序名为package
:$ ls Icon.png main.go $ fyne package $ ls Icon.png main.go package package.app
在继续之前,我们应该删除任何临时文件,因为我们只是进行测试,所以也可以删除我们创建的打包应用:
$ rm -r package.app package
-
接下来,我们将为 Microsoft Windows 进行构建。如前表所述,这需要使用 Homebrew 或其他包管理器安装
mingw-w64
包。安装后,我们可以设置环境变量,包括将CC
设置为指定编译器。命令将如下所示:package.exe file as well as a .syso temporary file (this is what Windows builds use to bundle metadata – it can normally be ignored). Before packaging for Linux, we will remove these files:
删除
package.exe
和fyne.syso
-
从 macOS 准备 Linux 构建需要更多的工作。首先,你需要安装编译器工具链,这是 Homebrew 中的
FiloSottile/musl-cross/musl-cross
包。安装此包后,你需要找到并安装适合 Linux 开发的合适的 X11 和 OpenGL 包(这里的详细信息将根据你构建的 Linux 计算机而变化;更详细的信息可以在 , 交叉编译 中找到)。一旦所有这些都安装好了,你就可以像之前使用 Windows 命令一样执行 Linux 构建,但使用适当的CC
变量:$ GOOS=linux CC=x86_64-linux-musl-gcc CGO_ENABLED=1 fyne package $ ls Icon.png main.go package package.tar.gz
因此,你可以看到,从单个开发计算机构建所有不同的平台是可能的。
然而,这涉及到大量的包安装和配置。为了避免这种复杂性,有一个有用的工具,fyne-cross
,它打包了所需的文件以简化交叉编译。
对于前面的每个构建,如果我们想针对例如 32 位计算机进行构建,同时在我们 64 位桌面上构建,我们也可以指定一个 GOARCH
变量。同样,指定 ARM 架构允许我们为树莓派计算机编译。
注意,iOS 和 Android 目标不使用传统的工具链
为移动目标构建的能力由特定平台的发展包提供(例如,Xcode 或 Android SDK)。这意味着你可以避免手动编译器配置,但需要使用 fyne
包而不是传统的 go build
过程。
使用 fyne-cross
工具
fyne-cross
工具是为了为 Fyne 工具包提供一个简单的交叉编译方法而创建的。它利用 Docker 容器来打包所有构建工具,这样开发者就不必手动安装它们,就像我们在上一节中所做的那样。你可以在github.com/fyne-io/fyne-cross
上了解更多关于这个项目的信息。
使用 fyne-cross
,您可以在命令行上简单地指定您想要构建的平台,该工具将设置开发环境并按请求构建包。platform
参数类似于我们之前使用的 -os
参数。
要能够安装和使用此工具,我们需要的只是我们现有的 Go 编译器和 fyne-cross
的安装。
在此示例中,我们有一个 macOS 计算机为 Linux 构建应用程序(这是前一个章节中复杂的配置):
-
首先,我们必须安装 Docker。最简单的方法是下载并从他们的网站运行桌面安装程序,网址为
docs.docker.com/get-started/
。不幸的是,这不支持 Linux,因此您需要使用您的包管理器安装 Docker 引擎(通常在名为docker
的软件包中)。 -
要运行
fyne-cross
,Docker 应用必须正在运行。如果您使用 Docker Desktop,您应该在系统托盘中看到图标(见 Figure 9.3 中的左侧图标)。如果它没有运行,只需使用其启动图标(Figure 9.3 中的右侧图标)打开应用程序即可:Figure 9.3 – The Docker running symbol and app icon
如果在 Linux 上运行,请确保根据您特定发行版的文档启动服务。
-
要安装
fyne-cross
工具,我们使用go get
命令的版本,它将把它与其他基于 Go 的应用程序一起安装到~/go/bin/
目录中:$ go get github.com/fyne-io/fyne-cross
-
接下来,我们发出运行
fyne-cross
的命令。基本构建需要一个参数,即我们想要为哪个操作系统构建,因此对于 Linux,我们只需调用以下命令:$ fyne-cross linux [i] Target: linux/amd64 [i] Cleaning target directories... [√] "bin" dir cleaned: /.../Chapter09/package/fyne-cross/bin/linux-amd64 [√] "dist" dir cleaned: /.../Chapter09/package/fyne-cross/dist/linux-amd64 [√] "temp" dir cleaned: /.../Chapter09/package/fyne-cross/tmp/linux-amd64 [i] Checking for go.mod: /.../Chapter09/package/go.mod [i] go.mod not found, creating a temporary one... Unable to find image 'fyneio/fyne-cross:base-latest' locally base-latest: Pulling from fyneio/fyne-cross (downloads lots of stuff) [√] Binary: /.../Chapter09/package/fyne-cross/bin/linux-amd64/package [i] Packaging app... [√] Package: /.../Chapter09/package/fyne-cross/dist/linux-amd64/package.tar.gz
-
一旦完成(第一次运行将花费一些时间,因为需要下载 Docker 镜像),我们应该看到为我们创建了预期的包:
$ ls fyne-cross/dist/linux-amd64 package.tar.gz
如您所见,fyne-cross
工具能够为其他难以编译的系统创建应用程序包。
对支持列表(在撰写本文时)中的任何操作系统和平台进行的构建包括以下内容:
-
darwin/amd64
-
darwin/386
-
freebsd/amd64
-
linux/amd64
-
linux/386
-
linux/arm
-
linux/arm64
-
windows/amd64
-
windows/386
-
android
-
ios
注意
iOS 编译仅在 macOS 计算机上受支持。您需要从 Apple App Store 下载并安装 Xcode。这是 Apple 许可证的限制,不幸的是无法绕过。
如果您能够安装 Docker,这可能是为不同计算机构建的更简单方法。
摘要
在本章中,我们看到了将应用程序从源代码运行到打包文件准备分发所涉及的步骤。我们看到了可用的技术和工具,可以帮助使应用程序可移植,以及 Fyne 工具包如何提供改进 UX 的提示。
我们还探索了交叉编译的世界以及如何为不同的操作系统创建应用程序包。正如本章所示,您可以将您的开发计算机设置为构建所有受支持的平台;然而,我们发现这可能会很复杂。fyne-cross
工具被引入作为一种解决这种复杂性并使为众多潜在目标系统打包构建变得简单的方法。
在下一章中,我们将探讨如何分发这些文件。我们将探讨您如何与测试人员共享打包文件,以及如何准备适用于应用商店和市场上传所需的认证包。
第十章:第十章:分发 – 应用商店及其他
跨平台开发的最终挑战是如何将您的应用程序分发到系统应用商店、移动市场和下载站点。无论您是想通过平台特定的应用商店、包管理器还是通过简单的下载站点进行分发,都需要做更多的工作。本章探讨了将应用程序分发给系统应用商店、移动市场和下载站点的步骤。
本章将涵盖以下主题:
-
为发布构建应用程序
-
将应用程序分发到桌面应用商店
-
将应用程序上传到 Google Play 和 iOS 应用商店
到本章结束时,您应该具备将您的应用程序分发到任何平台的知识。
技术要求
本章与第三章的要求相同,窗口、画布和绘图,即需要安装Fyne工具包,并且Go和C编译器正在运行。更多信息,请参阅该章节。
对于部署到Android设备,您需要安装Android SDK和NDK(见附录 B,移动构建工具的安装)。要为iOS设备构建,您还需要在您的Macintosh计算机上安装Xcode(由于许可原因,需要苹果 Mac)。
本章的完整源代码可以在github.com/PacktPublishing/Building-Cross-Platform-GUI-Applications-with-Fyne/tree/master/Chapter10
找到。
为发布构建应用程序
正如我们在第九章中看到的,捆绑资源和准备发布,fyne package
命令将我们的应用程序二进制文件和元数据打包成可以在fyne release
上安装的格式。
在本节中,我们将学习如何使用release
命令为共享准备应用程序。
运行发布命令
就像我们在第九章中看到的fyne
package
命令一样,捆绑资源和准备发布,这个新的release
命令负责将我们的应用程序及其元数据打包。然而,release
命令会对应用程序进行更改,以准备分发。具体更改将根据操作系统而有所不同,但通常包括以下内容:
-
关闭任何调试输出
-
指示应用程序使用生产身份验证的 Web 服务
-
将应用程序打包成特定于分发的存档
-
申请应用商店所需的认证
在我们学习本章的过程中,我们将查看每个平台特有的选项,但在本节中,我们可以探索如何为任何操作系统适配应用程序的发布构建。
让我们假设在我们的应用程序中有一个名为connectToServer()
的函数,它将启动与公司服务之一的网络连接。在整个开发过程中,它一直连接到开发服务器,但对我们分发的应用程序,我们希望使用生产(有时称为实时)服务器。
以下步骤演示了如何使用此类构建来适当地调整代码以适应发布:
-
为了构建这个演示,我们创建一个新的
main.go
文件,定义了两种不同的服务器认证密钥,serverKeyDevelopment
和serverKeyProduction
:const ( serverKeyDevelopment = "DEVELOPMENT_KEY" serverKeyProduction = "PRODUCTION_KEY" )
-
接下来,我们添加一个简单的函数,该函数打开一个对话框窗口,显示将要使用的认证密钥:
func connectToServer(a fyne.App, w fyne.Window) { key := serverKeyDevelopment if a.Settings().BuildType() == fyne.BuildRelease { key = serverKeyProduction } dialog.ShowInformation("Connect to server", "Using key: "+key, w) }
如您所见,此函数接受当前的
fyne.App
作为参数之一,这样我们就可以使用BuildType()
函数查询构建类型。第二个是当前的fyne.Window
参数,我们使用它来显示此示例的对话框。此类函数通常会返回服务器连接,但这只是一个简单的演示。 -
就像之前的示例一样,我们还需要创建一个基本的
main
函数来运行示例。在这种情况下,我们打开一个新窗口,显示简单的connectToServer
方法,将演示构建类型:func main() { a := app.New() w := a.NewWindow("Server key demo") w.SetContent(widget.NewLabel("Connecting...")) w.Resize(fyne.NewSize(300, 160)) connectToServer(a, w) w.ShowAndRun() }
-
现在我们简单地运行应用程序:
$ go run .
您应该看到它创建了一个窗口,如下面的截图所示:
![图 10.1 – 开发模式下的运行
![图 10.1 – 开发模式下的运行
图 10.1 – 开发模式下的运行
-
接下来,让我们构建用于发布的应用程序。由于我们现在正在打包应用程序元数据,我们需要一个图标文件。书中包含了示例文件
Icon.png
,但您也可以添加任何喜欢的图标,将其放置在main.go
旁边。然后我们可以使用fyne
工具准备发布版本:$ fyne release
-
通过双击应用程序运行打包的应用程序,您应该看到不同的输出:
![图 10.2 – 发布模式下的运行
![图 10.2 – 发布模式下的运行
图 10.2 – 发布模式下的运行
上述示例是关于release
命令及其如何调整应用程序行为的简单介绍。随着我们在本章中使用它,您将看到各种系统为认证和其他功能所需的附加参数。我们本可以在此处添加有关版本和构建号(使用-appVersion
和-appBuild
)的信息,但对于大多数桌面发布来说,这是可选的。我们将在本章的后面部分开始将其添加到命令中。
现在应用程序已在此部分捆绑,我们可以将其分发给测试人员或小型社区,他们愿意通过从网络下载来管理自己的软件安装。
在网络上分享您的应用
现在应用程序已设置好,在适当的地方使用生产值,我们可以开始制定分发计划。在许多情况下,fyne release
创建的文件将与fyne package
(因为此命令专注于应用程序商店和市场分发)的文件格式不同。要将发布参数应用于之前的包格式,您可以使用fyne package –release
。
如果您想在不用平台官方分发的情况下分享您的应用程序,可以通过将release
命令的结果上传到网站或文件共享平台来实现。在大多数情况下,这只是一个简单地将文件复制或上传到您想要分享的地方。然而,在某些系统(主要是 macOS)中,应用程序是一个包或目录,这可能无法通过网站链接下载。在这些情况下,将文件集压缩或归档成一个可下载的单个文件是一个好主意。
在您的 mac(macOS 应用程序通常创建的地方)中,您可以在Finder中打开应用程序文件夹,右键单击包,然后从上下文菜单中选择压缩
![图 10.3 – 压缩 macOS 应用程序包以便在网络上共享]
![img/Figure_10.3_B16820.jpg]
图 10.3 – 压缩 macOS 应用程序包以便在网络上共享
生成的.zip
文件可以更容易地从网站共享,下载它的人只需双击文件即可展开您的应用程序,然后可以像平常一样运行。
在本节中,我们了解了如何打包应用程序以供发布,以及如何共享生成的文件,以便他人可以安装我们的软件。然而,对于大多数应用程序来说,通过提供的商店或市场进行分发将更有益。在接下来的几节中,我们将逐步介绍这一过程,针对最常见的操作系统。
将应用程序分发到桌面应用程序商店
大多数桌面操作系统现在都有一个用于发现和安装应用程序的中心位置。苹果创建了Mac App Store,Windows 有Microsoft Store,每个 Linux 发行版都有自己的首选包管理器。将应用程序列在(并由平台市场托管)可以显著增加您预期的用户数量,并降低相关的托管成本。当与精心准备的元数据(如第九章,捆绑资源和准备发布)相结合时,市场可以轻松成为您最大的分发渠道。
在本节中,我们将逐步介绍 macOS 和 Windows 商店的过程。我们将在本章的后面部分回到 Linux 和 BSD 分发,因为它们不太主流,而且更加复杂。
Mac App Store
Mac App Store 是苹果公司著名的 iOS App Store 的桌面版本。它提供了成千上万的应用程序可供购买和下载,或赠送给他人。此外,还有精选内容,包括各种类别中最受欢迎的应用程序列表,以及员工精选和推荐软件。不幸的是,Mac App Store 不能在线浏览,因为它需要预安装在兼容 Mac 电脑上的 App Store 软件。
要在 App Store 上分发应用程序,你需要安装开发工具(参见附录 B,移动构建工具的安装)),但你还需要注册Apple Developer Program。如果你还不是会员,你可以在developer.apple.com/programs/enroll/注册。开发资源是免费访问的,但为了访问代码签名工具,你需要支付年度订阅费用,这些工具是完成我们即将探讨的发布过程所必需的。
打包 macOS 发布版本
将发布版本打包上传到 Mac App Store 的过程类似于我们在本章前面探索的发布过程,但我们还必须提供适用于对应用程序进行代码签名的认证细节。
代码签名是一个复杂的设置过程,因此在本描述中,我们假设你已经安装了分发证书。有关下载证书的进一步说明,请参阅苹果公司的文档developer.apple.com/support/certificates/。你需要注意证书的名称(稍后使用build
命令时需要。你还需要注意你正在使用的配置文件。与证书一样,这应该已经下载并安装在你的电脑上,但你需要注意其名称(你可以在苹果开发者门户developer.apple.com/account/resources/certificates/上找到更多详细信息)。
一旦你确定了证书名称和配置文件,你就可以使用以下命令中的详细信息:
$ fyne release -appVersion 1.0 -appBuild 1 -certificate "CertificateName" -profile "ProfileName"
生成的应用包已准备好上传到App Store Connect网站进行验证。
上传到 Mac App Store
App Store 应用程序通过 App Store Connect 网站(appstoreconnect.apple.com/)进行管理。使用你的苹果开发者账户登录并创建一个新的应用程序(如果你还没有这样做)。这是你添加将在商店中显示的元数据的地方——务必仔细检查信息,因为一些数据在发布后无法更改。
选择的描述和截图将有助于您的应用程序更容易被发现。在此应用程序定义中,您需要开始准备一个新版本,包括适当的版本号和相关支持信息。您可能会注意到您还不能选择构建版本——为了启用此功能,我们首先需要上传编译包。
苹果公司最近创建了一个名为Transporter的新应用程序,目前这是上传新构建版本最简单的方式。您可以按照以下步骤进行操作:
-
打开 Transporter 应用程序,并使用您的Apple ID登录。
-
登录后,您将被要求选择要上传的应用程序——选择上一节中由
release
命令创建的应用程序包,然后进行上传。 -
一旦完成,构建版本将出现在 App Store Connect 网站上(您可能需要刷新页面)。如果您更喜欢使用命令行工具来管理上传进度,可以使用xcrun altool,它提供相同的功能。无论您以何种方式上传包,您都需要在网站上的应用程序详情中选择相应的构建版本。
-
一旦选择了这个新构建版本,您就可以点击提交审查按钮开始审查流程。
我们现在将进入下一节,了解审查流程。
Mac App Store 审查流程
一旦应用程序提交审查,它就会通过一系列自动的代码检查。这个过程验证应用程序不包含元数据或代码签名中的明显错误,并执行代码分析以确保您没有使用苹果公司专有的或受限制的 API。假设这些自动检查通过,那么应用程序将被发送给 App Store 审查团队的成员进行最终接受。
审查团队会检查您的应用程序的质量、可靠性、是否符合人类界面指南(HIG:developer.apple.com/app-store/review/),以及它是否符合商店收录的其他标准。这个过程通常需要一两天,但新应用程序的首个版本可能需要更长的时间。一旦这个过程完成,您的软件将可在 App Store 上购买或下载。在您的第一周分发期间,它甚至可能被包含在新功能和值得注意部分。
既然我们已经了解了 Mac App Store 的流程,让我们看看微软商店的流程。
微软商店
微软商店是查找和安装所有当前Windows、Windows Phone和Xbox设备软件、应用程序和游戏的官方位置。它提供托管和搜索功能,以及处理付费软件的支付,还支持折扣和优惠券。您可以在网上浏览微软商店的内容(在www.microsoft.com/store/apps),或者通过使用它支持的每个系统上的商店应用程序。
要将应用程序提交到微软商店,你需要一个微软账户(如果你已经登录过 Windows、Xbox 或 Office 365,你可能已经有了账户)。你还需要开始一个年度订阅,以便访问合作伙伴门户的相关部分。你可以在 partner.microsoft.com/en-US/dashboard/apps/signup 登录和注册。
Windows 发布打包
准备微软商店的发布包与之前我们做的打包过程非常相似,但作为一个发布资产,它将需要应用版本号。就像在 macOS 应用商店一样,我们需要对软件进行签名。如果你还没有这样做,请确保下载你的证书,并注意其名称。Windows 应用使用一个组合版本号,例如 1.2.3.4
,但 fyne
工具将其分为两部分。为了打包特定版本的软件,将最后一个数字作为 -appBuild
传递,其余版本作为 -appVersion
,如下所示:
$ fyne release -appVersion 1.2.3 -appBuild 4 –certificate "CertificateName" -password "MyPassword"
此命令的输出将是一个 .appx
文件。此文件是上传到商店所需的包类型,不应以其他方式共享。
上传到微软商店
完成的包应上传到合作伙伴门户的 包 页面。在准备上传时,请确保你的所有应用程序元数据都已添加到正确的位置,以便人们可以轻松找到你的软件。一旦包上传,它将检查可能阻止其发布的各种错误。如果你遇到任何警告,你需要从门户中删除已上传的构建版本,修复问题,并上传一个新的包以重新测试。
微软商店审查流程
一旦你的包上传并通过初步验证,它将被添加到队列中等待审查。微软工作人员将审查你的应用程序的正确性和适用性,并验证其是否具有足够高的质量,可以包含在商店中。假设所有这些检查都通过,他们将在你提交过程中指定的设备上发布它。
在下一节中,我们将了解 Linux 和 BSD 发行版。
Linux 和 BSD 发行版
Linux 和 BSD 发行版在处理包分发方面享有良好声誉;桌面系统可能有一个图形包管理应用程序,它提供易于搜索的索引,包含数千个包。尽管有数百种不同的 Linux 和 BSD 发行版,但它们都基于类似的二进制文件和文件结构。
Linux/Unix 打包
在本章开头“为发布构建”部分创建的.tar.gz
文件是 Linux 和 BSD 系统打包 Fyne 应用的基础。该文件的内容将在打包目标分发时进行适配。通过添加平台特定的元数据和运行文件的包命令,您可以创建适合您首选分发的包。
Linux 软件包的审核流程
一旦为系统创建了包,应用程序开发者可以将其提交到包列表。对于每个系统,请求添加的方法可能不同,但通常可以在项目文档中找到。然而,由于 Linux 和 BSD 是开源系统,您可能会发现现有的包维护者可能愿意为您做这件事。
在本节中,我们看到了如何使用可用的工具完成 Linux 和 BSD 的打包。结合Mac App Store和Microsoft Store部分,涵盖了如何在大多数主要桌面操作系统上分发您的应用。接下来,我们将看到如何调整这种方法以将移动应用程序上传到适当的分发渠道。
上传应用到 Google Play 和 iOS App Store
管理移动平台的发布可能稍微复杂一些,因为应用不能直接在我们构建它们的设备上进行测试。然而,Fyne 发布流程旨在涵盖所有平台,因此在本节中,我们将看到如何使用相同的工具发布到移动设备商店。
iOS App Store
为 iOS 打包应用与本章前面描述的 macOS 分发流程非常相似。您需要安装Xcode,并从 Apple 开发者门户developer.apple.com/account/resources/certificates/准备和下载分发证书和配置文件。
打包 iOS 发布版本
对于 iOS 发布包命令,我们在我们的 Mac 系统上运行以下命令,记得添加-os ios
参数:
$ fyne release -os ios -appVersion 1.0 -appBuild 1 -certificate "CertificateName" -profile "ProfileName"
前面命令生成的文件将是一个.ipa
文件,这是上传 iOS 应用到 App Store 所需的格式。一旦您有了这个文件,就可以使用本章前面Mac App Store部分中描述的相同流程上传。
iOS App Store 审核流程
iOS 应用的审核流程与 Mac App Store 非常相似。提交构建版本将启动一个自动检查流程,通常只需几分钟。之后,将进行人工审核,这可能需要几天时间,但在繁忙时期对复杂应用的审核通常在一周内完成。
在下一节中,我们将查看 Google Play Store 流程。
Google Play Store
将 Android 应用打包到 Google Play 商店需要我们将版本信息和我们的认证应用到包文件中。再次强调,fyne release
命令将处理此操作,但我们需要收集相关信息。版本信息可以像之前构建那样传递,但 Android 的认证不同。
打包 Android 发布版本
Android 应用的认证信息存储在 .keystore
文件中。如果您之前已经分发过 Android 应用,您可能已经有一个。如果是第一次,您需要设置您的开发者凭据,包括私钥和证书。更多详细信息可在官方文档developer.android.com/studio/publish/app-signing中找到。请确保您的 keystore 安全,因为它将用于发布您应用的未来版本。
一旦找到包含此信息的 keystore,您就可以开始发布流程。使用与其他构建相同的版本信息,我们将运行带有额外 -keyStore
参数的 fyne release
命令,该参数后面跟着文件的路径。完整的命令看起来可能如下所示:
$ fyne release -os android -appVersion 1.0 -appBuild 1 -keyStore "path/to/file.keystore"
确保您有密码和可选的密钥别名在手,因为命令将要求提供额外信息以完成签名过程。这将产生一个类似于之前开发打包的 .apk
文件,但带有为上传准备的签名细节。您可以考虑将文件重命名为标记其为发布版本,这样在您再次开始开发时就不会产生混淆。
上传到 Play Console
要将您的应用程序上传到 Google Play 商店,您需要登录到 Play Console(如果您尚未注册,将收取少量费用)play.google.com/console/。登录后,您就可以开始创建新应用程序的过程。在这里,您将上传元数据、图标和其他营销材料,就像之前探索的 Apple iOS 应用商店一样。
在 release
命令中,确保文件与在线元数据匹配。填写完信息后,找到 .apk
文件(或轻触 上传 按钮选择它)。上传完成后,它将经过一些自动化检查以确认兼容性。如果在此阶段有任何问题,您可以返回项目进行更改,以发布模式重新构建文件,并上传替换文件。
Play 商店审查流程
一旦您的应用程序提交到 Google Play Console,您就可以通过审查过程跟踪其进度。检查过程中有一些手动环节,可能会导致等待时间约为一天或两天。谷歌指出,在某些情况下,可能需要进行额外的检查,这可能需要长达 7 天或更长时间。这通常发生在新用户账户或应用程序首次发布时。一旦批准,您的应用程序将在所有支持的 Android 设备上的 Play 商店中可见。
摘要
在最后一章中,我们探讨了如何使用 Fyne 工具打包和分发图形应用程序。与命令行或系统工具的分发不同,交付 GUI 应用程序的过程需要额外的元数据和打包,以便与每个操作系统良好集成。我们看到了基本发布流程如何创建一个适用于分发到我们开发和测试团队成员之外的应用程序包。
为不同平台打包可能会很复杂,因此我们详细介绍了构建原生外观的图形包的步骤,包括适用于 macOS、Windows 和 Linux 的图形包,以及适用于 iOS 和 Android 的移动打包。每个包都有自己的元数据格式和包结构,但这些都是通过使用 fyne
release
工具自动生成的。
我们还看到了如何构建用于在官方商店分发的发布包,以及如何将这些包提交到将在用户设备上预先安装的应用商店或市场。Windows、macOS、iOS 和 Android 商店为应用程序在发布后赚取收入提供了机会,而 Linux 软件列表将有助于提高我们软件包的可见性。
在学习了使用 Fyne 开发图形应用程序的各个方面之后,我们已经完成了从开发到发布的全过程。希望这个指南对您有所帮助,并使您能够创建您一直想要构建的应用程序,并在各种操作系统上让它在您的所有设备上运行。
如果您想将您的应用程序分发给社区,请考虑通过访问 developer.fyne.io/submit 在 Fyne 应用列表中分享它。
第十一章:附录 A:开发者工具安装
在准备运行本书中的代码示例之前,您需要安装 Go 编译器和 C 编译器(以支持 CGo)。如果您还没有设置这些,本附录将指导您完成安装。
在 Apple macOS 上安装
许多开发者工具(包括 Git)作为 Xcode 包的一部分安装。如果您还没有为其他开发工作安装 Xcode,您可以从 Mac App Store 免费下载它,网址为 apps.apple.com/app/xcode/id497799835。
安装完成后,您还应该设置命令行工具。为此,转到 Xcode 菜单 | xcode-select
。如果工具已经安装,这将正常执行。如果没有,您将提示运行安装,如下面的截图所示:
图 11.1 – 当未安装开发者工具时的安装对话框
除了这些工具之外,您还需要安装 Go。您可以从 golang.org/dl 下载下载包。从那里,点击特色下载链接以获取 Apple macOS,然后运行下载的安装程序包。您可能需要关闭任何打开的 Terminal 窗口以更新环境变量。
在 Microsoft Windows 上安装
配置 Windows 的开发环境可能很复杂,因为默认情况下安装的工具不多。因此,有多个设置选项,包括使用外部工具和包(如 MSYS2、MinGW 和 Windows Subsystem for Linux)。然而,探索这些内容超出了本书的范围。
以下步骤展示了如何使用 MSYS2 开始运行,它提供了一个为 Fyne 开发设置的专用命令行应用程序。让我们开始吧:
-
您需要从 www.msys2.org 下载安装程序。您应该根据您的计算机架构选择 32 位(
i686
)或 64 位(x86_64
)版本。 -
下载完成后,运行安装程序,它将在您的计算机上下载基本包,包括包管理器(
pacman
)。 -
安装完成后,您将有机会启动 MSYS Command Prompt – 请勿接受此选项,因为这不是我们想要运行的版本。
-
完成后,打开您选择安装应用程序的目录,并运行
mingw64.exe
应用程序。这是预先配置了 Windows 编译知识的命令行。现在我们可以使用包管理器来安装 Go 和 Git,以及 C 编译器工具链和pkg-config
(CGo 用于查找包):$ pacman -S git mingw-w64-x86_64-go mingw-w64-x86_64-toolchain mingw-w64-x86_64-pkg-config
此前述命令将提供安装许多软件包的选项,这正是我们想要的。只需按回车或Enter键即可安装这些依赖项:
图 11.2 – 运行已安装的 Mingw64 终端以安装额外软件包
注意,前面的终端提示表示MINGW64。如果您看到MSYS2或其他提示,那么您打开了错误的终端应用程序。
一旦安装了这些软件包,您将拥有完整的开发环境。默认的 Go 主目录路径将是C:/Users/<username>/go
,尽管您应该考虑将C:/Users/<username>/go/bin
添加到您的%PATH%
环境变量中。
在 Linux 上安装
在 Linux 上设置先决软件应仅需要安装适合您发行版的正确软件包。git
软件包将提供源代码控制工具,而 Go 语言应包含在go
或golang
软件包中。此外,CGo 需求意味着gcc
软件包也必须存在。安装这些软件包将为运行本书中的示例提供必要的命令。您可能需要将~/go/bin
添加到您的PATH
环境变量中,以便能够运行 Go 安装的工具。
Linux 有各种不同的包管理器,每个包管理器对软件包的命名约定略有不同,以及不同的命令。以下命令是安装每个最受欢迎的发行版所需软件包的示例。某些平台需要安装额外的库头文件或依赖项,这些依赖项在需要时已包含在内:
-
Arch Linux: sudo pacman -S go gcc xorg-server-devel
-
Fedora: sudo dnf install golang gcc libXcursor-devel libXrandr-devel mesa-libGL-devel libXi-devel libXinerama-devel libXxf86vm-devel
-
Solus: sudo eopkg it -c system.devel golang mesalib-devel libxrandr-devel libxcursor-devel libxi-devel libxinerama-devel
-
Ubuntu / Debian: sudo apt-get install golang gcc libgl1-mesa-dev xorg-dev
-
Void Linux: sudo xbps-install -S go base-devel xorg-server-devel libXrandr-devel libXcursor-devel libXinerama-devel
使用上述命令后,您将在计算机上准备好完整的 Fyne 开发环境。
第十二章:附录 B:安装移动构建工具
由于移动应用程序的编译方式,它们在测试或安装到设备上时需要额外的工具和包。在本附录中,我们将学习如何设置 iOS 和 Android 开发的附加组件。
准备 Android
要为 Android 开发应用程序,我们需要额外的开发工具。开发过程与本书早期章节中看到的过程相同,您的应用程序同样可以使用相同的 Fyne API – 只是构建/打包阶段有所不同。以下是我们需要遵循的必要步骤:
-
首先,您需要安装 Android SDK。最简单的方法是安装Android Studio,它可在developer.android.com/studio找到。点击该网站上的下载按钮,并按照您电脑类型的安装说明进行操作。
-
安装完成后,您还需要安装
sdkmanager
应用程序。 -
加载完成后,选择 SDK Tools 以查看可用的完整工具列表。在这里,您需要检查NDK选项(有时称为并行)和Android SDK Tools,如果它们未被选中。点击应用按钮,包将被安装,如下所示:
图 12.1 – Android SDK 管理器(显示已安装必要的 SDK 工具和 NDK)
-
一旦设置完成,您应该能够使用以下命令之类的命令为您的 Android 手机或平板电脑构建任何 Fyne 应用程序:
.apk file that can be installed using the Android tools available to you, the adb install command, or the fyne install command.
也可以使用 fyne-cross
进行 Android 打包,如附录 C**:交叉编译所示。
准备 iOS 和 iPadOS
为 Apple 移动设备构建应用程序与在 macOS(桌面计算机)上编译应用程序略有不同。首先,由于许可限制,这必须在 Apple Macintosh 计算机上完成(iMac、MacBook Pro等)。
其次,您必须安装 Xcode 工具(对于桌面应用程序创建来说这有些可选)。最后,如果您想在物理设备上进行测试或将应用程序分发到商店,您需要注册 Apple 开发者计划,这需要每年支付一定的费用。
Xcode 的安装描述在附录 A**:开发者工具安装中的 Apple macOS 部分。如果您之前已经进行过 iOS 开发,那么这些设置可能已经完成。
接下来,你需要拥有一个苹果开发者账户。如果你还没有注册,你可以在 developer.apple.com/programs/enroll/ 上注册。这需要支付年费,如果你发布了应用程序但未能续订会员资格,它们将被从商店中移除。一旦注册,你应该遵循文档来设置开发者证书并将你的设备添加到账户设备列表中,以便你可以使用它们进行测试。一旦添加,你需要创建一个配置文件——一个通用的通配符配置文件就足够用于一般开发。
一旦完成这些步骤,你应该能够使用以下命令为你的苹果手机或平板电脑构建任何 Fyne 应用程序:
$ fyne package -os ios -appID com.example.myapp
成功执行前面的命令将创建一个可以安装到你的设备上进行测试的 .ipa
文件。这个过程在第九章,打包资源和准备发布中有更详细的说明。
第十三章:附录 C: 交叉编译
当构建需要访问本地 API 和图形硬件的应用程序时,我们可以使用CGo。尽管对于常规开发来说并不困难,但这确实使得交叉编译变得更加复杂。对于你想要为每个目标平台构建的,都必须有一个C 编译器知道如何创建本地二进制文件。本附录概述了设置本附录中先前提到的每个组合所需的交叉编译目标步骤。
重要提示
请注意,交叉编译对于日常开发不是必需的。对于大多数开发,你不需要交叉编译器的设置。Go 编译器和附录 A**: 开发者工具安装中讨论的标准工具,就足够你在标准计算机上进行开发了。本附录是关于安装创建针对不同操作系统或架构的编译应用程序的附加工具。
在本附录中,我们将介绍两种可能的编译方法:
-
手动安装交叉编译工具链
-
使用
fyne-cross
自动处理编译
我们将从手动过程开始,因为在看到自动化过程之前了解编译的复杂性是有用的。
手动安装编译器
安装编译器和工具链是复杂的,但本附录将尝试引导你通过主要步骤。这种方法有时被希望管理他们计算机每个细节的开发者所青睐。如果你的计算机之前曾用于C开发并创建多个平台的本地应用程序,这也可能更容易。
准备为不同的目标编译有所不同,这取决于你想要编译的系统。我们将首先查看macOS,然后探索Windows和Linux。由于我们在附录 B**: 安装移动构建工具中安装了这些工具,因此不需要遵循这些步骤来构建移动应用程序。
为 macOS 进行交叉编译
当为 macOS 进行交叉编译时,有必要安装来自苹果的软件开发工具包(SDK)以及一个合适的编译器。Windows 的说明(使用MSYS2,如附录 A,开发者工具安装中所述)和 Linux 几乎相同;我们主要需要做的是安装 macOS SDK。
你需要注意的一件事是,苹果公司已经移除了对构建 32 位二进制文件的支持。如果你希望支持不是 64 位的旧设备,你需要安装较旧的 Xcode(9.4.1 或更低版本)和 Go(1.13 或更低版本)。
安装 macOS SDK 最简单的方法是使用 osxcross
项目。以下步骤显示了如何下载和安装 SDK 以及构建 macOS Fyne 应用所需的所有必要工具,而无需使用 Macintosh 计算机。这里我们使用 Linux,但对于使用 MSYS2 命令行工具的 Windows 开发者来说,过程是相同的:
-
我们将使用
clang
编译器代替gcc
,因为它的设计更便携。为了使此过程正常工作,您需要使用您的包管理器安装clang
、cmake
和libxml2-dev
:-
在 Linux 上,使用
apt-get install clang cmake libxml2-dev
(或根据您的发行版使用适当的pacman
或dnf
命令) -
在 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%2010.2 下载
XCode.dmg
(10.2 推荐用于针对 64 位分布的 osxcross,尽管如果您想支持 32 位计算机,也可以下载 9.4)。 -
然后,我们必须安装
git
命令:$ git clone https://github.com/tpoechtrager/osxcross.git
-
下载完成后,进入新目录。使用此存储库中的包工具,我们必须从下载的
Xcode.dmg
文件中提取 macOS SDK:MacOSX10.11.sdk.tar.xz file should be copied into the tarballs/ directory.
-
最后,我们必须通过执行提供的构建脚本来构建 osxcross 编译器扩展:
$ ./build.sh
-
完成此操作后,将出现一个名为
target/bin/
的新目录,您应该将其添加到您的PATH
环境变量中。现在可以在CC=o64-clang
环境变量中使用编译器;例如:$ CC=o64-clang GOOS=darwin CGO_ENABLED=1 go build .
关于此过程以及如何将其适应其他平台的更多详细信息,可在 osxcross 项目网站上找到,网址为 github.com/tpoechtrager/osxcross。
为 Windows 进行交叉编译
从其他平台为 Windows 构建需要我们安装 mingw
工具链(这与我们在 Windows 上安装的类似,以支持 CGo)。这应该在您的包管理器中可用,名称类似于 mingw-w64-clang
或 w64- mingw
,如果没有,您可以直接使用 github.com/tpoechtrager/wclang 上的说明进行安装。
在 macOS 上安装 Windows 工具
在 macOS 上安装软件包时,建议您使用 brew.sh
。一旦 Homebrew 设置完成,可以使用以下命令安装编译器软件包:
$ brew install mingw-w64
安装完成后,可以通过设置 CC=x86_64-w64-mingw64-gcc
来使用编译器,如下所示:
$ CC= x86_64-w64-mingw64-gcc GOOS=windows CGO_ENABLED=1 go build .
在下一节中,我们将学习如何在 Linux 上安装 Windows 工具。
在 Linux 上安装 Windows 工具
在 Linux 上安装只需在发行版的列表中找到正确的包即可。例如,对于 Debian 或 Ubuntu,你会执行以下命令:
$ sudo apt-get install gcc-mingw-w64
安装完成后,可以通过设置 CC=x86_64-w64-mingw64-gcc
来使用 CGo,如下面的命令所示:
$ CC= x86_64-w64-mingw64-gcc GOOS=windows CGO_ENABLED=1 go build .
最后,我们将探讨如何使用手动工具链安装来为 Linux 计算机编译。
为 Linux 进行交叉编译
要为 Linux 进行交叉编译,我们需要 musl-cross
(musl
有许多其他优点,你可以在 www.etalabs.net/compare_libcs.html 上了解更多)。在 Windows 上,linux-gcc
包是绰绰有余的。让我们逐一完成这些步骤。
在 macOS 上安装 Linux 编译器
为了安装依赖项以便我们可以为 Linux 进行交叉编译,我们将再次使用 Homebrew 软件包管理器 – 请参阅 在 macOS 上安装 Windows 工具 部分,或访问 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-gcc
环境变量,如下所示:
$ CC=x86_64-linux-musl-gcc GOOS=linux CGO_ENABLED=1 go build
这个过程对于 Windows 也是类似的,我们将在下一节中看到。
在 Windows 上安装 Linux 编译器
使用 MSYS2,就像我们在 为 macOS 交叉编译 部分中所做的那样,我们可以安装 gcc
包以提供 Linux 的交叉编译:
$ pacman -S gcc
安装完成后,我们可以通过设置 CC=gcc
环境变量来告诉我们的 Go 编译器使用 gcc
。如果你按照当前示例中的说明操作,编译现在应该会成功,如下所示:
$ CC=gcc GOOS=linux CGO_ENABLED=1 go build
在这一点上,你可能会看到由于缺少头文件而引起的额外错误。为了解决这个问题,你需要搜索并安装所需的库。这通常是因为编译器没有内置的关于 Linux 桌面图形工作方式的了解。
例如,如果你的错误信息表明找不到 X11
头文件,那么你可以使用 pacman -Ss x11
来搜索安装正确包。在这种情况下,所需的包是 mingw-w64-libxcb
(X11
库的 Windows 版本),可以按照以下方式安装:
$ pacman –S mingw-w64-libxcb
如果你找不到合适的包,你可以尝试使用 Windows 子系统 for Linux。更多信息可在 docs.microsoft.com/en-us/windows/wsl 找到(这将在你的 Windows 桌面上带来完整的 Linux 发行版)。
使用 fyne-cross
为了更方便地管理用于交叉编译项目的各种开发环境和工具链,我们可以使用 fyne-cross
命令。这个交叉编译工具使用 Docker 容器下载和运行不同操作系统的开发工具,直接从您最喜欢的桌面计算机上操作。
安装 fyne-cross
在您安装 fyne-cross
之前,您需要准备一个最新的 docker
软件包。有关使用 Docker 的更多信息,请参阅第九章,资源打包和发布准备。
一旦安装了 Docker,您就可以使用标准的 Go 工具安装 fyne-cross
。以下命令就足够了:
$ go get github.com/fyne-io/fyne-cross
fyne-cross
二进制文件将被安装在 $GOPATH/bin
。如果以下章节中提供的命令不起作用,请检查二进制文件是否在您的全局 $PATH
环境中。
使用 fyne-cross
安装完成后,fyne-cross
命令可以用来为任何目标操作系统和架构构建应用程序。它有一个必需的参数:目标系统。要从 Linux 计算机上构建 Windows 可执行文件,只需调用以下命令:
$ fyne-cross windows
有许多可用的选项(更多信息请参阅 fyne-cross help
)。最常用的两个参数是 -arch
,允许您指定不同的架构,以及 -release
,它调用 Fyne 发布管道来构建准备上传到市场的应用程序(您可以在第十章,分发 – 应用商店及其他中了解更多。为了创建适用于较小 Linux 计算机上的应用程序,例如 Raspberry Pi,我们可以使用以下命令:
$ fyne-cross linux –arch arm64
要构建用于发布的 Android 应用程序,您可以使用以下命令:
$ fyne-cross android -release
上述步骤应能帮助您以最小的硬件投资构建和分发适用于任何平台的应用程序。
请注意,如果您正在运行 fyne-cross
来构建 iOS 应用程序,您必须在 macOS 主机计算机上运行它。这是苹果许可协议的要求。
第十四章:其他您可能喜欢的书籍
如果您喜欢这本书,您可能对 Packt 的以下其他书籍也感兴趣:
![图表
《精通 Go 第二版》
Mihalis Tsoukalos
ISBN: 978-1-83855-933-5
-
使用 Go 进行生产系统的清晰指导
-
详细解释 Go 内部工作原理、语言背后的设计选择以及如何优化你的 Go 代码
-
完全指南:所有 Go 数据类型、组合类型和数据结构
-
掌握包、反射和接口以进行有效的 Go 编程
-
构建高性能系统网络代码,包括服务器端和客户端应用程序
-
使用 WebAssembly、JSON 和 gRPC 与其他系统进行接口
-
编写可靠、高性能的并发代码
-
在 Go 中构建机器学习系统,从简单的统计回归到复杂的神经网络
![图形用户界面
《动手实践 Go 高性能》
Bob Strecansky
ISBN: 978-1-78980-578-9
-
使用集群和作业队列有效地组织和操作数据
-
探索常用的 Go 数据结构和算法
-
在 Go 中编写匿名函数以构建可重用应用程序
-
分析和跟踪 Go 应用程序以减少瓶颈并提高效率
-
部署、监控和迭代以性能为重点的 Go 程序
-
深入了解 Go 中的内存管理以及 CPU 和 GPU 并行性
留下评论 - 让其他读者了解您的看法
请通过在您购买书籍的网站上留下评论来与别人分享您对这本书的看法。如果您从亚马逊购买了这本书,请在本书的亚马逊页面上留下一个诚实的评论。这对其他潜在读者来说至关重要,他们可以看到并使用您的客观意见来做出购买决定,我们可以了解我们的客户对我们产品的看法,我们的作者可以查看他们与 Packt 合作创作的标题的反馈。这只需要您几分钟的时间,但对其他潜在客户、我们的作者和 Packt 来说都很有价值。谢谢!