iOS跑步软件开发-从无到有

2,198 阅读14分钟
原文链接: www.jianshu.com

前言

经过两个多月的开发与调试,全民星跑1.0.1终于上线了,首先要感谢曲总雷建民的技术支持.全民星跑作为一个以跑步计步为主要功能的软件,骚栋在开发过程中实在是遇到了不少的坑,这篇博客会分为加速仪计步和跑步计步两个模块来说明,不过有一点我想先声明,因为人力资源有限,所以可能在计步的逻辑上跟不上咕咚或者是Keep这些大厂,望各位看官见谅 . 😂 😂 😂

功能规划

一个App如何统计一个人的运动?这里主要有两种方式,一种是使用陀螺仪(或是加速仪)获取手机各个方向的加速度来统计用户的运动,另外一种就是通过GPS定位地图来统计用户的运动.在我的做的应用里面也是两种方案都采用了.接下来,我们分别讲解每一种方式是如何使用的.


陀螺仪简介以及原始数据获取

陀螺仪又叫角速度传感器,是不同于加速度计(G-sensor)的,他的测量物理量是偏转、倾斜时的转动角速度。在手机上,仅用加速度计没办法测量或重构出完整的3D动作,测不到转动的动作的,G-sensor只能检测轴向的线性动作。但陀螺仪则可以对转动、偏转的动作做很好的测量,这样就可以精确分析判断出使用者的实际动作。而后根据动作,可以对手机做相应的操作!


上面是概念部分.但是在说陀螺仪使用之前,我们要谈一谈两个框架,一个是CoreMotion框架,另外一个是HealthKit框架,好多刚搞跑步软件的童鞋都会有这样的疑问,这两个框架根据不同的回调方法获取到用户的运动信息,那么它们有什么不同呢?其实CoreMotion框架获取的是陀螺仪的加速度,然后通过加速度来计算用户的运动情况.这是实时更新的,而HealthKit框架是从苹果自带的健康软件中获取到数据,并不是实时的更新,这个就需要我们根据App的需求来酌情处理了.


对于HealthKit框架这里就不过啰嗦了.下面我们就来说明陀螺仪是如何使用的.我们使用的框架是CoreMotion这个iOS原生框架,那么这个框架在实际开发中是如何使用的呢?

我们先导入在需要的地方导入CoreMotion这框架.

#import <CoreMotion/CoreMotion.h>

在初始化阶段,不管你要获取的是什么数据,首先需要做的就是创建一个CMMotionManager对象.

motionManager = [[CMMotionManager alloc] init]; 

所有的操作都会由这个manager接管。后面的初始化操作相当直观,

if (!motionManager.accelerometerAvailable) {  
// fail code // 检查传感器到底在设备上是否可用  
}  
motionManager.accelerometerUpdateInterval = 0.01; // 告诉manager,更新频率是100Hz  
[motionManager startAccelerometerUpdates]; // 开始更新,后台线程开始运行。这是pull方式。 

我在项目中是使用block回调的方式来获取数据的.代码如下所示.

[motionManager startAccelerometerUpdatesToQueue:[NSOperationQueue currentQueue] withHandler:^(CMAccelerometerData *latestAcc, NSError *error)  
{  
// Your code here  
}]; 

接下来就是获取x,y,z轴三个方法的加速度数据了。如下所示.

                //三个方向加速度值
                double x = accelerometerData.acceleration.x;
                double y = accelerometerData.acceleration.y;
                double z = accelerometerData.acceleration.z;

这样我们就拿到了x,y,z轴三个方法加速度的原始数据了.

陀螺仪的数据处理

那么,拿到数据之后我们该如何处理呢?获取原始数据的操作很简单,但是我们还需要做最重要的部分,那就是处理原始数据,有的童鞋就会问,为什么要处理这些数据每一次获取数据,难道手机不都是在动吗?实际上确实如此,但是我们需要的是最大程度上来估算用户的运动步数,如果一个用户在不断晃动手机,那么我们还需要把这种数据计算进来吗?这时候就需要我们把这种数据给过滤掉,来减少数据的误差.提高数据的精确性.

首先我们创建一个数据Model.Model的属性有震动幅度的系数(通过x,y,z轴三个方法加速度来获取,),Model对象的获取时间.Model获取时间的格式化时间.Model获取的位置.代码如下所示.

#import <Foundation/Foundation.h>

@interface StepModel : NSObject

@property(nonatomic,strong) NSDate *date;

@property(nonatomic,assign) int record_no;

@property(nonatomic, strong) NSString *record_time;

//g是一个震动幅度的系数,通过一定的判断条件来判断是否计做一步
@property(nonatomic,assign) double g;

@end

其他的几个参数都好理解,最关键的就是这个震动幅度系数了,说白了 ,它是存储了手机x,y,z轴三种方向加速度的总和.具体我们会怎么计算呢?如下所示.

                double g = sqrt(pow(x, 2) + pow(y, 2) + pow(z, 2)) - 1;
                StepModel *stepsAll = [[StepModel alloc] init];
                stepsAll.g = g;
                      .    
                      . 
                      . 
                // 加速度传感器采集的原始数组
                [arrAll addObject:stepsAll];            

然后,我们会把每一个Model都放在原始数组里面.因为陀螺仪我们设置的频率一般会很快,所以,我们要在必要的时间内清理数据,防止出现内存暴增.大约十条数据左右我们就可以做一下数据出来,然后清除原始数据即可.

                if (arrAll.count == 10) {
                    // 步数缓存数组
                    NSMutableArray *arrBuffer = [[NSMutableArray alloc] init];
                    arrBuffer = [arrAll copy];
                    [arrAll removeAllObjects];
                }

接下来我们就处理第一步数据,根据震动幅度系数来判断是否是合适的数据,如果震动幅度系数相比于前后两个数据的震动系数过大或者过小,那样这样的数据就不是我们所需要的数据.如下图所示.这种判断的依据是一个人很少会在1秒之后又加速又减速,你当你是Car呢!🌚 🌚 🌚,具体代码如下所示.

                    // 踩点数组
                    NSMutableArray *arrCaiDian = [[NSMutableArray alloc] init];
                    
                    //遍历步数缓存数组
                    for (int i = 1; i < arrBuffer.count - 2; i++) {
                        //如果数组个数大于3,继续,否则跳出循环,用连续的三个点,要判断其振幅是否一样,如果一样,然并卵
                        if (![arrBuffer objectAtIndex:i-1] || ![arrBuffer objectAtIndex:i] || ![arrBuffer objectAtIndex:i+1])
                        {
                            continue;
                        }
                        StepModel *bufferPrevious = (StepModel *)[arrBuffer objectAtIndex:i-1];
                        StepModel *bufferCurrent = (StepModel *)[arrBuffer objectAtIndex:i];
                        StepModel *bufferNext = (StepModel *)[arrBuffer objectAtIndex:i+1];
                        //控制震动幅度,,,,,,根据震动幅度让其加入踩点数组,
                        if (bufferCurrent.g < -0.12 && bufferCurrent.g < bufferPrevious.g && bufferCurrent.g < bufferNext.g) {
                            [arrCaiDian addObject:bufferCurrent];
                        }
                    }
                    

通过,加速度处理完的数据难道就没有问题了吗?假定加速度合适,用户用手快速晃动手机,这时候也是会有误差数据的产生,所以这时候我们还是需要根据一个值来判断arrCaiDian数组中的数据是否合理.这个属性就是时间,时间从哪里来呢?时间当然从Model中取到了.

具体的操作步骤是我们先遍历arrCaiDian这个数据,然后先判断是否是第一个数据,如果是我们存储它的时间属性,如果不是,我们直接比较当前Model和前一个Model的时间差,看是否在允许范围之内.如果在允许范围之内,那么我们就认为当前这个数据是一个有效的数据.具体代码如下所示.

                    for (int j = 0; j < arrCaiDian.count; j++) {
                        StepModel *caidianCurrent = (StepModel *)[arrCaiDian objectAtIndex:j];

                        //如果之前的步数为0,则重新开始记录
                        if (j == 0) {
                            //上次记录的时间
                            lastDate = caidianCurrent.date;
                        }else {
                            int intervalCaidian = [caidianCurrent.date timeIntervalSinceDate:lastDate] * 1000;
                            // 步行最大每秒2.5步,跑步最大每秒3.5步,超过此范围,数据有可能丢失
                            int min = 259;
                            if (intervalCaidian >= min) {
                                if (motionManager.isAccelerometerActive) {
                                    //存一下时间
                                    lastDate = caidianCurrent.date;
                                }
                            }
                        }
                    }

经过这两步的数据处理,基本上数据的处理就完成了,接下来我们就直接在if(motionManager.isAccelerometerActive) {}这里面计算用户的步数即可.代码如下所示.self.step就是我们需要的步数.

// 计步器开始计步时间(秒)
#define ACCELERO_START_TIME 2

// 计步器开始计步步数(步)
#define ACCELERO_START_STEP 1
                                    if (intervalCaidian >= ACCELERO_START_TIME * 1000) {// 计步器开始计步时间(秒)
                                        self.startStep = 0;
                                    }
                                    
                                    if (self.startStep < ACCELERO_START_STEP) {//计步器开始计步步数 (步)
                                        
                                        self.startStep ++;
                                        break;
                                    }
                                    else if (self.startStep == ACCELERO_START_STEP) {
                                        self.startStep ++;
                                        // 计步器开始步数
                                        // 运动步数(总计)
                                        self.step = self.step + self.startStep;
                                    }
                                    else {
                                        self.step ++;
                                    }

好了,基本上陀螺仪的开发就到这里了,Demo我会放在文章最后,各位看官去下载就好.

GPS定位开发运动

上面陀螺仪开发运动主要适用于室内跑步机,或者日常走路情况,当用户需要看到他们的运动轨迹的时候,这时候我们就不能使用陀螺仪进行开发了,而是使用GPS定位+地图轨迹绘制来进行开发.这里我是基于高德地图进行开发的,这里是需要注意.具体如何集成高德地图这里就不过多啰嗦了.下面我们就几个问题来探讨一下如何使用高德地图来实时绘制用户的运动轨迹.


如何处理杂乱的运动轨迹?

其实这个问题说白了就是运动轨迹的容错处理,现在市面上的大厂App一共有两种方案,一种是轨迹绘制时间短,用户运动轨迹比较具体,但是如果信号不好,那么会造成用户运动轨迹线条杂乱;另外一种方案就是绘制时间长,这样线条就比较笔直,用户体验比较好,但是用户轨迹就没有那么具体了.我测试多种方案之后,决定偏向于第二种进行开发运动轨迹的绘制.

由于我使用的是高德地图,我们都知道高德地图是直接封装了苹果的原生地图.所以,很多方法也类似.我们先对地图和定位对象进行初始化.代码如下所示.具体属性什么的我就不过多啰嗦了.

-(MAMapView *)mapView{

    if (_mapView == nil) {
        
        ///初始化地图
        _mapView = [[MAMapView alloc] initWithFrame:self.view.bounds];
        _mapView.desiredAccuracy = kCLLocationAccuracyBest;
        _mapView.distanceFilter = 1.0f;
        _mapView.showsUserLocation = YES;
        _mapView.userTrackingMode = MAUserTrackingModeFollow;//地图跟着位置移动
        MAUserLocationRepresentation *r = [[MAUserLocationRepresentation alloc] init];
        r.showsAccuracyRing = NO;///精度圈是否显示,默认YES
        r.showsHeadingIndicator = YES;
        [_mapView updateUserLocationRepresentation:r];
        _mapView.zoomLevel = 16;
        _mapView.maxZoomLevel = 18;
        //不显示比例尺
        _mapView.showsScale =NO;
        //不显示罗盘
        _mapView.showsCompass = NO;    
        _mapView.delegate  = self;
    }
    
    return _mapView;
}
-(void)startRun{
    self.locationManager = [[AMapLocationManager alloc] init];
    self.locationManager.delegate = self;
    self.locationManager.distanceFilter = 10;//设置移动精度(单位:米)
    self.locationManager.locationTimeout = 2;//定位时间
    self.locationManager.allowsBackgroundLocationUpdates = YES;//开启后台定位
    [self.locationManager setLocatingWithReGeocode:YES];
    [self.locationManager startUpdatingLocation];
    self.distance = 0;
    self.startTime = [NSDate date];
}

当我们使用[self.locationManager startUpdatingLocation];就可以让下面的回调方法不断的回调,然后获取到我们的原始数据.参数location就是我们需要的位置信息原始数据.

- (void)amapLocationManager:(AMapLocationManager *)manager didUpdateLocation:(CLLocation *)location reGeocode:(AMapLocationReGeocode *)reGeocode{}

接下来我们就需要处理数据了,当时一开始做的时候我想到了几个因素,一个是GPS信号强弱,另外一个是两点之间的速度.但是后来发现在iOS这边使用GPS信号来做判断效果并不是太好,所以就去掉了.现在就是通过了两点之间的速度来进行判断是否是合理的点.

定位原始数据处理
首先我们先创建一个Model,用来存储当前点的时间,位置两个信息.代码如下所示.

#import <Foundation/Foundation.h>
#import <AMapFoundationKit/AMapFoundationKit.h>

@interface RunLocationModel : NSObject

//RunLocationModel是跑步过程中每一条记录的Model
@property(nonatomic,assign)CLLocationCoordinate2D location;
@property(nonatomic,strong)NSDate *time;//每一次记录的时间点

@end

接下来,我们就处理我们的数据了.在实际过程中遇到这么一个坑,那就是定位的第一个位置是在大西洋东海岸刚果附近.这是怎么造成的?我分析主要是由于定位还未来及打开,或者说定位的初始点位就是在那里.我们做的就是要把这个点去除即可.我们从第二个点进行取值,这样就不会造成这样的问题了.因为是在开启一瞬间,所以用户也是感觉不到的.符合我们的用户体验性.

那么数据处理,我自己写了一个方法,就是根据前一个有效点(第一个有效的定位点就直接拿了第一个原始数据)和新的定位点来通过距离和时间计算速度,比较速度的合理性即可.联合上面的定位回调方法代码如下.

- (void)amapLocationManager:(AMapLocationManager *)manager didUpdateLocation:(CLLocation *)location reGeocode:(AMapLocationReGeocode *)reGeocode{
    
    RunLocationModel *locationModel = [[RunLocationModel alloc]init];
    locationModel.location = location.coordinate;
    locationModel.time = [NSDate date];
    RunLocationModel *lastModel =locationModel;
    RunLocationModel *lastButOneModel =_finishLocationArray.lastObject;
    [self distanceWithLocation:lastModel andLastButOneModel:lastButOneModel];
}

//计算距离,估算误差值
-(void)distanceWithLocation:(RunLocationModel *)lastModel andLastButOneModel:(RunLocationModel *)lastButOneModel{

        MAMapPoint point1 = MAMapPointForCoordinate(lastModel.location);
        MAMapPoint point2 = MAMapPointForCoordinate(lastButOneModel.location);
        //2.计算距离
        CLLocationDistance newdistance = MAMetersBetweenMapPoints(point1,point2);
    
        //估算两者之间的时间差,单位 秒
        NSTimeInterval secondsBetweenDates= [lastModel.time timeIntervalSinceDate:lastButOneModel.time];
        
        //世界飞人9.97秒百米,当超过这个速度,即为误差值,可能是GPS不准
        if ((float)newdistance/secondsBetweenDates < (float)100/9.74) {
            
            [self.finishLocationArray addObject:lastModel];
            [self mapAddCommonPolyline];//绘制运动轨迹
            self.distance  = self.distance +newdistance;
        }
}

上面,self.distance就是用户的运动距离了,那么运动轨迹我们该如何搞呢,难道说我们每回调一次我们都需要绘制一条运动轨迹?NONONO,如果是那样的话,我们的运动轨迹就会非常的凌乱的.所以我们的处理原则,我们判断地图上绘制的最后一个点和从finishLocationArray中取的点是否在距离上合适,如果合适,那么我们就进行绘制,如果不合适,我们就等待下一个点的出现,然后再进行判断.当然了,找点就少不了遍历finishLocationArray数组,我们需要从绘制的最后一个点进行遍历,这样会大大减少遍历的次数,减少程序的内存损耗.代码如下所示.

-(void)mapAddCommonPolyline{

    for (int i = _endIndexPath; i<self.finishLocationArray.count; i++) {
        
        RunLocationModel *newlocation = self.finishLocationArray[i];
        MAMapPoint point1 = MAMapPointForCoordinate(newlocation.location);
        MAMapPoint point2 = MAMapPointForCoordinate(_endLocation.location);

        //2.计算距离
        CLLocationDistance newDistance = MAMetersBetweenMapPoints(point1,point2);
        if (newDistance>10) {

            CLLocationCoordinate2D commonPolylineCoords[2];
            commonPolylineCoords[0] = newlocation.location;
            commonPolylineCoords[1] = _endLocation.location;
            
            MAPolyline *commonPolyline = [MAPolyline polylineWithCoordinates:commonPolylineCoords count:2];
            
            [_lineArray addObject:commonPolyline];
            [self.mapView addOverlay: commonPolyline];
            
            _endIndexPath = i;
            _endLocation = newlocation;
        }
    }
}

这样,用户的运动轨迹绘制就基本完成了.

如何实现GPS信号的强弱的展示?

GPS信号是没有直接数据的展示的.我们需要从回调方法的location参数中拿到horizontalAccuracy属性和verticalAccuracy属性的值,这两个值就是判断精度圈大小的,如果GPS信号弱的话,那么精度圈就会很大,horizontalAccuracy属性和verticalAccuracy这两个值就会很大.相反,如果GPS信号强的话,那么两者的值就会很小.具体代码如下所示.

typedef enum : NSUInteger {
    strengthGradeBest  = 1,//信号最好 可精确到0-20米
    strengthGradeBetter,//信号强 可精确到20-100米
    strengthGradeAverage,//信号弱 可精确到100-200米
    strengthGradeBad,//信号很弱 ,200米开外
} strengthGrade;
- (void)amapLocationManager:(AMapLocationManager *)manager didUpdateLocation:(CLLocation *)location reGeocode:(AMapLocationReGeocode *)reGeocode{
   
    locationModel.gpsStrength = [self gpsStrengthWithLocation:location];
}

#pragma mark ---GPS信号强弱---
-(strengthGrade)gpsStrengthWithLocation:(CLLocation *)location{
    if (location.horizontalAccuracy>200 &&location.verticalAccuracy >200) {
        
        return strengthGradeBad;
    }
    if (location.horizontalAccuracy>100 &&location.verticalAccuracy >100&&location.horizontalAccuracy<200 &&location.verticalAccuracy <200) {
        
        return strengthGradeAverage;
    }
    if (location.horizontalAccuracy>20 &&location.verticalAccuracy >20&&location.horizontalAccuracy<100 &&location.verticalAccuracy <100) {
        
        return strengthGradeBetter;
    }
    if (location.horizontalAccuracy<20 &&location.verticalAccuracy <20) {
        
        return strengthGradeBest;
    }
    
    return strengthGradeBad;
}

如何实现用户方向的展示?

跑步软件都会有用户方向的展示,那么这是怎么做到的呢?这时候,我们需要另外的一个回调方法.那就是-(void)mapView:(MAMapView *)mapView didUpdateUserLocation:(MAUserLocation *)userLocation updatingLocation:(BOOL)updatingLocation,通过这个方法,我们获取当前的heading信息,然后方向图标通过heading信息旋转对应的角度即可.代码如下所示.

//根据头部信息显示方向
-(void)mapView:(MAMapView *)mapView didUpdateUserLocation:(MAUserLocation *)userLocation updatingLocation:(BOOL)updatingLocation{

    if(nil == userLocation || nil == userLocation.heading
       || userLocation.heading.headingAccuracy < 0) {
        return;
    }
    
    CLLocationDirection  theHeading = userLocation.heading.magneticHeading;
    
    float direction = theHeading;
    
    if(nil != _myLocationAnnotationView) {
        if (direction > 180)
        {
            direction = 360 - direction;
        }
        else
        {
            direction = 0 - direction;
        }
        _myLocationAnnotationView.image = [self.myLocationImage imageRotatedByDegrees:-direction];
    }

}
总结

本来这篇文章打算在8月初就写的,但是由于近来一直在做Java项目,所以一直没有时间,直到今天终于抽时间写完了这篇跑步软件项目总结,希望大家喜欢,如果有什么问题或者疑问,欢迎和骚栋一起探讨.最后附上Demo.Demo非本人撰写,乃雷建民所有,如有侵权,请通知骚栋,立马删除,谢谢大家的一路陪伴.

Demo传送门