WWDC22 Sessions 记录
虽然 iOS 16 和 macOS 13 并没有太大的变化,但是 SDK(特别是 SwiftUI)更新的东西相当多,解决了很多以前开发中的痛点。
目录(根据观看时间顺序,大致按照发布顺序):
- Meet Swift Regex
- Meet desktop class iPad
- The SwiftUI cookbook for navigation
- Hello Swift Charts
- What’s new in SwiftUI
- What’s new in UIKit
- Compose custom layouts with SwiftUI
- Swift Charts: Raise the bar
- Embrace Swift generics
- Design protocol interfaces in Swift
Meet Swift Regex⌗
https://developer.apple.com/videos/play/wwdc2022/110357/
Swift 5.7 新增了强类型的正则表达式类型,自动将字符串转化为需要的类型,支持编译器检查。另外还提供了 RegexBuilder
框架,使用 SwiftUI 所使用的 result builder 语法来写正则表达式,非常具有表达力。编译器帮忙检查的 regex 和强类型的 capture 太香了。
Regex literal,强类型静态正则表达式:
// Regex literals
let digits = /\d+/
// digits: Regex<Substring>
Regex Builder,使用 result builder 构建正则表达式:
// CREDIT 03/02/2022 Payroll from employer $200.23
// CREDIT 03/03/2022 Suspect A $2,000,000.00
// DEBIT 03/03/2022 Ted's Pet Rock Sanctuary $2,000,000.00
// DEBIT 03/05/2022 Doug's Dugout Dogs $33.27
import RegexBuilder
let fieldSeparator = /\s{2,}|\t/
let transactionMatcher = Regex {
Capture { /CREDIT|DEBIT/ }
fieldSeparator
Capture { One(.date(.numeric, locale: Locale(identifier: "en_US"), timeZone: .gmt)) }
fieldSeparator
Capture {
OneOrMore {
NegativeLookahead { fieldSeparator }
CharacterClass.any
}
}
fieldSeparator
Capture { One(.localizedCurrency(code: "USD").locale(Locale(identifier: "en_US"))) }
}
// transactionMatcher: Regex<(Substring, Substring, Date, Substring, Decimal)>}
Named captures,给各个 group 起名(返回的结果是一个 Tuple
,因此可以在编译期加上标识):
let regex = #/
(?<date> \d{2} / \d{2} / \d{4})
(?<middle> \P{currencySymbol}+)
(?<currency> \p{currencySymbol})
/#
// Regex<(Substring, date: Substring, middle: Substring, currency: Substring)>
Meet desktop class iPad⌗
https://developer.apple.com/videos/play/wwdc2022/10069/
针对 iPad 生产力问题作的改进,推出了更像 macOS 的 toolbar。不过,这远远不能解决 iPad 缺少足够 productive 的应用的问题。
The SwiftUI cookbook for navigation⌗
https://developer.apple.com/videos/play/wwdc2022/10054/
苹果终于改进了 SwiftUI 上视图导航的逻辑。简单来说就是以前只能傻傻地打开另一个视图而无法获取之前的视图的情况,现在可以获取并可以用程序控制当前所在的层级了。
官方的示例代码:
import SwiftUI
// Pushable stack
struct PushableStack: View {
@State private var path: [Recipe] = []
@StateObject private var dataModel = DataModel()
var body: some View {
NavigationStack(path: $path) {
List(Category.allCases) { category in
Section(category.localizedName) {
ForEach(dataModel.recipes(in: category)) { recipe in
NavigationLink(recipe.name, value: recipe)
}
}
}
.navigationTitle("Categories")
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
}
.environmentObject(dataModel)
}
}
相比于之前 NavigationLink
提供一个 View
,现在可以提供一个用于区分不同目标的值来代替(这样就可以记录栈上的内容):
NavigationLink(recipe.name, value: recipe)
然后,使用 navigationDestination()
来返回对应的 View
:
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetail(recipe: recipe)
}
使用新的 NavigationStack
,我们传递一个 path
来获取当前导航栈上的所有 destination 所对应的值:
NavigationStack(path: $path)
这样,我们可以通过更改 path
来随意更改导航栈:
path.removeAll()
另外,还可以通过 SceneStorage
持久化当前的状态。
Hello Swift Charts⌗
https://developer.apple.com/videos/play/wwdc2022/10136/
官方的图表框架,使用 SwiftUI 快速构建图表,非常简洁。比如,创建折线图:
Chart(partyTasksRemaining) { element in
LineMark(
x: .value("Date", element.date, unit: .day),
y: .value("Tasks Remaining", element.remainingCount)
)
.foregroundStyle(by: .value("Category", element.category))
.symbol(by: .value("Category", element.category))
}
最后效果很不错(也很苹果):
声明式语法真是太适合做图表了,matplotlib 虽然强大但是画个图挺麻烦的,在想着以后科研能不能用 Swift 画图(
What’s new in SwiftUI⌗
https://developer.apple.com/videos/play/wwdc2022/10052/
除了上面提到的 Swift Charts 和 navigation 之外,这次 SwiftUI 的主要更新还有:
Window 和 Menu bar extras⌗
macOS 可以创建单独的、可以控制显示/关闭的窗口了:
@main
struct PartyPlanner: App {
var body: some Scene {
WindowGroup("Party Planner") {
PartyPlannerHome()
}
Window("Party Budget", id: "budget") {
Text("Budget View")
}
.keyboardShortcut("0")
}
}
另外,终于支持状态栏 app 了:
@main
struct PartyPlanner: App {
var body: some Scene {
MenuBarExtra("Bulletin Board", systemImage: "quote.bubble") {
BulletinBoard()
}
.menuBarExtraStyle(.window)
}
}
Resizable sheets⌗
加入了去年 UIKit 新增的可变高度的 sheet:
.sheet(isPresented: $presented) {
Text("Budget View")
.presentationDetents([.height(250), .medium])
.presentationDragIndicator(.visible)
}
其他⌗
其他的更新包括:
- macOS 上
Form
新的外观 - 支持多行的
TextField
和限定行高范围 - iPad 支持
Table
- 可自定义的 toolbar
- 可自定义的 layout
What’s new in UIKit⌗
https://developer.apple.com/videos/play/wwdc2022/10068/
现在基本不写 UIKit 了,这部分就没怎么记录。比较有意思的是,现在创建 UICollectionView
和
UITableView
可以用 SwiftUI 来写 cell 的 UI 了:
cell.contentConfiguration = UIHostingConfiguration {
VStack {
Image(systemName: "wand.and.stars")
.font(.title)
Text("Like magic!")
.font(.title2).bold()
}
.foregroundStyle(Color.purple)
}
其他的更新,如新的 toolbar,到暑假可以用来改进一下 Whiz Reader(不过可能用 SwiftUI 重写而不是继续用 UIKit)。
Compose custom layouts with SwiftUI⌗
https://developer.apple.com/videos/play/wwdc2022/10056/
支持自定义布局应该是本次 SwiftUI 最重要的更新了。现在我们可以访问到一些以前获取不到的信息来放置子视图了。
Grid⌗
在介绍自定义布局之前,首先介绍了新增的 Grid
类型,可以构建静态的对齐与网格的布局。
与之前的 LazyHGrid
和 LazyVGrid
不同,Grid
不是懒加载的,因此高/宽是确定的,适合于静态 grid 的构建。
与 VStack
和 HStack
的组合相比,Grid
的各行/列可以自动根据最大元素调整大小,而 VStack
和 HStack
不行。比如,现在可以实现这样一个列表:
左侧的名称一栏自动将宽度调整到适合于最大的 Goldfish
,右侧同理。
在以前,使用
VStack
和HStack
想要实现各列大小一致,只能给各列指定固定的大小。比如,树洞的评论界面大概是这样实现的:VStack { ForEach(comments) { comment in HStack { Text(comment.name) .fixedSize(.horizontal, 20) Text(comment.content) } } }
当
comment.name
过长时,就会变成两行,非常影响体验。
自定义布局⌗
自定义布局对应一个新的协议:Layout
。
Layout
通过两个函数参与布局过程:
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
) -> CGSize
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout Void
)
第一个函数 sizeThatFits()
通过返回一个大小来告诉父 View
我们需要多少空间;第二个函数 placeSubviews()
将所有的子 View
放置在合适的位置(使用我们在 sizeThatFits
中要求的空间)。
sizeThatFits⌗
我们先来看 sizeThatFits()
。它有三个参数:
proposal
:父View
所期望这个布局的大小。我们(可以)根据proposal
的大小来决定我们最终要占据多少空间,当然也可以忽略subviews
:布局中所有的子View
。这并不是View
本身,而是它的“代理”(proxy),仅给我们提供必要的信息和接口cache
:可用来缓存中间计算结果
关于
proposal
,官方文档是这样介绍的:A proposed size for the subview. In SwiftUI, views choose their own size, but can take a size proposal from their parent view into account when doing so.
可以看到,
proposal
是父视图对子视图大小的“建议”。父视图在进行布局时,将建议的大小传入每个子视图,子视图经过计算返回最终的大小(可以是proposal
的大小,也可以不是),父视图最终根据子视图返回的大小来进行布局的计算(而不是它所认为的大小,即proposal
)。
我们以官方的例子为例。这个例子实现了一个 EqualWidthHStack
,有几个需求:
- 水平布局
- 每个子
View
大小相同 - 每个子
View
的大小都等于最大的子View
对于这个 layout,sizeThatFits()
如下:
原理很简单:计算子 View
的最大的 size,加上之间的 spacing,即是布局需要的大小。我们通过 LayoutSubview
的 sizeThatFits()
获取每个子 View
的大小,然后计算最大值:
private func maxSize(subviews: Subviews) -> CGSize {
let subviewSizes = subviews.map { $0.sizeThatFits(.unspecified) }
let maxSize: CGSize = subviewSizes.reduce(.zero) { currentMax, subviewSize in
CGSize(
width: max(currentMax.width, subviewSize.width),
height: max(currentMax.height, subviewSize.height))
}
return maxSize
}
在 sizeThatFits()
中,我们忽略了 proposal
,这样在父视图的任何大小下这个布局所占的空间都是一样的。如果需要更灵活的布局,如空间不足时自动缩小,可以根据不同的 proposal
返回不同的大小。
placeSubviews⌗
placeSubviews()
相较于 sizeThatFits()
多了一个参数 bounds
,其余参数均相同;我们通过调用 LayoutSubview
的方法来放置子视图。其实完全可以理解为两个函数是同一个函数的两个部分,sizeThatFits()
用于计算视图大小,placeSubviews()
用我们计算好的大小来放置视图;只是因为上层调用是两个不连续的过程,才将它们分开为两个函数。
因此,这两个函数一般具有部分相同的逻辑,比如上面的 maxSize()
和 spacing()
,最好将其写到一个新的函数中以便复用,或者直接将中间结果放到 cache
中(待考证)。
实现 EqualWidthHStack
的 placeSubviews()
很简单,我们只需要通过相同的方法计算 maxSize
和 spacing
,然后将每个 subView
放置在合适的位置就可以了。这个过程通过 LayoutSubView
的 place()
方法完成。
传递额外信息⌗
这种方式有一个问题:传入的 subviews
只是视图的代理,不带有视图的信息。如果我们需要传递子视图的额外信息用于布局的计算,官方提供了一种(强类型的)方式:
struct Rank: LayoutValueKey {
static let defaultValue: Int = 1
}
MyLayout(1...10) { rank in
Text("\(rank)")
.layoutValue(key: Rank.self, value: rank)
}
使用下标运算符,我们可以从 subview
中获取对应的值:
let ranks = subviews.map { subview in
subview[Rank.self]
}
这样就实现了额外信息的传递。
自适应布局⌗
有意思的是,官方还实现了一个能够根据可用空间的大小自动调整布局的布局 ViewThatFits
:
ViewThatFits {
EqualWidthHStack {
Buttons(...)
}
VStack {
Buttons(...)
}
}
当空间足够时,选择第一个 EqualWidthHStack
;空间不够时,选择所需空间更少的 VStack
。
其实这个非常 fancy 的布局的实现应该很简单:根据 EqualWidthHStack
和 VStack
的 sizeThatFits()
的返回值,按顺序选择能够在可用空间内放置的一个。当然,我们没办法在自定义排布中选择哪些子视图不显示,因此是没办法完美地手动实现一个类似的容器的(可以通过不给子视图分配空间来实现,不过这样子视图仍然会加载)。
AnyLayout⌗
最后,官方提供了一个 type-erased 的 Layout
,即 AnyLayout
,可以用于动态改变布局类型:
let layout = isThreeWayTie ? AnyLayout(HStack())
: AnyLayout(MyRadialLayout())
layout {
ForEach(pets) { pet in
Avatar(pet: pet)
}
}
.animation(...)
另外,由于 layout
中的 View
(在这里是若干个 Avatar
)在不同的布局下具有相同的 view identity,因此布局变化前后子视图是不变的。通过这一点,我们甚至可以为不同布局的变化加上动画。
Swift Charts: Raise the bar⌗
https://developer.apple.com/videos/play/wwdc2022/10137/
Swift Charts 框架的进阶介绍,主要介绍了一些自定义方法。
增加标注:
指定图表颜色:
自定义刻度:
自定义图表区域:
在图表上添加一层 overlay,可用于交互:
Embrace Swift generics⌗
https://developer.apple.com/videos/play/wwdc2022/110352/
介绍了 Swift 范型的更新,主要针对 protocol。
protocol Animal {
associatedtype Feed: AnimalFeed
func eat(_ feed: Feed)
}
struct Farm {
func feed(_ animal: some Animal) {
let crop = type(of: animal).Feed.grow()
let produce = crop.harvest()
animal.eat(produce)
}
func feedAll(_ animals: [any Animal]) {
for animal in animals {
feed(animal)
}
}
}
上面的代码中有几个关键点:
- 协议限制在 Swift 5.7 中可以用 opaque type,即
some
关键字表示 - 可以用
type(of:)
来获取确定的具体类型 - 在 Swift 5.7 中可使用
any
实现针对 protocol 的类型消除(type erasure)
any
非常实用。之前,数组只能储存同一具体类型的数据,不能储存遵循相同协议而类型不同的数据(主要原因应该是内存大小不同);在 Swift 5.7 中,使用 any
可以储存遵循某协议的任意类型的数据。现在,数组就不一定直接储存对象本身了。如果对象大小合适(这里不太清楚“合适”的含义,估计是指针大小以内?),可以直接存到数组里,否则存在外面:
这种方式有点像存了一堆指针,但是又不完全是(较小的对象可以直接放在数组内),可能是为了减少虚函数查表的开销?
另外,any
类型可以用于 some
,具体类型的获取在运行时完成。这种转换叫做“opening boxes”,即“打开盒子看究竟是什么动物”:
最后,官方建议:
Write some by default
道理很简单,直接调用带 some
参数的函数实际上在编译期是确定了类型的,而 any
在编译期去掉了具体类型的信息,在运行期才能确定,相比之下效率较低。
Design protocol interfaces in Swift⌗
https://developer.apple.com/videos/play/wwdc2022/110353/
在上面 Embrace Swift generics 的基础上,进一步介绍使用新的语言特性设计协议。
Type erasure⌗
和上面类似的例子。当我们使用类型消除时,所有类型变成“上界”(不太明白这个名词的意思…),与之相关的所有泛型都变成对应的上界。
比如,any Animal
调用 produce()
的结果类型为 any Food
:
但是,反过来,不能够将某一个符合 associatedtype 的类型作为参数传入一个上界类型的函数中。
比如,Crop()
不能作为 any Animal
的 eat()
的参数:
这里的关键在于,一个具体类型总是可以安全地转换为 any
,而 any
不能反过来安全地转换为某一个具体的类型。
Hide implementation details⌗
有时候,我们不希望暴露太多实现细节,比如下面的这个例子:
我们希望使用 LazyFilterSequence
来提高性能,然而并不希望将这个信息暴露在 API 中。Swift 为了解决这个问题,增加了一个叫做 primary associated types 的语法:
Primary associated types 大致的意思是,将 protocol 中与实现细节相关的 associatedtype 使用尖括号暴露出来,从而能够使用 some
或者 any
来表示满足这个 associatedtype 要求的类型。
比如,标准库中的 Collection
:
protocol Collection<Element>: Sequence {
associatedtype Element
}
关于 type(of:)
⌗
看完视频最主要的疑问是:type(of:)
究竟是完全由运行时决定的,还是有编译器参与的。比如上面的例子:
protocol Animal {
associatedtype Feed: AnimalFeed
func eat(_ feed: Feed)
}
func feed(_ animal: some Animal) {
let crop = type(of: animal).Feed.grow()
let produce = crop.harvest()
animal.eat(produce)
}
编译器知道 some Animal
是一个具体的、遵循 Animal
的类型(包括运行时才能确定类型的 any Animal
),因此可以确定 type(of: animal).Feed
的类型就是具体类型的 Feed
,上面的代码得以通过编译。
但是,脱离了 some
的语境,any
就没有这种机制了:
let animal: any Animal = Cow()
let crop = type(of: animal).Feed.grow()
// error: type of expression is ambiguous without more context
// let crop = type(of: animal).Feed.grow()
// ~~~~~~~~~~~~~~~~~^^^^~~~~~~~
编译器实际上通过 some
给 any
提供了便利,即虽然不能在编译期知道具体的类型,但是可以保证 associatedtype 的正确性,这也是上面所说的“Open boxing”实现的效果。要想利用这种便利,我们需要使用带有 some
参数的函数。