Tryswift andyy hope cover

Swift 的泛字符串类型 API 大讨论

随着 Swift 3 的发布,Swift 迎来了巨大的改进和更迭,我们看到 Objective-C 遗留给 Swift 的一些枷锁开始被打破。然而,很多这些改进仍然依赖于「泛字符串类型 (Stringly typed)」API,这些 API 很可能会在我们开发应用的时候让我们犯下许多错误。

在本次 try! Swift 的讲演当中,我们将探讨如何避免使用此种类型的 API,换而使用别的来替代,从而使我们的代码更具可读性、更安全、更能体现开发者的意图、更加 Swift 化。

何谓「泛字符串类型 API」?(0:18)

「Stringly typed」是一个与代码策略相关的 IT 俚语,是源于术语「强类型 (Strong Type)」而出现的一个调侃语,这是对滥用字符串类型的一个讽刺,特指在可以采取对程序员和重构更加友好的实现方式时,却不必要地使用了字符串类型(通俗而言,是一个贬义词)。

// 泛字符串类型
let image = UIImage(named: "PalletTown")
      
// 强类型
let tableView = UITableView(frame: CGRect(), style: .plain)

上述的这两个例子,第一个便是所谓的「泛字符串类型」。如果您使用过 UIImage 构造器,您会知道它需要接受一个 String 作为参数,而这个字符串对应了您资源文件夹当中的某个文件。而 UITableView 构造器则是使用了枚举作为参数;我们只能够实用 .plain 或者 .group 风格来完成初始化。

缺点 (0:51)

拼写错误

扪心自问,当我们离开了手中的设备、没有预编译器的时候,会不会写很多错别字呢?于我而言,我总是拼错单词 “notification”。

let notification = "userAlertNotificaton"

副作用

这里假设有一个 UIImage 构造器,但是我却把 “Charmander” 拼写错误了。这边会产生副作用 (side effect),因为这会导致构造器返回 nil

let image = UIImage(named: "Charmandr")

冲突

如果我们要使用 UserDefaults,然后我们应用的 A 部分将 isUserLoggedIn 设为了 false,而 B 部分则将 isUserLoggedIn 设为了 true。应用的这两个部分无法真正了解对方的作用,但是它们却同时将不同的值存储到了相同的键当中。这可能会导致意想不到的副作用。

let keyA = "isUserLoggedIn"
let keyB = "isUserLoggedIn"
keyA == keyB

运行时崩溃

在 iOS 5 之前的 TableView 当中,我们不必注册我们的类。但是当 iOS 5 推出之后,我们如果想显示 UITableViewCell 的话,我们必须注册这个单元格,然后给其赋一个重用标识符。之后我们到 UITableView 函数,便得到了 .dequeueReusableCell

在这里,我不小心拼错了单词 “CellIdentifier”,所以这会导致人人都不想见到的运行时崩溃发生。

tableView.register(UITableViewCell.self,
  forCellReuseIdentifier: "CellIdentifer")

...

let cell: UITableViewCell! = tableView
  .dequeueReusableCell(withIdentifier: "CelIdentifer")

Unicode 的副产物

如果您从未知源 (例如富文本编辑器或者 iMessage) 当中复制了字符串,您可能会将代码带入到您的源代码当中来。乍看起来这似乎没有什么问题,但是,Xcode 当中的 emoji 可能便无法显示出来。

这一个好处便是这个问题只会影响 Objective-C,Swift 预编译器会告知我们「嘿,这里有一个非法的字符,您需要修复它」。

无序

某些人认为「泛字符串类型」是完全无序的。这里有两种命名方式:首字母大写和驼峰式命名法,如果您和我一样有强迫症的话,这种情况很可能会让您抓狂。

  • 首字母大写
"IsUserLoggedIn"
  • 驼峰式命名法
"isUserLoggedIn"
  • 前缀

Receive news and updates from Realm straight to your inbox

有些时候我们会使用前缀,这当然很棒,因为这可以让我们为键值建立命名空间。由于前缀的存在,使得命名冲突不会发生。在后面的那个例子当中,我们甚至会使用包标识符来实现前缀。之所以这样做的原因往往都是为了避免与第三方框架发生冲突的情况出现。

"isUserLoggedIn"
"Account.isUserLoggedIn"
"com.andyyhope.Account.isUserLoggedIn"
  • 命名

这里我们有三种不同的方法来拼写键的名称:

"isUserBlocked"
"userBlocked"
"hasUserBeenBlocked"

我们很可能会混合各式各样的时态,这些混乱无处不在。UIImageUIKitFoundation 是导致这些问题产生的罪魁祸首。

UserDefaults (4:17)

来快速回顾一下,什么是 UserDefaults 呢?它用来存储少量需要进行持久化的信息。

我很喜欢将其用作应用偏好设置的存储位置。此外我有些时候也喜欢在里面存放一些东西,因为我不想为此而引入 Core Data。大家都知道我们也有些时候会将 UserDefaults 用作 Core Data 的替代品存在。这就是为什么我们会将 UserDefaults 称之为「快餐型的 Core Data」。我们能够无需引入性能损耗便能得到 Core Data 的大多数功能,不过不幸的是,它是「泛字符串类型」的。

首先,让我们来快速回顾一下 Swift 3 的演变:

实例

  • 原代码
NSUserDefaults.standardUserDefaults()
  • 现代码
UserDefaults.standard

UserDefaults 现在不再是 NSUserDefaults 了。此外,根据 Swift API 指南所述,实例现在只需要使用 strandard 便可以得到了。您会注意到它现在不再是一个函数,而是一个计算属性了。

Set API

  • 原代码
.setBool(true, forKey: "isUserLoggedIn")
.setInt(1, forKey: "pokeballCount")
.setObject(pikachu, forKey: "pikachu")
  • 现代码
.set(true, forKey: "isUserLoggedIn")
.set(1, forKey: "pokeballCount")
.set(pikachu, forKey: "pikachu")

我们不再去调用 setBoolsetInt 或者 setObject,现在我们只需要调用 set 即可。我们使用 Swift 的函数重载功能来实现此功能。您可以使用相同的函数名称、相同的参数数量,但是只要参数类型不同,那么编译器便可以根据您传递的参数来推断您打算调用的 API 是什么。这里我们传递了一个布尔值,因此它等价于去调用 setBool

Get API

  • 原代码
.boolForKey("isUserLoggedIn")
.integerForKey("pokeballCount")
.objectForKey("pikachu")
  • 现代码
.bool(forKey: "isUserLoggedIn")
.integer(forKey: "pokeballCount")
.object(forKey: "pikachu")

只要您遵循 Swift API 指南,那么会发现 Get API 几乎和此前是相同的。我们只需要从函数名称中删除第一个参数名称,然后在第一个参数当中显式声明即可。

同步

同步操作已经被弃用了。

  • 原代码
NSUserDefaults.standardUserDefaults().synchronize()
  • 现代码
// deprecated

UserDefaults 大改造 (6:49)

我们发现,UserDefaults 本质上就是一个「泛字符串类型」的 API。下面便是我们在 Swift 3 当中的使用日常。现在让我们来对其进行一番改造吧!

// Setter
UserDefaults.standard.set(true, forKey: "isUserLoggedIn")

// Getter
UserDefaults.standard.bool(forKey: "isUserLoggedIn")

首先,如果某个字符串重复出现了多次,那么就应该将其转换为常量。

UserDefaults.standard.set(true, forKey: "isUserLoggedIn")

UserDefaults.standard.bool(forKey: "isUserLoggedIn")

...

let isUserLoggedInKey = "isUserLoggedIn"

UserDefaults.standard.set(true, forKey: isUserLoggedInKey)
UserDefaults.standard.bool(forKey: isUserLoggedInKey)

这使得我们避免拼写错误的情况发生,这使得我们的代码更简洁、更易读。

我们从 Objective-C 引入的一个模式便是:我们喜欢将事物统一起来。

struct Constants {
	struct Keys {
		// 账户操作
		static let isUserLoggedIn = "isUserLoggedIn"

		// 入职操作
		...
	}
}

这里我们创建了一个结构体 Constants,然后里面又添加了一个名为 Keys 的子结构体。借此我们便能够对应用当中的所有常量有着完整的了解,并且可以帮助我们保持常量的一致性。

当我们将其应用到 UserDefaults API 当中后,它看起来便是这样的:

// Set
UserDefaults.standard
	.set(true, forKey: Constants.Keys.isUserLoggedIn)

// Get
UserDefaults.standard
	.bool(forKey: Constants.Keys.isUserLoggedIn)

我们为什么不再创建一个子结构体,名为 Account 呢?

struct Constants {
	struct Keys {
		struct Account {
			static let isUserLoggedIn = "isUserLoggedIn"
		}
	}
}

这里定义了静态常量,因为我们不希望每次使用这些键的时候,都要构造子结构体。现在它看起来是这样的:

// Set
UserDefaults.standard
	.set(true, forKey: Constants.Keys.Account.isUserLoggedIn)

// Get
UserDefaults.standard
	.bool(forKey: Constants.Keys.Account.isUserLoggedIn)

但是,这个子结构体仍然有问题。

Constants.swift

struct Account {

	static let isUserLoggedIn = "isUserLoggedIn"
}

再次强调,这种做法很容易出错。如果我拼错了 isUserLoggedIn,多加了个 N 什么的,然后将它应用到了生产环境当中,我会恨死我自己的。我所要给出的建议是改用枚举。

Constants.swift

enum Account : String {
	case isUserLoggedIn
}

为啥要使用枚举呢?这里您可以看到我们创建了一个 Account 枚举,它是一个 字符串原始值表示的枚举。这个时候我们不再使用静态常量,而是使用枚举值来替代。使用原始值表示的枚举的优点在于:如果我们没有提供枚举值,那么其值会与枚举的名字相同。当我们使用的时候,它看起来会是这个样子的:

struct Constants {
	struct Keys {
		enum Account : String {
			case isUserLoggedIn
		}
	}
}

因此代码看起来会好看不少。

// Set
UserDefaults.standard
	.set(true, forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)

// Get
UserDefaults.standard
	.bool(forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)

让我们来回顾一下。

// 字符串
let key = "isUserLoggedIn"

// 常量 & 大杂烩
let key = Constants.isUserLoggedIn

// 添加了上下文
let key = Constants.Keys.Account.isUserLoggedIn

// 更为安全
let key = Constants.Keys.Account.isUserLoggedIn.rawValue

一开始我们先是使用字符串,随后我们将这些键变为了常量并杂糅在了一起。之后我们通过添加路径为键增加了上下文,因此我们便可以依次声明常量→键→账户来使用 isUserLoggedIn 了。最后我们通过将结构体转变为枚举来提升安全性。

// Set
UserDefaults.standard
	.set(true, forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)

// Get
UserDefaults.standard
	.bool(forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)

扩展 UserDefaults (10:09)

接下来,我们准备将代码从这样:

// Set
UserDefaults.standard
	  .set(true, forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)

// Get
UserDefaults.standard
    .bool(forKey: Constants.Keys.Account.isUserLoggedIn.rawValue)

变成这样:

// Set
UserDefaults.standard.set(true, forKey: .isUserLoggedIn)

// Get
UserDefaults.standard.bool(forKey: .isUserLoggedIn)

如果您向 Swift 程序员询问如何解决这个问题的话,那么您会知道他们的答案便是「万事皆协议」。

协议 (11:03)

那么我们的协议该是什么样子的呢?我将其取名为 BoolDefaultSettable

protocol BoolDefaultSettable {
	associatedtype BoolKey : RawRepresentable
}

如您所见,这里面放置了一个名为 BoolKeyassociatedtype,而这个类型需要满足 RawRepresentable 协议。在我们详细了解它的代码实现之前,让我们先回忆一下 WWDC 2015 上的内容,那个时候介绍了一位名人:Crusty。

译者注:可以前往观看 WWDC 2015 的 Protocol-Oriented Programming in Swift 一节。

Crusty 是一位守旧的开发者,他不信任 IDE、调试器,从不追逐编程界的潮流。他是一个愤世嫉俗、脾气暴躁的人。他非常埋怨所谓的面向对象编程,因此非常怀念旧式 C 编译器还存在的日子。

您或许已经认识了 Crusty,但是您很可能不知道所谓的「Crusty 第三定律」:「对于每个协议而言,都有着一个对应且相等的协议扩展」。基于 Crusty 第三定律,我们便可以得出我们的协议扩展。

译者注:经过搜索发现,所谓的「三大定律」,应该是 Andyy Hope 作者本人提出的。译者经过推测认为,这三大定律应该是针对 Crusty 提出的类的三种缺点所做出的解决方案。这三种缺点是:

  • 隐式共享 (Implicit Sharing)
  • 业务中无止境的继承
  • 类型关系丢失
protocol BoolDefaultSettable {
	associatedtype BoolKey : RawRepresentable
}

extension BoolDefaultSettable where BoolKey.RawValue == String {
}

如您所见,这里我们添加了一个 where 从句。现在我们便可以开始进行配置了:

extension BoolDefaultSettable where BoolKey.RawValue == String {

	// Setter
	func set(_ value: Bool, forKey key: BoolKey) {
		let key = key.rawValue
		UserDefaults.standard.set(value, forKey: key)
	}
}

我们只要提取与 UserDefaults 相同的 API 即可。

extension BoolDefaultSettable where BoolKey.RawValue == String {

	// Getter
	func bool(forKey key: BoolKey) -> Bool {
		let key = key.rawValue
		return UserDefaults.standard.bool(forKey: key)
	}
}

总而言之,我们的协议和协议扩展看起来如下所示:

protocol BoolDefaultSettable {
	associatedtype BoolKey : RawRepresentable
}

extension BoolDefaultSettable where BoolKey.RawValue == String {

	func set(_ value: Bool, forKey key: BoolKey) {
		let key = key.rawValue
		UserDefaults.standard.set(value, forKey: key)
	}

	func bool(forKey key: BoolKey) -> Bool {
		let key = key.rawValue
		return UserDefaults.standard.bool(forKey: key)
	}
}

这里我们声明了 BoolKey,您还可以完成余下的默认配置,例如 IntDoubleFloatObjectURL 等等。

protocol IntegerDefaultSettable { ... }

protocol DoubleDefaultSettable { ... }

protocol FloatDefaultSettable { ... }

protocol ObjectDefaultSettable { ... }

protocol URLDefaultSettable { ... }

现在我们便可以对 UserDefaults 进行扩展了:

extension UserDefaults : BoolDefaultSettable {
	enum BoolKey : String {
		case isUserLoggedIn
	}
}

UserDefaults 如今将扩展实现 BoolDefaultSettable 协议。为了实现这个协议,我们必须要在其中包含我们的关联类型。我们这里使用的例子是 BoolKey,这是一个 enum 类型,它可以用字符串原始值类型表示,然后我们的第一个枚举值就是之前的 isUserLoggedIn。随后我们使用起来便是这样子的:

extension UserDefaults : BoolDefaultSettable {
	enum BoolKey : String {
		case isUserLoggedIn
	}
}

...

UserDefaults.standard.set(true, forKey: .isUserLoggedIn)
UserDefaults.standard.bool(forKey: .isUserLoggedIn)

看起来很不错,对吧?只是因为我们创建了协议并不意味着我们就局限在 UserDefaults 当中了。如果您想要添加额外的上下文的话,我们还可以对其他对象进行扩展。

extension Account : BoolDefaultSettable {
	enum BoolKey : String {
		case isUserLoggedIn
	}
}

...

Account.set(true, forKey: .isUserLoggedIn)
Account.bool(forKey: .isUserLoggedIn)

这里是对 Account 进行扩展。然而,现在我们就遇到了冲突问题,对吧?

Account.BoolKey.isUserLoggedIn.rawValue
// key: "isUserLoggedIn"

UserDefaults.BoolKey.isUserLoggedIn.rawValue
// key: "isUserLoggedIn"

Account.BoolKey.isUserLoggedIn 的作用与 UserDefaults.BoolKey.isUserLoggedIn 相同,因此我们需要再创建另一个协议。

protocol KeyNamespaceable {
	func namespaced<T: RawRepresentable>(_ key: T) -> String
}

这是一个很简单的函数。它接受一个满足 RawRepresentable 协议的泛型作为参数,然后返回一个字符串。再次声明一遍,我们要遵循 Crusty 第三定律,然后对其进行扩展。

protocol KeyNamespaceable {
	func namespaced<T: RawRepresentable>(_ key: T) -> String
}

extension KeyNamespaceable {

	func namespaced<T: RawRepresentable>(_ key: T) -> String {
		return "\(Self.self).\(key.rawValue)"
	}
}

我们只是简单地使用字符串插值来返回一个字符串。我们将 selfrawValue 的字符串值之间使用 . 相互连接了起来。在本例当中,我们将会得到 UserDefaults.isUserLoggedIn

protocol KeyNamespaceable {
	func namespaced<T: RawRepresentable>(_ key: T) -> String
}

extension KeyNamespaceable {
	func namespaced<T: RawRepresentable>(_ key: T) -> String {
		return "\(Self.self).\(key.rawValue)"
	}
}

// key: "UserDefaults.isUserLoggedIn"

现在我们回到 BoolDefaultSettable 协议当中,然后让其实现 KeyNamespaceable 协议。

protocol BoolDefaultSettable : KeyNamespaceable {
	associatedtype BoolKey : RawRepresentable
}

回到我们的 settergetter 方法当中。由于 BoolDefaultSettable 实现了 KeyNamespaceable 协议,因此我们便可以使用 let key = namespaced(key) 了。

extension BoolDefaultSettable where BoolKey.RawValue == String {

	func set(_ value: Bool, forKey key: BoolKey) {
		let key = namespaced(key)
		UserDefaults.standard.set(value, forKey: key)
	}

	func bool(forKey key: BoolKey) -> Bool {
		let key = namespaced(key)
		return UserDefaults.standard.bool(forKey: key)
	}
}

现在我们成功地完全避免了冲突

UserDefaults.set(true, forKey: .isUserLoggedIn)
// key: "UserDefaults.isUserLoggedIn"

Account.set(true, forKey: .isUserLoggedIn)
// key: "Account.isUserLoggedIn"

匀称性与上下文 (14:50)

Swift 当中很有意思的一点特性便是:当我们扩展一个类或者对象时,这个扩展无需在同个类或者文件当中进行声明。因此让我们回到 Constants.swift 文件当中,我们可以将这些扩展从 Constants.swift 文件当中提取出来,放到 Constants 结构体之外。

extension Account : BoolDefaultSettable { ... }

extension Onboarding : BoolDefaultSettable { ... }

struct Constants { ... }

...

Account.set(true, forKey: .isUserLoggedIn)
Account.bool(forKey: .isUserLoggedIn)

借此我们便可以对一切了如指掌。但是上下文信息怎么办呢?我们不再使用 UserDefaults 来直接设置 isUserLoggedIn 的值,而是使用 Account 来完成,这使得我们能够获得更多的上下文信息。我们不应该坚持常量模式,而是应该配置一个默认值。

struct Defaults {
	struct Account : BoolDefaultSettable { ... }
	struct Onboarding : BoolDefaultSettable { ... }
}

...

Defaults.Account.set(true, forKey: .isUserLoggedIn)
Defaults.Onboarding.set(true, forKey: .isUserOnboarded)

Defaults.Account.bool(forKey: .isUserLoggedIn)
Defaults.Onboarding.bool(forKey: .isUserOnboarded)

现在与此前相比,读起来更容易多了。

结论 (16:23)

泛字符串类型 API 是非常糟糕的。您应该尽力去避免使用它们。

我们的代码仍然存在很多改进的余地。我们应该始终挑战常规,去探索我们能做什么、不能做什么。

对常量进行分组通常是没有必要的,但是我很喜欢这样做,因为这让我能够清楚地意识到我在做什么,从而保证统一性。

对我们的 API 添加命名空间可以让我们了解更多的上下文信息,字符串和键值也是如此。这还可以避免碰撞的情况发生。

目前协议的概念非常热门。

最后,我们应该重新思考该如何才能更好地使用 API。虽然这些 API 基本是由 Apple 为我们提供的,但是这不意味着我们必须要遵循他们所定义的使用方式。Apple 之所以为我们提供泛字符串类型的 API,是因为这样才能完成所有的案例覆盖,我们才能够实现所有想实现的功能。Apple 是无法为我们提供枚举值的,这和 TableView 大大不同,Apple 并不能预知我们要用这些功能做些什么。

这就好像我们刚刚买了一套二手房,我们搬了进来,但是我们并不想直接使用那些旧家具,我们需要对这些家具进行改造,从而满足自己的需要,必要的时候还会自配家具。对吧?

资源

About the content

This talk was delivered live in September 2016 at try! Swift NYC. The video was recorded, produced, and transcribed by Realm, and is published here with the permission of the conference organizers.

Andyy Hope

Andyy is the lead iOS Engineer for Punters in Melbourne, Australia. He’s constantly studying the language and finding new ways to challenge the status quo. You can read more of his work on Medium or follow him on Twitter @andyyhope.

He’s also the proud organiser of the Playgrounds conference in Melbourne! Check them out on Twitter @playgroundscon.

4 design patterns for a RESTless mobile integration »

close