如何用Swift实现聊天应用Table View Cell内类似WhatsApp的语音消息播放器
Swift 聊天应用实现 WhatsApp 风格语音消息播放方案
核心实现思路
- 全局使用单例管理音频播放逻辑,避免多个 Cell 同时持有
AVAudioPlayer导致多音频同时播放、状态混乱的问题 - Cell 层仅负责 UI 展示和点击事件响应,不存储播放状态,所有播放状态同步从全局单例获取
- 维护全局唯一的
当前播放语音ID标识,点击新语音时自动停止上一条播放,点击同一条语音时切换暂停/播放状态
核心代码实现
1. 全局音频播放单例
import AVFoundation class AudioPlayerManager: NSObject, AVAudioPlayerDelegate { static let shared = AudioPlayerManager() private var player: AVAudioPlayer? private(set) var currentPlayingMessageId: String? private var progressTimer: Timer? private override init() { super.init() // 配置音频会话 do { try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default, options: .defaultToSpeaker) try AVAudioSession.sharedInstance().setActive(true) } catch { print("音频会话配置失败: \(error.localizedDescription)") } } // 播放/暂停切换 func playAudio(with localPath: URL, messageId: String) { if messageId == currentPlayingMessageId { player?.isPlaying == true ? pause() : resume() return } // 停止上一条播放 stopCurrentPlayback() currentPlayingMessageId = messageId // 初始化播放器 do { player = try AVAudioPlayer(contentsOf: localPath) player?.delegate = self player?.play() startProgressTimer() } catch { print("音频初始化失败: \(error.localizedDescription)") currentPlayingMessageId = nil } } func pause() { player?.pause() stopProgressTimer() } func resume() { player?.play() startProgressTimer() } func seek(to time: TimeInterval) { player?.currentTime = time } func stopCurrentPlayback() { player?.stop() player = nil currentPlayingMessageId = nil stopProgressTimer() NotificationCenter.default.post(name: NSNotification.Name("AudioPlaybackCompleted"), object: nil) } private func startProgressTimer() { stopProgressTimer() progressTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true, block: { [weak self] _ in guard let self = self, let player = self.player else { return } // 广播播放进度 NotificationCenter.default.post( name: NSNotification.Name("AudioPlaybackProgressUpdated"), object: nil, userInfo: [ "currentTime": player.currentTime, "totalDuration": player.duration ] ) }) } private func stopProgressTimer() { progressTimer?.invalidate() progressTimer = nil } func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { stopCurrentPlayback() } }
2. 语音消息 TableView Cell 实现
首先定义基础消息模型结构参考:
struct ChatMessageModel { let messageId: String let audioLocalPath: URL let audioDuration: TimeInterval // 其他消息属性按需添加 }
添加URL扩展快速获取音频时长:
import AVFoundation extension URL { func getAudioDuration() -> TimeInterval { return CMTimeGetSeconds(AVURLAsset(url: self).duration) } }
Cell实现代码:
class VoiceMessageCell: UITableViewCell { static let reuseID = "VoiceMessageCell" private var currentMessageId: String? private var audioPath: URL? // UI 元素,可根据需求替换为自定义波形视图 private let playButton: UIButton = { let btn = UIButton(type: .system) btn.setImage(UIImage(systemName: "play.fill"), for: .normal) btn.tintColor = .white return btn }() private let durationLabel: UILabel = { let label = UILabel() label.font = .systemFont(ofSize: 12) label.textColor = .white return label }() private let progressView: UIProgressView = { let progress = UIProgressView(progressViewStyle: .default) progress.trackTintColor = .lightGray progress.tintColor = .white return progress }() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) setupUI() setupObservers() playButton.addTarget(self, action: #selector(playBtnClicked), for: .touchUpInside) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func prepareForReuse() { super.prepareForReuse() currentMessageId = nil audioPath = nil progressView.progress = 0 playButton.setImage(UIImage(systemName: "play.fill"), for: .normal) } // 配置Cell数据 func configure(with model: ChatMessageModel) { currentMessageId = model.messageId audioPath = model.audioLocalPath durationLabel.text = String(format: "%.0f\"", model.audioDuration) // 同步当前播放状态 if AudioPlayerManager.shared.currentPlayingMessageId == model.messageId { playButton.setImage(UIImage(systemName: "pause.fill"), for: .normal) } else { playButton.setImage(UIImage(systemName: "play.fill"), for: .normal) progressView.progress = 0 } } @objc private func playBtnClicked() { guard let msgId = currentMessageId, let path = audioPath else { return } AudioPlayerManager.shared.playAudio(with: path, messageId: msgId) } @objc private func updateProgress(_ notification: Notification) { guard let currentPlayId = AudioPlayerManager.shared.currentPlayingMessageId, currentPlayId == currentMessageId, let userInfo = notification.userInfo, let currentTime = userInfo["currentTime"] as? TimeInterval, let total = userInfo["totalDuration"] as? TimeInterval else { return } progressView.progress = Float(currentTime / total) durationLabel.text = String(format: "%.0f\"", currentTime) } @objc private func playCompleted() { guard let msgId = currentMessageId, let path = audioPath else { return } playButton.setImage(UIImage(systemName: "play.fill"), for: .normal) progressView.progress = 0 durationLabel.text = String(format: "%.0f\"", path.getAudioDuration()) } private func setupObservers() { NotificationCenter.default.addObserver( self, selector: #selector(updateProgress(_:)), name: NSNotification.Name("AudioPlaybackProgressUpdated"), object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(playCompleted), name: NSNotification.Name("AudioPlaybackCompleted"), object: nil ) } private func setupUI() { backgroundColor = .clear contentView.backgroundColor = .systemBlue // 聊天气泡背景,可根据收发消息调整位置和颜色 [playButton, progressView, durationLabel].forEach { $0.translatesAutoresizingMaskIntoConstraints = false contentView.addSubview($0) } // 布局约束可根据自己的UI设计调整 NSLayoutConstraint.activate([ playButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 12), playButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), playButton.widthAnchor.constraint(equalToConstant: 24), playButton.heightAnchor.constraint(equalToConstant: 24), progressView.leadingAnchor.constraint(equalTo: playButton.trailingAnchor, constant: 8), progressView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), progressView.widthAnchor.constraint(equalToConstant: 120), durationLabel.leadingAnchor.constraint(equalTo: progressView.trailingAnchor, constant: 8), durationLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), durationLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), contentView.heightAnchor.constraint(equalToConstant: 44) ]) } }
3. 优化扩展方案
- 自定义波形视图:提前解析音频振幅数据,用贝塞尔曲线绘制波形,播放时动态更新已播放区域的颜色,更贴近WhatsApp的视觉效果
- 滑动进度跳转:给进度条添加拖动手势,调用单例的
seek(to:)方法实现播放进度跳转 - 音频路由自动切换:监听设备距离传感器,用户贴近耳边时自动切换到听筒播放,远离时切回扬声器
- 未读标记:本地维护已播放语音ID列表,未播放的语音显示红点提示
- 网络音频适配:如果语音文件存储在云端,点击播放时先触发下载逻辑,显示loading状态,下载完成后再启动播放
内容的提问来源于stack exchange,提问作者Azaz Ur Rehman
相关产品推荐
相关产品推荐

