调研 daoqi/msext NewRootVC.m:1190-1208 initJSdata 发现 app_appversion
真实业务语义是审核切换标志,**不是 App 的版本号**:
if (远端 ResolvedVersion.appVersion >= 本地 BundleConfig.appVersion) {
app_gameconfig = BundleConfig.gameConfig; // 正常业务接口
app_appversion = '0'; // 标志:正常
} else { // 远端 < 本地(罕见,审核期 / IPA 已升远端未跟上)
app_gameconfig = BundleConfig.appleConfig; // 苹果审核期接口
app_appversion = '1'; // 标志:审核期
}
H5 端 if (app_appversion === '1') 切苹果审核期分支;99% 时间下都是 '0'
+ gameconfig。
当前代码两处错误:
❌ app_appversion 写成 "43"(版本号字符串)
❌ app_gameconfig 永远是 BundleConfig.gameConfig 硬编码
不符合契约(H5 拿到错误标志会走错分支)。按 CLAUDE.md 原则 A 第一准则
必须 1:1 等价 msext。
修复(Source/WebView/AppDataWriter.swift writeAppData):
- 加 result 计算:localAppVer = Int(bc.appVersion), remoteAppVer =
resolvedVersion?.appVersion ?? localAppVer;result = remoteAppVer >=
localAppVer ? 0 : 1
- 加 gameConfigStr 切换:result == 0 ? bc.gameConfig : bc.appleConfig
- app_appversion 写字面 '\(result)' 单引号包裹(与 msext "var
app_appversion='%d';" 等价)
- Logger 输出加 result 说明 + local/remote 版本号便于调试
Contract §4.2:新增详细业务语义说明段(含 msext 原 ObjC 代码 + result
0/1 含义解读 + app_gameconfig 动态切换说明),修订记录加一行。
Design §7.5.1 表格:#2 app_gameconfig 数据源改为"动态切换",#6
app_appversion 数据源改为"审核切换标志"。
Plan §6.4 里程碑加一行。
BuildProject 通过
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
66 KiB
进贤聚友棋牌 iOS 端 — 启动流程 & H5/原生通讯契约
适用版本:当前 master 分支线上版本(msext target) 受众:负责"用新技术栈重写一份等价 iOS 外壳"的开发者 目标:H5(gamehall.zip 内的 index.html 及子游戏)零修改即可在新 iOS 外壳上运行
契约边界(最重要)
新外壳与现网外壳之间,只有以下三类内容是契约,必须 1:1 一致:
- 桥接接口名 — H5 调用的 handler 名字符串(如
mediaTypeAudio、friendsSharetypeUrlToptitleDescript、OpenurlTitleData),少一个字符 H5 就找不到。 - 参数字段名 & 数据结构 — H5 传入的
data字段名、Native 回调时的 payload 结构。含历史包袱:sharelogin.Province(大写 P)、OpenurlTitleData的入参键名"title "(末尾有空格)、appservice用"1"/"2"字符串而不是数字、字段值约定(如isloop: -1/0/1三态),必须照抄。 - 空实现接口也必须注册 —
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/ ← 苹果配置(当前为空目录)
读取实现:
+(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+):
- (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]) |
旧外壳实现路径(已识别为安全风险):
[WXApiRequestHandler sendAuthRequestScope:kAuthScope State:kAuthState OpenID:kAuthOpenID InViewController:self]- 微信回包
managerDidRecvAuthResponse:拿到 code - 客户端直接拼:
api.weixin.qq.com/sns/oauth2/access_token?code=...&secret=Appsecret&appid=...&grant_type=authorization_code— secret 永久驻留 IPA - 再拉
api.weixin.qq.com/sns/userinfo?access_token=...&openid=... - 把 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.pngtype == 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 = srcisloop == -1→ 如果当前backgroundType == src,则停止 backgroundPlayer
【4】 prepareaudio — 启动麦克风录音
| 字段 | 类型 | 说明 |
|---|---|---|
| 入参 | (忽略) | |
| responseCallback | "Response from prepareaudio" |
副作用:
FuncPublic ifauth检查麦克风权限0(denied/restricted):通过 H5 alert 提示"{gamehallname}需要访问您的麦克风"1(not determined):开始录音时系统自动弹询问框2(authorized):直接录音
- 用时间戳生成文件名(
yyyyMMddHHmmss) [recorderVC beginRecordByFileName:fileName]- 录音完成回调流程:
- 触发
VoiceRecorderBaseVCRecordFinish:fileName:代理 - WAV → AMR 转换(
VoiceConverter ConvertWavToAmr:) - AMR 上传到七牛云(新版)或
http://gameapi.0791ts.cn/api/UpLoad/PostFile(旧版) - 通过
getaudiourlcallback 把 URL + 时长回给 H5(见 §3.2 [9])
- 触发
【5】 mediaTypeAudio — 远程语音回放
| 字段 | 类型 | 说明 |
|---|---|---|
audiourl |
string | 远程 AMR 文件 URL(通常是七牛 CDN) |
user |
string | 发声方的用户 ID |
responseCallback:"Response from mediaTypeAudio"
流程:
- 下载 AMR 到本地
VoiceConverter ConvertAmrToWav转 WAVAVAudioPlayer播放- 开始播放时 callback H5:
gameui_play_voice(user)(见 §3.2 [7]) - 播放结束 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"
副作用:
- 停止 backgroundPlayer
- push 一个新的
gameController(WKWebView + 视频房间能力) - 子游戏内首次进入会拉取 zip → 解压到
Library/Caches/{Gamedirectory}/ - 2 秒内重复调用被忽略(
one_Time节流) - 子游戏返回大厅时调用
backgameData(H5→Native,由 threeView/gameController 实现),通过NSNotification "backgameDatatwo"通知大厅,大厅再 callbackgetWebdata
【18】 backgameData — 子游戏退出并回传数据
⚠️ 仅在
gameController注册,NewRootVC 没有
- 入参:string(业务数据 JSON)
- responseCallback:
"backgameData" - 副作用:
- 停止 backgroundPlayer
- cleanUpAction(清理 Agora 房间等子游戏副作用)
- 移除 backgameDatatwo / enterForeground / applicationWillResignActive 通知监听
[FuncPublic PopAnimation:self]回大厅[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" - 真实数据通过
getphoneinfocallback(注意是小写 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=YESagoraKit 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](AgoraleaveChannel,清理本地/远端 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:
{
"PhoneAdresseMAC": "<UUID idForVendor>",
"PhoneDeviceBrand": "<UIDevice.model>", // "iPhone" / "iPad"
"PhoneIMEI": "<IDFA>", // ASIdentifierManager.advertisingIdentifier
"PhoneModel": "<UIDevice.localizedModel>",
"PhoneProvidersName": "<carrier name>",
"PhoneVersion": "<systemVersion>"
}
表 B — getlocationinfo (成功):
{
"address": "<完整地址>",
"city": "<市>",
"cityCode": "<城市编码>",
"country": "<国>",
"district": "<区>",
"latitude": "30.567890", // ⚠️ string,原生 stringWithFormat:@"%f",不是 double
"longitude": "104.123456", // ⚠️ string
"province": "<省>", // ⚠️ 小写 p,与 sharelogin 的 "Province" 大写形成不一致,沿用历史
"street": "<街道>"
}
失败:
{ "errorCode": 12, "errorMsg": "缺少定位权限" } // errorCode 是 NSNumber,JSON 看是数字 12
表 C — sharelogin:
{
"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: 内执行:
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: 时注入:
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)
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 注入(最终路径:原生 writeToFile 写文件,与 msext 1:1 等价)
H5 业务通过 <script src="app_*.js"> 同步引入 4 个 .js 文件,文件内是 var app_xxx = "..." 形式的全局变量声明。原生在 loadFileURL 之前用 writeToFile/ String.write(to:) 把这 4 个文件写到 {gamedir}/{gamestart}/,H5 启动时 <script src> 同步引入直接读到实际值。
⚠️ 2026-06-22 路径决策最终落定(中间曾尝试改为"H5 自带 + 原生
evaluateJavaScript覆盖window.app_xxx"路径,commit246f215,已撤销)。撤销原因:WKUserScript(.atDocumentStart)注入会被 H5 自带var app_xxx声明覆盖回退;.atDocumentEnd又太晚,H5 业务顶层<script>已经读过默认值。任何"H5 配合改动"的妥协方案违反 CLAUDE.md 原则 A 第一准则。正确解法:回到 msext 老路写文件,时序 100% 等价。详细分析见 CLAUDE.md「典型案例 3」。
原生侧的工作(与原 msext NewRootVC.initJSdata 等价):
loadFileURL之前用String.write(to:)写 4 个.js文件到{gamedir}/{gamestart}/沙盒目录- battery / network 变化时重写对应
.js文件(H5 下次 reload 读到新值;业务期间的实时变化由 §3.2 [2][3]反向 callback 推送) - 文件内容用单引号包裹字符串(与 msext 字面对齐),数值字段无引号
⚠️ 命名约束:文件名 ≠ 变量名(沿用 msext 历史)
| 文件名(不带 get) | 文件内变量名(带 get),原生需覆盖的 window.xxx |
|---|---|
app_battery.js |
app_getbattery |
app_network.js |
app_getnetwork |
H5 业务读 app_getbattery(带 get 前缀),不是 app_battery。任何拼写错误都会让 H5 业务读到 H5 端的默认占位值(不是实际值)。
app_data.js(写到 {gamedir}/{gamestart}/app_data.js)
12 个变量(没有 app_gameid / app_compareCode,那两个是 §4.2 早期文档错误记录,原项目代码 NewRootVC.m:1204 / gameController.m:2160 字面字符串里根本不写它们):
var app_version = 1; // 硬编码,沿用历史,无引号数字
var app_gameconfig = '<动态>'; // ⚠️ 动态切换,见下方 result 计算
var app_gamedir = '<gamedir>';
var app_gamestart = '<gamestart>';
var app_agent = '<agent>';
var app_appversion = '<0或1>'; // ⚠️ 不是版本号,是审核切换标志!见下方说明
var app_market = '<market>';
var app_channel = '<channel>';
var app_Launchtype = 0; // ⚠️ L 大写!大厅=0 / 子游戏=1
var app_getwifisignalLevel = 1; // ⚠️ wifi 小写、signal/Level 区分大小写,硬编码 1
var app_gamename = '<gamename>';
var app_invitationcode = '<tuiguang_id>'; // 推广码 / 邀请码
⚠️
app_appversion真实业务语义(msextNewRootVC.m:1190-1208initJSdata):不是 "App 的版本号",是审核切换标志:
int result; NSString *str; if ([self.version_ios intValue] >= [self.iosNumber intValue]) // 远端 >= 本地 { str = [FuncPublic filename:@"gameconfig"]; // app_gameconfig 取 gameconfig result = 0; // app_appversion = '0' } else { // 远端 < 本地 str = [FuncPublic filename:@"appleconfig"]; // app_gameconfig 取 appleconfig result = 1; // app_appversion = '1' }含义:
result = 0(远端 ResolvedVersion.appVersion ≥ 本地 BundleConfig.appVersion):正常业务模式,H5 用gameconfig字段配置接口result = 1(远端 < 本地,罕见,可能审核期 / IPA 已升远端未跟上):审核期模式,H5 用appleconfig字段配置接口(无害业务展示给审核员)H5 读到
app_appversion === '1'时切到苹果审核期分支;99% 时间下都是'0'。因此
app_gameconfig不是简单读BundleConfig.gameConfig硬编码,而是根据result动态在gameConfig/appleConfig两个 plist 字段间切换。
大厅 / 子游戏的字面差异(详见 §7.5 Design):
app_Launchtype:大厅0/ 子游戏1app_gamedir:大厅 =self.gamedir(即 BundleConfig.gameDir)/ 子游戏 =self.gamefilepath(子游戏目录)app_gamestart+app_gamename:大厅 =self.gamestart/ 子游戏 =self.game_name(子游戏名)
app_battery.js(写到 {gamedir}/{gamestart}/app_battery.js)
var app_getbattery = 0.85; // ⚠️ 变量名带 get!msext "var app_getbattery=%lf" double 字面
更新时机:
loadFileURL之前补一次(保证 H5 启动就能读到当前电量)UIDevice.batteryLevelDidChangeNotification触发重写UIApplication.willEnterForegroundNotification(前台切换补 retroact)
app_network.js(写到 {gamedir}/{gamestart}/app_network.js)
var app_getnetwork = 2; // ⚠️ 变量名带 get!msext "var app_getnetwork=%d" int 字面:1 无网 / 2 WiFi / 3 蜂窝
更新时机:
loadFileURL之前补一次NWPathMonitor状态变化(msext 用 AFNetworkReachability,新外壳用 NWPathMonitor)- 前台切换
app_gamesname.js(写到 {gamedir}/{gamestart}/app_gamesname.js)
var app_gamesname = new Array('game1','game2',...); // ⚠️ var 后面两个空格,沿用 msext AppDelegate.m:259 字面
更新时机:
AppDelegate.applicationDidFinishLaunching(首次启动写入)- 子游戏
gameController.viewInit(新装一个子游戏后追加;msextgameController.m:197历史上注释掉了实际写入逻辑,仅AppDelegate首次写入是活的)
H5 模板里
index.html通过<script src="app_data.js">等同步引入这 4 个文件。新外壳必须保证这些文件在 loadFileURL 之前就写好,否则 H5 启动时变量未定义直接崩。
修订记录
| 日期 | 修订点 | 依据 |
|---|---|---|
| 2026-06-22 | 删 app_gameid / app_compareCode:原项目 grep var app_ 在 NewRootVC.m:1204 / gameController.m:2160 字面字符串里没有这两项 |
daoqi 原项目代码全面 grep 验证 |
| 2026-06-22 | app_battery 改为 app_getbattery、app_network 改为 app_getnetwork:变量名带 get 前缀,与文件名(不带 get)不一致 |
daoqi gameController.m:1191/1198、NewRootVC.m:1706/1714 |
| 2026-06-22 | 新增 app_version / app_Launchtype / app_getwifisignalLevel / app_gamename / app_invitationcode / app_gamesname 共 6 项 |
daoqi NewRootVC.m:1204、gameController.m:2160、AppDelegate.m:259 |
| 2026-06-22 | 路径调整:从"原生 writeToFile 写 4 个 .js 文件"改为"H5 团队 zip 包自带 + 原生 WKUserScript(.atDocumentEnd) / evaluateJavaScript 覆盖 window.app_xxx 全局变量值" |
H5 团队 / 项目方约定 |
| 2026-06-22 | 路径再次调整(最终):撤销上一行的 evaluateJavaScript 路径,回到原 msext "原生 writeToFile 写文件"路径。原因:WKUserScript 时序无法 1:1 等价 msext(.atDocumentStart 被 H5 自带 var 覆盖,.atDocumentEnd 太晚 H5 业务已读默认值),任何要求 H5 配合改动的方案违反 CLAUDE.md 原则 A 第一准则。Design §7.5 + 本节同步回退 |
CLAUDE.md 原则 A 升级为第一准则后的重新评估 |
| 2026-06-22 | app_appversion 业务语义重大纠正:早期文档把 app_appversion 描述为"App 的版本号字符串",实际是 msext NewRootVC.m:1190-1208 的审核切换标志('0' 或 '1')。当远端 < 本地时切到 '1' + app_gameconfig 切到 BundleConfig.appleConfig(审核期接口);其它情况都是 '0' + gameConfig(正常业务)。app_gameconfig 数据源同步从"硬编码 gameConfig"改为"动态切换 gameConfig/appleConfig"。Design §7.5.1 同步、代码 AppDataWriter.writeAppData 修复 |
daoqi NewRootVC.m:1190-1208 initJSdata 全面 grep 重读 |
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 调用约定
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 键时:
- 把所有键按字典序排序(实现里是按插入顺序遍历——这里历史实现有歧义,沿用现状)
- 拼接所有 value 为字符串
- 追加密钥
"WERTY#$&(HJKfghjWERTYUIFGJFGHadf2222" - MD5
- 设回
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.aspxUpdateAPNSDevice.aspxInsertBookComment.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 兼容性关卡(按重要性排序)
-
桥接 JS API 初始化协议:H5 进入页面后会调用
WebViewJavascriptBridge的_setupWebViewJavascriptBridge协议 —— 包括 iframe 注入和 console.log 风格的握手。新外壳如果不用 WebViewJavascriptBridge 原版库,必须 100% 兼容这套握手。简单做法:直接引入 marcuswestin/WebViewJavascriptBridge Swift 分支。 -
handler 名字面一致:§3 表中所有 handler 名(如
mediaTypeAudio、friendsSharetypeUrlToptitleDescript)必须 1:1 注册,差一个字符 H5 就失败。 -
callback 数据结构 1:1:尤其是
sharelogin(7 字段、Province大写 P)、getphoneinfo(小写 i)、getlocationinfo(9 字段)。 -
responseCallback 字面字符串:H5 是否真依赖
"Response from accreditlogin"这类字符串未知。保留以减少风险。 -
渠道注入目录:新外壳必须读 11 个空目录名注入值。如果改用 plist 必须等价同步更新打包脚本。
-
NSNotification 名:仅 msext 旧外壳内部使用
enterForeground/applicationWillResignActive(命名错位)。新项目不是契约边界,可自由命名(推荐.appDidEnterBackground等清晰名),只要最终桥事件appservice("1"|"2")正确即可。 -
URL Scheme 处理顺序:QQ → 微信。
-
首次启动同步解压:必须落地,否则 H5 进不去(version.xml 不在)。
9.3 可以重构的地方
- 旧的
Bridge.hJSExport 路径整套删掉 RootVC/fourviewVC删掉threeView合并到通用 WebView 容器- 散落的
canback_/one_Time/first_Time/NothereBOOL 改为单一节流状态机 SGGateway整套换成 URLSession,签名算法保留(服务端在校验)- Bugly + 极光的混合监控统一为一种(或都去掉)
9.4 不可重构的地方(线上稳定优先)
- 微信 Appsecret 客户端直拼 OAuth:按 daoqi/CLAUDE.md 原则 B,新外壳应改为后台中转(
/wechat/login)。前置依赖:后台需提供该接口(项目方协调);若后台暂未就绪,新外壳可临时沿用客户端直拼,但应在代码标注 TODO。无论实现路径如何切换,sharelogin7 字段(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 上启动不卡死
app_*.js4 个文件在沙盒:{gamedir}/{gamestart}/下app_data.js/app_battery.js/app_network.js/app_gamesname.js全部存在、loadFileURL 前已落盘(xcrun simctl get_app_container booted <bundle>+cat验证)app_*12 + 1 + 1 + 1 = 15 个全局变量命名 100% 正确(参 §4.2):H5 console 检查app_version/app_gameconfig/app_gamedir/app_gamestart/app_agent/app_appversion/app_market/app_channel/app_Launchtype/app_getwifisignalLevel/app_gamename/app_invitationcode/app_getbattery/app_getnetwork/app_gamesname全部!== undefined;尤其大小写app_Launchtype(L 大写)/app_getwifisignalLevel(wifi 小写 + signal/Level 区分)/app_getbattery、app_getnetwork(带 get 前缀)必须精确- 值是实际渠道值而非占位:H5 console 读
app_channel应为ChannelConfig.plist的 channel 值(不是 H5 团队 zip 内可能预留的占位字符串) - 大厅 vs 子游戏字面差异:大厅 H5 内
app_Launchtype === 0(数字 0,非字符串"0")、子游戏 H5 内app_Launchtype === 1 - 不要存在已撤销的变量:H5 console 检查
app_gameid/app_compareCode应为undefined(早期 Contract §4.2 误列,原项目代码字面字符串里不写) - battery / network 变化重写:模拟器 Features → Battery State 切换电量;reload 后 H5 读
app_getbattery应跟随;同样切换 Network Link Conditioner 验证app_getnetwork(业务期间实时变化由 §3.2 [2][3]反向 callback 推送,不依赖文件重写)
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 - H5 调
alert("登录失败")→ 弹出原生 UIAlertController,标题 = appDisplayName,单"确定"按钮(不实现 WKUIDelegate 则 H5 提示完全不弹) - H5 调
confirm("是否退出")→ 弹"取消"/"确定"两按钮 → 取消返回 false / 确定返回 true,H5 业务正常分支
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→NativegetVideoinfo({...})放置远端窗 → 看到对方画面 - 接电话 → H5 收到
phonestate("2");挂电话 → 收到phonestate("0") - H5
exitRoom→ AgoraleaveChannel,房间内其他人看到对方离开
E. 回归
- 微信回调命中
- QQ 回调命中(QQ 必须先判,否则被 WXApi 吞)
- 截屏分享真实截屏(FuncPublic getImageWithFullScreenshot 等价实现)
附录 A — 旧桥 JSExport 协议精确签名(仅供参考)
这是 iOS<9 路径使用的旧协议,新外壳如最低系统 ≥ iOS 14 可不实现。 JSExport 默认转换规则把 ObjC selector → JS 方法名,结果与新桥 handler 名一致。
Bridge.h / JSProtocol(28 方法)
-(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 在旧路径下调用形式:
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 弹层用
-(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