第29天 项目 5 第一部分
又到了新项目的时间,这实际上是本课程中最后一个简单项目 —— 在这之后,随着我们开始处理更复杂的应用,难度将会有所提升,所以好好享受这个项目吧!
在这个应用中,你将接触到应用开发中两个真正的基础知识点:用于处理数据表格的 List,以及用于处理文本的字符串。是的,我们之前已经对字符串有过不少讲解,但现在我们要深入研究它们,包括如何处理它们的 Unicode 编码,以便实现与旧版 Objective-C 框架的兼容性。
Unicode 是一种用于存储和表示文本的标准,乍一看你可能觉得这听起来很简单。但请相信我:事情远非如此。你还记得我说过日期处理很难吗?其实和正确存储文本相比,日期处理简直是小菜一碟。事实上,甚至有一款搞笑马克杯上印着 “I ? Unicode”(我?Unicode)—— 这痛苦地提醒着我们:当文本表示出现问题时,原本应显示符号的地方只会出现一个问号。
今天你需要学习四个主题,还会接触到 List、Bundle、UITextChecker 等更多内容。
- 单词拼写游戏:介绍
- 认识你的好帮手:List
- 从应用资源包中加载资源
- 字符串处理
完成学习后,在任意平台发布一条简短的消息,和大家分享你的学习进度吧!
单词拼写游戏(Word Scramble):介绍
作者:Paul Hudson 2023年10月20日
本项目将是又一款游戏,但实际上,这是我用来介绍更多Swift和SwiftUI知识的“小技巧”!这款游戏会向玩家展示一个随机的8字母单词,并要求玩家用其中的字母拼出其他单词。例如,如果初始单词是“alarming”,玩家可能会拼出“alarm”“ring”“main”等等。
在学习过程中,你将接触到List、onAppear()、Bundle、fatalError()等内容——这些都是未来多年里你会频繁用到的实用技能。你还会练习使用@State、NavigationStack等知识点,好好好享受这个过程吧,因为这是我们最后一个相对简单的项目了!
开始前,请创建一个名为“Word Scramble”的新App项目。你需要下载本项目对应的文件,其中包含一个名为“start.txt”的文件,后续会用到它。
好了,让我们开始编写代码吧……
认识你的好帮手:List
作者:Paul Hudson 2023年10月20日
在SwiftUI的所有视图类型中,List是你最依赖的一种。这并不意味着你会“最频繁地使用”它——我敢肯定Text或VStack的使用频率会更高——而是说它就像一个“多面手”,你会一次次地用到它。而且这并非新特性:在UIKit中,与List对应的组件是UITableView,其使用频率也同样很高。
List的作用是提供一个可滚动的数据表格。实际上,它与Form几乎完全相同,区别不同之处在于List用于展示数据,而Form用于获取用户输入。别误会,Form你也会经常用到,但它本质上只是List的一种特殊类型。
和Form一样,你可以给List提供一系列静态视图,这些视图会被渲染成单独的行:
List {
Text("Hello World")
Text("Hello World")
Text("Hello World")
}我们也可以使用ForEach,从数组或范围(range)中动态创建行:
List {
ForEach(0..<5) {
Text("动态行 \($0)")
}
}更有趣的用法是混合使用静态行和动态行:
List {
Text("静态行 1")
Text("静态行 2")
ForEach(0..<5) {
Text("动态行 \($0)")
}
Text("静态行 3")
Text("静态行 4")
}当然,我们还可以结合“分组(Section)”来让列表更易读:
List {
Section("分组 1") {
Text("静态行 1")
Text("静态行 2")
}
Section("分组 2") {
ForEach(0..<5) {
Text("动态行 \($0)")
}
}
Section("分组 3") {
Text("静态行 3")
Text("静态行 4")
}
}提示:如你所见,如果分组标题只是简单的文本,你可以直接传入字符串——这是一个很实用的快捷方式,适用于不需要复杂标题的场景。
能够同时包含静态内容和动态内容,让我们可以复刻类似苹果“设置”应用中“Wi-Fi”界面的效果:一个用于开启全局Wi-Fi的开关,接着是附近网络的动态列表,最后是“自动加入热点”等静态选项。
你会发现这个列表看起来和之前的Form很像,但我们可以使用listStyle()修饰符调整列表的样式,如下所示:
.listStyle(.grouped)到目前为止,你看到的所有用法,Form和List都支持——包括动态内容。但List有一个Form做不到的功能:无需ForEach,直接通过动态内容生成所有行。
因此,如果你的整个列表都由动态行组成,只需这样写:
List(0..<5) {
Text("动态行 \($0)")
}这让我们能快速创建列表,鉴于列表的高使用率,这个特性非常实用。
在本项目中,我们使用List的方式会略有不同,因为我们要遍历一个字符串数组。之前我们经常将ForEach与范围配合使用(无论是硬编码的0..<5,还是依赖变量数据的0..<students.count),这种方式很有效,因为SwiftUI可以根据行在范围中的位置来唯一标识每一行。
当处理数据数组时,SwiftUI仍然需要知道如何唯一标识每一行——这样当某一行被删除时,它只需删除那一行,而无需重新绘制整个列表。这就是id参数的作用,它在List和ForEach中的用法完全相同:告诉SwiftUI数组中每个元素的“唯一标识”是什么。
当处理字符串数组或数字数组时,这些值本身就是唯一标识。例如,如果我们有数组[2, 4, 6, 8, 10],那么这些数字本身就可以作为唯一标识——毕竟我们没有其他可用于标识的信息!
处理这类列表数据时,我们会使用id: \.self,代码如下:
struct ContentView: View {
let people = ["芬恩(Finn)", "莱娅(Leia)", "卢克(Luke)", "蕾伊(Rey)"]
var body: some View {
List(people, id: \.self) {
Text($0)
}
}
}ForEach中也可以这样用,所以如果我们想混合静态行和动态行,也可以写成:
List {
Text("静态行")
ForEach(people, id: \.self) {
Text($0)
}
Text("静态行")
}从应用包(Bundle)中加载资源
作者:Paul Hudson 2024年4月11日
当我们使用Image视图时,SwiftUI会自动在应用的“资源目录(asset catalog)”中查找图片资源,甚至会根据当前屏幕分辨率自动调整资源——这就是我们之前提到的@2x、@3x图片的作用。
但对于文本文件等其他类型的数据,我们需要做更多操作。这一点也适用于XML、JSON等特定格式的数据——无论加载哪种文件类型,操作步骤都是类似的。
当Xcode构建iOS应用时,会创建一个名为“包(Bundle)”的东西。苹果的所有平台(包括macOS)都会如此,这样系统就能将单个应用的所有文件集中存储在一个位置——包括二进制代码(我们编写的Swift代码编译后的结果)、所有图片资源,以及其他需要的文件。
未来,随着你技能的提升,你会学到如何在单个应用中包含多个包,从而实现Siri扩展、iMessage应用、小组件等功能,这些都可以放在同一个iOS应用包中。尽管这些扩展会随应用一起从App Store下载,但它们与主应用包是分开存储的——主应用包中存放的是iOS应用的核心代码和资源。
了解这些很重要,因为我们经常需要在包中查找已放入的文件。这会用到一种新的数据类型URL,它的作用和你想的差不多:存储一个URL地址,比如https://www.hackingwithswift.com。但URL的功能不止于此——它还可以存储文件的路径,这也是它在本文场景中有用的原因。
让我们开始编写代码吧。如果要读取主应用包中某个文件的URL,可以使用Bundle.main.url()方法。如果文件存在,会返回对应的URL;如果不存在,则返回nil,因此这是一个可选类型(optional)的URL。我们需要像这样解包:
if let fileURL = Bundle.main.url(forResource: "some-file", withExtension: "txt") {
// 成功在包中找到文件!
}URL内部的具体内容并不重要,因为iOS使用的路径是无法猜测的——应用运行在自己的“沙盒”中,我们不应尝试读取沙盒之外的内容。
拿到URL后,我们可以使用一个特殊的初始化方法将文件内容加载为字符串:String(contentsOf:)。我们给这个方法传入文件的URL,如果加载成功,它会返回包含文件内容的字符串;如果加载失败,会抛出错误(throw error),因此需要使用try或try?来调用,代码如下:
if let fileContents = try? String(contentsOf: fileURL) {
// 成功将文件内容加载为字符串!
}拿到文件内容后,你就可以随意处理了——它只是一个普通的字符串。
字符串处理技巧
作者:Paul Hudson 2023年10月20日
iOS为我们提供了非常强大的字符串处理API,包括将字符串拆分为数组、去除空白字符,甚至检查拼写等功能。我们之前已经接触过其中一些,但还有至少一个重要功能需要了解。
在本应用中,我们会从应用包中加载一个包含超过10000个8字母单词的文件,这些单词都可以作为游戏的初始单词。这些单词每行存储一个,因此我们需要将这个字符串拆分为字符串数组,以便随机选择一个单词。
Swift提供了components(separatedBy:)方法,该方法可以根据指定的分隔符将单个字符串拆分为字符串数组。例如,以下代码会创建数组["a", "b", "c"]:
let input = "a b c"
let letters = input.components(separatedBy: " ")我们的文件中,单词之间用换行符分隔,因此要将其转换为字符串数组,需要用换行符作为分隔符。
在编程中(几乎所有场景都是如此),我们用一个特殊的字符序列\n来表示换行符。因此,我们可以编写如下代码:
let input = """
a
b
c
"""
let letters = input.components(separatedBy: "\n")无论使用什么分隔符,拆分后得到的都是字符串数组。之后,我们可以通过索引(如letters[0]或letters[2])访问数组中的单个元素,但Swift还提供了一个更实用的方法:randomElement(),它会从数组中随机返回一个元素。
例如,以下代码会从数组中随机读取一个字母:
let letter = letters.randomElement()虽然我们能看到letters数组包含3个元素,但Swift并不知道这一点——比如,我们可能尝试拆分一个空字符串。因此,randomElement()方法返回的是可选类型(optional)的字符串,我们必须对其进行解包,或使用空合运算符(nil coalescing)处理。
另一个实用的字符串方法是trimmingCharacters(in:),它可以让Swift移除字符串开头和结尾的特定字符。这个方法需要传入CharacterSet类型的参数,但大多数情况下,我们需要的功能是“移除空白字符和换行符”——包括空格、制表符(tab)和换行符。
这种常用功能已内置在CharacterSet结构体中,因此我们可以像这样移除字符串开头和结尾的所有空白字符:
let trimmed = letter?.trimmingCharacters(in: .whitespacesAndNewlines)在深入主项目之前,最后一个需要介绍的字符串功能是“拼写检查”。
该功能由UITextChecker类提供。你可能没意识到,类名中的“UI”有两个额外含义:
- 这个类来自UIKit框架。但这并不意味着我们要加载整个旧的用户界面框架——实际上,SwiftUI会自动为我们引入它。
- 它是用苹果较旧的语言Objective-C编写的。我们不需要写Objective-C代码来使用它,但对于Swift用户来说,它的API确实有些繁琐。
检查字符串拼写总共需要4个步骤。首先,我们创建一个要检查的单词,以及一个用于检查的UITextChecker实例:
let word = "swift"
let checker = UITextChecker()第二步,我们需要告诉检查器要检查字符串的哪个部分。想象一下文字处理软件中的拼写检查器:你可能只想检查用户选中的文本,而不是整个文档。
但这里有个需要注意的点:Swift使用一种非常智能、先进的字符串处理方式,支持将 emoji 等复杂字符与英文字母同等对待。然而,Objective-C并不支持这种字符串存储方式,因此我们需要让Swift创建一个Objective-C兼容的字符串范围(range),覆盖整个字符串的长度,代码如下:
let range = NSRange(location: 0, length: word.utf16.count)UTF-16是一种“字符编码”——即字符串的存储方式。我们在这里使用它,是为了让Objective-C能理解Swift字符串的存储格式;它是连接两种语言的“桥梁”格式。
第三步,我们可以让文本检查器报告在单词中发现的拼写错误位置,需要传入以下参数:要检查的范围、开始检查的位置(以便实现“查找下一个”等功能)、是否在到达末尾后循环检查,以及用于检查的词典语言:
let misspelledRange = checker.rangeOfMisspelledWord(in: word, range: range, startingAt: 0, wrap: false, language: "en")该方法会返回另一个Objective-C字符串范围,告诉我们拼写错误的位置。即便如此,这里仍有一个需要注意的点:Objective-C没有“可选类型”的概念,而是通过特殊值来表示“无数据”。
在这个场景中,如果Objective-C返回的范围为空(即字符串拼写正确,没有错误),我们会得到一个特殊值NSNotFound。
因此,我们可以通过以下代码检查拼写结果是否存在错误:
let allGood = misspelledRange.location == NSNotFound好了,API介绍就到这里——让我们开始编写主项目的代码吧……