提示信息
# 同伴 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 目录清除**,重装后自动重建。