最近在做一款移动端棋牌游戏,为了进一步提高用户体验、拉近玩家的距离,我们决定在游戏中加入好友功能,而对于体验好友功能的玩家来说,要是玩牌的时候可以看看附近都有谁在玩牌,跟他们交流交流玩牌心得什么的无疑是个不错的想法。而要实现查看附近的人就需要提提LBS(Location Based Service),他的意思就是基于位置的服务,就是通过移动终端获取到许多用户或者物体的经纬度坐标,通过这些位置信息所提供的服务。
好了,扯了这么多,我们来看看如何实现查看附近人的功能的:
首先要具备下面这些环境:
- php+MySQL(MySQL不是必须,本文中用的是redis来存储用户的信息)
- redis(本文用的是redis,当然你也可以用MySQL)
- geohash.class.php类(这个类是用来处理经纬度坐标的一些基本函数,当然这些东西完全可以自己去写,如果时间充裕的话)
好了,等这些环境都具备了之后,我来讲讲这个实现过程:
- 首先介绍下GeoHash思想
第一步. 编码
这个功能应用到了一个很好的算法GeoHash,也许有同学听过这个功能,没错GeoHash就是通过一个巧妙的算法(不由得惊叹前辈们真牛!)把经纬度转化为字符串,这样有什么好处呢,显而易见,将二维的数据转化为了一维,这样一来存储就方便了,搜索效率也会高很多,那么现在问题来了,GeoHash算法是如何把经纬度坐标转化为字符串的?
将经纬度编码为字符串的过程可以分为以下3个步骤:
首先就是编码,对于经纬度的编码通过折半比较法,当大于中值时该位编码为1(小于时编码为0),下次新的区间为中值到最大值(或者最小值到中值),这样一直比较下去,直到到达要求的精度,精度和纬度的方法是一样的,只不过一个原始区间是(-90,90),一个是(-180,180),光说不好理解,下面我们看看一个简单的例子:
编码 | min | mid | max |
1 | -90 | 0 | 90 |
0 | 0 | 45 | 90 |
1 | 0 | 22.5 | 45 |
0 | 22.5 | 033.75 | 45 |
1 | 22.5 | 28.125 | 33.75 |
1 | 28.125 | 30.9375 | 33.75 |
0 | 30.9375 | 32.34375 | 33.75 |
1 | 30.9375 | 31.640625 | 32.34375 |
1 | 31.640625 | 31.9921875 | 32.34375 |
0 | 31.9921875 | 32.16796875 | 32.34375 |
编码 | min | mid | max |
1 | -180 | 0 | 180 |
0 | 0 | 90 | 180 |
1 | 0 | 45 | 90 |
1 | 45 | 67.5 | 90 |
1 | 67.5 | 78.75 | 90 |
1 | 78.75 | 84.375 | 90 |
1 | 84.375 | 87.1875 | 90 |
1 | 87.1875 | 88.59375 | 90 |
0 | 88.59375 | 89.296875 | 90 |
1 | 88.59375 | 88.9453125 | 89.296875 |
这样便可将(89.156,32.165) => (10101 10110,10111 11101)
这个时候就到了第二步骤——组码了,顾名思义,将第一步产生的编码组合起来为下一步产生字符串做准备,组码的方式是偶数位放置经度,奇数位放纬度(为什么要这么做呢,我猜可能是谷歌为了大家统一规范,仅此而已,其实奇偶数位互换也可以的),对于上面的经纬度编码后再组码如下:
经度:10101 11101 | 纬度:10111 11101 |
位置编码:11001 11011 11111 10011 |
对于上面的位置编码,为什么要这么编码呢,为什么要奇数位放纬度,偶数为方经度呢,我们看看下面这张图,用这张图模拟地图的经纬度,A点(-180,90),B点(180,90),C点(-180,-90),D点(180,-90);
A | B | ||||||||
010101 | 010111 | 011101 | 011111 | 110101 | 110111 | 111101 | 111111 | ||
010100 | 010110 | 011100 | 011110 | 110100 | 110110 | 111100 | 111110 | ||
010001 | 010011 | 011001 | 011011 | 110001 | 110011 | 111001 | 111011 | ||
010000 | 010010 | 011000 | 011010 | 110000 | 110010 | 111000 | 111010 | ||
000101 | 000111 | 001101 | 001111 | 100101 | 100111 | 101101 | 101111 | ||
000100 | 000110 | 001100 | 001110 | 100100 | 100110 | 101100 | 101110 | ||
000001 | 000011 | 001001 | 001011 | 100001 | 100011 | 101001 | 101011 | ||
000000 | 000010 | 001000 | 001010 | 100000 | 100010 | 101000 | 101010 | ||
C | D |
如图4所示,这样就可以将地图(经度-180~180,纬度-90~90)分为很多很多多的小块,每一个小块都有唯一的二进制编码,当位数达到一定的长度时就可以表示很小的一块区域,这不就可以根据二进制编码定位一个唯一的位置了吗,对于划分的进一步理解可看下面的图。
图 6
- 如何具体的应用到程序中
首先思考一下查看附近的人的流程:
- 用户点击查看附近的人按钮,首先获取到该用户的选位置信息(经纬度),传给服务器。
- 服务器收到数据之后对该用户的位置信息进行geohash计算,获得该用户的位置hash字符串。
- 对该用户的位置信息hash串进行缓存(缓存时间长短根据具体情况而定)。
- 根据该hash串选出附近的人。
- 对hash进行解码,计算出附近用户的位置,返回给用户。
首先看看geohash.class.php这个公共类库里面的基本方法:
[public]Geohash() 初始化hash映射表 | |
Geohash | [public]encode($lat,$long) 对经纬度进行编码 |
[public]decode() 对hash进行解码 |
如图7所示,显而易见这个类库里面有3个函数,第一个用来初始化hash映射表,其实就是把0123456789bcdefghjkmnpqrstuvwxyz字符串中的每个字符和它对应的二进制编码对应起来(左边补零至5位)。encode()是用来生成hash的,decode是用来解码hash得到hash对应的经纬度的。
mid | 坐标 |
(42.61233,-5.61234) | |
(-20.25689, 50.56897) | |
(10.11233, 57.21234) | |
(49.26343, -123.26895) | |
(0.00534, -179.56732) | |
(-30.55555, 0.28958) | |
(5.00001, -140.63422) | |
(42.61234, -5.61234) | |
(5.00001, -140.63422) |
图8的数据发送到服务器经过geohash计算得出下面的hash表:
mid | 坐标 |
100 | ezs42m34yfp_100 |
mh7uy8r5n6j_101 | |
t3b9tbuu84u_102 | |
c2b26bnk32b_103 | |
80021bgp45m_104 | |
k484ntdc58w_105 | |
8bgury1r1jm_106 | |
ezs42m34ygz_107 | |
8bgury1r1jm_108 |
计算出这些hash值,将hash值存入redis中,存入redis中之后,那么问题来了,如何去获取一个用户附近的用户呢?当redis数据库中有了一些用户的记录之后,来一个用户,我们先对其进行编码,然后根据该用户的位置hash从redis中选出该用户附近的hash,选取附近的hash这一步很简单,对于redis只需这么做:
<?php
$mid = 2014;
$level = 7; //获取的精度等级,数字越大,附近这个范围越小
$redis = Redis::init(); //假设这样获取到redis实例
$mykey = '8gur95yjmz'; //假设我的hash为这个
$redis->setex($mykey.'_'.$mid,$_SERVER['REQUEST_TIME'],86400); //这里设置缓存1天,具体情况具体对待
$search = substr($makey,0,$level);
$nearbys = $redis->keys("{$search}*");
?>
程序 1
上面的几句代码就可以选出我附近的人的hash,当然,其中的level来设置精度的,这个数字越大,附近的人范围越小,具体参考图10中的值,这个表中的值是从我的导师李伟(weickly)那里获取到的。要注意,这个地方搜索完之后要排除自己。还有一点要注意,就是在缓存时键名的最后一定要加上_{$mid},这样做可以避免多个用户在同一位置是互相覆盖的情况(就像图9中mid为106和108的用户),放在最后是为了不影响搜索。
1 | 2500000m |
2 | 630000m |
3 | 78000m |
4 | 20000m |
5 | 2400m |
6 | 610m |
7 | 76m |
8 | 19m |
9 | 2m |
100 | ezs42m34yfp_100 | (42.61233,-5.61234) | 3.3m |
107 | ezs42m34ygz_107 | (42.61234, -5.61234) | 2.2m |
109 | ezs42m34yzx_109 | (42.61236, -5.61234) | 0m |