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

iOS中基于UIGraphics的交互式音频波形缩放性能优化咨询

优化交互式音频波形的流畅缩放体验

问题背景

已通过AVAssetReader提取音频采样数据,在UIScrollView的内容视图中使用UIBezierPath绘制波形。但捏合缩放时,每次手势状态变更都会实时对采样数据下采样并更新UI,导致交互卡顿,需要优化流畅度。

现有代码

WaveformView 绘制类

class WaveformView: UIView {
    var amplitudes: [CGFloat] = [] {
        didSet {
            setNeedsDisplay()
        }
    }

    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext(), !amplitudes.isEmpty else { return }

        // Set up drawing parameters
        context.setStrokeColor(UIColor.black.cgColor)
        context.setLineWidth(1.0)
        context.setLineCap(.round)

        let midY = rect.height / 2
        let widthPerSample = rect.width / CGFloat(amplitudes.count)

        // Draw waveform
        let path = UIBezierPath()

        for (index, amplitude) in amplitudes.enumerated() {
            let x = CGFloat(index) * widthPerSample
            let height = amplitude * rect.height * 0.8

            // Draw vertical line for each sample
            path.move(to: CGPoint(x: x, y: midY - height))
            path.addLine(to: CGPoint(x: x, y: midY + height))
        }

        path.stroke()
    }
}

手势处理与采样更新

@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
        switch gesture.state {
        case .began:
            initialPinchDistance = gesture.scale
            
        case .changed:
            let scaleFactor = gesture.scale / initialPinchDistance
            var newScale = currentScale * scaleFactor
            newScale = min(max(newScale, minScale), maxScale)
            
            // Update displayed samples with new scale
            updateDisplayedSamples(scale: newScale)
            print(newScale)
            // Maintain zoom center point
            let pinchCenter = gesture.location(in: scrollView)
            let offsetX = (pinchCenter.x - scrollView.bounds.origin.x) / scrollView.bounds.width
            let newOffsetX = (totalWidth * offsetX) - (pinchCenter.x - scrollView.bounds.origin.x)
            scrollView.contentOffset.x = max(0, min(newOffsetX, totalWidth - scrollView.bounds.width))
            
            view.layoutIfNeeded()
            
        case .ended, .cancelled:
            currentScale = scrollView.contentSize.width / (baseWidth * widthPerSample)
            
        default:
            break
        }
    }

private func updateDisplayedSamples(scale: CGFloat) {
        let targetSampleCount = Int(baseWidth * scale)
        displayedSamples = downsampleWaveform(samples: rawSamples, targetCount: targetSampleCount)
        waveformView.amplitudes = displayedSamples
        
        totalWidth = CGFloat(displayedSamples.count) * widthPerSample
        contentWidthConstraint?.constant = totalWidth
        scrollView.contentSize = CGSize(width: totalWidth, height: 300)
    }

private func downsampleWaveform(samples: [CGFloat], targetCount: Int) -> [CGFloat] {
        guard samples.count > 0, targetCount > 0 else { return [] }
        
        if samples.count <= targetCount {
            return samples
        }
        
        var downsampled: [CGFloat] = []
        let sampleSize = samples.count / targetCount
        
        for i in 0..<targetCount {
            let startIndex = i * sampleSize
            let endIndex = min(startIndex + sampleSize, samples.count)
            let slice = samples[startIndex..<endIndex]
            
            // For each window, take the maximum value to preserve peaks
            if let maxValue = slice.max() {
                downsampled.append(maxValue)
            }
        }
        
        return downsampled
    }

优化方案

1. 预生成多分辨率下采样缓存

提前计算并缓存不同缩放级别对应的下采样数据,避免手势交互时实时计算。可以按2的倍数(如1x、2x、4x、8x等)生成缓存,缩放时直接选取最接近当前需求的缓存数据,后续再根据最终缩放级别微调。

示例代码:

// 预缓存字典,key为目标采样数,value为对应的下采样结果
private var downsampleCache: [Int: [CGFloat]] = [:]

// 初始化时预生成常用级别的缓存
private func preGenerateDownsampleCaches() {
    let cacheLevels = [100, 200, 400, 800, 1600, 3200, 6400] // 根据音频长度调整
    for level in cacheLevels {
        downsampleCache[level] = downsampleWaveform(samples: rawSamples, targetCount: level)
    }
}

// 更新显示时优先取缓存
private func updateDisplayedSamples(scale: CGFloat) {
    let targetSampleCount = Int(baseWidth * scale)
    // 找最接近的缓存key
    let closestCacheKey = downsampleCache.keys.min { abs($0 - targetSampleCount) < abs($1 - targetSampleCount) }
    
    if let cachedSamples = closestCacheKey.flatMap({ downsampleCache[$0] }) {
        displayedSamples = cachedSamples
    } else {
        // 无缓存时再实时计算
        displayedSamples = downsampleWaveform(samples: rawSamples, targetCount: targetSampleCount)
        downsampleCache[targetSampleCount] = displayedSamples
    }
    
    // 后续更新UI逻辑不变
    waveformView.amplitudes = displayedSamples
    totalWidth = CGFloat(displayedSamples.count) * widthPerSample
    contentWidthConstraint?.constant = totalWidth
    scrollView.contentSize = CGSize(width: totalWidth, height: 300)
}

2. 手势节流,减少实时计算次数

在手势changed状态下,不要每次都触发计算,而是通过节流控制更新频率,比如每16ms(对应60fps)更新一次,避免短时间内重复计算。

示例代码:

private var updateWorkItem: DispatchWorkItem?

@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
    switch gesture.state {
    case .began:
        initialPinchDistance = gesture.scale
        // 取消未执行的任务
        updateWorkItem?.cancel()
        
    case .changed:
        let scaleFactor = gesture.scale / initialPinchDistance
        var newScale = currentScale * scaleFactor
        newScale = min(max(newScale, minScale), maxScale)
        
        // 节流:延迟16ms执行,避免频繁计算
        updateWorkItem?.cancel()
        let workItem = DispatchWorkItem { [weak self] in
            self?.updateDisplayedSamples(scale: newScale)
            self?.adjustScrollOffset(for: newScale, pinchCenter: gesture.location(in: self?.scrollView))
        }
        updateWorkItem = workItem
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.016, execute: workItem)
        
    case .ended, .cancelled:
        updateWorkItem?.cancel()
        let scaleFactor = gesture.scale / initialPinchDistance
        var finalScale = currentScale * scaleFactor
        finalScale = min(max(finalScale, minScale), maxScale)
        // 手势结束后强制更新一次,确保最终状态正确
        updateDisplayedSamples(scale: finalScale)
        currentScale = finalScale
        
    default:
        break
    }
}

// 把偏移调整逻辑抽出来,避免重复代码
private func adjustScrollOffset(for scale: CGFloat, pinchCenter: CGPoint) {
    let totalWidth = CGFloat(displayedSamples.count) * widthPerSample
    let offsetX = (pinchCenter.x - scrollView.bounds.origin.x) / scrollView.bounds.width
    let newOffsetX = (totalWidth * offsetX) - (pinchCenter.x - scrollView.bounds.origin.x)
    scrollView.contentOffset.x = max(0, min(newOffsetX, totalWidth - scrollView.bounds.width))
    view.layoutIfNeeded()
}

3. 优化波形绘制性能

替换draw(_:)里的循环路径绘制,改为批量绘制或缓存为图片,减少实时渲染开销:

方案A:缓存波形为UIImage

class WaveformView: UIView {
    var amplitudes: [CGFloat] = [] {
        didSet {
            // 生成缓存图片
            waveformImage = generateWaveformImage()
            setNeedsDisplay()
        }
    }
    private var waveformImage: UIImage?

    override func draw(_ rect: CGRect) {
        waveformImage?.draw(in: bounds)
    }

    private func generateWaveformImage() -> UIImage? {
        UIGraphicsBeginImageContextWithOptions(bounds.size, false, UIScreen.main.scale)
        guard let context = UIGraphicsGetCurrentContext(), !amplitudes.isEmpty else {
            UIGraphicsEndImageContext()
            return nil
        }

        context.setStrokeColor(UIColor.black.cgColor)
        context.setLineWidth(1.0)
        context.setLineCap(.round)

        let midY = bounds.height / 2
        let widthPerSample = bounds.width / CGFloat(amplitudes.count)

        let path = UIBezierPath()
        for (index, amplitude) in amplitudes.enumerated() {
            let x = CGFloat(index) * widthPerSample
            let height = amplitude * bounds.height * 0.8
            path.move(to: CGPoint(x: x, y: midY - height))
            path.addLine(to: CGPoint(x: x, y: midY + height))
        }
        path.stroke()

        let image = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()
        return image
    }
}

方案B:用Core Graphics批量绘制(减少路径操作)

override func draw(_ rect: CGRect) {
    guard let context = UIGraphicsGetCurrentContext(), !amplitudes.isEmpty else { return }

    context.setStrokeColor(UIColor.black.cgColor)
    context.setLineWidth(1.0)
    context.setLineCap(.round)

    let midY = rect.height / 2
    let widthPerSample = rect.width / CGFloat(amplitudes.count)

    // 直接用CGContext绘制,避免创建UIBezierPath的额外开销
    context.beginPath()
    for (index, amplitude) in amplitudes.enumerated() {
        let x = CGFloat(index) * widthPerSample
        let height = amplitude * rect.height * 0.8
        context.move(to: CGPoint(x: x, y: midY - height))
        context.addLine(to: CGPoint(x: x, y: midY + height))
    }
    context.strokePath()
}

4. 优化下采样算法

减少数组切片的开销,直接遍历数组计算最大值:

private func downsampleWaveform(samples: [CGFloat], targetCount: Int) -> [CGFloat] {
    guard samples.count > 0, targetCount > 0 else { return [] }
    
    if samples.count <= targetCount {
        return samples
    }
    
    var downsampled: [CGFloat] = []
    downsampled.reserveCapacity(targetCount) // 预分配内存,避免扩容开销
    
    let sampleSize = samples.count / targetCount
    var currentIndex = 0
    
    for _ in 0..<targetCount {
        let endIndex = min(currentIndex + sampleSize, samples.count)
        var maxValue: CGFloat = 0
        // 直接遍历,避免创建切片
        for i in currentIndex..<endIndex {
            if samples[i] > maxValue {
                maxValue = samples[i]
            }
        }
        downsampled.append(maxValue)
        currentIndex = endIndex
    }
    
    return downsampled
}

5. 利用UIScrollView内置缩放功能

放弃手动修改contentSize和offset的方式,改用UIScrollView的原生缩放机制,系统会自动优化缩放过程:

// 让ViewController遵守UIScrollViewDelegate
extension YourViewController: UIScrollViewDelegate {
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        return waveformView
    }
    
    func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) {
        // 缩放结束后,重新计算对应级别的下采样数据,更新波形
        let targetSampleCount = Int(baseWidth * scale)
        displayedSamples = downsampleWaveform(samples: rawSamples, targetCount: targetSampleCount)
        waveformView.amplitudes = displayedSamples
        // 重置缩放比例,避免视图被拉伸
        scrollView.setZoomScale(1.0, animated: false)
        // 更新contentSize适配新的采样数
        totalWidth = CGFloat(displayedSamples.count) * widthPerSample
        contentWidthConstraint?.constant = totalWidth
        scrollView.contentSize = CGSize(width: totalWidth, height: 300)
    }
}

内容的提问来源于stack exchange,提问作者Ahsan Habib Swassow

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.14 16:34:56