阅读 302

iOS 低功耗蓝牙相关项目总结

去年一直做物联网这一块。开始接手了一个Wifi的项目,之后从零开始负责了一个BLE项目。这里有一个简单的DemoGithub - BLE Demo可供参考。运行起来可以搜索附近的蓝牙设备并展示获取的一些基本信息,点击链接查看各项服务以及字段。在实际的开发当中,确实遇到了不少的问题。这篇文章我想结合之前Wifi项目的经验,总结一下。

BLE项目中使用的mesh网络是网状自组网络。移动端尝试扫描周围的设备,尝试和这个网络中的任意一台蓝牙设备进行连接,直连的蓝牙设备类似于一个路由。之后移动端和网络中的每一台设备的通讯都需要通过这台直连的设备。

mesh网络

我们日常的应用开发是站在应用层的层面,在iOS的应用里调用系统API或者使用网络库进行通讯,这些都是已经高度封装好的,无需过多的操心底层传输的问题。应用可以从回调里收集到足够的信息来处理和应付当前的网络情况。而mesh网络的情况类似于OSI模型里的网络层,在没有上层协议的支持下,mesh网络的传输是不可靠的。数据的传输量也非常的有限。频繁的数据传输(比如从设备获取一些数据量比较大的信息需要多次连续的传输)很可能造成mesh网络的网络风暴。这些问题都需要在我们的应用里处理。

在BLE项目中,整个项目被分成三层。底层是网络层,负责和mesh网络的通讯;上层是我们的业务逻辑代码;中间层是Model层负责数据的处理以及转发。开始并没有想到设计中间层,只是希望网络层在收发消息以后通过回调直接和业务逻辑代码进行交互,但随着功能的增多,开始出现了问题。

  • 网络层承担的任务太重。 首先网络层需要和蓝牙设备进行通讯。在与蓝牙设备建立连接以及通讯的各个回调方法中,我们需要执行检查设备,自动连接,设备验证以及等操作。发现设备之后还需要网络层查询设备信息进行本地缓存。在通讯的过程中,网络层还需要处理数据进行分发起到一个路由的作用。自上而下看,上层下发的每一类命令都需要网络层拼接发送。网络层慢慢变的非常臃肿,过多的任务也让原先网络层通讯的职能显得不明确。

  • 数据未经处理直接转发 当我们接收到设备返回给我们的数据之后,我们通过回调传给业务逻辑代码进行处理。网络层不知道业务逻辑层如何处理,只能每次把数据直接丢给上层。 在项目中,有一些界面会显示一个设备的列表,会根据我们接收的信息来即时刷新设备的状态。正常情况下没有任何问题,但在一些批量操作或者特定情况下,我们会收到大量的设备状态信息。这时前端的tableView会不停的调用刷新操作,造成界面的卡顿。实际上很多冗余的信息不需要刷新,我们可以尝试进行过滤并且做一些缓冲的操作来避免波峰对我们项目的冲击。

  • 代码难以复用 在BLE项目中,因为我们需要显示给客户的是当前环境下可操作的蓝牙设备。也就是说只支持本地不支持远程,所以我们本身不需要缓存运行时搜索到的设备。(实际我们还是有做缓存,将搜索到的设备的mac地址以及设备类型等固定不变的信息缓存到本地,在搜索到设备的时候先匹配本地,避免一些不必要的请求操作。)设备模型的生命周期是和App的生命周期保持一致的。项目中多处都需要使用当前环境的蓝牙设备列表,我们需要在每一个地方都处理网络层返回的设备信息并进行处理。随着项目的发展,重复代码越来越多,并且我们难以保证各个地方蓝牙设备列表的一致性。

为了解决这些问题并明确每一层的任务,我尝试在网络层和业务逻辑层中间添加一个中间件。对于网络层它是上层处理业务逻辑的对象,对于业务逻辑代码它是底层数据通讯的管理者。网络层只关心和蓝牙设备之间的验证以及通讯,业务逻辑层只关心事件的流程处理。我们将之前网络层数据的处理转发,业务逻辑层的设备模型管理剥离出来,交给中间层处理。

分层结束,考虑每一层之间的通讯。自上向下来看:

项目架构

网络层暴露命令发送接口给Model层,只接收拼接好的命令而不关心命令的内容。具体的命令由Model层拼接,单个设备的操作命令绑定到模型对象上作为实例方法,关于mesh网络所有设备的操作我们绑定到Model层的管理类上作为类方法。业务逻辑层根据需要调用Model层的方法即可。

自下向上,网络层接收mesh网络的消息,通过回调层层上抛。在iOS当中,有三种回调的方式:block,delegate以及notification。在这个项目当中,网络层和Model层之间采用的是delegate,Model层和业务逻辑层采用的是notification。

日常开发当中,我最喜欢也是最常用的是block来处理回调,因为它非常的轻,非常简单的就嵌入了逻辑代码当中,便于阅读。非常简单的就可以访问上下文。作为一个匿名函数,独立的词法作用域可以保存现场便于处理。但非常遗憾在这个项目里它并不合适。 我们说了非常多的block的优点,但它的缺点也非常的明显。因为block太轻了,所以稍微复杂一点的情况就会显得力不从心。在业务逻辑层和Model层之间有非常多的交互场景,如果为每一种场景都去设置一个block就会显得非常琐碎和难以管理。Model层和网络层之间回调的处理比较简单,主要就是将收到的消息简单处理上抛给Model层处理,但是我们要考虑到一点的是,block非常容易造成retain cycle。除了非常常见的strong/weak self的处理,还有一点需要注意的是,因为我们长期需要这个block所以它是一直被持有不被释放的。一旦造成内存泄漏,非常难以察觉出来。 我们可以参考苹果的做法,block和delegate在Cocoa中都有大量实例,比如简单的UIView的animation类方法,一些界面跳转的completion使用的是block,UITableView却是使用的delegate。对比一下,临时性一次性的,或者说长期持有但有一个明确的完成时间的,我们使用block;但是需要频繁调用的我们最好还是采用delegate。

在剩下的两种回调的方式里,比起delegate,notification的使用太过随意,你可以在任意位置收发notification。而且注册通知使用完以后你还必须手动去取消注册。但是notification有一点比delegate强那就是它具有类似广播的特性能够一对多。

实际上block也可以实现一对多,只是会比较的麻烦。在SDWebImage中用一个字典按照url生成的标识符来保存block的数组,执行完某个任务后根据标识符取出数组依次执行block即可。

回到项目中,在网络层和Model层之间这是一对一的关系。非常适合delegate的发挥。我们通过Protocol来定义通讯过程中的各个部分,处理起来非常的有条理。还有一个很重要的原因就是网络层和Model层之间处理的数据都是基本类型,而notification只能传递ObjectType类型。但是在Model层和业务逻辑层之间,常常一个模型参数的变化需要多个地方去响应,这个时候我们使用nitofication就会比较的合适了。

当然,notification本身的一些缺点我们可以通过规范代码来改善。在本项目中:

  • 所有notificationName都统一在一个文件中进行宏定义。
  • 一个notificationName为get和post定义成两套宏,按照get/post+事件名称的规则来命名。
  • 相同事件的响应函数名称保持一致。按照get+事件名称+notification的规则命名

这样规范以后,notification相关的内容管理起来就会方便很多。


在BLE的项目中,app一直和蓝牙设备保持着密切的通讯。除了app向蓝牙设备请求之外,蓝牙设备自身在状态变化的时候也会主动推给app消息。网络层实际只承担简单的收发作用,至于具体的什么内容,不关心也不知道。这一切都丢给了Model层来打理。

之前接手的Wifi项目,出现了一个什么问题呢。在Wifi项目中所有收发的消息都通过notification发出去了,在notification的信息里只是简单的区分了是哪种协议收到的消息,但不关心消息的类型,全部广播出去。需要接收消息的地方注册notification接收了消息以后根据消息的内容(字典)再去判断是不是我需要的内容。

问题很大:

  • 通讯的消息很多,消息类型有很多种。很多时候某个界面只是关心特定的消息类型,但是却被迫要去处理每一条返回的消息。 这里的处理不仅仅是简单的判断,需要你取出字典里的内容进行比对,每一条关键的数据都需要进行合法判定。消息类型判定的逻辑如果发生了变化你需要在每一个notification里去修改。

  • 无法过滤这些消息。比如设备会定时上报自己的状态,假设我的界面显示设备是打开的状态,如果最新设备上报的状态仍然是开。我们实际可以直接处理掉这条消息,只在设备状态变化的时候才去通知我们的业务逻辑做处理

  • 可能会影响未来业务的逻辑判断。整个项目实际上是不断在增加新的业务需求。回调里的一些操作可能会因为新业务的增加而在开发者不明了的情况下被触发,造成一些稀奇古怪的问题。比如重连,登录名和密码被切换。

除了这几点BLE项目还有一个比较麻烦的地方是,mesh网络内各个设备之间会定期进行通信确认设备的在线状态,但是外部如果正在请求数据会影响到内部的通讯,超过一定时间会判定某个设备下线,但是下线状态会立刻被刷新回来。反映到app里就是显示设备开关的按钮会出现闪烁的情况。我们希望能够在应用内添加判断把这种情况给处理掉。

网络层在接收消息之后,会将数据通过回调传递给Model层。大概的流程是:Model层会先根据消息里的标志位区分消息的任务类型。之后对消息进行清洗。在这一步所有不合法的消息都会被丢弃,合法的信息将被解析处理成模型**(信息是一个char类型的数组)**,确保各项数据无误以后再通过notification分发出去。 Model层在消息接收的地方添加了一个时延,类似TCP协议里的ack捎带操作。一些状态类型的消息在接收以后我们会延迟一段时间等待是否有其他的状态信息上报,确认没有之后再进行下一步操作,如果有新的消息则重置延迟时间。上报状态消息的设备如果相同则保存最后一条状态消息,设备不同就打包这些设备模型等到延迟结束一起丢给业务逻辑层处理,这样做可以有效避免mesh网络自身通讯不畅引起的问题以及减轻前端UI部分的压力。

如果同时有多个设备的状态信息上报过来可以一次刷新表格处理完而不用短时间内多次调用单行刷新的操作。类似微信的收消息,如果单条短讯就一条一条提示,但是如果同时来了多条短讯比如刷表情就会一起提示有‘xx'个未读消息。

合法的消息被处理成模型之后进行分发,Model层在这一块做了一些处理。首先,我们将返回类型大致分为几种。通知分发的时候走不同的notificationName。这里区分的标准是各种模型在项目中涉及的界面和服务层级。服务层级可能描述的不是准确。做一个简单的类比,在Python中logger模块有一个level的参数,你可以设定不同的level来区分log的级别也就是重要程度。同样在BLE项目中不同的消息我们关心的程度会不一样。开关之类的是基础消息频繁而简单;设备类型之类的查询消息简单且相关的查询需求基本都是集中使用;定时器的查询信息查询量大且服务比较重要独占一个模块。我们依据各种消息的特点进行划分层级区别分发,但是并没有区分的太细。这样做的目的是为了让各个notification能够尽可能独立不会打扰到不相关的部分,又不会分的太散导致notification过多难以管理,某些地方可能会出现需要注册多个通知去接收所需的消息。在notification分完层级之后,在每一个notification中添加一个int类型的掩码来具体指明每一个notification所传递的类型。每一位标示一个消息类型,注册了notification的地方通过和掩码的位运算就可以快速确定每条notification中的模型所代表的内容。

分发流程


mesh网络中有多个通道,为了保证开关之类的基础消息能够即时传递,留给其他服务的资源是非常有限的。在应用里我们如果瞬时交互大量的数据会造成非常严重的丢包和网络状况的不稳定。在硬件条件无法改善的情况下,我们必须在应用里做相关的处理来改善情况。并且涉及到一些关键消息的传输,可靠性的支持必须由我们应用来实现。

在BLE项目中发送的消息可以分为两类。一类是发送出去不关心结果没有回调的消息,比如开关之类;另一类是需要从BLE设备处获取信息的查询命令,比如获取闹钟,查询BLE设备信息之类的命令。 有关设备的基础命令我们直接发送,指定好设备和消息类型由Model层打包命令丢给网络层发送就好。因为之前已经说过mesh网络已经为这些服务预留了足够的资源,那么我们可以直接发送不做任何延时之类的处理。组的开关也无需担心,因为和一般项目的不同是,BLE组功能是将地址位设为0xffff其余部分和单条开关消息一致。消息在mesh网络中传递,设备对地址进行匹配判断自己是否在组内来进行响应即可,而不是逐个通知增大了消息量。而后一种的查询命令,就复杂了很多。因为mesh网络预留的资源不多而且需要使用大量的数据交互,同时我们还要求BLE设备返回消息给我们。文章的开始提到过mesh网络类似网络层,对应的协议是IP协议。而我们日常开发所接触的TCP/IP协议簇以及基于此的HTTP协议的可靠性支持都是由IP协议的上层协议TCP协议实现的。针对BLE项目中需要明确结果的查询需求如何处理?

先简单列举一下实际遇到的麻烦。

  • 丢包率高,数据传输不可靠。简单来说就是通信压力过大mesh网络无法负担,一般来说如果是非直连的BLE设备都会丢百分之十至三十的包,甚至BLE设备瘫痪造成mesh网络短时间无法通信的情况

  • 数据量如果比较大,无法一个包发完,需要移动端依次请求让BLE设备逐个发送**(类似分片)**。我们的设备需要处理发送和接收的逻辑来保证数据的正确性。

  • BLE设备会有多个版本,对于部分消息类型协议有所不同。

解决问题之前需要明确的一点是,什么是可靠性。查询的结果每一条都有明确的回复,这个回复可以是BLE设备正确的返回,也可以是出现错误我们得到的状态码。大概类似于所谓的生要见人死要见尸,我们必须知道每一条命令发送出去的结果。 对比真实的网络,mesh网络的情况还是简单的很多。在HTTP请求中,response的status code可以提供给client本次request的结果。status code种类非常多,涵盖了几乎所有网络中可能出现的情况,情况非常的多和复杂。但实际在BLE项目中,出现问题的时候比如超时,权限不够被拒绝,数据包过大之类,BLE设备和mesh网络不会通知移动端。我们可能遇到的情况实际只有发送成功正确收到返回消息和等待超时没有返回两种。

实际还有一种可能是我们多个消息发送出去同时返回多个结果,造成接收的错乱。但后面我们会提到这种情况不会发生,因为考虑到mesh网络的承担能力以及本身设备不支持我们查询一组数据中特定位置的数据,我们必须使用串行队列依次发送消息。实现类似TCP协议中的Negla算法。

为了避免出现丢包的情况,我们应当避免短时间内较大数据量的传输。但是如果需求确实要进行大量的数据交互,在硬件条件无法改善的环境下,我们考虑的是限制传输的速率来减轻mesh网络的压力。 具体的实现是一个NSOperationQueue来做一个串行队列,它的最大允许并行数设置为1。必须等待上一挑命令执行完成才会执行下一条的命令。这样做的目的一来是为了减轻通讯的压力;二来如果同时返回多条消息可能出现乱序(一条消息返回的路径并不确定。后发出的消息可能通过较短的路径先到达目的端。而且我们的设备不支持查询特定index的数据只能一次查询全部),移动端无法正确识别。 关于传输可靠性的实现也是基于这个串行队列的方法:每一条命令发送出去的时候都是可以明确返回数据的格式,移动端在发送一条命令之后只有接收到对应的消息才会确认当前命令已经完成。第一次发出查询命令以后会等待1s,如果没有返回则2S后发送第二条命令;等待1s,接收到数据继续查询,没有返回则5s后尝试查询最后一次,还没有正确接收认定当前通讯状况不佳,这条命令没有成功返回我们有理由相信其他命令在当前网络环境下也是无法正确收到返回的,所以程序返回超时错误,提示用户当前网络状况不佳。每一次间隔时间的延长是考虑到如果没有成功接收到返回消息,程序认为当前网络环境不佳,多等待一些时间允许mesh网络进行调整和处理拥塞的消息。

查询流程
关于和mesh网络通讯不畅的情况,还有一种可能是用户在使用程序的同时位置发生了改变,离开始连接的设备相隔了一定的距离从而出现了假死。实际网络层会有一个计数器,出现超时情况之后计数器会累加。计数到上限值时做一个检查操作,重新扫描附近的设备对比RSSI。如果当前连接的设备信号强度较弱,则尝试切换到信号良好的设备上。

在拿到数据之后,记录型的数据我们需要做好数据的本地化存储。之后每次查询这类数据先尝试从本地获取,如果没有再向设备请求。以此减少查询的数据量。


上面是从整体的概念上去解释了整个项目的规划设计。实际项目中,使用了Category来将一些管理类划分成多个部分。组合优于继承,特别是在BLE项目中新业务新功能不断增加的环境下。我们在主支上实现基础功能,根据不同业务再将相关业务代码剥离出去。方便我们后续的维护以及拓展。