建立一个类型安全的 Swift 模型

UserProfile

每个用户都有 namefirstNamelastName 有些用户还有 emailphoneNumber 。模型可能会是这样:

1
2
3
4
5
6
struct UserProfile {
let name: String
let lastName: String
let phoneNumber: String
let email: String?
}

可能我们每天都会看到这样的模型,也习惯了这样的模型,但仔细思考一下。email 有没有把信息完整的表示出来呢?还有phoneNumber ? 他俩仅仅只是字符串吗?

如果他有在父类中没有的其他行为,则引入一个新的类型。 — Martin Fowler

make a type if it will have some special behavior in its operations that the base type doesn’t have. by Martin Fowler

在之后的代码里面,我们可能就会用到这两个属性。比如说打电话,或者是给用户发送邮件。但是从模型里面,我们只能推导出来这两个东西是 String? 当然,我们可以在每次使用的时候都做一次校验,或者在初始化方法里面校验,但是这都不是类型层面上的东。如果有其他人要用这段代码的时候,他们知道这两个东西需要做一次校验吗?加一段注释:// 这个属性需要做一次校验 是最有效的方法吗?又如果项目中还有一个类Contact 也需要用到 phoneNumber 呢?校验的代码又复制过去?

1
2
3
4
5
6
7
8
9
typealias Email = String
typealias PhoneNumber = String
struct UserProfile {
let name: String
let lastName: String
let phoneNumber: PhoneNumber?
let email: Email?
}

我们至少能用到 typealias 来让他们知道, 这些东西跟 String 还是有一点区别的。

不知道你有没有发现 TimeIntervalCLLocationDegrees 这个两个类型,实际上都是 Double 的类型别名。为什么要这么做呢?答案是: 为了上下文。当我们看到 CLLocationDegrees 的时候,我们就能知道这个值的范围在 [-180,180] 之间。如果这个值是不可能是 1000 的。同样,手机号也不可能是一个类似 “HelloWorld” 的字符串。

当我们使用 typealias 来声明自定义类型的时候,我们可以在今后的版本中轻易的给他添加更多的上下文,而不用去修改很多的代码。比如说 CGFloat ,在早期的 Swift 版本中,这个属性只是 Double 的一个类型别名,而现在他已经是一个完整的数据类型了。

现在我们来试试怎样把刚刚的类型别名改成一个完整的数据类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
struct Email {
let address: String
/** 或者还可以将邮箱地址分隔开成 domain 还有 localPart*/
// let domain: String
// let localPart: String
}
struct PhoneNumber {
let digist: String
/** 跟邮箱一样,手机号码可以分成 国家代码,区域代码,号码三个部分 */
// let countryCode: String?
// let areaCode: String
// let destination: String
}
struct UserProfile {
let name: String
let lastName: String
let phoneNumber: PhoneNumber?
let email: Email?
}

现在的代码就更能表示出 UserFrofile.email 具体是什么东西了,这样的代码甚至能够避免以后会出现的问题。

比如下面的问题。比如说我们并没有重写 UserPrifile ,现在我们需要用户的全名。

1
2
3
4
5
6
7
8
9
10
11
12
struct UserProfile {
let name: String
let lastName: String
let phoneNumber: String
let email: String?
}
extension UserProfile {
var fullName: String {
return name + " " + cellNumber
}
}

处于一些原因,我们错误的把 cellNumber 拼在了后面(正确的应该是 lastName )。编译器不会发现这其实是个错误,因为对编译器来说他们都是 String。这个问题有可能在运行时才会被发现出来,甚至是测试阶段。但是如果使用 PhoneNumber 这个类型的话,这个问题在编译期就能够被发现了。

User

随着 App 的发展,我们肯能会用到 OTP 作为 App 的登录方法。这时候可能还会引入一个新的类型 User

1
2
3
4
5
6
7
8
9
10
11
12
struct User {
let id: String
let isRegistered: Bool
let phoneNumber: String?
let profile: UserProfile?
}
struct UserProfile {
let name: String
let lastName: String
let email: String?
}

如果完全按照上文的方法,代码会变成这样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typealias UserID = String
struct PhoneNumber {
let digits: String
}
struct Email {
let address: String
}
struct User {
let id: UserID
let phoneNumber: PhoneNumber?
let isRegistered: Bool
let profile: UserProfile?
}
struct UserProfile {
let name: String
let lastName: String
let email: Email?
}

如果我们接着写下去,代码会变得很啰嗦。PhoneNumber 还有 Email 还有一些意义, 而 UserID 还有 UserProfileUser 之外没有任何意义。所以再把代码整理一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct User {
typealias ID = String
let id: ID
let phoneNumber: PhoneNumber?
let isRegistered: Bool
let profile: Profile?
struct Profile {
let name: String
let lastName: String
let email: Email?
}
}

如果要使用 UserProfile 的时候,我们可以使用 User.Profile 来替代。同样 UserID 也用 User.ID 替代。这样就更有意义了。

但是这样仍然还有问题。 对 profile 还有 isRegisterted 来说仍然还有可能会有问题。要知道,如果用户已经注册,就一定会有用户资料。反之,如果未注册,就肯定没有用户资料。但是从这个类的声明讲,这并没有体现出来这个逻辑。我们使用枚举和模式匹配来做这件事情:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct User {
typealias ID = String
let id: ID
let phoneNumber: PhoneNumber?
let status: Status
enum Status {
case notRegistered
case registered(profile: Profile)
}
struct Profile {
let name: String
let lastName: String
let email: Email?
}
}

现在我们就能确定只有当用户已注册的时候才会有 Profile 了。

是否使用枚举,完全取决于 App 的上下文。

如果我们有一个新属性 isEmailVerified 。用来表示 email 是否在服务端做过验证。这就不需要使用枚举,因为是否验证跟这个值是否存在没有关系。

JSON & Codable

首先我们需要知道 JSON 并不是类型安全的。他只支持基本数据类型,数组还有字典。

JSON 包含了很多的上下文,因为在 JSON 文件中, email 和 phoneNumber 都是字符串。我们应该知道如何去处理它。

下面是一个 User 的 JSON 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 未注册用户
"user": {
"id": "a_unique_id",
"phone_number": "+989354358291",
"is_registered": false
}
// 注册用户
"user": {
"id": "a_unique_id",
"phone_number": "+989354358291",
"is_registered": true,
"profile": {
"name": "Farzad",
"last_name": "Sharbafian",
"email": "farzad.shbfn@gmail.com",
"is_email_verified": true
}
}

首先我们来处理 PhoneNumberEmail。 Codable 有一个方法 singleValueContainer 用来表示 JOSN 的这部分数据就是它本身,我们并不需要为了它而做更多的事情。用这个方法可以讲 JSON 文件中的字符串直接转换成 Model。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct PhoneNumber: Codable {
let digits: String
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawDigits = try container.decode(String.self)
digits = rawDigits
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(digits)
}
}
struct Email: Codable {
let address: String
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let rawAddress = try container.decode(String.self)
address = rawAddress
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(address)
}
}

我们不需要为 Profile 实现 Codable 的方法,因为它所包含的所有类型都是 Codable 的,编译器会自动帮我们生成这部分代码。我们需要做的只是修改 CodingKey 因为 JSON 中的 key 跟我们需要的 key 不一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct User {
struct Profile: Codable {
let name: String
let lastName: String
let email: Email?
let isEmailVerified: Bool
private enum CodingKeys: String, CodingKey {
case name
case lastName = "last_name"
case email
case isEmailVerified = "is_email_verified"
}
}
}

但是对于 User,我们还有一些很复杂的事情要做,因为在 JONS 中没有 status 这个东西。在这部分,代码就有点丑了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
struct User: Codable {
typealias ID = String
let id: ID
let phoneNumber: PhoneNumber
let status: Status
enum Status {
case notRegistered
case registered(profile: Profile)
}
private enum CodingKeys: String, CodingKey {
case id
case phoneNumber = "phone_number"
case isRegistered = "is_registered"
case profile
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(ID.self, forKey: .id)
phoneNumber = try container.decode(PhoneNumber.self, forKey: .phoneNumber)
let isRegistered = try container.decode(Bool.self, forKey: .isRegistered)
status = isRegistered ? try .registered(profile: container.decode(Profile.self, forKey: .profile)) : .notRegistered
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(phoneNumber, forKey: .phoneNumber)
switch status {
case .notRegistered:
try container.encode(false, forKey: .isRegistered)
case .registered(let profile):
try container.encode(true, forKey: .isRegistered)
try container.encode(profile, forKey: .profile)
}
}
struct Profile: Codable {
let name: String
let lastName: String
let email: Email?
let isEmailVerified: Bool
private enum CodingKeys: String, CodingKey {
case name
case lastName = "last_name"
case email
case isEmailVerified = "is_email_verified"
}
}
}

现在,所有的模型都是类型安全的了,不需要知道什么额外的信息,所有的信息代码都能告诉我们了。

现在仍然能够像之前一样有 isRegisteredprofile 两个属性。这两个信息我们并不需要存起来,只需要看看 User 的实现,我们很容易就能够实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// MARK:- User Helper methods
extension User {
var isRegistered: Bool {
return status.isRegistered
}
var profile: Profile? {
return status.profile
}
}
// MARK:- User.Status helper methods
extension User.Status {
fileprivate var isRegistered: Bool {
switch self {
case .registered: return true
case .notRegistered: return false
}
}
fileprivate var profile: User.Profile? {
switch self {
case .registered(let profile): return profile
case .notRegistered: return nil
}
}
}

用户信息只是建立模型的一个非常基本的例子,这样来做,我们就可以省去将来可能会犯下的错误。

CepheusSun wechat
订阅我的公众号,每次更新我都不一定会告诉你!
坚持原创技术分享,您的支持将鼓励我继续创作!
0%