1066 lines
57 KiB
Markdown
1066 lines
57 KiB
Markdown
# 进贤聚友棋牌 iOS 端 — 启动流程 & H5/原生通讯契约
|
||
|
||
> 适用版本:当前 master 分支线上版本(msext target)
|
||
> 受众:负责"用新技术栈重写一份等价 iOS 外壳"的开发者
|
||
> 目标:H5(gamehall.zip 内的 index.html 及子游戏)**零修改**即可在新 iOS 外壳上运行
|
||
|
||
---
|
||
|
||
## 契约边界(最重要)
|
||
|
||
新外壳与现网外壳之间,**只有以下三类内容是契约,必须 1:1 一致**:
|
||
|
||
1. **桥接接口名** — H5 调用的 handler 名字符串(如 `mediaTypeAudio`、`friendsSharetypeUrlToptitleDescript`、`OpenurlTitleData`),少一个字符 H5 就找不到。
|
||
2. **参数字段名 & 数据结构** — H5 传入的 `data` 字段名、Native 回调时的 payload 结构。**含历史包袱**:`sharelogin.Province`(大写 P)、`OpenurlTitleData` 的入参键名 `"title "`(末尾有空格)、`appservice` 用 `"1"`/`"2"` 字符串而不是数字、字段值约定(如 `isloop: -1/0/1` 三态),**必须照抄**。
|
||
3. **空实现接口也必须注册** — `opensaoma`、`getGameplay` 等当前为空实现的 handler,新外壳必须以同名空 handler 注册(响应 callback 但不做任何事),否则 H5 调用时会触发"bridge not found"错误分支。
|
||
|
||
**除以上三类之外,原生内部一切都可以重写**:技术栈(Swift/SwiftUI/Combine 都行)、WebView 接入方式(直接用 WKScriptMessageHandler 或继续用 WebViewJavascriptBridge)、网络层(URLSession/Alamofire)、解压库(ZIPFoundation)、状态机、控制器层级、命名风格——只要外部行为不变,怎么实现都行。本文档后续章节中描述的"现有实现细节"只是为了帮助你理解**为什么 H5 期待某种行为**,不是要求你照抄实现方式。
|
||
|
||
判断某项内容属不属于契约的方法:**问自己"H5 代码能不能感知到?"**。能感知(接口名、字段名、数据值、副作用)→ 是契约;不能感知(用什么 SDK、用什么线程模型、Bugly 接不接、代码注释怎么写)→ 不是契约,自由决定。
|
||
|
||
---
|
||
|
||
## 0. 阅读须知 / 关键概念
|
||
|
||
### 0.1 三套桥并存的事实
|
||
|
||
本项目 H5 ↔ 原生实际有**三套通讯通道**同时存在(不是双桥):
|
||
|
||
| 通道 | 触发条件 | WebView | 桥接技术 | 在哪些 VC 用 |
|
||
|------|---------|---------|---------|---------|
|
||
| 旧大厅桥 (JSExport / Bridge) | iOS < 9.0 主大厅路径 | `UIWebView` | JavaScriptCore + JSExport,`window.settings.方法名()` | `RootVC` / `fourviewVC` |
|
||
| 新大厅桥 (WVJB) | iOS ≥ 9.0 主大厅路径 | `WKWebView` | WebViewJavascriptBridge,`bridge.callHandler('方法名', data, cb)` | `NewRootVC` / `gameController` |
|
||
| 弹层桥 (JSExport / Bridgetwo) | 任何系统打开外链/公告页 | `UIWebView` | JSExport(轻量 3 接口),`settings.方法名()` | `threeView`(无论系统版本始终用这个) |
|
||
|
||
实际系统版本分支由 `AppDelegate.m didFinishLaunchingWithOptions:` 中 `if([UIDevice currentDevice].systemVersion.floatValue >= 9.0f)` 决定。**注意 threeView 不分支**——iOS 9+ 路径下主大厅是 WKWebView,但只要弹出 threeView 就降级回 UIWebView + JSExport。这是文档里最容易踩坑的地方。
|
||
|
||
iOS 26 时代 iOS 9 路径再也跑不到了(最低支持 iOS 9.0,但所有现役设备都 ≥ 12),新外壳**可以只实现"新大厅桥 + 弹层桥"**:
|
||
|
||
- **新大厅桥的接口名 = 旧桥 JSExport 方法转换为 JS 后的方法名**(H5 端字符串一致),差别只在调用风格
|
||
- **弹层桥的接口名是固定的 3 个**:`backgameData(data)` / `browser(url)` / `finishweb()`,H5 在外链页内用 `settings.xxx(...)` 调用。新外壳如果把 threeView 升级到 WKWebView,必须自己提供同名的 JS 注入(`window.settings = { backgameData, browser, finishweb }`),否则外链页内的关闭/回传链路断开。
|
||
|
||
→ **下面 §3 主大厅桥接表以新桥为准**,弹层桥单列于 §3.4,旧大厅桥 ObjC 签名作为附录列出。
|
||
|
||
### 0.2 H5 入口与资源分发
|
||
|
||
- H5 源码以 `gamehall.zip`(≈ 11 MB)形式打入 Bundle
|
||
- 首次启动解压到沙盒 `Library/Caches/{gamedir}/`(`gamedir` 是渠道注入值)
|
||
- 启动入口固定为:`Library/Caches/{gamedir}/{gamestart}/index.html`
|
||
- WebView 直接 `loadFileURL:` 加载,**走 file:// 协议**,不依赖 HTTP 服务器
|
||
- 启动 query 参数:旧桥附 `?Launchtype=0`,新桥未附;新桥额外注入 `app_data.js`/`app_battery.js`/`app_network.js`
|
||
|
||
### 0.3 渠道注入机制("空目录名注入")
|
||
|
||
11 个 Bundle 根目录是"空容器":目录里只有一个唯一子目录,其**目录名本身**就是配置值。运行时通过 `FuncPublic filename:` 读出。
|
||
|
||
```
|
||
msext/qiniudomain/iosaudio.daoqi88.cn/ ← 七牛 CDN 域名
|
||
msext/gameid/G2hw0ubng0zcoI0r4mx3H2yr4GejidwO/ ← 游戏 ID(混淆串)
|
||
msext/channel/FtJf073aa0d6rI1xD8J1Y42fINTm0ziK/ ← 渠道 ID
|
||
msext/gamedir/FtJf073aa0d6rI1xD8J1Y42fINTm0ziK/ ← 解压后顶层目录名(同 channel)
|
||
msext/gamestart/gamehall/ ← 启动子目录名
|
||
msext/gameconfig/tsgames.daoqi88.cn-config_test-update_jsonv2_test/ ← 配置服 URL 编码
|
||
msext/market/2/ ← 应用市场 ID
|
||
msext/agent/veRa0qrBf0df2K1G4de2tgfmVxB2jxpv/ ← 代理 ID
|
||
msext/appversion/43/ ← App 版本号
|
||
msext/other/ ← 其他(当前为空目录)
|
||
msext/appleconfig/ ← 苹果配置(当前为空目录)
|
||
```
|
||
|
||
读取实现:
|
||
```objc
|
||
+(NSString *)filename:(NSString *)file {
|
||
NSString *path = [FuncPublic getFilePath:file PathType:3]; // Bundle/{file}
|
||
NSArray *files = [fm subpathsAtPath:path];
|
||
for (NSString *name in files) {
|
||
if (![name hasPrefix:@"."] && ![name isEqualToString:@".DS_Store"]) return name;
|
||
}
|
||
return @"";
|
||
}
|
||
```
|
||
|
||
`PathType` 取值:
|
||
- `1` → `Documents/`
|
||
- `2` → `Library/Caches/`
|
||
- `3` → Bundle 根目录
|
||
|
||
→ 新外壳必须保留这套机制(或用等价的"扫描 Bundle 同名空目录"实现),否则换渠道时整套打包脚本都失效。
|
||
|
||
### 0.4 全局常量(PCH 暴露,全工程可用)
|
||
|
||
`SGDefineInfo.h` 中通过 PCH 暴露:
|
||
|
||
```
|
||
SERVER = http://niuniuapi.0791ts.cn/ // 旧主 API
|
||
SERVERNew = http://ylyxservice3.0791ts.cn/config/ // 新配置服
|
||
SERVERtest = https://www.tsysjw.com/niuniu/updata_json.txt
|
||
SERVERShop = http://ylyx.0791ts.cn/version_shop.json
|
||
SERVERTwo = http://bookstore.eobook.com/post/
|
||
SERVERSUB = http://ylyxservice2.0791ts.cn
|
||
appstore = "0" // 0=企业签,1=AppStore
|
||
gamehallname = @"进贤聚友棋牌"
|
||
kAuthOpenID = @"wx586a9b321e56efb7" // 微信 AppID
|
||
kAuthScope = @"snsapi_message,snsapi_userinfo,snsapi_friend,snsapi_contact"
|
||
kAuthState = @"wechat_sdk"
|
||
Appsecret = @"b2792724b9565be23e8f5ba548f117cf" // 微信 AppSecret(旧外壳客户端硬编码 — 已识别为安全风险)
|
||
DEVW / DEVH = 屏幕宽高(宏)
|
||
```
|
||
|
||
第三方 SDK Key:
|
||
```
|
||
APIKey = @"b0d4a8e3fcbbcc0dd96283b7df6a4494" // 高德地图
|
||
appID = @"ad97e1945b1e4fb5ad5b5246c8ca21e4" // 声网 Agora(gameController 视频房间用)
|
||
极光 appKey = @"3752badf07677981decdb7c2"
|
||
闲聊 appID = @"U1jJq3wgWluyB660"
|
||
Bugly appID = @"222f47a4ea"
|
||
```
|
||
|
||
---
|
||
|
||
## 1. 启动流程(AppDelegate)
|
||
|
||
### 1.1 完整流程图
|
||
|
||
```
|
||
application:didFinishLaunchingWithOptions:
|
||
│
|
||
├─ ① 读 qiniudomain 注入目录 → 写入全局 kQiniuDomain
|
||
├─ ② configureAPIKey
|
||
│ • 检查 APIKey 非空
|
||
│ • AMapLocationManager updatePrivacyShow / updatePrivacyAgree
|
||
│ • [AMapServices sharedServices].apiKey = APIKey
|
||
├─ ③ setupBugly
|
||
│ • blockMonitorEnable=YES, blockMonitorTimeout=1.5s
|
||
│ • [Bugly startWithAppId:@"222f47a4ea" config:...]
|
||
│ • 注:Bugly 在旧外壳中仍然集成,但后台账号已废弃、不再上报、实际不起作用。新外壳不再集成 Bugly
|
||
├─ ④ [XianliaoApiManager registerApp:@"U1jJq3wgWluyB660"]
|
||
├─ ⑤ 极光统计 JANALYTICSService.setupWithConfig (appKey=3752badf07677981decdb7c2, channel=tongji)
|
||
├─ ⑥ [NSURLProtocol registerClass:[RNCachingURLProtocol class]] // HTTP 资源本地缓存代理
|
||
├─ ⑦ 读 gamedir / gamestart 注入目录
|
||
├─ ⑧ 检查 Library/Caches/{gamedir}/{gamestart}/version.xml
|
||
│ • 不存在 → 同步解压 Bundle/gamehall.zip → Library/Caches/{gamedir}/
|
||
│ (≈0.2–0.5s,仅首次安装命中,远低于 Watchdog 20s)
|
||
│ • 存在 → 跳过
|
||
├─ ⑨ 创建 zips/images/caches 目录(如果 {gamedir} 不存在)
|
||
├─ ⑩ 首次启动标记 (everLaunched=NO):
|
||
│ • setBool:YES forKey:@"everLaunched"
|
||
│ • SaveDefaultInfo:@"0" Key:@"getcompareCode"
|
||
│ • SaveDefaultInfo:@"set0" Key:@"FirstBOOL"
|
||
│ • SaveDefaultInfo:@"set0" Key:@"SecondBOOL"
|
||
│ • SaveDefaultInfo:@"set0" Key:@"ThirdBOOL"
|
||
│ • 生成 app_gamesname.js: var app_gamesname=new Array('{gamestart}');
|
||
├─ ⑪ SaveDefaultInfo:@"1.0" Key:@"VersionInfo"
|
||
├─ ⑫ 创建 CustomWindow,按 iOS 版本分支:
|
||
│ • iOS ≥ 9.0 → NewRootVC + NavgationController (NavgationTypeMaskPortrait)
|
||
│ • iOS < 9.0 → RootVC + NavgationController
|
||
│ • navigationBarHidden = NO(但页面 viewWillAppear 内会 setNavigationBarHidden:YES)
|
||
├─ ⑬ status bar 显示,statusBarStyle = Default
|
||
├─ ⑭ window makeKeyAndVisible
|
||
├─ ⑮ 微信 SDK 注册:
|
||
│ • [WXApi registerApp:@"wx586a9b321e56efb7" enableMTA:YES]
|
||
│ • registerAppSupportContentFlag: 文本/图片/位置/视频/音频/网页/DOC/DOCX/PPT/PPTX/XLS/XLSX/PDF
|
||
└─ return YES
|
||
```
|
||
|
||
### 1.2 URL Scheme 回调(微信 / QQ 分享)
|
||
|
||
`AppDelegate.m` 同时实现 3 个 openURL 入口(兼容 iOS 8 / 9+):
|
||
|
||
```objc
|
||
- (BOOL)application:handleOpenURL:url // iOS 8 及更早
|
||
- (BOOL)application:openURL:sourceApplication:annotation: // iOS 9
|
||
- (BOOL)application:openURL:options: // iOS 9+
|
||
```
|
||
|
||
三个入口的实现完全一致,处理顺序固定:
|
||
|
||
```
|
||
1. [QQShareManager handleOpenURL:url] → 命中即 return YES
|
||
2. [WXApi handleOpenURL:url delegate:[WXApiManager sharedManager]]
|
||
```
|
||
|
||
**注意**:QQ 必须在微信之前判断(QQ 分享回调如果走到 WXApi 会被吞掉)。新外壳的 SceneDelegate / UIApplicationDelegate 实现必须保留这一顺序。
|
||
|
||
涉及到的 URL Scheme(来自微信/QQ SDK 注册需求):
|
||
- 微信:`wx586a9b321e56efb7`
|
||
- QQ:由 `QQShareManager` 管理(具体 scheme 见 Info.plist)
|
||
|
||
### 1.3 生命周期通知
|
||
|
||
| 系统事件 | 内部行为 |
|
||
|---------|---------|
|
||
| `applicationDidEnterBackground` | 发 `enterForeground` 通知(命名反人类,沿用历史)|
|
||
| `applicationDidBecomeActive` | 节流 0.3s 后发 `applicationWillResignActive` 通知 |
|
||
| `applicationDidReceiveMemoryWarning` | 仅 NSLog,不弹窗 |
|
||
|
||
两个通知最终被 RootVC/NewRootVC 监听,转发为 `appservice` 桥事件(见 §3.2)。
|
||
|
||
### 1.4 UIApplication openURL: 兼容性 swizzle
|
||
|
||
`AppDelegate.m` 末尾对 `UIApplication.openURL:` 做了 method swizzle,在 iOS 10+ 自动转发到 `openURL:options:completionHandler:`。新外壳如果用 Swift 重写,建议直接使用 `UIApplication.shared.open(_:options:completionHandler:)` 而无需 swizzle。
|
||
|
||
---
|
||
|
||
## 2. 主控制器生命周期 & 资源加载
|
||
|
||
### 2.1 NewRootVC(iOS 9+)
|
||
|
||
```
|
||
init
|
||
↓ filename: 读取所有渠道注入值(gamedir/gamestart/gameconfig/channel/agent/iosNumber/gameinfo/market/...)
|
||
↓
|
||
viewDidLoad
|
||
├─ 创建 WKWebView(frame = 全屏)
|
||
├─ _bridge = [WebViewJavascriptBridge bridgeForWebView:_webView]
|
||
├─ [_bridge setWebViewDelegate:self]
|
||
├─ [_bridge enableLogging]
|
||
├─ registerHandler × 20+ (见 §3.1)
|
||
├─ 监听 NSNotification:
|
||
│ - "backgameDatatwo" → 转发为 getWebdata
|
||
│ - "enterForeground" → 转发为 appservice("2")
|
||
│ - "applicationWillResignActive" → 转发为 appservice("1")
|
||
│ - UIDeviceBatteryLevelDidChangeNotification → 转发为 getBattery
|
||
├─ AFNetworkReachabilityManager 监听 → 转发为 getnetwork
|
||
├─ 启动 gonet 计时器(4s 轮询 gameconfig 服)
|
||
└─ navigationController setNavigationBarHidden:YES
|
||
↓
|
||
viewWillAppear
|
||
├─ 下载 SERVERNew/{gameconfig}.json 配置
|
||
├─ 解析 agent/game/channel/market 版本号
|
||
├─ 比较远端 vs 本地,必要时 uplevel() → 下载 game.zip + 解压
|
||
└─ initView() → 注入 app_data.js / app_battery.js / app_network.js
|
||
→ loadFileURL:Library/Caches/{gamedir}/{gamestart}/index.html
|
||
↓
|
||
dealloc → 移除所有通知,释放 AVAudioPlayer / locationManager / bridge
|
||
```
|
||
|
||
### 2.2 RootVC(iOS < 9)
|
||
|
||
差异:
|
||
- 使用 `UIWebView`,桥接走 `documentView.webView.mainFrame.javaScriptContext`
|
||
- `context[@"settings"] = jo` 暴露 `Bridge` 实例
|
||
- Native→H5 用 `[context evaluateScript:@"funcName(args)"]`
|
||
- 启动 URL 附 `?Launchtype=0` 查询参数
|
||
- 注入 JS 仅靠 `webViewDidFinishLoad` 时 `stringByEvaluatingJavaScriptFromString`
|
||
- 接口契约与 NewRootVC 对外名字一致(见 0.1 节关于 JSExport 默认转换规则)
|
||
|
||
### 2.3 三个子页面控制器
|
||
|
||
| 控制器 | WebView | 桥 | 推入时机 | 入口路径 |
|
||
|--------|---------|-----|---------|---------|
|
||
| `threeView` | UIWebView | Bridgetwo(轻量,3 接口)| `OpenurlTitleData` / `Openurl:Title:Data:` 桥接调用 | 外部 URL(H5 传入)|
|
||
| `fourviewVC` | UIWebView | Bridge(与 RootVC 同协议)| iOS<9 子游戏切换 | Library/Caches/{newgamedir}/{newgamestart}/index.html?Launchtype=1 |
|
||
| `gameController` | WKWebView | WebViewJavascriptBridge + 声网 Agora | iOS≥9 子游戏切换 / `SwitchOverGameData` | Library/Caches/{newgamedir}/{newgamestart}/index.html |
|
||
|
||
- `threeView` 用途:H5 调 `OpenurlTitleData` 时打开一个浮层 WebView 显示外部网页(公告/活动页/客服)
|
||
- `gameController` 比 NewRootVC 多 3 个**声网视频房间**接口(exitRoom / getVideoinfo / createRoom)
|
||
- `fourviewVC`、`threeView` 是 iOS<9 旧路径产物,新外壳可以合并为一个"通用 WebView 容器 + 视频房间扩展"
|
||
|
||
### 2.4 控制器层级关系
|
||
|
||
```
|
||
window.rootViewController = NavgationController
|
||
└─ NewRootVC(大厅)
|
||
├─ push threeView(H5 OpenurlTitleData:打开外链/公告)
|
||
└─ push gameController(H5 SwitchOverGameData:进入子游戏)
|
||
└─ push threeView(子游戏内也可以打开外链)
|
||
```
|
||
|
||
返回门控变量:`canbackone` / `one_Time` / `first_Time` / `Nothere`,用于防止抖动重复 push 和阻塞返回。新外壳推荐用 `UINavigationController` 内置 push 节流(disable 一段时间)+ DispatchWorkItem 实现,不要照搬这些散落 BOOL。
|
||
|
||
---
|
||
|
||
## 3. H5 ↔ 原生 桥接契约(**新外壳必须严格落地**)
|
||
|
||
> 以下接口名 = H5 端书写的字符串。新外壳必须使用相同的 Handler 名,相同的 data 字段、相同的 callback 数据结构、相同的副作用。
|
||
>
|
||
> 表中"入参"对应 `bridge.callHandler('xxx', data, responseCallback)` 中的 `data`;"responseCallback"对应同步 ack;"Native→H5 callback"指 `bridge.callHandler('xxx', payload)` 反向通知 H5。
|
||
>
|
||
> 对于 iOS<9 的 H5 调用风格 `settings.xxx(...)`,参数会按 ObjC selector 切分成位置参数(详见附录 A),新外壳如果不再支持 iOS<9 可以忽略;H5 适配层通常会自动二选一。
|
||
|
||
### 3.1 H5 → Native(registerHandler)
|
||
|
||
**真实分布**(以源码为准):
|
||
|
||
- `NewRootVC` 注册 **20 个** handler(大厅页用)
|
||
- `gameController` 注册 **23 个** handler(子游戏页用)= 与 NewRootVC 共享 **19 个** + 子游戏独有 **4 个**(`backgameData` / `createRoom` / `getVideoinfo` / `exitRoom`)
|
||
- 两者唯一差异:`SwitchOverGameData` 只在 NewRootVC(大厅 → 子游戏跳转),`backgameData` 只在 gameController(子游戏 → 大厅返回)
|
||
- `threeView` 不在此列,弹层桥见 §3.4
|
||
|
||
#### § A. 登录 / 分享
|
||
|
||
##### 【1】 `accreditlogin` — 微信授权登录
|
||
|
||
| 字段 | 说明 |
|
||
|------|------|
|
||
| **入参 data** | 无(H5 传任意值,原生忽略)|
|
||
| **responseCallback** | 字符串 `"Response from accreditlogin"` |
|
||
| **副作用** | 调起微信 OAuth2 授权 |
|
||
| **后续回调** | Native→H5 调用 `sharelogin`(见 §3.2 [10])|
|
||
|
||
**旧外壳实现路径**(已识别为安全风险):
|
||
1. `[WXApiRequestHandler sendAuthRequestScope:kAuthScope State:kAuthState OpenID:kAuthOpenID InViewController:self]`
|
||
2. 微信回包 `managerDidRecvAuthResponse:` 拿到 code
|
||
3. **客户端直接拼**:`api.weixin.qq.com/sns/oauth2/access_token?code=...&secret=Appsecret&appid=...&grant_type=authorization_code` — secret 永久驻留 IPA
|
||
4. 再拉 `api.weixin.qq.com/sns/userinfo?access_token=...&openid=...`
|
||
5. 把 userinfo 通过 `callHandler:@"sharelogin"` 回给 H5
|
||
|
||
> **新外壳实现建议**(按 daoqi/CLAUDE.md 原则 B —— 内部自由重构、追求专业):
|
||
> - 步骤 1 / 5 保持不变(微信原生 SDK 调起 + sharelogin 回包字段,H5 契约边界)
|
||
> - 步骤 3 / 4 改为:客户端把 code 发给项目自有后台的 `/wechat/login` 接口,由后台完成 access_token + userinfo 拉取,再回传 7 个字段给客户端 → 客户端再 `callHandler:@"sharelogin"` 给 H5
|
||
> - **H5 视角完全无感**:仍然收到 `sharelogin({openid, headimgurl, nickname, sex, city, Province, unionid})` 7 字段
|
||
> - 收益:新外壳的 IPA 不再携带 secret,根除"二次泄露"风险
|
||
> - 前置依赖:后台需提供 `/wechat/login` 中转接口(项目方协调,超出本文档范围);若后台暂不提供,新外壳可临时沿用客户端直拼方案,但应在代码里标注 TODO 并优先推动后台改造
|
||
|
||
##### 【2】 `friendsSharetypeUrlToptitleDescript` — 分享
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `sharefriend` | string `"1"` / `"2"` | 1=好友/私聊,2=朋友圈/动态 |
|
||
| `sharetype` | string `"1"` / `"2"` / `"3"` | 3=闲聊;否则微信 |
|
||
| `type` | string `"1"` / `"2"` / 其他 | 1=链接分享,2=截图分享,其他=远程图片 URL 分享 |
|
||
| `webpageUrl` | string | 链接 URL 或图片 URL |
|
||
| `title` | string | 分享标题(**注意此键没有末尾空格**,与 §3.1 [15] OpenurlTitleData 的 `"title "` 不同)|
|
||
| `description` | string | 分享描述 |
|
||
|
||
**responseCallback**:字符串 `"sharefriend"`
|
||
**Native→H5 callback**:`sharesuccess`(成功后),数据见 §3.2 [11]
|
||
|
||
实现分发(外层按 `type`,内层按 `sharetype`):
|
||
- `type == 1` → 链接分享;`sharetype==3` 走 XianliaoShareLinkObject,否则走 `WXApiRequestHandler sendLinkURL:`;缩略图用 `sharelogo.png`
|
||
- `type == 2` → 截图分享;用 `FuncPublic getImageWithFullScreenshot` + `UIImageJPEGRepresentation(0.6)`;`sharetype==3` 走 XianliaoShareImageObject,否则 `WXApiRequestHandler sendImageData:`
|
||
- `type` 取其他值(含 3)→ 远程图片 URL 分享;从 `webpageUrl` 下载 NSData 再分享;分支同上
|
||
- 场景:`sharefriend==1` → `WXSceneSession`;`sharefriend==2` → `WXSceneTimeline`
|
||
|
||
#### § B. 音频 / 录音
|
||
|
||
##### 【3】 `srcIsloop` — 本地音频播放
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `src` | string | 音频文件名(位于 `Library/Caches/{gamedir}/{gamestart}/assets/wav/{src}`)|
|
||
| `isloop` | int `0` / `1` / `-1` | 0=单次按钮音;1=循环背景音;-1=停当前同类背景音 |
|
||
|
||
**responseCallback**:`"Response from srcIsloop"`
|
||
**实现**:
|
||
- `isloop == 0` → 单次 `buttunPlayer` 播放
|
||
- `isloop == 1` → `backgroundPlayer numberOfLoops = -1`,记录 `backgroundType = src`
|
||
- `isloop == -1` → 如果当前 `backgroundType == src`,则停止 backgroundPlayer
|
||
|
||
##### 【4】 `prepareaudio` — 启动麦克风录音
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| 入参 | (忽略) | |
|
||
| responseCallback | `"Response from prepareaudio"` | |
|
||
|
||
副作用:
|
||
1. `FuncPublic ifauth` 检查麦克风权限
|
||
- `0`(denied/restricted):通过 H5 alert 提示"{gamehallname}需要访问您的麦克风"
|
||
- `1`(not determined):开始录音时系统自动弹询问框
|
||
- `2`(authorized):直接录音
|
||
2. 用时间戳生成文件名(`yyyyMMddHHmmss`)
|
||
3. `[recorderVC beginRecordByFileName:fileName]`
|
||
4. 录音完成回调流程:
|
||
- 触发 `VoiceRecorderBaseVCRecordFinish:fileName:` 代理
|
||
- WAV → AMR 转换(`VoiceConverter ConvertWavToAmr:`)
|
||
- AMR 上传到七牛云(新版)或 `http://gameapi.0791ts.cn/api/UpLoad/PostFile`(旧版)
|
||
- 通过 `getaudiourl` callback 把 URL + 时长回给 H5(见 §3.2 [9])
|
||
|
||
##### 【5】 `mediaTypeAudio` — 远程语音回放
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `audiourl` | string | 远程 AMR 文件 URL(通常是七牛 CDN)|
|
||
| `user` | string | 发声方的用户 ID |
|
||
|
||
**responseCallback**:`"Response from mediaTypeAudio"`
|
||
**流程**:
|
||
1. 下载 AMR 到本地
|
||
2. `VoiceConverter ConvertAmrToWav` 转 WAV
|
||
3. `AVAudioPlayer` 播放
|
||
4. 开始播放时 callback H5:`gameui_play_voice(user)` (见 §3.2 [7])
|
||
5. 播放结束 callback H5:`gameui_stop_voice(user)` (见 §3.2 [8])
|
||
|
||
##### 【6】 `voicePlaying` — 语音播放总开关
|
||
|
||
| 入参 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| data | int `1` / `0` | 1=允许 mediaTypeAudio 实际播放;其他=静默不播 |
|
||
|
||
**responseCallback**:`"voicePlaying"`
|
||
|
||
#### § C. 摇一摇 / 振动
|
||
|
||
##### 【7】 `startshake`
|
||
- 入参:(忽略) → `canshake = YES`
|
||
- responseCallback:`"startshake from accreditlogin"`(字面字符串,沿用历史;H5 不依赖此值,但拷贝保留以减少差异)
|
||
|
||
##### 【8】 `stopshake`
|
||
- 入参:(忽略) → `canshake = NO`
|
||
- responseCallback:同上
|
||
|
||
##### 【9】 `SwitchShake` — 摇一摇音效开关
|
||
- 入参:int `1`/`其他` → `canvoice = (1==YES)`
|
||
- responseCallback:`"SwitchShake"`
|
||
|
||
##### 【10】 `vibrator` — 单次振动
|
||
- 入参:(忽略) → `AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)`
|
||
- responseCallback:`"vibrator"`
|
||
|
||
##### 【11】 `repeatvibrator` — 重复振动(实际同 vibrator)
|
||
- 入参:int
|
||
- responseCallback:`"repeatvibrator"`
|
||
- 实现就是单次 vibrate,不真的重复,沿用历史
|
||
|
||
##### 【12】 `canclevibrator` — 释放振动
|
||
- 入参:(忽略) → `AudioServicesDisposeSystemSoundID(kSystemSoundID_Vibrate)`
|
||
- responseCallback:`"canclevibrator"`
|
||
|
||
> 摇一摇结束事件 → §3.2 [5] `shakeEnd`
|
||
|
||
#### § D. 剪贴板
|
||
|
||
##### 【13】 `gamepastetext` — 读剪贴板
|
||
- 入参:(忽略)
|
||
- responseCallback:`UIPasteboard.generalPasteboard.string`(字符串本体)
|
||
|
||
##### 【14】 `gameCopytext` — 写剪贴板
|
||
- 入参:string → `UIPasteboard.generalPasteboard.string = data`
|
||
- responseCallback:`"gameCopytext"`
|
||
|
||
#### § E. 网页 / 浏览器 / 子游戏切换
|
||
|
||
##### 【15】 `OpenurlTitleData` — 打开内嵌 WebView 弹层(threeView)
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `url` | string | 待加载 URL(HTTP)|
|
||
| `title ` | string | **键名末尾有空格**,沿用历史,新外壳必须用 `@"title "` 取值 |
|
||
| `data` | string | 业务数据,传给 threeView 内 H5 |
|
||
| `orientation` | int `0`/`1` | 0=竖屏(实际逻辑见实现),1=横屏 |
|
||
|
||
**responseCallback**:`"OpenurlTitleData"`
|
||
**节流**:第一次调用后 3 秒内重复调用被吞掉(`first_Time` 标志)
|
||
|
||
##### 【16】 `browser` — 系统 Safari 打开外链
|
||
- 入参:string(URL,需要 URL 编码后传给 `UIApplication openURL:`)
|
||
- responseCallback:`"browser"`
|
||
|
||
##### 【17】 `SwitchOverGameData` — 切换到子游戏(push gameController)
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `Gamedirectory` | string | 子游戏 gamedir 名(解压目录)|
|
||
| `gamedownloadurl` | string | 子游戏 zip 包 CDN URL |
|
||
| `data` | string | 父大厅传给子游戏的数据(H5 自由 JSON)|
|
||
|
||
**responseCallback**:`"SwitchOverGameData"`
|
||
**副作用**:
|
||
1. 停止 backgroundPlayer
|
||
2. push 一个新的 `gameController`(WKWebView + 视频房间能力)
|
||
3. 子游戏内首次进入会拉取 zip → 解压到 `Library/Caches/{Gamedirectory}/`
|
||
4. 2 秒内重复调用被忽略(`one_Time` 节流)
|
||
5. 子游戏返回大厅时调用 `backgameData`(H5→Native,由 threeView/gameController 实现),通过 `NSNotification "backgameDatatwo"` 通知大厅,大厅再 callback `getWebdata`
|
||
|
||
##### 【18】 `backgameData` — 子游戏退出并回传数据
|
||
> ⚠️ **仅在 `gameController` 注册,NewRootVC 没有**
|
||
- 入参:string(业务数据 JSON)
|
||
- responseCallback:`"backgameData"`
|
||
- 副作用:
|
||
1. 停止 backgroundPlayer
|
||
2. cleanUpAction(清理 Agora 房间等子游戏副作用)
|
||
3. 移除 backgameDatatwo / enterForeground / applicationWillResignActive 通知监听
|
||
4. `[FuncPublic PopAnimation:self]` 回大厅
|
||
5. `[NSNotificationCenter postNotificationName:@"backgameDatatwo" object:data]`
|
||
- 大厅 NewRootVC 监听 `backgameDatatwo` 后会执行 `[_bridge callHandler:@"getWebdata" data:data]` 通知 H5
|
||
|
||
> ❌ `finishweb` 不在大厅桥/子游戏桥注册,它是**弹层桥**的接口。详见 §3.4。
|
||
|
||
#### § F. 定位
|
||
|
||
##### 【20】 `startlocation`
|
||
| 入参 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| data | int `1`/其他 | 1=持续 `startUpdatingLocation`;其他=一次性 `reGeocodeAction` |
|
||
|
||
**responseCallback**:`"startlocation"`
|
||
**Native→H5 callback**:`getlocationinfo`(见 §3.2 [6])
|
||
**实现**:基于 `AMapLocationManager`,精度 `kCLLocationAccuracyHundredMeters`
|
||
|
||
#### § G. 系统 / 设备
|
||
|
||
##### 【21】 `getphoneInfo` — 请求设备信息
|
||
- 入参:(忽略)
|
||
- responseCallback:`"getphoneInfo"`
|
||
- 真实数据通过 `getphoneinfo` callback(注意是小写 i!)返回,见 §3.2 [1]
|
||
|
||
##### 【22】 `opensaoma` — 二维码扫描(未实现)
|
||
- 入参:(忽略)
|
||
- responseCallback:`"opensaoma"`
|
||
- 当前为空实现,H5 调了不会出错也不会有动作。新外壳可以保留空实现以保持契约。
|
||
|
||
#### § H. 视频房间 / 子游戏返回(仅 `gameController` 注册)
|
||
|
||
##### 【19】 `createRoom` — 创建/加入声网音视频房间,并放置自己画面
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `pmw` | int | H5 端设计稿宽(用于把 H5 坐标换算到原生屏幕坐标)|
|
||
| `pmh` | int | H5 端设计稿高 |
|
||
| `top` | float | 视频窗 top(H5 设计稿坐标系)|
|
||
| `left` | float | 视频窗 left |
|
||
| `width` | float | 视频窗 width |
|
||
| `height` | float | 视频窗 height |
|
||
| `playerid` | string(数字串)| 本地用户 ID(自己,转 NSUInteger 作 Agora uid)|
|
||
| `roomid` | string | 频道源串;原生会执行 `md5(agentinfo + gameinfo + roomid)` 作为 Agora channel name |
|
||
|
||
**responseCallback**:`"createRoom"`
|
||
**坐标变换公式**:`x_pix = x_h5 * (DEVW / pmw)`,`y_pix = y_h5 * (DEVH / pmh)`
|
||
**副作用**:
|
||
- 内部创建 `localVideoBG`(214×129 容器)+ `localVideo`(136×109 自画像)
|
||
- 远端画面容器 `remoteVideo1` 按上述变换定位
|
||
- 默认 `localVideoBG.hidden=YES`
|
||
- `setEnableSpeakerphone:YES` + `idleTimerDisabled=YES`
|
||
- `agoraKit joinChannelByKey:nil channelName:md5(...) info:nil uid:playerid`
|
||
- **不会立刻触发任何 Native→H5 callback**;当远端用户加入并首帧解码后,触发 `getVideoinfo`(见 §3.2 [13])
|
||
|
||
##### 【20】 `getVideoinfo` — 添加/排版一个新的远端视频窗
|
||
|
||
入参与 `createRoom` **几乎相同**,但缺少 `roomid`(已在房间内),多一个隐含约定:
|
||
|
||
| 字段 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `pmw` / `pmh` | int | 设计稿宽高(同上)|
|
||
| `top` / `left` / `width` / `height` | float | 远端视频窗位置(H5 设计稿坐标)|
|
||
| `playerid` | string(数字串)| 远端用户 ID,等同 Agora 的 uid |
|
||
|
||
**responseCallback**:`"getVideoinfo"`
|
||
**节流**:内部 `players` 数组按 playerid 去重,已存在则跳过
|
||
**副作用**:创建 `remoteVideo` 子视图 + Agora `setupRemoteVideo` 绑定远端 uid
|
||
|
||
> 注意:H5 调用的 `getVideoinfo` 和 Native 反向触发的 `getVideoinfo`(见 §3.2 [13])**是同名两个方向**——bridge.callHandler 在 WVJB 里对同名是分通道的:Native 端 callHandler 是 Native→H5,H5 端调 callHandler 是 H5→Native,互不干扰。新外壳实现时不要把这二者合并。
|
||
|
||
##### 【21】 `exitRoom` — 离开房间
|
||
- 入参:(忽略)
|
||
- responseCallback:`"exitRoom"`
|
||
- 副作用:`[self leaveChannel]`(Agora `leaveChannel`,清理本地/远端 view)
|
||
|
||
##### 【22】 `backgameData` — 子游戏退出回大厅(同 §3.1 [18])
|
||
(见上方 [18],是同一个 handler,在 gameController 这一类下也算)
|
||
|
||
> Agora SDK appID 见 §0.4。新外壳如果不上视频可以把这 3 个视频 handler 注册为空响应——但只有 H5 子游戏不依赖语音/视频时才允许,否则 H5 会等不到 `getVideoinfo` 排版而画面空白。
|
||
|
||
### 3.2 Native → H5(callHandler 反向通知)
|
||
|
||
| # | Handler 名 | 触发时机 | 数据结构 | NewRootVC | gameController |
|
||
|---|-----------|---------|---------|:--------:|:--------:|
|
||
| 1 | `getphoneinfo` | 注册 `getphoneInfo` handler 收到调用后 | 见下方表 A | ✓ | ✓ |
|
||
| 2 | `getBattery` | UIDeviceBatteryLevelDidChangeNotification | 字符串 `"%.2f"` 形式的小数电量(0~1)| ✓ | ✓ |
|
||
| 3 | `getnetwork` | AFNetworkReachability 变化 | 字符串 `"1"`/`"2"`/`"3"` (1=无网,2=WiFi,3=蜂窝)| ✓ | ✓ |
|
||
| 4 | `appservice` | App 前后台切换 | 字符串 `"1"`=进入后台,`"2"`=回到前台(命名错位,沿用历史)| ✓ | ✓ |
|
||
| 5 | `shakeEnd` | motionEnded 摇一摇结束 | `nil` | ✓ | ✓ |
|
||
| 6 | `getlocationinfo` | 定位成功/失败 | 见下方表 B | ✓ | ✓ |
|
||
| 7 | `gameui_play_voice` | mediaTypeAudio 开始播放(且 voicePlaying=1 时)| 字符串 user_id | ✓ | ✓ |
|
||
| 8 | `gameui_stop_voice` | mediaTypeAudio AVAudioPlayer 播放结束 | 字符串 user_id(最近一次 mediaTypeAudio 的 user 字段)| ✓ | ✓ |
|
||
| 9 | `getaudiourl` | 录音 + 上传成功(七牛或旧 PostFile)| `{ "audiourl": "<七牛 URL>", "time": "<秒数字符串>" }` | ✓ | ✓ |
|
||
| 10 | `sharelogin` | 微信授权 OAuth 拿到 userinfo 后 | 见下方表 C | ✓ | ✓ |
|
||
| 11 | `sharesuccess` | 分享完成(微信 SendMessageToWXResp / 闲聊 finishBlock)| `{ "success": "2", "type": "<sharefriend 原值如 '1' 或 '2'>" }` | ✓ | ✓ |
|
||
| 12 | `getWebdata` | 收到 `backgameDatatwo` 通知(来自子页面/子游戏)| 字符串(透传 backgameData 入参)| ✓ | ✓ |
|
||
| 13 | `getVideoinfo` | 远端用户 Agora `didDecodedRemoteVideo` | 字符串 uid(数字串)| ✗ | ✓ |
|
||
| 14 | `phonestate` | CTCallCenter 检测到电话状态变化 | 字符串 `"2"`=来电,`"0"`=挂断 | ✗(已注释)| ✓ |
|
||
| 15 | `recordSuccess` | 七牛云上传完成后**额外**触发(与 getaudiourl 同时发)| `{ "fileUrl": "<完整 URL>", "fileName": "<7n key 文件名>", "fileKey": "<七牛 key>" }` | ✗ | ✓ |
|
||
|
||
**差异提示**:第 13–15 项是 gameController 独有的 callback。新外壳如果要让子游戏 H5 行为完全一致,必须实现这 3 项。其中 `phonestate` 与 `recordSuccess` 比较容易忽视:
|
||
- `phonestate`:H5 子游戏会在收到 `"2"` 时主动哑麦/暂停,收到 `"0"` 恢复,跟视频房间体验强相关
|
||
- `recordSuccess`:H5 收到后会拿 `fileUrl` 而非 `audiourl` 走业务(早期接口与新接口并存的兼容产物);新外壳两个 handler 都得发,data 字段不同
|
||
|
||
**表 A — `getphoneinfo`**:
|
||
```json
|
||
{
|
||
"PhoneAdresseMAC": "<UUID idForVendor>",
|
||
"PhoneDeviceBrand": "<UIDevice.model>", // "iPhone" / "iPad"
|
||
"PhoneIMEI": "<IDFA>", // ASIdentifierManager.advertisingIdentifier
|
||
"PhoneModel": "<UIDevice.localizedModel>",
|
||
"PhoneProvidersName": "<carrier name>",
|
||
"PhoneVersion": "<systemVersion>"
|
||
}
|
||
```
|
||
|
||
**表 B — `getlocationinfo`** (成功):
|
||
```jsonc
|
||
{
|
||
"address": "<完整地址>",
|
||
"city": "<市>",
|
||
"cityCode": "<城市编码>",
|
||
"country": "<国>",
|
||
"district": "<区>",
|
||
"latitude": "30.567890", // ⚠️ string,原生 stringWithFormat:@"%f",不是 double
|
||
"longitude": "104.123456", // ⚠️ string
|
||
"province": "<省>", // ⚠️ 小写 p,与 sharelogin 的 "Province" 大写形成不一致,沿用历史
|
||
"street": "<街道>"
|
||
}
|
||
```
|
||
失败:
|
||
```jsonc
|
||
{ "errorCode": 12, "errorMsg": "缺少定位权限" } // errorCode 是 NSNumber,JSON 看是数字 12
|
||
```
|
||
|
||
**表 C — `sharelogin`**:
|
||
```jsonc
|
||
{
|
||
"openid": "...",
|
||
"headimgurl": "...",
|
||
"nickname": "...", // 直接来自微信 userinfo(部分版本会带单引号包裹,由 H5 自行 trim 或原生在更早处理)
|
||
"sex": "1|2|0", // 1=男,2=女,0=未知
|
||
"city": "...", // 经 FuncPublic danbian: 去除单引号
|
||
"Province": "...", // ⚠️ 大写 P;经 FuncPublic danbian: 去除单引号
|
||
"unionid": "..."
|
||
}
|
||
```
|
||
|
||
### 3.3 关于 iOS<9 旧桥的精确签名(附录用,新外壳可不实现)
|
||
|
||
旧桥 `Bridge.h` 的 `JSProtocol`(共 **28** 方法)和 JS 调用方式见 **附录 A**。这些 ObjC selector 在 JSExport 默认转换规则下产生的 JS 方法名,已经和新桥的 handler 名一一对应(例如 `media:Type:Audio:` → JS `mediaTypeAudio`),所以 H5 上层调用是一致的。
|
||
|
||
### 3.4 弹层桥(threeView 内)
|
||
|
||
threeView 用 **UIWebView + JSExport (Bridgetwo)**,**与系统版本无关**——iOS 9+ 也走这条。`webViewDidFinishLoad:` 内执行:
|
||
|
||
```objc
|
||
JSContext *ctx = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
|
||
Bridgetwo *jo = [[Bridgetwo alloc] init];
|
||
ctx[@"settings"] = jo;
|
||
jo.delegate = self;
|
||
```
|
||
|
||
故 H5(弹层内的外链/公告页)的可用 JS API 是 `window.settings.方法名(...)`,**不是** `bridge.callHandler`。共 3 个方法:
|
||
|
||
| JS 调用 | 入参 | 行为 |
|
||
|--------|------|------|
|
||
| `settings.backgameData(data)` | string | pop 自己 + `[NSNotificationCenter postNotificationName:@"backgameDatatwo" object:data]`;大厅监听后 callback H5 `getWebdata` |
|
||
| `settings.browser(url)` | string | URL 编码后 `[UIApplication openURL:]` 调起 Safari |
|
||
| `settings.finishweb()` | (无) | pop 自己 |
|
||
|
||
弹层桥**没有** Native→H5 callback。
|
||
|
||
**新外壳实现建议**:threeView 可以升级到 WKWebView,但需要在 `webView didFinishNavigation:` 时注入:
|
||
|
||
```js
|
||
window.settings = {
|
||
backgameData: function(data) { window.webkit.messageHandlers.backgameData.postMessage(data); },
|
||
browser: function(url) { window.webkit.messageHandlers.browser.postMessage(url); },
|
||
finishweb: function() { window.webkit.messageHandlers.finishweb.postMessage(""); }
|
||
};
|
||
```
|
||
并注册对应 3 个 `WKScriptMessageHandler`。这样 H5 外链页代码完全不用动。
|
||
|
||
> **同名注意**:`backgameData` / `browser` 在弹层桥与大厅桥都有,但语义略不同(弹层 backgameData 会 pop 自己;大厅 backgameData 在 NewRootVC 不存在、在 gameController 是回大厅)。H5 在弹层内调 `settings.backgameData` 走的是弹层桥,不会跟主大厅 WVJB 撞车,因为后者用的是 `bridge.callHandler('backgameData', ...)`。
|
||
|
||
---
|
||
|
||
## 4. WebView 详细配置(必须照抄)
|
||
|
||
### 4.1 WKWebView 配置(NewRootVC / gameController)
|
||
|
||
```objc
|
||
WKWebViewConfiguration *cfg = [[WKWebViewConfiguration alloc] init];
|
||
WKPreferences *pref = [[WKPreferences alloc] init];
|
||
pref.javaScriptEnabled = YES;
|
||
pref.javaScriptCanOpenWindowsAutomatically = NO; // ⚠️ 注意是 NO
|
||
pref.minimumFontSize = 10;
|
||
cfg.preferences = pref;
|
||
|
||
_webView = [[WKWebView alloc] initWithFrame:CGRectMake(0,0,DEVW,DEVH) configuration:cfg];
|
||
_webView.navigationDelegate = self;
|
||
_webView.UIDelegate = self;
|
||
|
||
if (@available(iOS 11.0, *)) {
|
||
_webView.scrollView.contentInsetAdjustmentBehavior =
|
||
UIScrollViewContentInsetAdjustmentNever;
|
||
}
|
||
_webView.scrollView.bounces = NO;
|
||
_webView.scrollView.scrollEnabled = NO;
|
||
_webView.scrollView.showsVerticalScrollIndicator = NO;
|
||
_webView.scrollView.showsHorizontalScrollIndicator = NO;
|
||
```
|
||
|
||
- 不设置自定义 User-Agent
|
||
- 不显式管理 Cookie
|
||
- LocalStorage 由 H5 自管(WKWebView 默认走 WebsiteDataStore)
|
||
- 加载方式:**`loadFileURL:fileURL allowingReadAccessToURL:dirURL`**(file://),不走 HTTP
|
||
|
||
### 4.2 JS 注入
|
||
|
||
在 `viewWillAppear` 内(每次加载 H5 之前)写出三个文件:
|
||
|
||
#### app_data.js(写到 `{gamedir}/{gamestart}/app_data.js`)
|
||
```js
|
||
var app_gameconfig = "<gameconfig>";
|
||
var app_gamedir = "<gamedir>";
|
||
var app_gamestart = "<gamestart>";
|
||
var app_agent = "<agent>";
|
||
var app_appversion = "<appversion>";
|
||
var app_market = "<market>";
|
||
var app_channel = "<channel>";
|
||
var app_gameid = "<gameid>";
|
||
var app_compareCode= "<getcompareCode>"; // NSUserDefaults
|
||
```
|
||
|
||
#### app_battery.js
|
||
```js
|
||
var app_battery = "<UIDevice batteryLevel>"; // e.g. "0.85"
|
||
```
|
||
|
||
#### app_network.js
|
||
```js
|
||
var app_network = "<state>"; // "1"/"2"/"3"
|
||
```
|
||
|
||
> H5 模板里 `index.html` 通过 `<script src="app_data.js">` 等同步引入这三个文件。新外壳必须保证这些文件**在 loadFileURL 之前**就写好,否则 H5 启动时变量未定义直接崩。
|
||
|
||
### 4.3 WKUIDelegate(H5 alert / confirm)
|
||
|
||
`NewRootVC` 实现:
|
||
- `webView:runJavaScriptAlertPanelWithMessage:initiatedByFrame:completionHandler:` → 弹 UIAlertController 确认框
|
||
- `webView:runJavaScriptConfirmPanelWithMessage:initiatedByFrame:completionHandler:` → 确定/取消
|
||
|
||
`alert(msg)`:标题用 `{gamehallname}`,按钮"确定"。新外壳必须实现,否则 H5 调 `alert()` 没响应。
|
||
|
||
---
|
||
|
||
## 5. 网络层(SGGateway / SGGatewayApi)
|
||
|
||
### 5.1 调用约定
|
||
|
||
```objc
|
||
NSMutableDictionary *obj_do = [[NSMutableDictionary alloc] init];
|
||
NSMutableDictionary *obj_post = [[NSMutableDictionary alloc] init];
|
||
[obj_post setObject:@"get_group_pic" forKey:@"t"]; // 接口名
|
||
[obj_post setObject:@"123" forKey:@"i"]; // 业务参数
|
||
[obj_post setObject:@"<待算>" forKey:@"code"]; // 签名占位(有此键自动签名)
|
||
|
||
[[SGGateway defaultGateway]
|
||
gateway:self
|
||
func:@selector(ReturnInfo:)
|
||
obj:obj_do
|
||
obj_post:obj_post
|
||
STR:@"<path>" // 拼到 baseURL 后
|
||
HTTPMethod:@"POST"]; // POST / POSTtwo / GET
|
||
```
|
||
|
||
### 5.2 签名算法
|
||
|
||
当 `obj_post` 含 `code` 键时:
|
||
1. 把所有键按字典序排序(实现里是按插入顺序遍历——这里历史实现有歧义,沿用现状)
|
||
2. 拼接所有 value 为字符串
|
||
3. 追加密钥 `"WERTY#$&(HJKfghjWERTYUIFGJFGHadf2222"`
|
||
4. MD5
|
||
5. 设回 `obj_post[@"code"]`
|
||
|
||
### 5.3 BaseURL 选择
|
||
|
||
| HTTPMethod 入参 | baseURL |
|
||
|----------------|---------|
|
||
| `"POST"` | `SERVER` = `http://niuniuapi.0791ts.cn/` |
|
||
| `"POSTtwo"` | `SERVERTwo` = `http://bookstore.eobook.com/post/` |
|
||
| 其他(`"GET"` 等)| `SERVER`,参数走 query string |
|
||
|
||
### 5.4 响应规约
|
||
|
||
- 20s 超时,超时 UI 提示 `"无法与服务器通讯..."`,不回调
|
||
- 响应体 trim 空格 → JSON 解析
|
||
- 三个接口特殊包装为 `{"data":[{"result": value}]}`:
|
||
- `InsertSuggestInfo.aspx`
|
||
- `UpdateAPNSDevice.aspx`
|
||
- `InsertBookComment.aspx`
|
||
- 解析后写到 `obj_do[@"apiReturnData"]`
|
||
- `[_mvReturn performSelector:_returnFunction withObject:obj_do]` 回调
|
||
|
||
### 5.5 大厅配置 / 版本检查走另一套(不经 SGGateway)
|
||
|
||
`NewRootVC viewWillAppear` 自己用 NSURLSession 拉:
|
||
- `{SERVERNew}/{gameconfig}.json`(注意 SERVERNew 末尾有 `/config/`)
|
||
- 解析 `{ agent:[...], game:[...], channel:[...], market:[...] }` 等数组
|
||
- 比较版本号决定是否下载 game.zip
|
||
|
||
zip 包下载 + 解压由 `ASIHTTPRequest`(旧)/ `AFNetworking`(新)+ `ZipArchive` 完成。新外壳推荐用 `URLSession + ZIPFoundation`。
|
||
|
||
---
|
||
|
||
## 6. NSNotification 名清单(跨模块通讯,必须保留)
|
||
|
||
| 通知名 | 发送方 | 接收方 | 用途 |
|
||
|--------|--------|--------|------|
|
||
| `enterForeground` | AppDelegate `applicationDidEnterBackground` | Root/NewRootVC/fourview/game/three | 转 `appservice("2")` |
|
||
| `applicationWillResignActive` | AppDelegate `applicationDidBecomeActive`(节流 0.3s) | 同上 | 转 `appservice("1")` |
|
||
| `backgameDatatwo` | threeView / 子游戏 / RootVC.backgameData: | 父大厅 | 转 `getWebdata(data)` |
|
||
| `stopradio` | 任意 | 主 VC | 暂停 backgroundPlayer |
|
||
| `canbackye` | 任意(防抖恢复)| 主 VC | 允许返回手势 |
|
||
| `UIDeviceBatteryLevelDidChangeNotification` | 系统 | 主 VC | 转 `getBattery(level)` |
|
||
|
||
**命名注意**:旧 msext 中 `enterForeground` 实际表示"进入后台",`applicationWillResignActive` 实际表示"回到前台"(命名被换反,历史包袱)。
|
||
|
||
这些 NSNotification 是**模块内通讯**,**不是 H5 可见的契约边界** —— H5 只能看到桥事件 `appservice("1")`/`appservice("2")`。
|
||
|
||
新项目自由命名(推荐用 `.appDidEnterBackground` / `.appDidBecomeActive` 等清晰命名),只要最终把 `"1"`/`"2"` 正确转发给 H5 桥即可。详见 `H5-Native-Implementation-Design.md` §4.4。
|
||
|
||
---
|
||
|
||
## 7. 沙盒目录布局(运行时)
|
||
|
||
```
|
||
{App Bundle}/
|
||
gamehall.zip ← 初装资源包
|
||
{qiniudomain/<域名>}/ ← 渠道注入
|
||
{gameid/<ID>}/
|
||
{channel/<ID>}/
|
||
{market/<num>}/
|
||
{agent/<ID>}/
|
||
{appversion/<num>}/
|
||
{gamedir/<dir>}/
|
||
{gamestart/<dir>}/
|
||
{gameconfig/<URL编码字符串>}/
|
||
Class/Common/VoiceConvert/lib/libopencore-amr*.a ← AMR 编解码闭源静态库(业界标准实现)
|
||
|
||
Documents/
|
||
(用户级持久化,目前空,未使用)
|
||
|
||
Library/Caches/
|
||
{gamedir}/ ← gamehall.zip 解压目标
|
||
{gamestart}/
|
||
index.html ← 大厅入口
|
||
version.xml ← 版本号
|
||
app_data.js ← 启动时写入
|
||
app_battery.js
|
||
app_network.js
|
||
app_gamesname.js ← 首次启动写入
|
||
assets/wav/*.wav ← srcIsloop 播的本地音效
|
||
...其他 H5 资源
|
||
{子游戏 gamedir}/
|
||
{子游戏 gamestart}/
|
||
index.html
|
||
...
|
||
zips/ ← 下载 zip 暂存
|
||
images/ ← 缩略图等
|
||
caches/ ← 其他缓存
|
||
录音临时文件 (yyyyMMddHHmmss.wav / .amr)
|
||
```
|
||
|
||
---
|
||
|
||
## 8. 第三方 SDK 依赖矩阵
|
||
|
||
| SDK | 用途 | 关键 API | 替换难度 |
|
||
|------|------|---------|---------|
|
||
| **WebViewJavascriptBridge** | H5 ↔ Native 通讯 | `bridgeForWebView:` / `registerHandler:handler:` / `callHandler:data:` | 高(H5 强依赖 `WebViewJavascriptBridge_setup` JS 初始化协议)。新外壳必须实现等价 JS 协议 |
|
||
| **微信 SDK** | 登录、分享 | `WXApi registerApp:` / `sendReq:` / `handleOpenURL:delegate:` | 中(替换需保留 sharelogin/sharesuccess 字段)|
|
||
| **QQShareManager**(项目内自封装)| QQ 分享 | `handleOpenURL:` | 中 |
|
||
| **闲聊 SDK** | 闲聊分享 | `XianliaoApiManager registerApp:` / `share:fininshBlock:` | 低 |
|
||
| **抖音 SDK**(如有)| 抖音分享 | 同上 | 低 |
|
||
| **高德定位 SDK** | 定位 + 逆地理 | `AMapLocationManager` | 中(要保留 getlocationinfo 字段 9 个)|
|
||
| **Agora SDK** | 子游戏语音/视频房间 | `AgoraRtcEngineKit` | 高(只在 gameController 用,新外壳如果不上视频功能可桩掉)|
|
||
| **七牛 SDK** | 音频上传 CDN | `QNUploadManager` | 低 |
|
||
| **AFNetworking** | 网络监听 + HTTP | `AFNetworkReachabilityManager` | 低(可换 Network framework + URLSession)|
|
||
| **ASIHTTPRequest** | 老的 HTTP | (旧依赖)| 低(已不推荐用,新外壳全替 URLSession)|
|
||
| **ZipArchive** | 解压 | `UnzipFileTo:overWrite:` | 低(替成 ZIPFoundation)|
|
||
| **GDataXML** | 解析 version.xml | `nodesForXPath:` | 低 |
|
||
| **VoiceConverter** | AMR ↔ WAV | `ConvertAmrToWav:` / `ConvertWavToAmr:` | 高(依赖 opencore-amr fat 库,2014 年打包,新链接器要求 architecture slice align ≥ 2^3,已用 `lipo -segalign 8` 重新打包)|
|
||
| **Bugly** | 崩溃监控 | 旧外壳集成但后台账号已废弃实际不起作用 | **新外壳不集成**,改用 Sentry(详见实施设计文档)|
|
||
| **极光统计** | 用户统计 | `JANALYTICSService setupWithConfig:` | 低 |
|
||
| **RNCachingURLProtocol** | HTTP 资源本地缓存代理 | `NSURLProtocol registerClass:` | 中(新外壳建议改 URLProtocol 或 WKURLSchemeHandler)|
|
||
|
||
---
|
||
|
||
## 9. 新外壳实现路线(建议骨架)
|
||
|
||
> 这一节是建议,不是强制。**必须照抄的契约部分已在 §3–§7。**
|
||
|
||
### 9.1 推荐技术栈
|
||
|
||
| 层 | 旧 | 新建议 |
|
||
|----|----|--------|
|
||
| 语言 | Objective-C MRC | Swift(启 Strict Concurrency / async-await)|
|
||
| 最低系统 | iOS 9.0 | iOS 14.0(覆盖 99% 设备,去掉所有 iOS<9 兼容代码)|
|
||
| WebView | UIWebView + WKWebView 双轨 | **只用 WKWebView**,旧路径删掉 |
|
||
| H5 桥 | WebViewJavascriptBridge(OC 版)| 自实现 WKScriptMessageHandler + 等价 JS 协议;或继续用 WebViewJavascriptBridge Swift 版 |
|
||
| 网络 | ASIHTTPRequest + AFNetworking | URLSession + async/await |
|
||
| 解压 | ZipArchive | ZIPFoundation |
|
||
| 音频转码 | opencore-amr fat 库 | 保留(接收端兼容现网格式)|
|
||
| 路由 | UINavigationController + 手写 push | UINavigationController + 单状态机管理 push 节流 |
|
||
| 监控 | 无 | 建议接 Sentry(一次性集成,对线上稳定有意义) |
|
||
|
||
### 9.2 兼容性关卡(按重要性排序)
|
||
|
||
1. **桥接 JS API 初始化协议**:H5 进入页面后会调用 `WebViewJavascriptBridge` 的 `_setupWebViewJavascriptBridge` 协议 —— 包括 iframe 注入和 console.log 风格的握手。新外壳如果不用 WebViewJavascriptBridge 原版库,**必须 100% 兼容**这套握手。简单做法:直接引入 [marcuswestin/WebViewJavascriptBridge](https://github.com/marcuswestin/WebViewJavascriptBridge) Swift 分支。
|
||
|
||
2. **handler 名字面一致**:§3 表中所有 handler 名(如 `mediaTypeAudio`、`friendsSharetypeUrlToptitleDescript`)必须 1:1 注册,差一个字符 H5 就失败。
|
||
|
||
3. **callback 数据结构 1:1**:尤其是 `sharelogin`(7 字段、`Province` 大写 P)、`getphoneinfo`(小写 i)、`getlocationinfo`(9 字段)。
|
||
|
||
4. **responseCallback 字面字符串**:H5 是否真依赖 `"Response from accreditlogin"` 这类字符串未知。保留以减少风险。
|
||
|
||
5. **渠道注入目录**:新外壳必须读 11 个空目录名注入值。如果改用 plist 必须等价同步更新打包脚本。
|
||
|
||
6. **NSNotification 名**:仅 msext 旧外壳内部使用 `enterForeground`/`applicationWillResignActive`(命名错位)。新项目**不是契约边界**,可自由命名(推荐 `.appDidEnterBackground` 等清晰名),只要最终桥事件 `appservice("1"|"2")` 正确即可。
|
||
|
||
7. **URL Scheme 处理顺序**:QQ → 微信。
|
||
|
||
8. **首次启动同步解压**:必须落地,否则 H5 进不去(version.xml 不在)。
|
||
|
||
### 9.3 可以重构的地方
|
||
|
||
- 旧的 `Bridge.h` JSExport 路径整套删掉
|
||
- `RootVC` / `fourviewVC` 删掉
|
||
- `threeView` 合并到通用 WebView 容器
|
||
- 散落的 `canback_` / `one_Time` / `first_Time` / `Nothere` BOOL 改为单一节流状态机
|
||
- `SGGateway` 整套换成 URLSession,签名算法保留(服务端在校验)
|
||
- Bugly + 极光的混合监控统一为一种(或都去掉)
|
||
|
||
### 9.4 不可重构的地方(线上稳定优先)
|
||
|
||
- 微信 Appsecret 客户端直拼 OAuth:**按 daoqi/CLAUDE.md 原则 B,新外壳应改为后台中转**(`/wechat/login`)。前置依赖:后台需提供该接口(项目方协调);若后台暂未就绪,新外壳可临时沿用客户端直拼,但应在代码标注 TODO。无论实现路径如何切换,`sharelogin` 7 字段(H5 契约)必须保持一致
|
||
- 视频房间 Agora appID 不能换(线上历史用户依赖)
|
||
- 七牛 CDN 域名通过 qiniudomain 目录注入:保留机制
|
||
- 桥接 handler 名、字段名、字面字符串
|
||
|
||
---
|
||
|
||
## 10. 验收检查表
|
||
|
||
> 验收口径:**只看 H5 表现**。原生内部代码长什么样、用了什么 SDK、用了多少行 Swift——都不审。下表中任意一项表现与现网不一致,即视为契约违反,需要排查;表现一致即视为通过,原生内部实现自由。
|
||
|
||
新外壳完成后,**在不修改 H5 任何一行代码**的前提下,逐项验证:
|
||
|
||
### A. 启动
|
||
- [ ] 首次安装:1 秒内进入大厅 H5(gamehall.zip 已解压)
|
||
- [ ] 重装 + 热启动:< 1 秒进入大厅
|
||
- [ ] 渠道注入:把 `msext/channel/xxx` 目录名改成 `yyy`,重打包后大厅显示的渠道 ID 变为 `yyy`
|
||
- [ ] iOS 18 / 19 / 26 上启动不卡死
|
||
|
||
### B. 桥接
|
||
- [ ] H5 调 `accreditlogin` → 微信弹授权页 → 同意后 H5 收到 `sharelogin` 回调,7 字段完整
|
||
- [ ] H5 调 `friendsSharetypeUrlToptitleDescript` (type=1/2/3 各一次)→ 微信分享/朋友圈分享成功 → H5 收到 `sharesuccess({success:"2", type:"1"|"2"})`
|
||
- [ ] H5 调 `getphoneInfo` → 收到 `getphoneinfo`,6 字段完整
|
||
- [ ] H5 调 `startlocation` → 收到 `getlocationinfo`,9 字段完整
|
||
- [ ] H5 调 `srcIsloop({src:"bg.wav", isloop:1})` → 听到背景音;再调 `isloop:-1` 停止
|
||
- [ ] H5 调 `mediaTypeAudio({audiourl:"<远程amr>", user:"u123"})` → 听到声音;H5 收到 `gameui_play_voice("u123")` 和 `gameui_stop_voice("u123")`
|
||
- [ ] H5 调 `prepareaudio` → 录音 → 放手 → H5 收到 `getaudiourl({audiourl:"<七牛 URL>", time:"<秒数>"})`;在 gameController 内还**额外**收到 `recordSuccess({fileUrl, fileName, fileKey})`
|
||
- [ ] H5 调 `gameCopytext` + `gamepastetext` → 正确复制/读取
|
||
- [ ] H5 调 `vibrator` / `repeatvibrator` → 设备震动
|
||
- [ ] H5 调 `OpenurlTitleData`(大厅 WVJB)→ 打开内嵌弹层;弹层内 H5 调 `settings.finishweb()`(弹层 JSExport)关闭,且弹层内 `settings.backgameData(data)` 能把 data 传回大厅,大厅 H5 收到 `getWebdata`
|
||
- [ ] H5 调 `browser` → Safari 打开外链
|
||
- [ ] H5 调 `SwitchOverGameData` → push 到子游戏;子游戏内调 `backgameData` → 回到大厅;H5 收到 `getWebdata`
|
||
|
||
### C. 系统事件
|
||
- [ ] 切后台 → H5 收到 `appservice("1")`;回前台 → 收到 `appservice("2")`
|
||
- [ ] 电量变化 → H5 收到 `getBattery("0.XX")`
|
||
- [ ] 飞行模式开关 → H5 收到 `getnetwork("1")` / `getnetwork("2"|"3")`
|
||
- [ ] 摇一摇(startshake==YES 时)→ H5 收到 `shakeEnd`
|
||
|
||
### D. 视频房间(仅 gameController 子游戏页)
|
||
- [ ] H5 `createRoom({pmw, pmh, top, left, width, height, playerid, roomid})` → 多个真机进入同一 `md5(agentinfo+gameinfo+roomid)` 频道,看到本地画像
|
||
- [ ] 远端用户首帧解码后,**Native → H5 callback `getVideoinfo(uid 字符串)`** 触发;H5 收到后再调 H5→Native `getVideoinfo({...})` 放置远端窗 → 看到对方画面
|
||
- [ ] 接电话 → H5 收到 `phonestate("2")`;挂电话 → 收到 `phonestate("0")`
|
||
- [ ] H5 `exitRoom` → Agora `leaveChannel`,房间内其他人看到对方离开
|
||
|
||
### E. 回归
|
||
- [ ] 微信回调命中
|
||
- [ ] QQ 回调命中(QQ 必须先判,否则被 WXApi 吞)
|
||
- [ ] 截屏分享真实截屏(FuncPublic getImageWithFullScreenshot 等价实现)
|
||
|
||
---
|
||
|
||
## 附录 A — 旧桥 JSExport 协议精确签名(仅供参考)
|
||
|
||
> 这是 iOS<9 路径使用的旧协议,新外壳如最低系统 ≥ iOS 14 可不实现。
|
||
> JSExport 默认转换规则把 ObjC selector → JS 方法名,结果与新桥 handler 名一致。
|
||
|
||
### Bridge.h / JSProtocol(28 方法)
|
||
|
||
```objc
|
||
-(void)backgameData:(NSString *)data; // → JS: backgameData(data)
|
||
-(void)opensaoma; // → JS: opensaoma()
|
||
-(void)startlocation:(NSString *)Type; // → JS: startlocation(type)
|
||
-(void)prepareaudio; // → JS: prepareaudio()
|
||
-(void)startshake; // → JS: startshake()
|
||
-(void)stopshake; // → JS: stopshake()
|
||
-(int)getcompareCode; // → JS: getcompareCode()
|
||
-(NSString *)getchannelName; // → JS: getchannelName()
|
||
-(double)getbattery; // → JS: getbattery()
|
||
-(int)getnetwork; // → JS: getnetwork()
|
||
-(void)SwitchShake:(NSString *)voice; // → JS: SwitchShake(voice)
|
||
-(void)media:(NSString *)url Type:(NSString *)history Audio:(NSString *)userid; // → JS: mediaTypeAudio(url, history, userid)
|
||
-(void)vibrator:(NSString *)time; // → JS: vibrator(time)
|
||
-(void)repeatvibrator:(NSString *)repeat; // → JS: repeatvibrator(repeat)
|
||
-(void)canclevibrator; // → JS: canclevibrator()
|
||
-(void)friends:(NSString *)one Sharetype:(NSString *)two Url:(NSString *)three Toptitle:(NSString *)four Descript:(NSString *)five;
|
||
// → JS: friendsSharetypeUrlToptitleDescript(one,two,three,four,five)
|
||
-(void)accreditlogin; // → JS: accreditlogin()
|
||
-(void)browser:(NSString *)url; // → JS: browser(url)
|
||
-(void)src:(NSString *)message Isloop:(NSString *)message2; // → JS: srcIsloop(message, message2)
|
||
-(void)Switch:(NSString *)type Over:(NSString *)directory Game:(NSString *)downurl Data:(NSString *)data;
|
||
// → JS: SwitchOverGameData(type, directory, downurl, data)
|
||
-(int)getGameinstall:(NSString *)gamename; // → JS: getGameinstall(gamename)
|
||
-(void)getGameplay:(NSString *)jsondata; // → JS: getGameplay(jsondata) [空实现]
|
||
-(void)Openurl:(NSString *)url Title:(NSString *)title Data:(NSString *)data; // → JS: OpenurlTitleData(url, title, data)
|
||
-(NSString *)getmarketname; // → JS: getmarketname()
|
||
-(NSString *)gamepastetext; // → JS: gamepastetext()
|
||
-(void)gameCopytext:(NSString *)text; // → JS: gameCopytext(text)
|
||
-(NSString *)getOther; // → JS: getOther()
|
||
-(NSString *)getothername:(NSString *)othername; // → JS: getothername(othername)
|
||
-(void)voicePlaying:(NSString *)type; // → JS: voicePlaying(type)
|
||
-(void)finishweb; // → JS: finishweb()
|
||
```
|
||
|
||
H5 在旧路径下调用形式:
|
||
```javascript
|
||
window.settings.mediaTypeAudio(url, type, userid); // 同步阻塞调用
|
||
var network = window.settings.getnetwork(); // 同步返回 int
|
||
window.settings.friendsSharetypeUrlToptitleDescript(sharefriend, sharetype, type, webpageUrl, title, description);
|
||
```
|
||
|
||
Native → JS 走 `[JSContext evaluateScript:@"funcName('arg1','arg2')"]`,函数名见 §3.2。
|
||
|
||
### Bridgetwo.h / JSProtocol(3 方法)— threeView 弹层用
|
||
|
||
```objc
|
||
-(void)backgameData:(NSString *)data; // 发 backgameDatatwo 通知给父大厅
|
||
-(void)browser:(NSString *)url;
|
||
-(void)finishweb; // pop self
|
||
```
|
||
|
||
---
|
||
|
||
## 附录 B — 关键文件索引
|
||
|
||
| 关注点 | 文件 |
|
||
|--------|------|
|
||
| 启动 | `msext/msext/AppDelegate.m:140-302` |
|
||
| 微信/QQ 回调 | `msext/msext/AppDelegate.m:38-64` |
|
||
| 大厅(新)| `msext/msext/Class/RootVC/NewRootVC.m` |
|
||
| 大厅(旧)| `msext/msext/Class/RootVC/RootVC.m` |
|
||
| 子游戏 | `msext/msext/Class/RootVC/gameController.m` |
|
||
| 子游戏(旧)| `msext/msext/Class/RootVC/fourviewVC.m` |
|
||
| 弹层 | `msext/msext/Class/RootVC/threeView.m` |
|
||
| 旧桥协议 | `msext/msext/Class/RootVC/Bridge.h` / `Bridgetwo.h` |
|
||
| 网络层 | `msext/msext/Class/SGGateway/SGGateway.m` / `SGGatewayApi.m` |
|
||
| 工具/渠道注入 | `msext/msext/Class/Common/FuncPublic.m:1269` (filename:) / `:1089` (getFilePath:) |
|
||
| 常量 | `msext/msext/Class/Common/SGDefineInfo.h` / `AppID.m` / `APIKey.h` |
|
||
| AMR ↔ WAV | `msext/msext/Class/Common/VoiceConvert/` |
|
||
| HTTP 缓存代理 | `msext/msext/Class/webCache/RNCachingURLProtocol.m` |
|
||
|
||
---
|
||
|
||
## 附录 C — 与 daoqi/CLAUDE.md 的关系
|
||
|
||
本文档与 `daoqi/CLAUDE.md` 配套:
|
||
|
||
- **daoqi/CLAUDE.md** 规定项目协作的两条平行原则(A. H5 适配契约 / B. 原生内部自由重构)
|
||
- **本文档(H5-Native-Contract.md)** 是原则 A 的细化:H5 ↔ 原生 所有黑盒可观察行为的精确契约
|
||
- **H5-Native-Implementation-Design.md** 是原则 B 的细化:原生内部架构、模块、技术选型
|
||
|
||
三份文档**自成体系,不依赖父目录或其它仓库的任何文件**。如在工作环境中看到上级目录另有 CLAUDE.md,请忽略,以本仓库的 CLAUDE.md 为唯一权威(详见 daoqi/CLAUDE.md "适用范围声明")。
|
||
|
||
文档完成日期:2026-06-21
|