阅读视图

发现新文章,点击刷新页面。

NSPredicate

作者:Jordan Morgan,原文链接,原文日期:2018-05-18
译者:石榴;校对:numbbbbbNemocdz;定稿:Pancf

Swift 刚出现的时候,我们因它比 Objective-C 简洁而着迷。接着它很快打开了面向协议编程的大门。并且,让我们忘掉引用类型和类,还有很多。

确实,这些东西都是很棒的工具,都有优秀的用例。但我感觉它们经常被捧作银弹,在决定架构时缺乏足够的考虑。

因此在 2018 年,技术博客中充斥着各种 Swift 黑魔法(我的博客也不例外🤷🏻‍♂️),会议演讲也都在讨论 Swift 的函数式编程未来(没错,我也做了这种演讲🙋🏻‍♂️)。

所有人都对在 Swift 中使用集合感到激动,但是我们从 iOS 3 开始就可以用 Objective-C 来做相似的事了。所以今天我会讨论 NSPredicate 的威力,以及如何用🦖筛选集合。

有必要提一下:我们最近看到了一些开发者一开始学了 Swift,后来又得回去维护 Objective-C 的代码。如果说的就是你,那你很可能正在发愁如何优雅地在 Objective-C 中处理集合。

这里讲的东西可能对你有用。

用例

近几年来,Objective-C 的集合有了长足的进步。还在几年以前,我们还必须教这愚蠢的编译器:

NSString *aString = (NSString *)[anArray indexOfObject:0];

感谢老天、库比提诺[^1]和朋友们©终于用类型擦除的方式添加了泛型。这是一个很大的进步:

[^1]: 译者注:Cupertino, CA,苹果总部所在城市。

NSArray *anArray = @[@"Sup"];
NSString *aString = [anArray firstObject];

但无论是不是泛型,我们经常通过与下面类似的方法与 Objective-C 集合中的内容交互:

for (NSString *str in anArray)
{
if ([str isEqualToString:@"The Key"])
{
// 做些什么
}
}

很多情况下,这样写是可以接受的。但是当需求越来越复杂,关系更加多种多样,代码就会变得不确定。如果你遵从代码更少更稳定更容易维护的观念,那么这种简单的查询集合操作也可能成为困扰。

Predicate 可以改善这个状况。不是要在代码中耍些小聪明,而是写出简洁和实用的代码。

概览

NSPredicate 的核心用途是限制或定义对内存中的数据过滤,或进行取回时的参数。当它和 Core Data 一起使用的时候才会如虎添翼。它和 SQL 很像,只不过没那么糟糕*。

开个玩笑,只是我对基于集合的操作都无感🙃。

你给它提供逻辑条件,然后就会返回符合条件的东西。这意味着它可以提供基础比较、复合 predicate、KeyPath 集合查询、子查询、合计以及更多的支持。

因为它用来筛选集合,它可以获得 Foundation 类的原生支持。使用可变版本时支持用结果直接修改,不可变版本会返回一个新实例:

// 修改原数组
[mutableArray filterUsingPredicate:/*NSPredicate*/];

// 返回新的数组
[mutableArray filteredArrayUsingPredicate:/*NSPredicate*/];

虽然 predicate 可以从 NSExpressionNSCompoundPredicateNSComparsionPredicate 中实例化,但它还可以用一个字符串的语法生成。这和可视化格式语言类似,我们可以用它定义排版约束。

在这里我们主要关注能用字符串语法生成的能力。

配置

为了更好的说明,文章的剩余部分以下面的代码为前提。

// 伪代码
Person:NSObject
Identifier:NSString
Name:NSString
PayGrade:NSNumber

// 某个含有 Person 实例的属性
NSArray *employees

查询⚡️

本文剩下都在用直接的例子来介绍如何用字符串格式语法来配置查询。

我们可以从一个简单的搜索的情景开始。先假设我们有一个含有表示 Person 对象标识符的数组:

{
@"erersdg32453tr",
@"dfs8rw093jrkls",
// etc
}

现在,我们想通过这些识别符从一个现存的 Person 数组中获取 Person 对象。可以使用一个双层嵌套的 for 循环来解决这个问题:

// 假设 "employees" 是一个存有 Person 对象的数组
NSArray *morningEventAttendees = @[/*上面的人的识别符*/];
NSMutableArray *peopleAttendingMorningEvent = [NSMutableArray new];

for (NSString *userID in morningEventAttendees)
{
for (Person *person in employees)
{
if ([person.identifier isEqualToString:userID])
{
[peopleAttending addObject:person];
}
}
}

// 现在 peopleAttendingMorningEvent 里面就有我们想要的东西了

我们也可以使用 predicate 来达到完全一样的效果:

NSPredicate *morningAttendees = [NSPredicate predicateWithFormat:@"SELF.identifier IN %@", peopleAttendingMorningEvent];

NSArray *peopleAttendingMorningEvent = [employees filteredArrayUsingPredicate:morningAttendees];

💫。

Predicate 的语法允许我们使用 SELF,它在这里发挥了很大的作用。它表示数组里正在被操作的对象,在这里就是 Person 对象。

另一个额外的好处是我们不用把数组定义成可变的了。

正是因为这个原因,我们可以访问与 SELF 所表示对象关联的 KeyPath。在上面的代码中,引用了 identifier 属性。

如果你喜欢的话,任何 KeyPath 可以用放在 “%K” 位置的变量来表示。这个版本和上面的效果一样:

[NSPredicate predicateWithFormat:@"SELF.%K IN %@", @"identifier", peopleAttendingMorningEvent];

复合 Predicate

合并多个比较很简单。假设我们还需要像上面一样找到所有参加活动的人,但还要满足他们的工资水平在 50000 到 60000 之间。

如果使用传统的方法,我们的 if 语句只会越写越长:

// 和上面的代码一样
if ([person.identifier isEqualToString:userID] && (person.paygrade.integerValue >= 5 && person.paygrade.integerValue <= 10))
{
[peopleAttending addObject:person];
}

但使用一个重构过的 predicate 可以让我们用一种更符合语言习惯的方式来解决问题:

NSPredicate *morningAttendees = [NSPredicate predicateWithFormat:@"SELF.identifier IN %@ && SELF.paygrade.integerValue BETWEEN {50000, 60000}", peopleAttendingMorningEvent];

它允许用不同的操作符表示同样的作用,可以根据你的偏好来提升可读性。比如:

  • “&&” 或 “AND”
  • “||” 或 “OR”
  • “!” 或 “NOT”

如你所想,它们经常会在基本比较操作之间出现,聚合在一个 predicate 里。

字符串比较

我们经常会处理一些基于字符串比较的匹配。大家都知道 Objective-C 对冗余代码的无止尽追求,在处理 NSString 的时候也丝毫不减:

NSString *name = @"Jordan";
name = [name stringByAppendingString:[NSString stringWithFormat:@"%@ %@", @"Wesley", @"Morgan"]];

……而 Swift 则一边偷笑,一边低调地把字符串们连接起来。幸亏我们在用 NSPredicate 来比较字符串时不会写出上面那么冗余的代码。

// 假设 mutablePersonAr 是一个 Person 数组,里面有 "Karl" 和 "Jordan"
NSPredicate *namesStartingWithK = [NSPredicate predicateWithFormat:@"SELF.name BEGINSWITH 'K'"];
// 现在只有 Karl 了
[mutablePersonAr filterUsingPredicate:namesStartingWithK];

实际上任何比较都可以用 predicate 语法中的 CONTAINSBEGINSWITHENDSWITHLIKE 来实现:

// 假设 mutablePersonAr 是一个 Person 数组,里面有 "Karl" 和 "Kathryn"
NSPredicate *namesStartingWithK = [NSPredicate predicateWithFormat:@"SELF.name LIKE 'Kar*'"];

// 现在只有 Karl 了
[mutablePersonAr filterUsingPredicate:namesStartingWithK];

你可能已经注意到上面的星号了;和很多的 DSL 一样,这个星号代表一个通配符。

当你在一个查询里结合多个比较运算符时,这种简洁用法的重要性就会体现出来了:

NSString *predicateFormat = @"(SELF.name LIKE 'Kar*') AND (SELF.paygrade.intValue >= 10)";

NSPredicate *namesStringWithK = [NSPredicate predicateWithFormat:predicateFormat];

// 现在只有 Karl 了
[mutablePersonAr filterUsingPredicate:namesStartingWithK];

更进一步,它还支持用 MATCHES 语法实现 NSPredicate 的类 SQL 语法与正则表达式混用:

[NSPredicate predicateWithFormat:@"SELF.phoneNumber MATCHES %@", phoneNumberRegex];

然而是时候该指出一点,predicate 语法十分严格。它就是一个字符串。除非你是 Mavis Beacon[^2], 否则你总会一遍又一遍地不小心打错字。

[^2]: 译者注:Mavis Beacon Teaches Typing,一款在 1987 年发售的教盲打的软件。

好消息是你会很快的发现问题 — 运行时的异常在等着你。我们获得了能力和灵活性,但在某种程度上失去了静态检查的安全性。

为了说明这一点,这段从上面代码稍微修改而来的代码会导致崩溃。你能看出来是为什么吗?

NSString *predicateFormat = @"SELF.name LIKE 'Kar*') AND (SELF.paygrade.intValue >= 10)"

NSPredicate *namesStartingWithK = [NSPredicate predicateWithFormat:predicateFormat];

// 现在只有 Karl 了
[mutablePersonAr filterUsingPredicate:namesStartingWithK];

为了减轻这些问题,我经常把 predicate 和 NSStringFromSelector() 结合在一起用,以此应对错别字和为以后的重构提供多一层安全保障。

NSString *predicateFormat = @"(SELF.%@ LIKE 'Kar*') AND (SELF.paygrade.intValue >= 10)"

NSString *kpName = NSStringFromSelector(@selector(identifier));
NSString *kpPaygrade = NSStringFromSelector(@selector(paygrade));

NSPredicate *namesStartingWithK = [NSPredicate predicateWithFormat:predicateFormat, kpName, kpPaygrade];

// 现在只有 Karl 了
[mutablePersonAr filterUsingPredicate:namesStartingWithK];

有点复杂了?确实。更安全了?毫无疑问。

KeyPath 集合查询

由于基于 KeyPath 的用法,NSPredicate 拥有一全套工具去操作它们,以提供一个更好的搜索。考虑下面的代码:

// 假设一个 Person 对象现在有一个下面的属性:
// NSArray *previousPay

// 找到所有满足过去工资的平均值大于 10 的人
NSString *predicateFormat = @"SELF.previousPay.@avg.doubleValue > 10";
NSPredicate *previousPayOverTen = [NSPredicate predicateWithFormat:predicateFormat];

// 所有过去工资的平均值大于 10 的人
[mutablePersonAr filterUsingPredicate:previousPayOverTen];

你可以把 @avg 换成:

  • @sum
  • @max
  • @min
  • @count

想象下如果不使用 predicate 情况下完成同样的工作,就不得不写大量尽管很简单的代码。你可以开始将这些技巧用在你日常的工具链里。

对数组的深究

和 KeyPath 查询很像,predicate 也支持以更细的维度检查数组:

  • array[FIRST]
  • array[LAST]
  • array[SIZE]
  • array[index]

应用在上面的代码样例上,我们就可以这样查询:

// 找到所有过去有三份不同工资的人
NSString *predicateFormat = @"previousPay[SIZE] == 3";

NSPredicate *threePreviousSalaries = [NSPredicate predicateWithFormat:predicateFormat];

// 这些 Person 对象过去有三份不同的工资
[mutablePersonAr filterUsingPredicate:threePreviousSalaries];

和在上面提到的一样,我们也可以应用多个条件:

// 找到所有过去有三个不同的工资以及第一份工资大于 8 的人
NSString *predicateFormat = @"(previousPay[SIZE] == 3) AND (previousPay[FIRST].intValue > 8)";

NSPredicate *predicate = [NSPredicate predicateWithFormat:predicateFormat];
[mutablePersonAr filterUsingPredicate:predicate];

更加深入,你可以使用下面的操作符来实现更复杂的操作:

  • @distinctUnionOfArrays
  • @unionOfArrays
  • @unionOfObjects
  • @distinctUnionOfObjects

假设我们有一个含有 Person 对象的数组,我们需要的是找出在所有数组中识别符不同的 Person 实例:

// 假设 p1/2/3/4 都是 Person 对象
NSArray *> *previousEmployees = @[@[p1],@[p2,p1,p2],@[p1],@[p4,p2],@[p4],@[p4],@[p1]];

// 获取所有不同的 ID
NSArray *unqiuePreviousEmployeeIDs = [previousEmployees valueForKeyPath:@"@distinctUnionOfObjects.identifier"];

// 现在数组里应该只含有不同的 ID

厉害吧!

还有更好玩的呢,还支持子查询:

// 假设 Person 对象有了一个新的属性表示他们的队伍:
// NSArray *team;

// 从雇员数组中找出这样的人,他们的团队中有人满足这个条件:没有历史工资数据并且工资大于 1
NSString *predicateFormat = @"SUBQUERY(team, $teamMember, $teamMember.paygrade.intValue > 1 AND $teamMember.previousPay == nil).@count > 0";

NSPredicate *predicate = [NSPredicate predicateWithFormat:predicateFormat];
[employeeAr filterUsingPredicate:predicate];

当你发现你需要在一个含有对象的数组里搜索,而这些对象含有的属性本身就是一个集合的时候,子查询十分有用。所以在上面的例子里,我们有一个 Person 对象的数组,并且查询它的 teamMember 数组。

便捷才是关键[^3]

[^3]: 译者注:此处原作者用了双关。原文是 “Convenience is Key(Path)”,既有便捷是关键的意思,又在暗指这里的关键其实是 Key Path。

尽管 NSPredicate 是为了搜索而设计出来的,但如果你不把它用在和原本设计稍微偏离的地方那它就不是 Objective-C 了。这里也不例外。

当你想到 predicate,你想到的是从一个集合里筛选 — 也就是说它的返回值(或更改过的原来数组)还含有相同的东西。

但是也可以让他们含有同的东西。其实我们在之前的代码中已经这样操作过了。上面的二维数组被用来返回一个识别符的数组 — NSString 实例。KeyPath 让这些变得可能。

这有一个更直接的例子:

// 我们得到一个长度大于 10 的识别符字符串的数组
NSString *predicateFormat = @"SELF.identifier.length > 10";
NSPredicate *predicate = [NSPredicate predicateWithFormat:predicateFormat];
NSArray *longEmployeeIDs = [[employeeArray filteredArrayUsingPredicate:predicate] valueForKey:@"identifier"];

// 现在 longEmployeeIDs 已经不含有 Person 对象了,只有字符串

总结

马上在 Objective-C 的集合里使用这些语法糖,这样就可以不使用嵌套循环从一个特定的子集中提取数据。使用 NSPredicate 可以让眼睛轻松很多。

虽然 Swift 从语言级别支持对集合进行切片操作,但使用创建的 NSPredicate 对象来解决相同的问题也不难。如果你发现你在维护一个成熟的代码库,或是需要用上古时代 Objective-C 的新项目,随心所欲的使用 predicate 吧。

下次见吧✌️。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

调试内存不足问题:使用运行时魔法捕获布局反馈循环

作者:Ruslan Serebriakov,原文链接,原文日期:2019-01-10
译者:Sunset Wan;校对:numbbbbbNemocdz;定稿:Pancf

让我们想象这样一个场景:你有一个很成功的应用程序,这个应用程序有大量的日活用户并且崩溃率为 0。你很开心并且认为生活很美好。但是某天开始,你在应用商店上看到关于应用程序总是崩溃的负面评价。检查 Fabric 也没有起到任何帮助————没有出现新的崩溃记录。那么,这个现象可能是什么原因呢?

答案是内存不足(Out Of Memory),从而导致应用程序运行终止。

当你在终端用户的设备上使用 RAM 时,操作系统可以决定是否为其他进程而回收该内存,并终止你的应用程序。我们把它称作内存不足异常终止。
可能有各种原因:

  • 循环引用;
  • 竞争条件;
  • 废弃的线程;
  • 死锁;
  • 布局反馈循环。

Apple 提供了很多方法来解决这类问题:

  • Instruments 里的 Allocations 和 Leaks 工具用于解决循环引用和 其他类型的泄漏
  • 在 Xcode 8 中引入的 Memory Debugger 代替了 Allocations 和 Leaks 的一部分功能
  • Thread Sanitizer 帮助你找到竞争条件、废弃的线程或者死锁

布局反馈循环

下面我们来讨论下布局反馈循环。它不是一个频繁出现的问题,一旦遇到了,可能让你很头痛。

当视图正在运行它们的布局代码,但某种方法导致它们再一次开始布局传递,此时布局反馈循环就会出现。这可能是因为某个视图正在改变某个父视图的大小,或者因为你有一个模棱两可的布局。无论哪种原因,这个问题的表现是你的 CPU 使用被占满和 RAM 使用量稳步上升,因为你的视图正在一次又一次地运行它们的布局代码,却没有返回。
- 来自 HackingWithSwift 的 Paul Hudson

幸运的是,在 WWDC 16 中 Apple 花了整整 15 分钟(!)来介绍“布局反馈循环调试器”。这个调试器有助于识别在调试过程中发生循环的时间点。这就是一个符号断点,它的工作方式非常简单:它会计算在单个 run loop 迭代中调用每个视图上的 layoutSubviews() 方法的次数。一旦这个计数值超过某个临界值(比如,100),这个应用程序将会停在这个断点并打印出日志,来帮助你找到根本原因。这篇文章 快速地介绍如何使用这个调试器。

这个方法在你可以重现问题的情况下十分有效。但是如果你有几十个屏幕,几百个视图,但应用商店中你的应用程序的评价仅仅是:“这个应用程序糟透了,总是崩溃,再也不用了!”。你希望可以将这些人带到办公室并为他们设置布局反馈循环调试器。虽然因为通用数据保护条例(GDPR),这部分无法完全实现,但是你可以尝试把 UIViewLayoutFeedbackLoopDebuggingThreshold 的代码复制到生产代码中。

让我们回顾一下符号断点是如何工作的:它会计算 layoutSubviews() 的调用次数并在单个 run loop 迭代中超过某个临界值时发送一个事件。听起来很简单,对吧?

class TrackableView: UIView {
var counter: Int = 0

override func layoutSubviews() {
super.layoutSubviews()

counter += 1;
if (counter == 100) {
YourAnalyticsFramework.event(name: "loop")
}
}
}

对于一个视图,这段代码运行正常。但是现在你想要在另一个视图上实现它。当然,你可以创建一个 UIView 的子类,在这里实现它并使你项目中的所有视图都继承这个子类。然后为 UITableViewUIScrollViewUIStackView 等做同样的事情。

你希望可以将此逻辑注入你想要的任何类,而无需编写大量重复的代码。这时候就可以借助运行时编程了。

我们会做同样的事情——创建一个子类,重写 layoutSubviews() 方法并计算其调用次数。唯一的区别是所有这些都将在运行时完成,而不是在项目中创建重复的类。

让我们开始吧——我们将创建自定义子类,并将原始视图的类更改为新的子类:

struct LayoutLoopHunter {

struct RuntimeConstants {
static let Prefix = “runtime”
}

static func setUp(for view: UIView, threshold: Int = 100, onLoop: @escaping () -> ()) {
// 我们根据功能的前缀和原始类名为新类创建名称。
let classFullName = “\(RuntimeConstants.Prefix)_\(String(describing: view.self))”
let originalClass = type(of: view)

if let trackableClass = objc_allocateClassPair(originalClass, classFullName, 0) {
// 在当前运行时会话期间尚未创建此类。
// 注册这个类,并且用原始视图的类来和它交换。
objc_registerClassPair(trackableClass)
object_setClass(view, trackableClass)
} else if let trackableClass = NSClassFromString(classFullName) {
// 我们之前在此运行时会话中分配了一个具有相同名称的类。
// 我们可以从原始字符串中获取它,并以相同的方式与我们的视图交换。
object_setClass(view, trackableClass)
}
}
}

objc_allocateClassPair() 方法的文档告诉我们这个方法何时失败:

新类,或者如果无法创建类,则为 Nil (例如,所需名称已被使用)。

这就意味着不能拥有两个同名的类。我们的策略是为单个视图类创建一个单独的运行时类。这就是我们在原始类名前加上前缀来形成新类的名称的原因。

现在添加一个计数器到子类中。理论上,有两种方法可以做到这一点。

  1. 添加一个保存计数器的属性。
  2. 为这个类创建一个关联对象(Associated object)。

但是目前,只有一个方法奏效。你可以想象属性是存储在分配给类的内存里的东西,然而关联对象则储存在一个完全不同的地方。因为分配给已存在对象的内存是固定的,所以我们在自定义类上新添加的属性将会从其他资源里“窃取”内存。它可能导致意料之外的行为和难以调试的程序崩溃(点击 这里 查看更多信息)。但是在使用关联对象的情况下,它们将会存储在运行时创建的一个哈希表里,这是完全安全的。

static var CounterKey = "_counter"

...

objc_setAssociatedObject(trackableClass, &RuntimeConstants.CounterKey, 0, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)

当新的子类被创建时,计数器初值设置为 0。接下来,让我们实现这个新的layoutSubviews() 方法,并将它添加到我们的类中:

let layoutSubviews: @convention(block) (Any?) -> () = { nullableSelf in
guard let _self = nullableSelf else { return }

if let counter = objc_getAssociatedObject(_self, &RuntimeConstants.CounterKey) as? Int {
if counter == threshold {
onLoop()
}

objc_setAssociatedObject(trackableClass, &RuntimeConstants.CounterKey, counter+1, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
let implementation = imp_implementationWithBlock(layoutSubviews)
class_addMethod(trackableClass, #selector(originalClass.layoutSubviews), implementation, "v@:")

为了理解上面这段代码实际上在干什么,让我们看一下这个来自 <objc/runtime.h> 的结构体:

struct objc_method {
SEL method_name;
char *method_types;
IMP method_imp;
}

尽管我们再也不会在 Swift 中直接使用这个结构体,但它很清楚地解释了一个方法实际上是由什么组成的:

  • 方法的实现,这是调用方法时要执行的实际函数。它的前两个形参总是方法接收者和消息选择器。
  • 包含方法签名的方法类型字符串。你可以在 这里 详细了解其格式。但是在现在的情况下,需要明确说明的字符串是 “v@:”。作为返回类型,v 代表 void,而 @: 分别代表接收者和消息选择器。
  • 选择器作为键,用于在运行时查找方法的实现。

你可以把 Witness Table(在其他编程语言中,它也被称作方法派发表)想象成一个简单的字典数据结构。那么选择器为键,且实现部分则为对应的值。
在下面这行代码中:

class_addMethod(trackableClass,#selector(originalClass.layoutSubviews), implementation, "v@:")

我们所做的是给 layoutSubviews() 方法对应的键分配新值。

这个方法直截了当。我们获得这个计数器,使它的计数值加一。如果计数值超过临界值,我们会发送分析事件,其中包含类名和想要的任何数据体。

让我们回顾一下如何对关联对象实现和使用键:

static var CounterKey = “_counter”
...

objc_setAssociatedObject(trackableClass, &RuntimeConstants.CounterKey, counter+1, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)

为什么我们使用 var 来修饰计数器的键这个静态属性并在传递到其他地方时使用引用?答案隐藏在 Swift 语言基础——字符串之中。字符串像其他所有的值类型一样,是按值传递的。那么,当你把它传入这个闭包时,这个字符串将会被复制到一个不同的地址,这会导致在关联对象表中产生一个完全不同的键。& 符号总是保证将相同的地址作为键参数的值。你可以尝试以下代码:

func printAddress(_ string: UnsafeRawPointer) {
print("\(string)")
}

let str = "test"

printAddress(str)
printAddress(str)
let closure = {
printAddress(str)
printAddress(str)
}
closure()
// 最后两个函数调用的地址将始终不同

用引用的方式来传递键的主意总是好的,因为有时,即使你没有使用闭包,变量的地址仍可能因内存管理而更改。在我们例子中,如果你把上面的代码运行多次,即使是前两个 printAddress() 的调用也可能会输出不同的地址。

让我们回到运行时的魔法里来。在新 layoutSubviews() 的实现里,还有一件很重要的事情没有完成。这件事是每次重写父类的方法时通常都会做的事情——调用父类实现。layoutSubviews() 的文档里提到:

在 iOS 5.1 及更早版本中,这个方法的默认实现不执行任何操作。而之后的默认实现会使用你设置的任何约束来确定任何子视图的大小和位置。

为了避免发生一些难以预料的布局行为,我们得调用父类的实现,但这不像平常那样简单明了:

let selector = #selector(originalClass.layoutSubviews)
let originalImpl = class_getMethodImplementation(originalClass, selector)


// @convention(c) 告知 Swift 这是一个裸函数指针(没有上下文对象)
// 所有的 Obj-C 方法函数把接收者和消息当作前两个参数
// 所以这意味着一个类型为 `() -> Void` 的方法,这与 `layoutSubview` 方法相符
typealias ObjCVoidVoidFn = @convention(c) (Any, Selector) -> Void
let originalLayoutSubviews = unsafeBitCast(originalImpl, to: ObjCVoidVoidFn.self)
originalLayoutSubviews(view, selector)

这里实际发生的是:我们检索方法所需的实现部分,并直接从代码中调用它,而不是用常见的方式来调用方法(即执行一个会在 Witness Table 中寻找对应实现的选择器)。

目前为止,让我们看看实现部分:

static func setUp(for view: UIView, threshold: Int = 100, onLoop: @escaping () -> ()) {
// 我们根据功能的前缀和原始类名为新类创建名称
let classFullName = “\(RuntimeConstants.Prefix)_\(String(describing: view.self))”
let originalClass = type(of: view)

if let trackableClass = objc_allocateClassPair(originalClass, classFullName, 0) {
// 在当前运行时会话期间尚未创建此类
// 注册这个类并将其与原始视图的类交换
objc_registerClassPair(trackableClass)
object_setClass(view, trackableClass)

// 现在可以创建关联对象
objc_setAssociatedObject(view, &RuntimeConstants.CounterKey, 0, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)

// 添加我们自己 layoutSubviews 的实现
let layoutSubviews: @convention(block) (Any?) -> () = { nullableSelf in
guard let _self = nullableSelf else { return }

let selector = #selector(originalClass.layoutSubviews)
let originalImpl = class_getMethodImplementation(originalClass, selector)


// @convention(c) 告知 Swift 这是一个裸函数指针(没有上下文对象)
// 所有的 Obj-C 方法函数把接收者和消息当作前两个参数
// 所以这意味着一个类型为 `() -> Void` 的方法,这与 `layoutSubview` 方法相符
typealias ObjCVoidVoidFn = @convention(c) (Any, Selector) -> Void
let originalLayoutSubviews = unsafeBitCast(originalImpl, to: ObjCVoidVoidFn.self)
originalLayoutSubviews(view, selector)

if let counter = objc_getAssociatedObject(_self, &RuntimeConstants.CounterKey) as? Int {
if counter == threshold {
onLoop()
}

objc_setAssociatedObject(view, &RuntimeConstants.CounterKey, counter+1, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
}
let implementation = imp_implementationWithBlock(layoutSubviews)
class_addMethod(trackableClass, #selector(originalClass.layoutSubviews), implementation, “v@:“)
} else if let trackableClass = NSClassFromString(classFullName) {
// 我们之前在此运行时会话中分配了一个具有相同名称的类
// 我们可以从原始字符串中获取它,并以相同的方式与我们的视图交换
object_setClass(view, trackableClass)
}
}

让我们为视图创建模拟布局循环,并为其设置计数器来进行测试:

class ViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()

LayoutLoopHunter.setUp(for: view) {
print("Hello, world")
}
}

override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
view.setNeedsLayout() // loop creation
}
}

是不是忘记了什么事情?让我们再次回顾一下 UIViewLayoutFeedbackLoopDebuggingThreshold 断点的工作原理:

在确认为反馈循环之前,定义某个视图的子视图在单个 run loop 里必须布局的次数

我们从未把“单个 run loop ”这一条件考虑进来。如果视图在屏幕上停留了相当长的时间,并经常被反复布局,计数器迟早会超过临界值。但这可不是因为内存的问题。

我们该怎么解决这个问题呢?只需在每次 run loop 迭代时重置计数器。为了做到这一点,我们可以创建一个 DispatchWorkItem,它重置计数器,并在主队列上异步传递它。通过这种方式,它会在 run loop 下一次进入主线程时被调用:

static var ResetWorkItemKey = “_resetWorkItem”

...

if let previousResetWorkItem = objc_getAssociatedObject(view, &RuntimeConstants.ResetWorkItemKey) as? DispatchWorkItem {
previousResetWorkItem.cancel()
}
let currentResetWorkItem = DispatchWorkItem { [weak view] in
guard let strongView = view else { return }
objc_setAssociatedObject(strongView, &RuntimeConstants.CounterKey, 0, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
DispatchQueue.main.async(execute: currentResetWorkItem)
objc_setAssociatedObject(view, &RuntimeConstants.ResetWorkItemKey, currentResetWorkItem, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)

最终的代码:

struct LayoutLoopHunter {

struct RuntimeConstants {
static let Prefix = “runtime”

// Associated objects keys
// 关联对象键
static var CounterKey = “_counter”
static var ResetWorkItemKey = “_resetWorkItem”
}

static func setUp(for view: UIView, threshold: Int = 100, onLoop: @escaping () -> ()) {
// 我们根据功能的前缀和原始类名为新类创建名称。
let classFullName = “\(RuntimeConstants.Prefix)_\(String(describing: view.self))”
let originalClass = type(of: view)

if let trackableClass = objc_allocateClassPair(originalClass, classFullName, 0) {
// 在当前运行时会话期间尚未创建此类。
// 注册这个类,并且用原始视图的类来和它交换。
objc_registerClassPair(trackableClass)
object_setClass(view, trackableClass)

// 现在可以创建关联对象
objc_setAssociatedObject(view, &RuntimeConstants.CounterKey, 0, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)

// 添加我们自己 layoutSubviews 的实现
let layoutSubviews: @convention(block) (Any?) -> () = { nullableSelf in
guard let _self = nullableSelf else { return }

let selector = #selector(originalClass.layoutSubviews)
let originalImpl = class_getMethodImplementation(originalClass, selector)

// @convention(c) 告知 Swift 这是一个裸函数指针(没有上下文对象)
// 所有的 Obj-C 方法函数把接收者和消息当作前两个参数
// 所以这意味着一个类型为 `() -> Void` 的方法,这与 `layoutSubview` 方法相符
typealias ObjCVoidVoidFn = @convention(c) (Any, Selector) -> Void
let originalLayoutSubviews = unsafeBitCast(originalImpl, to: ObjCVoidVoidFn.self)
originalLayoutSubviews(view, selector)

if let counter = objc_getAssociatedObject(_self, &RuntimeConstants.CounterKey) as? Int {
if counter == threshold {
onLoop()
}

objc_setAssociatedObject(view, &RuntimeConstants.CounterKey, counter+1, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}

// 为重置计数器,在每个新的 run loop 遍历中分发 work item
if let previousResetWorkItem = objc_getAssociatedObject(view, &RuntimeConstants.ResetWorkItemKey) as? DispatchWorkItem {
previousResetWorkItem.cancel()
}
let counterResetWorkItem = DispatchWorkItem { [weak view] in
guard let strongView = view else { return }
objc_setAssociatedObject(strongView, &RuntimeConstants.CounterKey, 0, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
DispatchQueue.main.async(execute: counterResetWorkItem)
objc_setAssociatedObject(view, &RuntimeConstants.ResetWorkItemKey, counterResetWorkItem, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
let implementation = imp_implementationWithBlock(layoutSubviews)
class_addMethod(trackableClass, #selector(originalClass.layoutSubviews), implementation, “v@:“)
} else if let trackableClass = NSClassFromString(classFullName) {
// 我们之前在此运行时会话中分配了一个具有相同名称的类。
// 我们可以从原始字符串中获取它,并以相同的方式与我们的视图交换。
object_setClass(view, trackableClass)
}
}
}

结论

是的!现在你可以为所有可疑的视图设置分析事件了,发布应用程序,并找到这个问题的确切出处。你可以把这个问题的范围缩小到某个特定的视图,并在用户不知情的情况下借助于他们来解决这个问题。

最后要提到的一件事是:能力越大责任越大。运行时编程非常容易出错,因此很容易在不知情的情况下为应用程序引入另一个严重的问题。这就是为什么总是建议将应用程序中的所有危险代码包装在某种可停止开关中,因为你可以在发现代码导致问题时从后端触发开关禁用该功能。这有一篇介绍 Firebase 的 Feature Flags 的 好文章

完整代码可以从这个 GitHub 仓库 里获取,并且也将会发布到 CocoPods 上,以跟踪项目中的布局循环。

附:我想特别感谢 Aleksandr Gusev 帮助审阅并且为本文提供了很多意见。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

分析复杂度

作者:Soroush Khanlou,原文链接,原文日期:2018-12-17
译者:RocZhang;校对:numbbbbbWAMaker;定稿:Pancf

在 Dave Abraham 的 WWDC 演讲 Embracing Algorithms(拥抱算法)中,谈到了要发现通用的算法,并将其提取到通用且可测试的函数中。在这个方向上,我发现一些对集合类型的多次操作可以被聚齐起来,合并成单次操作。并且通常情况下,这些操作被合并之后还能带来性能上的收获。

第一个例子是 Swift 3 添加的一个方法:

// 当你看到:
myArray.filter({ /* some test */ }).first

// 你应该把它改成:
myArray.first(where: { /* some test */ })

这里两种写法的断言描述闭包和操作结果都完全相同,但下面的写法更简短,语义更清晰,而且性能更好。因为它不会进行新数组的分配,也不需要对数组中每一个元素是否能够通过测试都进行判断,只需要找出第一个就好了。

另一个例子是 我帮助添加到 Swift 5 中的 count(where:) 函数:

// 当你看到:
myArray.filter({ /* some test */ }).count

// 你应该把它改成:
myArray.count(where: { /* some test */ })

这又是一个更短、更清晰而且更快的例子。没有额外要被分配的数组,也没有多余的操作。

在我们的一个项目中,有一个通用的范式,需要先将集合进行 sort,随后再进行 prefix 操作。例如下述的示例代码,需要找出前 20 张最新创建的图像:

return images
.sorted(by: { $0.createdAt > $1.createdAt })
.prefix(20)

同样,也可以想象成在排行榜中找到前 5 位得分最高的用户,也需要使用这类范式。

我盯着这段代码直到我的眼睛开始流血,感到这段代码可能存在问题。我首先想到的是分析它的时间复杂度。如果把原始数组的长度用 n 表示,再把最后想要得到的元素的数目用 m 表示,在分析代码之后可以得出,排序的复杂度是 O(nlogn),取前子集合的操作则更快,时间复杂度为 O(1)(取前子集合操作本身最慢时可能会达到 O(m),但对这里我们要处理的数组而言,由于它是可随机访问的集合,因此取前子集合操作能在常数时间中完成)。

这正是让我感到困惑的地方:获取一个序列的最小元素(使用 min() 函数)只需要单次遍历所有元素,或者说时间复杂度为 O(n)。将其所有元素进行完整排序需要的时间复杂度是 O(nlogn)。而从集合中获取 m 个数,当 m 比 n 小时,时间复杂度应该位于它们之间。且当 m 比 n 小非常多时,时间复杂度应该更接近 O(n)。

在我们的例子里,图片的数量会非常大(n 约为 55000),而我们想得到的元素数量却很小(m 为 20)。因此,这里应该存在有优化的空间。我们是否能够优化排序,使其仅排序出前 m 个元素?

答案是肯定的,我们能够在这个方向上进行一些优化。我将此函数命名为 smallest(_:by:),它接收 sortprefix 函数的所有参数,也就是上面提到的 m 和用于排序做比较的闭包:

func smallest(_ m: Int, by areInIncreasingOrder: (Element, Element) -> Bool) -> [Element] {

首先从排序前 m 个元素开始(因为 m 比序列的总长度要小很多,所以此操作会进行的很快):

var result = self.prefix(m).sorted(by: areInIncreasingOrder)

然后我们再遍历所有剩下的元素:

for element in self.dropFirst(m) {

对集合中剩下的每一个元素,我们需要找到 result 中第一个比它大的项的索引。通过 areInIncreasingOrder 函数,我们把 element 作为第一个参数传入,再把 result 中的元素作为第二个参数传入。

if let insertionIndex = result.index(where: { areInIncreasingOrder(element, $0) }) { // 译者注:此方法在 Swift 4.2 后已更名为 `firstIndex(where:)`

如果能够找到符合条件的索引值,这就表示存在有比我们 result 数组中的元素更小的值。我们把新的值插入到计算出的索引的位置,它便会被正确的排序:

result.insert(element, at: insertionIndex)

再将最后一个元素移除(因为我们只需要 m 个元素):

result.removeLast()

如果没有找到满足条件的索引,我们就可以忽略这个值。最后,当 for 循环完成,便可将 result 返回。

完整的函数如下所示:

extension Sequence {
func smallest(_ m: Int, by areInIncreasingOrder: (Element, Element) -> Bool) -> [Element] {
var result = self.prefix(m).sorted(by: areInIncreasingOrder)

for e in self.dropFirst(n) {
if let insertionIndex = result.index(where: { areInIncreasingOrder(e, $0) }) {
result.insert(e, at: insertionIndex)
result.removeLast()
}
}

return result
}
}

如果这让你想起了之前在计算机科学中学过的课程,那就再好不过了。实际上这里的算法就类似于选择排序的过程(但它们并非完全相同,因为这里会预先排序一部分元素,而选择排序则不同,是可变序列算法(mutating algorithm))。

这里的时间复杂度分析起来可能会有些困难,但是我们还是可以尝试进行分析。初始部分的排序是 O(mlogm),外层的循环是 O(n)。每次的循环中,会分别调用时间复杂度都为 O(m) 的 index(where:)insert(_:at:)(插入操作的时间复杂度是 O(m) 的原因在于,它可能需要将所有的元素后移,为新元素腾出空间)。因此,总时间复杂度应为 O(mlogm + n * (m + m)),或者说 O(mlogm + 2nm)。常数项被移除后,留下的则是 O(mlogm + nm)。

当 m 比 n 小得多时,m 项会接近于常数,最终我们得到的会是 O(n)。相较于之前的 O(nlogn) 而言,这是一个巨大的改进。对应到之前提到的 55000 张图片的例子,这可能会是多达 5 倍的性能提升。

大体上来说,这里的函数是对 prefix + sort 函数的优化。但我还想要再讨论两处更细小一些的优化。

一处唾手可得的优化是:我们是在 55000 个元素的数组中查找 20 个最小的元素,其中我们检查的大部分(几乎是全部)元素不会落入到最后的 result 数组中。因此我们可以去检查元素是否比 result 数组中的最后一个元素要大,如果是,它就完全可以被跳过。因为当元素比 result 中的最后一个还要大时,再去查找插入的索引就没有任何意义了。

if let last = result.last, areInIncreasingOrder(last, e) { continue }

在测试中,此处增加的判断可以减少线性搜索 result 数组 99.5% 的时间,算法整体上又会加快十倍左右。感谢 Greg Titus 告诉我此处可以优化──之前我完全没有想到这一点。

如果想更近一步的话,还可以做另一处(稍微难实现一些)的优化。此优化基于两处事实:第一,我们使用 index(where:) 来找出应在 result 数组中进行插入的位置;第二,result 数组总是保持有序的。index(where:) 通常情况下是一项线性操作,但如果是在一个已经排好序的数组中进行搜索,我们可以将线性搜索替换成二分搜索。我对此进行了尝试。

为了能够更好的理解这些优化会如何影响算法的性能,Nate Cook 帮助我了解了 Karoy Lorentey 的 Attabench 工具,它能够对这些解决方案进行基准测试。因为截止目前,我们对复杂度的分析都是停留在理论层面的,在真正对代码进行实际测试之前(最理想的情况应该是在真实的设备上),所有的结论都只是有根据的推测。例如,通常来说排序的复杂度为 O(nlogn),但不同的算法处理不同类型的数据时,其表现也会有所不同。具体来说,已经排好序的数据在特定的算法中可能会变得更快或更慢。

Attabench 的执行结果如下:

(我还添加了一个 由 Time Vermuelen 所写的优先队列/堆解决方案,因为有些人好奇它与其他方案比较起来表现如何。)

首先,我对在数组中进行单次搜索比对数组进行完整排序要快的猜测是正确的。尤其是在实际问题中序列可能会很长,排序的性能则会变得更差,但我们的“简单优化”(图中的 “Naive Optimization”)却能保持在常数水平上(Y 轴表示的是单个元素上所花的时间,而不是总时间。这意味着 O(n) 的算法在图中会是一条直线)。

第二,对最后一个元素的检查(图中的 “Last Check”)和二分搜索优化在独立运行时具有几乎完全相同的性能表现(实际上你可能没法看到橘色和黄色的线,因为它们被绿线挡住了),把它们放在一起使用时也是一样。但是由于二分搜索难以编写,甚至更难把它写对,你也可以说把它加上是不值得的。

对我而言,这里传递出的关键信息是测量和优化很难。虽然分析复杂度这件事听起来有些学术:“我什么时候会在自己的职业生涯上用到这个?” 有人会问。但理解你的算法的时间和空间复杂度能够帮助你决定向哪个方向进行探索。在这个例子中,理解排序的时间复杂度引导我们对问题产生了概括性的认知,得到成果。最后,通过使用各种数据进行的进一步的基准测试与分析,能告诉我们代码在生产环境下将如何运作的最准确的信息。

下一次再看到 sort 后面紧跟着一个 prefix 时,不妨考虑将它替换成 smallest(_: by:)

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

图像渲染优化技巧

作者:Mattt,原文链接,原文日期:2019-05-06
译者:ericchuhong;校对:numbbbbbWAMaker;定稿:Pancf

长期以来,iOS 开发人员一直被一个奇怪的问题困扰着:

“如何对一张图像进行渲染优化?”

这个令人困扰的问题,是由于开发者和平台的相互不信任引起的。各种各样的代码示例充斥着 Stack Overflow,每个人都声称只有自己的方法是真正的解决方案 —— 而别人的是错的。

在本周的文章中,我们将介绍 5 种不同的 iOS 图像渲染优化技巧(在 MacOS 上时适当地将 UIImage 转换成 NSImage)。相比于对每一种情况都规定一种方法,我们将从人类工程学和性能表现方面进行衡量,以便你更好地理解什么时该用哪一种,不该用哪一些。

你可以自己下载、构建和运行 示例项目代码 来试验这些图像渲染优化技巧。


图像渲染优化的时机和理由

在开始之前,让我们先讨论一下为什么需要对图像进行渲染优化。毕竟,UIImageView 会自动根据 contentmode 属性 规定的行为缩放和裁剪图像。在绝大多数情况下,.scaleAspectFit.scaleAspectFill.scaleToFill 已经完全满足你的所需。

imageView.contentMode = .scaleAspectFit
imageView.image = image

那么,什么时候对图像进行渲染优化才有意义呢?

当它明显大于 UIImageView 显示尺寸的时候


看看来自 NASA 视觉地球相册集锦 的这张 令人赞叹的图片

image-resizing-earth

想要完整渲染这张宽高为 12,000 px 的图片,需要高达 20 MB 的空间。对于当今的硬件来说,你可能不会在意这么少兆字节的占用。但那只是它压缩后的尺寸。要展示它,UIImageView 首先需要把 JPEG 数据解码成位图(bitmap),如果要在一个 UIImageView 上按原样设置这张全尺寸图片,你的应用内存占用将会激增到几百兆,对用户明显没有什么好处(毕竟,屏幕能显示的像素有限)。但只要在设置 UIImageViewimage 属性之前,将图像渲染的尺寸调整成 UIImageView 的大小,你用到的内存就会少一个数量级:

内存消耗 (MB)
无下采样 220.2
下采样 23.7

这个技巧就是众所周知的下采样(downsampling),在这些情况下,它可以有效地优化你应用的性能表现。如果你想了解更多关于下采样的知识或者其它图形图像的最佳实践,请参照 来自 WWDC 2018 的精彩课程

而现在,很少有应用程序会尝试一次性加载这么大的图像了,但是也跟我从设计师那里拿到的图片资源不会差多。(认真的吗?一张颜色渐变的 PNG 图片要 3 MB?) 考虑到这一点,让我们来看看有什么不同的方法,可以让你用来对图像进行优化或者下采样。

不用说,这里所有从 URL 加载的示例图像都是针对本地文件。记住,在应用的主线程同步使用网络请求图像绝不是什么好主意。


图像渲染优化技巧

优化图像渲染的方法有很多种,每种都有不同的功能和性能特性。我们在本文看到的这些例子,架构层次跨度上从底层的 Core Graphics、vImage、Image I/O 到上层的 Core Image 和 UIKit 都有。

  1. 绘制到 UIGraphicsImageRenderer 上
  2. 绘制到 Core Graphics Context 上
  3. 使用 Image I/O 创建缩略图像
  4. 使用 Core Image 进行 Lanczos 重采样
  5. 使用 vImage 优化图片渲染

为了统一调用方式,以下的每种技术共用一个公共接口方法:

func resizedImage(at url: URL, for size: CGSize) -> UIImage? { <#...#> }

imageView.image = resizedImage(at: url, for: size)

这里,size 的计量单位不是用 pixel,而是用 point。想要计算出你调整大小后图像的等效尺寸,用主 UIScreenscale,等比例放大你 UIImageViewsize 大小:

let scaleFactor = UIScreen.main.scale
let scale = CGAffineTransform(scaleX: scaleFactor, y: scaleFactor)
let size = imageView.bounds.size.applying(scale)

如果你是在异步加载一张大图,使用一个过渡动画让图像逐渐显示到 UIImageView 上。例如:

class ViewController: UIViewController {
@IBOutlet var imageView: UIImageView!

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

let url = Bundle.main.url(forResource: "Blue Marble West",
withExtension: "tiff")!

DispatchQueue.global(qos: .userInitiated).async {
let image = resizedImage(at: url, for: self.imageView.bounds.size)

DispatchQueue.main.sync {
UIView.transition(with: self.imageView,
duration: 1.0,
options: [.curveEaseOut, .transitionCrossDissolve],
animations: {
self.imageView.image = image
})
}
}
}
}

技巧 #1: 绘制到 UIGraphicsImageRenderer 上

图像渲染优化的最上层 API 位于 UIKit 框架中。给定一个 UIImage,你可以绘制到 UIGraphicsImageRenderer 的上下文(context)中以渲染缩小版本的图像:

import UIKit

// 技巧 #1
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
guard let image = UIImage(contentsOfFile: url.path) else {
return nil
}

let renderer = UIGraphicsImageRenderer(size: size)
return renderer.image { (context) in
image.draw(in: CGRect(origin: .zero, size: size))
}
}

UIGraphicsImageRenderer 是一项相对较新的技术,在 iOS 10 中被引入,用以取代旧版本的 UIGraphicsBeginImageContextWithOptions / UIGraphicsEndImageContext API。你通过指定以 point 计量的 size 创建了一个 UIGraphicsImageRendererimage 方法带有一个闭包参数,返回的是一个经过闭包处理后的位图。最终,原始图像便会在缩小到指定的范围内绘制。

在不改变图像原始纵横比(aspect ratio)的情况下,缩小图像原始的尺寸来显示通常很有用。AVMakeRect(aspectRatio:insideRect:) 是在 AVFoundation 框架中很方便的一个函数,负责帮你做如下的计算:

import func AVFoundation.AVMakeRect
let rect = AVMakeRect(aspectRatio: image.size, insideRect: imageView.bounds)

技巧 #2:绘制到 Core Graphics Context 中

Core Graphics / Quartz 2D 提供了一系列底层 API 让我们可以进行更多高级的配置。

给定一个 CGImage 作为暂时的位图上下文,使用 draw(_:in:) 方法来绘制缩放后的图像:

import UIKit
import CoreGraphics

// 技巧 #2
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
else {
return nil
}

let context = CGContext(data: nil,
width: Int(size.width),
height: Int(size.height),
bitsPerComponent: image.bitsPerComponent,
bytesPerRow: image.bytesPerRow,
space: image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: image.bitmapInfo.rawValue)
context?.interpolationQuality = .high
context?.draw(image, in: CGRect(origin: .zero, size: size))

guard let scaledImage = context?.makeImage() else { return nil }

return UIImage(cgImage: scaledImage)
}

这个 CGContext 初始化方法接收了几个参数来构造一个上下文,包括了必要的宽高参数,还有在给出的色域范围内每个颜色通道所需要的内存大小。在这个例子中,这些参数都是通过 CGImage 这个对象获取的。下一步,设置 interpolationQuality 属性为 .high 指示上下文在保证一定的精度上填充像素。draw(_:in:) 方法则是在给定的宽高和位置绘制图像,可以让图片在特定的边距下裁剪,也可以适用于一些像是人脸识别之类的图像特性。最后 makeImage() 从上下文获取信息并且渲染到一个 CGImage 值上(之后会用来构造 UIImage 对象)。

技巧 #3:使用 Image I/O 创建缩略图像

Image I/O 是一个强大(却鲜有人知)的图像处理框架。抛开 Core Graphics 不说,它可以读写许多不同图像格式,访问图像的元数据,还有执行常规的图像处理操作。这个框架通过先进的缓存机制,提供了平台上最快的图片编码器和解码器,甚至可以增量加载图片。

这个重要的 CGImageSourceCreateThumbnailAtIndex 提供了一个带有许多不同配置选项的 API,比起在 Core Graphics 中等价的处理操作要简洁得多:

import ImageIO

// 技巧 #3
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageIfAbsent: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: max(size.width, size.height)
]

guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
let image = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options as CFDictionary)
else {
return nil
}

return UIImage(cgImage: image)
}

给定一个 CGImageSource 和一系列配置选项,CGImageSourceCreateThumbnailAtIndex(_:_:_:) 函数创建了一个图像的缩略图。优化尺寸大小的操作是通过 kCGImageSourceThumbnailMaxPixelSize 完成的,它根据图像原始宽高比指定的最大尺寸来缩放图像。通过设定 kCGImageSourceCreateThumbnailFromImageIfAbsentkCGImageSourceCreateThumbnailFromImageAlways 选项,Image I/O 可以自动缓存优化后的结果以便后续调用。

技巧 #4:使用 Core Image 进行 Lanczos 重采样

Core Image 内置了 Lanczos 重采样(resampling) 功能,它是以 CILanczosScaleTransform 的同名滤镜命名的。虽然可以说它是在 UIKit 层级之上的 API,但无处不在的 key-value 编写方式导致它使用起来很不方便。

即便如此,它的处理模式还是一致的。

创建转换滤镜,对滤镜进行配置,最后渲染输出图像,这样的步骤和其他任何 Core Image 的工作流没什么不同。

import UIKit
import CoreImage

let sharedContext = CIContext(options: [.useSoftwareRenderer : false])

// 技巧 #4
func resizedImage(at url: URL, scale: CGFloat, aspectRatio: CGFloat) -> UIImage? {
guard let image = CIImage(contentsOf: url) else {
return nil
}

let filter = CIFilter(name: "CILanczosScaleTransform")
filter?.setValue(image, forKey: kCIInputImageKey)
filter?.setValue(scale, forKey: kCIInputScaleKey)
filter?.setValue(aspectRatio, forKey: kCIInputAspectRatioKey)

guard let outputCIImage = filter?.outputImage,
let outputCGImage = sharedContext.createCGImage(outputCIImage,
from: outputCIImage.extent)
else {
return nil
}

return UIImage(cgImage: outputCGImage)
}

这个名叫 CILanczosScaleTransform 的 Core Image 滤镜分别接收了 inputImageinputScaleinputAspectRatio 三个参数,每一个参数的意思也都不言自明。

更有趣的是,CIContext 在这里被用来创建一个 UIImage(间接通过 CGImageRef 表示),因为 UIImage(CIImage:) 经常不能按我们本意使用。创建 CIContext 是一个代价很昂贵的操作,所以使用上下文缓存以便重复的渲染工作。

一个 CIContext 可以使用 GPU 或者 CPU(慢很多)渲染创建出来。通过指定构造方法中的 .useSoftwareRenderer 选项来选择使用哪个硬件。(提示:用更快的那个,你觉得呢?)

技巧 #5: 使用 vImage 优化图片渲染

最后一个了,它是古老的 Accelerate 框架 —— 更具体点来说,它是 vImage 的图像处理子框架。

vImage 附带有 一些不同的功能,可以用来裁剪图像缓冲区大小。这些底层 API 保证了高性能同时低能耗,但会导致你对缓冲区的管理操作增加(更不用说要编写更多的代码了):

import UIKit
import Accelerate.vImage

// 技巧 #5
func resizedImage(at url: URL, for size: CGSize) -> UIImage? {
// 解码源图像
guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, nil),
let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any],
let imageWidth = properties[kCGImagePropertyPixelWidth] as? vImagePixelCount,
let imageHeight = properties[kCGImagePropertyPixelHeight] as? vImagePixelCount
else {
return nil
}

// 定义图像格式
var format = vImage_CGImageFormat(bitsPerComponent: 8,
bitsPerPixel: 32,
colorSpace: nil,
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.first.rawValue),
version: 0,
decode: nil,
renderingIntent: .defaultIntent)

var error: vImage_Error

// 创建并初始化源缓冲区
var sourceBuffer = vImage_Buffer()
defer { sourceBuffer.data.deallocate() }
error = vImageBuffer_InitWithCGImage(&sourceBuffer,
&format,
nil,
image,
vImage_Flags(kvImageNoFlags))
guard error == kvImageNoError else { return nil }

// 创建并初始化目标缓冲区
var destinationBuffer = vImage_Buffer()
error = vImageBuffer_Init(&destinationBuffer,
vImagePixelCount(size.height),
vImagePixelCount(size.width),
format.bitsPerPixel,
vImage_Flags(kvImageNoFlags))
guard error == kvImageNoError else { return nil }

// 优化缩放图像
error = vImageScale_ARGB8888(&sourceBuffer,
&destinationBuffer,
nil,
vImage_Flags(kvImageHighQualityResampling))
guard error == kvImageNoError else { return nil }

// 从目标缓冲区创建一个 CGImage 对象
guard let resizedImage =
vImageCreateCGImageFromBuffer(&destinationBuffer,
&format,
nil,
nil,
vImage_Flags(kvImageNoAllocate),
&error)?.takeRetainedValue(),
error == kvImageNoError
else {
return nil
}

return UIImage(cgImage: resizedImage)
}

这里使用 Accelerate API 进行的明确操作,比起目前为止讨论到的其他优化方法更加底层。但暂时不管这些不友好的类型申明和函数名称的话,你会发现这个方法相当直接了当。

  • 首先,根据你传入的图像创建一个输入的源缓冲区,
  • 接着,创建一个输出的目标缓冲区来接受优化后的图像,
  • 然后,在源缓冲区裁剪图像数据,然后传给目标缓冲区,
  • 最后,从目标缓冲区中根据处理完后的图像创建 UIImage 对象。

性能对比

那么这些不同的方法是如何相互对比的呢?

这个项目 是一些 性能对比 结果,运行环境是 iPhone 7 iOS 12.2。

image-resizing-app-screenshot

下面的这些数字是多次迭代加载、优化、渲染之前那张 超大地球图片 的平均时间:

耗时 (seconds)
技巧 #1: UIKit 0.1420
技巧 #2: Core Graphics 1 0.1722
技巧 #3: Image I/O 0.1616
技巧 #4: Core Image 2 2.4983
技巧 #5: vImage 2.3126

1  
设置不同的 CGInterpolationQuality 值出来的结果是一致的,在性能上的差异可以忽略不计。

2  
若在 CIContext 创建时设置 kCIContextUseSoftwareRenderer 的值为 true,会导致耗时相比基础结果慢一个数量级。

总结

  • UIKit, Core Graphics, 和 Image I/O 都能很好地用于大部分图片的优化操作。如果(在 iOS 平台,至少)要选择一个的话,UIGraphicsImageRenderer 是你最佳的选择。
  • Core Image 在图像优化渲染操作方面性能表现优越。实际上,根据 Apple 官方 Core Image 编程规范中的性能最佳实践单元,你应该使用 Core Graphics 或 Image I/O 对图像进行裁剪和下采样,而不是用 Core Image。
  • 除非你已经在使用 vImage,否则在大多数情况下用到底层的 Accelerate API 所需的额外工作可能是不合理的。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

Swift 5 字符串插值-AttributedStrings

作者:Olivier Halligon,原文链接,原文日期:2018-12-16
译者:Nemocdz;校对:numbbbbbWAMaker;定稿:Pancf


我们已经在 前文 里介绍了 Swift 5 全新的 StringInterpolation 设计。在这第二部分中,我会着眼于 ExpressibleByStringInterpolation 其中一种应用,让 NSAttributedString 变得更优雅。

目标

在看到 Swift 5 这个全新的 StringInterpolation 设计 时,我马上想到的应用之一就是简化 NSAttributedString 的生成。

我的目标是做到用类似下面的语法创建一个 attributed 字符串:

let username = "AliGator"
let str: AttrString = """
Hello \(username, .color(.red)), isn't this \("cool", .color(.blue), .oblique, .underline(.purple, .single))?

\(wrap: """
\(" Merry Xmas! ", .font(.systemFont(ofSize: 36)), .color(.red), .bgColor(.yellow))
\(image: #imageLiteral(resourceName: "santa.jpg"), scale: 0.2)
""", .alignment(.center))

Go there to \("learn more about String Interpolation", .link("https://github.com/apple/swift-evolution/blob/master/proposals/0228-fix-expressiblebystringinterpolation.md"), .underline(.blue, .single))!
"""

这一大串字符串不仅使用了多行字符串的字面量语法(顺带一提,这个特性是在 Swift4 中新增的,以免你错过了) ——而且在其中一个多行字符串字面量中包含了另一个(见 \(wrap: ...) 段落)!- 甚至还包含了给一部分字符添加一些样式的插值……所以由大量 Swift 新特性组合而成!

这个 NSAttributedString 如果在一个 UILabel 或者 NSTextView 中渲染,结果是这个样子的:

image

☝️ 是的,上面的文字和图片……真的是一个 NSAttributedString(而不是一个复杂的视图布局或者其他)! 🤯

初步实现

所以,从哪里开始实现?当然和第一部分中如何实现 GitHubComment 差不多!

好的,在实际解决字符串插值之前,我们先从声明特有类型开始。

struct AttrString {
let attributedString: NSAttributedString
}

extension AttrString: ExpressibleByStringLiteral {
init(stringLiteral: String) {
self.attributedString = NSAttributedString(string: stringLiteral)
}
}

extension AttrString: CustomStringConvertible {
var description: String {
return String(describing: self.attributedString)
}
}

挺简单的吧?仅仅给 NSAttributedString 封装了一下。现在,让我们添加 ExpressibleByStringInterpolation 的支持,来同时支持字面量和带 NSAttributedString 属性注释的字符串。

extension AttrString: ExpressibleByStringInterpolation {
init(stringInterpolation: StringInterpolation) {
self.attributedString = NSAttributedString(attributedString: stringInterpolation.attributedString)
}

struct StringInterpolation: StringInterpolationProtocol {
var attributedString: NSMutableAttributedString

init(literalCapacity: Int, interpolationCount: Int) {
self.attributedString = NSMutableAttributedString()
}

func appendLiteral(_ literal: String) {
let astr = NSAttributedString(string: literal)
self.attributedString.append(astr)
}

func appendInterpolation(_ string: String, attributes: [NSAttributedString.Key: Any]) {
let astr = NSAttributedString(string: string, attributes: attributes)
self.attributedString.append(astr)
}
}
}

这时,已经可以用下面这种方式简单地构建一个 NSAttributedString 了:

let user = "AliSoftware"
let str: AttrString = """
Hello \(user, attributes: [.foregroundColor: NSColor.blue])!
"""

这看起来已经优雅多了吧?

方便的样式添加

但用字典 [NAttributedString.Key: Any] 的方式处理属性不够优雅。特别是由于 Any 没有明确类型,要求了解每一个键值的明确类型……

所以可以通过创建特有的 Style 类型让它变得更优雅,并帮助我们构建属性的字典:

extension AttrString {
struct Style {
let attributes: [NSAttributedString.Key: Any]
static func font(_ font: NSFont) -> Style {
return Style(attributes: [.font: font])
}
static func color(_ color: NSColor) -> Style {
return Style(attributes: [.foregroundColor: color])
}
static func bgColor(_ color: NSColor) -> Style {
return Style(attributes: [.backgroundColor: color])
}
static func link(_ link: String) -> Style {
return .link(URL(string: link)!)
}
static func link(_ link: URL) -> Style {
return Style(attributes: [.link: link])
}
static let oblique = Style(attributes: [.obliqueness: 0.1])
static func underline(_ color: NSColor, _ style: NSUnderlineStyle) -> Style {
return Style(attributes: [
.underlineColor: color,
.underlineStyle: style.rawValue
])
}
static func alignment(_ alignment: NSTextAlignment) -> Style {
let ps = NSMutableParagraphStyle()
ps.alignment = alignment
return Style(attributes: [.paragraphStyle: ps])
}
}
}

这允许使用 Style.color(.blue) 来简单地创建一个封装了 [.foregroundColor: NSColor.blue]Style

可别止步于此,现在让我们的 StringInterpolation 可以处理下面这样的 Style 属性!

这个想法是可以做到像这样写:

let str: AttrString = """
Hello \(user, .color(.blue)), how do you like this?
"""

是不是更优雅?而我们仅仅需要为它正确实现 appendInterpolation 而已!

extension AttrString.StringInterpolation {
func appendInterpolation(_ string: String, _ style: AttrString.Style) {
let astr = NSAttributedString(string: string, attributes: style.attributes)
self.attributedString.append(astr)
}

然后就完成了!但……这样一次只支持一个 Style。为什么不允许它传入多个 Style 作为形参呢?这可以用一个 [Style] 形参来实现,但这要求调用侧将样式列表用括号括起来……不如让它使用可变形参?

让我们用这种方式来代替之前的实现:

extension AttrString.StringInterpolation {
func appendInterpolation(_ string: String, _ style: AttrString.Style...) {
var attrs: [NSAttributedString.Key: Any] = [:]
style.forEach { attrs.merge($0.attributes, uniquingKeysWith: {$1}) }
let astr = NSAttributedString(string: string, attributes: attrs)
self.attributedString.append(astr)
}
}

现在可以将多种样式混合起来了!

let str: AttrString = """
Hello \(user, .color(.blue), .underline(.red, .single)), how do you like this?
"""

支持图像

NSAttributedString 的另一种能力是使用 NSAttributedString(attachment: NSTextAttachment) 添加图像,让它成为字符串的一部分。要实现它,仅需要实现 appendInterpolation(image: NSImage) 并调用它。

我希望为这个特性顺便加上缩放图像的能力。由于我是在 macOS 的 playground 上尝试的,它的图形上下文是翻转的,所以也得将图像翻转回来(注意这个细节可能会和 iOS 上实现对 UIImage 的支持时不一样)。这里是我的做法:

extension AttrString.StringInterpolation {
func appendInterpolation(image: NSImage, scale: CGFloat = 1.0) {
let attachment = NSTextAttachment()
let size = NSSize(
width: image.size.width * scale,
height: image.size.height * scale
)
attachment.image = NSImage(size: size, flipped: false, drawingHandler: { (rect: NSRect) -> Bool in
NSGraphicsContext.current?.cgContext.translateBy(x: 0, y: size.height)
NSGraphicsContext.current?.cgContext.scaleBy(x: 1, y: -1)
image.draw(in: rect)
return true
})
self.attributedString.append(NSAttributedString(attachment: attachment))
}
}

样式嵌套

最后,有时候你会希望应用一个样式在一大段文字上,但里面可能也包含了子段落的样式。就像 HTML 里的 "<b>Hello <i>world</i></b>",整段是粗体但包含了一部分斜体的。

现在我们的 API 还不支持这样,所以让我们来加上它。思路是允许将一串 Style… 不止应用在 String 上,还能应用在已经存在属性的 AttrString 上。

这个实现和 appendInterpolation(_ string: String, _ style: Style…) 相似,但会修改 AttrString.attributedString添加属性到上面,而不是单纯用 String 创建一个全新的 NSAttributedString

extension AttrString.StringInterpolation {
func appendInterpolation(wrap string: AttrString, _ style: AttrString.Style...) {
var attrs: [NSAttributedString.Key: Any] = [:]
style.forEach { attrs.merge($0.attributes, uniquingKeysWith: {$1}) }
let mas = NSMutableAttributedString(attributedString: string.attributedString)
let fullRange = NSRange(mas.string.startIndex..<mas.string.endIndex, in: mas.string)
mas.addAttributes(attrs, range: fullRange)
self.attributedString.append(mas)
}
}

上面这些全部完成之后,目标就达成了,终于可以用单纯的字符串加上插值创建一个 AttributedString:

let username = "AliGator"
let str: AttrString = """
Hello \(username, .color(.red)), isn't this \("cool", .color(.blue), .oblique, .underline(.purple, .single))?

\(wrap: """
\(" Merry Xmas! ", .font(.systemFont(ofSize: 36)), .color(.red), .bgColor(.yellow))
\(image: #imageLiteral(resourceName: "santa.jpg"), scale: 0.2)
""", .alignment(.center))

Go there to \("learn more about String Interpolation", .link("https://github.com/apple/swift-evolution/blob/master/proposals/0228-fix-expressiblebystringinterpolation.md"), .underline(.blue, .single))!
"""

imgage

结论

期待你享受这一系列 StringInterpolation 文章,并且能从中瞥到这个新设计威力的冰山一角。

你可以 在这下载我的 Playground 文件,里面有 GitHubComment(见 第一部分),AttrString 的全部实现,说不定还能从我简单实现 RegEX 的尝试中得到一些灵感。

这里还有更多更好的思路去使用 Swift 5 中新的 ExpressibleByStringInterpolation API - 包括 Erica Sadun 博客里这篇这篇这篇 - 还在犹豫什么,阅读更多……从中感受乐趣吧!


  1. 这篇文章和 Playground 里的代码,需要使用 Swift 5。在写作时,最新的 Xcode 版本是 10.1,Swift 4.2,所以如果你想尝试这些代码,需要遵循官方指南去下载开发中的 Swift 5 快照。安装 Swift 5 工具链并在 Xcode 偏好设置里启用并不困难(见官方指南)。
  2. 当然,这里仅作为 Demo,只实现了一部分样式。未来可以延伸思路让 Style 类型支持更多的样式,在理想情况下,可以覆盖所有存在 NSAttributedString.Key

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

宏定义与可选括号

作者:Mike Ash,原文链接,原文日期:2015-03-20
译者:俊东;校对:numbbbbbNemocdz;定稿:Pancf

前几天我遇到了一个有趣的问题:如何编写一个 C 语言预处理器的宏,删除包围实参的括号?

今天的文章,将为大家分享我的解决方案。

起源

C 语言预处理器是一个相当盲目的文本替换引擎,它并不理解 C 代码,更不用说 Objective-C 了。它的工作原理还算不错,可以应付大部分情况,但偶尔也会出现判断失误。

这里举个典型的例子:

XCTAssertEqualObjects(someArray, @[ @"one", @"two" ], @"Array is not as expected");

这会无法编译,并且会出现非常古怪的错误提示。预处理器查找分隔宏参数的逗号时,没能将数组结构 @ [...] 中的东西理解为一个单一的元素。结果代码尝试比较 someArray@[@"one"。断言失败消息 @"two"]@"Array is not as expected" 是另外的实参。这些半成品部分用于 XCTAssertEqualObjects 的宏扩展中,生成的代码当然错得离谱。

要解决这个问题也很容易:添加括号就行。预编译器不能识别 [],但它确实知道 () 并且能够理解应该忽略里面的逗号。下面的代码就能正常运行:

XCTAssertEqualObjects(someArray, (@[ @"one", @"two" ]), @"Array is not as expected");

在 C 语言的许多场景下,你添加多余的括号也不会有任何区别。宏扩展开之后,生成的代码虽然在数组文字周围有括号,但没有异常。你可以写搞笑的多层括号表达式,编译器会愉快地帮你解析到最里面一层:

NSLog(@"%d",((((((((((42)))))))))));

甚至将 NSLog 这样处理也行:

((((((((((NSLog))))))))))(@"%d",42);

在 C 中有一个地方你不能随意添加括号:类型(types)。例如:

int f(void); // 合法
(int) f(void); // 不合法

什么时候会发生这种情况呢?这种情况并不常见,但如果你有一个使用类型的宏,并且类型包含的逗号不在括号内,则会出现这种情况。宏可以做很多事情,当一个类型遵循多个协议时,在 Objective-C 中可能出现一些类型带有未加括号的逗号;当使用带有多个模板参数的模板化类型时,在 C++ 中也可能出现。举个例子,这有一个简单的宏,创建从字典中提供静态类型值的 getter

#define GETTER(type,name) \
- (type)name { \
return [_dictionary objectForKey: @#name]; \
}

你能这样使用它:

@implementation SomeClass {
NSDictionary *_dictionary;
}

GETTER(NSView *,view)
GETTER(NSString *,name)
GETTER(id<NSCopying>,someCopyableThing)

到目前为止没问题。现在假设我们想要创建一个遵循两个协议的类型:

GETTER(id<NSCopying,NSCoding>,someCopyableAndCodeableThing)

哎呀!宏不起作用了。而且添加括号也无济于事:

GETTER((id<NSCopying,NSCoding>),someCopyableAndCodeableThing)

这会产生非法代码。这时我们需要一个删除可选括号的 UNPAREN 宏。将 GETTER 宏重写:

#define GETTER(type,name) \
- (UNPAREN(type))name { \
return [_dictionary objectForKey: @#name]; \
}

我们该怎么做呢?

必须的括号

删除括号很容易:

#define UNPAREN(...) __VA_ARGS__
#define GETTER(type,name) \
- (UNPAREN type)name { \
return [_dictionary objectForKey: @#name]; \
}

虽然看上去很扯,但这的确能运行。预编译器将 type 扩展为 (id <NSCopying,NSCoding>),生成 UNPAREN (id<NSCopying, NSCoding>)。然后它会将 UNPAREN 宏扩展为 id <NSCopying,NSCoding>。括号,消失!

但是,之前使用的 GETTER 失败了。例如,GETTER(NSView *,view) 在宏扩展中生成 UNPAREN NSView *。不会进一步扩展就直接提供给编译器。结果自然会报编译器错误,因为 UNPAREN NSView * 是无法编译的。这虽然可以通过编写 GETTER((NSView *),view) 来解决,但是被迫添加这些括号很烦人。这样的结果可不是我们想要的。

宏不能被重载

我立刻想到了如何摆脱剩余的 UNPAREN。当你想要一个标识符消失时,你可以使用一个空的 #define,如下所示:

#define UNPAREN

有了这个,a UNPAREN b 的序列变为 a b。完美解决问题!但是,如果已经存在带参数的另一个定义,则预处理器会拒绝此操作。即使预处理器可能选择其中一个,它也不会同时存在两种形式。如果可行的话,这能有效解决我们的问题,但可惜的是并不允许:

#define UNPAREN(...) __VA_ARGS__
#define UNPAREN
#define GETTER(type,name) \
- (UNPAREN type)name { \
return [_dictionary objectForKey: @#name]; \
}

这无法通过预处理器,它会由于 UNPAREN 的重复 #define 而报错。不过,它引导我们走上了成功的道路。现在的瓶颈是怎么找出一种方法来实现相同的效果,而不会使两个宏具有相同的名称。

关键

最终目标是让 UNPAREN(x)UNPAREN((x)) 结果都是 x。朝着这个目标迈出的第一步是制作一些宏,其中传递 x(x) 产生相同的输出,即使它并不确定 x 是什么。这可以通过将宏名称放在宏扩展中来实现,如下所示:

#define EXTRACT(...) EXTRACT __VA_ARGS__

现在如果你写 EXTRACT(x),结果是 EXTRACT x。当然,如果你写 EXTRACT x,结果也是 EXTRACT x,就像没有宏扩展的情况。这仍然给我们留下一个 EXTRACT。虽然不能用 #define 直接解决,但这已经进步了。

标识符粘合

预处理器有一个操作符 ##,它将两个标识符粘合在一起。例如,a ## b 变为 ab。这可以用于从片段构造标识符,但也可以用于调用宏。例如:

#define AA 1
#define AB 2
#define A(x) A ## x

从这里可以看到,A(A) 产生 1A(B) 产生 2

让我们将这个运算符与上面的 EXTRACT 宏结合起来,尝试生成一个 UNPAREN 宏。由于 EXTRACT(...) 使用前缀 EXTRACT 生成实参,因此我们可以使用标识符粘合来生成以 EXTRACT 结尾的其他标记。如果我们 #define 那个新标记为空,那就搞定了。

这是一个以 EXTRACT 结尾的宏,它不会产生任何结果:

#define NOTHING_EXTRACT

这是对 UNPAREN 宏的尝试,它将所有内容放在一起:

#define UNPAREN(x) NOTHING_ ## EXTRACT x

不幸的是,这并不能实现我们的目标。问题在操作顺序上。如果我们写 UNPAREN((int)),我们将会得到:

UNPAREN((int))
NOTHING_ ## EXTRACT (int)
NOTHING_EXTRACT (int)
(int)

标示符粘合太早起作用,EXTRACT 宏永远不会有机会扩展开。

可以使用间接的方式强制预处理器用不同的顺序判断事件。我们可以制作一个 PASTE 宏,而不是直接使用 ##

#define PASTE(x,...) x ## __VA_ARGS__

然后我们将根据它编写 UNPAREN

#define UNPAREN(x)  PASTE(NOTHING_,EXTRACT x)

仍然不起作用。情况如下:

UNPAREN((int))
PASTE(NOTHING_,EXTRACT (int))
NOTHING_ ## EXTRACT (int)
NOTHING_EXTRACT (int)
(int)

但更接近我们的目标了。序列 EXTRACT(int) 显然没有触发标示符粘合操作符。我们必须让预处理器在它看到 ## 之前解析它。可以通过另一种方式间接强制解析它。让我们定义一个只包装 PASTEEVALUATING_PASTE 宏:

#define EVALUATING_PASTE(x,...) PASTE(x,__VA_ARGS__)

现在让我们用UNPAREN

#define UNPAREN(x) EVALUATING_PASTE(NOTHING_,EXTRACT x)

这是展开之后:

UNPAREN((int))
EVALUATING_PASTE(NOTHING_,EXTRACT (int))
PASTE(NOTHING_,EXTRACT int)
NOTHING_ ## EXTRACT int
NOTHING_EXTRACT int
int

即使没有额外加括号也能正常运行,因为额外的赋值并没有影响:

UNPAREN(int)
EVALUATING_PASTE(NOTHING_,EXTRACT int)
PASTE(NOTHING_,EXTRACT int)
NOTHING_ ## EXTRACT int
NOTHING_EXTRACT int
int

成功了!我们现在编写 GETTER 时可以不需要围绕类型的括号了:

#define GETTER(type,name) \
- (UNPAREN(type))name { \
return [_dictionary objectForKey: @#name]; \
}

奖励宏

在选择一些宏来证明这个结构时,我构建了一个很好的 dispatch_once 宏来制作延迟初始化的常量。实现如下:

#define ONCE(type,name,...) \
UNPAREN(type) name() { \
static UNPAREN(type) static_ ## name; \
static dispatch_once_t predicate; \
dispatch_once(&predicate,^{ \
static_ ## name = ({ __VA_ARGS__; }); \
}); \
return static_ ## name; \
}

使用案例:

ONCE(NSSet *,AllowedFileTypes,[NSSet setWithArray:@[ @"mp3",@"m4a",@"aiff" ]])

然后,你可以调用 AllowedFileTypes() 来获取集合,并根据需要高效创建集合。如果类型不巧包括括号,添加括号就能运行。

结论

仅仅写这个宏,我就发现了很多艰涩的知识。我希望接触这些知识也不会影响你的思维。请谨慎使用这些知识。

今天就这样。以后还会有更多令人兴奋的探索,可能比这还要再不可思议。在此之前,如果你对此主题有任何建议,请发送给 我们

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

Swift 关键字

作者:Jordan Morgan,原文链接,原文日期:2017-02-11
译者:郑一一;校对:numbbbbbpmst;定稿:Pancf

有句话之前我提过,今天还想再说一次。那就是打铁还需自身硬。对于自身能力的严格要求,可以帮助实现我们所有梦寐以求的东西。

说起来可能有些消极,知识毕竟是永远学不完的。不论如何,今天 我们先来学习一下 Swift 中的每一个关键字(V3.0.1),在介绍每个关键字的时候,同时会附带一段代码加以说明。

在这些关键字之中,会有你熟悉或者不熟悉的部分。但为了最好的阅读和学习体验,我把它们全部列出来了。文章篇幅有些长,你准备好了么?

让我们现在就开始吧。

声明式关键字

associatedtype:在协议中,定义一个类型的占位符名称。直到协议被实现,该占位符才会被指定具体的类型。

protocol Entertainment  
{
associatedtype MediaType
}

class Foo : Entertainment
{
typealias MediaType = String //可以指定任意类型
}

class:通用、灵活的结构体,是程序的基础组成部分。与 struct 类似,不同之处在于:

  • 允许一个类继承另一个类的特性。
  • 类型转换,允许在运行时检查和指定一个类的实际类型。
  • 析构方法允许类的实例释放所有资源。
  • 引用计数允许多个引用指向一个实例。
class Person  
{
var name:String
var age:Int
var gender:String
}

deinit:当一个类的实例即将被销毁时,会调用这个方法。

class Person  
{
var name:String
var age:Int
var gender:String

deinit
{
//从堆中释放,并释放的资源
}
}

enum:定义了包含一组有关联的值的类型,并可以以一种类型安全的方式使用这些值。在 Swift 中,枚举是一等类型,拥有在其它语言中只有 class 才会支持的特性。

enum Gender  
{
case male
case female
}

extension:允许给已有的类、结构体、枚举、协议类型,添加新功能。

class Person  
{
var name:String = ""
var age:Int = 0
var gender:String = ""
}

extension Person
{
func printInfo()
{
print("My name is \(name), I'm \(age) years old and I'm a \(gender).")
}
}

fileprivate:访问控制权限,只允许在定义源文件中访问。

class Person  
{
fileprivate var jobTitle:String = ""
}

extension Person
{
//当 extension 和 class 在同一个文件中时,允许访问
func printJobTitle()
{
print("My job is (jobTitle)")
}
}

func:包含用于执行特定任务的代码块。

func addNumbers(num1:Int, num2:Int) -> Int  
{
return num1+num2
}

import:引入一个以独立单元构建的框架或者应用。

import UIKit

//可以使用 UIKit 框架下的所有代码
class Foo {}

init:类、结构体、枚举的实例的初始化准备过程。

class Person
{
init()
{
//设置默认值,实例准备被使用
}
}

inout:将一个值传入函数,并可以被函数修改,然后将值传回到调用处,来替换初始值。适用于引用类型和值类型。

func dangerousOp(_ error:inout NSError?)  
{
error = NSError(domain: "", code: 0, userInfo: ["":""])
}

var potentialError:NSError?
dangerousOp(&potentialError)

//代码运行到这里,potentialError 不再是 nil,而是已经被初始化

internal:访问控制权限,允许同一个模块下的所有源文件访问,如果在不同模块下则不允许访问。

class Person  
{
internal var jobTitle:String = ""
}

let aPerson = Person()
aPerson.jobTitle = "This can set anywhere in the application"

let:定义一个不可变的变量。

let constantString = "This cannot be mutated going forward"

open:访问控制权限,允许在定义的模块外也可以访问源文件里的所有类,并进行子类化。对于类成员,允许在定义的模块之外访问和重写。

open var foo:String? //这个属性允许在 app 内或 app 外重写和访问。在开发框架的时候,会应用到这个访问修饰符。

operator:特殊符号,用于检查、修改、组合值。

//一元运算符 "-",改变值的符号
let foo = 5
let anotherFoo = -foo //anotherFoo 等于 -5

//二元运算符 "+" 将两个值相加
let box = 5 + 3

//逻辑运算符 "&&" 将两个布尔值进行组合运算
if didPassCheckOne && didPassCheckTwo

//三元运算符需要使用三个值
let isLegalDrinkingAgeInUS:Bool = age >= 21 ? true : false

private:访问控制权限,只允许实体在定义的类以及相同源文件内的 extension 中访问。

class Person  
{
private var jobTitle:String = ""
}

// 当 extension 和 class 不在同一个源文件时
extension Person
{
// 无法编译通过,只有在同一个源文件下才可以访问
func printJobTitle()
{
print("My job is (jobTitle)")
}
}

protocol:定义了一组方法、属性或其它要求,用于满足特定任务和一系列功能。

protocol Blog  
{
var wordCount:Int { get set }
func printReaderStats()
}

class TTIDGPost : Blog
{
var wordCount:Int

init(wordCount:Int)
{
self.wordCount = wordCount
}

func printReaderStats()
{
//打印 post 的数据
}
}

public:访问控制权限,允许在定义的模块外也可以访问源文件里的所有类,但只有在同一个模块内可以进行子类化。对于类成员,允许在同个模块下访问和重写。

public var foo:String? //只允许在 app 内重写和访问。

static:用于定义类方法,在类型本身进行调用。此外还可以定义静态成员。

class Person  
{
var jobTitle:String?

static func assignRandomName(_ aPerson:Person)
{
aPerson.jobTitle = "Some random job"
}
}

let somePerson = Person()
Person.assignRandomName(somePerson)
//somePerson.jobTitle 的值是 "Some random job"

struct:通用、灵活的结构体,是程序的基础组成部分,并提供了默认初始化方法。与 class 不同,当 struct 在代码中被传递时,是被拷贝的,并不使用引用计数。除此之外,struct 没有下面的这些功能:

  • 使用继承。
  • 运行时的类型转换。
  • 使用析构方法。
struct Person  
{
var name:String
var age:Int
var gender:String
}

subscript:访问集合、列表、序列中成员元素的快捷方式。

var postMetrics = ["Likes":422, "ReadPercentage":0.58, "Views":3409]  
let postLikes = postMetrics["Likes"]

typealias:给代码中已经存在的类,取别名。

typealias JSONDictionary = [String: AnyObject]

func parseJSON(_ deserializedData:JSONDictionary){}

var:定义可变变量。

var mutableString = ""  
mutableString = "Mutated"

语句中的关键词

break:终止程序中循环的执行,比如 if 语句、switch 语句。

for idx in 0...3  
{
if idx % 2 == 0
{
//当 idx 等于偶数时,退出 for 循环
break
}
}

case:该语句在 switch 语句中列出,在每个分支可以进行模式匹配。

let box = 1

switch box
{
case 0:
print("Box equals 0")
case 1:
print("Box equals 1")
default:
print("Box doesn't equal 0 or 1")
}

continue:用于终止循环的当前迭代,并进入下一次迭代,而不会停止整个循环的执行。

for idx in 0...3  
{
if idx % 2 == 0
{
//直接开始循环的下一次迭代
continue
}

print("This code never fires on even numbers")
}

default:用于涵盖在 switch 语句中,所有未明确列出的枚举成员。

let box = 1

switch box
{
case 0:
print("Box equals 0")
case 1:
print("Box equals 1")
default:
print("Covers any scenario that doesn't get addressed above.")
}

defer:用于在程序离开当前作用域之前,执行一段代码。

func cleanUpIO()  
{
defer
{
print("This is called right before exiting scope")
}


//关闭文件流等。
}

do:用于表示处理错误代码段的开始。

do  
{
try expression
//语句
}
catch someError ex
{
//处理错误
}

else:与 if 语句结合使用。当条件为 true,执行一段代码。当条件为 false,执行另一段代码。

if val > 1  
{
print("val is greater than 1")
}
else
{
print("val is not greater than 1")
}

fallthrough:显式地允许从当前 case 跳转到下一个相邻 case 继续执行代码。

let box = 1

switch box
{
case 0:
print("Box equals 0")
fallthrough
case 1:
print("Box equals 0 or 1")
default:
print("Box doesn't equal 0 or 1")
}

for:在序列上迭代,比如一组特定范围内的数字,数组中的元素,字符串中的字符。*与关键字 in 成对使用。

for _ in 0..<3 { print ("This prints 3 times") }

guard:当有一个以上的条件不满足要求时,将离开当前作用域。同时还提供解包可选类型的功能。

private func printRecordFromLastName(userLastName: String?)
{
guard let name = userLastName, name != "Null" else
{
//userLastName = "Null",需要提前退出
return
}

//继续执行代码
print(dataStore.findByLastName(name))
}

if:当条件满足时,执行代码。

if 1 > 2  
{
print("This will never execute")
}

in:在序列上迭代,比如一组特定范围内的数字,数组中的元素,字符串中的字符。*与关键字 key 搭配使用。

for _ in 0..<3 { print ("This prints 3 times") }

repeat:在使用循环的判断条件之前,先执行一次循环中的代码。

repeat  
{
print("Always executes at least once before the condition is considered")
}
while 1 > 2

return:立刻终止当前上下文,离开当前作用域,此外在返回时可以额外携带一个值。

func doNothing()  
{
return //直接离开当前上下文

let anInt = 0
print("This never prints (anInt)")
}

func returnName() -> String?  
{
return self.userName //离开,并返回 userName 的值
}

switch:将给定的值与分支进行比较。执行第一个模式匹配成功的分支代码。

let box = 1

switch box
{
case 0:
print("Box equals 0")
fallthrough
case 1:
print("Box equals 0 or 1")
default:
print("Box doesn't equal 0 or 1")
}

where:要求关联类型必须遵守特定协议,或者类型参数和关联类型必须保持一致。也可以用于在 case 中提供额外条件,用于满足控制表达式。

where 从句可以应用于多种场景。以下例子指明了 where 的主要应用场景,泛型中的模式匹配。

protocol Nameable  
{
var name:String {get set}
}

func createdFormattedName(_ namedEntity:T) -> String where T:Equatable
{
//只有当实体同时遵守 Nameable 和 Equatable 协议的时候,才允许调用这个函数
return "This things name is " + namedEntity.name
}

for i in 03 where i % 2 == 0  
{
print(i) //打印 0 和 2
}

while:循环执行特定的一段语句,直到条件不满足时,停止循环。

while foo != bar  
{
print("Keeps going until the foo == bar")
}

表达式和类型中的关键字

Any:用于表示任意类型的实例,包括函数类型。

var anything = [Any]()

anything.append("Any Swift type can be added")
anything.append(0)
anything.append({(foo: String) -> String in "Passed in (foo)"})

as:类型转换运算符,用于尝试将值转成其它类型。

var anything = [Any]()

anything.append("Any Swift type can be added")
anything.append(0)
anything.append({(foo: String) -> String in "Passed in (foo)" })

let intInstance = anything[1] as? Int

或者

var anything = [Any]()

anything.append("Any Swift type can be added")
anything.append(0)
anything.append({(foo: String) -> String in "Passed in (foo)" })

for thing in anything
{
switch thing
{
case 0 as Int:
print("It's zero and an Int type")
case let someInt as Int:
print("It's an Int that's not zero but (someInt)")
default:
print("Who knows what it is")
}
}

catch:如果在 do 中抛出一个错误,catch 会尝试进行匹配,并决定如何处理错误。*我写的一篇 Swift 错误处理的博客节选

do  
{
try haveAWeekend(4)
}
catch WeekendError.Overtime(let hoursWorked)
{
print("You worked (hoursWorked) more than you should have")
}
catch WeekendError.WorkAllWeekend
{
print("You worked 48 hours :-0")
}
catch
{
print("Gulping the weekend exception")
}

false:Swift 用于表示布尔值的两个常量值之一,true 的相反值。

let alwaysFalse = false  
let alwaysTrue = true

if alwaysFalse { print("Won't print, alwaysFalse is false 😉")}

is:类型检查运算符,用于确定实例是否为某个子类类型。

class Person {}  
class Programmer : Person {}
class Nurse : Person {}

let people = [Programmer(), Nurse()]

for aPerson in people
{
if aPerson is Programmer
{
print("This person is a dev")
}
else if aPerson is Nurse
{
print("This person is a nurse")
}
}

nil:在 Swift 中表示任意类型的无状态值。

与 Objective-C 中的 nil 不同,Objective-C 中的 nil 表示指向不存在对象的指针。

class Person{}  
struct Place{}

//任何 Swift 类型或实例可以为 nil
var statelessPerson:Person? = nil
var statelessPlace:Place? = nil
var statelessInt:Int? = nil
var statelessString:String? = nil

rethrows:指明当前函数只有当参数抛出 error 时,才会抛出 error。

func networkCall(onComplete:() throws -> Void) rethrows  
{
do
{
try onComplete()
}
catch
{
throw SomeError.error
}
}

super:在子类中,暴露父类的方法、属性、下标。

class Person  
{
func printName()
{
print("Printing a name. ")
}
}

class Programmer : Person
{
override func printName()
{
super.printName()
print("Hello World!")
}
}

let aDev = Programmer()
aDev.printName() //打印 Printing a name. Hello World!

self:任何类型的实例都拥有的隐式属性,等同于实例本身。此外还可以用于区分函数参数和成员属性名称相同的情况。

class Person  
{
func printSelf()
{
print("This is me: (self)")
}
}

let aPerson = Person()
aPerson.printSelf() //打印 "This is me: Person"

Self:在协议中,表示遵守当前协议的实体类型。

protocol Printable  
{
func printTypeTwice(otherMe:Self)
}

struct Foo : Printable
{
func printTypeTwice(otherMe: Foo)
{
print("I am me plus (otherMe)")
}
}

let aFoo = Foo()
let anotherFoo = Foo()

aFoo.printTypeTwice(otherMe: anotherFoo) //打印 I am me plus Foo()

throw:用于在当前上下文,显式抛出 error。

enum WeekendError: Error  
{
case Overtime
case WorkAllWeekend
}

func workOvertime () throws
{
throw WeekendError.Overtime
}

throws:指明在一个函数、方法、初始化方法中可能会抛出 error。

enum WeekendError: Error  
{
case Overtime
case WorkAllWeekend
}

func workOvertime () throws
{
throw WeekendError.Overtime
}

//"throws" 表明在调用方法时,需要使用 try,try?,try!
try workOvertime()

true:Swift 用于表示布尔值的两个常量值之一,表示为真。

let alwaysFalse = false  
let alwaysTrue = true

if alwaysTrue { print("Always prints")}

try:表明接着调用的函数可能会抛出 error。有三种不同的使用方式:try,try?, try!。

let aResult = try dangerousFunction() //处理 error,或者继续传递 error  
let aResult = try! dangerousFunction() //程序可能会闪退
if let aResult = try? dangerousFunction() //解包可选类型。

模式中的关键字

_:用于匹配或省略任意值的通配符。

for _ in 0..<3  
{
print("Just loop 3 times, index has no meaning")
}

另外一种用法:

let _ = Singleton() //忽略不使用的变量

以#开头的关键字

#available:基于平台参数,通过 ifwhileguard 语句的条件,在运行时检查 API 的可用性。

if #available(iOS 10, *)  
{
print("iOS 10 APIs are available")
}

#colorLiteral:在 playground 中使用的字面表达式,用于创建颜色选取器,选取后赋值给变量。

let aColor = #colorLiteral //创建颜色选取器

#column:一种特殊的字面量表达式,用于获取字面量表示式的起始列数。

class Person  
{
func printInfo()
{
print("Some person info - on column (#column)")
}
}

let aPerson = Person()
aPerson.printInfo() //Some person info - on column 53

#else:条件编译控制语句,用于控制程序在不同条件下执行不同代码。与 #if 语句结合使用。当条件为 true,执行对应代码。当条件为 false,执行另一段代码。

#if os(iOS)  
print("Compiled for an iOS device")
#else
print("Not on an iOS device")
#endif

#elseif:条件编译控制语句,用于控制程序在不同条件下执行代码。与 #if 语句结合使用。当条件为 true,执行对应代码。

#if os(iOS)  
print("Compiled for an iOS device")
#elseif os(macOS)
print("Compiled on a mac computer")
#endif

#endif:条件编译控制语句,用于控制程序在不同条件下执行代码。用于表明条件编译代码的结尾。

#if os(iOS)  
print("Compiled for an iOS device")
#endif

#file:特殊字面量表达式,返回当前代码所在源文件的名称。

class Person  
{
func printInfo()
{
print("Some person info - inside file (#file)")
}
}

let aPerson = Person()
aPerson.printInfo() //Some person info - inside file /*代码所在 playground 文件路径*/

#fileReference:playground 字面量语法,用于创建文件选取器,选取并返回 NSURL 实例。

let fontFilePath = #fileReference //创建文件选取器

#function:特殊字面量表达式,返回函数名称。在方法中,返回方法名。在属性的 getter 或者 setter 中,返回属性名。在特殊的成员中,比如 init 或 subscript 中,返回关键字名称。在文件的最顶层时,返回当前所在模块名称。

class Person  
{
func printInfo()
{
print("Some person info - inside function (#function)")
}
}

let aPerson = Person()
aPerson.printInfo() //Some person info - inside function printInfo()

#if:条件编译控制语句,用于控制程序在不同条件下编译代码。通过判断条件,决定是否执行代码。

#if os(iOS)  
print("Compiled for an iOS device")
#endif

#imageLiteral:playground 字面量语法,创建图片选取器,选择并返回 UIImage 实例。

let anImage = #imageLiteral //在 playground 文件中选取图片

#line:特殊字面量表达式,用于获取当前代码的行数。

class Person  
{
func printInfo()
{
print("Some person info - on line number (#line)")
}
}

let aPerson = Person()
aPerson.printInfo() //Some person info - on line number 5

#selector:用于创建 Objective-C selector 的表达式,可以静态检查方法是否存在,并暴露给 Objective-C。

//静态检查,确保 doAnObjCMethod 方法存在  
control.sendAction(#selector(doAnObjCMethod), to: target, forEvent: event)

#sourceLocation:行控制语句,可以指定与原先完全不同的行数和源文件名。通常在 Swift 诊断、debug 时使用。

#sourceLocation(file:"foo.swift", line:6)

//打印新值
print(#file)
print(#line)

//重置行数和文件名
#sourceLocation()

print(#file)
print(#line)

特定上下文中的关键字

这些关键字,在处于对应上下文之外时,可以用作标识符。

associativity:指明同一优先级的运算符,在缺少大括号的情况,按什么顺序结合。使用 leftrightnone

infix operator ~ { associativity right precedence 140 }  
4 ~ 8

convenience:次等的便利构造器,最后会调用指定构造器初始化实例。

class Person  
{
var name:String

init(_ name:String)
{
self.name = name
}

convenience init()
{
self.init("No Name")
}
}

let me = Person()
print(me.name)//打印 "No Name"

dynamic:指明编译器不会对类成员或者函数的方法进行内联或虚拟化。这意味着对这个成员的访问是使用 Objective-C 运行时进行动态派发的(代替静态调用)。

class Person  
{
//隐式指明含有 "objc" 属性
//这对依赖于 Objc-C 黑魔法的库或者框架非常有用
//比如 KVO、KVC、Swizzling
dynamic var name:String?
}

didSet:属性观察者,当值存储到属性后马上调用。

var data = [1,2,3]  
{
didSet
{
tableView.reloadData()
}
}

final:防止方法、属性、下标被重写。

final class Person {}  
class Programmer : Person {} //编译错误

get:返回成员的值。还可以用在计算型属性上,间接获取其它属性的值。

class Person  
{
var name:String
{
get { return self.name }
set { self.name = newValue}
}

var indirectSetName:String
{
get
{
if let aFullTitle = self.fullTitle
{
return aFullTitle
}
return ""
}

set (newTitle)
{
//如果没有定义 newTitle,可以使用 newValue
self.fullTitle = "(self.name) :(newTitle)"
}
}
}

infix:指明一个用于两个值之间的运算符。如果一个全新的全局运算符被定义为 infix,还需要指定优先级。

let twoIntsAdded = 2 + 3

indirect:指明在枚举类型中,存在成员使用相同枚举类型的实例作为关联值的情况。

indirect enum Entertainment  
{
case eventType(String)
case oneEvent(Entertainment)
case twoEvents(Entertainment, Entertainment)
}

let dinner = Entertainment.eventType("Dinner")
let movie = Entertainment.eventType("Movie")

let dateNight = Entertainment.twoEvents(dinner, movie)

lazy:指明属性的初始值,直到第一次被使用时,才进行初始化。

class Person  
{
lazy var personalityTraits = {
//昂贵的数据库开销
return ["Nice", "Funny"]
}()
}
let aPerson = Person()
aPerson.personalityTraits //当 personalityTraits 首次被访问时,数据库才开始工作

left:指明运算符的结合性是从左到右。在没有使用大括号时,可以用于正确判断同一优先级运算符的执行顺序。

//"-" 运算符的结合性是从左到右
10-2-4 //根据结合性,可以看做 (10-2) - 4

mutating:允许在方法中修改结构体或者枚举实例的属性值。

struct Person  
{
var job = ""

mutating func assignJob(newJob:String)
{
self = Person(job: newJob)
}
}

var aPerson = Person()
aPerson.job //""

aPerson.assignJob(newJob: "iOS Engineer at Buffer")
aPerson.job //iOS Engineer at Buffer

none:是一个没有结合性的运算符。不允许这样的运算符相邻出现。

//"<" 是非结合性的运算符
1 < 2 < 3 //编译失败

nonmutating:指明成员的 setter 方法不会修改实例的值,但可能会有其它后果。

enum Paygrade  
{
case Junior, Middle, Senior, Master

var experiencePay:String?
{
get
{
database.payForGrade(String(describing:self))
}

nonmutating set
{
if let newPay = newValue
{
database.editPayForGrade(String(describing:self), newSalary:newPay)
}
}
}
}

let currentPay = Paygrade.Middle

//将 Middle pay 更新为 45k, 但不会修改 experiencePay 值
currentPay.experiencePay = "$45,000"

optional:用于指明协议中的可选方法。遵守该协议的实体类可以不实现这个方法。

@objc protocol Foo  
{
func requiredFunction()
@objc optional func optionalFunction()
}

class Person : Foo
{
func requiredFunction()
{
print("Conformance is now valid")
}
}

override:指明子类会提供自定义实现,覆盖父类的实例方法、类型方法、实例属性、类型属性、下标。如果没有实现,则会直接继承自父类。

class Person  
{
func printInfo()
{
print("I'm just a person!")
}
}

class Programmer : Person
{
override func printInfo()
{
print("I'm a person who is a dev!")
}
}

let aPerson = Person()
let aDev = Programmer()

aPerson.printInfo() //打印 I'm just a person!
aDev.printInfo() //打印 I'm a person who is a dev!

postfix:位于值后面的运算符。

var optionalStr:String? = "Optional"  
print(optionalStr!)

precedence:指明某个运算符的优先级高于别的运算符,从而被优先使用。

infix operator ~ { associativity right precedence 140 }  
4 ~ 8

prefix:位于值前面的运算符。

var anInt = 2  
anInt = -anInt //anInt 等于 -2

required:确保编译器会检查该类的所有子类,全部实现了指定的构造器方法。

class Person  
{
var name:String?

required init(_ name:String)
{
self.name = name
}
}

class Programmer : Person
{
//如果不实现这个方法,编译不会通过
required init(_ name: String)
{
super.init(name)
}
}

right:指明运算符的结合性是从右到左的。在没有使用大括号时,可以用于正确判断同一优先级运算符的顺序。

//"??" 运算符结合性是从右到左
var box:Int?
var sol:Int? = 2

let foo:Int = box ?? sol ?? 0 //Foo 等于 2

set:通过获取的新值来设置成员的值。同样可以用于计算型属性来间接设置其它属性。如果计算型属性的 setter 没有定义新值的名称,可以使用默认的 newValue。

class Person  
{
var name:String
{
get { return self.name }
set { self.name = newValue}
}

var indirectSetName:String
{
get
{
if let aFullTitle = self.fullTitle
{
return aFullTitle
}
return ""
}

set (newTitle)
{
//如果没有定义 newTitle,可以使用 newValue
self.fullTitle = "(self.name) :(newTitle)"
}
}
}

Type:表示任意类型的类型,包括类类型、结构类型、枚举类型、协议类型。

class Person {}  
class Programmer : Person {}

let aDev:Programmer.Type = Programmer.self

unowned:让循环引用中的实例 A 不要强引用实例 B。前提条件是实例 B 的生命周期要长于 A 实例。

class Person  
{
var occupation:Job?
}

//当 Person 实例不存在时,job 也不会存在。job 的生命周期取决于持有它的 Person。
class Job
{
unowned let employee:Person

init(with employee:Person)
{
self.employee = employee
}
}

weak:允许循环引用中的实例 A 弱引用实例 B ,而不是强引用。实例 B 的生命周期更短,并会被先释放。

class Person  
{
var residence:House?
}

class House
{
weak var occupant:Person?
}

var me:Person? = Person()
var myHome:House? = House()

me!.residence = myHome
myHome!.occupant = me

me = nil
myHome!.occupant // myHome 等于 nil

willSet:属性观察者,在值存储到属性之前调用。

class Person  
{
var name:String?
{
willSet(newValue) {print("I've got a new name, it's (newValue)!")}
}
}

let aPerson = Person()
aPerson.name = "Jordan" //在赋值之前,打印 "I've got a new name, it's Jordan!"

总结

哇噢!

这真是一次有趣的创作。我学会了好多在写之前没想到的东西。但我认为这里的诀窍并不是要把它记住,而是把它当做一份可以用于测验的定义清单。

相反地,我建议你把这份清单放在手边,并时不时地回顾一下。如果你能这样做的话,下一次在不同场景下需要使用特定的关键字,你肯定就能马上回想起来并使用它啦。

下回再见咯。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

给 UIView 来点烟花

作者:Tomasz Szulc,原文链接,原文日期:2018-09
译者:Joeytat;校对:numbbbbbWAMaker;定稿:Pancf

你也很喜欢常用 app 里的那些小细节吧?当我从 dribbble 中寻找灵感时,就发现了这个漂亮的设计:当用户在某个重要的视图中修改设置或者进行了什么操作时,会有烟花在周围绽放。于是我就在想这个东西有多难实现,然后过了一段时间,我完成了 :)

hero

烟花的细节

下面是对于这个效果的详细描述。烟花应该在视图周围的某个特殊的位置爆开,可能是按钮在点击事件响应时。当点击发生时,烟花应该在按钮的四角爆开,并且爆炸产生的火花应该按照自身的轨迹移动。

final

超喜欢这个效果! 不仅让我感受到视觉上的愉悦,还让我想要不停地戳这个按钮! :) 🎉

现在让我们再看一眼这个动画。每次生成的烟花,其整体行为是大致相似的。但还是在火花的轨迹和大小上有一些区别。让我们拆开来说。

  • 每一次点击都会产生两处烟花
  • 每一处烟花会产生 8 个火花
  • 每个火花都遵循着自己的轨迹
  • 轨迹看起来相似,但其实不完全一样。从爆炸开始的位置来看,有部分朝,有部分朝,剩余的朝

火花的分布

这个烟花特效有着简单的火花分布规则。将爆炸点分为四块「视线区域」来看:上左,上右,下左,下右,每个区域都有两个火花。

sparks distribution

火花的轨迹

火花的移动有着自己的轨迹。在一处烟花中有 8 个火花,那至少需要 8 道轨迹。理想状态下应该有更多的轨迹,可以增加一些随机性,这样连续爆发烟花的时候,不会看起来和前一个完全一样。

spark-trajectories

我为每一个区域创建了 4 条轨迹,这样就赋予了两倍于火花数量的随机性。为了方便计算,我统一了每条轨迹的初始点。因为我用了不同的工具来可视化这些轨迹,所以图上的轨迹和我完成的效果略有不同 - 但你能明白我的想法就行 :)

_实现_

理论足够了。接下来让我们把各个模块拼凑起来。

protocol SparkTrajectory {

/// 存储着定义轨迹所需要的所有的点
var points: [CGPoint] { get set }

/// 用 path 来表现轨迹
var path: UIBezierPath { get }
}

这是一个用于表示火花轨迹的协议。为了能够更简单地创建各式各样的轨迹,我定义了这个通用接口协议,并且选择基于三阶 贝塞尔曲线 来实现轨迹;还添加了一个 init 方法,这样我就可以通过一行代码来创建轨迹了。三阶贝塞尔曲线必须包含四个点。第一个和最后一个点定义了轨迹的开始和结束的位置,中间的两个点用于控制曲线的弯曲度。你可以用在线数学工具 desmos 来调整自己的贝塞尔曲线。

/// 拥有两个控制点的贝塞尔曲线
struct CubicBezierTrajectory: SparkTrajectory {

var points = [CGPoint]()

init(_ x0: CGFloat, _ y0: CGFloat,
_ x1: CGFloat, _ y1: CGFloat,
_ x2: CGFloat, _ y2: CGFloat,
_ x3: CGFloat, _ y3: CGFloat) {
self.points.append(CGPoint(x: x0, y: y0))
self.points.append(CGPoint(x: x1, y: y1))
self.points.append(CGPoint(x: x2, y: y2))
self.points.append(CGPoint(x: x3, y: y3))
}

var path: UIBezierPath {
guard self.points.count == 4 else { fatalError("4 points required") }

let path = UIBezierPath()
path.move(to: self.points[0])
path.addCurve(to: self.points[3], controlPoint1: self.points[1], controlPoint2: self.points[2])
return path
}
}

desmos-tool

接下来要实现的是一个能够创建随机轨迹的工厂。前面的图中你可以看到轨迹是根据颜色来分组的。我只创建了上右和下右两块位置的轨迹,然后进行了镜像复制。这对于我们将要发射的烟花来说已经足够了🚀

protocol SparkTrajectoryFactory {}

protocol ClassicSparkTrajectoryFactoryProtocol: SparkTrajectoryFactory {

func randomTopRight() -> SparkTrajectory
func randomBottomRight() -> SparkTrajectory
}

final class ClassicSparkTrajectoryFactory: ClassicSparkTrajectoryFactoryProtocol {

private lazy var topRight: [SparkTrajectory] = {
return [
CubicBezierTrajectory(0.00, 0.00, 0.31, -0.46, 0.74, -0.29, 0.99, 0.12),
CubicBezierTrajectory(0.00, 0.00, 0.31, -0.46, 0.62, -0.49, 0.88, -0.19),
CubicBezierTrajectory(0.00, 0.00, 0.10, -0.54, 0.44, -0.53, 0.66, -0.30),
CubicBezierTrajectory(0.00, 0.00, 0.19, -0.46, 0.41, -0.53, 0.65, -0.45),
]
}()

private lazy var bottomRight: [SparkTrajectory] = {
return [
CubicBezierTrajectory(0.00, 0.00, 0.42, -0.01, 0.68, 0.11, 0.87, 0.44),
CubicBezierTrajectory(0.00, 0.00, 0.35, 0.00, 0.55, 0.12, 0.62, 0.45),
CubicBezierTrajectory(0.00, 0.00, 0.21, 0.05, 0.31, 0.19, 0.32, 0.45),
CubicBezierTrajectory(0.00, 0.00, 0.18, 0.00, 0.31, 0.11, 0.35, 0.25),
]
}()

func randomTopRight() -> SparkTrajectory {
return self.topRight[Int(arc4random_uniform(UInt32(self.topRight.count)))]
}

func randomBottomRight() -> SparkTrajectory {
return self.bottomRight[Int(arc4random_uniform(UInt32(self.bottomRight.count)))]
}
}

这里先创建了用来表示火花轨迹工厂的抽象协议,还有一个我将其命名为经典烟花的火花轨迹的抽象协议,这样的抽象可以方便后续将其替换成其他的轨迹协议。

如同我前面提到的,我通过 desmos 创建了两组轨迹,对应着右上,和右下两块区域。

重要提醒:如果在 desmos 上 y 轴所显示的是正数,那么你应该将其转换成负数。因为在 iOS 系统中,越接近屏幕顶部 y 轴的值越小,所以 y 轴的值需要翻转一下。

并且值得一提的是,为了后面好计算,所有的轨迹初始点都是 (0,0)。

我们现在创建好了轨迹。接下来创建一些视图来表示火花。对于经典烟花来说,只需要有颜色的圆圈就行。通过抽象可以让我们在未来以更低的成本,创建不同的火花视图。比如小鸭子图片,或者是胖吉猫 :)

class SparkView: UIView {}

final class CircleColorSparkView: SparkView {

init(color: UIColor, size: CGSize) {
super.init(frame: CGRect(origin: .zero, size: size))
self.backgroundColor = color
self.layer.cornerRadius = self.frame.width / 2.0
}

required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}

extension UIColor {

static var sparkColorSet1: [UIColor] = {
return [
UIColor(red:0.89, green:0.58, blue:0.70, alpha:1.00),
UIColor(red:0.96, green:0.87, blue:0.62, alpha:1.00),
UIColor(red:0.67, green:0.82, blue:0.94, alpha:1.00),
UIColor(red:0.54, green:0.56, blue:0.94, alpha:1.00),
]
}()
}

为了创建火花视图,我们还需要一个工厂数据以填充,需要的数据是火花的大小,以及用来决定火花在哪个烟花的索引(用于增加随机性)。

protocol SparkViewFactoryData {

var size: CGSize { get }
var index: Int { get }
}

protocol SparkViewFactory {

func create(with data: SparkViewFactoryData) -> SparkView
}

class CircleColorSparkViewFactory: SparkViewFactory {

var colors: [UIColor] {
return UIColor.sparkColorSet1
}

func create(with data: SparkViewFactoryData) -> SparkView {
let color = self.colors[data.index % self.colors.count]
return CircleColorSparkView(color: color, size: data.size)
}
}

你看这样抽象了之后,就算再实现一个像胖吉猫的火花也会很简单。接下来让我们来创建经典烟花

typealias FireworkSpark = (sparkView: SparkView, trajectory: SparkTrajectory)

protocol Firework {

/// 烟花的初始位置
var origin: CGPoint { get set }

/// 定义了轨迹的大小. 轨迹都是统一大小
/// 所以需要在展示到屏幕上前将其放大
var scale: CGFloat { get set }

/// 火花的大小
var sparkSize: CGSize { get set }

/// 获取轨迹
var trajectoryFactory: SparkTrajectoryFactory { get }

/// 获取火花视图
var sparkViewFactory: SparkViewFactory { get }

func sparkViewFactoryData(at index: Int) -> SparkViewFactoryData
func sparkView(at index: Int) -> SparkView
func trajectory(at index: Int) -> SparkTrajectory
}

extension Firework {

/// 帮助方法,用于返回火花视图及对应的轨迹
func spark(at index: Int) -> FireworkSpark {
return FireworkSpark(self.sparkView(at: index), self.trajectory(at: index))
}
}

这就是烟花的抽象。为了表示一个烟花需要这些东西:

  • origin
  • scale
  • sparkSize
  • trajectoryFactory
  • sparkViewFactory

在我们实现协议之前,还有一个我之前没有提到过的叫做按轨迹缩放的概念。当火花处于轨迹 <-1, 1> 或相似的位置时,我们希望它的大小会跟随轨迹变化。我们还需要放大路径以覆盖更大的屏幕显示效果。此外,我们还需要支持水平翻转路径,以方便我们实现经典烟花左侧部分的轨迹,并且还要让轨迹能朝某个指定方向偏移一点(增加随机性)。下面是两个能够帮助我们达到目的的方法,我相信这段代码已经不需要更多描述了。

extension SparkTrajectory {

/// 缩放轨迹使其符合各种 UI 的要求
/// 在各种形变和 shift: 之前使用
func scale(by value: CGFloat) -> SparkTrajectory {
var copy = self
(0..<self.points.count).forEach { copy.points[$0].multiply(by: value) }
return copy
}

/// 水平翻转轨迹
func flip() -> SparkTrajectory {
var copy = self
(0..<self.points.count).forEach { copy.points[$0].x *= -1 }
return copy
}

/// 偏移轨迹,在每个点上生效
/// 在各种形变和 scale: 和之后使用
func shift(to point: CGPoint) -> SparkTrajectory {
var copy = self
let vector = CGVector(dx: point.x, dy: point.y)
(0..<self.points.count).forEach { copy.points[$0].add(vector: vector) }
return copy
}
}

好了,接下来就是实现经典烟花。

class ClassicFirework: Firework {

/**
x | x
x | x
|
---------------
x | x
x |
| x
**/

private struct FlipOptions: OptionSet {

let rawValue: Int

static let horizontally = FlipOptions(rawValue: 1 << 0)
static let vertically = FlipOptions(rawValue: 1 << 1)
}

private enum Quarter {

case topRight
case bottomRight
case bottomLeft
case topLeft
}

var origin: CGPoint
var scale: CGFloat
var sparkSize: CGSize

var maxChangeValue: Int {
return 10
}

var trajectoryFactory: SparkTrajectoryFactory {
return ClassicSparkTrajectoryFactory()
}

var classicTrajectoryFactory: ClassicSparkTrajectoryFactoryProtocol {
return self.trajectoryFactory as! ClassicSparkTrajectoryFactoryProtocol
}

var sparkViewFactory: SparkViewFactory {
return CircleColorSparkViewFactory()
}

private var quarters = [Quarter]()

init(origin: CGPoint, sparkSize: CGSize, scale: CGFloat) {
self.origin = origin
self.scale = scale
self.sparkSize = sparkSize
self.quarters = self.shuffledQuarters()
}

func sparkViewFactoryData(at index: Int) -> SparkViewFactoryData {
return DefaultSparkViewFactoryData(size: self.sparkSize, index: index)
}

func sparkView(at index: Int) -> SparkView {
return self.sparkViewFactory.create(with: self.sparkViewFactoryData(at: index))
}

func trajectory(at index: Int) -> SparkTrajectory {
let quarter = self.quarters[index]
let flipOptions = self.flipOptions(for: quarter)
let changeVector = self.randomChangeVector(flipOptions: flipOptions, maxValue: self.maxChangeValue)
let sparkOrigin = self.origin.adding(vector: changeVector)
return self.randomTrajectory(flipOptions: flipOptions).scale(by: self.scale).shift(to: sparkOrigin)
}

private func flipOptions(`for` quarter: Quarter) -> FlipOptions {
var flipOptions: FlipOptions = []
if quarter == .bottomLeft || quarter == .topLeft {
flipOptions.insert(.horizontally)
}

if quarter == .bottomLeft || quarter == .bottomRight {
flipOptions.insert(.vertically)
}

return flipOptions
}

private func shuffledQuarters() -> [Quarter] {
var quarters: [Quarter] = [
.topRight, .topRight,
.bottomRight, .bottomRight,
.bottomLeft, .bottomLeft,
.topLeft, .topLeft
]

var shuffled = [Quarter]()
for _ in 0..<quarters.count {
let idx = Int(arc4random_uniform(UInt32(quarters.count)))
shuffled.append(quarters[idx])
quarters.remove(at: idx)
}

return shuffled
}

private func randomTrajectory(flipOptions: FlipOptions) -> SparkTrajectory {
var trajectory: SparkTrajectory

if flipOptions.contains(.vertically) {
trajectory = self.classicTrajectoryFactory.randomBottomRight()
} else {
trajectory = self.classicTrajectoryFactory.randomTopRight()
}

return flipOptions.contains(.horizontally) ? trajectory.flip() : trajectory
}

private func randomChangeVector(flipOptions: FlipOptions, maxValue: Int) -> CGVector {
let values = (self.randomChange(maxValue), self.randomChange(maxValue))
let changeX = flipOptions.contains(.horizontally) ? -values.0 : values.0
let changeY = flipOptions.contains(.vertically) ? values.1 : -values.0
return CGVector(dx: changeX, dy: changeY)
}

private func randomChange(_ maxValue: Int) -> CGFloat {
return CGFloat(arc4random_uniform(UInt32(maxValue)))
}
}

大多数代码都是 Firework 协议的实现,所以应该很容易理解。我们在各处传递了需要的工厂类,还添加了一个额外的枚举类型来随机地为每个火花指定轨迹。

有少数几个方法用来为烟花和火花增加随机性。

还引入了一个 quarters 属性,其中包含了火花的所有的方位。我们通过 shuffledQuarters: 来重新排列,以确保我们不会总是在相同的方位创建相同数量的火花。

好了,我们创建好了烟花,接下来怎么让火花动起来呢?这就引入了火花动画启动器的概念。

protocol SparkViewAnimator {

func animate(spark: FireworkSpark, duration: TimeInterval)
}

这个方法接受一个包含火花视图和其对应轨迹的元组 FireworkSpark,以及动画的持续时间。方法的实现取决于我们。我自己的实现蛮多的,但主要做了三件事情:让火花视图跟随轨迹,同时缩放火花(带有随机性),修改其不透明度。简单吧。同时得益于 SparkViewAnimator 的抽象度,我们还可以很简单地将其替换成任何我们想要的动画效果。

struct ClassicFireworkAnimator: SparkViewAnimator {

func animate(spark: FireworkSpark, duration: TimeInterval) {
spark.sparkView.isHidden = false // show previously hidden spark view

CATransaction.begin()

// 火花的位置
let positionAnim = CAKeyframeAnimation(keyPath: "position")
positionAnim.path = spark.trajectory.path.cgPath
positionAnim.calculationMode = kCAAnimationLinear
positionAnim.rotationMode = kCAAnimationRotateAuto
positionAnim.duration = duration

// 火花的缩放
let randomMaxScale = 1.0 + CGFloat(arc4random_uniform(7)) / 10.0
let randomMinScale = 0.5 + CGFloat(arc4random_uniform(3)) / 10.0

let fromTransform = CATransform3DIdentity
let byTransform = CATransform3DScale(fromTransform, randomMaxScale, randomMaxScale, randomMaxScale)
let toTransform = CATransform3DScale(CATransform3DIdentity, randomMinScale, randomMinScale, randomMinScale)
let transformAnim = CAKeyframeAnimation(keyPath: "transform")

transformAnim.values = [
NSValue(caTransform3D: fromTransform),
NSValue(caTransform3D: byTransform),
NSValue(caTransform3D: toTransform)
]

transformAnim.duration = duration
transformAnim.timingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut)
spark.sparkView.layer.transform = toTransform

// 火花的不透明度
let opacityAnim = CAKeyframeAnimation(keyPath: "opacity")
opacityAnim.values = [1.0, 0.0]
opacityAnim.keyTimes = [0.95, 0.98]
opacityAnim.duration = duration
spark.sparkView.layer.opacity = 0.0

// 组合动画
let groupAnimation = CAAnimationGroup()
groupAnimation.animations = [positionAnim, transformAnim, opacityAnim]
groupAnimation.duration = duration

CATransaction.setCompletionBlock({
spark.sparkView.removeFromSuperview()
})

spark.sparkView.layer.add(groupAnimation, forKey: "spark-animation")

CATransaction.commit()
}
}

现在的代码已经足够让我们在特定的视图上展示烟花了。我又更进了一步,创建了一个 ClassicFireworkController 来处理所有的工作,这样用一行代码就能启动烟花。

这个烟花控制器还做了另一件事。它可以修改烟花的 zPosition,这样我们可以让烟花一前一后地展示,效果更好看一些。

class ClassicFireworkController {

var sparkAnimator: SparkViewAnimator {
return ClassicFireworkAnimator()
}

func createFirework(at origin: CGPoint, sparkSize: CGSize, scale: CGFloat) -> Firework {
return ClassicFirework(origin: origin, sparkSize: sparkSize, scale: scale)
}

/// 让烟花在其源视图的角落附近爆开
func addFireworks(count fireworksCount: Int = 1,
sparks sparksCount: Int,
around sourceView: UIView,
sparkSize: CGSize = CGSize(width: 7, height: 7),
scale: CGFloat = 45.0,
maxVectorChange: CGFloat = 15.0,
animationDuration: TimeInterval = 0.4,
canChangeZIndex: Bool = true) {
guard let superview = sourceView.superview else { fatalError() }

let origins = [
CGPoint(x: sourceView.frame.minX, y: sourceView.frame.minY),
CGPoint(x: sourceView.frame.maxX, y: sourceView.frame.minY),
CGPoint(x: sourceView.frame.minX, y: sourceView.frame.maxY),
CGPoint(x: sourceView.frame.maxX, y: sourceView.frame.maxY),
]

for _ in 0..<fireworksCount {
let idx = Int(arc4random_uniform(UInt32(origins.count)))
let origin = origins[idx].adding(vector: self.randomChangeVector(max: maxVectorChange))

let firework = self.createFirework(at: origin, sparkSize: sparkSize, scale: scale)

for sparkIndex in 0..<sparksCount {
let spark = firework.spark(at: sparkIndex)
spark.sparkView.isHidden = true
superview.addSubview(spark.sparkView)

if canChangeZIndex {
let zIndexChange: CGFloat = arc4random_uniform(2) == 0 ? -1 : +1
spark.sparkView.layer.zPosition = sourceView.layer.zPosition + zIndexChange
} else {
spark.sparkView.layer.zPosition = sourceView.layer.zPosition
}

self.sparkAnimator.animate(spark: spark, duration: animationDuration)
}
}
}

private func randomChangeVector(max: CGFloat) -> CGVector {
return CGVector(dx: self.randomChange(max: max), dy: self.randomChange(max: max))
}

private func randomChange(max: CGFloat) -> CGFloat {
return CGFloat(arc4random_uniform(UInt32(max))) - (max / 2.0)
}
}

这个控制器只做了几件事情。随机选择了一个角落展示烟花。在烟花出现的位置,烟花和火花的数量上增加了一些随机性。然后将火花添加到目标视图上,如果需要的话还会调整 zIndex,最后启动了动画。

几乎所有的参数都设置了默认参数,所以你可以不管他们。直接通过你的控制器调用这个:

self.fireworkController.addFireworks(count: 2, sparks: 8, around: button)

然后,哇!

classic

从这一步起,新添加一个像下面这样的烟花就变得非常简单了。你只需要定义新的轨迹,创建一个新的烟花,并且按照你希望的样子来实现即可。将这些代码放入一个控制器可以让你想在哪里启动烟花都很简单 :) 或者你也可以直接使用这个喷泉烟花,我已经把它放在了我的 github 项目 tomkowz/fireworks 中。

fountain

_总结_

这个动画效果的实现并不简单但也不算很难。通过对问题(在我们的情况下是动画效果)的正确分析,我们可以将其分解成多个小问题,逐个解决然后将其组合在一起。真希望我有机会能够在未来的的项目中使用这个效果🎉

好啦这就是今天的内容。感谢阅读!

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

使用 Swift 实现基于堆的优先级队列

作者:APPCODA EDITORIAL TEAM,原文链接,原文日期:2019-01-07
译者:Roc Zhang;校对:pmstnumbbbbb;定稿:Forelax

在计算机科学中,有很多问题可以通过将底层数据结构用优先级队列实现来改善算法的时间复杂度。其中 Dijkstra 的最短路径算法便是一个例子,该算法使用了优先级队列来在图中搜索两个顶点间的最短路径。

不幸的是,Swift 的标准库中并没有提供优先级队列的默认实现。所以我们将会研究如何自行实现基于堆的优先级队列。

如果你想在自己的 IDE 中一起动手来操作,点击 此链接 便可获取到源码。

什么是优先级队列?

优先级队列是一种可以对具有相对优先级的对象进行高效排序的数据结构。它会根据队列中每个对象的优先级,一个个将队列中的对象进行排序。

假设你创建了一系列任务并准备在将来的某个时间点运行它们,利用优先级队列就可以让这些任务按照你预期执行。

在接下来的文章中,我们将使用堆结构来实现我们的优先级队列。

什么是堆?

我们可以把堆看作是每个节点最多只有两个子节点的树,但与树不同的是,向堆中添加新节点时要尽可能往顶层左侧放置。如下图所示:

同时,堆还具有着一些与节点间相对大小关系相关的特性。一个最小堆(就是我们即将要使用的)有着每一个节点比其子节点都要小的特性。最大堆则正好相反。

为了能够维持这种性质,我们需要通过一些操作来得到节点的正确位置顺序。当我们插入一个新节点时,先将它放在树的顶层左侧开始的第一个空余可用的位置上。如果在放置后最小堆的性质不成立,则将此节点与它的父节点交换,直到最小堆性质成立为止。下图展示了向一个已有的最小堆中插入数字 2 的情况。

当要把一个对象移出队列时,需限制只从队列的某一端进行操作。在这里我们将通过限定只能删除根节点的方式来实现。当根节点被移除时,会被顶层最右边的节点替代。由于新节点成为根节点后有很大概率会过大,我们将把它向下移动,把它与最小的子节点交换,直到我们恢复最小堆。

关于实现本身的简短说明

我们将采用数组来实现一个既快速又节省空间的树结构。这里我不打算过于深入其中的数学运算,但如果你有兴趣的话,可以看一看这个 链接,它解释了数学在其中运用的方式与背后的原因。

准备好了吗?我们开始吧。

设计协议

同往常一样,我们先来定义对象要展示给外部用户怎样的功能。我们以定义协议的方式来完成这件事,稍后再让具体的类来遵循它。我为队列设计的协议如下:

protocol Queue {
associatedtype DataType: Comparable

/**
将一个新元素插入到队列中。
- 参数 item:要添加的元素。
- 返回值:插入是否成功。
*/
@discardableResult func add(_ item: DataType) -> Bool

/**
删除首个元素。
- 返回值:被移除的元素。
- 抛出值:QueueError 类型的错误。
*/
@discardableResult func remove() throws -> DataType

/**
获取到队列中的首个元素,并将其移出队列。
- 返回值:一个包含队列中首个元素的可选值。
*/
func dequeue() -> DataType?

/**
获取队列中的首个元素,但不将它移出队列。
- 返回值:一个包含队列中首个元素的可选值。
*/
func peek() -> DataType?

/**
清空队列。
*/
func clear() -> Void
}

该协议明确了我们需要实现的功能,供外部用户调用。协议同样还说明了其中的某一个方法可能会抛出错误,且根据文档我们能够了解到它会是一个 QueueError 类型的错误,因此我们同样也要实现它。

enum QueueError: Error {
case noSuchItem(String)
}

这段代码非常简明扼要:当用户尝试从空队列中删除元素时,我们会抛出上面这样的错误。

现在所有的准备工作已经完成,让我们开始实现队列本身。

实现优先级队列

我们将首先从声明 PriorityQueue 类开始,然后再实现它的初始化方法与存储元素,同时完成一些“有则更好”的方法。代码看起来是这样的:

/**
基于堆数据结构的 PriorityQueue 实现。
*/

class PriorityQueue<DataType: Comparable> {

/**
队列的存储。
*/
private var queue: Array<DataType>

/**
当前队列的大小。
*/
public var size: Int {
return self.queue.count
}

public init() {
self.queue = Array<DataType>()
}
}

你也许注意到了,我们目前还没有实现队列的协议。当我们进行编码时,通常希望事物之间能保持相对分离。并且希望能创建出一个概览从而方便我们去进行查找。有些类可能会逐渐变得非常大,解决这种情况的方法之一是使用扩展作用域。这样,每一个扩展倾向于只做一个任务(比如去遵循一个协议,处理存储与初始化,又或是嵌套类的声明等),事后再去查找时就会容易很多。让我们在这里也尝试使用这种方式。首先,实现一个 Int 类型的私有扩展,这能够帮助我们执行一些预先定义好的索引计算:

private extension Int {
var leftChild: Int {
return (2 * self) + 1
}

var rightChild: Int {
return (2 * self) + 2
}

var parent: Int {
return (self - 1) / 2
}
}

由于是私有的访问权限,这个扩展只在 PriorityQueue 文件中可用。这里聚集了我们将要使用的获取某个节点的子节点与父节点的计算。这样我们就可以通过调用 .leftChild 属性来方便的获取到左子节点的索引,而不必在实现中去进行一堆的数学运算了,以此类推。

下面是我们对 Queue 协议的遵循实现:

extension PriorityQueue: Queue {
@discardableResult
public func add(_ item: DataType) -> Bool {
self.queue.append(item)
self.heapifyUp(from: self.queue.count - 1)
return true
}

@discardableResult
public func remove() throws -> DataType {
guard self.queue.count > 0 else {
throw QueueError.noSuchItem("Attempt to remove item from an empty queue.")
}
return self.popAndHeapifyDown()
}

public func dequeue() -> DataType? {
guard self.queue.count > 0 else {
return nil
}
return self.popAndHeapifyDown()
}

public func peek() -> DataType? {
return self.queue.first
}

public func clear() {
self.queue.removeAll()
}

/**
弹出队列中的第一个元素,并通过将根元素移向队尾的方式恢复最小堆排序。
- 返回值: 队列中的第一个元素。
*/
private func popAndHeapifyDown() -> DataType {
let firstItem = self.queue[0]

if self.queue.count == 1 {
self.queue.remove(at: 0)
return firstItem
}

self.queue[0] = self.queue.remove(at: self.queue.count - 1)

self.heapifyDown()

return firstItem
}

/**
通过将元素移向队头的方式恢复最小堆排序。
- 参数 index: 要移动的元素的索引值。
*/
private func heapifyUp(from index: Int) {
var child = index
var parent = child.parent

while parent >= 0 && self.queue[parent] > self.queue[child] {
swap(parent, with: child)
child = parent
parent = child.parent
}
}

/**
通过将根元素移向队尾的方式恢复队列的最小堆排序。
*/
private func heapifyDown() {
var parent = 0

while true {
let leftChild = parent.leftChild
if leftChild >= self.queue.count {
break
}

let rightChild = parent.rightChild
var minChild = leftChild
if rightChild < self.queue.count && self.queue[minChild] > self.queue[rightChild] {
minChild = rightChild
}

if self.queue[parent] > self.queue[minChild] {
self.swap(parent, with: minChild)
parent = minChild
} else {
break
}
}
}

/**
交换存储中位于两处索引值位置的元素。
- 参数 firstIndex:第一个要交换元素的索引。
- 参数 secondIndex:第二个要交换元素的索引。
*/
private func swap(_ firstIndex: Int, with secondIndex: Int) {
let firstItem = self.queue[firstIndex]
self.queue[firstIndex] = self.queue[secondIndex]
self.queue[secondIndex] = firstItem
}
}

这里的内容有点多,你也许会想多读上一两次。其中,最上面是我们先前在协议中所定义好的所有方法,下面则是一些私有的,仅在此类中可用的辅助方法。我已经为这些辅助方法加上了注释,以便你能快速的了解到它们是用来做什么的。此外,记得关注一下先前对 Int 的扩展在这里是如何被使用的。依我看来,这是非常简洁实用的设计。

总结

现在,我们已经完成了所有 PriorityQueue 所需要的功能。现在我们将添加对 CustomStringConvertible 协议的实现,以便在向 print 函数传入一个队列后能得到一些可阅读的内容:

extension PriorityQueue: CustomStringConvertible {
public var description: String {
return self.queue.description
}
}

赞!

上述就是这次的全部内容了。现在你已经知道了如何去实现一个基于堆数据结构的优先级队列。如果有任何疑问,欢迎发表评论。

要了解 iOS 开发的更多信息,请查看我之前的文章:
Introduction To Protocol Oriented Programming
Using Swift Extensions To Clean Up Our Code

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

Swift 5 字符串插值-简介

作者:Olivier Halligon,原文链接,原文日期:2018-12-15
译者:Nemocdz;校对:numbbbbbYousanflics;定稿:Forelax

StringInterpolation 协议最初的设计效率低下又不易扩展,为了在后续的版本中能够将其彻底重构,Swift 4 中将该协议标记为废弃。即将在 Swift 5 中亮相的 SE-0228 提案介绍了一种新的 StringInterpolation 设计,使得 String 有了更大的潜能。

在 Swift 的 master 分支里实现之后,就可以下载一个 快照 来安装最新的 Swift 5 工具链到 Xcode 中,来尝试全新的 StringInterpolation。让我们来把玩一下。

全新的 StringInterpolation 设计

我强烈建议本篇文章的读者阅读一下 SE-0228 提案,感受一下新 API 的背后的设计思路和动机。

要让一个类型遵循 ExpressibleByStringInterpolation,最基本的你需要:

  • 让这个类型拥有一个类型为 StringInterpolation 的子类型,这个子类型遵循 StringInterpolationProtocol 并将负责解释插值
  • 这个子类型仅需要实现 appendLiteral(_ literal: String) 方法,再选择一个或多个你自己想要支持的 appendInterpolation(...) 签名的方法
  • 这个 StringInterpolation 子类型会作为“构造器”服务于你的主类型,然后编译器会调用那些 append… 方法一步一步地构造对象
  • 然后你的主类型需要实现 init(stringInterpolation: StringInterpolation) ,它会用上一步的结果来实例化它自己。

你可以实现任何你喜欢的 appenInterpolation(...) 方法,这意味着你可以任意选择支持什么插值。这是一个带来巨大的可能性的超强功能。

举个例子,如果你实现了 func appendInterpolation(_ string: String, pad: Int),那么意味着你将可以用类似这样的插值:"Hello \(name, pad: 10), how are you?" 来构造你的类型。插值只需要匹配你的 StringInterpolation 子类型其中一个支持的 appendInterpolation 方法签名。

一个简单的例子

让我用一个简单的例子来演示一下插值是如何运作的。一起来构造一个允许引用 issue 编号和用户的 GitHubComment 类型吧。

这个例子的目标是做到类似下面的写法:

let comment: GitHubComment = """
See \(issue: 123) where \(user: "alisoftware") explains the steps to reproduce.
"""

所以我们该怎么实现它呢?

首先,让我们声明基本的结构体 struct GitHubComment 并让它遵循 ExpressibleByStringLiteral(因为 ExpressibleByStringInterpolation 继承自这个协议所以我们将它的实现抽离)和 CustomStringConvertible(为了 debug 时友好地在控制台中打印)。

struct GitHubComment {
let markdown: String
}
extension GitHubComment: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
self.markdown = value
}
}
extension GitHubComment: CustomStringConvertible {
var description: String {
return self.markdown
}
}

然后,我们让 GitHubComment 遵循 ExpressibleByStringInterpolation。这意味着在剩下需要实现的功能,将由一个 StringInterpolation 子类型来完成:

  • 首先初始化它自己:init(literalCapacity: Int, interpolationCount: Int) 提供给你保留一部分数据到缓冲区的能力,在一步步构造类型时就会用到这个能力。在这个例子中,我们可以在构造实例的时候,简单用一个 String 并往它上面追加片段,不过这里我采用一个 parts: [String] 来代替,之后再将它组合起来

  • 实现 appendLiteral(_ string: String) 逐个追加文本到 parts

  • 实现 appendInterpolation(user: String) 在遇到 \(user: xxx) 时逐个追加 markdown 表示的用户配置链接

  • 实现 appendInterpolation(issue: Int) 逐个追加用 markdown 表示的 issue 链接

  • 然后在 GitHubComment 上实现 init(stringInterpolation: StringInterpolation)parts 构造成一个评论

extension GitHubComment: ExpressibleByStringInterpolation {
struct StringInterpolation: StringInterpolationProtocol {
var parts: [String]
init(literalCapacity: Int, interpolationCount: Int) {
self.parts = []
// - literalCapacity 文本片段的字符数 (L)
// - interpolationCount 插值片段数 (I)
// 我们预计通常结构会是像 "LILILIL"
// — e.g. "Hello \(world, .color(.blue))!" — 因此是 2n+1
self.parts.reserveCapacity(2*interpolationCount+1)
}
mutating func appendLiteral(_ literal: String) {
self.parts.append(literal)
}
mutating func appendInterpolation(user name: String) {
self.parts.append("[\(name)](https://github.com/\(name))")
}
mutating func appendInterpolation(issue number: Int) {
self.parts.append("[#\(number)](issues/\(number))")
}
}
init(stringInterpolation: StringInterpolation) {
self.markdown = stringInterpolation.parts.joined()
}
}

这就完事了!我们成功了!

注意,因为那些我们实现了的 appendInterpolation 方法签名,我们允许使用 Hello \(user: "alisoftware") 但不能使用 Hello \(user: 123),因为 appendInterpolation(user:) 期望一个 String 作为形参。类似的是,在你的字符串中 \(issue: 123) 只能允许一个 Int 因为 appendInterpolation(issue:) 采用一个 Int 作为形参。

实际上,如果你尝试在你的 StringInterpolation 子类中用不支持的插值,编译器会给你提示报错:

let comment: GitHubComment = """
See \(issue: "bob") where \(username: "alisoftware") explains the steps to reproduce.
"""
// ^~~~~ ^~~~~~~~~
// 错误: 无法转换 ‘String’ 类型的值到期望的形参类型 ‘Int’
// 错误: 调用 (have 'username:', expected 'user:')实参标签不正确

这仅仅只是个开始

这个新的设计打开了一大串脑洞让你去实现自己的 ExpressibleByStringInterpolation 类型。这些想法包括:

  • 创建一个 HTML 类型并遵循,你就可以用插值写 HTML
  • 创建一个 SQLStatement 类型并遵循,你就可以写更简单的 SQL 语句
  • 用字符串插值支持更多自定义格式,比如在你的插值字符串中用格式化 Double 或者 Date
  • 创建一个 RegEX 类型并遵循,你就可以用花里胡哨的语法写正则表达式
  • 创建一个 AttributedString 类型并遵循,就可以用字符串插值构建 NSAttributedString

带来新的字符串插值设计的 Brent Royal-GordonMichael Ilseman,提供了更多例子在这个 要点列表 中。

我个人尝试了一下支持 NSAttributedString 的实现,并想 在专门的一篇文章里分享它的初步实现,因为我发现它非常优雅。我们下一篇文章再见!

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

在 iOS 11 中使用 Core Bluetooth

作者:Andrew Jaffee,原文链接,原文日期:2018-04-17
译者:灰s;校对:Ceenumbbbbb;定稿:Forelax

作为 iOS 开发,我们十分清楚人们都喜欢互通性。我们喜欢通过无线设备与其他人进行沟通这一点是显而易见的。最近,我们开始希望能够与那些曾经被认为是独立的普通设备进行通信。我们开始喜欢,甚至是期望,部分无线设备可以收集并且分析自己的数据(通常称为“可穿戴设备”)。许多设备已经成为我们生活里的一部分,还为还有一个专门的术语来描述它:“Internet of Things” 或者 “IoT”(物联网)。现在地球上有数十亿的无线通讯设备。在这篇教程中,我们将聚焦 IoT 其中的一部分:蓝牙。

我将说明蓝牙技术背后的基本概念,以及:

  • 展示如何精通蓝牙方向的软件开发,从而为你提供巨大的职业机遇
  • 提醒你必须去确认在发布一个使用蓝牙技术的应用时是否需要通过“资格审查”
  • 给你提供 Apple 的Core Bluetooth 框架概述 (也可以参阅这里)
  • 最后,带领你使用 Swift 4 并通过 Core Bluetooth 和一个蓝牙设备来开发一款用于监控心率的 iOS 应用程序

提示:注意跟随阅读文章中包含的超链接。对于开发者这是重要的资料,它确保你完全理解蓝牙的工作方式以及苹果是如何支持蓝牙这种技术的。

蓝牙 - 一项迅速发展的技术

在一篇文章中不可能说清楚如何为整个物联网开发软件,但实际上,对所有这些无线设备进行数据分析是很有启发性的 - 实际上是很不可思议的。连接着的东西无处不在并且可以预测这个小东西的增长速度将是惊人的。如果你观察一下我们今天讨论的内容,在“短程段”中,使用如蓝牙和无线网的技术,然后添加上“广域类别”中,使用如电话的技术(比如: CDMA),你将看到 ~ 2014 年的 125 亿设备迅速增加到 2022 年预计的 300 亿。

蓝牙是一种短距离无线通讯技术的标准化规范。Bluetooth Special Interest Group(蓝牙技术联盟) 管理和保护这种短程无线技术背后的研发、发展还有知识产权。SIG 确保关于蓝牙的制造商,开发者和销售者他们的硬件和软件都是基于标准化规范。

根据 Bluetooth SIG 报道,“今年有将近 40 亿台设备使用蓝牙进行连接。蓝牙将连接手机、平板电脑、个人电脑,蓝牙将会将我们彼此连接。”。一家对短程通讯技术进行深度投资的公司 Ellisys 对此表示认同,并 “预估 2018 年将有近 40 亿台新的蓝牙设备上市”。请记住,仅在今年就有 40 亿蓝牙设备上市。

根据这个趋势,一家收集“市场和消费数据”的公司 Statista 认为全球的蓝牙设备 将从 2012 年的 35 亿增长到 2018 年预估的 100 亿

对于你的职业生涯,蓝牙意味着什么

Dogtown Media 有限责任公司,一家 iOS 端“物联网蓝牙应用”精品开发商,该公司声称 “根据麦肯锡全球研究所(McKinsey Global Institute)的专家预测,在未来 9 年内,物联网将对全球经济产生超过 6 万亿美元的影响” 。这对于像你我这样的 iOS 开发意味着什么?Dogtown 说 “未来几年,对那些有远见的初创企业和创业者来说,将是令人兴奋的、多产的,而且非常有利可图的。”
翻译:作为一个有前瞻性或者想创业的青年,应该学习使用蓝牙来进行应用程序的开发,因为在这个迅速扩大的市场,你的下个任务或者岗位有很大可能需要这个技能。

免责声明

  • 我与 Dogtown Media, LLC 之间没有任何的从属关系。在搜索到这篇文章的期间,我发现了这家公司的网站,看到他们专门从事 iOS 端的蓝牙开发。
  • 我是 Bluetooth SIG 的一名 “Adopter” 级别成员。

在提交你使用 Core Bluetooth 开发的应用程序被审核之前

在蓝牙技术刚展露头角之际,我经常看到开发者们找一些参考资料,然后立即投入到涉及无线设备的应用开发中,并提交蓝牙应用到 Apple 的 AppStore 中。我想说:别那么快,伙计。

Bluetooth SIG 规定,“所有使用蓝牙技术的产品必须完成 Bluetooth Qualification Process(蓝牙资格审核)。” 我听到有人说,“市面上有太多基于蓝牙的应用;没有人会注意到我的”。呃,并不是这样。蓝牙技术有 版权,专利,并且授权 给应用开发者。如果你想让你的应用程序被聚焦并且展示你集成了蓝牙技术的事实,请记住:

Bluetooth 商标 - 包括 BLUETOOTH 文字商标,图形商标(符文 B 和椭圆形设计),还有组合商标(蓝牙文字商标和设计)- 这些都被 Bluetooth SIG 所拥有。只有 Bluetooth SIG 的成员并且拥有对应资格和申报过的产品才可以展示,相关功能或者使用任何商标。为了保护这些商标,Bluetooth SIG 管理了一套执行程序,监控市场并进行审核,以确保会员使用商标的行为符合蓝牙品牌指南,并确保最终发布的产品与已通过资格审查程序的商品和服务相对应。

来看一下 Bluetooth SIG 的 资质 FAQ

如果我没有给我的产品申请相应的资质会怎么样?

如果你没有给你的产品申请相应的资质,你将成为执法行动的对象。请阅读这里的 更新策略,其中我们概述了升级计划。如果没有采取纠正措施,您的 Bluetooth SIG 会员资质可能被暂停或撤销。

别傻了,别去冒险。最重要的一点是,我们所有人都应该努力追求最高的诚信和诚实,在应该给予信任的时候给予信任,并促进遵守标准,使协同工作成为规范,而不是例外。数千个人贡献了数千个小时的工作和数百万美元用于发展蓝牙的标准和 多项专利,从而创造了一套明显有用的知识财产。

别让我吓着你

人们常常被「商标」、「专利」、「版权」、「资质」、「会员」、等严厉的词语所吓倒,尤其是 “强制执行。”不要开始担心使用蓝牙进行开发的事。加入 Bluetooth SIG!它是免费的! 点击这里,然后:

首先成为一个 Adopter 级别的会员。使用蓝牙技术开发一款产品,会员资格是必须的,Adopter 级别会员拥有以下这些福利:
• 根据 Bluetooth Patent/Copyright License Agreement(蓝牙专利/版权许可协议) 使用蓝牙技术生产产品的许可
• 根据 Bluetooth Trademark License Agreement(蓝牙商标许可协议) 在符合条件的产品上使用蓝牙商标的许可
• 能够与数以万计的 Bluetooth SIG 成员建立网络,并在各种各样的行业中合作 — 从芯片制造商到应用程序的开发者,设备制造商和服务提供商
• 能够参加 SIG 专家组、研究小组和工作组中的子小组
• 访问诸如 Profile Tuning Suite(PTS)之类的工具,提供协议和协同测试……

成为 Bluetooth SIG 的一员

成为 SIG 的一员 会包含很多好处。你可以免费使用教育工具包、培训视频、网络研讨会、开发人员论坛、开发人员支持服务、白皮书、产品测试工具,并帮助确保您的应用程序满足国际监管要求(主要是关于 射频排放)。

你只要成为会员就会得到一些曝光。我的公司是它的一个成员,所以在 Bluetooth SIG’s Member Directory 中可以 被看到

一旦你开发了一款应用,使其通过 SIG 认证,并获得 Apple App Store 的许可,那么你的产品同时也会被 SIG 公开上市,这时你将获得更多的曝光。

对应用程序进行资格认证既简单又便宜

当你对自己基于 Core Bluetooth 开发的应用程序感到满意,并准备将其提交到 Apple App Store 进行审核,请停下,然后前往 Bluetooth SIG 的网页对你的应用程序进行 认证。SIG 将为您提供一个整洁的 “Launch Studio”,它是您用来完成 Bluetooth Qualification Process 的在线工具。”

对于大多数应用程序,比如我将在本教程中介绍的 “GATT - based Profile Client(app),”认证和上市的费用是 100 美元。花一些精力来确保您的代码符合 Bluetooth 规范和做一些测试,将是非常值得的。最后,可以给你的应用程序印上蓝牙的商标。这个 商标 “在全球范围内都是可识别的,消费者认知度高达92%。”

请不要担心 100 美元的问题。你更有可能获得一份拥有丰厚薪水或者时薪的工作,并为公司处理这些蓝牙的合规问题。

理解 Core Bluetooth

大多数情况下,使用蓝牙设备是非常简单的。开发与蓝牙通讯的软件却有可能非常复杂。这就是为什么 Apple 创造了 Core Bluetooth 框架

Core Bluetooth 框架让您的 iOS 和 Mac 应用程序与蓝牙低能耗设备通信。例如,您的应用程序可以发现、搜索低能量的外围设备还有与之交互,比如心率监视器、数字恒温器,甚至其他 iOS 设备。

该框架是蓝牙 4.0 规范中关于使用低能耗设备的抽象。就是说,它为你,也就是开发者,隐藏了规范中很多底层的细节,使你更容易开发与低能耗设备进行交互的应用程序。因为该框架是基于标准规范的,所有规范中的很多概念和术语被采用了……

请注意是“低能量设备”。当使用 Core Bluetooth 我们并不是处理如无线扬声器这样的经典蓝牙设备。与这类设备的通讯会很快的耗尽电池能量。Core Bluetooth 是针对“Bluetooth Low Energy”(BLE)的 API,也称为“Bluetooth 4.0”。BLE 使用的电力要少得多,因为它的设计目的是通信少量的数据。BLE 设备的一个很好的例子是心率监测器(HRM)。它几乎每秒钟只发送几个字节的数据。这就是为什么人们可以带着一个 HRM 或者带着他们的 iPhone 跑一个小时,记录跑步期间心率的变化,而看不到电池电量的巨大消耗。注意,随着本文的进行,像 BLE 这种首字母缩略词的数量正在增加。

为了我们能够一起流畅的讨论 Core Bluetooth 你需要学习一个新的词汇表。

分别从 客户端/服务端和生产者/消费者模型 的角度考虑 BLE 协议。

The Peripheral(外围设备)

外围设备是硬件/软件的一部分,就像 HRM。大多数 HRM 设备搜集或/和计算数据,如每分钟心跳、HRM 的电池电量水平、以及所谓的“RR-Interval”。设备传输这些数据到另一个需要它们的实体或实体组。外围设备是服务者生产者。市场上比较流行的 HRM 有 Wahoo TICKR,Polar H7,Scosche Rhythm+

我将通过编写连接到这三种设备的 Swift 4 代码来展示 BLE 等标准的重要性。

Core Bluetooth 视角

来自 苹果的文档

CBPeripheralDelegate

CBPeripheral 对象的代理必须遵守 CBPeripheralDelegate 协议。代理使用这个协议的方法来对一个远程外围设备的服务和属性,进行发现、探索、还有交互方面的监控。这个协议里面没有必须遵守的方法。

The Central(中央设备)

中央设备是硬件/软件的一部分,就像 iPhone、iPad、MacBook、
iMac 等。这些设备可以使用应用程序扫描像 HRM 这样的蓝牙外围设备。中央设备是一个客户以及 消费者。它们与 HRM 是连通的,所以它们可以使用从外围设备中取出的像每分钟心跳、电池的电量水平、还有“RR-Interval”这样的数据。中央设备接收这些数据,可以对数据执行增值计算,或者只是通过用户界面显示数据,或者是存储数据以供将来分析、展示,或者是聚合和数据分析(就像统计分析需要足够的数据来确定重要的和有意义的趋势),或其他类似的操作。

Core Bluetooth 视角

来自 苹果的文档

CBCentralManagerDelegate 协议定义了方法,CBCentralManager 对象的代理必须遵守它。协议中的可选方法允许代理来监控对外围设备的发现、连接、还有检索。唯一必须实现的方法表明中央设备的可用性,并且当中央设备的状态发生更新时被调用。

通过广播找到外围设备

如果你的 iPhone 或 iPad 找不到这些外设从而不能连接到它们,那么 HRM 之类的外设就没什么用了。因此,它们不断通过无线频段发送着数据的小片段(包),说着类似这样的话:“嘿,我是 Scosche Rhythm+ 心率检测器;我能提供类似我的穿戴者每分钟心率的功能;我能提供类似我的电池电量水平的信息。”当一个对心率感兴趣的中央设备通过扫描找到了这个外围设备,中央设备将连接到它并且它会停止广播。

你可能已经使用过 iPhone -> 设置 -> 蓝牙 来开启或关闭蓝牙(包括传统的和 BLE)。当切换到开启,你可以看到你的 iPhone 扫描设备并与它们建立连接,就像下面我所截的两张图,搜索,并且将我的 iPhone 连接到一个 Scosche Rhythm+ HRM:

依照 苹果 的说法:

外围设备以广播包的形式广播一些数据。一个广播包是一个相对较小的数据束,其中可能包含外围设备所能提供的有用信息,比如外围设备的名字还有主要功能。例如,数字恒温器可能会广播它能提供房间的当前温度。在 BLE 中,广播是外围设备展示其存在的主要方式。另一方面,中央设备可以扫描和监听任何外围设备,只要这些设备的广播信息是它感兴趣的……

在这篇教程中,过一会我会向你展示怎样使用 Swift 4 来编码进行外围设备的扫描并连接它们。

外围设备的各种服务

服务可能不是你认为的那样。服务描述外围设备提供的主要特性或功能。但它并不是一种具体的测量方法,如每分钟心跳数,而是一种描述从外围设备可以得到的与心脏相关的测量方法的分类。

依照 苹果 的说法:

服务是一个数据和相关行为的集合,用于实现设备(或设备的一部分)的功能或特性。比如,心率检测器的一项服务可能是公开来自监测器的心率传感器的心率数据。

具体定义一个蓝牙“服务”,我们应该看看 Bluetooth SIG 的 “GATT Services(服务)” 列表,这里 GATT 代表 “Generic Attributes(通用属性)”

向下滚动服务 列表,直到你在 Name(名字) 列中看到 “Heart Rate”。注意, Uniform Type Identifier (统一类型标识符) 对应的是 “org.bluetooth.service.heart_rate”,Assigned Number(指定编码) 则是 0x180D。请注意在后面的代码中我们将使用 0x180D 这个值。

点击 “Heart Rate(心率)”,你将打开一个网页,上面用粗体字写着 Name: Heart Rate。请注意 Summary(摘要) ,“HEART RATE Service(心率服务)公开心率和其他与心率传感器相关的数据,用于健身应用。”向下滚动页面就会发现 Heart Rate service 本身并不会提供每分钟跳动的实际心率。这个服务是一个其他数据片段的集合,它们被称为 characteristics(特征)。最后,你会得到一个特征来提供重要数据:心率。

Core Bluetooth 视角

来自 苹果的文档

CBService 和它的子类 CBMutableService 代表一个外围设备的服务 - 为实现设备(或设备的一部分)的功能或特性而收集的数据和相关行为。CBService 对象特指远程外围设备(使用 CBPeripheral 对象来表示)的服务。服务组可能是主要的,也有可能是次要的,可能会包含一个特征组的代码,也有可能会包含一个服务组(代表其他的服务组)。

外围设备服务的特征

外围设备的服务常常被分解成更细化但相关的信息。特征通常是我们找到重要信息、真实数据的地方。再次查看 苹果 的说明:

服务本身是由特征或包含的服务(这里指别的服务)组成。特征更详细的提供了外围设备的服务信息。例如,刚才描述的心率服务,可能包含一个描述设备的心率传感器所在目标身体位置的特征和另一个传递心率测量数据的特征。

让我们继续使用 HRM 作为例子。请返回那个用粗体字写着 Name: Heart Rate(名字:心率)界面。向下滚动直到你看到 Service Characteristics(服务特征)。那是一个包含大量元数据(关于信息的数据)的大表格。请找到 Heart Rate Measurement(心率测量) 并点击 org.bluetooth.characteristic.heart_rate_measurement 然后审查。稍后我会对这个界面进行解释。

Core Bluetooth 视角
来自 苹果的文档

CBCharacteristic 和它的子类 CBMutableCharacteristic 代表关于外围设备服务的详细信息。CBCharacteristic 对象特指远程外围设备(远程外围设备使用 CBPeripheral 对象表示)服务的特征。一个特征包含一个单一的值以及任意个描述符来描述这个值。特征的属性描述了如何使用这个特征的值以及如何访问这些描述符。

GATT 规范

当你使用 Core Bluetooth 开发一款需要与蓝牙外围设备交互的应用程序时,你首先应该前往 Bluetooth SIG 的首页。

让我们一起回顾我曾经的经历,那会我在开发一个应用程序,用 HRM 做了各种各样非常好玩的功能。查看 GATT Specifications(GATT 技术指标) 部分,然后在 GATT Services(GATT 服务) 下面找到你需要的外围设备服务。

在本文介绍的 HRM 示例中,首先在 GATT Services(GATT 服务) 界面的 Name(名字) 列中找到 “Heart Rate”(也就是一个超链接)项。点击 “Heart Rate(心率)” 链接并且查看完整的网站。请记住 Assigned Number(分配符)(0x180D)然后滑动到底部的 Service Characteristics(服务特征) 表。仔细的查看表格并且找到有兴趣的特征。

在这个例子中,阅读 Heart Rate Measurement(心率测量)Body Sensor Location(传感器所在身体部位) 分区,然后点击各自的详细链接,org.bluetooth.characteristic.heart_rate_measurementorg.bluetooth.characteristic.body_sensor_location

Heart Rate Measurement(心率测量) 以及 Body Sensor Location(传感器所在身体部位) 界面中,分别记住它们的 Assigned Number(分配符),(0x2A37)和(0x2A38),然后查看界面中的所有信息,以便了解将被发送到该 HRM 应用程序中的蓝牙编码数据结构该如何解译。编写代码时必须把蓝牙编码数据转换成人类可读的格式。

随着本教程的深入,我将向你介绍更多细节,特别是当我向你展示,我用来与 BLE HRM 通信的应用程序代码。

如果你 加入 Bluetooth SIG ,你可以获得更多关于使用服务和特征进行编程的详细信息。

编写 Core Bluetooth 代码

在这次讨论中,我将假设你了解 iOS 应用程序开发的基础知识,包括 Swift 编程语言和 Xcode Single View App(单视图应用程序)模板。测试应用程序的用户界面(UI),包括 Auto Layout(自动布局),代码如下所示,非常简单。

我将用一系列步骤来描述代码 — 这些步骤在下面的代码中同样会被解释。因此,在阅读本节中的步骤时,请参阅下面代码中对应的步骤。整个过程基本上是线性的。请记住,其中一些步骤表示回调 — 正在调用的委托方法。

在编写应用程序时,我会将 Core Bluetooth 组件分解成协议或类 — 例如,将核心功能从 UI 中分离出来。但这段代码的目的是向您展示 Core Bluetooth 如何在最少的干扰下工作。我的注释很简单,而且有实际意义。在一个页面中你只会看到重要部分。

示例应用程序样式

针对这篇文章我所开发的应用程序 UI 极其简单。当应用程序被启动,它开始扫描并尝试匹配一个 HRM。扫描的过程通过 UIActivityIndicatorView 类在屏幕上显示并旋转来表明。当没有匹配上任一 HRM 时,通过一个红色正方形的 UIView 来表明。一旦发现一个 HRM 并初步链接,UIActivityIndicatorView 停止旋转并隐藏,并且红色 UIView 转变为绿色。当 HRM 完全链接并被访问,我会显示 HRM 的品牌型号和穿戴者放置在身体上的预定位置。此时我会开始读取并且显示穿戴者每分钟的心率,大约每秒更新。大多数 HRM 都是每秒发送一次每分钟心率值。我人为地设计了一个心率数字的脉冲动画让应用看起来更有吸引力,但是你看到的是我真实的心率。当 HRM 断开链接,我清空所有的信息文本,将正方形 UIView 转变为红色,显示 UIActivityIndicatorView 并开始旋转,同时再次开始扫描 HRM。

以下是我的应用程序在与三个不同品牌的 HRM 匹配运行时的样式 — Scosche Rhythm+,Wahoo TICKR,还有Polar H7:

Rhythm+ 使用红外光“看”我的静脉以确定心率。TICKR 和 H7 使用电极检测告诉我心跳的电脉冲。

逐步了解我的代码

你可以在下一段找到完整的源代码。在这里,我将向你介绍实现步骤。

Step 0.00 : 我必须导入 CoreBluetooth 框架。

Step 0.0 : 指定 GATT 中的 Assigned Numbers(分配符) 为常量。我这样做让蓝牙规范的标识符更具可读性和可维护性,针对 “心率” 服务,其 “心率测量” 特征,还有其 “身体传感器位置” 特征。

Step 0.1 : 创建一个 UIViewController 的子类 HeartRateMonitorViewController。使 HeartRateMonitorViewController 遵守 **CBCentralManagerDelegate****CBPeripheralDelegate** 协议。我使用协议和委托的设计模式,正如我在 AppCoda 文章中 这里 还有 这里 分别描述的那样。我们将实现来自两个协议的方法。我们将调用一些 Core Bluetooth 的方法,一些方法将由 Core Bluetooth 为我们调用,以响应我们自己的调用。

Step 0.2 : 我们在 HeartRateMonitorViewController 类中定义实例变量,它们代表 CBCentralManagerCBPeripheral 类,所以它们在应用程序的生命周期内都是持续存在的。

Step 1 : 我们为进程在后台创建一个并发队列。我希望 Core Bluetooth 的运行发生在后台。我希望 UI 保持响应。说不定,在一个更复杂的应用程序中,HRM 可能会运行数小时,为用户收集心率数据。用户可能希望使用其他应用程序特性,例如,修改应用程序设置,或者,如果用户正在跑步,并且希望使用 Core Location 来跟踪跑步的路线。因此,在心率数据正在收集和显示的同时,用户可以收集和/或查看他们的地理位置。

Step 2 : 创建用于扫描、连接、管理和从外围设备收集数据的控制中心。这是必要的一步。缺少了控制中心 Core Bluetooth 将无法工作。另一个必要的:由于 HeartRateMonitorViewController 采用了 CBCentralManagerDelegate,我们将 centralManager 的委托属性设置成 HeartRateMonitorViewControllerself)。同时我们还为控制中心指定了 DispatchQueue

Step 3.1 : centralManagerDidUpdateState 方法的调用基于设备的蓝牙状态。理想情况下,我们应该考虑一个场景,在该场景中,用户无意(或故意)在 Settings(设置) 应用程序中关闭蓝牙。我们只能在蓝牙为 .poweredOn 状态时才能扫描外围设备。

Step 3.2 : 控制中心应该扫描感兴趣的外围设备,但前提是设备(如iPhone)开启了蓝牙。还记得上面标题为“通过广播找到外围设备”的部分吗?我们就是这样处理这个调用的。我们的监听针对正在广播 心率 服务(0x180D)的 HRM。我们可以通过添加特定服务的 CBUUIDsserviceUUIDs 数组参数(标记为 withServices),从而达到监听并且连接更多外围设备的目的。例如,在一些健康相关的应用程序中,我们可以监听并连接到 HRM 血压监测器或者 BPM(尽管我们需要再创建一个 CBPeripheral 类的实例变量)。注意,如果我们做了这个调用:

centralManager?.scanForPeripherals(withServices: nil)

我们可以监听范围内所有蓝牙设备的广播。在一些蓝牙功能类的应用程序中它可能有用。

Step 4.1 : 找到这个应用程序可以连接哪些感兴趣的外围设备(HRM)。这个 didDiscover 方法告诉我们,在扫描时,控制中心已经发现了正在广播的 HRM。

Step 4.2 : 我们必须在类的实例变量中保存刚刚发现的外围设备的引用,它将持续存在。如果我们仅仅只是使用了一个局部变量,我们会倒霉的。

Step 4.3 : 因为 HeartRateMonitorViewController 采用了 CBPeripheralDelegate 协议,所以 peripheralHeartRateMonitor 对象必须将它的 delegate 属性设置为 HeartRateMonitorViewControllerself)。

Step 5 : 我们在 didDiscover 中告诉控制中心停止扫描以便保护电池寿命。当已经连接的 HRM 或外围设备断开连接时,我们可以再次开启扫描。

Step 6 : 此时还在 didDiscover 中,我们连接到被发现的感兴趣的外围设备,一个 HRM。

Step 7 : didConnect 方法仅仅“当成功与一个外围设备连接时调用。”请注意“成功”这个词。如果你发现一个外围设备但不能连接,那么你需要进行一些调试。请注意我更新了 UI 用来显示我连接了那个外围设备,并表明我已经停止扫描,以及其他一些事情。

Step 8 : 此时还在 didConnect 方法中,我们在外围设备上寻找感兴趣的服务。具体来说,我们希望找到 Heart Rate(心率)(0x180D)服务。

Step 9 :didDiscoverServices 方法被调用的时候,说明在我们所连接的外围设备上发现了 “Heart Rate(心率)” 服务。请记住我们需要寻找感兴趣的特征。这里我对 Heart Rate(心率) 服务的所有特征进行了一次循环以找到我接下来要用的那个。如果你前往 Bluetooth SIG 网页中 “Heart Rate(心率)” 服务对应的页面,滚动到下面标记为 Service Characteristics(服务特征) 的分区,就可以查看那三个可用的特征。

Step 10 : didDiscoverCharacteristicsFor service 方法证明我们已经发现了感兴趣的服务中所有的特征。

Step 11 : 首先,我订阅了一个通知 - “read” - 关于感兴趣的 Body Sensor Location(传感器所在身体部位) 特征。前往 “Heart Rate(心率)” 服务的页面,你会发现这个特征被标记为“Read Mandatory。”调用 peripheral.readValue 将会引起 peripheral:didUpdateValueForCharacteristic:error: 方法稍后被调用,所以我可以将这个特征解析成人类语言。其次,我订阅了一个定期通知 — “notify” — 关于感兴趣的 Heart Rate Measurement(心率测量) 特征。前往 “Heart Rate(心率)” 服务的页面,你会发现这个特征被标记为“Notify Mandatory。”调用 peripheral.setNotifyValue 将会引起 peripheral:didUpdateValueForCharacteristic:error: 方法稍后被调用,并且是几乎每一秒钟触发一次,所以我可以将这个特征解析成人类语言。

Step 12 : 因为我对特征 Body Sensor Location(传感器所在身体部位) (0x2A38)订阅了读取值,并且对特征 Heart Rate Measurement(心率测量) (0x2A37)订阅了定期获取通知,所以如果它们发送值或者定期更新,我将分别获得这两个二进制值。

Step 13 : 将 BLE Heart Rate Measurement(心率测量) 的数据解译成人们可读的格式。前往 GATT 规范的 页面 找到这个特征。第一个字节是关于其余数据的元数据 (标记)。规范告诉我看第一个字节的最低有效位,Heart Rate Value Format bit(心率值的标识位)。如果是 0(zero),每分钟的心跳数将以 UINT8 格式在第二字节。我从来没有遇到过一个 HRM 使用第二个字节以外的任何字节,我在这里演示的三个 HRM 也不例外。这就是为什么我忽略了 Heart Rate Value Format bit(心率值的标识位) 值为 1(one)的用例。我看过所有被提到的实现,但从来没有能够测试这些实现。对于我无法重现的情况,我不会发表任何看法。

Step 14 : 将 BLE Body Sensor Location(传感器所在身体部位) 的数据解译成人们可读的格式。前往 GATT 规范的 页面 找到这个特征。这个特征非常简单。将值 1、2、3、4、5、6 或 7 存储在 8 位中。形成的文本字符串与这些值以解译为目的的展示是一样的。

Step 15 : 当一个外围设备从控制中心断开时,采取适当的行动。我更新我的 UI 以及……

Step 16 : 开始扫描,为了发现一个正在广播 Heart Rate(心率) 服务(0x180D)的外围设备。

我的源代码

这里是对于我们刚刚所讨论的实现,完整的源代码:

import UIKit

// STEP 0.00: 必须导入 CoreBluetooth framework
import CoreBluetooth

// STEP 0.0: 指定 GATT 中的 "Assigned Numbers" 为常量,这样它们会拥有更好的可读性和可维护性

// MARK: - Core Bluetooth 服务 ID
let BLE_Heart_Rate_Service_CBUUID = CBUUID(string: "0x180D")

// MARK: - Core Bluetooth 特征 ID
let BLE_Heart_Rate_Measurement_Characteristic_CBUUID = CBUUID(string: "0x2A37")
let BLE_Body_Sensor_Location_Characteristic_CBUUID = CBUUID(string: "0x2A38")

// STEP 0.1: 这个类同时采用了控制中心和外围设备的委托协议,所以必须遵守这些协议的要求
class HeartRateMonitorViewController: UIViewController, CBCentralManagerDelegate, CBPeripheralDelegate {

// MARK: - Core Bluetooth 类的成员变量

// STEP 0.2: 分别创建 CBCentralManager 和 CBPeripheral 的实例变量
// 所以它们在应用程序的生命周期里持续存在
var centralManager: CBCentralManager?
var peripheralHeartRateMonitor: CBPeripheral?

// MARK: - UI outlets / 成员变量

@IBOutlet weak var connectingActivityIndicator: UIActivityIndicatorView!
@IBOutlet weak var connectionStatusView: UIView!
@IBOutlet weak var brandNameTextField: UITextField!
@IBOutlet weak var sensorLocationTextField: UITextField!
@IBOutlet weak var beatsPerMinuteLabel: UILabel!
@IBOutlet weak var bluetoothOffLabel: UILabel!

// 设置 HealthKit
let healthKitInterface = HealthKitInterface()

// MARK: - UIViewController delegate

override func viewDidLoad() {
super.viewDidLoad()
// 在视图加载完成以后,通常是通过一个 nib,做所有附加的设置。

// 最初,我们在进行扫描并且没有产生连接
connectingActivityIndicator.backgroundColor = UIColor.white
connectingActivityIndicator.startAnimating()
connectionStatusView.backgroundColor = UIColor.red
brandNameTextField.text = "----"
sensorLocationTextField.text = "----"
beatsPerMinuteLabel.text = "---"
// 以防 Bluetooth 被关闭
bluetoothOffLabel.alpha = 0.0

// STEP 1: 为控制中心在后台创建一个并发队列
let centralQueue: DispatchQueue = DispatchQueue(label: "com.iosbrain.centralQueueName", attributes: .concurrent)
// STEP 2: 创建用于扫描、连接、管理和从外围设备收集数据的控制中心。
centralManager = CBCentralManager(delegate: self, queue: centralQueue)

// 从 HKHealthStore 读取心率数据
// healthKitInterface.readHeartRateData()

// 从 HKHealthStore 读取性别类型
// healthKitInterface.readGenderType()
}

override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// 处理任何可以重新创建的资源
}

// MARK: - CBCentralManagerDelegate methods

// STEP 3.1: 这个方法的调用基于设备的蓝牙状态;
// 仅在 Bluetooth 为 .poweredOn 时才可以扫描外围设备
func centralManagerDidUpdateState(_ central: CBCentralManager) {

switch central.state {

case .unknown:
print("Bluetooth status is UNKNOWN")
bluetoothOffLabel.alpha = 1.0
case .resetting:
print("Bluetooth status is RESETTING")
bluetoothOffLabel.alpha = 1.0
case .unsupported:
print("Bluetooth status is UNSUPPORTED")
bluetoothOffLabel.alpha = 1.0
case .unauthorized:
print("Bluetooth status is UNAUTHORIZED")
bluetoothOffLabel.alpha = 1.0
case .poweredOff:
print("Bluetooth status is POWERED OFF")
bluetoothOffLabel.alpha = 1.0
case .poweredOn:
print("Bluetooth status is POWERED ON")

DispatchQueue.main.async { () -> Void in
self.bluetoothOffLabel.alpha = 0.0
self.connectingActivityIndicator.startAnimating()
}

// STEP 3.2: 扫描我们感兴趣的外围设备
centralManager?.scanForPeripherals(withServices: [BLE_Heart_Rate_Service_CBUUID])

} // END switch

} // END func centralManagerDidUpdateState

// STEP 4.1: 找到这个应用程序可以连接哪些感兴趣的外围设备
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {

print(peripheral.name!)
decodePeripheralState(peripheralState: peripheral.state)
// STEP 4.2: 必须储存一个外围设备的引用到类的实例变量中
peripheralHeartRateMonitor = peripheral
// STEP 4.3: 因为 HeartRateMonitorViewController 采用了 CBPeripheralDelegate 协议,
// 所以 peripheralHeartRateMonitor 必须设置他的
// delegate 属性为 HeartRateMonitorViewController (self)
peripheralHeartRateMonitor?.delegate = self

// STEP 5: 停止扫描以保护电池的寿命;当断开链接的时候再次扫描。
centralManager?.stopScan()

// STEP 6: 与已经发现的,感兴趣的外围设备建立连接
centralManager?.connect(peripheralHeartRateMonitor!)

} // END func centralManager(... didDiscover peripheral

// STEP 7: “当一个与外围设备的连接被成功创建时调用。”
// 只有当我们知道与外围设备的连接建立成功之后才能前往下一步
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {

DispatchQueue.main.async { () -> Void in

self.brandNameTextField.text = peripheral.name!
self.connectionStatusView.backgroundColor = UIColor.green
self.beatsPerMinuteLabel.text = "---"
self.sensorLocationTextField.text = "----"
self.connectingActivityIndicator.stopAnimating()

}

// STEP 8: 在外围设备上寻找感兴趣的服务
peripheralHeartRateMonitor?.discoverServices([BLE_Heart_Rate_Service_CBUUID])

} // END func centralManager(... didConnect peripheral

// STEP 15: 当一个外围设备断开连接,使用适当的方法
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {

// print("Disconnected!")

DispatchQueue.main.async { () -> Void in

self.brandNameTextField.text = "----"
self.connectionStatusView.backgroundColor = UIColor.red
self.beatsPerMinuteLabel.text = "---"
self.sensorLocationTextField.text = "----"
self.connectingActivityIndicator.startAnimating()

}

// STEP 16: 在这个用例中,开始扫描相同或其他的外设,只要它们是 HRM,就可以重新联机
centralManager?.scanForPeripherals(withServices: [BLE_Heart_Rate_Service_CBUUID])

} // END func centralManager(... didDisconnectPeripheral peripheral

// MARK: - CBPeripheralDelegate methods

func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {

for service in peripheral.services! {

if service.uuid == BLE_Heart_Rate_Service_CBUUID {

print("Service: \(service)")

// STEP 9: 在感兴趣的服务中寻找感兴趣的特征
peripheral.discoverCharacteristics(nil, for: service)

}

}

} // END func peripheral(... didDiscoverServices

// STEP 10: 从感兴趣的服务中,确认我们所发现感兴趣的特征
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {

for characteristic in service.characteristics! {
print(characteristic)

if characteristic.uuid == BLE_Body_Sensor_Location_Characteristic_CBUUID {

// STEP 11: 订阅关于感兴趣特征的单次通知;
// “当你使用这个方法去读取特征的值时,外围设备将会调用……
// peripheral:didUpdateValueForCharacteristic:error:”
//
// Read Mandatory
//
peripheral.readValue(for: characteristic)

}

if characteristic.uuid == BLE_Heart_Rate_Measurement_Characteristic_CBUUID {

// STEP 11: 订阅关于感兴趣特征的持续通知;
// “当你启用特征值的通知时,外围设备调用……
// peripheral(_:didUpdateValueFor:error:)”
//
// Notify Mandatory
//
peripheral.setNotifyValue(true, for: characteristic)

}

} // END for

} // END func peripheral(... didDiscoverCharacteristicsFor service

// STEP 12: 每当一个特征值定期更新或者发布一次时,我们都会收到通知;
// 阅读并解译我们订阅的特征值
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {

if characteristic.uuid == BLE_Heart_Rate_Measurement_Characteristic_CBUUID {

// STEP 13: 通常我们需要将 BLE 的数据解析成人类可读的格式
let heartRate = deriveBeatsPerMinute(using: characteristic)

DispatchQueue.main.async { () -> Void in

UIView.animate(withDuration: 1.0, animations: {
self.beatsPerMinuteLabel.alpha = 1.0
self.beatsPerMinuteLabel.text = String(heartRate)
}, completion: { (true) in
self.beatsPerMinuteLabel.alpha = 0.0
})

} // END DispatchQueue.main.async...

} // END if characteristic.uuid ==...

if characteristic.uuid == BLE_Body_Sensor_Location_Characteristic_CBUUID {

// STEP 14: 通常我们需要将 BLE 的数据解析成人类可读的格式
let sensorLocation = readSensorLocation(using: characteristic)

DispatchQueue.main.async { () -> Void in
self.sensorLocationTextField.text = sensorLocation
}
} // END if characteristic.uuid ==...

} // END func peripheral(... didUpdateValueFor characteristic

// MARK: - Utilities

func deriveBeatsPerMinute(using heartRateMeasurementCharacteristic: CBCharacteristic) -> Int {

let heartRateValue = heartRateMeasurementCharacteristic.value!
// 转换为无符号 8 位整数数组
let buffer = [UInt8](heartRateValue)

// UInt8: “一个 8 位无符号整数类型。”

// 在缓冲区的第一个字节(8 位)是标记(元数据,用于管理包中其余部分);
// 如果最低有效位(LSB)是 0,心率(bpm)则是 UInt8 格式,
// 如果 LSB 是 1,BPM 则是 UInt16
if ((buffer[0] &amp; 0x01) == 0) {
// 第二个字节:“心率的格式被设置为 UINT8”
print("BPM is UInt8")
// 将心率写入 HKHealthStore
// healthKitInterface.writeHeartRateData(heartRate: Int(buffer[1]))
return Int(buffer[1])
} else { // 我从来没有看到过这个用例,所以我把它留给理论学家去争论
// 第二个和第三个字节:“心率的格式被设置为 UINT16”
print("BPM is UInt16")
return -1
}

} // END func deriveBeatsPerMinute

func readSensorLocation(using sensorLocationCharacteristic: CBCharacteristic) -> String {

let sensorLocationValue = sensorLocationCharacteristic.value!
// 转换为无符号 8 位整数数组
let buffer = [UInt8](sensorLocationValue)
var sensorLocation = ""

// 只看 8 位
if buffer[0] == 1
{
sensorLocation = "Chest"
}
else if buffer[0] == 2
{
sensorLocation = "Wrist"
}
else
{
sensorLocation = "N/A"
}

return sensorLocation

} // END func readSensorLocation

func decodePeripheralState(peripheralState: CBPeripheralState) {

switch peripheralState {
case .disconnected:
print("Peripheral state: disconnected")
case .connected:
print("Peripheral state: connected")
case .connecting:
print("Peripheral state: connecting")
case .disconnecting:
print("Peripheral state: disconnecting")
}

} // END func decodePeripheralState(peripheralState

} // END class HeartRateMonitorViewController

总结

我希望你喜欢这篇教程。买或者借一个 BLE 设备,然后使用我的代码或自己编写代码来连接它。遵循教程中所有我提供的超链接并且阅读它们。查阅 Bluetooth SIG 的 网页 以及苹果的 Core Bluetooth这里 也可以看到)框架文档,你一定可以对蓝牙技术有一个概览。

感谢阅读。记得享受你的工作。不要忘记,当你的简历上面有蓝牙的经验将是你的职业生涯的一大亮点。

作为参考,你可以 在 GitHub 上面查看完整的源代码

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

Swift 5 字符串插值之美

作者:Erica Sadun,原文链接,原文日期:2018-12-12
译者:RocZhang;校对:numbbbbbpmst;定稿:Forelax

感谢提案 SE-0228,让我们能够精确控制字符串插值的打印方式。感谢 Brent 带给我们这个非常棒的功能。让我来分享一些例子。

回想一下在我们要打印可选值的时候,会这样写:

"There's \(value1) and \(value2)"

但这样写会立即得到一个警告:

我们可以点击修复按钮来消除这些警告,得到如下的代码。但我们仍然会看到一个类似于这样的输出:“There’s Optional(23) and nil”。

"There's \(String(describing: value1)) and \(String(describing: value2))"

现在我们可以通过下面这种方式去掉输出中的“Optional”,直接打印出“There’s 23 and nil”:

extension String.StringInterpolation {
/// 提供 `Optional` 字符串插值
/// 而不必强制使用 `String(describing:)`
public mutating func appendInterpolation(_ value: T?, default defaultValue: String) {
if let value = value {
appendInterpolation(value)
} else {
appendLiteral(defaultValue)
}
}
}

// There's 23 and nil
"There's \(value1, default: "nil") and \(value2, default: "nil")"

我们也可以创建一组样式,从而使可选值能够保持一致的输出展示方式:

extension String.StringInterpolation {
/// 可选值插值样式
public enum OptionalStyle {
/// 有值和没有值两种情况下都包含单词 `Optional`
case descriptive
/// 有值和没有值两种情况下都去除单词 `Optional`
case stripped
/// 使用系统的插值方式,在有值时包含单词 `Optional`,没有值时则不包含
case `default`
}

/// 使用提供的 `optStyle` 样式来插入可选值
public mutating func appendInterpolation(_ value: T?, optStyle style: String.StringInterpolation.OptionalStyle) {
switch style {
// 有值和没有值两种情况下都包含单词 `Optional`
case .descriptive:
if value == nil {
appendLiteral("Optional(nil)")
} else {
appendLiteral(String(describing: value))
}
// 有值和没有值两种情况下都去除单词 `Optional`
case .stripped:
if let value = value {
appendInterpolation(value)
} else {
appendLiteral("nil")
}
// 使用系统的插值方式,在有值时包含单词 `Optional`,没有值时则不包含
default:
appendLiteral(String(describing: value))
}
}

/// 使用 `stripped` 样式来对可选值进行插值
/// 有值和没有值两种情况下都省略单词 `Optional`
public mutating func appendInterpolation(describing value: T?) {
appendInterpolation(value, optStyle: .stripped)
}
}

// "There's Optional(23) and nil"
"There's \(value1, optStyle: .default) and \(value2, optStyle: .default)"

// "There's Optional(23) and Optional(nil)"
"There's \(value1, optStyle: .descriptive) and \(value2, optStyle: .descriptive)"

// "There's 23 and nil"
"There's \(describing: value1) and \(describing: value2)"

插值不仅仅用于调整可选值的输出方式,在其他方面也很有用。比如你想控制输出是否带有特定的字符,就不需要写一个带有空字符串的三元表达式:

// 成功时包含(感谢 Nate Cook)
extension String.StringInterpolation {
/// 只有 `condition` 的返回值为 `true` 才进行插值
mutating func appendInterpolation(if condition: @autoclosure () -> Bool, _ literal: StringLiteralType) {
guard condition() else { return }
appendLiteral(literal)
}
}

// 旧写法
"Cheese Sandwich \(isStarred ? "(*)" : "")"

// 新写法
"Cheese Sandwich \(if: isStarred, "(*)")"

我们还可以用字符串插值来做更多有趣的事情。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

以流的形式执行 Multipart 请求

作者:Soroush Khanlou,原文链接,原文日期:2018-11-14
译者:郑一一;校对:numbbbbbpmst;定稿:Forelax

Foundation 框架中的 URL 类提供了非常全面的功能,此后还在 iOS 7 中新增了 URLSession 类。尽管如此,基础库中仍然缺少 multipart 文件上传的功能。

什么是 multipart 请求?

Multipart 编码实际上就是在网络中上传大型文件的方法。在浏览器中,有时候你会选择一个文件作为表单提交内容的一部分。这个文件便是以 multipart 请求的方式实现上传的。

乍一看,multipart 请求和一般请求差不多。不同之处是 multipart 请求额外为 HTTP 请求体指定了唯一编码。同 JSON 编码({"key": "value"})或者 URL 字符编码 (key=value) 相比,multipart 编码干的事略微有所不同。因为 multipart 请求体实际上只是一串字节流,接收端实体在解析数据时,需要知道字节流中各个部分之间的界限。所以 multipart 请求需要使用 “boundaries” 来解决这个问题。在请求首部的 Content-Type 中,可以定义 boundary:

Accept: application/json
Content-Type: multipart/form-data; boundary=khanlou.comNczcJGcxe

Boundary 的具体内容并不重要,唯一需要注意的是:在请求体中,boundary 是不能重复出现(这样才能体现 boundary 的作用)。你可以使用 UUID 作为 boundary。

请求的每一部分可以是普通数据(比如图片)或者元数据(一般是文本,对应一个名字,组成一个键值对)。如果数据是图片的话,那它看起来应该是这样的:

--<boundary>
Content-Disposition: form-data; name=<name>; filename=<filename.jpg>
Content-Type: image/jpeg

<image data>

如果是普通文本,则是这样:

--<boundary>
Content-Disposition: form-data; name=<name>
Content-Type: text/plain

<some text>

请求结尾会有一个带着两个连字符的 boundary,--<boundary>--。(此处需要注意,所有新行必须是回车换行。)

以上就是关于 multipart 请求的所有内容,并不是特别复杂。事实上,当在写第一个有关 multipart 编码的客户端实现时,我有些抵触阅读 multipart/form-data 的 RFC。可是在开始阅读之后,我对这个协议的理解更深了。整个文档可读性很强,很轻易就能直达知识的源头。

我在开源的 Backchannel SDK 实现了上述功能。BAKUploadAttachmentRequestBAKMultipartRequestBuilder 类包含了处理 mulitipart 的方法。在这个项目中,仅仅包含了处理单个文件的情况,并且没有包括元数据。但是作为范例,依旧很好地展示了 mulitipart 请求是如何构建的。可以通过添加额外的实现代码,来支持元数据和多文件的功能。

无论是使用一个请求上传多个文件,还是多个请求分别对应上传一个文件,来实现多文件上传功能,都会碰到一个问题。这个问题就是,如果你尝试一次性上传很多文件的话,app 将会闪退。这是因为使用 该版本的代码,加载的数据会直接进入内存,在内存暴涨的情况下,即使使用当下性能最强的旗舰手机也会有闪退发生。

将硬盘中数据以流的形式读取

最常见的解决方法是将硬盘中的数据以流的形式读取出来。其核心思想是文件的字节数据会一直保存在硬盘里,直到被读取并发往网络。内存中只保留了很小一部分的镜像数据。

目前,我想出两种方法可以解决这个问题。第一个方法,把 multipart 请求体中的所有数据写到硬盘的一个新文件中,并使用 URLSession 的 uploadTask(with request: URLRequest, fromFile fileURL: URL) 方法将文件转化为流。这个方法可以奏效,但我并不想为每一个请求新建一个新文件保存到硬盘中。因为这意味着在请求发出后,还需要删除这个文件。

第二种方法是将内存和硬盘的数据合并在一起,并通过统一的接口向网络输出数据。

如果你觉得第二种方法听起来像是 类簇,恭喜你,完全正确。很多常用 Cocoa 类都允许创建子类,并实现一些父类方法,使其和父类表现一致。回想一下 NSArray-count 属性和 -objectAtIndex: 方法。因为 NSArray 的所有其它方法都是基于 -count 属性和 -objectAtIndex: 方法实现的,你可以非常轻易地创建优化版本的 NSArray 子类。

你可以创建一个 NSData 子类,它无需真正从硬盘读取数据,而只是创建一个指针直接指向硬盘中的数据。这样做的好处是是不需要把数据载入内存中进行读取。这种方法称为内存映射,基于 Unix 方法 mmap。你可以通过 .mappedIfSafe 或者 alwaysMapped 选项,来使用 NSData 的这项特性。因为 NSData 是一个类簇,我们将创建一个 ConcatenatedData 子类(就像 FlattenCollection 在 Swift 中的工作方式),该子类会将多个 NSData 对象视作一个连续的 NSData。完成创建以后,我们就做好所有准备来解决这个问题啦。

通过查看 NSData 所有原生方法,可以发现,需要实现的是 -count-bytes。实现 -count 并不难,我们可以把所有 NSData 对象的大小相加得到;但在实现 -bytes 时则会有个问题。 -bytes 需要返回一个指向一段连续缓冲区的指针,而目前我们并没有这个指针。

在基础库中,提供了 NSInputStream 类用于处理不连续的数据。非常幸运,NSInputStream 同样是一个类簇。我们可以创建一个子类,将多条流合并。在使用子类时,感觉上就像是一条流。通过使用 +inputStreamWithData:+inputStreamWithURL: 方法,可以轻易地创建一条输入流,用来代表硬盘中的文件和内存中的数据(比如 boundaries)。

通过阅读最好的第三方网络库源代码,你会发现 AFNetworking 采用了这种方法。(Alamofire,Swift 版本的 AFNetworking,则采用了第一种方法,将数据全部加载到内存中,但如果数据量太大,就会写到硬盘的一个文件中。)

将所有部分拼接起来

你可以在 这里 看看我的串行输入流的实现(是用 Objective-C 实现的,以后我可能还会写一个 Swift 版本的)。

通过 SKSerialInputStream 类,可以将流组合在一起。下面展示了前缀和后缀属性:

extension MultipartComponent {
var prefixData: Data {
let string = """
\(self.boundary)
Content-Disposition: form-data; name="\(self.name); filename="\(self.filename)"
"""
return string.data(using: .utf8)
}

var postfixData: Data {
return "\r\n".data(using: .utf8)
}
}

将元数据和文件的 dataStream 组合在一起,得到一条输入流:

extension MultipartComponent {
var inputStream: NSInputStream {

let streams = [
NSInputStream(data: prefixData),
self.fileDataStream,
NSInputStream(data: postfixData),
]

return SKSerialInputStream(inputStreams: streams)
}
}

创建好每一部分输入流之后,就可以把所有流组合在一起,得到一条完整输入流。此外,在请求结尾还需要添加一个 boundary:

extension RequestBuilder {
var bodyInputStream: NSInputStream {
let stream = parts
.map({ $0.inputStream })
+ [NSInputStream(data: "--\(self.boundary)--".data(using: .utf8))]

return SKSerialInputStream(inputStreams: streams)
}
}

最后,将 bodyInputStream 赋值给 URL 请求的 httpBodyStream 属性:

let urlRequest = URLRequest(url: url)

urlRequest.httpBodyStream = requestBuilder.bodyInputStream;

注意,httpBodyStreamhttpBody 两个属性是互斥的——两个属性不会同时生效。设置 httpBodyStream 会使得 Data 版本 httpBody 失效,反之亦然。

流文件上传的关键是能够将多条输入流合并成一条流。SKSerialInputStream 类完成了整个工作。尽管说子类化 NSInputStream 有一些困难,可一旦解决这个问题,我们就离成功不远啦。

子类化过程中需要注意的问题

子类化 NSInputStream 的过程不会太轻松,甚至可以说很困难。你必须实现 9 个方法。其中的 7 个方法,父类只有一些微不足道的默认实现。而在文档中只提到了 9 个方法中的 3 个,所以你还得实现 6 个 NSStreamNSInputStream 的父类)的方法,其中有 2 个是 run loop 方法,并允许空实现。在这之前,你还需要额外 实现 3 个私有方法,不过现在不必实现了。此外,还需要定义 3 个只读属性:streamStatusstreamErrordelegate

在处理完上述子类化相关的细节后,接下来的挑战是创建一个 NSInputStream 子类,其行为应该和 API 使用者所期望的保持一致。然而,这个类状态的重度耦合是不容易被人发现的。

有一些状态需要保证行为一致。举个例子,hasBytesAvailable 是不同于其它状态的,但还是存在细微的联系。在我最近发现的一个 bug 里,hasBytesAvailable 属性会返回 self.currentIndex != self.inputStreams.count,但是这会造成一个 bug,流会一直处于开启的状态,并最终造成请求超时。修复这个 bug 的办法是改为返回 YES,但我一直没有找到这个 bug 的根源所在。

另外一个状态 streamStatus,存在许多可能的值,其中比较重要的两个值是 NSStreamStatusOpenNSStreamStatusClosed

最后一个比较有意思的状态是字节数,从 read 方法中返回值。这个属性除了会返回正整型数之外,还会返回 -1,-1 代表有错误产生,需要进一步检查非空属性 streamError 来获取更多信息。字节数还可以返回 0,根据文档描述,这是标明流结尾的另外一种方式。

文档并不会告诉你哪些状态的组合是有意义的。比如说流产生一个 streamError,但状态却是 NSStreamStatusClosed,而不是 NSStreamStatusError,在这种情况下是否会有问题?想要管理好所有的状态非常难,不过到最后终究还是能解决的。

对于 SKSerialStream 类,是否可以在所有情况下都能正常工作,我还不是特别有信心。但看起来,SKSerialStream 通过使用 URLSession 能很好地支持上传 multipart 数据。如果你在使用这份代码的时候发现任何问题,请务必联系我,我们可以一起不断优化这个类。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

Swift 傻瓜技巧 #6:有动画或无动画

作者:Wooji Juice,原文链接,原文日期:2018-11-14
译者:石榴;校对:numbbbbbCee;定稿:Forelax

流畅的动画一开始就被认为是 iOS 应用的特点之一。这不仅归功于 iOS 系统强大的动画引擎(从而使得 App 能够一边展示流畅的动画一边做着其他的事情),还归功于系统提供的非常方便的动画 API:

// 无动画
doStuff()
// 有动画
UIView.animate(withDuration: 1) { doStuff() }

只需要将你的代码放进 block(闭包)中,就可以让它们拥有流畅的缓入缓出的动画效果。

然而,如果你使用过这套系统,你可能会遇到一些问题。这个系统可以完美地处理简单的情况,比如让一个东西淡入、淡出,或改变它的颜色,但在更复杂的情况下,这种方法就会开始出现问题。

例如下面这个例子,你想要淡出一个元素,然后删除它。UIView 支持这种操作:

UIView.animate(withDuration: 1, animations:
{
someting.alpha = 0
}, completion:
{
something.removeFormSuperView()
})

但你只能把所有东西都写在 completion block 里时才会工作。在大型项目中,我们需要把复杂的任务拆解成小的方法。但问题就在这些方法中,像在上个例子中的 doStuff(),我们无法在 completion block 中添加代码。

我们也无法得知动画有多长(甚至都不知道有没有动画),所以如果我们没有办法简单地和动画时间之间同步(如在 一个音频编辑软件 中让进度条同步前进)。

总的来说,我们无法获知关于动画的信息,他们仅仅是执行代码,进行或不进行动画,并不会受我们控制。

如果我们在视图中添加带有 Auto Layout 的新元素,事情就会变得更复杂:你需要小心地调用 UIView.performWithoutAnimation { },否则新出现的视图就会从 (x: 0, y: 0, w: 0, h: 0) 瞬移到它们的目标位置。

视图属性 Animator

很长时间以来,我一直在改变代码中动画的写法。最开始我写了我自己的 AnimationContext 类来协助,后来苹果提供了他们功能相同的 UIViewPropertyAnimator,现在我会在所有可能的地方使用它。

一般来说,我发现最有效的方法是写一个「可动画」的方法并显式接受一个 animator 参数:

func doStuff(with animator: UIViewPropertyAnimator? = nil)
{
// ...
}

之后我就可以直接调用 doStuff() 不添加动画并完成任务,或调用 doStuff(with: UIViewPropertyAnimator(duration: 1, curve: .easeInOut)) 或加其他的参数去完成任务并添加动画。

(实际情况中,上述方法通常会被称作 reflectCurrentState() 或其他特定领域的名字;该方法执行所有必要的修改,并将视图与最新的数据同步。该方法一般不会被本视图以外的代码调用,而是被视图自己调用,然后会根据需要继续调用其他内部方法,或将 animator 传给其他内部方法。不过这不在本文的讨论范围内。)

doStuff() 可以像之前一样,带有或不带有动画执行一个任务。但现在它带有了更多信息:它知道自己是否执行动画;它可以读取 animator 的 duration 属性(如果有的话)。他可以调用 animator 的 addAnimation 来明确地指定哪些代码需要动画,并直接执行不需要动画的代码;他可以调用 addCompletion 来处理 removeFromSuperView() 或其他方法。

以上都是相比于之前改进的地方,但也不是没有问题。尤其是它开始变得有点啰嗦:

  1. doStuff(with: ...) 需要写入一个很长的 UIViewPropertyAnimator 构造函数。不是很理想,不过跟下面比起来不算什么:
  2. doStuff() 内部,需要检查 UIViewPropertyAnimator 是否存在并调整代码。

我们不能简单的依赖 optional chaining(可选链式调用)(如 animator?.addcompletion { something.removeFromSuperview() }),因为如果 animator 是 nil 会导致 block 中的代码被直接跳过,然而无论有没有动画,我们都希望该视图在父视图中被移除。

为了保证正确的行为,你的代码会类似这个样子:

func doStuff(with animator: UIViewPropertyAnimator? = nil)
{
if let animator = animator
{
_ in something.removeFromSuperview()
}
else
{
something.removeFromSuperview()
}
}

Objective-C 爱好者即使瞧不起 Optional(可选)也笑不出来 – 使用 Objective-C 也不会改善这种情况:

- (void) doStuffWithAnimator: (nullable UIViewPropertyAnimator *) animator
{
if (animator != nil)
{
[animator addCompletion: ^(UIViewAnimatingPosition position)
{
[something removeFromSuperview];
}];
}
else
{
[something removeFromSuperView];
}
}

一旦你在生产环境中想使用这样的代码,你最终会写出更杂乱、更难于阅读和维护的代码。

幸运的是,我们可以进一步的改进这段代码。

Optional 不是 Nil 的另一个叫法

改进这段代码的诀窍就在于,UIViewPropertyAnimator 在这里是 Optional,关键点就在于 Optional 在 Swift 中的意义。

有的时候人们会抱怨 Swift 的 Optional 非常烦人,因为在 Objective-C 中(Objective-C 中使用 nil 指针来替代 Swift 中的 Optional)你可以直接对指针调用方法。

Objective-C 不会抱怨指针是不是 nil:如果指针非空,方法会直接被调用;如果是空指针,调用会被无声地忽略掉,不用程序员做其他的事情。

我不同意这个意见。在 Swift 中,在你知道你在做什么的情况下,你只需要加一个 ?,并不是一个很大的负担。但是由于有了 Swift 的 Optional,我们可以做更多事情。

因为在 Swift 中,Optional 是一个“真实的东西”,而不是“缺少的东西”。无论一个 Optional 的值是什么,就算是 nil,它也是一个枚举值,你可以对它调用方法,调用的方法也会被执行。讲真的,Swift 的枚举超级好用!

(有趣的是,在 Objective-C 类中对 Swift 的 nil 的底层表示就是空指针,所以它们的效率还是很高的。但是语法层面,它们非常的不同。我们会在接下来利用这个性质。)

因为在 Swift 中,你可以对几乎所有类型添加拓展,不仅仅是 Objective-C 类。你可以:

extension Optional where Wrapped == UIViewPropertyAnimator
{
@discardableResult
func addCompletion(_ block: @escaping (UIViewAnimatingPosition)->()) -> Optional<UIViewPropertyAnimator>
{
if let animator = self
{
animator.addCompletion(block)
}
else
{
block(.end)
}
return self
}
}

这段代码将难看的代码移动到了 Optional 的库中(但只针对 UIViewPropertyAnimator)。现在,你的视图可以:

func doStuff(with animator: UIViewPropertyAnimator? = nil)
{
animator.addCompletion { _ in something.removeFromSuperview() }
}

现在回调函数总会被执行,无论有没有 animator。

(注意 animatoraddCompletion 之间没有 ?

如果有 animator,block 中的代码会在动画完成时被调用;如果没有 animator,block 中的代码会被立即调用,因为 nil Optional 仍然是 Optional,拥有所有 Optional 的方法,当然也包括我们刚刚添加的方法 – 而不是一个吞下所有的滚落到它表面的方法调用的黑洞。

我还有类似的拓展方法来执行总是需要被执行的任务,有些是动画的一部分,或其他的立即执行的代码:如果我想让一个元素缓入,我会在把元素放入视图之前将 alpha 值设置成 0,然后调用 animator.perform { something.alpha = 1 } 来保证它无论有没有动画都会变得可见。

与 Optional 无关,我还在 UIViewPropertyAnimator 中添加了一些静态方法来生成一些常见的动画,如:static func spring(...)static func linear(...)。Swift 的名称解析方法决定了你可以写出更简洁的代码,如:doStuff(with: .spring(duration: 1))

当然,以上只是一些小的代码技巧,而不是重新构想代码或应用结构。但是随着项目的复杂度增加,像这种小的改进也会叠加起来,帮助我们对抗不断增加的复杂度,维持大型项目的可控性。谢谢你,Swift。Thwift.

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg

❌