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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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)
}

View File

@ -3,6 +3,7 @@
// suixinkan
//
import CoreLocation
import UIKit
/// Tab Android `MainTabViewModel.signIn`

View File

@ -7,6 +7,10 @@ import CoreLocation
import SnapKit
import UIKit
#if targetEnvironment(simulator)
import MapKit
#endif
/// /
final class LocationReportMapControlStack: UIView {
@ -295,7 +299,112 @@ final class LocationReportBottomActionView: UIControl {
}
}
/// `MAMapView`
/// 使 MapKit
#if targetEnvironment(simulator)
final class LocationReportMapView: UIView, MKMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
let mapView: MKMapView
private var markerAnnotation: MKPointAnnotation?
private var shouldCenterOnNextUserLocation = false
override init(frame: CGRect) {
mapView = MKMapView(frame: .zero)
super.init(frame: frame)
mapView.delegate = self
mapView.showsUserLocation = true
mapView.showsCompass = false
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
tapGesture.cancelsTouchesInView = false
mapView.addGestureRecognizer(tapGesture)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
///
func enableInitialUserLocationCentering() {
shouldCenterOnNextUserLocation = true
centerOnUserLocationIfNeeded()
}
func centerOnUserLocation() {
guard let coordinate = validUserCoordinate else { return }
setCenter(coordinate)
}
func setCenter(_ coordinate: CLLocationCoordinate2D, animated: Bool = true) {
let region = MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.012, longitudeDelta: 0.012)
)
mapView.setRegion(region, animated: animated)
}
func zoomIn() {
updateZoom(scale: 0.5)
}
func zoomOut() {
updateZoom(scale: 2)
}
func updateMarker(latitude: Double?, longitude: Double?) {
if let markerAnnotation {
mapView.removeAnnotation(markerAnnotation)
self.markerAnnotation = nil
}
guard let latitude, let longitude else { return }
let annotation = MKPointAnnotation()
annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
markerAnnotation = annotation
mapView.addAnnotation(annotation)
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
centerOnUserLocationIfNeeded()
}
@objc private func mapTapped(_ gesture: UITapGestureRecognizer) {
guard gesture.state == .ended else { return }
let point = gesture.location(in: mapView)
onMapTap?(mapView.convert(point, toCoordinateFrom: mapView))
}
private var validUserCoordinate: CLLocationCoordinate2D? {
guard let coordinate = mapView.userLocation.location?.coordinate,
CLLocationCoordinate2DIsValid(coordinate),
coordinate.latitude != 0 || coordinate.longitude != 0 else {
return nil
}
return coordinate
}
private func centerOnUserLocationIfNeeded() {
guard shouldCenterOnNextUserLocation, let coordinate = validUserCoordinate else { return }
setCenter(coordinate, animated: false)
shouldCenterOnNextUserLocation = false
}
private func updateZoom(scale: Double) {
let current = mapView.region
let span = MKCoordinateSpan(
latitudeDelta: min(max(current.span.latitudeDelta * scale, 0.0005), 120),
longitudeDelta: min(max(current.span.longitudeDelta * scale, 0.0005), 120)
)
mapView.setRegion(MKCoordinateRegion(center: current.center, span: span), animated: true)
}
}
#else
/// `MAMapView`
final class LocationReportMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
@ -381,3 +490,4 @@ final class LocationReportMapView: UIView, MAMapViewDelegate {
shouldCenterOnNextUserLocation = false
}
}
#endif

View File

@ -3,11 +3,16 @@
// suixinkan
//
import CoreLocation
import Kingfisher
import Photos
import SnapKit
import UIKit
#if targetEnvironment(simulator)
import MapKit
#endif
/// Android `PunchPointDetailScreen`
final class PunchPointDetailViewController: BaseViewController {
private let viewModel: PunchPointDetailViewModel
@ -447,7 +452,192 @@ final class PunchPointDetailViewController: BaseViewController {
}
///
/// marker
/// 使 MapKit marker
#if targetEnvironment(simulator)
final class PunchPointMapView: UIView, MKMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
private let mapView: MKMapView
private var punchPointAnnotation: MKPointAnnotation?
private var selectedAnnotation: MKPointAnnotation?
private var punchPointImageURL: URL?
private var fitWithUserLocation = false
override init(frame: CGRect) {
mapView = MKMapView(frame: .zero)
super.init(frame: frame)
mapView.delegate = self
mapView.showsUserLocation = true
mapView.showsCompass = false
mapView.showsScale = false
mapView.pointOfInterestFilter = .includingAll
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(mapTapped(_:)))
tapGesture.cancelsTouchesInView = false
mapView.addGestureRecognizer(tapGesture)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// 使 Marker
func updatePunchPointMarker(
coordinate: CLLocationCoordinate2D?,
imageURL: String? = nil,
fitWithUserLocation: Bool = false
) {
if let punchPointAnnotation {
mapView.removeAnnotation(punchPointAnnotation)
self.punchPointAnnotation = nil
}
punchPointImageURL = imageURL.flatMap(URL.init(string:))
self.fitWithUserLocation = fitWithUserLocation
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
let annotation = MKPointAnnotation()
annotation.coordinate = coordinate
annotation.title = "punch_point_original"
punchPointAnnotation = annotation
mapView.addAnnotation(annotation)
if fitWithUserLocation {
fitPunchPointAndUserLocation()
}
}
/// Marker
func updateMarker(coordinate: CLLocationCoordinate2D?) {
updatePunchPointMarker(coordinate: coordinate)
}
/// Marker
func updateSelectedMarker(coordinate: CLLocationCoordinate2D?) {
if let selectedAnnotation {
mapView.removeAnnotation(selectedAnnotation)
self.selectedAnnotation = nil
}
guard let coordinate, CLLocationCoordinate2DIsValid(coordinate) else { return }
let annotation = MKPointAnnotation()
annotation.coordinate = coordinate
annotation.title = "punch_point_selected"
selectedAnnotation = annotation
mapView.addAnnotation(annotation)
}
///
func setCenter(_ coordinate: CLLocationCoordinate2D, zoomLevel: Double = 15, animated: Bool = true) {
let delta = min(max(0.012 * pow(2, 15 - zoomLevel), 0.0005), 120)
let region = MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: delta, longitudeDelta: delta)
)
mapView.setRegion(region, animated: animated)
}
///
func zoomIn() {
updateZoom(scale: 0.5)
}
///
func zoomOut() {
updateZoom(scale: 2)
}
///
func centerOnUserLocation() {
guard let coordinate = mapView.userLocation.location?.coordinate,
CLLocationCoordinate2DIsValid(coordinate),
coordinate.latitude != 0 || coordinate.longitude != 0 else { return }
setCenter(coordinate)
}
///
func fitAllMarkers(edgePadding: UIEdgeInsets = UIEdgeInsets(top: 80, left: 56, bottom: 80, right: 56)) {
var annotations: [any MKAnnotation] = []
if let punchPointAnnotation { annotations.append(punchPointAnnotation) }
if let selectedAnnotation { annotations.append(selectedAnnotation) }
if mapView.userLocation.location != nil { annotations.append(mapView.userLocation) }
guard !annotations.isEmpty else { return }
if annotations.count == 1, let annotation = annotations.first {
setCenter(annotation.coordinate, zoomLevel: 16)
} else {
var mapRect = MKMapRect.null
for annotation in annotations {
let point = MKMapPoint(annotation.coordinate)
mapRect = mapRect.union(MKMapRect(
origin: point,
size: MKMapSize(width: 1, height: 1)
))
}
mapView.setVisibleMapRect(mapRect, edgePadding: edgePadding, animated: true)
}
}
func mapView(_ mapView: MKMapView, viewFor annotation: any MKAnnotation) -> MKAnnotationView? {
if annotation is MKUserLocation { return nil }
let isOriginal = (annotation as AnyObject) === punchPointAnnotation
let reuseIdentifier = isOriginal ? "PunchPointImageMarker" : "PunchPointSelectedMarker"
let annotationView = mapView.dequeueReusableAnnotationView(withIdentifier: reuseIdentifier)
?? MKAnnotationView(annotation: annotation, reuseIdentifier: reuseIdentifier)
annotationView.annotation = annotation
annotationView.canShowCallout = false
annotationView.centerOffset = CGPoint(x: 0, y: -12)
if isOriginal, let punchPointImageURL {
annotationView.image = UIImage(named: "punch_point_marker")
KingfisherManager.shared.retrieveImage(with: punchPointImageURL) { result in
guard case let .success(value) = result else { return }
Task { @MainActor in
guard annotationView.annotation === annotation else { return }
annotationView.image = Self.roundedMarkerImage(value.image)
}
}
} else {
annotationView.image = UIImage(named: "punch_point_marker")
}
return annotationView
}
func mapView(_ mapView: MKMapView, didUpdate userLocation: MKUserLocation) {
guard userLocation.location != nil else { return }
fitPunchPointAndUserLocation()
}
private func fitPunchPointAndUserLocation() {
guard fitWithUserLocation else { return }
fitAllMarkers(edgePadding: UIEdgeInsets(top: 80, left: 50, bottom: 80, right: 50))
}
private static func roundedMarkerImage(_ source: UIImage) -> UIImage {
let size = CGSize(width: 40, height: 40)
return UIGraphicsImageRenderer(size: size).image { _ in
UIBezierPath(roundedRect: CGRect(origin: .zero, size: size), cornerRadius: 6).addClip()
source.draw(in: CGRect(origin: .zero, size: size))
}
}
@objc private func mapTapped(_ gesture: UITapGestureRecognizer) {
guard gesture.state == .ended else { return }
let point = gesture.location(in: mapView)
onMapTap?(mapView.convert(point, toCoordinateFrom: mapView))
}
private func updateZoom(scale: Double) {
let current = mapView.region
let span = MKCoordinateSpan(
latitudeDelta: min(max(current.span.latitudeDelta * scale, 0.0005), 120),
longitudeDelta: min(max(current.span.longitudeDelta * scale, 0.0005), 120)
)
mapView.setRegion(MKCoordinateRegion(center: current.center, span: span), animated: true)
}
}
#else
/// marker
final class PunchPointMapView: UIView, MAMapViewDelegate {
var onMapTap: ((CLLocationCoordinate2D) -> Void)?
@ -614,6 +804,7 @@ final class PunchPointMapView: UIView, MAMapViewDelegate {
onMapTap?(poi.coordinate)
}
}
#endif
///
///

View File

@ -4,10 +4,15 @@
//
import Kingfisher
import MAMapKit
import SnapKit
import UIKit
#if targetEnvironment(simulator)
import MapKit
#else
import MAMapKit
#endif
///
final class WildPhotographerReportListViewController: BaseViewController {
private let viewModel: WildPhotographerReportListViewModel
@ -1797,6 +1802,49 @@ private final class WildReportDetailMediaThumbnailView: UIView {
}
///
#if targetEnvironment(simulator)
private final class WildReportDetailMapPreviewView: UIView {
private let mapView: MKMapView
init(scenicName: String, detailAddress: String, coordinate: CLLocationCoordinate2D?) {
mapView = MKMapView(frame: .zero)
super.init(frame: .zero)
backgroundColor = AppColor.pageBackgroundSoft
layer.cornerRadius = 10
clipsToBounds = true
mapView.showsCompass = false
mapView.showsScale = false
mapView.isScrollEnabled = false
mapView.isZoomEnabled = false
mapView.isRotateEnabled = false
mapView.isPitchEnabled = false
addSubview(mapView)
mapView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
guard let coordinate else { return }
let annotation = MKPointAnnotation()
annotation.coordinate = coordinate
annotation.title = scenicName
annotation.subtitle = detailAddress
mapView.addAnnotation(annotation)
mapView.setRegion(
MKCoordinateRegion(
center: coordinate,
span: MKCoordinateSpan(latitudeDelta: 0.006, longitudeDelta: 0.006)
),
animated: false
)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
#else
private final class WildReportDetailMapPreviewView: UIView {
private let mapView: MAMapView
@ -1834,6 +1882,7 @@ private final class WildReportDetailMapPreviewView: UIView {
fatalError("init(coder:) has not been implemented")
}
}
#endif
/// 线
private final class WildReportDetailTimelineRowView: UIView {

View File

@ -3,19 +3,19 @@
// suixinkan
//
#if __has_include(<AMapFoundationKit/AMapFoundationKit.h>)
#if !TARGET_OS_SIMULATOR && __has_include(<AMapFoundationKit/AMapFoundationKit.h>)
#import <AMapFoundationKit/AMapFoundationKit.h>
#endif
#if __has_include(<AMapLocationKit/AMapLocationKit.h>)
#if !TARGET_OS_SIMULATOR && __has_include(<AMapLocationKit/AMapLocationKit.h>)
#import <AMapLocationKit/AMapLocationKit.h>
#endif
#if __has_include(<MAMapKit/MAMapKit.h>)
#if !TARGET_OS_SIMULATOR && __has_include(<MAMapKit/MAMapKit.h>)
#import <MAMapKit/MAMapKit.h>
#endif
#if __has_include(<AMapSearchKit/AMapSearchKit.h>)
#if !TARGET_OS_SIMULATOR && __has_include(<AMapSearchKit/AMapSearchKit.h>)
#import <AMapSearchKit/AMapSearchKit.h>
#endif