怎么样创建一个像RunKeeper一样的App(一)swift版
</br>
本博将不定期更新外网的iOS最新教程
简书: @西木
微博: @角落里的monster
本文翻译自raywenderlich,版权归原作者所有,转载请注明出处
原文地址为 http://www.raywenderlich.com/97944/make-app-like-runkeeper-swift-part-1
</br>
原博提供了两份示例代码,分别为刚开始项目配置时的、part 1完成时的,地址分别为:
http://cdn4.raywenderlich.com/wp-content/uploads/2015/05/MoonRunner-Starter.zip
http://cdn4.raywenderlich.com/wp-content/uploads/2015/05/MoonRunner-Part1-Final.zip
</br>
这篇教程将向你展示,如何做一个类似于RunKeeper一样,基于GPS的可以记录你跑步轨迹的app.
这个新的app.我们就叫它MoonRunner吧.
接下来,你将要完成这个动态轨迹记录app的所有功能
- 追踪核心位置
- 当你跑步时显示地图,并且实时更新行动路径
- 在跑步时持续显示当前的平均速度
- 根据距离不同设置徽章奖励系统
- 当你这次跑步结束的时候显示这次跑步整体的运行轨迹
Getting Started
请下载本教程对应的代码,路径为: http://cdn4.raywenderlich.com/wp-content/uploads/2015/05/MoonRunner-Starter.zip
打开并运行程序就可以看见一个非常简洁的布局:
- 首页显示的是3个简单的导航按钮
- 你可以记录或者开始一次新的跑步记录的时候所在的那个界面是NewRun界面
- 在一次跑步的详情页可以看见这次跑步的详细信息,包括彩色标注的地图
Starting the Run
首先,我们需要对项目做一些设置
- 点击MoonRunner的project navigator
- 选择Capabilities tab
- 打开Background Modes
- 勾选Location Updates
这个设置可以保证,即使你零时需要接听电话,程序推入后台时依然保持位置信息的更新
接下来,选择Info tab,打开Custom iOS Target Properties,将下面两行加入plist
key | type | value |
---|---|---|
NSLocationWhenInUseUsageDescription | String | MoonRunner wants to track your run |
NSLocationAlwaysUsageDescription | String | MoonRunner wants to track your run |
它的作用是,iOS会弹出提示框询问用户是否允许该app使用location data
注意
如果你的app要上传App Store,你需要在App的discription中注明:在后台持续使用GPS会减少电池的寿命
接下来回到代码中,打开NewRunViewController.swift加入
import CoreLocation
import HealthKit
因为你需要很多的基于位置信息的API和Health模块的method
然后,你还需要在文件尾部加入class extension,来遵守CLLocationManagerDelegate协议
// MARK:- CLLocationManagerDelegate
extension NewRunViewController:CLLocationManagerDelegate{
}
随后你需要实现一些代理方法,完成对于位置信息更新的监听
接下来加入一些成员属性
var seconds = 0.0
var distance = 0.0
lazy var locationManager:CLLocationManager = {
var _locationManager = CLLocationManager()
_locationManager.deledate = self
_locationManager.desiredAccuracy = kCLLocationAccuracyBest
_locationManager.activityType = .Fitness
// Movement threshold for new events
_locationManager.distanceFilter = 10.0
return _locationManager
}()
lazy var locations = [CLLocation]()
lazy var timer = NSTimer()
这些属性的意思
- seconds : 记录轨迹的事件间隔,单位是秒
- distance : 截止当前时刻跑了多远,单位是米
- locationManager : 对象,在start或者stop的时刻记录用户的位置
- timer : 记录时刻,更新UI
CLLocationManager和它的配置
当懒加载的时候,你就会为NewRunViewController设置代理CLLocationManagerDelegate
紧接着设置了精确性为best(_locationManager.desiredAccuracy = kCLLocationAccuracyBest),在你运动的时候,你可以非常精确地读取自己的位置信息,同样,你也会消耗比较多的电量
activityType属性设置为.Fitness的用途: 例如当你过马路或者停止的时候,它会只能的更改配置崩你节省电量
distanceFilter设置为10米,相对于desireAccuracy,这个属性不会影响电量,它是方便显示其他属性的值的
如果你做个小小的测试你就会发现,你的位移信息不是一条直线而是有很多锯齿状
高精度的distanceFilter就可以减少锯齿,给你一个更精确地轨迹,但是,太高的精度值会让你的轨迹像素化(看到很多马赛克),所以10m是一个相对比较合适的值
接下来,在viewWillAppear(animated: BOOL)方法的最后加上这一行
locationManager.requestAlawayAuthorization()
这个方法是iOS8才有的,用来请求用户授权允许使用位置信息,如果你想让你的App兼容iOS8之前的版本,还需要测试兼容性
接下来在实现中加入这个方法
override func viewWillDisappear(animated: Bool) {
super.viewWillDisappear(animated)
timer.invalidate()
}
这个方法的意思是,当导航控制器不显示该页面时,时间的记录也会停止
再添加这个方法
func eachSecond(timer:NSTimer) {
seconds++
let secondsQuantity = HKQuantity(unit: HKUnit.secondUnit(), doubleValue: seconds)
timeLabel.text = "Time: " + secondsQuantity.description
let distanceQuantity = HKQuantity(unit: HKUnit.meterUnit(), doubleValue: distance)
distanceLabel.text = "Distance: " + distanceQuantity.description
let paceUnit = HKUnit.secondUnit().unitDividedByUnit(HKUnit.meterUnit())
let paceQuantity = HKQuantity(unit: paceUnit, doubleValue: seconds / distance)
paceLabel.text = "Pace: " + paceQuantity.description
}
这个方法每一秒都会调用,在调用的时候,你所有的数据值都会跟随时间变化而更新
在你开始跑步前呢,还有最后一个方法要调用
func startLocationUpdates() {
// Here, the location manager will be lazily instantiated
locationManager.startUpdatingLocation()
}
这个方法会告诉manager,需要开始更新位置信息了
在真正奔跑之前呢,还需要在startPressed(sender: AnyObject)方法中加入这些代码
seconds = 0.0
distance = 0.0
locations.removeAll(keepCapacity: false)
timer = NSTimer.scheduledTimerWithTimeInterval(1, target: self, selector: "eachSecond", userInfo: nil, repeats: true)
startLocationUpdates()
这个界面会持续更新所有数据
编译运行,如果你start你就可以看见时间值开始在不断增加
但是,distance和pace文本框不会更新,因为你还没有绘制位移轨迹,接下来我们做这部分
Recording the Run
你已经创建了一个CLLocationManager对象,你应该去那里拿到要更新的数据,这些可以通过代理来实现
仍然是NewRunViewController.swift这个文件,给我们之前写的 class extension 实现 CLLocationManagerDelegate 代理方法
func locationManager(manager: CLLocationManager!, didUpdateLocations locations: [AnyObject]!) {
for location in locations as! [CLLocation] {
if location.horizontalAccuracy < 20 {
// update distance
if self.locations.count > 0 {
distance += location.distanceFromLocation(self.locations.last)
}
//sace location
self.locations.append(location)
}
}
}
一旦有位置更新的时候这个方法就会被调用,通常状况下,locations这个数组只有一个元素,如果有多个,它们会按照时间先后排序
CLLocation中包含了很多信息,包括经纬度等等。但是在读取这些信息之前,会有一个horizonAccuracy的核对,如果设备觉得这项数据在20米之内的值不是很准确的时候,会自动的将这项数据从数据集中移除。这个核对功能在跑步的时候极其重要,用户在第一次启动进行校准的时候,这个时候,它可能会更新一些不准确的数据
如果CLLocation通过了检测,就会开始计算距离。这时候distaFromLocation(_location: CLLocation)方法就很方便了,它能够考虑到各种稀奇古怪的涉及到地球曲面的情况
最后,添加这个位置对象生成一个一段时间内位产生的包含多个位置对象的数组
注意
CLLocation对象也包含了相对应的VerticalAccuracy的海拔的数值,每一个runner都知道,小山坡会给跑步过程添加很多不一样的感受,因为海拔高度会影响你对氧气的需求量,它会给你一些小小的挑战,当然,这个数据也会收录在App里
Send the Simulator on a run
我希望这个教程和App的开放能让你对运动与健身产生极大的热情,但是你开发的时候不需要按照字面意思逐字地去理解它
你不需要在测试的时候真正的拿着手机去跑步,模拟器就可以帮你完成这个任务
在模拟器中启动程序,然后选择Debug ->Location ->City Run,模拟器就会给你一个虚拟的数据
当然,这样比较容易也不需要耗费精力去测试相对于其他的基于位置信息的App
然而,我也会建议你真的拿着手机做一个实地测试,这样你才有机会去微调你的位置管理的参数,去评估你得到的数据质量
同时也有助于你养成健康的生活习惯
Saving the Run
你之前已经设计好了UI,那么现在设置数据吧
加入这个方法到NewRunViewController.swift中
func saveRun() {
// 1
let savedRun = NSEntityDescription.insertNewObjectForEntityForName("Run",
inManagedObjectContext: managedObjectContext!) as! Run
savedRun.distance = distance
savedRun.duration = seconds
savedRun.timestamp = NSDate()
// 2
var savedLocations = [Location]()
for location in locations {
let savedLocation = NSEntityDescription.insertNewObjectForEntityForName("Location",
inManagedObjectContext: managedObjectContext!) as! Location
savedLocation.timestamp = location.timestamp
savedLocation.latitude = location.coordinate.latitude
savedLocation.longitude = location.coordinate.longitude
savedLocations.append(savedLocation)
}
savedRun.locations = NSOrderedSet(array: savedLocations)
run = savedRun
// 3
var error: NSError?
let success = managedObjectContext!.save(&error)
if !success {
println("Could not save the run!")
}
}
这里做了什么呢?如果你之前做了Core Data Flow的话,这看起来很像是保存了一条新的跑步记录:
1.你创建了一个新的记录,记录了你运动的距离和花费的时间
2.当把一系列的CLLocation对象保存到一个Location对象里面的时候,你就把跑步经过的那些坐标点都连接起来了
3.保存NSManagedObjectContext
最后,当用户停止跑步并且要把这次跑步的记录保存下来的时候就会调用这个方法。找到
extension NewRunViewController: UIActionSheetDelegate {
}
这个extension,在
if buttonIndex == 1
这个block里地第一行写上
saceRun()
编译、运行,你就可以开始一次新的跑步记录并且把数据保存下来
然而,跑步详细信息的界面仍然是空的,现在我们去完成它
Revealing the Map
现在,需要我们去调出地图,打开 DetailViewController.swift 并且导入 Healthkit
import HralthKit
然后,将下面的代码写到configView()方法里
func configureView() {
let distanceQuantity = HKQuantity(unit: HKUnit.meterUnit(), doubleValue: run.distance.doubleValue)
distanceLabel.text = "Distance: " + distanceQuantity.description
let dateFormatter = NSDateFormatter()
dateFormatter.dateStyle = .MediumStyle
dateLabel.text = dateFormatter.stringFromDate(run.timestamp)
let secondsQuantity = HKQuantity(unit: HKUnit.secondUnit(), doubleValue: run.duration.doubleValue)
timeLabel.text = "Time: " + secondsQuantity.description
let paceUnit = HKUnit.secondUnit().unitDividedByUnit(HKUnit.meterUnit())
let paceQuantity = HKQuantity(unit: paceUnit, doubleValue: run.duration.doubleValue / run.distance.doubleValue)
paceLabel.text = "Pace: " + paceQuantity.description
}
这里设置了跑步的详细信息,可以显示在屏幕的文本框里
1.首先,要设置你所在的地理位置
2.设置了运动轨迹的起始点
3.设置了速度的显示风格
将下面的方法加入类中
func mapRegion() -> MKCoordinateRegion {
let initialLoc = run.locations.firstObject as! Location
var minLat = initialLoc.latitude.doubleValue
var minLng = initialLoc.longitude.doubleValue
var maxLat = minLat
var maxLng = minLng
let locations = run.locations.array as! [Location]
for location in locations {
minLat = min(minLat, location.latitude.doubleValue)
minLng = min(minLng, location.longitude.doubleValue)
maxLat = max(maxLat, location.latitude.doubleValue)
maxLng = max(maxLng, location.longitude.doubleValue)
}
return MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: (minLat + maxLat)/2,
longitude: (minLng + maxLng)/2),
span: MKCoordinateSpan(latitudeDelta: (maxLat - minLat)*1.1,
longitudeDelta: (maxLng - minLng)*1.1))
}
MKCoordinateRegion可以根据你提供的中心位置,水平和竖直范围来确定当前屏幕显示的是哪个区域
例如,当你想让你的运动轨迹的显示看起来比较舒服一点的话,可以拖拽或者缩放地图。这一点需要明确地告诉用户,这样他看到的路线才能显示在屏幕中心
接着,添加下面这个方法
func mapView(mapView: MKMapView!, rendererForOverlay overlay: MKOverlay!) -> MKOverlayRenderer! {
if !overlay.isKindOfClass(MKPolyline) {
return nil
}
let polyline = overlay as! MKPolyline
let renderer = MKPolylineRenderer(polyline:polyline)
renderer.strokeColor = UIColor.blackColor()
renderer.lineWidth = 3
return renderer
}
这个方法表示当表示轨迹的曲线重合的时候,你的轨迹曲线的颜色会加深,让你看起来更直观,颜色加深的那一部分是由一连串的位置点产生的
接下来,你需要为polyline定义一个coordinates,添加这个方法
func polyline() -> MKPolyline {
var coords = [CLLocationCoordinate2D]()
let locations = run.locations.array as! [Location]
for location in locations {
coords.append(CLLocationCoordinate2D(latitude: location.latitude.doubleValue,
longitude: location.longitude.doubleValue))
}
return MKPolyline(coordinates: &coords, count: run.locations.count)
}
这里你将Location的数据传入到了CLLocationCoordinate2D这个数组中
然后,添加以下方法
func loadMap() {
if run.locations.count > 0 {
mapView.hidden = false
// Set the map bounds
mapView.region = mapRegion()
// Make the line(s!) on the map
loadMap()
} else {
// No locations were found!
mapView.hidden = true
UIAlertView(title: "Error",
message: "Sorry, this run has no locations saved",
delegate:nil,
cancelButtonTitle: "OK").show()
}
}
这个方法中,位置点绘制完毕,地图显示的区域为开始跑步前设置的区域,重合轨迹的部分做了加深的颜色渲染
最后,将这个方法添加到configView()的最后
loadMap()
现在,编译运行,你就可以在模拟器上看到这样的地图显示了
Finding the Right Color
这个App已经很cool了,但是你还可以显示用户跑的到底有多快,那样,他们就可以辨别在不同的地形环境中,他们有没有保持在合适的速率上
要做这个功能的话,你需要扩展polyline这个类
新建一个类,叫做MulticolorPolylineSegment,打开,删除里面的内容,写入以下代码
import UIKit
import MapKit
class MulticolorPolylineSegment: MKPolyline {
var color: UIColor?
}
这个自定义的polyline将用来渲染轨迹的每一个片段。颜色的深浅将表示速度的快慢,如此以外,它和MKPolyline是一样的。它们都是用来描绘连接两个位置点支架的线段
接下来你要确定,在什么样的线段上面使用什么样的颜色。添加这个类方法在MulticolorPolylineSegment 这个类中
private class func allSpeeds(forLocations locations: [Location]) -> (speeds: [Double], minSpeed: Double, maxSpeed: Double) {
// Make Array of all speeds. Find slowest and fastest
var speeds = [Double]()
var minSpeed = DBL_MAX
var maxSpeed = 0.0
for i in 1..<locations.count {
let l1 = locations[i-1]
let l2 = locations[i]
let cl1 = CLLocation(latitude: l1.latitude.doubleValue, longitude: l1.longitude.doubleValue)
let cl2 = CLLocation(latitude: l2.latitude.doubleValue, longitude: l2.longitude.doubleValue)
let distance = cl2.distanceFromLocation(cl1)
let time = l2.timestamp.timeIntervalSinceDate(l1.timestamp)
let speed = distance/time
minSpeed = min(minSpeed, speed)
maxSpeed = max(maxSpeed, speed)
speeds.append(speed)
}
return (speeds, minSpeed, maxSpeed)
}
这个方法会返回一个数组,这个数组装得是一连串的位置点相对应的速度值,其中也就包括了最大速度和最小速度。返回的多个值,你可以将它们放在一个元组里
首先,你应该注意的是输入的所有位置点是一个环。你需要将每一个Location转换成CLLocation,这里你可以使用 func distanceFromLocation(_ location: CLLocation!) -> CLLocationDistance 这个方法
根据物理学常识,速度 = 路程 / 时间,所以你就可以得到用户在跑步中每一时刻速度的变化情况
这个方法是私有方法,只能在类里面调用。然后,添加这个方法
class func colorSegments(forLocations locations: [Location]) -> [MulticolorPolylineSegment] {
var colorSegments = [MulticolorPolylineSegment]()
// RGB for Red (slowest)
let red = (r: 1.0, g: 20.0 / 255.0, b: 44.0 / 255.0)
// RGB for Yellow (middle)
let yellow = (r: 1.0, g: 215.0 / 255.0, b: 0.0)
// RGB for Green (fastest)
let green = (r: 0.0, g: 146.0 / 255.0, b: 78.0 / 255.0)
let (speeds, minSpeed, maxSpeed) = allSpeeds(forLocations: locations)
// now knowing the slowest+fastest, we can get mean too
let meanSpeed = (minSpeed + maxSpeed)/2
return colorSegments
}
这里,你定义了三种颜色分别表示慢速、中速、快速。每一种颜色,分别有它的RGB值得范围,最慢得部分是全红色,最快是全绿色,中速是纯黄色,其它时候颜色会根据速度大小在红色->黄色->绿色时间渐变,所以最后显示出来的结果一定会很炫目
需要注意的是你怎么样从allspeeds这个元组中拿到最大值、最小值和平均值
最后,在刚才的方法的后面到return colorSegments 之前加入这段代码
for i in 1..<locations.count {
let l1 = locations[i-1]
let l2 = locations[i]
var coords = [CLLocationCoordinate2D]()
coords.append(CLLocationCoordinate2D(latitude: l1.latitude.doubleValue, longitude: l1.longitude.doubleValue))
coords.append(CLLocationCoordinate2D(latitude: l2.latitude.doubleValue, longitude: l2.longitude.doubleValue))
let speed = speeds[i-1]
var color = UIColor.blackColor()
if speed < minSpeed { // Between Red & Yellow
let ratio = (speed - minSpeed) / (meanSpeed - minSpeed)
let r = CGFloat(red.r + ratio * (yellow.r - red.r))
let g = CGFloat(red.g + ratio * (yellow.g - red.g))
let b = CGFloat(red.r + ratio * (yellow.r - red.r))
color = UIColor(red: r, green: g, blue: b, alpha: 1)
}
else { // Between Yellow & Green
let ratio = (speed - meanSpeed) / (maxSpeed - meanSpeed)
let r = CGFloat(yellow.r + ratio * (green.r - yellow.r))
let g = CGFloat(yellow.g + ratio * (green.g - yellow.g))
let b = CGFloat(yellow.b + ratio * (green.b - yellow.b))
color = UIColor(red: r, green: g, blue: b, alpha: 1)
}
let segment = MulticolorPolylineSegment(coordinates: &coords, count: coords.count)
segment.color = color
colorSegments.append(segment)
}
在这里,你可以拿到预先计算的速度值、速度的范围,也就可以由速度变化的快慢程度来确定颜色变化的深浅程度
接下来,你可以根据两个像对应的坐标和颜色创建一个新的MulticolorPolylineSegment。最后,你收集到所有的颜色片段后,就可以准备开始渲染了
Applying the Multicolored Segments
想要让detail View Controller使用新的 multicolor polyline 很简单,打开DetailViewController.swift,找到 loadMap() 方法,将
mapView.addOverlay(polyline())
替换成
let colorSegments = MulticolorPolylineSegment.colorSegments(forLocations: run.locations.array as! [Location])
mapView.addOverlays(colorSegments)
这里创建了一个segments的数组,并且把所有overlays加到了map上
最后,你要准备给polyline上面每个segment渲染成特定的颜色,所以,用下面的代码重写你的mapView方法
func mapView(mapView:MKMapView!, rendererForOverlay Overlay:MKOverlay!)->NKOverlayRenderer!{
if !overlay.isKindOfClass(MulticolorPolylineSegment) {
return nil
}
let polyline = overlay as! MulticolorPolylineSegment
let renderer = MKPolylineRenderer(polyline: polyline)
renderer.strokeColor = polyline.color
renderer.lineWidth = 3
return renderer
}
看起来和之前的很像,但是现在,每一个segment都被渲染成了特定的颜色
再次编译运行,你就能看到这样一个色彩丰富的地图展示了
Leaving a Trail Of Breadcrumbs
最后生成的地图看起来很绚丽,但是在跑步过程中它是怎么样的呢
打开Main.storyboard 找到New Run Scene,拖一个MapKit View进来到“Ready to launch” label和start button之间
然后,为它添加约束
- 顶部距离label 20 point
- 地步距离button 20 point
- 左右距离superView都为0
然后打开 NewRunViewController.swift 添加
import MapKit
接着,添加成员属性
@IBOutlet weak var mapView:MKMapView!
在 viewWillAppear 方法中添加
mapView.hidden = true
使地图开始时处于hidden状态,在startPressed 方法末尾添加
mapView.hidden = false
点击start的时候地图出现
在文件末尾添加 class extension 实现代理方法
// MARK: - MKMapViewDelegate
extension NewRunViewController: MKMapViewDelegate {
func mapView(mapView: MKMapView!, rendererForOverlay overlay: MKOverlay!) -> MKOverlayRenderer! {
if !overlay.isKindOfClass(MKPolyline) {
return nil
}
let polyline = overlay as! MKPolyline
let renderer = MKPolylineRenderer(polyline: polyline)
renderer.strokeColor = UIColor.blueColor()
renderer.lineWidth = 3
return renderer
}
}
这里和 run details screen 里地很像,但是这里的stroke color仍然是蓝色的
接下来,你需要写代码去更新地图的显示区域,并且在每产生一个有效地Location的时候描绘轨迹,将你的locationManager(_:didUpdateLocations:)方法的实现更新成
func locationManager(manager: CLLocationManager!, didUpdateLocations locations: [AnyObject]!) {
for location in locations as! [CLLocation] {
let howRecent = location.timestamp.timeIntervalSinceNow
if abs(howRecent) < 10 && location.horizontalAccuracy < 20 {
//update distance
if self.locations.count > 0 {
distance += location.distanceFromLocation(self.locations.last)
var coords = [CLLocationCoordinate2D]()
coords.append(self.locations.last!.coordinate)
coords.append(location.coordinate)
let region = MKCoordinateRegionMakeWithDistance(location.coordinate, 500, 500)
mapView.setRegion(region, animated: true)
mapView.addOverlay(MKPolyline(coordinates: &coords, count: coords.count))
}
//save location
self.locations.append(location)
}
}
}
现在,你当前的位置始终在地图的最中心,同时,蓝色的运动轨迹随着你的运动在不断延伸
打开Main.storyboard找到NewRunScene,连接mapView 到map View,并且设置代理为当前控制器
编译运行,将会看到地图实时更新
Where To Go From Here
这里有这个例子的完整代码 http://cdn4.raywenderlich.com/wp-content/uploads/2015/05/MoonRunner-Part1-Final.zip
你可以看看怎么样用Core Data存储数据,怎么样在地图上显示详细的跑步信息,这是这个App最核心的部分
如果你的技术比较好的话,你可以试试怎么样使用海拔高度信息,怎么样改变轨迹宽度,怎么样使用一小段的平均速度使颜色变化比之前更加流畅
in any case,这篇教程还会有第二部分,为你介绍为每个用户定制的徽章奖励体制
注:
1.本文翻译自 http://www.raywenderlich.com/97944/make-app-like-runkeeper-swift-part-1
2.原博part 2 已更新,着急的同学可先行查看 http://www.raywenderlich.com/97945/make-app-like-runkeeper-swift-part-2
- 不定期送上iOS最新教程,但能力有限,翻译不准的地方还望指正
简书:@西木
微博:@角落里的monster