第47天 里程碑 项目 7-9
恭喜你又完成了三个项目!在完成导航技术项目后,你可能已经感到有些疲惫,但今天和明天应该会是很好的调剂——今天是巩固知识的日子,明天则会有些不一样。
今天的挑战很有意思,说实话,如果你有时间,它完全有潜力发展成一个更大型的应用。像今天这样的日子很重要,因为它会给你一个触手可及的想法,并且为你提供执行这个想法的时间和空间。希望你能充分利用这个机会——就像宇航员梅·杰米森曾经说过的:“我喜欢把想法看作势能:它们确实很棒,但除非我们敢于将其付诸行动,否则一切都不会发生。”
所以,今天是行动的日子:你有很多编码工作要做,如果想进一步推进这个项目,还会有更多任务等着你。让我们开始吧!
今天你需要完成三个主题,其中一个是你的挑战任务。
- 你学到的内容
- 重点内容
- 挑战任务
注意:如果当天没有完成挑战任务也不用担心——在之后的日子里,你会发现时不时会有一些空闲时间,所以挑战任务是可以在之后再回来完成的。
你学到的内容
希望你能感觉到,这些项目在一定程度上对你提出了挑战,它们不仅进一步提升了你的SwiftUI技能,还教给了你一些更高级的Swift知识。当然,你还完成了两个新的SwiftUI项目——你可以继续自定义这些项目,把它们放到GitHub上,或者把它们改造成更符合你喜好的样子。
下面快速回顾一下在过去三个项目中我们涵盖的所有新内容:
- 为什么
@State能与结构体配合使用。 - 如何使用
@Observable在类中存储数据。 - 使用
sheet()修饰符和dismiss环境键展示和关闭视图。 - 使用
onDelete(perform:)实现侧滑删除功能。 - 在导航栏项中添加
EditButton,让用户更轻松地编辑列表数据。 - 使用
UserDefaults读取和写入数据。 - 使用
Codable对数据进行归档和解档,包括处理层次结构中存储的数据。 - 使用
Identifiable协议确保用户界面中的所有项目都能被唯一标识。 - 如何使用
containerRelativeFrame()让内容适配屏幕。 - 使用
ScrollView在可滚动区域中布局自定义视图。 - 使用Swift的泛型系统编写可处理不同类型数据的方法。
- 使用
NavigationLink将新视图推入导航栈。 - 以编程方式进行导航,包括使用类型擦除的
NavigationPath。 - 如何自定义导航栏外观。
- 在确切位置放置工具栏项。
我想你会认同,这涵盖的内容非常多,而且范围也很广——我们从核心的语言特性一直讲到面向用户的视图,中间涉及的内容极为丰富。有些人可能更喜欢纯粹的语言相关内容,而另一些人则更喜欢编码中更具创造性的方面,这都很正常——我们的学习方式各不相同!
重点内容
虽然在之前的三个项目中我们涵盖了很多内容,但有三个具体要点我想更详细地讲解一下。
类与结构体:区别是什么,为什么重要?
Swift为我们提供了两种创建自定义复杂数据类型的方式,理解为什么会有这两种方式,以及在特定任务中该选择哪一种,这一点很重要。
类和结构体最根本的区别在于,一个是“值类型”,另一个是“引用类型”。这是编程中的标准术语,描述我们如何处理数据:数据是像“Hello”或5这样的简单值,还是仅仅是一个“指向标”,表示“我的数据存储在内存的这个位置”。
理解了这个区别后,结构体和类就成了两种截然不同的事物,但在学习过程中,这些区别可能看起来并不明显。可以这样理解:当我们创建一个存储结构体的变量时,该数据实际上就存储在这个变量内部。相比之下,当我们使用类时,数据会被存储在内存的某个位置,而变量中存储的是一个长数字,这个数字标识了该内存的位置。
“引用类型”这个名称就是由此而来:引用类型以对某个内存位置的引用形式存储,有点像一个指向标。变量不是直接指向我的房子,而是指向一个指向我房子的指向标——这里多了一层间接指向关系。这就是为什么如果让两个或多个变量指向同一个类的实例,它们能修改相同数据的原因:实际上只是有多个指向标都指向同一所房子。
这也是为什么引用类型和值类型在作为常量使用时表现不同。如果我们创建一个类的常量实例,实际上是创建了一个常量指向标——我们相当于在说“这个指向标始终始终指向24601号房子,不能指向其他房子”。但是,这并不妨碍我们对房子进行改造:比如我们可能想加一层楼、更换厨房,甚至把房子完全拆掉再建一座新的。如果你希望这些内容是固定的——希望房子本身是不可变的——那么你需要为类使用常量属性。
所以,我们可以创建一个常量指向标(let myHouse = House()),同时拥有可变的数据(var numberOfFloors = 3)。但我们也可以反过来:创建一个可变的指向标(var myHouse = House()),但它拥有不可变的数据(let numberOfFloors = 3),这种情况下表现会大不相同:我们可以移动指向标,让它指向不同的房子,但不能对房子本身进行改造。
现在想想这一切与Swift、SwiftUI甚至UIKit有什么关系。如果一个应用中有三个界面,且所有界面都共享相同的数据,那么确保这些数据在后台(所有变量包含相同的值)和对用户(所有列表/文本视图等显示相同的值)方面都保持同步就非常重要。
SwiftUI提供了如@State这样的包装器,确保视图在数据变化时能及时更新,但UIKit中并没有这些包装器——你需要自己响应变化,然后更新用户界面以反映这些变化。
这就带来了一个问题:
- 视图A可以创建一个类的实例。
- 视图A可以将这个实例传递给视图B,以便它们共享该实例。
- 视图B随后可以修改数据并更新自身的用户界面。
- 视图A完全不知道数据发生了变化,会继续显示旧的用户界面。
因此,UIKit开发者通常会使用结构体来存储数据,因为这样每个视图都拥有自己的数据副本,数据不会意外发生变化。更有趣的是,UIKit的所有视图类型都是用类构建的,这意味着UIKit开发者会将视图构建为类,而将结构体用于存储数据——这与SwiftUI完全相反。
合理使用UserDefaults
UserDefaults让我们可以轻松存储少量数据——它会自动与应用关联,这意味着应用一启动,就能立即加载这些数据。虽然它非常实用(你以后会经常依赖它!),但它也有两个缺点:
- 你应该只在其中存储少量数据——存储超过约512KB的数据就不太合适了。
- 你只能轻松存储特定类型的数据;其他所有类型的数据都必须先使用
Codable转换为二进制数据。
UserDefaults支持的类型列表简短且明确:字符串、数字、日期、URL和二进制数据,以及这些类型的数组和字典。除了URL(它本质上只是一种特殊的字符串),所有这些类型都是可以存储在plist文件(即“属性列表”的缩写)中的类型。
这并非巧合:UserDefaults实际上就是使用属性列表来写入数据的,就像我们的Info.plist文件一样。事实上,记住这种关联有助于你更好地使用UserDefaults——如果我们的Info.plist文件中包含10万个数据条目,那会很奇怪,同样,在UserDefaults中存储10万个项目也很奇怪。
因此,要把UserDefaults用在它设计的用途上——正如苹果官方文档所说,它被称为“用户默认设置”(user defaults),“是因为它们通常用于确定应用启动时的默认状态或应用的默认行为方式”。
何时使用泛型
我们使用泛型创建了一个解码方法,该方法能够从应用资源包中获取任何JSON文件,并将其加载到我们选择的Codable类型中。但是——这一点很重要!——我们最初编写的方法是非泛型的:如果你还记得,它最初只能解码宇航员数组,之后才被改进为可以加载任何类型的Codable数据。
我并不是在浪费你的时间,而是在向你介绍一种思考泛型和协议的合理方法。在这个项目中,我们需要从astronauts.json文件中解码Astronaut实例的数组,所以我们编写了一个专门用于此目的的方法——没有使用协议,也没有使用泛型,只是在Bundle上扩展了一个简单的方法,以帮助组织代码。这模拟了我们大脑的思考方式:我们能理解像“宇航员”这样具体的事物,并且能很容易地描述它们。
然而,对于协议和泛型,情况就没那么简单了——现在我们要处理的是一系列可能的类型,这些类型除了遵循相同的协议外,可能毫无关联。例如,整数和字符串都遵循Swift的内置Comparable协议,这就是为什么Swift知道如何对它们的数组进行排序,但除此之外,它们是完全不同的事物。
可能让人困惑的是,我们不能比较两个Comparable类型的对象,实际上,即使尝试从方法中返回Comparable类型也无法实现。如果你不相信,可以试试看:
func makeString() -> Comparable {
"Hello"
}这段代码无法编译,这是有充分理由的:Comparable本身没有任何实际意义。正如我所说,字符串和整数都遵循Comparable协议,但这只意味着你可以将一个整数与另一个整数进行比较,而不是说你可以将任何Comparable类型与另一个Comparable类型进行比较——那样做在逻辑上根本说不通。
这就是为什么泛型“约束”如此有用:它们让我们可以说“这可以是任何类型的对象,只要……”,然后添加一些限制条件。而且——可能与直觉相反——添加限制条件往往能启用更多功能。正如你所看到的,当我们说解码方法可以处理任何类型时,这意味着我们不能将JSONDecoder与它一起使用;直到我们明确添加了Codable约束,Swift才能够确定可以安全地将JSON解码为该类型。
因此,正确使用泛型的关键在于,一开始不要急于使用它们,而当你确实需要使用泛型时,要添加适当的限制条件,以便获得尽可能多的功能。
挑战任务
在进入下一批项目之前,你有一个新的挑战任务要完成。这意味着你需要凭借自己的力量,利用在之前三个项目中获得的技能,从头开始构建一个完整的应用。
这次你的目标是构建一个习惯追踪应用,供那些想要记录自己做某些事情频率的人使用。这些事情可能是学习一门语言、练习一种乐器、锻炼身体等等——用户可以自己决定要添加哪些活动,并以自己喜欢的方式进行追踪。
至少,这个应用应该包含一个用户想要追踪的所有活动的列表,以及一个添加新活动的表单——活动的标题和描述是必不可少的。
如果想增加挑战难度,可以让点击其中一个活动时显示详情界面,展示该活动的描述。如果想尝试更高难度的挑战(可以参考下面的提示!),可以在详情界面中显示用户完成该活动的次数,并添加一个按钮来增加完成次数。
如果你想让这个应用更实用,可以使用Codable和UserDefaults来加载和保存所有数据。
所以,这个应用有三个难度级别,你可以根据自己的时间和想要挑战的程度来选择完成到哪一步。不过,我建议你至少尝试一下每个级别的任务——每多一次练习,都能帮助你巩固所学的知识!
提示:
- 从数据入手:定义一个存储单个活动的结构体,以及一个存储活动数组的类。
- 该类需要使用
Observable宏,以便SwiftUI能够监控数据的变化。 - 主列表和表单都应该能够读取这个共享的活动对象。
- 确保你的活动结构体遵循
Identifiable协议,以避免出现问题。 - 使用
sheet()展示添加活动的表单,如果添加了活动详情视图,则使用NavigationLink跳转。建议不要使用呈现值(presentation values)进行导航——这里保持简单就好!
实现增加完成次数的按钮会对你构成一定挑战,因为你需要修改传入的活动数据。如果你遇到困难,最简单的方法如下:
- 让你的结构体遵循
Equatable协议。这一步不需要做任何特殊操作——只需在Codable和Identifiable之后添加Equatable即可。 - 将选中的活动和
@Observable类都传入详情视图。 - 当点击增加按钮时,复制当前的活动,并将其完成次数加1。
- 使用
firstIndex(of:)找到该活动在类的数组中的位置,然后将其替换为新创建的活动——类似data.activities[index] = newActivity这样的代码就可以实现(这一步需要用到第一步中添加的Equatable协议遵循!)。
这确实是一个很实用的应用,尤其是如果针对特定兴趣进行专门化开发的话——比如,如果目标是练习乐器,那么可以开发一个更高级的应用,推荐不同的练习内容;如果目标是锻炼身体,那么可以推荐新的锻炼方式,避免单调。
虽然这个挑战任务只是一个小型应用,但希望它至少能让你有所思考。祝你好运!