# 进贤聚友棋牌 iOS 新外壳开发计划 > 配套文档: > - `docs/H5-Native-Implementation-Design.md` — 架构蓝图(**WHAT to build / HOW to design**) > - `docs/H5-Native-Contract.md` — H5↔原生契约(**外部可观察行为,验收以此为准**) > > 本文档:执行图(**WHEN / IN WHAT ORDER / ACCEPTANCE CRITERIA**),随实施持续更新。 > > 起点:M0 部分完成(项目基础配置就绪);终点:通过契约 §10 全部 26 项验收清单后灰度上线。 --- ## 1. 文档三角关系 ``` Contract Design Plan(本文档) ↓ ↓ ↓ 契约边界 架构蓝图 执行路线 "必须长这样" "推荐怎么做" "按这个顺序做" 不可越界 可自由重构 随进度更新 ``` - **Contract 与 Design 是稳定文档**,除非契约或架构本身要变更,否则不动 - **Plan 是活文档**,每完成一个 Phase 就在 §8 进度追踪里勾掉、把后续 Phase 调整为新现实 - **冲突时优先级**:Contract > Design > Plan(不能为了赶进度违背契约) --- ## 2. 起点:当前项目状态快照(2026-06-21) ### 2.1 已完成 - [x] Xcode 工程骨架(File System Synchronized Group,objectVersion 77,Xcode 26.5) - [x] iOS Deployment Target 15.6 - [x] Swift 6.0 + `SWIFT_APPROACHABLE_CONCURRENCY=YES` + `SWIFT_DEFAULT_ACTOR_ISOLATION=MainActor` - [x] iPhone + iPad 仅横屏,`UIRequiresFullScreen=true` - [x] 删除默认 Storyboard,改用 `SceneDelegate` 代码启动 → `RootViewController`(M0 占位) - [x] Info.plist 契约前置项:`UIStatusBarHidden=NO` / `UIViewControllerBasedStatusBarAppearance=YES` - [x] `AppDelegate` 设置 `applicationSupportsShakeToEdit=true` - [x] `.gitignore`(标准 iOS/Swift 模板,含签名 / 敏感配置 / xcuserdata) - [x] CLAUDE.md 加入"及时提交"规则 - [x] BuildProject 验证通过 ### 2.2 已就位的原始素材 项目维护者的私人素材池 `docs/res/` 已包含以下可按需取用的素材(详见 CLAUDE.md「docs/res/ 与项目资源的关系」与 Design §7.0): - `docs/res/gamehall.zip`(2023-12 旧版,11.5 MB;用时拷贝到 `ylgamehall/Resources/`) - `docs/res/Images.xcassets/`(AppIcon / LaunchImage / 分享平台图标;用时迁移 / 拷贝条目到 `ylgamehall/Assets.xcassets/`) - `docs/res/Res/`(`sharelogo.png` / `shake_sound_male.mp3` 等散落原生资源;用时拷贝到 `ylgamehall/Resources/Sounds/` 等子目录) > `docs/res/` 不进 Bundle 也不被工程引用。Phase 1 / 3 / 4 等实施时按需从中拷贝到 `ylgamehall/Resources/` 或 `ylgamehall/Assets.xcassets/`。 → Phase 1 不再阻塞于 H5 团队(gamehall.zip 旧版可用于打通管道),可立即展开。 ### 2.3 待项目方协调的外部资源(阻塞项) | 资源 | 用途 | 阻塞起始 Phase | 协调对象 | |------|------|--------------|---------| | `gamehall.zip` **最新版**(上线前替换) | 真实 H5 业务代码 | Phase 10 灰度前 | H5/前端团队 | | 11 个渠道注入目录的目录名值 | 渠道 / 游戏 ID / 七牛域名 / market 等 | Phase 1 | 项目方/运营 | | 微信 OpenSDK `.framework` | 登录 / 分享 | Phase 4 | 项目方(可从 msext/Vendor 拷贝) | | 微信 AppID(`wx586a9b321e56efb7`)+ Universal Links 配置 | 微信回调 | Phase 4 | 项目方 | | ~~QQ OpenSDK `.framework`~~ | QQ 分享 | **不需要 SDK** — msext `QQShareManager.m:14` `__has_include` fallback 路径已有完整 URL Scheme 实现(`mqqapi://share/to_fri?...`),新外壳直接照搬即可。Info.plist 仅需加 `LSApplicationQueriesSchemes`(`mqq` / `mqqapi` / `mqqopensdkfriend` 等) | — | | ~~抖音 OpenSDK `.framework`~~ | 抖音分享 | **不需要 SDK** — msext `DouyinShareManager.m` 全程 URL Scheme(`snssdk1128://share/video` / `snssdk1128://camera`)+ Photos 框架(保存图片到相册让用户在抖音里选)。Info.plist 仅需加 `LSApplicationQueriesSchemes`(`snssdk1128`)+ `NSPhotoLibraryAddUsageDescription` 写入相册权限文案 | — | | 后台 `/wechat/login` 中转接口 | secret 不入 IPA 的前提 | Phase 4 | 后台团队 | | 高德地图 `APIKey` + SDK XCFramework | 定位 | Phase 5 | 已有 key `b0d4a8e3fcbbcc0dd96283b7df6a4494`(但绑死 msext Bundle ID,需用新 Bundle ID 重新申请);SDK 走 Vendor 手动接入(ADR-006),下载页 https://lbs.amap.com/api/ios-location-sdk/download | | 七牛上传 token 颁发接口 | 录音上传 | Phase 3 | 后台团队 | | ~~极光 AppKey~~ | ~~用户统计~~ | **已决策暂不集成(ADR-005),未来启用时再协调** | — | | Sentry DSN | 崩溃监控 | Phase 9 | 项目方(需开通账号) | | Agora AppID(视频房间) | 子游戏视频 | Phase 8(默认 stub,可延后) | 项目方 | | 闲聊 SDK | 闲聊分享 | Phase 9(默认 stub) | 项目方 | | 开发 / 企业签名证书 + 描述文件 | 真机调试 / 分发 | Phase 1 起持续 | 项目方/iOS 账号管理员 | > **协调原则**:当某 Phase 阻塞在外部资源时,**先做不依赖该资源的 Phase**(并行机会见 §3 依赖图)。 ### 2.4 已确定的项目约束 | 项 | 值 | |---|---| | H5 设计分辨率 | **1280 × 720(16:9)** | | WebView 适配策略 | 保持 16:9 比例(设备比例不匹配时上下/左右 letterbox) | | Bundle ID | `com.skyapp.ylgamehall`(与 msext 分离) | | Team ID | NX5W3B4QP3 | | 设备 | iPhone + iPad(不去 iPad) | | 支持方向 | 仅横屏(LandscapeLeft / LandscapeRight) | | Swift 版本 | 6.0 | | 最低 iOS | 15.6 | --- ## 3. 全局依赖图 ``` ┌─────────────────────────────────────────────────────────────┐ │ Phase 0 工程化基线(剩余项:SwiftLint / 测试 target / CI) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 1 资源层 + 桥核心 + 第一个 handler │ │ ResourceKit / BundleConfig / BridgeCore / BridgedWebView │ │ 验收:H5 加载 + 一个简单 handler 双向通 │ └─────────────────────────────────────────────────────────────┘ │ ┌─────────────┼─────────────┐ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Phase 2 │ │ Phase 3 │ │ Phase 5 │ │ 简单 │ │ 音频体系 │ │ 定位 │ │ handler │ │ │ │ │ │ + 反向cb │ │ │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ └─────────────┴─────────────┘ │ 并行可行 ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 4 分享 + 登录(前置:项目方提供 SDK / 后台接口) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 6 子游戏容器(SwitchOverGameData / backgameData) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 7 弹层(Overlay + window.settings polyfill) │ │ 完成后契约 §10 A/B/C/E 节可全跑通 │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 8 视频房间(默认 NoopVideoRoom;Agora 启用是开关) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 9 SDK 真实化(Sentry / 极光)+ Stub 替换为真实 SDK │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ Phase 10 多渠道打包 + 真机回归 + 灰度切流 │ └─────────────────────────────────────────────────────────────┘ ``` **关键并行机会**:Phase 2 / 3 / 5 互不依赖,可按当下手头的资源到位情况自由调度。 --- ## 4. Phase 总览 | Phase | 目标 | 关键产出 | 验收来源 | 相对成本 | |-------|------|---------|---------|---------| | **P0** | 工程化基线 | SwiftLint、单测 target、CI 流水线 | 本文档 §5.0 | 小 | | **P1** | 最小垂直闭环 | `BundleConfig` / `ResourceUnzipper` / `BridgeCore` / `BridgedWebView` + `vibrator` handler | 契约 §10 中 `vibrator` 项;H5 file:// 加载成功 | 大 | | **P2** | 大厅简单 handler + 反向 callback | `DeviceKit` / `Pasteboard` / 振动 / 摇一摇 / 网络 / 电池 / 前后台 | 契约 §10 B 节 + C 节 | 中 | | **P3** | 音频体系 | `AudioKit`(播放 / 录音 / AMR↔WAV / 七牛上传) | 契约 §10 中 `srcIsloop` / `mediaTypeAudio` / `prepareaudio` 项 | 大 | | **P4** | 分享 + 登录 | `LoginKit`(WeChat OAuth)/ `ShareKit`(WeChat + Noop 闲聊) | 契约 §10 B 节 `accreditlogin` / `friendsShare...` 项 | 大 | | **P5** | 定位 | `LocationKit`(高德 SDK 封装) | 契约 §10 B 节 `startlocation` 项 | 中 | | **P6** | 子游戏容器 | `SubGameViewController` + `Coordinator` + `SwitchOverGameData` / `backgameData` | 契约 §10 中 `SwitchOverGameData` 项 | 中 | | **P7** | 弹层 | `OverlayViewController` + `window.settings` polyfill + 节流 | 契约 §10 中 `OpenurlTitleData` / `settings.*` 项 | 中 | | **P8** | 视频房间 stub | 3 个 stub handler + Native→H5 `getVideoinfo` / `phonestate` / `recordSuccess` callback | 契约 §10 D 节(NoopVideoRoom 路径) | 小 | | **P9** | SDK 真实化 + 监控 | Sentry / 七牛 SPM 接入;极光 / 闲聊 / Bugly 决策落地为 Noop / 不集成 | 契约 §10 全 26 项 | 中 | | **P10** | 多渠道 + 灰度 | 渠道注入脚本 / CI 多渠道出包 / 真机回归 / 灰度切流 | 真机验收 + 灰度无回退 | 大 | **相对成本说明**: - 小:1 个工作日内可独立完成 - 中:2–4 个工作日,含联调 - 大:5–10 个工作日,跨多个模块或需对外联调 --- ## 5. 各 Phase 详细计划 ### Phase 0: 工程化基线(剩余项) #### 目标 建立项目级别的代码质量与回归基线,使后续 Phase 每个 commit 都可被自动验证。 #### 前置 无。当前 M0 基础配置已完成。 #### 任务清单 - [ ] **0.1** 加入 SwiftLint(SPM 插件方式,避免全局安装依赖) - `Package.swift` 加 `SwiftLintPlugin`;规则文件 `.swiftlint.yml` 按 Design §13.4 配(行长 120、文件 ≤400、函数 ≤40) - 验收:`xcodebuild` 时 lint 自动跑,违规 warning 出现 - [ ] **0.2** 新建 `ylgamehallTests` 单测 target(Testing framework,不引 XCTest 旧 API) - 第一个测试 `BootstrapTests.testAppDelegateRespondsToShake`,确认 `applicationSupportsShakeToEdit=true` 已生效 - 验收:`xcodebuild test` 通过 - [ ] **0.3** 新建 `ylgamehallContractTests` 契约测试 target(与 unit test 分离,跑得久也无所谓) - 暂只放空骨架,Phase 1 起逐项填入 - [ ] **0.4** 新建 `ylgamehallUITests` UI 测试 target(XCUI) - 暂只放空骨架,Phase 10 真机回归用 - [ ] **0.5** 加 CI 配置(GitHub Actions 或本地 Jenkinsfile,按项目方实际 CI 平台) - 跑:`xcodebuild test -scheme ylgamehall` + lint - 验收:push 后 CI 绿 - [ ] **0.6** 准备 SPM 包目录结构(**先建空 `Package.swift` 但暂不切 target**) - 决策:先单 target 内按目录组织,等 P3 之后代码量 > 5K 行再切 SPM - 这样早期开发不被 SPM 切分成本拖慢 #### 验收 - `xcodebuild test` 通过;CI 绿;SwiftLint 报告 0 违规 #### 风险 - 单测 / UI 测 target 的 signing 需配置(自动签 + 不同 Bundle ID 后缀) --- ### Phase 1: 最小垂直闭环(资源 + 桥 + 远程配置 + 第一个 handler) #### 目标 打通**从启动 → 渠道注入读取 → 远程配置拉取 → 版本对比 → zip 升级(若需要)→ 加载 H5 → JS 与原生互调**的全链路,用最简单的 handler `vibrator` 作为验证标的。 > **Phase 1 范围扩展(ADR-008)**:原 Plan 漏掉了"远程配置 + 版本对比 + zip 升级"这一关键环节——若不做,上线后用户永远看不到 H5 团队的最新版本,永远停留在 IPA 内打包那一刻的 H5 旧版。2026-06-22 调研 msext `NewRootVC.m` 发现完整流程后插入新子项 1.10-1.13,原 1.10-1.13 后移为 1.14-1.17。 #### 前置 - ✅ Phase 0 基线 - ✅ `docs/res/gamehall.zip`(2023-12 旧版,足以验收 Phase 1;实施时拷贝到 `ylgamehall/Resources/gamehall.zip`) - ⏳ 项目方提供 11 个渠道注入目录名值(缺则用临时 demo 值,含可访问的远端 `gameconfig`.txt) - 备选:若 zip 加载有问题,临时用一个最小 H5(`` 含一个 button 调 `bridge.callHandler('vibrator')`)做工程内测排查 #### 任务清单 ##### 1.A 资源层 - [x] **1.1** 创建 `ylgamehall/Resources/ChannelConfig.plist` 含 11 个渠道键值对(沿用 msext 现网 demo 作为母包默认值;ADR-007 决策) - [x] **1.2** 实现 `ylgamehall/Source/Resource/BundleConfig.swift` - `init(bundle: Bundle = .main)`:读 `bundle.url(forResource: "ChannelConfig", withExtension: "plist")`,用 `PropertyListSerialization` 反序列化为 `[String: String]` - 公开 10 个只读属性:`gameId` / `channel` / `gameDir` / `gameStart` / `gameConfig` / `market` / `agent` / `appVersion` / `other` / `appleConfig`(`qiniuDomain` 已于 2026-06-27 移除,七牛 CDN 改 RemoteConfig.audio_domain 远端注入,详见 ADR-009) - 单测 fixture:建立 mock bundle 含 fixture plist,验证 10 个 key 全部能读出且缺失 key 返回空串 - [x] **1.3** 实现 `ylgamehall/Source/Resource/SandboxPaths.swift` - 常量:`caches` / `documents` / `bundle` - `lobbyIndex() -> URL` 拼出 `{Caches}/{gamedir}/{gamestart}/index.html` - [x] **1.4** 加 ZIPFoundation SPM 依赖 - [x] **1.5** 实现 `ylgamehall/Source/Resource/ResourceUnzipper.swift`(actor) - 从 `docs/res/gamehall.zip` 拷贝一份到 `ylgamehall/Resources/gamehall.zip` - `ensureReady() async throws`:检测 `version.xml` 不存在则解压 Bundle 内 `gamehall.zip` 到 caches - 单测:用 fixture zip 验证解压幂等性 ##### 1.B 桥核心 - [x] **1.6** 实现 `Source/Bridge/BridgeProtocol.swift` - `BridgeProtocol` / `BridgeData` / `BridgeHandler` / `BridgeCallback` 类型 - [x] **1.7** 实现 `Source/Bridge/BridgeBus.swift`(@MainActor) - `register(_:handler:)` / `call(_:data:callback:)` / `didReceive(_:)` - 单测:mock WKWebView,验证 handler 注册 / 分发 / callback 配对 - [x] **1.8** 引入 WVJB JS 端协议源码 - 从 [marcuswestin/WebViewJavascriptBridge](https://github.com/marcuswestin/WebViewJavascriptBridge) 取 `WebViewJavascriptBridge.js.txt`,作为 `Resources/JS/WebViewJavascriptBridge.js` - 注入方式:`WKUserScript`,`atDocumentStart` ##### 1.C WebView 容器(视图层 + 一个 handler) - [x] **1.9** 实现 `Source/WebView/BridgedWebView.swift` - WKWebView 配置照 Contract §4.1(`javaScriptCanOpenWindowsAutomatically=NO`、`minimumFontSize=10`、`bounces=NO`、`scrollEnabled=NO` 等) - `WKProcessPool` 单例 `SharedProcessPool.shared` ##### 1.D 远程配置 + 版本对比 + zip 升级(**Phase 1 关键缺口,ADR-008**) - [x] **1.10** 实现 `ylgamehall/Source/Network/RemoteConfigClient.swift`(actor) - URL 构造:`http://` + `BundleConfig.shared.gameConfig.replacingOccurrences("-", "/")` + `.txt`(**注意**:不带 `SERVERNew` 前缀,原 `daoqi/NewRootVC.m:250` 就是这样构造) - 拉远端 `.txt` 内容(实际是 JSON)→ `URLSession.async data(from:)`,10 s 超时 - 指数退避重试(msext 用 4s 等间隔暴力 timer,新外壳用 1s / 2s / 4s 三次) - 反 JSON → 强类型 `RemoteConfig` Codable 模型(嵌套:`agentlist[].channellist[].marketlist[]` + `agentlist[].gamelist[].channellist[].marketlist[]`) - 单测:mock URLSession + fixture JSON 验证嵌套解析正确 - [x] **1.11** 实现 `ylgamehall/Source/Network/VersionResolver.swift`(纯函数 + 单测) - 输入:`RemoteConfig + (agentId, channelId, marketId, gameId)` 四组 ID - 输出:`ResolvedVersion { appVersion, appDownload, gameVersion, gameZip }` - 算法:4 级覆盖合并 —— 顶层 → 当前 agent → 当前 channel → 当前 market(最深胜出);agent 子树和 game 子树分别合并,game 子树最终覆盖 agent 子树 - 参考 `daoqi/NewRootVC.m:1372-1492` 的散落 if/else,新外壳实现成纯 reduce - 单测:覆盖所有边界(顶层有/无、深层缺失、agent vs game 优先级 4 组场景至少 12 用例) - [x] **1.12** 实现 `ylgamehall/Source/Resource/LocalVersionReader.swift`(nonisolated namespace) - `localAppVersion: Int`:从 `BundleConfig.shared.appVersion` 读,转 Int - `localGameVersion: Int`:读 `Library/Caches/{gamedir}/{gamestart}/version.xml` 用 `XMLParser` 解析 `/game/version@value` 转 Int;缺失返回 0 - 单测:fixture xml 验证解析;缺失 / 损坏 xml 返回 0 - [x] **1.13** 实现 `ylgamehall/Source/Resource/LobbyZipUpgrader.swift`(actor) - `upgradeIfNeeded(remote: ResolvedVersion) async throws -> UpgradeOutcome` - 比较 `remote.gameVersion > LocalVersionReader.localGameVersion` → 触发下载,否则直接返回 `.noop` - 下载链路:`URLSession.download(from:)` → 临时文件 `tmp/lobbyzip-{uuid}.zip` → ZIPFoundation 解压到 `tmp/lobbyzip-{uuid}/` → **原子 rename** 到 `lobbyRoot`(解压前清空旧 lobbyRoot;不学 msext "目录名 +1" hack) - 失败处理:超时 / 网络断 / hash 校验失败 / 解压失败 → throw 类型化错误;调用方决定是否回退用旧 H5 - 单测:mock URLSession + fixture zip 验证升级路径与 noop 路径 ##### 1.E 集成 + 烟雾测试 - [x] **1.14.a** AppIcon:从 `docs/res/Images.xcassets/AppIcon-1.appiconset` 拷贝 9 个 png + Contents.json 到 `ylgamehall/Assets.xcassets/AppIcon.appiconset`,替换 Xcode 默认空模板。已实测 BuildProject 通过。actool 3 个 warning(iPad 76 / 83.5 / 1024 缺失)暂不处理,iPad 会自动用 iPhone 缩放,1024 是 App Store 上架要求(企业签不强制);待 Phase 10 上线前 polish 时补齐 - [x] **1.14.b** LaunchScreen:源素材 `docs/res/Res/Default-568h@2x~iphone.png` 是 CgBI PNG,物理 640×1136 但画面内容是"横躺"在竖向容器里(msext 旧 LaunchImage 系统依赖 device orientation 自动旋转);现代 LaunchScreen.storyboard 没有这个魔法,直接显示会看到躺倒画面。处理:用 `sips -r -90` 一次性把素材逆时针旋转 90° 输出为标准 PNG(去 CgBI),物理像素 1136×640,存到 `ylgamehall/Assets.xcassets/SplashImage.imageset/SplashImage@2x.png`(universal idiom)。storyboard 用单一 UIImageView 全屏铺满,`contentMode = scaleAspectFit`,背景 `.black`,四边约束到 superview。aspectFit 在比 16:9 更宽的现代横屏 iPhone 上左右补黑边(黑底无违和),保画面完整不裁切 logo。WebContainer 的 SplashOverlay 用同款 contentMode,避免启动 → 容器视觉过渡时跳变。BuildProject 通过 - [x] **1.14.c** `LobbyZipUpgrader` 加 progress 回调 - `upgradeIfNeeded(resolved:onProgress:) async throws -> Outcome`,`onProgress: @escaping @Sendable (Double) -> Void` 默认 no-op 报告 0.0…1.0 - 文件内私有 `DownloadProgressDelegate: URLSessionDownloadDelegate, @unchecked Sendable`,实现 `didWriteData:totalBytesWritten:totalBytesExpectedToWrite:`;通过 `session.download(from:delegate:)` 注入 - 完成时兜底报 1.0(部分 server 不发 Content-Length 或末尾片段晚到,避免进度条停在 99%) - RootViewController 烟雾测试用 `ProgressTicker` 节流到 10% 阶梯打印;BuildProject 通过 - [x] **1.14.d** 实现 `Source/WebView/WebContainerViewController.swift`(基类) - 持有 `BridgedWebView`(通过其再持有 `BridgeBus`)+ 文件内私有 `SplashOverlay` - 16:9 letterbox:aspectRatio(16:9) + widthMax/heightMax required + widthFill/heightFill .defaultLow + centerXY 居中。屏幕比 < 16:9 → 上下黑边;> 16:9 → 左右黑边 - **SplashOverlay**:UIImageView(SplashImage, scaleAspectFill) + UIProgressView + UILabel;`update(text:progress:)` 切换状态机 - 启动流水线 `runBootPipeline()`:ensureReady → "拉取配置中..." → RemoteConfigClient.fetch → 分支(.shortText / .parsed → showmessage / IPA 升级 / LobbyZipUpgrader.upgradeIfNeeded with onProgress hop 到 MainActor)→ "加载大厅..." → `loadFileURL(SandboxPaths.lobbyIndex, allowingReadAccessTo: lobbyRoot)` - 终态弹窗:`showBlockingAlert`(短文本 / showmessage,永停)/ `showIPAUpgradeAlert`(确定 → openURL,永停)/ `showFatalAlert`(重试 → 重跑 pipeline) - 横屏锁定 + statusBar 显示,与契约 §4.2 一致 - **暂不实现**:`webViewWebContentProcessDidTerminate:` 退避 reload(移到 Phase 3 网络层时一并处理) - BuildProject 通过 - [x] **1.14.e** WebView 加载完成后淡出 splash - `webView(_:didFinish:)` 内 `UIView.animate(withDuration: 0.3)` splash.alpha = 0 → completion 内 `splash.removeFromSuperview()` - 期间 webView 已渲染好 H5 大厅,无白屏闪烁;splash 出 view 层级避免空持有 - BuildProject 通过 - [x] **1.15** 实现 `Source/Bridge/Handlers/VibratorHandler.swift` - 无状态 `public enum VibratorHandler`,`static func register(on bridge: any BridgeProtocol)` - 入参忽略(参考 msext `RootVC.m:1816-1818`:`time` 参数实际不读取) - 副作用:`AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)`;responseCallback:`.string("vibrator")` - `WebContainerViewController.viewDidLoad` 新增 `registerBridgeHandlers()` 单一聚合点,Phase 2+ 在此追加。BuildProject 通过 - [x] **1.16** `SceneDelegate` → `WebContainerViewController` 作 rootViewController - 替换 M0 占位 `RootViewController` + 删除 `ylgamehall/RootViewController.swift`(M0 烟雾测试堆栈整体退役,所有 1.2–1.13 print 验证记录在 git log,留着是死代码) - File System Synchronized Group 自动适应文件删除,无需改 pbxproj - BuildProject 通过 - [ ] **1.17** 把渠道目录里临时填入的 demo 值 + 一个 demo H5 跑通 / 真机验证完整 1.10-1.16 链路 ##### 1.F WKURLSchemeHandler 迁移(Cocos 适配前置 + 跨页 localStorage 共享,ADR-010) > Phase 1.F 是对 Phase 1.14.d / Phase 6.x `loadFileURL` 路径的**架构级替换**——把大厅 + 所有子游戏迁到 `ylgame://h5/...` 自定义 scheme,由 `AppSchemeHandler`(`WKURLSchemeHandler`)反向映射回沙盒。详见 ADR-010。三条触发动因:① 为未来 Cocos 子游戏 H5 build 适配铺路(file:// 下 Cocos `XHR/fetch` 受 null-origin 限制几乎必踩坑)② 大厅 + 子游戏共用单虚拟 host (`ylgame://h5`) → same-origin → **跨 H5 共享 localStorage**(现状 file:// 下也是同 null-origin 共享,A 方案显式保留此语义)③ 给 `