提示信息
# 同伴 App — Swift 聊天模块实现
> 业务规格 + 完整 SwiftUI 实现。对应 Flutter `features/chat/` 目录。
> IM SDK 封装层见 `swift_IM适配器与推送.md`(已实现)。
---
## 一、数据模型
```swift
// ChatModels.swift
// ── 会话 ────────────────────────────────────────────────────────────────
struct Conversation: Identifiable, Hashable, Sendable {
let id: String // conversationId,格式:si_{imUid1}_{imUid2}
let targetUid: Int
var nickname: String
var avatar: String
var isOnline: Bool = false
// 信号上下文(来自信号详情页 push 时携带)
var fromSignal: Bool = false
var signalId: Int? = nil
var receiveId: String? = nil
var signalReply: String? = nil // 对方的回复文字(首条消息预填)
var signalContent: String? = nil // 原始信号内容
// 会话状态
var isImOpened: Bool = false // 是否已发送 signalQuote
var isPinned: Bool = false
var draftText: String? = nil
// 列表展示
var unreadCount: Int = 0
var lastMessagePreview: String = ""
var lastMessageTime: Int = 0 // Unix 毫秒
func hash(into hasher: inout Hasher) { hasher.combine(id) }
static func == (lhs: Conversation, rhs: Conversation) -> Bool { lhs.id == rhs.id }
}
// ── 消息类型 ────────────────────────────────────────────────────────────
enum ChatMessageType: String, Sendable {
case text
case image
case flashImage
case multiImage
case voice
case signalQuote
case flashViewed
case revoked
case time // 纯 UI 时间分隔符
case unknown
}
// ── 消息 ────────────────────────────────────────────────────────────────
struct ChatMessage: Identifiable, Sendable {
let id: String // clientMsgId(乐观更新 key)
var serverId: String?
let senderId: String
let receiverId: String
var type: ChatMessageType
var content: Any // 不同类型含义不同
let createTime: Int // 毫秒时间戳
var isRead: Bool
let isMe: Bool
// 媒体字段
var imageUrl: String?
var urlExpireAt: Int? // Unix 秒,0=永不过期
var objectKey: String?
var multiObjectKeys: [String]?
var durationSec: Int?
var width: Double?
var height: Double?
var isFlash: Bool = false
// 生命周期
var isRecalled: Bool = false
var isDestroyed: Bool = false // 闪照已查看销毁
var uploadProgress: Double = 0
var status: String = "success" // "sending" | "success" | "failed"
var errorReason: String?
// 信号引用(type=signalQuote 时有值)
var signalQuoteId: Int?
var signalQuoteContent: String?
var signalQuoteReply: String?
var isURLExpired: Bool {
guard let exp = urlExpireAt, exp > 0 else { return false }
return Int(Date().timeIntervalSince1970) > exp
}
}
```
---
## 二、会话 ID 工具
```swift
// ConversationIDHelper.swift
enum ConversationIDHelper {
/// 生成 si_tb_{小uid}_tb_{大uid} 格式的会话 ID
static func make(myUid: Int, targetUid: Int) -> String {
let a = "tb_\(myUid)"
let b = "tb_\(targetUid)"
let sorted = [a, b].sorted()
return "si_\(sorted[0])_\(sorted[1])"
}
}
```
---
## 三、MediaCacheService(媒体缓存)
`actor MediaCacheService` 位于 `Tongban/Features/Chat/MediaCacheService.swift`,对外暴露以下核心接口:
### 目录结构
```
Library/Caches/
└── media_<uid>/ ← 按登录用户隔离
├── <filename>.jpg ← 平铺根目录(接收方 prefetch 下载的文件)
└── <conversationId>/ ← 按会话子目录(发送方上传后 save 的文件)
└── <filename>.jpg
```
文件名规则:`objectKey.replacingOccurrences(of: "/", with: "_")`,例如
`chat/image/2026/03/20/abc.jpg` → `chat_image_2026_03_20_abc.jpg`
> **⚠️ 与 Flutter 规范差异**:iOS 采用 `Library/Caches/` 目录(系统在低存储时自动清理),Flutter 采用 `ApplicationDocumentsDirectory`(持久存储)。iOS 版暂不实现 SQLite 索引,改用内存字典 `memoryCache` + 文件系统扫描。
### 关键方法
| 方法 | 说明 |
|-----|-----|
| `initialize(userId:)` | 登录后初始化缓存目录;登出时需重新调用(切换账号隔离) |
| `localURL(for:)` | 先查内存缓存,再扫描磁盘(平铺根 + 所有子目录) |
| `save(data:for:conversationId:)` | 有 `conversationId` → 存子目录;无 → 存根目录 |
| `prefetch(url:objectKey:)` | 后台下载并缓存;URL 过期时自动重签;返回新鲜 URL 供调用方回写气泡 |
| `resolveMediaURLs(for:)` | 批量策略:本地命中 → file:// URL;未命中且已过期 → batchSignURLs 重签;未命中且未过期 → 原 URL 不动(由 prefetchMedia 后台缓存) |
### 媒体 URL 解析流程(resolveMediaURLs)
```
for each message:
if 本地缓存命中 (localURL != nil):
result[key] = file:// URL ← 零网络请求
else if URL 为空 or isURLExpired:
needSign.append(key) ← 收集待重签 key
else:
保留原签名 URL,由 prefetchMedia 后台缓存
↓ (不加入 result)
if needSign 非空:
batchSignURLs(needSign) → 新签名 URL
for each new URL:
result[key] = newURL
Task { prefetch(url:objectKey:) } ← 后台下载缓存
```
### ATS 要求
OSS 签名 URL 格式:`http://metch.oss.xiaopai.vip/<objectKey>?OSSAccessKeyId=...`
**`metch.oss.xiaopai.vip` 必须加入 `Info.plist` 的 `NSExceptionDomains`**,否则 URLSession 和 Kingfisher 均无法下载(ATS 默认阻断 HTTP)。已配置:
```xml
<key>metch.oss.xiaopai.vip</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
```
### multiImageUrls 更新注意事项
`refreshExpiredMediaURLs(in:msgs:inout)` 更新多图 URL 时,**必须先提取数组到临时变量再写回**,不能用可选链 `msgs[idx].multiImageUrls?[ki] = url`(Swift `inout` + 可选链下标赋值存在写回不确定性):
```swift
// ✅ 正确
var updatedURLs = msgs[idx].multiImageUrls ?? Array(repeating: "", count: keys.count)
updatedURLs[ki] = url
msgs[idx].multiImageUrls = updatedURLs
// ❌ 避免
msgs[idx].multiImageUrls?[ki] = url
```
---
## 四、ChatRepository(消息发送串行队列)
```swift
// ChatRepository.swift
// 串行队列保证慢上传不被后续消息超越
actor ChatRepository {
static let shared = ChatRepository()
private var sendQueue: [() async -> Void] = []
private var isSending = false
// ── 消息发送入口(加入串行队列) ─────────────────────────────────
func enqueue(_ task: @escaping () async -> Void) {
sendQueue.append(task)
if !isSending { Task { await drainQueue() } }
}
private func drainQueue() async {
isSending = true
while !sendQueue.isEmpty {
let task = sendQueue.removeFirst()
await task()
}
isSending = false
}
// ── 发送文字消息 ──────────────────────────────────────────────────
func sendText(
conversationId: String,
text: String,
receiveId: String?,
updateMsg: @escaping @Sendable (String, String) -> Void, // (clientMsgId, status)
updateServerId: @escaping @Sendable (String, String) -> Void
) async {
do {
let resp = try await callSendAPI(
conversationId: conversationId,
content: text,
contentType: 101,
receiveId: receiveId,
pushDesc: text
)
updateServerId(resp.clientMsgID, resp.serverMsgID)
updateMsg(resp.clientMsgID, "success")
} catch let e as AppAPIError {
updateMsg("", "failed")
_ = e
}
}
// ── 发送自定义消息(图片/语音/闪照/信号引用) ─────────────────────
func sendCustom(
conversationId: String,
payload: [String: Any],
receiveId: String?,
pushDesc: String,
clientMsgId: String,
updateMsg: @escaping @Sendable (String, String) -> Void,
updateServerId: @escaping @Sendable (String, String) -> Void
) async {
guard let jsonData = try? JSONSerialization.data(withJSONObject: payload),
let jsonStr = String(data: jsonData, encoding: .utf8) else { return }
do {
let resp = try await callSendAPI(
conversationId: conversationId,
content: jsonStr,
contentType: 110,
receiveId: receiveId,
pushDesc: pushDesc
)
updateServerId(resp.clientMsgID, resp.serverMsgID)
updateMsg(resp.clientMsgID, "success")
} catch {
updateMsg(clientMsgId, "failed")
}
}
// ── 撤回消息 ──────────────────────────────────────────────────────
func revokeMessage(conversationId: String, clientMsgId: String) async throws {
try await APIClient.shared.post("/chat/message/revoke",
body: ["conversation_id": conversationId,
"client_msg_id": clientMsgId] as [String: Any])
}
// ── REST 发送接口 ─────────────────────────────────────────────────
private struct SendResponse: Decodable, Sendable {
let serverMsgID: String
let clientMsgID: String
let sendTime: Int
}
private func callSendAPI(
conversationId: String,
content: String,
contentType: Int,
receiveId: String?,
pushDesc: String
) async throws -> SendResponse {
var body: [String: Any] = [
"recvID": conversationId,
"content": content,
"contentType": contentType,
"clientMsgId": UUID().uuidString,
"offlinePushInfo": ["title": "新消息", "desc": pushDesc] as [String: Any]
]
if let rid = receiveId { body["receive_id"] = rid }
return try await APIClient.shared.post("/chat/message/send",
body: body as [String: Any],
as: SendResponse.self)
}
}
```
---
## 五、会话列表(ConversationListView)
### ViewModel
```swift
// ConversationListViewModel.swift
@MainActor
@Observable
final class ConversationListViewModel {
var conversations: [Conversation] = []
var isLoading = false
private var imTask: Task<Void, Never>?
func onAppear() async {
// 先展示 MMKV 缓存
loadFromCache()
// 从 IM SDK 拉取
isLoading = true
await refreshFromIM()
isLoading = false
// 启动实时监听
startListening()
}
private func loadFromCache() {
guard let str = AppStorage.getString(StorageKeys.convListCache),
let data = str.data(using: .utf8),
let cached = try? JSONDecoder().decode([CachedConversation].self, from: data)
else { return }
// 只用缓存填充空列表,避免闪烁覆盖 IM 数据
if conversations.isEmpty {
conversations = cached.map { $0.toConversation() }
}
}
private func refreshFromIM() async {
let summaries = await IMAdapter.shared.getConversationList()
var updated = summaries.map { makeConversation(from: $0) }
await enrichUserProfiles(conversations: &updated)
conversations = sorted(updated)
saveToCache(updated)
}
// 监听 IM 会话变更(实时刷新)
private func startListening() {
imTask = Task {
for await summaries in await IMAdapter.shared.conversationChangedStream {
let changed = summaries.map { makeConversation(from: $0) }
mergeChanges(changed)
}
}
}
private func mergeChanges(_ changed: [Conversation]) {
for conv in changed {
if let idx = conversations.firstIndex(where: { $0.id == conv.id }) {
conversations[idx] = conv
} else {
conversations.append(conv)
}
}
conversations = sorted(conversations)
}
// ── 会话操作 ──────────────────────────────────────────────────────
func pinConversation(id: String, isPinned: Bool) async {
try? await IMAdapter.shared.pinConversation(conversationID: id, isPinned: isPinned)
if let idx = conversations.firstIndex(where: { $0.id == id }) {
conversations[idx].isPinned = isPinned
conversations = sorted(conversations)
}
}
func deleteConversation(id: String) async {
try? await IMAdapter.shared.deleteConversation(conversationID: id)
conversations.removeAll { $0.id == id }
}
// ── 辅助 ──────────────────────────────────────────────────────────
private func sorted(_ list: [Conversation]) -> [Conversation] {
list.sorted {
if $0.isPinned != $1.isPinned { return $0.isPinned }
return $0.lastMessageTime > $1.lastMessageTime
}
}
private func makeConversation(from summary: IMConversationSummary) -> Conversation {
Conversation(
id: summary.conversationId,
targetUid: Int(summary.userId?.replacingOccurrences(of: "tb_", with: "") ?? "0") ?? 0,
nickname: summary.showName ?? "",
avatar: summary.faceURL ?? "",
isPinned: summary.isPinned,
draftText: summary.draftText,
unreadCount: summary.unreadCount,
lastMessagePreview: summary.lastMessagePreview,
lastMessageTime: summary.lastMessageTime
)
}
private func enrichUserProfiles(conversations: inout [Conversation]) async {
let uids = conversations.map(\.targetUid)
let cached = (try? await UserProfileCache.shared.getProfiles(uids)) ?? []
let cachedMap = Dictionary(uniqueKeysWithValues: cached.map { ($0.uid, $0) })
let missingUids = uids.filter { cachedMap[$0] == nil }
var fetchedMap: [Int: UserBasicInfo] = [:]
if !missingUids.isEmpty {
let fetched = (try? await APIClient.shared.post(
"/user/profile/openim-batch",
body: ["user_ids": missingUids, "scene": "list"] as [String: Any],
as: [UserBasicInfo].self
)) ?? []
try? await UserProfileCache.shared.saveProfiles(fetched)
fetchedMap = Dictionary(uniqueKeysWithValues: fetched.map { ($0.uid, $0) })
}
for idx in conversations.indices {
let uid = conversations[idx].targetUid
if let info = cachedMap[uid] ?? fetchedMap[uid] {
conversations[idx].nickname = info.username
conversations[idx].avatar = info.avatar
}
}
}
private func saveToCache(_ convs: [Conversation]) {
let cached = convs.prefix(50).map { CachedConversation(from: $0) }
if let data = try? JSONEncoder().encode(cached),
let str = String(data: data, encoding: .utf8) {
AppStorage.setString(StorageKeys.convListCache, str)
}
}
}
// 用于 MMKV 序列化的精简结构
private struct CachedConversation: Codable {
let id: String; let targetUid: Int; let nickname: String; let avatar: String
let isPinned: Bool; let unreadCount: Int
let lastMessagePreview: String; let lastMessageTime: Int
init(from c: Conversation) {
id = c.id; targetUid = c.targetUid; nickname = c.nickname; avatar = c.avatar
isPinned = c.isPinned; unreadCount = c.unreadCount
lastMessagePreview = c.lastMessagePreview; lastMessageTime = c.lastMessageTime
}
func toConversation() -> Conversation {
Conversation(id: id, targetUid: targetUid, nickname: nickname, avatar: avatar,
isPinned: isPinned, unreadCount: unreadCount,
lastMessagePreview: lastMessagePreview, lastMessageTime: lastMessageTime)
}
}
```
### View
```swift
// ConversationListView.swift
struct ConversationListView: View {
@State private var vm = ConversationListViewModel()
@State private var navigationPath = NavigationPath()
var body: some View {
NavigationStack(path: $navigationPath) {
Group {
if vm.isLoading && vm.conversations.isEmpty {
listSkeleton
} else if vm.conversations.isEmpty {
ContentUnavailableView(
"暂无消息",
systemImage: "message",
description: Text("通过信号互动后,会话将显示在这里")
)
} else {
List {
ForEach(vm.conversations) { conv in
ConversationRow(conversation: conv)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
.contentShape(Rectangle())
.onTapGesture {
navigationPath.append(conv)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
Task { await vm.deleteConversation(id: conv.id) }
} label: {
Label("删除", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await vm.pinConversation(id: conv.id, isPinned: !conv.isPinned) }
} label: {
Label(conv.isPinned ? "取消置顶" : "置顶",
systemImage: conv.isPinned ? "pin.slash" : "pin")
}
.tint(.orange)
}
}
}
.listStyle(.plain)
.refreshable { await vm.refreshFromIM() }
}
}
.navigationTitle("消息")
.navigationDestination(for: Conversation.self) { conv in
ConversationView(conversation: conv)
}
}
.task { await vm.onAppear() }
}
private var listSkeleton: some View {
List(0..<8, id: \.self) { _ in
HStack(spacing: 12) {
Circle().frame(width: 52, height: 52)
VStack(alignment: .leading, spacing: 8) {
RoundedRectangle(cornerRadius: 4).frame(width: 100, height: 14)
RoundedRectangle(cornerRadius: 4).frame(width: 180, height: 12)
}
Spacer()
RoundedRectangle(cornerRadius: 4).frame(width: 36, height: 10)
}
.padding(.vertical, 8)
.redacted(reason: .placeholder)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.allowsHitTesting(false)
}
}
struct ConversationRow: View {
let conversation: Conversation
var body: some View {
HStack(spacing: 12) {
ZStack(alignment: .topTrailing) {
AsyncImage(url: URL(string: conversation.avatar)) { img in
img.resizable().scaledToFill()
} placeholder: {
Circle().fill(.secondary.opacity(0.2))
}
.frame(width: 52, height: 52)
.clipShape(Circle())
if conversation.unreadCount > 0 {
Text(conversation.unreadCount > 99 ? "99+" : "\(conversation.unreadCount)")
.font(.caption2.bold())
.padding(.horizontal, 5).padding(.vertical, 2)
.background(.red, in: Capsule())
.foregroundStyle(.white)
.offset(x: 4, y: -4)
}
}
VStack(alignment: .leading, spacing: 4) {
HStack {
if conversation.isPinned {
Image(systemName: "pin.fill").font(.caption).foregroundStyle(.orange)
}
Text(conversation.nickname).font(.subheadline.bold()).lineLimit(1)
Spacer()
Text(formattedTime(conversation.lastMessageTime))
.font(.caption).foregroundStyle(.secondary)
}
Text(conversation.draftText.map { "[草稿] \($0)" } ?? conversation.lastMessagePreview)
.font(.caption)
.foregroundStyle(conversation.draftText != nil ? .red : .secondary)
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
}
private func formattedTime(_ ms: Int) -> String {
let date = Date(timeIntervalSince1970: TimeInterval(ms) / 1000)
let cal = Calendar.current
if cal.isDateInToday(date) {
let fmt = DateFormatter(); fmt.dateFormat = "HH:mm"
return fmt.string(from: date)
} else if cal.isDateInYesterday(date) {
return "昨天"
} else {
let fmt = DateFormatter(); fmt.dateFormat = "M/d"
return fmt.string(from: date)
}
}
}
```
---
## 六、私聊页面(ConversationView)
### ViewModel
```swift
// ConversationViewModel.swift
@MainActor
@Observable
final class ConversationViewModel {
let conversation: Conversation
var messages: [ChatMessage] = []
var isLoading = false
var hasMoreHistory = true
var newMessageBannerCount = 0 // 底部"N条新消息"悬浮
var isAtBottom = true
private var myUID: String = ""
private var listenTask: Task<Void, Never>?
private var oldestMsg: OIMMessageInfo?
init(conversation: Conversation) {
self.conversation = conversation
}
func onAppear() async {
myUID = AppStorage.getString(StorageKeys.userId).map { "tb_\($0)" } ?? ""
// 1. 加载历史消息
isLoading = true
await loadHistory()
isLoading = false
// 2. 首次进入自动发送 signalQuote
if conversation.fromSignal && !conversation.isImOpened {
await sendSignalQuote()
}
// 3. 监听新消息
startListening()
// 4. 标记已读
markConversationRead()
}
func onDisappear() {
// 保存草稿
listenTask?.cancel()
}
// ── 历史消息 ──────────────────────────────────────────────────────
private func loadHistory(startMsg: OIMMessageInfo? = nil) async {
let rawMsgs = await IMAdapter.shared.getMessageHistory(
conversationId: conversation.id,
count: 40,
startMsg: startMsg
)
guard !rawMsgs.isEmpty else {
hasMoreHistory = false
return
}
oldestMsg = rawMsgs.first
var mapped = rawMsgs.map { IMMessageMapper.map($0, currentUID: myUID) }
mapped = insertTimeMarkers(into: mapped)
// 批量预加载媒体 URL
await refreshExpiredMediaURLs(in: &mapped)
if startMsg == nil {
messages = mapped
} else {
messages = mapped + messages
}
hasMoreHistory = rawMsgs.count >= 40
// 后台预下载未缓存媒体
prefetchMedia(in: mapped)
}
func loadMoreHistory() async {
guard hasMoreHistory, let startMsg = oldestMsg else { return }
await loadHistory(startMsg: startMsg)
}
// ── 新消息监听 ────────────────────────────────────────────────────
private func startListening() {
listenTask = Task {
for await raw in await IMAdapter.shared.newMessageStream {
guard raw.recvID == conversation.id || raw.sendID == myUID else { continue }
let msg = IMMessageMapper.map(raw, currentUID: myUID)
handleIncoming(msg)
}
}
}
private func handleIncoming(_ msg: ChatMessage) {
switch msg.type {
case .revoked:
// 更新对应消息为撤回状态
if let id = msg.content as? String,
let idx = messages.firstIndex(where: { $0.id == id }) {
messages[idx].isRecalled = true
}
case .flashViewed:
if let id = msg.content as? String,
let idx = messages.firstIndex(where: { $0.id == id }) {
messages[idx].isDestroyed = true
}
default:
var incoming = msg
// 如果 URL 过期则刷新
Task {
if incoming.isURLExpired, let key = incoming.objectKey,
let refreshed = try? await UploadClient.shared.batchSignURLs([key]),
let newURL = refreshed[key] {
incoming.imageUrl = newURL
}
messages.insert(incoming, at: 0)
if !isAtBottom { newMessageBannerCount += 1 }
prefetchMedia(in: [incoming])
}
}
}
// ── 发送消息 ──────────────────────────────────────────────────────
func sendText(_ text: String) {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
let msgId = UUID().uuidString
var msg = ChatMessage(
id: msgId, senderId: myUID, receiverId: conversation.id,
type: .text, content: trimmed,
createTime: Int(Date().timeIntervalSince1970 * 1000),
isRead: false, isMe: true
)
msg.status = "sending"
messages.insert(msg, at: 0)
let convId = conversation.id
let receiveId = conversation.receiveId
ChatRepository.shared.enqueue {
await ChatRepository.shared.sendText(
conversationId: convId, text: trimmed, receiveId: receiveId,
updateMsg: { [weak self] id, status in
await MainActor.run {
if let idx = self?.messages.firstIndex(where: { $0.id == msgId }) {
self?.messages[idx].status = status
}
}
},
updateServerId: { [weak self] _, serverId in
await MainActor.run {
if let idx = self?.messages.firstIndex(where: { $0.id == msgId }) {
self?.messages[idx].serverId = serverId
}
}
}
)
}
}
func sendImage(fileURL: URL, isFlash: Bool = false) {
let msgId = UUID().uuidString
let type: ChatMessageType = isFlash ? .flashImage : .image
var msg = ChatMessage(
id: msgId, senderId: myUID, receiverId: conversation.id,
type: type, content: "",
createTime: Int(Date().timeIntervalSince1970 * 1000),
isRead: false, isMe: true
)
msg.imageUrl = fileURL.absoluteString
msg.status = "sending"
msg.isFlash = isFlash
messages.insert(msg, at: 0)
let convId = conversation.id
let receiveId = conversation.receiveId
ChatRepository.shared.enqueue {
do {
// 压缩图片
let compressed = try self.compressImage(at: fileURL)
// 上传
let result = try await UploadClient.shared.uploadFile(
at: compressed,
type: isFlash ? "chat_image" : "chat_image"
) { [weak self] progress in
await MainActor.run {
if let idx = self?.messages.firstIndex(where: { $0.id == msgId }) {
self?.messages[idx].uploadProgress = progress
}
}
}
// 签名 URL
let signed = try? await UploadClient.shared.batchSignURLs([result.objectKey])
let signedURL = signed?[result.objectKey]
await MainActor.run {
if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
self.messages[idx].objectKey = result.objectKey
self.messages[idx].imageUrl = signedURL ?? fileURL.absoluteString
}
}
let payload: [String: Any] = isFlash
? ["type": "flash_image", "objectKey": result.objectKey]
: ["type": "image", "objectKey": result.objectKey,
"url": signedURL ?? "", "expireAt": 0]
await ChatRepository.shared.sendCustom(
conversationId: convId, payload: payload,
receiveId: receiveId,
pushDesc: isFlash ? "[闪照]" : "[图片]",
clientMsgId: msgId,
updateMsg: { id, status in
await MainActor.run {
if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
self.messages[idx].status = status
}
}
},
updateServerId: { _, sid in
await MainActor.run {
if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
self.messages[idx].serverId = sid
}
}
}
)
// 缓存本地
if let data = try? Data(contentsOf: compressed), let key = result.objectKey as String? {
await MediaCacheService.shared.save(data: data, for: key)
}
} catch {
await MainActor.run {
if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
self.messages[idx].status = "failed"
}
}
}
}
}
func sendVoice(fileURL: URL, duration: Int) {
let msgId = UUID().uuidString
var msg = ChatMessage(
id: msgId, senderId: myUID, receiverId: conversation.id,
type: .voice, content: "",
createTime: Int(Date().timeIntervalSince1970 * 1000),
isRead: false, isMe: true
)
msg.durationSec = duration
msg.status = "sending"
messages.insert(msg, at: 0)
let convId = conversation.id
let receiveId = conversation.receiveId
ChatRepository.shared.enqueue {
do {
let result = try await UploadClient.shared.uploadFile(at: fileURL, type: "chat_audio")
let payload: [String: Any] = [
"type": "audio",
"objectKey": result.objectKey,
"durationSec": duration
]
await ChatRepository.shared.sendCustom(
conversationId: convId, payload: payload,
receiveId: receiveId, pushDesc: "[语音]",
clientMsgId: msgId,
updateMsg: { _, status in
await MainActor.run {
if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
self.messages[idx].status = status
}
}
},
updateServerId: { _, sid in
await MainActor.run {
if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
self.messages[idx].serverId = sid
}
}
}
)
} catch {
await MainActor.run {
if let idx = self.messages.firstIndex(where: { $0.id == msgId }) {
self.messages[idx].status = "failed"
}
}
}
}
}
// ── 信号引用首条消息 ──────────────────────────────────────────────
private func sendSignalQuote() async {
guard let reply = conversation.signalReply,
let signalId = conversation.signalId else { return }
let payload: [String: Any] = [
"type": "signal_quote",
"signalId": signalId,
"replyContent": reply,
"signalContent": conversation.signalContent ?? ""
]
let msgId = UUID().uuidString
let convId = conversation.id
let receiveId = conversation.receiveId
await ChatRepository.shared.sendCustom(
conversationId: convId, payload: payload,
receiveId: receiveId, pushDesc: "[信号回复]",
clientMsgId: msgId,
updateMsg: { _, _ in },
updateServerId: { _, _ in }
)
// 本地记录已开启
AppStorage.setBool(StorageKeys.imOpened(convId), true)
// 调后端 open-im 接口
try? await SignalRepository.shared.openIM(receiveId: Int(receiveId ?? "0") ?? 0)
}
// ── 撤回消息 ──────────────────────────────────────────────────────
func revokeMessage(msgId: String) async {
guard let idx = messages.firstIndex(where: { $0.id == msgId }) else { return }
let snapshot = messages[idx]
messages[idx].isRecalled = true
do {
try await ChatRepository.shared.revokeMessage(
conversationId: conversation.id, clientMsgId: msgId
)
} catch {
messages[idx] = snapshot
}
}
// ── 闪照已查看 ────────────────────────────────────────────────────
func markFlashViewed(msgId: String) {
if let idx = messages.firstIndex(where: { $0.id == msgId }) {
messages[idx].isDestroyed = true
}
let convId = conversation.id
let receiveId = conversation.receiveId
ChatRepository.shared.enqueue {
let payload: [String: Any] = ["type": "flash_viewed", "msgId": msgId]
await ChatRepository.shared.sendCustom(
conversationId: convId, payload: payload,
receiveId: receiveId, pushDesc: "",
clientMsgId: UUID().uuidString,
updateMsg: { _, _ in }, updateServerId: { _, _ in }
)
}
}
// ── 辅助 ──────────────────────────────────────────────────────────
private func insertTimeMarkers(into msgs: [ChatMessage]) -> [ChatMessage] {
guard !msgs.isEmpty else { return [] }
var result: [ChatMessage] = []
var lastTimestamp: Int = 0
for msg in msgs.reversed() {
let diff = msg.createTime - lastTimestamp
if diff > 5 * 60 * 1000 { // 超 5 分钟插时间分隔
let fmt = DateFormatter()
fmt.dateFormat = "MM-dd HH:mm"
let timeStr = fmt.string(from: Date(timeIntervalSince1970: TimeInterval(msg.createTime) / 1000))
var marker = ChatMessage(
id: "time_\(msg.createTime)", senderId: "", receiverId: "",
type: .time, content: timeStr,
createTime: msg.createTime, isRead: true, isMe: false
)
result.append(marker)
lastTimestamp = msg.createTime
}
result.append(msg)
}
return result.reversed()
}
private func refreshExpiredMediaURLs(in msgs: inout [ChatMessage]) async {
let newURLs = await MediaCacheService.shared.refreshExpiredURLs(for: msgs)
for idx in msgs.indices {
if let key = msgs[idx].objectKey, let url = newURLs[key] {
msgs[idx].imageUrl = url
}
}
}
private func prefetchMedia(in msgs: [ChatMessage]) {
for msg in msgs {
guard let key = msg.objectKey, let url = msg.imageUrl else { continue }
if msg.isFlash { continue } // 闪照不缓存
Task { await MediaCacheService.shared.prefetch(url: url, objectKey: key) }
}
}
private func markConversationRead() {
// OpenIM SDK 标记已读(内部实现,无需业务层显式调用)
}
private func compressImage(at url: URL) throws -> URL {
guard let image = UIImage(contentsOfFile: url.path) else {
throw AppAPIError(code: -1, message: "图片加载失败")
}
let maxSize = 200 * 1024 // 200KB
var data = image.jpegData(compressionQuality: 0.8) ?? Data()
if data.count > maxSize {
var q: CGFloat = 0.7
while data.count > maxSize && q > 0.1 {
data = image.jpegData(compressionQuality: q) ?? data
q -= 0.1
}
}
let out = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID()).jpg")
try data.write(to: out)
return out
}
}
```
### View
```swift
// ConversationView.swift
struct ConversationView: View {
let conversation: Conversation
@State private var vm: ConversationViewModel
@State private var scrollProxy: ScrollViewProxy?
@Environment(\.dismiss) private var dismiss
init(conversation: Conversation) {
self.conversation = conversation
_vm = State(initialValue: ConversationViewModel(conversation: conversation))
}
var body: some View {
VStack(spacing: 0) {
// 消息列表(reverse: 最新在底部,列表倒序排列)
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 0) {
// 加载更多
if vm.hasMoreHistory {
Button {
Task { await vm.loadMoreHistory() }
} label: {
Text("加载更多").font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity).padding(.vertical, 12)
}
}
ForEach(vm.messages.reversed()) { msg in
MessageBubble(
message: msg,
onRevoke: { Task { await vm.revokeMessage(msgId: msg.id) } },
onFlashViewed: { vm.markFlashViewed(msgId: msg.id) }
)
.id(msg.id)
.padding(.vertical, 2)
}
}
.padding(.horizontal, 12)
}
.onAppear { scrollProxy = proxy }
.defaultScrollAnchor(.bottom)
}
// 新消息悬浮提示
if vm.newMessageBannerCount > 0 {
Button {
vm.newMessageBannerCount = 0
vm.isAtBottom = true
} label: {
Text("\(vm.newMessageBannerCount) 条新消息 ↓")
.font(.caption.weight(.medium))
.padding(.horizontal, 12).padding(.vertical, 6)
.glassEffect()
}
.padding(.bottom, 4)
}
Divider()
// 输入栏
ChatInputBar(
conversationId: conversation.id,
onSendText: { vm.sendText($0) },
onSendImage: { vm.sendImage(fileURL: $0) },
onSendFlash: { vm.sendImage(fileURL: $0, isFlash: true) },
onSendVoice: { url, dur in vm.sendVoice(fileURL: url, duration: dur) }
)
}
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(false)
.toolbar {
ToolbarItem(placement: .principal) {
ChatNavBar(conversation: conversation)
}
ToolbarItem(placement: .topBarTrailing) {
NavigationLink(destination: ChatSettingsView(conversation: conversation)) {
Image(systemName: "ellipsis.circle")
}
}
}
.task { await vm.onAppear() }
.onDisappear { vm.onDisappear() }
}
}
// ── 导航栏 ────────────────────────────────────────────────────────────────
struct ChatNavBar: View {
let conversation: Conversation
var body: some View {
HStack(spacing: 8) {
AsyncImage(url: URL(string: conversation.avatar)) { img in
img.resizable().scaledToFill()
} placeholder: {
Circle().fill(.secondary.opacity(0.2))
}
.frame(width: 32, height: 32).clipShape(Circle())
.overlay(alignment: .bottomTrailing) {
if conversation.isOnline {
Circle().fill(.green).frame(width: 9, height: 9)
}
}
VStack(alignment: .leading, spacing: 1) {
Text(conversation.nickname).font(.subheadline.weight(.semibold))
if conversation.isOnline {
Text("在线").font(.caption2).foregroundStyle(.green)
}
}
}
}
}
```
---
## 七、ChatInputBar(输入栏)
```swift
// ChatInputBar.swift
struct ChatInputBar: View {
let conversationId: String
let onSendText: (String) -> Void
let onSendImage: (URL) -> Void
let onSendFlash: (URL) -> Void
let onSendVoice: (URL, Int) -> Void
@State private var text = ""
@State private var isVoiceMode = false
@State private var showMediaPicker = false
@State private var isRecording = false
@State private var recordedURL: URL?
@State private var recordDuration = 0
private let recorder = VoiceRecordingSession()
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
// 语音 / 键盘 切换
Button {
isVoiceMode.toggle()
} label: {
Image(systemName: isVoiceMode ? "keyboard" : "waveform")
.font(.title3)
.frame(width: 36, height: 36)
}
.foregroundStyle(.secondary)
if isVoiceMode {
// 长按录音按钮
VoicePressButton { event in
switch event {
case .began: recorder.start()
case .ended: if let (url, dur) = recorder.stop() { onSendVoice(url, dur) }
case .cancelled: recorder.cancel()
}
}
.frame(maxWidth: .infinity)
} else {
// 文字输入
TextField("发送消息", text: $text, axis: .vertical)
.lineLimit(1...5)
.textFieldStyle(.roundedBorder)
.onSubmit { sendText() }
}
// 媒体 / 发送 按钮
if text.isEmpty && !isVoiceMode {
Button {
showMediaPicker = true
} label: {
Image(systemName: "photo")
.font(.title3)
.frame(width: 36, height: 36)
}
.foregroundStyle(.secondary)
} else if !text.isEmpty {
Button {
sendText()
} label: {
Image(systemName: "paperplane.fill")
.font(.title3)
.frame(width: 36, height: 36)
}
.foregroundStyle(.accent)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.background)
.sheet(isPresented: $showMediaPicker) {
MediaPickerSheet(
onPickNormal: { url in onSendImage(url) },
onPickFlash: { url in onSendFlash(url) }
)
.presentationDetents([.medium])
}
}
private func sendText() {
let t = text
text = ""
onSendText(t)
}
}
// 媒体选择 Sheet(普通图片 / 闪照 / 多图)
struct MediaPickerSheet: View {
let onPickNormal: (URL) -> Void
let onPickFlash: (URL) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Button {
// PHPickerViewController → 普通图片
dismiss()
} label: {
Label("图片", systemImage: "photo")
}
Button {
// PHPickerViewController → 闪照
dismiss()
} label: {
Label("闪照(阅后即焚)", systemImage: "eye.slash")
}
}
.navigationTitle("发送媒体")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("取消") { dismiss() }
}
}
}
}
}
```
---
## 八、消息气泡(MessageBubble)
```swift
// MessageBubble.swift
struct MessageBubble: View {
let message: ChatMessage
let onRevoke: () -> Void
let onFlashViewed: () -> Void
var body: some View {
HStack(alignment: .bottom, spacing: 8) {
if message.isMe { Spacer(minLength: 40) }
if !message.isMe && message.type != .time {
AsyncImage(url: nil) { _ in EmptyView() } placeholder: {
Circle().fill(.secondary.opacity(0.2))
}
.frame(width: 32, height: 32).clipShape(Circle())
}
bubbleContent
.contextMenu {
if message.isMe && !message.isRecalled && message.type != .time {
Button(role: .destructive, action: onRevoke) {
Label("撤回", systemImage: "arrow.uturn.left")
}
}
}
if !message.isMe { Spacer(minLength: 40) }
}
.padding(.vertical, 2)
}
@ViewBuilder
private var bubbleContent: some View {
switch message.type {
case .time:
Text(message.content as? String ?? "")
.font(.caption2).foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
case .revoked, _ where message.isRecalled:
Text("消息已撤回")
.font(.caption).foregroundStyle(.secondary)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 16))
case .text:
TextBubble(text: message.content as? String ?? "", isMe: message.isMe,
status: message.status)
case .image:
ImageBubble(url: message.imageUrl, isExpired: message.isURLExpired,
progress: message.uploadProgress, status: message.status)
case .flashImage:
FlashImageBubble(
url: message.imageUrl,
isMe: message.isMe,
isDestroyed: message.isDestroyed,
onViewed: onFlashViewed
)
case .voice:
VoiceMessageBubble(
url: message.imageUrl,
duration: message.durationSec ?? 0,
isMe: message.isMe
)
case .signalQuote:
SignalQuoteBubble(message: message)
default:
Text("[未知消息类型]")
.font(.caption).foregroundStyle(.secondary)
}
}
}
// ── 文字气泡 ──────────────────────────────────────────────────────────────
struct TextBubble: View {
let text: String; let isMe: Bool; let status: String
var body: some View {
HStack(alignment: .bottom, spacing: 4) {
if isMe {
statusIcon
Text(text)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(Color.accentColor, in: RoundedRectangle(cornerRadius: 16))
.foregroundStyle(.white)
} else {
Text(text)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 16))
statusIcon
}
}
}
@ViewBuilder
private var statusIcon: some View {
switch status {
case "sending": ProgressView().scaleEffect(0.6)
case "failed": Image(systemName: "exclamationmark.circle.fill").foregroundStyle(.red)
default: EmptyView()
}
}
}
// ── 图片气泡 ──────────────────────────────────────────────────────────────
struct ImageBubble: View {
let url: String?; let isExpired: Bool
let progress: Double; let status: String
var body: some View {
ZStack {
if isExpired {
RoundedRectangle(cornerRadius: 12)
.fill(.secondary.opacity(0.1))
.frame(width: 180, height: 180)
.overlay {
VStack(spacing: 8) {
Image(systemName: "photo.slash").font(.title2).foregroundStyle(.secondary)
Text("图片已过期").font(.caption).foregroundStyle(.secondary)
}
}
} else {
AsyncImage(url: url.flatMap(URL.init)) { img in
img.resizable().scaledToFill()
} placeholder: {
Rectangle().fill(.secondary.opacity(0.2))
}
.frame(width: 180, height: 180)
.clipShape(RoundedRectangle(cornerRadius: 12))
if status == "sending" && progress > 0 {
RoundedRectangle(cornerRadius: 12)
.fill(.black.opacity(0.4))
.frame(width: 180, height: 180)
.overlay {
VStack(spacing: 8) {
ProgressView(value: progress)
.tint(.white).frame(width: 120)
Text("\(Int(progress * 100))%").font(.caption).foregroundStyle(.white)
}
}
}
}
}
}
}
// ── 闪照气泡 ──────────────────────────────────────────────────────────────
struct FlashImageBubble: View {
let url: String?; let isMe: Bool
let isDestroyed: Bool; let onViewed: () -> Void
var body: some View {
if isDestroyed {
Text(isMe ? "闪照已被查看" : "闪照已查看")
.font(.caption).foregroundStyle(.secondary)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 16))
} else if isMe {
// 发送方:显示缩略图
AsyncImage(url: url.flatMap(URL.init)) { img in
img.resizable().scaledToFill()
} placeholder: {
Rectangle().fill(.secondary.opacity(0.2))
}
.frame(width: 120, height: 120)
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay { Image(systemName: "eye.slash.fill").foregroundStyle(.white.opacity(0.8)) }
} else {
// 接收方:点击才可查看
Button(action: onViewed) {
RoundedRectangle(cornerRadius: 12)
.fill(Color.accentColor.opacity(0.15))
.frame(width: 120, height: 120)
.overlay {
VStack(spacing: 8) {
Image(systemName: "eye").font(.title2).foregroundStyle(.accent)
Text("点击查看闪照").font(.caption2).foregroundStyle(.accent)
}
}
}
}
}
}
// ── 语音气泡 ──────────────────────────────────────────────────────────────
struct VoiceMessageBubble: View {
let url: String?; let duration: Int; let isMe: Bool
@State private var isPlaying = false
var body: some View {
Button {
togglePlay()
} label: {
HStack(spacing: 8) {
Image(systemName: isPlaying ? "stop.fill" : "play.fill")
.font(.subheadline)
Image(systemName: "waveform")
.symbolEffect(.variableColor, isActive: isPlaying)
Text("\(duration)″").font(.caption.monospacedDigit())
}
.padding(.horizontal, 12).padding(.vertical, 8)
.background(isMe ? Color.accentColor : Color.secondary.opacity(0.15),
in: RoundedRectangle(cornerRadius: 20))
.foregroundStyle(isMe ? .white : .primary)
}
}
private func togglePlay() {
// 使用 AVPlayer 播放
isPlaying.toggle()
}
}
// ── 信号引用气泡 ──────────────────────────────────────────────────────────
struct SignalQuoteBubble: View {
let message: ChatMessage
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("信号引用").font(.caption.weight(.semibold)).foregroundStyle(.accent)
if let content = message.signalQuoteContent {
Text(content).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
if let reply = message.signalQuoteReply {
Divider()
Text(reply).font(.caption)
}
}
.padding(12)
.background(.accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.strokeBorder(.accent.opacity(0.3), lineWidth: 1)
}
.frame(maxWidth: 240, alignment: .leading)
}
}
```
---
## 九、聊天设置(ChatSettingsView)
```swift
// ChatSettingsView.swift
struct ChatSettingsView: View {
let conversation: Conversation
@State private var showClearConfirm = false
@Environment(\.dismiss) private var dismiss
var body: some View {
List {
Section {
// 对方资料
NavigationLink(destination: UserProfileView(uid: conversation.targetUid)) {
HStack(spacing: 12) {
AsyncImage(url: URL(string: conversation.avatar)) { img in
img.resizable().scaledToFill()
} placeholder: {
Circle().fill(.secondary.opacity(0.2))
}
.frame(width: 40, height: 40).clipShape(Circle())
Text(conversation.nickname).font(.body)
}
}
}
Section {
Button("清空聊天记录", role: .destructive) {
showClearConfirm = true
}
}
}
.navigationTitle("聊天设置")
.navigationBarTitleDisplayMode(.inline)
.alert("清空聊天记录", isPresented: $showClearConfirm) {
Button("清空", role: .destructive) {
Task {
try? await IMAdapter.shared.clearHistory(conversationID: conversation.id)
dismiss()
}
}
Button("取消", role: .cancel) { }
} message: {
Text("此操作将清除本地聊天记录,不可恢复。")
}
}
}
```
---
## 十、IM 协议速查(OpenIM contentType 与自定义消息格式)
> 实现在 `IMMessageMapper`(见 `swift_IM适配器与推送.md`)。此处仅备查业务规格。
| contentType | 含义 |
|---|---|
| `101` | 文字(`textElem.content`) |
| `110` | 自定义(`customElem.data`:JSON 字符串) |
| `2101` | 撤回通知 |
**自定义消息 type 字段值:**
| type | 说明 | 关键字段 |
|---|---|---|
| `image` | 单张图片 | `objectKey`, `url`, `expireAt`, `w`, `h` |
| `flash_image` | 闪照 | `objectKey`, `url`, `expireAt` |
| `flash_viewed` | 闪照已查看 | `msgId`(目标消息的 clientMsgId) |
| `multi_image` | 多图 | `multiObjectKeys: [String]`, `multiUrls: [String]`, `expireAt` |
| `audio` | 语音 | `objectKey`, `url`, `expireAt`, `durationSec` |
| `signal_quote` | 信号引用首条消息 | `signalId`, `replyContent`, `signalContent` |
**会话 ID 规则:**
```
imUid = "tb_" + 业务UID
conversationId = "si_" + sorted([myImUid, targetImUid]).joined("_")
```
---
## 十一、路由注册
```swift
// MainTabView — 消息 Tab 挂载 ConversationListView
// ConversationListView → NavigationDestination(for: Conversation.self)
// SignalDetailView → enterChat() 时 push ConversationView(conversation:)
// ChatSettingsView → ConversationView 导航栏右上角跳转
```
---
## 十二、已修复问题记录
| 问题 | 根因 | 修复 |
|-----|------|------|
| 多图消息无法加载(接收方首次查看) | `metch.oss.xiaopai.vip` HTTP 未在 `Info.plist` 中豁免 ATS,URLSession 和 Kingfisher 均被系统拒绝 | `Info.plist` 新增 `NSExceptionDomains` 条目 |
| 多图 URL 写回不生效(历史消息 re-resolve) | `msgs[idx].multiImageUrls?[ki] = url` 在 `inout` 数组上可选链赋值有写回风险 | 改为提取临时变量修改后整体赋值 |
| 多图气泡崩溃(`multiImageUrls` 为空时) | `MultiImageBubble.card(at: 0)` 无条件执行 `urls[0]`,数组为空时越界 | `body` 顶部加 `if urls.isEmpty` 占位分支 |
| 消息重复出现 | 乐观插入和 OpenIM 回显各生成不同 `clientMsgId` | `ChatRepository.sendText/sendCustom` 接收调用方传入的 `clientMsgId` 而非内部重新生成 |
| 撤回无效 | `IMAdapter.revokeMessage` 基于 OpenIM SDK 调用,但消息通过 REST API 发送,OpenIM 不认识该消息 | 改用 `ChatRepository.revokeMessage` 调用后端 `/chat/message/revoke` |
| 消息列表顺序(最新消息应在底部) | `defaultScrollAnchor(.bottom)` 在异步加载后失效 | ScrollView + 每条消息均旋转 `180°`,镜像 Flutter `ListView(reverse: true)` 行为 |