Swifter 读书笔记(二)

下标

我们通过下标访问字典得到的结果是一个 Optional 的值,由于无法限制下标访问的输入值,对于数组来说,越界了会崩掉,但是对于字典,查询不到是很正常的,在 Swift 中,就会返回 nil 告诉你没有要找的东西。

Swift 允许我们自定义下标,不仅包含了对自己写的类型进行下标自定义,也包括了对那些已经支持下标访问的类型进行扩展。在 Swift 的定义文件中,找到 Array 已经支持的下标访问类型:

subscript (index: Int) -> T
subscript (subRange: Range) -> Slice

共有两种,分别是接受单个 Int 类型和一个表示范围的 range

这样其实就会有一个问题,我们很难一次性取出某几个特定位置的元素

其中一种做法就是接受数组为下标输入的读取方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
extension Array {
subscript(input: [Int]) -> ArraySlice<Element> {
get {
var result = ArraySlice<Element>()
for i in input {
assert(i < self.count, "Index out of range")
result.append(self[i])
}
return result
}
set {
for (index, i) in input.enumerate() {
assert(i < self.count, "Index out of range")
self[i] = newValue[index]
}
}
}
}

var arr = [1, 2, 3, 4, 5]
arr[[0, 2, 3]]

arr[[0, 2, 3]] = [-1, -3, -4]
arr

练习:虽然我们实现了下标为数组的版本,但是并不推荐使用这样的形式,如果阅读过 参数列表 一节的堵着也许会想为什么在这里我们不使用看起来更优雅的参数列表方式,就是 subscript(input: Int…),存在一个问题,只有一个输入参数的时候,参数列表会导致和现有定义冲突,当然我们完全可以使用至少两个参数的参数列表形式避免这个冲突,定义形如: subcript(first: Int, second: Int, others: Int)的下标方法。

方法嵌套

方法终于成了 First-class citizen 也就是说,我们可以将方法当做变量或者参数使用了。更进一步,我们甚至可以在一个方法中定义新的方法,这给代码结构层次和访问级别的控制带来的心的选择。

在开发中,有很多情况下,由于一个方法主体太长,需要拆分成一个个小方法去调用,这些具体负责一个个小功能块的方法也许整个项目就调用这么一次,却不得不存在于整个类型的作用域中。虽然会标记为私有方法,但是事实上它们承担的任务往往和这个类型没有关系,只会在类型的某个方法中被用到。

甚至这些小方法也有可能很复杂,我们还想进一步将它分解成更小的某块,这样一来,本来应该是进深的结构,却被展平了。导致之后对代码的理解和维护上都很成问题。

在 Swift 中,我们对于这种情况有了很好的应对。我们可以在方法中定义其他方法。也就是说让方法嵌套起来。

举个例子,我们在写一个网络请求的类 Request 时,可能面临将请求的参数编码到 url 的任务,因为输入的参数可能包括单个的值,字典,数组,为了结构漂亮保持方法短小,我们可能将情况分开,写出这样的代码:

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
func appendQuery(var url: String, key: String, value: AnyObject) -> String {

func appendQueryDictionary(var url: String, key: String, value: [String: AnyObject]) -> String {
// ...
return result
}

func appendQueryArray(var url: String, key: String, value: [AnyObject]) -> String {
// ...
return result
}

func appendQuerySingle(var url: String, key: String, value: AnyObject) -> String {
// ...
return result
}

if let dictionary = value as? [String: AnyObject] {
return appendQueryDictionary(url, key: key, value: dictionary)
} else if let array = value as? [AnyObject] {
return appendQueryArray(url, key: key, value: array)
} else {
return appendQuerySingle(url, key: key, value: value)
}

}

事实上,前面三个方法都只会第一个方法中被调用,他们其实和 Request 没有直接的关系,所有将他们放到 appendQuery 中去会是一个更好的组织形式

虽然 Swift 提供了 public,internal,private 三种访问权限,有些方法我们完全不希望在其他地方被直接使用,最常见的就是在方法的模板中:我们一方面希望灵活地提供一个模板让使用者可以通过模板定制他们想要的方法,但是又不希望暴露太多实现细节,一个最简单的例子:

1
2
3
4
5
6
func makeIncrementor(addNumber: Int) -> ((inout Int) -> Void {
func incrementor(inout variable: Int) -> Void {
variable += addNumber;
}
return incrementor;
}

命名空间

OC 一直以来的缺陷就是没有命名空间,在应用开发时,所有的代码和引用的静态库最终都会被编译到同一个域和二进制中。这样的后果是,一旦有重复的类名,就会导致编译失败和冲突。一般 OC 类型都会加上两到3个字母前缀,Apple 保留的 NS 和 UI,框架前缀等。

OC 社区大部分开发者也遵守了这个约定。一般会将自己名字的缩写作为前缀。但是前缀并不意味着不会冲突。

Swift 中,即使名字相同的类型,只要来自不同的命名空间,都是可以共存。Swift 的命名空间是基于 module 而不是在代码中显式指明,每个 module 代表 Swift 中的一个命名空间。同一个 target 里的类名还是不能相同,

在进行 app 开发时,默认添加到 app 的主 target 的内容都是处于同一个命名空间的,我们可以通过创建Cocoa Framework 的 target 方法新建一个 module,就可以在两个不同的 target 中添加相同的类型了

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// MyFramework.swift

// 这个文件存在于 MyFramework.framework 中
public class MyClass {
public class func hello() {
print("hello from framework")
}
}


// MyApp.swift

// 这个文件存在于 app 的主 target 中

class MyClass {
class func hello() {
print("hello from app")
}

}

使用时,如果出现可能冲突的时候,需要在类型名前加上 module 名字:

1
2
3
4
5
MyClass.hello()
// hello from app

MyFramework.MyClass.hello()
// hello from framework

因为是在 app 的 target 中调用的,所以第一个 MyClass 会直接使用 app 中的版本,第二个调用我们指定了 MyFramework 中的版本

另一种策略是使用类型嵌套的方法指定访问的范围。常见做法是将名字重复的类型定义到不同的 struct 中,以此避免冲突。这样在不使用多个 module 的情况下也能取得隔离效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct MyClassContainer1 {
class MyClass {
class func hello() {
print("hello from MyClassContainer1")
}
}
}


struct MyClassContainer2 {
class MyClass {
class func hello() {
print("hello from MyClassContainer2")
}
}
}

// 使用时:

MyClassContainer1.MyClass.hello()
MyClassContainer2.MyClass.hello()

Any 和 AnyObject

AnyObject 可以代表任何 class 类型的实例
Any 可以表示任意类型,甚至包括方法(func)类型

OC 中有一个 id 的概念,编译器不会对向声明为 id 的变量进行类型检查,它可以表示任意类的实例。

Swift 依然使用 Cocoa 框架进行 App 开发,为了与 Cocoa 架构协作,原来 id 的概念用 AnyObject 来进行替代。

Swift 中编译器不仅不对 AnyObject 实例的方法调用做出检查,甚至对 AnyObject 的所有方法调用都会返回 Optional 的结果。这在 OC 下是很正常的,返回 nil

在 Swift 下使用会比较麻烦。正确的做法是在使用时先确定 AnyObject 真正的类型并进行转换以后再进行调用。

原来 OC 中的某个 API 返回的是 id 的话,在 Swift 中都会被映射为 AnyObject?。我们依然最好这样写:

1
2
3
4
5
6
7
8
9
10
func someMethod() -> AnyObject? {
return result
}

let anyObject: AnyObject? = SomeClass.someMethod()
if let someInstance = anyObject as? SomeRealClass {

// 这里我们拿到了具体的 SomeRealClass 的实例
someInstance.funcOfSomeRealClass()
}

protocol AnyObject {

}

所有的 class 都隐式地实现了这个借口,但是 AnyObject 只适用于 class 的原因。在 Swift 中,所有的基本类型,包括 Array 和 Dictionary 这些传统意义上会是 class 的东西,统统都是 struct 类型,并不能由 AnyObject 来表示,于是 Apple 提出一个 Any 的概念,除了 class,它还可以表示包括 struct 和 enum 在内的所有类型。

1
2
3
4
5
6
7
8
9
10
import UIKit

let swiftInt: Int = 1
let swiftString: String = "miao"

var array: [AnyObject] = []

array.append(swiftInt)

array.append(swiftString)

这里声明了一个 Int 和 String ,按理它们都应该只能被 Any 代表,而不能被 AnyObjcet 代表,但是这段代码运行时会通过编译。

此时如果我们打印一下 array,会发现里面的元素已经变成了 NSNumber 和 NSString 了,这里发生了一个自动转换。Swift 和 Cocoa 中这几个对应的类型是可以进行自动转换。因为我们显示声明了 AnyObject,编译器认为我们需要的是 Cocoa 类型,而非原生类型。帮我们进行了自动转换。

在上面的代码,如果去掉 import UIKit 就会编译错误了,而如果将类型变为 Any,就一切正确了。
这里需要说明的:如果只使用 Swift 类型,不转为 Cocoa 的话,对性能是有提升的。所以我们尽量使用原生类型。

如果我们代码里需要经常使用这两者。往往意味着代码在结构和设计上存在问题。最好避免依赖和使用这两者,尝试明确指出确定的类型。

typealias 和泛型接口

typealias 是用来为已经存在的类型重新定义名字,通过命名可以让代码变得更加清晰。

1
2
3
4
5
6
7
8
9
10
func distanceBetweenPoint(point: CGPoint, toPoint: CGPoint) -> Double {
let dx = Double(toPoint.x - point.x)
let dy = Double(toPoint.y - point.y)
return sqrt(dx * dx + dy * dy)
}

let origin: CGPoint = CGPoint(x: 0, y: 0)
let point: CGPoint = CGPoint(x: 1, y: 1)

let distance: Double = distanceBetweenPoint(origin, toPoint: point)

数学上和运行程序上都没有问题,但是阅读和维护,我们没有将数学抽象和实际问题结合起来。所以在阅读代码时,我们需要在大脑中额外进行转换:CGPoint 代表一个点,Double 是一个数字,代表两点之间的距离。

如果我们使用 typealias,就可以将这种转换直接写在代码里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typealias Location = CGPoint
typealias Distance = Double

func distanceBetweenPoint(location: Location, toLocation: Location) -> Distance {
let dx = Distance(location.x - toLocation.x)
let dy = Distance(location.y - toLocation.y)
return sqrt(dx * dx + dy * dy)
}


let origin: Location = Location(x: 0, y: 0)
let point: Location = Location(x: 1, y: 1)

let distance: Distance = distanceBetweenPoint(origin, toLocation: point)

对于普通类型,没有什么难点,但是涉及到泛型时,情况就会不太一样。typealias 是单一的,你必须指定将某个特定的类型通过 typealias 赋值为新的名字,而不能将整个泛型类型重命名,比如:

1
2
3
4
class Person<T> {}
typealias Worker = Person
typealias Worker = Person<T>
typealias Worker<T> = PerSon<T>

但是一旦泛型类型确性得到暴涨后,就可以重命名了

1
2
3
class Person<T> {}
typealias WorkId = String
typealias Worker = Person<WorkId>

Swift 中是没有泛型接口的,但是使用 typealias 我们可以在接口里自定义一个必须实现的别名,比如 GeneratorType 和 SequenceType 这两个接口中,Swift 都用到了这个技巧,来为接口确定一个使用的类似泛型的特性:

1
2
3
4
5
6
7
8
9
protocol GeneratorType {
typealias Element
mutating func next() -> Self.Element?
}

protocol SequenceType {
typealias Generator: GeneratorType
func generate() -> Self.Generator
}

在实现这些接口时,我们不仅需要实现指定的方法,还要实现对应的 typealias,这时对接口适用范围的抽象和约束。

可变参数函数

可变参数,是指可以接受任意多个参数的函数。在 Swift 中,写一个可变参数的函数,只需要在声明参数时在类型后面加上…就可以了。下面就声明了一个接受可变参数的 Int 累加函数:

1
2
3
func sum(input: Int...) -> Int {

}

输入的 input 在函数体内被作为数组[Int]使用。

1
2
3
4
func sum(input: Int...) -> Int {
return input.reduce(0, combine: +)
}
print(sum(1,2,3,4,5))

在使用可变参数时,要注意的是可变参数只能作为方法中最后一个参数来使用,不能先声明一个可变参数,然后再声明其他参数。这很容易理解,因为编译器不知道输入的参数应该从哪里截取。在一个方法中,最多只能有一组可变参数的。

但是参数必须是同一种类型,要传入多个类型的参数时需要做一些变通。Swift 中一种解决方案是使用 Any 作为参数类型

1
2
3
4
5
6
7
8
extension NSString {
convenience init(format: NSString, _ args: CVarArgType...)
//...
}

let name = "Tom"
let date = NSDate()
let string = NSString(format: "Hello %@. Date: %@", name, date)

初始化方法顺序

与 OC 不同,Swift 的初始化方法需要保证类型的所有属性都被初始化,所以初始化方法的调用顺序就很有讲究,在某个类的子类中,初始化方法里的语句调用顺序并不是随意的,要保证当前子类实例的成员初始化完成后才能调用父类的初始化方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Cat {
var name: String
init() {
name = "cat"
}

}


class Tiger: Cat {
let power: Int
override init() {
power = 10
super.init()
name = "tiger"
}
}

一般来说,子类的初始化顺序是:

  1. 设置子类自己需要初始化的参数,power = 10
  2. 调用父类的响应的初始化方法,super.init()
  3. 对父类中的需要改变的成员进行设定,name = “tiger”

第三步视情况而定,如果我们在子类中不需要对父类的成员作出改变,就不存在第三步。
这种情况,Swift 会自动的对父类的对应 init 方法进行调用,也就是说,第2步的 super.init()也是可以不用写,这种情况下初始化方法看起来就很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Cat {
var name: String
init() {
name = "cat"
}

}


class Tiger: Cat {
let power: Int
override init() {
power = 10
// 如果我们不需要改变 name 的话
// 虽然我们没有显式地对 super.init()进行调用
// 不过由于这是初始化的最后了,Swift 替我们自动完成了
}
}

Designated, Convenience 和 Required

在 OC 中,init 方法是非常不安全的,没人能保证 init 只被调用一次,如果在初始化里使用属性设置的话,可能会造成各种问题。不应该在 init 中使用属性访问。但这不是编译器强制的。

Swift 有了超级严格的初始化方法,就是为了改进这一点,安全,Swift 有了超级严格的初始化方法,Swift 强化了 designated 初始化方法的地位。 Swift 初始化方式,保证了初始化后,所有成员变量均有初始值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class ClassA {
let numA: Int
init(num: Int) {
numA = num
}
}

class ClassB: ClassA {
let numB: Int

override init(num: Int) {
numB = num + 1
super.init(num: num)
}
}

在 init 方法中,我们可以对 let 的实例常量进行赋值,这是初始化方法的重要特点。在 Swift 中,let 声明的值是不变量,无法被写入赋值,这对构建线程安全的 API 十分有用。而因为 Swift 的 init 只能被调用一次,因此在 init 中,我们可以为不变量进行辅助不会引起任何线程安全问题。

convenience 关键字的初始化方法,这类方法是 Swift 初始化方法中的”二等公民”,只作为补充和提供使用上的方便。所有 convenience 初始化方法都必须调用同一个类中的 designated 初始化设置。convenience 的初始化方法是不能被子类重写或者是从子类中以 super 的方式被调用的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class ClassA {
let numA: Int
init(num: Int) {
numA = num
}

convenience init(bigNum: Bool) {
self.init(num: bigNum ? 10000 : 1)
}
}

class ClassB: ClassA {
let numB: Int

override init(num: Int) {
numB = num + 1
super.init(num: num)
}
}

只需要在子类中实现重写父类的 convenience 方法所需的 init 方法,在子类中就可以使用父类的 convenience 初始化方法了。

在上面的代码中,我们在 ClassB 中实现了 init(num: Int)的重写。虽然没有实现 convenience 仍然可以用这个方法来完成子类初始化

1
2
let anObj = ClassB(bigNum: true)
// anObj.numA = 10000, anObj.numB = 10001

系统会自动调用子类的初始化构造方法。

总结:

  1. 初始化路径必须保证对象完全初始化,这可以通过调用本类型的 designated 初始化方法来得到保证
  2. 子类的 designated 初始化方法必须调用父类的 designated 方法,以保证父类也完成初始化。

对于某些我们希望子类中一定实现的 designated 初始化方法,可以通过添加 required 关键字进行限制,强制子类对这个方法重写实现。

这样做最大的好处是,可以保证依赖于某个 designated 初始化方法的 convenience 一直可以被使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class ClassA {
let numA: Int
required init(num: Int) {
numA = num
}

convenience init(bigNum: Bool) {
self.init(num: bigNum ? 10000: 1)
}
}


class ClassB: ClassA {
let numB: Int

required init(num: Int) {
numB = num + 1
super.init(num: num)
}
}

let b = ClassB(bigNum: true)
print(b.numB) // 10001

protocol 组合

Any 这个类型的定义十分奇怪,它是一个 protocol<>的同名类型

一般来说,标准写法是:protocol<ProtocolA, ProtocolB, ProtocolC>

但是 protocol<>是什么意思的?从语义上来说,这代表一个“需要实现空接口的接口” ,其实就是任意类型的意思

protocol 的组合相比新建一个接口的最大区别就是匿名性。

有时候可以借助这个特性写出更清晰的代码。

Swift 的类型组织是比较松散的,你的类型可以由不同的 extension 来定义实现不同的接口,Swift 也并没有要求他们在同一个文件中。这样,当一个类型实现了很多接口时,在使用该类我们很可能在不查询相关代码的情况下很难知道这个类型所实现的接口。

例子:

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
protocol KittenLike {
func meow() -> String
}

protocol DogLike {
func bark() -> String
}

protocol TigerLike {
func aou() -> String
}

class MysteryAnimal: KittenLike, DogLike, TigerLike {
func meow() -> String {
return "meow"
}

func bark() -> String {
return "bark"
}

func aou() -> String {
return "aou"
}
}

现在如果重新定义一个叫做 PetLike 的接口,表明其实现 KittenLike 和 DogLike;如果稍后我们又想检查某种动物作为猫科动物的叫声的话,也许会创建新的 protocol

这时候一种比较好的写法是:

1
2
typealias PetLike = protocol<KittenLike, DogLike>
typealias CatLike = protocol<KittenLike, TigerLike>

这样既保持了可读性,也没有多定义不必要的新类型。
也可以直接匿名化:

1
2
3
4
5
6
7
8
9
struct SoundChecker {
static func checkPetTalking(pet: protocol<KittenLike, DogLike>) {
//...
}

static func checkCatTalking(cat: protocol<KittenLike, TigerLike>) {
//...
}
}

需要注意的地方:

在 Swift 中,没有人限制或保证不同接口的方法不能重名,所以有可能出现的情况,比如 A 和 B 两个接口:

1
2
3
4
5
6
7
protocol A {
func bar() -> Int
}

protocol B {
func bar() -> String
}

两个接口中,bar()只有返回值的类型不同。我们如果有一个类型 Class 同时实现了 A 和 B,我们要怎么才能避免和解决调用冲突呢

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
class Class: A, B {
func bar() -> Int {
return 1
}

func bar() -> String {
return "Hi"
}
}


protocol A {
func bar() -> Int
}

protocol B {
func bar() -> String
}
两个接口中 bar() 只有返回值的类型不同。我们如果有一个类型 Class 同时实现了 AB,我们要怎么才能避免和解决调用冲突呢?

class Class: A, B {
func bar() -> Int {
return 1
}

func bar() -> String {
return "Hi"
}
}

let instance = Class()
let num = (instance as A).bar() // 1
let str = (instance as B).bar() // "Hi

这样一来,对于 bar(),只要在调用前进行类型转换就可以了

正则表达式

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
// 封装:

// 正则工具类
class RegexTool: NSObject {

// 单粒
private static let instance = RegexTool()

class func sharedInstance() -> RegexTool {
return instance
}

// 根据正则 匹配字符串
func isMatch(string: String, withRegexString regexString: String) throws -> Bool {
let regex = try NSRegularExpression(pattern: regexString, options: .CaseInsensitive)
let matches = regex.matchesInString(string, options: [], range: NSMakeRange(0, string.characters.count))

return matches.count > 0
}

}


// 操作符封装
infix operator =~ {
associativity none
precedence 130
}

func =~(lhs: String, rhs: String) -> Bool {
let regexToolInstance = RegexTool.sharedInstance()
do {
return try regexToolInstance.isMatch(lhs, withRegexString: rhs)
} catch _ {
return false
}
}

// 使用

let mailPattern = "^([a-z0-9_\\.-]+)@([\\da-z\\.-]+)\\.([a-z\\.]{2,6})$"
let maybeMailAddress = "onev@onevcat.com"

let regexToolInstance = RegexTool.sharedInstance()

do {
try regexToolInstance.isMatch(maybeMailAddress, withRegexString: mailPattern)
} catch {
print("CaseInsensitive")
}

// 或者如下使用

if maybeMailAddress =~ mailPattern {
print("有效的地址")
}

模式匹配

虽然 Swift 中没有内置的正则表达式支持,但是和正则匹配有些相似的特性其实是内置于 Swift 中的,匹配模式。

相等匹配和范围匹配,在 Swift 里现在的模式匹配还很初级,使用~=来表示模式匹配操作符。看看 API:

1
2
3
4
func ~=<T: Equatable>(a: T, b: T) -> Bool 
func ~=<T>(lhs: _OptionalNilComparisonType, rhs: T?) -> Bool

func ~=<I : IntervalType>(pattern: I, value: I.Bound) -> Bool

从上至下载操作符左右两边分别接收可以判等的类型,可以与 nil 比较的类型,以及一个范围输入和某个特定值

Switch

  1. 可以判等的类型的判断
  2. 对 Optional 的判断
  3. 对范围的判断

Swift 的 switch 就是使用了~=操作符进行模式匹配,case 指定的模式作为左参数输入,等待匹配的被 switch 的元素作为操作符的右参数。按需求重载 ~= 操作符就可以使用 Switch 匹配正则表达式。

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
func ~=(pattern: NSRegularExpression, input: String) -> Bool {
return pattern.numberOfMatchesInString(input,
options: [],
range: NSRange(location: 0, length: input.characters.count)) > 0
}

prefix operator ~/ {}

prefix func ~/(pattern: String) -> NSRegularExpression {
return NSRegularExpression(pattern: pattern, options: nil, error: nil)
}

let contact = ("http://onevcat.com", "onev@onevcat.com")

let mailRegex: NSRegularExpression
let siteRegex: NSRegularExpression

mailRegex =
try ~/"^([a-z0-9_\\.-]+)@([\\da-z\\.-]+)\\.([a-z\\.]{2,6})$"
siteRegex =
try ~/"^(https?:\\/\\/)?([\\da-z\\.-]+)\\.([a-z\\.]{2,6})([\\/\\w \\.-]*)*\\/?$"

switch contact {
case (siteRegex, mailRegex): print("同时拥有有效的网站和邮箱")
case (_, mailRegex): print("只拥有有效的邮箱")
case (siteRegex, _): print("只拥有有效的网站")
default: print("嘛都没有")
}

// 输出
// 同时拥有网站和邮箱

… 和 ..<

文章作者: Ammar
文章链接: http://lizhaoloveit.cn/2016/06/27/Swifter%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0(%E4%BA%8C)/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Ammar's Blog
打赏
  • 微信
  • 支付宝

评论