feat: 支持模拟器定位与地图 fallback

This commit is contained in:
2026-07-24 10:04:55 +08:00
parent 0e0415eec4
commit f87b13cc05
24 changed files with 4040 additions and 2161 deletions
@@ -20,6 +20,7 @@ enum AMapBootstrap {
defer { lock.unlock() }
guard !isConfigured else { return true }
#if !targetEnvironment(simulator)
AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain)
AMapLocationManager.updatePrivacyAgree(.didAgree)
MAMapView.updatePrivacyShow(.didShow, privacyInfo: .didContain)
@@ -29,6 +30,7 @@ enum AMapBootstrap {
AMapServices.shared().apiKey = AMapConfig.apiKey
AMapServices.shared().enableHTTPS = true
#endif
isConfigured = true
return true
}
@@ -3,16 +3,28 @@
// suixinkan
//
import CoreLocation
import Foundation
/// 高德逆地理编码封装,对齐 Android `GeocodeSearch`。
/// 地址逆地理编码能力,供平台定位实现注入。
@MainActor
final class LocationGeocoder: NSObject, AMapSearchDelegate {
protocol LocationAddressGeocoding: AnyObject {
/// 将坐标解析为可读地址,失败时返回空字符串。
func reverseGeocode(latitude: Double, longitude: Double) async -> String
}
/// 平台逆地理编码封装;真机使用高德,模拟器使用 Core Location。
@MainActor
final class LocationGeocoder: NSObject, LocationAddressGeocoding {
nonisolated static let shared = LocationGeocoder()
#if targetEnvironment(simulator)
private let geocoder = CLGeocoder()
#else
private var searchAPI: AMapSearchAPI?
private var continuation: CheckedContinuation<String, Never>?
#endif
nonisolated private override init() {
super.init()
@@ -20,6 +32,26 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
/// 将坐标解析为可读地址。
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
#if targetEnvironment(simulator)
let location = CLLocation(latitude: latitude, longitude: longitude)
guard let placemark = try? await geocoder.reverseGeocodeLocation(location).first else {
return ""
}
if let name = placemark.name?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty {
return name
}
return [
placemark.administrativeArea,
placemark.locality,
placemark.subLocality,
placemark.thoroughfare,
placemark.subThoroughfare,
]
.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
.joined()
#else
do {
try AMapBootstrap.requireConfigured()
} catch {
@@ -40,8 +72,10 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
request.radius = 200
api.aMapReGoecodeSearch(request)
}
#endif
}
#if !targetEnvironment(simulator)
func onReGeocodeSearchDone(_ request: AMapReGeocodeSearchRequest!, response: AMapReGeocodeSearchResponse!) {
let address = response?.regeocode?.formattedAddress ?? ""
continuation?.resume(returning: address)
@@ -62,4 +96,9 @@ final class LocationGeocoder: NSObject, AMapSearchDelegate {
searchAPI = api
return api
}
#endif
}
#if !targetEnvironment(simulator)
extension LocationGeocoder: AMapSearchDelegate {}
#endif
@@ -6,7 +6,165 @@
import CoreLocation
import Foundation
/// 统一定位服务,封装高德 `AMapLocationManager`,对齐 Android `LocationProvider`。
/// Core Location 管理器最小接口,便于模拟器定位实现注入测试替身。
@MainActor
protocol CoreLocationManaging: AnyObject {
var authorizationStatus: CLAuthorizationStatus { get }
var desiredAccuracy: CLLocationAccuracy { get set }
var delegate: CLLocationManagerDelegate? { get set }
/// 请求使用期间定位权限。
func requestWhenInUseAuthorization()
/// 请求一次当前位置。
func requestLocation()
}
extension CLLocationManager: CoreLocationManaging {}
/// Core Location 定位实现,供模拟器运行并支持依赖注入测试。
@MainActor
final class CoreLocationProvider: NSObject, LocationProviding, CLLocationManagerDelegate {
nonisolated static let shared = CoreLocationProvider()
private var appStore: AppStore = .shared
private var manager: any CoreLocationManaging = CLLocationManager()
private var geocoder: any LocationAddressGeocoding = LocationGeocoder.shared
private var permissionContinuation: CheckedContinuation<Void, Error>?
private var locationContinuation: CheckedContinuation<CLLocationCoordinate2D, Error>?
nonisolated override init() {
super.init()
}
/// 使用可控依赖创建 Core Location 定位服务。
init(
appStore: AppStore,
manager: any CoreLocationManaging,
geocoder: any LocationAddressGeocoding
) {
self.appStore = appStore
self.manager = manager
self.geocoder = geocoder
super.init()
manager.delegate = self
}
/// 请求坐标及逆地理编码地址。
func requestSnapshot(
desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest
) async throws -> HomeLocationSnapshot {
let coordinate = try await requestCoordinate(desiredAccuracy: desiredAccuracy)
let address = await geocoder.reverseGeocode(
latitude: coordinate.latitude,
longitude: coordinate.longitude
)
return HomeLocationSnapshot(
latitude: coordinate.latitude,
longitude: coordinate.longitude,
address: address
)
}
/// 请求一次当前位置坐标。
func requestCoordinate(
desiredAccuracy: CLLocationAccuracy
) async throws -> CLLocationCoordinate2D {
try requirePrivacyAgreement()
try await ensureLocationPermission()
guard locationContinuation == nil else {
throw LocationProviderError.locationFailed
}
manager.delegate = self
manager.desiredAccuracy = desiredAccuracy
return try await withCheckedThrowingContinuation { continuation in
locationContinuation = continuation
manager.requestLocation()
}
}
/// 使用系统服务将坐标解析为地址。
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
guard appStore.session.privacyAgreementAccepted else { return "" }
return await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
}
/// 请求系统定位权限,等待用户完成首次授权。
func ensureLocationPermission() async throws {
manager.delegate = self
switch manager.authorizationStatus {
case .authorizedAlways, .authorizedWhenInUse:
return
case .denied, .restricted:
throw LocationProviderError.permissionDenied
case .notDetermined:
guard permissionContinuation == nil else {
throw LocationProviderError.locationFailed
}
try await withCheckedThrowingContinuation { continuation in
permissionContinuation = continuation
manager.requestWhenInUseAuthorization()
}
@unknown default:
throw LocationProviderError.permissionDenied
}
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
resolvePermission(status: self.manager.authorizationStatus)
}
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
guard let continuation = locationContinuation else { return }
locationContinuation = nil
guard let coordinate = locations.last?.coordinate,
CLLocationCoordinate2DIsValid(coordinate),
coordinate.latitude != 0 || coordinate.longitude != 0 else {
continuation.resume(throwing: LocationProviderError.locationFailed)
return
}
continuation.resume(returning: coordinate)
}
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
guard let continuation = locationContinuation else { return }
locationContinuation = nil
continuation.resume(throwing: LocationProviderError.locationFailed)
}
private func requirePrivacyAgreement() throws {
guard appStore.session.privacyAgreementAccepted else {
throw LocationProviderError.privacyNotAccepted
}
}
private func resolvePermission(status: CLAuthorizationStatus) {
guard let continuation = permissionContinuation else { return }
switch status {
case .authorizedAlways, .authorizedWhenInUse:
permissionContinuation = nil
continuation.resume()
case .denied, .restricted:
permissionContinuation = nil
continuation.resume(throwing: LocationProviderError.permissionDenied)
case .notDetermined:
break
@unknown default:
permissionContinuation = nil
continuation.resume(throwing: LocationProviderError.permissionDenied)
}
}
}
#if targetEnvironment(simulator)
/// 模拟器默认定位服务,使用 Core Location 与系统逆地理编码。
typealias LocationProvider = CoreLocationProvider
#else
/// 真机统一定位服务,封装高德 `AMapLocationManager`,对齐 Android `LocationProvider`。
@MainActor
final class LocationProvider: NSObject, LocationProviding {
@@ -19,7 +177,7 @@ final class LocationProvider: NSObject, LocationProviding {
super.init()
}
@MainActor
/// 请求坐标及高德逆地理编码地址。
func requestSnapshot(desiredAccuracy: CLLocationAccuracy = kCLLocationAccuracyBest) async throws -> HomeLocationSnapshot {
try AMapBootstrap.requireConfigured()
try await ensureLocationPermission()
@@ -51,7 +209,7 @@ final class LocationProvider: NSObject, LocationProviding {
}
}
@MainActor
/// 请求一次当前位置坐标。
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
try AMapBootstrap.requireConfigured()
try await ensureLocationPermission()
@@ -78,7 +236,7 @@ final class LocationProvider: NSObject, LocationProviding {
}
}
@MainActor
/// 使用高德服务将坐标解析为地址。
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
await geocoder.reverseGeocode(latitude: latitude, longitude: longitude)
}
@@ -110,3 +268,5 @@ final class LocationProvider: NSObject, LocationProviding {
return manager
}
}
#endif
@@ -16,20 +16,25 @@ struct HomeLocationSnapshot: Sendable, Equatable {
/// 定位服务协议,便于 ViewModel 注入与单元测试 mock。
protocol LocationProviding: Sendable {
/// 按指定精度请求当前坐标与地址。
@MainActor
func requestSnapshot(desiredAccuracy: CLLocationAccuracy) async throws -> HomeLocationSnapshot
/// 按指定精度请求当前坐标。
@MainActor
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D
/// 逆地理编码。
@MainActor
func reverseGeocode(latitude: Double, longitude: Double) async -> String
}
extension LocationProviding {
/// 请求当前坐标与地址,默认使用最高精度以保持既有业务行为。
@MainActor
func requestSnapshot() async throws -> HomeLocationSnapshot {
try await requestSnapshot(desiredAccuracy: kCLLocationAccuracyBest)
}
/// 请求当前坐标,默认使用最高精度以保持既有业务行为。
@MainActor
func requestCoordinate() async throws -> CLLocationCoordinate2D {
try await requestCoordinate(desiredAccuracy: kCLLocationAccuracyBest)
}
@@ -3,6 +3,7 @@
// suixinkan
//
import CoreLocation
import UIKit
/// 主 Tab 中间扫码按钮结果处理,对齐 Android `MainTabViewModel.signIn`。