如何开发一个像runkeeper一样的应用:第一部分


原文地址:http://www.raywenderlich.com/73984/make-app-like-runkeeper-part-1
泰然翻译组:纯洁的大白纸。校对:glory。

在刚刚举办的全球开发者大会上,苹果公司发布了HealthKit API以及相应的 健康类应用。同时,健康和运动类目下的应用,变得十分受欢迎。

Runkeeper是一个利用GPS的应用程序,拥有超过2500万的用户!从这个增长的趋势,我们可以清楚的知道:

  • 健康是非常重要的。
  • 智能手机可以像一个精明能干的助手一样管理和追踪我们的健康状况。
  • 开发一个有趣的应用,能够让人们在达成目标的道路上保持动力,更重要的是能让人们保持健康。

这篇教程将向你展示如何开发一个像runkeeper一样的应用,我们会以一个能够激励用户运动的跑步检测应用为示例,如果你跟着教程学到最后,你会做出一个具有如下功能的应用:

  • 使用 Core Location 记录你的跑步路线。
  • 在你跑步的过程中,能向你展示一个实时更新的路线图。
  • 在你跑步的过程中,能持续不断的向你报告你的 平均速度
  • 不同的跑步距离,奖励不同的 成就徽章

    • 金质和银质徽章的获得,只取决于你进步的大小,和你的起始点无关。
  • 通过追踪当前位置与下一个目的地的 剩余距离 来鼓励你。
  • 完成跑步时,向你展示完整的路线图。

    • 路线图的线条是 不同颜色 的,跑的慢的部分用红色的线条表示,跑的快的部分用绿色的线条表示。

最后,我们把这个应用取名为 MoonRunner,因为它的徽章都是以太阳系的行星和卫星命名的。也就是说,如果你要获得所有的徽章,就相当于你在太阳系中穿越了所有的空间。

在开始学习这篇教程之前,我们列举了你应该掌握的技能和一些值得一读的建议:

  1. Storyboards:你将使用refresher构建应用的用户界面,具体使用参阅这篇教程
  2. Core Data:在个人学习的训练中,知道从哪里开始和规划进度是一项基本能力。在这个过程中,为了存储你的数据,你将使用Cocoa中被最广泛使用的数据持久性框架Core Data。具体使用参阅这篇教程
  3. 苹果公司提供的Breadcrumb的示例工程包含了许多有用的示例代码(目前不推荐使用)在跑步的过程中显示路线图。跟着我们的教程,你也会一点一点的完成同样的功能。

本教程分两部分,第一部分主要是集中在存储跑步数据和绘制由不同颜色组成的路线图,第二部分介绍徽章系统。


开始吧!

首先,创建一个新工程。
打开Xcode,选择 File\New\Project,然后选择 iOS\Application\Master-Detail Application
step1
给你的新工程取一个名字 MoonRunner,并且检查是够勾选了 Use Core Data
step2
做完上面两步,你已经有了一个由 Core Datastoryboards 构建的模板工程!


模块:跑和位置

在这个应用中,对 Core Data 的使用很少,只用到了两个entities:RunsLocations

打开 MoonRunner.xcdatamodeld,删除 Event。然后添加两个新entities,名为RunLocation。按下图所示设置 Run
step3
一个 Run 有三个属性:durationdistancetimestamp。它也有一个名为 locations 的 relationship,用来关联其他对象。

现在,按照下图设置 Location
step4
一个 locations 包含像 timestamp 一样的 latitudelongitude,此外,LocationRun 关联。
确保将 locations 的relationship设置成 to-manyordered
step5
下一步,Xcode已经生成了模型类。单击 File\New\File, 然后选择 Core Data\NSManagedObject subclass
step6
在接下来的设置中,确保勾选了 MoonRunnerRunLocation
step7

step8
Alright!,你已经完成了这个应用的Core Data部分。
这个教程需要使用 MapKit, 所以我们需要连接它。单击顶部工程导航栏里的工程,打开target里的 Build Phases,将 MapKit 添加到 Link Binary With Libraries列表中。
step9


设置 Storyboards

到了设计用户界面的时间了,首先设计storyboard,如果接下来讲到的东西你没有接触过或者暂时忘记了,请参考step10
打开 Main.storyboard.
开始阶段,MoonRunner只有一些简单的界面:

  • Home 界面,提供一些应用程序的导航选项
  • New Run界面,显示用户的起点并记录一个新的跑步活动
  • Run Details界面,展示包含不同颜色线条组成的路线图在内的跑步细节

这些界面都没有使用 UITableViewController,所以单击Master View Controller下面的黑框选中它,并按下键盘的 delete
step11

然后,从对象库中拖拽出两个view controler,将其中一个命名为‘Home’,然后在attributes inspector中设置标题。将另一个命名为‘New Run’。

Navigation ControllerHome 通过 Control-drag 相关联,将relationship设置为‘root view’。
step12

然后,将 New Run 下方黑框中的黄色图标和 Detail 界面相关联,并将segue设置为 push
step13

单击刚刚添加的segue,然后在 Attributes Inspector中,将名字指定为‘RunDetails’。
step14

Look!你的Storyboards:
step15

现在,你需要去单独设计每一个界面。


丰富你的Storyboards

请下载step16,解压并将所有的文件拖拽进工程,请确保你选择了‘Copy items into destination group’s folder (if needed)’。

打开 Main.storyboard ,找到‘Home’ view controler。

‘Home’ view controller是你应用的主菜单,拖拽一个 UILabel 放在这个View的顶部,并添加一句问候语‘Welcome To MoonRunner!’。

然后拖拽进三个 UIButtons,分别命名为 ‘New Run’, ‘Past Runs’ 和 ‘My Badges’。

在starter pack中有green-btn 和 blue-btn,我使用黑色的北京白色文字,现在界面看起来像下面:
step17

然后,用control-drag将‘New Run’ 按钮和 ‘New Run’ 界面关联起来,并且将segue设置为push.
step18

New Run 界面有两个模块:pre-runduring-run。View Controller处理每一个模块如何显示。现在,拖拽三个UILabels,用来显示实时更新的 distance , timepace

添加一个UILabels作为提示(比如: “Ready To Launch?”),添加两个 UIButtons控制开始和结束。

我使用黑色背景和 green-btnred-btn,如果你和我一样,你的storyboard将会是这样的:
step19

最后,Run Details 界面显示用户指定的一个跑步活动的线路图。

在storyboard中找到‘Detail View Controller’,然后删除已存在的label,添加一个 MKMapView 和 四个 UILabels, 内容分别为 distance, date, time 和 pace.

你的storyboard看起来应该是这样:
step20

好了,你已经为界面做了一些简单的连接,是时候用Controllers去做一些控制了。


完善Basic App Flow

Xcode在Master-Detail模板中做了大量的工作,但是你并不需要其中的MasterViewController,所以,删除 MasterViewController.hMasterViewController.m

然后使用 HomeViewController代替,选择 File\New\File,再选择 iOS\Cocoa Touch\Objective-C class,命名为HomeViewController,并选择 UIViewController 作为子类。

你需要给View Controller声明一个NSManagedObjectContext,所以,在头文件HomeViewController.h添加一个NSManagedObjectContext声明,看起来应该和下面一样:

#import 

@interface HomeViewController : UIViewController

@property (strong, nonatomic) NSManagedObjectContext *managedObjectContext;

@end

暂时回到AppDelegate.m中,在上面的步骤删除了MasterViewController之后出现了一个错误,这只是一个小问题,在第一行的导入头文件中,使用HomeViewController.h代替MasterViewController.h

import "HomeViewController.h"

接下来,实现*application:didFinishLaunchingWithOptions*:

(BOOL)application:(UIApplication )application didFinishLaunchingWithOptions:(NSDictionary )launchOptions
{
  UINavigationController navigationController = (UINavigationController )self.window.rootViewController;
  HomeViewController controller = (HomeViewController )navigationController.topViewController;
  controller.managedObjectContext = self.managedObjectContext;
  return YES;
}

现在,再添加一个文件,选择File\New\File,再选择 iOS\Cocoa Touch\Objective-C class,命名为NewRunViewController,继承UIViewController
像在HomeViewController.h中做的一样,在NewRunViewController.h中声明一个NSManagedObjectContext

import 

@interface NewRunViewController : UIViewController

@property (strong, nonatomic) NSManagedObjectContext *managedObjectContext;

@end

打开 **NewRunViewController.m**,在顶部添加如下代码:

import "DetailViewController.h"

import "Run.h"

static NSString * const detailSegueName = @"RunDetails";

@interface NewRunViewController () 

@property (nonatomic, strong) Run *run;

@property (nonatomic, weak) IBOutlet UILabel promptLabel;
@property (nonatomic, weak) IBOutlet UILabel timeLabel;
@property (nonatomic, weak) IBOutlet UILabel distLabel;
@property (nonatomic, weak) IBOutlet UILabel paceLabel;
@property (nonatomic, weak) IBOutlet UIButton startButton;
@property (nonatomic, weak) IBOutlet UIButton stopButton;
@end

请注意,我们还没有添加 UIActionSheetDelegate的实现,Let's do it.

下一步是定义UI的初始状态和按钮的关联动作。
将下面的方法添加到的

NewRunViewController

中的主函数:

- (void)viewWillAppear:(BOOL)animated
{
    [super viewWillAppear:animated];

self.startButton.hidden = NO;
self.promptLabel.hidden = NO;

self.timeLabel.text = @"";
self.timeLabel.hidden = YES;
self.distLabel.hidden = YES;
self.paceLabel.hidden = YES;
self.stopButton.hidden = YES;
}

开始时,你只能看到开始按钮和提示。下一步,添加下面的方法:

-(IBAction)startPressed:(id)sender
{
    // 隐藏开始界面
    self.startButton.hidden = YES;
    self.promptLabel.hidden = YES;

// 显示跑步界面
self.timeLabel.hidden = NO;
self.distLabel.hidden = NO;
self.paceLabel.hidden = NO;
self.stopButton.hidden = NO;

}
(IBAction)stopPressed:(id)sender
{
  UIActionSheet actionSheet = [[UIActionSheet alloc] initWithTitle:@"" delegate:self
          cancelButtonTitle:@"Cancel" destructiveButtonTitle:nil
          otherButtonTitles:@"Save", @"Discard", nil];
  actionSheet.actionSheetStyle = UIActionSheetStyleDefault;
  [actionSheet showInView:self.view];
}

这个两个方法实现了两个按钮的两个动作。按下开始按钮时,将UI切换到 “during-run” ,按下停止按钮时,会显示一个 UIActionSheet*,这样用户可以选择是否存储保存跑步数据。

现在,你需要做一些事情去响应用户在UIActionSheet中的选择,添加如下方法:

- (void)actionSheet:(UIActionSheet *)actionSheet clickedButtonAtIndex:(NSInteger)buttonIndex
{
    // 保存
    if (buttonIndex == 0) {
        [self performSegueWithIdentifier:detailSegueName sender:nil];

// 放弃保存
} else if (buttonIndex == 1) {
    [self.navigationController popToRootViewControllerAnimated:YES];
}

}


这个方法能够使得用户按下 ‘Save’ 时继续显示跑步详情,按下‘Discard’ 时返回主菜单。
最后,添加如下方法:

- (void)prepareForSegue:(UIStoryboardSegue *)segue sender:(id)sender
{
    [[segue destinationViewController] setRun:self.run];
}


这个方法使得当segue被触发时,DetailViewController和当前的跑步活动联系起来。

现在,跑步活动是空的,那是因为你还没有开始一个跑步活动,或者结束了应用。你可以看到,这个应用会根据从跑步中读取到的位置信息构建这一个跑步活动。

现在,开始实现NewRunViewController.m
打开 DetailViewController.h,按如下代码所示:


import 

@class Run;

@interface DetailViewController : UIViewController

@property (strong, nonatomic) Run *run;

@end


DetailViewController方法中添加一个Run属性声明,这会跑步过程中显示详情。

然后打开 DetailViewController.m,用如下代码代替整个文件。

import "DetailViewController.h"

import 

@interface DetailViewController () 

@property (nonatomic, weak) IBOutlet MKMapView mapView;
@property (nonatomic, weak) IBOutlet UILabel distanceLabel;
@property (nonatomic, weak) IBOutlet UILabel dateLabel;
@property (nonatomic, weak) IBOutlet UILabel timeLabel;
@property (nonatomic, weak) IBOutlet UILabel *paceLabel;

@end

@implementation DetailViewController

pragma mark - Managing the detail item

(void)setRun:(Run *)run
{
  if (run != run) {
      run = run;
      [self configureView];
  }
}
(void)configureView
{
}
(void)viewDidLoad
{
  [super viewDidLoad];
  [self configureView];
}

@end


这里导入了MapKit,所以你就可以使用 MKMapView,它也为UI里的属性添加了私有方法。其他方法也添加了基本的实现,当界面被加载和一个新的跑步活动开始时,调用这个方法配置这个界面。

在返回Storyboards和连接私有方法之前,还有最后一步,打开HomeViewController.m,添加如下语句:

import "NewRunViewController.h"


然后添加如下的方法实现:

(void)prepareForSegue:(UIStoryboardSegue)segue sender:(id)sender
{
UIViewController nextController = [segue destinationViewController];
if ([nextController isKindOfClass:[NewRunViewController class]]) {
    ((NewRunViewController *) nextController).managedObjectContext = self.managedObjectContext;
}
}


最后,找到storyboard,然后做如下设置:
  • HomeViewController设置成Home View Controller。

  • NewRunViewController设置成New Run View Controller。

  • NewRunViewController 和 *DetailViewController**中的私有方法联系起来。

  • NewRunViewController中接收到的两个动作(startPressed:stopPressed:)联系起来。

  • MKMapView 设置成DetailViewController的delegate。




  • 做完这些之后,你已经到达了第一个目的地。Build and Run!
    step21

    你的应用现在有一个非常基础的UI:你应该有一个导航栏能在三个界面和开始结束界面之间切换。






    Math and Units



    注意你创建的一系列界面和依附在storyboard中显示状态和时间的界面。Core Location都会以公制的数据显示,一个很好的用户体验设计是让英制和公制都可以使用。

    单击File\New\File,选择iOS\Cocoa Touch\Objective-C class,调用并创建MathController,继承NSObject,然后打开MathController.h,加入如下所示的代码:

    import 
    
    @interface MathController : NSObject
    
    (NSString *)stringifyDistance:(float)meters;
    (NSString *)stringifySecondCount:(int)seconds usingLongFormat:(BOOL)longFormat;
    (NSString *)stringifyAvgPaceFromDist:(float)meters overTime:(int)seconds;
    @end
    


    打开**MathController.m**,然后在文件头部添加如下代码:

    static bool const isMetric = YES;
    static float const metersInKM = 1000;
    static float const metersInMile = 1609.344;
    


    在美国,可以将*isMetric*的值修改成*NO*。
    下一步,添加如下代码:


    + (NSString )stringifyDistance:(float)meters {
    float unitDivider; NSString
    unitName;



    // 公制
    if (isMetric) {
    unitName = @"km";
    // to get from meters to kilometers divide by this
    unitDivider = metersInKM;
    // 美国
    } else {
    unitName = @"mi";
    // to get from meters to miles divide by this
    unitDivider = metersInMile;
    }

    return [NSString stringWithFormat:@"%.2f %@", (meters / unitDivider), unitName];

    }

    (NSString *)stringifySecondCount:(int)seconds usingLongFormat:(BOOL)longFormat
    {
    int remainingSeconds = seconds;
    int hours = remainingSeconds / 3600;
    remainingSeconds = remainingSeconds - hours * 3600;
    int minutes = remainingSeconds / 60;
    remainingSeconds = remainingSeconds - minutes * 60;



    if (longFormat) {
    if (hours > 0) {
    return [NSString stringWithFormat:@"%ihr %imin %isec", hours, minutes, remainingSeconds];
    } else if (minutes > 0) {
    return [NSString stringWithFormat:@"%imin %isec", minutes, remainingSeconds];
    } else {
    return [NSString stringWithFormat:@"%isec", remainingSeconds];
    }
    } else {
    if (hours > 0) {
    return [NSString stringWithFormat:@"%02i:%02i:%02i", hours, minutes, remainingSeconds];
    } else if (minutes > 0) {
    return [NSString stringWithFormat:@"%02i:%02i", minutes, remainingSeconds];
    } else {
    return [NSString stringWithFormat:@"00:%02i", remainingSeconds];
    }
    }
    }
    (NSString *)stringifyAvgPaceFromDist:(float)meters overTime:(int)seconds
    {
    if (seconds == 0 || meters == 0) {
    return @"0";
    }

    float avgPaceSecMeters = seconds / meters;

    float unitMultiplier;
    NSString *unitName;

    // 公制
    if (isMetric) {
    unitName = @"min/km";
    unitMultiplier = metersInKM;
    // 美国
    } else {
    unitName = @"min/mi";
    unitMultiplier = metersInMile;
    }

    int paceMin = (int) ((avgPaceSecMeters * unitMultiplier) / 60);
    int paceSec = (int) (avgPaceSecMeters * unitMultiplier - (paceMin*60));

    return [NSString stringWithFormat:@"%i:%02i %@", paceMin, paceSec, unitName];
    }


    这些方法能够将 distances, time 和 speeds转换成字符串。这里不做太多细节的解释,详情可以参考这篇教程








    开始一个跑步活动



    你是否记得有人说过:“即使是马拉松也是用一个CLLocationManager update开始的。”也许没人说过,但是现在我们就来证明这句话。

    下一步,你将开始记录用户在跑步中的位置信息。

    你需要对你的工程做一个非常重要的改变,点击工程导航栏中的工程,选择Capabilities,然后打开Background Modes,在展开的右边部分,勾选Location Updates,这样,即使用户返回主菜单或者接听电话或者上网或者查看附近的星巴克的时候,你的应用也能更新位置信息。
    step23



    你知道吗?如果你的应用上架到APP > > Store,你需要为你的应用添加一份免责声明:在后台继续使用GPS会很容易的减少电池的使用寿命。



    现在,回到代码中来,打开NewRunViewController.m,将下列语句添加到文件头部:

    import 
    
    import "MathController.h"
    
    import "Location.h"
    


    下一步,添加 [CLLocationManagerDelegate][26] 和一些新属性。

    @interface NewRunViewController () 
    
    @property int seconds;
    @property float distance;
    @property (nonatomic, strong) CLLocationManager locationManager;
    @property (nonatomic, strong) NSMutableArray locations;
    @property (nonatomic, strong) NSTimer *timer;
    
    ...
    


    • seconds:监测跑步的持续时间,以秒为单位。
    • distance:记录跑步中持续增长的距离,以米为单位。
    • locationManager:用来接收你的停止和开始读取用户位置信息的命令。
    • locations:一个数组,用来存储不断存入的位置信息。
    • timer:fire每秒并更新UI。


    现在,添加如下方法:

    - (void)viewWillDisappear:(BOOL)animated
    {
        [super viewWillDisappear:animated];
        [self.timer invalidate];
    }
    


    这个方法中,当用户导航在视图中不可见的时候,停止timer。

    添加如下方法:

    - (void)eachSecond
    {
        self.seconds++;
        self.timeLabel.text = [NSString stringWithFormat:@"Time: %@",  [MathController stringifySecondCount:self.seconds usingLongFormat:NO]];
        self.distLabel.text = [NSString stringWithFormat:@"Distance: %@", [MathController stringifyDistance:self.distance]];
        self.paceLabel.text = [NSString stringWithFormat:@"Pace: %@",  [MathController stringifyAvgPaceFromDist:self.distance overTime:self.seconds]];
    }
    


    通过使用NSTimer,这个方法每一秒都会被调用,每一次调用这个方法,增加计时并更新对应的标签信息。

    添加如下所示的另一个方法:

    - (void)startLocationUpdates
    {
        // 如果没有位置管理器,创建一个。
        if (self.locationManager == nil) {
            self.locationManager = [[CLLocationManager alloc] init];
        }
    
        self.locationManager.delegate = self;
        self.locationManager.desiredAccuracy = kCLLocationAccuracyBest;
        self.locationManager.activityType = CLActivityTypeFitness;
    
        // 时间的入口
        self.locationManager.distanceFilter = 10; // 米
    
        [self.locationManager startUpdatingLocation];
    }
    


    如果需要的话,你需要一个 CLLocationManager,将它的入口设置为这一个类,这样它就知道要把更新的位置信息发送到哪里。

    之后,你需要提供kCLLocationAccuracyBestdesiredAccuracy ,你可能觉得这么做很傻 ,为什么不做到最好哪?

    手机可以从GPS硬件获取精确地读数,但是它加剧了电池的使用。无节制的使用已经打开的无线通讯功能,比如 Wi-Fi or cell-tower 也会加剧电池的使用。

    是的,GPS会消耗大量的电量,这里有一些粗略读取数据的使用场景,比如:当你只需要确定用户的大致位置时。无论如何,这个应用追踪你的真实跑步路径,所以你需要尽可能的精确。

    activityType参数正是用来给这样的应用使用。它能很智能的在用户的整个跑步过程中节省电量。

    最后,你要设置一个10米的distanceFilter,和activityType不同的是,它不对电池使用产生影响。activityType在读取数据时使用,distanceFilter是在报告读取的数据时使用。

    在一个跑步之后,你会看到,读取的位置路线和直线有一些不同。

    高精度的distanceFilter的能尽量减少和真实路线的区别,给你一个更真实的路线。
    不幸的是,太高精度的过滤器会使获得的数据失真,这就是为什么10米是一个很好地选择。

    最后,你要告诉管理器去开始获得位置信息。

    开始一个跑步活动,添加startPressed文件末尾添加如下信息:

    self.seconds = 0;
    self.distance = 0;
    self.locations = [NSMutableArray array];
    self.timer = [NSTimer scheduledTimerWithTimeInterval:(1.0) target:self
            selector:@selector(eachSecond) userInfo:nil repeats:YES];
    [self startLocationUpdates];
    


    这段代码实现了,即使你重置了一个跑步活动,所有的变量也能继续更新。



    记录跑步活动



    你已经创建了CLLocationManager,现在,你需要通过它更新数据。我们可以通过它的入口实现。打开NewRunController.m,添加如下代码:

    (void)locationManager:(CLLocationManager )manager
       didUpdateLocations:(NSArray )locations
    {
      for (CLLocation *newLocation in locations) {
          if (newLocation.horizontalAccuracy < 20) {
    
          // 更新距离
          if (self.locations.count > 0) {
              self.distance += [newLocation distanceFromLocation:self.locations.lastObject];
          }
    
          [self.locations addObject:newLocation];
      }
      }
    }
    


    每一次位置更新的时候,这个方法都会被调用。

    它通常使用一个*CLLocations*数组更新数据,通常数组里只有一个对象, 但是如果有多个对象的时候,只保存最新位置的信息。

    *CLLocation*包含了许多有用的信息。比如随着读取的时间的不同,保存不同的经纬度。

    但是在盲目的接收读取的数据之前,需要一个*horizontalAccuracy*检查,当手机位置不确定时,它读取的用户位置数据有一个20米左右的误差,最好从你的数据集合中移除这个数据。

    > 注意:这个检查在开始跑步活动是非常重要,在这个阶段,他可能一些不确定的信息更新。

    如果CLLocation通过检查,这个位置和最近的一个点之间的位置就会被添加到增长的距离中, [distanceFromLocation][28]:这个方法很方便,描述了一些包括地球曲面曲度在内的复杂的数学问题。

    最后,位置对象被添加进位置数组中。

    > 注意:*CLLocation*对象使用*verticalAccuracy*包含高度信息。所有的跑步者都知道,山丘是跑步过程中的一个转折点,高度能够影响氧气密度。对你的挑战是,想出一个办法将这个信息包含进你的应用中。

    ---
    ## 保存跑步信息

    在一些点上,尽管身体里有个声音在激励你继续下去,但是到了跑步结束的时间。你应经在UI中做了接收输入的措施,现在是时候去处理数据了。

    将下列代码添加进**NewRunViewController.m**:

    (void)saveRun
    {
    Run *newRun = [NSEntityDescription insertNewObjectForEntityForName:@"Run"
    inManagedObjectContext:self.managedObjectContext];

    newRun.distance = [NSNumber numberWithFloat:self.distance];
    newRun.duration = [NSNumber numberWithInt:self.seconds];
    newRun.timestamp = [NSDate date];



    NSMutableArray locationArray = [NSMutableArray array]; for (CLLocation location in self.locations) {
    Location *locationObject = [NSEntityDescription insertNewObjectForEntityForName:@"Location"
    inManagedObjectContext:self.managedObjectContext];

    locationObject.timestamp = location.timestamp;
    locationObject.latitude = [NSNumber numberWithDouble:location.coordinate.latitude];
    locationObject.longitude = [NSNumber numberWithDouble:location.coordinate.longitude];
    [locationArray addObject:locationObject];

    }

    newRun.locations = [NSOrderedSet orderedSetWithArray:locationArray];
    self.run = newRun;

    // Save the context.
    NSError error = nil; if (![self.managedObjectContext save:&error]) { NSLog(@"Unresolved error %@, %@", error, [error userInfo]); abort(); } }
    发生了什么?如果之前,你对Core Data做了简单的处理,你现在应该感觉到存储对象有点熟悉。你创建了一个新的run对象,并且给它设置了一个持续增长的距离和跑步的持续时间。 跑步过程中记录的每一个CLLocation都被裁剪进一个新的CLLocation*的对象并且保存,位置信息和跑步连接,接下来去实现这个功能。





    最后,编辑actionSheet:clickedButtonAtIndex,在执行segue之前,停止读取位置信息,保存跑步活动。代码如下所示:

    - (void)actionSheet:(UIActionSheet *)actionSheet clickedButtonAtIndex:(NSInteger)buttonIndex
    {
        [self.locationManager stopUpdatingLocation];
    
    // 保存
    if (buttonIndex == 0) {
        [self saveRun]; ///< 添加这一行
        [self performSegueWithIdentifier:detailSegueName sender:nil];
    
    // 放弃
    } else if (buttonIndex == 1) {
        [self.navigationController popToRootViewControllerAnimated:YES];
    }
    }
    


    运行模拟器



    正如我希望的,这篇教程和你跟随着做的应用应该给你健康运动一定的鼓励。当你开发这个应用的时候,不需要逐步执行Build and Run。

    Build & runin模拟器,点击Debug\Location\City Run开启模拟器生产虚拟数据。
    step27

    当然,这样做比真实的去跑步测试容易的多。

    无论如何,我都建议你最终做一个真实的测试,这么做给你机会去调整位置管理器的参数,评估你获得的位置数据的质量。



    展示路线图



    现在是时候显示路线图了。

    打开DetailViewController.m,添加如下代码:

    import "MathController.h"
    
    import "Run.h"
    
    import "Location.h"
    


    然后,如下修改**configureView**:

    (void)configureView
    {
      self.distanceLabel.text = [MathController stringifyDistance:self.run.distance.floatValue];
    
      NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
      [formatter setDateStyle:NSDateFormatterMediumStyle];
      self.dateLabel.text = [formatter stringFromDate:self.run.timestamp];
    
      self.timeLabel.text = [NSString stringWithFormat:@"Time: %@",  [MathController stringifySecondCount:self.run.duration.intValue usingLongFormat:YES]];
    
      self.paceLabel.text = [NSString stringWithFormat:@"Pace: %@",  [MathController stringifyAvgPaceFromDist:self.run.distance.floatValue overTime:self.run.duration.intValue]];
    }
    


    这段代码实现了将跑步的细节在标签变量上显示出来。

    传递路线图还需要一些小细节。这里有三个基本的步骤实现。第一,区域要设置,只能获取跑步路线的信息不能获取全世界。



    添加如下代码:

    - (MKCoordinateRegion)mapRegion
    {
        MKCoordinateRegion region;
        Location *initialLoc = self.run.locations.firstObject;
    
    float minLat = initialLoc.latitude.floatValue;
    float minLng = initialLoc.longitude.floatValue;
    float maxLat = initialLoc.latitude.floatValue;
    float maxLng = initialLoc.longitude.floatValue;
    
    for (Location *location in self.run.locations) {
        if (location.latitude.floatValue < minLat) {
            minLat = location.latitude.floatValue;
        }
        if (location.longitude.floatValue < minLng) {
            minLng = location.longitude.floatValue;
        }
        if (location.latitude.floatValue > maxLat) {
            maxLat = location.latitude.floatValue;
        }
        if (location.longitude.floatValue > maxLng) {
            maxLng = location.longitude.floatValue;
        }
    }
    
    region.center.latitude = (minLat + maxLat) / 2.0f;
    region.center.longitude = (minLng + maxLng) / 2.0f;
    
    region.span.latitudeDelta = (maxLat - minLat) * 1.1f; // 10% padding
    region.span.longitudeDelta = (maxLng - minLng) * 1.1f; // 10% padding
    
    return region;
    }
    


    MKCoordinateRegion用来显示区域。你要用一个中心点和区域去定义它。

    例如,我慢跑可能相当与放大我的短跑路线周围区域。而我更喜欢跑步的朋友将显示更大区域的缩略图。

    一点填充也是很重要的,它让你的路线和路线图的边缘不至于那么拥挤。



    下一步,添加如下代码:

    - (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 = [UIColor blackColor];
            aRenderer.lineWidth = 3;
            return aRenderer;
        }
    
    return nil;
    }
    


    这个方法的功能是:无论路线图是否遇到了一个添加overlay的请求,它都应该去检查是否是一个MKPolyline,如果是,它应该使用一个画黑线的渲染器。overlay是画在路线图头部的。

    最后,你需要给折线定义坐标,添加如下代码:

    - (MKPolyline *)polyLine {
    
    CLLocationCoordinate2D coords[self.run.locations.count];
    
    for (int i = 0; i < self.run.locations.count; i++) {
        Location *location = [self.run.locations objectAtIndex:i];
        coords[i] = CLLocationCoordinate2DMake(location.latitude.doubleValue, location.longitude.doubleValue);
    }
    
    return [MKPolyline polylineWithCoordinates:coords count:self.run.locations.count];
    
    }
    


    在这里,你将从Location对象获取的数据推进CLLocationCoordinate2D(折线需要的格式)数组中。

    现在,把这三步合在一起,添加如下方法:

    - (void)loadMap
    {
        if (self.run.locations.count > 0) {
    
        self.mapView.hidden = NO;
    
        // set the map bounds
        [self.mapView setRegion:[self mapRegion]];
    
        // make the line(s!) on the map
        [self.mapView addOverlay:[self polyLine]];
    
    } else {
    
        // no locations were found!
        self.mapView.hidden = YES;
    
        UIAlertView *alertView = [[UIAlertView alloc]
                                  initWithTitle:@"Error"
                                  message:@"Sorry, this run has no locations saved."
                                  delegate:nil
                                  cancelButtonTitle:@"OK"
                                  otherButtonTitles:nil];
        [alertView show];
    }
    }
    


    添加如下代码到*configureView*的底部:

    [self loadMap];
    


    现在执行build & run,你应该在模拟器上看到路线图:

    step28






    找到正确的颜色



    这个应用现在已经很好了,有一个方法可以帮你的用户将应用训练的更聪明,那就是让应用知道你在每一次跑步中胳膊的快慢。

    点击File\New\File,选择iOS\Cocoa Touch\Objective-C class,调用MulticolorPolylineSegment,继承MKPolyline并创建它,然后打开MulticolorPolylineSegment.h,修改成如下:

    import 
    
    @interface MulticolorPolylineSegment : MKPolyline
    
    @property (strong, nonatomic) UIColor *color;
    
    @end
    


    这种特殊的,自定义的折线将用来转换跑步活动中的每一部分。颜色代表着速度折线上每一部分的颜色都是存储在这里。
    下一步,你需要计算出怎么样给折线的每一部分指定颜色。听起来是数学问题。打开**MathController.h**,添加如下代码:

    + (NSArray )colorSegmentsForLocations:(NSArray )locations;
    


    然后打开**MathController.m**,并添加如下代码:

    import "Location.h"
    
    import "MulticolorPolylineSegment.h"
    


    然后添加如下方法实现:

    (NSArray )colorSegmentsForLocations:(NSArray )locations
    {
      // make array of all speeds, find slowest+fastest
      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.longitude.doubleValue];
      CLLocation *secondLocCL = [[CLLocation alloc] initWithLatitude:secondLoc.latitude.doubleValue longitude:secondLoc.longitude.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)];
    
      }
    
      return speeds;
    }
    


    这个方法为位置的每一个顺序对返回一个速度数组。
    你看到的第一件事是 循环遍历了输入的所有位置,你需要将每一个*Location*转换成*CLLocation*,所以你需要使用*distanceFromLocation*。
    不要忘了物理知识:距离等于速度乘以时间,第一个位置之后的每一个位置都会和它的前一个位置比较,循环结束后,你会得到一个完整速度变化的集合。
    下一步,添加如下代码:

    //知道最快和最慢速度,求meanSpeed
    double meanSpeed = (slowestSpeed + fastestSpeed)/2;
    
    // 慢的用红色
    CGFloat r_red = 1.0f;
    CGFloat r_green = 20/255.0f;
    CGFloat r_blue = 44/255.0f;
    
    // 不快不慢的用黄色
    CGFloat y_red = 1.0f;
    CGFloat y_green = 215/255.0f;
    CGFloat y_blue = 0.0f;
    
    // 快的用绿色
    CGFloat g_red = 0.0f;
    CGFloat g_green = 146/255.0f;
    CGFloat g_blue = 78/255.0f;
    


    在这里定义了三种颜色分别代表折现中的三种不同的速度。

    每一种颜色,都有它的RGB值,最慢的使用纯红色,介于最慢和最快中间的使用黄色,最快的使用绿色。其他的就是它两边的颜色的混合。所以最后的结果可能是五彩缤纷的。
    step29



    最后,移除原本的返回值并且添加如下代码:

    NSMutableArray *colorSegments = [NSMutableArray array];
    
    for (int i = 1; i < locations.count; i++) {
      Location firstLoc = [locations objectAtIndex:(i-1)];
      Location secondLoc = [locations objectAtIndex:i];
    
      CLLocationCoordinate2D coords2;
      coords[0].latitude = firstLoc.latitude.doubleValue;
      coords[0].longitude = firstLoc.longitude.doubleValue;
    
      coords1.latitude = secondLoc.latitude.doubleValue;
      coords1.longitude = secondLoc.longitude.doubleValue;
    
      NSNumber speed = [speeds objectAtIndex:(i-1)];
      UIColor color = [UIColor blackColor];
    
      // between red and yellow
      if (speed.doubleValue < meanSpeed) {
        double ratio = (speed.doubleValue - slowestSpeed) / (meanSpeed - slowestSpeed);
        CGFloat red = r_red + ratio * (y_red - r_red);
        CGFloat green = r_green + ratio * (y_green - r_green);
        CGFloat blue = r_blue + ratio * (y_blue - r_blue);
        color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0f];
    
    // between yellow and green
      } else {
        double ratio = (speed.doubleValue - meanSpeed) / (fastestSpeed - meanSpeed);
        CGFloat red = y_red + ratio * (g_red - y_red);
        CGFloat green = y_green + ratio * (g_green - y_green);
        CGFloat blue = y_blue + ratio * (g_blue - y_blue);
        color = [UIColor colorWithRed:red green:green blue:blue alpha:1.0f];
      }
    
      MulticolorPolylineSegment *segment = [MulticolorPolylineSegment polylineWithCoordinates:coords count:2];
      segment.color = color;
    
      [colorSegments addObject:segment];
    }
    
    return colorSegments;
    


    在这个循环中,你决定了每一个与计算的速度的值。这个比率在后面决定了UIColor应用到的部分。

    下一步,你使用两个坐标和混合的颜色构建一个MulticolorPolylineSegment

    最后,你获得了所有的颜色混合的部分,你已经做好了转换的准备。






    应用多种颜色



    使用多颜色的折线重构你的detail view controller是很简单的事,打开DetailViewController.m,添加如下所示的头文件:

    import "MulticolorPolylineSegment.h"
    


    现在,找到*loadMap*,用这一行:

    NSArray *colorSegmentArray = [MathController colorSegmentsForLocations:self.run.locations.array];
    [self.mapView addOverlays:colorSegmentArray];
    


    替换下面的行:

    [self.mapView addOverlay:[self polyLine]];
    


    它使用math controller创建了一个segments数组,并将所有的overlays添加进路线图。

    最后,你需要准备你的折线转换器去注意每一部分的特殊颜色,所以,使用下列代码替换mapView:rendererForOverlay的实现:

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


    这和之前做的很相似,但是现在每一段特定的颜色都是单独呈现。
    好了,现在启动模拟器运行。
    step30






    Leaving a Trail Of Breadcrumbs



    那个跑步后的地图是惊人的,但是它是怎么运行的那?Breadcrumb sample project有它的功能。但是在这篇文章里面,包含了一些在iOS7中不推荐使用的方法。

    打开Main.storyboard,找到‘New Run’ view controller,拖拽进一个新的MKMapView
    step32



    然后打开NewRunViewController.m,并且添加如下导入语句:

    import 


    添加MKMapViewDelegate声明:

    @interface NewRunViewController () 
    


    接下来,为路线图添加IBOutlet到扩展类:

    @property (nonatomic, weak) IBOutlet MKMapView *mapView;
    


    然后在viewWillAppear后添加如下一行:

    self.mapView.hidden = YES;
    


    现在在startPressed后添加下面一行:

    self.mapView.hidden = NO;
    


    它实现了当跑步开始时,显示路线图。

    足迹将会开始一个新的折线,所以是时候去添加你的老朋友mapView:rendererForOverlay了,添加如下所示的代码:

    - (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 = [UIColor blueColor];
            aRenderer.lineWidth = 3;
            return aRenderer;
        }
        return nil;
    }
    


    这个版本和跑步细节视图是相似的,除了这里的strokeColor总是蓝色的。
    接下来,你需要写一些代码去更新你的地图区域,并且在每一个通过验证的位置信息被获取之后,画折线。找到locationManager:didUpdateLocations,并用下面代码更新它:

    - (void)locationManager:(CLLocationManager )manager
         didUpdateLocations:(NSArray )locations
    {
        for (CLLocation *newLocation in locations) {
    
        NSDate *eventDate = newLocation.timestamp;
    
        NSTimeInterval howRecent = [eventDate timeIntervalSinceNow];
    
        if (abs(howRecent) < 10.0 && newLocation.horizontalAccuracy < 20) {
    
            // update distance
            if (self.locations.count > 0) {
                self.distance += [newLocation distanceFromLocation:self.locations.lastObject];
    
                CLLocationCoordinate2D coords[2];
                coords[0] = ((CLLocation *)self.locations.lastObject).coordinate;
                coords[1] = newLocation.coordinate;
    
                MKCoordinateRegion region =
                MKCoordinateRegionMakeWithDistance(newLocation.coordinate, 500, 500);
                [self.mapView setRegion:region animated:YES];
    
                [self.mapView addOverlay:[MKPolyline polylineWithCoordinates:coords count:2]];
            }
    
            [self.locations addObject:newLocation];
        }
    }
    }
    


    现在,地图总是集中在最近的位置,不断增加的蓝色小折线显示用户的轨迹。
    打开Main.storyboard,找到‘New Run’ view controller,连接mapView 的出口到地图视图。然后将mapView的入口设置为view controller。
    Build & run,开始一个新的跑步活动,你会看到实时更新的地图。
    step33


    还能做些什么?

    干得好,这里有一些不错的想法去尝试:

    • 利用从NewRunController获得的高度信息计算路面的曲度。

    • 如果你寻求一种纯数学方面的挑战,你可以尝试去使用平均速度更平滑的混合颜色。

    敬请期待本篇教程的第二部分,第二部分将介绍徽章系统。
    一如即让,欢迎发表意见和评论。

    标签: iOS

    ?>