CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)

版本记录

版本号 时间
V1.0 2018.10.18 星期四

前言

很多的app都有定位功能,比如说滴滴,美团等,他们都需要获取客户所在的位置,并且根据位置推送不同的模块数据以及服务,可以说,定位方便了我们的生活,接下来这几篇我们就说一下定位框架CoreLocation。感兴趣的可以看我写的上面几篇。
1. CoreLocation框架详细解析 —— 基本概览(一)
2. CoreLocation框架详细解析 —— 选择定位服务的授权级别(二)
3. CoreLocation框架详细解析 —— 确定定位服务的可用性(三)
4. CoreLocation框架详细解析 —— 获取用户位置(四)
5. CoreLocation框架详细解析 —— 监控用户与地理区域的距离(五)
6. CoreLocation框架详细解析 —— 确定接近iBeacon(六)
7. CoreLocation框架详细解析 —— 将iOS设备转换为iBeacon(七)
8. CoreLocation框架详细解析 —— 获取指向和路线信息(八)
9. CoreLocation框架详细解析 —— 在坐标和用户友好的地名之间转换(九)
10. CoreLocation框架详细解析(十) —— 跟踪访问位置简单示例(一)
11. CoreLocation框架详细解析(十一) —— 跟踪访问位置简单示例(二)

开始

首先看一下写作环境

Swift 4, iOS 11, Xcode 9

Runkeeper是一款GPS应用程序,就像您即将制作的那样,拥有超过4000万用户! 本教程将向您展示如何制作像Runkeeper这样的应用程序。

运动跟踪应用程序Runkeeper拥有超过4000万用户!本教程将向您展示如何制作像Runkeeper这样的应用程序,它将教您如下:

  • 使用Core Location来跟踪您的路线。
  • 在跑步期间显示地图,并标记路径的不断更新的线。
  • 报告您跑步时的平均速度。
  • 奖励跑步各种距离的徽章。无论您的起点如何,每个徽章的银色和金色版本都能识别个人进步。
  • 通过跟踪到下一个徽章的剩余距离来鼓励您。
  • 完成后显示路线地图。地图线采用颜色编码,以反映您的步伐。

你的新应用程序 - MoonRunner - 在我们的太阳系中使用基于行星和卫星的徽章!

在您深入学习本教程之前,您应该熟悉StoryboardCore Data

这个教程也使用iOS 10的新MeasurementMeasurementFormatter功能。

有很多内容可以讨论,本教程分为两部分。第一部分重点是记录运行数据并渲染颜色编码的地图。第二部分介绍了徽章系统。

打开入门项目。 它包含完成本教程所需的所有项目文件和资产。

花几分钟时间来探索这个项目。 Main.storyboard已包含UI。 CoreDataStack.swiftAppDelegate中删除Apple的模板Core Data代码并将其放在自己的类中。Assets.xcassets包含您将使用的图像和声音。


Model: Runs and Locations - 模型:跑步和位置

MoonRunner使用Core Data非常简单,只使用两个实体:RunLocation

打开MoonRunner.xcdatamodeld并创建两个实体:RunLocation。 使用以下属性配置Run

CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)_第1张图片

Run有三个属性:distancedurationtimestamp。 它有一个关系,即locations,将它连接到Location实体。

注意:在下一步之后,您将无法设置反向关系。 这会引起警告。 别恐慌!

现在,使用以下属性设置Location

CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)_第2张图片

Location还有三个属性:latitude, longitudetimestamp以及单个关系,run

选择Run实体并验证其locations关系Inverse属性现在显示为“run”

CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)_第3张图片

选择locations关系,并将Type设置为To Many,然后选中Data Model Inspector’s Relationship窗格中的Ordered框。

CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)_第4张图片

最后,验证RunLocation实体的Codegen属性是否在Data Model InspectorEntity窗格中设置为Class Definition(这是默认值)。

CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)_第5张图片

构建项目,以便Xcode可以为您的Core Data模型生成必要的Swift定义。


Completing the Basic App Flow - 完成基本应用程序流程

打开RunDetailsViewController.swift并在viewDidLoad()之前添加以下行:

var run: Run!

接下来,在viewDidLoad中()之后添加以下函数:

private func configureView() {
}

最后,在调用super.viewDidLoad()之后的viewDidLoad()内部,添加对configureView()的调用。

configureView()

这设置了在应用程序中完成导航所需的最低限度。

打开NewRunViewController.swift并在viewDidLoad()之前添加以下行:

private var run: Run?

接下来,添加以下新方法:

private func startRun() {
  launchPromptStackView.isHidden = true
  dataStackView.isHidden = false
  startButton.isHidden = true
  stopButton.isHidden = false
}
  
private func stopRun() {
  launchPromptStackView.isHidden = false
  dataStackView.isHidden = true
  startButton.isHidden = false
  stopButton.isHidden = true
}

停止按钮和包含描述运行的标签的UIStackView隐藏在故事板中。 这些例程在“未运行”和“运行期间”模式之间切换UI。

startTapped()中,添加对startRun()的调用。

startRun()

在文件的末尾,在结束括号后,添加以下扩展名:

extension NewRunViewController: SegueHandlerType {
  enum SegueIdentifier: String {
    case details = "RunDetailsViewController"
  }
  
  override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
    switch segueIdentifier(for: segue) {
    case .details:
      let destination = segue.destination as! RunDetailsViewController
      destination.run = run
    }
  }
}

Apple的故事板segues接口通常被称为“stringly typed”。 segue标识符是一个字符串,没有错误检查。 利用Swift协议和枚举的强大功能,以及StoryboardSupport.swift中的一点点小精灵,您可以避免这种“stringly typed”界面的大部分痛苦。

接下来,将以下行添加到stopTapped()

let alertController = UIAlertController(title: "End run?", 
                                        message: "Do you wish to end your run?", 
                                        preferredStyle: .actionSheet)
alertController.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alertController.addAction(UIAlertAction(title: "Save", style: .default) { _ in
  self.stopRun()
  self.performSegue(withIdentifier: .details, sender: nil)
})
alertController.addAction(UIAlertAction(title: "Discard", style: .destructive) { _ in
  self.stopRun()
  _ = self.navigationController?.popToRootViewController(animated: true)
})
    
present(alertController, animated: true)

当用户按下停止按钮时,您应该让他们决定是保存,丢弃还是继续跑。 您使用UIAlertController来提示用户并获得他们的响应。

Build并运行。 按New Run按钮,然后按Start按钮。 验证UI是否更改为“running mode”

CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)_第6张图片

Stop按钮,确认按Save将您带到“详细信息”屏幕。

CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)_第7张图片

注意:在控制台中,您可能会看到一些如下所示的错误消息:

MoonRunner[5400:226999] [VKDefault] /BuildRoot/Library/Caches/com.apple.xbs/Sources/VectorKit_Sim/VectorKit-1295.30.5.4.13/src/MDFlyoverAvailability.mm:66: Missing latitude in trigger specification

这是正常现象,并不表示您的错误。


Units and Formatting - 单位和格式

iOS 10引入了新功能,使得使用和显示测量单位变得更加容易。 跑步者倾向于考虑他们在步速pace(每单位距离的时间)方面的进步,这是速度speed的反转(每单位时间的距离)。 您必须扩展UnitSpeed以支持步伐pace的概念。

将新的Swift文件添加到名为UnitExtensions.swift的项目中。 在import语句后添加以下内容:

class UnitConverterPace: UnitConverter {
  private let coefficient: Double
  
  init(coefficient: Double) {
    self.coefficient = coefficient
  }
  
  override func baseUnitValue(fromValue value: Double) -> Double {
    return reciprocal(value * coefficient)
  }
  
  override func value(fromBaseUnitValue baseUnitValue: Double) -> Double {
    return reciprocal(baseUnitValue * coefficient)
  }
  
  private func reciprocal(_ value: Double) -> Double {
    guard value != 0 else { return 0 }
    return 1.0 / value
  }
}

在扩展UnitSpeed以进行pace测量之间转换之前,必须创建一个可以处理数学运算的UnitConverter。 子类化UnitConverter要求您实现baseUnitValue(fromValue :)value(fromBaseUnitValue :)

现在,将此代码添加到文件的末尾:

extension UnitSpeed {
  class var secondsPerMeter: UnitSpeed {
    return UnitSpeed(symbol: "sec/m", converter: UnitConverterPace(coefficient: 1))
  }
  
  class var minutesPerKilometer: UnitSpeed {
    return UnitSpeed(symbol: "min/km", converter: UnitConverterPace(coefficient: 60.0 / 1000.0))
  }
  
  class var minutesPerMile: UnitSpeed {
    return UnitSpeed(symbol: "min/mi", converter: UnitConverterPace(coefficient: 60.0 / 1609.34))
  }
}

UnitSpeedFoundation中提供的众多Unit之一。 UnitSpeed的默认单位是“米/秒”。 您的扩展允许以minutes/km 或者 minutes/mile表示速度。

您需要一种统一的方式来显示MoonRunner中的距离,时间,步速和日期等数量。 MeasurementFormatterDateFormatter使这很简单。

将新的Swift文件添加到名为FormatDisplay.swift的项目中。 在import语句后添加以下内容:

struct FormatDisplay {
  static func distance(_ distance: Double) -> String {
    let distanceMeasurement = Measurement(value: distance, unit: UnitLength.meters)
    return FormatDisplay.distance(distanceMeasurement)
  }
  
  static func distance(_ distance: Measurement) -> String {
    let formatter = MeasurementFormatter()
    return formatter.string(from: distance)
  }
  
  static func time(_ seconds: Int) -> String {
    let formatter = DateComponentsFormatter()
    formatter.allowedUnits = [.hour, .minute, .second]
    formatter.unitsStyle = .positional
    formatter.zeroFormattingBehavior = .pad
    return formatter.string(from: TimeInterval(seconds))!
  }
  
  static func pace(distance: Measurement, seconds: Int, outputUnit: UnitSpeed) -> String {
    let formatter = MeasurementFormatter()
    formatter.unitOptions = [.providedUnit] // 1
    let speedMagnitude = seconds != 0 ? distance.value / Double(seconds) : 0
    let speed = Measurement(value: speedMagnitude, unit: UnitSpeed.metersPerSecond)
    return formatter.string(from: speed.converted(to: outputUnit))
  }
  
  static func date(_ timestamp: Date?) -> String {
    guard let timestamp = timestamp as Date? else { return "" }
    let formatter = DateFormatter()
    formatter.dateStyle = .medium
    return formatter.string(from: timestamp)
  }
}

这些简单的功能应该是不言自明的。 在pace(distance:seconds:outputUnit :)中,必须将MeasurementFormatterunitOptions设置为.providedUnits,以防止它显示速度的本地化测量值(例如mphkph)。


Starting a Run - 开始跑步

现在几乎是开始跑步的时候了。 但首先,应用程序需要知道它在哪里。 为此,您将使用Core Location。 重要的是,您的应用程序中只有一个CLLocationManager实例,并且不会无意中删除它。

为此,请将另一个Swift文件添加到名为LocationManager.swift的项目中。 用以下内容替换文件的内容:

import CoreLocation

class LocationManager {
  static let shared = CLLocationManager()
  
  private init() { }
}

在开始跟踪用户的位置之前,您需要进行一些项目级别的更改。

首先,单击Project Navigator顶部的项目。

选择Capabilities选项卡并将Background Modes切换为ON。 勾选Location updates

CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)_第8张图片

接下来,打开Info.plist。 单击Information Property List旁边的+。 从生成的下拉列表中,选择Privacy – Location When In Use Usage Description并将其值设置为MoonRunner needs access to your location in order to record and track your run!

注意:此Info.plist密钥至关重要。 没有它,您的用户将永远无法授权您的应用访问位置服务。

在您的应用可以使用位置信息之前,它必须获得用户的许可。 在return true之前,打开AppDelegate.swift并将以下内容添加到application(_:didFinishLaunchingWithOptions:)

let locationManager = LocationManager.shared
locationManager.requestWhenInUseAuthorization()

打开NewRunViewController.swift并导入CoreLocation

import CoreLocation

接下来,在run属性之后添加以下内容:

private let locationManager = LocationManager.shared
private var seconds = 0
private var timer: Timer?
private var distance = Measurement(value: 0, unit: UnitLength.meters)
private var locationList: [CLLocation] = []

分步细说:

  • locationManager是您用于启动和停止位置服务的对象。
  • seconds跟踪跑步的持续时间,以秒为单位。
  • timer将每秒触发并相应地更新UI。
  • distance保持跑步的累积距离。
  • locationList是一个数组,用于保存运行期间收集的所有CLLocation对象。

viewDidLoad()之后添加以下内容:

override func viewWillDisappear(_ animated: Bool) {
  super.viewWillDisappear(animated)
  timer?.invalidate()
  locationManager.stopUpdatingLocation()
}

这确保了当用户离开视图时停止位置更新,消耗电池以及计时器。

添加以下两种方法:

func eachSecond() {
  seconds += 1
  updateDisplay()
}

private func updateDisplay() {
  let formattedDistance = FormatDisplay.distance(distance)
  let formattedTime = FormatDisplay.time(seconds)
  let formattedPace = FormatDisplay.pace(distance: distance, 
                                         seconds: seconds, 
                                         outputUnit: UnitSpeed.minutesPerMile)
   
  distanceLabel.text = "Distance:  \(formattedDistance)"
  timeLabel.text = "Time:  \(formattedTime)"
  paceLabel.text = "Pace:  \(formattedPace)"
}

eachSecond()将由您将很快设置的Timer每秒调用一次。

updateDisplay()使用您在FormatDisplay.swift中构建的花哨格式化功能来使用当前运行的详细信息更新UI。

Core Location通过其CLLocationManagerDelegate报告位置更新。 现在将其添加到文件末尾的扩展中:

extension NewRunViewController: CLLocationManagerDelegate {

  func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    for newLocation in locations {
      let howRecent = newLocation.timestamp.timeIntervalSinceNow
      guard newLocation.horizontalAccuracy < 20 && abs(howRecent) < 10 else { continue }

      if let lastLocation = locationList.last {
        let delta = newLocation.distance(from: lastLocation)
        distance = distance + Measurement(value: delta, unit: UnitLength.meters)
      }

      locationList.append(newLocation)
    }
  }
}

每次Core Location更新用户的位置时,都会调用此委托方法,从而提供CLLocation对象的数组。 通常此数组仅包含一个对象,但如果有更多,则按时间排序,最后一个位置为最后一个。

CLLocation包含一些很好的信息,包括读取的纬度,经度和时间戳。

在盲目接受读取数据之前,值得检查数据的准确性。 如果设备不确定其读数在距离用户实际位置20米的范围内,则最好将其保留在数据集之外。 确保数据是最近的也很重要。

注意:当设备首次开始缩小用户的一般区域时,此检查在跑步开始时尤为重要。 在那个阶段,它可能会更新前几个点的一些不准确的数据。

如果CLLocation通过了检查,则它与最近保存的点之间的距离将添加到运行的累积距离。 distance(from:)非常方便,考虑到一些涉及地球曲率的令人惊讶的困难数学,并以米为单位返回距离。

最后,位置对象本身被添加到不断增长的位置阵列中。

现在在NewRunViewController(不是扩展中)中添加以下方法:

private func startLocationUpdates() {
  locationManager.delegate = self
  locationManager.activityType = .fitness
  locationManager.distanceFilter = 10
  locationManager.startUpdatingLocation()
}

您将此类作为Core Location的委托,以便您可以接收和处理位置更新。

activityType参数专门针对这样的应用程序。 它有助于设备在整个用户的运行过程中智能地节省电力,例如当他们停下来越过道路时。

最后,您将distanceFilter设置为10米。 与activityType相反,此参数不会影响电池寿命。 activityType用于读取,distanceFilter用于报告读取。

正如您稍后进行测试后所看到的那样,位置读数可能会偏离直线。 更高的distanceFilter可以减少ziggingzagging,从而为您提供更准确的线条。 不幸的是,过高的过滤器会使您的读数像素化。 这就是为什么10米是一个很好的平衡。

最后,您告诉Core Location开始获取位置更新!

要实际开始运行,请将这些行添加到startRun()的末尾:

seconds = 0
distance = Measurement(value: 0, unit: UnitLength.meters)
locationList.removeAll()
updateDisplay()
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
  self.eachSecond()
}
startLocationUpdates()

这会将跑步期间要更新的所有值重置为初始状态,启动每秒将触发的Timer,并开始收集位置更新。


Saving the Run - 保存跑步

在某些时候,您的用户会感到疲倦并停止跑步。 你有UI可以做到这一点,但你还需要保存跑步的数据,否则你的用户会非常不高兴看到所有这些努力都浪费掉了。

将以下方法添加到NewRunViewController.swift中的NewRunViewController

private func saveRun() {
  let newRun = Run(context: CoreDataStack.context)
  newRun.distance = distance.value
  newRun.duration = Int16(seconds)
  newRun.timestamp = Date()
  
  for location in locationList {
    let locationObject = Location(context: CoreDataStack.context)
    locationObject.timestamp = location.timestamp
    locationObject.latitude = location.coordinate.latitude
    locationObject.longitude = location.coordinate.longitude
    newRun.addToLocations(locationObject)
  }
  
  CoreDataStack.saveContext()
  
  run = newRun
}

如果你在Swift 3之前使用过Core Data,你会发现iOS 10 Core Data支持的强大和简单。 您可以像创建任何其他Swift对象一样创建新的Run对象并初始化其值。 然后,为记录的每个CLLocation创建一个Location对象,仅保存相关数据。 最后,使用自动生成的addToLocations(_ :)将每个新Location添加到Run中。

当用户结束运行时,您希望停止跟踪位置。 将以下内容添加到stopRun()的末尾:

locationManager.stopUpdatingLocation()

最后,在stopTapped()中找到标题为“Save”UIAlertAction并添加对self.saveRun()的调用,使其看起来像这样:

alertController.addAction(UIAlertAction(title: "Save", style: .default) { _ in
  self.stopRun()
  self.saveRun() // ADD THIS LINE!
  self.performSegue(withIdentifier: .details, sender: nil)
})

后记

本篇主要讲述了仿Runkeeper的简单实现,感兴趣的给个赞或者关注~~~

CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一)_第9张图片

你可能感兴趣的:(CoreLocation框架详细解析(十二) —— 仿Runkeeper的简单实现(一))