普通视图

发现新文章,点击刷新页面。
昨天以前SwiftGG

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

作者 SwiftGG
2019年11月11日 00:00

作者: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

Swift 中的面向协议编程:引言

作者 SwiftGG
2019年9月5日 00:00

作者:Andrew Jaffee,原文链接,原文日期:2018-03-20
译者:灰s;校对:numbbbbbWAMaker;定稿:Pancf

对于开发者来说,复杂性是最大的敌人,因此我会去了解那些可以帮助我管理混乱的新技术。Swift 中的“面向协议编程”(POP)是最近(至少自2015年以来)引起广泛关注的“热门”方法之一。在这里我们将使用 Swift 4。在我自己编写代码时,发现 POP 很有前途。更吸引人的是,Apple 宣称 “Swift 的核心是面对协议的”。我想在一个正式的报告中分享关于 POP 的经验,一篇关于这个新兴技术清晰而简洁的教程。

我将解释关键概念,提供大量代码示例,无法避免的将 POP 和 OOP (面向对象编程)进行比较,并对面向流行编程(FOP?)的人群所声称的 POP 是解决所有问题的灵丹妙药这一说法进行泼冷水。

面向协议编程是一个很棒的新工具,值得添加到你现有的编程工具库中,但是没有什么可以代替那些经久不衰的基本功,就像将大的函数拆分成若干个小函数,将大的代码文件拆分成若干个小的文件,使用有意义的变量名,在敲代码之前花时间设计架构,合理而一致的使用间距和缩进,将相关的属性和行为分配到类和结构体中 - 遵循这些常识可以让世界变得不同。如果你编写的代码无法被同事理解,那它就是无用的代码。

学习和采用像 POP 这样的新技术并不需要绝对的唯一。POP 和 OOP 不仅可以共存,还可以互相协助。对于大多数开发者包括我自己,掌握 POP 需要时间和耐心。因为 POP 真的很重要,所以我将教程分成两篇文章。本文将主要介绍和解释 Swift 的协议和 POP。第二篇文章将深入研究 POP 的高级应用方式(比如从协议开始构建应用程序的功能),范型协议,从引用类型到值类型转变背后的动机,列举 POP 的利弊,列举 OOP 的利弊,比较 OOP 和 POP,阐述为什么“Swift 是面向协议的”,并且深入研究一个被称为 “局部推理” 的概念,它被认为是通过使用 POP 增强的。这次我们只会粗略涉及一些高级主题。

引言

作为软件开发者,管理复杂性本质上是我们最应该关注的问题。当我们尝试学习 POP 这项新技术时,你可能无法从时间的投资中看到即时回报。但是,就像你对我的认识有个过程一样,你将会了解 POP 处理复杂性的方法,同时为你提供另一种工具来控制软件系统中固有的混乱。

我听到越来越多关于 POP 的讨论,但是却很少看到使用这种方式编写的产品代码,换句话说,我还没有看到有很多人从协议而不是类开始创建应用程序的功能。这不仅仅是因为人类有抗拒改变的倾向。学习一种全新的范式并将其付诸实践,说起来容易做起来难。在我编写新应用程序时,逐渐发现自己开始使用 POP 来设计和实现功能 — 有组织的且自然而然的。

伴随着新潮流带来的刺激,很多人都在谈论用 POP 取代 OOP。我认为除非像 Swift 这样的 POP 语言被广泛改进,否则这是不可能发生的 — 也或许根本就不会发生。我是个实用主义者,而不是追求时髦的人。在开发新的 Swift 项目时,我发现自己的行为是一种折衷的方法。我在合理的地方利用 OOP,而用 POP 更合适的地方也不会死脑筋的一定要使用 OOP,这样反而了解到这两种模式并不相互排斥。我把这两种技术结合在一起。在本期由两部分组成的 POP 教程中,你将了解我在说什么。

我投入到 OOP 中已经有很久了。1990 年,我买了一个零售版本的 Turbo Pascal。在使用了 OOP 大约一年后,我开始设计、开发和发布面向对象的应用程序产品。我成了一个忠粉。当我发现可以扩展增强自己的类,简直兴奋的飞起。随着时间的推移,Microsoft 和 Apple 等公司开始开发基于 OOP 的大型代码库,如 Microsoft Foundation Classes(MFC)和 .NET,以及 iOS 和 OS X SDK。现在,开发人员在开发新应用程序时很少需要重新造轮子。没有完美的方法,OOP 也有一些缺点,但是优点仍然大于缺点。我们将花一些时间来比较 OOP 和 POP。

理解协议

当开发人员设计一个新的 iOS 应用程序的基本结构时,他们几乎总是从 FoundationUIKit 等框架中的现有 开始。我能想到的几乎所有应用程序都需要某种用户界面导航系统。用户需要一些进入应用程序的入口点和引导他们使用应用程序功能的路标。可以浏览一下你的 iPhone 或 iPad 上的应用程序。

当这些应用程序打开时,你看到了什么?我打赌你看到的是 UITableViewControllerUICollectionViewControllerUIPageViewController 的子类。

当你第一次创建新的 iOS 项目时,所有人都必须认识下面的代码片段,例如,一个新的 iOS 项目基于 Xcode 中的 Single View App(单视图应用) 模板:

...
import UIKit

class ViewController: UIViewController
{
...

部分开发人员将在这里停下来,创建完全定制的接口,但大多数人将采取另一个步骤。

当 iOS 开发者开发新的应用程序时,最常见的特征就是 OOP,那么 POP 在这里扮演什么角色呢?

你知道我将怎样继续么?想象大多数开发人员的下一个主要步骤是什么。那就是遵循协议(并实现 委托,但我们已经讨论过了)。

让我给你们看一个例子使其便于理解。我相信你们很多人都用过 UITableView。虽然这不是一个关于 UITableView 的教程,但是你应该知道在 UIViewController 中将其实现时,协议扮演着重要的角色。在向 UIViewController 中添加 UITableView时,UIViewController 必须遵循 UITableViewDataSourceUITableViewDelegate 协议,就像这样:

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate

简而言之,遵循 UITableViewDataSource 允许你用数据填充所有的 UITableViewCell,比如给用户提供导航的菜单项名称。采用 UITableViewDelegate,你可以对用户与 UITableView 的交互进行更细粒度的控制,比如在用户点击特定的 UITableViewCell 时执行适当的操作。

定义

我发现,在进行技术性定义和讨论之前,理解常用的术语定义可以帮助读者更好地理解某个主题。首先,让我们 考虑 “协议”一词的通俗定义

……管理国家事务或外交领域的正式程序或规则体系。……
在任何团体、组织或形势下,公认或已制定的程序或行为准则。……
进行科学实验时的程序……

Apple 的“Swift 编程语言(Swift 4.0.3)” 文档中的声明

协议定义了适合特定任务或功能的方法、属性和其他需求的蓝图。然后,类、结构体或枚举可以遵循该协议来提供这些需求的实际实现。任何满足协议要求的类型都被称为遵循该协议。

协议是最重要的工具之一,我们必须给软件固有的混乱带来一些秩序。协议使我们能够要求一个或多个类和结构体包含特定的最小且必需的属性,和/或提供特定的最小且必需的实现/方法。通过 协议扩展,我们可以为一些或所有协议的方法提供默认实现。

遵循协议

下面,我们将使自定义的 Person遵循采用)Apple 自带 Equatable 协议。

遵循 Equatable 协议以后可以使用等于运算符(==)来判断是否相等,使用不等于运算符(!=)来判断是否不等。Swift 标准库中的大部分基础类型都遵循了 Equatable 协议……

class Person : Equatable
{
var name:String
var weight:Int
var sex:String

init(weight:Int, name:String, sex:String)
{
self.name = name
self.weight = weight
self.sex = sex
}

static func == (lhs: Person, rhs: Person) -> Bool
{
if lhs.weight == rhs.weight &&
lhs.name == rhs.name &&
lhs.sex == rhs.sex
{
return true
}
else
{
return false
}
}
}

Apple 规定,“自定义类型声明它们采用特定的协议,需要将协议的名称放在类型名称之后,以冒号分隔,作为其定义的一部分。”这也正是我所做的:

class Person : Equatable

你可以将协议理解为专门针对 classstructenum约定承诺。我通过 Equatable 协议使自定义的 Person 类遵守了一个约定,Person承诺通过现实 Equatable 协议需要的方法或成员变量来履行该约定,即将其实现。

Equatable 协议并没有实现任何东西。它只是指明了采用(遵循) Equatable 协议的 classstruct,或者 enum 必须实现的方法和/或成员变量。有一些协议通过 extensions 实现了功能,稍后我们会进行讨论。我不会花太多时间来讲述关于 enum 的 POP 用法。我将它作为练习留给你。

定义协议

理解协议最好的方式是通过例子。我将自己构建一个 Equatable 来向你展示协议的用法:

protocol IsEqual
{
static func == (lhs: Self, rhs: Self) -> Bool

static func != (lhs: Self, rhs: Self) -> Bool
}

请记住,我的“IsEqual”协议并没有对 ==!= 运算符进行实现。“IsEqual”需要协议的遵循者实现他们自己的 ==!= 运算符。

所有定义协议属性和方法的规则都在 Apple 的 Swift 文档 中进行了总结。比如,在协议中定义属性永远不要用 let 关键字。只读属性规定使用 var 关键字,并在后面单独跟上 { get }。如果有一个方法改变了一个或多个属性,你需要标记它为 mutating。你需要知道为什么我重写的 ==!= 操作符被定义为 static。如果你不知道,找出原因将会是一个很好的练习。

为了向你展示我的 IsEqual(或者 Equatable)这样的协议具有广泛的适用性,我们将使用它在下面构建一个类。但是在我们开始之前,让我们先讨论一下“引用类型”与“值类型”。

引用类型与值类型

在继续之前,您应该阅读 Apple 关于 “值和引用类型” 的文章。它将让你思考引用类型和值类型。我故意不在这里讲太多细节,因为我想让你们思考并理解这个非常重要的概念。它太过重要,以至于针对 POP 引用/值类型的讨论同时出现在这些地方:

  1. WWDC 2015 展示的 “Protocol-Oriented Programming in Swift”
  2. WWDC 2015 展示的 “Building Better Apps with Value Types in Swift”
  3. WWDC 2016 展示的 “Protocol and Value Oriented Programming in UIKit Apps”

我会给你一个提示和作业……假设你有多个指向同一个类实例的引用,用于修改或“改变”属性。这些引用指向相同的数据块,因此将其称为“共享”数据并不夸张。在某些情况下,共享数据可能会导致问题,如下面的示例所示。这是否表示我们要将所有的代码改成值类型?并不是!就像 Apple 的一个工程师指出:“例如,以 Window 为例。复制一个 Window 是什么意思?” 查看下面的代码,并思考这个问题。

引用类型

下面的代码片段来自 Xcode playground,在创建对象副本然后更改属性时,会遇到一个有趣的难题。你能找到问题么?我们将在下一篇文章中讨论这个问题。

这段代码同时也演示了协议的定义和 extension

// 引用类型:每个人都使用类很长时间了 
// -- 想想 COCOA 中进行的所有隐式复制。

protocol ObjectThatFlies
{
var flightTerminology: String { get }
func fly() // 不需要提供实现,除非我想
}

extension ObjectThatFlies
{
func fly() -> Void
{
let myType = String(describing: type(of: self))
let flightTerminologyForType = myType + " " + flightTerminology + "\n"
print(flightTerminologyForType)
}
}

class Bird : ObjectThatFlies
{
var flightTerminology: String = "flies WITH feathers, and flaps wings differently than bats"
}

class Bat : ObjectThatFlies
{
var flightTerminology: String = "flies WITHOUT feathers, and flaps wings differently than birds"
}

// 引用类型

let bat = Bat()
bat.fly()
// "Bat flies WITHOUT feathers, and flaps wings differently than birds"

let bird = Bird()
bird.fly()
// "Bird flies WITH feathers, and flaps wings differently than bats"

var batCopy = bat
batCopy.fly()
// "Bird flies WITH feathers, and flaps wings differently than bats"

batCopy.flightTerminology = ""
batCopy.fly()
// 控制台输出 "Bat"

bat.fly()
// 控制台输出 "Bat"

来自前面代码片段的控制台输出

Bat flies WITHOUT feathers, and flaps wings differently than birds

Bird flies WITH feathers, and flaps wings differently than bats

Bird flies WITH feathers, and flaps wings differently than bats

Bat

Bat

值类型

在接下来的 Swift 代码片段中,我们使用 struct 替代 class。在这里,代码看起来更安全,而 Apple 似乎在推广值类型和 POP。注意,他们目前还没有放弃 class

// 这是范式转变的起点,不仅仅是协议,还有值类型

protocol ObjectThatFlies
{
var flightTerminology: String { get }
func fly() // 不需要提供实现,除非我想
}

extension ObjectThatFlies
{
func fly() -> Void
{
let myType = String(describing: type(of: self))
let flightTerminologyForType = myType + " " + flightTerminology + "\n"
print(flightTerminologyForType)
}
}

struct Bird : ObjectThatFlies
{
var flightTerminology: String = "flies WITH feathers, and flaps wings differently than bats"
}

struct Bat : ObjectThatFlies
{
var flightTerminology: String = "flies WITHOUT feathers, and flaps wings differently than birds"
}

// 值类型

let bat = Bat()
bat.fly()
// "Bat flies WITHOUT feathers, and flaps wings differently than birds"

let bird = Bird()
bird.fly()
// "Bird flies WITH feathers, and flaps wings differently than bats"

var batCopy = bat
batCopy.fly()
// "Bird flies WITH feathers, and flaps wings differently than bats"

// 我在这里对 Bat 实例所做的事情是显而易见的
batCopy.flightTerminology = ""
batCopy.fly()
// 控制台输出 "Bat"

// 但是,因为我们使用的是值类型,所以 Bat 实例的原始数据并没有因为之前的操作而被篡改。
bat.fly()
// "Bat flies WITHOUT feathers, and flaps wings differently than birds"

来自前面代码片段的控制台输出

Bat flies WITHOUT feathers, and flaps wings differently than birds

Bird flies WITH feathers, and flaps wings differently than bats

Bat flies WITHOUT feathers, and flaps wings differently than birds

Bat

Bat flies WITHOUT feathers, and flaps wings differently than birds

示例代码

我写了一些面向协议的代码。请通读代码,阅读内联注释,阅读附带的文章,跟随我的超链接,并充分理解我在做什么。你将在下一篇关于 POP 的文章中用到它。

采用多种协议

刚开始写这篇文章的时候,我很贪心,想要自定义一个协议,使它能同时体现 Apple 的内置协议 EquatableComparable

protocol IsEqualAndComparable
{

static func == (lhs: Self, rhs: Self) -> Bool

static func != (lhs: Self, rhs: Self) -> Bool

static func > (lhs: Self, rhs: Self) -> Bool

static func < (lhs: Self, rhs: Self) -> Bool

static func >= (lhs: Self, rhs: Self) -> Bool

static func <= (lhs: Self, rhs: Self) -> Bool

}

我意识到应该将它们分开,使我的代码尽可能灵活。为什么不呢?Apple 声明同一个类,结构体,枚举可以遵循多个协议,就像接下来我们将看到的一样。下面是我提出的两个协议:

protocol IsEqual
{
static func == (lhs: Self, rhs: Self) -> Bool

static func != (lhs: Self, rhs: Self) -> Bool
}

protocol Comparable
{
static func > (lhs: Self, rhs: Self) -> Bool

static func < (lhs: Self, rhs: Self) -> Bool

static func >= (lhs: Self, rhs: Self) -> Bool

static func <= (lhs: Self, rhs: Self) -> Bool
}

记住你的算法

你需要磨练的一项重要技能是编程的算法,并将它们转换为代码。我保证在将来的某一天,会有人给你一个复杂过程的口头描述并要求你对它进行编码。用人类语言描述某些步骤,之后用软件将其实现,它们之间一般都会有很大的差距。当我想要将 IsEqualComparable 应用于表示直线(向量)的类时,我意识到了这一点。我记得计算一个直线的长度是基于勾股定理的(参考 这里这里),并且对向量使用 ==!=<><=,和 >= 这些运算符进行比较时,直线的长度是必须的。我的 Line 类迟早会派上用场,例如,在一个绘图应用程序或游戏中,用户点击屏幕上的两个位置,在两点之间创建一条线。

自定义类采用多个协议

这是我的 Line 类,它采用了两个协议,IsEqualComparable(如下)。这是多继承的一种形式!

class Line : IsEqual, Comparable
{
var beginPoint:CGPoint
var endPoint:CGPoint

init()
{
beginPoint = CGPoint(x: 0, y: 0);
endPoint = CGPoint(x: 0, y: 0);
}

init(beginPoint:CGPoint, endPoint:CGPoint)
{
self.beginPoint = CGPoint( x: beginPoint.x, y: beginPoint.y )
self.endPoint = CGPoint( x: endPoint.x, y: endPoint.y )
}

// 线长的计算基于勾股定理。
func length () -> CGFloat
{
let length = sqrt( pow(endPoint.x - beginPoint.x, 2) + pow(endPoint.y - beginPoint.y, 2) )
return length
}

static func == (leftHandSideLine: Line, rightHandSideLine: Line) -> Bool
{
return (leftHandSideLine.length() == rightHandSideLine.length())
}

static func != (leftHandSideLine: Line, rightHandSideLine: Line) -> Bool
{
return (leftHandSideLine.length() != rightHandSideLine.length())
}

static func > (leftHandSideLine: Line, rightHandSideLine: Line) -> Bool
{
return (leftHandSideLine.length() > rightHandSideLine.length())
}

static func < (leftHandSideLine: Line, rightHandSideLine: Line) -> Bool
{
return (leftHandSideLine.length() < rightHandSideLine.length())
}

static func >= (leftHandSideLine: Line, rightHandSideLine: Line) -> Bool
{
return (leftHandSideLine.length() >= rightHandSideLine.length())
}

static func <= (leftHandSideLine: Line, rightHandSideLine: Line) -> Bool
{
return (leftHandSideLine.length() <= rightHandSideLine.length())
}

} // 类的结束行:IsEqual, Comparable

验证你的算法

我使用电子制表软件 Apple Numbers,并准备了两个向量的可视化表示,对 Line 类的 length() 方法做了一些基本测试:

这里是我根据上面图表中的点,写的测试代码:

let x1 = CGPoint(x: 0, y: 0)
let y1 = CGPoint(x: 2, y: 2)
let line1 = Line(beginPoint: x1, endPoint: y1)
line1.length()
// returns 2.82842712474619

let x2 = CGPoint(x: 3, y: 2)
let y2 = CGPoint(x: 5, y: 4)
let line2 = Line(beginPoint: x2, endPoint: y2)
line2.length()
// returns 2.82842712474619

line1 == line2
// returns true
line1 != line2
// returns false
line1 > line2
// returns false
line1 <= line2
// returns true

使用 Xcode “Single View” playground 模版测试/原型化 UI

你是否知道可以使用 Xcode 9 Single View playground 模板来原型化和测试用户界面(UI)?它非常棒 - 可以节省大量时间并快速原型化的工具。为了更好的测试我的 Line 类,我创建了这样一个 playground。作业:在我解释之前,我想让你自己试一下。向你展示我的 playground 代码、模拟器输出和我的 Swift 测试语句。

这里是我的 playground 代码:

import UIKit
import PlaygroundSupport

class LineDrawingView: UIView
{
override func draw(_ rect: CGRect)
{
let currGraphicsContext = UIGraphicsGetCurrentContext()
currGraphicsContext?.setLineWidth(2.0)
currGraphicsContext?.setStrokeColor(UIColor.blue.cgColor)
currGraphicsContext?.move(to: CGPoint(x: 40, y: 400))
currGraphicsContext?.addLine(to: CGPoint(x: 320, y: 40))
currGraphicsContext?.strokePath()

currGraphicsContext?.setLineWidth(4.0)
currGraphicsContext?.setStrokeColor(UIColor.red.cgColor)
currGraphicsContext?.move(to: CGPoint(x: 40, y: 400))
currGraphicsContext?.addLine(to: CGPoint(x: 320, y: 60))
currGraphicsContext?.strokePath()

currGraphicsContext?.setLineWidth(6.0)
currGraphicsContext?.setStrokeColor(UIColor.green.cgColor)
currGraphicsContext?.move(to: CGPoint(x: 40, y: 400))
currGraphicsContext?.addLine(to: CGPoint(x: 250, y: 80))
currGraphicsContext?.strokePath()
}
}

class MyViewController : UIViewController
{
override func loadView()
{
let view = LineDrawingView()
view.backgroundColor = .white

self.view = view
}
}

// 在实时视图窗口中显示视图控制器
PlaygroundPage.current.liveView = MyViewController()

这是我在 playground 模拟器上的视图输出:

下面是测试我的 Line 类型实例与我在 playground 上所画向量匹配的 Swift 代码:

let xxBlue = CGPoint(x: 40, y: 400)
let yyBlue = CGPoint(x: 320, y: 40)
let lineBlue = Line(beginPoint: xxBlue, endPoint: yyBlue)

let xxRed = CGPoint(x: 40, y: 400)
let yyRed = CGPoint(x: 320, y: 60)
let lineRed = Line(beginPoint: xxRed, endPoint: yyRed)
lineRed.length()
// returns 440.454310910905

lineBlue != lineRed
// returns true
lineBlue > lineRed
// returns true
lineBlue >= lineRed
// returns true

let xxGreen = CGPoint(x: 40, y: 400)
let yyGreen = CGPoint(x: 250, y: 80)
let lineGreen = Line(beginPoint: xxGreen, endPoint: yyGreen)
lineGreen.length()
// returns 382.753184180093
lineGreen < lineBlue
// returns true
lineGreen <= lineRed
// returns true
lineGreen > lineBlue
// returns false
lineGreen >= lineBlue
// returns false
lineGreen == lineGreen
// returns true

总结

我希望你喜欢今天的文章,并且非常期待阅读本文的“第二部分”。记住,我们将深入研究使用 POP 的先进应用程序,范型协议,从引用类型到值类型背后的动机,列举 POP 的优缺点,列举 OOP 的优缺点,比较 OOP 和 POP,确定为什么“Swift 是面向协议的”,并深入研究称为“局部推理”的概念。

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

给 UIView 来点烟花

作者 SwiftGG
2019年8月14日 00:00

作者: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

在 iOS 11 中使用 Core Bluetooth

作者 SwiftGG
2019年4月15日 13:00

作者: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 傻瓜技巧 #6:有动画或无动画

作者 SwiftGG
2019年1月7日 14:00

作者: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

❌
❌