fix: 优化启动引导、坐标处理和日志管理

This commit is contained in:
xweiba
2026-08-06 16:23:09 +08:00
parent 0e8e83fd33
commit e86909cc2f
30 changed files with 2296 additions and 857 deletions
+180 -121
View File
@@ -2,121 +2,199 @@ import Foundation
import CoreLocation
import MapKit
struct CoordinatePair: Codable, Equatable {
static let currentConversionVersion = 1
struct Value: Codable, Equatable {
let latitude: Double
let longitude: Double
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
}
let wgs84: Value
let gcj02: Value
let conversionVersion: Int
init(wgs84: Value, gcj02: Value, conversionVersion: Int = CoordinatePair.currentConversionVersion) {
self.wgs84 = wgs84
self.gcj02 = gcj02
self.conversionVersion = conversionVersion
}
init(mapCoordinate: CLLocationCoordinate2D, mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) {
self = CoordinateConverter.coordinatePair(
lat: mapCoordinate.latitude,
lon: mapCoordinate.longitude,
mapCoordinateSystem: mapCoordinateSystem
)
}
func coordinate(for mapCoordinateSystem: CoordinateConverter.MapCoordinateSystem) -> CLLocationCoordinate2D {
switch mapCoordinateSystem {
case .wgs84: return wgs84.coordinate
case .gcj02: return gcj02.coordinate
}
}
func matchesWGS84(latitude: Double, longitude: Double, tolerance: Double = 0.0001) -> Bool {
abs(wgs84.latitude - latitude) <= tolerance
&& abs(wgs84.longitude - longitude) <= tolerance
}
}
/// GCJ-02 () WGS-84
///
/// MKMapView GCJ-02 Apple WGS-84
/// App WGS-84
/// MapKit does not expose a public API for its active coordinate reference system.
/// The app resolves it with a bounded heuristic, then stores both WGS-84 and
/// GCJ-02 representations at each write boundary so replay does not convert again.
enum CoordinateConverter {
// (Krasovsky 1940)
private static let a = 6378245.0
private static let ee = 0.00669342162296594323
///
enum CoordType: String {
enum MapCoordinateSystem: String {
case gcj02 = "GCJ-02"
case wgs84 = "WGS-84"
}
// MARK: -
// MARK: -
struct TileTypeChange: Equatable {
let previous: CoordType
let current: CoordType
struct MapCoordinateSystemChange: Equatable {
let previous: MapCoordinateSystem
let current: MapCoordinateSystem
}
/// 使 GCJ-02
@MainActor static var currentTileType = CoordType.gcj02
@MainActor private static var lastTileCheck: Date?
@MainActor private static var tileCheckPending = false
/// Apple 使 GCJ-02
@MainActor static var currentMapCoordinateSystem = MapCoordinateSystem.gcj02
@MainActor private static var mapCoordinateSystemCheckPending = false
@MainActor private(set) static var initialMapCoordinateSystemUsedFallback = true
/// Uses one fixed, known reference result as a best-effort tile heuristic.
///
/// MapKit does not expose a supported public API for its active tile CRS. A
/// timeout, empty response, or search error is therefore not evidence for
/// WGS-84: those cases deliberately fall back to the domestic GCJ-02 mode.
/// Startup gate: only request realtime location if the public MapKit probe
/// cannot resolve a coordinate type. This guarantees a finite answer before
/// persisted map positions are replayed.
@MainActor
@discardableResult
static func detectTileByFixedGeocode(force: Bool = false) async -> TileTypeChange? {
guard !tileCheckPending else {
RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 跳过(进行中)")
return nil
static func resolveInitialMapCoordinateSystem() async -> MapCoordinateSystem {
guard !mapCoordinateSystemCheckPending else {
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测已有请求进行中")
return currentMapCoordinateSystem
}
if !force, let last = lastTileCheck, -last.timeIntervalSinceNow < 30 {
RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 跳过(缓存\(Int(-last.timeIntervalSinceNow))s)")
return nil
}
tileCheckPending = true
defer { tileCheckPending = false }
RuntimeLogger.info("APP", "坐标转换", "瓦片检测: 发起查询", details: [
"force": String(force),
"当前瓦片": currentTileType.rawValue
mapCoordinateSystemCheckPending = true
defer { mapCoordinateSystemCheckPending = false }
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测开始", details: [
"锚点": "22.283819,114.158439",
"判定规则": "首条名称=林士街→GCJ-02,否则→WGS-84",
"缓存": "false"
])
let probeResult = await fixedGeocodeProbe()
guard !Task.isCancelled else { return nil }
lastTileCheck = Date()
let nextType: CoordType
switch probeResult {
let nextType: MapCoordinateSystem
switch await fixedAnchorCoordinateSystemProbe() {
case let .response(name, count):
nextType = name == "林士街" ? .gcj02 : .wgs84
RuntimeLogger.info("APP", "坐标转换", "瓦片检测完成 → \(nextType.rawValue)", details: [
initialMapCoordinateSystemUsedFallback = false
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准检测获得明确结果", details: [
"首条名称": name,
"结果数": String(count),
"命中锚点": String(nextType == .gcj02)
"命中林士街": String(name == "林士街"),
"最终标准": nextType.rawValue,
"结果来源": "固定锚点"
])
case .unavailable(let reason), .timedOut(let reason):
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测不可用,开始实时定位兜底", details: [
"原因": reason
])
let realtime = await RealtimeLocationManager.shared.requestLocation()
if let realtime,
CLLocationCoordinate2DIsValid(realtime),
!usesGCJ02ServiceArea(lat: realtime.latitude, lon: realtime.longitude) {
nextType = .wgs84
} else {
nextType = .gcj02
}
initialMapCoordinateSystemUsedFallback = true
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测使用兜底结果", details: [
"探测失败原因": reason,
"实时定位存在": String(realtime != nil),
"最终标准": nextType.rawValue,
"结果来源": realtime == nil ? "默认国内标准" : "实时定位服务区域"
])
case .unavailable:
nextType = .gcj02
RuntimeLogger.warning("APP", "坐标转换", "瓦片检测无结果,回退 GCJ-02")
case .timedOut:
nextType = .gcj02
RuntimeLogger.warning("APP", "坐标转换", "瓦片检测超时,回退 GCJ-02")
case .cancelled:
return nil
initialMapCoordinateSystemUsedFallback = true
RuntimeLogger.warning("APP", "坐标转换", "地图坐标标准检测被取消,保留默认国内标准", details: [
"最终标准": currentMapCoordinateSystem.rawValue
])
return currentMapCoordinateSystem
}
guard nextType != currentTileType else { return nil }
let change = TileTypeChange(previous: currentTileType, current: nextType)
currentTileType = nextType
currentMapCoordinateSystem = nextType
RuntimeLogger.info("APP", "坐标转换", "地图坐标标准已确定,允许创建地图", details: [
"最终标准": nextType.rawValue,
"使用兜底": String(initialMapCoordinateSystemUsedFallback),
"缓存": "false"
])
return nextType
}
/// A user-requested realtime sample is WGS-84 and can correct a provisional
/// startup map coordinate system without altering persisted coordinate pairs.
@MainActor
static func correctMapCoordinateSystemUsingRealtime(_ coordinate: CLLocationCoordinate2D) -> MapCoordinateSystemChange? {
guard CLLocationCoordinate2DIsValid(coordinate) else { return nil }
guard initialMapCoordinateSystemUsedFallback else {
RuntimeLogger.info("APP", "坐标转换", "实时定位不覆盖固定锚点的明确检测结果", details: [
"当前标准": currentMapCoordinateSystem.rawValue
])
return nil
}
let next: MapCoordinateSystem = usesGCJ02ServiceArea(lat: coordinate.latitude, lon: coordinate.longitude) ? .gcj02 : .wgs84
guard next != currentMapCoordinateSystem else {
RuntimeLogger.info("APP", "坐标转换", "实时定位确认兜底地图坐标标准无需修正", details: [
"当前标准": currentMapCoordinateSystem.rawValue
])
return nil
}
let change = MapCoordinateSystemChange(previous: currentMapCoordinateSystem, current: next)
currentMapCoordinateSystem = next
RuntimeLogger.warning("APP", "坐标转换", "实时定位修正启动兜底地图坐标标准", details: [
"from": change.previous.rawValue,
"to": change.current.rawValue
])
return change
}
/// Reprojects a map-display coordinate after the tile heuristic changes.
/// The physical coordinate remains WGS-84 in between the two display modes.
static func reprojectDisplayCoordinate(
_ coordinate: CLLocationCoordinate2D,
from previous: CoordType,
to current: CoordType
) -> CLLocationCoordinate2D {
guard previous != current else { return coordinate }
let stored = storedCoordinate(lat: coordinate.latitude, lon: coordinate.longitude, tileType: previous)
let display = displayCoordinate(lat: stored.lat, lon: stored.lon, tileType: current)
return CLLocationCoordinate2D(latitude: display.lat, longitude: display.lon)
}
private static func fixedGeocodeProbe() async -> TileProbeResult {
private static func fixedAnchorCoordinateSystemProbe() async -> MapCoordinateSystemProbeResult {
let request = MKLocalSearch.Request()
request.naturalLanguageQuery = "22.283819, 114.158439"
let search = MKLocalSearch(request: request)
let resolver = TileProbeResolver()
let resolver = MapCoordinateSystemProbeResolver()
return await withTaskCancellationHandler(operation: {
await withCheckedContinuation { continuation in
let timeout = DispatchWorkItem {
search.cancel()
resolver.resolve(.timedOut)
resolver.resolve(.timedOut(reason: "固定锚点查询超过5秒"))
}
resolver.install(continuation, timeout: timeout)
guard !resolver.isResolved else { return }
search.start { response, error in
guard error == nil else {
resolver.resolve(.unavailable)
if let error {
let nsError = error as NSError
resolver.resolve(.unavailable(
reason: "\(nsError.domain)(\(nsError.code)): \(nsError.localizedDescription)"
))
return
}
resolver.resolve(.response(
name: response?.mapItems.first?.name ?? "",
count: response?.mapItems.count ?? 0
))
let items = response?.mapItems ?? []
guard let first = items.first,
let name = first.name?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty else {
resolver.resolve(.unavailable(reason: "固定锚点查询返回空结果"))
return
}
resolver.resolve(.response(name: name, count: items.count))
}
DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: timeout)
}
@@ -126,48 +204,30 @@ enum CoordinateConverter {
})
}
// MARK: -
/// WGS-84
@MainActor
static func toStored(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
let stored = storedCoordinate(lat: lat, lon: lon, tileType: currentTileType)
RuntimeLogger.info("APP", "坐标转换", "地图坐标已规范为 WGS-84", details: [
"转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon))
])
return stored
}
/// WGS-84
@MainActor
static func toDisplay(lat: Double, lon: Double) -> (lat: Double, lon: Double) {
let display = displayCoordinate(lat: lat, lon: lon, tileType: currentTileType)
RuntimeLogger.info("APP", "坐标转换", "WGS-84 坐标已适配地图显示", details: [
"转换": String(currentTileType == .gcj02 && usesGCJ02ServiceArea(lat: lat, lon: lon))
])
return display
}
private static func storedCoordinate(
lat: Double,
lon: Double,
tileType: CoordType
) -> (lat: Double, lon: Double) {
guard tileType == .gcj02, usesGCJ02ServiceArea(lat: lat, lon: lon) else {
return (lat, lon)
/// Creates the complete persisted pair once at the map input boundary.
static func coordinatePair(lat: Double, lon: Double, mapCoordinateSystem: MapCoordinateSystem) -> CoordinatePair {
let raw = CoordinatePair.Value(latitude: lat, longitude: lon)
switch mapCoordinateSystem {
case .wgs84:
let gcj = wgs84ToGcj02(lat: lat, lon: lon)
return CoordinatePair(
wgs84: raw,
gcj02: .init(latitude: gcj.lat, longitude: gcj.lon)
)
case .gcj02:
let wgs = gcj02ToWgs84(lat: lat, lon: lon)
return CoordinatePair(
wgs84: .init(latitude: wgs.lat, longitude: wgs.lon),
gcj02: raw
)
}
return gcj02ToWgs84(lat: lat, lon: lon)
}
private static func displayCoordinate(
lat: Double,
lon: Double,
tileType: CoordType
) -> (lat: Double, lon: Double) {
guard tileType == .gcj02, usesGCJ02ServiceArea(lat: lat, lon: lon) else {
return (lat, lon)
}
return wgs84ToGcj02(lat: lat, lon: lon)
/// Legacy raw domestic map values were historically displayed as GCJ-02;
/// overseas values were WGS-84 and stay identity coordinates.
static func legacyCoordinatePair(lat: Double, lon: Double) -> CoordinatePair {
let type: MapCoordinateSystem = usesGCJ02ServiceArea(lat: lat, lon: lon) ? .gcj02 : .wgs84
return coordinatePair(lat: lat, lon: lon, mapCoordinateSystem: type)
}
// MARK: -
@@ -205,9 +265,8 @@ enum CoordinateConverter {
return (lat + d.lat, lon + d.lon)
}
/// AMap documents GCJ-02 for mainland China, Hong Kong, Macao and Taiwan;
/// its overseas world map uses WGS-84. Keep the bounds explicit so the
/// domestic fallback tile type never shifts an overseas coordinate.
/// Keep the GCJ-02 service region explicit. Outside this region, conversion
/// is identity so the domestic fallback can never shift an overseas value.
static func usesGCJ02ServiceArea(lat: Double, lon: Double) -> Bool {
let mainland = lat >= 0.8293 && lat <= 55.8271 && lon >= 72.004 && lon <= 137.8347
let hongKong = lat >= 22.13 && lat <= 22.57 && lon >= 113.82 && lon <= 114.45
@@ -249,17 +308,17 @@ enum CoordinateConverter {
}
}
private enum TileProbeResult {
private enum MapCoordinateSystemProbeResult {
case response(name: String, count: Int)
case unavailable
case timedOut
case unavailable(reason: String)
case timedOut(reason: String)
case cancelled
}
private final class TileProbeResolver: @unchecked Sendable {
private final class MapCoordinateSystemProbeResolver: @unchecked Sendable {
private let lock = NSLock()
private var result: TileProbeResult?
private var continuation: CheckedContinuation<TileProbeResult, Never>?
private var result: MapCoordinateSystemProbeResult?
private var continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>?
private var timeout: DispatchWorkItem?
var isResolved: Bool {
@@ -269,7 +328,7 @@ private final class TileProbeResolver: @unchecked Sendable {
}
func install(
_ continuation: CheckedContinuation<TileProbeResult, Never>,
_ continuation: CheckedContinuation<MapCoordinateSystemProbeResult, Never>,
timeout: DispatchWorkItem
) {
lock.lock()
@@ -284,7 +343,7 @@ private final class TileProbeResolver: @unchecked Sendable {
lock.unlock()
}
func resolve(_ nextResult: TileProbeResult) {
func resolve(_ nextResult: MapCoordinateSystemProbeResult) {
lock.lock()
guard result == nil else {
lock.unlock()