feat: iOS UpdateSDK 增强 - 对齐 Android/RN 功能

新增功能:
- 设备信息上报(deviceId, model, osVersion, vendor=APNS)
- 更新缓存(30分钟 TTL,UserDefaults)
- 版本忽略机制(ignoreVersion, clearIgnoredVersions, isVersionIgnored)
- bypassIgnore 参数
- requiresLogin 字段
- downloadUrl/marketUrl 字段
- currentVersionName 上报
- UpdateListener 协议
- Bundle 扩展(versionCode, versionName)

Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
XuqmGroup 2026-06-19 14:13:25 +08:00
父节点 227bc66bfd
当前提交 71c4a9f2d1

查看文件

@ -4,43 +4,225 @@ import XuqmCoreSDK
import UIKit
#endif
///
public struct AppUpdateInfo: Decodable, Sendable {
public let needsUpdate: Bool
public let versionName: String?
public let versionCode: Int?
public let downloadUrl: String?
public let changeLog: String?
public let forceUpdate: Bool?
public let forceUpdate: Bool
public let appStoreUrl: String?
public let marketUrl: String?
public let requiresLogin: Bool
public let alreadyDownloaded: Bool
public let apkHash: String?
enum CodingKeys: String, CodingKey {
case needsUpdate, versionName, versionCode, downloadUrl, changeLog
case forceUpdate, appStoreUrl, marketUrl, requiresLogin, apkHash
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
needsUpdate = try container.decodeIfPresent(Bool.self, forKey: .needsUpdate) ?? false
versionName = try container.decodeIfPresent(String.self, forKey: .versionName)
versionCode = try container.decodeIfPresent(Int.self, forKey: .versionCode)
downloadUrl = try container.decodeIfPresent(String.self, forKey: .downloadUrl)
changeLog = try container.decodeIfPresent(String.self, forKey: .changeLog)
forceUpdate = try container.decodeIfPresent(Bool.self, forKey: .forceUpdate) ?? false
appStoreUrl = try container.decodeIfPresent(String.self, forKey: .appStoreUrl)
marketUrl = try container.decodeIfPresent(String.self, forKey: .marketUrl)
requiresLogin = try container.decodeIfPresent(Bool.self, forKey: .requiresLogin) ?? false
apkHash = try container.decodeIfPresent(String.self, forKey: .apkHash)
alreadyDownloaded = false
}
public init(needsUpdate: Bool, versionName: String? = nil, versionCode: Int? = nil,
downloadUrl: String? = nil, changeLog: String? = nil, forceUpdate: Bool = false,
appStoreUrl: String? = nil, marketUrl: String? = nil, requiresLogin: Bool = false,
alreadyDownloaded: Bool = false, apkHash: String? = nil) {
self.needsUpdate = needsUpdate
self.versionName = versionName
self.versionCode = versionCode
self.downloadUrl = downloadUrl
self.changeLog = changeLog
self.forceUpdate = forceUpdate
self.appStoreUrl = appStoreUrl
self.marketUrl = marketUrl
self.requiresLogin = requiresLogin
self.alreadyDownloaded = alreadyDownloaded
self.apkHash = apkHash
}
}
///
public protocol UpdateListener: AnyObject, Sendable {
func onUpdateAvailable(_ updateInfo: AppUpdateInfo)
func onNoUpdate()
func onError(_ error: Error)
}
/// SDK
@MainActor
public final class UpdateSDK {
public static let shared = UpdateSDK()
private init() {}
public func checkAppUpdate(currentVersionCode: Int) async throws -> AppUpdateInfo {
private static let cacheKey = "xuqm_update_cache"
private static let ignoredPrefix = "xuqm_ignored_v"
private static let cacheTtl: TimeInterval = 30 * 60 // 30 minutes
///
/// - Parameters:
/// - bypassIgnore:
/// - currentVersionCode: Bundle
public func checkAppUpdate(bypassIgnore: Bool = false, currentVersionCode: Int? = nil) async throws -> AppUpdateInfo {
await XuqmSDK.shared.awaitInitialization()
let config = XuqmSDK.shared.requireConfig()
let userId = XuqmSDK.shared.currentUserId
let versionCode = currentVersionCode ?? Bundle.main.versionCode
// Check cache (skip if bypassIgnore)
if !bypassIgnore {
if let cached = readCache(appKey: config.appKey, versionCode: versionCode, userId: userId) {
return cached
}
}
let deviceInfo = getDeviceInfo()
let versionName = Bundle.main.versionName
var queryItems = [
URLQueryItem(name: "appKey", value: config.appKey),
URLQueryItem(name: "platform", value: "IOS"),
URLQueryItem(name: "currentVersionCode", value: "\(currentVersionCode)"),
URLQueryItem(name: "currentVersionCode", value: "\(versionCode)"),
URLQueryItem(name: "currentVersionName", value: versionName),
URLQueryItem(name: "deviceId", value: deviceInfo.deviceId),
URLQueryItem(name: "model", value: deviceInfo.model),
URLQueryItem(name: "osVersion", value: deviceInfo.osVersion),
URLQueryItem(name: "vendor", value: "APNS"),
]
if let userId, !userId.isEmpty {
queryItems.append(URLQueryItem(name: "userId", value: userId))
}
return try await ApiClient.shared.request(
if bypassIgnore {
queryItems.append(URLQueryItem(name: "bypassIgnore", value: "true"))
}
var info: AppUpdateInfo = try await ApiClient.shared.request(
path: "/api/v1/updates/app/check",
queryItems: queryItems
)
// Apply version ignore
if !bypassIgnore && info.needsUpdate && !info.forceUpdate, let vc = info.versionCode {
if isVersionIgnored(vc) {
return AppUpdateInfo(needsUpdate: false)
}
}
// Write cache
writeCache(appKey: config.appKey, versionCode: versionCode, userId: userId, info: info)
return info
}
/// App Store
public func openAppStore(url: String) {
guard let storeURL = URL(string: url) else { return }
#if canImport(UIKit)
UIApplication.shared.open(storeURL)
#endif
}
///
public func ignoreVersion(_ versionCode: Int) {
UserDefaults.standard.set(true, forKey: "\(Self.ignoredPrefix)\(versionCode)")
}
///
public func clearIgnoredVersions() {
let keys = UserDefaults.standard.dictionaryRepresentation().keys
for key in keys where key.hasPrefix(Self.ignoredPrefix) {
UserDefaults.standard.removeObject(forKey: key)
}
}
///
public func isVersionIgnored(_ versionCode: Int) -> Bool {
UserDefaults.standard.bool(forKey: "\(Self.ignoredPrefix)\(versionCode)")
}
// MARK: - Device Info
private struct DeviceInfo {
let deviceId: String
let model: String
let osVersion: String
}
private func getDeviceInfo() -> DeviceInfo {
let device = UIDevice.current
let deviceId = device.identifierForVendor?.uuidString ?? ""
let model = device.model
let osVersion = "iOS \(device.systemVersion)"
return DeviceInfo(deviceId: deviceId, model: model, osVersion: osVersion)
}
// MARK: - Cache
private func readCache(appKey: String, versionCode: Int, userId: String?) -> AppUpdateInfo? {
let key = "\(Self.cacheKey)_\(appKey)_\(versionCode)_\(userId ?? "")"
guard let data = UserDefaults.standard.dictionary(forKey: key) else { return nil }
guard let ts = data["ts"] as? TimeInterval else { return nil }
guard Date().timeIntervalSince1970 - ts < Self.cacheTtl else { return nil }
guard let infoData = data["data"] as? [String: Any] else { return nil }
return AppUpdateInfo(
needsUpdate: infoData["needsUpdate"] as? Bool ?? false,
versionName: infoData["versionName"] as? String,
versionCode: infoData["versionCode"] as? Int,
downloadUrl: infoData["downloadUrl"] as? String,
changeLog: infoData["changeLog"] as? String,
forceUpdate: infoData["forceUpdate"] as? Bool ?? false,
appStoreUrl: infoData["appStoreUrl"] as? String,
marketUrl: infoData["marketUrl"] as? String,
requiresLogin: infoData["requiresLogin"] as? Bool ?? false,
apkHash: infoData["apkHash"] as? String
)
}
private func writeCache(appKey: String, versionCode: Int, userId: String?, info: AppUpdateInfo) {
let key = "\(Self.cacheKey)_\(appKey)_\(versionCode)_\(userId ?? "")"
let data: [String: Any] = [
"ts": Date().timeIntervalSince1970,
"data": [
"needsUpdate": info.needsUpdate,
"versionName": info.versionName as Any,
"versionCode": info.versionCode as Any,
"downloadUrl": info.downloadUrl as Any,
"changeLog": info.changeLog as Any,
"forceUpdate": info.forceUpdate,
"appStoreUrl": info.appStoreUrl as Any,
"marketUrl": info.marketUrl as Any,
"requiresLogin": info.requiresLogin,
"apkHash": info.apkHash as Any,
] as [String: Any]
]
UserDefaults.standard.set(data, forKey: key)
}
}
// MARK: - Bundle Extension
private extension Bundle {
var versionCode: Int {
Int(infoDictionary?["CFBundleVersion"] as? String ?? "0") ?? 0
}
var versionName: String {
infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0"
}
}