普通视图

发现新文章,点击刷新页面。
昨天以前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年5月6日 13:00

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

在 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

❌
❌