The craft of SwiftUI API design: Progressive disclosure
Swift & UI 进阶 20m

SwiftUI API 设计之道:渐进式披露的四个策略

The craft of SwiftUI API design: Progressive disclosure

2022年6月6日

在 Apple 官方观看视频

一句话判断

这不是一个教你用 SwiftUI 的 session,而是教你设计 SwiftUI API 的 session——Apple 首次公开了他们设计 API 时的核心原则「渐进式披露」,并且用大量真实代码演化过程来论证。

这场 Session 讲了什么

SwiftUI 团队的 Sam Lazarus 从一个很巧妙的角度切入:macOS 的保存对话框。简单模式只显示文件名和几个常用位置,展开后变成完整的文件浏览器。不同复杂度的需求看到不同复杂度的 UI——这就是渐进式披露(Progressive Disclosure)。

把这个概念搬到 API 设计上:调用者的复杂度应该和使用场景的复杂度成正比。一个理想的 API 对简单场景极简,对复杂场景足够强大。

Session 给出了四个策略。第一,考虑常见用例——Button 的 label 99% 的情况下是纯文本,所以 Button("Next Page") { } 是最常见的调用方式。第二,提供智能默认值——Text("Hello") 背后自动做了本地化、Dark Mode 适配、Dynamic Type 缩放、行间距调整,你不需要指定任何一项。第三,优化调用点——Table 从完全体到最简体经过四步精简,最终每个字符都有明确用途。第四,组合而非枚举——HStack 的间距不用 enum 来枚举所有排列方式,而是用 Spacer 这个可组合的元素来表达无限种间距方案。

值得深挖的点

「从调用者角度设计」是一个被低估的原则。 大多数开发者在写可复用组件时,习惯从声明端(declaration site)出发——怎么定义结构体、怎么组织内部逻辑。但 API 的质量应该从调用端(call site)来衡量。Session 反复强调:你的代码在被使用的地方看起来是什么样,比在你写的地方看起来是什么样重要得多。Table API 的演化过程是最好的例子——同一个功能,从几十行参数缩减到几行,但能力一点没少。

「组合而非枚举」避免了 API 的死亡螺旋。 Session 用 HStack 的间距设计来说明:如果你用 enum 来枚举 leading/centered/trailing/evenly-spaced/between-elements… 你永远枚举不完所有有用的情况。但用 Spacer 这个可组合元素,上面的所有排列以及更多都能表达。这个原则不仅适用于布局,适用于任何需要灵活性的 API 设计——用小而正交的构建块组合出复杂行为,而不是用一个越来越大的 enum 来覆盖所有 case。

代码片段

Table API 的四步精简过程:

// 第一步:完全体,所有细节都显式指定
Table(sortOrder: $sortOrder) {
    TableColumn("Title", value: \Book.title) { book in
        Text(book.title).bold()
    }
    TableColumn("Author", value: \Book.author) { book in
        Text(book.author).italic()
    }
} rows: {
    ForEach(currentlyReading) { book in
        TableRow(book)
    }
}
.onChange(of: sortOrder) { newValue in
    currentlyReading.sort(using: newValue)
}

// 第二步:集合直接传入 Table,省掉 ForEach
Table(currentlyReading, sortOrder: $sortOrder) { ... }

// 第三步:String keypath 省掉闭包
TableColumn("Title", value: \.title)    // 不需要 { Text($0.title) }

// 最终:最简形态,不需要排序的就省掉 sortOrder
Table(currentlyReading) {
    TableColumn("Title", value: \.title)
    TableColumn("Author", value: \.author)
}

组合 vs 枚举的对比:

// 枚举方式(不推荐):永远枚举不完
enum Arrangement { case leading, centered, trailing, evenlySpaced, ... }

// 组合方式(推荐):用 Spacer 表达无限种间距方案
HStack { Box(); Spacer(); Box(); Box() }     // space before last
HStack { Spacer(); Box(); Spacer(); Box(); Spacer() }  // centered
HStack { Box(); Spacer(); Box(); Spacer(); Box(); Spacer() }  // evenly spaced
// 任何你想要的间距方案,都能用 Spacer 组合出来

智能默认值的威力——Text 做了什么:

// 你写的
Text("Hello WWDC22!")

// SwiftUI 自动处理的:
// - 从 app bundle 查找本地化字符串
// - 适配当前 color scheme(Dark Mode)
// - 根据 Dynamic Type 缩放
// - 多个 Text 相邻时自动调整行间距
// 你不需要指定以上任何一项

最佳实践

  • 设计可复用组件时,先写两三处实际调用点,从调用端反推声明端的最佳形态。
  • 为 99% 的常见用例提供便捷入口,1% 的用例用完整版 API。
  • 在每次添加参数前问自己:这真的是每次调用都需要指定的吗?如果不是,把它变成默认值。
  • 遇到需要枚举很多 case 的场景,停下来想想能不能拆成可组合的小单元。
  • some 返回类型隐藏实现细节,让调用者不依赖你的内部类型。

还有什么值得关注

  • Session 提到的 macOS save dialog 是渐进式披露的经典 UI 实例,可以用来说服团队接受这个设计理念。
  • Toolbar items 不指定 placement 时按平台惯例自动放置(macOS leading,iOS trailing,watchOS 只显示第一个)。
  • SwiftUI 内部的 Apple 团队自己用这些原则在重写和优化每一个新 API。
WWDC 2022