# Daoqi iOS 外壳新项目实施设计 > 配套文档:[`H5-Native-Contract.md`](./H5-Native-Contract.md) (契约,必须 1:1 落地) > 本文档:实现蓝图,开发团队拿到即可启动 > 写作日期:2026-06-21 --- ## 项目性质声明(读前必读) 本文档描述的是一个 **greenfield 全新 iOS 项目** 的完整架构设计。 请用"从零开始建一个新 iOS App"的姿态阅读: - 文件结构、命名、模块边界、技术栈、并发模型、依赖选型 — 完全独立设计,按 Swift / iOS 现代最佳实践 - 一切原生内部实现追求 **架构优雅、高效、高性能、专业、成熟** - 不背任何历史包袱,不复刻任何既有 iOS 项目的代码 - 整体姿态:**架构师从零起设计一个新 App**,而不是"工程师维护/重构一个老 App" ### 设计中需要"照搬"什么 只有以下三类外部契约要求新项目按既定形式落地,其它一切自由: | 契约类型 | 来源 | 必须照搬什么 | 文档位置 | |---------|------|------------|---------| | H5 契约 | H5 端业务代码 | 桥接口名 / 参数字段名 / 数据结构 / JS 协议 / 初始化字面值 | `H5-Native-Contract.md` | | 服务端契约 | 后台 API | 签名算法 / 字段顺序 / URL 路径 / 错误码 | 本文档 §6 | | 三方 SDK 契约 | 微信/QQ/高德/Agora/七牛 SDK 文档 | 注册顺序 / 文件类型 flag / 回调签名 | 本文档 §4 §14 | **判断某项是否属于契约的方法**:问"外部能否感知"。能感知 → 照搬;不能 → 自由设计。 ### 渐进集成原则(未启用 SDK 处理方式) 部分桥接 handler 当前业务**暂未使用**(子游戏视频房间、闲聊分享、Bugly 监控等)。新项目首版**不集成这些 SDK**,但 **handler 仍然必须注册**,内部 stub 实现 —— 立即调 `responseCallback` 返回契约要求的字面值,不做实际业务,不依赖任何未集成的 SDK。 理由: - H5 端代码不知道哪些 SDK 没集成,它仍然会调 `createRoom` / `friendsSharetypeUrlToptitleDescript(sharetype=3)` 等。若 handler 不注册,H5 会走"bridge handler not found"错误分支,业务流程中断 - Stub handler 保证契约不被违反,即使功能没启用也不会引起 H5 端报错 - 未来某天业务启动这些功能时,只需把 stub 换成真实 SDK 调用,**契约边界不需要任何改动** 详细的 stub 清单见 §5 / §8 / §14。 --- ## 0. TL;DR | 项 | 决策 | |----|------| | 语言 | **Swift 5.10+**(Swift 6 strict concurrency 渐进开启) | | 最低系统 | **iOS 14.0**(覆盖 99.5% 现役设备,删掉所有 iOS <9 旧路径) | | UI 层 | **UIKit + Storyboard 极少**,以代码 UI 为主;不引入 SwiftUI(WebView 容器场景不受益) | | 并发 | **async/await + Actor**,弃用 Combine 与 GCD 散落用法 | | Web 渲染 | **WKWebView** 单例 + ProcessPool 复用 | | H5 桥 | 自研 Swift 桥(实现 WebViewJavascriptBridge 的 JS 端协议作为契约边界)+ 弹层注入 `window.settings.*` polyfill | | 网络 | **URLSession + async/await + Codable**,自研 HTTPClient,Endpoint 协议化 | | 持久化 | UserDefaults + FileManager;**不引入 CoreData/SQLite** | | 依赖管理 | **纯 SPM + Vendor `.xcframework` 手动**(完全不引入 CocoaPods 工具链);开源 / 官方 SPM 走 SPM,闭源二进制无 SPM 的走 Vendor 手动 | | 架构 | **Modular Clean Architecture** + **Coordinator 路由** | | 监控 | **默认接入 Sentry**(崩溃 + 性能 + breadcrumb);提供编译开关 `SENTRY_ENABLED` 作为关闭逃生口 | | 包名 / Target | 与现 `msext` 解耦,新名 `Daoqi`(支持企业签 / 超签 / TF 同包并存) | | 上架 | 由项目方业务决定;设计层面不预设(若上架则补 PrivacyInfo.xcprivacy / ATT 文案,模块化解耦不影响其它代码) | --- ## 1. 设计目标 & 非目标 ### 1.1 目标 1. **契约零偏差** — H5 不改一行代码,在新外壳上行为与现网一致(以 `H5-Native-Contract.md` §10 验收清单为准) 2. **架构优雅** — 模块边界清晰,单一职责;新增桥接接口/SDK 只动一处 3. **高性能** — 启动 < 1.5 s 进大厅 H5;桥接消息往返 < 16 ms(单帧);视频房间 1080p@30 稳定 4. **专业成熟** — 包含完整的崩溃监控/日志/单测/集成测/CI 流程;团队随时可换人维护 5. **可演进** — 新增渠道/功能不需要回改既有模块;SDK 可热替换(如 Agora → 自研) ### 1.2 非目标(避免过度设计) - 不做 ARM64 之外的架构(armv7/armv7s 已无现役设备) - 不做 iPadOS 适配优化(产品定位是 iPhone) - 不引入 SwiftUI(纯 WebView 容器,SwiftUI 不会更快也不会更优雅) - 不做 Combine + RxSwift 任何一种响应式流(用 async/await 足矣) - 不做云原生 / 后端服务(范畴外) --- ## 2. 模块化架构 ### 2.1 顶层依赖图 ``` ┌──────────────────────────────────────────────┐ │ App │ 入口、Coordinator、生命周期 └──────────────────────────────────────────────┘ │ │ │ ▼ ▼ ▼ ┌─────────────────┐ ┌──────────────┐ ┌─────────────┐ │ Features │ │ Core │ │ SDKs │ │ (Lobby/SubGame │ │ (Bridge/Net/ │ │ (WeChat/ │ │ /Overlay/...) │ │ Resource) │ │ Agora/...) │ └─────────────────┘ └──────────────┘ └─────────────┘ │ │ │ └──────────────┴──────────────┘ │ ▼ ┌──────────────────────┐ │ Foundation │ 纯工具、扩展、协议 └──────────────────────┘ ``` **依赖方向严格自上而下**,任意一层不引用同层兄弟模块、不反向依赖。 ### 2.2 SPM 包结构 按 Swift Package Manager 切分,每个目录是一个 SPM target: ``` Daoqi/ ├── Package.swift # 顶层 SPM 配置(多 target) ├── Sources/ │ ├── App/ # 入口 │ ├── AppCoordinator/ # 路由 │ ├── Lobby/ # 大厅 feature │ ├── SubGame/ # 子游戏 feature │ ├── Overlay/ # 弹层 feature │ ├── BridgeCore/ # H5 桥核心(协议+消息总线) │ ├── BridgeHandlers/ # 桥 handler 注册(纯函数) │ ├── WebViewKit/ # WKWebView 封装、JS 注入 │ ├── NetworkKit/ # HTTPClient、SGGateway facade │ ├── ResourceKit/ # gamehall.zip 解压、渠道注入、目录管理 │ ├── AudioKit/ # 录音/播放/AMR-WAV 转码 │ ├── ShareKit/ # 分享统一入口 │ ├── LoginKit/ # 微信授权 │ ├── LocationKit/ # 高德定位 │ ├── DeviceKit/ # 设备信息/电池/网络/振动 │ ├── VideoRoomKit/ # Agora 封装 │ ├── AnalyticsKit/ # Sentry(默认启用) / JAnalytics 极光(暂不集成,Noop 占位) │ ├── Logger/ # 统一日志 │ └── Foundation+/ # 通用扩展 ├── Vendor/ # 闭源 SDK(.framework / .a) │ ├── WechatSDK.framework # 启用 │ ├── AMapLocationKit.framework # 启用 │ └── libopencore-amr*.a # 启用 │ # 暂不集成(对应代码模块用 Noop 占位): │ # - JAnalytics.framework # 启用用户统计时再放入 │ # - XianliaoSDK.framework # 启用闲聊分享时再放入 │ # - AgoraRtcKit.framework # 启用视频房间时再放入(或改用 SPM) ├── Resources/ │ ├── gamehall.zip │ ├── Info.plist │ ├── Daoqi.entitlements │ ├── Assets.xcassets │ └── ChannelInjection/ # 11 个渠道注入目录,打包脚本生成 │ ├── qiniudomain/ │ ├── gameid/ │ ├── channel/ │ ├── gamedir/ │ ├── gamestart/ │ ├── gameconfig/ │ ├── market/ │ ├── agent/ │ ├── appversion/ │ ├── other/ │ └── appleconfig/ ├── Tests/ # 单测 │ ├── BridgeCoreTests/ │ ├── ResourceKitTests/ │ ├── NetworkKitTests/ │ └── ... ├── UITests/ # XCUI 集成测 ├── Scripts/ # 打包/渠道注入脚本 │ ├── inject_channel.sh │ ├── archive.sh │ └── version_bump.sh └── Daoqi.xcworkspace # Xcode 工作区 ``` > **切分粒度提示**:上述 17 个 SPM target 是初始规划,M0 立项时跑一次 `xcodebuild -scheme Daoqi clean build` baseline 测量。若 clean build 时间 > 60s,把强耦合模块合并:`Lobby` / `SubGame` / `Overlay` 合为 `Containers`;`DeviceKit` 并入 `Foundation+`;`AudioKit` 与 `ShareKit` 看依赖深度可保持独立或合并。**目标稳态 8-10 个 target**,以日常开发体验为先。 ### 2.3 模块职责矩阵 | 模块 | 暴露能力 | 内部依赖 | |------|---------|---------| | `BridgeCore` | `BridgeProtocol`、`BridgeMessage`、`BridgeBus`(actor) | Foundation+ | | `WebViewKit` | `BridgedWebView`(包装 WKWebView+消息桥), `JSInjector` | BridgeCore, ResourceKit, Foundation+ | | `BridgeHandlers` | `LobbyHandlers`、`SubGameHandlers`、`OverlayHandlers` | BridgeCore, 各 Kit(AudioKit/ShareKit/...) | | `Lobby` / `SubGame` / `Overlay` | ViewController + Coordinator | WebViewKit, BridgeHandlers | | `NetworkKit` | `HTTPClient`(async), `GameAPIClient`(签名 + Endpoint) | Foundation+ | | `ResourceKit` | `BundleConfig`(渠道读取), `ResourceUnzipper`, `SandboxPaths` | Foundation+ | | `AudioKit` | `AudioRecorder`, `AudioPlayer`, `VoiceCoder`(AMR↔WAV) | ResourceKit, NetworkKit | | `ShareKit` | `ShareCenter`(协议) → `WeChatShare` 启用 / `XianliaoShare` 暂以 `NoopSharePlatform` 替代 | SDK Wrappers | | `LoginKit` | `WeChatAuth`(async OAuth) | SDK Wrappers, NetworkKit | | `LocationKit` | `LocationService` | AMapKit Wrapper | | `DeviceKit` | `DeviceInfo`, `BatteryMonitor`, `NetworkMonitor`, `Vibrator`, `Pasteboard` | Foundation+ | | `VideoRoomKit` | `VideoRoom`(协议) → `NoopVideoRoom` 当前启用 / `AgoraVideoRoom` 留蓝图 | (Agora SDK 暂未引入) | | `AnalyticsKit` | `CrashReporter`(协议) → `SentryCrashReporter` 启用 / `Tracker`(协议) → `NoopAnalytics` 当前启用 / `JAnalyticsTracker` 留蓝图 | SDK Wrappers | --- ### 2.4 WebContainer 容器模型与生命周期 大厅 / 子游戏 / 弹层在新架构中是**同一种 `WebContainer` 的不同角色**,共享基类,差异只在注入的 handler 集合和入口 URL: ``` WebContainerViewController(基类 ~200 行) ├── WKWebView + ProcessPool 复用 ├── BridgeBus 创建与公共 handler 注册委托 ├── JSInjector(app_data.js / app_battery.js / app_network.js) ├── 生命周期 → 桥事件(appservice / getBattery / getnetwork) ├── 摇一摇 motionEnded → shakeEnd └── 外部订阅生命周期管理(suspend/resume) ▲ │ 继承 + 注入 ┌────┴────────────────────┐ ▼ ▼ ▼ Lobby SubGame Overlay (20 handler) (23 handler) (3 handler) + 大厅 URL + 子游戏 URL + 外链 URL + VideoRoom + JSExport polyfill + CallCenter + RecordUploader ``` #### 2.4.1 状态保持原则:push 不销毁 大厅 → 子游戏使用 `UINavigationController.pushViewController`,**大厅 VC + WebView + Bridge + JS 上下文全程保留**在 navigation stack 里。子游戏 → 大厅用 `popViewController`,大厅状态原封不动恢复。 理由: - H5 端业务隐式依赖大厅状态(列表滚动、玩家余额、待办通知等) - `getWebdata` callback 要求大厅 handler 一直注册着,新建大厅会丢数据 - 返回瞬时(无 loadFileURL 黑屏) #### 2.4.2 外部订阅暂停 — 避免桥事件双发 旧架构隐患:大厅和子游戏都监听 `enterForeground` / 电池 / 网络 等通知,前后台切换时**两个都响应**,H5 收到双倍桥事件。 新架构方案:WebContainer 用 viewWillAppear / viewWillDisappear 管理外部订阅生命周期,**任何时刻只有最顶层可见 VC 向 H5 派发桥事件**: ```swift public class WebContainerViewController: UIViewController { private let externalSubs: ExternalSubscriptions // 注入 override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) externalSubs.resume() // resume 内部:bind battery/network/lifecycle 监听 // 立即派发一次当前状态(避免 H5 错过暂停期间的变化) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) externalSubs.suspend() // suspend 内部:unbind 所有监听,WebView 不释放,JS 继续运行 } } @MainActor final class ExternalSubscriptions { weak var bridge: BridgeProtocol? private let battery = BatteryMonitor() private let network = NetworkMonitor() private let lifecycle = AppLifecycleObserver() func resume() { battery.onChange = { [weak bridge] level in bridge?.call("getBattery", data: .string(String(format: "%.2f", level)), callback: nil) } network.onChange = { [weak bridge] state in bridge?.call("getnetwork", data: .string(state.rawValue), callback: nil) } lifecycle.onChange = { [weak bridge] state in bridge?.call("appservice", data: .string(state == .background ? "1" : "2"), callback: nil) } battery.start(); network.start(); lifecycle.start() // 立即派发一次,补齐暂停期间的状态 battery.emitNow(); network.emitNow() } func suspend() { battery.stop(); network.stop(); lifecycle.stop() // 解绑回调,避免在子游戏 push 上来后大厅仍收到事件 } } ``` #### 2.4.3 栈深约束 ≤ 2 — 防止内存堆栈 旧架构隐患:H5 连续调 SwitchOverGameData 时,navigation stack 不断增长(`[Lobby, SubA, SubB, SubC, ...]`),内存爆炸。 新架构方案:Coordinator 在路由时显式判断: ```swift @MainActor final class AppCoordinator { private let nav: UINavigationController /// 进入子游戏:保证 nav stack 最多两层(Lobby + 当前 SubGame) func showSubGame(_ params: SwitchGameParams) { if nav.topViewController is SubGameViewController { nav.popViewController(animated: false) // 先退出当前子游戏 } let vc = SubGameViewController(params: params, coordinator: self) nav.pushViewController(vc, animated: true) } /// 打开弹层(Overlay 是浮层,允许 [Lobby, SubGame, Overlay] 三层) func showOverlay(_ params: OverlayParams) { let vc = OverlayViewController(params: params, coordinator: self) nav.pushViewController(vc, animated: true) } func popToLobby() { nav.popToRootViewController(animated: true) } } ``` → 任意 H5 操作下,navigation stack 最多 3 层(Lobby + SubGame + Overlay),内存占用可预测。 #### 2.4.4 WebView 数据隔离策略 新架构主张大厅 / 子游戏 / 弹层共用同一个 `WKProcessPool` 加速启动,但**默认共享 Cookie 和 LocalStorage**(因为它们都属于同一个 dataStore)。这在不同源 H5 间可能产生意外串扰。 策略: - **大厅 + 子游戏** 共用 `WKWebsiteDataStore.default()`(都是项目自家 H5,共享业务态合理 — 比如登录态、用户偏好等) - **弹层 Overlay**(加载第三方外链时)使用 `WKWebsiteDataStore.nonPersistent()` 私有 store,Cookie / LocalStorage 不写盘,**不污染主业务态** ```swift extension WKWebsiteDataStore { static let businessStore = WKWebsiteDataStore.default() // 大厅 + 子游戏 static let overlayStore = WKWebsiteDataStore.nonPersistent() // 弹层外链 } ``` OverlayViewController 在 WKWebViewConfiguration 上设置 `websiteDataStore = .overlayStore` 即可隔离。 #### 2.4.5 副作用清单:进入 / 退出子游戏的强制动作 无论 H5 端逻辑如何变化,以下副作用由 Coordinator + Handler 在路由时**强制完成**,不允许 H5 端遗漏: | 时机 | 强制副作用 | |------|----------| | 大厅 → 子游戏(SwitchOverGameData) | ① `audio.stopBackground()` ② `WXApiManager` delegate 切上下文(详见 §8.4) | | 子游戏 → 大厅(backgameData) | ① `audio.stopBackground()` ② `videoRoom.leave()` ③ 通过 `.subGameDidReturn` 通知 LobbyHandlers 回调 `getWebdata` | | 进入 Overlay(OpenurlTitleData) | ① 节流(3 秒内重复打开吞掉) | | 退出 Overlay(settings.finishweb / settings.backgameData) | ① 弹层 backgameData 同样发 `.subGameDidReturn` 通知,LobbyHandlers / SubGameHandlers 监听并 callback `getWebdata` 给当前 active 容器的 H5 | --- ## 3. H5 桥接核心实现 ### 3.1 设计要点 WVJB 的 JS 端协议很简单:页面加载完成后,JS 会执行 `_setupWebViewJavascriptBridge`,把队列 message 通过 `iframe src` 或 `console.log` 通知原生。新外壳**自己实现 WVJB-JS 端的协议**,而不是引用 OC 版 WebViewJavascriptBridge 库: - 优点:纯 Swift、无 OC 依赖、可严格并发安全、单元测试容易 - 落地路径:把 WebViewJavascriptBridge 的 JS 端代码(`WebViewJavascriptBridge.js.txt`)作为 WKUserScript 注入到 WKWebView,原生侧自研 `WKScriptMessageHandler` 接收并分发消息 ### 3.2 协议骨架 ```swift // BridgeCore/BridgeProtocol.swift public protocol BridgeProtocol: AnyObject, Sendable { /// 注册一个 H5 → Native handler。重复名覆盖。 func register(_ name: String, handler: @escaping BridgeHandler) /// 主动调用 H5 端的 handler。callback 可选。 func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) } public typealias BridgeHandler = @Sendable (BridgeData?, BridgeCallback?) async -> Void public typealias BridgeCallback = @Sendable (BridgeData?) -> Void public enum BridgeData: Sendable { case string(String) case number(Double) case bool(Bool) case null case array([BridgeData]) case object([String: BridgeData]) // 字段访问语法糖 public subscript(key: String) -> BridgeData? { ... } public var asString: String? { ... } public var asInt: Int? { ... } } ``` ### 3.3 关键实现 ```swift // WebViewKit/BridgedWebView.swift @MainActor public final class BridgedWebView: UIView { public let webView: WKWebView public let bridge: BridgeBus public init(config: WebViewConfig) { let cfg = WKWebViewConfiguration() cfg.processPool = SharedProcessPool.shared // 进程池复用 cfg.preferences.javaScriptEnabled = true cfg.preferences.javaScriptCanOpenWindowsAutomatically = false cfg.preferences.minimumFontSize = 10 cfg.allowsInlineMediaPlayback = true // 注入 WVJB-JS 协议 let userScript = WKUserScript( source: JSInjector.wvjbProtocol, injectionTime: .atDocumentStart, forMainFrameOnly: true) cfg.userContentController.addUserScript(userScript) self.webView = WKWebView(frame: .zero, configuration: cfg) self.bridge = BridgeBus(webView: webView, controller: cfg.userContentController) super.init(frame: .zero) layout() // 契约 §4.1:与现网保持的 ScrollView 配置 webView.scrollView.contentInsetAdjustmentBehavior = .never // iOS 11+ webView.scrollView.bounces = false webView.scrollView.showsVerticalScrollIndicator = false webView.scrollView.showsHorizontalScrollIndicator = false webView.scrollView.isScrollEnabled = false // ⚠️ 契约要求,漏则 H5 上下滑动失控 } } // BridgeCore/BridgeBus.swift @MainActor public final class BridgeBus: BridgeProtocol { private var handlers: [String: BridgeHandler] = [:] private weak var webView: WKWebView? private var pendingCallbacks: [String: BridgeCallback] = [:] private var callbackCounter = 0 public func register(_ name: String, handler: @escaping BridgeHandler) { handlers[name] = handler } public func call(_ name: String, data: BridgeData?, callback: BridgeCallback?) { var payload: [String: Any] = ["handlerName": name] if let data = data { payload["data"] = data.jsonObject } if let callback = callback { callbackCounter += 1 let cbId = "objc_cb_\(callbackCounter)" pendingCallbacks[cbId] = callback payload["callbackId"] = cbId } let json = try! JSONSerialization.data(withJSONObject: payload) let js = "WebViewJavascriptBridge._handleMessageFromObjC('\(json.base64)')" webView?.evaluateJavaScript(js) } // 接收 H5 → Native func didReceive(_ message: [String: Any]) async { guard let name = message["handlerName"] as? String else { return } let data = (message["data"]).map(BridgeData.from) let cbId = message["callbackId"] as? String let cb: BridgeCallback? = cbId.map { id in { [weak self] resp in self?.call(id, data: resp, callback: nil) } } if let handler = handlers[name] { await handler(data, cb) } else { Log.warn("Bridge: no handler for \(name)") cb?(nil) } } } ``` ### 3.4 弹层桥(window.settings polyfill) H5 弹层页约定使用 `window.settings.xxx(...)` 同步调用形式(契约边界)。新项目弹层 WebView 用 WKWebView,通过注入一段 polyfill 把 `window.settings` 转换为 `WKScriptMessageHandler` 消息: ```swift // WebViewKit/OverlayBridge.swift public enum OverlayBridge { public static let polyfill = """ window.settings = { backgameData: function(data) { window.webkit.messageHandlers.overlayBackgameData.postMessage(String(data)); }, browser: function(url) { window.webkit.messageHandlers.overlayBrowser.postMessage(String(url)); }, finishweb: function() { window.webkit.messageHandlers.overlayFinishweb.postMessage(""); } }; """ } ``` 3 个 `WKScriptMessageHandler` 在 OverlayViewController 内注册即可。 #### 3.4.1 ⚠️ 撤销:大厅 / 子游戏的 `window.settings.getXxx()` polyfill(无需实现) > 本子节早期版本(commit `82bad8a`)曾设计 9 项同步 getter polyfill(`getchannelName` / `getmarketname` / `getothername` / `getbattery` / `getnetwork` / `getcompareCode` / `getGameinstall` / `getGameplay` / `getOther`),把 §附录 A 旧桥 JSExport 方法重新实现为 `window.settings.getXxx()` JS 函数。**经原项目深度审查后撤销**,理由: > > 1. **契约自身已明示可不实现**:Contract §3.3 标题"关于 iOS<9 旧桥的精确签名(**附录用,新外壳可不实现**)",§附录 A 同样声明"这是 iOS<9 路径使用的旧协议,新外壳如最低系统 ≥ iOS 14 可不实现"。本项目最低 iOS 15.6 → 不需要 > 2. **H5 端真实路径是 §4.2 的 `app_*.js` 文件预注入**:H5 业务代码通过 `` 等同步引入。新外壳如果不写这三个文件,H5 启动时变量未定义直接崩。 ### 20.6 NSUserDefaults Key 清单(契约 §10 持久化) | Key 名 | 类型 | 写入时机 | 说明 | |--------|------|---------|------| | `everLaunched` | Bool | 首次启动设 YES | 用于判断是否要执行首次初始化逻辑 | | `getcompareCode` | String | 首次启动设 "0",H5 业务过程会改写 | H5 通过 app_data.js 读 | | `FirstBOOL` | String | 首次启动设 "set0" | H5 业务标记 | | `SecondBOOL` | String | 首次启动设 "set0" | H5 业务标记 | | `ThirdBOOL` | String | 首次启动设 "set0" | H5 业务标记 | | `VersionInfo` | String | 每次启动设 "1.0" | H5 读取版本号 | **禁止重命名 key**,否则升级覆盖安装时 H5 业务状态全丢。 ### 20.7 首次启动写 `app_gamesname.js` ```swift // 仅 everLaunched=false 时执行一次 if !UserDefaults.standard.bool(forKey: DefaultsKey.everLaunched) { let dir = SandboxPaths.caches .appendingPathComponent(BundleConfig.shared.gameDir) .appendingPathComponent(BundleConfig.shared.gameStart) let js = "var app_gamesname=new Array('\(BundleConfig.shared.gameStart)');" try? js.write(to: dir.appendingPathComponent("app_gamesname.js"), atomically: true, encoding: .utf8) UserDefaults.standard.set(true, forKey: DefaultsKey.everLaunched) } ``` > 注意 `var` 后**两个空格**:H5 静态引用此文件,字符级必须一致(契约边界,从现网 H5 代码反推得到)。 --- 文档完成日期:2026-06-21