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

iOS UIScrollView内嵌波形视图绘制时触发EXC_BAD_ACCESS崩溃

问题描述

应用在UIScrollView上叠加绘制波形时出现偶发崩溃,崩溃交替出现在draw方法的两个代码位置,两处错误不会同时触发。

崩溃点1

触发代码:

if values.count < length {

崩溃信息:

Thread 79: EXC_BAD_ACCESS (code=1, address=0x11a668008)

崩溃点2

触发代码:

toDraw.forEach({

崩溃信息:

Thread 129: EXC_BAD_ACCESS (code=1, address=0x11e23f030)


相关代码

RecordWaveViewer 波形视图完整代码

import UIKit

final class RecordWaveViewer: UIView {

// MARK: - Properties

var liveLineColor = UIColor.white {
    didSet { update() }
}

var savedLineColor = UIColor.gray {
    didSet { update() }
}

var lineWidth: Float = 2.0 {
    didSet { update() }
}
var lineSeparation: Float = 2.0 {
    didSet { update() }
}
var values = [ValueElement]() {
    didSet { update() }
}
var replacementValues = [ValueElement]()

var showPlayingProgress = false {
    didSet { update() }
}
var playingProgress: CGFloat = 0.0 {
    didSet { update() }
}
var playingProgressLineColor = Asset.Colors.yellow.color {
    didSet { update() }
}
var offsetX: CGFloat = 0.0 {
    didSet { update() }
}
var waveWidth: CGFloat {
    CGFloat(values.count) * CGFloat(lineWidth + lineSeparation)
}
var actualWaveWidth: CGFloat {
    guard let lastActualIndex = values.lastIndex(where: { $0.type == .live }) else {
        return waveWidth
    }
    return CGFloat(lastActualIndex + 1) * CGFloat(lineWidth + lineSeparation)
}
var sampleWidth: CGFloat {
    return CGFloat(lineWidth + lineSeparation)
}

private var replacementStartIndex: Int?
    
private let fillingPercentage: CGFloat = 0.95
private var tiledLayer: CATiledLayer {
    layer as! CATiledLayer
}

override class var layerClass: AnyClass {
    CATiledLayer.self
}
override var transform: CGAffineTransform { // 禁止垂直缩放
    get { super.transform }
    set {
        var value = newValue
        value.d = 1.0
        super.transform = value
    }
}


// MARK: - Override
override func layoutSubviews() {
    super.layoutSubviews()
    
    // 配置瓦片参数
    tiledLayer.tileSize = CGSize(width: 2048, height: bounds.height * contentScaleFactor)
    
}

override func draw(_ rect: CGRect) {
    super.draw(rect)
}

override func draw(_ layer: CALayer, in ctx: CGContext) {
    super.draw(layer, in: ctx)
    // --------------------------- 绘制波形线
    let rect = ctx.boundingBoxOfClipPath
    let divider = CGFloat(lineWidth + lineSeparation)
    
    let offSet = Int(rect.origin.x / divider)
    var length = offSet + Int(rect.width / divider)
    
        if self.values.count < length {
            length = self.values.count
        }
        
        if (self.values.count > 0 ) {
            
            let toDraw = self.values[offSet..<length]
            
            // 线条起始偏移
            var xOffset: CGFloat = self.offsetX + rect.origin.x
            
            // 遍历绘制线条
            toDraw.forEach({
                var lineColor: UIColor
                switch $0.type {
                case .live:
                    lineColor = self.liveLineColor
                case .saved:
                    lineColor = self.savedLineColor
                }
                
                // 计算线条位置尺寸
                let height = rect.height * CGFloat($0.value) * self.fillingPercentage
                let y = (rect.height - height) / 2.0
                
                // 绘制线条
                ctx.setLineWidth(CGFloat(self.lineWidth))
                ctx.setLineCap(.round)
                ctx.move(to: CGPoint(x: xOffset, y: y))
                ctx.addLine(to: CGPoint(x: xOffset, y: y + height))
                ctx.setStrokeColor(lineColor.cgColor)
                if self.showPlayingProgress && xOffset >= self.playingProgress {
                    ctx.setStrokeColor(lineColor.withAlphaComponent(0.5).cgColor)
                }
                ctx.strokePath()
                
                // 累加x偏移
                xOffset += CGFloat(self.lineWidth + self.lineSeparation)
            })
         
    }
            
    // --------------------------- 绘制播放进度线
    if showPlayingProgress && playingProgress > 0.0 {
    
        ctx.setLineWidth(CGFloat(lineWidth))
        ctx.setStrokeColor(playingProgressLineColor.cgColor)
        ctx.setLineCap(.square)
        ctx.move(to: CGPoint(x: 1.0 + playingProgress, y: 0.0))
        ctx.addLine(to: CGPoint(x: 1.0 + playingProgress, y: rect.height))
        ctx.strokePath()
        
    }

}

// MARK: - 自定义方法
private func update() {
    
    DispatchQueue.main.async {
        self.setNeedsDisplay()
    }
    
}

func add(value: Float, time: TimeInterval) {
    if replacementStartIndex != nil {
        addReplacement(value: value, time: time)
    } else {
        DispatchQueue.main.async {
            self.values.append(ValueElement(value, .live, time: time))
        }
        
    }
}

func addReplacement(value: Float, time: TimeInterval) {
    DispatchQueue.main.async {
        let replacementValue = ValueElement(value, .live, time: time)
        self.replacementValues.append(replacementValue)
        let index = self.replacementValues.count - 1
        guard let replacementStartIndex = self.replacementStartIndex,
              replacementStartIndex < self.values.count,
              index >= 0 else {
                  return
              }
        let replacementIndex = replacementStartIndex + index
        
        if replacementIndex < self.values.count {
            self.values[replacementIndex] = replacementValue
        } else {
            self.values.append(replacementValue)
        }
    }
}

func reset() {
    DispatchQueue.main.async {
        self.values.removeAll()
    }
    update()
    
}

func convertValuesToSaved() {
    DispatchQueue.main.async {
        self.values = self.values.compactMap({ ValueElement($0.value, .saved, $0.time )})
        self.replacementValues.removeAll()
        self.replacementStartIndex = nil
    }
}

func startReplacement(at time: TimeInterval) {
    DispatchQueue.main.async {
        self.replacementStartIndex = self.values.firstIndex(where: { $0.time >= time })
    }
}
}

// MARK: - 嵌套类型
extension RecordWaveViewer {
typealias ValueElement = (value: Float, type: ValueType, time: TimeInterval)

enum ValueType {
    case saved
    case live
}
}

外部更新values的调用代码

  • 调用1(追加实时波形点):
DispatchQueue.main.async {
   self.waveViewer.add(value: value, time: time)
}
  • 调用2(批量赋值已保存波形数据):
DispatchQueue.main.async {
  self.waveViewer.values = values.compactMap({ RecordWaveViewer.ValueElement(value: $0.power, type: .saved, time: $0.time)})
}

崩溃根因

所有数据更新虽然都放在主线程执行,但CATiledLayer的绘制回调draw(_:in:)默认不在主线程执行:CATiledLayer会开启后台并行线程渲染各个瓦片,以此提升大尺寸内容的滚动流畅度。
这就触发了典型的多线程数组读写竞争:

  1. 后台渲染线程正在读取values数组的count、遍历数组切片
  2. 主线程同时修改values数组(追加元素、全量替换、删除元素、修改元素)
    Swift的Array是值类型但不具备线程安全性,数组修改时会触发内存重新分配,此时后台线程访问已经被释放的旧数组内存,就会触发EXC_BAD_ACCESS野指针崩溃,这也是崩溃点随机出现在count读取、数组遍历两个位置的核心原因。

另外两个会加剧崩溃的细节问题:

  • reset()方法中先异步在主线程清空values,又直接调用update()触发绘制,会放大线程竞争概率
  • 计算offSet时没有做边界保护,如果offSet大于values.count,会直接触发数组越界

修复方案

1. 绘制逻辑读取values时生成只读快照

在draw(_:in:)方法最开头,将当前values拷贝为本地不可变常量,后续所有绘制逻辑都基于这份快照操作,保证绘制过程中读取的数组不会被其他线程修改:

override func draw(_ layer: CALayer, in ctx: CGContext) {
    super.draw(layer, in: ctx)
    // 生成不可变本地快照,整个绘制流程仅使用这份数据,避免和主线程写操作竞争
    let drawValues = self.values
    // --------------------------- 绘制波形线
    let rect = ctx.boundingBoxOfClipPath
    let divider = CGFloat(lineWidth + lineSeparation)
    
    let offSet = max(0, Int(rect.origin.x / divider))
    var length = offSet + Int(rect.width / divider)
    // 所有count判断、切片操作都使用本地快照drawValues
    if drawValues.count < length {
        length = drawValues.count
    }
    // 增加offset边界判断,避免越界
    guard offSet < drawValues.count else { return }
    
    let toDraw = drawValues[offSet..<length]
    // 后续原有绘制逻辑保持不变,将所有访问self.values的位置替换为drawValues即可
    // ... 省略原有绘制代码 ...
}

2. 修正reset方法的线程调用问题

将reset()里的清空操作和UI刷新统一放在主线程执行,避免跨线程触发绘制:

func reset() {
    DispatchQueue.main.async {
        self.values.removeAll()
        self.setNeedsDisplay()
    }
}

3. (可选优化)按需关闭CATiledLayer并行渲染

如果波形数据量不大,不需要瓦片渲染的内存优化,可以直接关闭CATiledLayer的异步绘制,彻底杜绝后台线程访问UI数据:

override func layoutSubviews() {
    super.layoutSubviews()
    tiledLayer.tileSize = CGSize(width: 2048, height: bounds.height * contentScaleFactor)
    // 关闭异步绘制,所有绘制逻辑走主线程
    tiledLayer.drawsAsynchronously = false
}

注意:如果波形数据量较大(上万采样点),关闭异步绘制可能导致滚动掉帧,优先使用快照拷贝方案更稳妥。


内容的提问来源于stack exchange,提问作者uzair

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.26 21:09:18