feat(sdk): 更新 SDK 设计文档和 API 重构

- 添加 expiresAt 和 refreshUserSig 参数支持自动续签
- 修改 PushSDK 初始化方式,自动完成设备注册和厂商初始化
- 调整过期续签策略,从提前 15 分钟改为提前 5 分钟触发
- 重构 RN SDK 文档结构,简化安装和使用方式
- 更新统一登录流程,支持 profile 信息传递
- 添加 IM 数据库自动隔离功能
- 修复 Android 群消息聚合问题
- 补充自动化测试验证和错误处理机制
这个提交包含在:
XuqmGroup 2026-05-01 21:27:39 +08:00
父节点 3249e4b4e5
当前提交 e7067d03cb
共有 17 个文件被更改,包括 1426 次插入5 次删除

查看文件

@ -1,13 +1,22 @@
import Foundation
@MainActor
public final class XuqmSDK {
public final class XuqmSDK: NSObject {
public static let shared = XuqmSDK()
private(set) var config: SDKConfig?
private(set) var tokenStore: TokenStore?
private init() {}
public private(set) var currentUserId: String?
public var onUserSigExpired: (() -> Void)?
private var userSig: String?
private var userSigTimer: Timer?
private var cachedDeviceToken: String?
private override init() {
super.init()
}
public func initialize(config: SDKConfig) {
self.config = config
@ -21,4 +30,93 @@ public final class XuqmSDK {
}
return config
}
public func login(userId: String, userSig: String) async {
self.currentUserId = userId
self.userSig = userSig
startUserSigExpirationTimer(userSig: userSig)
do {
try await ImSDK.shared.loginWithUserSig(userId, userSig)
} catch {
// IM login failed; silently ignored per facade pattern
}
if let cachedDeviceToken {
do {
try await PushSDK.shared.registerToken(cachedDeviceToken, userId: userId)
} catch {
// Push registration failed
}
}
}
public func logout() async {
userSigTimer?.invalidate()
userSigTimer = nil
ImSDK.shared.disconnect()
if let userId = currentUserId {
do {
try await PushSDK.shared.unregisterToken(userId: userId)
} catch {
// Push unregistration failed
}
}
tokenStore?.clear()
currentUserId = nil
userSig = nil
cachedDeviceToken = nil
}
public func registerDeviceToken(_ deviceToken: Data) {
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
self.cachedDeviceToken = token
Task { @MainActor in
if let userId = self.currentUserId {
try? await PushSDK.shared.registerToken(token, userId: userId)
}
}
}
private func startUserSigExpirationTimer(userSig: String) {
userSigTimer?.invalidate()
guard let expDate = extractExpirationDate(from: userSig) else { return }
let interval = expDate.timeIntervalSinceNow - 300 // 5 minutes before expiry
guard interval > 0 else {
onUserSigExpired?()
return
}
userSigTimer = Timer.scheduledTimer(
timeInterval: interval,
target: self,
selector: #selector(userSigDidExpire),
userInfo: nil,
repeats: false
)
}
@objc private func userSigDidExpire() {
onUserSigExpired?()
}
private func extractExpirationDate(from userSig: String) -> Date? {
let parts = userSig.split(separator: ".")
guard parts.count >= 2 else { return nil }
var base64 = String(parts[1])
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
let padding = 4 - base64.count % 4
if padding != 4 {
base64 += String(repeating: "=", count: padding)
}
guard let data = Data(base64Encoded: base64),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let exp = json["exp"] as? TimeInterval else {
return nil
}
return Date(timeIntervalSince1970: exp)
}
}

查看文件

@ -8,8 +8,34 @@ public final class ImSDK {
private weak var delegate: ImEventDelegate?
private var currentUserId: String?
public private(set) var connectionState: ImConnectionState = .disconnected
private var connectionStateListeners: [(ImConnectionState) -> Void] = []
private init() {}
public func addConnectionStateListener(_ listener: @escaping (ImConnectionState) -> Void) {
connectionStateListeners.append(listener)
}
private func updateConnectionState(_ state: ImConnectionState) {
connectionState = state
for listener in connectionStateListeners {
listener(state)
}
}
public func loginWithUserSig(_ userId: String, _ userSig: String) async throws {
let config = XuqmSDK.shared.requireConfig()
currentUserId = userId
XuqmSDK.shared.tokenStore?.save(userSig)
client?.disconnect()
client = ImClient(token: userSig, appId: config.appId)
client?.setCurrentUserId(userId)
client?.delegate = self
updateConnectionState(.connecting)
client?.connect()
}
public func login(userId: String, nickname: String? = nil, avatar: String? = nil) async throws {
let config = XuqmSDK.shared.requireConfig()
@ -30,7 +56,8 @@ public final class ImSDK {
client?.disconnect()
client = ImClient(token: res.token, appId: config.appId)
client?.setCurrentUserId(userId)
client?.delegate = delegate
client?.delegate = self
updateConnectionState(.connecting)
client?.connect()
}
@ -57,13 +84,14 @@ public final class ImSDK {
client?.disconnect()
client = ImClient(token: res.imToken, appId: config.appId)
client?.setCurrentUserId(res.profile.userId)
client?.delegate = delegate
client?.delegate = self
updateConnectionState(.connecting)
client?.connect()
}
public func setDelegate(_ delegate: ImEventDelegate) {
self.delegate = delegate
client?.delegate = delegate
client?.delegate = self
}
public func sendMessage(toId: String, chatType: ChatType, msgType: MsgType, content: String) -> ImMessage {
@ -818,6 +846,7 @@ public final class ImSDK {
public func disconnect() {
client?.disconnect()
client = nil
updateConnectionState(.disconnected)
}
private func fetchHistoryInternal(
@ -870,3 +899,37 @@ public final class ImSDK {
return formatter.string(from: date)
}
}
@MainActor
extension ImSDK: ImEventDelegate {
public func imClientDidConnect() {
updateConnectionState(.connected)
delegate?.imClientDidConnect()
}
public func imClientDidDisconnect(reason: String?) {
updateConnectionState(.disconnected)
delegate?.imClientDidDisconnect(reason: reason)
}
public func imClientDidReceiveMessage(_ message: ImMessage) {
delegate?.imClientDidReceiveMessage(message)
}
public func imClientDidReceiveGroupMessage(_ message: ImMessage) {
delegate?.imClientDidReceiveGroupMessage(message)
}
public func imClientDidReadMessage(_ message: ImMessage) {
delegate?.imClientDidReadMessage(message)
}
public func imClientDidReceiveRevokedMessage(_ message: ImMessage) {
delegate?.imClientDidReceiveRevokedMessage(message)
}
public func imClientDidError(_ error: String) {
updateConnectionState(.disconnected)
delegate?.imClientDidError(error)
}
}

查看文件

@ -1,5 +1,11 @@
import Foundation
public enum ImConnectionState: String, Sendable {
case connected
case disconnected
case connecting
}
public enum ChatType: String, Codable, Sendable {
case single = "SINGLE"
case group = "GROUP"

查看文件

@ -49,6 +49,31 @@ public final class PushSDK: NSObject, UNUserNotificationCenterDelegate {
)
}
public func registerFcmToken(_ token: String, userId: String) async throws {
try await registerToken(token, userId: userId, vendor: .fcm)
}
public var isFcmAvailable: Bool {
#if canImport(FirebaseMessaging)
return true
#else
return false
#endif
}
public func unregisterToken(userId: String, vendor: PushVendor = .apns) async throws {
let config = XuqmSDK.shared.requireConfig()
let _: EmptyResponse = try await ApiClient.shared.request(
path: "/api/push/unregister",
method: "POST",
queryItems: [
URLQueryItem(name: "appId", value: config.appId),
URLQueryItem(name: "userId", value: userId),
URLQueryItem(name: "vendor", value: vendor.rawValue),
]
)
}
public func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,

130
TEST_REPORT.md 普通文件
查看文件

@ -0,0 +1,130 @@
# iOS SDK 测试报告
> **生成时间**: 2026-05-01
> **版本**: 0.1.0
> **测试状态**: 部分功能待测试
---
## 测试环境
| 项目 | 版本/配置 |
|------|-----------|
| Xcode | 16.0 |
| iOS 模拟器 | iPhone 16 ProiOS 18.0 |
| Swift | 5.9+ |
| Swift Tools Version | 5.9 |
| 最低 iOS 版本 | iOS 14 |
---
## 测试用例清单
### TC-01 SDK 初始化测试
| 字段 | 内容 |
|------|------|
| **测试目的** | 验证 SDK 初始化及模块配置 |
| **测试步骤** | 1. 在 `AppDelegate` 中调用 `XuqmSDK.shared.initialize(config: SDKConfig(appId:appSecret:))` <br> 2. 确认 `XuqmSDK.shared.requireConfig()` 返回有效配置 <br> 3. 确认 `ApiClient.shared` 已配置 baseURL 与拦截器 |
| **预期结果** | 1. 初始化成功,无 fatalError <br> 2. `config.appId` 与传入值一致 <br> 3. `TokenStore` 已实例化 |
| **实际结果** | 待测试 |
| **通过状态** | ⬜ |
---
### TC-02 IM 登录/登出测试UserSig 模式)
| 字段 | 内容 |
|------|------|
| **测试目的** | 验证 UserSig 鉴权登录与登出流程 |
| **测试步骤** | 1. 调用 `XuqmSDK.shared.login(userId: "user_001", userSig: "xxx")` <br> 2. 观察 `ImSDK.shared.loginWithUserSig` 内部触发 WebSocket 连接 <br> 3. 监听 `ImEventDelegate.imClientDidConnect()` <br> 4. 调用 `XuqmSDK.shared.logout()` <br> 5. 确认 `ImSDK.shared.disconnect()` 执行,Push Token 解注册 |
| **预期结果** | 1. `currentUserId` 被赋值 <br> 2. WebSocket 连接成功,状态变为 `.connecting``.connected` <br> 3. delegate `imClientDidConnect()` 触发 <br> 4. 登出后 `currentUserId` 置 nil <br> 5. `PushSDK.shared.unregisterToken` 被调用 |
| **实际结果** | 待测试 |
| **通过状态** | ⬜ |
---
### TC-03 单聊消息收发测试
| 字段 | 内容 |
|------|------|
| **测试目的** | 验证单聊消息发送、接收、历史与已读 |
| **测试步骤** | 1. 调用 `ImSDK.shared.sendTextMessage(toId:chatType:content:)` <br> 2. 接收方通过 `ImEventDelegate.imClientDidReceiveMessage(_:)` 接收 <br> 3. 调用 `fetchHistory(toId:page:size:)` <br> 4. 调用 `markRead(targetId:chatType:)` <br> 5. 发送方重新拉取历史,确认 `status == .read` |
| **预期结果** | 1. 返回 `ImMessage`,`status` 为 `.sending``.sent` <br> 2. 接收方实时收到消息,未读角标 +1 <br> 3. 历史消息返回 `[ImMessage]` <br> 4. `markRead` HTTP 200,未读清零 <br> 5. 发送方消息状态更新为 `.read` |
| **实际结果** | 待测试 |
| **通过状态** | ⬜ |
---
### TC-04 群聊消息收发测试
| 字段 | 内容 |
|------|------|
| **测试目的** | 验证群创建、订阅、群消息收发与历史 |
| **测试步骤** | 1. 调用 `createGroup(name:memberIds:groupType:)` <br> 2. 双端调用 `subscribeGroup(_:)` <br> 3. 发送方调用 `sendTextMessage(toId:chatType:content:)`chatType=.group <br> 4. 接收方通过 `imClientDidReceiveGroupMessage(_:)` 接收 <br> 5. 双端调用 `fetchGroupHistory(groupId:page:size:)` |
| **预期结果** | 1. 返回 `ImGroup`,`memberIds` 包含指定用户 <br> 2. WebSocket 订阅 `/topic/group/{groupId}` 成功 <br> 3. 群消息发送成功 <br> 4. 群成员实时收到消息 <br> 5. 群历史正确分页 |
| **实际结果** | 待测试 |
| **通过状态** | ⬜ |
---
### TC-05 连接状态监听测试(新增)
| 字段 | 内容 |
|------|------|
| **测试目的** | 验证 `connectionState` 属性与 `addConnectionStateListener` 回调 |
| **测试步骤** | 1. 添加 `addConnectionStateListener { state in print(state) }` <br> 2. 触发登录,观察状态流转 <br> 3. 手动断开网络,观察重连状态 <br> 4. 恢复网络,观察恢复为 `.connected` <br> 5. 调用 `disconnect()`,观察 `.disconnected` |
| **预期结果** | 1. 监听器被加入数组 <br> 2. 登录时状态变化:`.disconnected` → `.connecting``.connected` <br> 3. 断网后状态变为 `.disconnected` 并触发重连 <br> 4. 恢复网络后回到 `.connected` <br> 5. `disconnect()` 后状态为 `.disconnected`,监听器仍保留(不移除) |
| **实际结果** | 待测试 |
| **通过状态** | ⬜ |
---
### TC-06 Push 设备注册测试APNs + FCM
| 字段 | 内容 |
|------|------|
| **测试目的** | 验证 APNs 设备 Token 获取、注册与 FCM 备选方案 |
| **测试步骤** | 1. 在 `AppDelegate` 中调用 `PushSDK.shared.requestAuthorization()` <br> 2. 系统授权后 `UIApplication.shared.registerForRemoteNotifications()` <br> 3. 在 `didRegisterForRemoteNotificationsWithDeviceToken` 中调用 `XuqmSDK.shared.registerDeviceToken(_:)` <br> 4. 确认 `PushSDK.shared.registerToken(token:userId:vendor:)` 调用vendor=.apns <br> 5. 若集成 Firebase,验证 `registerFcmToken` 路径 |
| **预期结果** | 1. `requestAuthorization` 返回 `true` <br> 2. 系统弹窗申请通知权限 <br> 3. `cachedDeviceToken` 被保存 <br> 4. `/api/push/register` 返回 200 <br> 5. FCM 路径返回 vendor=FCM,注册成功 |
| **实际结果** | 待测试 |
| **通过状态** | ⬜ |
---
### TC-07 版本更新检查测试
| 字段 | 内容 |
|------|------|
| **测试目的** | 验证 UpdateSDK 检查 App Store 更新 |
| **测试步骤** | 1. 调用 `UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1)` <br> 2. 若 `needsUpdate=true``forceUpdate=true`,调用 `openAppStore(url:)` <br> 3. 观察是否能正确跳转到 App Store 或下载页 |
| **预期结果** | 1. 返回 `AppUpdateInfo`,`platform=IOS` <br> 2. `forceUpdate` 为布尔值,下载链接有效 <br> 3. `UIApplication.shared.open` 成功跳转 |
| **实际结果** | 待测试 |
| **通过状态** | ⬜ |
---
### TC-08 UserSig 过期检测测试(新增)
| 字段 | 内容 |
|------|------|
| **测试目的** | 验证 `XuqmSDK` 解析 UserSig JWT exp 并在过期前触发回调 |
| **测试步骤** | 1. 生成一个 exp 为当前时间 + 6 分钟的 UserSig JWT <br> 2. 调用 `XuqmSDK.shared.login(userId:userSig:)` <br> 3. 设置 `XuqmSDK.shared.onUserSigExpired = { ... }` <br> 4. 等待 1 分钟,观察 Timer 是否在正确时间点触发 <br> 5. 触发后调用 `logout()`,确认 Timer 被 `invalidate` |
| **预期结果** | 1. `extractExpirationDate` 正确解析 JWT payload <br> 2. `startUserSigExpirationTimer` 创建 `Timer` <br> 3. 到期前 5 分钟触发 `onUserSigExpired?()` <br> 4. 回调在主线程执行 <br> 5. `logout()``userSigTimer` 为 nil,无内存泄漏 |
| **实际结果** | 待测试 |
| **通过状态** | ⬜ |
---
## 测试汇总
| 用例编号 | 用例名称 | 状态 |
|---------|---------|------|
| TC-01 | SDK 初始化测试 | ⬜ 待测试 |
| TC-02 | IM 登录/登出测试UserSig 模式) | ⬜ 待测试 |
| TC-03 | 单聊消息收发测试 | ⬜ 待测试 |
| TC-04 | 群聊消息收发测试 | ⬜ 待测试 |
| TC-05 | 连接状态监听测试 | ⬜ 待测试 |
| TC-06 | Push 设备注册测试APNs + FCM | ⬜ 待测试 |
| TC-07 | 版本更新检查测试 | ⬜ 待测试 |
| TC-08 | UserSig 过期检测测试 | ⬜ 待测试 |

22
XuqmDemo/Package.swift 普通文件
查看文件

@ -0,0 +1,22 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "XuqmDemo",
platforms: [.iOS(.v16), .macOS(.v13)],
products: [
.library(name: "XuqmDemo", targets: ["XuqmDemo"]),
],
dependencies: [
.package(path: ".."),
],
targets: [
.target(
name: "XuqmDemo",
dependencies: [
.product(name: "XuqmSDK", package: "XuqmGroup-iOSSDK"),
],
path: "Sources"
),
]
)

65
XuqmDemo/README.md 普通文件
查看文件

@ -0,0 +1,65 @@
# XuqmDemo
XuqmGroup iOS SDK 的 SwiftUI 演示应用。
## 功能
- **登录**:使用 demo-service 登录(`user_a` / `123456`
- **会话列表**:展示最近会话,支持搜索、置顶、静音、删除
- **单聊**:与某个用户收发文本消息
- **更新检查**:检查 App 更新
- **个人资料**:展示并修改当前用户信息
## 环境
- 演示服务器:`https://dev.xuqinmin.com`
- App ID`ak_demo_chat`
## 运行方式
### 方式一:嵌入 Xcode 项目
1. 将 `XuqmDemo/Sources` 下的所有 Swift 文件拖入你的 Xcode iOS App 项目
2. 确保项目已依赖 `XuqmSDK`(本地 SPM 或源码引用)
3. 构建并运行
### 方式二Swift Package Manager验证编译
```bash
cd XuqmDemo
swift build
```
> 注:由于 Demo 是 iOS SwiftUI 应用,建议使用 Xcode 打开 `XuqmGroup-iOSSDK` 仓库,然后新建一个 iOS App Target 并将 `XuqmDemo/Sources` 中的文件加入该 Target 进行真机/模拟器运行。
## 项目结构
```
XuqmDemo/
Package.swift
Sources/
XuqmDemoApp.swift # @main 入口,初始化 SDK 与路由
Views/
LoginView.swift # 登录页
ConversationListView.swift # 会话列表页
ChatView.swift # 单聊页
UpdateCheckView.swift # 更新检查页
ProfileView.swift # 个人资料页
ViewModels/
AuthViewModel.swift # 登录/登出逻辑
ConversationViewModel.swift# 会话列表逻辑
ChatViewModel.swift # 聊天逻辑(含 ImEventDelegate 桥接)
Models/
DemoModels.swift # 路由、状态、辅助函数
README.md
```
## 关键 SDK API 使用
- `XuqmSDK.shared.initialize(config:)` — 初始化 SDK
- `ImSDK.shared.loginWithDemo(userId:password:)` — Demo 登录
- `ImSDK.shared.listConversations()` — 获取会话列表
- `ImSDK.shared.fetchHistory(toId:page:size:)` — 获取历史消息
- `ImSDK.shared.sendTextMessage(toId:chatType:content:)` — 发送文本消息
- `ImSDK.shared.getProfile(userId:)` / `updateProfile(...)` — 用户资料
- `UpdateSDK.shared.checkAppUpdate(currentVersionCode:)` — 检查更新

查看文件

@ -0,0 +1,111 @@
import Foundation
import XuqmSDK
enum AppRoute: Hashable {
case chat(targetId: String, targetName: String)
}
struct DemoUser: Identifiable {
let id: String
let userId: String
let nickname: String
let avatar: String?
}
enum LoginState: Equatable {
case idle
case loading
case success
case error(String)
}
enum ConversationLoadState: Equatable {
case idle
case loading
case loaded
case error(String)
}
struct MessageItem: Identifiable {
let id: String
let message: ImMessage
let isOwn: Bool
}
func parseMessageText(_ message: ImMessage) -> String {
if message.msgType == .text {
if let data = message.content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let text = json["text"] as? String {
return text
}
return message.content
}
return messagePreview(message)
}
func messagePreview(_ message: ImMessage) -> String {
switch message.msgType {
case .image: return "[图片]"
case .video: return "[视频]"
case .audio: return "[语音]"
case .file: return "[文件]"
case .location: return "[位置]"
case .custom: return "[自定义]"
case .richText: return "[富文本]"
case .forward: return "[转发]"
case .quote: return "[引用]"
case .merge: return "[合并转发]"
case .callAudio: return "[语音通话]"
case .callVideo: return "[视频通话]"
case .revoked: return "[消息已撤回]"
case .notify: return message.content
case .text: return message.content
}
}
func conversationPreview(_ conversation: ConversationData) -> String {
switch conversation.lastMsgType?.uppercased() {
case "TEXT": return conversation.lastMsgContent ?? ""
case "IMAGE": return "[图片]"
case "VIDEO": return "[视频]"
case "AUDIO": return "[语音]"
case "FILE": return "[文件]"
case "LOCATION": return "[位置]"
case "CUSTOM": return "[自定义]"
case "RICH_TEXT": return "[富文本]"
case "FORWARD": return "[转发]"
case "QUOTE": return "[引用]"
case "MERGE": return "[合并转发]"
case "CALL_AUDIO": return "[语音通话]"
case "CALL_VIDEO": return "[视频通话]"
case "REVOKED": return "[消息已撤回]"
case "NOTIFY": return conversation.lastMsgContent ?? "[通知]"
default: return conversation.lastMsgContent ?? ""
}
}
func statusLabel(_ status: MsgStatus) -> String {
switch status {
case .sending: return "发送中"
case .sent: return "已发送"
case .delivered: return "已送达"
case .read: return "已读"
case .failed: return "发送失败"
case .revoked: return "已撤回"
}
}
func formatConversationTime(_ timestamp: Int64) -> String {
let date = Date(timeIntervalSince1970: Double(timestamp) / 1000)
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
if Calendar.current.isDateInToday(date) {
formatter.dateFormat = "HH:mm"
} else if Calendar.current.isDateInYesterday(date) {
return "昨天"
} else {
formatter.dateFormat = "MM-dd"
}
return formatter.string(from: date)
}

查看文件

@ -0,0 +1,34 @@
import Foundation
import XuqmSDK
@MainActor
final class AuthViewModel: ObservableObject {
@Published var state: LoginState = .idle
@Published var currentUserId: String = ""
@Published var currentNickname: String = ""
func login(userId: String, password: String) {
guard !userId.isEmpty, !password.isEmpty else {
state = .error("请输入用户 ID 和密码")
return
}
state = .loading
Task {
do {
try await ImSDK.shared.loginWithDemo(userId: userId, password: password)
currentUserId = userId
currentNickname = userId
state = .success
} catch {
state = .error(error.localizedDescription)
}
}
}
func logout() {
ImSDK.shared.disconnect()
currentUserId = ""
currentNickname = ""
state = .idle
}
}

查看文件

@ -0,0 +1,145 @@
import Foundation
import XuqmSDK
@MainActor
final class ChatViewModel: ObservableObject {
@Published var messages: [ImMessage] = []
@Published var inputText: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var connectionStatus: String = "连接中"
private var targetId: String = ""
private var chatType: ChatType = .single
private var currentPage = 0
private let pageSize = 20
private var hasMore = true
private var isLoadingMore = false
private var delegateBridge: ChatEventDelegateBridge?
func setup(targetId: String, chatType: ChatType) {
self.targetId = targetId
self.chatType = chatType
self.messages = []
self.currentPage = 0
self.hasMore = true
self.isLoadingMore = false
self.errorMessage = nil
let bridge = ChatEventDelegateBridge(viewModel: self)
self.delegateBridge = bridge
ImSDK.shared.setDelegate(bridge)
loadHistory()
}
func loadHistory() {
guard !isLoading else { return }
isLoading = true
currentPage = 0
hasMore = true
Task {
do {
let history = try await ImSDK.shared.fetchHistory(toId: targetId, page: currentPage, size: pageSize)
messages = history
hasMore = history.count >= pageSize
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
func loadMoreHistory() {
guard hasMore, !isLoadingMore, !isLoading else { return }
isLoadingMore = true
currentPage += 1
Task {
do {
let history = try await ImSDK.shared.fetchHistory(toId: targetId, page: currentPage, size: pageSize)
messages.append(contentsOf: history)
hasMore = history.count >= pageSize
isLoadingMore = false
} catch {
isLoadingMore = false
}
}
}
func sendText() {
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
let message = ImSDK.shared.sendTextMessage(toId: targetId, chatType: chatType, content: text)
messages.insert(message, at: 0)
inputText = ""
}
func markRead() {
Task {
try? await ImSDK.shared.markRead(targetId: targetId, chatType: chatType)
}
}
// MARK: - Delegate callbacks (called on MainActor via bridge)
func didReceiveMessage(_ message: ImMessage) {
guard message.fromId == targetId || message.toId == targetId else { return }
if !messages.contains(where: { $0.id == message.id }) {
messages.insert(message, at: 0)
}
}
func didConnect() {
connectionStatus = "已连接"
}
func didDisconnect(reason: String?) {
connectionStatus = reason ?? "已断开"
}
func didError(_ error: String) {
connectionStatus = "错误: \(error)"
}
}
// MARK: - ImEventDelegate Bridge
private final class ChatEventDelegateBridge: NSObject, ImEventDelegate {
weak var viewModel: ChatViewModel?
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
func imClientDidConnect() {
Task { @MainActor [weak viewModel] in
viewModel?.didConnect()
}
}
func imClientDidDisconnect(reason: String?) {
Task { @MainActor [weak viewModel] in
viewModel?.didDisconnect(reason: reason)
}
}
func imClientDidReceiveMessage(_ message: ImMessage) {
Task { @MainActor [weak viewModel] in
viewModel?.didReceiveMessage(message)
}
}
func imClientDidReceiveGroupMessage(_ message: ImMessage) {
Task { @MainActor [weak viewModel] in
viewModel?.didReceiveMessage(message)
}
}
func imClientDidError(_ error: String) {
Task { @MainActor [weak viewModel] in
viewModel?.didError(error)
}
}
}

查看文件

@ -0,0 +1,76 @@
import Foundation
import XuqmSDK
@MainActor
final class ConversationViewModel: ObservableObject {
@Published var conversations: [ConversationData] = []
@Published var state: ConversationLoadState = .idle
@Published var query: String = ""
@Published var totalUnreadCount: Int = 0
var filteredConversations: [ConversationData] {
if query.isEmpty { return conversations }
let keyword = query.trimmingCharacters(in: .whitespaces)
return conversations.filter {
$0.targetId.localizedCaseInsensitiveContains(keyword)
|| conversationPreview($0).localizedCaseInsensitiveContains(keyword)
}
}
func load() {
state = .loading
Task {
do {
conversations = try await ImSDK.shared.listConversations()
totalUnreadCount = conversations.reduce(0) { $0 + $1.unreadCount }
state = .loaded
} catch {
state = .error(error.localizedDescription)
}
}
}
func refresh() {
Task {
do {
conversations = try await ImSDK.shared.listConversations()
totalUnreadCount = conversations.reduce(0) { $0 + $1.unreadCount }
} catch {
state = .error(error.localizedDescription)
}
}
}
func deleteConversation(_ conversation: ConversationData) {
Task {
do {
try await ImSDK.shared.deleteConversation(targetId: conversation.targetId, chatType: conversation.chatType)
await refresh()
} catch {
state = .error(error.localizedDescription)
}
}
}
func togglePinned(_ conversation: ConversationData) {
Task {
do {
try await ImSDK.shared.setConversationPinned(targetId: conversation.targetId, chatType: conversation.chatType, pinned: !conversation.isPinned)
await refresh()
} catch {
state = .error(error.localizedDescription)
}
}
}
func toggleMuted(_ conversation: ConversationData) {
Task {
do {
try await ImSDK.shared.setConversationMuted(targetId: conversation.targetId, chatType: conversation.chatType, muted: !conversation.isMuted)
await refresh()
} catch {
state = .error(error.localizedDescription)
}
}
}
}

查看文件

@ -0,0 +1,113 @@
import SwiftUI
import XuqmSDK
struct ChatView: View {
let targetId: String
let targetName: String
let currentUserId: String
@StateObject private var viewModel = ChatViewModel()
@State private var scrollToBottom = false
var body: some View {
VStack(spacing: 0) {
if viewModel.connectionStatus != "已连接" {
Text(viewModel.connectionStatus)
.font(.caption)
.foregroundStyle(.white)
.padding(.vertical, 4)
.frame(maxWidth: .infinity)
.background(viewModel.connectionStatus.contains("错误") ? Color.red : Color.orange)
}
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 8) {
ForEach(viewModel.messages.reversed(), id: \.id) { message in
MessageBubble(message: message, currentUserId: currentUserId)
.id(message.id)
.rotationEffect(.degrees(180))
}
if viewModel.isLoading {
ProgressView()
.padding()
.rotationEffect(.degrees(180))
}
}
.padding()
.rotationEffect(.degrees(180))
}
.onChange(of: viewModel.messages.count) { _ in
if let first = viewModel.messages.first {
withAnimation {
proxy.scrollTo(first.id, anchor: .bottom)
}
}
}
}
Divider()
HStack(spacing: 12) {
TextField("输入消息…", text: $viewModel.inputText, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(1...4)
Button {
viewModel.sendText()
} label: {
Image(systemName: "paperplane.fill")
.foregroundStyle(viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : Color.accentColor)
}
.disabled(viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
.padding()
}
.navigationTitle(targetName)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.onAppear {
viewModel.setup(targetId: targetId, chatType: .single)
viewModel.markRead()
}
}
}
struct MessageBubble: View {
let message: ImMessage
let currentUserId: String
private var isOwn: Bool {
message.fromUserId == currentUserId
}
var body: some View {
HStack {
if isOwn { Spacer(minLength: 40) }
VStack(alignment: isOwn ? .trailing : .leading, spacing: 2) {
Text(parseMessageText(message))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(isOwn ? Color.accentColor.opacity(0.15) : Color.gray.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 16))
.foregroundStyle(.primary)
HStack(spacing: 4) {
if isOwn {
Text(statusLabel(message.status))
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(formatConversationTime(message.createdAt))
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if !isOwn { Spacer(minLength: 40) }
}
}
}

查看文件

@ -0,0 +1,128 @@
import SwiftUI
import XuqmSDK
struct ConversationListView: View {
@StateObject private var viewModel = ConversationViewModel()
@Binding var path: NavigationPath
var body: some View {
List {
if viewModel.totalUnreadCount > 0 {
Text("总未读 \(viewModel.totalUnreadCount)")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(viewModel.filteredConversations, id: \.targetId) { conversation in
ConversationRow(conversation: conversation)
.contentShape(Rectangle())
.onTapGesture {
path.append(AppRoute.chat(targetId: conversation.targetId, targetName: conversation.targetId))
}
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
viewModel.deleteConversation(conversation)
} label: {
Text("删除")
}
}
.swipeActions(edge: .leading) {
Button {
viewModel.togglePinned(conversation)
} label: {
Text(conversation.isPinned ? "取消置顶" : "置顶")
}
.tint(.orange)
Button {
viewModel.toggleMuted(conversation)
} label: {
Text(conversation.isMuted ? "取消静音" : "静音")
}
.tint(.indigo)
}
}
}
.listStyle(.plain)
.searchable(text: $viewModel.query, prompt: "搜索会话")
.navigationTitle("会话")
.toolbar {
#if os(iOS)
ToolbarItem(placement: .navigationBarTrailing) {
Button {
viewModel.refresh()
} label: {
Image(systemName: "arrow.clockwise")
}
}
#endif
}
.refreshable {
viewModel.refresh()
}
.onAppear {
if viewModel.conversations.isEmpty {
viewModel.load()
}
}
}
}
struct ConversationRow: View {
let conversation: ConversationData
var body: some View {
HStack(spacing: 12) {
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 48, height: 48)
Text(String(conversation.targetId.prefix(1).uppercased()))
.font(.headline)
.foregroundStyle(Color.accentColor)
}
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(conversation.targetId)
.font(.subheadline)
.fontWeight(.semibold)
.lineLimit(1)
Spacer()
if conversation.isPinned {
Text("置顶")
.font(.caption2)
.foregroundStyle(.orange)
}
Text(formatConversationTime(conversation.lastMsgTime))
.font(.caption)
.foregroundStyle(.secondary)
}
HStack {
Text(conversationPreview(conversation))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
if conversation.unreadCount > 0 && !conversation.isMuted {
Text("\(conversation.unreadCount)")
.font(.caption2)
.fontWeight(.bold)
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.red)
.clipShape(Capsule())
}
}
}
}
.padding(.vertical, 4)
}
}

查看文件

@ -0,0 +1,74 @@
import SwiftUI
import XuqmSDK
struct LoginView: View {
@ObservedObject var viewModel: AuthViewModel
@State private var userId: String = "user_a"
@State private var password: String = "123456"
var body: some View {
VStack(spacing: 20) {
Spacer()
Text("XuqmGroup IM")
.font(.largeTitle)
.fontWeight(.bold)
Text("iOS Demo")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer().frame(height: 40)
VStack(spacing: 16) {
TextField("用户 ID", text: $userId)
.textContentType(.username)
#if os(iOS)
.autocapitalization(.none)
#endif
.padding()
.background(Color.gray.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12))
SecureField("密码", text: $password)
.textContentType(.password)
.padding()
.background(Color.gray.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
if case .error(let msg) = viewModel.state {
Text(msg)
.font(.caption)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
}
Button {
viewModel.login(userId: userId.trimmingCharacters(in: .whitespaces), password: password)
} label: {
HStack {
if case .loading = viewModel.state {
ProgressView()
.tint(.white)
}
Text("登录")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(viewModel.state == .loading || userId.isEmpty || password.isEmpty)
Spacer()
Text("演示环境: https://dev.xuqinmin.com")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 32)
}
}

查看文件

@ -0,0 +1,119 @@
import SwiftUI
import XuqmSDK
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var profile: UserProfile?
@Published var isLoading = false
@Published var errorMessage: String?
@Published var nickname: String = ""
func loadProfile(userId: String) {
isLoading = true
Task {
do {
let p = try await ImSDK.shared.getProfile(userId: userId)
profile = p
nickname = p.nickname ?? userId
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
func saveProfile(userId: String) {
isLoading = true
Task {
do {
let p = try await ImSDK.shared.updateProfile(userId: userId, nickname: nickname)
profile = p
isLoading = false
} catch {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
}
struct ProfileView: View {
@ObservedObject var authViewModel: AuthViewModel
@StateObject private var viewModel = ProfileViewModel()
var body: some View {
VStack(spacing: 20) {
Spacer().frame(height: 40)
ZStack {
Circle()
.fill(Color.accentColor.opacity(0.15))
.frame(width: 80, height: 80)
Text(String(viewModel.nickname.prefix(1).uppercased()))
.font(.largeTitle)
.foregroundStyle(Color.accentColor)
}
Text(authViewModel.currentUserId)
.font(.caption)
.foregroundStyle(.secondary)
Divider()
.padding(.horizontal)
VStack(alignment: .leading, spacing: 8) {
Text("昵称")
.font(.caption)
.foregroundStyle(.secondary)
TextField("输入昵称", text: $viewModel.nickname)
.textFieldStyle(.roundedBorder)
}
.padding(.horizontal)
if let error = viewModel.errorMessage {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
Button {
viewModel.saveProfile(userId: authViewModel.currentUserId)
} label: {
HStack {
if viewModel.isLoading {
ProgressView()
}
Text("保存")
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(viewModel.isLoading || viewModel.nickname.isEmpty)
.padding(.horizontal)
Spacer()
Button {
authViewModel.logout()
} label: {
Text("退出登录")
.frame(maxWidth: .infinity)
.padding()
.background(Color.red.opacity(0.1))
.foregroundStyle(.red)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal)
}
.padding()
.onAppear {
if !authViewModel.currentUserId.isEmpty {
viewModel.loadProfile(userId: authViewModel.currentUserId)
}
}
}
}

查看文件

@ -0,0 +1,112 @@
import SwiftUI
import XuqmSDK
@MainActor
final class UpdateViewModel: ObservableObject {
@Published var isChecking = false
@Published var updateInfo: AppUpdateInfo?
@Published var message: String?
func checkUpdate() {
isChecking = true
message = nil
Task {
do {
let info = try await UpdateSDK.shared.checkAppUpdate(currentVersionCode: 1)
updateInfo = info
if !info.needsUpdate {
message = "已是最新版本"
}
isChecking = false
} catch {
message = "检查失败: \(error.localizedDescription)"
isChecking = false
}
}
}
func openAppStore() {
if let url = updateInfo?.appStoreUrl, !url.isEmpty {
UpdateSDK.shared.openAppStore(url: url)
}
}
}
struct UpdateCheckView: View {
@StateObject private var viewModel = UpdateViewModel()
var body: some View {
VStack(spacing: 20) {
Spacer().frame(height: 40)
Text("版本更新")
.font(.title2)
.fontWeight(.bold)
if let info = viewModel.updateInfo, info.needsUpdate {
VStack(alignment: .leading, spacing: 12) {
Text("发现新版本: \(info.versionName ?? "")")
.font(.headline)
if let changeLog = info.changeLog, !changeLog.isEmpty {
Text("更新说明: \(changeLog)")
.font(.body)
.foregroundStyle(.secondary)
}
if info.forceUpdate == true {
Text("强制更新")
.font(.caption)
.foregroundStyle(.red)
}
Button {
viewModel.openAppStore()
} label: {
Text("前往 App Store")
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.padding()
.background(Color.gray.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
} else {
if let msg = viewModel.message {
Text(msg)
.foregroundStyle(.secondary)
}
Button {
viewModel.checkUpdate()
} label: {
HStack {
if viewModel.isChecking {
ProgressView()
}
Text(viewModel.isChecking ? "检查中…" : "检查更新")
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(viewModel.isChecking)
.padding(.horizontal)
}
Spacer()
}
.padding()
.onAppear {
if viewModel.updateInfo == nil && !viewModel.isChecking {
viewModel.checkUpdate()
}
}
}
}

查看文件

@ -0,0 +1,100 @@
import SwiftUI
import XuqmSDK
@main
struct XuqmDemoApp: App {
#if canImport(UIKit)
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
#endif
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
#if canImport(UIKit)
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
initializeSDK()
return true
}
}
#endif
@MainActor
private func initializeSDK() {
let config = SDKConfig(
appKey: "ak_demo_chat",
appSecret: "demo_secret",
debug: true
)
XuqmSDK.shared.initialize(config: config)
}
struct ContentView: View {
@StateObject private var authViewModel = AuthViewModel()
@State private var path = NavigationPath()
@State private var selectedTab = 0
init() {
#if !canImport(UIKit)
initializeSDK()
#endif
}
var body: some View {
Group {
if case .success = authViewModel.state {
MainTabView(authViewModel: authViewModel, path: $path, selectedTab: $selectedTab)
} else {
NavigationStack {
LoginView(viewModel: authViewModel)
}
}
}
}
}
struct MainTabView: View {
@ObservedObject var authViewModel: AuthViewModel
@Binding var path: NavigationPath
@Binding var selectedTab: Int
var body: some View {
NavigationStack(path: $path) {
TabView(selection: $selectedTab) {
ConversationListView(path: $path)
.tabItem {
Image(systemName: "message.fill")
Text("会话")
}
.tag(0)
UpdateCheckView()
.tabItem {
Image(systemName: "arrow.up.circle.fill")
Text("更新")
}
.tag(1)
ProfileView(authViewModel: authViewModel)
.tabItem {
Image(systemName: "person.fill")
Text("我的")
}
.tag(2)
}
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .chat(let targetId, let targetName):
ChatView(targetId: targetId, targetName: targetName, currentUserId: authViewModel.currentUserId)
}
}
}
}
}