引言

Swift 作为 Objective-C 的替代者,苹果每年都为其投入了大量的资源。随着 Swift ABI 稳定,国内大厂也开始投入人力推广 Swift。诚如苹果所言,他们应该是拥有 Objective-C framework 数量最多的公司了,他们一直在持续更新其 Objective-C framework 的 interface 使其对 Swfit 友好,更有甚者是直接重写。同时也提供了很多纯 Swift 的原生框架,如 Combine、CoreML、SPM、RealityKit 等。

而今天我们要讨论的是,要如何改造 Objective-C frameork 使其更友好的支持 Swift API。

本文主要翻译自 SDWebImage 6.0 提案:Rewriten Swift API with the overlay framework instead of Objective-C exported one,感谢作者 @Dreampiggy

背景

对普通 iOS 开发者而言,让现有的 Objective-C 框架更好的支持 Swift 也是我们无法绕开的问题之一。尽管 Swift 编译器在转换 Objective-C 接口时做了很多不错的优化工作,但仍旧无法满足所有开发者的需求。

Tips: WWDC20 有专门 Session-10680 来讨论 Refine Objective-C frameworks for Swift,也推荐看sketchk.xyz 文章

以 SDWebImage 5.0 的 Objective-C 代码为例:

1[imageView sd_setImageWithURL:url placeholderImage:nil options:0 context:@{SDWebImageContextQueryCacheType: @(SDImageCacheTypeMemory)} progress:nil completion:^(UIImage *image, NSError *error, SDImageCacheType cacheType, NSURL *url) {
2  // do something with result
3    if (error) {
4        NSLog(@"%@", error);
5    } else {
6       // do with image
7    }
8}];

对于这样的 API,clang 编译器可以将其转换成如下的 Swift 形式的代码:

1imageView.sd_setImage(with: url, placeholderImage:nil options:[] context:[.queryCacheType : SDImageCacheType.memory.rawValue] progress:nil) { image, error, cacheType, url in
2  // do something with result
3  if let error = error {
4      print(error)
5  } else {
6      let image = image!
7  }
8}

相比于纯 Swift API,它还是少了一些 Swifty 的味道。一个简洁优雅 Swift API 设计应该能够充分的利用其语法,包括但不限于以下特性:

它看起来应该是这样的:

1imageView.sd.setImage(with: url, options: [.queryCacheType(.memory)] { result
2    switch result {
3    case .success(let value):
4        let image = value.image
5        let data = value.data
6    case .failure(let error):
7        print(error)
8    }
9}

注意:options 参数在这里是以关联类型枚举来声明,它将 SDWebImageOptionsSDWebImageContext 结合到一起。而在 Objective-C 中我们就无法做到这样的效果。你知道的 Objective-C 是 C 语言的超集,而在 C 语言中 Int 类型的枚举是无法绑定对象的

可选方案

想要为现有的 Objective-C framework 提供优雅的 Swift API,我们有哪些路径呢 ?

0x01 利用 NS_SWIFT_NAME 修改 Objective-C 的 Public Interface

NS_SWIFT_NAME 能够解决重命名的问题。例如,可以将 SDImageCache 重命名为 SDWebImage.ImageCache 以此来去除其前缀 SD:

1NS_SWIFT_NAME(SDWebImage.ImageCache)
2@interface SDImageCache : NSObject

然而,重命名的方式无法解决需要将 SDWebImageOptionsSDWebImageContext 结合为 Swift 提供的枚举结构,如 SDWebImage.ImageOptions

1enum ImageOptions {
2	case queryCacheType(SDImageCacheType)
3	case priority(SDImageQueryPriority)
4	...
5}

0x02 Overlay Framework & 重写旧 Swift API

通过创建的 overlay framework 来提供 Swfit 友好的 API。Swift 社区有说明 Apple 是如何实现 传送门

对于稳定的商业化产品而言,如果推倒重来直接用 Swift 来新写内部组件,其开发成本和潜在的风险都是需要合理评估的。因此,能够提供 Swift 友好的 API 层也不失为一个合理方案。

我们以 SDWebImage 为例。

首先,创建一个名称为 SwiftWebImage (名字待定) 的 framework,内部包含了一个名为 SDWebImage.swift 的文件且内容如下:

1@_exported import SDWebImage

利用 @_exported 关键字来扩展 import 框架的可见性,这样就不需要在每个使用 SD 的地方进行 import。

接着将原有 Objective-C 的公共 API 标记为 @unavailable,具体如下:

1@available(*, unavailable, renamed: "SwiftWebImage.ImageOptions")
2public struct SDWebImageOptions {}
3
4@available(*, unavailable, renamed: "SwiftWebImage.ImageOptions")
5public struct SDWebImageContext {}

最终,重写的 Swift API 将通过调用原有 SDWebImage 的 API 来完成逻辑:

 1/// Wrapper for SDWebImage compatible types. 
 2public struct SDWebImageWrapper<Base> {
 3    public let base: Base
 4    public init(_ base: Base) {
 5        self.base = base
 6    }
 7}
 8
 9extension SDWebImageCompatible {
10    /// Gets a namespace holder for SDWebImage compatible types.
11    public var sd: SDWebImageWrapper<Self> {
12        get { return SDWebImageWrapper(self) }
13        set { }
14    }
15}
16
17extension UIImageView : SDWebImageCompatible {}
18
19public protocol ImageResource {}
20
21extension URL : ImageResource {}
22
23extension SDWebImageWrapper where Base: UIImageView {
24    @discardableResult
25    public func setImage(
26        with resource: ImageResource?,
27        placeholder: UIImage? = nil,
28        options: ImageOptions? = nil,
29        progress: LoaderProgressBlock? = nil,
30        completion: ((Result<ImageResult, ImageError>) -> Void)? = nil) -> CombinedOperation? {
31            // convert the `ImageOptions` into the actual `SDWebImage` and `SDWebImageContext`
32            // finally call the exist `sd_setImage(with:) API
33        }
34}

当用户引入 SwiftWebImage framework 时,旧的 Swift API 将被标记为不可用。此时,我们就能愉快的使用新 API 了。

1import SwiftWebIamge
2import SDWebImage // This will be overlayed and not visible, actuallly you don't need to import this

另外,这里通过 SDWebImageWrapper 实现了 Swift NameSpace 形式的 extension。SDWebImageWrapper 作为装饰器将对原类型进行封装,然后我们再对 SDWebImageWrapper 进行自定义方法的扩展,从而避免了命名冲突的问题,方便我们对系统库中的已有类型作自定义扩展。

0x03 Overlay framework naming

我们发现一个问题:像苹果提供的标准库 Network.framework, 它在 Swift Runtime 时能够提供一种 overlay framework 其名称与原有 framework 一样,同为 Network。此时我们可以像下面这样使用:

1import Network

其背后,我们 import 的并非 Network.framework, 而是 libSwiftNetwork.dylib 及其对应的 module。

 1import SwiftNetwork // Actually what you do
 2// The libSwiftNetwork has this:
 3@_exported import Network
 4
 5@available(*, unavailable, renamed: "Network.NWInterface")
 6typealias nw_interface_t = OS_nw_interface
 7
 8public class NWInterface {
 9    // ...Call C API for internal implementations
10}

SDWebImage 也想采用这样的方案,如此就不用将 import SDWebImage 替换为 import SwiftWebImage,然而事与愿违,毕竟我们同时支持了 3 种包管理方式:

  • CocoaPods: 支持自定义的 script phase,prepare_script 来完成 module name 的替换;
  • Carthage: 支持在 Xcode Project 中自定义 Build Phase;
  • SwiftPM: 不允许在一个 module name 下同时声明两个 framework;

同时,对现有的 SDWebImage 5.0 用户,如果他们不愿意更新为新的 Swifty API,仍可以通过 import SDWebImage 来使用原有 Objective-C 生成的 API。

以重命名的方式提供不同于 SDWebImage 名称的 overlay framework 可以支持这样的操作。

什么意思呢 ?意味着你的项目中可同时存在两种不同的 Swift API,它取决于你导入的 framework。

  • 不使用 overlay framework
1import SDWebImage
2
3let imageCache = SDImageCache.shared
4imageView.sd_setImage(with: url, options: [], context: [.imageScaleFactor : 3])
  • 使用 overlay framework
1import SwiftWebImage
2
3let imageCache = ImageCache.shared
4imageView.sd.setImage(with: url, options: [.scaleFactor(3)])

毕竟,我们都知道 Swift 是有 name space 隔离的。

0x04 放弃 Objective-C 用户 & 用 Swift 重写

作为个人意见,这并非一个好主意。在 iOS 社区中已经有很多很棒的纯 Swift 的图片加载框架,如 Kingfisher, Nuke 等。

大家都有一些共通的设计和解决方案。如果完全重写将需要花费大量的时间和单测来保证功能的稳定性。况且,仍然有很多 Objective-C 项目和用户在使用 SDWebImage 5.0。尤其在国内,80% 以上采用 Objective-C 的公司都在使用 SDWebImage (非个人)。他们中还有大量使用 Swift 与 Objective-C 混编。放弃这些用户值得深思!

对于现阶段而言,不论是选择 Objective-C 或者 Swift 都是实现细节,而采用 Swift 可能有的优势:

  • 线程安全:不赞同,在 Kingfisher 和 Nuke 中同样有线程安全问题,这并非语言层面可以解决的。 当然,Swift 提供了严格的 Optioanl 类型来避免一些常见的错误,如 null 检查。另外,Swift 5.5 中提供的 Actor 也能从一定程度上规避并非带来的问题;
  • **性能:**对于图片加载框架而言其性能瓶颈并非由 Objective-C runtime 的消息发送架构决定的,而是由一些调度队列、图片解码等其他问题的。这些也并非 Swift 能解决的;
  • **维护:**作为主要原因,对于 iOS 程序员的新手来说,他们可能不太了解一些 Objective-C 的最佳实践和良好的代码规范,而用 Swift 实现的话可以吸引更多优秀新人为 SDWebImage 贡献;

因此,现阶段而言,我们仅需要提供 Swift 友好的 API 即可,内核仍以 Objective-C 实现。毕竟 SDWebImage 经过这么多年的迭代,可靠性与稳定性都有很好的保证。

开源实践

在提案的讨论中,也提到了优秀大厂的一些实践。他们的做法也是值得我们考虑的。

facebook-ios-sdk

facebook 家提供的 facebook-ios-sdk,他们为 Swift 用户提供了新的符合 Swift 特性的 Swift Module,不过它基于原有的 Objective-C framework 来包装的产物。因此,用户可以根据其选择来 import 不同的 framework。显然像 facebook-ios-sdk 复杂的 framework,他们选择了成本相对较低的 Overlay Framework 方案。如前面提到用 Swift 重写也未能显著提高性能或者安全性,毕竟他们已通过使用较低的 C API 和线程安全锁解决了,这在 Swift 语言层面无法解决的。

我们通过版本 v12.0.0Package.swift 的以产物之一 FacebookCore 来举例。

 1import PackageDescription
 2
 3let conditionalCompilationFlag = "FBSDK_SWIFT_PACKAGE"
 4
 5let package = Package(
 6    name: "Facebook",
 7    products: [
 8        .library(
 9            name: "FacebookCore",
10            targets: ["FacebookCore", "FBSDKCoreKit"]
11        ),
12	     ...
13    ],
14    targets: [
15        .target(
16            name: "FBSDKCoreKit_Basics"
17        ),
18        .target(
19            name: "LegacyCore",
20            dependencies: ["FBSDKCoreKit_Basics", "FBAEMKit"],
21            path: "FBSDKCoreKit/FBSDKCoreKit",
22            exclude: ["Swift"], ...
23        ),
24        .target(
25            name: "FacebookCore",
26            dependencies: ["LegacyCore"], ...
27        ),
28        .target(
29            name: "FBSDKCoreKit",
30            dependencies: ["LegacyCore", "FacebookCore"], ...
31        ),
32    ]
33)

Tips:新提供的 FacebookCore framework 仅支持 SPM,尚未支持 CocoaPods 方式引入。

framework products

看构建产物 library FacebookCore 作为核心 SDK,它提供了两种可导入 module 产物,FacebookCoreFBSDKCoreKit 。注意,这里有两种不同的前缀 FacebookFBFacebook 代表的则是新提供的 Swift framework,而 FB 则代表的是原有的 Objective-C framework。

这里按语言将 FacebookCore 分成 Objective-C 与 Swift 两个 target 也是不得已而为之,因为 SPM 目前仍不支持 多语言混编。另外为了更好复用编译产物,他们对公用逻辑进行二次拆分,剥离出 LegacyCore target。

FacebookCore 将通过 overlay framework 来提供面向 Swift 友好的 API,最后用户也将被分为三部分:

  1. 引入 FacebookCore 来使用完善 Swift API 的 Swift 用户;
  2. 引入 FBSDKCoreKit 来使用由 Objective-C 接口翻译过来的 Swift 用户;
  3. 引入 FBSDKCoreKit 的 Objective-C 用户;

总结

要说使用 Swift 优点的话,对 iOS 开发初学者更具吸引力算是一点。对于需要深入特定领域的问题,如 decoding 或者 transformer,用 Swift 重写和 Objective-C 一样丑陋,并无太多区别 😅。因此,作为框架提供者,要跳出语言的限制,从更高角度看待问题。

纵观整个 SDWebImage 6.0 提案的讨论过程,我们也可以看到其维护者的严谨态度。不仅能从用户使用体验和维护成本角度来权衡方案变更带来的影响,并且做了详细的调研和研究分析。

希望本文能为从 Objective-C 转向 Swift 的开发者提供一些帮助。