斯坦福-CS193p-IOS-应用开发笔记-全-

斯坦福 CS193p IOS 应用开发笔记(全)

001:课程介绍与SwiftUI基础 🚀

在本节课中,我们将学习斯坦福大学CS193p课程的第一讲内容。课程将介绍iOS应用开发的基础知识,特别是使用SwiftUI框架。我们将从课程概述开始,逐步深入到SwiftUI的核心概念,包括视图、结构体、函数式编程等。通过本讲的学习,你将能够理解SwiftUI的基本工作原理,并开始构建一个简单的卡片游戏应用。


课程概述与准备工作 📋

大家好,欢迎来到斯坦福大学CS193p课程,本课程专注于使用SwiftUI开发iOS应用。我是Paul Hegarty,已经教授这门课程13到14年了。本季度我们回到校园授课,虽然没有录制完整的课堂视频,但我录制了屏幕演示,以便学生回顾课程内容。这些演示对你们同样有帮助,因为你们可以跟随学习。

课程内容基于2023年春季学期的教学,但请注意,苹果可能会在WWDC开发者大会上发布SwiftUI和iOS的更新。因此,如果你在2023年底或2024年之后观看本课程,可能需要留意一些变化。不过,由于这是一门入门课程,核心内容不太可能发生重大变化。

本课程主要关注SwiftUI,前七到八周专注于iOS应用开发,课程后期会展示如何将所学知识应用于macOS、watchOS和Apple TV应用开发。课程内容包括函数式编程、响应式用户界面编程以及面向对象数据库等。课程的重点是实际应用,将你在其他课程中学到的知识整合到iOS开发中。

课程的前提条件包括一定的编程经验,因为所有作业和最终项目都涉及编码。建议你至少学习过一门结构化编程语言(如Python、Java或C++),以便更好地理解SwiftUI中的新概念。

课程将通过演示、阅读作业和编程作业进行。前五周我们将构建一个大型应用,以叙事的方式学习SwiftUI的核心概念。之后,你将通过小片段(vignettes)进一步深入学习。课程还包括六次编程作业和一次最终项目,最终评分基于作业和项目的完成情况。

在开始编码之前,你需要完成“作业0”,即安装Xcode并配置Apple ID和GitHub账户。Xcode是开发iOS应用的主要工具,GitHub用于提交作业。接下来,我们将创建一个新项目,开始构建我们的第一个应用。


创建第一个SwiftUI项目 🛠️

上一节我们介绍了课程概述和准备工作,本节中我们来看看如何创建第一个SwiftUI项目。我们将使用Xcode创建一个名为“Memorize”的卡片游戏应用。这个游戏类似于“记忆匹配”游戏,玩家需要翻转卡片并匹配相同的图案。

首先,打开Xcode并创建一个新项目。选择“iOS App”模板,将项目命名为“Memorize”。在组织标识符中,建议使用反向DNS格式(例如“edu.stanford.yourid”)。确保选择SwiftUI作为用户界面框架,并取消选择其他选项(如Core Data)。创建项目后,Xcode会自动生成一些文件,包括ContentView.swiftMemorizeApp.swift

Xcode界面分为多个部分:

  • 导航器:显示项目文件、搜索、错误等信息。
  • 编辑器:编写代码的主要区域。
  • 检查器:编辑选中元素的属性。
  • 预览面板:实时显示UI效果。
  • 调试器与控制台:用于调试和输出日志。

预览面板是SwiftUI的一大亮点,它可以实时更新UI变化。例如,如果你修改代码中的文本,预览面板会立即显示更新后的效果。此外,你还可以在预览面板中切换设备、颜色模式(深色/浅色)和字体大小,以确保应用在不同环境下都能正常显示。

接下来,我们将开始编写代码,构建游戏的基本界面。


SwiftUI基础:视图与结构体 🧱

上一节我们创建了第一个SwiftUI项目,本节中我们来看看SwiftUI的核心概念:视图与结构体。在SwiftUI中,几乎所有内容都是结构体(struct),这些结构体通过遵循协议(如View)来定义行为。

首先,让我们分析ContentView.swift中的代码:

import SwiftUI

struct ContentView: View {
    var body: some View {
        Text("Hello, CS193p!")
    }
}
  • import SwiftUI:导入SwiftUI框架,这是所有UI相关代码的基础。
  • struct ContentView: View:定义一个名为ContentView的结构体,并声明它遵循View协议。这意味着ContentView必须实现View协议的要求。
  • var body: some View:这是一个计算属性,返回一个遵循View协议的类型。some View表示返回的类型可以是任何遵循View协议的结构体。

View协议的核心要求是提供一个body属性,该属性返回一个视图。通过遵循View协议,你的结构体可以访问SwiftUI提供的数百个函数和修饰符,用于定制UI。

在SwiftUI中,视图可以通过组合其他视图来构建。例如,VStack(垂直堆栈)和HStack(水平堆栈)用于排列多个视图。视图还可以通过修饰符(如.font().foregroundColor())进行定制。这些修饰符返回一个新的视图,因此可以链式调用。

为了理解视图的组合方式,我们可以用乐高积木来类比:

  • 基本视图(如TextImage)就像乐高积木块。
  • 组合视图(如VStackHStack)就像将多个积木块堆叠在一起。
  • 修饰符就像为积木块添加颜色或纹理。

通过这种方式,我们可以构建复杂的UI界面。接下来,我们将开始构建游戏中的卡片视图。


构建卡片视图 🃏

上一节我们介绍了SwiftUI的基础概念,本节中我们来看看如何构建游戏中的卡片视图。卡片视图需要显示一个矩形背景,并在正面显示一个表情符号,背面显示统一的颜色。

首先,我们创建一个名为CardView的结构体,用于表示单个卡片:

struct CardView: View {
    var isFaceUp: Bool = false

    var body: some View {
        ZStack {
            if isFaceUp {
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color.white)
                RoundedRectangle(cornerRadius: 12)
                    .strokeBorder(lineWidth: 2)
                Text("👻")
                    .font(.largeTitle)
            } else {
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color.orange)
            }
        }
    }
}
  • ZStack:用于将多个视图叠加在一起。在这个例子中,我们将卡片背景、边框和表情符号叠加显示。
  • RoundedRectangle:一个圆角矩形形状,用于表示卡片的背景。
  • fill:修饰符用于填充形状的颜色。
  • strokeBorder:修饰符用于绘制形状的边框。
  • Text:显示表情符号的文本视图。

卡片视图通过isFaceUp属性控制正面或背面的显示。如果isFaceUptrue,则显示白色背景、边框和表情符号;如果为false,则显示橙色背景。

接下来,我们在ContentView中使用多个CardView实例,并通过HStack水平排列它们:

struct ContentView: View {
    var body: some View {
        HStack {
            CardView(isFaceUp: true)
            CardView()
            CardView()
            CardView()
        }
        .padding()
        .foregroundColor(.orange)
    }
}
  • HStack:水平排列四个卡片视图。
  • padding:为整个堆栈添加内边距,避免卡片紧贴屏幕边缘。
  • foregroundColor:设置所有子视图的前景色为橙色。这个颜色会传递给CardView,用于背面背景。

通过这种方式,我们构建了一个简单的游戏界面,显示了四张卡片,其中一张正面朝上,其余三张背面朝上。在接下来的课程中,我们将进一步优化这个界面,例如使用网格布局代替水平堆栈,并添加游戏逻辑。


总结与下一步计划 🎯

本节课中我们一起学习了SwiftUI的基础知识,包括视图、结构体、协议和修饰符。我们创建了一个简单的卡片游戏应用,构建了卡片视图,并通过组合视图实现了基本的UI布局。

以下是本讲的核心要点:

  1. SwiftUI基于结构体和协议:几乎所有UI元素都是结构体,通过遵循协议(如View)定义行为。
  2. 视图通过组合构建:使用VStackHStackZStack等容器视图组合多个子视图。
  3. 修饰符用于定制视图:通过链式调用修饰符(如.font().foregroundColor())可以修改视图的外观和行为。
  4. 预览面板提供实时反馈:Xcode的预览面板可以实时显示UI变化,支持深色模式、横竖屏等多种变体。

在下一讲中,我们将继续优化游戏界面,例如将水平堆栈改为网格布局,并开始分离游戏逻辑与UI代码。我们还将学习如何使用SwiftUI的响应式编程特性,使游戏更加动态和交互式。

希望你能通过本课程掌握SwiftUI的核心概念,并开始构建自己的iOS应用。如果你有任何问题,请随时在课程讨论区提问。祝你在iOS开发的道路上一切顺利!


注意:本教程根据斯坦福大学CS193p课程第一讲内容整理,旨在帮助初学者理解SwiftUI的基础知识。所有代码示例和解释均基于课程演示,力求简单直白,便于理解。

002:视图构建与交互 🧩

概述

在本节课中,我们将深入学习SwiftUI的核心概念,包括视图的构建、状态管理、用户交互以及如何组织代码以提高可读性。我们将通过构建一个记忆卡片游戏来实践这些概念。


回顾与补充

上一讲我们介绍了SwiftUI的基础知识,本节我们将回顾并补充一些重要细节。

检查器与选择模式

检查器允许您通过选择模式调整界面元素。即使您熟悉代码,检查器也很有用,例如用于微调颜色或内边距,而无需频繁编辑源代码。选择是双向的:在画布中选择一个元素会同时选中对应的代码行,反之亦然。

项目设置

在导航器的文件区域,除了应用文件外,还有一个项目设置项。这里可以配置构建目标、部署版本、签名和功能等。Xcode是一个强大的构建引擎,用于管理具有依赖关系的复杂项目。

视图行为

ContentView: View 表示 ContentView 结构体行为像一个视图。这与面向对象编程中的继承无关。我们将在后续课程中深入探讨“行为像”的含义。

some View 详解

some View 是一种不透明返回类型,它让编译器推断视图的具体类型。例如,如果 body 只包含一个 Text,那么 some View 就是 Text 类型。如果包含一个 VStack,类型会变得复杂(如 TupleView<(Text, Text)>)。some View 简化了我们的工作,我们无需关心这些底层类型。

GitHub集成

我们使用Git进行版本控制。在Xcode中,可以通过“Source Control”菜单提交更改。提交时需添加注释以描述更改内容。提交后,可以推送到远程仓库(如GitHub)以提交作业。反馈分支用于查看助教的评分意见。


SwiftUI语法深入

尾随闭包语法

当函数的最后一个参数是闭包时,可以使用尾随闭包语法。例如,ZStackcontent 参数是一个返回视图的闭包。我们可以省略参数标签,并将闭包写在函数调用括号之后。

ZStack {
    // 视图内容
}

如果所有参数都有默认值,并且使用了尾随闭包,甚至可以省略空括号。

视图构建器中的限制

在视图构建器(如 bodyViewBuilder 闭包)中,只能使用条件语句、列表和局部变量。不能使用循环或其他复杂逻辑。为了创建动态视图列表,我们使用 ForEach 视图。


状态管理与交互

视图的不可变性

SwiftUI视图是结构体,因此是不可变的。视图中的属性在创建后不能更改。这是SwiftUI声明式编程模型的核心。

@State 属性包装器

为了在视图中管理可变状态,我们使用 @State 属性包装器。它用于存储与视图显示相关的临时状态(如动画状态),而不是应用的核心数据模型。@State 在内存中创建一个指向状态的引用,因此视图本身保持不变,但指向的数据可以改变。

@State private var isFaceUp = false

添加交互:按钮与点击手势

我们可以使用 onTapGesture 修饰符为视图添加点击交互。也可以使用 Button 视图创建标准的交互式按钮。

// 点击手势
ZStack()
    .onTapGesture {
        isFaceUp.toggle()
    }

// 按钮
Button("添加卡片") {
    cardCount += 1
}

按钮的标签可以是任何视图,这提供了极大的灵活性。我们可以使用系统提供的符号(SF Symbols)作为按钮图标。


构建用户界面

使用栈布局

HStackVStack 是用于水平或垂直排列子视图的基本布局容器。通过嵌套这些栈,可以构建复杂的界面。

创建动态视图列表:ForEach

由于视图构建器中不能使用 for 循环,我们使用 ForEach 视图来动态创建视图列表。ForEach 需要一个数据集合(如数组或范围)和一个为每个元素提供视图的闭包。

ForEach(emojis.indices, id: \.self) { index in
    CardView(content: emojis[index])
}

id 参数帮助SwiftUI识别列表中的每个元素,这对于动画和性能优化至关重要。

网格布局:LazyVGrid

为了将卡片排列成网格,我们可以使用 LazyVGrid。它通过 GridItem 数组来定义列。使用 .adaptive 类型的 GridItem 可以创建自适应的网格布局。

LazyVGrid(columns: [GridItem(.adaptive(minimum: 65))]) {
    ForEach(emojis.indices, id: \.self) { index in
        CardView(content: emojis[index])
    }
}

滚动视图:ScrollView

如果内容超出屏幕范围,可以将其包裹在 ScrollView 中,以实现滚动浏览。


代码组织与最佳实践

提取子视图和计算属性

为了保持 body 属性的简洁和可读性,可以将复杂的UI部分提取为单独的计算属性或函数。

var body: some View {
    VStack {
        cards
        cardCountAdjusters
    }
}

var cards: some View {
    LazyVGrid(columns: [GridItem(.adaptive(minimum: 65))]) {
        // ...
    }
}

func cardCountAdjuster(by offset: Int, symbol: String) -> some View {
    Button {
        // 调整卡片数量
    } label: {
        Image(systemName: symbol)
    }
    .disabled(/* 禁用条件 */)
}

隐式返回

如果函数或计算属性只有一行代码,可以省略 return 关键字,这称为隐式返回。

使用 Group 应用修饰符

Group 是一个视图容器,它本身不改变布局,但允许我们将修饰符同时应用到多个子视图上。

使用不透明度替代条件显示

有时,为了保持布局的稳定性(避免视图因条件显示而改变大小),我们可以使用 .opacity 修饰符来控制视图的可见性,而不是完全添加或移除视图。

Group {
    // 卡片正面
}
.opacity(isFaceUp ? 1 : 0)

Group {
    // 卡片背面
}
.opacity(isFaceUp ? 0 : 1)

总结

本节课我们一起学习了SwiftUI的多个核心概念。我们回顾了检查器和项目设置,深入理解了 some View 和视图的不可变性。我们掌握了如何使用 @State 管理临时状态,以及如何通过 onTapGestureButton 添加用户交互。在界面构建方面,我们使用了 HStackVStackForEachLazyVGridScrollView 来创建灵活且响应式的布局。最后,我们探讨了通过提取子视图、使用函数以及合理应用修饰符来组织代码的最佳实践,使代码更清晰、更易维护。这些知识为我们构建更复杂的iOS应用奠定了坚实的基础。

003:斯坦福大学《SwiftUI的iOS应用开发|CS193p Developing Applications for iOS using SwiftUI 2023》 p03 -03-Lecture 3 _ Stanford CS193p 2023.zh_en -BV1HyzNYdEiD_p3-

概述 📋

在本节课中,我们将学习两个核心主题:MVVM架构Swift类型系统。理解MVVM是构建SwiftUI应用的基础,它能帮助我们清晰地分离应用的逻辑、数据与用户界面。之后,我们将探讨Swift中的关键类型,如结构体、类和协议,这些是构建应用功能模块的基石。最后,我们会回到演示项目,开始为记忆卡片游戏构建核心的游戏逻辑。

MVVM架构:设计范式 🏗️

在深入演示之前,我们需要先了解一些基础架构。MVVM是您将用来构建应用的设计范式。

在Swift中,将应用的逻辑和数据部分与用户界面分离至关重要。SwiftUI正是围绕这一理念构建的:您将拥有处理应用逻辑和数据的部分,以及向用户展示并与之交互的独立UI部分。逻辑和数据部分,我们称之为模型。例如,在我们的记忆卡片应用中,模型处理点击卡片时发生什么、卡片是正面朝上还是朝下等所有逻辑。我们目前所做的UI部分,有时称为视图,它是我们观察模型的窗口。

模型可以是一个单一的结构体,也可以是一个完整的SQL数据库、机器学习代码,甚至是互联网上的REST API。它是一个概念性的存在。UI部分则像是模型的一个可参数化的外壳,是模型的可视化呈现。因此,您应该这样思考:模型是应用的本质,而UI只是向用户展示它的方式。

到目前为止,我们放在视图中的 @State 变量,如 faceUpCardCount,实际上属于模型,因为它们描述了游戏的状态。我们将把它们移入模型。

SwiftUI的一个重要职责是确保模型中的任何变化都能反映在UI上。它内置了大量基础设施来为您处理这件事。您只需向SwiftUI提供一些关于模型中哪些部分会影响UI的提示,之后SwiftUI会负责更新。因此,您主要需要关心的是如何将这两部分清晰地分离。

连接模型与视图 🔗

如果我们分离了模型和视图,它们如何相互通信呢?主要有三种连接方式:

  1. 模型作为视图的 @State:将整个模型作为视图的一个 @State 变量。这种方式分离度极低,通常不推荐。
  2. 通过“守门员”访问模型:这是我们将要采用的主要方式,即MVVM。一个称为“视图模型”的中间层负责协调模型与视图之间的安全通信。99%的应用开发都会采用这种方式。
  3. 混合方式:视图模型作为守门员,但有时也允许UI直接访问模型的某些部分。这种方式在应用增长时容易变得混乱,不够灵活。

简单的答案是:始终采用第二种方式。即使对于非常简单的模型,使用视图模型作为守门员也是值得的,因为它为应用未来的增长提供了灵活性。在我们的演示中,我们将主要展示第二种方式,但也会提及第三种。

深入MVVM:模型、视图、视图模型 🧩

MVVM代表 Model-View-ViewModel

  • 模型:是UI无关的。它包含应用的数据(如卡片状态)和逻辑(如选择卡片时的行为)。模型是唯一的真相来源。您不应将数据复制到视图模型或UI的 @State 中,而应始终向模型询问信息。
  • 视图:是模型的可视化呈现。它应该是无状态的,始终反映模型的当前状态。我们使用 @State 标记的状态变量是罕见的例外,提醒我们正在做一件不寻常的事。视图的构建是声明式的:我们描述UI应该是什么样子,然后由模型数据驱动其变化。这使得UI是响应式的:当模型变化时,UI自动更新。
  • 视图模型:是连接视图和模型的绑定器守门员。它的职责包括:
    • 解释模型:例如,将复杂的SQL查询结果转化为视图可以理解的简单变量。
    • 保护模型:防止UI对模型进行不当操作。
    • 发布变更:当模型变化时,通知整个系统(特别是SwiftUI)。
    • 处理用户意图:当用户在视图中进行操作(如点击)时,视图会调用视图模型中的一个函数来表达用户意图(例如 chooseCard),而不是具体的UI动作(如 tapped)。视图模型负责将此意图转化为对模型的相应操作。

数据流:模型数据通过视图模型流向视图(只读)。当用户通过视图表达意图时,该意图通过视图模型传递给模型,模型随之改变。视图模型注意到模型的变化,发布“某物已变”的消息,SwiftUI随后智能地更新受影响的视图。

Swift类型系统:构建模块 🧱

理解一门语言的基础类型至关重要,因为语言的所有能力都源于这些类型。我们将讨论结构体、类、泛型(“不在乎”类型)、协议(第一部分)和函数。

结构体与类 🆚

结构体和类在语法上非常相似:它们都可以有存储属性、计算属性、常量、函数和初始化器。

核心区别在于:结构体是值类型,而类是引用类型

  • 引用类型(类):变量存储的是指向堆内存中对象的指针。多个部分可以共享并修改同一个对象。Swift使用自动引用计数来管理内存。
  • 值类型(结构体):变量直接存储值本身。当传递值类型(如赋值给另一个变量或作为函数参数)时,会创建该值的副本。Swift使用写时复制技术来高效处理大型数据结构的复制。可变性是显式的:存储在 var 中的结构体可以修改,存储在 let 中的则不能。

设计哲学

  • 面向对象编程的基础,侧重于数据封装和继承。
  • 结构体函数式编程协议导向编程的基础,侧重于描述行为(功能)和可证明性。由于值类型的特性,您可以更容易地推理代码的行为。

在本课程中,99.9%的情况下您将使用结构体。我们唯一会使用类的地方是视图模型,因为它需要在多个视图间共享,并且其作为“守门员”的角色设计可以安全地管理这种共享。

泛型(“不在乎”类型)🎲

有时您想构建一个结构体,其中包含一些您不关心具体类型的数据。Swift是强类型语言,因此我们需要一种方式来指定这种“不在乎”的类型,这就是泛型

语法:在类型名后使用尖括号 <...> 声明一个或多个类型参数。

struct Array<Element> {
    func append(_ element: Element) { ... }
    subscript(index: Int) -> Element { ... }
}

这里的 Element 就是一个“不在乎”的类型参数。当您创建实例时,需要指定具体类型:

var a: Array<Int> = [1, 2, 3] // 现在 Element 就是 Int 类型
a.append(4) // 参数必须是 Int

泛型可以与协议结合,对“不在乎”的类型施加一些约束(“在乎一点点”),我们将在演示中看到。

协议(第一部分)📜

协议定义了行为(或功能)的蓝图,但不提供实现。它只包含函数和属性的声明。

protocol Movable {
    func move(by: Int)
    var hasMoved: Bool { get }
    var distanceFromStart: Int { get }
}

如果一个类型要遵循(或说“表现得像”)某个协议,它必须实现协议中要求的所有内容。

struct Car: Movable {
    func move(by: Int) { ... }
    var hasMoved: Bool = false
    var distanceFromStart: Int = 0
}

协议的作用

  1. 约束与获益:约束遵循它的类型必须实现某些功能,同时这些类型能获得协议扩展带来的额外功能(我们将在后续讨论扩展)。
  2. 作为类型使用:协议本身可以作为变量、参数或返回值的类型。
  3. 约束泛型:使用 where 子句可以要求泛型类型参数必须遵循某个协议。

协议是实现协议导向编程的关键,它允许我们隐藏具体实现,专注于描述功能。

函数作为类型 🧮

在Swift中,函数是一等公民类型。这意味着您可以像使用其他类型(如 IntString)一样使用函数类型。

函数类型语法:由参数类型和返回类型组成,例如:

  • (Int, Int) -> Bool:一个接受两个 Int 并返回 Bool 的函数。
  • (Double) -> Void:一个接受 Double 且不返回值的函数。
  • () -> [String]:一个无参数但返回字符串数组的函数。

您可以声明函数类型的变量、参数或返回值:

var operation: (Double) -> Double // 变量 operation 是一个函数类型
func square(operand: Double) -> Double { return operand * operand }
operation = square // 将 square 函数赋值给 operation
let result = operation(4) // 调用 operation,相当于调用 square(4)

闭包本质上就是内联的匿名函数,它们可以捕获定义环境中的变量。我们在构建UI时已经大量使用了闭包(例如 ZStackcontent 参数、按钮的 action)。

回到演示:构建记忆游戏模型 🎮

上一节我们介绍了MVVM架构和Swift的核心类型,本节我们将回到演示项目,开始构建记忆卡片游戏的模型。

我们将创建一个独立的Swift文件来存放模型,确保其UI无关性。模型的核心是一个 MemoryGame 结构体,它包含一个卡片数组和一个处理选择卡片逻辑的函数。卡片本身也是一个嵌套的结构体 Card,包含是否正面朝上、是否已匹配以及卡片内容等属性。为了使模型通用,我们使用泛型让卡片内容可以是任意类型(如字符串代表表情符号,或图像数据)。

接着,我们创建视图模型 EmojiMemoryGame。它是一个,因为需要在多个视图间共享。视图模型持有一个 MemoryGame<String> 类型的模型实例,作为UI与模型之间的桥梁。目前,我们遇到了两个需要解决的问题:类需要初始化器,以及我们需要保护模型不被UI直接访问(实现“守门员”角色)。这些将在下一讲中完善。

总结 ✨

本节课我们一起学习了构建SwiftUI应用的两大基石。

首先,我们深入探讨了MVVM架构,理解了模型(数据与逻辑)、视图(UI呈现)和视图模型(中间协调者)各自的职责与协作方式。这种分离对于构建清晰、可维护的应用至关重要。

其次,我们梳理了Swift类型系统的关键部分:值类型的结构体与引用类型的类的区别、泛型如何表达“不在乎”的类型、协议如何定义行为规范,以及函数如何作为一等公民类型使用。这些类型是构建应用功能模块的基本工具。

最后,我们在演示中开始了模型层的构建,并创建了视图模型的雏形,为下一讲连接所有部分打下了基础。记住,在SwiftUI中,我们主要采用函数式和协议导向的编程风格,结构体和协议是我们的主要工具。

004:构建记忆游戏逻辑

在本节课中,我们将学习如何应用MVVM架构来构建记忆游戏的逻辑。我们将深入探讨模型、视图模型和视图之间的交互,并实现游戏的核心功能。

概述

上一讲我们介绍了MVVM架构的基本概念。本节我们将具体应用这个架构来构建记忆游戏的逻辑,目标是让游戏能够真正运行起来。

回顾架构

让我们回顾一下当前的代码结构。我们有一个名为EmojiMemoryGame的类,这就是我们的视图模型。在MVVM架构图中,视图模型是绿色的部分,它负责连接模型和视图。

我们的模型是蓝色的部分,目前已经构建了一个名为MemoryGame的结构体。视图部分目前叫做ContentView,我们稍后会将其重命名为更合适的名称。

视图模型与模型的连接

视图模型需要能够与模型进行完整的通信,因为它负责理解模型数据并以友好的方式呈现给视图。有时我将视图模型比作视图的“管家”——它负责整理模型中的数据,让视图代码保持简洁明了。

在我们的代码中,视图模型通过一个变量来持有模型:

var model: MemoryGame<String>

视图则需要一个变量来指向视图模型,以便能够请求所需的数据:

var viewModel: EmojiMemoryGame

访问控制与封装

在MVVM架构中,视图模型与视图之间的通信是单向的。视图模型永远不会直接引用视图,它只通过“某些东西改变了”的信号来通知视图更新。

为了实现更好的封装,我们可以使用访问控制关键字。例如,将模型变量标记为private可以实现完全分离:

private var model: MemoryGame<String>

这样,视图就无法直接访问模型,必须通过视图模型提供的方法来获取数据。

在模型端,我们也可以使用private(set)来保护数据:

private(set) var cards: [Card]

这意味着只有模型内部可以修改卡片,但其他代码可以查看卡片状态。

初始化问题解决

我们的视图模型目前有一个错误:“类EmojiMemoryGame没有初始化器”。这是因为类中的变量model没有初始值。

为了解决这个问题,我们需要为模型提供一个初始化器。模型需要知道要创建多少对卡片,所以我们在初始化器中添加这个参数:

init(numberOfPairsOfCards: Int)

在初始化器中,我们需要创建指定数量的卡片对。

使用闭包传递知识

模型不知道如何创建卡片内容(对于我们的游戏来说是表情符号),但视图模型知道。我们可以通过传递一个函数来解决这个问题:

init(numberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent)

这个函数参数允许创建者(视图模型)提供创建卡片内容的知识。

静态变量与函数

为了在属性初始化器中使用表情符号数组,我们将其定义为静态变量:

private static let emojis = ["🚗", "🚕", "🚙", "🚌", "🚎", "🏎️"]

静态变量是命名空间内的全局变量,不会污染全局命名空间。

我们还可以创建静态函数来创建游戏实例:

private static func createMemoryGame() -> MemoryGame<String>

更新视图代码

现在我们可以更新视图代码来使用视图模型。首先将ContentView重命名为EmojiMemoryGameView,然后更新它来使用视图模型提供的数据。

卡片视图现在只需要一个参数——卡片本身:

struct CardView: View {
    let card: MemoryGame<String>.Card
    // ...
}

主视图通过遍历视图模型的卡片数组来创建卡片视图。

添加交互功能

接下来我们添加一个洗牌按钮。这需要视图模型提供一个洗牌意图函数:

func shuffle()

在模型内部,我们需要实现洗牌功能,并标记为mutating,因为这会修改结构体。

实现响应式UI

为了让UI能够响应数据变化,我们需要使视图模型遵循ObservableObject协议,并将可能变化的变量标记为@Published

class EmojiMemoryGame: ObservableObject {
    @Published private var model: MemoryGame<String>
    // ...
}

在视图中,我们使用@ObservedObject来观察视图模型的变化。

总结

本节课我们一起学习了如何应用MVVM架构来构建记忆游戏的逻辑。我们实现了模型与视图模型的连接,解决了初始化问题,使用闭包传递知识,添加了静态变量和函数,更新了视图代码,并实现了响应式UI。

通过这节课的学习,你应该对SwiftUI中的MVVM架构有了更深入的理解,并能够应用这些概念来构建实际的应用程序功能。

005:协议、枚举与可选类型 🧩

在本节课中,我们将继续完善记忆卡片游戏,并深入学习Swift中的几个核心概念:协议、枚举和可选类型。我们将通过实践来理解如何让自定义类型遵循协议,如何使用枚举来定义离散值,以及如何利用可选类型安全地处理可能缺失的值。

课程概述

上一节我们介绍了视图模型和模型之间的数据流。本节中,我们将首先通过添加动画来引出对协议的需求,然后深入探讨枚举类型,并重点学习一个特殊的枚举——可选类型。最后,我们将运用这些知识来实现完整的游戏逻辑。


状态对象与可观察对象的区别 🔍

在继续演示之前,我们先明确一下 @StateObject@ObservedObject 的区别。理解它们的关键在于生命周期和作用域。

  • 生命周期@StateObject 的生命周期与声明它的视图紧密绑定。当视图出现在屏幕上时,该变量被创建;当视图从界面中移除时,它也随之销毁。如果将 @StateObject 声明在 App 层级,则其生命周期与整个应用相同。@ObservedObject 的生命周期则由传递给它的一方决定。
  • 作用域:在视图中使用 @StateObject 声明的变量,只能在该视图及其子视图体中使用,无法在兄弟视图中共享。而 @ObservedObject 是外部传入的,可以被传递给多个兄弟视图。

关于预览,我们通常直接创建实例(例如 MemoryGameViewModel()),这是因为预览代码会频繁执行,每次都会生成新的实例,这有助于重置状态。


为卡片添加动画 🎬

虽然完整的动画教学将在后续课程进行,但今天我们将添加一个简单的动画,这能帮助我们理解协议和泛型约束。

我们的目标是让卡片在洗牌时能够平滑移动,而不是生硬地切换。我们在显示卡片的 LazyVGrid 上添加 .animation 视图修饰符。

// 在 CardView 的容器上添加动画
.animation(.default, value: viewModel.cards)

.animation 修饰符接受一个 value 参数。当这个值发生变化时,视图内部所有可动画化的变化都会以动画形式呈现。这里我们传入 viewModel.cards,意味着当卡牌数组发生变化时(如洗牌),会触发动画。

然而,添加这行代码后出现了错误:“Referencing operator function '==' on 'Array' requires that 'MemoryGame.Card' conforms to 'Equatable'”

这是因为 Swift 的动画系统需要比较动画前后的状态。它会在值变化时保存一份快照,变化后再比较新旧值是否相等(==),如果不相等则执行动画。数组的 == 操作要求其元素类型必须遵循 Equatable 协议。


遵循 Equatable 协议 ⚖️

为了解决上述错误,我们需要让 Card 结构体遵循 Equatable 协议。

  1. 在模型文件中,为 Card 结构体声明遵循 Equatable
    struct Card: Equatable {
        // ... 现有属性
    }
    
  2. Xcode 会提示我们实现协议要求。对于 Equatable,通常需要实现一个静态的 == 函数。我们可以使用 Fix-it 功能自动生成模板。
  3. 生成的函数如下:
    static func == (lhs: Card, rhs: Card) -> Bool {
        return lhs.isFaceUp == rhs.isFaceUp &&
               lhs.isMatched == rhs.isMatched &&
               lhs.content == rhs.content
    }
    
    这个函数比较两张卡片的三个属性是否全部相等。

但这里又出现了新的错误:“Referencing operator function '==' on 'Equatable' requires that 'CardContent' conforms to 'Equatable'”。这是因为我们尝试比较 lhs.content == rhs.content,而 content 的类型 CardContent 是一个泛型占位符,我们不知道它是否能比较相等。

我们需要为泛型 CardContent 添加约束,要求它也必须遵循 Equatable

struct Card<CardContent> where CardContent: Equatable {
    // ... 现有属性
}
// 或者使用更简洁的语法
struct Card<CardContent: Equatable> {
    // ... 现有属性
}

这样我们就告诉编译器:Card 可以接受任何类型作为 CardContent,但该类型必须能够判断相等。

Swift 的便利功能:当你的 == 实现仅仅是逐一比较所有存储属性时(就像我们上面做的那样),Swift 可以自动合成(synthesize)这个实现。因此,我们可以直接删除整个 == 函数,Swift 会自动为我们生成正确的实现。现在错误消失了。


修复动画与 Identifiable 协议 🆔

添加动画后,洗牌时卡片只是淡入淡出,而不是我们期望的移动效果。问题出在创建视图的 ForEach 上。

之前我们使用索引来遍历:

ForEach(viewModel.cards.indices) { index in
    CardView(card: viewModel.cards[index])
}

这种方式将视图与数组的索引位置绑定。洗牌时,卡片在数组中的位置变了,但 ForEach 仍然为索引 0 的位置创建视图,只是该位置上的卡片内容变了,所以表现为淡入淡出。

我们需要将视图与卡片本身绑定,这样当卡片在数组中移动时,对应的视图也会移动。

ForEach(viewModel.cards) { card in
    CardView(card: card)
}

我们将遍历对象从 indices 改为 cards 数组本身,并直接将 card 传递给 CardView

但这导致了新的错误:“Generic struct 'ForEach' requires that 'MemoryGame.Card' conforms to 'Hashable'”。这与 ForEachid 参数有关。

ForEach 需要唯一标识每个数据项,以便正确管理视图的生命周期。之前使用 \.self 作为标识,意味着使用数据项本身作为 ID。这对于像 Int 这样本身可哈希且唯一的索引是有效的。但对于我们的 Card,其属性(如 isFaceUp)会变化,导致“标识”也变化,这不合适。

我们需要一个唯一且不变的标识符来代表每张卡片。这就是 Identifiable 协议的作用。

  1. Card 遵循 Identifiable 协议。
    struct Card: Identifiable {
        // ... 现有属性
        let id: String
    }
    
  2. Identifiable 协议要求有一个 id 属性,其类型只要遵循 Hashable 即可。我们通常使用 StringInt。这里我们添加一个 let id: String
  3. 在初始化卡片时,为 id 赋值。我们可以利用字符串插值来生成 ID,例如 "\(pairIndex+1)a""\(pairIndex+1)b"

现在,ForEach 可以通过 card.id 来唯一识别每张卡片。再次运行洗牌,可以看到卡片现在能够正确移动了!


自定义调试输出 🐛

在开发过程中,我们经常在控制台打印对象以进行调试。Swift 为所有类型提供了默认的字符串表示,但有时过于冗长。我们可以让自定义类型遵循 CustomDebugStringConvertible 协议来提供更清晰的调试描述。

extension Card: CustomDebugStringConvertible {
    var debugDescription: String {
        return "\(id): \(content) \(isFaceUp ? "up" : "down")\(isMatched ? " matched" : "")"
    }
}

实现 debugDescription 计算属性,返回我们想要的格式字符串。之后在控制台打印 Card 对象时,就会使用这个简洁的格式。


实现卡片点击与游戏逻辑 🎮

现在让我们实现卡片的点击翻转和完整的游戏匹配逻辑。

1. 添加点击手势

CardView 上添加 .onTapGesture 修饰符,调用视图模型的 choose 方法(这是一个“意图”)。

.onTapGesture {
    viewModel.choose(card)
}

2. 处理点击意图

在模型的 choose 方法中,我们首先需要找到被点击卡片在数组中的索引。这里我们遇到了 值类型 的一个重要特性:传递即拷贝。我们不能直接修改传入的 card 参数,因为它只是原始卡片的一个副本。我们必须修改模型内部 cards 数组中的原始卡片。

我们创建一个工具函数来根据卡片的 id 查找索引:

private func index(of card: Card) -> Int? {
    for index in cards.indices {
        if cards[index].id == card.id {
            return index
        }
    }
    return nil // 如果没找到,返回 nil
}

注意,这个函数返回 Int?(可选整数),因为可能找不到对应的卡片。

3. 使用可选类型处理未找到的情况

choose 方法中,我们使用 if let 安全地解包这个可选索引:

if let chosenIndex = index(of: card) {
    // 只有找到索引时才执行游戏逻辑
    cards[chosenIndex].isFaceUp.toggle()
}

可选类型(Optional) 是 Swift 中用于表示值可能缺失的核心类型。它是一个枚举:.none 表示没有值(nil),.some(Wrapped) 表示包含某个类型的值。Int?Optional<Int> 的语法糖。

4. 优化查找方法

我们可以利用数组的高阶函数 firstIndex(where:) 来更简洁地实现查找:

private func index(of card: Card) -> Int? {
    return cards.firstIndex(where: { $0.id == card.id })
}

firstIndex(where:) 接受一个返回布尔值的闭包,返回第一个使闭包为真的元素的索引(可选类型)。这同样体现了函数式编程的思想。

5. 实现完整游戏逻辑

游戏的核心规则是:

  • 每次只能翻开一张或两张卡片。
  • 如果翻开一张,等待下一张。
  • 如果翻开两张,检查是否匹配。若匹配,则标记为已匹配;若不匹配,则在下一张卡片被点击时将它们翻回。

我们使用一个计算属性 indexOfTheOneAndOnlyFaceUpCard 来跟踪当前唯一面朝上的卡片索引(如果没有或有两张,则为 nil)。这个属性既是获取器也是设置器:

  • 获取器(get):遍历 cards,找出所有面朝上的卡片索引。如果恰好只有一个,则返回它;否则返回 nil
  • 设置器(set):当设置这个属性时(例如,将其设为用户刚点击的卡片索引),我们将数组中所有卡片的 isFaceUp 设为 false,然后将指定索引的卡片设为 true。这保证了任何时候最多只有一张卡片是面朝上的(除非正在匹配过程中)。

choose 方法中,逻辑如下:

if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) {
    // 只处理未匹配且面朝下的卡片
    if !cards[chosenIndex].isFaceUp && !cards[chosenIndex].isMatched {
        // 如果已有一张面朝上的卡片,尝试匹配
        if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
            if cards[chosenIndex].content == cards[potentialMatchIndex].content {
                // 匹配成功
                cards[chosenIndex].isMatched = true
                cards[potentialMatchIndex].isMatched = true
            }
            // 无论是否匹配,现在有两张面朝上的卡片,所以将唯一面朝上索引设为 nil
            // 注意:此时卡片还未翻面,下面会统一处理
        } else {
            // 如果没有面朝上的卡片,则将所有其他卡片翻到背面,并将当前卡片设为唯一面朝上的
            indexOfTheOneAndOnlyFaceUpCard = chosenIndex
        }
        // 翻转被点击的卡片
        cards[chosenIndex].isFaceUp = true
    }
}

6. 优化代码与函数式编程

我们可以利用函数式编程进一步简化计算属性的实现:

获取器 可以使用 filter 和自定义的数组扩展方法 only

var indexOfTheOneAndOnlyFaceUpCard: Int? {
    get {
        let faceUpCardIndices = cards.indices.filter { index in cards[index].isFaceUp }
        return faceUpCardIndices.only
    }
    set {
        cards.indices.forEach { index in
            cards[index].isFaceUp = (index == newValue)
        }
    }
}

我们为 Array 添加了一个扩展,提供 only 属性,当数组只有一个元素时返回该元素,否则返回 nil

extension Array {
    var only: Element? {
        count == 1 ? first : nil
    }
}

设置器 使用 forEach 遍历所有索引,简洁地设置每张卡片的朝向。


枚举(Enum)深度解析 📚

枚举用于定义一组相关的离散值。与结构体和类不同,枚举没有存储属性。Swift 枚举的强大之处在于其关联值——可以为每个枚举案例附加额外的数据。

enum FastFoodMenuItem {
    case hamburger(numberOfPatties: Int)
    case fries(size: FryOrderSize)
    case drink(String, ounces: Int) // 未命名的 String 关联值
    case cookie
}
  • 赋值let menuItem: FastFoodMenuItem = .hamburger(numberOfPatties: 2)
  • 切换判断:使用 switch 语句处理枚举值,并可以提取关联值。
    switch menuItem {
    case .hamburger(let pattyCount): print("Burger with \(pattyCount) patties!")
    case .fries(let size): print("\(size) fries!")
    case .drink(let brand, let ounces): print("\(ounces)oz \(brand)")
    case .cookie: print("Cookie")
    }
    
  • 必须穷尽switch 必须处理所有枚举案例,或用 default 涵盖剩余情况。
  • 方法和计算属性:枚举也可以有方法和计算属性(但不能有存储属性)。
  • 遍历所有案例:让枚举遵循 CaseIterable 协议,即可通过 allCases 进行遍历。


可选类型(Optional)详解 🎁

可选类型本质上就是一个枚举:

enum Optional<T> {
    case none
    case some(T)
}

它用于表示“可能有值,也可能是 nil(无值)”的场景。

语法糖

  • 声明var hello: String? 等价于 var hello: Optional<String>
  • 赋值hello = nil 对应 .nonehello = "world" 对应 .some("world")
  • 强制解包print(hello!) – 如果 hellonil,程序会崩溃。应谨慎使用。
  • 安全解包if let safeHello = hello { ... } – 这是安全处理可选值的标准方式。
  • 空合运算符let y = x ?? "foo" – 如果 xnil,则使用默认值 "foo"

在我们的游戏代码中,index(of:) 函数返回 Int?,以及在 choose 方法中使用 if let 来安全处理可能找不到卡片的情况,都是可选类型的典型应用。


课程总结

本节课我们完成了以下内容:

  1. 明确了 @StateObject@ObservedObject 在生命周期和作用域上的关键区别。
  2. 添加了动画,并由此引出类型需要遵循 Equatable 协议才能被动画系统比较。
  3. 学习了协议遵循:通过让 Card 遵循 EquatableIdentifiable 协议,解决了动画和 ForEach 的标识问题。
  4. 深入理解了枚举,特别是其关联值的特性。
  5. 掌握了可选类型,这是 Swift 安全处理缺失值的核心机制,并在游戏逻辑中用它来安全地查找卡片索引。
  6. 实现了完整的游戏逻辑,包括卡片匹配、状态管理,并运用函数式编程思想优化了代码。

通过本课的学习,你不仅完善了记忆卡片游戏,更重要的是掌握了 Swift 中协议、枚举和可选类型这几个构建健壮、安全应用程序的基石。在接下来的作业和项目中,你会反复运用这些概念。

006:布局与视图构建器

在本节课中,我们将深入学习SwiftUI的两个核心概念:布局系统视图构建器。我们将探讨视图在屏幕上如何排列,以及@ViewBuilder如何将多个视图组合成一个。理解这些原理将帮助你构建更可预测、更灵活的界面。

布局系统:三步舞曲

上一节我们介绍了SwiftUI的基本视图组件,本节中我们来看看它们是如何在屏幕上确定自身位置和大小的。SwiftUI的布局系统非常优雅且可预测,它遵循一个简单的三步流程:

  1. 容器提供空间:容器视图(如HStackVStack)会获得一定空间,然后根据特定算法将空间提供给其子视图。
  2. 视图选择尺寸这是整个系统的核心规则。每个视图根据提供给它的空间,自行决定其想要的尺寸。没有任何外部力量可以强制规定一个视图的尺寸。
  3. 容器放置视图:在所有子视图确定了自己的尺寸后,容器视图负责将这些子视图放置在分配给自己的空间内。

总结:尺寸由视图自身选择,位置由容器视图决定。这个规则保证了布局行为的完全可预测性。

堆栈布局的工作原理

HStackVStack是最基础的布局容器。它们接收提供的空间,并尝试将其分配给内部的所有视图。关键在于理解视图的“灵活性”。

以下是几种视图灵活性的例子:

  • Image:最不灵活。它希望保持其固有尺寸,既不想变小,也无法变大。
  • Text:相对不灵活。它希望尺寸能完整显示文本,但在空间紧张时,可以通过缩放字体或添加省略号(...)来适应。
  • RoundedRectangle 等形状:非常灵活。可以很小,也可以很大,能适应任何尺寸。

HStackVStack的布局算法是:优先满足最不灵活的视图。它先为最不灵活的视图分配其所需空间,然后用剩余空间服务下一个最不灵活的视图,依此类推。如果所有视图分配完后还有剩余空间,则分配给灵活的视图。如果空间不足以容纳所有视图的最小需求,容器会尝试与那些“相对灵活”的视图(如Text)协商,看它们能否接受更小的空间。

容器自身(如HStack)也是一个视图,它最终会根据其子视图的整体尺寸来决定自己的尺寸。这可能小于提供给它的空间(如果子视图都不需要那么多空间),也可能大于提供的空间(如果无法容纳所有子视图的最小需求)。

有用的布局视图

HStackVStack中,有两个特别有用的辅助视图:

  • Spacer:它会占用所有提供给它的空间(在排列方向和垂直方向),是最灵活的视图。常用于将其他视图推向一侧。
    HStack {
        Text("Left")
        Spacer() // 占用中间所有空间
        Text("Right")
    }
    
  • Divider:在垂直方向(对于HStack)使用最小空间,但在交叉方向(对于HStack是垂直方向)会占用所有空间,并绘制一条分隔线。其外观因平台而异。

控制布局优先级

你可以使用.layoutPriority(_:)视图修饰符来覆盖默认的“最不灵活优先”规则。优先级值是一个浮点数,默认为0。优先级最高的视图会最先获得分配空间的机会。

HStack {
    Text("Important").layoutPriority(100) // 最高优先级,最先获得足够空间
    Image(systemName: "star")
    Text("Unimportant") // 默认优先级为0,最后分配空间
}

对齐方式

HStackVStack可以通过alignment参数控制子视图在交叉方向上的对齐方式(例如,HStack中控制垂直对齐)。

  • 使用leading/trailing而非left/right,以支持从右向左阅读的语言(如阿拉伯语、希伯来语)。
  • 可以使用.top.bottom.center.firstTextBaseline等对齐指南。
  • 你甚至可以创建自定义的对齐方式,但这超出了本入门课程的范围。

惰性堆栈与网格

  • LazyHStack / LazyVStack:“惰性”意味着它们只对当前在屏幕上的视图进行布局。对于长列表,这能显著提升性能。它们通常需要放在ScrollView中,因为其自身尺寸会收缩以适应内容。
  • LazyHGrid / LazyVGrid:我们已经使用过LazyVGrid来布局卡片。它们也是惰性的,并且通常放在ScrollView中。
  • Grid:这是一个可以在两个方向(行和列)上布局的视图,类似于表格或电子表格。它通过一系列以.grid...开头的视图修饰符(如.gridColumnAlignment)来精细控制列对齐等属性。

其他布局容器

  • ScrollView:占用所有提供给它的空间,并允许其内部内容滚动。
  • ViewThatFits:一个非常酷的视图。它接收一个视图构建器(通常包含2-3个备选视图),然后选择在提供空间内最合适的那个。例如,可以在纵向时使用VStack,横向时自动切换到HStack;或者根据用户设置的动态字体大小自动调整布局。
  • ListOutlineGroupDisclosureGroup:这些是更智能的“堆栈”,支持选择、层级展开等高级交互,我们将在后续课程中讨论。
  • 自定义布局协议:你可以通过实现Layout协议来创建完全自定义的布局容器。该协议精确对应了“提供空间、选择尺寸、放置视图”的三步舞曲。

ZStack、背景与覆盖

ZStack用于在Z轴方向(垂直于屏幕)叠加视图。

  • 尺寸规则:如果ZStack任何一个子视图是完全灵活的(如RoundedRectangle),那么整个ZStack就会变得完全灵活,以尽可能满足该子视图的空间需求。ZStack的尺寸将是其子视图中所需的最大尺寸。
  • background 修饰符:这就像一个简化的双视图ZStack,但尺寸由前景视图决定,背景视图只是被放置在后面。非常适合为文本等视图添加背景色或形状。
    Text("Hello").background(Circle().fill(.blue)) // Circle的尺寸由Text决定
    
  • overlay 修饰符:与background相反,尺寸由背景视图决定,前景视图作为覆盖层。可以指定对齐方式。
    Circle().fill(.blue).overlay(Text("Hi"), alignment: .center) // Text在Circle上
    

作为布局引擎的修饰符

许多视图修饰符本身也参与布局过程:

  • .padding(_:):接收空间后,先向内收缩指定的间距,然后将剩余空间提供给被修饰的视图。被修饰视图选择尺寸后,padding再将自己的尺寸恢复为(子视图尺寸 + 间距)。
  • .aspectRatio(_:contentMode:)
    • .fit模式下,它会选择一个符合宽高比且能放入提供空间的最大尺寸。
    • .fill模式下,它会选择一个符合宽高比且能填满提供空间的最小尺寸(可能超出边界)。
      然后,它将这个新尺寸的空间提供给内部视图,并将内部视图的内容居中放置。

几何阅读器

GeometryReader是一个强大的工具,它允许视图了解自己被提供了多少空间。

  • 工作原理GeometryReader会占用所有提供给它的空间,然后通过一个GeometryProxy对象,将空间信息(尺寸、安全区域等)传递给其内容视图。
  • 关键点GeometryReader总是会占用所有提供空间。如果你希望它内部的视图保持较小尺寸,你需要手动控制内部视图的布局。
  • GeometryProxy 提供:
    • size:被提供的空间尺寸。
    • safeAreaInsets:安全区域的插入量(如iPhone的刘海区域)。
  • 忽略安全区域:可以使用.ignoresSafeArea(_:edges:)修饰符让视图绘制到安全区域之外。

视图构建器详解

@ViewBuilder是一个属性包装器,它允许我们将多个视图组合成一个单一的视图返回。它支持条件语句(ifswitch)和局部变量(let)。

  • 本质:它接收一个函数体,并将其中的视图列表、条件逻辑转换成一个特殊的视图类型,如TupleView_ConditionalContentEmptyView
  • 应用位置
    1. 计算属性或函数:你可以用@ViewBuilder标记一个返回some View的计算属性或函数,使其内部可以使用视图构建器语法。
      @ViewBuilder
      var frontOfCard: some View {
          RoundedRectangle(...)
          Text(...)
      }
      
    2. 函数参数:在自定义视图的初始化器中,可以将参数标记为@ViewBuilder,这样调用者就可以传递一个视图构建器闭包。
      init(items: [Item], @ViewBuilder content: @escaping (Item) -> ItemView) {
          ...
      }
      
    3. 存储属性:也可以标记存储属性,但较少见。
  • 限制:视图构建器内不是任意的Swift代码,它主要支持视图的声明、条件逻辑和局部变量。

总结

本节课中我们一起学习了SwiftUI布局系统的核心机制——“提供空间、选择尺寸、放置视图”的三步舞曲,并深入探讨了HStackVStack等容器的工作原理。我们还了解了如何使用SpacerDividerlayoutPriority和对齐方式来控制布局。此外,我们介绍了GeometryReader如何让视图感知空间,以及@ViewBuilder如何将多个视图组合成一个逻辑单元。理解这些概念是构建复杂、自适应界面的基础。

007:视图组件化、形状与视图修饰器

在本节课中,我们将学习如何将视图组件化到独立的文件中,管理代码中的常量,并深入探讨SwiftUI中形状和视图修饰器的工作原理。我们将通过构建一个“卡片化”视图修饰器和一个自定义的饼图形状来实践这些概念。

演示环节:分离CardView

上一节我们介绍了如何构建复杂的视图。本节中,我们来看看如何将视图组件化,以提高代码的可维护性。

目前,CardView 直接定义在 EmojiMemoryGameView 中。随着其功能变得复杂,最好将其分离到独立的文件中。

以下是操作步骤:

  1. 在Xcode中,选择 File > New > File...
  2. 在模板选择器中,选择 SwiftUI View
  3. 将文件命名为 CardView.swift,并确保将其保存在项目的正确目录中。
  4. 回到 EmojiMemoryGameView,将 CardView 的代码剪切并粘贴到新创建的文件中。

分离后,CardView 的使用不受影响,因为它是一个独立的 struct

现在,我们需要为 CardView 创建预览。由于 CardView 需要一个 Card 参数来初始化,我们不能直接使用默认的预览。

以下是创建预览的方法:

struct CardView_Previews: PreviewProvider {
    static var previews: some View {
        let card = MemoryGame<String>.Card(content: "X", id: "test1")
        CardView(card: card)
            .padding()
            .foregroundColor(.green)
    }
}

在预览中添加填充和颜色有助于在开发时快速检查视图的外观。

此外,我们可以使用类型别名来简化冗长的类型名称。例如,在 EmojiMemoryGameViewModel 中:

typealias Card = MemoryGame<String>.Card

然后就可以在代码中使用 Card 来代替 MemoryGame<String>.Card。请注意,类型别名的作用域是局部的。

管理常量

我们的代码中开始出现一些“魔法数字”(即未经解释的硬编码数值)。良好的实践是将它们定义为常量。

对于简单的常量,可以直接在视图中定义:

private let aspectRatio: CGFloat = 2/3
private let spacing: CGFloat = 4

对于拥有多个相关常量的视图(如 CardView),更好的做法是使用一个嵌套的 struct 来组织它们:

private struct Constants {
    static let cornerRadius: CGFloat = 12
    static let lineWidth: CGFloat = 2
    static let inset: CGFloat = 5

    struct FontSize {
        static let largest: CGFloat = 200
        static let smallest: CGFloat = 10
        static let scaleFactor = smallest / largest
    }
}

然后,在代码中通过 Constants.cornerRadius 等方式使用它们。这种方式不仅将常量组织在一起,还通过 static 属性确保了它们是真正的常量,并且可以包含计算属性甚至函数。

通常,将这些常量 struct 放在视图文件的底部,这样它们不会干扰对主体视图逻辑的阅读。

对于像 .white.black.orange 这样的颜色,如果它们是设计系统的一部分且可能调整,也可以定义为常量。但像 .white 这样的基础颜色通常直接使用。

理解与创建形状

上一节我们整理了代码结构。本节中,我们来看看SwiftUI中形状的工作原理以及如何创建自定义形状。

Shape 是一个继承自 View 的协议。这意味着所有形状同时也是视图。你已经见过 CircleRoundedRectangle 等内建形状。

形状默认使用当前前景色填充自身。我们也可以使用 .stroke().fill() 修饰器来描边或使用特定样式填充。这些修饰器是专门用于 Shape 的,它们将形状转换为视图。

.fill() 修饰器实际上是一个泛型函数,它接受一个符合 ShapeStyle 协议的类型,例如 ColorLinearGradientImagePaint

创建自定义形状:Pie(饼图)

为了演示,我们将创建一个饼图形状,它将用于后续的动画计时器。

要创建自定义形状,需要实现 Shape 协议。该协议要求实现一个 path(in rect: CGRect) -> Path 方法,用于在给定的矩形区域内定义形状的路径。

以下是 Pie 形状的核心实现思路:

  1. 形状需要知道起始角和终止角。
  2. 计算矩形的中心点和半径(取宽度和高度中较小值的一半)。
  3. 使用 Path API 移动画笔到中心点,画一条线到起始点,沿圆弧画到终点,再画一条线回到中心点。

注意:iOS的坐标系原点在左上角,Y轴向下为正。这与常见的笛卡尔坐标系不同。此外,“顺时针”方向也是基于这个坐标系定义的。

以下是 Pie 形状的简化代码结构:

import SwiftUI
import CoreGraphics

struct Pie: Shape {
    var startAngle: Angle = .zero
    var endAngle: Angle
    var clockwise: Bool = true

    func path(in rect: CGRect) -> Path {
        let center = CGPoint(x: rect.midX, y: rect.midY)
        let radius = min(rect.width, rect.height) / 2
        let start = CGPoint(
            x: center.x + radius * cos(startAngle.radians),
            y: center.y + radius * sin(startAngle.radians)
        )

        var p = Path()
        p.move(to: center)
        p.addLine(to: start)
        p.addArc(center: center,
                 radius: radius,
                 startAngle: startAngle,
                 endAngle: endAngle,
                 clockwise: !clockwise) // 调整坐标系方向
        p.addLine(to: center)
        return p
    }
}

创建后,就可以像使用其他形状一样使用 Pie,例如 Pie(startAngle: .degrees(-90), endAngle: .degrees(20)).fill(.blue)

理解与创建视图修饰器

视图修饰器是SwiftUI的基石。我们每天都在使用 .font().padding().foregroundColor() 等。本节中,我们来看看它们背后的机制以及如何创建自己的修饰器。

视图修饰器本质上是一个符合 ViewModifier 协议的结构体。该协议要求实现一个方法:

func body(content: Content) -> some View

当我们将 .modifier(MyModifier()) 应用到一个视图上时,该视图会作为 content 参数传递给修饰器的 body 方法。修饰器则返回一个新的、经过修改的视图。

创建 Cardify 视图修饰器

我们将创建一个名为 Cardify 的修饰器,它可以将任何视图“卡片化”,即加上圆角矩形边框、背景等,就像我们记忆游戏中的卡片一样。

首先,创建 Cardify.swift 文件,并定义符合 ViewModifier 协议的结构体:

struct Cardify: ViewModifier {
    var isFaceUp: Bool

    func body(content: Content) -> some View {
        ZStack {
            let base = RoundedRectangle(cornerRadius: Constants.cornerRadius)
            if isFaceUp {
                base.fill(.white)
                base.strokeBorder(lineWidth: Constants.lineWidth)
                content // 这是被“卡片化”的原始内容
            } else {
                base.fill() // 使用前景色填充(例如橙色)
            }
        }
    }

    private struct Constants {
        static let cornerRadius: CGFloat = 12
        static let lineWidth: CGFloat = 2
    }
}

然后,为了能像 .cardify(isFaceUp: true) 这样优雅地使用,我们为 View 协议添加一个扩展:

extension View {
    func cardify(isFaceUp: Bool) -> some View {
        modifier(Cardify(isFaceUp: isFaceUp))
    }
}

现在,在 CardView 中,我们可以将包裹内容的ZStack替换为:

Pie(endAngle: .degrees(240))
    .opacity(Constants.pieOpacity)
    .overlay(Text(card.content))
    .cardify(isFaceUp: card.isFaceUp)

这样代码更简洁,并且将卡片样式逻辑封装在了独立的修饰器中。更重要的是,这为动画打下了基础。当 isFaceUp 状态改变时,我们可以在这个修饰器内部添加自定义的翻转动画(这将在下周的动画课程中实现)。

协议进阶:扩展与泛型

最后,我们简要探讨Swift类型系统中协议的强大功能,特别是通过扩展进行代码共享。

之前我们主要将协议视为一份要求清单。但实际上,可以通过 extension 为协议添加默认实现。这就是为什么 ArrayStringDictionary 这些数据表示完全不同的类型,都能拥有像 .filter().map() 这样的方法。这些方法被实现在它们共同遵循的协议(如 Sequence)的扩展中。

在SwiftUI中,View 协议本身只要求一个 body 属性。而 .padding().foregroundColor() 等数百个修饰器方法,都是通过 extension View 添加的。这实现了强大的功能共享,而无需传统的继承,避免了继承带来的数据表示约束。

泛型协议与 some、any

有些协议是泛型的,例如 Identifiable,它有一个关联类型 ID。这确保了每个可识别实体都有一个唯一标识符。

  • some:用于指代“某个遵循了特定协议的具体类型”,但类型对使用者是透明的(不透明的)。你已熟悉 var body: some View。它要求函数或计算属性每次都必须返回相同的具体类型。
  • any:用于存在类型包装,允许你将不同类型但遵循同一协议的对象放入集合(如数组)中。由于类型信息被“装箱”,使用时通常需要将其传递给接受 some 类型参数的函数来“拆箱”使用。在本课程中,你较少会直接使用 any

理解 some 和泛型协议有助于阅读文档和高级API。例如,之前提到的 .fill(_ style: some ShapeStyle),其参数可以是任何符合 ShapeStyle 的类型(ColorGradient等)。

总结

本节课中我们一起学习了:

  1. 视图组件化:将 CardView 分离到独立文件,并为其创建预览。
  2. 常量管理:使用嵌套的 struct 组织和管理魔法数字,提高代码可读性和可维护性。
  3. 自定义形状:理解了 Shape 协议,并创建了一个 Pie 饼图形状,学习了 Path API 和iOS坐标系。
  4. 视图修饰器:深入了解了 ViewModifier 协议的工作原理,创建了 Cardify 修饰器来封装卡片样式,这为后续添加动画做好了准备。
  5. 协议进阶:了解了如何通过扩展协议来共享代码实现,并简要介绍了泛型协议中的 someany 关键字。

这些知识帮助你更好地组织SwiftUI代码,并理解其底层部分工作机制。下一周,我们将聚焦于动画,利用视图修饰器来实现卡片翻转等动态效果。

008:动画与过渡

在本节课中,我们将深入学习SwiftUI中的动画系统。动画是iOS应用开发中至关重要的一部分,它能让界面变化更加平滑自然,提升用户体验。我们将从动画的基本概念讲起,涵盖隐式动画、显式动画、过渡效果以及如何创建自定义的动画视图修饰符。

概述

本周的课程和第四次作业将完全围绕动画展开。事实上,第四次作业就是为你的第三次作业添加动画效果。我们将深入探讨动画的工作原理,这是iOS开发中一个非常重要的主题。

1. 属性观察器与onChange

在深入动画之前,我们先介绍一个与动画关系不大但非常有用的概念:属性观察器。这是一个之前没有合适机会插入讲解的话题。

属性观察器

SwiftUI的值类型可以检测到属性的变化。属性观察器允许你在一个属性的值被设置时执行代码。它的语法看起来很像计算属性,但语义完全不同。

var isFaceUp: Bool {
    willSet {
        if newValue {
            startUsingBonusTime()
        } else {
            stopUsingBonusTime()
        }
    }
}

在上面的例子中,isFaceUp是一个普通的存储属性。willSet闭包中的代码会在isFaceUp的值即将被设置时执行。其中有一个特殊的变量newValue,代表即将被设置的新值。还有一个didSet观察器,它在值被设置后执行,其中可以使用oldValue变量来访问旧值。

属性观察器非常适合在模型中使用,用于同步执行与属性变化相关的操作。

onChange视图修饰符

在视图中,我们通常不使用属性观察器来观察@State@Published变量的变化,因为视图的重新绘制已经是对这些变化的响应。相反,我们使用onChange视图修饰符。

Text("Taps: \(taps)")
    .onChange(of: viewModel.cards) { newValue in
        taps += 1
    }

onChange可以附加到任何视图上。当它监视的表达式发生变化时,就会执行提供的闭包。闭包参数newValue是变化后的新值。

2. 动画基础

现在,让我们回到动画的主题。我将通过幻灯片讲解、演示和作业实践这三种方式来教授动画知识。

动画的本质

理解动画的首要原则是:动画只是向你展示已经发生的变化

动画展示的是模型中的变化,通常是随时间展示的。这种变化通过视图修饰符的参数、形状的变化以及视图的进出屏幕来体现。动画系统并不会在动画持续的五秒内逐步改变一个不透明度变量。当你将不透明度从0设置为1时,它在你的整个视图中瞬间就变成了1。动画系统只是在五秒内将这个变化展示给用户看。

一旦你理解了这一点,动画如何融入你的其他代码就会变得清晰。

变化的类型

主要有三种类型的变化可以动画化:

  1. 视图修饰符参数的变化:这是UI变化的主要驱动力。例如,不透明度、宽高比,甚至是视图的移动(通过position视图修饰符实现)。
  2. 形状的变化:自定义形状的路径参数变化。
  3. 视图的进出屏幕:视图进入或离开屏幕,例如淡入淡出或飞入飞出。

视图只有在已经出现在屏幕上之后,其视图修饰符参数的变化才会被动画化。视图刚出现在屏幕上时,会以其初始值直接显示,不会从某个随机值动画过来。

并非所有视图修饰符参数都可以动画化,但绝大多数你认为可以动画化的参数都是可以的。

视图的进出

视图主要通过以下方式进出屏幕:

  • if-elseswitch语句。
  • ForEach:当数组中的元素被移除或添加时,对应的视图也会被移除或添加。

视图的进出动画(称为“过渡”)只有在视图所在的容器已经在屏幕上时才会发生。如果容器和视图一起出现或消失,那么只有容器的过渡动画会生效。

3. 触发动画的三种方式

有三种方式可以触发动画:

  1. 隐式动画:使用.animation视图修饰符。它会使应用到该视图上的所有符合条件的视图修饰符变化都产生动画。这是我们之前使用shuffle时看到的动画类型。
  2. 显式动画:使用withAnimation代码块包裹一段操作。这会使得该代码块内引起的所有变化一起被动画化。这是我们进行动画的主要方式。
  3. 视图过渡:通过添加或移除视图来触发其定义的过渡动画。

同样,所有这些动画都只对已经在屏幕上的视图生效。

4. 隐式动画

隐式动画,有些人称之为自动动画,本质上是一个视图修饰符。它标记了一个视图,使得所有应用在该视图上的、位于此修饰符之前的视图修饰符的变化,只要满足.animation中指定的条件,就会按照某个动画曲线进行动画。

GhostView()
    .opacity(scary ? 1 : 0)
    .rotationEffect(.degrees(upsideDown ? 180 : 0))
    .animation(.easeInOut)

在上面的例子中,.animation(.easeInOut)会导致opacityrotationEffect的变化被动画化。如果我将.animation放在GhostView()后面,那么两个效果都不会被动画化。如果放在opacityrotationEffect之间,则只有opacity会被动画化。

.animation视图修饰符的行为更像.font而不是.padding。如果你将它应用在一个VStack上,它几乎会影响到VStack内的所有视图。这有时会让人困惑。通常,我们较少在容器上使用隐式动画,更多的是在叶子视图(如Text或我们自定义的视图)上使用。

动画曲线

.animation的第一个参数是动画曲线,它是一个Animation结构体。它控制动画如何进行,允许你设置:

  • 持续时间:动画持续多久。
  • 延迟:动画开始前的等待时间。
  • 重复:动画是否以及如何重复。
  • 曲线:动画速度随时间的变化方式。
    • .easeInOut(默认):开始慢,加速,结束慢。
    • .linear:匀速。
    • .spring:类似弹簧效果,快速到达目标,然后轻微振动。

你可以在Animation的文档中查看所有可用的曲线和设置。

隐式动画并非我们动画的主要来源。我们主要将它们用于叶子视图,或者用于那些与其他视图动画完全独立的动画。

5. 显式动画

应用中动画最可能的触发原因是用户交互,例如点击卡片或点击洗牌按钮。对于这类变化,我们几乎总是使用显式动画。

显式动画的本质是用withAnimation代码块包裹一段会引起变化的操作。

withAnimation(.linear(duration: 2)) {
    // 执行一些会改变视图修饰符或形状参数的操作
}

显式动画几乎总是包裹在视图模型意图函数(intent function)的调用周围。在视图模型中调用一个意图函数而不包裹显式动画是极不常见的。

重要提示:隐式动画比显式动画具有更高的优先级。如果你在一个显式动画块内部,有一个视图应用了隐式动画(.animation),那么隐式动画的设置将覆盖显式动画。这是合理的,因为隐式动画通常用于定义独立的动画行为。

6. 过渡

过渡专门处理视图进出屏幕的动画。它只对CTAAOS(Containers That Are Already On Screen,已经在屏幕上的容器)内的视图有效。

在底层,过渡实际上只是一对视图修饰符:一个用于视图进入屏幕前的状态,另一个用于视图进入屏幕后的状态。系统会在这两个状态之间进行动画。对于不对称过渡(例如淡入但缩放消失),则需要两对共四个视图修饰符。

我们99%的时间都使用预定义的过渡。这些过渡定义在一个名为AnyTransition的结构体中(虽然真正的过渡结构体是Transition,但AnyTransition进行了类型擦除,我们可以在这里找到所有静态定义)。

一些常见的过渡包括:

  • .opacity:淡入淡出。
  • .scale:缩放进入/消失。
  • .offset:通过偏移量飞入飞出。
  • .move(edge:):从屏幕边缘移入移出。

你也可以使用.modifier(active:identity:)自定义过渡。

指定过渡

使用.transition视图修饰符来指定视图进出时应使用哪种过渡。

ZStack {
    if isFaceUp {
        RoundedRectangle(cornerRadius: 12)
            .stroke()
        Text("👻")
            .transition(.scale) // 文字缩放
    } else {
        RoundedRectangle(cornerRadius: 12)
            .fill()
            .transition(.identity) // 背面立即出现
    }
}
// .transition(.opacity) // 整个ZStack淡入淡出(默认)

.transition作用于整个视图。如果你将它应用在一个ZStack上,你指定的是整个ZStack如何淡入或生长,而不是内部的每个子视图。

关键理解.transition是一个名词,它指定了“当这个视图出现或消失时,使用哪种过渡效果”。让过渡动画发生的动作是将视图从屏幕上移除或添加到屏幕上。仅仅添加.transition修饰符并不会触发动画。

你可以为特定的过渡指定动画曲线,但这并不常用。

.transition(.opacity.animation(.linear(duration: 20)))

7. 匹配几何效果

有时,你想让一个视图从一个位置移动到另一个位置。如果两个视图在同一个容器内(例如洗牌时LazyVGrid内卡片的重排),这很容易,因为改变position视图修饰符的参数就能实现动画。

但是,如果两个视图不在同一个容器内呢?例如,你想从屏幕角落的一个牌堆中发牌到游戏区域的LazyVGrid中。这时,你需要使用matchedGeometryEffect

它的原理是:你创建两个视图,一个在源容器(牌堆),一个在目标容器(游戏区)。你为这两个视图应用matchedGeometryEffect视图修饰符,并指定相同的id。然后,你通过代码确保同一时刻只有一个视图在屏幕上(例如,通过控制驱动ForEach的数组)。当源视图消失而目标视图出现时,系统会匹配它们的位置和大小,让视图从源位置“飞”到目标位置,并可能伴随缩放。

你还需要一个@Namespace来区分不同的匹配几何效果组(例如,同时有发牌堆和弃牌堆时)。

@Namespace private var dealingNamespace

// 在牌堆中的视图
CardView(card)
    .matchedGeometryEffect(id: card.id, in: dealingNamespace)

// 在游戏区中的视图
CardView(card)
    .matchedGeometryEffect(id: card.id, in: dealingNamespace)

8. onAppear 与动画启动

由于动画只对已经在屏幕上的视图生效,那么如果一个视图一出现在屏幕上就想启动一个动画该怎么办?这时可以使用.onAppear视图修饰符。

.onAppear接受一个闭包,该闭包会在视图每次出现在屏幕上时执行。既然视图出现在屏幕上,它必然已经在一个CTAAOS里。因此,你可以在.onAppear内部使用withAnimation来启动动画。

SomeView()
    .onAppear {
        withAnimation {
            // 改变某些状态,触发动画
        }
    }

这对于实现诸如“+2”得分提示飞出的效果非常有用。

9. 动画引擎:Animatable 协议

视图修饰符和形状是动画发生的地方。那么,驱动它们动画的引擎是如何工作的呢?

本质上,动画系统会将动画持续时间分割成许多小片段,然后不断询问视图修饰符或形状:“在当前的片段,你应该是什么样子?”视图修饰符或形状只需要根据当前片段的值来绘制自己。

这个通信是通过实现Animatable协议来完成的。该协议只有一个要求:一个名为animatableData的计算属性。

protocol Animatable {
    associatedtype AnimatableData : VectorArithmetic
    var animatableData: Self.AnimatableData { get set }
}
  • animatableData的类型必须遵循VectorArithmetic协议,这意味着它可以进行代数运算,从而能够被分割。
  • 设置 animatableData:动画系统通过设置这个值来告诉视图“请绘制对应于这个片段的状态”。
  • 获取 animatableData:动画开始前,系统通过获取这个值来知道动画的起点和终点。

通常,animatableData会是一个DoubleFloat。如果你需要同时动画多个值,可以使用AnimatablePair<First, Second>,其中FirstSecond也需遵循VectorArithmetic。通过组合AnimatablePair,你可以动画几乎任何数学上可分割的数据。

在实践中,你通常不会直接使用animatableData这个变量名。你会有自己的变量(例如rotation),然后让animatableDatagetset方法映射到这个变量上。

var animatableData: Double {
    get { rotation }
    set { rotation = newValue }
}

一旦你实现了Animatable协议,系统就会将动画控制权交给你,原本会自动动画的视图修饰符(如.opacity)将不再自动工作,需要你在代码中根据animatableData(即rotation)来手动计算和设置它们。

总结

本节课我们一起深入探讨了SwiftUI动画的核心机制。我们学习了:

  1. 属性观察器 (willSet/didSet) 和 onChange 视图修饰符,用于响应属性变化。
  2. 动画的本质:展示已经发生的变化。
  3. 触发动画的三种方式:隐式动画 (.animation)、显式动画 (withAnimation) 和视图过渡。
  4. 过渡效果:控制视图进出屏幕的动画,如.opacity, .scale等。
  5. 匹配几何效果 (matchedGeometryEffect):用于在不同容器间动画化地移动视图。
  6. onAppear:在视图出现时执行代码,常用于启动初始动画。
  7. Animatable协议:创建自定义动画视图修饰符和形状的底层机制。

理解这些概念将帮助你构建出流畅、响应式的用户界面。在接下来的演示和作业中,你将有机会实践这些知识,让动画在你的应用中活起来。

009:动画与过渡效果

在本节课中,我们将继续完善记忆卡片游戏,重点学习如何实现动画效果和视图过渡。我们将为游戏添加分数飞行动画、卡片计时器动画以及发牌动画,并深入理解matchedGeometryEffect等高级动画技术。

上一节我们为游戏添加了基本的卡片匹配逻辑和分数计算。本节中,我们来看看如何通过动画让这些交互变得更加生动和直观。

实现分数飞行动画

首先,我们需要在卡片匹配或失配时,让分数变化以动画形式“飞”出。这需要一个叠加在卡片上的视图来显示分数变化。

跟踪分数变化

为了知道哪个卡片导致了最近的分数变化以及变化了多少,我们使用一个元组来存储这两条信息。元组是Swift中一种将多个值组合成一个复合值的数据结构。

// 使用元组存储最后一次分数变化的信息
private var lastScoreChange: (amount: Int, causedByCardId: Card.ID) = (0, "")

在用户选择卡片时,我们更新这个状态:

let scoreBeforeChoosing = viewModel.score
// ... 处理卡片选择逻辑 ...
let scoreChange = viewModel.score - scoreBeforeChoosing
lastScoreChange = (scoreChange, card.id)

创建飞行动画视图

FlyingNumber视图负责显示并动画化分数。我们使用offset修饰符来移动它的位置,并使用withAnimation在视图出现时触发动画。

struct FlyingNumber: View {
    let number: Int
    @State private var offset: CGFloat = 0

    var body: some View {
        if number != 0 {
            Text(number, format: .number.sign(strategy: .always()))
                .font(.largeTitle)
                .foregroundColor(number < 0 ? .red : .green)
                .shadow(color: .black, radius: 1.5, x: 1, y: 1)
                .offset(x: 0, y: offset)
                .opacity(offset != 0 ? 0 : 1) // 根据偏移量淡出
                .onAppear {
                    withAnimation(.easeIn(duration: 1.5)) {
                        // 根据分数正负决定飞行方向
                        offset = number < 0 ? 200 : -200
                    }
                }
                .onDisappear {
                    offset = 0 // 视图消失时重置状态
                }
        }
    }
}

关键点

  • offset用于控制视图的垂直位移。
  • onAppear闭包在视图出现时执行,我们在这里用动画改变offset
  • opacityoffset绑定,实现飞走时淡出的效果。
  • onDisappear用于重置动画状态,确保下次出现时能正确播放。

确保视图层级

为了让飞出的分数显示在所有卡片之上,我们需要使用zIndex来控制视图的堆叠顺序。

.zIndex(scoreChange(causedBy: card) != 0 ? 100 : 0)

为计时器饼图添加动画

游戏中的卡片有一个6秒的计时器,匹配越快,获得的奖励分数越多。我们需要让表示剩余时间的饼图动画化。

使用TimelineView实现平滑动画

TimelineView是一个强大的视图,它会以系统认为合适的频率(例如动画帧率)反复调用其内容闭包,非常适合驱动基于时间的动画。

TimelineView(.animation) { timeline in
    Pie(endAngle: .degrees(card.bonusPercentRemaining * 360))
        .opacity(0.5)
        .foregroundColor(.blue)
}

关键点

  • .animation调度器让TimelineView与动画系统同步更新。
  • 每次更新时,Pie视图会根据card.bonusPercentRemaining(一个随时间减少的模型属性)重新绘制,从而产生动画效果。
  • 你可以通过.animation(minimumInterval: 0.1)等方式控制更新频率,以平衡流畅度和性能。

实现发牌动画

最后,我们来实现一个更复杂的动画:从牌堆中一张张发牌到游戏区域。

管理“已发”状态

发牌是一个纯粹的UI行为,与游戏模型无关。我们使用一个Set来跟踪哪些卡片的ID已经被发出。

@State private var dealt = Set<Card.ID>() // 存储已发卡片的ID

// 辅助计算属性
var undealtCards: [Card] {
    viewModel.cards.filter { !dealt.contains($0.id) }
}
func isDealt(_ card: Card) -> Bool {
    dealt.contains(card.id)
}

在游戏主视图中,我们只显示已发的卡片:

AspectVGrid(items: viewModel.cards, aspectRatio: 2/3) { card in
    if isDealt(card) { // 仅当卡片已发时才显示
        CardView(card: card)
            .matchedGeometryEffect(id: card.id, in: dealingNamespace)
            .transition(.asymmetric(insertion: .identity, removal: .identity))
    }
}

创建牌堆视图

牌堆由所有未发出的卡片堆叠而成。

@Namespace private var dealingNamespace

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs193p-ios-appdev/img/711a6f9e75d5251933b0dd4981118cf8_23.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs193p-ios-appdev/img/711a6f9e75d5251933b0dd4981118cf8_24.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs193p-ios-appdev/img/711a6f9e75d5251933b0dd4981118cf8_26.png)

ZStack {
    ForEach(undealtCards) { card in
        CardView(card: card)
            .matchedGeometryEffect(id: card.id, in: dealingNamespace)
            .transition(.asymmetric(insertion: .identity, removal: .identity))
    }
    .frame(width: deckWidth, height: deckWidth / aspectRatio)
    .foregroundColor(viewModel.color)
    .onTapGesture {
        deal() // 点击牌堆发牌
    }
}

实现发牌动画

发牌函数遍历所有卡片,将它们加入dealt集合,并为每张卡片添加递增的动画延迟,模拟依次发出的效果。

private func deal() {
    var delay: TimeInterval = 0
    for card in viewModel.cards {
        withAnimation(dealAnimation.delay(delay)) {
            _ = dealt.insert(card.id) // 触发动画
        }
        delay += dealInterval
    }
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs193p-ios-appdev/img/711a6f9e75d5251933b0dd4981118cf8_31.png)

// 动画常量
private let dealAnimation: Animation = .easeInOut(duration: 1)
private let dealInterval: TimeInterval = 0.15

使用Matched Geometry Effect

为了实现卡片从牌堆“飞”到游戏区域的视觉效果,我们使用了matchedGeometryEffect。它能让两个在不同位置的、具有相同ID的视图,在其中一个被插入、另一个被移除时,其尺寸和位置产生平滑的动画过渡。

关键点

  • id参数必须唯一标识视图(这里使用卡片ID)。
  • namespace参数用于区分不同的动画组(例如,发牌堆和弃牌堆需要不同的命名空间)。
  • 为了使用几何匹配动画而禁用默认的淡入淡出过渡,我们需要设置一个不对称过渡:transition(.asymmetric(insertion: .identity, removal: .identity))

本节课中我们一起学习了SwiftUI中几种核心的动画技术:

  1. 使用withAnimation和状态驱动视图属性变化,实现了分数的飞入飞出。
  2. 利用TimelineView创建基于时间的连续动画,让计时器饼图得以平滑转动。
  3. 综合运用状态管理、延迟动画和matchedGeometryEffect,构建了复杂的发牌序列动画,并理解了命名空间和过渡修饰符在其中的关键作用。

通过这些实践,你应当能够为应用中的各种状态变化添加生动、流畅的视觉反馈,极大地提升用户体验。记住,动画的目的不仅是美观,更是为了清晰地传达信息。

010:EmojiArt应用入门与拖放功能 🎨

在本节课中,我们将开始构建一个全新的应用——EmojiArt。这个应用允许用户使用表情符号创作艺术作品。我们将学习拖放功能、手势操作、多视图模型(MVVM)架构,以及如何处理颜色、图像和多线程错误。课程将从创建项目开始,逐步实现应用的核心功能。

概述 📋

EmojiArt应用的核心功能包括:

  • 使用表情符号创作艺术作品。
  • 通过拖放功能添加背景图像和表情符号。
  • 支持缩放和平移操作。
  • 使用多视图模型(MVVM)架构管理应用状态。

接下来,我们将从创建项目开始,逐步实现这些功能。

创建项目与模型 🛠️

首先,我们需要在Xcode中创建一个新项目。选择“App”模板,将项目命名为“EmojiArt”,并确保选择iPad作为目标设备,以便在开发过程中获得更大的操作空间。

定义数据模型

数据模型是应用的核心,它定义了艺术作品的结构。我们的模型包括背景图像和表情符号的位置、大小等信息。

以下是EmojiArt数据模型的代码:

struct EmojiArt {
    var background: URL?
    var emojis: [Emoji]

    struct Emoji: Identifiable {
        let id: Int
        let string: String
        var position: Position
        var size: Int

        struct Position {
            var x: Int
            var y: Int
        }
    }
}

在这个模型中:

  • background 存储背景图像的URL。
  • emojis 是一个数组,包含所有表情符号及其位置和大小信息。
  • Emoji 结构体实现了 Identifiable 协议,确保每个表情符号都有唯一的标识符。

添加表情符号功能

为了管理表情符号的添加,我们在模型中添加一个函数:

mutating func addEmoji(_ emoji: String, at position: Emoji.Position, size: Int) {
    uniqueEmojiID += 1
    emojis.append(Emoji(id: uniqueEmojiID, string: emoji, position: position, size: size))
}

这个函数会为每个新添加的表情符号分配一个唯一的ID,并确保模型的状态可以被正确修改。

视图模型(ViewModel) 🧩

视图模型负责将数据模型转换为视图可以使用的格式。在EmojiArt中,视图模型还提供了拖放功能的支持。

以下是视图模型的核心代码:

class EmojiArtDocument: ObservableObject {
    @Published private var emojiArt = EmojiArt()

    var emojis: [EmojiArt.Emoji] {
        emojiArt.emojis
    }

    var background: URL? {
        emojiArt.background
    }

    func setBackground(_ url: URL) {
        emojiArt.background = url
    }

    func addEmoji(_ emoji: String, at position: CGPoint, size: CGFloat) {
        let emojiPosition = EmojiArt.Emoji.Position(x: Int(position.x), y: Int(position.y))
        emojiArt.addEmoji(emoji, at: emojiPosition, size: Int(size))
    }
}

视图模型通过 @Published 属性包装器确保数据变化时视图能够及时更新。同时,它提供了添加表情符号和设置背景图像的功能。

视图(View)设计 🖼️

视图是用户与应用交互的界面。在EmojiArt中,视图包括背景图像和表情符号的显示区域。

主视图结构

主视图使用 ZStack 布局,将背景图像和表情符号叠加显示:

struct EmojiArtDocumentView: View {
    @ObservedObject var document: EmojiArtDocument

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                Color.white
                AsyncImage(url: document.background)
                    .position(CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2))
                ForEach(document.emojis) { emoji in
                    Text(emoji.string)
                        .font(.system(size: CGFloat(emoji.size)))
                        .position(emoji.position.in(geometry))
                }
            }
        }
    }
}

在这个视图中:

  • GeometryReader 用于获取视图的坐标系统,以便正确放置表情符号。
  • AsyncImage 异步加载背景图像,避免阻塞用户界面。
  • 表情符号通过 ForEach 循环动态生成,并根据其位置和大小进行布局。

添加拖放功能

拖放功能是EmojiArt的核心交互之一。我们通过 draggabledropDestination 修饰符实现。

以下是实现拖放功能的代码:

struct EmojiArtDocumentView: View {
    // ... 其他代码

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                // ... 背景和表情符号
            }
            .dropDestination(for: SterileData.self) { items, location in
                return drop(items, at: location, in: geometry)
            }
        }
    }

    private func drop(_ items: [SterileData], at location: CGPoint, in geometry: GeometryProxy) -> Bool {
        for item in items {
            switch item {
            case .url(let url):
                document.setBackground(url)
                return true
            case .string(let emoji):
                let emojiPosition = emojiPosition(at: location, in: geometry)
                document.addEmoji(emoji, at: emojiPosition, size: 40)
                return true
            default:
                break
            }
        }
        return false
    }
}

在这个实现中:

  • dropDestination 修饰符允许视图接收拖放的数据。
  • SterileData 是一个自定义类型,支持字符串、URL和图像数据的拖放。
  • drop 函数根据拖放的数据类型执行相应的操作,例如设置背景图像或添加表情符号。

手势操作与缩放功能 ✋

接下来,我们将为EmojiArt添加手势操作功能,支持缩放和平移。这是通过 gesture 修饰符实现的。

以下是实现缩放功能的代码:

struct EmojiArtDocumentView: View {
    @State private var zoomScale: CGFloat = 1.0

    var body: some View {
        GeometryReader { geometry in
            ZStack {
                // ... 背景和表情符号
            }
            .scaleEffect(zoomScale)
            .gesture(
                MagnificationGesture()
                    .onChanged { value in
                        zoomScale = value
                    }
            )
        }
    }
}

在这个实现中:

  • MagnificationGesture 用于检测缩放手势。
  • scaleEffect 修饰符根据缩放比例调整视图的大小。

总结 🎉

在本节课中,我们一起学习了如何构建EmojiArt应用的核心功能。我们从创建项目和定义数据模型开始,逐步实现了视图模型和视图的设计。通过拖放功能,用户可以轻松添加背景图像和表情符号。此外,我们还介绍了手势操作的基本实现,为后续的缩放和平移功能打下基础。

在接下来的课程中,我们将进一步完善EmojiArt应用,添加更多交互功能,例如多选表情符号、调整大小和位置等。希望本节课的内容能够帮助你更好地理解SwiftUI和iOS应用开发的核心概念。继续加油,期待在下一节课中与你再次相见!

011:手势与多视图模型

在本节课中,我们将学习如何在SwiftUI应用中处理复杂的手势(如缩放和拖拽),并探索如何在一个应用中使用多个视图模型(MVVM)来组织代码。我们将以EmojiArt应用为例,实现文档的缩放与平移功能,并添加一个独立的调色板选择器。

手势处理概述

上一节我们介绍了拖放功能。本节中,我们来看看如何通过手势与用户进行更丰富的交互。SwiftUI内置了强大的手势识别系统,可以识别常见的多点触控手势,如捏合、拖拽、旋转等。我们的任务不是识别这些手势,而是编写代码来响应这些手势。

添加手势识别器

要为视图添加手势识别,需要使用 .gesture 视图修饰符。传递给它的参数必须遵循 Gesture 协议,通常我们会使用Apple提供的预定义手势。

以下是如何创建一个手势的示例:

private var zoomGesture: some Gesture {
    MagnificationGesture()
        .onEnded { value in
            // 手势结束时执行的代码
        }
}

离散与非离散手势

手势分为两类:离散手势和非离散手势。

  • 离散手势:如点击(TapGesture)和长按(LongPressGesture)。它们瞬间发生,我们通常只在手势结束时进行处理。
  • 非离散手势:如拖拽(DragGesture)、缩放(MagnificationGesture)和旋转(RotationGesture)。它们持续一段时间,我们需要在手势进行中持续更新UI以提供即时反馈。

处理离散手势非常简单,只需使用 .onEnded 闭包。而对于非离散手势,我们需要更复杂的机制来跟踪手势过程中的状态变化。

处理非离散手势

处理非离散手势的核心是使用 @GestureState 属性包装器。它专门用于存储手势进行期间的临时状态。

以下是处理缩放手势的关键步骤:

  1. 定义手势状态:使用 @GestureState 声明一个变量来存储手势过程中的缩放比例。
    @GestureState private var gestureZoom: CGFloat = 1.0
    

  1. 更新手势状态:使用 .updating 修饰符,在手势进行中不断更新 gestureZoom

    .updating($gestureZoom) { value, gestureState, _ in
        gestureState = value
    }
    
    • value:手势的当前值(例如,缩放比例)。
    • gestureState:需要更新的 @GestureState 变量。
    • 注意:只能在 .updating 闭包中修改 @GestureState 变量
  2. 应用手势状态到视图:在视图的修饰符中,结合使用永久状态(如 @State private var zoom: CGFloat = 1.0)和手势状态。

    .scaleEffect(zoom * gestureZoom)
    
  3. 更新永久状态:在手势结束时(.onEnded),根据最终的手势值更新应用的永久状态(@State 或模型数据)。

    .onEnded { value in
        zoom *= value
    }
    

总结处理流程.updating 用于更新临时的 @GestureState.onEnded 用于更新永久的 @State 或模型;视图绘制时同时依赖这两种状态。

同时识别多个手势

有时需要让一个视图同时响应多种手势(例如,既能缩放又能平移)。可以使用 .simultaneously 方法将多个手势组合起来。

.gesture(panGesture.simultaneously(with: zoomGesture))

在EmojiArt中实现缩放与平移

现在,我们将上述概念应用到EmojiArt项目中,为文档内容添加缩放和平移功能。

第一步:提取文档内容并添加状态

首先,我们将文档内容(背景和表情符号)提取到一个独立的视图 documentContents 中。然后,声明两个 @State 变量来存储缩放比例和平移偏移量。

@State private var zoom: CGFloat = 1.0
@State private var pan: CGOffset = .zero // CGOffset 是 CGSize 的别名

第二步:应用变换

documentContents 视图上应用 .scaleEffect.offset 修饰符,使其能够根据 zoompan 状态进行变换。

.scaleEffect(zoom)
.offset(pan)

第三步:创建缩放手势

创建一个 MagnificationGesture,并实现 .updating.onEnded 逻辑。

@GestureState private var gestureZoom: CGFloat = 1.0

private var zoomGesture: some Gesture {
    MagnificationGesture()
        .updating($gestureZoom) { value, gestureState, _ in
            gestureState = value
        }
        .onEnded { value in
            zoom *= value
        }
}

在视图的 .scaleEffect 中,结合使用 zoomgestureZoom

.scaleEffect(zoom * gestureZoom)

第四步:创建平移手势

类似地,创建一个 DragGesture 来处理平移。

@GestureState private var gesturePan: CGOffset = .zero

private var panGesture: some Gesture {
    DragGesture()
        .updating($gesturePan) { value, gestureState, _ in
            gestureState = value.translation
        }
        .onEnded { value in
            pan += value.translation
        }
}

在视图的 .offset 中,结合使用 pangesturePan

.offset(pan + gesturePan)

第五步:组合手势并应用

将缩放和平移手势组合,并应用到包含 documentContents 的容器视图上。

.gesture(panGesture.simultaneously(with: zoomGesture))

第六步:调整坐标转换

由于视图现在可以缩放和平移,之前添加表情符号时使用的坐标转换逻辑需要更新,以考虑当前的 zoompan 值,确保表情符号被放置在正确的位置和大小。

引入第二个MVVM:调色板管理器

一个真实的应用通常由多个功能模块组成,每个模块都可以拥有自己的视图模型(MVVM)。接下来,我们为EmojiArt添加一个调色板选择器,它将作为一个独立的MVVM模块。

模型:Palette

首先定义数据模型 Palette,它代表一个命名的表情符号集合。

struct Palette: Identifiable {
    let id: UUID
    var name: String
    var emojis: String
}

视图模型:PaletteStore

创建 PaletteStore 作为视图模型。它负责管理一组 Palette 对象,并处理相关的业务逻辑,例如确保始终至少有一个调色板、提供游标索引等。

class PaletteStore: ObservableObject {
    @Published var palettes: [Palette]
    private var _cursorIndex = 0

    var cursorIndex: Int {
        get { boundsCheckedPaletteIndex(_cursorIndex) }
        set { _cursorIndex = boundsCheckedPaletteIndex(newValue) }
    }

    private func boundsCheckedPaletteIndex(_ index: Int) -> Int {
        // 确保索引在有效范围内
    }

    // 其他辅助方法:insert, append, delete 等
}

视图:PaletteChooser

创建 PaletteChooser 视图,它使用 PaletteStore。这个视图包含一个按钮用于切换调色板,以及一个显示当前调色板内容的区域。

struct PaletteChooser: View {
    @EnvironmentObject var store: PaletteStore

    var body: some View {
        HStack {
            // 切换按钮
            AnimatedActionButton(systemImage: "paintpalette") {
                store.cursorIndex += 1
            }
            .contextMenu {
                // 上下文菜单:新建、删除
                AnimatedActionButton("New", systemImage: "plus") {
                    store.insert(name: "New", emojis: "")
                }
                AnimatedActionButton("Delete", systemImage: "minus", role: .destructive) {
                    store.palettes.remove(at: store.cursorIndex)
                }
            }

            // 当前调色板显示
            HStack {
                Text(store.palettes[store.cursorIndex].name)
                ScrollingEmojisView(emojis: store.palettes[store.cursorIndex].emojis)
            }
            .id(store.palettes[store.cursorIndex].id) // 通过ID触发视图过渡动画
            .transition(.asymmetric(insertion: .move(edge: .bottom), removal: .move(edge: .top)))
            .clipped()
        }
    }
}

在应用层级注入共享状态

PaletteStore 需要在应用的所有相关部分共享。我们使用 @StateObject 在应用的根视图(EmojiArtApp)中创建它,并通过 .environmentObject 将其注入到视图环境中。

@main
struct EmojiArtApp: App {
    @StateObject var paletteStore = PaletteStore(name: "Main")

    var body: some Scene {
        WindowGroup {
            EmojiArtDocumentView()
                .environmentObject(paletteStore) // 注入
        }
    }
}

在需要使用 PaletteStore 的子视图中,使用 @EnvironmentObject 来获取它。

struct PaletteChooser: View {
    @EnvironmentObject var store: PaletteStore // 获取
    // ...
}

总结

本节课中我们一起学习了两个核心主题。

首先,我们深入探讨了SwiftUI中非离散手势的处理机制。关键在于理解 @GestureState 的用途:它作为手势过程中的临时状态存储,通过 .updating 修饰符进行更新,并在手势结束时将结果同步到永久的 @State 或模型数据中。我们还学习了如何使用 .simultaneously 让视图同时响应多个手势。

其次,我们实践了在单个应用中构建多个MVVM模块。通过创建独立的 Palette 模型、PaletteStore 视图模型和 PaletteChooser 视图,我们实现了一个功能完整的调色板管理器。更重要的是,我们学会了如何使用 @StateObject@EnvironmentObject 在应用顶层创建和共享视图模型,使其能够被整个视图层次结构方便地访问。

这些技能是构建复杂、模块化SwiftUI应用的基础。在接下来的课程中,我们将继续探索更多UI组件和数据处理技术。

012:数据持久化

在本节课中,我们将要学习数据持久化,即如何让数据在应用被关闭、设备重启等情况下依然保存在设备上。我们首先会讨论本地持久化,即数据存储在设备上,并会通过EmojiArt应用来实践这些概念。之后,我们将深入探讨属性包装器,如 @State@Published@ObservedObject 等,帮助你理解它们的工作原理。

概述

数据持久化是应用开发中的核心概念,它确保用户数据不会丢失。iOS提供了多种持久化方案,包括文件系统、SQL数据库、Core Data、iCloud以及UserDefaults等。本节课我们将重点介绍文件系统存储和UserDefaults这两种方式。

文件系统存储

iOS底层是一个类Unix操作系统,拥有一个受保护的文件系统。每个应用都运行在一个“沙盒”中,只能访问自己沙盒内的文件,这保证了安全性和隐私性。

沙盒目录

沙盒中包含几个重要的目录:

  • 应用程序目录:存放应用的可执行文件和资源文件,只读。
  • 文档目录:存放用户创建的数据,如文档。
  • 应用支持目录:存放应用运行所需但不被用户直接感知的数据。
  • 缓存目录:存放可重新生成的数据,不会备份到iCloud。

使用URL访问文件

我们使用 URL 结构体来定位沙盒中的文件。可以通过 URL 的静态属性获取沙盒目录的URL,然后通过追加路径组件来构建具体的文件路径。

let documentsURL = URL.documentsDirectory
let fileURL = documentsURL.appendingPathComponent("fileName.doc")

读写数据

Data 结构体代表一个字节包,是读写文件的主要方式。

  • 读取:使用 Data(contentsOf: url) 初始化器。
  • 写入:使用 data.write(to: url) 方法。

这些操作都可能抛出错误,例如磁盘已满或文件不存在。

文件管理器

FileManager 用于管理文件系统本身,例如复制文件、创建目录、列出目录内容等。通常使用其默认实例 FileManager.default

Codable与JSON编码

我们的应用数据通常是结构体或类,而非原始的 DataCodable 协议可以将这些自定义类型与JSON数据相互转换。

使类型可编码

如果一个结构体的所有属性都是 Codable 的,那么只需声明该结构体遵循 Codable 协议即可自动获得编码和解码能力。Swift标准库中的许多类型(如 StringIntURLArrayDictionaryOptional)都遵循 Codable

编码与解码

  • 编码为JSON Data:使用 JSONEncoder().encode(object)
  • 从JSON Data解码:使用 JSONDecoder().decode(Type.self, from: data)

这两个操作都可能抛出错误,需要进行错误处理。

UserDefaults

UserDefaults 是一个轻量级的持久化字典,适合存储用户偏好设置等小量数据。它并不是存储重要数据的首选方案。

使用UserDefaults

通常使用标准实例 UserDefaults.standard。存储的数据必须是“属性列表”类型,包括 StringDataDateArrayDictionary 等。

  • 存储数据:使用 set(_:forKey:) 方法。
  • 读取数据:使用类型特定的方法,如 string(forKey:)array(forKey:)data(forKey:) 等,这些方法返回可选值。

由于 Data 是属性列表类型,我们可以将任何 Codable 对象编码为JSON Data后存入 UserDefaults

错误处理

许多持久化操作可能失败,Swift使用“抛出错误”的机制来处理这些情况。

处理可能抛出错误的函数

调用标记为 throws 的函数时,必须使用 try 关键字。有几种处理方式:

  1. try?:忽略错误,如果失败则返回 nil
  2. try!:确信不会失败,如果失败则程序崩溃。常用于调试。
  3. 向上传递:将当前函数也标记为 throws,让调用者处理。
  4. do-catch 语句:捕获并处理错误。
do {
    let data = try Data(contentsOf: fileURL)
    // 处理数据
} catch {
    print("读取文件失败: \(error.localizedDescription)")
}

属性包装器

属性包装器为属性添加了统一的管理逻辑。我们常用的 @State@Published@ObservedObject 等都是属性包装器。

工作原理

当声明 @Published var emojiArt: EmojiArt 时,Swift会创建一个 Published 结构体的实例。这个实例包含:

  • wrappedValue:存储实际的值(EmojiArt)。
  • projectedValue:通过 $emojiArt 访问,对于 @Published,这是一个发布者。

常见属性包装器详解

  • @State
    • 作用:将值存储到堆中,保持其稳定性,并在值改变时使视图失效(重新绘制)。
    • $ 投影值:一个 Binding(绑定),可以传递给其他视图,让它们修改此状态。
  • @ObservedObject / @StateObject
    • 作用:观察一个遵循 ObservableObject 的类(如ViewModel),当其发布变化(通过 objectWillChange.send())时,使视图失效。
    • 区别@StateObject 由当前视图创建并拥有生命周期;@ObservedObject 由外部传入。
    • $ 投影值:一个 Binding,可以访问到被观察对象内部的可变属性。
  • @Binding
    • 作用:创建一个到其他源(如 @State@ObservedObject 的属性)的绑定。对该绑定的读写会直接反映到源上,并可能使相关视图失效。
    • 用途:在子视图中修改父视图的状态,或连接UI控件(如 TextFieldToggle)到状态。
  • @EnvironmentObject:用于接收通过环境传递的共享可观察对象。
  • @Environment:用于读取诸如颜色方案、布局方向等系统环境值。

绑定的重要性

绑定是SwiftUI数据流的核心,它确保了“单一数据源”原则。我们通过将 @State@ObservedObject 属性的投影值($)传递给子视图或UI控件,使它们能够直接修改源数据,而不是持有数据的副本。

总结

本节课我们一起学习了iOS数据持久化的基础知识。我们了解了如何使用文件系统来保存和加载应用数据,通过 Codable 协议将自定义类型与JSON格式相互转换,并使用了 UserDefaults 来存储轻量级的用户偏好。此外,我们还深入探讨了错误处理的方法以及SwiftUI中关键属性包装器的工作原理,特别是绑定如何在不同视图间建立数据连接。掌握这些概念对于构建能持久保存数据、反应灵敏的iOS应用至关重要。

013:模态视图、导航与多窗格界面

在本节课中,我们将学习如何构建更复杂的用户界面,包括使用模态视图(如Sheet和Popover)来编辑数据,利用导航栈(NavigationStack)和导航链接(NavigationLink)实现界面间的层级跳转,以及创建适用于iPad的多窗格(NavigationSplitView)布局。这些是构建功能丰富、结构清晰的iOS应用的核心技能。

上一节我们介绍了数据绑定和视图模型,本节中我们来看看如何通过模态视图和导航来组织和管理复杂的用户界面。

模态视图:Sheet与Popover

模态视图是一种临时覆盖在现有内容之上的界面,要求用户立即处理某项任务。SwiftUI提供了两种主要的模态视图:.sheet.popover

使用.sheet展示全屏编辑器

.sheet修饰符用于展示一个覆盖大部分屏幕的视图。它需要一个绑定到布尔值的isPresented参数来控制显示与隐藏。

@State private var showPaletteEditor = false

Button("编辑") {
    showPaletteEditor = true
}
.sheet(isPresented: $showPaletteEditor) {
    // 要展示的视图,例如一个编辑器
    PaletteEditor()
        .font(.nil) // 重置字体,避免继承父视图的字体样式
}

当我们将showPaletteEditor设置为true时,PaletteEditor视图会以模态形式弹出。当用户关闭这个视图时,系统会自动将绑定值设回false

使用.popover展示上下文相关视图

.popover.sheet类似,但它通常指向触发它的UI元素(比如一个按钮),并且不会覆盖整个屏幕,更适合展示额外的选项或信息。

.popover(isPresented: $showPaletteEditor) {
    PaletteEditor()
        .frame(minWidth: 300, minHeight: 350) // 可以设置最小尺寸
}

核心概念:无论是.sheet还是.popover,其isPresented参数都是一个双向绑定。我们通过代码设置它来显示视图,系统在用户关闭视图时将其设回false

构建表单(Form)编辑器

对于数据编辑界面,SwiftUI的Form视图是理想的选择。它能自动将内容组织成美观、易用的列表样式,类似于系统设置应用。

struct PaletteEditor: View {
    @Binding var palette: Palette // 绑定到要编辑的数据源

    var body: some View {
        Form {
            Section(header: Text("名称")) {
                TextField("名称", text: $palette.name) // 绑定到名称
            }
            Section(header: Text("表情符号")) {
                // ... 添加和删除表情符号的UI
            }
        }
    }
}

关键点:编辑器通过@Binding接收数据。这意味着对TextField的修改会直接写回外部的视图模型(ViewModel),实现了单一数据源的实时同步。

实现导航(Navigation)

导航允许用户在视图层级中深入和返回,是大多数应用的基础。

导航三要素

实现导航需要三个步骤:

  1. 导航容器: 使用NavigationStack包裹所有需要参与导航的视图。
  2. 导航链接: 使用NavigationLink标记可点击以触发导航的视图。
  3. 导航目标: 使用.navigationDestination修饰符定义点击链接后要显示的目标视图。
// 1. 导航容器
NavigationStack {
    // 2. 导航链接
    List(stores) { store in
        NavigationLink(value: store) { // 关联的值是`store`
            Text(store.name)
        }
    }
    // 3. 导航目标
    .navigationDestination(for: Store.self) { store in
        // 当`NavigationLink`的value是`Store`类型时,显示此视图
        EditablePaletteList(store: store)
    }
}

导航标题

可以通过.navigationTitle修饰符为当前显示的视图设置标题。标题会自动显示在导航栏,并且返回按钮的文字也会随之变化。

EditablePaletteList(store: store)
    .navigationTitle(store.name)

创建可编辑列表

列表(List)结合ForEach可以轻松实现滑动删除和移动排序功能。

以下是实现可编辑列表的操作:

  • 滑动删除: 在ForEach上使用.onDelete修饰符。
  • 移动排序: 在ForEach上使用.onMove修饰符。
  • 工具栏按钮: 在NavigationStack内的视图上使用.toolbar修饰符添加顶部按钮。
List {
    ForEach(store.palettes) { palette in
        PaletteRow(palette: palette)
    }
    .onDelete { indexSet in // 滑动删除
        store.palettes.remove(atOffsets: indexSet)
    }
    .onMove { indexSet, newOffset in // 长按移动
        store.palettes.move(fromOffsets: indexSet, toOffset: newOffset)
    }
}
.toolbar {
    Button {
        store.palettes.insert(Palette(name: "", emojis: ""), at: 0) // 添加新项
    } label: {
        Image(systemName: "plus")
    }
}

为iPad设计:多窗格界面(NavigationSplitView)

在iPad或Mac等大屏设备上,可以使用NavigationSplitView同时展示多个层级的视图,提供更高效的导航体验。

// 三栏布局示例
NavigationSplitView {
    // 侧边栏:第一栏(例如商店列表)
    List(stores, selection: $selectedStore) { store in
        Text(store.name).tag(store) // `.tag`将视图与选择值关联
    }
} content: {
    // 内容栏:第二栏(例如选中商店的表情符号集列表)
    if let selectedStore {
        EditablePaletteList(store: selectedStore)
    } else {
        Text("选择一个商店")
    }
} detail: {
    // 详情栏:第三栏(例如选中表情符号集的编辑器)
    if let selectedPaletteID {
        // 根据ID查找并显示编辑器
    }
}

关键点

  • selection参数配合@State变量和.tag()修饰符,可以管理列表中的选中项。
  • 各栏视图可以根据选中状态动态变化。
  • 确保导航逻辑(NavigationLink)与NavigationSplitView的层级配合,有时需要移除内层的NavigationStack以避免冲突。

处理键盘焦点

为了让视图出现时自动聚焦到某个输入框并弹出键盘,可以使用@FocusState

enum Field { case name, addEmojis }

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs193p-ios-appdev/img/a889660acbcc6bfb2f8c97c2d79590a3_60.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs193p-ios-appdev/img/a889660acbcc6bfb2f8c97c2d79590a3_62.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/stf-cs193p-ios-appdev/img/a889660acbcc6bfb2f8c97c2d79590a3_64.png)

struct PaletteEditor: View {
    @Binding var palette: Palette
    @FocusState private var focusedField: Field? // 可选的焦点状态

    var body: some View {
        Form {
            TextField("名称", text: $palette.name)
                .focused($focusedField, equals: .name) // 绑定到.name状态
            TextField("添加表情符号", text: $emojiInput)
                .focused($focusedField, equals: .addEmojis) // 绑定到.addEmojis状态
        }
        .onAppear {
            // 视图出现时,根据条件设置焦点
            focusedField = palette.name.isEmpty ? .name : .addEmojis
        }
    }
}

本节课中我们一起学习了SwiftUI中构建复杂界面的几种关键模式:使用Sheet和Popover进行模态展示,利用NavigationStack和NavigationLink实现视图导航,通过Form和List构建数据编辑界面,以及为iPad适配多窗格的NavigationSplitView。掌握这些模式,你将能够设计出结构清晰、交互流畅的iOS应用程序。

014:颜色、图像与多线程编程 🎨

在本节课中,我们将学习两个主题。首先,我们会简要介绍颜色和图像在SwiftUI中的表示方式,这是一个相对简单的知识点。然后,我们将深入探讨今天的核心内容——多线程编程。我们还将重新审视错误处理,并学习更多相关知识。多线程编程的核心目标是确保我们的应用保持响应,避免因长时间操作而导致的界面卡顿,从而为用户提供流畅的体验。

颜色与图像 🎨

上一节我们概述了本节课的内容,本节中我们来看看颜色和图像的具体表示。

颜色

你已经了解过 Color 结构体。它是一个非常酷的结构体,因为它实现了多种协议。它可以用来指定颜色,例如 Color.green 表示将前景色设置为绿色。同时,正如我们所见,它也可以作为形状样式使用,例如 fill(Color.green) 可以像渐变或其他形状样式一样进行填充。有趣的是,Color 甚至可以作为视图使用,例如 Color.white 会创建一个填充为白色的矩形,因为 Color 也实现了 View 协议。

然而,在计算机世界中精确表示一个颜色是件复杂的事情。例如,一个特定的粉色色调,在广告中可能需要精确地激发用户的情感反应。因此,存在多种机制来非常具体和准确地指定颜色。Color 结构体本身并不直接处理这些底层细节。

实际上,我们使用 UIColor 来真正表示一个颜色。它知道如何在不同的色彩空间(如RGB或HSB)中创建颜色。你可以通过 Color(uiColor: someUIColor) 轻松地从 UIColor 创建一个 Color 结构体。理解 ColorUIColor 的区别很重要,因为你会在代码中同时看到它们。

此外,还有一个 CGColor,它是Core Graphics系统中颜色的底层表示。你可能会偶尔看到它,例如通过 ColorcgColor 属性获取,但这种情况相对少见。

图像

图像也存在类似的双重表示。我们熟悉 Image 结构体,用它来在屏幕上显示图像,特别是通过 systemName 参数使用系统图标。你也可以将JPEG等图像文件拖入项目的Assets资源目录中,并通过 Image("name") 来使用它们。Assets目录允许你指定图像的分辨率等属性。

Image 还可以通过 .font 修饰符来缩放系统图标以匹配字体大小。系统图标另一个常见用途是作为遮罩使用,例如底部标签栏的图标,它们会通过颜色过滤来显示。

但是,如果你想在代码中“持有”一个图像数据(例如从网络下载的JPEG数据),你需要使用 UIImageUIImage 可以将JPEG等数据转换为图像对象。例如,AsyncImage 视图在从网络获取图像时,内部会先将数据转换为 UIImage,然后再用 Image 显示它。今天我们在构建自己的异步图像加载器时就会看到这个过程。

多线程编程 ⚙️

上一节我们介绍了颜色和图像的基础知识,本节中我们来看看为何以及如何进行多线程编程。

我们绝对不希望阻塞用户界面。所有用户都曾遇到过应用卡顿、触摸无响应的情况,这种体验非常糟糕,用户会因此停止使用这类应用。所以,我们必须找到方法避免这种情况,即使有时我们需要执行耗时的操作。

哪些操作会导致这个问题呢?任何网络访问操作都肯定需要多线程处理,因为网络请求可能耗时数秒甚至一分钟,我们不能让应用在此期间完全卡住。此外,一些非常消耗CPU的计算任务(例如机器学习模型推理)也可能耗时很长,同样不能在主线程上执行。

我们的解决方案是使用线程。线程是执行的路径。现代操作系统和设备大多支持多线程,即使是在单核处理器上,操作系统也可以通过快速切换来模拟并行执行。从程序员的角度看,就像是应用中的多段代码在同时运行。

在Swift中,线程由系统在幕后管理,我们不需要手动创建线程。但系统需要我们的帮助,通过一些API关键字来提示它何时可以将任务放到其他线程上执行。

使用 Task

以下是多线程编程中最基本的概念。如果你有一段可能耗时的代码,不希望它在运行UI的主线程上执行,你可以创建一个 Task 对象。

Task(priority: .background) {
    // 这里放置可能耗时的代码,例如网络请求
}

Task 接受一个优先级参数,表示这个任务的重要程度。UI相关的任务几乎总是拥有最高优先级。你只需提供一个闭包,闭包内的代码将在某个其他线程上运行。你不需要知道具体是哪个线程,系统会管理这一切。

关键点在于,创建 Task 的这行代码会立即返回。系统会获取这个闭包,并将其放入队列,准备在其他线程上执行。返回值是一个 Task 句柄,可以用来取消任务(但取消只是设置一个标志,任务本身需要检查并优雅退出)、等待任务完成等。不过,大多数时候我们只是简单地使用 Task { ... } 来启动一个任务,然后就不管它了。

这看起来很简单,但多线程编程有一个巨大的问题:对数据结构的并发访问。如果多个线程同时读写同一个数据结构(比如数组),会导致数据损坏。Swift通过一种类型帮助我们管理这种共享访问。

使用 Actor

除了之前提到的类、结构体、枚举和函数,Swift类型系统还有一个重要的成员:actoractor 的声明方式与 classstruct 类似。

actor MyActor {
    var myData: [String] = []
    func updateData() { ... }
}

actor 有两个关键特性:

  1. 它是引用类型(像类一样),可以有多处引用指向同一个 actor 实例。
  2. 它会同步对其内部变量和函数的所有访问。actor 是同步的基本单元。

actor 的工作规则如下:

  • 规则一:在任意时刻,一个 actor 中最多只能有一个函数或变量访问器正在运行。即使有100个线程可以运行这个 actor 的代码,同一时间也只有一个能执行。这从根本上防止了数据竞争。
  • 规则二actor 中的函数要么运行到完成,要么可以被挂起。如果一个函数被挂起,那么 actor 就可以去运行其他函数。正是这些“挂起点”使得在保证同步的同时,还能在多个函数间切换执行。

异步函数与 Await

为了让系统知道一个函数可能被挂起,我们需要将其标记为 async

func fetchImage(from url: URL) async throws -> UIImage {
    // 这里可能包含网络请求等耗时操作
}

调用一个 async 函数时,必须使用 await 关键字。

let image = try await fetchImage(from: someURL)

await 意味着“等待”。它表明此处可能会发生挂起。当执行到 await 时,当前函数可能会被挂起,actor 就可以去执行其他任务。当 await 的操作完成后,actor 会在合适的时候恢复执行这个函数。

重要提示:由于在 await 期间 actor 可能执行了其他代码,改变了内部状态,因此在 await 之后,你需要检查之前的假设是否仍然成立。这是多线程编程中需要仔细思考的部分。

如果你想启动一个耗时任务,但又不想让当前函数被挂起(例如,你在主线程上,希望保持UI响应),你可以将这个任务包装在 Task 中。这样,耗时操作在 Task 的闭包中运行,而当前函数可以立即继续执行。

闭包与异步

闭包本身不能直接标记为 async。只有当函数明确声明其参数闭包可以是 async 时,你才能在该闭包内使用 await。例如,SwiftUI 中的 .task.refreshable 修饰符就接受异步闭包。

.someView
    .task {
        // 这里可以安全地使用 await
        let data = try await loadData()
    }

.task 修饰符特别有用,它会在视图出现时启动任务,并在视图消失时自动取消任务,非常适合用于加载视图所需的数据。

主线程 Main Actor

UI 是一个巨大的、需要被保护的数据结构。所有 UI 操作都必须在主线程(Main Thread)上执行,在 Swift 并发模型中,这对应着 主Actor(Main Actor)

  • 所有视图(View)中的代码默认都在主Actor上运行。
  • 你的视图模型(ViewModel)默认在主Actor上运行。如果视图模型中的代码会更新UI,这就会导致问题。

解决方案是使用 @MainActor 属性包装器:

@MainActor
class MyViewModel: ObservableObject {
    // 这个类中的所有代码都将在主Actor上运行
}

你也可以只标记特定的方法:

class MyViewModel: ObservableObject {
    @MainActor
    func updateUI() {
        // 这个方法将在主Actor上运行
    }
}

如果你在非主Actor的线程上修改了 @Published 属性(从而触发UI更新),Xcode会在运行时显示紫色的警告。永远不要忽略这些警告,否则应用在用户设备上可能会出现不可预知的UI错误。

错误处理 🚨

上一节我们深入探讨了多线程机制,本节中我们来看看与之紧密相关的错误处理。

异步编程和错误处理配合得非常好。如果一个 async 函数在等待(await)时发生错误(例如网络故障),它可以立即 throw 这个错误。这使得等待该函数的任务能够被迅速唤醒并处理错误。

语法上,错误处理和多线程也很相似:

  • 可能抛出错误的函数用 throws 标记,调用时用 try
  • 可能挂起的函数用 async 标记,调用时用 await
  • 两者经常结合使用:async throws 函数和 try await 调用非常常见。

抛出与捕获错误

要抛出自定义错误,可以定义一个遵循 Error 协议的类型(通常是枚举):

enum MyError: Error {
    case networkFailure
    case invalidData
    case missedLecture
}

func doSomethingRisky() throws {
    if somethingBadHappened {
        throw MyError.missedLecture
    }
    // 如果抛出错误,函数会在此处退出
}

使用 do-catch 块来捕获和处理错误:

do {
    try doSomethingRisky()
    print("Success!")
} catch MyError.missedLecture {
    print("You missed the lecture!")
} catch {
    print("An unexpected error occurred: \(error)")
}
// 捕获错误后,代码会继续执行

你可以有多个 catch 分支来处理特定的错误类型,并使用 is 关键字进行类型判断。

实战:构建背景图片加载器 🖼️

现在,让我们将所学知识应用到一个实际案例中:为我们的应用构建一个背景图片加载器,替换掉之前使用的 AsyncImage,并实现更好的状态控制和错误处理。

我们将使用状态机(State Machine) 的概念来建模图片加载过程。状态机非常适合用枚举来表示。

定义状态

首先,我们定义一个枚举来表示所有可能的状态:

enum Background {
    case none // 无背景
    case fetching(URL) // 正在获取,关联要获取的URL
    case found(UIImage) // 获取成功,关联UIImage
    case failed(String) // 获取失败,关联错误信息字符串
}

为了方便使用,我们为这个枚举添加一些计算属性:

extension Background {
    var uiImage: UIImage? {
        if case .found(let image) = self { return image }
        return nil
    }
    var isFetching: Bool {
        if case .fetching = self { return true }
        return false
    }
    var failureReason: String? {
        if case .failed(let reason) = self { return reason }
        return nil
    }
}

在视图模型中,我们将使用 @Published 属性来存储当前状态:

@Published var background: Background = .none

在视图中使用状态

在视图中,我们可以根据状态来显示不同的内容:

// 如果成功获取到图片,则显示
if let uiImage = viewModel.background.uiImage {
    Image(uiImage: uiImage)
        .position(...)
}
// 如果正在获取,则显示进度指示器
if viewModel.background.isFetching {
    ProgressView()
        .scaleEffect(2)
        .tint(.blue)
        .position(...)
}
// 监听失败状态,显示警告框
.onChange(of: viewModel.background.failureReason) { reason in
    if reason != nil {
        showBackgroundFailureAlert = true
    }
}
.alert("Set Background",
       isPresented: $showBackgroundFailureAlert,
       presenting: viewModel.background.failureReason) { reason in
    Button("OK", role: .cancel) { }
} message: { reason in
    Text(reason)
}

实现状态转换

状态转换的触发点是在视图模型中,当背景URL发生变化时:

// 在视图模型的 didSet 观察器中
@Published var emojiArt: EmojiArt {
    didSet {
        if emojiArt.background != oldValue.background {
            // 不能直接在属性观察器中调用 async 函数,所以使用 Task
            Task {
                await fetchBackgroundImage()
            }
        }
    }
}

fetchBackgroundImage 函数驱动状态机运转:

private func fetchBackgroundImage() async {
    guard let url = emojiArt.background else {
        background = .none
        return
    }
    // 状态1: 开始获取
    background = .fetching(url)
    do {
        // 状态2 & 3: 尝试获取图片,可能成功或失败
        let image = try await fetchUIImage(from: url)
        // 关键检查:在等待期间,用户可能已经更改了URL
        if url == emojiArt.background {
            // 状态3: 获取成功
            background = .found(image)
        }
    } catch {
        // 状态4: 获取失败
        background = .failed("Could not set background: \(error.localizedDescription)")
    }
}

实现网络请求

fetchUIImage(from:) 函数执行实际的网络请求。注意:我们使用 URLSession.shared.data(from:) 这个 async throws 方法,而不是会阻塞线程的 Data(contentsOf:)

private func fetchUIImage(from url: URL) async throws -> UIImage {
    let (data, _) = try await URLSession.shared.data(from: url)
    if let image = UIImage(data: data) {
        return image
    } else {
        throw FetchError.badImageData
    }
}

enum FetchError: Error {
    case badImageData
}

确保主线程更新

由于网络请求在 Task 中发起,它默认不在主Actor上运行。而更新 @Publishedbackground 属性会触发UI更新,这必须在主线程上进行。我们有几种解决方法:

  1. 将整个视图模型标记为 @MainActor(简单直接):
    @MainActor
    class EmojiArtDocumentViewModel: ObservableObject { ... }
    
  2. 仅将更新状态的方法标记为 @MainActor(更精确):
    @MainActor
    private func fetchBackgroundImage() async { ... }
    

这样,当 fetchBackgroundImage 函数需要执行时,系统会确保它在主Actor的线程上运行。如果主Actor正忙(例如用户正在交互),函数会挂起等待,直到主线程空闲。这完美地解决了线程安全问题。

更多增强功能

基于这个清晰的状态机,我们可以轻松添加更多功能:

  • 双击缩放:双击画布可以缩放以包含所有表情和背景图。
  • 自适应缩放:当拖入新背景时,自动调整缩放比例以适应画布。
  • 这些功能都得益于我们对图片加载状态的精确掌控。

总结 📚

本节课中我们一起学习了以下核心内容:

  1. 颜色与图像:了解了 Color/UIColorImage/UIImage 的区别与联系,前者用于界面描述,后者用于底层数据表示。
  2. 多线程编程的必要性:为了避免耗时操作(网络、计算)阻塞用户界面,导致应用卡顿。
  3. Swift 并发模型
    • 使用 Task { ... } 将耗时任务转移到后台线程。
    • 使用 actor 来安全地同步共享数据的访问。
    • 使用 async 标记可能挂起的函数,使用 await 调用它们,并理解在 await 后需要重新验证程序状态。
    • 理解了主Actor(@MainActor 的重要性,所有UI更新都必须发生在主Actor上。
  4. 错误处理:结合 async/await 使用 throw/try/catch,可以优雅地处理异步操作中的失败。
  5. 实战应用:我们构建了一个基于状态机的背景图片加载器,它能够清晰地管理加载状态、显示进度、处理错误,并确保所有UI更新都在正确的线程上执行。这个案例展示了如何将并发编程、错误处理和状态管理结合起来,创建出健壮且响应迅速的用户界面。

通过掌握这些知识,你能够编写出不会冻结、反应灵敏的iOS应用,从而为用户提供卓越的使用体验。

015:面向文档的应用、撤销与通知 📄

在本节课中,我们将学习如何构建面向文档的iOS应用。与之前适用于几乎所有应用的主题不同,文档应用(如我们的EmojiArt)需要处理文件的保存、重命名和在云端移动等特定功能。我们还将深入探讨App与Scene协议的基础,并学习实现撤销功能以及使用通知系统。

App与Scene协议 🏗️

上一节我们介绍了课程的整体安排,本节中我们来看看构成SwiftUI应用基础的两个核心协议:AppScene

App协议与View协议类似,它有一个body属性,但其类型是some Scene,而非some View。你的应用由多个Scene组成,每个Scene在iPad或Mac上可以表现为一个独立的窗口。

Scene是一个容器,它包含你的顶级视图。我们通常使用系统内置的场景构造器,而不是创建自定义的Scene

以下是主要的场景构造器:

  • WindowGroup:这是我们目前一直在使用的,用于创建可以显示多个窗口的场景。
  • DocumentGroup:这是面向文档应用的核心,它有两种形式,用于创建可编辑或只读的文档界面。

WindowGroup中,所有窗口默认共享在App层级注入的@StateObject(视图模型)。而在DocumentGroup中,每个场景(窗口)通常会拥有自己独立的视图模型,因为它代表一个独立的文档。

实现DocumentGroup 📂

现在,让我们看看如何将我们的应用转换为一个文档应用。核心是将WindowGroup替换为DocumentGroup

在App的主结构中,代码将变得非常简洁:

@main
struct EmojiArtApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: { EmojiArtDocument() }) { config in
            EmojiArtDocumentView(document: config.document)
        }
    }
}

这段代码的含义是:

  • 黄色部分:调用DocumentGroup场景构造器。
  • 蓝色部分:一个闭包,用于在用户点击“新建文档”时创建一个新的视图模型(文档)。
  • 绿色部分:一个闭包,接收一个config参数。config.document就是系统为我们创建或打开的文档视图模型,我们直接将其传递给我们的主视图即可。

系统会自动处理文件的创建、打开和保存界面,我们只需要在视图模型中实现具体的读写逻辑。

使视图模型符合ReferenceFileDocument 📄

为了让DocumentGroup工作,我们的视图模型(例如EmojiArtDocument)必须符合ReferenceFileDocument协议。这个协议定义了如何从磁盘读取文档以及如何将文档写入磁盘。

首先,我们需要定义我们的自定义文档类型。这需要在项目设置和代码中完成。

在项目设置的 Info 标签页下,我们需要:

  1. 添加一个 “Exported Type Identifier”
  2. 填写名称(如 EmojiArt)、标识符(采用反向DNS格式,如 edu.stanford.cs193p.emojiart)和文件扩展名(如 .emojiart)。
  3. Conforms To 字段中添加 public.datapublic.content

在代码中,我们为 UTType 添加一个扩展,以便在代码中引用这个新类型:

import UniformTypeIdentifiers
extension UTType {
    static let emojiArt = UTType(exportedAs: "edu.stanford.cs193p.emojiart")
}

接下来,我们让视图模型符合 ReferenceFileDocument 协议并实现其要求:

1. 指定可读写的文档类型

static var readableContentTypes: [UTType] { [.emojiArt] }

2. 实现初始化器以读取文档

init(configuration: ReadConfiguration) throws {
    if let data = configuration.file.regularFileContents {
        emojiArt = try EmojiArt(json: data)
    } else {
        throw CocoaError(.fileReadCorruptFile)
    }
}

3. 实现snapshot方法(在后台线程执行)

func snapshot(contentType: UTType) throws -> Data {
    return try emojiArt.json()
}

4. 实现fileWrapper方法以写入文档

func fileWrapper(snapshot: Data, configuration: WriteConfiguration) throws -> FileWrapper {
    return FileWrapper(regularFileWithContents: snapshot)
}

实现撤销功能 ↩️

文档系统依赖撤销功能来感知文档是否被修改。只有注册了可撤销的操作,系统才会自动保存文档。

撤销功能通过 UndoManager 实现。我们可以在视图中通过 @Environment(\.undoManager) 获取 UndoManager,然后将其传递给视图模型的意图函数。

在视图模型中,我们创建一个通用的方法来包装任何修改模型的操作,使其可撤销:

private func undoablyPerform(_ action: String, with undoManager: UndoManager? = nil, doit: () -> Void) {
    let oldModel = emojiArt // 复制当前模型(因为它是值类型)
    doit() // 执行修改操作
    undoManager?.registerUndo(withTarget: self) { myself in
        myself.undoablyPerform(action, with: undoManager) {
            myself.emojiArt = oldModel // 撤销操作:恢复旧模型
        }
    }
    undoManager?.setActionName(action) // 为撤销操作命名
}

然后,在每个意图函数中,我们都使用这个包装器:

func setBackground(_ url: URL, undoWith undoManager: UndoManager?) {
    undoablyPerform("Set Background", with: undoManager) {
        // ... 设置背景的具体逻辑 ...
    }
}

这样,任何通过意图函数对模型的修改都会自动支持撤销和重做。

通知系统简介 📢

通知(Notification)是一种旧的API,用于在系统事件发生时(如用户默认设置更改、键盘弹出)异步通知代码的其他部分。在现代SwiftUI中,我们优先使用 @Environment 和响应式状态管理。

然而,在某些情况下,例如监听 UserDefaults 的变化,我们仍然需要使用通知。

在视图中,我们可以使用 .onReceive 修饰符来监听通知:

.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { notification in
    // 处理UserDefaults的变化
}

如果是在视图模型等非视图环境中,则需要手动添加和移除观察者:

private var observer: NSObjectProtocol?

init() {
    observer = NotificationCenter.default.addObserver(
        forName: UserDefaults.didChangeNotification,
        object: nil,
        queue: .main
    ) { [weak self] _ in
        self?.objectWillChange.send()
    }
}

deinit {
    if let observer = observer {
        NotificationCenter.default.removeObserver(observer)
    }
}

重要提示:为了避免循环引用导致内存泄漏,在闭包中捕获 self 时应使用 [weak self]

总结 🎯

本节课中我们一起学习了构建SwiftUI文档应用的核心知识。

我们首先了解了AppScene协议如何构成应用的基础结构。接着,我们掌握了使用DocumentGroup来搭建文档应用界面,并通过符合ReferenceFileDocument协议来实现模型数据的磁盘读写。我们还深入学习了如何利用UndoManager为操作添加撤销支持,这是文档自动保存的关键。最后,我们简要介绍了传统的通知系统,并了解了在SwiftUI中应优先考虑使用@Environment等现代响应式工具。

通过将这些技术整合到EmojiArt应用中,我们成功将其转变为一个功能完整的文档应用,支持多文档编辑、保存、撤销以及跨窗口状态管理。

posted @ 2026-03-29 09:46  布客飞龙III  阅读(5)  评论(0)    收藏  举报