开源项目Running Life 源码分析(一)

项目简介

基于HeathKit和高德地图开发健康跑步App,实现实时绘画运动轨迹、健康数据管理功能。
做这个项目出于两个原因:
1、喜欢跑步(也为了减减肥<( ̄3 ̄)>);
2、喜欢运用自己的知识实际,里面有我自己写的一些开源组件( 技术有限,设计得不好的地方,大家多多指导);

运行效果如下:

图片标题
图片标题

项目目录

title
title

Config目录:接口配置文件、宏定义和头文件配置文件;
AppINit目录:关于App的启动设置,如第三方SDK初始化、界面初始化、HeathKit初始化配置;
Module目录:业务模块,由以下这几个模块组成:公共模块、跑步模块、记录模块、个人模块、设置模块、登陆注册模块;
Resource目录:图片资源和字体资源;
RunKit目录:一些类的拓展、工具类、网络层方案、持久化存储层方案;
Vendor目录:一些不支持Cocoapod第三方库;
Pod:支持Cocoapod第三方库;

业务层架构

title
title

MVVM架构(使用Facebook的KVOController实现view和viewModel的绑定,项目往ReactCocoa迁移中)

viewModel如何设计?

viewModel负责从原始数据源获取原始数据,运用对应的数据处理逻辑,转化为view层显示的数据。他不引入UIKit相关类,所以他与UI无关,也方便我们进行单元测试。实际上,它就是一层function core,理想上对于相同的输入会导出相同的结果。所以viewmodel得设计主要包含三部分内容:输入、输出、命令,简化成函数表达就是y = f(x),f函数指的是命令,x是输入,y就是输出,这里要注意输出对外界来说只是一个只读属性。示例如下:

@interface ResultViewModel : NSObject

/**
 *  跑步距离
 */
@property (nonatomic, copy, readonly) NSString *distanceLabelText;

/**
 *  跑步时间
 */
@property (nonatomic, copy, readonly) NSString *timeLabelText;

/**
 *  跑步步数
 */
@property (nonatomic, copy, readonly) NSString *paceLabelText;

/**
 *  卡路里
 */
@property (nonatomic, copy, readonly) NSString *kcalLableText;

/**
 *  消耗鸡腿数
 */
@property (nonatomic, copy, readonly) NSString *countLabelText;

/**
 *  运动轨迹(不同颜色)
 */
@property (nonatomic, copy, readonly) NSArray *colorSegmentArray;

/**
 *  地图显示区域
 */
@property (nonatomic, assign, readonly) MKCoordinateRegion region;

/**
 *  跑步排名
 */
@property (nonatomic, copy, readonly) NSString *rank;


/**
 *  网络失败
 */
@property (nonatomic, strong, readonly) NSNumber *netFail;

/**
 *  构造器
 *
 *  @param run 跑步记录
 *
 *  @return 
 */
- (instancetype)initWithRunModel:(Run *)run;

/**
 *  上传跑步记录并获取排名
 */
- (void)postRunRecordToServerAndGetRank;

/**
 *  仅仅获取获取跑步排名
 */
- (void)getRankData;


@end

viewModel与view如何绑定?

绑定的目的就是为了解决view与viewModel通信的问题。MVVM天然最好的绑定机制就是Facebook的ReactCocoa,它是函数式响应式编程思想的一个体现,它的核心就是响应数据的变化、统一异步编程模型,绑定的具体做法就是view层通过订阅viewModel上面的信号,先模拟处理一遍,这里模拟的意思是先从脑海里过一遍逻辑,实际不响应,当有信号发过来的时候才实际触发。
但是他需要一定的学习成本,学习成本较大,本人也在不断学习当中,所有我们换种方式来实现这种响应机制。想一下,cocoa中是不是有提供这种监听-响应的机制,没错,就是KVO,但是原生KVO写起来会恶心死人,所有我们可以借助Facebook提供一个KVO框架(kvoController)来实现优雅的绑定。(Facebook真是为了iOS的开发做出很多贡献,开源了那么多好用的工具)。
绑定方式就是view层 kvo viewModel层的readonly属性,一旦属性变化就触发响应的处理逻辑。示例如下:

[self.KVOController observe:self.viewModel keyPath:@"rank" options:NSKeyValueObservingOptionNew block:^(id observer, id object, NSDictionary *change) {
        if (self.viewModel.rank) {
            self.recordCardView.rankLabel.text = self.viewModel.rank;
            
            [UIApplication sharedApplication].networkActivityIndicatorVisible = NO;
        }
    }];

功能实现

项目搭好条条框框,现在来分析具体的功能实现。本项目有两个功能,一个是跑步,另外一个就是记录,每个大功能点下又分几个小功能点,功能的示意图如下:


title
title

跑步

这里主要分析跑步过程的具体逻辑,界面如下:


title
title

源码在这个文件:"NewRunViewModel.m"

跑步数据源

跑步数据来源定位,这里定位SDK选择高德SDK,虽然原生也是高德地图,但经过测试发现原生的定位很不准,我也不知道具体原因是什么。
定义一个定位管理器,设置好相应的配置参数,因为为了跑步数据的精确度,所以将定位的准确度设置为最好,调用 [self.locationManager startUpdatingLocation]开启持续定位,具体实现如下:

   -(AMapLocationManager *)locationManager{
    if (!_locationManager) {
        _locationManager = [[AMapLocationManager alloc] init];
        
        _locationManager.delegate = self;
        
        _locationManager.desiredAccuracy = kCLLocationAccuracyBest;
        
        _locationManager.distanceFilter = kCLDistanceFilterNone;
        
        //设置允许后台定位参数,保持不会被系统挂起
        [_locationManager setPausesLocationUpdatesAutomatically:NO];
        
        if([[[UIDevice currentDevice] systemVersion] floatValue]>9.0){
            [_locationManager setAllowsBackgroundLocationUpdates:YES];//iOS9(含)以上系统需设置    
        }
        
    }
    return _locationManager;
}

定位成功后会不断的回调AMapLocationManagerDelegate的- (void)amapLocationManager:(AMapLocationManager *)manager didUpdateLocation:(CLLocation *)location方法,并不是所有定位数据都是有效,需要对数据进行过滤,过滤的依据就是horizontalAccuracy和时间偏差。horizontalAccuracy表示水平准确度,这么理解,它是以定位点为圆心的半径,返回的值越小,证明准确度越好,如果是负数,则表示corelocation定位失败,我们知道GPS信号会受地域的影响,有时强,有时弱,设置30是一个中和的做法,因为我们不能保证每次定位回来的数据都是绝对精确,如果设置得太小,可能过滤得到的数据很少,太大就会误差太大。howRecent用于计算定位结果与当前时间偏差,如果偏差超过2秒就过滤,这个2秒也是一个中和值。过滤完数据就可以计算跑步的距离,保存在_distance这个全局变量中。

- (void)amapLocationManager:(AMapLocationManager *)manager didUpdateLocation:(CLLocation *)location {
        if (location.horizontalAccuracy < 30) {
            NSDate *eventDate = location.timestamp;
            
            NSTimeInterval howRecent = [eventDate timeIntervalSinceNow];
            
            if (fabs(howRecent) < 2.0 ) {
                if (self.locations.count > 0) {
                    _distance += [location distanceFromLocation:self.locations.lastObject];
                }
                
                [self.locations addObject:location];
            }
        }
}

获取数据之后怎么实时刷新UI呢?
我的做法是在NewRunViewController开启一个定时器,时间间隔是1s,每隔1秒往VM传运动时间,运动时间相当于函数的自变量,经过VM处理后,它会给C发数据改变的信号,信号相对于函数的因变量。实现如下:

self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0f
                                                      target:self
                                                    selector:@selector(eachSecond:)
                                                    userInfo:nil
                                                     repeats:YES];
                                                     
/**
 *  运动计数器回调
 *
 *  @param timer
 */
- (void)eachSecond:(NSTimer*)timer {
    _seconds++;
    
    self.viewModel.duration = _seconds;
}

//下面是绑定的代码,监听VM传过来的信号,有变化就刷新UI
    [self.KVOController observe:self.viewModel keyPath:@"runDataChange" options:NSKeyValueObservingOptionOld block:^(id observer, id object, NSDictionary *change) {
        if ([self.viewModel.runDataChange boolValue]) {
            [_boardView configureViewWithViewModel:self.viewModel.currentRunData];
        }
    }];

智能判断跑步状态

通过CMMotionManager(是苹果的运动管理器框架,可以获取设备加速计、陀螺仪的即时数据)来智能判断跑步状态,以决定是否继续记录。这个功能点体现在当用户运动幅度变小的时候,小到一定程度的时候,app就判断用户处于休息阶段,当这个阶段持续超过8秒就暂停跑步记录,进入以下状态:


title
title

但用户又开始运动的时候,运动幅度到达一定程度的时候,有开启跑步状态。
它的实现原理是通过陀螺仪来实现(暂时还没适配旧版本手机,因为iphone5以下没有陀螺仪),一般我们跑步的时候,手机拿在手上或者放在裤袋里,所以y轴和z轴偏移最大也最频繁,所以通过判断y轴和z轴的加速度,如果他们的加速度小于2,则用户不处于跑步状态,这个2的值是自己试出来- -,如果大家有更好的依据欢迎到github issue我。具体实现如下:

    NSOperationQueue* queue = [[NSOperationQueue alloc]init];
    
    /**
     *  陀螺仪是否可用
     */
    if (self.motionManger.gyroAvailable) {
        
        [self.motionManger startGyroUpdatesToQueue:queue withHandler:^(CMGyroData * _Nullable gyroData, NSError * _Nullable error) {
            
            CGFloat y = gyroData.rotationRate.y;
            
            CGFloat z = gyroData.rotationRate.z;
            
            if (fabs(y)>2||fabs(z)>2) {
                
                _stopCount = 0;
                
                if(![self.isRunning boolValue]) self.isRunning = @YES;
                
            }else{
                
                _stopCount++;
                
                if (_stopCount > 8) {
                    
                    if([self.isRunning boolValue]) self.isRunning = @NO;
                }
            }
        }];
    }else{
        NSLog(@"陀螺仪不可用");
    }

运动轨迹

我将运动轨迹的绘画逻辑分离到MapViewController中,里面也有一个定位管理对象,定位成功也会不断的回调,相比NewRunController回调的处理,这里的处理多了对地图的处理,通过两个坐标确定一条线,并把线添加到地图上,代码如下:

- (void)amapLocationManager:(AMapLocationManager *)manager didUpdateLocation:(CLLocation *)location {
    
    if (location.horizontalAccuracy < 30) {
        _firstLocate = NO;
        NSDate *eventDate = location.timestamp;
        
        NSTimeInterval howRecent = [eventDate timeIntervalSinceNow];
        if (fabs(howRecent) < 2.0 && location.horizontalAccuracy < 30) {
            
            if (self.locations.count > 0) {
                
                CLLocationCoordinate2D coords[2];
                coords[0] = ((CLLocation *)self.locations.lastObject).coordinate;
                coords[1] = location.coordinate;
                
                MKCoordinateRegion region =
                MKCoordinateRegionMakeWithDistance(location.coordinate, 500, 500);
                [self.myMapView setRegion:region animated:YES];
                
                [self.myMapView addOverlay:[MKPolyline polylineWithCoordinates:coords count:2]];
            }
            
            [self.locations addObject:location];
        }
    }else{
        if (_firstLocate) {
            MKCoordinateRegion region =
            MKCoordinateRegionMakeWithDistance(location.coordinate, 500, 500);
            [self.myMapView setRegion:region animated:YES];
            _firstLocate = NO;
        }
        
    }
    
}

通过mapView的一个delegate方法设置轨迹的相关属性

- (MKOverlayRenderer *)mapView:(MKMapView *)mapView rendererForOverlay:(id < MKOverlay >)overlay {
    if ([overlay isKindOfClass:[MKPolyline class]]) {
        MKPolyline *polyLine = (MKPolyline *)overlay;
        MKPolylineRenderer *aRenderer = [[MKPolylineRenderer alloc] initWithPolyline:polyLine];
        aRenderer.strokeColor = UIColorFromRGB(0x43B5FE);
        aRenderer.lineWidth = 3;
        return aRenderer;
    }
    return nil;
}

保存跑步记录

数据保存在本地数据库中,出于学习的目的,我这边持久层选择了CoreData,它是苹果推荐的持久层存储框架,底层是sqlite,做了面向对象的封装。上手有点难度,需要一定的学习成本,关于CoreData的具体使用,大家自行Google或baidu,在这里就不展开将。我们通过.xcdatamodeld可以十分方便地创建我们的实体对象,该项目主要有两个实体对象:跑步记录、实时位置数据,两者是有关联的,一次跑步数据关联着一系列实时位置数据。


title
title
title
title

当时在设计数据存储方案的时候,遇到这样一个问题:
如果用户没登录就发起跑步,跑步结束后数据插入到数据库,这些数据是没有用户认领的。当用户登陆的时候,这部分无用户态的数据该如何处理。当用户退出登陆的时候,原有记录的数据是保存还是清除?保存又该如何处理呢?
后来我参考了Nike的Running的处理逻辑,一旦登陆用户,这些无用户态的数据就被登陆用户认领,退出登录数据保存在本地。
既然处理逻辑想好了,这么数据存储方案要如何让设计呢?
大家可以看我基于CoreData封装的CoreDataManager:

@interface CoreDataManager : NSObject

/**
 *  临时管理上下文对象
 */
@property (readonly, strong, nonatomic) NSManagedObjectContext *tempManagedObjectContext;

/**
 *  管理上下文对象
 */
@property (readonly, strong, nonatomic) NSManagedObjectContext *managedObjectContext;

/**
 *  全局管理类
 *
 *  @return 
 */
+ (CoreDataManager *)shareManager;

/**
 *  切换数据库,如果没有就新建
 *
 *  @param name 数据库名字
 */
- (void)switchToDatabase:(NSString *)name;

/**
 *  切换到临时数据库
 */
- (void)switchToTempDatabase;

/**
 *  保存上下文对象
 */
- (void)saveContext;

/**
 *  保存临时上下文对象
 */
- (void)saveTempContext;

@end

为了让大家更好地了解这个方案,我普及一点点CoreData的知识,CoreData框架包含三层内容:
1、底层数据库;
2、持久化存储助手,作为业务层与持久层的协调对象,负责从数据库获取数据并返回适合的数据给业务层:
3、管理上下文对象,参与具体的业务交互;
一个数据库对应一个上下文对象,所以我的方案设计了两个上下文对象,一个对应着存放临时数据的数据库,另一个对应存放用户数据的数据。tempManagedObjectContext主要作用是为了获取临时数据用于合并数据库,平时业务交互直接用managedObjectContext就行,因为底层会根据当前活跃的数据库切换相应的上下文对象。切换数据库的实现原理:

    DBNAME = name;
    _managedObjectContext = nil;
    _persistentStoreCoordinator = nil;

用一个static变量存放数据库的名字,数据库的命名规则是以用户的账户名的MD5哈希值作为用户的数据库名。因为切换了数据库,上下文对象改变了,持久化存储助手也改变,因为两个都是懒加载,置为nil,到时会重新调用他们的getter方法,getter方法内部根据对应的DBNAME创建相应的对象。
切换数据库的应用场景有三个:app初始化的时候、登陆的时候、退出登陆的时候。

跑步结果

效果如下:

title
title

这边有个功能点就是根据不同速度绘画不同颜色的运动轨迹。
实现原理:创建一个MKPolyline(地图轨迹类)的派生类MultiColorPolyline,该类多了一个属性color,用来记录当前轨迹的颜色。将普通的轨迹转化为带颜色的轨迹实现逻辑放在MathController这个转换的工具类中,具体代码如下:

+ (NSArray *)colorSegmentsForLocations:(NSArray *)locations {
    NSMutableArray *speeds = [NSMutableArray array];
    double slowestSpeed = DBL_MAX;
    double fastestSpeed = 0.0;
    
    //获取最慢速度和最快速度
    for (int i = 1; i < locations.count; i++) {
        Location *firstLoc = [locations objectAtIndex:(i-1)];
        Location *secondLoc = [locations objectAtIndex:i];
        
        CLLocation *firstLocCL = [[CLLocation alloc] initWithLatitude:firstLoc.latitude.doubleValue longitude:firstLoc.longtitude.doubleValue];
        CLLocation *secondLocCL = [[CLLocation alloc] initWithLatitude:secondLoc.latitude.doubleValue longitude:secondLoc.longtitude.doubleValue];
        
        double distance = [secondLocCL distanceFromLocation:firstLocCL];
        double time = [secondLoc.timestamp timeIntervalSinceDate:firstLoc.timestamp];
        double speed = distance/time;
        
        slowestSpeed = speed < slowestSpeed ? speed : slowestSpeed;
        fastestSpeed = speed > fastestSpeed ? speed : fastestSpeed;
        
        [speeds addObject:@(speed)];
        
    }
    
    double midSpeed = (slowestSpeed + fastestSpeed)/2;
    
    // 慢的用红色
    CGFloat s_red = 139/255.0f;
    CGFloat s_green = 254/255.0f;
    CGFloat s_blue = 132/255.0f;
    
    // 不快不慢的用黄色
    CGFloat m_red = 101/255.0f;
    CGFloat m_green = 254/255.0f;
    CGFloat m_blue = 249/255.0f;
    
    // 快的用绿色
    CGFloat f_red = 67/255.0f;
    CGFloat f_green = 181/255.0f;
    CGFloat f_blue = 254/255.0f;
    
    NSMutableArray *colorSegments = [NSMutableArray array];
    
    for (int i = 1; i < locations.count; i++) {
        Location* firstLoc = [locations objectAtIndex:(i-1)];
        Location* secondLoc = [locations objectAtIndex:i];
        
        CLLocationCoordinate2D coords[2];
        coords[0].latitude = firstLoc.latitude.doubleValue;
        coords[0].longitude = firstLoc.longtitude.doubleValue;
        
        coords[1].latitude = secondLoc.latitude.doubleValue;
        coords[1].longitude = secondLoc.longtitude.doubleValue;
        
        NSNumber * speed = [speeds objectAtIndex:(i-1)];
        UIColor * color = [UIColor blackColor];
        

        if (speed.doubleValue < midSpeed) {
            double ratio = (speed.doubleValue - slowestSpeed) / (midSpeed - slowestSpeed);
            CGFloat red = s_red + ratio * (m_red - s_red);
            CGFloat green = s_green + ratio * (m_green - s_green);
            CGFloat blue = s_blue + ratio * (m_blue - s_blue);
            color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0f];
            

        } else {
            double ratio = (speed.doubleValue - midSpeed) / (fastestSpeed - midSpeed);
            CGFloat red = m_red + ratio * (f_red - m_red);
            CGFloat green = m_green + ratio * (f_green - m_green);
            CGFloat blue = m_blue + ratio * (f_blue - m_blue);
            color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0f];
        }
        
        MultiColorPolyline *segment = [MultiColorPolyline polylineWithCoordinates:coords count:2];
        segment.color = color;
        
        [colorSegments addObject:segment];
    }
    
    return colorSegments;
}

遍历获取最大速度和最小速度,根据速度与最大速度和最小速度比较,设置一个比例,根据比例调配相应的颜色,颜色的计算算法如上,就不展开讲了。

小结

今天分析了大体框架和跑步模块一些细节的实现,关于记录模块的分析我打算放在第二篇来分析,先做下预告,内容主要有三个:
实现view的复用机制解决内存暴涨问题、贝塞尔曲线与动画实现一个优雅的数据展示界面、HeathKit框架的使用。
项目地址:github.com/caixindong/Running-Life---iOS,有问题欢迎大家提出讨论,大家觉得不错,就赏个star。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,376评论 6 491
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,126评论 2 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,966评论 0 347
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,432评论 1 283
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,519评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,792评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,933评论 3 406
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,701评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,143评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,488评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,626评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,292评论 4 329
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,896评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,742评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,977评论 1 265
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,324评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,494评论 2 348

推荐阅读更多精彩内容