运动-跑步行走骑行

image.png

很久没有更新文章了, 最近完成一项类似KEEP的运动软件.简单总结一下思想和实现方式.(因为原项目是swift,所以就用swift简单演示一下)

1. 架构.
image.png
  • MVC-State数据驱动
    -- Manager 业务管理器(数据管理,语音管理,运动轨迹管理)
    -- Model 业务模型(数据didSet发出通知)
    -- Module 业务模块(展示图表,运动主页,地图模块,排行模块,分享模块,设置模块,上传模块等等)
    -- Router 业务流转路由
    -- Lib 库文件
1. 实现.

SportTrackingManager.swift运动管理器通过高德定位AMapLocationManager回调方法绘制轨迹,判断GPS信号,计算运动数据.通过CMPedometer计步器校准步数,通过CMAltimeter气压计校准海拔数据,通过CMMotionActivityManager活动对象判断用户行为.

/// 定位管理
    public lazy var locationManager = AMapLocationManager()
/// 计步类
    private var pedometer: CMPedometer?
/// 气压计
    private var altimeter: CMAltimeter?
/// 活动器对象
    public var motionActivityManager: CMMotionActivityManager?
    public var motionActivitystatus: CMMotionActivity?

GPS 信号策略
1.定位管理返回的精度策略
i. 如果水平精度或垂直精度均小于0 或者 水平精度或垂直精度均大于等于100 --> 认为已经失去了GPS信号
ii. 如果水平精度和垂直精度均小于等于10 --> 认为GPS信号非常的好
iii. 如果水平精度小于60 --> 认为GPS信号一般
iiii.其他情况下 --> 认为GPS信号很差
2.定位返回位置的间隔时间判断
如果当前位置点相对等待了 超过了5s --> 认为GPS信号很差
如果当前位置点相对等待了 超过了10s --> 认为GPS信号失去

@objc private func gpsBad() {
        if self.gpsStatus != .Bad {
            self.gpsStatus = .Bad
        }
    }
    
    @objc private func gpsDisconnected() {
        if self.gpsStatus != .Disconnected {
            self.gpsStatus = .Disconnected
        }
    }
    
    @objc private func gpsDistanceFilter() {
        self.locationManager.distanceFilter = kCLDistanceFilterNone
    }
    
    // MARK:- GPS信号强弱时间判断
    private func gpsPerformRequests () {
        NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(gpsBad), object: nil)
        NSObject.cancelPreviousPerformRequests(withTarget: self, selector: #selector(gpsDisconnected), object: nil)
        self.perform(#selector(gpsBad), with: nil, afterDelay: 5)
        self.perform(#selector(gpsDisconnected), with: nil, afterDelay: 10)
    }
    
    // MARK:- GPS信号强弱精度判断
    public func gpsStatusWithCLLocation(location: CLLocation) -> SportTrackingGPSStatus {
        
        if location.horizontalAccuracy < 0 || location.verticalAccuracy < 0 || location.horizontalAccuracy >= SportConfig.disconnectLimitAccuracy || location.verticalAccuracy >= SportConfig.disconnectLimitAccuracy {
            return .Disconnected
        } else if location.horizontalAccuracy <= SportConfig.bestLimitAccuracy {
            return .Good
        } else if location.horizontalAccuracy < SportConfig.normalLimitAccuracy {
            return .Normal
        } else {
            return .Bad
        }
    }

实时定位打点轨迹策略

  1. 定位点时间戳与当前时间戳差值 > 1s 认为不是当前的点位 去除
  2. GPS信号丢失 去除
  3. 上一次获取点位时间戳与当前点位时间戳差值 <= 0.1s 认为在原地不动 去除
  4. 上一次获取点位位置与当前点位位置差值 <= 0.1m 认为原地不动 去除
  5. GPS获取当前点位置的速度 = 0 去除
  6. 自动检测当前运动类型是驾车和静止 去除
  7. 根据运动类型判断速度策略
    行走 --最小速度 minLimitSpeed = 0.3 m/s
    --最大速度 max(当前计算速度, gps返回的当前速度) > 5.5 m/s -> // 估算人类竞走最快纪录 5.5 m/s
    跑步 --最小速度 minLimitSpeed = 0.5 m/s
    --最大速度 max(当前计算速度, gps返回的当前速度) > 100 / 9.58 m/s -> // 目前最新百米赛跑的世界纪录是9.58 秒
    骑行 --最小速度 minLimitSpeed = 0.8 m/s
    --最大速度 max(当前计算速度, gps返回的当前速度) > 56 m/s -> // 公路自行车世界纪录 202km/h = 56 m/s
  8. 当gps信号很差的时候过滤:
    max(当前计算速度, gps返回的当前速度) > 300米后计算出的平均速度 * 2 如果当前计算出来的的速度跳变为当前平均速度的2倍 去除
  9. 手动和自动暂停 去除
  10. 经过卡尔曼滤波处理 优化轨迹平滑度
  11. 三角滤波去噪抽稀
  12. 道格拉斯算法压缩轨迹
extension SportTrackingManager : AMapLocationManagerDelegate{

    func amapLocationManager(_ manager: AMapLocationManager!, didFailWithError error: Error!) {
        //kCLErrorDenied
        manager.startUpdatingLocation()
    }
    
    // MARK:- 连续定位方法
    func amapLocationManager(_ manager: AMapLocationManager!, didUpdate location: CLLocation!, reGeocode: AMapLocationReGeocode!) {
        if self.status == .Finish || self.status == .Cancel {return}
        guard let location = location else { return }
        guard let currentSportTracking = self.currentSportTracking else {return}
        if self.locationManager.distanceFilter == kCLDistanceFilterNone {
            self.locationManager.distanceFilter = 5
        }
        if currentSportTracking.subType == SportTrackingSubType.indoorRun.rawValue {return}
        if currentSportTracking.subType != SportTrackingSubType.ride.rawValue && CMPedometer.isStepCountingAvailable() && currentSportTracking.coreMotionDistance == 0 {
            return
        }
        // GPS 信号 - 时间判断
        if currentSportTracking.subType != SportTrackingSubType.ride.rawValue {
            self.gpsPerformRequests()
        }
        self.gpsStatus = gpsStatusWithCLLocation(location: location)
        let howRecent = location.timestamp.timeIntervalSinceNow
        if  fabs(howRecent) >= 1 {return}

        var mapLocation = location
        
        // 去噪抽稀
        if currentSportTracking.lineArray.count >= 2 {
            
            var pre = currentSportTracking.lineArray[currentSportTracking.lineArray.count - 2]
            var cur = currentSportTracking.lineArray[currentSportTracking.lineArray.count - 1]

            let prev = MAMapPointForCoordinate(CLLocationCoordinate2DMake(pre.coordinate.latitude, pre.coordinate.longitude))
            let curP = MAMapPointForCoordinate(CLLocationCoordinate2DMake(cur.coordinate.latitude,cur.coordinate.longitude))
            let nextP = MAMapPointForCoordinate(CLLocationCoordinate2DMake(location.coordinate.latitude,location.coordinate.longitude))
            
            let threshHold = calculateDistance(pt: curP, begin: prev, end: nextP)
            if threshHold > 20 { // 垂直距离超过一定阈值认为是噪点去除
                #if DEBUG
                self.showCentralToast("过滤漂移点")
                #endif
                return
            }
            
            if threshHold > SportConfig.threshHold {  // 垂直距离在某些范围内,利用三角重心重新拉回点
                let curLatitude = (pre.coordinate.latitude + cur.coordinate.latitude + location.coordinate.latitude) / 3
                let curLongitude = (pre.coordinate.longitude + cur.coordinate.longitude + location.coordinate.longitude) / 3
                cur = CLLocation(latitude: curLatitude, longitude: curLongitude)
                currentSportTracking.lineArray[currentSportTracking.lineArray.count - 1] = cur
            }
            mapLocation = cur
            currentSportTracking.lineArray.append(location)
        } else {
            currentSportTracking.lineArray.append(location)
            return
        }
        
        
        // 上一次位置
        self.lastLocation = self.curLocation
        
        // 用户最新位置
        self.curLocation = mapLocation
        
        // 上一次没有位置
        guard self.lastLocation != nil else {return}
        guard self.curLocation != nil else {return}
        
        if #available(iOS 9.0, *) {
            // 卡尔曼滤波处理
            self.curLocation = hcKalmanFilter(location: self.curLocation!)
        }

        // GPS 信号信号丢失丢弃定位点
        if location.horizontalAccuracy >= SportConfig.disconnectLimitAccuracy  {return}
        // 2个时间差
        var delta = self.curLocation!.timestamp.timeIntervalSince(self.lastLocation!.timestamp)
        // 2个距离差
        var distance = self.curLocation!.distance(from: self.lastLocation!)
      
        if (distance.isNaN || distance <= 0.1) {
            return
        }
        
        if (delta.isNaN || delta <= 0.1) {
            return
        }

        if self.curLocation!.speed == 0.0 {return}
        
        if self.curLocation!.horizontalAccuracy > SportConfig.bestLimitAccuracy * 2   {
            // 处理线段添加前速度策略
            if handleTrack(isHandle: false, curSpeed: distance / delta) == false {return}

            // 运动状态控制
            if self.status == .Sporting  {
                if currentSportTracking.locationsArray.count > 0 {
                    if lastFlag == false {
                        self.lastLocation = currentSportTracking.locationsArray.last?.endLocation ?? CLLocation(coordinate: CLLocationCoordinate2D(latitude: currentSportTracking.locationsArray.last?.latitude ?? 0, longitude: currentSportTracking.locationsArray.last?.longitude ?? 0), altitude: currentSportTracking.locationsArray.last?.altitude ?? 0, horizontalAccuracy: currentSportTracking.locationsArray.last?.accuracy ?? 10, verticalAccuracy: currentSportTracking.locationsArray.last?.verticalAccuracy ?? 10, timestamp: Date(timeIntervalSince1970: TimeInterval(currentSportTracking.locationsArray.last?.timestamp ?? 0)))
                    } else {
                        lastFlag = false // 线段接续上一次的点位
                    }
                }
            } else {
                lastFlag = true // 线段中断
                return
            }
        }
        // 总距离计算
        distanceTmp = distanceTmp + self.curLocation!.distance(from: self.lastLocation!)
        delta = self.curLocation!.timestamp.timeIntervalSince(self.lastLocation!.timestamp)
        distance = self.curLocation!.distance(from: self.lastLocation!)
 
        let speed = (distance / delta).roundTo(places: 2) > minLimitSpeed ?  (distance / delta).roundTo(places: 2) : minLimitSpeed
        
        if self.status == .Paused  {return}
        currentSportTracking.totalDistance = (distanceTmp / 1000.0).roundTo(places: 2)
        // 将点位添加入数组
        let line = SportTrackingLine()
        line.sportId = currentSportTracking.id
        line.beginLocation = self.lastLocation
        line.endLocation = self.curLocation
        line.distance = currentSportTracking.totalDistance * 1000
        line.relativetime = currentSportTracking.totalTime
        line.speed = currentSportTracking.avgSpeed_m_s > minLimitSpeed ? currentSportTracking.avgSpeed_m_s : minLimitSpeed
        line.mileage = Int(distanceTmp)
        
        if CMPedometer.isStepCountingAvailable() {
            line.relativeAltitude = self.relativeAltitude
            line.pressure = self.pressure
        }
        
        // 当前速度计算
        currentSportTracking.curSpeed_m_s = speed > minLimitSpeed ? speed : line.speed
        currentSportTracking.curSpeed = String.stringifyAvgPaceFromDist(meters: currentSportTracking.curSpeed_m_s, seconds: 1)
        
        // 判断是否本次是否异常
        if currentSportTracking.locationsArray.count > 10 &&  self.curLocation!.horizontalAccuracy <= SportConfig.bestLimitAccuracy  && self.lastLocation!.horizontalAccuracy <= SportConfig.bestLimitAccuracy {
            if CMMotionActivityManager.isActivityAvailable() {
                if (self.motionActivitystatus?.automotive ?? false) {
                     handleTrack(isHandle: true,curSpeed: speed)
                }
            } else {
                handleTrack(isHandle: true,curSpeed: speed)
            }
            
        }
        
        // 发出产生线段通知
        let notiName = SportNotification.Notification.SportTrackingLineBeGenerated.rawValue
        NotificationCenter.default.post(name: NSNotification.Name(rawValue: notiName), object: self, userInfo: [notiName:self.curLocation!])
        
        let realm = SportDataManager.realm()
        try? realm.write {
            currentSportTracking.locationsArray.append(line)
        }
    }
}

// MARK:- 数据处理
extension SportTrackingManager {
    
    // MARK:- 卡尔曼滤波
    private func hcKalmanFilter(location: CLLocation) -> (CLLocation) {
        if hcKalmanFilter == nil {
            self.hcKalmanFilter = HCKalmanAlgorithm(initialLocation: location)
        } else {
            if let hcKalmanFilter = self.hcKalmanFilter {
                if resetKalmanFilter == true {
                    hcKalmanFilter.resetKalman(newStartLocation: location)
                    resetKalmanFilter = false
                } else {
                    return hcKalmanFilter.processState(currentLocation: location)
                }
            }
        }
        return location
    }
    // MARK:- 计算当前点到线的垂线距离
    private func calculateDistance(pt: MAMapPoint,begin: MAMapPoint, end: MAMapPoint) -> Double {
        var mappedPoint: MAMapPoint = MAMapPoint(x: 0, y: 0)
        var dx = begin.x - end.x
        var dy = begin.y - end.y
        if(fabs(dx) < 0.00000001 && fabs(dy) < 0.00000001 ) {
            mappedPoint = begin
        } else {
            var u = (pt.x - begin.x)*(begin.x - end.x) + (pt.y - begin.y)*(begin.y - end.y)
            u = u/((dx*dx)+(dy*dy))
            
            mappedPoint.x = begin.x + u*dx
            mappedPoint.y = begin.y + u*dy
        }
        
        return MAMetersBetweenMapPoints(pt, mappedPoint)
    }
    
}

语音播报使用AVFoundation的AVQueuePlayer先读取本地语音文件

    /// 初始化
    private override init(){
        super.init()
        guard let voicePath = SportAssets.hostBundle.url(forResource: "voice.bundle/voice.json", withExtension: nil),
            let data = try? Data(contentsOf: voicePath) else{
                fatalError("`JSON File Fetch Failed`")
        }

        // JSON序列化
        guard let json = try? JSONSerialization.jsonObject(with: data, options: .mutableContainers),
            let dict = json as? [String: Any] else{
                fatalError("`JSON Data Serialize Failed`")
        }
        
        try? AVAudioSession.sharedInstance().setCategory(AVAudioSessionCategoryPlayback, with: .duckOthers)
        try? AVAudioSession.sharedInstance().setActive(true)
        voiceDict = dict
    }

    /// 语音播放
    @objc private func speakText(text: String) {
        let manager = SportTrackingManager.shareInstance
        if canSpeak() { return }  //控制是否静音
        guard let player = player else {return}
        let itemStringArray = text.components(separatedBy: ",")
        guard let folderPath = SportAssets.hostBundle.path(forResource: "voice.bundle", ofType: nil) else {return}
        for itemString in itemStringArray {
            guard voiceDict.keys.contains(itemString) else {return}
            
            let itemPath = "\(folderPath)/\(voiceDict[itemString]!)"
            if voiceDict[itemString] == nil || FileManager.default.fileExists(atPath: itemPath) == false {
                break
            }
            let itemURL = URL(fileURLWithPath: itemPath)
            let item = AVPlayerItem(url: itemURL)

            if player.items().count == 0 {
                player.replaceCurrentItem(with: item)
            } else {
                if player.canInsert(item, after: player.items().last) {
                    player.insert(item, after: player.items().last)
                }
            }
        }
        if isCanSpeak || (manager.status == .Finish ) || (isAutoComplete) {
            player.play()
        }
    }

数据存储使用Realm


    override static func primaryKey() -> String? {
        return "primaryKey"
    }
    
    @objc dynamic var primaryKey: String?
    
    /// 运动id
    @objc dynamic var id:Int64 = -1
    
    /// 用户id
    @objc dynamic var userId: Int64 = 0
    
    /// 状态(运动开始与结束)
    @objc dynamic var status = SportTrackingSaveStatus.Start.rawValue
    
    /// 状态(上传成功与失败: 0: 失败,1: 成功)
    @objc dynamic var upstatus = 0
    
    /// 轨迹是否异常(0: 异常,1: 正常)
    @objc dynamic var trackStatus = 1
    
    /// 进程杀死后是否需要恢复运动(0: 不需要,1: 需要)
    @objc dynamic var killStatus = 0
    
    /// 是否是目标跑(0: 不是,1: 是)
    @objc dynamic var targetRun = 0
    
    /// 目标类型(0: 距离, 1: 时间, 2: 卡路里, 3:速度 )
    @objc dynamic var targetRunType = 0
    
    /// 线段集合
    var locationsArray = List<SportTrackingLine>()
    
    /// 全程的平均速度
    var avgSpeed_m_s_array = List<Double>()

    /// 1公里数组
    var SportUnitSpeedArray = List<SportUnitSpeedRecord>()
    
    /// 步频数组
    var sportStepRecords = List<SportCoreMotionModel>()

    /// 线段开始时间,1970开始
    @objc dynamic var startTime: Int64 = 0 {
        didSet{
            primaryKey = "\(startTime)"
        }
    }
    
    /// 线段截止时间
    @objc dynamic var endTime: Int64 = 0
    

    /// 运动类型
    @objc dynamic var type: String = SportTrackingType.Walk.rawValue
    
    /// 运动子类型
    @objc dynamic var subType: String = SportTrackingSubType.outdoorRun.rawValue

    /// 轨迹总距离 单位(公里)
    @objc dynamic var totalDistance: Double = 0.00 {
        didSet{
            if totalDistance < oldValue {
                totalDistance = oldValue
            }
            if totalDistance > oldValue  {
                calculate(totalDistance: totalDistance)
            }
        }
    }
    ///  最高海拔
    @objc dynamic var altitudeMax = 0
    
    ///  最低海拔
    @objc dynamic var altitudeMin = 0
    
    ///  卡路里
    @objc dynamic var calorie = 0 {
        didSet{
            if calorie < oldValue {
                calorie = oldValue
            }
            if calorie != oldValue {
                //时间发生变化时发出通知
                let notiName = SportNotification.Notification.SportTrackingCalorieChanged.rawValue
                NotificationCenter.default.post(name: NSNotification.Name(rawValue: notiName), object: self, userInfo: [notiName:calorie])
            }
        }
    }
    
    ///  轨迹的key
    @objc dynamic var trace: String?
    
    ///  步频的key
    @objc dynamic var stepKey: String?
    
    ///  轨迹图片的key
    @objc dynamic var traceImg: String?

    ///  步数
    @objc dynamic var numberOfSteps = 0
    
    ///  步速
    @objc dynamic var currentPace = 0.0 
    
    ///  上楼
    @objc dynamic var floorsAscended = 0.0
    
    ///  下楼
    @objc dynamic var floorsDescended = 0.0
    
    ///  传感器的距离
    @objc dynamic var coreMotionDistance = 0.0
    /// 步频限制参数
    @objc dynamic var currentCadenceLimit = 0.0
    /// 总共时长 s/秒
    @objc dynamic var totalTime: Double = 0.0
   /// 总共时长字符串
   @objc dynamic var totalTimeString: String {
        if self.totalTime == 0.0 {
            return "00:00:00"
        }
        return String.formatTime(time: self.totalTime)
    }
    
    /// 平均速度(字符串)分/公里
    @objc dynamic var avgSpeed: String = "0'00\""
    
    /// 当前格式化配速
    @objc dynamic var curSpeed: String = "0'00\""
    
    /// 平均速度 m/s
    @objc dynamic var avgSpeed_m_s: Double = 0.0
    
    /// 当前速度 m/s
    @objc dynamic var curSpeed_m_s: Double = 0.0
    
    @objc dynamic var maxSpeed: Double = 0.0
    
    @objc dynamic var minSpeed: Double = 0.0
    
    /// 1公里内最快速度
    @objc dynamic var maxSpeed_1: String {
        if SportUnitSpeedArray.count == 0 {return "0'00\""}
        let str = String.stringifyAvgPaceFromDist(meters:SportUnitSpeedArray.map{$0.speed}.max() ?? 0.0,seconds:1)
        return str
    }
    
    /// 1公里内最慢速度
    @objc dynamic var minSpeed_1: String {
        if SportUnitSpeedArray.count == 0 {return "0'00\""}
        let str = String.stringifyAvgPaceFromDist(meters:SportUnitSpeedArray.filter{ $0.speed != 0.0}.map{$0.speed}.min() ?? 0.0,seconds:1)
        return str
    }

    ///目标:跑量
    @objc dynamic var targetMileage: Int = 0
    
    ///目标:用时
    @objc dynamic var targetCastTime: Int64 = 0
    
    ///目标:热量
    @objc dynamic var targetCalorie: Int = 0
    
    ///目标:速度
    @objc dynamic var targetSpeed: Double = 0.0
    
    ///五公里时间
    @objc dynamic var fiveKmTime: Int64 = 0
    
    ///十公里时间
    @objc dynamic var tenKmTime: Int64 = 0
    
    ///半马时间
    @objc dynamic var halfMarathonTime: Int64 = 0 //21KM
    
    ///全马时间
    @objc dynamic var marathonTime: Int64 = 0 //42KM
    
    ///目标:数值字符串
    @objc dynamic var targetValueStr: String = ""

    

地图MAMapView: 自定义脉冲圈, 根据速度渐变色绘制线段.

    public lazy var mapView: MAMapView = {
        let mapView = MAMapView(frame:self.bounds)
        AMapServices.shared().enableHTTPS = true
        mapView.showsUserLocation = true
        mapView.showsCompass = false
        mapView.delegate = self
        mapView.setZoomLevel(16, animated: false)
        mapView.maxZoomLevel = 19
        mapView.userTrackingMode = .follow
        mapView.customizeUserLocationAccuracyCircleRepresentation = true
        mapView.showsScale = false
        mapView.isRotateEnabled = false
        mapView.isRotateCameraEnabled = false
       // 将高德地图标记去除
        for subview in mapView.subviews {
            if subview is UIImageView {
                subview.layer.contents = UIImage().cgImage
            }
        }
        return mapView
    } ()

 public func addSportTrackLines(currentSportTracking: SportTracking?) {
        guard let curSportTracking = currentSportTracking else { return }
        if curSportTracking.locationsArray.count == 0 {return}
        for line in curSportTracking.locationsArray {
            addPolyline(line: line,currentSportTracking: curSportTracking)
        }
        if isPlayBack {  
            showOverlays()
            let pointAnnotationfirst = MAPointAnnotation()
            pointAnnotationfirst.title = "起点"
            let endCoordinatefirst = CLLocationCoordinate2D(latitude: curSportTracking.locationsArray.first!.preLatitude, longitude: curSportTracking.locationsArray.first!.preLongitude)
            pointAnnotationfirst.coordinate = endCoordinatefirst
            self.mapView.addAnnotation(pointAnnotationfirst)
            
            if curSportTracking.locationsArray.count > 1 {
                let pointAnnotationlast = MAPointAnnotation()
                pointAnnotationlast.title = "终点"
                let endCoordinatelast = CLLocationCoordinate2D(latitude: curSportTracking.locationsArray.last!.latitude, longitude: curSportTracking.locationsArray.last!.longitude)
                pointAnnotationlast.coordinate = endCoordinatelast
                self.mapView.addAnnotation(pointAnnotationlast)
            }
            if endCoordinates.count > 0 {
                pointArray = endCoordinates.map { (endCoordinate) -> CGPoint in
                    let point = mapView.convert(endCoordinate, toPointTo: self)
                    return point
                }
            }
            
        } else {
            let endCoordinatelast = CLLocationCoordinate2D(latitude: curSportTracking.locationsArray.last!.latitude, longitude: curSportTracking.locationsArray.last!.longitude)
            self.mapView.showOverlays(self.mapView.overlays, edgePadding: UIEdgeInsets(top: 100, left: 100, bottom: 100, right: 100), animated: true)
        }
    }
    
    // MARK:- 添加线段
    public func addPolyline(line: SportTrackingLine,currentSportTracking: SportTracking) {
        
        if currentSportTracking.locationsArray.count == 0 {return}
        
        let beginCoordinate = CLLocationCoordinate2D(latitude: line.preLatitude, longitude: line.preLongitude)
        let endCoordinate = CLLocationCoordinate2D(latitude: line.latitude, longitude: line.longitude)

        if beginCoordinate.latitude == 0.0 || beginCoordinate.longitude == 0.0 {return}
        if endCoordinate.latitude == 0.0 || endCoordinate.longitude == 0.0 {return}
        if isBeAddedOrigin == false {
            let pointAnnotation = MAPointAnnotation()
            pointAnnotation.title = "起点"
            pointAnnotation.coordinate = beginCoordinate
            self.mapView.addAnnotation(pointAnnotation)
            isBeAddedOrigin = true
        }

        var polylineCoords: [CLLocationCoordinate2D] = [beginCoordinate,endCoordinate]
        let polyline = SportColorPolyline(coordinates: &polylineCoords, count: 2, drawStyleIndexes: [0,1])
        
        //如果回放
        if isPlayBack {
            polyline?.color = colorPolyline(curSpeed: line.speed,
                                            midSpeed: currentSportTracking.avgSpeed_m_s,
                                            maxSpeed: currentSportTracking.maxSpeed,
                                            minSpeed: currentSportTracking.minSpeed)
            lastColor = polyline?.color?.last ?? SportColor.normalColor
            polyline?.color = [SportColor.normalColor]
            endCoordinates.append(endCoordinate)
        }
        else {
            polyline?.color = [SportColor.normalColor]
        }
        self.mapView.add(polyline)
    }
    
    public func showOverlays(){

        self.mapView.showOverlays(self.mapView.overlays, edgePadding: UIEdgeInsets(top: 80, left: 50, bottom: self.frame.height * 0.5, right: 50), animated: false)
    }

    private func stringTimeFor(date: Date) -> String {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
        return dateFormatter.string(from: date)
    }
    
    // MARK:- 渐变色
    private func colorPolyline(curSpeed: Double,midSpeed: Double, maxSpeed: Double, minSpeed: Double)-> [UIColor] {

        // 慢的
        let s_red = 243/255.0
        let s_green = 82/255.0
        let s_blue = 82/255.0
        
        // 不快不慢
        let m_red = 36/255.0
        let m_green = 199/255.0
        let m_blue = 137/255.0
        
        // 快的
        let f_red = 44/255.0
        let f_green = 226/255.0
        let f_blue = 255/255.0
        
        if curSpeed == 0.0 || midSpeed == 0.0 || maxSpeed == 0.0 {
            return [lastColor,SportColor.normalColor]
        }
        if curSpeed <= minSpeed {
            if lastColor == SportColor.fastColor {
                return [lastColor,SportColor.normalColor]
            }
            return [lastColor,SportColor.slowColor]
            
        }
        if curSpeed >= maxSpeed {
            if lastColor == SportColor.slowColor {
                return [lastColor,SportColor.normalColor]
            }
            return [lastColor,SportColor.fastColor]
        }
        
        if curSpeed < midSpeed  {
            if midSpeed == minSpeed {return [lastColor,SportColor.normalColor]}
            let ratio = (curSpeed - minSpeed) / (midSpeed - minSpeed)
            let red = s_red + ratio * (m_red - s_red);
            let green = s_green + ratio * (m_green - s_green);
            let blue = s_blue + ratio * (m_blue - s_blue);
            let curColor = UIColor(red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: 1.0)
            if lastColor == SportColor.fastColor {
                return [lastColor,SportColor.normalColor]
            }
            return [lastColor,curColor]
            
        } else {
            if maxSpeed == midSpeed {return [lastColor,SportColor.normalColor]}
            let ratio = (curSpeed - midSpeed) / (maxSpeed - midSpeed);
            let red = m_red + ratio * (f_red - m_red);
            let green = m_green + ratio * (f_green - m_green);
            let blue = m_blue + ratio * (f_blue - m_blue);
            let curColor = UIColor(red: CGFloat(red), green: CGFloat(green), blue: CGFloat(blue), alpha: 1.0)
            if lastColor == SportColor.slowColor {
                return [lastColor,SportColor.normalColor]
            }
            return [lastColor,curColor]
        }
    }
}
extension SportMapView: MAMapViewDelegate {
    func mapView(_ mapView: MAMapView!, didUpdate userLocation: MAUserLocation!, updatingLocation: Bool) {
        if !updatingLocation && userAnnotationView != nil && !isPlayBack {
            
            UIView.animate(withDuration: 0.1, animations: {
                self.userAnnotationView?.rotateDegree = CGFloat(userLocation.heading.trueHeading) - mapView.rotationDegree
            })
        }
    }
    
    func mapView(_ mapView: MAMapView!, rendererFor overlay: MAOverlay!) -> MAOverlayRenderer! {
        if overlay.isKind(of: SportColorPolyline.self) {
            let renderer: MAMultiColoredPolylineRenderer = MAMultiColoredPolylineRenderer(overlay: overlay)
            let polyline = overlay as! SportColorPolyline
            renderer.strokeColors = polyline.color ?? [SportColor.normalColor]
            renderer.lineJoinType = kMALineJoinRound
            renderer.lineCapType = kMALineCapRound
            renderer.isGradient = true
            renderer.lineWidth = 7.0
            return renderer
        }
        return nil
    }
    
    func mapView(_ mapView: MAMapView!, viewFor annotation: MAAnnotation!) -> MAAnnotationView! {
        if annotation.isKind(of: MAUserLocation.self)  {
            
            var userAnnotationView = mapView.dequeueReusableAnnotationView(withIdentifier: SportMapView.userpointReuseIndetifier) as? RadialCircleAnnotationView
            
            if userAnnotationView == nil {
                userAnnotationView = RadialCircleAnnotationView(annotation: annotation, reuseIdentifier: SportMapView.userpointReuseIndetifier)
            }
            
            userAnnotationView?.canShowCallout  = true
            
            //脉冲圈个数
            userAnnotationView?.pulseCount = 1
            //单个脉冲圈动画时长
            userAnnotationView?.animationDuration = 2.0
            //单个脉冲圈缩放比例
            userAnnotationView?.scale = 2.0
            //单个脉冲圈fillColor
            userAnnotationView?.fillColor = UIColor.white
            //单个脉冲圈strokeColor
            userAnnotationView?.strokeColor = UIColor.white
            //更改设置后重新开始动画
            userAnnotationView?.startPulseAnimation()
            
            self.userAnnotationView = userAnnotationView
            return userAnnotationView
        }
        
        
        var annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: SportMapView.startpointReuseIndetifier) as? MAPinAnnotationView
        if annotationView == nil {
            annotationView = MAPinAnnotationView(annotation: annotation, reuseIdentifier: SportMapView.startpointReuseIndetifier)
        }
        
        //如果回放
        if isPlayBack {
            if annotation.title == "起点"{
                annotationView?.image = SportAssets.bundleImage(named: "map_annotation_start_playback")
            } else if annotation.title == "终点" {
                annotationView?.image = SportAssets.bundleImage(named: "map_annotation_end_playback")
            }
        } else {
            annotationView?.image = SportAssets.bundleImage(named: "map_annotation_start")
        }

        annotationView?.canShowCallout = true
        annotationView?.animatesDrop = true
        // 设置图片偏移量
        annotationView?.centerOffset = CGPoint(x: 0, y: -(annotationView?.image.size.height ?? 0) * 0.5 + 5)
        return annotationView
    }
}

总结: 时间紧迫,源码没有上传. 如果有问题,请联系我,欢迎指正.

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

推荐阅读更多精彩内容

  • 文章图片上传不正常,如需文档,可联系微信:1017429387 目录 1 安装... 4 1.1 配置探针... ...
    Mrhappy_a7eb阅读 6,287评论 0 5
  • 自控可以集中能量,时间,注意力去做更好的事,更多的去完成任务,目标,理想。自控当然是一个好东西。没有自控的人生好吗...
    肖雷_6650阅读 281评论 0 0
  • 参加完舅舅的葬礼,赶回来上了一个多小时的实战课,感谢雷老师对我的接纳,让我和大家一起上课。 今天的感受如下: 1、...
    吴秀芳阅读 237评论 0 0
  • 心态不同能带来很不一样的工作和生活状态。拿工作来说,先举一个积极的例子。那段时间自己状态不好的时候,恰逢我同办公室...
    六月花阅读 101评论 0 0
  • 老王生活在一个海边的渔村。他是一个木匠。他在刨一颗粗榆树,榆树皮一圈一圈的飘落,像一朵朵的浪花。他仿佛看到了一片蔚...
    书海一芥阅读 272评论 1 1