
简介这份 iOS 地图定位 Demo 面向初、中级 iOS 开发者聚焦在应用中集成 Core Location 与 MapKit、获取并展示用户当前位置的基础实践。包内共 23 个文件以 .m/.h 源码、plist 配置、strings 本地化、json 及工程配置文件为主覆盖定位管理器、测试视图控制器与应用入口等模块压缩包约 42KB结构轻量便于快速导入 Xcode 对照阅读。已有 267 人学习下载适合作为定位权限申请、定位精度与距离过滤设置、startUpdatingLocation 与代理回调处理、停止更新省电、MKMapView 显示用户位置及追踪模式切换的参考样例。通过运行示例读者可梳理 CLLocationManager 的初始化与授权流程理解 CLLocation 中经纬度等信息的读取方式并留意 Info.plist 定位用途说明的配置要点从而减少实际项目接入地图定位时的常见障碍。1. 一个“小蓝点不动”的下午先说清这个项目能干什么接手一个打卡类页面时最容易被问住的往往不是接口而是地图上那个小蓝点为什么不动。iOS 地图定位这个 Demo 解决的正是这类最基础也最容易翻车的问题——从权限申请、坐标回调、地图渲染到大头针和反地理编码给出一个能直接在 Xcode 里跑起来的最小闭环。它不是教你背 API 的文档搬运而是一个可运行、可修改、可裁剪进现有工程的样例拿到坐标之后怎么过滤漂移点、地图视野怎么只自动居中一次、反地理编码怎么避免被限流这些在真实项目里一定会撞上的细节它都有对应代码。适合三类人刚接手地图模块、要在一周内交出可用页面的开发者需要一份能跑通的定位参考实现用来对照排查问题的老手以及想把定位能力塞进已有 App、又不想引入第三方地图 SDK 的团队。2. 工程骨架与技术选型MapKit 与 CoreLocation 的职责边界很多人写地图功能喜欢一上来就找第三方地图 SDK结果是包体积涨了几十兆权限、密钥、坐标系转换一堆东西要配最后发现只需要一个蓝点加一个地址文本。这个 Demo 走的是系统框架路线选型上没有花哨的地方但边界划得很清楚。2.1 两套框架谁管取坐标谁管画地图系统给 iOS 的地图能力是拆成两半的定位归定位框架渲染归地图框架。这个划分不是形式主义它直接决定了你代码里该往哪儿放东西。CoreLocation 负责的是“我在哪”——它通过CLLocationManager向系统要坐标返回的是CLLocation对象里面除了经纬度还带着水平精度、垂直精度、速度、航向和时间戳。注意时间戳这个东西它是后面过滤缓存点的关键很多人只取coordinate就完事结果拿到一个几分钟前的旧坐标用户明明在楼下地图上却显示在两条街外。MapKit 负责的是“我怎么看”——它通过MKMapView渲染底图、绘制大头针、响应用户的拖拽和缩放。它自己不带定位能力showsUserLocation true这个开关背后其实也是它内部起了一个定位管理器所以真要精细控制还是得自己拿 CoreLocation 的坐标再喂给它。两者的连接点就是那一个CLLocationCoordinate2D结构体只有两个 Double 字段。理解这一点之后你的代码结构自然就清晰了定位层只吐出坐标渲染层只消费坐标中间不互相依赖。我在实际项目里一般会再抽一层LocationService出来让 ViewController 完全不认识CLLocationManager后面换数据源、加模拟数据、加埋点都不用动页面代码。2.2 目录结构与文件清单Demo 的文件数不多但每个文件的职责是单一的这一点值得在二次开发时保持。文件职责二次开发时是否常改AppDelegate.swift/SceneDelegate.swift启动入口挂载根控制器否MapViewController.swift地图页面负责渲染与交互是界面需求都在这LocationService.swift定位封装权限、启停、坐标回调是精度策略常调LocationAnnotation.swift自定义大头针数据模型按需Info.plist权限描述文案、后台模式声明是文案必须改PrivacyInfo.xcprivacy隐私清单声明定位类数据用途上架前必须补Assets.xcassets图标、大头针图片按需这个结构里最容易被人忽略的是PrivacyInfo.xcprivacy。现在提交审核时如果 App 收集了精确位置隐私清单里要声明对应的数据类型和用途缺了会被打回。Demo 里放了一份最小示例二次开发时直接在这份基础上加条目比从零写省事。另外看目录能看出来Demo 没有用 MVVM也没有引任何架构框架。这是有意为之定位这类功能的状态机本身就很短未决定、已授权、被拒、受限硬套一层 ViewModel 反而让权限回调的链路变长排查问题时要在三个文件之间跳。我一般会在这个规模上先写扁等业务多到页面里出现第二种数据源比如附近的店铺列表再考虑拆层。2.3 Info.plist 权限声明与后台模式定位权限弹窗弹不出来十有八九是这里的问题。系统对定位的权限文案要求很严键名写错或者文案留空弹窗直接不出现而且不会报错只会静默失败。!-- 使用期间定位最常用的一档弹窗文案会原样展示给用户 -- keyNSLocationWhenInUseUsageDescription/key string用于在地图上显示你的当前位置并计算到目的地距离/string !-- 始终允许定位只有需要后台持续记录轨迹时才加审核会重点看这个 -- keyNSLocationAlwaysAndWhenInUseUsageDescription/key string用于在后台持续记录运动轨迹退出页面后仍会采集位置/string !-- 后台定位能力开关不加这个allowsBackgroundLocationUpdates 设了也没用 -- keyUIBackgroundModes/key array stringlocation/string /array说明几点。第一WhenInUse和Always的文案要分开写两者的弹窗时机不同系统会分两次问用户文案写得含糊容易被拒。第二UIBackgroundModes里的location是硬门槛很多人代码里设了allowsBackgroundLocationUpdates true但 plist 没开后台模式运行时直接抛异常。第三文案里不要写“为了提供更好的服务”这类空话写清楚具体用途审核通过率会高不少。改完 plist 记得卸载重装。系统对权限弹窗有记忆同一个 Bundle ID 在同一台设备上只会弹一次你在 Xcode 里热重载是看不到新弹窗的这个坑我踩过不止一次。2.4 定位精度档位的取舍desiredAccuracy有六个档从三公里到最佳精度。选档位不是越准越好它直接对应耗电和首次定位速度。档位常量大致精度典型场景kCLLocationAccuracyBest最高通常 10 米内导航、打卡kCLLocationAccuracyNearestTenMeters10 米附近商家kCLLocationAccuracyHundredMeters100 米城市级天气kCLLocationAccuracyKilometer1 公里城市切换kCLLocationAccuracyThreeKilometers3 公里粗略地区Demo 里默认用的是 Best因为打卡场景对精度敏感。但如果你的页面只是显示“当前城市”用 Best 纯属浪费电而且首次定位反而更慢因为系统要等 GPS 芯片收敛。实际项目里我一般会按场景给两档进入页面用HundredMeters快速出结果用户点了“精确定位”再切到 Best。配合精度还有一个distanceFilter单位是米表示位移小于这个值就不回调。默认是kCLDistanceFilterNone也就是任何微小变化都回调移动中一秒能来好几次非常费电。Demo 里设的是 5 米这个值在步行打卡场景够用如果是轨迹记录可以调到 10 米如果是静止场景的“我在哪”干脆用requestLocation()单次请求别开持续更新。3. 定位主链路从权限状态机到坐标回调这一章是这个 Demo 的核心。定位功能所有的问题八成都能归到两条链路上权限没走对或者坐标没过滤。把这两条链路理顺剩下的都是展示层的事。3.1 权限状态机与请求时机定位权限有五个状态notDetermined还没问过、restricted被家长控制之类限制、denied用户拒了、authorizedWhenInUse使用期间、authorizedAlways始终。这五个状态里只有第一个能主动触发弹窗后面几个都是既成事实你只能读不能改。所以正确顺序永远是先读当前状态是notDetermined才请求其他状态直接走对应分支。常见的错误写法是不管三七二十一先requestWhenInUseAuthorization()结果用户之前拒过这次调用静默失败你还以为在等回调。// 读取当前状态iOS 14 之后用实例属性别再用类方法 let status manager.authorizationStatus switch status { case .notDetermined: // 只有这一种情况会真的弹窗 manager.requestWhenInUseAuthorization() case .authorizedWhenInUse, .authorizedAlways: manager.startUpdatingLocation() case .denied, .restricted: // 这里不要静默失败给用户一个跳设置页的入口 showPermissionGuide() unknown default: break }有个细节值得说denied之后用户去设置里改了权限再回来App 是会收到locationManagerDidChangeAuthorization回调的但这个回调不一定在你的页面还在栈上时来。所以我一般会在viewWillAppear里再读一次状态做一次兜底刷新。3.2 LocationService 封装一个文件收口所有定位逻辑把定位逻辑收进一个单例是 Demo 里最重要的一个设计决定。页面不碰 manager只认 delegate 或者闭包后面加 mock 数据、加日志、加超时都能在这一层做。import CoreLocation protocol LocationServiceDelegate: AnyObject { func locationService(_ service: LocationService, didUpdate location: CLLocation) func locationService(_ service: LocationService, didFail error: Error) func locationService(_ service: LocationService, didChange status: CLAuthorizationStatus) } enum LocationError: Error { case servicesDisabled // 系统定位总开关关了 case denied // 用户拒绝授权 case timeout // 超时没拿到有效坐标 } final class LocationService: NSObject { static let shared LocationService() // manager 必须是属性不能是局部变量否则出了作用域就被释放回调永远不来 private let manager CLLocationManager() weak var delegate: LocationServiceDelegate? private(set) var authorizationStatus: CLAuthorizationStatus .notDetermined override init() { super.init() manager.delegate self manager.desiredAccuracy kCLLocationAccuracyBest manager.distanceFilter 5 // 位移小于 5 米不回调省电 authorizationStatus manager.authorizationStatus } /// 请求权限已决定过的状态直接回调避免页面干等 func requestPermission() { if authorizationStatus .notDetermined { manager.requestWhenInUseAuthorization() } else { delegate?.locationService(self, didChange: authorizationStatus) } } /// 开始定位内部做了状态分流 func start() { guard CLLocationManager.locationServicesEnabled() else { delegate?.locationService(self, didFail: LocationError.servicesDisabled) return } switch manager.authorizationStatus { case .authorizedWhenInUse, .authorizedAlways: manager.startUpdatingLocation() case .notDetermined: manager.requestWhenInUseAuthorization() // 授权回调里再启动 default: delegate?.locationService(self, didFail: LocationError.denied) } } func stop() { manager.stopUpdatingLocation() } }逐段说。manager声明成private let属性而不是在方法里 new 一个这是新手最容易翻的车——CLLocationManager的 delegate 是弱引用manager 本身被释放之后回调和弹窗都会消失而编译器不会给你任何提示。authorizationStatus用一个private(set)属性缓存起来省得每次去问 manager。iOS 14 之后读状态用实例属性manager.authorizationStatus类方法CLLocationManager.authorizationStatus()已经废弃混用会报警告。start()里的状态分流是关键notDetermined时不能直接startUpdatingLocation()系统会忽略这次调用正确做法是先请求权限在授权回调里再启动。这个“延迟启动”的逻辑如果不写用户第一次进来授权同意了地图却不动得杀掉 App 重进才行。3.3 坐标可信度过滤别把缓存点和漂移点当用户位置didUpdateLocations回调给的是一串坐标数组最后一个是最新的。但“最新”不等于“可信”系统在刚启动、信号弱、刚从室内走到室外时会给你一些精度极差甚至时间戳过期的坐标。extension LocationService: CLLocationManagerDelegate { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { authorizationStatus manager.authorizationStatus delegate?.locationService(self, didChange: authorizationStatus) // 用户同意后立刻启动不要等页面再调用一次 if authorizationStatus .authorizedWhenInUse || authorizationStatus .authorizedAlways { manager.startUpdatingLocation() } } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let latest locations.last else { return } // 1. 水平精度为负说明这个坐标无效系统明确标注的失败 guard latest.horizontalAccuracy 0 else { return } // 2. 精度差于 100 米的点直接丢宁可等下一个 guard latest.horizontalAccuracy 100 else { return } // 3. 时间戳太旧的坐标是缓存可能差好几分钟 guard abs(latest.timestamp.timeIntervalSinceNow) 10 else { return } delegate?.locationService(self, didUpdate: latest) } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { // locationUnknown 是临时失败系统会自己重试不要当致命错误处理 let nsError error as NSError if nsError.domain kCLErrorDomain, nsError.code CLError.locationUnknown.rawValue { return } delegate?.locationService(self, didFail: error) } }三个guard分别挡三种脏数据。第一个挡无效坐标horizontalAccuracy小于 0 是系统明确告诉你这个点不能用。第二个挡低精度点阈值 100 米可以按业务调打卡类可以收紧到 50天气类放宽到 500 都行。第三个挡缓存点这条最容易被忽略——App 从后台回到前台时系统可能立刻回一个几十分钟前的坐标你不判断时间戳地图就会“闪现”到用户上次的位置然后过两秒再跳回来视觉上非常难受。didFailWithError里的locationUnknown处理也很重要。这个错误码表示“暂时定位不到”系统会根据你的desiredAccuracy自己重试你如果在这里弹个“定位失败”的 toast用户会看到反复弹窗。4. 地图渲染与交互MKMapView 封装、大头针与反地理编码拿到坐标之后的活儿都在这一层。Demo 的地图页代码不长但每行都有存在的理由尤其是在“自动居中”和“用户手势”这两个容易打架的地方。4.1 地图初始化与视野控制MKCoordinateRegion有两个关键参数中心点和跨度。跨度有两种写法一种是用度span一种是用米latitudinalMeters后者更好理解——“我想让屏幕纵向覆盖 800 米”。final class MapViewController: UIViewController { private let mapView MKMapView() private let locationService LocationService.shared private var hasCentered false // 只自动居中一次避免和用户手势打架 private let geocoder CLGeocoder() private var lastGeocodeAt Date.distantPast override func viewDidLoad() { super.viewDidLoad() mapView.frame view.bounds mapView.autoresizingMask [.flexibleWidth, .flexibleHeight] mapView.showsUserLocation true // 打开系统蓝点 mapView.userTrackingMode .none // 先不跟随等有坐标了再决定 mapView.delegate self view.addSubview(mapView) locationService.delegate self locationService.requestPermission() locationService.start() } private func centerIfNeeded(on coordinate: CLLocationCoordinate2D) { guard !hasCentered else { return } hasCentered true let region MKCoordinateRegion(center: coordinate, latitudinalMeters: 800, longitudinalMeters: 800) mapView.setRegion(region, animated: true) } }hasCentered这个布尔值看着不起眼但它解决的是“地图每次回调都把你拽回当前位置”这个经典体验问题。用户手动拖到别处看下一个定位回调又把他拽回来这在真实反馈里是最招骂的一条。加了这个标志位之后只有第一次定位会居中之后完全交给用户。userTrackingMode建议先用.none等拿到第一个有效坐标、居中完成之后再按需切成.follow。如果一开始就设.follow在还没定位到的时候地图可能会先跳到系统默认坐标视觉上有个突兀的跳变。4.2 大头针自定义 MKAnnotation 与复用系统自带的MKPointAnnotation只支持标题和副标题实际项目里通常要带业务 ID、类型、距离这些字段所以自定义一个遵守MKAnnotation协议的模型类更合适。final class LocationAnnotation: NSObject, MKAnnotation { let coordinate: CLLocationCoordinate2D let title: String? let placeID: String // 业务字段点击大头针时用来拉详情 init(coordinate: CLLocationCoordinate2D, title: String, placeID: String) { self.coordinate coordinate self.title title self.placeID placeID super.init() } }注意coordinate在协议里是var实现成let是可以的只要初始化时赋值即可。NSObject必须继承因为 MapKit 内部用 Objective-C 运行时管理这些对象。渲染代理方法里一定要处理复用否则大头针多了之后拖地图会明显掉帧extension MapViewController: MKMapViewDelegate { func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) - MKAnnotationView? { // 系统蓝点是 MapKit 自己管的视图返回 nil 表示交给系统画 if annotation is MKUserLocation { return nil } let reuseID location_pin var view mapView.dequeueReusableAnnotationView(withIdentifier: reuseID) as? MKMarkerAnnotationView if view nil { view MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: reuseID) view?.canShowCallout true view?.markerTintColor .systemBlue view?.rightCalloutAccessoryView UIButton(type: .detailDisclosure) } else { view?.annotation annotation // 复用时必须重新绑定数据 } return view } // 点击气泡右侧的详情按钮 func mapView(_ mapView: MKMapView, annotationView view: MKAnnotationView, calloutAccessoryControlTapped control: UIControl) { guard let anno view.annotation as? LocationAnnotation else { return } openDetail(placeID: anno.placeID) } }复用的时候最容易漏的是view?.annotation annotation这一行。少了它滚动地图之后大头针会显示成别的数据因为视图被复用但数据没更新这种 bug 在测试阶段很难复现上线之后用户一拖就出问题。4.3 反地理编码CLGeocoder 的限流与超时反地理编码就是把经纬度换成“某某路某某号”。系统提供的CLGeocoder免费但有频率限制短时间内请求太多次会返回kCLErrorGeocodeFoundNoResult或者直接失败一段时间。private func reverseGeocode(_ location: CLLocation) { // 节流3 秒内只请求一次移动中别跟着每个回调都发 guard Date().timeIntervalSince(lastGeocodeAt) 3 else { return } // 上一次还没回来就别叠加系统会直接判失败 guard !geocoder.isGeocoding else { return } lastGeocodeAt Date() geocoder.reverseGeocodeLocation(location, preferredLocale: Locale.current) { [weak self] placemarks, error in guard let self else { return } if let error error { // 限流错误不要重试等下一次节流窗口即可 print(geocode failed: \(error.localizedDescription)) return } guard let place placemarks?.first else { return } // 按粒度从粗到细拼一个人类可读的地址 let parts [place.locality, place.subLocality, place.thoroughfare, place.name] let address parts.compactMap { $0 }.joined() self.addressLabel.text address.isEmpty ? 未知位置 : address } }isGeocoding这个判断很关键。CLGeocoder是串行处理请求的你在前一个没回来时再发一个第二个会直接失败。加上节流时间窗之后移动中最多三秒更新一次地址既不卡也不会触发限流。地址拼接的顺序按业务调整做外卖的通常要“市区街道门牌”做社交的只要“区地标名”。compactMap会自动过滤 nil省得拼出一串逗号。4.4 手势与跟随模式的切换用户一拖地图跟随模式就该关掉。这个逻辑 MapKit 没有自动帮你做得在代理方法里手动切func mapView(_ mapView: MKMapView, regionWillChangeAnimated animated: Bool) { // 判断是不是用户手势触发的变化程序调用 setRegion 不算 if mapView.subviews.contains(where: { $0.gestureRecognizers?.contains(where: { $0.state .began }) true }) { mapView.userTrackingMode .none hasCentered true // 用户接管之后别再自动居中 } }这个判断办法不算优雅但胜在简单可靠。更规范的做法是给MKMapView加一个自定义手势识别器做标记不过在 Demo 这个规模上没必要。重点是hasCentered true这一句——用户手动拖走之后如果后面还有定位回调触发自动居中体验就毁了。5. 避坑与排查真机、模拟器和权限状态那些血泪经验定位这块的坑有个共同特点不报错、不崩溃就是不工作。下面这几条是我和周围人反复踩过的按现象、原因、解决三段写遇到问题时可以直接对着排查。权限弹窗一次都没出现过。现象是第一次运行 App地图出来了但没有任何权限提示蓝点也不显示。原因通常有两个一是Info.plist里NSLocationWhenInUseUsageDescription键名拼错或者值留空系统直接跳过弹窗二是CLLocationManager被声明成了方法里的局部变量函数返回后对象释放delegate 变 nil请求发出去也没人接。解决办法是把 manager 提成控制器或单例的属性仔细核对 plist 键名注意是NSLocationWhenInUseUsageDescription不是NSLocationUsageDescription后者早就废弃了。改完之后必须卸载重装因为系统对同一个 Bundle ID 只弹一次热重载看不到效果。模拟器里位置永远停在同一个点。现象是不管怎么改代码模拟器显示的位置都不变换台机器又是另一个固定坐标。原因是模拟器默认使用一个预设位置而且 Xcode 的 Run Scheme 里如果没开位置模拟它不会跟随任何输入。解决办法是在模拟器的Features Location里选Custom Location手动填经纬度或者在 Scheme 的 Options 页勾上Allow Location Simulation并指定一个 GPX 文件。真机测试永远比模拟器可靠涉及定位的功能我一般至少跑一遍真机再交付。地图一片空白或者显示网格。现象是MKMapView出来了但底图是灰白格子只有大头针能显示。原因有三个层次最常见的是网络不通底图瓦片拉不下来其次是mapView的 frame 是.zero用 Auto Layout 但忘了加约束最后是设备时间不对导致瓦片请求的签名校验失败。排查顺序建议从 frame 开始看打印一下mapView.frame再检查网络最后看系统时间。用setRegion传了一个非法坐标比如纬度超过 90也会导致白屏这个错误不会崩只会静默不显示。定位回调只来一次之后再也不更新。现象是刚进页面蓝点出现了走两步地图上的位置不动。原因可能是distanceFilter设得太大比如设了 100 米室内走动根本触发不了也可能是某处调用了stopUpdatingLocation()比如页面消失时的清理逻辑写错在viewWillDisappear里停掉但页面其实还在栈上还有一种情况是desiredAccuracy用了kCLLocationAccuracyThreeKilometers系统觉得你不需要精细坐标干脆很少回调。解决办法是先把distanceFilter设成kCLDistanceFilterNone验证链路通不通通了之后再按场景加回过滤值。后台定位完全没反应。现象是 App 切到后台之后轨迹断了或者allowsBackgroundLocationUpdates设了 true 但一运行就抛异常。原因是后台定位需要三个条件同时满足Info.plist的UIBackgroundModes里包含location、用户授予的是authorizedAlwaysWhenInUse不够、代码里显式设置allowsBackgroundLocationUpdates true且pausesLocationUpdatesAutomatically false。少任何一个都不行。另外记得设showsBackgroundLocationIndicator true状态栏会显示一个定位箭头这是系统要求也方便用户知道你在采位置。反地理编码时不时返回 nil。现象是同一个坐标第一次能拿到地址连续调用几次之后开始返回空。原因是系统地理编码服务有频率限制短时间高频请求会被临时拒绝。解决就是上面代码里那套节流加isGeocoding判断另外给结果加个本地缓存同一个网格可以用坐标保留三位小数做 key在几分钟内不重复请求。6. 二次开发改造把 Demo 收成能进项目的定位模块Demo 跑通之后真正要动手的是把它变成别人也能用的东西。第一件事是把 delegate 换成 async/await页面里的回调地狱会瞬间清爽很多。extension LocationService { private static var continuation: CheckedContinuationCLLocation, Error? /// 拿一次定位结果就收手适合「进页面定位一次」的场景 func currentLocation(timeout: TimeInterval 8) async throws - CLLocation { try await withThrowingTaskGroup(of: CLLocation.self) { group in group.addTask { try await self.firstFix() } group.addTask { try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) throw LocationError.timeout } let result try await group.next()! group.cancelAll() return result } } private func firstFix() async throws - CLLocation { try await withCheckedThrowingContinuation { cont in LocationService.continuation cont self.start() } } }withThrowingTaskGroup这个写法是为了加超时定位在室内可能几十秒都出不来不设超时页面就一直转圈。超时之后抛timeout上层给一个“手动选择位置”的兜底入口比一直白屏好得多。恢复 continuation 的地方在didUpdate和didFail里各一处记得先判断continuation ! nil再 resume重复 resume 会直接崩。第二件事是按需求改配置不同的业务对定位的要求差别很大需求改哪里关键点打卡/签到horizontalAccuracy阈值收到 50 米精度不够就提示重试别硬提交轨迹记录distanceFilter调 10开后台模式必须authorizedAlways只看当前城市desiredAccuracy降到HundredMeters用requestLocation()单次请求附近列表排序拿到坐标后按距离算不做频繁更新定位一次后stop()需要地址文本反地理编码加缓存按坐标网格缓存别按精确值第三件事是去掉单例依赖。Demo 里LocationService.shared是为了省事接入大工程时最好改成协议加注入这样单元测试里可以塞一个返回固定坐标的假实现protocol LocationProviding: AnyObject { func currentLocation() async throws - CLLocation var authorizationStatus: CLAuthorizationStatus { get } }页面只依赖LocationProviding测试时传一个 mock就不用每次跑真机验证逻辑了。这一步看着多余但等你调到第五个定位相关的 bug 时会感谢自己。最后说个我自己的习惯。任何涉及定位的改动我都会先在真机上走一遍“首次安装、拒绝授权、去设置页打开、切后台再回来”这四步因为这四步覆盖了 90% 的权限状态跃迁。以前我图省事只在模拟器上点两下就提测结果用户第一次安装授权完地图不动根因就是授权回调里少了一次startUpdatingLocation()改起来只是几行代码的事但排查花了大半天。从那以后我每次改完定位相关代码都强制走一遍这条手动回归链路宁可多花十分钟也不想再收到“地图上那个点不动”的反馈。希望这份拆解能帮你少走点弯路资源已经放在下面下载下来跑一遍对着代码看比读十遍文档都快。本文还有配套的精品资源点击获取