记一个AVAudioPlayer Crash修复

背景

之前曾经遇到过一个AVAudioPlayer的多线程crash,这里记录一下。

crash堆栈如下所示

1
2
3
4
5
6
7
8
9
10
Crashed: com.apple.main-thread
0 libobjc.A.dylib 0x1834fc910 objc_msgSend + 16
1 AVFAudio 0x189c814f4 -[AVAudioPlayer(AVAudioPlayerPriv) finishedPlaying:] + 92
2 Foundation 0x184d7a0ec __NSThreadPerformPerform + 340
3 CoreFoundation 0x1842d7404 CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION + 24
4 CoreFoundation 0x1842d6c2c __CFRunLoopDoSources0 + 276
5 CoreFoundation 0x1842d479c __CFRunLoopRun + 1204
6 CoreFoundation 0x1841f4da8 CFRunLoopRunSpecific + 552
7 GraphicsServices 0x1861d9020 GSEventRunModal + 100
8 UIKit 0x18e211758 UIApplicationMain + 236

通过查询,发现也有其他同学遇到了这个问题:

也尝试了一些网上的解决方案,发现并不奏效:

分析

观察堆栈,应该是在finishedPlaying:方法中访问了非法地址,而_objc_msgSend则大概率预示着这是一个多线程问题。
AVAudioPlayer播放声音是异步的,另起一个播放线程,并不在主线程。当声音播放完全之后,再切换到主线程调用AVAudioPlayer的finishedPlaying:方法,从而调用delegate的方法。而当从播放线程切换到主线程的过程当中,AVAudioPlayer会被引用,并不会被释放。这样当AVAudioPlayer的代理被释放后,AVAudioPlayer还是有可能调用了 delegate的方法。

通过google,也可以发现类似的解释

1
2
3
4
The cause of this problem was that the AVAudioPlayer object was trying
to invoke [avAudioPlayer.delegate audioPlayerDidFinishPlaying] but the
avAudioPlayer.delegate ref was no longer valid because it had been
deallocated when releasing the avAudioPlayer in my code

通过查看AVAudioPlayer.h头文件,发现其代理是weak的,同时该crash几乎通吃所有的系统版本和机型,这让人十分费解

1
2
3
4
5
6
7
8
9
10
// AVAudioPlayer.h
@interface AVAudioPlayer : NSObject {
@private
id _impl;
}

// the delegate will be sent messages from the AVAudioPlayerDelegate protocol
@property(weak, nullable) id<AVAudioPlayerDelegate> delegate;

@end

调试

由于finishedPlaying:并没有暴露为共有方法,为了能看下堆栈,所以hook了finishedPlaying:方法

通过调试,发现原AVAudioPlayer有一个内部实现类AudioPlayerImpl,这个内部实现类还有一个成员变量_delegate

进而详细查看了AudioPlayerImpl的头文件:AudioPlayerImpl的头文件

通过验证,发现这两个代理都指向同一个对象:

1
2
3
4
5
 (lldb) po self.delegate
<ThreadViewController: 0x7ff3334025e0>

Printing description of self->_impl->_delegate:
<ThreadViewController: 0x7ff3334025e0>

我们不由地怀疑这个内部类AudioPlayerImpl的成员变量_delegate有问题,初步推断crash是这样产生的:

假设AVAudioPlayerdelegate为A,内部类AudioPlayerImpl的成员变量_delegate为B

正常设计上,外部代理释放的时候,weak的A会自动变为nil,同时也应该将B设置为nil,并且要保证原子性。但是可能有些情况下A和B的变化并没有保持绝对同步,导致A变为nil的时候,B仍然指向一个非法的地址,如果此时访问B,便会crash。

解决办法

朝着这个怀疑点,尝试修复一下,原理很简单:

在执行方法finishedPlaying之前,先判断AVAudioPlayer的代理delegate是否为nil

  • 如果是nil,将AudioPlayerImpl的成员变量_delegate也设置为nil
  • 否则,强持有AVAudioPlayer的代理delegate,保证finishedPlaying方法执行期间不被释放;
    代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@implementation AVAudioPlayer (Extension)

+ (void)load {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
dm_swizzleSelector([self class], @selector(finishedPlaying:), @selector(dm_finishedPlaying:));
#pragma clang diagnostic pop
}

- (void)dm_finishedPlaying:(id)success {

Ivar ivar = class_getInstanceVariable([self class], "_impl");
id impl = object_getIvar(self, ivar);
NSString *implClassName = [@[@"Aud", @"ioPl", @"aye", @"rIm", @"pl"] componentsJoinedByString:@""];
Ivar implDelegate = class_getInstanceVariable(NSClassFromString(implClassName), "_delegate");

// if the weak delegate of AVAudioPlayer is nil, you should set the assign delegate of AudioPlayerImpl to nil too.
if (!self.delegate) {
object_setIvar(impl, implDelegate, nil);
}

// retain self.delegate to keep it alive during the execution of the finishedPlaying: method
id __strong strongDelegate = self.delegate;

// call the original method finishedPlaying:
[self dm_finishedPlaying:success];

// this line doesn't make sense, just to avoid warning and optimization by the complier
strongDelegate = nil;
}

@end

验证

通过上线验证后,该修复方法的确生效了

-------------本文结束 感谢您的阅读-------------

本文标题:记一个AVAudioPlayer Crash修复

文章作者:lingyun

发布时间:2021年05月10日 - 13:05

最后更新:2021年05月10日 - 14:05

原始链接:https://tsuijunxi.github.io/2021/05/10/AVAudioPlayer/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。