duolingo:我们如何使用宏来促进 MVVM 架构的采用

87 阅读6分钟

我们提高了代码一致性并提高了开发人员的速度。作者:卡特·莱文

原文:blog.duolingo.com/ios-mvvm-sw…

在过去的几年里,我们的 iOS 工程师团队已经显著壮大(并且还在继续壮大!),代码库的规模也在不断扩大。

image.png

为了适应这种规模,我的团队——客户端架构团队——一直在努力围绕一致的 MVVM 架构进行标准化。虽然这项努力总体上得到了好评,但在初始部署期间,我们收到反馈称,新准则导致样板代码大幅增加,令人恼火。起初,我们尝试使用辅助方法和共享实用程序尽可能地减少重复代码,但最终还是遇到了瓶颈,因为有时不可能将所有代码都精简掉。对于这些难以轻松抽象的样板代码,我们越来越多地转向了名为“宏”的 Swift 功能。

什么是 Swift 宏?

从最根本的角度来说,宏是附加到现有代码的标签,用于在编译时生成新代码。宏的实际实现方式是操作所附加源代码的抽象语法树 (AST),并生成一棵新树,该树可以插入到附加的类/结构/方法等中,也可以与其并列。此过程完全集成到编译器中,因此生成的代码与手动编写的代码完全相同,因此可以受益于所有相同的编译器优化,并无缝融入构建过程。鉴于这些性能优化以及基于结构和语义而非仅仅基于文本转换代码的能力,宏在生成样板文件方面非常强大。

应用程序到数据源实现

我们的 MVVM 架构的可视化。

虽然宏有很多潜在的应用场景,但我们决定从架构中的“数据源”组件入手。在我们的 MVVM 架构中,“数据源”充当各种数据源(本地文件、数据库、键值存储、远程 API 或内存存储)的抽象层。这使得消费者(或我们架构中的存储库)能够对底层数据存储机制保持透明,从而简化测试并使代码更能适应数据​​存储策略的变化。虽然数据源至关重要,但它们通常遵循一致的模式,并在不同的实现中共享相似的结构。因此,它们可能包含大量重复代码。这种一致性使它们成为基于宏的代码生成的理想起点,使我们能够减少样板代码,同时保留抽象的清晰度和优势。

例如,考虑以下两种更新方法KeyValueDataSource

public func updateValueA(valueA: Int, userID: DUOUserID) throws {
  let store = store(for: userID)
  try store.set(valueA, forKey: .valueAKey)
}

public func updateValueB(valueB: String, userID: DUOUserID) throws {
  let store = store()
  try store.set(valueB, forKey: .valueBKey)
}

这些方法几乎完全相同,仅在于设置的值及其对应的“键”。您可能想知道为什么我们不能编写一个通用updateValue<T>(...)方法将它们浓缩为一个方法。这是一个很好的问题,因为泛型通常是更简单的解决方案,但在这种情况下并不是那么简单,因为该updateValueA方法使用valueAKey来查询存储,而不是updateValueB使用的方法valueBKey。您可以假设将此键作为参数传递给DataSource,但这将暴露我们希望让DataSource消费者保持不可知的实现细节。考虑到所有这些重复和编写通用实用程序函数的困难,这些方法是宏代码生成的完美候选者。

实现 Swift 宏

为了使宏正常工作,它需要能够从其附加的代码中收集所有相关的上下文。KeyValueDataSources我们可以使用KeyValueStoreItem.Key扩展来定义关键变量。以下是“头像”键的示例:

private extension KeyValueStoreItem.Key {
    static let avatar = Key<Avatar?>("avatar")
}

通过将我们的 GenerateKeyValueDataSource 宏附加到此扩展,我们可以自动生成完整的KeyValueDataSource实现:

我们的代码带有 KeyValueDataSource 实现。

宏观优势与劣势

通过使用宏,开发人员既可以节省工程时间,又可以降低人为错误的可能性。由于宏生成的代码经过标准化和测试,因此不容易出现错误和一次性错误。此外,数据源中逻辑的更新或增强可以在宏内部高效处理,并立即反映到所有实现中。因此,宏代码比标准脚本生成的代码和AI代码更加可靠。

虽然 Swift 宏功能强大,但也要​​认识到它确实存在一些缺点。例如,它们生成的代码无法在 Xcode 中搜索,而且对于不熟悉其工作原理的用户来说,它们可能会增加复杂性。如果可以通过其他更直接的方式(例如辅助函数和类)来减少样板代码,最好先尝试一下。然而,对于本文中描述的情况,它们是一个很好的解决方案。与其他代码生成工具不同,它们可以很好地集成到构建过程中,以 Swift 原生实现,并且操作的是抽象语法树而不是原始文本。 

构建时间

奥斯卡驾驶着一辆黄色汽车,沮丧地看着路上的一只乌龟。

首次添加宏依赖项时,我们发现干净构建的时间大约增加了 10 到 20 秒。虽然我们始终找不到加快宏包本身构建时间的方法,但我们能够大幅减少构建宏包的次数。我们发现关键在于不要使用 Swift 包管理器导入宏包。

相反,我们使用标志手动链接二进制文件load-plugin-executable。二进制文件是通过 makefile 中的一个显式命令生成的,该命令仅在本地对包进行更改时才重建包二进制文件。此外,当 PR 合并到 master 分支并更改了宏包时,我们会构建并缓存这个新的二进制文件到 S3 中,以便其他工程师可以直接从存储桶中下载,而无需自行重建。

此流程几乎可以确保您无需重新构建二进制文件,除非您直接自行处理 Macros 包。然而,此过程中仍然存在一些小问题,例如,由于 Macro 对全局范围内任意名称的限制,有时工程师在添加对给定宏的新引用时需要编辑 Macros 包,从而需要重新构建整个包。因此,我们尝试使用允许宏在附加元素名称前添加前缀或后缀的命名约定。

结果

一条折线图显示了自 2024 年 7 月以来生成的代码行总数的增加情况。

我们的 iOS 代码库中现在有超过 4,300 行宏生成的代码。这意味着这 4.300 行代码不仅无需编写,而且无需审查或单元测试。这些代码也不太可能引入新的错误,并且更容易进行后续重构。虽然数据源是宏最明显的起点,但我们认为还有很多其他机会可以继续扩展我们的使用范围,并提升开发者的体验和代码质量。我们已经开始利用宏自动生成协议、指定的初始化器和其他繁重的样板实现,从而显著简化了我们的开发工作流程。

Swift 宏比较新,目前尚未被广泛采用。但我们乐于尝试新兴工具,因为它们能让我们的代码更简洁,团队效率更高。

如果这听起来像您所需的工程技术,我们正在招聘 iOS 工程师