Files
youle_app_ios_v2/docs/SDK-Integration-Guide.md
T
joywayerandClaude Opus 4.7 a2baf742b6 docs:新增 SDK-Integration-Guide.md(微信 / 高德定位 / opencore-amr / 七牛)
按 CLAUDE.md ADR-006「禁 CocoaPods,SPM 优先 + Vendor xcframework」组织,
每个 SDK 章节给出:凭证清单 / 工程接入 / 代码触点 / BackGameData 清理钩子 / 验收 /
风险,便于项目方发凭证后开发者按表落地。

关键约束(写入指南):
- 微信:不复用 msext AppID;AppSecret 客户端泄露风险,fallback 路径上线前必须切后台
- 高德:APIKey 与 Bundle ID 绑死,必须重新申请(msext 的 key 不可用);
  iOS 14+ 隐私合规三步(updatePrivacyShow → updatePrivacyAgree → apiKey)
- opencore-amr:复用 msext 已 segalign 8 修复版 .a,免重编源码;MRC wrapper 加
  -fno-objc-arc 文件级标志
- 七牛:SPM 拉官方 objc-sdk v8.9.x;token 缓存 + 失效前 30s 预刷新
- 每个 SDK 都标明在 BackGameDataHandler 必须加的清理钩子(WXApi.delegate=nil /
  LocationService.stop / AudioRecorder.cancel / QiniuUploader.cancelInFlight)

CLAUDE.md 文档索引追加一行指向新文件。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 21:36:51 +08:00

297 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 的真实接入步骤。
>
> **使用方式**:每接入一个 SDK 走一遍对应章节的"凭证清单 / 工程接入 / 代码触点 / 清理钩子 / 验收"五步,全程对照 CLAUDE.md「原则 A — H5 端零修改」与 ADR-006「禁止 CocoaPods」。
---
## 0. 总览
| SDK | 触发的契约项 | 选型 | 阻塞 |
|---|---|---|---|
| **微信 OpenSDK** | `accreditlogin` / `friendsSharetypeUrlToptitleDescript`(微信路径)/ `sharelogin` 反向 callback / `sharesuccess` | Vendor `.xcframework` 手动 | AppID + Universal Links + 后台 `/wechat/login`(或客户端 fallback |
| **高德定位(AMap** | `startlocation` / `getlocationinfo` 反向 callback9 字段)| Vendor `.xcframework` 手动 | **新 APIKey(绑死 Bundle ID,不可复用 msext** + 隐私合规调用 |
| **opencore-amr** | `prepareaudio`(录音上传)/ `mediaTypeAudio`(远端语音回放)AMR ↔ WAV 转码 | 从 msext 拷贝已修复版 `.a`fat header segalign 8 | 无外部凭证;二进制已在 msext 仓库内 |
| **七牛云上传** | `prepareaudio``getaudiourl({audiourl, time})` / `recordSuccess` 反向 callback | SPM 官方 `https://github.com/qiniu/objc-sdk` v8.9.x | 后台 token 颁发接口 / 长 token 缓存策略 |
**两条铁律(CLAUDE.md**
1. 任何接入步骤都不允许触碰 H5 端任何文件(包括 `app_*.js`
2. 不引入 CocoaPodsSPM 优先,闭源走 Vendor `.xcframework`
---
## 1. 微信 OpenSDKPhase 4.E
### 1.1 凭证清单(项目方提供)
- [ ] **AppID**:微信开放平台「移动应用」面板新建一个应用(新 Bundle ID 不能复用 msext 的 `wx586a9b321e56efb7`,必须为本项目重新申请)
- [ ] **Universal Links**:项目方拥有的域名 + `apple-app-site-association` 文件(部署到 `https://your-domain.com/.well-known/apple-app-site-association`HTTPS 必须)
- [ ] **后台 `/wechat/login` 中转接口**(强烈推荐):客户端拿 `code` 调后台,后台用 `AppSecret``access_token` + `unionid` + `userinfo`,回 7 字段给客户端
- 若后台未就绪,客户端走 fallback:`api.weixin.qq.com/sns/oauth2/access_token` + `sns/userinfo`**`AppSecret` 客户端硬编码 = 安全风险**,CLAUDE.md 父项目已明确警告。新项目尽量直接走后台中转
- [ ] **微信 OpenSDK XCFramework**:从 [微信开放平台官网下载](https://developers.weixin.qq.com/doc/oplatform/Downloads/iOS_Resource.html),使用 1.9.x 或最新(避免 1.8.x 的 ITMS-90809 历史包袱)
### 1.2 工程接入
1. **放置 SDK**
```
Vendor/WechatSDK/
├── WechatOpenSDK.xcframework
└── README.md # 记录版本号 + 下载日期 + 来源
```
按 Design §14.1 Vendor 接入标准流程:Target → General → Frameworks → Embed & Sign
2. **Info.plist 改动**(仅原生侧,不动 H5)
```xml
<!-- 1. URL Scheme:让微信 App 回调能打开本 App -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>weixin</string>
<key>CFBundleURLSchemes</key>
<array><string>YOUR_WX_APPID</string></array>
</dict>
</array>
<!-- 2. 查询微信是否安装 -->
<key>LSApplicationQueriesSchemes</key>
<array>
<string>weixin</string>
<string>weixinULAPI</string>
<string>weixinURLParamsAPI</string>
<!-- 已有的 mqq* / snssdk1128 别覆盖 -->
</array>
```
3. **Entitlement 改动**Signing & Capabilities
- Associated Domains 添加 `applinks:your-domain.com`(与 `apple-app-site-association` 的域名严格一致)
### 1.3 代码触点(接入此 SDK 必须改的文件)
| 文件 | 改动 |
|---|---|
| **`Source/SDK/WeChat/WeChatSDK.swift`**(新建) | `WXApi.registerApp(_:universalLink:)` 启动注册;包含全部 12 个 `MMAPP_SUPPORT_*` flagDesign §4.2 |
| **`AppDelegate.swift`** | `application(_:didFinishLaunchingWithOptions:)` 内调 `WeChatSDK.register()` |
| **`SceneDelegate.swift`** | `openURLContexts` 内按 **QQ → WXApi** 顺序判(msext `AppDelegate.m` 顺序硬约束,QQ 必须先判) |
| **`Source/SDK/WeChat/WeChatManager.swift`**(新建) | 持久 delegate`authorize() async throws -> WXAuthCode`state UUID 配对);`share(_:scene:) async throws`FIFO 串行)。Design §8.5 |
| **`Source/Login/WeChatAuth.swift`**(新建) | `authorize()` 拿 code → 转后台 `/wechat/login`(或 fallback 拼 `sns/oauth2/access_token` + `sns/userinfo`)→ 拿到 7 字段 user。Fallback 文件头标 `// FIXME: 待后台 /wechat/login 就绪后切换为后台中转` |
| **`Source/Bridge/Handlers/AccreditLoginHandler.swift`** | 移除 stub 返回,调 `WeChatAuth.authorize()` → 反向 `bridge.call("sharelogin", 7 字段)`<br>**字段名严格 1:1(含历史包袱)**:`openid` / `headimgurl` / `nickname` / `sex` / `city` / `Province`**大写 P**!)/ `unionid` |
| **`Source/Share/WechatShare.swift`** | 移除 stub `return .success`,调 `WeChatManager.share``isInstalled` 改为 `WXApi.isWXAppInstalled()` |
| **`Source/Bridge/Handlers/BackGameDataHandler.swift`** | **必须加** `WXApi.delegate = nil`(清理子游戏 push 期间的 delegate 指针) |
### 1.4 清理钩子(CLAUDE.md 第 6.0 节回响)
`BackGameDataHandler.swift` 的 `MainActor.run` 块加一行:
```swift
WXApi.delegate = nil // ← Phase 4.E 接入时取消注释
```
理由:msext `gameController.m:699` 在 backgameData 里清 delegate,防止 pop 后微信回调指向已销毁的 SubGameVC。
### 1.5 验收(H5 端零修改前提下跑)
- [ ] H5 调 `bridge.callHandler('accreditlogin')` → 拉起微信 → 同意授权 → 大厅收 `sharelogin({openid,...,Province})`**`Province` 是大写 P**
- [ ] H5 调 `bridge.callHandler('friendsSharetypeUrlToptitleDescript', {sharefriend:"2", ...})` → 直接调起微信朋友圈
- [ ] H5 调 `sharefriend:"1"` → SharePanel 三按钮,点微信 → 拉起微信好友选择 → 分享成功后大厅收 `sharesuccess({success:"2",type:"1"})`
- [ ] **关键回归**:截图分享 / QQ 分享 / 抖音分享路径不受影响
### 1.6 风险
- **AppSecret 客户端泄露**fallback 路径上线前必须切到后台中转,否则 secret 一旦泄露不可重置(CLAUDE.md 父项目「已识别为安全风险,明确接受不修」的同款问题,新项目从一开始避免)
- **Universal Links 配置错误**:授权回不来。验证方式:`xcrun simctl openurl booted "https://your-domain.com/test"` 看是否拉起本 App
- **微信审核**:审核期间 OpenSDK 行为差异(部分 API 限频),上线初期保持后台监控
---
## 2. 高德定位 SDKPhase 5
### 2.1 凭证清单
- [ ] **新 APIKey**[高德控制台](https://lbs.amap.com/dev/key/app) 用项目方账号新建应用,绑定本项目的 Bundle ID**msext 的 `b0d4a8e3fcbbcc0dd96283b7df6a4494` 不能复用**APIKey 与 Bundle ID 一一绑死)
- [ ] **AMap SDK XCFramework**[官方下载页](https://lbs.amap.com/api/ios-location-sdk/download)拉取最新(截至 2026-06 推荐 v2.10.x+
- `AMapLocationKit.xcframework` — 定位主库
- `AMapFoundationKit.xcframework` — 基础库(必须配合 LocationKit
### 2.2 工程接入
1. **放置 SDK**
```
Vendor/AMap/
├── AMapLocationKit.xcframework
├── AMapFoundationKit.xcframework
└── README.md # 记录 SDK 版本 + APIKey 创建日期 + 绑定 Bundle ID
```
Target → General → Frameworks → Embed & Sign(两个都要)
2. **Info.plist**(仅原生侧)
```xml
<key>NSLocationWhenInUseUsageDescription</key>
<string>{gamehallname}需要访问您的位置以提供本地化服务</string>
```
`{gamehallname}` 占位由 ChannelConfig.plist 注入或硬编为渠道名。
### 2.3 代码触点
| 文件 | 改动 |
|---|---|
| **`Source/SDK/AMap/AMapWrapper.swift`**(新建) | 启动期调用顺序:`updatePrivacyShow(.didShow, privacyInfo: .didContain)` → `updatePrivacyAgree(.didAgree)` → `AMapServices.shared().apiKey = "..."`**iOS 14+ 隐私合规必走的 3 步**,少一步定位拿不到结果) |
| **`AppDelegate.swift`** | `didFinishLaunchingWithOptions` 内调 `AMapWrapper.bootstrap()` |
| **`Source/Location/LocationService.swift`**(新建,actor | `requestOnce() async throws -> LocationPayload`(一次定位)<br>`startContinuous(onUpdate:)` / `stop()`(持续定位)<br>包 `AMapLocationManager`,回调转 `async` |
| **`Source/Bridge/Handlers/StartLocationHandler.swift`** | 移除 stub,按 `data == 1` 持续 / 其它一次性<br>反向 callback `bridge.call("getlocationinfo", 9 字段)`<br>**字段名严格 1:1**`address` / `city` / `cityCode` / `country` / `district` / `latitude`**string**`String(format: "%f", lat)`/ `longitude`**string**/ `province`**小写 p**!与 `sharelogin.Province` 不同)/ `street`<br>失败:`getlocationinfo({errorCode:12, errorMsg:"缺少定位权限"})` |
| **`Source/Bridge/Handlers/BackGameDataHandler.swift`** | **必须加** `LocationService.shared.stop()` |
### 2.4 清理钩子
`BackGameDataHandler.swift` 的 `MainActor.run` 块加:
```swift
LocationService.shared.stop() // ← Phase 5 接入时取消注释
```
理由:msext `gameController.m:2473-2478 cleanUpAction` 在 backgameData 里调 `[locationManager stopUpdatingLocation]` + delegate nil;防止 pop 后后台定位继续耗电 + 回调悬空。
### 2.5 验收
- [ ] 首次启动 → 系统权限弹框(文案为 Info.plist 设置的中文)
- [ ] H5 调 `bridge.callHandler('startlocation', "1")` → 收到 `getlocationinfo({...9 字段...})``latitude` / `longitude` 是 string 类型,`province` 是小写 p
- [ ] 拒绝权限 → H5 收 `getlocationinfo({errorCode:12,errorMsg:"缺少定位权限"})`
- [ ] 子游戏定位中 → 调 `backgameData` 退出 → 大厅不再收到悬挂定位回调(验证 cleanUp 钩子)
### 2.6 风险
- **APIKey 绑定**Bundle ID 改了 / 多 Target 用不同 ID 都要重新申请 key,否则定位失败但无明显报错(高德回 errorCode 7「KEY 校验失败」)
- **隐私合规调用顺序**:iOS 14+ 必须先 `updatePrivacyShow` → `updatePrivacyAgree` 再 `apiKey`,否则定位接口直接拒绝
- **模拟器**:高德定位在模拟器走 Apple 的 CoreLocation 模拟坐标,定位精度低,必须真机测
---
## 3. opencore-amr 转码库(Phase 3.B
### 3.1 凭证清单
- 无凭证。二进制已在 msext 仓库内,**且已经过 fat header `segalign 8` 修复**(参 CLAUDE.md 父项目「已处理的定时炸弹」一节,2026-06-20 摘掉 `-ld_classic` 的同一组 `.a`
### 3.2 工程接入
1. **拷贝二进制**(已修复版)
```bash
mkdir -p ylgamehall/Vendor/opencore-amr/
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/
```
2. **拷 VoiceConverterObjC wrapper**
```
ylgamehall/Vendor/opencore-amr/VoiceConverter/
├── VoiceConverter.h # ObjC interface(保留 ObjCwrapper 简单不重写 Swift
├── VoiceConverter.m # MRC 代码可保留,加 -fno-objc-arc 编译标志或转 ARC
├── amrFileCodec.h/m
└── interf_*.h/c
```
3. **Xcode 工程配置**
- Target → Build Phases → Link Binary With Libraries 加入两个 `.a`
- Target → Build Settings → Library Search Paths 加 `$(PROJECT_DIR)/ylgamehall/Vendor/opencore-amr`
- Target → Build Settings → Header Search Paths 加 `$(PROJECT_DIR)/ylgamehall/Vendor/opencore-amr/include`
- **不需要** `-ld_classic`(已用 `lipo -segalign 8` 修复 fat headerCLAUDE.md 父项目记录的修复同样适用)
- 若复用 msext MRC 的 `VoiceConverter.m`,加文件级 `-fno-objc-arc` 编译标志
4. **Bridging Header**(已有 `ylgamehall-Bridging-Header.h` 则追加;无则新建)
```objc
#import "VoiceConverter.h"
```
### 3.3 代码触点
| 文件 | 改动 |
|---|---|
| **`Source/Audio/VoiceCoder.swift`**(新建) | Swift wrapper`static func amrToWav(_ amrPath: URL) throws -> URL` / `static func wavToAmr(_ wavPath: URL) throws -> URL`<br>内部调 ObjC `VoiceConverter` |
| **`Source/Audio/AudioRecorder.swift`**(新建,actor | `record() async throws -> AudioFile``AVAudioRecorder` 录 WAV + 麦克风权限<br>录音参数与 msext `ChatVoiceRecorderVC` 等价:16kHz / 单声道 / 16-bit |
| **`Source/Bridge/Handlers/RemoteAudioHandler.swift`** | 移除 stub,分别接入:<br>**`prepareaudio`**(录音上传)→ `AudioRecorder.record()` → WAV→AMR `VoiceCoder.wavToAmr` → `QiniuUploader.upload`(见 §4 → `bridge.call("getaudiourl", {audiourl, time})`**子游戏页额外触发** `bridge.call("recordSuccess", {fileUrl, fileName, fileKey})`<br>**`mediaTypeAudio`**(远端回放)→ URLSession 下载 AMR → AMR→WAV `VoiceCoder.amrToWav` → `AVAudioPlayer` 播放<br>开始播放 `bridge.call("gameui_play_voice", user)` / 播放结束 `bridge.call("gameui_stop_voice", user)`<br>受 `VoicePlayingHandler` 的开关短路(=1 才播) |
| **`Info.plist`** | 加 `NSMicrophoneUsageDescription = "{gamehallname}需要访问您的麦克风录制语音消息"` |
| **`Source/Bridge/Handlers/BackGameDataHandler.swift`** | **必须加** `AudioRecorder.shared.cancel()` |
### 3.4 清理钩子
```swift
AudioRecorder.shared.cancel() // ← Phase 3.C 接入时取消注释
QiniuUploader.shared.cancelInFlight() // ← Phase 3.C 接入时取消注释
```
理由:录音 / 上传任务跨 VC 生命周期容易遗留;msext 通过 `ChatVoiceRecorderVC` 生命周期间接清理,我们的 actor 实现不绑 VC 必须显式 cancel。
### 3.5 验收
- [ ] H5 调 `prepareaudio` → 弹麦克风权限 → 同意后开始录音 → 录完上传七牛 → 大厅收 `getaudiourl({audiourl:"https://...", time:"3"})`
- [ ] 子游戏同样路径,额外收到 `recordSuccess({fileUrl, fileName, fileKey})`
- [ ] H5 调 `mediaTypeAudio({audiourl, user})` → 听到对方语音 + 收到 `gameui_play_voice` / 结束收 `gameui_stop_voice`
- [ ] `voicePlaying` 总开关设 0 → 调 `mediaTypeAudio` 不播放
---
## 4. 七牛云上传(Phase 3.C / 3.D
### 4.1 凭证清单
- [ ] **后台 token 颁发接口**:每次录音前向后台 GET `/api/qiniu/upload-token?scene=voice` 拿到上传凭证 + 域名
- [ ] **七牛 Bucket 名 / CDN 域名**:项目方提供(可硬编到 BundleConfig,或随 ChannelConfig 注入)
### 4.2 工程接入(SPM,优于二进制)
1. Xcode → File → Add Package Dependencies
2. URL`https://github.com/qiniu/objc-sdk`
3. 版本:`8.9.x` 最新(avoid 7.x 历史 API
4. 勾选 target → Add Package
注:七牛官方 SPM 包是 ObjC,但 SPM 直接支持 ObjC + Swift 混合,不需要 bridging headerSPM 自动处理)。
### 4.3 代码触点
| 文件 | 改动 |
|---|---|
| **`Source/Network/QiniuTokenService.swift`**(新建,actor | `fetchUploadToken() async throws -> QiniuToken`(含 token / 上传域名 / Key 模板 / 过期时间)<br>缓存策略:token 失效前 30 秒强制刷新;并发请求合并 |
| **`Source/Network/QiniuUploader.swift`**(新建,actor | `upload(_ file: URL, token: QiniuToken) async throws -> UploadedFile`<br>内部用 `QNUploadManager` 包成 asyncUploadedFile 含 `fileUrl` / `fileName` / `fileKey`<br>取消语义:`cancelInFlight()` 取消所有进行中任务(接清理钩子用) |
| **`Source/Bridge/Handlers/RemoteAudioHandler.swift`** | `prepareaudio` 完整链路第 3 步:`QiniuUploader.upload(amrFile, token: await QiniuTokenService.shared.fetchUploadToken())` |
### 4.4 清理钩子
见 §3.4(与 opencore-amr 同一处加 `QiniuUploader.shared.cancelInFlight()`)。
### 4.5 验收
- [ ] H5 调 `prepareaudio` → 录音 3 秒 → 上传 → 大厅收 `getaudiourl({audiourl:"https://your-cdn.com/voice/xxx.amr", time:"3"})`
- [ ] 录音中 / 上传中 → 退出子游戏 → 任务被 cancel,不再有悬空网络写
- [ ] token 过期场景:模拟后台返回过期 token → uploader 自动刷新重试一次
### 4.6 风险
- **token 时效**:通常 1 小时;高频录音场景需要 caching + 失效前预刷新
- **上传失败重试**:默认 3 次,超时建议设 30s(语音文件通常 < 100KB
- **CDN 域名 HTTPS**H5 业务侧拿到 `audiourl` 后会请求播放,必须保证域名 HTTPSNSAppTransportSecurity 我们没收紧 ATS,但七牛默认走 HTTPS)
---
## 5. 接入完毕的清单检查
每接入完一个 SDK,跑一遍:
- [ ] `BuildProject` 成功
- [ ] `Vendor/<SDK>/README.md` 已记录版本号 / 来源 / 凭证创建日期
- [ ] Info.plist 改动只在原生侧,未触 H5 任何文件
- [ ] `BackGameDataHandler.swift` 对应清理钩子已加(每个 SDK 都要回到此处补 1 行)
- [ ] `docs/Development-Plan.md §5 / §8` 进度勾选已同步(提交时附 commit 备注「Plan 进度已勾选」)
- [ ] `docs/Verification-Checklist.md` 对应 Phase 验证项已补充
- [ ] 真机跑过该 SDK 的验收项(模拟器对 SDK 行为不可靠:高德、微信、AVAudioRecorder 都受影响)
---
## 6. 与原项目 daoqi 的关系
接入过程中若行为与文档描述不一致,**优先参考原 daoqi 工程实现**(参 `CLAUDE.md`「原项目 daoqi:遇到问题时的参考来源」一节):
- 微信回调字段:`daoqi/msext/Class/RootVC/NewRootVC.m:2428``sharelogin` 7 字段,含 `Province` 大写)
- 高德 9 字段:`daoqi/msext/Class/RootVC/gameController.m:2528 / 2557``getlocationinfo` 字段顺序与类型)
- AMR 转码:`daoqi/msext/Class/Common/VoiceConvert/``VoiceConverter.h/m`、`amrFileCodec.h/m`
- 七牛上传:`daoqi/msext/Class/Utils/QiniuManager.m`(参数命名 + 回调结构)
**遇到契约边界 / 历史包袱时**:照搬 msext 字面 + 字段名 + 顺序,不要凭语义猜测。原项目稳定运行多年的写法是真理,文档 / 设计稿可能有传抄错误(已在 CLAUDE.md「典型案例」积累若干)。