Skip to content

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
  → sendText

Development/演示环境最小代码只有:

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 工程

  1. 打开 Xcode,选择 File → New → Project...
  2. 选择 iOS → App
  3. Interface 选择 SwiftUI,Language 选择 Swift
  4. Minimum Deployment 设为 iOS 15 或更高。
  5. 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'
end

0.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,否则公开配置的默认值
storageURLstorageRootURL 下按 App ID + User ID 自动隔离
deployment.bootstrapURLserver
Endpoint Key ID + Public KeyBootstrap 公开配置

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 是不透明二进制值;下一页只能原样传回 messagesconversations,不能转成字符串或自行解析。

用户资料、单聊和 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
)

返回值是服务端确认后的完整 XHIMMessagemutationKind.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
)

视频和语音文件分别调用同参数语义的 sendVideosendAudio;语音录制由宿主 使用系统音频 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 互发

  1. 两部 iPhone 与 Server 在同一可互访网络。
  2. 两部手机都使用同一个 Server 地址。
  3. 第一部登录 alice
  4. 第二部登录 bob
  5. Alice 调用 directConversation(with: "bob"),Bob 调用 directConversation(with: "alice"),使用服务端返回的稳定会话 ID。
  6. 等待状态变为 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

依次检查:

  1. server 是否与服务端负责人交付的地址完全一致;
  2. iPhone 是否允许当前 App 使用本地网络;
  3. iPhone 与测试服务是否处于可互访网络;
  4. Debug Info.plist 是否允许 Development HTTP;
  5. 仍不可用时,把地址、时间、错误信息交给服务端负责人检查。

BACKEND_NOT_CONFIGURED

当前使用的是 Preview Core,不是带 Apple Product Adapter 的 Development 或 Production XCFramework。重新添加完整 XHIMSwift-* Package。

能发送但看不到对方消息

确认两端使用服务端负责人交付的同一个 conversationID,并检查两端是否达到 ready。账号成员关系或服务端消息记录由服务端负责人检查。

13. Production 上线检查

  • 换成签名 Production 二进制 Swift Package;
  • 使用服务端负责人交付的 Production HTTPS 域名;
  • 使用 .business Credential 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 是应 立即应用的权威投影。示例里的 memberGroupownedGroup 分别代表已查询到 的成员群和本人拥有的群。

附录 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 DataString 和值类型,不会泄露 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 应立即回到登录态。日志和 字符串描述不得输出 sessionIDdeviceIDrevokeReason。登录完成前或 登出后读取策略会抛出原生 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 已只保留协议号和数量。

XHIM 客户端 SDK 与服务端文档