主题
iOS 从零接入晞晗IM
这份文档只面向 iOS App 开发者。服务端部署由运维人员按 XHIM Server 文档 独立完成,本页只包含客户端工程 步骤。
购买 XHIM 后,iOS 开发者不需要 XHIM 服务端源码、Docker、数据库密码、短期 Token、Endpoint 私钥或 XHIM 的 Apple 证书。你只需要 SDK 包、可访问的 Server URL、当前 User ID 和一个测试 Conversation ID。
跑通第一条消息后,所有公开方法、事件、模型和错误请查 SDK API Reference,不需要从 C ABI 猜用法。
最短接入路径:
text
添加 XHIM + XHIMSwiftUI
→ 填 Server URL 和 User ID
→ sendTextDevelopment/演示环境最小代码只有:
swift
XHIMClient.connect(
server: "http://192.168.2.250:18080",
userID: "alice",
onConnecting: {
print("正在连接")
},
onConnectSuccess: { client in
print("连接成功", client)
},
onConnectFailure: { error in
print("连接失败", error.code, error.message)
}
)connect 会自动完成:
text
读取 Server 公开配置
→ 取得默认 App ID 和 Endpoint 验签公钥
→ 创建账号隔离的加密数据库
→ 启动 C++ 内核
→ 完成 Development 测试登录
→ 连接 WebSocket、增量同步
→ 自动保持登录状态SDK 自动处理测试登录、连接、同步和续期,App 页面不处理 XHIM 登录票据。
1. 开始前领取四项公开信息
开始写 iOS 代码前,只需要服务端负责人交付:
server:可由手机访问的 XHIM Server 地址,例如https://im-test.customer.com;appID:租户 App ID;未指定时使用公开配置的默认值;userID:开发测试账号,例如alice;peerUserID:对端测试账号,例如bob。
Development 联调时,服务端负责人应确保测试账号可以直接登录。iOS 开发者无需 了解服务端部署形态。公开 App 配置和 Endpoint 验签信息由 SDK 自动读取。
如果上述四项还未准备好,请把 服务端交付客户端联调信息 发给服务端负责人;不要在 iOS 工程里补做服务端部署。
2. 新建 Xcode 工程
- 打开 Xcode,选择
File → New → Project...。 - 选择
iOS → App。 - Interface 选择
SwiftUI,Language 选择Swift。 - Minimum Deployment 设为 iOS 15 或更高。
- 在
Signing & Capabilities选择你自己的 Apple Team。
XHIM SDK 接入不要求你提供证书。Apple Team 只用于运行或发布你自己的 App。
3. 添加 SDK
XHIM 同时支持 CocoaPods 和 Swift Package Manager。已有 CocoaPods 工程建议 继续使用 CocoaPods;新工程可任选一种,不要在同一个 Target 中重复安装。
CocoaPods
完整的 Podfile、本地试用包和私有 Specs 仓库接入步骤见 iOS CocoaPods 从零接入。
最小 Podfile:
ruby
source 'git@git.xihansoftware.com:laowang/xhim-specs.git'
source 'https://cdn.cocoapods.org/'
platform :ios, '15.0'
target 'YourApp' do
use_frameworks! :linkage => :static
pod 'XHIM', '= 0.1.0-dev.2'
pod 'XHIMSwiftUI', '= 0.1.0-dev.2'
end0.1.0-dev.2 是本轮生成并通过隔离消费者构建的受控 Development 快照,必须 精确锁定;发行负责人登记到私有 Specs 前,先使用同版本本地 XHIMSwift-development 包。CocoaPods 的 ~> 0.1 不会自动选择 prerelease。 首个通过商业门禁的正式版本发布后,再按其 Release Manifest 改用稳定版本范围。
Swift Package Manager:当前本地 Development 包
在 Xcode 中选择:
text
File
→ Add Package Dependencies...
→ Add Local...
→ 选择 build-ios-development/distribution/XHIMSwift-development给 App Target 勾选:
XHIM:登录、消息、同步、媒体和本地数据库;XHIMSwiftUI:聊天页面、附件入口和主题组件。
不要单独拖入 .a、C++ 头文件、SQLite 或 SQLCipher。它们已经封装在 XHIMCore.xcframework 中。
Swift Package Manager:商业发行包
正式销售时客户添加受控 Swift Package 仓库地址,选择同样的两个 Product。 客户得到的是二进制 XCFramework 和公开 Swift API,不会得到 C++ 内核源码。
4. 局域网 Development 权限
使用 http://局域网IP:18080 时,只在 Debug/Development Info.plist 添加:
xml
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSLocalNetworkUsageDescription</key>
<string>晞晗IM 开发版需要连接局域网测试服务器。</string>生产包必须使用系统信任的 HTTPS/WSS,并删除 NSAllowsArbitraryLoads。
5. 第一次连接
在账号会话对象中:
swift
import XHIM
private var client: XHIMClient?
private var connectRequest: XHIMRequest?
connectRequest = XHIMClient.connect(
server: "http://192.168.2.250:18080",
userID: "alice",
onConnecting: {
print("XHIM 正在连接")
},
onConnectSuccess: { [weak self] client in
self?.client = client
self?.installEventListener(client)
},
onConnectFailure: { error in
print("连接失败", error.stableCode, error.message)
}
)connect 负责把公开接入参数转换成 XHIMClientConfiguration:
| Core 配置 | connect 的来源 |
|---|---|
appID | 显式 appID,否则公开配置的默认值 |
storageURL | storageRootURL 下按 App ID + User ID 自动隔离 |
deployment.bootstrapURL | server |
| Endpoint Key ID + Public Key | Bootstrap 公开配置 |
start() 和 login() 已由 connect 完成;Development 测试登录也由 SDK 处理。需要企业存储策略时才传 storageRootURL,目录必须位于 App 私有容器, 不能使用 Bundle 或 Caches。
多 App 部署才需要显式指定:
swift
connectRequest = XHIMClient.connect(
server: "https://im.customer.com",
userID: currentUserID,
appID: "com.customer.product",
storageRootURL: enterpriseApplicationSupportURL,
onConnecting: {},
onConnectSuccess: { [weak self] client in
self?.client = client
},
onConnectFailure: { error in
print(error.message)
}
)6. 收发消息
监听状态和消息变化。token 要由账号容器强引用:
swift
private var eventToken: XHIMEventListenerToken?
eventToken = client.addEventListener { [weak self] event in
switch event {
case .stateChanged(let state):
self?.updateConnectionState(state)
case .messageUpserted(let change),
.messageStateChanged(let change):
self?.reloadMessages(conversationID: change.conversationID)
case .conversationChanged:
self?.reloadConversations()
case .conversationReadChanged:
self?.reloadUnreadCount()
default:
break
}
}回调在 MainActor 按顺序执行。投影事件是可合并的失效通知,不是可重放日志; 收到未知 kind/schema 或发现 revision 断档时执行宽范围重查。
获取 Bob 的单聊会话并发送文字:
swift
client.directConversation(
with: "bob",
onSuccess: { conversation in
client.sendText(
conversationID: conversation.conversationID,
text: "你好,晞晗IM",
onSuccess: { _ in
print("消息已进入发送队列")
},
onFailure: { error in
print("发送失败", error.message)
}
)
},
onFailure: { error in
print("创建单聊失败", error.message)
}
)全部常用 iOS 回调原型、参数和错误说明见 iOS 回调式 API。下面开始是 高级能力示例;采用 Swift Concurrency 的团队也可以继续使用同名异步重载。
在线状态与“正在输入”
登录完成后直接调用 SDK,不需要自行请求接口、传 Token 或维护 WebSocket:
swift
_ = try await client.publishPresence(.online)
_ = try await client.publishTyping(
conversationID: conversationID,
isTyping: true
)
// 输入框清空、发送成功或页面退出时立即清除。
_ = try await client.publishTyping(
conversationID: conversationID,
isTyping: false
)对端从同一个 events 流接收强类型事件:
swift
switch event {
case .presenceChanged(let update):
showPresence(
userID: update.userID,
status: update.status,
expiresAt: update.expiresAtMilliseconds
)
case .typingChanged(let update):
showTyping(
conversationID: update.conversationID,
userID: update.userID,
isTyping: update.isTyping,
expiresAt: update.expiresAtMilliseconds
)
default:
break
}不要每次按键都上报:只在“未输入 → 正在输入”时发送一次,并在停止输入、发送 成功或离开会话时发送 false。服务端回传的 expiresAtMilliseconds 是最终 兜底,UI 到期必须自动清除;默认 TTL 由服务端统一配置。
发送首条文本时显式保存 clientMessageID,便于失败后精确重试:
swift
let clientMessageID = UUID().uuidString
_ = try await client.sendText(
conversationID: "xhim-demo-direct",
clientMessageID: clientMessageID,
text: "你好,晞晗IM"
)读取会话列表和最近消息:
swift
let conversations = try await client.conversations(limit: 50)
let page = try await client.messages(
conversationID: "xhim-demo-direct",
limit: 50
)sendText 成功表示消息已进入可靠 Outbox。断网时消息会保存在本地,恢复网络后 继续发送;最终状态以时间线中的 serverAccepted 为准。只对明确处于 .failed/.cancelled 的同一条消息复用其 ID:
swift
_ = try await client.retryMessage(clientMessageID: clientMessageID)用户看完当前时间线后,以服务端序号提交已读水位:
swift
if let through = page.messages.compactMap(\.serverSequence).max() {
_ = try await client.markConversationRead(
conversationID: "xhim-demo-direct",
throughServerSequence: through
)
}nextCursor 是不透明二进制值;下一页只能原样传回 messages 或 conversations,不能转成字符串或自行解析。
用户资料、单聊和 APNs
登录成功后直接使用强类型 API,不需要自己拼 HTTP、JSON 或用户凭证:
swift
let me = try await client.currentUserProfile()
let batch = try await client.userProfiles(
userIDs: [me.userID, "bob"]
)
// nil 表示不修改;空字符串表示明确清空。
let updated = try await client.updateCurrentUserProfile(
XHIMUserProfileUpdate(
displayName: "Alice",
avatarURL: nil,
bio: ""
)
)
let direct = try await client.directConversation(with: "bob")
_ = try await client.sendText(
conversationID: direct.conversationID,
text: "你好,Bob"
)收到系统 APNs device token 后注册。pushInstallationID 必须是保存在 Keychain 的安装级稳定 ID,不能每次启动重新生成:
swift
let providerToken = deviceToken.map {
String(format: "%02x", $0)
}.joined()
let receipt = try await client.registerPushDevice(
XHIMPushDevice(
platform: .apns,
deviceID: pushInstallationID,
token: providerToken,
environment: "production",
locale: Locale.current.identifier
)
)
print("push enabled:", receipt.enabled) // receipt 不包含 token禁止打印 providerToken 或整个注册请求。用户退出账号、关闭推送或删除该安装 绑定时调用:
swift
_ = try await client.disablePushDevice(
deviceID: pushInstallationID
)所有以上 async API 都支持 Swift 并发取消;取消 Task 会调用 Core 的通用请求 取消,不会销毁共享 Client:
swift
let lookup = Task {
try await client.userProfiles(userIDs: ["alice", "bob"])
}
lookup.cancel()服务端历史与仅自己可见的清理
messages(...) 读取本地时间线;需要补拉服务端历史时使用 getMessageHistory(...)。返回消息已经转换为可直接渲染的本地权威快照, continuation 只能原样回传:
swift
let history = try await client.getMessageHistory(
conversationID: conversationID,
limit: 50
)
if let continuation = history.continuation {
_ = try await client.getMessageHistory(
conversationID: conversationID,
continuation: continuation,
limit: 50
)
}
guard let serverMessageID = history.messages.first?.serverMessageID else {
return
}
let deleteMutationID = UUID().uuidString // 网络重试必须复用
_ = try await client.deleteMessageForSelf(
conversationID: conversationID,
serverMessageID: serverMessageID,
mutationID: deleteMutationID
)
if history.latestServerSequence > 0 {
let cleared = try await client.clearConversation(
conversationID: conversationID,
throughServerSequence: history.latestServerSequence,
mutationID: UUID().uuidString,
expectedRevision: history.view?.revision ?? 0
)
_ = try await client.hideConversation(
conversationID: conversationID,
mutationID: UUID().uuidString,
expectedRevision: cleared.view.revision
)
}删除、清空和隐藏都只影响当前账号视图,不撤回其他成员的消息。每次逻辑写操作 使用一个持久化的 mutationID;同一次失败重试复用它。后续 CAS 使用最新返回的 view.revision,冲突时先重新拉取历史。取消外层 Swift Task 会取消对应 native request。
编辑与撤回
只能编辑或撤回已经有 serverMessageID 的服务端消息。一次用户操作生成一个 mutationID;网络重试继续使用同一个 ID,不要重新生成。expectedRevision 使用当前消息的 mutationRevision,发生版本冲突时重新加载时间线再让用户操作。
swift
guard let serverMessageID = message.serverMessageID else { return }
let mutationID = UUID().uuidString
let edited = try await client.editText(
conversationID: message.conversationID,
serverMessageID: serverMessageID,
mutationID: mutationID,
expectedRevision: message.mutationRevision,
text: "修改后的内容"
)
let recalled = try await client.recall(
conversationID: edited.conversationID,
serverMessageID: serverMessageID,
mutationID: UUID().uuidString,
expectedRevision: edited.mutationRevision
)返回值是服务端确认后的完整 XHIMMessage。mutationKind 为 .editText 或 .recall;未知的新枚举会映射为 .unknown,原始值保存在 nativeMutationKind。取消外层 Swift Task 会继续向 native request 传递取消。
置顶、免打扰与本地草稿
置顶和免打扰由服务端同步;expectedRevision 来自会话的 preferenceRevision。草稿只保存在当前账号的加密本地数据库,不上传服务端:
swift
let preference = try await client.setConversationPreference(
conversationID: conversation.conversationID,
isPinned: true,
notificationsMuted: false,
mutationID: UUID().uuidString,
expectedRevision: conversation.preferenceRevision
)
let text = "尚未发送的内容"
let draftMessage = XHIMOutgoingMessage(
contentType: "text/plain",
contentVersion: 1,
payload: Data(text.utf8),
fallbackText: text
)
_ = try await client.setLocalDraft(
conversationID: conversation.conversationID,
message: draftMessage
)
let draft = try await client.localDraft(
conversationID: conversation.conversationID
)
_ = try await client.clearLocalDraft(
conversationID: conversation.conversationID
)draft.isPresent == false 表示没有草稿;不要把本地草稿当成多端同步数据。
7. 使用现成聊天 UI
swift
import SwiftUI
import XHIMSwiftUI
XHIMChatView(
messages: viewModel.messages,
onSend: { text in
viewModel.send(text)
}
)UI Kit 不持有登录凭证,也不直接访问数据库。ViewModel 将 XHIMMessage 映射为 XHIMMessageItem,因此客户可以替换主题、导航和消息 气泡,不需要修改内核。
7.1 自定义消息
自定义类型使用业务自有、稳定的 contentType,版本只在该类型内递增,并始终 提供旧客户端可展示的 fallbackText:
swift
import Foundation
import XHIM
struct OrderCard: Codable {
let orderID: String
let title: String
}
let payload = try JSONEncoder().encode(
OrderCard(orderID: "order-1001", title: "待付款订单")
)
let custom = XHIMOutgoingMessage(
contentType: "com.customer.message.order-card",
contentVersion: 1,
payload: payload,
fallbackText: "[订单] 待付款订单"
)
_ = try await client.sendMessage(
conversationID: conversationID,
message: custom
)接收端先按 contentType + contentVersion 解码;未知类型或版本必须显示 fallbackText。需要统一校验、会话预览和 Renderer Key 时,在账号 UI 层使用 XHIMMessagePluginRegistry 注册一个类型一个插件,插件失败也必须回退,不能 阻塞时间线。
8. 图片、视频、拍照和文件
附件入口:
swift
XHIMAttachmentPickerButton { attachment in
viewModel.send(attachment)
} onFailure: { error in
viewModel.show(error)
}XHIMAttachmentPickerButton 已封装:
- 相机;
- 系统相册;
- 视频选择;
- 文件选择;
- 临时文件复制和沙盒访问。
Picker 返回的文件先保留在 App 沙箱,然后直接交给 Core。宿主不创建上传器, 也不处理 Prepare、临时凭证或重试:
swift
let accepted = try await client.sendImage(
conversationID: "xhim-demo-direct",
fileURL: attachment.localURL,
mimeType: attachment.mimeType,
width: 1080,
height: 1920
)发送文件:
swift
_ = try await client.sendFile(
conversationID: "xhim-demo-direct",
fileURL: attachment.localURL,
mimeType: attachment.mimeType,
displayName: attachment.suggestedName
)视频和语音文件分别调用同参数语义的 sendVideo、sendAudio;语音录制由宿主 使用系统音频 API 完成,再把持久沙箱文件交给 SDK。
accepted 只表示上传任务和依赖消息已持久化受理,不表示传输完成。监听 .mediaTaskUpdated 后按 taskID 调用 mediaTask(taskID:),直到 .completed/.failed/.cancelled。取消一个 Swift 并发 Task 只取消本次 API 等待;业务上终止持久任务必须调用 cancelMediaTask(taskID:)。
收到消息里的稳定 XHIMMediaRef 后,可发起私有缓存下载:
swift
let download = try await client.acceptMediaDownload(
XHIMMediaDownloadIntent(
cacheKey: UUID().uuidString,
mediaRef: mediaRef
)
)下载同样以任务终态为准。cacheKey 只能是扁平标识,不是路径;不要拼接 storagePath 猜测缓存位置。任务、事件、诊断和网络 Payload 都不会暴露本地 路径、Token 或签名 URL。
任务到达 .completed 后,通过受限 Reader 把已校验字节交给图片/视频解码器:
swift
let reader = try await client.openMediaCacheReader(
cacheKey: cacheKey,
mediaRef: mediaRef
)
do {
for try await chunk in reader.chunks() {
decoder.append(chunk)
}
} catch {
await reader.close()
throw error
}
await reader.close()open 会执行完整 SHA-256 校验,SDK 已放到 utility I/O 队列;同一 Reader 的 read/close 自动串行。停止 chunks() 消费就是取消,不使用通用 request ID。
运行中健康快照可在任意 App 线程同步读取:
swift
let health = try client.diagnostics()该调用只读内存,不访问磁盘或网络,也不包含账号、消息正文和凭证。
把系统网络恢复回调转成一次提示即可:
swift
pathMonitor.pathUpdateHandler = { [weak client] path in
guard path.status == .satisfied else { return }
try? client?.notifyNetworkAvailable()
}该方法同步、best-effort、没有 callback/request ID;它只用于提前唤醒重连, Core 原有退避定时器仍然保底。
9. 两部 iPhone 互发
- 两部 iPhone 与 Server 在同一可互访网络。
- 两部手机都使用同一个 Server 地址。
- 第一部登录
alice。 - 第二部登录
bob。 - Alice 调用
directConversation(with: "bob"),Bob 调用directConversation(with: "alice"),使用服务端返回的稳定会话 ID。 - 等待状态变为
ready后互发消息。
两端登录都由 Easy Connect 或各自的业务鉴权回调完成。
10. 正式 App 怎么登录
正式环境不能仅凭 User ID 登录,否则任何人都能冒充其他用户。这个安全约束和 OpenIM、环信、融云等商用 IM 的用户身份模型一致。
Production 必须使用 .business、可信 HTTPS/WSS 和正式 Product Adapter; Release 不允许 .development、.localPreview、Easy Login 或匿名降级。
App 页面和普通业务代码不处理鉴权细节,只在账号容器配置一次回调:
swift
XHIMClient.connect(
server: "https://im.customer.com",
userID: account.userID,
authentication: .business(AccountAPI.current.xhimCredential),
onConnecting: {},
onConnectSuccess: { client in
accountContainer.client = client
},
onConnectFailure: { error in
accountContainer.show(error.message)
}
)AccountAPI.current.xhimCredential() 使用客户 App 已有登录态向客户自己的 业务服务端取得 XHIM 登录票据。XHIM SDK 会:
- 初次连接时调用一次;
- 需要续期时自动再次调用;
- 自动更新底层连接;
- 不把登录票据交给 SwiftUI 页面。
如果客户原有业务 App 已经登录,这通常只是业务服务端增加一个“获取 XHIM 登录票据”的接口。接口如何签发由服务端团队负责,不属于 iOS 页面接入步骤。
新项目正式上线时使用业务 Provider;不要把固定登录票据写在代码、Info.plist 或 UserDefaults 中。
11. 登出、换号和销毁
账号退出时先解绑该安装的推送,再登出并销毁 Client:
swift
_ = try? await client.disablePushDevice(deviceID: pushInstallationID)
try await client.logout()
await client.shutdown()
eventTask.cancel()
projectionRefresh.cancel()同一账号只保留一个 Client。切换账号必须完成以上流程后,用新 User ID 重新 connect;不同账号不能复用数据库目录。页面消失只取消页面任务,不应登出 账号级 Client。
12. 常见问题
连接发现错误按 XHIMConnectionError 分支;运行时错误按 XHIMNativeError.code/domain/stableCode/retryable 分支。不要解析可读 message 决定业务逻辑,也不要在日志中输出凭证、消息 Payload 或附件路径。
No such module 'XHIM'
确认 Swift Package 已添加到 App Target,并勾选 XHIM Product。不要只拖 XCFramework 文件。
Passwordless login is disabled
当前服务地址没有开放 Development 测试账号直登。请把错误和当前 server 地址发给服务端负责人处理;iOS 端不要改认证代码或搭建本地服务。
iPhone 无法连接局域网 IP
依次检查:
server是否与服务端负责人交付的地址完全一致;- iPhone 是否允许当前 App 使用本地网络;
- iPhone 与测试服务是否处于可互访网络;
- Debug Info.plist 是否允许 Development HTTP;
- 仍不可用时,把地址、时间、错误信息交给服务端负责人检查。
BACKEND_NOT_CONFIGURED
当前使用的是 Preview Core,不是带 Apple Product Adapter 的 Development 或 Production XCFramework。重新添加完整 XHIMSwift-* Package。
能发送但看不到对方消息
确认两端使用服务端负责人交付的同一个 conversationID,并检查两端是否达到 ready。账号成员关系或服务端消息记录由服务端负责人检查。
13. Production 上线检查
- 换成签名 Production 二进制 Swift Package;
- 使用服务端负责人交付的 Production HTTPS 域名;
- 使用
.businessCredential Provider,禁用 Local Preview 和 Easy Login; - Release 删除开发 ATS 例外;
- 由业务服务端提供 XHIM Credential;
- 接入 APNs;
- 完成账号切换、断网、弱网、后台、锁屏、重装和数据库恢复测试;
- 校验 XCFramework 签名、checksum、Privacy Manifest、SBOM、LICENSE 和 NOTICE。
服务端上线检查见 XHIM Server 文档,不属于 iOS 开发者的操作清单。SDK 发行、签名和内部构建内容见 iOS SDK 产品说明。
附录 A:好友、群组和黑名单写入
每个逻辑写入只生成一次 mutationID;只有原请求的网络重试才复用它。相同 ID 搭配不同参数会返回 IDEMPOTENCY_CONFLICT。群成员和治理修改必须使用刚 查询到的 group.revision 作为精确 expectedRevision:
swift
let sent = try await client.sendFriendRequest(
toUserID: "bob", introduction: "我是 Alice",
mutationID: UUID().uuidString
)
_ = try await client.resolveFriendRequest(
requestID: sent.requestID, decision: .accept,
mutationID: UUID().uuidString
)
let deletion = try await client.deleteFriendship(
peerUserID: "bob", mutationID: UUID().uuidString
)
let created = try await client.createGroup(
title: "项目群", memberUserIDs: ["alice", "bob"],
mutationID: UUID().uuidString
)
let changed = try await client.changeGroupMembers(
conversationID: created.group.conversationID,
addUserIDs: ["carol"], expectedRevision: created.group.revision,
mutationID: UUID().uuidString
)
_ = try await client.setBlock(
blockedUserID: "spam-user", isActive: true,
mutationID: UUID().uuidString
)
let join = try await client.requestGroupJoin(
conversationID: changed.group.conversationID,
mutationID: UUID().uuidString
)
_ = try await client.resolveGroupJoin(
requestID: join.request.requestID, decision: .accept,
mutationID: UUID().uuidString
)
_ = try await client.changeGroupGovernance(
conversationID: changed.group.conversationID,
expectedRevision: changed.group.revision,
mutationID: UUID().uuidString,
change: .setJoinApprovalRequired(true)
)
let left = try await client.leaveGroup(
conversationID: memberGroup.conversationID,
expectedRevision: memberGroup.revision,
mutationID: UUID().uuidString
)
let dismissed = try await client.dismissGroup(
conversationID: ownedGroup.conversationID,
expectedRevision: ownedGroup.revision,
mutationID: UUID().uuidString
)Swift Task 取消会转发到该 native request,但不会回滚已经提交的事务。冲突后 重新查询服务端投影,再以新的业务意图和新的 mutationID 提交。错误分支只看 XHIMNativeError.code/domain/stableCode,不要解析 message,也不要记录 用户凭证、申请附言或资料内容。deletion/left/dismissed.idempotentReplay 表示 服务端返回了同一逻辑写入的既有结果;群生命周期结果中的 groupChange 是应 立即应用的权威投影。示例里的 memberGroup 与 ownedGroup 分别代表已查询到 的成员群和本人拥有的群。
附录 B:离线只读数据库
扩展进程、诊断页或导出工具只需要读取已存在的账号数据库时,使用 XHIMOfflineReader。它不会连接服务器、创建数据库或迁移 Schema,所有 SQLite 调用都在 SDK 私有后台队列执行。
swift
let reader = try await XHIMOfflineReader.open(
databaseURL: accountDatabaseURL,
accountID: currentUserID,
keyProvider: {
// 从 Keychain 读取数据库随机 Key;不要写入代码、plist 或 UserDefaults。
try accountKeyStore.databaseKey(accountID: currentUserID)
}
)
let first = try await reader.messages(conversationID: conversationID)
if let cursor = first.nextCursor {
// cursor 是不透明二进制值,只能原样传回 SDK。
_ = try await reader.messages(
conversationID: conversationID,
cursor: cursor
)
}
_ = try await reader.searchMessages(
XHIMMessageSearchQuery(text: "合同")
)
_ = try await reader.conversations()
_ = try await reader.friendRequests()
_ = try await reader.friendships()
_ = try await reader.groups()
_ = try await reader.groupMembers(conversationID: groupID)
_ = try await reader.blocks()
_ = try await reader.groupJoinRequests()
await reader.close() // 可重复调用;close 之后的查询会明确失败。Key Provider 在 open 时才执行。SDK 会清理自己的临时 Key 副本;Provider 仍应 返回一次性 Data,并由 Keychain/应用安全层管理其原始材料。native borrowed view 会在每次调用返回前复制成 Swift Data、String 和值类型,不会泄露 C 指针生命周期。
附录 C:登录设备管理
登录成功后可直接查询当前账号的服务端权威会话;应用不需要解析 token 或维护 另一套设备状态:
swift
let page = try await client.listDeviceSessions()
let otherDevices = page.sessions.filter { !$0.isCurrent && $0.isActive }
if let target = otherDevices.first {
let result = try await client.revokeDeviceSession(
sessionID: target.sessionID,
mutationID: UUID().uuidString
)
// changed == false 表示目标此前已经过期/失活,仍是成功的幂等结果。
print(result.changed)
}
// 同步只读、调用方持有;不依赖先调用 list,也不发起网络/磁盘 I/O。
let policy = try client.deviceSessionPolicySnapshot()精确重试同一次踢设备操作时复用 mutationID;新操作生成新 ID。取消 Swift Task 会走通用 request cancel。撤销当前会话时,返回值仍为 isCurrent == true,但 isActive == false,App 应立即回到登录态。日志和 字符串描述不得输出 sessionID、deviceID 或 revokeReason。登录完成前或 登出后读取策略会抛出原生 INVALID_STATE,不要用空策略伪造登录态。
附录 D:协议兼容快照(仅诊断/灰度)
正常业务通常不需要处理协议协商:登录流程已经对不兼容的服务端 fail-closed, 不会让一个协议不兼容的 Client 进入可用态。只有诊断页、客服支持包或灰度发布 观测需要读取协商结果时,才调用:
swift
let compatibility = try client.compatibilitySnapshot()
print(compatibility.clientProtocolVersion)
print(compatibility.serverProtocolVersion)
print(compatibility.serverCapabilities.count)该同步 getter 只返回登录时已验证并深拷贝的内存快照,不发起网络或磁盘 I/O。 登录前和登出后会原样抛出 INVALID_STATE。不要根据 capability 自行绕过登录 结果,也不要把 SDK/Server 版本或 capability 值写入普通业务日志;模型 description/debugDescription 已只保留协议号和数量。