Swift 适配系列(-1-)同时兼容 Xcode7 和 Xcode8。
作为一名 ios 开发者,你一定对 ios 10 带来的新特性感到无比兴奋,并迫不及待地想要在应用中实践。虽然你想马上就动手以便第一时间就能“上车”。但 ios 10 正式上线却是几个月以后的事情,在那之前,你不得不保持每几周就为应用发布一个新版本的频率。这个情况听起来是不是跟你现在的处境很像呢?
当然,目前你还不能用 xcode 8 来编译需要发布的应用——因为它无法通过 app store 的验证。所以你需要把项目拆分成两个分支,稳定分支和 ios 10 开发分支……
而不可避免地是,这烂透了。如果只是暂时在分支上做一点某个特性的开发并无伤大雅。但是随着整个代码库的改变,主分支的演进,持续好几个月来维护这样一个庞大的分支的时候,你就会渐渐遇到一些不可描述的合并之痛。我的意思是,你尝试过处理.xcodeproj 文件的合并冲突么?
这篇文章的目的就是告诉你如何彻底避免使用分支。对于大部分应用而言,只用一个工程文件就同时支持 ios 9(xcode 7)和 ios 10(xcode 8)是完全可能的。而且即使你不得不使用分支,这些小技巧也可以帮助你减少两个分支之间的差异,从而更舒服地对它们进行同步。
你用的是 swift 2.3
我先说明一点:
我们都为 swift 3 的到来而兴奋。它很棒,但是如果你正在读这篇文章,请别用它(或者说暂时别)。虽然它足够好,但是在代码层面上存在的不兼容,比一年前的 swift 2 还要严重得多。而且一旦应用存在对第三方 swift 库的依赖,就得等这些库都升级到 swift 3,它才可以跟着升级。
而好消息是,史无前例地,xcode 8 支持两个版本的 swift:2.3 和 3.0。
为了防止你因错过了发布会而不太了解现状,我想再说一遍,除了少数的 api 有所调整(之后会详细介绍)以外,xcode 7 中的 swift 2.2 和 swift 2.3 基本是一致的。
所以!为了保持兼容性,我们还是用 swift 2.3 来进行开发。
xcode 的设置
说这么多你应该已经很明白了。现在让我来告诉你如何设置 xcode 项目,让它可以在这两个版本上运行。
swift 版本
首先,在 xcode 7 中打开你的项目,选中项目设置页的 build settings 选项,然后点击 “+“ 来增加一个 user-defined 设置项:
“swift_version” = “2.3” |
这个选项是 xcode 8 新增的,因此,即使它表示该项目使用 swift 2.3,xcode 7(实际上它并没有 swift 2.3)也会完全忽略这个设置并继续使用 swift 2.2 来进行构建。
framework provisioning
framework provisioning 的工作方式在 xcode 8 上稍有不同 —— 如果是模拟器,它们会按原样继续编译,而对于真机会构建失败。
要解决这个问题,可以像设置
swift_version 时所做的一样,遍历 build settings 中所有的 framework targets 并增加如下选项:
“provisioning_profile_specifier” = “abcdefghij/“ |
你需要把 “abcdefghij“ 替换成你的团队 id(你可以在 apple developer portal中找到它),然后保留最后的斜杠。
这实际上就是告诉 xcode 8 “嘿,我是来自这个团队的,你注意下 codesign,好吗?“,然后 xcode 7 仍然会忽略这个设置,这样就万事大吉了。
interface builder
interface builder
浏览所有
.xib 和
.storyboard 文件,打开右侧边栏,选中第一个选项(file inspector),然后找到 “opens in“ 设置项。
显示的内容很可能是 “default (7.0)“,将它修改为 “xcode 7.0“。这样就可以保证即使你是在 xcode 8 中操作这个文件,也只能做一些可以向后兼容 xcode 7 的变动。
再次提醒一定要注意在 xcode 8 中对 xib 所做的改动。因为它会添加一些 xcode 版本相关的数据(不能确定的是应用上传到 app store 之后这些数据是否会被移除掉),而且某些时候它还会尝试把文件回滚到只支持 xcode 8 的格式(这是个 bug)。可能的话,尽可能避免在 xcode 8 中操作 interface 文件,如果实在没办法,务必要仔细 review diff,并且只提交你需要的那几行。
sdk 版本
sdk 版本
确保项目所有构建目标的 “base sdk“ 设置项都已被设置为了 “latest ios“。(大部分情况下默认设置就是这样的,但是还是要再次确认下。)这样一来,xcode 7 就会针对 ios 9 来进行编译,但是你可以在 xcode 8 中打开同样的项目并使用 ios 10 的新特性。
cocoapods 设置
cocoapods 设置
如果你正在使用 cocoapods,你同样也需要更新 pods 项目的设置,以保证其 swift 和 provisioning 的设置是正确的。
不过你可以通过在
podfile 文件中添加如下 post-install 钩子脚本来代替手动设置:
post_install do |installer| installer.pods_project.build_configurations.each do |config| # configure pod targets for xcode 8 compatibility config.build_settings['swift_version'] = '2.3' config.build_settings['provisioning_profile_specifier'] = 'abcdefghij/' config.build_settings['always_embed_swift_standard_libraries'] = 'no' end end |
同样,记得把
abcdefghij 替换成你的团队 id。然后运行
pod install 来重新生成 pods 项目。
(如果发现这个 pod 不兼容 swift 2.3,那么你需要为 xcode 8 单独拉一个不同的分支, 这是由 igor palaguta 提供的)
在 xcode 8 中打开
在 xcode 8 中打开
好了,就是现在:在 xcode 8 中打开这个项目。第一次打开的时候你会被大量的请求轰炸。
xcode 会催促你升级到新版本的 swift。忽略。
xcode 还会建议更新项目的设置为 “推荐设置“,同样忽略。
记住,我们已经对项目做了设置,让它可以在两个版本下都可以编译通过。所以现在我们要做的是尽量少做改动,从而保证同时兼容。更重要的是,因为我们发布到 app store 的文件是同一个,所以我们不希望
.xcodeproj 文件中包含任何 xcode 8 相关的数据。
处理 swift 2.3 的差异
处理 swift 2.3 的差异
就像我之前说过的,swift 2.3 和 swift 2.2 是相同的语言。然而,ios 10 sdk 的 frameworks 已经更新了一些 swift 的注释。我不是在谈论(那只适用于 swift 3.0)—— 不过,swift 2.3 中许多 api 的名字,类型和可选性还是稍有一些变化的。
条件编译
条件编译
考虑到你可能会忽略这一点, swift 2.2 就了编译预处理宏。用法很简单:
#if swift(>=2.3) // this compiles on xcode 8 / swift 2.3 / ios 10 #else // this compiles on xcode 7 / swift 2.2 / ios 9 #endif |
太棒了!一个文件,没有分支,同时兼容两个版本的 xcode 。
有两个需要注意的事项:
#if swift(<2.3) 这种写法是不存在的,只有 >=。如果要表达相反的意思,你可以写 #if !swift(>=2.3)。(如果需要的话你还可以使用#else 和 #elseif)。不用于 c 预处理器,#if 和 #else 之间必须是有效的 swift 代码。例如,你不能只改变函数签名而不改变函数体。(对于这点后面会有相应的处理方案)
可选性的变化
可选性的变化
swift 2.3 中很多签名都把不必要的可选性都去掉了,而有些(比如很多
nsurl 的属性)现在 变成 了可选值。
你当然也可以用条件编译来处理这个问题,比如:
#if swift(>=2.3) let specifier = url.resourcespecifier ?? "" #else let specifier = url.resourcespecifier #endif |
但是下面的方法可能会小有帮助:
func optionalize(x: t?) -> t? { return x } |
我知道这有点难理解。也许你看过结果之后就会容易得多了:
let specifier = optionalize(url.resourcespecifier) ?? "" // 适用于两个版本! |
这样就发挥了可选值的封装优势,从而避免在调用的时候写恶心的条件编译代码了。
optionalize() 方法做的事情就是把任何传进去的值转换成可选值,除非传入的已经是可选值的情况,在这种情况下,它就把参数直接返回。这样一来,不管
url.resourcespecifier 是(xcode 8)不是(xcode 7)可选值,“optionalized“版本永远是一样的。
(更深入地说:在 swift 里面,
foo 可以被理解为
foo? 的子类,因为你可以在不丢失信息的情况下把任何一个
foo 类型的值封装成可选值。编译器一旦知道这点,它就允许传入一个非可选值来代替可选值参数 —— 将
foo 封装到
foo?。)
用别名来拯救签名的变化
用别名来拯救签名的变化
swift 2.3 中,一些方法(特别是在 macos 的 sdk 中)修改了它们的参数类型。
比如,之前
nswindow 的构造方法是这样的:
init(contentrect: nsrect, stylemask: int, backing: nsbackingstoretype, defer: bool) |
现在变成了这样:
init(contentrect: nsrect, stylemask: nswindowstylemask, backing: nsbackingstoretype, defer: bool) |
注意看
stylemask 的类型。之前它是一个 int 松散类型(以全局常量方式输入的选项),但是在 xcode 8 中,它以更合理的
optionsettype 类型输入。
不幸的是你不能条件编译函数体相同,而函数签名不同的两个版本。不过别担心,你可以通过条件编译给类型起别名的方式来解决这个问题!
#if !swift(>=2.3) typealias nswindowstylemask = int #endif |
这样你就可以像 swift 2.3 一样在方法签名中使用
nswindowstylemask 了。对于 swift 2.2 而言,这个类型并不存在,
nswindowstylemask 只是
int 的一个别名,类型检查器仍然可以完美工作。
非正式 vs 正式协议
非正式 vs 正式协议
swift 2.3 把一些之前的非正式协议 改成了正式协议。
比如,要实现一个
calayer 代理,你只需要继承
nsobject 就可以了,不需要声明它符合
calayerdelegate 协议。事实上,这个协议在 xcode 7 中根本就不存在,只是现在有了。
同样,直接对类声明那行代码做条件编译是不可行的。但是你可以通过在 swift 2.2 中声明虚协议的方式来解决这个问题,就像下面这样:
#if !swift(>=2.3) private protocol calayerdelegate {} #endif class myview: nsview, calayerdelegate { . . . } |
(joe groff 指出,你也可以为
calayerdelegate 起一个叫做
any 的别名 —— 同样的结果,但是没什么开销。)
构建 ios 10 的特性
构建 ios 10 的特性
至此,你的项目可以同时在 xcode 7 和 xcode 8 上进行编译,不需要建立任何分支,这简直太棒了!
现在就是构建 ios 10 特性的时候了,因为已经有了上面所说的各种提示和小技巧,所以这件事情会变得非常简单。但是,还是有一些需要注意的事情:
只用 @available(ios 10, *) 和 if #available(ios 10, *) 是不够的。首先,不要在发布的应用中编译任何 ios 10 的代码,因为这样比较安全。更为重要的原因是,编译器需要检查这些代码,从而保证 api 的使用是安全的,这样就需要注意被调用的 api 是存在的。如果你使用了 ios 9 的 sdk 中不存在的方法或者类型,那么你的代码就无法在 xcode 7 中通过编译。你需要把所有 ios 10 专用的代码封装在 #if swift(>=2.3) 中(目前你可以认为 swift 2.3 和 ios 10 是相等的)。大部分时候,你会同时需要条件编译(这样你就不会在 xcode 7 中编译那些不可用的代码) 和 @available/#available(用来通过 xcode 8 的安全检查)。如果需要处理 ios 10 独有的特性,最简单的方式就是把相关代码抽离到单独的文件中 —— 这样一来你就可以把整个文件的内容都包含在一个 #if swift… 判断中(在 xcode 7 中这个文件还是可能会被编译器处理,但是里面的内容都会被忽略。)
应用扩展
应用扩展
但问题是,你可能想要在 ios 10 上为你的应用添加一些新的扩展,而不是仅仅给应用本身添加更多的代码。
这就很棘手了。我们可以条件编译我们的代码,但是没有“条件目标“这种东西。
好消息是,只要 xcode 7 无需实际地编译这些目标,它就不会向你抱怨什么。(是的,它可能会发出警告,告诉你项目包含一个目标,用于配置将应用部署到一个比基础 sdk 版本更高的 ios 上,它会发布到一个比 base sdk 版本更高的 ios 版本上,但是这不是什么大问题。)
所以方法就是:在每个地方都保留构建目标和它的代码,但是有选择地从应用构建目标 build phases 标签页的 “target dependencies“ 和 “embed app extensions“ 选项中移除它们。
如何做到这一点呢?我想到的最佳方式是默认禁用构建设置中的应用扩展,从而兼容 xcode 7。然后只有在使用 xcode 8的时候,才暂时重新添加这些扩展,并且任何时候都不提交这些变动。
如果每次都手动做,听起来太反复无常了(更别说与 ci 和自动化构建的不兼容),别担心,我帮你写了!
安装:
sudo gem install configure_extensions |
在提交 xcode 项目的任何变化之前,从应用的构建目标中移除 ios 10 专用的应用扩展:
configure_extensions remove myapp.xcodeproj myapptarget notificationsui intents |
然后在 xcode 8 中使用时,把它们添加回来:
configure_extensions add myapp.xcodeproj myapptarget notificationsui intents |
你可以把这个放到你的
script/ 文件夹中,然后可以把它加到 xcode 构建的预处理中,也可以加到 git 的预提交 hook 上,或者集成到 ci 和自动化构建中。(更多信息请参照github)
关于 ios 10 应用扩展需要注意的最后一点:xcode 给这些扩展建立的模板是基于 swift 3 的,而不是 swift 2.3 的代码。所以一定要注意把应用扩展的 “use legacy swift language version“ 构建选项设置为 “yes“,然后把代码用 swift 2.3 重写。
发布xcode 8
发布xcode 8
到了 9 月份,ios 10 就出来了,那个时候我们需要去掉对 xcode 7 的支持并清理项目!
我给你准备了一个确认清单(记得加入书签,以备日后参考):
移除所有 swift 2.2 的代码和不必要的 #if swift(>=2.3) 检查移除所有过渡处理,比如对 optionalize() 的使用,临时定义的别名,或是创建的虚协议移除 configure_extensions 脚本,然后把增加了新应用扩展支持的项目设置提交到代码库如果你使用了 cocoapods,把它更新,然后移除之前我们添加到 podfile 中 post_install hook(9月份以后基本就用不上了)更新为 xcode 推荐的项目设置(在侧边栏中选中项目,然后在菜单中选择:editor → validate settings…)考虑把 provisioning 设置升级,使用新的 provisioning_profile_specifier回滚所有的 .xib 和 .storyboard 的文件,使用默认设置 “opens in: latest xcode (8.0)“。确保你依赖的所有 swift 库都已经升级到了 swift 3。如果没有,可以考虑自己对 swift 3 移植做出贡献上面的步骤都搞定之后,就可以把应用更新到 swift 3 了!找到 edit → convert → to current swift syntax…,选择所有的构建目标(记住,你需要一次全部转换好),review 一下 diff,测试,然后提交!如果你尚未完成这些步骤,不妨考虑移除对 ios 8 的支持——这样一来你就可以告别更多的 @available 检查和其他的条件语句。 祝好运!