From d48ecc0473aac68238922a2411ab74dc7c8e209c Mon Sep 17 00:00:00 2001 From: joywayer Date: Mon, 22 Jun 2026 07:30:06 +0800 Subject: [PATCH] =?UTF-8?q?Design=20=C2=A78.6=EF=BC=9A=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20LocationKit=20+=202=20=E9=A1=B9=E6=8E=A5=E5=8F=A3=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖 §3.1 [20]startlocation + §3.2 [6]getlocationinfo 反向 callback。 §8.6.1 LocationService module 骨架: - AMapServices.bootstrap:privacy + key 启动期完成 - locateOnce() async/await 包装 AMapLocationManager.requestLocation - startContinuous / stopContinuous + onContinuousUpdate 闭包钩子 - LocationInfo struct 转契约 §3.2 表 B 9 字段(latitude/longitude string 化用 String(format: "%f"),province 小写 p) - LocationError.permissionDenied(code: 12) §8.6.2 H5 handler(LocationHandlers.startLocation): - 入参 data: int 1=持续 / 其他=一次性,与 msext RootVC 沿用 - 持续模式:onContinuousUpdate 挂钩 → 每次位置变化推 getlocationinfo - 一次性:await locateOnce → 单次推 - responseCallback: "startlocation" - 失败 callback 数据结构 errorCode 数字 12 不是 string(契约硬约束) §8.6.3 与 msext 6 维度差异表(SDK 接入 / 隐私合规集中 / async wrapper / 入参解析 / latitude string / province 小写 p) Co-Authored-By: Claude Opus 4.7 --- docs/H5-Native-Implementation-Design.md | 196 ++++++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/docs/H5-Native-Implementation-Design.md b/docs/H5-Native-Implementation-Design.md index 055b03a..e06d47f 100644 --- a/docs/H5-Native-Implementation-Design.md +++ b/docs/H5-Native-Implementation-Design.md @@ -2643,6 +2643,202 @@ public final class QQShareManager: NSObject { → 上层 `accreditLogin` / `friendsShare...` handler 看不到这些细节,只看到 `try await`,代码大厅 / 子游戏完全一致。 +### 8.6 LocationKit + +覆盖契约 §F §3.1 [20]`startlocation` + §3.2 [6]`getlocationinfo` 反向 callback 共 2 项接口。 + +#### 8.6.1 Module 骨架 + +依赖高德 `AMapLocationKit.xcframework`(ADR-006 走 Vendor 手动接入),用 Swift wrapper 隔离 Objective-C SDK,避免业务层接触 AMap 类型。 + +```swift +// Source/LocationKit/LocationService.swift +import AMapLocationKit + +@MainActor +public final class LocationService: NSObject, AMapLocationManagerDelegate { + + /// 启动期完成 AMap privacy + key 配置(详见 §4.2 启动并行任务) + public static func bootstrap() { + AMapServices.shared().enableHTTPS = true + AMapServices.shared().apiKey = BundleConfig.shared.amapKey + AMapLocationManager.updatePrivacyShow(.didShow, privacyInfo: .didContain) + AMapLocationManager.updatePrivacyAgree(.didAgree) + } + + private let manager: AMapLocationManager = { + let m = AMapLocationManager() + m.desiredAccuracy = kCLLocationAccuracyHundredMeters + m.locationTimeout = 6 + m.reGeocodeTimeout = 4 + m.locatingWithReGeocode = true + return m + }() + + private var continuousActive = false + + public override init() { + super.init() + manager.delegate = self + } + + // ─── 一次性定位(startlocation 入参 ≠ 1)────────────── + public func locateOnce() async throws -> LocationInfo { + try await withCheckedThrowingContinuation { cont in + manager.requestLocation(withReGeocode: true) { loc, reGeo, error in + if let error = error { + cont.resume(throwing: error) + return + } + guard let loc = loc, let reGeo = reGeo else { + cont.resume(throwing: LocationError.empty) + return + } + cont.resume(returning: LocationInfo(coord: loc, reGeo: reGeo)) + } + } + } + + // ─── 持续定位(startlocation 入参 == 1)───────────────── + public var onContinuousUpdate: ((LocationInfo) -> Void)? + + public func startContinuous() { + guard !continuousActive else { return } + continuousActive = true + manager.startUpdatingLocation() + } + + public func stopContinuous() { + guard continuousActive else { return } + continuousActive = false + manager.stopUpdatingLocation() + } + + public func amapLocationManager(_ manager: AMapLocationManager!, + didUpdate location: CLLocation!, + reGeocode: AMapLocationReGeocode!) { + guard let loc = location, let reGeo = reGeocode else { return } + onContinuousUpdate?(LocationInfo(coord: loc, reGeo: reGeo)) + } +} + +public struct LocationInfo: Sendable { + public let address: String + public let city: String + public let cityCode: String + public let country: String + public let district: String + public let latitude: String // ⚠️ string,契约硬约束(stringWithFormat:@"%f") + public let longitude: String // ⚠️ string + public let province: String // ⚠️ 小写 p,与 sharelogin 大写 P 不一致,沿用历史 + public let street: String + + init(coord: CLLocation, reGeo: AMapLocationReGeocode) { + address = reGeo.formattedAddress ?? "" + city = reGeo.city ?? "" + cityCode = reGeo.citycode ?? "" + country = reGeo.country ?? "" + district = reGeo.district ?? "" + latitude = String(format: "%f", coord.coordinate.latitude) + longitude = String(format: "%f", coord.coordinate.longitude) + province = reGeo.province ?? "" + street = reGeo.street ?? "" + } +} + +public enum LocationError: Error, Sendable { + case empty + case permissionDenied(code: Int = 12) +} +``` + +#### 8.6.2 H5 location handler 实现骨架 + +```swift +// Source/Bridge/Handlers/LocationHandlers.swift +@MainActor +public struct LocationHandlers { + + let bridge: BridgeProtocol + let service: LocationService + + public func register() { + bridge.register("startlocation", handler: startLocation) + } + + // MARK: - 【20】 startlocation + // + // 契约 §3.1 [20]: + // 入参 data : int 1=持续 startUpdatingLocation + // 其他=一次性 reGeocodeAction + // responseCallback: "startlocation" + // 反向 callback : getlocationinfo(§3.2 [6]) + // 成功: 表 B 9 字段(latitude/longitude 是 string, + // province 小写 p) + // 失败: { errorCode: 12, errorMsg: "缺少定位权限" } + // ⚠️ errorCode 是数字(NSNumber)不是 string + // + // 持续模式下,service.onContinuousUpdate 在 register() 时挂钩; + // 一次性模式直接 await + 推一次 + + private func startLocation(_ data: BridgeData?, _ cb: BridgeCallback?) async { + defer { cb?(.string("startlocation")) } + let continuous = (data?.asInt ?? 0) == 1 + + // 持续模式:挂钩 + 启动 → 每次位置变化都推 getlocationinfo + if continuous { + service.onContinuousUpdate = { [weak bridge] info in + bridge?.call("getlocationinfo", data: info.bridgeData, callback: nil) + } + service.startContinuous() + return + } + + // 一次性:await + 单次推 getlocationinfo + do { + let info = try await service.locateOnce() + bridge.call("getlocationinfo", data: info.bridgeData, callback: nil) + } catch { + // 契约 §3.2 表 B 失败结构:errorCode 是数字(不是 string) + bridge.call("getlocationinfo", + data: .object([ + "errorCode": .number(12), + "errorMsg": .string("缺少定位权限") + ]), + callback: nil) + } + } +} + +extension LocationInfo { + /// 转成契约 §3.2 表 B 的 BridgeData 结构(所有字段 string) + var bridgeData: BridgeData { + .object([ + "address": .string(address), + "city": .string(city), + "cityCode": .string(cityCode), + "country": .string(country), + "district": .string(district), + "latitude": .string(latitude), // stringified + "longitude": .string(longitude), // stringified + "province": .string(province), // 小写 p + "street": .string(street) + ]) + } +} +``` + +#### 8.6.3 与原 msext 的差异 + +| 维度 | msext 现状(不要照抄) | 新外壳决策 | +|------|--------------------|----------| +| SDK 接入 | CocoaPods `AMapLocation` 包含的 framework + Reachability headers 多重耦合 | Vendor 手动 `AMapLocationKit.xcframework`(ADR-006),SPM/CocoaPods 双关 | +| 隐私合规 | `updatePrivacyShow:.didShow` / `updatePrivacyAgree:.didAgree` 在 AppDelegate 散落 | `LocationService.bootstrap()` 集中调用 | +| 回调风格 | `delegate amapLocationManager:didUpdateLocation:reGeocode:` 散落赋值给 self | `async/await` + `onContinuousUpdate` 闭包,actor 安全 | +| 持续/一次性入参 | 入参 string 类型,`[data intValue]` 转 int 后判断 | BridgeData.asInt 直接拿 | +| latitude 字段类型 | `stringWithFormat:@"%f"` 字符串 | `String(format: "%f", ...)`(等价) | +| province 大小写 | 小写 p(与 sharelogin 大写 P 不一致)| 严格保持 | + --- ## 9. 并发模型