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>
这个提交包含在:
父节点
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"
|
||||
}
|
||||
}
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户