308 lines
15 KiB
Swift
308 lines
15 KiB
Swift
//
|
||
// LocationService.swift
|
||
// ylgamehall
|
||
//
|
||
// 高德 AMapLocationManager 异步包装:一次性 / 持续两种模式。
|
||
// 契约 §3.2 [6] getlocationinfo 9 字段;msext gameController.m:2491-2557 等价。
|
||
//
|
||
// ⚠️ canImport(AMapLocationKit) 守卫:SDK 未链接时 LocationService 永远抛
|
||
// LocationError.sdkNotLinked,handler 兜底退化为 stub 行为。
|
||
//
|
||
|
||
import Foundation
|
||
import CoreLocation
|
||
import os.log
|
||
|
||
/// 定位诊断日志(文件级 let,Logger 是 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 double,H5 不可分辨,
|
||
// 但 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=nil(owner=\(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
|