Gotocon ash furrow cover

探寻 Swift 中的最佳实践

随着 Swift 的发布,我们得以重新设计应用问题的解决方式,这将与此前我们在 Objective-C 中所做的大大不同。在本次位于 GOTO Conference CPH 2015 的演讲中,Ash Furrow 将前往 Swift 中未知的领域,找出适用于 Swift 的最佳实践方式,他将会带来许多具体的例子,例如单元测试以及代码重构。


iOS 5 以前? (04:39)

在探究代码如何才能更符合 Swift 风格之前,我想说,我们此前就曾遇到过目前类似的境地。在 iOS 5 之前我们并没有对象常量,我们只能这样创建数组、字典以及 NSNumber

NSArray *array = [NSArray arrayWithObjects: @"这",
    @"货",
    @"真",
    @"烦", nil];

NSDictionary *dictionary = [NSDictionary dictionaryWithObjectsAndKeys:
@"谁想这么干?", @"反正我不想", nil];

NSNumber *number = [NSNumber numberWithInt:444];

我们需要使用数组的 arrayWithObjects: 方法,向其中传入一个以 nil 结尾的列别。同理,我们也需要使用诸如 dictionaryWithObjectsAndKeys: 以及 numberWithInt: 之类的东西。写这么一长段东西让人很是心烦,不过如果不使用 ARC 的话,这段代码会更加糟糕:

NSArray *array = [[NSArray arrayWithObjects: @"这",
    @"货",
    @"更",
    @"烦", nil] retain];

NSDictionary *dictionary = [[NSDictionary dictionaryWithObjectsAndKeys:
@"谁想这么干?", @"反正我不想", nil] retain];

NSNumber *number = [[NSNumber numberWithInt:444] retain];

对象常量 (05:59)

上面的代码有个问题:在 dictionaryWithObjectsAndKeys 中,是对象先行,因此键是_“反正我不想”_ ,而值是_“谁想这么干?”_ ,因此这个语法可能会和一般人思考的过程相逆。不过随着对象常量的推出,我很高兴我们就不用这样做了:我们可以用 @加上中括号来创建数组、用@加上大括号来创建字典,使用嵌套表达式(boxed expressions)来创建 NSNumber(用@加上数字)。因此在这个时候,使用对象常量便是所谓的“最佳实践”。

闭包 & GCD (07:08)

闭包是在 2010 年随着 iOS 4 推出的,当它首次出现的时候,这货的语法十分的古怪,实现它变成一件十分棘手的事情,可是如果没有这货 iOS 开发也会变成一件十分棘手的事情。在此之前我们使用协议、委托、对象动作以及其他古古怪怪的事情来实现回调,而实现闭包出现之后,由于它只是一种语法,因此它变成了“最佳实践”。我们可以实现函数响应式编程(functional reactor programming),还可以在一个上下文环境中运行一整段代码(比如说动画)。同样,我们还可以在得到异步网络响应的时候执行回调。还有,通过 enumerateObjectsUsingBlock 我们可以快速遍历一个集合,在其中还可以将所遍历的值映射到另一个集合当中。这些都是那时候的新特性了。我们适应苹果所提供的新的 iOS 开发技术已经有一段时间了,并且借助这些新功能我们实现了许许多多的绝妙功能!峰回路转,现在我们面临着新的选择—— Swift 2.

Swift 2 (10:00)

Swift 2 是苹果编程语言的第二个版本,第一个版本在去年首次发布。Swift 2 包含了新的语法,可以让我们实现许多新功能,不过语法只是工具而已。这些新语法包括 guard 语句、defer 语句以及错误处理中的 throw 语句。这些只是语法特性而已,不过这些语法并不是最好的,让我们看一些例子:

Receive news and updates from Realm straight to your inbox

我是否需要使用 guard? (10:49)

if let thing = optionalThing {
    if thing.shouldDoThing {
        if let otherThing = thing.otherThing {
            doStuffWithThing(otherThing)
        }
    }
}

Swift 2 将 if/let语句和 where 结合到了一起。因此现在上面这段代码变成下面这样了。这两段代码语义相同,我们将两个常量与可选值绑定,然后使用 where 来避免使用嵌套的 if 语句。

if let thing = optionalThing,
   let otherThing = thing.otherThing
   where thing.shoudDoThing {
    doStuffWithThing(otherThing)
}

避免可变性 (11:56)

func strings(
    parameter: [String],
    startingWith prefix: String) -> [String] {

    var mutableArray = [String]()
    for string in parameter {
        if string.hasPrefix(prefix) {
            mutableArray.append(string)
        }
    }     
    return mutableArray
}

这是我很讨厌的 Swift 代码,这实际上只是一个套着 Swift 外皮的 Objective-C 代码而已。我们应该这么写:

func strings(
    parameter: [String],
    startingWith prefix: String) -> [String] {

    return parameter.filter { $0.hasPrefix(prefix) }
}

这个函数功能相同:它获取一个字符串数组以及一个起始位,然后返回以这个起始位开始的剩余数组。代码更精简,效率更高效。

柯里化 (12:39)

Before Currying

有这样一个名为 containsAtSign 的函数,它接收一个字符串,返回一个布尔值。如果字符串包含@的话则返回真:

func containsAtSign(string: String) -> Bool {
    return string.characters.contains("@")
}
...
input.filter(containsAtSign)

我们可以通过 filter 来使用此函数,因为函数在语义上等同于闭包,不过我们只需要传递函数名字即可。

柯里化之后

func contains(substring: String) -> (String -> Bool) {
    return { string -> Bool in
        return string.characters.contains(substring)
    }
}
...
input.filter(contains("@"))

现在,我们用 contains 函数替代了 containsAtSign 函数。它接收一个子字符串,返回一个闭包。你可以看到这个闭包接收一个 string,然后返回Bool。这个函数和此前我们所做的相同,不过我们可以通过传递给子字符串不同的字符,检索更多的内容,而不是像之前那样只能检索@。这样我们就不需要创建什么 containsAtSigncontainsPercentSigncontainsDollarSign之类的函数了,contains 解决一切!

下面这段代码和上面的代码语义相同,不过要更为简单清晰:

func contains(substring: String)(string: String) -> Bool {
    return string.characters.contains(substring)
}
...
input.filter(contains("@"))

提取关联值 (15:07)

在 Swift 1 中,我们能够给枚举增加关联值,然后在需要的时候使用 case 语句对其进行提取。不过,那个时候 case 语句只能够在 switch 当中使用,因此代码会变成这个样子:

enum Result {
    case Success
    case Failure(reason: String)
}
switch doThing() {
case .Success:
      print("🎉")
case .Failure(let reason):
    print("Oops: \(reason)")
}

这个名为 doThing 的函数将会返回一个 Result 枚举,如果结果成功了,那么就会打印一个🎉!不过如果失败了,那么我们就可以提取其中的失败原因,然后将其打印出来。

Swift 2 让我们可以在 switch 之外使用 case 语句了:

enum Result {
    case Success
    case Failure(reason: String)
}
if case .Failure(let reason) = doThing() {
    print("😢 \(reason)")
}

如果我们只关心失败的结果,那么这样子写就可以了。

语法与想法孰重孰轻? (16:22)

这些都是我们能够轻易实现的东西,这些都是最好的做法了吗?不然,它们仅仅只是语法而已。我们在 switch 语句外面可以使用 case 语句有什么用呢?或许仅仅只是因为它简单(也很好用)?真正重要的是想法。Swift 2 给我们引入了许多新语法,我们需要的是该如何将这些新语法纳为己用。

苹果显然研究了其他的语言以及相关社区,寻找哪些功能能够引入到 Swift 当中。不过你怎么知道某个功能在其他语言中是属于新想法还是新语法呢?确切来说,你不能这样说。事情都有二面性,一味地一份为二是行不通的。

永远,不要,抛弃,想法 (18:54)

当 Swift 出来之后,我就迫不及待地想要推翻之前的东西,然而全面转战 Swift,因为它非常好用。我错了,我们不应该抛弃我们之前已有的东西,比如,Swift 对于键值观察并没有替代的方法,因此我们对此还需要使用 Objective-C 运行时。旧有思路仍然有用,因此不要不要将它们全部抛弃了。

iOS 开发者大都是喜新厌旧的 (19:49)

让我们讨论一下此前从未接触过编程的初学者吧!他们通过 Objective-C 来学习 iOS,这对他们来说非常艰难,因为初学者面对一切都很艰难(不过这并没有关系)。他们往往抱怨他们在 Objective-C 上所碰的壁,然而理所当然地认为这个语言是最糟糕的语言。不过最终,随着学习的深入,他们会越做越好。

然后,一个全新的语言 Swift 出炉了!学习 Swift 比学习 Objective-C 要轻松很多。之前的初学者们便会认为新东西一定是最好的,但是他们并没有意识到他们已经有了宝贵的经验。开发者往往混淆了这两种概念:这货学起来很简单,一旦有经验之后这货学起来很简单。这就是为什么新的 API之类的新东西都十分吸引人。

归功于苹果,我们常常会讨厌旧有的 API。这并不是什么见不得人的事情,相反当新东西出现的时候,学习它是一件很好的事情。

现在让我们谈谈重构 (22:26)

什么不是重构?:

  • 添加新功能
  • 改变某个类型的公共接口
  • 改变某个类型的行为

这些东西都只能叫做重写。有一个很简单的判断方式:你是否需要改变单元测试?如果需要的话,那么只就是重写,否则的话才是重构。

在我刚参加工作的时候读过一篇文章 Joel Spolsky 的 Things You Should Never Do, Part I,这篇文章给了我很大的影响。他写的是网景,和单一的视图控制器明显不同,这是关于一个应用的重构。如果你没办法重写应用的话,那么就应该考虑如何重写视图控制器了。

我们应当支持所谓的增量更新。将原有代码抛弃然后用全新的代码来替换是一项非常危险的举动,因为你会缺失该段代码如何与其余部分的代码交互的上下文。这就是为什么重写是一个很糟糕的举措,因为它会花费更多的时间,让你更容易犯错。

总而言之,我们不应当将原想的代码和想法抛除在外。

单元测试 & 思考 (25:30)

首先,我们先设定单元测试是一个非常棒的想法。测试本身并不重要,重要的是写测试的工程会强迫我思考,我的这段代码是不是最好的了?

测试的好处 (26:06)

单元测试的好处之一在于它限制了对象的范围(因此你需要考虑一下如何写才好)。如果你有一个复杂的对象,实现了诸多的功能,那么这将会非常难以测试,因此你就会去化整为零,将这个大对象分隔成一系列小对象,每个对象只做一件事情,并且也只做好一件事情。现在,你就会得到许多内聚对象(cohesive objects),你无需将这些对象互相关联。最好的方法就是控制公共接口,将其与依赖注入(dependency injection)相互关联。

依赖注入 (28:01)

依赖注入听起来就十分晦涩难懂,曾经我也很不想使用这玩意儿,因为它实现起来是在太复杂了。不过它能切实简化代码,如果我们想实现 ¢5 的话,给一个 ¢5 就可以。

基本原理在于你正在实现的功能不应该让它自己创建。让我们看一个例子:

这是一个很标准的 iOS 视图控制器。它拥有一个网络控制器,在 viewdidLoad 当中它从网络中检索事务。一旦检索完成,完成闭包就会被调启,然后将事务显示出来。

class ViewController: UIViewController {
    let networkController = NetworkController()

    func viewDidLoad() {
        super.viewDidLoad()
        networkController.fetchStuff {
            self.showStuff()
        }
    }
}

这个视图控制器看起来没有什么 错误 ,不过当我们应用上依赖注入之后会是什么样的呢?

class ViewController: UIViewController {
    var networkController: NetworkController?

    func viewDidLoad() {
        super.viewDidLoad()
        networkController?.fetchStuff {
            self.showStuff()
        }
    }
}

看见变化没有?我们不再创建那个网络控制器。相反,我们_使用_一个可空的网络控制器变量。这个类的其余部分基本没有什么变化,唯一一点就是调启该网络控制器的那行代码。

这样,我们将依赖别的东西为我们创建这个网络控制器,比如说应用的其余部分。可以在使用 prepareForSegue 跳转到此控制器的控制器当中为我们创建,也可以通过其中的一个引用来进行传递。依赖注入并不像你想象中那么难。

使用协议也一样可以。让我们看一下模拟的网络控制器协议:

protocol NetworkController {
    func fetchStuff(completion: () -> ())
}
...
class APINetworkController: NetworkController {
    func fetchStuff(completion: () -> ()) {
        // TODO: fetch stuff and call completion()
    }
}

这个协议中拥有一个函数:fetchStuff,它接收一个完成闭包,当结束匹配之后将会调用。此外,API 网络控制器将会实现这个协议,因此其中将会拥有 fetchStuff 函数。

protocol NetworkController {
    func fetchStuff(completion: () -> ())
}
...
class TestNetworkController: NetworkController {
    func fetchStuff(completion: () -> ()) {
        // TODO: stub fetched stuff
        completion()
    }
}

测试网络控制器同样也实现了这个协议,与从网络中匹配不同,它使用此前我们已经得到的嵌入数据,它将会立即调用我们的完成闭包。现在,我可以配置这个控制器,将视图控制器传递进去,让它做点事情,最后测试其行为是否正常。

不管这个协议做什么,我都将在后面对其单独进行测试。我让测试的网络控制器实现这个协议,然后对我已提前知道结果的东西进行测试,这时候我对这个视图控制器该如何响应的有了一个预期,接下来,就是测试我们的预期是否符合实际的时候了。

协议的使用可以帮助您限制对象的取值范围,让对象拥有一个明确的定义。向协议中添加何种方法才是最好的已经成为了一种选择。您无权访问类的函数列表,因此如果函数不再协议当中的话您将无权对其进行调用。如果您想要调用它,您就必须将这个函数写到协议当中。

共享的状态以及单例 (33:03)

假设我们有这样一个函数 loadAppSetup。如果我们此前没有启动过程序,那么就会给用户展示某些内容。它从 NSUserDefaults.standardUserDefaults() 中提取内容,如果没有提取到那么就调用需要展示的方法。

func loadAppSetup() {
    let defaults =
        NSUserDefaults.standardUserDefaults()
    if defaults.boolForKey("launchBefore") == false {
        runFirstLaunch()
    }
}

我们该如何对其进行测试呢?首先我们必须清空所有的默认设置,然后在特定的测试中建立这个函数,还得确保在测试完毕之后清空这些数据。依赖注入可以让我们向函数中注入默认设置。

func loadAppSetup(defaults: NSUserDefaults = .standardUserDefaults()) {
    if defaults.boolForKey("launchBefore") == false {
        runFirstLaunch()
    }
}

现在这个函数就支持依赖注入了。如果我没有给这个函数指定参数的话,它将会使用默认的 standardUserDefaults。在我的单元测试中,我可以创建自己的用户默认设置,然后将其注入到函数当中。

loadAppSetup() // 在应用当中
loadAppSetup(stubbedUserDefaults) // 在测试当中

这样我们就无需改变应用的剩余部分就可以在单元测试中使用特定的对象了。我们的测试变得更加简单,隔离更强,这样在测试进行操作便不会影响到应用的运行。

现在回到我们的视图控制器示例中来。使用同样的参数默认值方法,我可以将 networkController 设置为一个 APINetworkController 参数,在这里有一个非常重要的一点:我将其指定为需要实现某个协议。这样视图控制器仍然无权访问实际的 APINetworkController 值,它只能够访问这个协议。这样我们无需改变视图控制器中的其他值,现在就可以通过创建视图控制器然后向其中注入实现 NetworkController 协议的嵌入或者虚拟对象来进行测试了。只要我们在视图加载前完成注入,就可以测试视图控制器了。

class ViewController: UIViewController {
    lazy var networkController: NetworkController =
        APINetworkController()

    func viewDidLoad() {
        super.viewDidLoad()
        networkController.fetchStuff {
            self.showStuff()
        }
    }
}

回到单元测试 (36:16)

不要测试私有函数,同样,要学会开始将某些方法标记为私有方法。默认情况下函数是内部的,这意味着您可以在本项目中的任何地方调用它,这往往是应该避免的。如果有可能也要尽量避免重写,也避免对实现的方法进行测试。记住单元测试的目的是在于测试公共接口的行为。理想情况下,每一个对象都只有一个公共函数。上一周,Justin Searls 发布了一篇 关于使用Partial Mock的文章。Partial Mock(局部Mock测试) 是一种对真实对象和虚拟对象进行的测试。在我们的例子当中,由于我们使用了协议,因此我们无法使用这项测试技术。对于这个技术来说,我们需要使用子类,而我们仅仅只是重载了几个方法而已。

寻求抽象 (39:00)

让我们尝试点新东西。当你在尝试新东西发现其不能工作的时候,您仍需要掌握这样一个技巧。我鼓励大家尝试新事物。您需要随时编写单元测试,此外您最好不要到底层进行工作。

很多人总会说:我没有时间去尝试,我的时间很紧,开发周期很短,应用必须尽快放到 App Store 上面。因此没有时间来写单元测试,也没有时间探索新技术,没有任何时间尝试新事物。

但记住,当你尝试使用新技术以及单元测试之后,您会发现它们能让开发工作变得多么简单。当时间充裕的时候,你可以不停尝试新技术,当时间紧张时,你可以回滚到之前的版本然后迅速完成需求。这是学习的一种机会,我们需要好好利用学习的机会。

总结 (43:01)

iOS 社区不仅仅是一个社区,它还发展成了一个产业,它的历史都是在探索新技术中度过:使用那些又新又酷的技术,然后创造出一个令世人惊叹的应用。我们曾经在 Objective-C 中实现了不少,现在我们盯上了 Swift。是时候行动起来,探索新的解决方案了。学习并不是只有当 Xcode 处于测试版本的时候才进行。高效的单元测试可以让代码更容易逐步优化。最后,如果你不在底层工作的话,有一个最好的好处就是当你陷入困境的时候,你还有机会重新来过!

很高兴在此为大家分享我的经验和想法,谢谢大家!

问与答 (32:05)

问:如果我们不能传入对象的话,您如何向传递对象给视图控制器中的视图模型呢? Ash: 当我们说到个人偏好的时候,这个问题就变得很复杂。对我来说,我喜欢让在视图控制器中的一个懒加载闭包中创建视图模型。一旦测试开始运行,它就可以重���这个属性,这样就可以防止真的视图模型建立出来,让视图控制器使用单元测试中定义的测试视图模型。你可以通过视图控制器创建视图模型,也可以直接用另一个视图模型给这个模型赋值,这两种方式都是可以的,选择一个你最喜欢的方式就行。

问:如果你马上就要开始一个新的 iOS 项目,你更愿意用哪个语言进行开发,Swift 还是 Objective-C? Ash: 我选择 Objective-C 的唯一情况只可能是当团队中其他人不会 Swift ,并且产品开发周期短的时候。

问:方括号招你惹你了? Ash: 方括号并没有招惹我。我并不讨厌方括号或者分号。方括号可以让编程中的数据类型更为详细(比如说函数响应式编程)。

问:您说到您做了许多尝试,当然,其中某些尝试最终成为了产品的一部分,并且在此之后会有人要为此维护很长一段时间。如果每个人都在努力尝试新东西的话,我想构建产品的过程会十分“有趣”。 Ash: 没错,我觉得我应该规定如果某个尝试没法正常工作的话,不如将其抛弃,或者至少不要将其合并到主线上去。

问:我想,我们应该讨论一些关于“进阶技巧”之类的东西,而不是“让我们搞点奇怪的东西,然后看看会发生什么”。 Ash: 是的,我不太喜欢一遍一遍地做无用功。理想情况下,我们应该在 Playground 来进行技巧的演示,而不是在一个应用当中。但是我也只能在这里面进行!

问:您的一张幻灯片中提到:借助依赖注入,我可以为我的代码编写测试,并且我可以以更好的方式书写代码。但实际上您并没有改变任何代码,我对此有些失望,不知您的意下如何? Ash: 我没有改变代码的原因在于这整个类只有八行代码,这其中没有什么需要变化的东西。我在试图以一种简单的方式来展示依赖注入的思想,不过这确实不是一个好的例子。不过我的重心在于教导如何实现依赖注入,而不是如何借助单元测试编写更好的代码。对于特定情况下的视图控制器来说,我们并不必需要改变任何代码。在我们的例子中,这是一个网络控制器,只执行网络请求这么一件事。如果某个视图控制器只能够通过明确定义的协议来访问该网络控制器的话,那么这个控制器就会减少那些它不关注内部方法的调用。视图控制器和网络控制器之间是互相隔离的,除非我们通过特定的几种有限方式。这些视图控制器将完成网络控制器所不能做的工作,比如说添加子视图之类。视图控制器同样夜不能够解析 JSON。这两者拥有完全不同的理念,因此它们被放在不同的类中,限制它们之间的访问。我想或许在之后的演讲中,只有我切实谈论了更多关于单元测试的细节之后,才能把讲座命名为“合并最佳实践” 。

About the content

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

Ash Furrow

Ash Furrow is a Canadian iOS developer and author, currently working at Artsy. He has published four books, built multiple apps, and is a contributor to the open source community. He blogs about a range of topics, from interesting programming to explorations of analogue film photography.

4 design patterns for a RESTless mobile integration »

close