You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.07 14:27:02