虽然 iOS 16 和 macOS 13 并没有太大的变化,但是 SDK(特别是 SwiftUI)更新的东西相当多,解决了很多以前开发中的痛点。

目录(根据观看时间顺序,大致按照发布顺序):

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 了,这部分就没怎么记录。比较有意思的是,现在创建 UICollectionViewUITableView 可以用 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 类型,可以构建静态的对齐与网格的布局。

与之前的 LazyHGridLazyVGrid 不同,Grid 不是懒加载的,因此高/宽是确定的,适合于静态 grid 的构建。

VStackHStack 的组合相比,Grid 的各行/列可以自动根据最大元素调整大小,而 VStackHStack 不行。比如,现在可以实现这样一个列表:

左侧的名称一栏自动将宽度调整到适合于最大的 Goldfish,右侧同理。

在以前,使用 VStackHStack 想要实现各列大小一致,只能给各列指定固定的大小。比如,树洞的评论界面大概是这样实现的:

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,即是布局需要的大小。我们通过 LayoutSubviewsizeThatFits() 获取每个子 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 中(待考证)。

实现 EqualWidthHStackplaceSubviews() 很简单,我们只需要通过相同的方法计算 maxSizespacing,然后将每个 subView 放置在合适的位置就可以了。这个过程通过 LayoutSubViewplace() 方法完成。

传递额外信息

这种方式有一个问题:传入的 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 的布局的实现应该很简单:根据 EqualWidthHStackVStacksizeThatFits() 的返回值,按顺序选择能够在可用空间内放置的一个。当然,我们没办法在自定义排布中选择哪些子视图不显示,因此是没办法完美地手动实现一个类似的容器的(可以通过不给子视图分配空间来实现,不过这样子视图仍然会加载)。

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 Animaleat() 的参数:

这里的关键在于,一个具体类型总是可以安全地转换为 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()
//               ~~~~~~~~~~~~~~~~~^^^^~~~~~~~

编译器实际上通过 someany 提供了便利,即虽然不能在编译期知道具体的类型,但是可以保证 associatedtype 的正确性,这也是上面所说的“Open boxing”实现的效果。要想利用这种便利,我们需要使用带有 some 参数的函数。