Slug michele titolo cover cn

Swift 协议及应许之地

对于诸如泛型以及第一成员协议之类的语言特性来说,Swift 的设计使得它们提升为了在应用开发过程中关键的架构组件。然而,在绝大多数出现的逻辑模式中,包括从 Objective-C 中导入的那些逻辑模式,并不能够按照预期正常工作。在大多数情况下,Swift 的类型系统将会禁止使用某些简单的模式,例如将某个属性同时约束在类以及协议当中。本次演讲将会重点讲述这些挑战,讨论其根本原因,并评估解决方法。


简介 (0:00)

大家好,我是 Michele Titolo,是 Capital One 的首席软件工程师。目前为止,我开发 iOS 应用应该已经有 6 年的历史了。

在本文当中,我将谈论协议以及其应许之地。我会解决一些难点,提出关于语言设计的一些想法,并且我会告诉大家如何能更轻松地使用 Swift 来编码(尤其是在混编代码的时候)。然而,在我开始详细讲述之前,我想提醒大家的是,Swift 的样子将会影响我们日常开发的行为惯例。

事实 (1:10)

首先,Swift 需要在编译时获取类型信息。每天都使用 Swift 的人都知道,编译器只要无法找到某个类型,那么它就会立刻弹出错误。当然,Swift 和 Objective-C 拥有不同的语言特性,我们往往会像对待 Objective-C 一样去对待 Swift,实际上它们是完全不同的两个语言。

协议是一等公民,意思是协议可以单独成为一种类型,这和 Objective-C 中大有不同。

然而我最想强调的一件事情是:Swift 仍然是一门非常新的语言。它在 WWDC 2013 和 iOS 7 一同发布。现在我们这篇文章要面临的问题是,Swift 确实是一门非常新的语言,尤其是和 Objective-C 相比,因为 Objective-C 已经有超过 20 年的历史了。

交互操作 (2:30)

当 Swift 推出的时候,Apple 非常激动地说 Swift 要比 Objective-C 好用得多,在 Objective-C 中能做的,在 Swift 中都能做。然而,这仍然有一些缺陷:

'as?' 并不是无所不能 (2:48)

您是否已经厌倦看到那些遍地都是的 as 了?因为 Swift 和 Objective-C 之间的类型桥接需要进行各种检查,因此这玩意儿出现的频率非常高。这通常是因为 Swift 的 nil 不是对所有东西都有效;只有一些类型可以被设置为 nil,因此这导致您最终不得不执行很多的 as 检查,使得代码变得杂乱不堪。

Apple 对此引入了许多方法以让其变得更好用,比如说 if-let 和 guard 语句,但是即便如此,您的代码中仍然还是遍布着 as。还有,有时候 as 也并不是无所不能。上周我遇到了这样一个问题,是在 Core Data 中遇到的,因为亲爱的 Core Data 是用在 Objective-C 当中的。说白了,当您试图将 Swift 协议桥接到 Objective-C 协议的时候,as 根本不起作用。

试想一下,我想要做这样一件事:

let vendor = ObjcObject()
let items = vendor.giveMeItems() as? [Fruit]

这样我拥有了一个 Objective-C 对象。我想要给用户一些“水果”,但是 Fruit是一个 Swift 协议。Objective-C 根本无法识别它,因此编译器就会报错。

let vendor = ObjcObject()
let items = vendor.giveMeItems().map { $0 as Fruit }

相反,您必须使用这个奇怪的 .map函数,这样结果才是正常的。有时候,尤其是当您从一个 Objective-C 类中得到的返回类型是泛型 NSArray 的时候。这时候您就必须要放入 as ,如果您转换的类型不是该数组当中实际拥有的类型的话,除非运行到这里,否则的话是不会有任何报错的。

Receive news and updates from Realm straight to your inbox

泛滥成灾的 '@objc' (4:32)

突然间,您得到了一个编译器错误:”This class need to be Objective-C because it implements this protocol(这个类需要是 Objective-C 类,因为它实现了此协议)” 或者 “这需要是 Objective-C,因为种种诡异的原因……”。

@objc protocol SomeProtocol {
	func someFunc()
}

class MyClass: SomeProtocol {}

当您拥有一个 Objective-C 协议的时候,当您的某个 Swift 类继承了此协议的话,如果它不是 Objective-C 类型,那么是没有办法创建的。

@objc protocol SomeProtocol {
	func someFunc()
}

@objc class MyClass: NSObject, SomeProtocol {}

相反,您必须要克服这个问题,将类写成 Objective-C 类型。这种做法是没问题的,但是这样的话您就开始陷入了基础协议是 @objc 的嵌套协议层级 (nested protocol hierarchies) 当中,因此子协议也必须是 @objc,这意味着所有继承于此的对象都必须也是 @objc 的,这往往会导致您会到处添加 @objc,这导致您无法使用任何优秀的 Swift 新特性。

此外,有些东西也无法进行桥接的。如果您在某个 @objc 类当中拥有一个泛型函数,但是这个函数无法存在于 Objective-C 自动生成的头文件当中。函数指针也有同样的问题。如果您吃有一个函数指针变量的话,它同样也无法出现在头文件当中,这往往让我们感到十分心痛。

Swift 会摒弃无法正确桥接的方法和变量。我个人非常希望对此会有一个警告进行提醒,但是目前我没法看到这种功能出现。

XCTest 到处是坑 (6:25)

我希望我们能够获得 XCTest 的全新开源版本,这样我们就可以找出其中的某些 Bug。这个提议在标题为:”rdar://24200114: Objective-C 和 Swift 之间的双向互操作无法在 XCTest 目标中实现” 的 Radar 中得以了很好的总结。

这意味着,如果您拥有测试目标的话,并且写下了 @testable import MyApp,并且您还想在某个 Objective-C 文件中为您的应用导入交接头文件的话,那么您的测试目标将无法通过编译。它会向 Swift 头文件中加入某些奇怪的东西,这并不是很糟。当您正在处理一个混编代码库的时候,编写测试变得非常得困难,尤其是您只能够使用 Swift 来测试 Swift 本身。我建议您应当尽快摒弃掉 Objective-C 写的测试,即使您会因此失去掉很多绝佳的动态语言特性。

那些让人意想不到的玩意儿 (7:23)

我并没有任何计算机科学的教育背景,我是完全自学的编程。我所学到的绝大多数东西,尤其是术语,基本上都是在工作当中习得的。由于某些人这几年来用主要 Objective-C 来进行工作,当他们开始用 Swift 的时候就会觉得非常迷惑。我倒是觉得,“我可以在 Objective-C 中这样做,我也可以在诸如 Ruby 或者 Python 这样的语言中做这种事情……为啥在 Swift 中就不顶用了呢?”

协议 (8:07)

首先,我们最喜欢的东西之一就是,我们应当尽可能多得使用协议,这真是深得人心。协议真是太棒了:

class Fruit {}
class Peach: Fruit {}

protocol FruitHolder {
	var fruits: [Fruit] { get }
}

class PeachBasket: FruitHolder {
	var fruits: [Peach] = []
}

它使得我们能够做这样的事情:我们可以有非常简单明显的名字。例如,某种可以成为 “FruitHolder” 的东西。上面的一个例子就是所谓的 “PeachBasket”。然而,有一件事是协议所不能完成的,那就是类型重载。

有种在 Objective-C 中我真的很怀念的东西就是:能够实现一个协议,然后在协议中可以使用该类的一个子类型来定义一个属性或者函数。不幸的是,在 Swift 中这是没有办法的,但是您可以随时回到 Objective-C 中,这样就可以正常工作了(尽管语法有点奇怪):

@interface PeachBasket : NSObject <FruitHolder>
@property(nonatomic, strong) NSArray<peach*>* fruits;
@end

泛型 (9:01)

关于协议我最大的一个怨念就是:尽管泛型在某些方面来说确实是非常强大的,但是它们并不一定是最好的。我们都知道,泛型通常被认为是我们想要使用东西的替身。

protocol Holder {
	typealias Item
	var items: [Item] { get }
}

class PeachBasket: Holder {
	var items: [Peach] = []
}

在这里,我们拥有了一个 Holder 类,其中有一个任意的 Item 集合,我们并不知道 Item 当中实际拥有的是什么类型。我们只知道那里肯定有东西。从这里,我可以实现一些非常简单的东西。虽然这个协议现在是泛型的,但是仍然有很多涵义。

protocol Holder {
	typealias Item
	var items: [Item] { get }
}

class Basket<Thing>: Holder{
	var items: [Thing] = []
	func add(item: Thing) {
		items.append(item)
	}
}

class PeachBasket: Basket<Peach> {}

那么您偶尔快速使用泛型协议会出什么问题呢?这里的例子可能有点长,涉及到不同种类的泛型。我会在下面对这些进行引用的:

var someHolder: Holder
var someBasket: Basket

我想要引用一个 Holder,并且我还想引用一个 Basket。我不能做到这一点,因为每个引用我都会得到错误。我希望能够找到办法来解决这个问题,因为我经常发现我的工作能够受益于将泛型类型对应具体类型。这个想法非常美好,但是我没法做到它。

var someHolder: Holder<Peach>
var someBasket: Basket<Peach>

当然,您所能够做的就是持有一个泛型类。这就是泛型类和泛型协议的区别所在,因为您可以引用一个泛型类,但是泛型协议不可以。这就是您应该如何使用泛型类的做法,加上具体类型就行了。这是在变量中使用泛型协议的唯一方式,这真的很恼人。您可以使用函数声明完成很多的操作,但是在变量中,它总是需要拥有一个确定的类型。

协变性只适用于泛型类 (11:16)

对于那些和我一样,可能在六个月之前都从未听过“协变性 (covariance) ”是什么,下面是一个简单的例子:如果“猫”是“动物”的话,那么一个接受“动物”参数的函数同样也可以接受“猫”为参数。这本质上是一个子类型 (subType)。很多年来,继承一直是面向对象程序设计的一部分,不幸的是,Swift 泛型并不这么做。

typealias Object
typealias Object: String
typealias RoundObject: Object

您无法持有一个继承自现有确定类型的泛型类型别名。您同样也无法持有一个继承自泛型类型的泛型类型别名。目前没有办法在泛型和类型别名之间指定关联,这让人非常沮丧。

class Basket<Thing: Fruit>: Holder{
	var items: [Thing] = []
	func add(item: Thing) {
		items.append(item)
	}
}

另一方面,您可以使用继承,而不是使用泛型协议。在这种情况下,我拥有一个可以持有任何东西的 Basket 类,但是这种东西必须满足 Fruit 协议,所以本质上这实际上是一个 FruitBasket。

class FruitBasket: Basket<Fruit> {}
class PeachBasket: FruitBasket<Peach> {}

接下来你就可以这样说:我拥有一个 Basket,只要是 Fruit 类别的东西都可以放到里面去。我同样也想特定一个 Peach Basket,这也是一个 Fruit Basket,但是只能够向里面放入 Peach。除非……您无法这么做!

FruitBasket 不再是一个泛型类了。一旦其继承了一个普通类之后,它就变成了一个具体类 (Concrete Class)。人们总是喜欢说:“我们要一路保持泛型”,但是这里真的不是这样的。泛型实际上只存在于一个级别当中,除非你继承的也是泛型类,不然是没办法再得到一个泛型类的,而这种做法是我不推荐的。

class Bag<Stuff> {
	class Basket<Thing: Stuff>: Holder{
		var items: [Thing] = []
		func add(item: Thing) {
			items.append(item)
		}
	}
}

那么使用使用泛型来嵌套类型又会怎么样呢?这看起来是个很不错的方案,但是您不能够嵌套泛型类型。您无法在泛型类当中再次嵌套泛型类。基本上,任何类型的泛型类都必须是顶层级别的类,这让人非常失望。

新的语言,新的模式 (13:48)

不过这不全是坏消息。对于 Swift 来说仍然有很多非常赞的特性。我会谈论一些新的语言特性和模式,希望这些内容会有趣一些。

属性只能有一种类型 (14:04)

Swift 是一门静态类型语言,这意味着变量只能够拥有一个确定的类型。我最近正在执行重构操作,让我们的视图控制器全部实现这些非常赞的协议,因为在 Swift 中协议真的非常强大。

protocol Themeable {}
class ListViewController: UIViewController, Themeable {}

var themedViewController: // UIViewController, Themeable ????

比如说,我想要创建可以主题化的视图控制器,这样我就可以让导航栏和返回按钮能够自动建立风格,这样就不用再去写成千上万行那些无用的代码了。然而,由于 Swift 类型的限制,我必须对我的视图控制器建立引用。我想让其成为主题化视图控制器的引用,因为我想要展示它并且对其风格进行控制。

两种解决方案 (14:52)

我想出了两种可行的解决方案,但是为了找出合适的方法来解决这个问题,这花费了我大量的时间。大家可以自行看一下,想想我将会采用哪种方法!

方案 1:Swift 风格化,但是却是一个黑科技

public protocol ViewControllerProtocol {
	var view: UIView! { get }
	var storyboard: UIStoryboard? { get }
	init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: NSBundle?)
	func viewDidLoad()
	// etc
}

protocol ThemeableViewController: Themeable, ViewControllerProtocol {}
var themedViewController: ThemeableViewController

实现这种功能的一种方法就是,创建一个ViewControllerProtocol。您可以将任何 ViewController 需要调用的���法放到协议当中,比如 viewDidLoadviewWillAppearpresentpush 或者 pop。这就是为什么它是 Swift 风格化的,但是却又是有点黑科技的感觉。

接下来对于您的 ViewController来说,它是 UIViewController 的子类,它继承了一个需要同时实现主题和 ViewControllerProtocol 的协议。如果您曾经看过 Apple 有的那些 WWDC 视频的话,就会发现这叫做协议组合 (Protocol Compostion)。Apple 真的非常、非常希望您尽可能地实现协议组合。然而事实上,Apple 并没有提供给我们任何可以与视图控制器建立组合的协议,甚至对于任何 UIKit 类来说也是没办法建立组合的,这让人感到十分沮丧。希望大家能够多多提交 Radar!

这是我找到的一种方式,但是这完全是基于一个假设:UIKit 不再会发生任何改变。如果 UIKit 如果发生变化(因为 API 发生了变化)的话,每次您想要在 UIViewController 头部添加或者使用不在您协议当中的方法的话,您必须要克服这种困难,然后把它加入到其中。这工作量是非常大的。视图控制器是应用的动力所在,即使在 Swift 中,我们仍然需要能够完全地控制视图控制器,以让其能够完成各种各样的操作。我并没有采纳这种做法,因为我想要使用 UIViewController ,在这种情况下它能够更容易进行重构。

方案 2:同样 Swift 风格化,也同样是个黑科技

var themed: Themeable {
	get {
		return self.viewController as! Themeable
	}
	set(newThemeable) {
		if let themedViewController = newThemeable as? UIViewController{
			viewController = themedViewController
		}
	}
}

var viewController: UIViewController
init<T where T: Themeable, T: UIViewController>(viewController: T) {
	self.viewController = viewController
}

这最终用到了一个计算属性,我发现它在诸如返回基于状态的不同值方面是非常的有用。这里,我拥有两个变量:第一个是我实际的 ViewController,另一个是 Themeable 协议类型。我仅仅只需要重写计算属性,这样它就可以返回或者对 ViewController 进行设置了。

如果大家有更好的解决方案的话,请不吝赐教。这是那种您只需要完成一次两次的事情,但是如果您正在制作一个完全使用 Swift 开发的应用的话,您会发现协议真的非常好用,您能够选择的架构类型有着非常严格的限制。您没有办法引用,除非您创建了基类。如果您觉得基类很不错的话,那么也没关系,但是我们确实想要避免出现一个完整的基类这样的东西,因为这样的话我们就可以快速深入层次了。

无法在子类中重写类型(即使是其子类型) (18:19)

我真的很希望能够这样做!

class FruitBasket: Basket<Fruit> {
	var ribbonColor: UIColor
}

class PeachBasket: FruitBasket {
	var ribbonColor: Pattern
}

在 Swift 中只是没有很多对深层对象层次结构 (Deep Object Hierarchies) 的支持而已。这估计是因为语言的设计者认为这不值得花时间和精力让其能够生效,因此对于如果插入子类型这种需求,有着很多奇怪的解决方案,但是往往都没有办法生效。泛型对此可能稍微有点帮助,但是这样的话可能就会到处遍布着各种各样的泛型语法。对于那些初学 Swift 的人来说,泛型语法就跟火星语一般,他们对此毫无头绪。您会看到他们盯着这个语法盯上好几分钟,然后开始 Google,然后他们又会回来继续盯着这个语法。这对大多数开发者来说并不是一个很美妙的体验,尤其是那些在 Objective-C 工作了很久的人来说,让泛型遍布整个代码确实很让人不爽。这确实有点让人很不愉快,尤其是我们已经习惯了很多 Objective-C 中使用的范例。

非 NSObject 的泛型也有问题。当他们开始创建泛型类的时候,他们只会为 NSObject 编写测试。如果您编写了一个不是 NSObject 的泛型类的话,例如 UIViewControllerUIViewUIButtonUILabel 或者不是 NSObject 的 NSXX,这些目前用起来不会那么好用。

因此,在 Swift 中的这些限制确实很让人不爽。也就是说,我每天都使用 Swift 进行工作,我只是简单的谈论一下我的口头禅。这其中有很多我认为会有但实际上没有的东西。Swift 确实很奇妙。我经历了很多的困难和难点,有时为了实现某个功能还必须实现一些奇怪的事情,但是 Swift 确实是一个非常有趣、非常有用的语言。我很享受我每天的工作,即使我遇到再多的坑我也无怨无悔。

问答时间 (20:49)

问:我注意到在返回 Themeable 视图控制器的时候您使用了 as!。这玩意儿您觉得是没问题的么?我看到人们会这么做,但我通常都避免这种东西的出现。

Michele:我发现有两种情况 as! 是不可避免的。通常,它会出现在桥接当中。上周我遇到了一个 Core Data 的问题,比方说,我有一个 NSManagedObject 子类,同时它还是实现了一个只能用于 Swift 当中的协议。我知道这个对象满足了这个协议。它就在那里,我能够看到它,并且我知道这就是我带回来的对象。我可以对其进行类型检查以及之类的操作。然而,因为会有某些诡异的事情出现,它不一定总是能够被识别出来,尤其是在桥接当中,因此我必须使用 as!。这是我试图去避免的,虽然我最近已经大量地使用了 property!,但是仍然有其他不相关的理由导致我不得不这样做。您需要尽可能非常谨慎地使用它,使用 guard 或者 if-let 来对所有情况进行解包。有些时候如果您在使用 as? 的时候,类型检查可能会说谎,因此您需要确保所有的东西都是正确的类型,尤其是从 Objective-C 过来的类型。

问:到目前为止,您最喜欢 Swift 的哪种特性?

Michele:我最喜欢的是泛型。尽管有时它们在编译的时候会出现很多麻烦,并且被诸如 UIViewController 子类之类的东西定义时会发生很多 BUG,不过我创建了这个漂亮的模组,它位于我们应用中的某几个点中,用来展示几个非常相似但又不完全相同的屏幕。总是会有一个基类用来处理各种各样的逻辑,因此我将其转换成了虚拟基类。没有人可以再次使用这个基类了,因为这个基类实际上并不存在;他们已经试图这么做过,但是他们并没有使用到泛型,我将基类转换成了虚拟基类,然后他们将类型传递进去,万事大吉。

单例在处理单类型属性 (single-typed-property) 上也很有帮助。如果您拥有一个构造器,然后您想要确保您拥有的 ViewController 也是 Themable 的,那么您实际上可以向您的构造器函数中添加这个标记,这样您就可以确保在编译时,无论传递进去的是什么都系,都能够同时满足这两个协议。这些小技巧确实很迷人。此外,我也喜欢函数指针。我始终坚持在数组中使用函数,这真的太棒了!

问:如果您在 Objective-C 中习惯了大量使用委托,然后带着所有不同的新协议类型迁移到 Swift 中,那么有没有对此需要您特别关注的警告,或者有没有需要您思考的设计变化?

Michele:委托并没有改变太多。实际上,它可能变得更为流行了,因为协议在 Swift 中变得更为重要了。与往常一样,您需要特别关注循环引用的问题,因为 Swift 仍然是在 ARC 下进行内存管理的。

此外,请注意组合的问题,尤其是作为其他组合的委托。您首先应当确保它不会产生任何冲突,尤其是当您正在一个非常大的项目中工作的时候。在使用协议的时候,我会更小心地创建委托组合层次 (Delegate Compositional Hierarchy),因为您会碰到和深层次对象中遇到的相似问题。在扩展当中您可以使用函数,不过这意味着需要继承所有的东西。保证事物简短并且具体,这样您才能知道您在做什么。

协议扩展同样也对委托非常友好,因为对于简单的东西来说,您可以使用它来返回相同值,或者当您需要的时候对其进行重载。确保利用这些优点就可以了。

_问:您并没有特别提及您是如何执行交互操作的。您可以向现有的传统项目中添加 Swift,或者也可以创建一个能够很好建立隔离的框架。这么做是否有好处,或者您在处理过程中有没有遇到什么坑?

Michele:Capital One 在我之前就开始向他们的项目中添加 Swift 代码了。当我加入之后,他们仍然继续支持 iOS 7,并且现在的应用仍然在支持 iOS 7,因此框架只能遗憾出局。您必须要考虑到您正在支持的版本,因为 iOS 7 和 8、9 都截然不同。不然的话,这真的完全取决于您想要处理多少个 Bundle。很多时候,人们倾向于使用 NSBundle.mainBundle 来存储图片资源、字体、故事板等等,您越快将您的 UI 代码迁移到了框架当中,那么就越容易混乱。应用逻辑、网络请求等等,如果不必要的话根本没必要将它放置在主应用程序当中,尤其是您需要在另一个测试套件的 setUp 函数中执行测试的时候,因此当人们执行改变的时候就不会遭遇到测试失败的情况。个人而言,我通常喜欢重构应用逻辑,根据许多潜在的原因从 UI 代码中将逻辑代码分离出来。您可以重构您的 UI,这样就不用担心与其他代码混淆起来。另外,请不要在视图控制器中使用 APR 请求。我们很多人都这么做了,但是请不要这么做!

About the content

This content has been published here with the express permission of the author.

Michele Titolo

Michele Titolo has been making iOS apps for over 5 years. She has shipped over a dozen apps to the Apple AppStore, and designed and implemented APIs for a number of them. She enjoys debugging, refactoring, and finding elegant solutions to difficult problems. Outside of work, she is CTO of Women Who Code, and is an avid Doctor Who fan.

4 design patterns for a RESTless mobile integration »

close