SwiftUI集成MapKit与CoreLocation时地图区域更新失效问题
我重新发布昨日提交的问题,本次补充了可直接复现问题的精简代码示例:
我实现了嵌入MKMapView的自定义组件MyCustomMapView,组件初始加载时默认定位到固定坐标,同时提供gotoCoordinate方法,接收CLLocationCoordinate2D类型的坐标参数后,可将地图中心导航到指定坐标点。在示例代码中,点击标注为"Click here to change map position"的红色按钮即可触发该导航操作,未接入定位能力时该功能运行完全正常。
但在应用中接入负责处理用户定位逻辑的LocationViewModel后出现异常:当用户授予定位权限后,点击按钮无法再驱动地图中心移动到目标坐标;仅当注释掉@StateObject var locationViewModel = LocationViewModel()代码行后,地图移动功能才会恢复正常。
初步排查发现,只要使用了绑定delegate的CLLocationManager,修改地图显示区域的操作就不再生效,请问这是系统框架的Bug,还是我的代码实现存在错误?
复现代码
ContentView.swift
import SwiftUI struct ContentView: View { @StateObject var locationViewModel = LocationViewModel() var body: some View { switch locationViewModel.authorizationStatus { case .notDetermined: AnyView(RequestLocationView()) .environmentObject(locationViewModel) case .restricted: ErrorView(errorText: "Location use is restricted.") case .denied: ErrorView(errorText: "The app does not have location permissions. Please enable them in settings.") default: EmptyView() } GeometryReader { geometry in DisplayMapView(size:geometry.size) } } }
MyCustomMapView.swift
import SwiftUI import CoreLocation import MapKit struct MyCustomMapView: UIViewRepresentable { var map = MKMapView() // << constructor contract !! let coordinate: CLLocationCoordinate2D = CLLocationCoordinate2D(latitude:31,longitude: -86 ) func makeUIView(context: Context) -> MKMapView { map.delegate = context.coordinator map.showsUserLocation = true map.showsCompass = true let region = MKCoordinateRegion( center: CLLocationCoordinate2D(latitude: coordinate.latitude,longitude: coordinate.longitude), span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1)) map.setRegion(region, animated: true) return map } func gotoCoordinate(_ newCoordinate: CLLocationCoordinate2D ){ let region = MKCoordinateRegion(center: newCoordinate, span: MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2)) map.setRegion(region, animated: true) } func updateUIView(_ uiView: MKMapView, context: Context) { } func makeCoordinator() -> MyCustomMapView.Coordinator { return MyCustomMapView.Coordinator(parent1: self) } final class Coordinator: NSObject, MKMapViewDelegate { var parent:MyCustomMapView init(parent1:MyCustomMapView){ parent = parent1 } }//class Coordinator }
DisplayMapView.swift
import SwiftUI import CoreLocation import MapKit struct DisplayMapView: View { @Environment(\.presentationMode) var presentationMode var size: CGSize var startCoordinate: CLLocationCoordinate2D = CLLocationCoordinate2D(latitude:40.741895,longitude: -73.989308) var map = MyCustomMapView() var body: some View { ZStack(alignment:.top){ map VStack(alignment:.leading){ HStack { HStack { Text("Click here to change map position") .onTapGesture(){ map.gotoCoordinate(startCoordinate) } } .padding(EdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6)) .foregroundColor(.black) .background(Color(.red)) .cornerRadius(10.0) } }.padding(.top,50).padding(.leading,20).padding(.trailing,20) }.ignoresSafeArea() } }
LocationViewModel.swift
import Foundation import SwiftUI import CoreLocation class LocationViewModel: NSObject, ObservableObject, CLLocationManagerDelegate { @Published var authorizationStatus: CLAuthorizationStatus @Published var lastSeenLocation: CLLocation? @Published var currentPlacemark: CLPlacemark? private let locationManager: CLLocationManager static let shared = LocationViewModel() override init() { locationManager = CLLocationManager() authorizationStatus = locationManager.authorizationStatus super.init() locationManager.delegate = self locationManager.desiredAccuracy = kCLLocationAccuracyBest locationManager.distanceFilter = 0.4 locationManager.startUpdatingLocation() } func requestPermission() { locationManager.requestWhenInUseAuthorization() } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { authorizationStatus = manager.authorizationStatus } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { lastSeenLocation = locations.first } } struct RequestLocationView: View { @EnvironmentObject var locationViewModel: LocationViewModel var body: some View { VStack(spacing:50) { Image(systemName: "location.circle") .resizable() .frame(width: 100, height: 100, alignment: .center) .foregroundColor(Color.init(red: 0.258, green: 0.442, blue: 0.254)) Button(action: { locationViewModel.requestPermission() }, label: { Label(LocalizedStringKey("allowLocationAccess"), systemImage: "location") }) .padding(10) .foregroundColor(.white) .background(.green) .clipShape(RoundedRectangle(cornerRadius: 8)) Text("We need your permission to give you the best experience.") .foregroundColor(.gray) .font(.caption) } } } struct ErrorView: View { var errorText: String var body: some View { VStack { Image(systemName: "xmark.octagon") .resizable() .frame(width: 100, height: 100, alignment: .center) Text(errorText) } .padding() .foregroundColor(.white) .background(Color.red) .clipShape(RoundedRectangle(cornerRadius: 12)) } }
这不是系统框架Bug,是代码实现存在多个SwiftUI与UIKit混用时的典型错误:
- UIViewRepresentable的持有方式错误:
MyCustomMapView结构体内部直接创建持有MKMapView实例,又在DisplayMapView里把MyCustomMapView作为普通属性创建。SwiftUI的View是值类型,每次状态更新(比如定位权限变更、位置更新触发@Published属性刷新)时,整个ContentView和子视图都会重新计算重建,DisplayMapView里的map属性会生成新的MyCustomMapView实例,点击按钮调用的gotoCoordinate方法,操作的是已经被替换掉的旧地图实例,自然不会有效果。 showsUserLocation = true触发的地图自动追踪冲突:开启显示用户位置后,MKMapView默认会在位置更新时自动将视角移动到用户位置,代码中没有修改地图的userTrackingMode,手动设置区域的操作会被后续到来的位置更新自动覆盖。gotoCoordinate的调用逻辑不符合UIViewRepresentable的设计规范:UIViewRepresentable的所有UI更新逻辑都应该通过updateUIView方法驱动,不应该直接在结构体上暴露方法调用内部持有的UIKit实例,值类型复制后会导致方法调用的目标和实际渲染的UI实例不一致。
额外问题:LocationViewModel中同时写了static let shared = LocationViewModel()单例和@StateObject声明,@StateObject会自动管理实例生命周期,多余的单例容易引发实例复用混乱。
按照SwiftUI的设计规范调整代码即可:
- 不要在
UIViewRepresentable结构体内部持有MKMapView实例,MKMapView的创建、持有完全交给makeUIView生命周期管理 - 用
@Binding传递需要跳转的目标坐标,在updateUIView中监听坐标变化,操作传入的uiView实例更新地图区域 - 手动设置地图的用户追踪模式为
.none,避免自动追踪覆盖手动设置的区域 - 把
MyCustomMapView作为标准SwiftUI视图使用,不要在父视图中把它当普通类实例持有调用方法 - 删除
LocationViewModel中多余的单例声明
修复后的MyCustomMapView代码
struct MyCustomMapView: UIViewRepresentable { // 用Binding接收外部传入的目标坐标 @Binding var targetCoordinate: CLLocationCoordinate2D // 默认初始坐标 let defaultCoordinate = CLLocationCoordinate2D(latitude: 31, longitude: -86) func makeUIView(context: Context) -> MKMapView { let mapView = MKMapView() mapView.delegate = context.coordinator mapView.showsUserLocation = true mapView.showsCompass = true // 关闭自动追踪用户位置,避免覆盖手动移动的区域 mapView.userTrackingMode = .none // 初始化默认区域 let region = MKCoordinateRegion( center: defaultCoordinate, span: MKCoordinateSpan(latitudeDelta: 0.1, longitudeDelta: 0.1) ) mapView.setRegion(region, animated: true) return mapView } func updateUIView(_ uiView: MKMapView, context: Context) { // 当目标坐标变化时,更新地图区域 let targetRegion = MKCoordinateRegion( center: targetCoordinate, span: MKCoordinateSpan(latitudeDelta: 0.2, longitudeDelta: 0.2) ) uiView.setRegion(targetRegion, animated: true) } func makeCoordinator() -> Coordinator { Coordinator() } class Coordinator: NSObject, MKMapViewDelegate {} }
修复后的DisplayMapView代码
struct DisplayMapView: View { @Environment(\.presentationMode) var presentationMode var size: CGSize // 用@State管理需要跳转的目标坐标 @State private var targetCoordinate = CLLocationCoordinate2D(latitude: 31, longitude: -86) // 按钮点击后要跳转到的坐标 let jumpCoordinate = CLLocationCoordinate2D(latitude:40.741895,longitude: -73.989308) var body: some View { ZStack(alignment:.top){ // 传入绑定,不需要自己持有MyCustomMapView实例 MyCustomMapView(targetCoordinate: $targetCoordinate) VStack(alignment:.leading){ HStack { HStack { Text("Click here to change map position") .onTapGesture { // 修改状态即可驱动地图更新 targetCoordinate = jumpCoordinate } } .padding(EdgeInsets(top: 8, leading: 6, bottom: 8, trailing: 6)) .foregroundColor(.black) .background(Color(.red)) .cornerRadius(10.0) } }.padding(.top,50).padding(.leading,20).padding(.trailing,20) }.ignoresSafeArea() } }
内容的提问来源于stack exchange,提问作者Peter Van de Put

