2015年3月起我们着手用Objective C开发Skyscanner TravelPro应用。几个月后Swift 2.0发布时,我们开始慢慢采用Swift。8个月之后--100%的新代码都是用Swift写的。所有这些工作都没有重写现有的,运行良好的,健壮的、经过测试的Objective-C代码--这样做已经没有什么意义了。
市面上有很多论述如何决定是否要将Swift应用在新项目中的资料,以及Swift的最佳实践。如果你是在具备相当规模的Objective-C代码库上做开发,你或许会发现这篇文章很有用。如果不是--有一天也许你会碰到希望能用Swift来开发的项目:这篇文章提供了关于如何开始的建议。
下面这张可视化图表展示了我们的代码库在10个月内的变化。2015年11月以来,所有的新代码全都是用Swift写的,约占我们65000行代码库的10%,并且这一比例正在不断扩大。
所以我们通过什么途径从Objective-C过渡到Swift呢?
1.从简单的部分入手
我们决定从尽可能简单的部分入手,例如一些独立的可以自测和内部调用的类。我们最初选择的部分是简单的UI控制代码、实用函数以及已有类的扩展方法。
例如在最初加入的Swift组件中,我们写了一个String的扩展方法来使本地化字符串读起来更舒服:
extension String { var localized: String! { let localizedString = NSLocalizedString(self, comment: "") return localizedString } }
有趣的是我们本可以用Objective-C的分类来实现相同功能。然而我们团队再也不会去使用陈旧的NSLocalizedString(@"MyText", @"")。许多新的思路随着运用一门新语言而浮出水面。所以从使用Swift开发的第一天开始我们的Swift字符串都是写成"MyText".localized格式。
2.从Swift中调用已有的Objective-C代码
在写了一些独立的Swift组件并对它们进行单元测试后,我们开始用Swift调用已有的Objective-C的类。一切都开始变得实际起来。
要从Swift调用Objective-C的类你需要定义Swift桥接头文件。这是一个.h文件,用来定义所有能暴露给Swift调用的Objective-C头文件。在文件的开头,需要修改编译设置来让编译器在编译的时候把它加进去。当这一切完成后,这些Objective-C的类就被导入进Swift的世界,能被Swift很方便地调用。
当你在Swift中调用Objective-C类时,你可能会收到一个警告:pointer is missing a nullability type specifier。当Objective-C代码被导入到Swift,编译器会检查nullability compatibility--如果没有任何关于nullability的信息,这个提示就会出现。要做这个检查是因为在Swift中nullability信息都是显式申明的,不论是非空类型还是可选类型。
我们唯一需要做的仅仅是在被调用的Objective C头文件中加入nullability信息来消除这些编译器警告。我们使用新的nullable和nonnull标注来达成这一目的。重置工作仅仅只需要花几个小时,因为我们只需要修改我们在Swift中用到的类,无非是几百行的代码。然而,做这些修改时我们绞尽脑汁思考在已有代码库中哪些可以或不能被设置成nil,但当我们把这些代码暴露给Swift时我们无法避免的要做出一个明确的抉择。
在大部分情况下,重构涉及到一些像这样的修改:
// 在.h文件中原来的方法签名 @property (nonatomic, strong, readonly) THSession *session; // 新的,Swift友好型的方法签名 @property (nonatomic, strong, readonly, nullable) THSession *session;
在方法签名中存在block时,修改会略微复杂,但是没有不可掌控的:
// 在.h文件中原来的方法签名 - (NSURLSessionDataTask *)updateWithSuccess: (void(^)())success error:( void(^)(NSError * error))error; // 新的,Swift友好型的方法签名 - (nonnull NSURLSessionDataTask *)updateWithSuccess: (nullable void(^)())success error:(nullable void(^)(nonnull NSError *error))error;
3.在Objective C调用Swift代码
在有不少复杂度适中的使用了Objective-C类的Swift组件后,是时候在Objective C里面调用这些组件了。从Objective-C中调用Swift组件更直接,因为不需要额外的桥接头文件。
唯一需要对现有Swift文件做修改的是继承NSObject或给我们希望暴露的Swift类添加@objc属性。有一些特殊的Swift类Objective-C无法使用,像结构体(structures),元组(tuples),和泛型(generics),以及一些其他的类。这些限制不会造成什么影响,因为我们不想把任何新的结构暴露给Objective-C。唯一我们需要做些额外处理的特例是枚举类型。要使用Swift中定义的枚举类型,需要在Swift中明确申明Int值类型:
@objc enum FlightCabinClass: Int { case Economy = 1, PremiumEconomy, Business, First, PrivateJet }
4.从Swift角度重拾单元测试和依赖注入
当我们有更多复杂的模块使用了依赖时,我们遇到了一个没有明确解决方案的问题:单元测试。不像Objective-C,Swift不支持读写反射。简单来说,Swift中没有与OCMock等价的库,事实上mocking框架并不存在。
这里有一个令我们抓狂的例子。我们希望测试当我们在一个页面点击提交按钮时,saveTrip方法能被view对象的viewModel属性调用。在Objective-C中,使用OCMock,测试起来非常轻松:
// 测试当点击了提交按钮,ViewModel上的一个方法被调用 - (void)test_whenPressingSubmitButton_thenInvokesSaveTripOnViewModel { // given TripViewController *view = [TripViewController alloc] init]; id viewModelMock = OCMPartialMock(view.viewModel); OCMExpect([viewModelMock saveTrip]); // when [view.submitButton press]; // then OCMVerifyAll(viewModelMock); }
在Swift中这个方法不起作用。Objective-C单元测试常依赖于像OCMock这样完善的模拟框架。依赖注入是一个很好的实践,但因为OCMock让单元测试变得非常简单,甚至不需要显式的依赖注入,我们大部分的Objective-C依赖是隐式的。而在Swift中,像OCMock这样的动态模拟库并不存在。在Swift中唯一可以写出能测试代码的方式是让依赖变得显式和可注入。一旦这个解决后,你需要自己写模拟对象去验证行为。
重新审视前一个例子:在Swift中需要做修改,让viewModel可以作为view的依赖被传递。只要viewModel实现了协议,或者继承viewModel就能实现。测试类需要定义用来传递的模拟对象:
func test_whenPressingSubmitButton_thenInvokesSaveTripOnViewModel() { // given let viewModelMock = TripViewModelMock() let view = TripViewController(viewModelMock) // when view.submitButton.press() // then XCTAssertEqual(viewModelMock.saveTripInvoked) } class TripViewModelMock: TripViewModel { var saveTripInvoked = false override func saveTrip() { self.saveTripInvoked = true } }
Swift测试代码看起来要比Objective C版本更加冗长。事实上,显示依赖注入模式会让我们更关注尽可能减少代码的耦合度。在迁移到Swift之前,我们认为我们的Objective -C代码耦合度很低了。写了几周Swift代码后,'老'代码和'新'代码之间的差异越来越凸显。迁移到Swift并正确地测试代码让我们的代码库耦合度更低。
渐入佳境
在找到依赖注入的窍门并用这个方法来写我们自己的模拟对象后,我们在Swift中挖掘得更深入了,开始挑选一些别人未尝涉入的技术。在前一个例子我展示了如何重建Objective C的OCMPartialMock功能。一个更清晰的方法是用pure mocks而非partial mocks。在Swift中写灵活的,低耦合代码的更好方法是使用协议,以及面向协议编程技术。我们很快选择了这个方向,代码变得耦合度更低且更易测试。
新语言就有新的语言特性,像guard和defer和泛型generics、用do-catch、嵌套类型(nested types)、where clause和@testable关键字进行的错误处理(error handling),这些仅仅是冰山一角。即便如此Swift依旧很容易上手,有很多内容值得深入研究。
除了学习一门新语言外,我们还从迁移到Swift学到了什么呢?
更易读的代码:
// Objective C [self addConstraint:[NSLayoutConstraint constraintWithItem: myButton attribute: NSLayoutAttributeCenterX relatedBy: NSLayoutRelationEqual toItem: myView attribute: NSLayoutAttributeCenterX multiplier: 1.0 constant: 0.0]]; // 用Swift写的同样的代码 self.addConstraint(NSLayoutConstraint(item: myButton, attribute: .CenterX, relatedBy: .Equal, toItem: myView, attribute: .CenterX, multiplier: 1.0, constant: 0.0))
与Objective-C相比,更苛刻的编译时检查。除了类型安全和编译时间因此获益外,Swift编译器还对一些像if表达式不允许只有一行(not allowing single line if statements )或强制switch表达式把情况列举全面(enforcing exhaustive switch statements)等条件做了额外检查。
摆脱头文件。以及不再需要在.h和.m文件之间切换来复制方法申明。
当然还有学习和使用新语言特性带来的恐惧
相对优势,劣势的地方可以说是出人意料的少。一个明显的不便是我们的一些第三方依赖--像JSONModel--是建立在Objective-C动态特性上的,而这在Swift上并不管用。另一个问题是我们需要维护现存的Objective-C代码,这意味着额外的环境转换和动机去不断把更多的Objective-C代码转换成Swift代码。
当然Swift仍是一门新语言,在2016年晚些时候会有轰动的变化。尽管这样,我们的团队认为把我们的Objective-C代码转换到Swift这个项目取得了成功。它使结构更加清晰,代码更加易读,以及比之前使用Objective-C开发的时候更加高产。更重要的是:在保证平缓的改变以及不重写'老'代码的基础上,从Objective-C过渡到Swift没有丝毫降低我们的进度。