Files
youle_app_ios_v2/docs/SDK-Integration-Guide.md
T
joywayerandClaude Opus 4.7 323fb9074b 修复 AMap 真机链接错:补 3 个系统 framework + 永久文档化
真机首次跑 ylgamehall 时 linker 报 7 个 Undefined symbol:
- _CNCopyCurrentNetworkInfo / _CNCopySupportedInterfaces / _SCNetworkReachability*
  → SystemConfiguration.framework
- _OBJC_CLASS_$_CTTelephonyNetworkInfo → CoreTelephony.framework
- _OBJC_CLASS_$_EAAccessoryManager → ExternalAccessory.framework

根因:AMap 是 fat 静态库(`Vendor/AMap/*.framework`),它内部引用的系统 API
不会被自动带入 transitive 依赖,必须项目方手动 link。CocoaPods 时代由
podspec 自动声明(daoqi msext 走 Pods 故未在 pbxproj 显式 link
ExternalAccessory);本项目走 Vendor 手动接入路线,必须显式补齐。

工程:pbxproj 加 3 个 PBXBuildFile + PBXFileReference(Xcode UI 操作自动写入)

文档(永久记录避免重蹈):
- Vendor/AMap/README.md「工程接入步骤」第 5 步补齐 3 个系统 framework
  + 列出每个 framework 对应的 symbol
- Vendor/AMap/README.md 末尾加「如果未来真机链接报 C++ symbol 缺失」
  备用方案(加 libc++.tbd,daoqi 已加;当前 AMap 2.12.0 未触发)
- docs/SDK-Integration-Guide.md §A.2 第 5 步同步

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-23 02:50:29 +08:00

348 lines
18 KiB
Markdown
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.
# SDK 接入指南
> 本文档面向项目方 + 开发者,描述 Phase 4.E / Phase 5 / Phase 3.B-D 三组 SDK 的真实接入步骤。
>
> **核心前提**:本项目复用 daoqi(msext) 原项目的全部 SDK 凭证。**Bundle ID 必须保持 `com.skyapp.ylgamehall`**(已确认与 msext Release 配置一致)— 改 Bundle ID 会导致所有 key 失效。
>
> **代码状态(2026-06-22**:所有 Swift wrapper / handler 真实路径 / 生命周期清理钩子已落地,全部包在 `#if canImport(<ModuleName>)` 守卫里。**只需用户在 Xcode UI 完成 3 件事**(详见 §A 用户手动步骤),代码即激活真实路径。
---
## A. 用户手动 Xcode 步骤(必做 3 件事)
> **选型偏好**:官方 SPM 优先;闭源 / 无官方 SPM 的走 Vendor 二进制(社区 SPM wrapper 不计入官方,仓库一旦停更即为定时炸弹,按 daoqi `CLAUDE.md` ADR-006 否决)。
> 当前结果:
> - **七牛走官方 SPM**`https://github.com/qiniu/objc-sdk`,活跃维护)
> - **微信走 Vendor**Tencent 不维护官方 SPM;社区 wrapper 风险高,回 Vendor 与 AMap 统一处置)
> - **高德走 Vendor**(官方未提供 SPM
>
> 这 3 件事都是 Xcode UI 操作,无法 AI 自动完成。完成后 `BuildProject` 即激活所有真实路径。
### A.1 添加微信 OpenSDKVendor 手动)
> Tencent 官方至今未维护 SPM 仓库(`Tencent/WechatOpenSDK-XCFramework` 不存在,404);社区 wrapperyanyin1986 / anotheren)非官方,被否决。改走 Vendor `.xcframework`,与 AMap 同款处置方式。
**预先准备**:从腾讯开放平台下载 `OpenSDK*_NoPay.zip`(推荐 2.0.5+),解压取出 `WechatOpenSDK.xcframework`,放到 `Vendor/WechatSDK/`。下载页与详细说明见 `Vendor/WechatSDK/README.md`
1. 选中 `ylgamehall` Target → General → Frameworks, Libraries, and Embedded Content
2.`+` → Add Other → Add Files
3.`Vendor/WechatSDK/WechatOpenSDK.xcframework`
4. **Embed** 列选 **Embed & Sign**xcframework 是动态库,与下面 AMap 的 `Do Not Embed` 不同,别错)
5. ⌘B 编译
### A.2 添加高德定位 framework × 2Vendor 手动,无 SPM 替代)
> ⚠️ 高德官方至今未提供 SPM 包 / 也不提供 xcframework;只发布旧式 fat `.framework`,含 x86_64 + **device** arm64**不含** simulator arm64)。
>
> **结果**:真机 build OKM 芯片 Mac iOS-simulator 链接失败。**这是项目方接受的现状**——开发期跑真机,与 daoqi msext 同款实践。详细决策原因 / 不要做哪些事,见 `Vendor/AMap/README.md` 「已知限制」章节。
1. 选中 `ylgamehall` Target → General → Frameworks, Libraries, and Embedded Content
2.`+` → Add Other → Add Files
3. 一次性选两个:
- `Vendor/AMap/AMapFoundationKit.framework`
- `Vendor/AMap/AMapLocationKit.framework`
4. 两个的 **Embed** 列都选 **Do Not Embed**(关键,别错——静态 fat frameworkEmbed 会触发 `__OBJC` 段重复符号错)
5. **同一面板再 `+` 加 3 个系统 framework**(关键!漏一个真机链接器立即报 Undefined symbol):
- **SystemConfiguration.framework**
- **CoreTelephony.framework**
- **ExternalAccessory.framework**
全部 **Do Not Embed**。详细原因见 `Vendor/AMap/README.md` 「工程接入步骤」第 5 步。
6. ⌘B 编译 — **target 必须选真机**或 Generic iOS Device,模拟器 target 会因 simulator-arm64 slice 缺失而链接失败(按设计)
### A.3 添加七牛 SPM 依赖(远程)
> 项目方下载的 `docs/res/objc-sdk-8.9.2/` 改 SPM 后不再使用,可保留作离线备份或直接删除(不影响工程)。
1. Xcode → File → **Add Package Dependencies...**
2. 右上搜索框粘贴:`https://github.com/qiniu/objc-sdk`
3. **Dependency Rule****Up to Next Major Version**`8.9.0`
4. Add Package → 弹窗里 Library 选 `Qiniu` → Add Package
5. ⌘B 编译
---
## B. 完成 A 三步后会发生什么
代码侧已经全部写好,并用 `#if canImport(...)` 守卫围着真实路径。一旦 Xcode UI 把 SDK 加进 Target`canImport` 立刻返回 true,下面这些就会自动激活:
| 文件 | 激活后行为 |
|---|---|
| `AppDelegate.swift` | `WeChatSDK.register()` → 真实调 `WXApi.registerApp("wx586a9b321e56efb7", universalLink: "")` |
| `AppDelegate.swift` | `AMapWrapper.bootstrap()` → 真实调隐私合规 3 步 + apiKey |
| `SceneDelegate.swift` | `openURLContexts` → 真实调 `WXApi.handleOpen(url, delegate:)` |
| `AccreditLoginHandler` | `accreditlogin` → 真实拉起微信授权 → 反向 callback `sharelogin` 7 字段(Province 大写 P |
| `WechatShare` | `share()` → 真实调 `WXApi.send(SendMessageToWXReq)` |
| `StartLocationHandler` | `startlocation` → 真实调 `AMapLocationManager.requestLocation` → 反向 callback `getlocationinfo` 9 字段(latitude/longitude string, province 小写 p |
| `BackGameDataHandler` | 子游戏 backgameData 退出时自动清 `WXApi.delegate = nil` + `LocationService.stop()` |
**Qiniu 单独说明**:七牛 SDK 加进 SPM 后,`canImport(QiniuSDK)` 通过;但 `RemoteAudioHandler` 仍然是 stubprepareaudio 不真实录音上传),原因是录音→AMR 转码链路的 opencore-amr ObjC 库未接入。`QiniuUploader.upload(...)` 单独已可用,可用于其它非录音场景。完整录音上传链路见 §C。
---
## C. 仍需用户手动做的事(opencore-amr / AMR 录音上传)
> 这一块代码侧没有自动完成,因为需要从 msext 拷贝 ObjC 源码(含 MRC+ 修改 Build SettingsFile-level `-fno-objc-arc` flag+ Bridging Header。这些 pbxproj 改动对 Xcode 26 同步项目风险较高,留用户手动。
### C.1 拷贝 opencore-amr 静态库
```bash
mkdir -p ylgamehall/Vendor/opencore-amr/
# 已修复版(fat header segalign 8)静态库
cp ../daoqi/msext/msext/Class/Common/VoiceConvert/lib/libopencore-amrnb.a \
ylgamehall/Vendor/opencore-amr/
cp ../daoqi/msext/msext/Class/Common/VoiceConvert/lib/libopencore-amrwb.a \
ylgamehall/Vendor/opencore-amr/
# 拷头文件
cp -r ../daoqi/msext/msext/Class/Common/VoiceConvert/include \
ylgamehall/Vendor/opencore-amr/
# 拷 VoiceConverter ObjC wrapper(含 amrFileCodec / interf_*
cp -r ../daoqi/msext/msext/Class/Common/VoiceConvert \
ylgamehall/Vendor/opencore-amr/VoiceConverter
```
### C.2 Xcode 工程配置
1. 选中 Target → Build Phases → Link Binary With Libraries → `+` → Add Other → Add Files → 选两个 `.a`
2. Target → Build Settings
- **Library Search Paths** 加 `$(PROJECT_DIR)/Vendor/opencore-amr`
- **Header Search Paths** 加 `$(PROJECT_DIR)/Vendor/opencore-amr/include`
3.`Vendor/opencore-amr/VoiceConverter/` 下的 `.m` 文件拖入项目(Add to Target ylgamehall
4. 每个 `.m` 文件单独加 `-fno-objc-arc` 编译标志(msext 是 MRC):
- Target → Build Phases → Compile Sources → 找到每个 `.m` → 双击 → 加 `-fno-objc-arc`
5. 创建 Bridging Header `ylgamehall-Bridging-Header.h` 内容:
```objc
#import "VoiceConverter.h"
```
6. Target → Build Settings → **Objective-C Bridging Header** 设为 `ylgamehall/ylgamehall-Bridging-Header.h`
### C.3 接入 Swift 录音 + 上传链路(代码侧)
完成 C.1-C.2 后回到代码侧补两个文件:
**`Source/Audio/VoiceCoder.swift`**Swift wrapper
```swift
import Foundation
public enum VoiceCoder {
public static func wavToAmr(_ wav: URL) throws -> URL {
let amr = wav.deletingPathExtension().appendingPathExtension("amr")
// VoiceConverter 是 msext ObjC 类(MRC),桥到 Swift 后直接调
let ok = VoiceConverter.convertWav(toAmr: wav.path, amrSavePath: amr.path)
guard ok else { throw VoiceCodeError.transcodeFailed }
return amr
}
public static func amrToWav(_ amr: URL) throws -> URL {
let wav = amr.deletingPathExtension().appendingPathExtension("wav")
let ok = VoiceConverter.convertAmr(toWav: amr.path, wavSavePath: wav.path)
guard ok else { throw VoiceCodeError.transcodeFailed }
return wav
}
public enum VoiceCodeError: Error { case transcodeFailed }
}
```
**`Source/Audio/AudioRecorder.swift`**actor
```swift
import AVFoundation
public actor AudioRecorder {
public static let shared = AudioRecorder()
public func record() async throws -> (file: URL, duration: Int) {
// AVAudioRecorder 16kHz / mono / 16-bit WAV
// 等价 msext ChatVoiceRecorderVC 配置
// ... 实现略,约 60 行
}
public func cancel() { /* 停录音 */ }
}
```
**升级 `RemoteAudioHandler.swift` 的 prepareaudio 分支**
```swift
bridge.register("prepareaudio") { _, callback in
callback?(.string("Response from prepareaudio"))
Task { @MainActor in
do {
let rec = try await AudioRecorder.shared.record()
let amr = try VoiceCoder.wavToAmr(rec.file)
let uploaded = try await QiniuUploader.shared.upload(amr, timeSec: rec.duration)
bridge.call("getaudiourl", data: .object([
"audiourl": .string(uploaded.fileUrl),
"time": .string("\(uploaded.timeSec)")
]), callback: nil)
// 子游戏页额外触发(仅 SubGameViewController 注册的 handler 时才发,
// 需要在 SubGameViewController.registerBridgeHandlers 里包一层判别):
// bridge.call("recordSuccess", data: ...)
} catch {
// 失败兜底(msext 同款乐观策略:不反向通知 H5)
}
}
}
```
**升级 BackGameDataHandler 清理钩子**:取消那行 `AudioRecorder.shared.cancel()` 注释。
---
## 0. 凭证总表(沿用 msext
> 全部来自 daoqi/msext 原项目,**直接复用,无需重新申请**。
| 项 | 值 | 来源 |
|---|---|---|
| **Bundle ID** | `com.skyapp.ylgamehall` | `daoqi/msext.xcodeproj` Release |
| **App 显示名** | `进贤聚友棋牌` | `SGDefineInfo.h:103 gamehallname` |
| 微信 AppID | `wx586a9b321e56efb7` | `SGDefineInfo.h:105 kAuthOpenID` |
| 微信 AppSecret | `b2792724b9565be23e8f5ba548f117cf` | `SGDefineInfo.h:107 Appsecret` |
| 微信 AuthScope | `snsapi_message,snsapi_userinfo,snsapi_friend,snsapi_contact` | `SGDefineInfo.h:104` |
| 微信 AuthState | `wechat_sdk` | `SGDefineInfo.h:106 kAuthState` |
| 高德定位 APIKey | `b0d4a8e3fcbbcc0dd96283b7df6a4494` | `daoqi/msext/Class/Common/APIKey.h:14` |
| 七牛 AccessKey | `dQbQLUm1jIuL9PEq4jd6VKB-6pPxPEdg7le9KeBm` | `QiniuConfig.m:12` |
| 七牛 SecretKey | `RCZpwLhAPoQ2sQQyWXzMJc7Po2MyZWfUJeW4Jmfq` | `QiniuConfig.m:13` |
| 七牛 BucketName | `iosaudio` | `QiniuConfig.m:16` |
| 七牛 CDN Domain | `iosaudio.daoqi88.cn` | `daoqi/msext/msext/qiniudomain/` 目录名 |
### 0.1 安全权衡(与父项目 CLAUDE.md 同步)
父项目 CLAUDE.md 第 6 节明确指出 `AppSecret` / `SecretKey` 客户端硬编码是已识别但**接受不修**的安全风险。msext IPA 已分发数年等价泄露,新外壳沿用同一 key + 沿用同款客户端直拼路径 → **同等安全等级**,**不引入新的攻击面**,**不需要后台中转接口**。
---
## 1. 微信 OpenSDKPhase 4.E)— 代码已就绪
### 1.1 凭证(已具备,沿用 msext)
参 §0 总表。
### 1.2 工程接入
参 §A.1(用户手动 Xcode 步骤)。
### 1.3 代码已落地
| 文件 | 状态 | 说明 |
|---|---|---|
| `Vendor/WechatSDK/WechatOpenSDK.xcframework` | ⏳ 等用户放置 | 项目方从腾讯开放平台下 `OpenSDK2.0.5+_NoPay.zip` 解压 → 放进 `Vendor/WechatSDK/` → 按 §A.1 Embed & Sign |
| `Source/SDK/WeChat/WeChatSDK.swift` | ✅ 已写 | `register()` + `handleOpenURL` 包 canImport |
| `Source/SDK/WeChat/WeChatManager.swift` | ✅ 已写 | authorize / shareLink async wrapper + WXApiDelegate |
| `Source/Login/WeChatAuth.swift` | ✅ 已写 | OAuth2 客户端直拼 sns/oauth2 + userinfo + 7 字段 |
| `Source/Bridge/Handlers/AccreditLoginHandler.swift` | ✅ 已升级 | 真实拉起授权 + sharelogin 反向 callbackProvince 大写 P |
| `Source/Share/WechatShare.swift` | ✅ 已升级 | 真实链接分享(type=2/3 截图待 Phase 4.F |
| `Source/Bridge/Handlers/BackGameDataHandler.swift` | ✅ 已加钩子 | `WXApi.delegate = nil` 子游戏 pop 时清理 |
| `AppDelegate.swift` | ✅ 已加注册 | `WeChatSDK.register()` |
| `SceneDelegate.swift` | ✅ 已加 openURL | `WeChatSDK.handleOpenURL(ctx.url)` |
| `Info.plist` | ✅ 已加 | CFBundleURLTypes + LSApplicationQueriesSchemes 微信 3 项 |
### 1.4 验收(用户 Xcode 加完 framework 后跑)
- [ ] H5 调 `bridge.callHandler('accreditlogin')` → 拉起微信 → 同意授权 → 大厅 H5 收 `sharelogin({openid,...,Province})`**Province 大写 P**
- [ ] H5 调 `bridge.callHandler('friendsSharetypeUrlToptitleDescript', {sharefriend:"2", ...})` → 拉起微信朋友圈
- [ ] H5 调 `sharefriend:"1"` → SharePanel 三按钮,点微信 → 拉起微信好友选择
- [ ] **关键回归**:截图分享 / QQ 分享 / 抖音分享路径不受影响
### 1.5 风险
- AppSecret / SecretKey 已永久泄露(msext IPA 已分发数年)。复用同一 secret 安全等级不变,不引入新风险。
- 企业签 / TF 分发,不上 App Store,不需要 Universal Links / 审核合规。
---
## 2. 高德定位 SDKPhase 5)— 代码已就绪
### 2.1 凭证(已具备)
参 §0 总表。APIKey 与 Bundle ID `com.skyapp.ylgamehall` 绑死,已复用。
### 2.2 工程接入
参 §A.2(用户手动 Xcode 步骤)。
### 2.3 代码已落地
| 文件 | 状态 |
|---|---|
| `Vendor/AMap/AMapFoundationKit.framework` + `AMapLocationKit.framework` | ✅ 已就位 |
| `Source/SDK/AMap/AMapWrapper.swift` | ✅ 已写 | 隐私合规 3 步 + apiKey 注入 |
| `Source/Location/LocationService.swift` | ✅ 已写 | actorrequestOnce 异步包装 + stop |
| `Source/Bridge/Handlers/StartLocationHandler.swift` | ✅ 已升级 | 真实定位 + getlocationinfo 9 字段(latitude/longitude string, province 小写 p/ 失败回 errorCode 12 |
| `Source/Bridge/Handlers/BackGameDataHandler.swift` | ✅ 已加钩子 | `LocationService.shared.stop()` |
| `AppDelegate.swift` | ✅ 已加注册 | `AMapWrapper.bootstrap()` |
| `Info.plist` | ✅ 已加 | NSLocationWhenInUseUsageDescription 中文文案 |
### 2.4 验收
- [ ] 首次启动 → 系统权限弹框(中文文案)
- [ ] H5 调 `startlocation` → 收 `getlocationinfo({...9 字段...})`**latitude/longitude 是 string、province 小写 p**
- [ ] 拒绝权限 → H5 收 `getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})`
- [ ] 子游戏定位中 → 退出子游戏 → 大厅不再收悬挂定位回调
### 2.5 风险
- **APIKey 绑定 Bundle ID**:本项目已对齐 `com.skyapp.ylgamehall`,可复用。**改 Bundle ID 必须重新申请 key**
- **模拟器定位精度低**:必须真机测
---
## 3. opencore-amr 转码库(Phase 3.B)— 用户手动
参 §C(用户手动步骤)。代码侧的 wrapper 文件待用户完成 C.1-C.2 后自行新增(§C.3 已给出代码骨架)。
---
## 4. 七牛云上传(Phase 3.C / 3.D)— 代码已就绪(除录音链路)
### 4.1 凭证(已具备)
参 §0 总表。客户端用 AccessKey + SecretKey 本地 HMAC-SHA1 拼 token(与 msext 等价路径),**无后台依赖**。
### 4.2 工程接入
参 §A.3(用户手动添加 SPM 依赖)。
### 4.3 代码已落地
| 文件 | 状态 |
|---|---|
| `Source/Network/QiniuConfig.swift` | ✅ 已写 | 4 项常量(AccessKey / SecretKey / BucketName / cdnDomain+ publicURL 拼接 |
| `Source/Network/QiniuTokenSigner.swift` | ✅ 已写 | 纯 Swift 用 `CryptoKit.HMAC<Insecure.SHA1>` + Base64URL 自签 token(与 msext QiniuManager.m:200-230 等价) |
| `Source/Network/QiniuUploader.swift` | ✅ 已写 | actor`upload(_ file: URL, timeSec: Int) async throws -> UploadedFile`,包 `QNUploadManager` 异步 |
### 4.4 上层调用待 §C 完成
`Source/Bridge/Handlers/RemoteAudioHandler.swift` 的 `prepareaudio` 真实链路依赖 §C 的 AMR 转码 + AudioRecorder。完成后取消 §C.3 的 TODO 注释即可激活全链路。
### 4.5 验收(依赖 §C 完成)
- [ ] H5 调 `prepareaudio` → 录音 3 秒 → 上传 → 大厅 H5 收 `getaudiourl({audiourl:"http://iosaudio.daoqi88.cn/xxx.amr", time:"3"})`
- [ ] 浏览器粘贴 `audiourl` 能直接下载 amr 文件
- [ ] 退出子游戏 → 进行中上传任务被 cancelSwift Task.cancel 传播)
---
## 5. 接入完毕的清单检查
每接入完一个 SDK 跑一遍:
- [ ] `BuildProject` 成功
- [ ] `Vendor/<SDK>/README.md` 已记录版本号 / 来源 / 凭证沿用 msext 的备注 — 已就位
- [ ] Info.plist 改动只在原生侧,未触 H5 任何文件 — 已就位
- [ ] `BackGameDataHandler.swift` 对应清理钩子已加 — 微信 + 高德已加 canImport 守卫;录音待 §C
- [ ] `docs/Development-Plan.md §5 / §8` 进度勾选已同步
- [ ] `docs/Verification-Checklist.md` 对应 Phase 验证项已补充
- [ ] 真机跑过验收项(模拟器对微信 / 高德 / AVAudioRecorder 都不可靠)
- [ ] **Bundle ID 没改**`com.skyapp.ylgamehall`),改了所有 key 失效
---
## 6. 与原项目 daoqi 的关系
接入过程中若行为与文档描述不一致,**优先参考原 daoqi 工程实现**(参 `CLAUDE.md`「原项目 daoqi:遇到问题时的参考来源」一节):
- 微信 OAuth + 7 字段:`daoqi/msext/Class/RootVC/NewRootVC.m:2428``sharelogin` 7 字段,含 `Province` 大写)
- 微信 SDK 12 个 MMAPP flag`daoqi/msext/AppDelegate.m` 搜 `registerApp`
- 高德 9 字段:`daoqi/msext/Class/RootVC/gameController.m:2528 / 2557``getlocationinfo` 字段顺序与类型)
- AMR 转码:`daoqi/msext/Class/Common/VoiceConvert/``VoiceConverter.h/m`、`amrFileCodec.h/m`
- 七牛 token 自签算法:`daoqi/msext/Class/Utils/QiniuManager.m:200-230`putPolicy + HMAC-SHA1 + Base64URL
- 七牛域名读取:`daoqi/msext/AppDelegate.m:142` 用 `FuncPublic filename:@"qiniudomain"` 读 `Resources/qiniudomain/` 下第一个子目录名作为实际 Domain