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会开启后台并行线程渲染各个瓦片,以此提升大尺寸内容的滚动流畅度。
这就触发了典型的多线程数组读写竞争:
- 后台渲染线程正在读取
values数组的count、遍历数组切片 - 主线程同时修改
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
相关产品推荐
相关产品推荐

