南峰子的技术博客

攀登,一步一个脚印,方能知其乐


  • 首页

  • 知识小集

  • Swift

  • Objective-C

  • Cocoa

  • 翻译

  • 源码分析

  • 杂项

  • 归档

MVVM Tutorial with ReactiveCocoa: Part 1/2

发表于 2014-08-08   |   分类于 翻译

本文由Colin Eberhardt发表于raywenderlich,原文可查看MVVM Tutorial with ReactiveCocoa: Part 1/2

你可能已经在Twitter上听过这个这个笑话了:

“iOS Architecture, where MVC stands for Massive View Controller”

当然这在iOS开发圈内,这是个轻松的笑话,但我敢确定你大实践中遇到过这个问题:即视图控制器太大且难以管理。

这篇文章将介绍另一种构建应用程序的模式–MVVM(Model-View-ViewModel)。通过结合ReactiveCocoa便利性,这个模式提供了一个很好的代替MVC的方案,它保证了让视图控制器的轻量性。

在本文我,我们将通过构建一个简单的Flickr查询程序来一步步了解MVVM,这个程序的效果图如下所示:

image

在开始写代码之前,我们先来了解一些基本的原理。

原文简要介绍了一下ReactiveCocoa,在此不再翻译,可以查看以下两篇译文:

ReactiveCocoa Tutorial – The Definitive Introduction: Part 1/2

ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2

MVVM模式介绍

正如其名称一下,MVVM是一个UI设计模式。它是MV*模式集合中的一员。MV*模式还包含MVC(Model View Controller)、MVP(Model View Presenter)等。这些模式的目的在于将UI逻辑与业务逻辑分离,以让程序更容易开发和测试。为了更好的理解MVVM模式,我们可以看看其来源。

MVC是最初的UI设计模式,最早出现在Smalltalk语言中。下图展示了MVC模式的主要组成:

image

这个模式将UI分成Model(表示程序状态)、View(由UI控件组成)、Controller(处理用户交互与更新model)。MVC模式的最大问题是其令人相当困惑。它的概念看起来很好,但当我们实现MVC时,就会产生上图这种Model-View-Controller之间的环状关系。这种相互关系将会导致可怕的混乱。

最近Martin Fowler介绍了MVC模式的一个变种,这种模式命名为MVVM,并被微软广泛采用并推广。

image

这个模式的核心是ViewModel,它是一种特殊的model类型,用于表示程序的UI状态。它包含描述每个UI控件的状态的属性。例如,文本输入域的当前文本,或者一个特定按钮是否可用。它同样暴露了视图可以执行哪些行为,如按钮点击或手势。

我们可以将ViewModel看作是视图的模型(model-of-the-view)。MVVM模式中的三部分比MVC更加简洁,下面是一些严格的限制

  1. View引用了ViewModel,但反过来不行。
  2. ViewModel引用了Model,但反过来不行。

如果我们破坏了这些规则,便无法正确地使用MVVM。

这个模式有以下一些立竿见影的优势:

  1. 轻量的视图:所有的UI逻辑都在ViewModel中。
  2. 便于测试:我们可以在没有视图的情况下运行整个程序,这样大大地增加了它的可测试性。

现在你可能注意到一个问题。如果View引用了ViewModel,但ViewModel没有引用View,那ViewModel如何更新视图呢?哈哈,这就得靠MVVM模式的私密武器了。

MVVM和数据绑定

MVVM模式依赖于数据绑定,它是一个框架级别的特性,用于自动连接对象属性和UI控件。例如,在微软的WPF框架中,下面的标签将一个TextField的Text属性绑定到ViewModel的Username属性中。

1
<TextField Text=”{DataBinding Path=Username, Mode=TwoWay}”/>

WPF框架将这两个属性绑定到一起。

不过可惜的是,iOS没有数据绑定框架,幸运的是我们可以通过ReactiveCocoa来实现这一功能。我们从iOS开发的角度来看看MVVM模式,ViewController及其相关的UI(nib, stroyboard或纯代码的View)组成了View:

image

……而ReactiveCocoa绑定了View和ViewModel。

理论讲得差不多了,我们可以开始新的历程了。

启动项目结构

可以从FlickrSearchStarterProject.zip中下载启动项目。我们使用Cocoapods来管理第三方库,在对应目录下执行pod install命令生成依赖库后,我们就可以打开生成的RWTFlickrSearch.xcworkspace来运行我们的项目了,初始运行效果如下图:

image

我们行熟悉下工程的结构:

image

Model和ViewModel分组目前是空的,我们会慢慢往里面添加东西。View分组包含以下几个类

  1. RWTFlickSearchViewController:程序的主屏幕,包含一个搜索输入域和一个GO按钮。
  2. RWTRecentSearchItemTableViewCell:用于在主页中显示搜索结果的table cell
  3. RWTSearchResultsViewController:搜索结果页,显示来自Flickr的tableview
  4. RWTSearchResultsTableViewCell:渲染来自Flickr的单个图片的table cell。

现在来写我们的第一个ViewModel吧。

第一个ViewModel

在ViewModel分组中添加一个继承自NSObject的新类RWTFlickrSearchViewModel。然后在该类的头文件中,添加以下两行代码:

1
2
@property (nonatomic, strong) NSString *searchText;
@property (nonatomic, strong) NSString *title;

searchText属性表示文本域中显示文本,title属性表示导航条上的标题。

打开RWTFlickrSearchViewModel.m文件添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@implementation RWTFlickrSearchViewModel
- (instancetype)init
{
self = [super init];
if (self)
{
[self initialize];
}
return self;
}
- (void)initialize
{
self.searchText = @"search text";
self.title = @"Flickr Search";
}
@end

这段代码简单地设置了ViewModel的初始状态。

接下来我们将连接ViewModel到View。记住View保存了一个ViewModel的引用。在这种情况下,添加一个给定ViewModel的初始化方法来构造View是很有必要的。打开RWTFlickrSearchViewController.h,并导入ViewModel头文件:

1
#import "RWTFlickrSearchViewModel.h"

并添加以下初始化方法:

1
2
3
4
5
@interface RWTFlickrSearchViewController : UIViewController
- (instancetype)initWithViewModel:(RWTFlickrSearchViewModel *)viewModel;
@end

在RWTFlickrSearchViewController.m中,在类的扩展中添加以下私有属性:

1
@property (weak, nonatomic) RWTFlickrSearchViewModel *viewModel;

然后添加以下方法:

1
2
3
4
5
6
7
8
9
10
11
- (instancetype)initWithViewModel:(RWTFlickrSearchViewModel *)viewModel
{
self = [super init];
if (self)
{
_viewModel = viewModel;
}
return self;
}

这就在view中存储了一个到ViewModel的引用。注意这是一个弱引用,这样View引用了ViewModel,但没有拥有它。

接下来在viewDidLoad里面添加下面代码:

1
[self bindViewModel];

该方法的实现如下:

1
2
3
4
5
- (void)bindViewModel
{
self.title = self.viewModel.title;
self.searchTextField.text = self.viewModel.searchText;
}

最后我们需要创建ViewModel,并将其提供给View。在RWTAppDelegate.m中,添加以下头文件:

1
#import "RWTFlickrSearchViewModel.h"

同时添加一个私有属性:

1
@property (nonatomic, strong) RWTFlickrSearchViewModel *viewModel;

我们会发现这个类中已以有一个createInitialViewController方法了,我们用以下代码来更新它:

1
2
3
4
- (UIViewController *)createInitialViewController {
self.viewModel = [RWTFlickrSearchViewModel new];
return [[RWTFlickrSearchViewController alloc] initWithViewModel:self.viewModel];
}

这个方法创建了一个ViewModel实例,然后构造并返回了View。这个视图作程序导航控制器的初始视图。

运行后的状态如下:

image

这样我们就得到了第一个ViewModel。不过仍然有许多东西要学的。你可能已经发现了我们还没有使用ReactiveCocoa。到目前为止,用户在输入框上的输入操作不会影响到ViewModel。

检测可用的搜索状态

现在,我们来看看如何用ReactiveCocoa来绑定ViewModel和View,以将搜索输入框和按钮连接到ViewModel。

在RWTFlickrSearchViewController.m中,我们使用如下代码更新bindViewModel方法。

1
2
3
4
5
- (void)bindViewModel
{
self.title = self.viewModel.title;
RAC(self.viewModel, searchText) = self.searchTextField.rac_textSignal;
}

在ReactiveCocoa中,使用了分类将rac_textSignal属性添加到UITextField类中。它是一个信号,在文本域每次更新时会发送一个包含当前文本的next事件。

RAC是一个用于做绑定操作的宏,上面的代码会使用rac_textSignal发出的next信号来更新viewModel的searchText属性。

搜索按钮应该只有在用户输入有效时才可点击。为了方便起见,我们以输入字符大于3时输入有效为准。在RWTFlickrSearchViewModel.m中导入以下头文件。

1
#import <ReactiveCocoa/ReactiveCocoa.h>

然后更新初始化方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
- (void)initialize
{
self.title = @"Flickr Search";
RACSignal *validSearchSignal =
[[RACObserve(self, searchText)
map:^id(NSString *text) {
return @(text.length > 3);
}]
distinctUntilChanged];
[validSearchSignal subscribeNext:^(id x) {
NSLog(@"search text is valid %@", x);
}];
}

运行程序并在输入框中输入一些字符,在控制台中我们可以看到以下输出:

1
2
3
2014-08-07 21:50:44.078 RWTFlickrSearch[3116:60b] search text is valid 0
2014-08-07 21:50:59.493 RWTFlickrSearch[3116:60b] search text is valid 1
2014-08-07 21:51:02.594 RWTFlickrSearch[3116:60b] search text is valid 0

上面的代码使用RACObserve宏来从ViewModel的searchText属性创建一个信号。map操作将文本转化为一个true或false值的流。

最后,distinctUntilChanges确保信号只有在状态改变时才发出值。

到目前为止,我们可以看到ReactiveCocoa被用于将绑定View绑定到ViewModel,确保了这两者是同步的。另进一步地,ViewModel内部的ReactiveCocoa代码用于观察自己的状态及执行其它操作。

这就是MVVM模式的基本处理过程。ReactiveCocoa通常用于绑定View和ViewModel,但在程序的其它层也非常有用。

添加搜索命令

本节将上面创建的validSearchSignal来创建绑定到View的操作。打开RWTFlickrSearchViewModel.h并添加以下头文件

1
#import <ReactiveCocoa/ReactiveCocoa.h>

同时添加以下属性

1
@property (strong, nonatomic) RACCommand *executeSearch;

RACCommand是ReactiveCocoa中用于表示UI操作的一个类。它包含一个代表了UI操作的结果的信号以及标识操作当前是否被执行的一个状态。

在RWTFlickrSearchViewModel.m的initialize方法的最后添加以下代码:

1
2
3
4
self.executeSearch = [[RACCommand alloc] initWithEnabled:validSearchSignal
signalBlock:^RACSignal *(id input) {
return [self executeSearchSignal];
}];

这创建了一个在validSearchSignal发送true时可用的命令。另外,需要在下面实现executeSearchSignal方法,它提供了命令所执行的操作。

1
2
3
4
- (RACSignal *)executeSearchSignal
{
return [[[[RACSignal empty] logAll] delay:2.0] logAll];
}

在这个方法中,我们执行一些业务逻辑操作,以作为命令执行的结果,并通过信号异步返回结果。

到目前为止,上述代码只提供了一个简单的实现:空信号会立即完成。delay操作会将其所接收到的next或complete事件延迟两秒执行。

最后一步是将这个命令连接到View中。打开RWTFlickrSearchViewController.m并在bindViewModel方法的结尾中添加以下代码:

1
self.searchButton.rac_command = self.viewModel.executeSearch;

rac_command属性是UIButton的ReactiveCocoa分类中添加的属性。上面的代码确保点击按钮执行给定的命令,且按钮的可点击状态反应了命令的可用状态。

运行代码,输入一些字符并点击GO,得到如下结果:

image

可以看到,当输入有效点击按钮时,按钮会置灰2秒钟,当执行的信号完成时又可点击。我们可以看下控制台的输出,可以发现空信号会立即完成,而延迟操作会在2秒后发出事件:

1
2
2014-08-07 22:21:25.128 RWTFlickrSearch[3161:60b] <RACDynamicSignal: 0x17005ba20> name: +empty completed
2014-08-07 22:21:27.329 RWTFlickrSearch[3161:60b] <RACDynamicSignal: 0x17005dd30> name: [+empty] -delay: 2.000000 completed

是不是很酷?

绑定、绑定还是绑定

RACCommand监听了搜索按钮状态的更新,但处理activity indicator的可见性则由我们负责。RACCommand暴露了一个executing属性,它是一个信号,发送true或false来标明命令开始和结束执行的时间。我们可以用这个来影响当前命令的状态。

在RWTFlickrSearchViewController.m中的bindViewModel方法结尾处添加以下代码:

1
RAC([UIApplication sharedApplication], networkActivityIndicatorVisible) = self.viewModel.executeSearch.executing;

这将UIApplication的networkActivityIndicatorVisible属性绑定到命令的executing信号中。这确保了不管命令什么时候执行,状态栏中的网络activity indicator都会显示。

接下来添加以下代码:

1
RAC(self.loadingIndicator, hidden) = [self.viewModel.executeSearch.executing not];

当命令执行时,应该隐藏加载indicator。这可以通过not操作来反转信号。

最后,添加以下代码:

1
2
3
[self.viewModel.executeSearch.executionSignals subscribeNext:^(id x) {
[self.searchTextField resignFirstResponder];
}];

这段代码确保命令执行时隐藏键盘。executionSignals属性发送由命令每次执行时生成的信号。这个属性是信号的信号(见ReactiveCocoa Tutorial – The Definitive Introduction: Part 1/2)。当创建和发出一个新的命令执行信号时,隐藏键盘。

运行程序看看效果如何吧。

Model在哪?

到目前为止,我们已经有了一个清晰的View(RWTFlickrSearchViewController)和ViewModel(RWTFlickrSearchViewModel),但是Model在哪呢?

答案很简单:没有!

当前的程序执行一个命令来响应用户点击搜索按钮的操作,但是实现不做任何值的处理。ViewModel真正需要做的是使用当前的searchText来搜索Flickr,并返回一个匹配的列表。

我们应该可以直接在ViewModel添加业务逻辑,但相信我,你不希望这么做。如果这是一个viewcontroller,我打赌你一定会直接这么做。

ViewModel暴露属性来表示UI状态,它同样暴露命令来表示UI操作(通常是方法)。ViewModel负责管理基于用户交互的UI状态的改变。然而它不负责实际执行这些交互产生的的业务逻辑,那是Model的工作。

接下来,我们将在程序中添加Model层。

在Model分组中,添加RWTFlickrSearch协议并提供以下实现

1
2
3
4
5
6
7
#import <ReactiveCocoa/ReactiveCocoa.h>
@protocol RWTFlickrSearch <NSObject>
- (RACSignal *)flickrSearchSignal:(NSString *)searchString;
@end

这个协议定义了Model层的初始接口,并将搜索Flickr的责任移出ViewModel。

接下来在Model分组中添加RWTFlickrSearchImpl类,其继承自NSObject,并实现了RWTFlickrSearch协议,如下代码所示:

1
2
3
4
5
#import "RWTFlickrSearch.h"
@interface RWTFlickrSearchImpl : NSObject <RWTFlickrSearch>
@end

打开RWTFlickrSearchImpl.m文件,提供以下实现:

1
2
3
4
5
6
7
8
@implementation RWTFlickrSearchImpl
- (RACSignal *)flickrSearchSignal:(NSString *)searchString
{
return [[[[RACSignal empty] logAll] delay:2.0] logAll];
}
@end

看着是不是有点眼熟?没错,我们在上面的ViewModel中有相同的实现。

接下来我们需要在ViewModel层中使用Model层。在ViewModel分组中添加RWTViewModelServices协议并如下实现:

1
2
3
4
5
#import "RWTFlickrSearch.h"
@protocol RWTViewModelServices <NSObject>
- (id<RWTFlickrSearch>)getFlickrSearchService;
@end

这个协议定义了唯一的一个方法,以允许ViewModel获取一个引用,以指向RWTFlickrSearch协议的实现对象。

打开RWTFlickrSearchViewModel.h并导入头文件

1
#import "RWTViewModelServices.h"

更新初始化方法并将RWTViewModelServices作为一个参数:

1
- (instancetype)initWithServices:(id<RWTViewModelServices>)services;

在RWTFlickrSearchViewModel.m中,添加类的分类并提供一个私有属性来维护一个到RWTViewModelServices的引用:

1
2
3
@interface RWTFlickrSearchViewModel ()
@property (nonatomic, weak) id<RWTViewModelServices> services;
@end

在该文件下面,添加初始化方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
- (instancetype)initWithServices:(id<RWTViewModelServices>)services
{
self = [super init];
if (self)
{
_services = services;
[self initialize];
}
return self;
}

这只是简单的存储了services的引用。

最后,更新executeSearchSignal方法:

1
2
3
4
- (RACSignal *)executeSearchSignal
{
return [[self.services getFlickrSearchService] flickrSearchSignal:self.searchText];
}

最后是连接Model和ViewModel。

在工程的根分组中,添加一个NSObject的子类RWTViewModelServicesImpl。打开RWTViewModelServicesImpl.h并实现RWTViewModelServices协议:

1
2
3
4
#import "RWTViewModelServices.h"
@interface RWTViewModelServicesImpl : NSObject <RWTViewModelServices>
@end

打开RWTViewModelServicesImpl.m,并添加实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#import "RWTFlickrSearchImpl.h"
@interface RWTViewModelServicesImpl ()
@property (strong, nonatomic) RWTFlickrSearchImpl *searchService;
@end
@implementation RWTViewModelServicesImpl
- (instancetype)init
{
if (self = [super init])
{
_searchService = [RWTFlickrSearchImpl new];
}
return self;
}
- (id<RWTFlickrSearch>)getFlickrSearchService
{
return self.searchService;
}
@end

这个类简单创建了一个RWTFlickrSearchImpl实例,用于Model层搜索Flickr服务,并将其提供给ViewModel的请求。

最后,在RWTAppDelegate.m中添加以下头文件

1
#import "RWTViewModelServicesImpl.h"

并添加一个新的私有属性

1
@property (nonatomic, strong) RWTViewModelServicesImpl *viewModelServices;

再更新createInitialViewController方法:

1
2
3
4
5
- (UIViewController *)createInitialViewController {
self.viewModelServices = [RWTViewModelServicesImpl new];
self.viewModel = [[RWTFlickrSearchViewModel alloc] initWithServices:self.viewModelServices];
return [[RWTFlickrSearchViewController alloc] initWithViewModel:self.viewModel];
}

运行程序,验证程序有没有按之前的方式来工作。当然,这不是最有趣的变化,不过,可以看看新代码的形状了。

Model层暴露了一个ViewModel层使用的’服务’。一个协议定义了这个服务的接口,提供了松散的组合。

我们可以使用这种方式来为单元测试提供一个类似的服务实现。程序现在有了正确的MVVM结构,让我们小结一下:

  1. Model层暴露服务并负责提供程序的业务逻辑实现。
  2. ViewModel层表示程序的视图状态(view-state)。同时响应用户交互及来自Model层的事件,两者都受view-state变化的影响。
  3. View层很薄,只提供ViewModel状态的显示及输出用户交互事件。

搜索Flickr

我们继续来完成Flickr的搜索实现,事情变得越来越有趣了。

首先我们创建表示搜索结果的模型对象。在Model分组中,添加RWTFlickrPhoto类,并为其添加三个属性。

1
2
3
4
5
6
7
@interface RWTFlickrPhoto : NSObject
@property (nonatomic, strong) NSString *title;
@property (nonatomic, strong) NSURL *url;
@property (nonatomic, strong) NSString *identifier;
@end

这个模型对象表示由Flickr搜索API返回一个图片。

打开RWTFlickrPhoto.m,并添加以下描述方法的实现:

1
2
3
4
- (NSString *)description
{
return self.title;
}

接下来,新建一个新的模型对象类RWTFlickrSearchResults,并添加以下属性:

1
2
3
4
5
6
7
@interface RWTFlickrSearchResults : NSObject
@property (strong, nonatomic) NSString *searchString;
@property (strong, nonatomic) NSArray *photos;
@property (nonatomic) NSInteger totalResults;
@end

这个类表示由Flickr搜索返回的照片集合。

是时候实现搜索Flickr了。打开RWTFlickrSearchImpl.m并导入以下头文件:

1
2
3
4
#import "RWTFlickrSearchResults.h"
#import "RWTFlickrPhoto.h"
#import <objectiveflickr/ObjectiveFlickr.h>
#import <LinqToObjectiveC/NSArray+LinqExtensions.h>

然后添加以下类扩展:

1
2
3
4
5
6
@interface RWTFlickrSearchImpl () <OFFlickrAPIRequestDelegate>
@property (strong, nonatomic) NSMutableSet *requests;
@property (strong, nonatomic) OFFlickrAPIContext *flickrContext;
@end

这个类实现了OFFlickrAPIRequestDelegate协议,并添加了两个私有属性。我们会很快看到如何使用这些值。

继续添加代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (instancetype)init
{
self = [super init];
if (self)
{
NSString *OFSampleAppAPIKey = @"YOUR_API_KEY_GOES_HERE";
NSString *OFSampleAppAPISharedSecret = @"YOUR_SECRET_GOES_HERE";
_flickrContext = [[OFFlickrAPIContext alloc] initWithAPIKey:OFSampleAppAPIKey sharedSecret:OFSampleAppAPISharedSecret];
_requests = [NSMutableSet new];
}
return self;
}

这段代码创建了一个Flickr的上下文,用于存储ObjectiveFlickr请求的数据。

当前Model层服务类提供的API有一个单独的方法,用于查找基于文本搜索字符的图片。不过我们一会会添加更多的方法。

在RWTFlickrSearchImpl.m中添加以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
- (RACSignal *)signalFromAPIMethod:(NSString *)method arguments:(NSDictionary *)args transform:(id (^)(NSDictionary *response))block
{
// 1. 创建请求信号
return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
// 2. 创建一个Flick请求对象
OFFlickrAPIRequest *flickrRequest = [[OFFlickrAPIRequest alloc] initWithAPIContext:self.flickrContext];
flickrRequest.delegate = self;
[self.requests addObject:flickrRequest];
// 3. 从代理方法中创建一个信号
RACSignal *successSignal = [self rac_signalForSelector:@selector(flickrAPIRequest:didCompleteWithResponse:)
fromProtocol:@protocol(OFFlickrAPIRequestDelegate)];
// 4. 处理响应
[[[successSignal
map:^id(RACTuple *tuple) {
return tuple.second;
}]
map:block]
subscribeNext:^(id x) {
[subscriber sendNext:x];
[subscriber sendCompleted];
}];
// 5. 开始请求
[flickrRequest callAPIMethodWithGET:method arguments:args];
// 6. 完成后,移除请求的引用
return [RACDisposable disposableWithBlock:^{
[self.requests removeObject:flickrRequest];
}];
}];
}

这个方法需要传入请求方法及请求参数,然后使用block参数来转换响应对象。我们重点看一下第4步:

1
2
3
4
5
6
7
8
9
10
11
12
[[[successSignal
// 1. 从flickrAPIRequest:didCompleteWithResponse:代理方法中提取第二个参数
map:^id(RACTuple *tuple) {
return tuple.second;
}]
// 2. 转换结果
map:block]
subscribeNext:^(id x) {
// 3. 将结果发送给订阅者
[subscriber sendNext:x];
[subscriber sendCompleted];
}];

rac_signalForSelector:fromProtocol: 方法创建了successSignal,同样也在代理方法的调用中创建了信号。

代理方法每次调用时,发出的next事件会附带包含方法参数的RACTuple。

实现Flickr搜索的最后一步如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (RACSignal *)flickrSearchSignal:(NSString *)searchString {
return [self signalFromAPIMethod:@"flickr.photos.search"
arguments:@{@"text": searchString,
@"sort": @"interestingness-desc"}
transform:^id(NSDictionary *response) {
RWTFlickrSearchResults *results = [RWTFlickrSearchResults new];
results.searchString = searchString;
results.totalResults = [[response valueForKeyPath:@"photos.total"] integerValue];
NSArray *photos = [response valueForKeyPath:@"photos.photo"];
results.photos = [photos linq_select:^id(NSDictionary *jsonPhoto) {
RWTFlickrPhoto *photo = [RWTFlickrPhoto new];
photo.title = [jsonPhoto objectForKey:@"title"];
photo.identifier = [jsonPhoto objectForKey:@"id"];
photo.url = [self.flickrContext photoSourceURLFromDictionary:jsonPhoto
size:OFFlickrSmallSize];
return photo;
}];
return results;
}];
}

上面的方法使用signalFromAPIMethod:arguments:transform:方法。flickr.photos.search方法提供的字典来搜索照片。

传递给transform参数的block简单地将NSDictionary响应转化为一个等价的模型对象,让它在ViewModel中更容易使用。

最后一步是打开RWTFlickrSearchViewModel.m方法,然后更新搜索信号来记录日志:

1
2
3
4
5
- (RACSignal *)executeSearchSignal {
return [[[self.services getFlickrSearchService]
flickrSearchSignal:self.searchText]
logAll];
}

编译,运行并输入一些字符后可在控制台看到以下日志:

1
2
3
4
5
6
7
8
2014-06-03 [...] <RACDynamicSignal: 0x8c368a0> name: +createSignal: next: searchString=wibble, totalresults=1973, photos=(
"Wibble, wobble, wibble, wobble",
"unoa-army",
"Day 277: Cheers to the freakin' weekend!",
[...]
"Angry sky",
Nemesis
)

这样我们MVVM指南的第一部分就差不多结束了,但在结束之前,让我们先看看内存问题吧。

内存管理

正如在ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2中所讲的一样,我们在block中使用了self,这可能会导致循环引用的问题。而为了避免此问题,我们需要使用@weakify和@strongify宏来打破这种循环引用。

不过看看signalFromAPIMethod:arguments:transform:方法,你可能会迷惑为什么没有使用这两个宏来引用self?这是因为block是作为createSignal:方法的一个参数,它不会在self和block之间建立一个强引用关系。迷茫了吧?不相信的话只需要测试一样这段代码有没有内存泄露就行。当然这时候就得用Instruments了,自己去看吧。哈哈。

何去何从?

例子工程的完整代码可以在这里下载。在下一部分中,我们将看看如何从ViewModel中初始化一个视图控制器并实现更多的Flickr请求操作。

ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2

发表于 2014-08-02   |   分类于 翻译

原文由Colin Eberhardt发表于raywenderlich,ReactiveCocoa Tutorial – The Definitive Introduction: Part 2/2

第一部分我们学习了ReactiveCocoa处理信号的基本流程,如何发送流事件,以及分割及组合信号。在这一部分中,我们将继续学习ReactiveCocoa更多的特性,包括:

  1. error和completed事件类型
  2. 节流(Throttling)
  3. 线程
  4. 扩展

Twitter Instant

本部分我们将要开发的是一个称为Twitter Instant的程序,这是一个Twitter搜索应用,用于裡更新搜索结果。可以在这里下载初始程序,同时我们需要通过Cocoapods来下载依赖库,这个过程与第一部分相同。完成之后,运行程序,将得到下面的界面:

image

我们花点时间熟悉一下。这个程序很简单。左侧控制面板是RWSearchFormViewController,有一个搜索框。右侧是RWSearchResultsViewController。如果我们打开RWSearchFormViewController.m,我们可以看到viewDidLoad方法中指定了resultsViewController属性,这个程序的主要逻辑是在RWSearchFormViewController中,这个属性将搜索结果提供给RWSearchResultsViewController。

验证搜索框

首先我们来校验输入框的字符长度是否大于2。我们在RWSearchFormViewController.m的viewDidLoad方法下面添加以下代码:

1
2
3
4
- (BOOL)isValidSearchText:(NSString *)text
{
return text.length > 2;
}

接下来,我们在RWSearchFormViewController.m中导入ReactiveCocoa

1
#import <ReactiveCocoa/ReactiveCocoa.h>

同时在viewDidLoad方法最后加上以下代码:

1
2
3
4
5
[[self.searchText.rac_textSignal map:^id(NSString *text) {
return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor];
}] subscribeNext:^(UIColor *color) {
self.searchText.backgroundColor = color;
}];

这段代码通过信号来检测输入是否有效,并设置相应的输入框背影颜色值。运行后,可以看到如下效果:

image

其管道流程图如下所示:

image

rac_textSignal在每次输入时发出next事件,并包含当前输入框的文本。然后map操作将其转换为颜色值,最后subscribeNext:获取这个颜色值并用它来设置输入框的背景颜色。

在添加Twitter查找逻辑之前,我们先看看一些有趣的东西。

格式化管道代码

在调用信号的方法时,我们建议每个操作都新起一行,并排列所有的步骤。如下图所示,一个复杂的管道通过分行,看起来会更加清晰

image

内存管理

考虑下我们添加到TwitterInstant程序中的代码,想知道我们创建的管道是如何被保存的么?当然,因为它没有被指定给变量或属性,所以它没有增加引用计数,因此注定被销毁?ReactiveCocoa设计的目的之一是允许这样一种编程样式,即管道可以匿名创建。到目前为止,我们的管道都是这么处理的。为了支持这种模式,ReactiveCocoa维护了一个全局的信号集合。如果信号有一个或多个订阅者,它就是可用的。如果所有订阅者都被移除了,信号就被释放了。

剩下最后一个问题:如何取消对信号的订阅?在一个completed事件或error事件后,一个订阅者会自动将自己移除。手动移除可能通过RACDisposable来完成。RACSignal的所有订阅方法都返回一个RACDisposable实例,我们可以调用它的dispose方法来手动移除订阅者。如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
RACSignal *backgroundColorSignal =
[self.searchText.rac_textSignal
map:^id(NSString *text) {
return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor];
}];
RACDisposable *subscripion =
[backgroundColorSignal subscribeNext:^(UIColor *color) {
self.searchText.backgroundColor = color;
}];
// 在某个位置调用
[subscripion dispose];

当然实际上我们不需要这样来写,只需要知道是这么回事就行。

注意:如果我们创建了一个管道,但不去订阅它,则管理永远不会执行,包括任何如doNext:块这样的附加操作。

避免循环引用

ReactiveCocoa在幕后做了许多事情,让我们不需要担心信号的内存管理问题,但有一点关于内存管理的问题需要特别注意。我们先来看看下面的代码:

1
2
3
4
5
[[self.searchText.rac_textSignal map:^id(NSString *text) {
return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor];
}] subscribeNext:^(UIColor *color) {
self.searchText.backgroundColor = color;
}];

subscribeNext:块使用了self,以获取文本输入域。Block会捕获并保留闭包中的值,因此如果在self与信号之间有一个强引用,则会导致循环引用问题。这是不是问题取决于self对象的生命周期。如果self的生命周期是整个程序生存期,则没问题,好好用吧。但在大多数情况下,它确实是一个问题。

为了避循环引用,根据苹果的文档中推荐的捕获self的一个弱引用。如下代码所示:

1
2
3
4
5
6
7
__typeof(self) __weak weakSelf = self;
[[self.searchText.rac_textSignal map:^id(NSString *text) {
return [weakSelf isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor];
}] subscribeNext:^(UIColor *color) {
weakSelf.searchText.backgroundColor = color;
}];

在上面的代码中weakSelf是self对象的一个弱引用。现在subscribeNext:中使用了这个变量。不过ReactiveCocoa框架给我们提供了一个更好的选择。首先导入以下头文件:

1
#import <RACEXTScope.h>

然后使用以下代码:

1
2
3
4
5
6
7
8
@weakify(self)
[[self.searchText.rac_textSignal map:^id(NSString *text) {
return [self isValidSearchText:text] ? [UIColor whiteColor] : [UIColor yellowColor];
}] subscribeNext:^(UIColor *color) {
@strongify(self)
self.searchText.backgroundColor = color;
}];

宏@weakify与@strongify在Extendedobjc库中引用,它们包含在ReactiveCocoa框架中。@weakify允许我们创建一些影子变量,它是都是弱引用(可以同时创建多个),@strongify允许创建变量的强引用,这些变量是先前传递给@weakify的。

最后需要注意的是,当在block中使用实例变量时,block同样会捕获self的一个强引用。我们可以打开编译器警告,来提示我们这种情况。如下所求来处理

image

OK,内存问题说得差不多了,现在我们回到正题。

请求访问Twitter

我们将使用Social Framework以允许TwitterInstant程序搜索Tweets,同时使用Accounts Framework来获取对Twitter的访问。

在添加代码前,我们需要先登录Twitter。可以在系统的设置中登录,如下图所示:

image

我们的工程已经添加了所需要的框架,所以只需要在RWSearchFormViewController.m导入头文件。

1
2
#import <Accounts/Accounts.h>
#import <Social/Social.h>

然后在下面添加枚举及常量用于标识错误:

1
2
3
4
5
6
7
typedef NS_ENUM(NSInteger, RWTwitterInstantError) {
RWTwitterInstantErrorAccessDenied,
RWTwitterInstantErrorNoTwitterAccounts,
RWTwitterInstantErrorInvalidResponse
};
static NSString * const RWTwitterInstantDomain = @"TwitterInstant";

然后我们RWSearchFormViewController()分类中添加以下代码:

1
2
@property (strong, nonatomic) ACAccountStore *accountStore;
@property (strong, nonatomic) ACAccountType *twitterAccountType;

ACAccountsStore类提供了我们的设备可连接的多种社交账号,ACAccountType类表示账号的指定类型。

我们在viewDidLoad的结尾处添加以下代码,来创建账户存储及Twitter账户标识:

1
2
self.accountStore = [[ACAccountStore alloc] init];
self.twitterAccountType = [self.accountStore accountTypeWithAccountTypeIdentifier:ACAccountTypeIdentifierTwitter];

当账户请求社账号时,用户可以看到一个弹出框。这是一个异步操作,所以将其包装到一个信号中是很好的选择。

仍然在这个文件中,添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
- (RACSignal *)requestAccessToTwitterSignal
{
// 定义一个错误,如果用户拒绝访问则发送
NSError *accessError = [NSError errorWithDomain:RWTwitterInstantDomain code:RWTwitterInstantErrorAccessDenied userInfo:nil];
// 创建并返回信号
@weakify(self)
return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
// 请求访问twitter
@strongify(self)
[self.accountStore requestAccessToAccountsWithType:self.twitterAccountType
options:nil
completion:^(BOOL granted, NSError *error) {
// 处理响应
if (!granted)
{
[subscriber sendError:accessError];
}
else
{
[subscriber sendNext:nil];
[subscriber sendCompleted];
}
}];
return nil;
}];
}

一个信号可以发送三种事件类型:next, completed, error。

在信号的整个生命周期中,都可能不会发送事件,或者发送一个或多个next事件,其后跟着completed或error事件。

最后,为了使用这个信号,在viewDidLoad中添加以下代码:

1
2
3
4
5
6
[[self requestAccessToTwitterSignal]
subscribeNext:^(id x) {
NSLog(@"Access granted");
} error:^(NSError *error) {
NSLog(@"An error occurred: %@", error);
}];

运行程序,可以看到下面的提示

image

如果点击OK,subscribeNext:块中的日志会打印出来。如果点击Don't allow,则会执行错误块并打印期望的信息。

链接信号

一旦用户获取了Twitter账户的访问权限,程序需要继续监听搜索框的输入,以查询twitter。程序需要等待请求访问Twitter的信号来发出完成事件,然后订阅广西输入框的信号。不同信号的顺序链接是一个问题,但ReactiveCocoa已经做了很好的处理。

在viewDidLoad中用下面代码来替换当前的管道:

1
2
3
4
5
6
7
8
9
10
[[[self requestAccessToTwitterSignal]
then:^RACSignal *{
@strongify(self)
return self.searchText.rac_textSignal;
}]
subscribeNext:^(id x) {
NSLog(@"%@", x);
} error:^(NSError *error) {
NSLog(@"An error occurred: %@", error);
}];

then方法会等到completed事件发出后调用,然后订阅由block参数返回的信号。这有效地将控制从一个信号传递给下一个信号。运行程序,获取访问,然后在输入框输入,会在控制台看到以下输出:

1
2
3
4
5
6
2014-01-04 08:16:11.444 TwitterInstant[39118:a0b] m
2014-01-04 08:16:12.276 TwitterInstant[39118:a0b] ma
2014-01-04 08:16:12.413 TwitterInstant[39118:a0b] mag
2014-01-04 08:16:12.548 TwitterInstant[39118:a0b] magi
2014-01-04 08:16:12.628 TwitterInstant[39118:a0b] magic
2014-01-04 08:16:13.172 TwitterInstant[39118:a0b] magic!

下一步,我们添加一个filter操作到管道,以移除无效的搜索字符串。在这个实例中,是要求输入长度不小于3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[[[[self requestAccessToTwitterSignal]
then:^RACSignal *{
@strongify(self)
return self.searchText.rac_textSignal;
}]
filter:^BOOL(NSString *text) {
@strongify(self)
return [self isValidSearchText:text];
}]
subscribeNext:^(id x) {
NSLog(@"%@", x);
} error:^(NSError *error) {
NSLog(@"An error occurred: %@", error);
}];

运行后的输出是

1
2
3
2014-01-04 08:16:12.548 TwitterInstant[39118:a0b] magi
2014-01-04 08:16:12.628 TwitterInstant[39118:a0b] magic
2014-01-04 08:16:13.172 TwitterInstant[39118:a0b] magic!

当前管道如下图所示:

image

现在我们有一个发送搜索文本的信号了,是时候用它来搜索Twitter了。接下来才是正题。

搜索Twitter

Social Framework是访问Twitter搜索API的一个选择。但是Social Framework不是响应式的。接下来是封装所需要的API方法到信号中。现在,我们需要挂起这个过程。

在RWSearchFormViewController.m中,添加以下方法:

1
2
3
4
5
6
7
8
9
10
11
- (SLRequest *)requestforTwitterSearchWithText:(NSString *)text
{
NSURL *url = [NSURL URLWithString:@"https://api.twitter.com/1.1/search/tweets.json"];
NSDictionary *params = @{@"q": text};
SLRequest *request = [SLRequest requestForServiceType:SLServiceTypeTwitter
requestMethod:SLRequestMethodGET
URL:url
parameters:params];
return request;
}

这个方法通过v1.1 REST API创建了一个搜索Twitter的请求。关于这个API,可以在Twitter API docs中查看更多信息。

接下来创建一个基于请求的信号。在同一文件中,添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
- (RACSignal *)signalForSearchWithText:(NSString *)text {
// 定义错误
NSError *noAccountError = [NSError errorWithDomain:RWTwitterInstantDomain code:RWTwitterInstantErrorNoTwitterAccounts userInfo:nil];
NSError *invalidResponseError = [NSError errorWithDomain:RWTwitterInstantDomain code:RWTwitterInstantErrorInvalidResponse userInfo:nil];
// 创建信号block
@weakify(self)
return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
@strongify(self)
// 创建请求
SLRequest *request = [self requestforTwitterSearchWithText:text];
// 提供Twitter账户
NSArray *twitterAccounts = [self.accountStore accountsWithAccountType:self.twitterAccountType];
if (twitterAccounts.count == 0) {
[subscriber sendError:noAccountError];
} else {
[request setAccount:[twitterAccounts lastObject]];
// 执行请求
[request performRequestWithHandler:^(NSData *responseData, NSHTTPURLResponse *urlResponse, NSError *error) {
if (urlResponse.statusCode == 200) {
// 成功,解析响应
NSDictionary *timelineData = [NSJSONSerialization JSONObjectWithData:responseData options:NSJSONReadingAllowFragments error:nil];
[subscriber sendNext:timelineData];
[subscriber sendCompleted];
} else {
// 失败,发送一个错误
[subscriber sendError:invalidResponseError];
}
}];
}
return nil;
}];
}

现在我们来使用这个新信号。

在第一部分中我们学习了如何使用flattenMap来将每个next事件映射到一个新的被订阅的信号。这里我们再次使用它们。在viewDidLoad的最后用如下代码更新:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[[[[[self requestAccessToTwitterSignal]
then:^RACSignal *{
@strongify(self)
return self.searchText.rac_textSignal;
}]
filter:^BOOL(NSString *text) {
@strongify(self)
return [self isValidSearchText:text];
}]
flattenMap:^RACStream *(NSString *text ) {
@strongify(self)
return [self signalForSearchWithText:text];
}]
subscribeNext:^(id x) {
NSLog(@"%@", x);
} error:^(NSError *error) {
NSLog(@"An error occurred: %@", error);
}];

运行并在搜索框中输入一些文本。一旦文本字符串长度大于3后,我们可以在控制台查看搜索的结果。如下显示了返回数据的一个片断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2014-01-05 07:42:27.697 TwitterInstant[40308:5403] {
"search_metadata" = {
"completed_in" = "0.019";
count = 15;
"max_id" = 419735546840117248;
"max_id_str" = 419735546840117248;
"next_results" = "?max_id=419734921599787007&q=asd&include_entities=1";
query = asd;
"refresh_url" = "?since_id=419735546840117248&q=asd&include_entities=1";
"since_id" = 0;
"since_id_str" = 0;
};
statuses = (
{
contributors = "<null>";
coordinates = "<null>";
"created_at" = "Sun Jan 05 07:42:07 +0000 2014";
entities = {
hashtags = ...

signalForSearchText:方法同样发出了一个error事件,其由subscribeNext:error:块来处理。

线程

现在一定想把返回的JSON数据显示到UI上了吧,不过,在此之前我们还有一件事情需要处理。要了解这是什么,我们还需要探索一下。

在下图的subscribeNext:error:中打个断点:

image

重新运行程序,如果需要则再次输入Twitter账号密码,在搜索框中输入一些文本。当程序运行到断点位置时可以看到类似于下图的场景:

image

注意,从左侧的线程列表中我们可以看到debugger到的代码并没有运行在主线程,即线程Thread 1。记住,更新UI的操作一定得在主线程中操作;因此,如果要在UI上显示tweet列表,则必须切换线程。

这说明了ReactiveCocoa框架的一个重要点。上面显示的操作是在信号初始发出事件时的那个线程执行。尝试在管道的其它步骤添加断点,我们会很惊奇的发现它们会运行在多个不同的线程上。

因此,我们应该如何来更新UI呢?当然ReactiveCocoa也为我们解决了这个问题。我们只需要在flattenMap:后面添加deliverOn:操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[[[[[[self requestAccessToTwitterSignal]
then:^RACSignal *{
@strongify(self)
return self.searchText.rac_textSignal;
}]
filter:^BOOL(NSString *text) {
@strongify(self)
return [self isValidSearchText:text];
}] flattenMap:^RACStream *(NSString *text) {
@strongify(self)
return [self signalForSearchWithText:text];
}]
deliverOn:[RACScheduler mainThreadScheduler]]
subscribeNext:^(id x) {
NSLog(@"%@", x);
} error:^(NSError *error) {
NSLog(@"An error occurred: %@", error);
}];

现在重新运行,此时我们可以看到subscribeNext:error:是运行在主线程了。

image

这样我们就可以安全地更新我们的UI了。

更新UI

打开RWSearchResultsViewController.h文件,我们可以看到displayTweets:方法,这个方法会让右侧的ViewController来渲染tweet数组。实现非常简单,它只是一个标准UITableView数据源。displayTweets:只需要一个包含RWTweet实例的数组作为参数。我们同样发现RWTweet实例是作为初始工程的一部分提供的。

在subscibeNext:error:步骤中获取到的数据现在是一个NSDictionary,它是在signalForSearchWithText:解析JSON数据时构造的。那么,我们如何处理这个字典的内容呢?

如果看一看Twitter API documentation,我们可以发现一个示例响应。在NSDictionary反映了这种结构,所以我们需要找到一个键名为statues的字典,其值为一个tweets数组。具体如何解析我们就不在此说明。这里给个更好的实现方式。

我们现在讲的是ReactiveCocoa及函数式编程。当我们使用函数式API时,数据从一种格式转换到另一种格式会变得更优雅。我们可以使用LinqToObjectiveC来执行这个任务。

我们需要使用Cocoapods来导入LinqToObjectiveC。在配置文件中加入以下代码:

1
pod 'LinqToObjectiveC', '2.0.0'

关闭工程,在终端执行pod update命令,完成后在我们的Pods工程中就可以看到LinqToObjectiveC了。

打开RWSearchFormViewController.m并导入以下文件:

1
2
#import "RWTweet.h"
#import "NSArray+LinqExtensions.h"

NSArray+LinqExtensions.h头文件来自于LinqToObjectiveC,并为NSArray添加了许多方法以允许我们使用一个流畅的API来转换、排序、分组及过滤数组的数据。

现在我们使用这些API来更新当前管道操作,在viewDidLoad代码中做如下修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[[[[[[self requestAccessToTwitterSignal]
then:^RACSignal *{
@strongify(self)
return self.searchText.rac_textSignal;
}]
filter:^BOOL(NSString *text) {
@strongify(self)
return [self isValidSearchText:text];
}]
flattenMap:^RACStream *(NSString *text) {
@strongify(self)
return [self signalForSearchWithText:text];
}]
deliverOn:[RACScheduler mainThreadScheduler]]
subscribeNext:^(NSDictionary *jsonSearchResult) {
NSArray *statuses = jsonSearchResult[@"statuses"];
NSArray *tweets = [statuses linq_select:^id(id tweet) {
return [RWTweet tweetWithStatus:tweet];
}];
[self.resultsViewController displayTweets:tweets];
} error:^(NSError *error) {
NSLog(@"An error occurred: %@", error);
}];

如上所看到的,subscribeNext:块首先获取tweets的NSArray对象。linq_select方法通过执行应用于每个数组元素的block来转换NSDictionary字典的数组,并生成一个RWTweet实例的数组。

一旦转换完成,tweets将结果发送给ViewController。

运行程序后我们可以看到以下UI:

image

异步加载图片

在上图中,我们可以看到每行数据前面有一片空白,这是用来显示用户头像的。RWTweet类已经有一个profileImageUrl属性,它是一个图片的URL地址。为了让UITableTable滑动得更平滑,我们需要让获取指定URL的图片的操作不运行在主线程中。这可以使用GCD或者是NSOperationQueue。不过,ReactiveCocoa同样为我们提供了解决方案。

打开RWSearchResultsViewController.m,添加以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-(RACSignal *)signalForLoadingImage:(NSString *)imageUrl {
RACScheduler *scheduler = [RACScheduler
schedulerWithPriority:RACSchedulerPriorityBackground];
return [[RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
NSData *data = [NSData dataWithContentsOfURL:[NSURL URLWithString:imageUrl]];
UIImage *image = [UIImage imageWithData:data];
[subscriber sendNext:image];
[subscriber sendCompleted];
return nil;
}] subscribeOn:scheduler];
}

现在我们应该熟悉这种模式了。以上的方法首先获取一个后台scheduler作为信号执行的线程,而不是主线程。接下来,创建一个下载图片数据的信号并在其有订阅者时创建一个UIImage。最后我们调用subscribeOn:,以确保信号在给定的scheduler上执行。

现在,我们可以更新tableView:cellForRowAtIndex:,在return之前添加以下代码:

1
2
3
4
5
6
7
cell.twitterAvatarView.image = nil;
[[[self signalForLoadingImage:tweet.profileImageUrl]
deliverOn:[RACScheduler mainThreadScheduler]]
subscribeNext:^(UIImage *image) {
cell.twitterAvatarView.image = image;
}];

上面的代码首先重新设置图片,因为重用的单元格可能包含之前的数据。然后创建一个请求信号去获取数据,在deliverOn:中我们将后面的next事件运行在主线程,这样subscribeNext:可以安全运行。

运行后得到如下结果:

image

节流

你可能已经发现每次我们输入一个新的字符时,搜索操作都会立即执行。如果我们快速输入,可能会导致程序在一秒钟内执行了多次搜索操作。这当然是不好的,因为:

  1. 我们多次调用了Twitter搜索API,同时扔掉了大部分结果。
  2. 我们不断更新结果会分散用户的注意力。

一个更好的方案是如果搜索文本在一个较短时间内没有改变时我们再去执行搜索操作,如500毫秒。ReactiveCocoa框架让这一任务变得相当简单。

打开RWSearchFormViewController.m并更新管道操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[[[[[[[self requestAccessToTwitterSignal]
then:^RACSignal *{
@strongify(self)
return self.searchText.rac_textSignal;
}]
filter:^BOOL(NSString *text) {
@strongify(self)
return [self isValidSearchText:text];
}]
throttle:0.5]
flattenMap:^RACStream *(NSString *text) {
@strongify(self)
return [self signalForSearchWithText:text];
}]
deliverOn:[RACScheduler mainThreadScheduler]]
subscribeNext:^(NSDictionary *jsonSearchResult) {
NSArray *statuses = jsonSearchResult[@"statuses"];
NSArray *tweets = [statuses linq_select:^id(id tweet) {
return [RWTweet tweetWithStatus:tweet];
}];
[self.resultsViewController displayTweets:tweets];
} error:^(NSError *error) {
NSLog(@"An error occurred: %@", error);
}];

throttle操作只有在两次next事件间隔指定的时间时才会发送第二个next事件。相当简单吧。运行程序看看效果吧。

小结

在庆祝胜利前,看看程序最终的管道是值得的。

image

这是一个相当复杂的数据流,但可以作为一个响应管道简洁地表示出来。看起来不错吧。如果使用非响应式技术,你会觉得这会有多复杂呢?在这样一个程序中,数据流的流动又会是多难以理解呢?听起来很麻烦吧。但有了ReactiveCocoa,我们不必再考虑这些了。现在我们知道ReactiveCocoa有多棒了吧。

最后,ReactiveCocoa让使用Model View ViewModel(MVVM)设计模式变成可能。如果有兴趣研究MVVM,可以去网上搜索相关的文章。

ReactiveCocoa Tutorial – The Definitive Introduction: Part 1/2

发表于 2014-08-02   |   分类于 翻译

原文由Colin Eberhardt发表于raywenderlich,ReactiveCocoa Tutorial – The Definitive Introduction: Part 1/2

在编写iOS代码时,我们的大部分代码都是在响应一些事件:按钮点击、接收网络消息、属性变化等等。但是这些事件在代码中的表现形式却不一样:如target-action、代理方法、KVO、回调或其它。ReactiveCocoa的目的就是定义一个统一的事件处理接口,这样它们可以非常简单地进行链接、过滤和组合。

ReactiveCocoa结合了一些编程模式:

  1. 函数式编程:利用高阶函数,即将函数作为其它函数的参数。
  2. 响应式编程:关注于数据流及变化的传播。

基于以上两点,ReactiveCocoa被当成是函数响应编程(Functional Reactive Programming, FRP)框架。我们将在下面以实例来看看ReactiveCocoa的实用价值。

Reactive Playground实例

虽然这是一篇指南性质的文章,但我们将以一个简单的实例来介绍ReactiveCocoa。可以在这里下载源代码,然后编译并运行以确保程序可以运行。

ReactivePlayground是个非常简单的应用,只有一个用户登录界面。只需要提供正确的用户名及密码,就可以显示一幅可爱的小猫的图片。如下图所示:

image

这个工程很简单,所以花几分钟来熟悉一下这个工程。打开RWViewController.m,可以快速查找一下如何设置Sign in按钮可用的代码,以及显示/隐藏signInFailure Label的规则。在简单的实现中,我们能快速定位这些问题,但如果实现很复杂,那可能需要花一些时间来分析代码。

现在,我们有了ReactiveCocoa,它能让代码变得更清晰。来看看它是怎么做到的吧。

添加ReactiveCocoa框架

添加ReactiveCocoa框架到我们工程的最简单的方法是使用Cocoapods。我们先关闭ReactivePlayground工程。Cocoapods会创建一个Xcode workspace,它会替代我们的原始工程文件。

首先创建一个名为Podfile的空文件,打开并添加如下信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
platform :ios, '6.0'
inhibit_all_warnings!
xcodeproj 'RWReactivePlayground'
target :RWReactivePlayground do
pod 'ReactiveCocoa', '~> 2.3.1'
end
post_install do |installer|
installer.project.targets.each do |target|
puts "#{target.name}"
end
end

配置完成后保存文件,打开终端并转到工程所在目录,然后输入以下命令:

1
pod install

然后终端会有如下输出

1
2
3
4
5
6
7
8
9
Analyzing dependencies
Downloading dependencies
Installing ReactiveCocoa (2.3.1)
Generating Pods project
Pods-RWReactivePlayground-ReactiveCocoa
Pods-RWReactivePlayground
Integrating client project
[!] From now on use `RWReactivePlayground.xcworkspace`.

这表示已经下载了ReactiveCocoa框架,同时Cocoapods创建了一个Xcode workspace,同时将框架整合到了我们的工程中。打开新生成的workspace文件(RWReactivePlayground.xcworkspace),将看到如下的工程结构:

image

我们看到有一个命名为ReactivePlayground的工程,这实际上是我们的初始工程,它依赖于Pods工程。做完这一切后,我们就可以开始玩了,哈哈。

Time to Play

如上所述,ReactiveCocoa提供了一个标准的接口来处理不同的事件流。在ReactiveCocoa中,这些被统一称为信号,由RACSignal类表示。

打开程序的初始视图控制器RWViewController.m文件,在文件头部导入以下头文件:

1
#import <ReactiveCocoa/ReactiveCocoa.h>

我们暂时先不替换原来的代码,先看看如何使用ReactiveCocoa。在viewDidLoad方法中加入如下代码:

1
2
3
4
[self.usernameTextField.rac_textSignal subscribeNext:^(id x) {
NSLog(@"%@", x);
}];

运行程序并在用户名输入框中键入”reactive cocoa“,我们可以看到控制台会有如下输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
2014-07-31 15:32:30.890 RWReactivePlayground[9191:60b] r
2014-07-31 15:32:32.007 RWReactivePlayground[9191:60b] re
2014-07-31 15:32:32.289 RWReactivePlayground[9191:60b] rea
2014-07-31 15:32:33.990 RWReactivePlayground[9191:60b] reac
2014-07-31 15:32:34.889 RWReactivePlayground[9191:60b] react
2014-07-31 15:32:35.557 RWReactivePlayground[9191:60b] reacti
2014-07-31 15:32:36.022 RWReactivePlayground[9191:60b] reactiv
2014-07-31 15:32:36.505 RWReactivePlayground[9191:60b] reactive
2014-07-31 15:32:42.328 RWReactivePlayground[9191:60b] reactive
2014-07-31 15:32:47.223 RWReactivePlayground[9191:60b] reactive c
2014-07-31 15:32:47.794 RWReactivePlayground[9191:60b] reactive co
2014-07-31 15:32:48.191 RWReactivePlayground[9191:60b] reactive coc
2014-07-31 15:32:48.657 RWReactivePlayground[9191:60b] reactive coco
2014-07-31 15:32:49.141 RWReactivePlayground[9191:60b] reactive cocoa

我们可以看到,每次在text field中输入时,都会执行block中的代码。没有target-action,没有代理,只有信号与block。是不是很棒?

ReactiveCocoa信号发送一个事件流到它们的订阅者中。我们需要知道三种类型的事件:next, error和completed。一个信号可能由于error事件或completed事件而终止,在此之前它会发送很多个next事件。在这一部分中,我们将重点关注next事件。在学习关于error和completed事件前,请仔细阅读第二部分。

RACSignal有许多方法用于订阅这些不同的事件类型。每个方法会有一个或多个block,每个block执行不同的逻辑处理。在上面这个例子中,我们看到subscribeNext:方法提供了一个响应next事件的block。

ReactiveCocoa框架通过类别来为大部分标准UIKit控件添加信号,以便这些控件可以添加其相应事件的订阅,如上面的UITextField包含了rac_textSignal属性。

理论讲得差不多了,我们继续吧!!!

ReactiveCocoa有大量的操作右用于处理事件流。例如,如果我们只对长度大于3的用户名感兴趣,则我们可以使用filter操作。在viewDidLoad中更新我们的代码如下:

1
2
3
4
5
6
[[self.usernameTextField.rac_textSignal filter:^BOOL(id value) {
NSString *text = value;
return text.length > 3;
}] subscribeNext:^(id x) {
NSLog(@"%@", x);
}];

运行并在用户名输入框中输入"reactive cocoa",我们可以看到控制台会有如下输出:

1
2
3
4
5
6
7
8
9
10
11
2014-07-31 15:52:13.558 RWReactivePlayground[9249:60b] reac
2014-07-31 15:52:15.960 RWReactivePlayground[9249:60b] react
2014-07-31 15:52:16.589 RWReactivePlayground[9249:60b] reacti
2014-07-31 15:52:17.158 RWReactivePlayground[9249:60b] reactiv
2014-07-31 15:52:17.807 RWReactivePlayground[9249:60b] reactive
2014-07-31 15:52:18.674 RWReactivePlayground[9249:60b] reactive
2014-07-31 15:52:19.176 RWReactivePlayground[9249:60b] reactive c
2014-07-31 15:52:19.710 RWReactivePlayground[9249:60b] reactive co
2014-07-31 15:52:20.057 RWReactivePlayground[9249:60b] reactive coc
2014-07-31 15:52:20.530 RWReactivePlayground[9249:60b] reactive coco
2014-07-31 15:52:20.978 RWReactivePlayground[9249:60b] reactive cocoa

可以看到当长度小于3时,并不执行后续的操作。通过这种方式,我们创建了一个简单的管道。这就是响应式编程的实质,我们将我们程序的功能表示为数据流的形式。我们可以将上述调用表示为以下图例:

image

从上图中我们可以看到rac_textSignal是事件的初始源头。通过filter的数据流只有在其长度大于3时,才会被传递到下一处理流程中。管道的最后一步是subscribeNext:,在这个block中,我们记录日志。

在这里需要注意的是filter操作的输出仍然是一个RACSignal对象。我们可以将上面这段管道处理拆分成如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
RACSignal *usernameSourceSignal = self.usernameTextField.rac_textSignal;
RACSignal *filteredUsername = [usernameSourceSignal filter:^BOOL(id value) {
NSString *text = value;
return text.length > 3;
}];
[filteredUsername subscribeNext:^(id x) {
NSLog(@"%@", x);
}];

因为RACSignal对象的每个操作都返回一个RACSignal对象,所以我们不需要使用变量就可以构建一个管道。

事件是什么

目前为止,我们已经描述了3种不同的事件类型,但还没有深入这些事件的结构。有趣的是,事件可以包含任何东西。为了证明这一点,我们在上面的管道中加入另一个操作。更新我们的代码:

1
2
3
4
5
6
7
8
9
[[[self.usernameTextField.rac_textSignal map:^id(NSString *text) {
return @(text.length);
}]
filter:^BOOL(NSNumber *length) {
return [length intValue] > 3;
}]
subscribeNext:^(id x) {
NSLog(@"%@", x);
}];

编译并运行,我们会发现控制台输出如下信息:

1
2
3
4
5
6
7
8
9
2014-07-31 16:13:47.652 RWReactivePlayground[9321:60b] 4
2014-07-31 16:13:47.819 RWReactivePlayground[9321:60b] 5
2014-07-31 16:13:47.985 RWReactivePlayground[9321:60b] 6
2014-07-31 16:13:48.134 RWReactivePlayground[9321:60b] 7
2014-07-31 16:13:48.284 RWReactivePlayground[9321:60b] 8
2014-07-31 16:13:48.417 RWReactivePlayground[9321:60b] 9
2014-07-31 16:13:48.583 RWReactivePlayground[9321:60b] 10
2014-07-31 16:13:48.734 RWReactivePlayground[9321:60b] 11
2014-07-31 16:13:48.883 RWReactivePlayground[9321:60b] 12

新添加的map操作使用提供的block来转换事件数据。对于收到的每一个next事件,都会运行给定的block,并将返回值作为next事件发送。在上面的代码中,map操作获取一个NSString输入,并将其映射为一个NSNumber对象,并返回。下图演示了这个管道处理:

image

我们可以看到,map操作后的每一步接收的都是一个NSNumber对象。我们可以使用map操作来转换我们想要的数据,只需要它是一个对象。

OK,是时候修改ReactivePlayground应用的代码了。

创建有效的状态信号

我们要做的第一件事就是创建一对信号来校验用户名与密码的输入是否有效。添加如下代码到RWViewController.m的viewDidLoad中。

1
2
3
4
5
6
7
RACSignal *validUsernameSignal = [self.usernameTextField.rac_textSignal map:^id(NSString *text) {
return @([self isValidUsername:text]);
}];
RACSignal *validPasswordSignal = [self.passwordTextField.rac_textSignal map:^id(NSString *text) {
return @([self isValidPassword:text]);
}];

我们使用将map操作应用于文本输入框的rac_textSignal,输出是一个NSNumber对象。接着将转换这些信号,以便其可以为文本输入框提供一个合适的背影颜色。我们可以订阅这个信号并使用其结果来更新文本输入框的颜色。可以如下操作:

1
2
3
4
5
[[validPasswordSignal map:^id(NSNumber *passwordValid) {
return [passwordValid boolValue] ? [UIColor clearColor] : [UIColor yellowColor];
}] subscribeNext:^(UIColor *color) {
self.passwordTextField.backgroundColor = color;
}];

从概念上讲,我们将信号的输出值赋值给文本输入框的backgroundColor属性。但是这段代码有点糟糕。我们可以以另外一种方式来做相同的处理。这得益于ReactiveCocoa定义的一些宏。如下代码所示:

1
2
3
4
5
6
7
RAC(self.passwordTextField, backgroundColor) = [validPasswordSignal map:^id(NSNumber *passwordValid) {
return [passwordValid boolValue] ? [UIColor clearColor] : [UIColor yellowColor];
}];
RAC(self.usernameTextField, backgroundColor) = [validUsernameSignal map:^id(NSNumber *passwordValid) {
return [passwordValid boolValue] ? [UIColor clearColor] : [UIColor yellowColor];
}];

RAC宏我们将信号的输入值指派给对象的属性。它带有两个参数,第一个参数是对象,第二个参数是对象的属性名。每次信号发送下一个事件时,其输出值都会指派给给定的属性。这是个非常优雅的解决方案,对吧?

在运行前,我们先找到updateUIState方法,并注释掉下面两行代码:

1
2
self.usernameTextField.backgroundColor = self.usernameIsValid ? [UIColor clearColor] : [UIColor yellowColor];
self.passwordTextField.backgroundColor = self.passwordIsValid ? [UIColor clearColor] : [UIColor yellowColor];

运行程序,我们可以看到当输入无效时文本输入框是高亮的,有效时则清除高亮。在这里,我们可以看到两条带有文本信号的简单的管道,都是将它们映射到标明是否有效的布尔对象,然后再映射到UIColor对象。如下图所示:

image

组合信号

在当前的程序中,Sign in按钮只有在两个输入框都有效时才可点击。是时候处理这个响应了。

当前代码有两个信号来标识用户名和密码是否有效:validUsernameSignal和validPasswordSignal。我们的任务是要组合这两个信号,来确定按钮是否可用。

在viewDidLoad中添加下面的代码

1
2
3
4
5
RACSignal *signUpActiveSignal = [RACSignal combineLatest:@[validUsernameSignal, validPasswordSignal]
reduce:^id(NSNumber *usernameValid, NSNumber *passwordValid){
return @([usernameValid boolValue] && [passwordValid boolValue]);
}];

上面的代码使用了combineLatest:reduce:方法来组合validUsernameSignal与validPasswordSignal最后输出的值,并生成一个新的信号。每次两个源信号中的一个输出新值时,reduce块都会被执行,而返回的值会作为组合信号的下一个值。

注意:RACSignal组合方法可以组合任何数量的信号,而reduce块的参数会对应每一个信号。

现在我们已以有了一个合适的信号,接着在viewDidLoad结尾中添加以下代码,这将信号连接到按钮的enabled属性。

1
2
3
4
[signUpActiveSignal subscribeNext:^(NSNumber *signupActive) {
self.signInButton.enabled = [signupActive boolValue];
}];

同样,在运行前移除以下代码:

1
2
@property (nonatomic) BOOL passwordIsValid;
@property (nonatomic) BOOL usernameIsValid;

同时移除viewDidLoad中以下代码:

1
2
[self.usernameTextField addTarget:self action:@selector(usernameTextFieldChanged) forControlEvents:UIControlEventEditingChanged];
[self.passwordTextField addTarget:self action:@selector(passwordTextFieldChanged) forControlEvents:UIControlEventEditingChanged];

当然我们还需要移除updateUIState, usernameTextFieldChanged和passwordTextFieldChanged方法及相关的调用。瞧,我们已经删除了不少代码了。感谢自己吧!

运行,并检查Sign in按钮。如同之前一下,如果用户名和密码都有效,则按钮是可用的。

更新后程序的逻辑如下图所示:

image

上面我们已经用ReactiveCocoa实现了一些非常棒的功能,它包含了两个重要的概念:

  1. Spliting: 信号可以有多个订阅者,且作为资源服务于序列化管道的多个步骤。
  2. Combining: 多个信号可以组合起来创建新的信号。

在上面的程序中,这些改变让程序不再需要私有属性,来标明两个输入域的有效状态。这是使用响应式编程的关键区别–我们不需要使用实例变量来跟踪短暂的状态。

响应Sign-in

程序目前使用了响应式管道来管理输入框与按钮的状态。按钮的点击操作仍然使用target-action。所以,这是我们下一步的目标。

Sign-in按钮的Touch Up Inside事件通过storyboard action连接到RWViewController.m的signInButtonTouched方法中。我们现在使用响应式方法来替换它,所以第一步我们需要解除当前storyboard action的连接。这个自己处理吧。

为了处理按钮事件,我们需要使用ReactiveCocoa添加到UIKit的另一个方法:rac_signalForControlEvents。我们在viewDidLoad结尾加入以下代码:

1
2
3
4
[[self.signInButton rac_signalForControlEvents:UIControlEventTouchUpInside] subscribeNext:^(id x) {
NSLog(@"Button clicked");
}];

上面的代码从按钮的UIControlEventTouchUpInside事件中创建一个信号,并添加订阅以在每次事件发生时添加日志。

运行程序,当按钮可点时点击按钮,会记录以下日志:

1
2
3
4
5
2014-07-31 17:45:43.660 RWReactivePlayground[9617:60b] Button clicked
2014-07-31 17:45:44.493 RWReactivePlayground[9617:60b] Button clicked
2014-07-31 17:45:44.660 RWReactivePlayground[9617:60b] Button clicked
2014-07-31 17:45:44.810 RWReactivePlayground[9617:60b] Button clicked
2014-07-31 17:45:44.944 RWReactivePlayground[9617:60b] Button clicked

现在点击事件有一个信号了,接下来将信号与登录处理连接起来。打开RWDummySignInService.h文件,我们会看到下面的接口:

1
2
3
4
5
6
7
typedef void (^RWSignInResponse)(BOOL);
@interface RWDummySignInService : NSObject
- (void)signInWithUsername:(NSString *)username password:(NSString *)password complete:(RWSignInResponse)completeBlock;
@end

这个方法带有一个用户名、密码和一个完成block。block会在登录成功或失败时调用。我们可以在subscribeNext:块中直接调用这个方法,但为什么不呢?因为这是一个异步操作,小心了。

创建信号

幸运的是,将一个已存在的异步API表示为一个信号相当简单。我们来看看。

首先,从RWViewController.m移除当前的signInButtonTouched:方法。我们通过响应式编程来取代它。

在RWViewController.m中添加以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (RACSignal *)signInSignal
{
return [RACSignal createSignal:^RACDisposable *(id<RACSubscriber> subscriber) {
[self.signInService signInWithUsername:self.usernameTextField.text
password:self.passwordTextField.text
complete:^(BOOL success) {
[subscriber sendNext:@(success)];
[subscriber sendCompleted];
}];
return nil;
}];
}

上面的代码创建了一个使用当前用户名与密码登录的信号。现在我们来分解一下这个方法。createSignal:方法用于创建一个信号。描述信号的block是一个信号参数,当信号有一个订阅者时,block中的代码会被执行。

block传递一个实现RACSubscriber协议的subscriber(订阅者),这个订阅者包含我们调用的用于发送事件的方法;我们也可以发送多个next事件,这些事件由一个error事件或complete事件结束。在上面这种情况下,它发送一个next事件来表示登录是否成功,后续是一个complete事件。

这个block的返回类型是一个RACDisposable对象,它允许我们执行一些清理任务,这些操作可能发生在订阅取消或丢弃时。上面这个这个信号没有任何清理需求,所以返回nil。

可以看到,我们就这样在信号中封装了一个异步API。现在,我们可以使用这个新的信号了,更新viewDidLoad中我们的代码吧:

1
2
3
4
5
[[[self.signInButton rac_signalForControlEvents:UIControlEventTouchUpInside] map:^id(id value) {
return [self signInButton];
}] subscribeNext:^(id x) {
NSLog(@"Sign in result: %@", x);
}];

上面的代码使用map方法将按钮点击信号转换为登录信号。订阅者简单输出了结果。

运行程序,点击按钮,可以看到以下输出:

1
2014-07-31 18:29:27.134 RWReactivePlayground[9749:60b] Sign in result: <UIButton: 0x13651ed40; frame = (192 201; 76 30); opaque = NO; autoresize = RM+BM; layer = <CALayer: 0x178224c00>>

可以看到subscribeNext:块传递了一个正确的信号,但结果不是登录信号。我们用图来展示这个管道操作:

image

当点击按钮时rac_signalForControlEvents发出了一个next事件。map这一步创建并返回一个登录信号,意味着接下来的管理接收一个RACSignal。这是我们在subscribeNext:中观察到的对象。

上面这个方案有时候称为信号的信号(signal of signals),换句话说,就是一个外部信号包含一个内部信号。可以在输出信号的subscribeNext:块中订阅内部信号。但这会引起嵌套的麻烦。幸运的是,这是个普遍的问题,而ReactiveCocoa已经提供了解决方案。

Signal of Signals

这个问题有解决方案是直观的,只需要使用flattenMap来替换map。如下代码所示:

1
2
3
4
5
[[[self.signInButton rac_signalForControlEvents:UIControlEventTouchUpInside] flattenMap:^RACStream *(id value) {
return [self signInSignal];
}] subscribeNext:^(id x) {
NSLog(@"Sign in result: %@", x);
}];

这将按钮点击事件映射到一个登录信号,但同时通过将事件从内部信号发送到外部信号,使这个过程变得扁平化。再次运行程序,我们将得到以下的输出

1
2014-07-31 18:46:19.535 RWReactivePlayground[9785:60b] Sign in result: 1

这回对了。

现在管道处理得到了我们想要的结果,最后我们在subscriptNext中添加登录处理逻辑。使用以下代码:

1
2
3
4
5
6
7
8
9
10
11
[[[self.signInButton rac_signalForControlEvents:UIControlEventTouchUpInside] flattenMap:^RACStream *(id value) {
return [self signInSignal];
}] subscribeNext:^(NSNumber *signedIn) {
BOOL success = [signedIn boolValue];
self.signInFailureText.hidden = success;
if (success)
{
[self performSegueWithIdentifier:@"signInSuccess" sender:self];
}
}];

运行程序,我们就可以得到下面的结果了:

image

不知道你是否注意到一个细节问题。当点击登录进行验证时,我们应该置灰登录按钮。这样可以阻止用户在验证的过程中再次去点击登录。那么这个逻辑添加在哪呢?改变按钮的可用状态不是个转换、过滤或其它的信号。这就是下一步要讲的。

添加附加操作(side-effects)

使用下面的代码替换当前管道:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[[[[self.signInButton rac_signalForControlEvents:UIControlEventTouchUpInside]
doNext:^(id x) {
self.signInButton.enabled = NO;
self.signInFailureText.hidden = YES;
}]
flattenMap:^RACStream *(id value) {
return [self signInSignal];
}]
subscribeNext:^(NSNumber *signedIn) {
self.signInButton.enabled = YES;
BOOL success = [signedIn boolValue];
self.signInFailureText.hidden = success;
if (success) {
[self performSegueWithIdentifier:@"signInSuccess" sender:self];
}
}];

我们可以看到在按钮点击事件后添加了doNext:步骤。注意doNext:并不返回一个值,因为它是附加操作。它完成时不改变事件。下图展示了这个过程:

image

运行程序看看效果。如何?

注意:在执行异步方法时禁用按钮是个普遍的问题,ReactiveCocoa同样解决了这个问题。RACCommand类封装了这个概念,同时有一个enabled信号以允许我们将一个按钮的enabled属性连接到信号。可以试试。

小结

ReactiveCocoa的核心是信号,它是一个事件流。使用ReactiveCocoa时,对于同一个问题,可能会有多种不同的方法来解决。ReactiveCocoa的目的就是为了简化我们的代码并更容易理解。如果使用一个清晰的管道,我们可以很容易理解问题的处理过程。在下一部分,我们将会讨论错误事件的处理及完成事件的处理。

Core Bluetooth框架之三:最佳实践

发表于 2014-08-01   |   分类于 翻译

在iOS设备中使用BLE时,无论是将其作为central端还是peripheral端,其在通信时都会使用设备自身的无线电来发送信号。考虑到其它形式的无线通信也需要使用无线电,因此开发程序时应该尽量少使用无线电。另外,这对于设备电池的寿命及程序的性能也有所帮助。以此为出发点,我们将介绍一些使用BLE时的最佳实践,希望有所帮助。

与Peripheral设备交互的最佳实践

Core Bluetooth框架让程序的大部分Central端交互变得透明。即程序能够控制且有责任实现大部分Central端的操作,如设备搜索及连接,解析并与远程peripheral数据交互。下面我们将介绍一些Central端的最佳实践。

留意无线电的使用及电量消耗

只有当需要时才扫描设备

当调用CBCentralManager类的scanForPeripheralsWithServices:options:方法来搜索正在广告服务的peripheral设备时,central设备使用无线电来监听广告的设备,直到我们显示停止它。除非需要搜索更多的设备,否则当发现想要连接的设备时就停止扫描操作。此时可以调用CBCentralManager实例的stopScan方法来处理。

只有当需要时才指定CBCentralManagerScanOptionAllowDuplicatesKey选项

远程peripheral设备可能每秒发送多个广告包来声明它们的存在。当我们使用scanForPeripheralsWithServices:options:方法扫描设备时,该方法的默认行为是将多个搜索到的广告peripheral事件归集为一个事件–即central管理器在只有在每次发现新的peripheral时都调用其代理对象的centralManager:didDiscoverPeripheral:advertisementData:RSSI:,而不管它收到多少个广告包。central管理器在已发现的peripheral改变广告的数据时也会调用这个代理方法。

如果想要改变默认行为,可以指定CBCentralManagerScanOptionAllowDuplicatesKey作为扫描选项。此时,central管理器会在每次收到peripheral端的广告包时都触发一个事件。在某些情况下关闭默认行为很有用处,但记住指定CBCentralManagerScanOptionAllowDuplicatesKey扫描选项不利于电池的寿命及程序性能。因此,只在需要的时候使用这个选项以完成特定的任务。

解析peripheral数据

一个peripheral设备可能有多个服务和特性,但在我们的应用中,可能只对其中一些感兴趣。搜索peripheral设备的所有服务和特性可能不利于电池的寿命及程序性能。因此,我们只去搜索那些与我们的的应用相关的服务和特性。

例如,假设我们正在连接一个有很多可用服务的peripheral设备,但是我们的程序只需要访问其中两个。我们可以只查找这两个服务,即在调用CBPeripheral对象的discoverServices:方法时传入感兴趣服务的UUID的数组即可。如下代码所示:

1
[peripheral discoverServices:@[firstServiceUUID, secondServiceUUID]];

在搜索到这两个感兴趣的服务后,我们可以用类似的方法去搜索我们感兴趣的服务中的特性。此时调用CBPeripheral实例的discoverCharacteristics:forService:方法并传入特性UUID的数组。

订阅经常改变的特性值

我们可以通过两种方式获取特性的值:

  1. 在我们每次需要值时调用readValueForCharacteristic:方法来显示的轮循特性的值
  2. 调用setNotifyValue:forCharacteristic:方法来订阅特性的值,这样当值改变时我们可以收到来自于peripheral的通知。

通常最好是去订阅特性的值,特别是特性值经常改变时。

当获取到所有需要的数据时断开到设备的连接

当连接不再需要时,我们可以断开连接,以减少无线电的使用。在下面两种情况下,我们应该断开连接:

  1. 所有订阅的特性值已经停止发送通知(我们可以访问特性的isNotifying属性来查看属性值是否正在被通知)
  2. 我们已以获取来来自peripheral设备的全部值。

两种情况下,取自我们有的所有订阅并断开连接。我们通过调用setNotifyValue:forCharacteristic:方法并设置第一个参数为NO来取消订阅。同时调用CBCentralManager实例的cancelPeripheralConnection:方法来断开连接。注意这个cancelPeripheralConnection:方法是非阻塞的,如果我们尝试断开连接的peripheral设备仍然挂起,则CBPeripheral实例的命令可能完成执行,也可能没有。因为其它程序可能也连接着那个peripheral设备。取消一个本地连接不能保证底层物理链接会立即断开。

重新链接Peripheral

使用Core Bluetooth框架,有三种方式来重新连接peripheral设备:

  1. 使用retrievePeripheralsWithIdentifiers:方法获取已知peripheral设备的列表,这些设备是我们已经搜索并连接过的设备。如果我们查找的peripheral在列表中,则尝试重新连接。
  2. 使用retrieveConnectedPeripheralsWithServices:方法获取当前连接到系统的peripheral设备的列表。如果我们查找的peripheral设备在列表中,则连接它。
  3. 使用scanForPeripheralsWithServices:options:方法扫描并搜索peripheral设备。如果找到,则连接它。

根据使用的场景,我们可能不希望每次重新连接设备时,都去扫描并搜索设备。相反,我们可能想首先使用其它方式来重新连接。如下图所示,一个可能的重新连接操作流是按照上面列出来的方式去重新连接:

获取已知peripheral设备的列表

我们第一次发现一个peripheral设备时,系统生成一个标识符(NSUUID对象)来标识peripheral设备。我们可以存储这些设备,后续我们可以使用CBCentralManager实例的retrievePeripheralsWithIdentifiers:方法来重新连接这个peripheral设备。

当我们启动程序时,调用retrievePeripheralsWithIdentifiers:方法,传递一个我们先前搜索并连接过的peripheral设备的标识符的数组,如下代码所示:

1
knownPeripherals = [myCentralManager retrievePeripheralsWithIdentifiers:savedIdentifiers];

central管理器尝试在这个列表中匹配我们提供的标识符,并返回一个CBPeripheral对象的数组。如果没找到,则返回的数组为空,那么我们需要尝试另外两种方法。如果返回的数组不为空,则让用户选择连接哪一个peripheral设备。当用户选择后,调用CBCentralManager实例的connectPeripheral:options:方法来尝试连接。如果peripheral设备仍然可以连接,则central管理器调用代理对象的centralManager:didConnectPeripheral:方法,且成功连接上peripheral设备。

获取已连接peripheral设备的列表

另一种重新连接peripheral设备的方法是查看我们正在查找的设备是否正由系统连接着(如被其它程序连接着)。我们可以调用CBCentralManager实例的retrieveConnectedPeripheralsWithServices:方法,它返回一个表示当前系统正在连接着的peripheral设备的CBPeripheral对象的数组。

因为可以有多于一个peripheral设备正在连接着系统,我们可以传递一个CBUUID对象的数组来获取只包含指定UUID所标识服务的设备。如果当前系统没有连接任何peripheral设备,则返回数组为空,我们应该尝试其它两种方法。如果返回数组不为空,则让用户选择连接哪个设备。

假设用户找到并选择了需要的peripheral设备,则调用CBCentralManager实例的connectPeripheral:options:方法来连接它(即使系统已经连接了它,我们的程序仍然需要连接它以开始解析并交互数据)。当连接建立后,central管理器调用代理对象的centralManager:didConnectPeripheral:方法,然后成功连接peripheral设备。

将本地设备设置为peripheral设备的最佳实践

广告注意事项

在设置本地设备作为peripheral端时,广告peripheral数据是非常重要的一部分。我们下面将介绍一下如何以适当的方式来实现这一功能。

我们广告peripheral数据时,是将其放在一个字典中传递给CBPeripheralManager对象的startAdvertising:方法中。当创建一个广告字典时,需要知道我们可以广告什么及能广告多少数据。

虽然广告数据包通常可以放置关于peripheral设备的多种信息,但建议只放置设备的本地名及我们需要广告的服务的UUID。即,当创建广告字典时,可能只指定下面两个键:CBAdvertisementDataLocalNameKey和CBAdvertisementDataServiceUUIDsKey。如果指定其它键,则会收到一个错误。

同样,广告数据时也限定了可以使用多少空间。当程序在前台时,可广告的数据对于上述两个key值的任意组合来说,初始值不能超过28个字节。如果这个空间用完了,在扫描响应时可以有额外的10个字节的空间,但这只能用于本地名。任何超出的数据都会被放到一个指定的“溢出”区域;它们只能被显示扫描它们的iOS设备发现。当程序在后台时,本地名不能被广告,且所有的服务UUID都被放在溢出区域。

为了符合这此限制条件,我们需要将广告的服务UUID限制在主要服务的标识上。

另外,因为广告peripheral数据使用本地设备的无线电,所以只在需要其它设备连接的时候广告数据。一旦连接后,这些设备可以直接解析并交互数据,而不需要任何广告包。因此,为了减少无线电的使用、提高程序的性能及节省电量,当不再需要任何试图进行BLE交易时可以停止广告。为了停止本地peripheral,可以调用CBPeripheralManager对象的stopAdvertising方法,如下所示:

1
[myPeripheralManager stopAdvertising];

通常,只有用户才知道什么时候广告数据。例如,当我们知道没有任何BLE设备在附近时,在我们的设备上广告服务没有任何意义。因为我们的程序通常不知道是否有其它设备在附近,所以提供一个界面让用户来决定什么时候广告数据。

配置特性

当创建一个可变的特性时,我们设置它的属性、值和权限。这些设置决定了如何连接central访问及交互特性值。虽然我们可能基于程序的需求来配置特性的属性和权限,但当执行下面两种任务时,我们还是有章可循的

  1. 允许连接的central订阅属性
  2. 保护敏感特性值,不让其被未配对的central访问

对于这两种情况,首先我们配置特性以支持通知。通常建议central去订阅那些经常改变的特性值。当我们创建一个可变特性时,可以通过使用CBCharacteristicPropertyNotify常量来设置特性属性以支持订阅,如下所示:

1
myCharacteristic = [[CBMutableCharacteristic alloc] initWithType:myCharacteristicUUID properties:CBCharacteristicPropertyRead | CBCharacteristicPropertyNotify value:nil permissions:CBAttributePermissionsReadable];

在这个例子中,特性值是可读的,且可以被连接的central端订阅。

其它,要求配对的连接才能访问敏感数据。根据场景的不同,我们可能想要提供一个服务,这个服务有一个或多个需要加密值的特性。例如,假设我们想要提供一个社交媒体配置文件服务。这个服务有一些特性,它们的值表示成员的配置信息,如姓名、电子邮件地址。更可能的是,我们只允许受信任的设备来获取成员的电子邮件地址。

我们可以设置合适的特性属性及权限来确保只有受信任的设备可以访问敏感的特性值。继续上面的例子,为了只允许受信任的设备来获取成员的邮箱地址,可以如下设置合适的特性属性与权限:

1
emailCharacteristic = [[CBMutableCharacteristic alloc] initWithType:emailCharacteristicUUID properties:CBCharacteristicPropertyRead | CBCharacteristicPropertyNotifyEncryptionRequired value:nil permissions:CBAttributePermissionsReadEncryptionRequired];

在这个例子中,特性配置为只有受信任的设备才可以读取或订阅它的值。当一个连接的central尝试读取或订阅特性值时,Core Bluetooth尝试配对本地peripheral和central端来创建安全连接。

例如,如果central和peripheral都是iOS设备,两端都接收一个提示显示对方想要配对。central设备上的提示包含包含一个确认码,这个确认码必须在peripheral设备提示框的输入域中输入,来完成配对操作。

在配对成功后,peripheral认为配对的central是一个受信任的设备且允许central访问它的加密特性值。

小结

在使用BLE时,基于以下几点,程序开发过程中我们合理地使用蓝牙

  1. 程序性能
  2. 电池电量消耗
  3. 与其它通信方法争抢无线电资源

通常我们只在需要时才使用BLE,尽量减少设备扫描搜索操作。

参考

  1. Core Bluetooth Programming Guide

Core Bluetooth框架之二:后台处理

发表于 2014-07-31   |   分类于 翻译

在开发BLE相关应用时,由于应用在后台时会有诸多资源限制,需要考虑应用的后台处理问题。默认情况下,当程序位于后台或挂起时,大多数普通的Core Bluetooth任务都无法使用,不管是Central端还是Peripheral端。但我们可以声明我们的应用支持Core Bluetooth后台执行模式,以允许程序从挂起状态中被唤醒以处理蓝牙相关的事件。

然而,即使我们的应用支持两端的Core Bluetooth后台执行模式,它也不能一直运行。在某些情况下,系统可能会关闭我们的应用来释放内存,以为当前前台的应用提供更多的内存空间。在iOS7及后续版本中,Core Bluetooth支持保存Central及Peripheral管理器对象的状态信息,并在程序启动时恢复这些信息。我们可以使用这个特性来支持与蓝牙设备相关的长时间任务。

下面我们将详细讨论下这些问题。

只支持前台操作(Foreground-Only)的应用

大多数应用在进入到后台后都会在短时间内进入挂起状态,除非我们请求执行一些特定的后台任务。当处理挂起状态时,我们的应用无法继续执行蓝牙相关的任务。

在Central端,Foreground-Only应用在进入后台或挂起时,无法继续扫描并发现下在广告的Peripheral设备。而在Peripheral端,无法广告自身,同时Central端对其的任何访问操作都会返回一个错误。

Foreground-Only应用挂起时,所有蓝牙相关的事件都会被系统放入一个队列,当应用进入前台后,系统会将这些事件发送给我们的应用。也就是说,当某些事件发生时,Core Bluetooth提供了一种方法来提示用户。用户可以使用这些提示来决定是否将应用放到前台。在《Core Bluetooth框架之一:Central与Peripheral》中我们介绍了connectPeripheral:options:方法,在调用这个方法时,我们可以设备options参数来设置这些提示:

  1. CBConnectPeripheralOptionNotifyOnConnectionKey:当应用挂起时,如果有一个连接成功时,如果我们想要系统为指定的peripheral显示一个提示时,就使用这个key值。
  2. CBConnectPeripheralOptionNotifyOnDisconnectionKey:当应用挂起时,如果连接断开时,如果我们想要系统为指定的peripheral显示一个断开连接的提示时,就使用这个key值。
  3. CBConnectPeripheralOptionNotifyOnNotificationKey:当应用挂起时,使用该key值表示只要接收到给定peripheral端的通知就显示一个提示。

Core Bluetooth后台执行模式

我们可以在Info.plist文件中设置Core Bluetooth后台执行模式,以让应用支持在后台执行一些蓝牙相关的任务。当应用声明了这一功能时,系统会将应用唤醒以允许它处理蓝牙相关的任务。这个特性对于与那种定时发送数据的BLE交互的应用非常有用。

有两种Core Bluetooth后台执行模式,一种用于实现Central端操作,一种用于实现Peripheral端操作。如果我们的应用同时实现了这两端的功能,则需要声明同时支持两种模式。我们需要在Info.plist文件添加UIBackgroundModes键,同时添加以下两个值或其中之一:

  1. bluetooth-central(App communicates using CoreBluetooth)
  2. bluetooth-peripheral(App shares data using CoreBluetooth)

bluetooth-central模式

如果设置了bluetooth-central值,则我们的应用在后台时,仍然可以查找并连接到Peripheral设备,以及查找相应数据。另外,系统会在CBCentralManagerDelegate或CBPeripheralDelegate代理方法被调用时唤醒我们的应用,允许应用处理事件,如建立连接或断开连接等等。

虽然应用在后台时,我们可以执行很多蓝牙相关任务,但需要记住应用在前后台扫描Peripheral设备时还是不一样的。当我们的应用在后台扫描Peripheral设备时,

  1. CBCentralManagerScanOptionAllowDuplicatesKey扫描选项会被忽略,同一个Peripheral端的多个发现事件会被聚合成一个发现事件。
  2. 如果扫描Peripheral设备的多个应用都在后台,则Central设备扫描广告数据的时间间隔会增加。结果是发现一个广告的Peripheral设备可能需要很长时间。

这些处理在iOS设备中最小化无线电的使用及改善电池的使用寿命非常有用。

bluetooth-peripheral模式

如果设置了bluetooth-peripheral值,则我们的应用在后台时,应用会被唤醒以处理来自于连接的Central端的读、写及订阅请求,Core Bluetooth还允许我们在后台进行广告。与Central端类似,也需要注意前后台的操作区别。特别是在广告时,有以下几点区别:

  1. CBAdvertisementDataLocalNameKey广告key值会被忽略,Peripheral端的本地名不会被广告
  2. CBAdvertisementDataServiceUUIDsKey键的所有服务的UUID都被放在一个”overflow”区域中,它们只能被那些显示要扫描它们的网络设备发现。
  3. 如果多个应用在后台广告,则Peripheral设备发送广告包的时间间隔会变长。

在后台执行长(Long-Term)任务

虽然建议尽快完成后台任务,但有些应该仍然需要使用Core Bluetooth来执行一个长任务。这时就涉及到状态的保存与恢复操作。

状态保存与恢复

因为状态保存与恢复是内置于Core Bluetooth的,我们的程序可以选择这个特性,让系统保存Central和Peripheral管理器的状态并继续执行一些蓝牙相关的任务,即使此时程序不再运行。当这些任务中的一个完成时,系统会在后台重启程序,程序可以恢复先前的状态以处理事件。Core Bluetooth支持Central端、Peripheral端的状态保存与恢复,也可以同时支持两者。

在Central端,系统会在关闭程序释放内存时保存Central管理器对象的状态(如果有多个Central管理器,我们可以选择系统跟踪哪个管理器)。对于给定的CBCentralManager对象,系统会跟踪如下信息:

  1. Central管理器扫描的服务
  2. Central管理器尝试或已经连接的Peripheral
  3. Central管理器订阅的特性

在Peripheral端,对于给定的CBPeripheralManager对象,系统会跟踪以下信息:

  1. Peripheral管理器广告的数据
  2. Peripheral管理器发布到设备数据库的服务和特性
  3. 订阅Peripheral管理器的特性值的Central端

当系统将程序重启到后台后,我们可以重新重新初始化我们程序的Central和Peripheral管理器并恢复状态。我们接下来将详细介绍一下如何使用状态保存与恢复。

添加状态保存和恢复支持

Core Bluetooth中的状态保存与恢复是可选的特性,需要程序的支持才能工作。我们可以按照以下步骤来添加这一特性的支持:

  1. (必要步骤)当分配及初始化Central或Peripheral管理器对象时,选择性加入状态保存与恢复。
  2. (必要步骤)在系统重启程序时,重新初始化Central或Peripheral管理器对象
  3. (必要步骤)实现适当的恢复代理方法
  4. (可选步骤)更新Central或Peripheral管理器初始化过程

选择性加入状态保存与恢复

为了选择性加入状态保存与恢复特性,在分配及初始化Central或Peripheral管理器时提供一个一个唯一恢复标识。恢复标识是一个字条串,用来让Core Bluetooth和程序标识Central或Peripheral管理器。字符串的值只在自己的代码中有意义,但这个字符串告诉Core Bluetooth我们需要保存对象的状态。Core Bluetooth只保存那些有恢复标识的对象。

例如,在实现Central端时,为了选择性加入状态保存与恢复特性,在初始化CBCentralManager对象时,可以指定初始化选项CBCentralManagerOptionRestoreIdentifierKey,并提供一个恢复标识,如下代码所示:

1
centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil options:@{CBCentralManagerOptionRestoreIdentifierKey: @"restoreIdentifier"}];

实现Peripheral端时的操作也类似,只不过我们使用选项CBPeripheralManagerOptionRestoreIdentifierKey键。

因为程序可以有多个Central或Peripheral管理器,所以需要确保恢复标识是唯一的,这样系统可以区分这些管理器对象。

重新初始化Central或Peripheral管理器对象

当系统重启程序到后台后,我们所需要做的第一件事就是使用恢复标识来重新初始化这些对象。如果我们的应用只有一个Central管理器或Peripheral管理器,且管理器在程序的整个生命周期都存在,则后续我们便不需要再做更多的处理。但如果我们有多个管理器,或者管理器不是存在于程序的整个生命周期,则系统重启应用时,我们需要知道重新初始化哪一个管理器。我们可以通过在程序代理对象的application:didFinishLaunchingWithOptions:方法中,使用合适的启动选项键来访问管理器对象的列表(这个列表是程序关闭是系统为程序保存的)。

下面代码展示了程序重新启动时,我们获取所有Central管理器对象的恢复标识:

1
2
3
4
5
6
7
8
9
10
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
// Override point for customization after application launch.
NSArray *centralManagerIdentifiers = launchOptions[UIApplicationLaunchOptionsBluetoothCentralsKey];
// TODO: ...
return YES;
}

有了这个恢复标识的列表后,我们就可以重新初始化我们所需要的管理器对象了。

实现适当的恢复代理方法

重新初始化Central或Peripheral管理器对象后,我们通过使用蓝牙系统的状态同步这些对象的状态来恢复它们。此时,我们需要实现一些恢复代理方法。对于Central管理器,我们实现centralManager:willRestoreState:代理方法;对于Peripheral管理器管理器,我们实现peripheralManager:willRestoreState:代理方法。

对于选择性加入保存与恢复特性的应用来说,这些方法是程序启动到后台以完成一些蓝牙相关任务所调用的第一个方法。而对于非选择性加入特性的应用来说,会首先调用centralManagerDidUpdateState:和peripheralManagerDidUpdateState:代理方法。

在上述两个代理方法中,最后一个参数是一个字典,包含程序关闭时保存的关于管理器的信息。如下代码所示,我们可以使用CBCentralManagerRestoredStatePeripheralsKey键来获取Central管理器已连接的或尝试连接的所有Peripheral设备的列表:

1
2
3
4
5
6
- (void)centralManager:(CBCentralManager *)central willRestoreState:(NSDictionary *)state
{
NSArray *peripherals = state[CBCentralManagerRestoredStatePeripheralsKey];
// TODO: ...
}

获取到这个列表后,我们便可以根据需要来做处理。

更新初始化过程

在实现了前面的三个步骤后,我们可能想更新我们的管理器的初始化过程。虽然这一步是可选的,但如果要确认任务是否运行正常时,非常有用。例如,我们的程序可能在解析所连接的Peripheral设备的数据的过程中被关闭。当程序使用这个Peripheral设备作恢复操作时,无法知道数据处理到哪了。我们需要确保程序从数据操作停止的位置继续开始操作。

又如下面的代码展示了在centralManagerDidUpdateState:代理方法中初始化程序操作时,我们可以找出是否成功发现了被恢复的Peripheral设备的指定服务:

NSUInteger serviceUUIDIndex = [peripheral.services indexOfObjectPassingTest:^BOOL(CBService *obj, NSUInteger index, BOOL *stop) {
        return [obj.UUID isEqual:myServiceUUIDString];
    }];


    if (serviceUUIDIndex == NSNotFound) {
        [peripheral discoverServices:@[myServiceUUIDString]];
        ...
}

如上例所述,如果系统在程序完成搜索服务时关闭了应用,则通过调用discoverServices:方法在关闭的那个点开始解析恢复的Peripheral数据。如果程序成功搜索到服务,我们可以确认是否搜索到正确的特性。通过更新初始化过程,我们可以确保在正确的时间调用正确的方法。

小结

虽然我们可能需要声明应用支持Core Bluetooth后台执行模式,以完成特定的任务,但总是应该慎重考虑执行后台操作。因为执行太多的蓝牙相关任务需要使用iOS设备的内置无线电,而无线电的使用会影响到电池的寿命,所以尽量减少在后台执行的任务。任何会被蓝牙相关任务唤醒的应用应该尽快处理任务并在完成时重新挂起。

下面是一些基础的准则:

  1. 应用应该是基于会话的,并提供接口以允许用户决定什么时候开始及结束蓝牙相关事件的分发。
  2. 一旦被唤醒,一个应用大概有10s的时间来完成任务。理想情况下,应用应该尽快完成任务并重新挂起。系统可以对执行时间太长的后台任务进行限流甚至杀死。
  3. 应用被唤醒时,不应该执行一些无关紧要的操作。

参考

  1. Core Bluetooth Programming Guide

Core Bluetooth框架之一:Central与Peripheral

发表于 2014-07-29   |   分类于 翻译

iOS和Mac应用使用Core Bluetooth framework来与BLE(低功耗蓝牙)设备通信。我们的程序可以发现、搜索并与低功耗外围(Peripheral)蓝牙设备通信,如心跳监听器、数字温控器、甚至是其它iOS设备。这个框架抽象了支持蓝牙4.0标准低功耗设备的基本操作,隐藏了4.0标准的底层实现细节,让我们可以方便的使用BLE设备。

蓝牙通信中的角色

在BLE通信中,主要有两个角色:Central和Peripheral。类似于传统的客户端-服务端架构,一个Peripheral端是提供数据的一方(相当于服务端);而Central是使用Peripheral端提供的数据完成特定任务的一方(相当于客户端)。下图以心跳监听器为例展示了这样一个架构:

image

Peripheral端以广告包的形式来广播一些数据。一个广告包(advertising packet)是一小束相关数据,可能包含Peripheral提供的有用的信息,如Peripheral名或主要功能。在BLE下,广告是Peripheral设备表现的主要形式。

Central端可以扫描并监听其感兴趣的任何广播信息的Peripheral设备。

数据的广播及接收需要以一定的数据结构来表示。而服务就是这样一种数据结构。Peripheral端可能包含一个或多个服务或提供关于连接信号强度的有用信息。一个服务是一个设备的数据的集合及数据相关的操作。

而服务本身又是由特性或所包含的服务组成的。一个特性提供了关于服务的更详细的信息。下图展示了心率监听器中的各种数据结构

image

在一个Central端与Peripheral端成功建立连接后,Central可以发现Peripheral端提供的完整的服务及特性的集合。一个Central也可以读写Peripheral端的服务特性的值。我们将会在下面详细介绍。

Central、Peripherals及Peripheral数据的表示

当我们使用本地Central与Peripheral端交互时,我们会在BLE通信的Central端执行操作。除非我们设置了一个本地Peripheral设备,否则大部分蓝牙交互都是在Central端进行的。(下文也会讲Peripheral端的基本操作)

在Central端,本地Central设备由CBCentralManager对象表示。这个对象用于管理发现与连接Peripheral设备(CBPeripheral对象)的操作,包括扫描、查找和连接。下图本地Central端与peripheral对象

image

当与peripheral设备交互时,我们主要是在处理它的服务及特性。在Core Bluetooth框架中,服务是一个CBService对象,特性是一个CBCharacteristic对象,下图演示了Central端的服务与特性的基本结构:

image

苹果在OS X 10.9和iOS 6版本后,提供了BLE外设(Peripheral)功能,可以将设备作为Peripheral来处理。在Peripheral端,本地Peripheral设备表示为一个CBPeripheralManager对象。这些对象用于管理将服务及特性发布到本地Peripheral设备数据库,并广告这些服务给Central设备。Peripheral管理器也用于响应来自Central端的读写请求。如下图展示了一个Peripheral端角色:

image

当在本地Peripheral设备上设置数据时,我们实际上处理的是服务与特性的可变版本。在Core Bluetooth框架中,本地Peripheral服务由CBMutableService对象表示,而特性由CBMutableCharacteristic对象表示,下图展示了本地Peripheral端服务与特性的基本结构:

image

Peripheral(Server)端操作

一个Peripheral端操作主要有以下步骤:

  1. 启动一个Peripheral管理对象
  2. 在本地Peripheral中设置服务及特性
  3. 将服务及特性发布给设备的本地数据库
  4. 广告我们的服务
  5. 针对连接的Central端的读写请求作出响应
  6. 发送更新的特性值到订阅Central端

我们将在下面结合代码对每一步分别进行讲解

启动一个Peripheral管理器

要在本地设备上实现一个Peripheral端,我们需要分配并初始化一个Peripheral管理器实例,如下代码所示

1
2
3
4
// 创建一个Peripheral管理器
// 我们将当前类作为peripheralManager,因此必须实现CBPeripheralManagerDelegate
// 第二个参数如果指定为nil,则默认使用主队列
peripheralManager = [[CBPeripheralManager alloc] initWithDelegate:self queue:nil];

创建Peripheral管理器后,Peripheral管理器会调用代理对象的peripheralManagerDidUpdateState:方法。我们需要实现这个方法来确保本地设备支持BLE。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- (void)peripheralManagerDidUpdateState:(CBPeripheralManager *)peripheral
{
NSLog(@"Peripheral Manager Did Update State");
switch (peripheral.state) {
case CBPeripheralManagerStatePoweredOn:
NSLog(@"CBPeripheralManagerStatePoweredOn");
break;
case CBPeripheralManagerStatePoweredOff:
NSLog(@"CBPeripheralManagerStatePoweredOff");
break;
case CBPeripheralManagerStateUnsupported:
NSLog(@"CBPeripheralManagerStateUnsupported");
break;
default:
break;
}
}

设置服务及特性

一个本地Peripheral数据库以类似树的结构来组织服务及特性。所以,在设置服务及特性时,我们将其组织成树结构。

一个Peripheral的服务和特性通过128位的蓝牙指定的UUID来标识,该标识是一个CBUUID对象。虽然SIG组织没的预先定义所有的服务与特性的UUID,但是SIG已经定义并发布了一些通过的UUID,这些UUID被简化成16位以方便使用。例如,SIG定义了一个16位的UUID作为心跳服务的标识(180D)。

CBUUID类提供了方法,以从字符串中生成一个CBUUID对象。当字条串使用的是预定义的16位UUID时,Core Bluetooth使用它时会预先自动补全成128位的标识。

1
CBUUID *heartRateServiceUUID = [CBUUID UUIDWithString:@"180D"];

当然我们也可以自己生成一个128位的UUID来标识我们的服务与特性。在命令行中使用uuidgen命令会生成一个128位的UUID字符串,然后我们可以使用它来生成一个CBUUID对象。

生成UUID对象后,我们就可以用这个对象来创建我们的服务及特性,然后再将它们组织成树状结构。

创建特性的代码如下所示

1
2
3
4
5
6
CBUUID *characteristicUUID1 = [CBUUID UUIDWithString:@"C22D1ECA-0F78-463B-8C21-688A517D7D2B"];
CBUUID *characteristicUUID2 = [CBUUID UUIDWithString:@"632FB3C9-2078-419B-83AA-DBC64B5B685A"];
CBMutableCharacteristic *character1 = [[CBMutableCharacteristic alloc] initWithType:characteristicUUID1 properties:CBCharacteristicPropertyRead value:nil permissions:CBAttributePermissionsReadable];
CBMutableCharacteristic *character2 = [[CBMutableCharacteristic alloc] initWithType:characteristicUUID2 properties:CBCharacteristicPropertyNotify value:nil permissions:CBAttributePermissionsWriteable];

我们需要设置特性的属性、值及权限。属性及权限值确定了属性值是可读的还是可写的,及连接的Central端是否可以订阅特性的值。另外,如果我们指定了特性的值,则这个值会被缓存且其属性及权限被设置成可读的。如果我们要让特性的值是可写的,或者期望属性所属的服务的生命周期里这个值可以被修改,则必须指定值为nil。

创建的特性之后,我们便可以创建一个与特性相关的服务,然后将特性关联到服务上,如下代码所示:

1
2
3
CBUUID *serviceUUID = [CBUUID UUIDWithString:@"3655296F-96CE-44D4-912D-CD83F06E7E7E"];
CBMutableService *service = [[CBMutableService alloc] initWithType:serviceUUID primary:YES];
service.characteristics = @[character1, character2]; // 组织成树状结构

上例中primary参数传递的是YES,表示这是一个主服务,即描述了一个设备的主要功能且能被其它服务引用。与之相对的是次要服务(secondary service),其只在引用它的另一个服务的上下文中描述一个服务。

发布服务及特性

创建服务及特性后交将其组织成树状结构后,我们需要将这些服务发布到设备的本地数据库上。我们可以使用CBPeripheralManager的addService:方法来完成此工作。如下代码所示:

1
[peripheralManager addService:service];

在调用些方法发布服务时,CBPeripheralManager对象会调用它的代理的peripheralManager:didAddService:error:方法。如果发布过程中出现错误导致无法以布,则可以实现该代理方法来处理错误,如下代码所示:

1
2
3
4
5
6
7
8
9
- (void)peripheralManager:(CBPeripheralManager *)peripheral didAddService:(CBService *)service error:(NSError *)error
{
NSLog(@"Add Service");
if (error)
{
NSLog(@"Error publishing service: %@", [error localizedDescription]);
}
}

在将服务与特性发布到设备数据库后,服务将会被缓存,且我们不能再修改这个服务。

广告服务

处理完以上步骤,我们便可以将这些服务广告给对服务感兴趣的Central端。我们可以通过调用CBPeripheralManager实例的startAdvertising:方法来完成这一操作,如下代码所示:

1
[peripheralManager startAdvertising:@{CBAdvertisementDataServiceUUIDsKey: @[service.UUID]}];

startAdvertising:的参数是一个字典,Peripheral管理器支持且仅支持两个key值:CBAdvertisementDataLocalNameKey与CBAdvertisementDataServiceUUIDsKey。这两个值描述了数据的详情。key值所对应的value期望是一个表示多个服务的数组。

当广告服务时,CBPeripheralManager对象会调用代码对象的peripheralManagerDidStartAdvertising:error:方法,我们可以在此做相应的处理,如下代码所示:

1
2
3
4
5
6
7
8
9
- (void)peripheralManagerDidStartAdvertising:(CBPeripheralManager *)peripheral error:(NSError *)error
{
NSLog(@"Start Advertising");
if (error)
{
NSLog(@"Error advertising: %@", [error localizedDescription]);
}
}

广告服务之后,Central端便可以发现设备并初始化一个连接。

对Central端的读写请求作出响应

在与Central端进行连接后,可能需要从其接收读写请求,我们需要以适当的方式作出响应。

当连接的Central端请求读取特性的值时,CBPeripheralManager对象会调用代理对象的peripheralManager:didReceiveReadRequest:方法,代理方法提供一个CBATTRequest对象以表示Central端的请求,我们可以使用它的属性来填充请求。下面代码简单展示了这样一个过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (void)peripheralManager:(CBPeripheralManager *)peripheral didReceiveReadRequest:(CBATTRequest *)request
{
// 查看请求的特性是否是指定的特性
if ([request.characteristic.UUID isEqual:cha1.UUID])
{
NSLog(@"Request character 1");
// 确保读请求所请求的偏移量没有超出我们的特性的值的长度范围
// offset属性指定的请求所要读取值的偏移位置
if (request.offset > cha1.value.length)
{
[peripheralManager respondToRequest:request withResult:CBATTErrorInvalidOffset];
return;
}
// 如果读取位置未越界,则将特性中的值的指定范围赋给请求的value属性。
request.value = [cha1.value subdataWithRange:(NSRange){request.offset, cha1.value.length - request.offset}];
// 对请求作出成功响应
[peripheralManager respondToRequest:request withResult:CBATTErrorSuccess];
}
}

在每次调用代理对象的peripheralManager:didReceiveReadRequest:时调用respondToRequest:withResult:方法以对请求做出响应。

处理写请求类似于上述过程,此时会调用代理对象的peripheralManager:didReceiveWriteRequests:方法。不同的是代理方法会给我们一个包含一个或多个CBATTRequest对象的数组,每一个都表示一个写请求。我们可以使用请求对象的value属性来给我们的特性属性赋值,如下代码所示:

1
2
3
4
5
6
7
8
- (void)peripheralManager:(CBPeripheralManager *)peripheral didReceiveWriteRequests:(NSArray *)requests
{
CBATTRequest *request = requests[0];
cha1.value = request.value;
[peripheralManager respondToRequest:request withResult:CBATTErrorSuccess];
}

响应处理与请求类似。

发送更新的特性值给订阅的Central端

如果有一个或多个Central端订阅了我们的服务的特性时,当特性发生变化时,我们需要通知这些Central端。为此,代理对象需要实现peripheralManager:central:didSubscribeToCharacteristic:方法。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)peripheralManager:(CBPeripheralManager *)peripheral central:(CBCentral *)central didUnsubscribeFromCharacteristic:(CBCharacteristic *)characteristic
{
NSLog(@"Central subscribed to characteristic %@", characteristic);
NSData *updatedData = characteristic.value;
// 获取属性更新的值并调用以下方法将其发送到Central端
// 最后一个参数指定我们想将修改发送给哪个Central端,如果传nil,则会发送给所有连接的Central
// 将方法返回一个BOOL值,表示修改是否被成功发送,如果用于传送更新值的队列被填充满,则方法返回NO
BOOL didSendValue = [peripheralManager updateValue:updatedData forCharacteristic:(CBMutableCharacteristic *)characteristic onSubscribedCentrals:nil];
NSLog(@"Send Success ? %@", (didSendValue ? @"YES" : @"NO"));
}

在上述代码中,当传输队列有可用的空间时,CBPeripheralManager对象会调用代码对象的peripheralManagerIsReadyToUpdateSubscribers:方法。我们可以在这个方法中调用updateValue:forCharacteristic:onSubscribedCentrals:来重新发送值。

我们使用通知来将单个数据包发送给订阅的Central。当我们更新订阅的Central时,我们应该通过调用一次updateValue:forCharacteristic:onSubscribedCentrals:方法将整个更新的值放在一个通知中。

由于特性的值大小不一,所以不是所有值都会被通知传输。如果发生这种情况,需要在Central端调用CBPeripheral实例的readValueForCharacteristic:方法来处理,该方法可以获取整个值。

Central(Client)端操作

一个Central端主要包含以下操作:

  1. 启动一个Central端管理器对象
  2. 搜索并连接正在广告的Peripheral设备
  3. 在连接到Peripheral端后查询数据
  4. 发送一个对特性值的读写请求到Peripheral端
  5. 当Peripheral端特性值改变时接收通知

我们将在下面结合代码对每一步分别进行讲解

启动一个Central管理器

CBCentralManager对象在Core Bluetooth中表示一个本地Central设备,我们在执行任何BLE交互时必须分配并初始化一个Central管理器对象。创建代码如下所示:

1
2
3
// 指定当前类为代理对象,所以其需要实现CBCentralManagerDelegate协议
// 如果queue为nil,则Central管理器使用主队列来发送事件
centralManager = [[CBCentralManager alloc] initWithDelegate:self queue:nil options:nil];

创建Central管理器时,管理器对象会调用代理对象的centralManagerDidUpdateState:方法。我们需要实现这个方法来确保本地设备支持BLE。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
- (void)centralManagerDidUpdateState:(CBCentralManager *)central
{
NSLog(@"Central Update State");
switch (central.state) {
case CBCentralManagerStatePoweredOn:
NSLog(@"CBCentralManagerStatePoweredOn");
break;
case CBCentralManagerStatePoweredOff:
NSLog(@"CBCentralManagerStatePoweredOff");
break;
case CBCentralManagerStateUnsupported:
NSLog(@"CBCentralManagerStateUnsupported");
break;
default:
break;
}
}

发现正在广告的Peripheral设备

Central端的首要任务是发现正在广告的Peripheral设备,以备后续连接。我们可以调用CBCentralManager实例的scanForPeripheralsWithServices:options:方法来发现正在广告的Peripheral设备。如下代码所示:

1
2
3
4
// 查找Peripheral设备
// 如果第一个参数传递nil,则管理器会返回所有发现的Peripheral设备。
// 通常我们会指定一个UUID对象的数组,来查找特定的设备
[centralManager scanForPeripheralsWithServices:nil options:nil];

在调用上述方法后,CBCentralManager对象在每次发现设备时会调用代理对象的centralManager:didDiscoverPeripheral:advertisementData:RSSI:方法。

1
2
3
4
5
6
7
8
9
- (void)centralManager:(CBCentralManager *)central didDiscoverPeripheral:(CBPeripheral *)peripheral advertisementData:(NSDictionary *)advertisementData RSSI:(NSNumber *)RSSI
{
NSLog(@"Discover name : %@", peripheral.name);
// 当我们查找到Peripheral端时,我们可以停止查找其它设备,以节省电量
[centralManager stopScan];
NSLog(@"Scanning stop");
}

连接Peripheral设备

在查找到Peripheral设备后,我们可以调用CBCentralManager实例的connectPeripheral:options:方法来连接Peripheral设备。如下代码所示

1
[centralManager connectPeripheral:peripheral options:nil];

如果连接成功,则会调用代码对象的centralManager:didConnectPeripheral:方法,我们可以实现该方法以做相应处理。另外,在开始与Peripheral设备交互之前,我们需要设置peripheral对象的代理,以确保接收到合适的回调。

1
2
3
4
5
6
- (void)centralManager:(CBCentralManager *)central didConnectPeripheral:(CBPeripheral *)peripheral
{
NSLog(@"Peripheral Connected");
peripheral.delegate = self;
}

查找所连接Peripheral设备的服务

建立到Peripheral设备的连接后,我们就可以开始查询数据了。首先我们需要查找Peripheral设备中可用的服务。由于Peripheral设备可以广告的数据有限,所以Peripheral设备实际的服务可能比它广告的服务要多。我们可以调用peripheral对象的discoverServices:方法来查找所有的服务。如下代码所示:

1
[peripheral discoverServices:nil];

参数传递nil可以查找所有的服务,但一般情况下我们会指定感兴趣的服务。

当调用上述方法时,peripheral会调用代理对象的peripheral:didDiscoverServices:方法。Core Bluetooth创建一个CBService对象的数组,数组中的元素是peripheral中找到的服务。

1
2
3
4
5
6
7
8
9
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverServices:(NSError *)error
{
NSLog(@"Discover Service");
for (CBService *service in peripheral.services)
{
NSLog(@"Discovered service %@", service);
}
}

查找服务中的特性

假设我们已经找到感兴趣的服务,接下来就是查询服务中的特性了。为了查找服务中的特性,我们只需要调用CBPeripheral类的discoverCharacteristics:forService:方法,如下所示:

1
2
NSLog(@"Discovering characteristics for service %@", service);
[peripheral discoverCharacteristics:nil forService:service];

当发现特定服务的特性时,peripheral对象会调用代理对象的peripheral:didDiscoverCharacteristicsForService:error:方法。在这个方法中,Core Bluetooth会创建一个CBCharacteristic对象的数组,每个元素表示一个查找到的特性对象。如下代码所示:

1
2
3
4
5
6
7
8
- (void)peripheral:(CBPeripheral *)peripheral didDiscoverCharacteristicsForService:(CBService *)service error:(NSError *)error
{
NSLog(@"Discover Characteristics");
for (CBCharacteristic *characteristic in service.characteristics)
{
NSLog(@"Discovered characteristic %@", characteristic);
}
}

获取特性的值

一个特性包含一个单一的值,这个值包含了Peripheral服务的信息。在获取到特性之后,我们就可以从特性中获取这个值。只需要调用CBPeripheral实例的readValueForCharacteristic:方法即可。如下所示:

1
2
NSLog(@"Reading value for characteristic %@", characteristic);
[peripheral readValueForCharacteristic:characteristic];

当我们读取特性中的值时,peripheral对象会调用代理对象的peripheral:didUpdateValueForCharacteristic:error:方法来获取该值。如果获取成功,我们可以通过特性的value属性来访问它,如下所示:

1
2
3
4
5
6
- (void)peripheral:(CBPeripheral *)peripheral didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic error:(NSError *)error
{
NSData *data = characteristic.value;
NSLog(@"Data = %@", data);
}

订阅特性的值

虽然使用readValueForCharacteristic:方法读取特性值对于一些使用场景非常有效,但对于获取改变的值不太有效。对于大多数变动的值来讲,我们需要通过订阅来获取它们。当我们订阅特性的值时,在值改变时,我们会从peripheral对象收到通知。

我们可以调用CBPeripheral类的setNotifyValue:forCharacteristic:方法来订阅感兴趣的特性的值。如下所示:

1
[peripheral setNotifyValue:YES forCharacteristic:characteristic];

当我们尝试订阅特性的值时,会调用peripheral对象的代理对象的peripheral:didUpdateNotificationStateForCharacteristic:error: 方法。如果订阅失败,我们可以实现该代理方法来访问错误,如下所示:

1
2
3
4
5
6
7
8
9
- (void)peripheral:(CBPeripheral *)peripheral didUpdateValueForCharacteristic:(CBCharacteristic *)characteristic error:(NSError *)error
{
...
if (error)
{
NSLog(@"Error changing notification state: %@", [error localizedDescription]);
}
}

在成功订阅特性的值后,当特性值改变时,peripheral设备会通知我们的应用。

写入特性的值

一些场景下,我们需要写入特性的值。例如我们需要与BLE数字恒温器交互时,可能需要给恒温器提供一个值来设定房间的温度。如果特性的值是可写的,我们可以通过调用CBPeripheral实例的writeValue:forCharacteristic:type:方法来写入值。

1
2
NSData *data = [NSData dataWithBytes:[@"test" UTF8String] length:@"test".length];
[peripheral writeValue:data forCharacteristic:characteristic type:CBCharacteristicWriteWithResponse];

当尝试写入特性值时,我们需要指定想要执行的写入类型。上例指定了写入类型是CBCharacteristicWriteWithResponse,表示peripheral让我们的应用知道是否写入成功。

指定写入类型为CBCharacteristicWriteWithResponse的peripheral对象,在响应请求时会调用代理对象的peripheral:didWriteValueForCharacteristic:error:方法。如果写入失败,我们可以在这个方法中处理错误信息。

小结

Core Bluetooth框架已经为我们封装了蓝牙通信的底层实现,我们只需要做简单的处理就可以在程序中实现基于蓝牙的通信。不过在游戏中,一般使用Game Kit中自带的蓝牙处理功能,以实现大数据量的通信。Core Bluetooth框架还是比较适合小数据量的通信。

参考

  1. Core Bluetooth Programming Guide

iBeacon技术初探

发表于 2014-07-27   |   分类于 杂项

苹果在WWDC 2013上正式推出了iBeacon,并在iOS7设备上配备了该功能。苹果期望将其做为一种技术标准,这个标准允许移动App(包括iOS或Android设备)监听来自于iBeacon设备上的信号并做出响应。这种iBeacon设备配备有低功耗蓝牙(Bluetooth Low Energy, BLE)通信功能,并使用BLE向周围发送自己特有的ID,移动设备上的App在接收到该ID后可以做出相应的反应。比如,我们在店铺里设置iBeacon发射器,便可让应用接收到信息并将这一信息通知给服务器,服务器向我们的App返回与该店铺相关的产品或折扣信息。本质上讲,iBeacon技术允许移动应用了解它们在某个局部范围内的位置,并向用户分发基于位置的超文本上下文内容。本文将对iBeacon技术做个简要的介绍。

低功耗蓝牙技术(Bluetooth Low Energy)

iBeacon的底层通信技术是采用低功耗蓝牙技术,这种技术是在短距离范围内数据传输的无线区域网络技术。顾名思义,它即要求低功耗,又需要保证传统蓝牙技术的通信范围。

BLE与传统蓝牙有几点不同:

  1. 耗电量:BLE是低耗电量的,iBeacon设备使用一个纽扣电池即可持续运作长达3年时间。
  2. 低成本:BLE的成本只相当于传统蓝牙设备的60%-80%
  3. 应用:BLE适用于只要求少量定期传输数据的简单应用。传统的蓝牙适用于需要持续通信和大量数据传输的复杂应用。

BLE通信数据包主要由称为”Advertisements”的数据或小的数据包组成,并通过无线电波由Beacons或其它BLE设备以有规律的时间间隔来广播。BLE广告是一种单向通信(one-way communication)方法。希望被发现的Beacon设备可以以设定的时间间隔广播或”广告”自包含的数据包。这些数据包由智能手机等设备收集,并在设备上触发一些事件,如推送消息、提示等。

苹果iBeacon标准要求广播时间的间隔是100ms。频率超高越耗电,但可以让智能手机或其它监听设备越快地发现iBeacon。标准的BLE广播距离是100m,这使Beacon在室内位置跟踪场景下的效果更理想。

监听Beacon区域

Beacon区域监听使用iOS设备的内置无线电来检测用户何时在广告iBeacon信息的BLE设备附近。由于是地理位置的监听,当用户进入或在一个Beacon区域时,我们可以使用这些功能来生成提示或提供其它相关的信息。一个Beacon区域通过以下值的组合来进行标识:

  1. proximity UUID(全局唯一标识):一个128位值,唯一标识一类或一个组织中的一个或多个Beacon。
  2. major值,一个16位无符号整数,用于分组有相同proximity UUID值的相关的Beacon。
  3. minor值,一个16位无符号整数,用于区分具有相同proximity UUID值和major的值。

因为一个Beacon区域可以包含多个iBeacon设备,Beacon区域监听支持同时监听多个感兴趣的设备。例如,一个App为了在特定商场提高用户体验,可用使用相同的proximity UUID来监听商场的所有商铺。当用户进入一个商店时,App检测商铺的iBeacon设备并使用major和minor值来获取额外的信息,如用户进入哪家商店或用户在商店的哪个区域(注意:虽然每个iBeacon都必须有一个proximity UUID,但major和minor的值是可选的)。

定义一个Beacon区域

我们使用CLBeaconRegion类的初始化方法来定义一个beacon区域,并在系统中注册,以开启一个监听。当创建一个CLBeaconRegion对象时,我们指定需要监听的Beacon区域的proximityUUID, major和minor属性。我们必须提供一个唯一标识区域的字符串以便我们在代码中能引用它。注意一个区域的标识与Beacon标识信息无关。

为了注册一个beacon区域,调用CLLocationManager对象的startMonitoringForRegion方法。代码清单1是创建并注册的beacon区域的简单方法。

代码清单1:创建和注册一个beacon区域

1
2
3
4
5
6
- (void)registerBeaconRegionWithUUID:(NSUUID *)proximityUUID andIdentifier:(NSString *)identifier
{
CLBeaconRegion *beaconRegion = [[CLBeaconRegion alloc] initWithProximityUUID:proximityUUID identifier:identifier];
[manager startMonitoringForRegion:beaconRegion];
}

App在注册并监听某个Beacon区域后,会立即开始监听此区域。当App监听到注册的Beacon区域时,系统会为App生成一个合适的区域事件。

处理边界穿越事件

当用户进入一个注册的Beacon区域时,CLLocationManager对象将调用其代理对象的locationManager:didEnterRegion:方法。类似的,当用户离开这个区域时,将调用代理对象的locationManager:didExitRegion:方法。注意,用户必须穿过区域边界来触发这些调用,如果用户已经在区域中,CLLocationManager对象将不会调用locationManager:didEnterRegion:。我们可以实现这些代理方法来提示用户或展示一个指定位置的UI。

我们可以设置Beacon区域的notifyOnEntry和notifyOnExit属性来指定哪个边界穿越事件应该通知给我们的App。例如,如果在用户离开区域时不需要通知App,则可以设置区域的notifyOnEntry属性为NO。

我们也可以在用户打开设备的屏幕时再通知用户已进入一个Beacon区域。我们只需要在注册Beacon区域时,简单的设置Beacon区域的notifyEntryStateOnDisplay属性为YES,以及设置notifyOnEntry属性为NO。为了防止多余的通知被发送给用户,只在进入一个区域时才发送一个本地通知。

当用户已经在注册的Beacon区域内时,App可以调用CLLocationManager类的startRangingBeaconsInRegion:方法来确定我们的设备与区域中的一个或多个iBeacon设备的相对距离,并在距离改变时被通知。(注意:我们总是在尝试查找区域内的iBeacon设备时调用CLLocationManager的类方法isRangingAvailable)。对于大多数程序来说,知道这个相对距离是很有用的。例如,想像一个在每个展品上安装了iBeacon设备的博物馆。如果我们使用这个博物馆相应的App,当我们走近某个展品时,就可以通过iBeacon来向我们的App发送关于展品的信息。

当指定Beacon区域的iBeacon设备进入范围、离开范围或相对距离改变时,CLLocationManager对象调用代理对象的locationManager:didRangeBeacons:inRegion:方法。这个代理方法提供一个CLBeacon对象的数组来表示当前设备范围内的iBeacon。iBeacon的数组根据离设备的相对距离来排序,最近的在最前面。我们可以使用这些对象中的信息来确定用户离每个iBeacon设备的距离。CLBeacon对象的proximity属性给出了到一个beacon的相对距离。

注:iBeacon的排列依赖于检测到的BLE无线信号的强度,以及这些信号受墙壁、门或其它物理实体的影响。当我们使用iBeacon时需要考虑这些影响因素。

代码清单2显示了如何使用一个Beacon的proximity属性来确定到用户设备的相对距离。

代码清单2:确定用户设备与beacon的相对距离

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- (void)locationManager:(CLLocationManager *)manager didRangeBeacons:(NSArray *)beacons inRegion:(CLBeaconRegion *)region
{
if ([beacons count] > 0)
{
CLBeacon *nearestExhibit = [beacons firstObject];
if (CLProximityNear == nearestExhibit.proximity)
{
NSLog(@"%d", nearestExhibit.major.integerValue);
}
else
{
NSLog(@"test");
}
}
}

注意:当多个iBeacon设备广告了相同的proximityUUID, major和minor属性组合时,在locationManager:didRangeBeacons:inRegion:方法中它们可能会有不同的距离与精度。建议每一个beacon设备都是唯一标识的。

在iOS设备中开启iBeacon功能

支持使用BLE分享数据的iOS设备可以当作iBeacon设备来使用。由于应用必须运行于前台,因此iOS设备上的iBeacon支持主要用于测试目的和任何情况下都运行于前台的App。对于其它类型的iBeacon实现,我们必须从第三方的制造商那获取专用的iBeacon设备。

因为将iOS设备作为一个iBeacon设备需要使用Core Bluetooth framework,所以我们需要在XCode工程中链接CoreBluetooth.framework。同时在相关的资源文件中导入头文件。

要在一个iOS设备上创建和广告一个Beacon区域,首先我们需要生成一个128位的UUID作为我们的proximity UUID。我们可以在终端使用uuidgen命令来生成一个UUID。然后,我们使用这个UUID来创建一个Beacon区域,同时定义所需的major和minor值。代码清单3显示了如何创建一个新Beacon区域

代码清单3

1
2
3
NSUUID *proximityUUID = [[NSUUID alloc] initWithUUIDString:@"D80415EC-AC22-4046-8497-4BDAC297323C"];
CLBeaconRegion *beaconRegion = [[CLBeaconRegion alloc] initWithProximityUUID:proximityUUID identifier:@"com.mycompany.myregion"];

现在我们已经创建了一个Beacon区域,然后我们需要使用Core Bluetooth framework的CBPeripheralManager来广告我们的Beacon的proximity UUID(及major或minor值)。在Core Bluetooth中,一个peripheral是使用BLE来广告和分享数据的设备。广告Beacon的数据是其它设备能检测并排列我们的Beacon设备的唯一方法。

为了在Core Bluetooth中广告peripheral数据,我们调用CBPeripheralManager实例的startAdvertising:方法。这个方法需要广告数据的字典。然后我们创建一个CBPeripheralManager类的实例,并让其广告我们的Beacon来让其它设备监听。代码清单4所示了这一过程。

代码清单4

1
2
3
4
NSDictionary *beaconPeripheralData = [beaconRegion peripheralDataWithMeasuredPower:nil];
manager = [[CBPeripheralManager alloc] initWithDelegate:self queue:nil options:nil];
[manager startAdvertising:beaconPeripheralData];

重要:当我们创建一个peripheral管理对象时,peripheral管理对象会调用它的代理的peripheralManagerDidUpdateState:方法。我们必须实现这个代理方法以确保BLE在本地peripheral设备上可用。

在广告我们的iBeacon后,我们必须在前台继续运行程序来广播所需要的蓝牙信号。

小结

iBeacon是苹果提出的近场定位技术,其目标直指NFC。在App中使用iBeacon并不复杂,苹果已经为我们解决了大部分的底层技术问题。我们只需要简单的创建一个Beacon区域并监听它,在代理对象中处理相应的事件即可。它的实质就是一种定位技术,其在室内导航方面前景光明,相信这一技术会在今后大有作为。

参考

  1. Location and Maps Programming Guide
  2. 如何使用iOS 7的iBeacons来提高你的应用
  3. 开发使用 iBeacon 的 iOS 7 应用
  4. 代码示例:AirLocate: Using CoreLocation to monitor, range, and configure your device as an iBeacon

Bonjour理论2:域命名约定、API及其操作

发表于 2014-07-20   |   分类于 翻译

域命名约定

服务实例与服务类型的Bonjour名称与DNS域名相关。这里我们将介绍DNS域名,Bonjour本地“域”,和Bonjour服务实例与服务类型的命名规则。

域名与DNS

DNS使用specific-to-general命名方案来为域名命名。最能用的域是”.”,称为根域名,这是类似于UNIX文件系统中的根目录”/“。所有其它域都在根域名的层次结构中。例如,域名www.apple.com.可以解析为根域”.”,其下包含顶级域名”com.”,再下面是二级域”apple.com.”, 其下就是”www.apple.com.”。下图显示了这种层次结构:

image

在这棵倒树的顶部是根域。在其下面是一些顶级域:com., edu., org.等。在顶级域下面是一些二级域,如apple, darwin, zeroconf。这棵树可以无限延伸下去。

Bonjour和本地链路

Bonjour协议很大程度上都是在处理本地链路(网络的一部分)。一个主机的本地链路或才链路本地网络(link-lock network)包括主机本身及其它可以不修改IP分组数据就是交换包的主机。在实践中,这包含所有没被路由器分开的主机。

在Bonjour系统中,”local.”用于标识在本地IP网络中应该使用IP多播查询来进行查找的名称。

注意:”local.”不是一个真实的域。我们可以将”local.”当成一个伪域。它与传统的DNS域有一个根本的区别:其它域的名称是全局唯一的,而链路本地域名不是。www.apple.com在全球只有唯一的一个DNS入口。而以local.结尾的主机名是本地网络中由多播DNS响应者的集合管理的,所以命名范围就是”local.”(本地)。所以只要不在同一个本地网络中,就可以有两台命名相同的主机,即使是在同一栋楼中。而在同一个本地网络中,也需要确保名称的唯一性。如果在本地网络中发生名称冲突,一个Bonjour主机会自动查找一个新的名称或让用户输入一个新的。

现有服务类型的Bonjour名称

Bonjour服务根据IP服务的现有网络标准(RFC 2782)来命名。Bonjour服务名结合服务类型和传输协议绑定以形成一个注册类型。注册类型用于注册一个服务并创建DNS资源记录。如果要在DNS资源记录中区分注册类型和域名,可以要注册类型每个组件前使用下划线。其格式如下

_ServiceType._TransportProtocolName

服务类型是该服务的官方IANA注册名称,如ftp,http或打印机。传输协议的名称是tcp或udp,取决于服务使用的传输协议。一个运行在TCP上的FTP服务注册类型应该是_ftp._tcp,并将注册一个名称为_ftp._tcp.local.的DNS PTR记录,以作为服务所在主机的多播DNS响应者。

新服务的Bonjour名称

如果我们正在设计一个新的协议以作为Bonjour网络服务来推广,则应该在IANA中注册它。

IANA目前要求每个每个注册的服务都与一个”众所周知的端口”或众所周知的端口范围相关联。例如,http的端口是80,所以我们在浏览器中访问一个站点时,程序都假设HTTP服务运行在80端口上,除非我们另行指定。

但在Bonjour中,我们不需要知道端口号。因为客户端程序通过对服务类型进行简单查询,就能发现我们的服务,因此不需要端口。

服务实例的Bonjour名称

服务实例名称是一种可读的字符串,因此,它们的名称具有描述性,并可以让用户重写我们提供的默认名称。由于它们是可浏览的,而不是类型化的,服务实例名可以是编码为UTF8的任何Unicode字符串,最多63个字节长度。

例如,一个在网络中分享音乐的程序可能使用本地用户名来分享服务(如Emille的曲库)。用户可以重写默认服务名,并将服务命名为Zealous Lizard’s Tune Studio._music._tcp.local.

下图说明了一个Bonjour服务实例的名称结构。在树的顶部是域,如本地网络的local.。下一级是注册类型,它由前面加下划线的服务类型(_music)和传输协议(同样前面有开线)组成。在树的底部是可读的服务实例名,如Zealous Lizard’s Tune Studio。完整的名称由底至顶,每个组件由”.”分割。

image

Bonjour的API架构

Bonjour网络服务的API结构如下图所示:

image

其主要有这么几部分:

  1. NSNetService和NSNetServiceBrowser类:在Foundation框架中
  2. CFNetServices:Core Services中CFNetwork框架的一部分;
  3. DNS Service Discovery for Java(OS X)
  4. 底层DNS Service Discovery API:构建在BSD socket

所有这些API集合提供了发布、搜索和网络服务解析的基础方法。下面我们简单介绍一下这几个部分

NSNetService和NSNetServiceBrowser

NSNetService和NSNetServiceBrowser提供了服务搜索和发布的面向对象接口。NSNetService对象表示Bonjour服务的实例,用于客户端发布和服务搜索;NSNetServiceBrowser表示特定类型服务的浏览器。大多数Cocoa程序员应该使用这些类来处理需求。如果需要更多详细的控制,可以使用DNS Service Discovery API。

NSNetService和NSNetServiceBrowser被安排到默认的NSRunLoop对象中来异步执行发布、搜索和解析操作。所以NSNetService和NSNetServiceBrowser返回的结果都由代理对象来处理。这些对象必须与一个run loop相关联,但不必是默认的run loop。

CFNetServices

CFNetServices API位于Core Services框架中,提供Core Foundation样式的类型和函数来管理服务和服务搜索。CFNetServices定义了三个Core Foundation对象类型:

  1. CFNetService:服务实例的抽象表示,用于发布或其它用途。相关的函数提供了发布和解析服务的支持
  2. CFNetServiceBrowser:表示特定域中特定类型服务的浏览者。通常只在基于Core Foundation编码时使用这个类型
  3. CFNetServiceMonitor

CFNetService和CFNetServiceBrowser对象通常在CFRunLoop中使用。为了获取结果,程序需要实现处理事件的回调函数,如新的服务产生和消失等。与NSNetService和NSNetServiceBrowser不同的是,CFNetServices类型可以不需要一个run loop,可以在需要的时候同步运行。但通常不建议同步使用。

DNS Service Discovery

DNS Service Discovery API声明在/usr/include/dns_sd.h中,它为Bonjour服务提供了低级别BSD socket通信。DNS Service Discovery作为软件与多播DNS响应者或DNS服务器之间的中介层。它为我们管理DNS响应者,让我们编写程序时专注于服务和服务浏览者而不是DNS资源记录。

由于DNS Service Discovery API是Darwin开源工程的一部分,我们应该在写跨平台代码使用它,或者在诸如在NSNetService这样的高级别API无法获取低级特性时使用它。

Bonjour操作

Bonjour的网络服务结构包含一个简单易用的机制来发布、搜索和使用基于IP的服务。Bonjour支持三种基础操作,每一种都是零配置网络服务所必须的:

  1. 发布(Publication): 广告一个服务
  2. 搜索(Discovery): 浏览可用的服务
  3. 解析(Resolution): 将服务实例名转化为地址和端口

下面将分别介绍这三个部分。

Publication

为了发布一个服务,程序或设备必须使用一个多播DNS响应者来注册服务,或者通过高级API,或者直接与响应者(mDNSResponser)通信。Bonjour同样支持在传统的DNS服务中存储记录。当注册服务后,会创建三个相关的DNS记录:服务记录(SRV), 指针记录(PTR)和文本记录(TXT)。其中TXT记录会包含额外的数据用于解析或使用服务,虽然这些数据通常是空的。

服务记录

服务记录将服务实例名称映射到实际使用服务所需要的信息。客户端通过持久化方式存储服务实例名以访问这些服务,并在连接的时候执行针对主机名和端口号的DNS查询。这个额外的indirection级提供了两个重要的特性

  1. 服务由一个可读的名称标识,而不是域名和端口号
  2. 客户端可以在服务的端口号和IP地址变化时访问服务,只要服务名不变即可。

SRV记录包含两部分信息来标识一个服务

  1. 主机名
  2. 端口号

主机名是服务可被发现的域名。用主机名代替IP地址的原因是一个主机可能对应多个IP地址,或者可以同时有IPv4地址和IPv6地址。使用主机名可以让所有这些情况被正确的处理。

端口号标识的服务的TCP或UDP端口号。

SRV记录以下面的规则来命名:

1
<Instance Name>.<Service Type>.<Domain>

\是服务实例名称,可以是任何UTF-8编码的字符串,通常是可读的有意义的字符串

\是标准的IP协议名称,前面带有下划线,后面跟着传输协议(TCP/UDP,前缀也是下划线)。

\是标准的DNS域名。这可能是一个特定的域名,如apple.com.,也可以是通用的以local.为后缀的域名(用于本地链路的服务)

下面是SRV记录的例子,它是一个运行在TCP上端口号为515上,名称为PrintsAlot的打印后台处理程序:

1
PrintsAlot._printer._tcp.local. 120 IN src 0 0 515 blackhawk.local.

这条记录将在本地链路的叫做blackhawk.local.的打印机的多播DNS响应者设备上被创建。初始的120是用于缓存的TTL值。两个0是分别表示权重和优先级,在传统DNS上选择多个与给定名匹配的记录时需要使用这两个值,而对于多播DNS,将忽略这两个值。

指针记录

PTR记录可以通过将实例的类型映射到服务的特定类型的实例的名字来开启服务搜索。这个记录添加了另一个indirection层,以便服务可以只通过查询使用服务类型标定的PTR记录就被找到。

这个记录只包含信息的一小块–服务实例的名称。PTR记录的命名与SRV记录类似,不过没有实例名,如下所示:

1
<Service Type>.<Domain>

文本记录

TXT记录与相应的SRV记录有相同的名称,并且可以包含少量的关于服务实例的额外的信息,一般不超过100-200个字节。记录也可以是空的。例如,一个网络游戏可以在多人游戏中广告所使用的地图名称。如果需要传输大量的数据,主机需要与客户端建立一个连接并直接发送数据。

通常,这个记录用于运行在同一地址同一端口的多个服务上,例如在同一个打印服务器上运行的多个打印队列,在这种情况下,TXT记录中额外的信息可用于标识预期的打印队列。如下表所示:

image

这么做是必要的,因为服务类型曾经与众所周知的端口相关联。建议新的Bonjour协议的设计者在不同的动态分配的端口上来运行每一个服务的实例,而不是在相同的众所周知的端口号上运行它们(这种情况下还需要额外的信息来指定客户端试图通信的服务实例)。

TXT记录中的数据的特性和格式特定于每种服务的类型,所有每个新的服务类型需要为自己相关的TXT记录定义数据的格式,并将其作为协议规范的一部分发布。

搜索

服务搜索使用服务发布期间注册的DNS记录来查找特定类型的服务的所有实例。为了做到这一点,所有的应用执行一个匹配服务类型的PTR记录的查询。如_http._tcp,通常使用高级接口。运行于每个设备上的多播DNS响应者将使用服务实例名来返回PTR记录。以音乐共享服务为例,下图显示了搜索的过程

image

在搜索音乐共享服务的过程,主要有两步:

  1. 客户端程序向标准多播地址224.0.0.251发出一个在local.域中服务类型为_music._tcp的查询。
  2. 网络中的每一个多播DNS响应者都将接收到这个请求,但只有音乐共享设备会使用一个PTR记录来作出响应。在这种情况下,PTR记录保存一个服务实例名Ed’s Party Mix._music._tcp.local.,客户端程序可以从PTR记录中提取服务实例名然后将其添加到一个音乐服务器的离线列表中。

解析

服务搜索通常只会发生一次,例如,当用户第一次选择打印机时。这个操作保存了服务实例名,和一个服务的任何给定实例的稳定的标识符。端口号,IP地址,主机名可能经常改变,但用户在每次连接服务时不需要再次选择一个打印机。因此,将一个服务名解析为socket信息只有在服务真正使用时才发生。

为了解析一个服务,程序使用服务名来执行SRV记录的DNS查询。多播DNS响应者使用包含当前信息的SRV记录来作出响应。下图演示了音乐共享实例中服务解析的这样一个过程:

image

这个过程主要分为几步:

  1. 解析进程发送一个DNS查询到多播地址224.0.0.251,查询Ed’s Party Mix._music._tcp.local.的SRV记录。
  2. 查询返回服务的主机名和端口号(eds-musicbox.local., 1010)
  3. 客户端发送一个IP地址的多播请求
  4. 请求解析为IP地址169.254.150.84.然后客户端使用这个IP地址和端口号来连接服务器。这个过程发生在服务被使用时,从而总是查找服务的最新地址和端口号。

参考

  1. Bonjour Overview

Bonjour理论1:基本概念

发表于 2014-07-19   |   分类于 翻译

在过去的二十年里,IP协议已经成为计算机等硬件设备之间通信的基本协议。大部分计算机和其它网络设备都是基于TCP/IP进行通信的。在这种网络中,每个设备都需要一个唯一的IP地址,不管是手动设定的还是由DHCP服务器动态分配的。动态指定的地址是可以改变的,但是像打印机等设备是必须手动设定一个静态地址的,这样网络中的计算机才能连接到它们。然后网络管理员需要配置一个DNS服务器,这样计算机用户就不需要通过IP地址来连接打印机了。这样,一个看起来很小的工作需要比较复杂的配置。但是如果我们需要在自己家中搭建一个局域网,而我们对此一无所知的话,这就是个大问题了。即使对于专业的网络管理员,也需要去手动配置打印机。但我们很多时候并不希望去做这些配置,就可以直接连入局域网内去获取打印机、或在文件服务器、甚至游戏服务器去获取我们想要的东西。

我们希望找到可用的设备并从一个列表中选择它们,而不是必须知道每个服务名或IP地址。这便是Bonjour所需要解决的问题。它是基于IP的一组零配置网络的协议。零配置网络有很大的潜力。

假设我们带着笔记本电脑去客户的公司,这时我们需要打印一些东西。如果这家公司有一台支持Bonjour协议的打印机,那么只要两台设备在同一个局域网内,就可以直接打印。此时,我们的每户本会搜索到任何可用的设备。我们只需要打开文档,选择设备列表中的打印机,点击打印就可完成打印操作。图1演示了这样一个过程:

image

这种零配置网络能够满足手机游戏、家庭网络、分布式计算和其它很多网络应用的需求。

那么Bonjour到底是什么呢?

Bonjour三要素

Bonjour是由苹果提出的基于IP的无配置网络建议。它产生于ZEROCONF工作组的工作,是IETF的一部分。ZEROCONF工作组对于基于IP的零配置网络的需要主要有三个要求

  1. 寻址(分配IP地址给主机)
  2. 命名(使用名字而非IP地址来表示主机)
  3. 服务搜索(自动搜索网络服务)

Bonjour即是这些需求的一个解决方案。它允许服务提供商、硬件制造商和程序开发者在使用新的技术时只使用单一的网络协议–IP。网络用户不再需要指定IP地址和主机名,甚至不需要指定网络中访问设备的类型。用户只需要简单的查看有哪些网络服务可用,然后从列表中选择。程序能自动检测到所需要的服务或它们需要进行交互的其它程序,允许自动连接、通信和进行数据传输,而不需要用户的介入。

下面我们来分别看看Bonjour如何解决零配置网络的三个要素

寻址

寻址问题通过自分配链路本地寻址方法来解决。链路本地寻址(Link-local addressing)使用为本地网络保留的地址范围,特别是一个小的LAN或单独的LAN片断。IPv6标准将自分配链路本地寻址作为协议的一部分。而IPv4本身不包含链路本地寻址,因此零配置网络寻址的主要挑战是如何在IPv4中改进此功能。

在IPv4中,自分配寻址通过在链路本地范围内挑选一个随机的IP地址并对其进行测试。如果地址没有使用,则作为本地地址。如果已经使用了,计算机或其它设备将选择另一个随机地址并测试。

目前大多数主流操作系统都支持IPv4和IPv6的链路本地寻址。

命名

在本地网络中进行name-to-address转换操作建议的方案是使用多播DNS,其中DNS格式查询使用IP多播传送到本地网络中。因为这些DNS查询被发送到多播地址中,因此没有一个全局的DNS服务器来回答这个查询。每个服务或设备都可以提供自己的DNS功能,当前发现一个针对自己名称的查询时,它使用自己的地址来响应这个DNS查询。

Bonjour更进了一步。它在主机或iOS设备上包含一个响应来处理任何网络服务的多播DNS查询。这降低了中断应用来响应多播消息的压力。通过注册服务,Bonjour的mDNSResponder守护进程自动广告我们的服务是否可用,以便把我们名字的查询自动被引导到正确的IP地址和端口号上。

为了让name-to-address转换能正确的工作,需要一个本地网络的唯一的名字。与转换DNS主机名不同的是,这个本地名称只在本地网络或LAN段上有效。我们可以像自分配一个本地地址一样自分配一个本地名称,选中其中一个;如果这个名称没有使用,则:

  1. 硬件制造商通过设备发送一个多播DNS查询并查看响应来确定其选中的名字是否已使用。如果有响应,则设备应该选择另外一个名字。没有用户界面的设备可以在默认名字后面添加一下大数直到这个名字是唯一的。例如,如果在网络中的默认名字是XYZ-LaserPrinter.local,则可以使用XYZ-LaserPrinter.local,XYZ-LaserPrinter-2.local,XYZ-LaserPrinter-3.local进行测试,直到名字唯一。
  2. 软件服务在注册Bonjour时提供一个名字,如果提供的名字已使用,则Bonjour会自动重命名我们的服务。

在OS X中,用户可以在系统偏好设置的共享面板中设置本地主机名来为计算机设置一个主机名,在iOS中,主机名是自动产生的且不能配置。这个主机名可用于任何DNS主机名常规使用的地方,如Web浏览器、命令行工具等等。为了向系统表明名字是一个本地主机名,可在主机名后添加.local.,如Setve.local.即为一个本地主机名。

如果用户在浏览器中输入steve.local.,这将告诉系统多播一个请求以在本地网络中查询steve,而不是将其发送到常规DNS服务器。如果在本地网络中有一台支持Bonjour的计算机名字为steve,则用户的浏览器将发送正确的IP地址给它。这允许用户访问本地主机和服务崦不需要常规DNS服务。

服务搜索

Bonjour最后一个要素是服务搜索。服务搜索允许程序查找所有可用的特定类型的服务并维护一个命名服务及端口的列表。应用可以将服务主机名解析为IPv4和IPv6地址列表。

命名服务的列表在服务与其当前DNS名和端口中提供了一个indirection层。indirection允许程序保存一个可用服务的持久化列表并在使用服务前解析一个实际的网络地址。该列表允许服务被动态迁移,而不会产生大量的网络流量来宣告这个改变。

在Bonjour中服务搜索是通过”browsing”来实现的。一个多播DNS查询发送一个指定的服务类型和域,任何匹配的服务都会回复他们的名字。其结果就是一个可用服务的列表。这与传统的网络服务以设备为中心的思考很不一样。对于处理服务、网络设备和网络编程的的个人来讲,很容易习惯性认为服务基于物理硬件(services in terms of physical hardware)。
从设备为中心的角度来看,网络由许多设备或主机组成,每个包含一组服务。例如,网络可能由服务器和客户机组成。在一个设备为中心的浏览架构中,一个客户端向服务器查询哪些服务正在运行,获取一个列表(FTP, HTTP等),并决定使用哪个服务。这个接口反映了物理系统组织的方式。但这不一定是用户在逻辑上希望的或想要的。

用户通常想要完成特定的任务,而不是查找设备列表来找出什么服务正在运行。让客户端只询问一次“什么打印服务可用?”比查询每个可用的设备来问“你正在运行什么服务”然后筛选结果查找打印服务来得更有意义。以设备为中心的方法不但耗时,而且需要大量的网络流量,且其中大部分是无用的。而以服务为中心的方法发送单个查询,只生成相关的回复。

此外,服务不与指定的IP或主机名绑定。例如,一个站点可能被多个有不同地址的服务器托管。在一个组织中,网络管理员可能需要将一个服务从一个服务器移到另一个服务器来做负载均衡。如果客户端存储了主机名,如果服务移到另一个不同的主机,则无法再连接。

Bonjour从面向服务的角度来看问题。它通过所需要的服务类型来查找,而不是主机名。应用存储的是服务实例名,而不是地址,所以如果IP地址、端口号,甚至主机名改变了,应用仍然可以连接。通过专注于服务而不是设备,用户的浏览体验将更有用且无故障。

降低消耗

服务器的寻址、命名和服务搜索可能会产生大量的网络流量,但Bonjour采取了一些措施来将流量降低到最少。这允许Bonjour达到AppleTalk的易用性,同时又避免了不必要的“繁琐”。Bonjour采用了多种机制来降低零配置的开销,包括缓存、禁止重复响应,指数回退和服务公告。下面我们将简单介绍下这几种机制。

缓存

Bonjour采用了多播DNS记录缓存来防止主机请求那些已请求过的信息。例如,当一个主机请求一个LPR打印后台的处理程序列表时,列表通过多播传回来,所有本地主机都将看到这个列表。下次本地网络中的一个主机需要一个打印后台处理程序的列表时,因为它已经缓存了这个列表,所以不需要再次发起请求。多播DNS响应者负责维护这个缓存;程序开发者不需要做任何事情来维护它

禁止重复响应

为了阻止重复响应相同的请求,Bonjour服务查询包含一个已知答案的列表。例如,如果主机正在浏览打印机,第一个查询不包含打印服务,但从可用打印服务器得到12个回复。下一次该主机查询打印服务时,查询包含已知服务器的列表。已经在列表中的打倒服务器将不做响应。

Bonjour以另一种方式来抑制重复响应。如果一个主机将要响应,但发现另一个主机已经响应了相同的信息,则主机会抑制它的响应。

程序开发者不需要采取任何措施来抑制重复发送,Bonjour会处理。

指数回退和服务公告

当主机浏览服务时,它不会不间断地发送查询来查看是否有新的服务。相反,主机会初始一个查询,后续会不断增加查询时间的间隔,如1s, 3s, 9s, 27s这样一个时长间隔,最后可能会长达1小时的间隔。

但这不意味着花费一个小时的时间间隔再来查看新的服务。当在网络中启动一个服务时,它会使用几次回退算法来通知它的存在。这样就将服务公告和搜索的网络流量保持在最小,而新的服务也会很快就知晓。

在一个Bonjour配置主机上运行的服务在注册到mDNSResponder守护程序时会自动发出公告。在其它硬件上的服务,如打印机,将使用指数回退算法来公告其存在,这样充分利用了Bonjour的优势。

参考

  1. Bonjour Overview

Cover Flow特效实现

发表于 2014-07-18   |   分类于 源码分析

原文发表在cocoachina上,现在把它整理过来。

Cover Flow介绍

Cover flow是苹果首创的将多首歌曲的封面以3D界面的形式显示出来的方式。

image

随处可见的Cover Flow特效

iTunes:在iTunes音乐中点击搜索框左边“查看”项第三个,即以cover flow形式显示专辑封面(前提是你得添加插图或音乐自带插图),也可以在全屏模式使用

iPhone/iPod Touch:在竖屏模式播放音乐,iPhone只会显示一张专辑的封面;但当用户将机身旋转为横屏模式后,则能看到多首歌曲的封面以3D界面的形式显示出来,用手指左右的滑动能够进行歌曲的选择,点击相应的专辑封面则会显示该张唱片的曲目,点击相应歌曲即可开始播放。

苹果官网:官网上有以Flash展示的cover flow界面iPod NANO3/4/5:基本类似于在iphone中的操作,利用触摸轮滑动使封面转换Safari

使用 Cover Flow,您可以像在 iTunes 中翻看专辑插图一样轻松地翻看网站。Cover Flow 可以将您的书签和历史记录显示为大图预览,这样您就能立即找到网站。要查看 Cover Flow 如何工作,请单击 Safari“书签”栏左端的打开书本图标来打开书签列表。在“精选”列表中选择“历史记录”或您要查看其标签的精选。使用水平滚动条来翻看网页预览。您还可以使用鼠标上的滚动按钮来翻看预览。如果您的触控板已配置为支持触控板手势,则您可以左右扫动。

特效制作

方法一:UICoverFlowLayer

正式的SDK并未包含UICoverFlowLayer,但是它仍然是标准的UIKit。通过steve nygard的类转储(class-dump), 能从UIKit框架中提取 UICoverFlowLayer头文件。

由于UICoverFlowLayer是私有的,无法应用于应用程序(无法通过苹果的审查),所以在此简单介绍使用方法:

  1. 将UICoverFlowLayer.h文件拷贝到工程中创建cover flow视图,并将UICoverFlowLayer图层分配到视图图层中
  2. 视图发送dragFlow:atPoint消息,以处理与Cover Flow图层的触摸和拖动的交互过程构建cover flow视图控制器,分配和初始化视图,并提供委托和数据源方法

使用UICoverFlowLayer的方法如下代码所示

1
2
3
UICoverFlowLayer *cfLayer = [[UICoverFlowLayer alloc] initWithFrame:frame numberOfCovers:count];
[[self layer] addSublayer:cfLayer];

方法二:OpenFlow

OpenFlow是一个用于实现Cover Flow特效的开源库,它是基于Quartz实现的,能很好的实现Cover Flow特效,同时又易于使用。

下载地址:https://github.com/thefaj/OpenFlow

使用OpenFlow的基本步骤如下:

  1. 创建工程
  2. 添加OpenFlow源代码到工程中
  3. 添加QuartzCore和CoreGraphics框架到工程中
  4. 定义CoverFlowViewController(自定义)类
  5. 在CoverFlowViewController.h中导入”AFOpenFlowView.h”
  6. 实现AFOpenFlowViewDelegate类和AFOpenFlowDataSource协议

定义CoverFlowViewController类的代码如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
// CoverFlowViewController.h
// CoverFlow
//
// Created by Avinash on 4/7/10.
// Copyright Apple Inc 2010. All rights reserved.
//
#import "AFOpenFlowView.h"
@interface CoverFlowViewController : UIViewController {
// Queue to hold the cover flow p_w_picpath
NSOperationQueue *loadImagesOperationQueue;
}
@end

实现CoverFlowViewController类

加载图片

1
2
3
4
5
6
7
8
9
10
11
12
13
- (void)viewDidLoad {
[super viewDidLoad];
// loading p_w_picpath into the queue
loadImagesOperationQueue = [[NSOperationQueue alloc] init];
NSString *imageName;
for (int i=0; i < 10; i++) {
imageName = [[NSString alloc] initWithFormat:@"cover_%d.jpg", i];
[(AFOpenFlowView *)self.view setImage:[UIImage imageNamed:imageName] forIndex:i];
[imageName release];
NSLog(@"%d is the index",i);
}
[(AFOpenFlowView *)self.view setNumberOfImages:10];
}

实现委托方法,以设置Cover Flow默认图片及通知哪幅图片被选中

1
2
3
4
5
6
7
8
9
//delegate protocols
// delegate protocol to tell which image is selected
- (void)openFlowView:(AFOpenFlowView *)openFlowView selectionDidChange:(int)index{
NSLog(@"%d is selected",index);
}
// setting the image 1 as the default pic
- (UIImage *)defaultImage{
return [UIImage imageNamed:@"cover_1.jpg"];
}

修改xib文件中视图的类UIView为AFOpenFlowView(重要)

完成上述步骤之后,就可以运行一下程序看一下效果了。虽然与苹果的Cover Flow效果还是有点差距,但还是不错哦。

方法三:FlowCover

FlowCover也是一个开源库,它是基于OpenGL ES。FlowCover的源代码非常简单,只有FlowCoverView和DataCache两个类。这两个类的功能如下:

  1. FlowCoverView:定义主视图。这是一个OpenGL ES视点,可以被嵌套在其它视图中。
  2. DataCache:提供一个简单的数据缓存方案,保存一定量的对象,当对象超过最大值时,旧的对象会被舍弃。

使用FlowCover的基本步骤如下:

  1. 创建工程
  2. 添加FlowCover源代码到工程中
  3. 然后就可以像用其它UIView一样使用FlowCoverView了

FlowCover中需要实现FlowCoverViewDelegate协议,该协议中主要有三个方法:

1
-(int)flowCoverNumberImages:(FlowCoverView *)view;

返回FlowCoverView视图中显示的图片数量

1
-(UIImage *)flowCover:(FlowCoverView *)view cover:(int)cover;

返回FlowCoverView视图中用cover指定的图片

1
-(void)flowCover:(FlowCoverView *)view didSelect:(int)cover;

当用户触击FlowCoverView中的cover时调用。

方法四:Tapku框架

Tapku库是一个开源的iOS框架,它包含CoverFlow, Calendar Grid, Char View等等API,总之还是一个比较强大的库。把Tapku加下工程中还是比较复杂的,有兴趣的童鞋可以去网上搜一下。

Tapku下载地址:https://github.com/devinross/tapkulibrary

Tapku中与Cover Flow相关的类主要有如下两个:

  1. TKCoverflowCoverView: 该类表示的是单个cover。相当于UITableViewCell
  2. TKCoverflowView:该类相当于UITableView类,用来管理和显示cover flow中图片及实现cover flow效果。

同时还有两个相关的协议:TKCoverflowViewDelegate, TKCoverflowViewDataSource,分别是 TKCoverflowView的代理及数据源。这两个协议分别有一个必须实现的方法,分别是
TKCoverflowViewDelegate协议的

1
- (void) coverflowView:(TKCoverflowView*)coverflowView coverAtIndexWasBroughtToFront:(int)index;

TKCoverflowViewDataSource协议的

1
- (void) coverflowView:(TKCoverflowView*)coverflowView coverAtIndexWasDoubleTapped:(int)index

在工程中使用Tapku的CoverFlow步骤如下

  1. 创建工程
  2. 添加Tapku库到工程中(该步骤有点麻烦,而且Tapku库比较大,个人认为可以只把CoverFlow相关的类抽取出来直接用)。
  3. 新建一个视图控制器CoverflowViewController,在该控制器中添加如下代码

在头文件CoverflowViewController.h中

1
2
3
4
5
@interface CoverflowViewController : UIViewController <TKCoverflowViewDelegate,TKCoverflowViewDataSource,UIScrollViewDelegate> {
TKCoverflowView *coverflow;
NSMutableArray *covers; // album covers p_w_picpath
......
}

在CoverflowViewController.m文件中主要有如下处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//创建视图
- (void) loadView{
[super loadView];
......
coverflow = [[TKCoverflowView alloc] initWithFrame:self.view.bounds];
coverflow.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
coverflow.coverflowDelegate = self;
coverflow.dataSource = self;
if([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad){
coverflow.coverSpacing = 100;
coverflow.coverSize = CGSizeMake(300, 300);
}
[self.view addSubview:coverflow];
......
}

实现代理方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
- (void) coverflowView:(TKCoverflowView*)coverflowView coverAtIndexWasBroughtToFront:(int)index{
}
//生成单个cover flow
- (TKCoverflowCoverView*) coverflowView:(TKCoverflowView*)coverflowView coverAtIndex:(int)index{
TKCoverflowCoverView *cover = [coverflowView dequeueReusableCoverView];
if(cover == nil){
BOOL phone = [UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone;
CGRect rect = phone ? CGRectMake(0, 0, 224, 300) : CGRectMake(0, 0, 300, 600);
cover = [[[TKCoverflowCoverView alloc] initWithFrame:rect] autorelease]; // 224
cover.baseline = 224;
}
cover.image = [covers objectAtIndex:index%[covers count]];
return cover;
}
- (void) coverflowView:(TKCoverflowView*)coverflowView coverAtIndexWasDoubleTapped:(int)index{
TKCoverflowCoverView *cover = [coverflowView coverAtIndex:index];
if(cover == nil) return;
[UIView beginAnimations:nil context:nil];
[UIView setAnimationDuration:1];
[UIView setAnimationTransition:UIViewAnimationTransitionFlipFromLeft forView:cover cache:YES];
[UIView commitAnimations];
NSLog(@"Index: %d",index);
}

效果比较

在效果上个人感觉Tapku会好些,渲染流畅,美中不足的是在快速拖动时,停止下来的时候会有抖动的感觉(当然快速拖动这一功能是否需要可视情况而定,如果将此功能禁掉,跟苹果自身的效果还是差不多的)。

OpenFlow的问题在于当改变图像时,新选中的图像会先放大并置于表层,然后才缓动到中间。这是其一个瑕疵。

总体感觉上来讲,苹果自身的CoverFlow的缓动效果还是最好的,有那种渐进渐出的效果,而如上几个开源的库其动画显得有点生硬,有兴趣的童鞋可以试着改进一下。

1…6789
南峰子

南峰子

86 日志
7 分类
© 2017 南峰子
由 Hexo 强力驱动
主题 - NexT.Pisces