参加SF.GG黑客马拉松项目技术要点总结

前言

本文主要介绍我们做的项目,以及用到的技术和一些坑的总结。

周末大搜车前端团队的几位同学一起参加了 SegmentFault 举办的黑客马拉松,虽然最后没得奖,但是大家一起玩了不少技术,项目涉及到不少东西,这里稍微总结一下。

项目

大搜“狗”。主要是一个利用蓝牙终端(狗身上)和小区里的手机 App(理想化的场景)来定位狗狗的位置。场景有些理想化了,不过主要是对这中间涉及到的技术比较感兴趣,于是大家就去做啦。收获不少,奖品没有,不过还是感谢 SF 给予我们这样一个机会。

这是我们参赛的一些照片:

主要用到的技术:

  • 芋头:Arduino + 蓝牙模块
  • PlusMan:iOS 蓝牙搜索 + WebView bridge
  • 死月:NodeJS 提供后端服务,以及定位算法
  • 郑淳:前端 React 实现登陆注册,地图打点等界面

接下来按上述顺序对每项做做介绍和总结,分别由负责的同学总结。

Arduino + 蓝牙模块

这次没有用到太复杂的蓝牙数据交互,不过蓝牙模块的交互其实还是很简单的,通过串口跟 Arduino 通信也很简单,本次主要是用 Arduino 给每个蓝牙设备编号,然后通过 iOS 设备来扫描特殊编号的蓝牙芯片的信号。

不过给 Arduino 写入程序的时候一定要注意先断开跟蓝牙芯片的串口连接,因为 Arduino 的串口只有一个,当利用串口给芯片写入程序的时候,不能连接其他串口。否则会报 avrdude: stk500_recv(): programmer is not responding - Google 类似的错误。写入好 Arduino 的程序之后再向蓝牙芯片写入指令。这里可以通过一个中断来控制写入的时机。

其实本来买 Arduino 和蓝牙之类的芯片是为了实现一个自己的想法,没想到正好碰上了这次比赛,而且队友正好想出了这样的点子,就这样顺其自然啦。

后端服务

后端采用了 Node.js 来完成 RESTful 的 HTTP 服务,由于是个 Demo,所以存储直接使用了 MySQL 来持久化。

其实后端还有另外两位小伙伴默默做着付出,几个接口以及数据库结构设计都是他们来完成的,可以说包揽了整个后端的业务逻辑。一个是同样来自大搜车的小龙同学,还有就是来自天猫的芙兰同学。

死月承担的工作是后端脚手架的搭建以及粗糙的定位算法实现。

后端的接口很简单,算下来就只有注册、登录、绑定、手机提交某个蓝牙设备发送的信息以及获取某个蓝牙设备的最后 N 条记录这么几个接口。

数据库采用 MySQL,所以使用了我们公司自己研发的 ORM 包——Toshihiko。由于整个项目比较小,所以前后端放在一个 Repo 当中,使用 Git Subtree 来整合。

至于定位算法,由于我们此前都没经验,所有算法都是在 Hackathon 参赛期间临时学习以及设计的,的确存在很多不合理的地方,也欢迎指正。

定位算法

我们的定位是基于蓝牙设备(狗脖子上穿戴的)以及由“地推团队”推广安装的寻狗 App 配合完成的。

蓝牙设备会定时向外发送广播,而附近安装了 App 的设备就能搜索到这个蓝牙设备发送的消息。这个时候 App 就会连着蓝牙设备的唯一 ID 等信息,加上自身得到的蓝牙信号强度以及该设备此时的 GPS 地理位置信息一同提交到服务端

当该信息在服务端存储完毕之后,就会执行计算解析的函数了。

在该函数中,首先会读取所有手机端在同一时刻提交的某一唯一 ID 的蓝牙信号信息——包含了这些手机在当时的一个 GPS 信息。

只有一条记录

当只有一条记录的情况时,我们无法准确得到蓝牙设备的位置,只能假设这个时候蓝牙设备就在手机旁边,所以将这条记录的手机地理位置作为蓝牙位置存到“已解析表”中。

只有两条记录

当只有两条记录的情况时,我们通过蓝牙信号强弱来估算出蓝牙设备和手机的一个距离。不过事实认证这个估算的距离误差还是比较大的。

当计算好距离后,我们根据距离比例来垂直切割两个手机设备所连接的直线,以交点作为距离。

这么做的原因是——就算在蓝牙测距上面没有误差,光凭两个手机设备的信息也是无法求出蓝牙设备的位置的。

我们已知两设备距离、蓝牙和两设备间各有一个距离——三个边长能确定一个三角形,但是无法确定这个三角形第三个顶点的方向,也就是说如果它们能构成一个三角形我们也只是能得到两个可能的位置。

所以这里就折中了一下将位置中和到了两个设备线段之间的一个位置。

有三条及以上的记录

理论上三个手机就能确定蓝牙的位置了。

在求出蓝牙距各手机的距离之后,我们就能以手机为圆心、距离为半径作圆,所得到的交点就是蓝牙设备的位置了。

然而现实是残酷的——这个距离还是有比较大的出入的。

于是我们还是作半径求交点——然后这个时候我们有可能得到至少零个交点。

这里有一个赛后想到的优化方案,对于每个圆都半径各扩大半米、一米、两米克隆几个起来一起求交点。

然后如果有零个交点的话本来能通过比较高深的算法搞出来最近似的一个点——由于木有这个经验以及时间紧迫,所以就随意拿了第一个设备的位置顶上去了。

如果有一个交点的话就拿这一个交点当做蓝牙设备的位置。

如果有两个以上交点的话,就将坐标系按一米切割成小方格离散化,将这些交点撒到坐标系中,取交点最多的那个小方格的中心点作为这个蓝牙设备的位置。

具体的算法代码可以参考 Github 上的 Repo

蓝牙测距算法

网上查了一些蓝牙测距算法,资料比较少,也没时间看论文,所以这里我们随便拿了一个公式来。

function getDistance(rssi, txPower) {  
    /**
     * RSSI = TxPower - 10 * n * lg(d)
     * n = 2 (in free space)
     * 
     * d = 10 ^ ((TxPower - RSSI) / (10 * n))
     */

    return Math.pow(10, (txPower - rssi) / (10 * 2));
}
经纬度与单位为米的坐标矢量转化

由于上述算法中距离单位为米,坐标系是经纬度,所以在计算中需要统一单位。在转化的过程中我们假设地球半径为 6378.137 公里,然后分表求出坐标据东经 0 度和北纬 0 度的矢量值即可。

var mileToCoor = exports.mileToCoor = function(mile) {  
    var lngAbs = Math.abs(mile.lng);
    var latAbs = Math.abs(mile.lat);
    var newLng = lngAbs / ((EARTH_PERIMETER * 1000) / 360);
    var newLat = latAbs / ((EARTH_PERIMETER * 1000) / 360);

    return {
        lng: newLng,
        lat: newLat
    };
};
栅格化多点矫正算法

说起来这么高大上,实际上就是将坐标系离散化成小方块,看哪个小方块里面的点多,取该小方块的中点。

function calcFinalCoordinate(intersections) {  
    var map = {};
    for(var i = 0; i < intersections.length; i++) {
        var lng = parseInt(intersections[i].lng);
        var lat = parseInt(intersections[i].lat);

        var str = lng + "," + lat;
        if(!map[str]) {
            map[str] = 1;
        } else {
            map[str]++;
        }
    }

    var str = "";
    for(var key in map) {
        if(!map.hasOwnProperty(key)) continue;
        if(!str) {
            str = key;
            continue;
        }

        if(map[key] > map[str]) {
            str = key;
        }
    }

    var temp = str.split(",").map(function(a) {
        a = parseInt(a);
        a -= 0.5;
        return a;
    });

    return {
        lng: temp[0],
        lat: temp[1]
    };
}

实际上在 iOS 提交自身位置的时候也采用了该算法进行矫正。因为 GPS 获取的位置有误差,经常会“飞”到别的位置去的,如果用线图标出几个时间点的位置的话,效果是跳来跳去的。

这个时候就需要频繁采样然后栅格化估算出最有可能的点了,用的也是上面的算法。

硬广

推荐一下我们自己的 Node.js ORM 包——Toshihiko

iOS 部分

技术渣表示感想在先。

PlusMan:平常写 Node.js,业余 iOS(以至于连项目怎么创建都忘了 ╮( ̄⊿ ̄")╭ ),感谢芋头大大手把手的支援。团队里还有好多牛人,和一群比自己牛逼的人一起 Fighting,感觉棒棒哒 (´∀`)。虽然几次遇到瓶颈,困到想放弃,可是大家都坚持了下来,大家的专注和坚持,对我的三观矫正工程影响非常大。

iOS 蓝牙搜索

蓝牙模块使用了 BabyBluetooth,开启扫描后,定时向服务器传递如下格式的包

{
    "identifier": "E9248807-978B-C794-99A3-FD24F65B0F98", // 宠物设备标识    
    "RSSI": -92, // 信号强度
    "name": "F1n5-M4-P6t-plusman", // 设备名,以 F1n5-M4-P6t- 开头的为需要上传的设备信息
    "lng": 121.148272, // 经度
    "lat": 30.158372, // 纬度
    "timestamp": 1445673908 // 时间戳,精确到秒
}

当时碰到了蓝牙信息包不能持续上传的问题,在源码分析无果,时间紧迫的情况下,我们想出了一个馊主意:开启一个循环定时器,循环调用 cancelScanscanForPeripherals().begin() 方法,暴力解决问题。

iOS 定位

定位采用 iOS 自带 CoreLocation 库,参阅 iOS 地图定位——iOS 8/iOS 9 新特性

但是发现获取到点后,哪怕人不动,经纬度也会出现跳跃的情况,后来,采用抽样 + 栅格选取的方式(同服务器端:calcFinalCoordinate 方法),解决了这个问题。

WebView 混合

用的是头哥推荐的 Jockey,是一个 JSBridge,解决网页和原生交互问题。 在实际应用中,网页可以调起原生的蓝牙列表,进行设备选取和绑定。

前端

采用了 React + Flux + WebPack的方案制作。

设计稿如下,实现登陆注册,还有找狗功能。设计稿如下。

组件结构如下

  • 弹出框
    • 结果描述 (this.state.info)
    • 登陆框 (this.state.isLogin)
      • ...输入
      • 登陆按钮 (Actions.login)
    • 注册框 (this.state.!isLogin)
      • ...输入
      • 注册按钮 (Actions.register)
  • 找狗按钮 (Actions.findDog)

这里以登陆为例,最简答描述flux结构。

  1. 组件绑定 store
  2. 组件点击登陆触发 Actions.login
  3. Dispatcher 分发 action 到绑定了的 store
  4. store 通知组件,更新 state,刷新组件。

由于数据的流动是单向的,所以调试起来就很清晰明了。

然而,在 PC 端完美的情况下,移动端在提交前却一直触发不了点击事件。

坑:在采用百度地图时,目测是地图将 div 的点击屏蔽了或者覆盖了。

这一次也给了教训,过度依赖 div 和惯性思维是很危险的。(完全想不到 div 不能触发)

我对react组件的理解是:

  1. 状态机
  2. 父组件绑定 store,控制状态。
  3. 子组件根据父组件提供 props 渲染。(尽量纯净,简单)
  4. 子组件发送 action 影响 store

#打着 Happy Coding 的旗帜用牛刀杀鸡#

微信扫描查看或分享
加入我们