# 进贤聚友棋牌 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": "" }` | ✓ | ✓ | | 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": "", "PhoneDeviceBrand": "", // "iPhone" / "iPad" "PhoneIMEI": "", // ASIdentifierManager.advertisingIdentifier "PhoneModel": "", "PhoneProvidersName": "", "PhoneVersion": "" } ``` **表 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 注入(**已按原项目代码全面修订** — 见底部"修订记录") H5 业务通过 `