引言:本文主要介绍爱彼迎在 iOS App 中应用 SwiftUI 并替换原有 UI 框架的实践
在构建 App 的用户界面(UI)时,选择合适的框架非常重要。正确的 UI 框架可以使应用程序感觉流畅、响应迅速,甚至令人愉悦,而不适合应用程序需求的 UI 框架可能会让人感觉迟缓,令人崩溃。这个原则对于开发者的体验也同样重要;一个具有良好 API 设计的 UI 框架可以使工程师流畅、高效且准确地表达自己的想法,而一个具有错误抽象或不一致 API 的框架可能会由于一些不必要的复杂性而使工程师的工作变得更加困难,低效。
在爱彼迎,我们希望我们的移动端应用可以提供世界一流的用户体验和开发者体验。基于这个愿望,我们在 2016 年构建了我们自己的 UI 框架:Epoxy。Epoxy 是一个声明式的 UI 框架,工程师只需要描述在任一视图状态下想要什么样的 UI 结构,框架会自动更新视图层次结构并渲染页面内容。Epoxy 再使用 UIKit 来渲染视图。
在 2019 年,随着 SwiftUI 的推出,iOS 的 UI 框架格局发生了变化。SwiftUI 是第一个官方的声明式 UI 框架,实现了与 Epoxy 类似的目标。尽管在最初的三年里,SwiftUI 并不符合我们的需求,但到了 2022 年,它的稳定性和 API 可用性得到了显著提高。因此,我们开始考虑在爱彼迎中采用 SwiftUI。
在这篇文章中,我们将分享为什么以及最终如何在爱彼迎中用 SwiftUI 替代了 Epoxy 和 UIKit。我们将详细介绍如何将 SwiftUI 集成到爱彼迎的界面设计体系中,讲解这项工作所带来的成果,并列举一些我们仍在解决的挑战。阅读完这篇文章后,你将了解为什么 SwiftUI 已经达到我们对用户和开发者体验的高标准。
评估和规划使用 SwiftUI
切换到一个新的 UI 框架并不是一项轻松的任务。经过调研,我们提出了以下假设,即 SwiftUI 不会对用户体验造成影响,并且会改善开发者体验,因为:
灵活且可组合性:SwiftUI 能通过使用泛型视图和视图修饰符来提供更强大且灵活的模式来管理视图变体和样式。这会大幅减少构建应用所需的视图数量,因为它使得自定义现有视图和在调用处内联组合新的行为变得更容易。
完全声明式:SwiftUI 代码更容易理解和维护。不会出现需要频繁在命令式和声明式编程之间切换的场景,例如之前在使用 Epoxy 时,工程师经常需要切换到编写 UIKit 代码。
代码更少:由于 SwiftUI 完全采用声明式编程,构建一个 SwiftUI 视图组件所需的代码量将大大减少。通常情况下,bug 数量也会随之减少。
更快的迭代速度:与使用 UIKit 时需要 30 秒甚至更长时间进行编译和运行的迭代周期相比,Xcode 预览功能能够在 SwiftUI 视图组件和页面上实现几乎即时的迭代周期。
符合习惯:由于 SwiftUI 较少使用定制的范式和模式,所以在构建 UI 时会降低认知负担。这将使得新工程师更容易上手。
基于这些,我们制定了一个计划,分为三个阶段来评估和应用 SwiftUI:
第一阶段:构建我们设计系统中的可重用视图组件等叶子视图。
第二阶段:构建完整的页面,如预订详情页或用户个人资料页。
第三阶段:构建由多个页面组成的完整功能。
截至发布本文时,我们已经成功完成了前两个阶段,而第三阶段则需要等待 SwiftUI 增添灵活的导航 API。在组件(第一阶段)和页面(第二阶段)阶段,我们进行了一个小规模的试点项目,工程师们可以通过报名尝试使用 SwiftUI。我们通过收集试点项目反馈,来在进入下一阶段前来改进对 SwiftUI 的支持。这种方法使我们能够在过程的每个阶段都提供了价值,而不是从一开始就为整个功能应用 SwiftUI 而进行大规模和不确定的基础架构重构。
启用 SwiftUI
我们在基础架构和普及培训方面做了很多工作,以确保工程师们能够成功迁移到 SwiftUI。
界面设计体系
为了加快在全公司范围内对 SwiftUI 的应用,对爱彼迎的界面设计体系提供一流的 SwiftUI 支持是一个关键且优先的事项。我们并非只是简单地将现有的 UIKit 组件与 SwiftUI 桥接,而是重新构建了 SwiftUI 中的界面设计体系,使其更加灵活和强大。
在我们的界面设计体系中,每个视图组件都支持通过自定义样式化来提高可重用性。我们有一系列的样式协议,当与自动生成代码结合使用时,可以将样式对象通过 SwiftUI 环境注入来模拟 SwiftUI 内置的样式化范例。其中符合此协议的样式被称为“灵活样式”。以下是一些示例代码:
public protocol FlexibleSwiftUIViewStyle: DynamicProperty {
/// The content view type of this style, passed to `body()`.
associatedtype Content
/// The type of view representing the body.
associatedtype Body: View
/// Renders a view for this style.
@ViewBuilder
func body(content: Content) -> Body
}
该协议允许我们创建一个样式对象,其中包含一系列可设置的属性,可以完全自定义组件的渲染。样式对象接收一个内容对象,以使它在创建新的视图体时可以访问视图的底层状态或交互行为,以下是一个数字步进器的示例样式实现(为了简洁起见,省略了一些样式设置):
public struct DefaultStepperStyle: DLSNumericStepperStyle {
public var valueLabel = TextStyle…
public func body(content: DLSNumericStepperStyleContent) -> some View {
HStack {
Button(action: content.onDecrement) { subtractIcon }
.disabled(content.atLowerBound)
Text(content.description)
.textStyle(valueLabel)
Button(action: content.onIncrement) { addIcon }
.disabled(content.atUpperBound)
}
}
}
默认样式的数字步进器
有了这样的灵活样式协议,工程师只需编写几十行代码,就可以添加一个完全定制的步进器样式。只需要实现一个符合 DLSNumericStepperStyle 的新类型即可。该样式可以使用自动生成的视图修饰符设置在视图上:
DLSNumericStepper(value: $value, in: 0...)
.dlsNumericStepperStyle(CustomStepperStyle())
自定义样式的数字步进器
由于在 DLSNumericStepper 视图中实现了优化的辅助功能支持,自定义样式会自动获得适当的辅助功能行为。我们在界面设计体系的实现过程中广泛使用了这种灵活样式的方法,使得产品工程师能够快速构建新的组件变体,而且不会出现辅助功能方面的问题。
桥接 Epoxy
爱彼迎 App 的数千个页面都是由 Epoxy 支持的。为了实现对 SwiftUI 的无缝迁移,我们构建了基础设施,使 Epoxy 能够将 SwiftUI 视图桥接到基于 UIKit 的 Epoxy 列表中,同时还能够将 Epoxy 的 UIKit 视图桥接到 SwiftUI 中。
为了将 SwiftUI 视图桥接到 UIKit 的 Epoxy 列表中,我们创建了一个 itemModel 视图修饰符,为 SwiftUI 视图建立了 Epoxy 的标识。在实现中,此方法将视图包装在 UIHostingController 中,并将其嵌入到集合视图单元格中。这个工具使在现有的 Epoxy 页面中采用 SwiftUI 变得非常简单,从而开启了我们在 SwiftUI 试运行的第一个阶段。
SwiftUIRow(
title: "Row \(id)",
subtitle: "Subtitle")
.itemModel(dataID: id)
类似地,我们可以通过使用视图扩展来将 UIKit 视图桥接到 SwiftUI 中,该扩展可以使用视图内容、样式约束和任何其他视图配置从 UIKit 组件创建一个 SwiftUI 视图。在实现中,这个API 使用了一个泛型的 UIViewRepresentable,在内容和样式发生变化时自动创建和更新 UIView。
EpoxyRow.swiftUIView(
content: .init(title: "Row \(index)", subtitle: …),
style: .small)
.configure { context in
print("Configuring \(context.view)")
}
.onTapGesture {
print("Row \(index) tapped!")
}
由于 SwiftUI 具有截然不同的布局系统,正确布局一个 UIKit 组件是一个挑战。因此我们开发了一种可配置的方法,自动支持复杂的视图,比如需要额外的布局过程来正确调整大小的 UILabel。
单向数据流
在使用 Epoxy 时,我们发现利用单向数据流模式可以使我们的 UI 变得更加可预测和易于理解。我们构建页面,使 Epoxy内容作为页面状态的函数来进行渲染。用户交互当作行为被分派,并导致页面状态发生变化,进而触发页面的重新渲染。我们使用 StateStore 对象来存储页面状态并对行为进行处理以改变该状态。为了将这种模式应用到 SwiftUI,我们更新了 StateStore,使其符合 ObservableObject 协议,这使得 store 在状态变化时可以触发页面中 SwiftUI 视图的重新渲染。我们发现工程师们更喜欢继续使用这种模式在 SwiftUI 中构建页面,因为它使业务和状态变化逻辑与呈现逻辑分离。在很多情况下,我们能够将页面逻辑从 Epoxy 转移到 SwiftUI 页面而不需要进行任何更改。为了说明它们的相似性,这里是一个简单的计数器页面在两个视图系统中的实现:
// In Epoxy/UIKit:
struct CounterContentPresenter: StateStoreContentPresenter {
let store: StateStore<CounterState, CounterAction>
var content: UniListViewControllerContent {
.currentDLSStandardStyle()
.items {
BasicRow.itemModel(
dataID: ItemID.count,
content: .init(titleText: "Count \(state.count)"),
style: .standard)
.didSelect { _ in
store.handle(.increment)
}
}
}
}
// In SwiftUI
struct CounterScreen: View {
@ObservedObject
let store: StateStore<CounterState, CounterAction>
var body: some View {
DLSListScreen {
DLSRow(title: "Count \(store.state.count)")
.highlightEffectButton {
store.handle(.increment)
}
}
}
}
测试
为了确保产品的质量,我们希望 SwiftUI 代码在设计上具有可测试性。快照测试是我们测试视图的主要方法,我们使用一个静态定义来为我们的组件浏览器和快照测试服务提供命名的视图变体:
enum DLSPrimaryButton_Definition: ViewDefinition, PreviewProvider {
static var contentVariants: ContentVariants {
DLSPrimaryButton(title: "Title") { … }
.named("Short text")
DLSPrimaryButton(title: "Title") { … }
.disabled(true)
.named("Disabled")
}
}
由于我们在这里返回视图变体,这可以在测试中具有很多灵活性 - 该框架接受任何内容变体或视图修饰符的组合。此外,我们使这些组件定义符合 SwiftUI 的 PreviewProvider 协议,并将这些内容变体转换为预期的返回类型,以便工程师可以使用 Xcode Previews 快速迭代组件。
与其他平台上的声明式 UI 框架不同,SwiftUI 没有提供内置的测试库。为了支持对组件和页面进行行为测试,我们集成了开源的 ViewInspector 库,并也对这个仓库贡献了代码。
培训
应用 SwiftUI 的一个重大挑战是在大型 iOS 团队中建立内部的专业知识。为了积极应对这个问题,我们组织了多次为期半周的 SwiftUI 工作坊,几乎有一半的 iOS 工程师参加了这些工作坊。参与者表示,他们 SwiftUI 基础知识方面的自信程度提高了 37%,在构建新组件方面的自信程度提高了 39%。此外,我们发现,参加工作坊近一年后的人员的 SwiftUI 专业知识比没有参加工作坊的人员高出了 8%。
SwiftUI 的相关发现
代码行数
鉴于爱彼迎有数百万行的 iOS 代码,我们对 SwiftUI 减少构建 UI 所需代码量的潜力感到非常兴奋。在我们进行的一项早期实验中,我们重新编写了评论卡片,并发现代码行数减少了 6 倍 - 从 1121 行减少到仅剩 174 行代码!在过去的两年里,随着我们对 SwiftUI 的应用不断推进,我们看到了类似幅度的代码行数减少。
性能
在评估 SwiftUI 时,UI 性能是一个重要的关注点。幸运的是,经过多次实验验证,我们发现在使用 SwiftUI 时的页面性能分数与 UIKit 实现相当。虽然在实例化 UIHostingController 时有一些小的开销,但通过向 Epoxy添加一个复用的 Hosting Controller 池,我们成功地减少了这个开销。
SwiftUI 的普及和开发者满意度
由于公司内部对 SwiftUI 的热情,该框架的应用进展迅速。我们的 SwiftUI 组件构建有限试点始于 2022 年 1 月,随后于同年 5 月扩大推广。而对于完整页面的 SwiftUI 构建,试点阶段在 2022 年 10 月开始,然后于 2023 年 1 月正式推出。
截至九月,我们已经拥有超过 500 个 SwiftUI 视图和大约 200 个 SwiftUI 页面。其中许多页面都完全使用 SwiftUI 实现,这些页面已经在爱彼迎 2023 年的夏季发布会的版本中使用。
爱彼迎产品中 SwiftUI 组件和页面的增长
爱彼迎的 iOS 工程师对 SwiftUI 非常满意。在最近的一次调查中,77% 的受访者表示 SwiftUI 提高了他们的效率。许多受访者提到,随着更多使用 SwiftUI 的经验,他们的效率得到了进一步提高,包括那些在一开始认为 SwiftUI 会降低他们开发效率的人。调查中所有的受访者都表示 SwiftUI 没有对其产品功能的质量产生负面影响,一些受访者还表示 SwiftUI 提高了他们的代码质量。
挑战
尽管整体上转向 SwiftUI 已经取得了重大成功,但我们也遇到了以下挑战:
虽然 Swift 及其相关底层代码已经开源,但 SwiftUI 的实现仍然是黑盒。如果 SwiftUI 开源,我们可以更好地理解框架并进行更有效的调试。
我们对 SwiftUI 的未来发展了解有限,目前只能通过每年一次的开发者大会来了解其进展。如果我们能更清楚地了解 SwiftUI 的发展方向,我们可以更好地确定应用它的重点并知道在定制化解决方案方面做哪些努力。
爱彼迎支持最新的两个 iOS 版本 App。如果较新的 SwiftUI API 能够向旧版本的 iOS 进行回溯兼容,我们可以更快地利用强大的新功能,并减少编写回退方案的时间。
为了完全弃用 UIKit,我们需要一套支持自定义过渡效果和导航模式的 SwiftUI API。
我们在使用 LazyVStack 和 ScrollView 时遇到了一些挑战和限制,包括:
插入、删除和更新动画的确可能会遇到一些问题。
对屏幕外单元格的预加载以及图片或数据的预加载可能会受到一些限制。
当视图离开屏幕时,某些状态可能会被重置。
在 SwiftUI 中,文本输入的 API 确实没有完全支持 UIKit 的所有功能。这导致工程师们需要通过与 UIKit 进行桥接来获取一些额外的功能。
我们向 Apple 进行的 18 个相关反馈,其中记录了我们遇到的 SwiftUI 的错误或改进建议。
总结
尽管面临这些挑战,但在爱彼迎对 SwiftUI 的谨慎应用过程中,我们总体上的经历还算顺利。通过重建我们的界面设计体系、重视培训普及,并提供与现有框架的无缝集成,我们提高了开发速度和开发者满意度,同时保持了高质量的标准。我们对 SwiftUI 的持续发展并在我们的应用中能支持更多的体验感到兴奋!
作者:Bryn Bodayle
译者:Jun Luan
感谢所有项目参与者的努力,合作与支持!
如果你想了解关于爱彼迎技术的更多进展,欢迎关注我们的 Github 账号(https://github.com/airbnb/) 以及微信公众号(爱彼迎技术团队)