3

前言

SE-0409 提案引入了一项新功能,即允许使用 Swift 的任何可用访问级别标记导入声明,以限制导入的符号可以在哪些类型或接口中使用。由于这些变化,现在可以将依赖项标记为对当前源文件(privatefileprivate)、模块(internal)、包(package)或所有客户端(public)可见。

此提案引入了两个功能标志后面的更改,这两个功能标志将在 Swift 6 中默认启用:

  • AccessLevelOnImport:这是一个已经可用的实验性功能标志,允许开发人员将导入声明标记为访问级别。
  • InternalImportsByDefault:这是一个即将推出的功能标志,目前尚不可用,它将导入语句的隐式访问级别从 public 更改为 internal,就像 Swift 6 将要做的那样。

这是语言中的一项很好的补充,我个人很长时间以来一直期待着,因为它可以帮助开发人员更好地隐藏实现细节并强制执行关注点分离。不仅如此,它还限制了包的客户端导入的依赖项数量,只允许满足一定条件的标记为 public 的依赖项导入,从而缩短了编译时间。

示例

假设我们创建了一个名为 Services 的 Swift 包,该包定义了一个 FeedService 目标。该目标的工作是获取要在应用程序中显示的项目的动态源。反过来,FeedService 依赖于另一个名为 FeedDTO 的目标,该目标定义了与 API 数据结构匹配的一组自动生成的可解码模型,代码如下:

// swift-tools-version: 5.10

import PackageDescription

let package = Package(
    name: "Services",
    platforms: [.iOS(.v13), .macOS(.v10_15)],
    products: [
        .library(
            name: "FeedService",
            targets: ["FeedService"]
        ),
    ],
    targets: [
        .target(
            name: "FeedService",
            dependencies: ["FeedDTO"]
        ),
        .target(
            name: "FeedDTO"
        )
    ]
)

FeedDTO 目标的代码非常简单,并且是基于 OpenAPI 规范自动生成的:

import Foundation

public struct Feed: Decodable {
    let items: [Item]
    
    public struct Item: Decodable {
        let title: String
        let image: URL
        let body: String
    }
}

FeedService 目标并不复杂,它包含一个协议,该协议定义了服务的接口,供客户端使用。该协议的实现也属于 FeedService 目标,但对于本例来说并不重要,FeedService.swift 文件代码如下:

import FeedDTO

public protocol FeedService {
    func fetch() -> Feed
}

正如你所看到的,我们在服务的公共接口中包含了 FeedDTO 目标中的 Feed 模型。由于在 Swift 5 中,所有导入声明都隐式为 public,并且没有办法更改此行为,上述代码可以编译而不会出现任何问题。尽管如此,架构远非理想,我们被允许暴露实现细节,并且我们没有办法让编译器阻止此泄漏。

如果我们注意到这个问题并想要解决它,我们可以从公共接口中删除 Feed 模型,并创建一个领域模型,该模型将成为公共接口的一部分。服务的实际实现将负责将 FeedDTO.Feed 模型转换为领域模型。FeedService.swift 文件代码如下:

import Foundation
import FeedDTO

public struct Feed {
    let items: [Item]
    
    public struct Item {
        let title: String
        let image: URL
        let body: String
    }
}

public protocol FeedService {
    func fetch() -> Feed
}

尽管上述代码是朝着正确方向迈出的一步,但代码中没有明确说明 FeedDTO 模块在此文件中的用法是实现细节,不应该是模块的公共接口的一部分。这就是 Swift 6 的功能派上用场的地方。

启用 AccessLevelOnImport

启用 AccessLevelOnImport 实验性标志

让我们看看如何通过为导入语句添加访问级别来使前一节的代码更加明确,并防范未来的更改可能会在此文件中暴露实现细节。

在我们这样做之前,由于此功能仍在实验性标志后面,我们需要在我们的Swift包中启用它,Package.swift 文件代码如下:

// swift-tools-version: 5.10

import PackageDescription

let package = Package(
    name: "FeedService",
    platforms: [.iOS(.v13), .macOS(.v10_15)],
    products: [
        .library(
            name: "FeedService",
            targets: ["FeedService"]
        ),
    ],
    targets: [
        .target(
            name: "FeedService",
            dependencies: ["FeedDTO"],
            swiftSettings: [
                .enableExperimentalFeature("AccessLevelOnImport")
            ]
        ),
        .target(name: "FeedDTO")
    ]
)

如果你使用的是 Xcode 项目,则可以通过将 -enable-experimental-feature AccessLevelOnImport 标志添加到目标的 OTHER_SWIFT_FLAGS 构建设置中来启用该功能。

现在我们已经启用了该功能,我们可以在 FeedService.swift 文件中的导入语句中添加访问级别,代码如下:

import Foundation
private import FeedDTO

public struct Feed {
    let items: [Item]
    
    public struct Item {
        let title: String
        let image: URL
        let body: String
    }
}

public protocol FeedService {
    func fetch() -> Feed
}

通过这个改变,如果我们再次在模块的公共接口中使用 FeedDTO,编译器将会报错。这是一种强制实现关注点分离和隐藏模块客户端的实现细节的绝佳方式。

请注意,你可以在同一个依赖项在目标中使用不同的访问级别。在执行优化和决定是否将依赖项带给模块的消费者时,构建系统将考虑最不限制的访问级别。

破坏性变更

与 SE-0409 引入的更改相关的一个重大破坏性变更是:导入语句的默认访问级别将从 public 更改为 internal。这意味着,如果你在模块的公共接口中包含来自依赖项的符号,你需要明确将导入语句标记为 public,以避免编译错误。

有一个第二个功能标志,你很快就可以在 Swift 工具链的主要分支上启用,称为 InternalImportsByDefault,以测试新的行为。当它正式发布时,你将能够在你的 Swift 包中启用它:

// swift-tools-version: 5.10

import PackageDescription

let package = Package(
    name: "FeedService",
    platforms: [.iOS(.v13), .macOS(.v10_15)],
    products: [
        .library(
            name: "FeedService",
            targets: ["FeedService"]
        ),
    ],
    targets: [
        .target(
            name: "FeedService",
            dependencies: ["FeedDTO"],
            swiftSettings: [
                .enableExperimentalFeature("AccessLevelOnImport"),
                .enableUpcomingFeature("InternalImportsByDefault")
            ]
        ),
        .target(name: "FeedDTO")
    ]
)

如果你使用的是 Xcode 项目,则可以通过将 -enable-upcoming-feature InternalImportsByDefault 标志添加到目标的 OTHER_SWIFT_FLAGS 构建设置中来启用该功能。

采用这些更改

在采用这些新更改时的最佳实践是首先在你的 Swift 包中启用 AccessLevelOnImport 功能标志,并开始将最严格的访问级别添加到所有的导入语句中,让编译器告诉你可能需要进行更改的地方。

这是一个为你执行此操作的小脚本,replace-imports.swift 文件代码如下:

#!/usr/bin/swift

private import Foundation

let fileManager = FileManager.default
let currentDirectory = fileManager.currentDirectoryPath
let swiftFiles = fileManager.enumerator(atPath: currentDirectory)?
    .compactMap { $0 as? String }
    .filter { $0.hasSuffix(".swift") }

for file in swiftFiles ?? [] {
    let filePath = "\(currentDirectory)/\(file)"
    guard let content = try? String(contentsOfFile: filePath) else {
        continue
    }
    
    let updatedContent = content
        .replacingOccurrences(of: #"import (\w+)"#, with: "private import $1", options: .regularExpression)
    
    try? updatedContent.write(toFile: filePath, atomically: true, encoding: .utf8)
}

如果你对你的公共接口和它们所暴露的内容感到满意,或者如果你发现当你打开 InternalImportsByDefault 即将推出的功能标志时,有很多编译错误你不想立即修复,你可以修改上述脚本以将 public 访问级别添加到所有导入语句中。

总结

该文章介绍了 Swift 6 中关于导入声明访问级别的新功能。SE-0409 提案引入了此功能,允许开发人员使用任何可用的访问级别标记导入声明,从而限制了导入的符号在哪些类型或接口中可以使用。这项功能通过两个功能标志实现,即 AccessLevelOnImportInternalImportsByDefault,它们将在 Swift 6 中默认启用。文章通过示例说明了如何在 Swift 包中使用这些功能,并介绍了相关的破坏性变更。最后,文章提出了采用这些更改的最佳实践,并提供了一个小脚本来帮助开发人员执行相应的更改。


Swift社区
11.9k 声望3.8k 粉丝

我们希望做一个最专业最权威的 Swift 中文社区,我们希望更多的人学习和使用Swift。我们会分享以 Swift 实战、SwiftUI、Swift 基础为核心的技术干货,欢迎您的关注与支持。