Mbltdev marius rackwitz cover option cn

构建 Swift 框架所面临的挑战

在 Realm 首次发布之前,Apple 就推出了 Swift 这门全新的语言。Realm 团队很快意识到这门语言将会变得十分重���,因此他们开始全力打造 Realm 的 Swift 版本。这意味着他们做了很多开创性的工作。到目前为止,有很多新的开源工具如雨后春笋一般纷纷冒出,现有的工具也都进行了大幅度的扩展和改进。然而,在构建 Swift 动态框架 (framework) 的过程中,仍然存在着不少的挑战。在这个 MBLTDev 2015 的演讲中,Marius 总结了团队的相关经验,指出需要避免的陷阱,并且给予相应的提示,以便帮助您找到在快速发展的 Swift 生态系统中进行开发的舒适点。


概述 (0:00)

本演讲专注于介绍搭建 Swift 动态框架中所面临的挑战。我会尽可能介绍以下内容:我们是如何克服这些挑战的、我们从中得到了哪些经验,以及我们所学到的教训。我希望这能够帮助您理解:如何在移动端构建开源软件或者第三方库,以及帮助您能够以正确的姿势完成它。

这个主题我非常的感兴趣,因为我在参与 CocoaPods 的开发,这是一个依赖管理器 (Dependency Manager)。今年下来,应该有不少 Android 开发者知道了我们,因为我们在今年的 Google I/O 大会上被 Google 提及到了

至于我的日常工作的话,我是在 Realm 工作的。

Realm 有什么特别的? (2:03)

Realm 是一个轻便的嵌入式数据库,是完完全全针对移动端进行开发的。目前已经有多家世界 500 强的公司在使用它了,并且许多 Top 100 应用也同样在使用。

Realm 是很特别的,因为它是一个面向对象的数据库,同样也完全满足了数据库的 ACID (原子性 Atomicity、一致性 Consistency、隔离性 Isolation、持久性 Durability) 要求(这也是每个数据库应当满足的)。Realm 由一个多版本并发控制的算法驱动,通过这个算法定义了一个良好的线程模型,同时在您的数据库当中还添加了本地链 (native link) 建立对一或对多关系的概念。通过 Realm,关系将变成一等公民。它们不再是互相分离的“表”,因此您不需要进行复杂的连接操作。您也不会遇到对象-关系不匹配的问题,也无需引入外键。最重要的是,Realm 也是跨平台的。我们有 iOS 和 Android 版本。

我们有一个用 C++ 编写的核心引擎,这样来实现跨平台操作。对于数据库来说,这是唯一一种可以在不同平台之间分享代码的方式。对于 Android 来说,一个 .JAR 坐落于核心的顶层。对于这个 .JAR 来说,您只需要使用 Java 就可以了(加上一些 JNI 的胶水代码即可)。

那么 Swift 呢? (4:35)

最大的挑战在于如何让 Swift 与 C++ 进行交互。Swift 1.0 与 Objective-C 的互操作性极其有限。随后,在 Swift 2.0 当中有了不少的改进。我们意识,将重点放在提供 Realm 的 Swift 版本,对我们以及开发者社区来说都是非常重要的。

Receive news and updates from Realm straight to your inbox

我们不得不在现有的 Realm 动态框架上构建 Realm 的 Swift 版本,这会在很大的程度上改变我们的架构。从本质上来讲,这意味着 Realm 的 Swift 动态框架主要是基于 Swift 构建的,无需变动的部分将会很少。这对我们来说是否合适呢?

当我们想要在某个库 (Library) 中的某个部分构建东西的时候,可能会遇到一些私有的符号标识 (Symbol)。这对我们来说是一个很棘手的事情,因为您会想要在其中使用自己构建的封装器,而这正是 Realm 的 Swift 版本所要做的。我们不想要暴露 Realm 动态框架的所有细节,我们只需要实现相互操作就可以了,并且我们希望还能够使用现有的私有 API,但是这些 API 不能重新暴露给 Realm Swift。然而,这些办法对使用我们最终产品的最终用户来说并不是很有用,所以我们不得不想其他的办法来实现这个功能。

对于我们的 Realm 动态框架来说,我们想出了一个解决办法,那就是自定义的 Clang Modulemap:

framework module Realm {
	umbrella header "Realm.h"

	export *
	module * { export * }

	explicit module Private {
		header "RLMArray_Private.h"
		header "RLMObject_Private.h"
		// ...
	}
}

首先是第一部分,也就是 umbrella header 以及通用的 export 语句,和其他的 modulemap 是非常类似的。有趣的部分在于这个 explicit module Private 这里。这意味着如果您导入了 Realm.private 的话,只有这特定部分当中的 API 能够使用,而如果您不这样做的话,您是没有办法获取其他也被标记为 _private 的头文件的,因此这就非常明显了,您不应该使用它们。它们同样其他被标记为 private 的地方当中声明。然而,我们仍然可以继续使用被 Swift 的 public 标记的部分。虽然这个功能还没有完全提供给用户,因为它不是一个完全的专用模块。这只是一个虚拟的编译器例子。

一种分发框架的可行方法 (8:04)

我们的第一个想法是使用一个包含其他框架的大框架。然后,我们会将底层的 Objective-C 部分隐藏起来,而这个部分是当您开始构建一个新的 Swift 应用时不希望处理的部分。

这种方法有不少的困难。首先,您必须要变动用户的 Build Setting。不过这个操作在 OS X 上很早以前就可以使用了。然而,App Store 并不允许使用代码签名 (Code Sign) 这个概念,因此这对我们来说是不可用的,因为我们希望让我们的用户能够构建能够分发到 App Store 上的真实应用。从本质上来讲,这意味着我们不得不将两个框架相互集成在一起,不过您仍然只会使用 Realm Swift 这个部分。

除了二进制分发版本外,我们在我们的平台上也有相应的依赖管理器。除了 CocoaPods 之外,还有另一个来自 Swift 的依赖管理器,名为 “Carthage”。Carthage 使用了不同的假设:每个 repo 都只有一个框架。我们在一个 repo 当中将所有东西构建在了一起,因为这对我们来说要容易一些,从而为我们那些遇到问题的用户提供支持。两个管理器都只是简单的封装了一下现有的 Realm 框架,因此对于我们的 Realm Cocoa 团队来说,只有一个 repo 是有意义的。不过为了能够在 Carthage 上使用,我们必须要拿出一种解决方法。

从本质上来讲,我们在 Github 上的发布版本中,提供了预构建的二进制版本。我们在这里上传了一个包含两个预构建框架的 Carthage 框架的 zip 文件。这样做运行很成功,因为我们在 Carthage 的基础上将 Realm 构建为了一个框架,因此这限制了我们进行整合的可能性。这也是没问题的,因为它将所有事情当中的复杂性给移除掉了。在以前,我们只想要通过一种方法构建并维护东西。我们现在已经有两种方式了,就是通过 Xcode 项目以及 CocoaPods pod spec,然后这是下一种方式。

Pod::Spec.new do |s|
  s.name = 'Realm'
  # ...

  s.module_map = 'Realm/module.modulemap'
end

Pod::Spec.new do |s|
  s.name = 'RealmSwift'
  # ...

  s.dependency 'Realm', "= #{s.version}"
end

我们需要让 CocoaPods 能够使用框架继承模块 (framework integration module)。我们需要在 CocoaPods 当中构建对 modulemap 的支持。这样就可以用两个独立的 pod specs 了。Realm pod spec 声明了 Realm 的 modulemap,这也是它最特别的地方,然后 Realm Swift pod spec 声明了与第一个 spec 的依赖关系。这看起来我们已经准备好进行分发了!

美妙的文档🎵 (12:06)

在我们分发之前,我们需要关注的一件大事就是文档了,尤其是 API 文档。要对诸如 Swift 之类的语言建立文档,一切都变得十分棘手。我们已经有了针对 Objective-C 的 Appledoc。自 Swift 1.2 以来,我们有了另一种格式化文档的方式:重组文本 (REST)。而对于这种文档格式化方式来说,目前还没有任何工具可以对其进行处理。然后在 Swift 2.0 当中,我们面临的变化更加复杂。我们现在有了 Swift 风格的 markdown 处理格式,至少到了现在,好歹有了解决办法。使用这种格式处理就较为安全了,即使在 Swift 2.1 当中也是这样的,因此我们搭建了 jazzy

jazzy 是一个用于生成 Swift 和 Objective-C 文档的工具。它会读取您的源代码,然后使用分析器将您的注释提取出来,最后根据这些信息生成一个漂亮的 HTML 文档。您可以将您的文档和代码结合起来,这样就可以方便进行管理,尤其是当代码发生改变的时候。如果您引入了新的参数或者移除了现有的某个参数,那么在同一个地方您就可以对代码和文档同时进行管理。

jazzy 通过 SourceKitten 来获取到 AST,而这是一个基于 SourceKitService 顶层构建的一个工具。我们将 jazzy 构建到了 CocoaDocs 当中,这是另一个 CocoaPods 项目,它可以为您的 pod spec 自动产生相关的文档,这样您就不必要自行管理了。jazzy 是在云服务器当中运行的,它将会在每次您对您的库进行更新的时候,自动分析每一个新的代码版本,然后生成新的文档。随后会将它们放到一起,然后让它们可以通过 CocoaPods 网站访问到,因此作为构建第三方库的作者,那么您就无需自行对其进行配置了。

我们同样还有其他的文档需求,就比如说在我们文档中的那些代码示例。Swift 这门编程语言日新月异,我们要经常回去更新我们的那些代码示例。这是非常重要的一点,因为我们从那些第一次试图使用它的用户那里,得到了越来越多的支持请求。我觉得这对您在 Github 上的 Readme 来说也是很重要的。如果您在这里的代码示例发生了错误,那么对于用户来说这将是一个非常糟糕的体验。因此,我们根据这个需求构建了一些工具,这样我们就可以确保我们每次构建的示例代码都是正确的。这对我们来说非常有用,尤其是我们的文档每次更新的时候需要更新多个语言版本。

Swift 的发展日新月异:CI 遇到的问题 (16:17)

这门语言每个新的版本都会有变化发生,这导致我们在编写实际应用的时候,很难跟上这门语言演变的步伐。在 Realm 中,我们还是很方便进行维护的,因为我们仅仅只有两个框架,但是如果您在编写多个应用的时候,迁移到另一门语言是一个很难做出的决定。Apple 的生态系统同样也是纷繁复杂,例如扩展、watch 应用、3D Touch、AppleTV 等等,这些功能您都可能想要提供给您的用户。对于 Swift 1.2 来说,虽然有很大的不同,但是这点仍然适用。

在这么一个环境当中,对于生态系统以及更长久的发展来说,这都意味着什么呢?不容忽视的就是 CI 了;我们该如何一次性就能支持多个 Swift 版本?

许多开发者提出了使用多分支模型 (multi-branch model) 的方法,也就是对于当前的版本来说使用 master 分支,也就是所谓的在 Xcode App Store 上运行的版本,而其他的语言版本就由其他分支来维护,例如:Swift 1.2 以及 Swift 2.0

然而,这种系统有一些缺点。首先,在特殊条件下运行 CI 是有一点麻烦的。您基本上只有 matster 分支是作为用于分布版本的 CI 特殊分支,而使用其他用于发布的分支就更为麻烦。我们同样不希望在不同的分支上重新集成这些新特性,也不希望使用自动化的方式来处理合并冲突之类的东西。我们的决定就是将所有的东西都放到 master 当中:

$ tree -L 2 | 🎩
.
|--- RealmSwift -> RealmSwift-swift2.0
|--- RealmSwift-swift1.2
|    |--- Object.swift
|    |--- …
|    |--- Tests
|
|----RealmSwift-swift2.0
|    |--- Object.swift
|    |--- …
|    |--- Tests
|
|--- RealmSwift-swift2.1 -> RealmSwift-swift2.0
|
|--- …

这会导致新问题的发生。我们不得不和 Xcode 作斗争。我们决定在我们的仓库中使用多个目录,在其中加上每个 Swift 版本的后缀,这样就可以根据在其中的 Swift 文件,在 master 仓库中的一个阶段中管理多个版本。最顶部的入口只是一个指针,它会根据您所在的分支自动指定,这样就可以提示周围所有的诸如 pod specs 之类的工具,使用正确的版本,然后选择您集成的分支版本。为此,我们需要将 Realm Swift 目录放到 Xcode 当中,作为 Swift 目录的根目录。

不幸的是,事实证明,Xcode 并不能很好地处理某些链接。使用内置的 git 功能并不能很好地工作,因为它无法为您找到所有东西。它只是指定了预期路径,但是却没有指明正确的路径。如果您拖入或者拽出了文件,那么它是没办法直接生效的,您需要重新解决某些链接问题。最后,我们决定当您在构建项目的时候,在运行时决定 Swift 版本。这不是一个很好的解决办法,但是这对我们来说是有用的,并且帮助我们解决了一些问题。

CI 测试不能停 (20:27)

我们不仅在我们的源代码中使用 CI,同样也在测试当中使用了它。我们仍然希望能够在其之上运行我们的测试,这样我们就可以对不同的 Swift 版本同步地运行我们的测试。我们同样需要测试所有的集成场景,这对我们来说就是要测试预编译的二进制文件、Carthage 以及 CocoaPods。

对于 CocoaPods 来说,这是比较容易的。要做到这一点的话,您可以运行一个通用的 pod lib lint,但是我不会深入这个主题。

CI 风格指南也是非常有趣的。对于新的语言来说,我们首先面临的第一个问题就是建立新的约定。对于我们来说,我们基本上是采纳 Github 上提出的约定。我们希望能够确保我们能够遵循这些约定,我们同样还检查了其他用户的提交请求,以确保他们的代码风格和我们的风格不会出现混淆,也不会出现不同的代码格式。对于这种需求,制作一个一个相应的工具是非常棒的,因为从机器当中得到关于您代码的反馈,比从人为的观念中得到反馈要好得多。个人观念对于约定和代码指南有着巨大的影响。

为了遵守约定,我们开发了 Swift Lint,这是我们所用的文档工具。它采用 [SourceKitten] 引擎下的东西来获取 AST 和分析信息,然后验证代码是否符合我们所定义的约定。不过,您可以配置自己的规则,这些就是我们在 CI 中同样要建立的东西。

问题时间到 (23:37)

问:如果我们同样也要使用动态框架的话,那么未来是否有机会可以不用继承管理对象,或者继承父类?我们为什么需要这么做呢?

Marius:对于这个问题有很多处理方法,不过现在您应该会明白为什么我们想要这么做了。我们之前还采用了静负载 (dead weight) 的方式,这让我们无需使用单独的架构信息,就可以很容易地定义您对象模组中的模组。这是目前为止它对我们来说的优点所在。另一个已经可用的解决方案就是使用已经编码在您 Realm 数据库当中的架构,并且只能够通过动态访问器对象进行访问,而这是不依赖于任何类的。这样,您就可以直接使用它们,然后将您的数据结构映射到结构体当中,不过这同样也会带来一些问题。

问:今年早些时候,Apple 宣布在今年年底 [2015] 的时候会将 Swift 开源,这可能意味着 Swift 将可能会在其他平台上使用,比如说 Linux、Windows 以及 Android。对于 Realm 来说,有没有想法根据这点来构建某些有趣的东西,例如说不基于 Cocoa 以及 Objective-C 的 Realm 数据库,这样就可以和本地 Swift 进行兼容了?

Marius:要和本地 Swift 进行兼容,这个问题有点棘手。在代码库当中肯定会与 Cocoa、NSObjects 以及 key-value 编码之类的东西建立依赖,这样我们就可以支持 键值观察以及其中很不错的功能。从我们当前的架构来看,您同样也可以在本次演讲中看出,要让 Realm Swift 变成一个独立的模块还有很长的一段路要走,尤其是在没有 Objective-C 参与的本地 Swift 栈当中。如果您将一个开源的 Objective-C 标准实现库添加进去,或许这可以行,但是这还不是我们打算进行测试的操作。我们不得不看看未来的发展会是怎样,如果对于实际用户和开发人员来说,他们有兴趣去试图建立这种东西的话,我们一定会调查这种做法的是否有意义,以及是否有可能性。

About the content

This talk was delivered live in October 2015 at MBLTdev. The video was transcribed by Realm and is published here with the permission of the conference organizers.

Marius Rackwitz

Marius is an iOS Developer based in Berlin. As a Core Member of the CocoaPods team, he helped implement Swift & framework support for CocoaPods. When he’s not busy working on development tools for iOS & Mac, he works on Realm, a new database for iOS & Android.

4 design patterns for a RESTless mobile integration »

close