三 Flavor(Shijiaobao / Clinical / Yiwangxin)iOS 项目
架构
- Shared/ 全部共用层:AppHomeView、WebMessageHandler(JSBridge)、DevUrlSettings
- *Flavor/ 各 flavor 入口:AppConfig、*App(@main)、AppDelegate(Yiwangxin 含 Push)
- project.yml + xcodegen 管理项目结构
SDK 集成
- 引用 Gitea SPM 包(branch: main,SNAPSHOT 开发模式)
- sdk.properties 追踪当前 SDK_REF,scripts/use-sdk.sh 支持 main/local/x.y.z 一键切换
- 含各 flavor xuqm/*.xuqmconfig 配置文件
JSBridge 协议
- window.webkit.messageHandlers.SZYX_APP.postMessage(json)
- 回调 window.SZYX_YWX_WebViewBridge.onMessage(json)
- 实现 setTitle/getDeviceInfo/login/setUserInfo/getAppVersion/checkUpdate
setStorage/getStorage/removeStorage/clearStorage/setOrientation/apiError
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
271 行
11 KiB
Swift
271 行
11 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import XuqmCoreSDK
|
|
import XuqmWebViewSDK
|
|
import XuqmBugCollectSDK
|
|
|
|
/// H5 本地存储的 UserDefaults suite 文件名,与 Update SDK 隔离
|
|
private let H5StorageSuite = "szyx_h5_storage"
|
|
|
|
/// H5 能感知的最高 App API 版本
|
|
private let APP_API_VERSION = 4
|
|
|
|
// MARK: - Message Dispatcher
|
|
|
|
@MainActor
|
|
func handleWebMessage(
|
|
data: String,
|
|
onStatusBarColorChanged: ((UIColor) -> Void)? = nil,
|
|
onNavBarChanged: ((String, UIColor?, UIColor?, [UIColor]?, Float, String?) -> Void)? = nil,
|
|
onCheckUpdateRequested: (() -> Void)? = nil,
|
|
onOrientationChanged: ((String) -> Void)? = nil
|
|
) {
|
|
do {
|
|
guard let jsonData = data.data(using: .utf8),
|
|
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { return }
|
|
|
|
let model = json["model"] as? String ?? ""
|
|
let functionId = json["functionId"] as? String ?? ""
|
|
|
|
switch model {
|
|
case "setTitle":
|
|
handleSetTitle(json: json, functionId: functionId,
|
|
onStatusBarColorChanged: onStatusBarColorChanged,
|
|
onNavBarChanged: onNavBarChanged)
|
|
|
|
case "isWechatInstalled":
|
|
// WeChat is never installed on iOS (App Store policy)
|
|
respondToWeb(functionId: functionId, model: model, status: "0",
|
|
message: "isWechatInstalled:ok",
|
|
value: ["isWechatInstalled": false])
|
|
|
|
case "wechatPay":
|
|
// WeChat Pay is not supported in this build
|
|
respondToWeb(functionId: functionId, model: model,
|
|
status: "E50001", message: "当前App不支持微信支付")
|
|
|
|
case "getDeviceInfo":
|
|
let device = UIDevice.current
|
|
let deviceInfo: [String: Any] = [
|
|
"osName": "iOS",
|
|
"osVersion": "iOS \(device.systemVersion)",
|
|
"baseVersion": device.systemVersion,
|
|
"brand": "Apple",
|
|
"manufacturer": "Apple",
|
|
"model": device.model,
|
|
"marketName": device.name,
|
|
"deviceId": device.identifierForVendor?.uuidString ?? "",
|
|
]
|
|
respondToWeb(functionId: functionId, model: model, status: "0",
|
|
message: "getDeviceInfo:ok", value: ["deviceInfo": deviceInfo])
|
|
|
|
case "login":
|
|
let userId = json["userId"] as? String ?? ""
|
|
if !userId.isEmpty {
|
|
let userSig = (json["userSig"] as? String)?.nonEmpty
|
|
let name = (json["name"] as? String)?.nonEmpty
|
|
let avatar = (json["avatar"] as? String)?.nonEmpty
|
|
XuqmSDK.shared.setUserInfo(userId: userId, nickname: name, avatar: avatar)
|
|
if let userSig {
|
|
Task { await XuqmSDK.shared.login(userId: userId, userSig: userSig) }
|
|
}
|
|
BugCollectSDK.shared.addBreadcrumb(category: "auth", message: "login userId=\(userId)")
|
|
}
|
|
respondOk(functionId: functionId, model: model, message: "login:ok")
|
|
|
|
case "setUserInfo":
|
|
let userId = ((json["userId"] as? String)?.nonEmpty ?? (json["id"] as? String)?.nonEmpty) ?? ""
|
|
if !userId.isEmpty {
|
|
let userSig = (json["userSig"] as? String)?.nonEmpty
|
|
let name = (json["name"] as? String)?.nonEmpty
|
|
let avatar = (json["avatar"] as? String)?.nonEmpty
|
|
XuqmSDK.shared.setUserInfo(userId: userId, nickname: name, avatar: avatar)
|
|
if let userSig {
|
|
Task { await XuqmSDK.shared.login(userId: userId, userSig: userSig) }
|
|
}
|
|
}
|
|
respondOk(functionId: functionId, model: model, message: "setUserInfo:ok")
|
|
|
|
case "getAppVersion":
|
|
let versionName = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
|
|
respondToWeb(functionId: functionId, model: model, status: "0",
|
|
message: "getAppVersion:ok",
|
|
value: ["version": APP_API_VERSION, "appVersion": versionName])
|
|
|
|
case "checkUpdate":
|
|
onCheckUpdateRequested?()
|
|
respondOk(functionId: functionId, model: model, message: "checkUpdate:ok")
|
|
|
|
case "setStorage":
|
|
let storage = json["storage"] as? [String: Any]
|
|
let key = (storage?["key"] as? String) ?? ""
|
|
let value = (storage?["value"] as? String) ?? ""
|
|
if !key.isEmpty {
|
|
let defaults = UserDefaults(suiteName: H5StorageSuite)
|
|
defaults?.set(value, forKey: key)
|
|
}
|
|
respondOk(functionId: functionId, model: model, message: "setStorage:ok")
|
|
|
|
case "getStorage":
|
|
let storage = json["storage"] as? [String: Any]
|
|
let key = (storage?["key"] as? String) ?? ""
|
|
let defaultValue = (storage?["defaultValue"] as? String) ?? ""
|
|
let stored: String
|
|
if !key.isEmpty, let v = UserDefaults(suiteName: H5StorageSuite)?.string(forKey: key) {
|
|
stored = v
|
|
} else {
|
|
stored = defaultValue
|
|
}
|
|
respondToWeb(functionId: functionId, model: model, status: "0",
|
|
message: "getStorage:ok",
|
|
value: ["storage": ["key": key, "value": stored]])
|
|
|
|
case "removeStorage":
|
|
let key = (json["storage"] as? [String: Any])?["key"] as? String ?? ""
|
|
if !key.isEmpty { UserDefaults(suiteName: H5StorageSuite)?.removeObject(forKey: key) }
|
|
respondOk(functionId: functionId, model: model, message: "removeStorage:ok")
|
|
|
|
case "clearStorage":
|
|
if let d = UserDefaults(suiteName: H5StorageSuite) {
|
|
d.dictionaryRepresentation().keys.forEach { d.removeObject(forKey: $0) }
|
|
}
|
|
respondOk(functionId: functionId, model: model, message: "clearStorage:ok")
|
|
|
|
case "setOrientation":
|
|
let orientation = json["orientation"] as? String ?? ""
|
|
onOrientationChanged?(orientation)
|
|
respondOk(functionId: functionId, model: model, message: "setOrientation:ok")
|
|
|
|
case "apiError":
|
|
let d = (json["data"] as? [String: Any]) ?? json
|
|
let url = d["url"] as? String ?? ""
|
|
let method = d["method"] as? String ?? ""
|
|
let status = d["status"] as? Int ?? 0
|
|
let statusText = d["statusText"] as? String ?? ""
|
|
let body = d["response"] as? String ?? ""
|
|
BugCollectSDK.shared.captureError(
|
|
ApiCallError(method: method, url: url, status: status, statusText: statusText, body: body),
|
|
tags: ["context": "webViewApi", "url": url, "method": method, "status": status]
|
|
)
|
|
|
|
default:
|
|
respondToWeb(functionId: functionId, model: model,
|
|
status: "E40001", message: "当前App版本不支持该接口")
|
|
}
|
|
} catch {
|
|
BugCollectSDK.shared.captureError(error, tags: ["context": "handleWebMessage"])
|
|
}
|
|
}
|
|
|
|
// MARK: - setTitle helpers
|
|
|
|
private func handleSetTitle(
|
|
json: [String: Any],
|
|
functionId: String,
|
|
onStatusBarColorChanged: ((UIColor) -> Void)?,
|
|
onNavBarChanged: ((String, UIColor?, UIColor?, [UIColor]?, Float, String?) -> Void)?
|
|
) {
|
|
let titleObj = json["title"] as? [String: Any]
|
|
let titleText = titleObj?["titleText"] as? String ?? ""
|
|
let textColor = parseHexColor(titleObj?["titleTextColor"] as? String)
|
|
let bgImage = (titleObj?["titleBackgroundImage"] as? String)?.nonEmpty
|
|
|
|
let gradientArr = titleObj?["titleBackgroundGradient"] as? [String]
|
|
let bgGradient: [UIColor]?
|
|
let bgColor: UIColor?
|
|
if let gradientArr, gradientArr.count >= 2 {
|
|
bgGradient = gradientArr.compactMap { parseHexColor($0) }
|
|
bgColor = bgGradient?.first
|
|
} else {
|
|
bgGradient = nil
|
|
bgColor = parseHexColor(titleObj?["titleBackgroundColor"] as? String)
|
|
}
|
|
let gradientAngle = (titleObj?["titleBackgroundGradientAngle"] as? Double).map { Float($0) } ?? 0
|
|
|
|
if let bgColor { onStatusBarColorChanged?(bgColor) }
|
|
onNavBarChanged?(titleText, textColor, bgColor, bgGradient, gradientAngle, bgImage)
|
|
respondOk(functionId: functionId, model: "setTitle", message: "setTitle:ok")
|
|
}
|
|
|
|
// MARK: - Response helpers
|
|
|
|
private func respondOk(functionId: String, model: String, message: String) {
|
|
respondToWeb(functionId: functionId, model: model, status: "0", message: message, value: [:])
|
|
}
|
|
|
|
private func respondToWeb(
|
|
functionId: String,
|
|
model: String,
|
|
status: String = "0",
|
|
message: String,
|
|
value: [String: Any] = [:]
|
|
) {
|
|
var response: [String: Any] = [
|
|
"functionId": functionId,
|
|
"model": model,
|
|
"status": status,
|
|
"message": message,
|
|
"value": value,
|
|
]
|
|
guard let data = try? JSONSerialization.data(withJSONObject: response),
|
|
let json = String(data: data, encoding: .utf8) else { return }
|
|
|
|
let js = "(function(){window.SZYX_YWX_WebViewBridge&&window.SZYX_YWX_WebViewBridge.onMessage(\(json));})()"
|
|
postJSToWebView(js)
|
|
}
|
|
|
|
// MARK: - Color helpers
|
|
|
|
func parseHexColor(_ hex: String?) -> UIColor? {
|
|
guard var h = hex?.trimmingCharacters(in: .whitespaces), !h.isEmpty else { return nil }
|
|
if h.hasPrefix("#") { h = String(h.dropFirst()) }
|
|
guard h.count == 6 || h.count == 8 else { return nil }
|
|
var value: UInt64 = 0
|
|
Scanner(string: h).scanHexInt64(&value)
|
|
if h.count == 6 {
|
|
return UIColor(
|
|
red: CGFloat((value >> 16) & 0xFF) / 255,
|
|
green: CGFloat((value >> 8) & 0xFF) / 255,
|
|
blue: CGFloat( value & 0xFF) / 255,
|
|
alpha: 1
|
|
)
|
|
} else {
|
|
return UIColor(
|
|
red: CGFloat((value >> 24) & 0xFF) / 255,
|
|
green: CGFloat((value >> 16) & 0xFF) / 255,
|
|
blue: CGFloat((value >> 8) & 0xFF) / 255,
|
|
alpha: CGFloat( value & 0xFF) / 255
|
|
)
|
|
}
|
|
}
|
|
|
|
func isLightColor(_ color: UIColor) -> Bool {
|
|
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0
|
|
color.getRed(&r, green: &g, blue: &b, alpha: nil)
|
|
return 0.299 * r + 0.587 * g + 0.114 * b > 0.5
|
|
}
|
|
|
|
// MARK: - JS posting
|
|
|
|
func postJSToWebView(_ js: String) {
|
|
// Forward via XWebViewBridge to the active WKWebView
|
|
Task { @MainActor in
|
|
XWebViewBridge.shared.postMessageToWeb(js)
|
|
}
|
|
}
|
|
|
|
// MARK: - Supporting types
|
|
|
|
private struct ApiCallError: Error {
|
|
let method: String
|
|
let url: String
|
|
let status: Int
|
|
let statusText: String
|
|
let body: String
|
|
var localizedDescription: String { "API \(method) \(url) → \(status) \(statusText)" }
|
|
}
|
|
|
|
private extension String {
|
|
var nonEmpty: String? { isEmpty ? nil : self }
|
|
}
|