Files
youle_app_ios_v2/docs/H5-Native-Contract.md
T
joywayerandClaude Opus 4.7 0cdd709e66 中优 3 项收尾:Plan §6.4 里程碑 + Design §20.1 拆分 + Contract §10 验收清单
补完本轮 audit 与 Design 大改后的文档边界,避免后续 Phase 实施者
按陈旧描述走弯路。

1. Plan §6.4 文档同步段新增「Design 接口骨架覆盖里程碑」表:
   - 4 行时间线记录 Phase 1 闭环 / Design 全 51 项接口骨架 / Contract §4.2
     按 daoqi 原项目修订 / CLAUDE.md 加典型案例的 commit hash
   - 末尾澄清「§3.4.1 撤销说明」与 §7.5 主路径关系,避免新人疑惑

2. Design §20.1 LobbyHandlers 加架构说明段:
   - 明示实际拆为 AudioHandlers / DeviceHandlers / LocationHandlers /
     ShakeHandlers / OpenurlTitleDataHandler 5 个 sub-struct(§8.1.1 /
     §8.4.1 / §8.6 / §8.7 / §3.4.2 各自归属)
   - LobbyHandlers 成为聚合点,依次调子 .register()
   - 保留 monolithic register 速查表作为与 Contract §3.1 编号对账用
   - 每行注释 §3.1 [N] 编号便于追溯

3. Contract §10 验收清单加 5 项硬约束:
   §A 启动新增:
     - app_*.js 4 个文件存在
     - 15 个 app_* 全局变量命名 100% 正确(大小写硬约束)
     - 大厅 vs 子游戏 app_Launchtype = "0"/"1" 字面差异
     - app_gameid / app_compareCode 必须 undefined(早期 Contract 误列)
   §B 桥接新增:
     - H5 alert(msg) → UIAlertController 单按钮
     - H5 confirm(msg) → 取消/确定 双按钮 + 正确返回 bool

至此 Plan / Design / Contract 三文档完全对齐 51 项接口(含 §7.5 app_*.js
15 个、§3.7 WKUIDelegate 2 个)。Phase 2 实施可直接对照 Contract §10
验收清单做契约测试。

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

62 KiB
Raw Blame History

进贤聚友棋牌 iOS 端 — 启动流程 & H5/原生通讯契约

适用版本:当前 master 分支线上版本(msext target) 受众:负责"用新技术栈重写一份等价 iOS 外壳"的开发者 目标:H5gamehall.zip 内的 index.html 及子游戏)零修改即可在新 iOS 外壳上运行


契约边界(最重要)

新外壳与现网外壳之间,只有以下三类内容是契约,必须 1:1 一致

  1. 桥接接口名 — H5 调用的 handler 名字符串(如 mediaTypeAudiofriendsSharetypeUrlToptitleDescriptOpenurlTitleData),少一个字符 H5 就找不到。
  2. 参数字段名 & 数据结构 — H5 传入的 data 字段名、Native 回调时的 payload 结构。含历史包袱sharelogin.Province(大写 P)、OpenurlTitleData 的入参键名 "title "(末尾有空格)、appservice"1"/"2" 字符串而不是数字、字段值约定(如 isloop: -1/0/1 三态),必须照抄
  3. 空实现接口也必须注册opensaomagetGameplay 等当前为空实现的 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 + JSExportwindow.settings.方法名() RootVC / fourviewVC
新大厅桥 (WVJB) iOS ≥ 9.0 主大厅路径 WKWebView WebViewJavascriptBridgebridge.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 取值:

  • 1Documents/
  • 2Library/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"   // 声网 AgoragameController 视频房间用)
极光 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 NewRootVCiOS 9+

init
  ↓ filename: 读取所有渠道注入值(gamedir/gamestart/gameconfig/channel/agent/iosNumber/gameinfo/market/...
  ↓
viewDidLoad
  ├─ 创建 WKWebViewframe = 全屏)
  ├─ _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 RootVCiOS < 9

差异:

  • 使用 UIWebView,桥接走 documentView.webView.mainFrame.javaScriptContext
  • context[@"settings"] = jo 暴露 Bridge 实例
  • Native→H5 用 [context evaluateScript:@"funcName(args)"]
  • 启动 URL 附 ?Launchtype=0 查询参数
  • 注入 JS 仅靠 webViewDidFinishLoadstringByEvaluatingJavaScriptFromString
  • 接口契约与 NewRootVC 对外名字一致(见 0.1 节关于 JSExport 默认转换规则)

2.3 三个子页面控制器

控制器 WebView 推入时机 入口路径
threeView UIWebView Bridgetwo(轻量,3 接口) OpenurlTitleData / Openurl:Title:Data: 桥接调用 外部 URLH5 传入)
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
  • fourviewVCthreeView 是 iOS<9 旧路径产物,新外壳可以合并为一个"通用 WebView 容器 + 视频房间扩展"

2.4 控制器层级关系

window.rootViewController = NavgationController
   └─ NewRootVC(大厅)
          ├─ push threeViewH5 OpenurlTitleData:打开外链/公告)
          └─ push gameControllerH5 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 → NativeregisterHandler

真实分布(以源码为准):

  • 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 callbacksharesuccess(成功后),数据见 §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==1WXSceneSessionsharefriend==2WXSceneTimeline

§ 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 == 1backgroundPlayer numberOfLoops = -1,记录 backgroundType = src
  • isloop == -1 → 如果当前 backgroundType == src,则停止 backgroundPlayer
【4】 prepareaudio — 启动麦克风录音
字段 类型 说明
入参 (忽略)
responseCallback "Response from prepareaudio"

副作用:

  1. FuncPublic ifauth 检查麦克风权限
    • 0denied/restricted):通过 H5 alert 提示"{gamehallname}需要访问您的麦克风"
    • 1not determined):开始录音时系统自动弹询问框
    • 2authorized):直接录音
  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 H5gameui_play_voice(user) (见 §3.2 7])
  5. 播放结束 callback H5gameui_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 — 读剪贴板
  • 入参:(忽略)
  • responseCallbackUIPasteboard.generalPasteboard.string(字符串本体)
【14】 gameCopytext — 写剪贴板
  • 入参:string → UIPasteboard.generalPasteboard.string = data
  • responseCallback"gameCopytext"

§ E. 网页 / 浏览器 / 子游戏切换

【15】 OpenurlTitleData — 打开内嵌 WebView 弹层(threeView
字段 类型 说明
url string 待加载 URLHTTP
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 一个新的 gameControllerWKWebView + 视频房间能力)
  3. 子游戏内首次进入会拉取 zip → 解压到 Library/Caches/{Gamedirectory}/
  4. 2 秒内重复调用被忽略(one_Time 节流)
  5. 子游戏返回大厅时调用 backgameDataH5→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 callbackgetlocationinfo(见 §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 视频窗 topH5 设计稿坐标系)
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) 副作用

  • 内部创建 localVideoBG214×129 容器)+ localVideo136×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→H5H5 端调 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 → H5callHandler 反向通知)

# Handler 名 触发时机 数据结构 NewRootVC gameController
1 getphoneinfo 注册 getphoneInfo handler 收到调用后 见下方表 A
2 getBattery UIDeviceBatteryLevelDidChangeNotification 字符串 "%.2f" 形式的小数电量(0~1
3 getnetwork AFNetworkReachability 变化 字符串 "1"/"2"/"3" 1=无网,2=WiFi3=蜂窝)
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>" }

差异提示:第 1315 项是 gameController 独有的 callback。新外壳如果要让子游戏 H5 行为完全一致,必须实现这 3 项。其中 phonestaterecordSuccess 比较容易忽视:

  • phonestateH5 子游戏会在收到 "2" 时主动哑麦/暂停,收到 "0" 恢复,跟视频房间体验强相关
  • recordSuccessH5 收到后会拿 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 是 NSNumberJSON 看是数字 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.hJSProtocol(共 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:dirURLfile://),不走 HTTP

4.2 JS 注入(已按原项目代码全面修订 — 见底部"修订记录"

H5 业务通过 <script src="app_*.js"> 同步引入 4 个预生成的 .js 文件,文件内全是 var app_xxx=... 全局变量声明。原 msext 写入位置:NewRootVC.m:1204(大厅 initJSdata/ gameController.m:2160(子游戏 initJSdata/ gameController.m:1191 NewRootVC.m:1706battery 更新)/ gameController.m:1198 NewRootVC.m:1714network 更新)/ AppDelegate.m:259 gameController.m:197gamesname 更新)。

⚠️ 关键约束:文件名 ≠ 变量名

文件名(不带 get 文件内变量名(带 get
app_battery.js var app_getbattery=...
app_network.js var app_getnetwork=...

H5 端 <script src="app_battery.js"> 引入后读到的是 app_getbattery(带 get 前缀),不是 app_battery。任何拼写错误都会让 H5 业务读到 undefined。

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         = "<gameconfig>";
var app_gamedir            = "<gamedir>";
var app_gamestart          = "<gamestart>";
var app_agent              = "<agent>";
var app_appversion         = "<appversion>";    // int 字面,msext "var app_appversion='%d'"
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>";   // 推广码 / 邀请码

大厅 / 子游戏的字面差异(详见 §7.5 Design):

  • app_Launchtype:大厅 0 / 子游戏 1
  • app_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;   // ⚠️ 变量名带 getmsext "var app_getbattery=%lf" double 字面

更新时机:viewWillAppear + UIDeviceBatteryLevelDidChangeNotification + 前台切换通知。

app_network.js(写到 {gamedir}/{gamestart}/app_network.js

var app_getnetwork = 2;   // ⚠️ 变量名带 getmsext "var app_getnetwork=%d" int 字面:1 无网 / 2 WiFi / 3 蜂窝

更新时机:viewWillAppear + 网络变化(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(新装一个子游戏后追加;msext gameController.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_getbatteryapp_network 改为 app_getnetwork:变量名带 get 前缀,与文件名(不带 get)不一致 daoqi gameController.m:1191/1198NewRootVC.m:1706/1714
2026-06-22 新增 app_version / app_Launchtype / app_getwifisignalLevel / app_gamename / app_invitationcode / app_gamesname 共 6 项 daoqi NewRootVC.m:1204gameController.m:2160AppDelegate.m:259

4.3 WKUIDelegateH5 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_postcode 键时:

  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 桥 WebViewJavascriptBridgeOC 版) 自实现 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 Swift 分支。

  2. handler 名字面一致:§3 表中所有 handler 名(如 mediaTypeAudiofriendsSharetypeUrlToptitleDescript)必须 1:1 注册,差一个字符 H5 就失败。

  3. callback 数据结构 1:1:尤其是 sharelogin7 字段、Province 大写 P)、getphoneinfo(小写 i)、getlocationinfo9 字段)。

  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 上启动不卡死
  • app_*.js 4 个文件存在{gamedir}/{gamestart}/app_data.js / app_battery.js / app_network.js / app_gamesname.js 全部生成、loadFileURL 前已落盘
  • 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_LaunchtypeL 大写)/ app_getwifisignalLevelwifi 小写 + signal/Level 区分)/ app_getbatteryapp_getnetwork(带 get 前缀)必须精确
  • 大厅 vs 子游戏字面差异:大厅 H5 内 app_Launchtype === "0"、子游戏 H5 内 app_Launchtype === "1"
  • 不要存在已撤销的变量H5 console 检查 app_gameid / app_compareCode 应为 undefined(早期 Contract §4.2 误列,原项目代码字面字符串里不写)

B. 桥接

  • H5 调 accreditlogin → 微信弹授权页 → 同意后 H5 收到 sharelogin 回调,7 字段完整
  • H5 调 friendsSharetypeUrlToptitleDescript type=1/2/3 各一次)→ 微信分享/朋友圈分享成功 → H5 收到 sharesuccess({success:"2", type:"1"|"2"})
  • H5 调 getphoneInfo → 收到 getphoneinfo6 字段完整
  • H5 调 startlocation → 收到 getlocationinfo9 字段完整
  • 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→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 / JSProtocol28 方法)

-(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 / JSProtocol3 方法)— 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