← API | 列表 | swift_IM适配器与推送
提示信息
# 同伴 App — Swift IM 适配器(OpenIM)与 APNs 推送

> 对应 Flutter 的 `RealImAdapter` + `PushAdapter`。Swift 版用 `actor` 封装 OpenIM SDK,APNs 原生集成,无需第三方推送 SDK。

---

## 一、OpenIM 配置参数

| 参数 | 值 |
|---|---|
| API 地址 | `https://im-api.tongban.wang` |
| WS 地址 | `wss://im-ws.tongban.wang/msg_gateway` |
| 平台 ID | `5`(iOS) |
| UID 前缀 | `tb_`(例:uid=12345 → IM 用的 userID=`tb_12345`) |
| DB 目录 | `Documents/im_db/` |

---

## 二、目录结构

```
Core/Adapters/
├── IMAdapter.swift          # 协议 + actor 实现
├── IMMessageMapper.swift    # SDK Message → 业务 ChatMessage
└── PushManager.swift        # APNs 注册 + 消息路由
```

---

## 三、IMAdapter 协议

```swift
// IMAdapter.swift(协议部分)

enum IMStatus: Sendable {
    case connecting, connected, disconnected, failed
}

struct IMConversationSummary: Sendable {
    let conversationId: String
    let userId: String?
    let showName: String?
    let faceURL: String?
    let isPinned: Bool
    let draftText: String?
    let unreadCount: Int
    let lastMessagePreview: String
    let lastMessageTime: Int
}

protocol IMAdapterProtocol: Actor {
    func initialize(
        onTokenExpired: (@Sendable () async -> Void)?,
        onAccountConflict: (@Sendable (_ deviceModel: String, _ text: String) -> Void)?
    ) async

    func login(uid: String, token: String) async throws
    func logout() async

    var isLoggedIn: Bool { get }
    var statusStream: AsyncStream<IMStatus> { get }
    var newMessageStream: AsyncStream<OIMMessageInfo> { get }
    var conversationChangedStream: AsyncStream<[IMConversationSummary]> { get }

    func manualRetry() async
    func getConversationList() async -> [IMConversationSummary]
    func getMessageHistory(conversationId: String, count: Int, startMsg: OIMMessageInfo?) async -> [OIMMessageInfo]
    func pinConversation(conversationID: String, isPinned: Bool) async throws
    func clearHistory(conversationID: String) async throws
    func deleteConversation(conversationID: String) async throws
    func revokeMessage(conversationID: String, clientMsgID: String) async throws
    func setDraft(conversationID: String, draftText: String) async
}
```

---

## 四、IMAdapter 实现

```swift
// IMAdapter.swift(实现部分)
actor IMAdapter: IMAdapterProtocol {
    static let shared = IMAdapter()

    private var _isLoggedIn  = false
    private var currentUID: String?
    private var currentToken: String?
    private var onTokenExpired: (@Sendable () async -> Void)?
    private var onAccountConflict: (@Sendable (String, String) -> Void)?

    var isLoggedIn: Bool { _isLoggedIn }

    // AsyncStream continuations
    private var statusContinuation: AsyncStream<IMStatus>.Continuation?
    private var messageContinuation: AsyncStream<OIMMessageInfo>.Continuation?
    private var conversationContinuation: AsyncStream<[IMConversationSummary]>.Continuation?

    lazy var statusStream: AsyncStream<IMStatus> = AsyncStream { self.statusContinuation = $0 }
    lazy var newMessageStream: AsyncStream<OIMMessageInfo> = AsyncStream { self.messageContinuation = $0 }
    lazy var conversationChangedStream: AsyncStream<[IMConversationSummary]> = AsyncStream { self.conversationContinuation = $0 }

    // ── 初始化 ─────────────────────────────────────────────────────────

    func initialize(
        onTokenExpired: (@Sendable () async -> Void)? = nil,
        onAccountConflict: (@Sendable (String, String) -> Void)? = nil
    ) async {
        self.onTokenExpired    = onTokenExpired
        self.onAccountConflict = onAccountConflict

        let docDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
        let dbPath = docDir.appendingPathComponent("im_db").path
        try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true)

        // SDK 初始化
        let config = OIMInitConfig()
        config.apiAddr   = "https://im-api.tongban.wang"
        config.wsAddr    = "wss://im-ws.tongban.wang/msg_gateway"
        config.dataDir   = dbPath
        config.platformID = 5  // iOS

        OIMManager.manager.initSDK(
            config: config,
            onConnecting: { [weak self] in
                self?.statusContinuation?.yield(.connecting)
            },
            onConnectSuccess: { [weak self] in
                self?.statusContinuation?.yield(.connected)
            },
            onConnectFailure: { [weak self] _, _ in
                self?.statusContinuation?.yield(.failed)
            },
            onKickedOffline: { [weak self] in
                self?._isLoggedIn = false
                self?.statusContinuation?.yield(.disconnected)
            },
            onUserTokenExpired: { [weak self] in
                self?._isLoggedIn = false
                self?.statusContinuation?.yield(.disconnected)
                Task { await self?.onTokenExpired?() }
            }
        )

        // 消息监听
        OIMManager.manager.addAdvancedMsgListener(listener: self)

        // 会话变更监听
        OIMManager.manager.addConversationListener(listener: self)

        // 网络恢复自动重连
        NotificationCenter.default.addObserver(
            forName: .init("NetworkReachabilityChanged"),
            object: nil,
            queue: nil
        ) { [weak self] _ in
            Task {
                guard let self, await !self.isLoggedIn,
                      let uid = await self.currentUID,
                      let token = await self.currentToken else { return }
                try? await self.login(uid: uid, token: token)
            }
        }
    }

    // ── 登录 ───────────────────────────────────────────────────────────

    func login(uid: String, token: String) async throws {
        let prefixedUID = uid.hasPrefix("tb_") ? uid : "tb_\(uid)"
        currentUID   = prefixedUID
        currentToken = token

        return try await withCheckedThrowingContinuation { continuation in
            OIMManager.manager.login(userID: prefixedUID, token: token) { [weak self] _ in
                self?._isLoggedIn = true
                continuation.resume()
            } onFailure: { code, msg in
                continuation.resume(throwing: AppAPIError(code: code, message: msg ?? "IM 登录失败"))
            }
        }
    }

    // ── 退出 ───────────────────────────────────────────────────────────

    func logout() async {
        await withCheckedContinuation { continuation in
            OIMManager.manager.logout {
                continuation.resume()
            } onFailure: { _, _ in
                continuation.resume()
            }
        }
        _isLoggedIn  = false
        currentUID   = nil
        currentToken = nil
    }

    func manualRetry() async {
        guard let uid = currentUID, let token = currentToken else { return }
        try? await login(uid: uid, token: token)
    }

    // ── 会话列表 ───────────────────────────────────────────────────────

    func getConversationList() async -> [IMConversationSummary] {
        await withCheckedContinuation { continuation in
            OIMManager.manager.getAllConversationList { list in
                continuation.resume(returning: list?.map { self.mapConversation($0) } ?? [])
            } onFailure: { _, _ in
                continuation.resume(returning: [])
            }
        }
    }

    // ── 消息历史 ───────────────────────────────────────────────────────

    func getMessageHistory(
        conversationId: String,
        count: Int = 40,
        startMsg: OIMMessageInfo? = nil
    ) async -> [OIMMessageInfo] {
        await withCheckedContinuation { continuation in
            OIMManager.manager.getAdvancedHistoryMessageList(
                conversationID: conversationId,
                startMsg: startMsg,
                count: Int32(count),
                lastMinSeq: 0
            ) { result in
                continuation.resume(returning: result?.messageList ?? [])
            } onFailure: { _, _ in
                continuation.resume(returning: [])
            }
        }
    }

    // ── 会话操作 ───────────────────────────────────────────────────────

    func pinConversation(conversationID: String, isPinned: Bool) async throws {
        try await withCheckedThrowingContinuation { continuation in
            OIMManager.manager.pinConversation(conversationID: conversationID, isPinned: isPinned) {
                continuation.resume()
            } onFailure: { code, msg in
                continuation.resume(throwing: AppAPIError(code: code, message: msg ?? "操作失败"))
            }
        }
    }

    func clearHistory(conversationID: String) async throws {
        try await withCheckedThrowingContinuation { continuation in
            OIMManager.manager.clearConversationAndDeleteAllMsg(conversationID: conversationID) {
                continuation.resume()
            } onFailure: { code, msg in
                continuation.resume(throwing: AppAPIError(code: code, message: msg ?? "操作失败"))
            }
        }
    }

    func deleteConversation(conversationID: String) async throws {
        try await withCheckedThrowingContinuation { continuation in
            OIMManager.manager.deleteConversationAndDeleteAllMsg(conversationID: conversationID) {
                continuation.resume()
            } onFailure: { code, msg in
                continuation.resume(throwing: AppAPIError(code: code, message: msg ?? "操作失败"))
            }
        }
    }

    func revokeMessage(conversationID: String, clientMsgID: String) async throws {
        try await withCheckedThrowingContinuation { continuation in
            OIMManager.manager.revokeMessage(conversationID: conversationID, clientMsgID: clientMsgID) {
                continuation.resume()
            } onFailure: { code, msg in
                continuation.resume(throwing: AppAPIError(code: code, message: msg ?? "撤回失败"))
            }
        }
    }

    func setDraft(conversationID: String, draftText: String) async {
        OIMManager.manager.setConversationDraft(conversationID: conversationID, draftText: draftText) { _ in }
    }

    // ── 内部转换 ───────────────────────────────────────────────────────

    private func mapConversation(_ info: OIMConversationInfo) -> IMConversationSummary {
        IMConversationSummary(
            conversationId:      info.conversationID ?? "",
            userId:              info.userID,
            showName:            info.showName,
            faceURL:             info.faceURL,
            isPinned:            info.isPinned,
            draftText:           info.draftText?.isEmpty == false ? info.draftText : nil,
            unreadCount:         Int(info.unreadCount),
            lastMessagePreview:  parsePreview(info),
            lastMessageTime:     Int(info.latestMsgSendTime)
        )
    }

    private func parsePreview(_ info: OIMConversationInfo) -> String {
        guard let msg = info.latestMsg else { return "" }
        switch msg.contentType {
        case .text:   return msg.textElem?.content?.trimmingCharacters(in: .whitespaces) ?? ""
        case .custom:
            guard let data = msg.customElem?.data,
                  let json = try? JSONSerialization.jsonObject(with: Data(data.utf8)) as? [String: Any],
                  let type = json["type"] as? String else { return "[消息]" }
            switch type {
            case "image":        return "[图片]"
            case "flash_image":  return "[闪照]"
            case "multi_image":  return "[图片]"
            case "audio":        return "[语音]"
            case "signal_quote": return "[信号回复]"
            default:             return "[消息]"
            }
        case .revokeMessage: return "消息已撤回"
        default:             return ""
        }
    }
}

// ── 消息监听代理 ───────────────────────────────────────────────────────

extension IMAdapter: OIMAdvancedMsgListener {
    nonisolated func onRecvNewMessage(_ message: OIMMessageInfo) {
        Task { await handleIncomingMessage(message) }
    }

    private func handleIncomingMessage(_ msg: OIMMessageInfo) {
        // 过滤系统指令型自定义消息(account_conflict)
        if msg.contentType == .custom,
           let data = msg.customElem?.data,
           let json = try? JSONSerialization.jsonObject(with: Data(data.utf8)) as? [String: Any],
           json["type"] as? String == "account_conflict" {
            handleAccountConflict(json)
            return
        }
        messageContinuation?.yield(msg)
    }

    private func handleAccountConflict(_ json: [String: Any]) {
        let deviceModel = json["device_model"] as? String ?? "另一台设备"
        let text        = json["text"]         as? String ?? "您的账号在另一台设备登录,如非本人请修改密码。"
        _isLoggedIn = false
        statusContinuation?.yield(.disconnected)
        onAccountConflict?(deviceModel, text)
    }
}

extension IMAdapter: OIMConversationListener {
    nonisolated func onConversationChanged(_ conversations: [OIMConversationInfo]) {
        Task {
            let summaries = conversations.map { await self.mapConversation($0) }
            await self.conversationContinuation?.yield(summaries)
        }
    }
}
```

---

## 五、IMMessageMapper(SDK 原始消息 → 业务 ChatMessage)

```swift
// IMMessageMapper.swift
// 将 OpenIM 的 OIMMessageInfo 转为业务层 ChatMessage
// 此处逻辑与 Flutter chat_controller 中的映射完全对应

enum IMMessageMapper {

    static func map(_ msg: OIMMessageInfo, currentUID: String) -> ChatMessage {
        let senderId   = msg.sendID ?? ""
        let receiverId = msg.recvID ?? ""
        let isMe       = senderId == currentUID || senderId == "tb_\(currentUID)"

        let (type, content) = parseContent(msg)

        return ChatMessage(
            id:         msg.clientMsgID ?? UUID().uuidString,
            serverId:   msg.serverMsgID,
            senderId:   senderId,
            receiverId: receiverId,
            type:       type,
            content:    content,
            createTime: Int(msg.sendTime),
            isRead:     msg.isRead,
            isMe:       isMe
        )
    }

    // ── 内容解析 ───────────────────────────────────────────────────────

    private static func parseContent(_ msg: OIMMessageInfo) -> (ChatMessageType, Any) {
        switch msg.contentType {
        case .text:
            return (.text, msg.textElem?.content ?? "")

        case .revokeMessage:
            return (.revoked, "")

        case .custom:
            return parseCustom(msg)

        default:
            return (.unknown, "")
        }
    }

    private static func parseCustom(_ msg: OIMMessageInfo) -> (ChatMessageType, Any) {
        guard let dataStr = msg.customElem?.data,
              let json = try? JSONSerialization.jsonObject(with: Data(dataStr.utf8)) as? [String: Any],
              let typeStr = json["type"] as? String else {
            return (.unknown, [:] as [String: Any])
        }

        switch typeStr {
        case "image":
            // { type, object_key, width, height, url_expire_at }
            return (.image, json)

        case "flash_image":
            return (.flashImage, json)

        case "flash_viewed":
            return (.flashViewed, json)

        case "multi_image":
            // { type, object_keys: [String], count }
            return (.multiImage, json)

        case "audio":
            // { type, object_key, duration_sec }
            return (.voice, json)

        case "signal_quote":
            // { type, signal_id, signal_content, signal_reply }
            return (.signalQuote, json)

        default:
            return (.unknown, json)
        }
    }
}
```

---

## 六、消息发送(Chat 模块调用 OpenIM SDK 发送)

```swift
// 在 ChatRepository 中调用 — 不通过 IMAdapter,直接调 SDK 发消息
extension OIMManager {

    // 文本消息
    func sendText(
        conversationID: String,
        text: String
    ) async throws -> OIMMessageInfo {
        let msg = createTextMessage(text: text)
        return try await sendMessage(msg, conversationID: conversationID)
    }

    // 自定义消息(图片 / 语音 / 闪照 / 信号回复)
    func sendCustom(
        conversationID: String,
        payload: [String: Any]
    ) async throws -> OIMMessageInfo {
        let data = (try? JSONSerialization.data(withJSONObject: payload)).flatMap {
            String(data: $0, encoding: .utf8)
        } ?? "{}"
        let msg = createCustomMessage(data: data, extension: nil, description: nil)
        return try await sendMessage(msg, conversationID: conversationID)
    }

    // 内部封装:发送消息并等待回调
    private func sendMessage(
        _ message: OIMMessageInfo,
        conversationID: String
    ) async throws -> OIMMessageInfo {
        try await withCheckedThrowingContinuation { continuation in
            self.sendMessage(
                message,
                to: conversationID,
                messageType: .one2One,
                onProgress: nil
            ) { sentMsg in
                continuation.resume(returning: sentMsg ?? message)
            } onFailure: { code, msg in
                continuation.resume(throwing: AppAPIError(code: code, message: msg ?? "发送失败"))
            }
        }
    }
}
```

---

## 七、APNs 推送集成

### 7.1 AppDelegate 注册

```swift
// AppDelegate.swift
func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
    // ...
    PushManager.shared.requestAuthorization()
    return true
}

func application(
    _ application: UIApplication,
    didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
    PushManager.shared.onDeviceToken(deviceToken)
}

func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable: Any],
    fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
    PushManager.shared.handleRemoteNotification(userInfo)
    completionHandler(.newData)
}
```

### 7.2 PushManager

```swift
// PushManager.swift
@MainActor
final class PushManager: NSObject {
    static let shared = PushManager()

    private var deviceToken: String?

    // ── 权限请求 ───────────────────────────────────────────────────────
    func requestAuthorization() {
        UNUserNotificationCenter.current().delegate = self
        UNUserNotificationCenter.current().requestAuthorization(
            options: [.alert, .sound, .badge]
        ) { granted, _ in
            guard granted else { return }
            DispatchQueue.main.async {
                UIApplication.shared.registerForRemoteNotifications()
            }
        }
    }

    // ── Token 上报 ─────────────────────────────────────────────────────
    func onDeviceToken(_ data: Data) {
        let token = data.map { String(format: "%02x", $0) }.joined()
        deviceToken = token
        AppStorage.setString(StorageKeys.pushToken, token)

        // 已登录则立即上报
        if let uid = AppStorage.getString(StorageKeys.userId) {
            Task { try? await bindPushToken(uid: uid) }
        }
    }

    func bindPushToken(uid: String) async throws {
        guard let token = deviceToken else { return }
        try await APIClient.shared.post("/user/device/push-token", body: [
            "uid":    uid,
            "token":  token,
            "type":   "apns"
        ] as [String: Any])
    }

    func unbindPushToken() async throws {
        try await APIClient.shared.post("/user/device/push-token/unbind")
    }

    // ── 前台收到推送 ───────────────────────────────────────────────────
    // 前台不弹系统通知,由 IM SDK onRecvNewMessage 处理(显示应用内 Toast)
    func handleRemoteNotification(_ userInfo: [AnyHashable: Any]) {
        guard let payload = userInfo["ext"] as? [String: Any],
              let type    = payload["type"] as? String else { return }

        switch type {
        case "chat":
            // 直接更新角标,IM SDK 会推送会话变更,UI 自动刷新
            updateBadge()
        default:
            break
        }
    }

    func updateBadge() {
        let count = AppStorage.getInt(StorageKeys.totalUnread) ?? 0
        UNUserNotificationCenter.current().setBadgeCount(count)
    }

    func clearBadge() {
        UNUserNotificationCenter.current().setBadgeCount(0)
    }
}

// ── 前台通知展示控制 ───────────────────────────────────────────────────
extension PushManager: UNUserNotificationCenterDelegate {
    // 前台收到推送:不展示 Banner(应用内自己处理)
    nonisolated func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification
    ) async -> UNNotificationPresentationOptions {
        return []   // 静默:IM SDK 实时推送,不需要系统 Banner
    }

    // 用户点击通知(后台/锁屏状态)
    nonisolated func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse
    ) async {
        let userInfo = response.notification.request.content.userInfo
        guard let payload = userInfo["ext"] as? [String: Any],
              let type    = payload["type"] as? String else { return }

        await MainActor.run {
            switch type {
            case "chat":
                if let convId = payload["conversation_id"] as? String {
                    AppCoordinator.shared.openConversation(id: convId)
                }
            default:
                break
            }
        }
    }
}
```

---

## 八、PrivacyService(隐私合规 SDK 初始化时序)

合规要求:**用户同意隐私协议后**才能初始化采集类 SDK。

```swift
// PrivacyService.swift
@MainActor
final class PrivacyService {
    static let shared = PrivacyService()

    private var sdkInitialized = false

    var hasAgreed: Bool {
        AppStorage.getBool(StorageKeys.privacyAgreed) ?? false
    }

    func markAgreed() async {
        AppStorage.setBool(StorageKeys.privacyAgreed, true)
        await initSDKs()
    }

    // 合规初始化(同意协议 OR App 重启且已同意过)
    func initSDKs() async {
        guard hasAgreed, !sdkInitialized else { return }
        sdkInitialized = true

        // 1. 初始化 IM SDK(注册回调)
        await IMAdapter.shared.initialize(
            onTokenExpired: {
                await AuthService.shared.silentRefreshImToken()
            },
            onAccountConflict: { deviceModel, text in
                await PrivacyService.shared.handleAccountConflict(deviceModel: deviceModel, text: text)
            }
        )

        // 2. 支付 SDK
        // PaymentAdapter.shared.initialize()

        // 3. APM / 监控(可在此加友盟等)
    }

    private func handleAccountConflict(deviceModel: String, text: String) {
        [StorageKeys.accessToken, StorageKeys.refreshToken,
         StorageKeys.userId, StorageKeys.imToken, StorageKeys.userProfile]
            .forEach { AppStorage.remove($0) }

        let alert = UIAlertController(title: "下线通知", message: text, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "重新登录", style: .default) { _ in
            AppCoordinator.shared.goLogin()
        })
        alert.addAction(UIAlertAction(title: "退出 APP", style: .destructive) { _ in
            exit(0)
        })
        UIApplication.topViewController?.present(alert, animated: true)
    }
}
```

---

## 九、隐私协议更新(错误码 1033)

当 API 返回 1033 时,需要弹出新版协议要求用户重新同意:

```swift
// 在 ResponseHandler 中处理 1033
case 1033:
    Task { @MainActor in
        await showPrivacyUpdateSheet()
    }
    throw AppAPIError(code: 1033, message: msg)

// 弹出协议更新 Sheet
@MainActor
private static func showPrivacyUpdateSheet() async {
    let vc = PrivacyUpdateViewController()  // 展示新版协议,不可关闭
    vc.onAgreed = {
        await PrivacyService.shared.markAgreed()
    }
    UIApplication.topViewController?.present(vc, animated: true)
}
```

---

## 十、Podfile 依赖(IM 相关)

```ruby
# OpenIM iOS SDK(对应 flutter_openim_sdk 3.8.x)
pod 'OpenIMSDK-iOS', '~> 3.8'
```

> OpenIM SDK 初始化完成后会在 Documents/im_db/ 目录建立 SQLite 数据库,**卸载 App 时会随 Documents 目录清除**,重装后自动重建。