构建 Swift 框架所面临的挑战

692 阅读18分钟
原文链接: news.realm.io
Success!

构建 Swift 框架所面临的挑战

Play Video

在 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 的胶水代码即可)。

Get more development news like this

订阅

那么 Swift 呢? (4:35)

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

我们不得不在现有的 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 标准实现库添加进去,或许这可以行,但是这还不是我们打算进行测试的操作。我们不得不看看未来的发展会是怎样,如果对于实际用户和开发人员来说,他们有兴趣去试图建立这种东西的话,我们一定会调查这种做法的是否有意义,以及是否有可能性。

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.

分分钟了解如何在 app 中保存数据