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
相关产品推荐
相关产品推荐

