Files
youle_app_ios_v2/ylgamehall/Source/Location/LocationService.swift
T
2026-08-07 12:18:44 +08:00

308 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// LocationService.swift
// ylgamehall
//
// 高德 AMapLocationManager 异步包装:一次性 / 持续两种模式。
// 契约 §3.2 6 getlocationinfo 9 字段;msext gameController.m:2491-2557 等价。
//
// ⚠️ canImport(AMapLocationKit) 守卫:SDK 未链接时 LocationService 永远抛
// LocationError.sdkNotLinkedhandler 兜底退化为 stub 行为。
//
import Foundation
import CoreLocation
import os.log
/// 定位诊断日志(文件级 letLogger 是 Sendable,便于在 AMap 回调闭包内直接用)。
private let locLog = Logger(subsystem: "ylgamehall", category: "Location")
#if canImport(AMapLocationKit)
import AMapLocationKit
#endif
#if canImport(AMapFoundationKit)
import AMapFoundationKit
#endif
/// 9 字段定位结果。字段名严格 1:1 H5 端契约(contract §3.2 表 [6]):
/// - latitude / longitude **是 string** (`String(format: "%f", ...)`)
/// - province **小写 p**(与 sharelogin.Province 大写不同)
public struct LocationPayload: Sendable {
public let address: String
public let city: String
public let cityCode: String
public let country: String
public let district: String
/// ⚠️ **JSON 数字**(不是字符串),且保留 `%f` 的 6 位小数文本。
/// 用 `Decimal` 而不是 `Double`,见下方 init 注释与契约 §3.2 表 B。
public let latitude: Decimal
public let longitude: Decimal
public let province: String
public let street: String
}
#if canImport(AMapLocationKit)
extension LocationPayload {
/// 严格照搬 msext 的 `@{...}` 字面量语义。
///
/// msext
/// ```objc
/// @try{ [_bridge callHandler:@"getlocationinfo" data:@{
/// @"address":regeocode.formattedAddress, @"city":regeocode.city, ... }]; }
/// @catch (NSException * e) { NSLog(...); }
/// ```
/// `@{}` 字面量里**任何一个 value 为 nil 都会抛 NSException**(这正是它包
/// `@try/@catch` 的原因),净效果是:只要 9 个字段里有一个是 nil,
/// **整条 getlocationinfo 都不会发给 H5**。
///
/// 早前我们对每个字段做 `?? ""` 兜底,于是会发出一个带空串的包 —— 数据格式
/// 与原工程不一致(H5 会收到 msext 里根本收不到的消息)。这里改为任一字段
/// 为 nil 就返回 nil,由调用方跳过发送。
nonisolated init?(location: CLLocation, reGeocode: AMapLocationReGeocode) {
guard let address = reGeocode.formattedAddress,
let city = reGeocode.city,
let cityCode = reGeocode.citycode,
let country = reGeocode.country,
let district = reGeocode.district,
let province = reGeocode.province,
let street = reGeocode.street
else { return nil }
self.address = address
self.city = city
self.cityCode = cityCode
self.country = country
self.district = district
// 经纬度是 **数字**,保留 `%f` 的 6 位小数精度。
//
// ⚠️ 契约影响(docs/H5-Native-Contract.md §3.2 表 B):msext 的 WVJB 路径
// `gameController.m:2528` / `NewRootVC.m:2132`)用
// `[NSString stringWithFormat:@"%f", ...]` 把经纬度发成**字符串**;但老
// UIWebView / JSContext 路径(`RootVC.m:1998` / `fourviewVC.m:1541`)拼的是
// `\"latitude\":%f` —— **不带引号,是 JSON 数字**。现网子游戏 H5
// jinxianmahjong 05_Func.js:2029-2031)自造定位对象时也写
// `"latitude":28.623546` 数字形式。与 `errorCode` 是同一类问题:WVJB 路径
// 当年把它字符串化了。按项目方决定改回数字。
//
// 用 `%f` 文本构造 `Decimal`:既保住 6 位小数的舍入行为,又让
// JSONSerialization 输出 `28.636486` 而不是 Double 的 17 位有效数字
// `28.636486000000001`(两者 JS parse 后是同一个 IEEE754 doubleH5 不可分辨,
// 但 JSON 文本要与原工程一致)。
let latText = String(format: "%f", location.coordinate.latitude)
let lonText = String(format: "%f", location.coordinate.longitude)
self.latitude = Decimal(string: latText) ?? Decimal(location.coordinate.latitude)
self.longitude = Decimal(string: lonText) ?? Decimal(location.coordinate.longitude)
self.province = province
self.street = street
}
}
#endif
/// 一次定位请求可能产出的事件。**一次请求可以产出两条**(先 failure 再 success),
/// 这是 msext `completionBlock` 的原始控制流,不是笔误 —— 详见 requestOnce 注释。
public enum LocationOutcome: Sendable {
/// → H5 `getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})`
case failure
/// → H5 `getlocationinfo(<9 字段>)`
case success(LocationPayload)
}
@MainActor
public final class LocationService {
/// 容器标识(`lobby` / `subGame`),仅用于诊断日志。
private let owner: String
/// **每个容器必须持有自己的实例**(不要做成单例)。
/// msext `NewRootVC.m:1568` / `gameController.m:1230` 各自
/// `[[AMapLocationManager alloc] init]`,两套 manager + completionBlock +
/// delegate 完全独立。共用一个 manager 会引入三个原生侧故障:
/// 1. `requestLocationWithReGeocode:completionBlock:` 同一时刻只保留一个
/// 单次定位请求,两个容器并发请求会互相顶掉,被顶掉的一侧
/// completionBlock 永不触发 → continuation 永挂 → H5 什么都收不到;
/// 2. `stopUpdatingLocation`「会 cancel 掉所有的单次定位请求」,子游戏
/// `backgameData` 会连带取消大厅在飞的定位;
/// 3. `delegate = nil` 作用在共享 manager 上是**进程级永久**副作用。
public init(owner: String = "?") {
self.owner = owner
#if canImport(AMapLocationKit)
// msext `configLocationManager` 里 `setDelegate:self` 是**无条件**装上的,
// 不只服务持续定位:`amapLocationManager:doRequireLocationAuth:` 也走 delegate。
// 所以这里在 init 就把 shim 挂上,而不是等持续定位分支才 lazy 触发。
_ = delegateShim
#endif
}
#if canImport(AMapLocationKit)
/// msext `configLocationManager` 逐项等价。
private let manager: AMapLocationManager = {
let m = AMapLocationManager()
m.desiredAccuracy = kCLLocationAccuracyHundredMeters
m.pausesLocationUpdatesAutomatically = false
m.locationTimeout = 6
m.reGeocodeTimeout = 3
m.locatingWithReGeocode = true
return m
}()
/// 持续定位(`startlocation` type == 1)的 delegate 承接器。
/// msext `configLocationManager` 里 `setDelegate:self` 由 VC 自己承担,
/// 这里用独立对象持有,避免 LocationService 被迫继承 NSObject。
private lazy var delegateShim: LocationDelegateShim = {
let shim = LocationDelegateShim(owner: owner)
manager.delegate = shim
return shim
}()
#endif
/// 诊断用:requestOnce 调用序号,日志里可对上「谁发起、谁回来」。
private var requestSeq: UInt64 = 0
/// 一次性定位 + 逆地理。**逐行等价 msext `initCompleteBlock` 的 completionBlock**
/// `gameController.m:2493-2545` / `NewRootVC.m:2099-2150`):
///
/// ```objc
/// if (error) {
/// callHandler getlocationinfo {errorCode:12,...} // ①
/// if (error.code == AMapLocationErrorLocateFailed) return;
/// }
/// if (location) { if (regeocode) {
/// if (regeocode.formattedAddress != nil)
/// callHandler getlocationinfo <9 字段> // ②
/// }}
/// ```
///
/// 注意 ① 之后**没有 return**(除 locateFailed):高德在「拿到了 CLLocation 但
/// 逆地理超时/网络出错」时会带着 error **和** location 一起回调,msext 会先发一条
/// errorCode 12、再把真实定位补发出去,H5 最终拿到的是真实定位。
///
/// 早前的实现用 `async throws -> LocationPayload` 表达,单个返回值天然只能二选一,
/// 于是在 error 分支直接 return,②永远不会发 —— H5 只收到 errorCode 12
/// `C_Player.SetLocationInfo({errorCode:12})` 还会把已有的 addr 覆盖掉。子游戏
/// 进房瞬间网络/CPU 最忙、逆地理最容易超时,所以这个分叉在子游戏侧暴露得多。
/// 因此改成回调式:一次请求可以按 msext 的顺序产出两条 outcome。
///
/// - Parameter emit: 在 MainActor 上调用,可能被调用 0 / 1 / 2 次。
public func requestOnce(caller: String = "?",
emit: @escaping @MainActor (LocationOutcome) -> Void) {
#if canImport(AMapLocationKit)
requestSeq += 1
let seq = requestSeq
diagLog("[\(caller)] requestOnce #\(seq) 发起(owner=\(owner) manager=\(UInt(bitPattern: ObjectIdentifier(self.manager).hashValue))")
let t0 = Date()
// 高德的 completionBlock 在主线程回调;这里保持与 msext 同样的同步语义。
let accepted = manager.requestLocation(withReGeocode: true) { loc, regeo, err in
let ms = Int(Date().timeIntervalSince(t0) * 1000)
diagLog("[\(caller)] requestOnce #\(seq) completionBlock 回来(耗时 \(ms)ms):loc=\(loc != nil) regeo=\(regeo != nil) err=\(err?.localizedDescription ?? "nil")")
MainActor.assumeIsolated {
if let err = err as NSError? {
emit(.failure) // ①
if err.code == AMapLocationErrorCode.locateFailed.rawValue {
diagLog("[\(caller)] requestOnce #\(seq) locateFailed,按 msext 就此结束")
return
}
diagLog("[\(caller)] requestOnce #\(seq) 有 error 但非 locateFailed,按 msext 继续尝试补发真实定位")
}
// msext 要求 location / regeocode / formattedAddress 三者都在才发 ②
guard let loc, let regeo, regeo.formattedAddress != nil else {
diagLog("[\(caller)] requestOnce #\(seq) 无可用逆地理(loc=\(loc != nil) regeo=\(regeo != nil)),按 msext 不发 9 字段")
return
}
guard let payload = LocationPayload(location: loc, reGeocode: regeo) else {
diagLog("[\(caller)] requestOnce #\(seq) 逆地理有 nil 字段,按 msext@{} 抛 NSException)整条不发")
return
}
emit(.success(payload)) // ②
}
}
// AMapLocationManager.h:返回值是「是否成功添加单次定位Request」。msext 忽略它,
// 效果就是什么都不发给 H5;回调式实现天然等价(不再有 continuation 可挂死),
// 这里只留一条日志便于排查。
if !accepted {
diagLog("[\(caller)] requestOnce #\(seq) requestLocation 返回 NO(未挂上请求),按 msext 静默")
}
#else
diagLog("[\(caller)] requestOnce: AMapLocationKit 未链接,按 msext 无 SDK 场景不回 H5")
#endif
}
/// 持续定位(`startlocation` type == 1)。
/// msext `NewRootVC.m:414` / `gameController.m:537`
/// `if (tempinfo == 1) [self.locationManager startUpdatingLocation];`
/// 结果通过 delegate `amapLocationManager:didUpdateLocation:reGeocode:` 逐次推
/// `getlocationinfo`msext `gameController.m:2551-2560`),只在 `reGeocode` 与
/// `formattedAddress` 都非空时发;`didFailWithError` msext 只 NSLog、不通知 H5。
///
/// - Parameter emit: 每次定位更新都会调用(不是一次性)。
public func startContinuous(caller: String = "?",
emit: @escaping @MainActor (LocationOutcome) -> Void) {
#if canImport(AMapLocationKit)
diagLog("[\(caller)] startContinuous 发起(owner=\(owner)")
delegateShim.onUpdate = emit
delegateShim.caller = caller
manager.startUpdatingLocation()
#else
diagLog("[\(caller)] startContinuous: AMapLocationKit 未链接,按 msext 无 SDK 场景不回 H5")
#endif
}
/// 停止:msext gameController.m:2473-2478 cleanUpAction 等价
public func stop(caller: String = "?") {
#if canImport(AMapLocationKit)
// `stopUpdatingLocation` 会 cancel 掉本 manager 上所有单次定位请求。因为
// manager 是本容器私有的(每容器一个实例),影响范围仅限本容器 —— 与 msext
// cleanUpAction 只动自己那个 manager 一致。
diagLog("[\(caller)] stop()stopUpdatingLocation + delegate=nilowner=\(owner)")
manager.stopUpdatingLocation()
manager.delegate = nil
#endif
}
}
#if canImport(AMapLocationKit)
/// `AMapLocationManagerDelegate` 承接器(仅持续定位路径用到)。
/// msext 里这些方法直接实现在 NewRootVC / gameController 上;新外壳把它独立出来,
/// 每个 LocationService 一个,生命周期随宿主容器。
@MainActor
private final class LocationDelegateShim: NSObject, AMapLocationManagerDelegate {
var onUpdate: (@MainActor (LocationOutcome) -> Void)?
var caller: String = "?"
private let owner: String
init(owner: String) {
self.owner = owner
super.init()
}
/// msext `gameController.m:2551-2560` 等价:仅 reGeocode 与 formattedAddress
/// 都非空时推 9 字段,其余情况什么都不发。
nonisolated func amapLocationManager(_ manager: AMapLocationManager!,
didUpdate location: CLLocation!,
reGeocode: AMapLocationReGeocode!) {
// 先在当前上下文把 AMap 的非 Sendable 对象拆成纯值,只把 Sendable 的
// LocationPayload 送进 MainActor(否则 Swift 6 判定 reGeocode 跨隔离域有数据竞争)。
guard let location, let reGeocode, reGeocode.formattedAddress != nil else {
diagLog("[持续定位] 更新但无可用逆地理,按 msext 不发")
return
}
guard let payload = LocationPayload(location: location, reGeocode: reGeocode) else {
diagLog("[持续定位] 逆地理有 nil 字段,按 msext@{} 抛 NSException)整条不发")
return
}
MainActor.assumeIsolated {
diagLog("[\(caller)] 持续定位 → getlocationinfo city=\(payload.city)")
onUpdate?(.success(payload))
}
}
/// msext `gameController.m:2547` 等价:**只打日志,不通知 H5**。
nonisolated func amapLocationManager(_ manager: AMapLocationManager!,
didFailWithError error: (any Error)!) {
diagLog("[持续定位 owner=\(owner)] didFailWithError: \(error?.localizedDescription ?? "nil")msext 同款:不通知 H5")
}
}
#endif