Skip to content

Commit 3e8b813

Browse files
authored
[video_player] Fix iOS crash with multiple players (flutter#4202)
This PR fixes crash in `video_player` package on iOS. flutter/flutter#124937 ### Detailed description I can observe the crash when displaying a couple of the different players (different URLs) inside a `ListView`. The crash happens inside of `AVFoundation` framework: ```objc [AVPlayer _createAndConfigureFigPlayerWithType:completionHandler:] ``` In order to debug the issue, I ran the application using the plugin with `Zombie Objects` inspection turned on. The `Zombie Objects` reports the following issue: ``` *** -[FLTVideoPlayer retainWeakReference]: message sent to deallocated instance 0x6030009b2e10 ``` This, in conjunction with the `NSKeyValueWillChange` line present in the stack trace led me to believe, that culprit is sending a KVO notification to the `FLTVideoPlayer` instance that's deallocated. Next, I examined the plugin code and identified one property that doesn't have the KVO removed. In `addObserversForItem:player:` method in `FLTVideoPlayerPlugin.m`: ``` [player addObserver:self forKeyPath:@"rate" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:rateContext]; ``` The observer for `@"rate"` is never cleaned up. To be entirely sure that the issue comes from KVO that's not cleaned up, I've added the following class: ``` @implementation EmptyObserver - (void)observeValueForKeyPath:(NSString *)path ofObject:(id)object change:(NSDictionary *)change context:(void *)context {} @EnD ``` and registered the observation as follows (notice that `EmptyObserver` is never retained and deallocated instantly): ``` [player addObserver:[[EmptyObserver alloc] init] forKeyPath:@"rate" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:rateContext]; ``` The exception I got seems to be matching the underlying issue: ``` *** -[EmptyObserver retainWeakReference]: message sent to deallocated instance 0x6020001ceb70 ``` This means the fix for the issue is to add the following to `disposeSansEventChannel` method: ``` [self.player removeObserver:self forKeyPath:@"rate"]; ``` After applying the patch, I can no longer crash the player.
1 parent d4d761b commit 3e8b813

File tree

4 files changed

+121
-1
lines changed

4 files changed

+121
-1
lines changed

packages/video_player/video_player_avfoundation/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 2.4.9
2+
3+
* Fixes the iOS crash when using multiple players on the same screen.
4+
See: https://github.com/flutter/flutter/issues/124937
5+
16
## 2.4.8
27

38
* Fixes missing `isPlaybackLikelyToKeepUp` check for iOS video player `bufferingEnd` event and `bufferingStart` event.

packages/video_player/video_player_avfoundation/example/ios/RunnerTests/VideoPlayerTests.m

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ @interface FLTVideoPlayer : NSObject <FlutterStreamHandler>
1414
@property(readonly, nonatomic) AVPlayer *player;
1515
@property(readonly, nonatomic) AVPlayerLayer *playerLayer;
1616
@property(readonly, nonatomic) int64_t position;
17+
18+
- (void)onTextureUnregistered:(NSObject<FlutterTexture> *)texture;
1719
@end
1820

1921
@interface FLTVideoPlayerPlugin (Test) <FLTAVFoundationVideoPlayerApi>
@@ -442,6 +444,110 @@ - (void)testSeekToleranceWhenSeekingToEnd {
442444
return initializationEvent;
443445
}
444446

447+
// Checks whether [AVPlayer rate] KVO observations are correctly detached.
448+
// - https://github.com/flutter/flutter/issues/124937
449+
//
450+
// Failing to de-register results in a crash in [AVPlayer willChangeValueForKey:].
451+
- (void)testDoesNotCrashOnRateObservationAfterDisposal {
452+
NSObject<FlutterPluginRegistry> *registry =
453+
(NSObject<FlutterPluginRegistry> *)[[UIApplication sharedApplication] delegate];
454+
NSObject<FlutterPluginRegistrar> *registrar =
455+
[registry registrarForPlugin:@"testDoesNotCrashOnRateObservationAfterDisposal"];
456+
457+
AVPlayer *avPlayer = nil;
458+
__weak FLTVideoPlayer *player = nil;
459+
460+
// Autoreleasepool is needed to simulate conditions of FLTVideoPlayer deallocation.
461+
@autoreleasepool {
462+
FLTVideoPlayerPlugin *videoPlayerPlugin =
463+
(FLTVideoPlayerPlugin *)[[FLTVideoPlayerPlugin alloc] initWithRegistrar:registrar];
464+
465+
FlutterError *error;
466+
[videoPlayerPlugin initialize:&error];
467+
XCTAssertNil(error);
468+
469+
FLTCreateMessage *create = [FLTCreateMessage
470+
makeWithAsset:nil
471+
uri:@"https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4"
472+
packageName:nil
473+
formatHint:nil
474+
httpHeaders:@{}];
475+
FLTTextureMessage *textureMessage = [videoPlayerPlugin create:create error:&error];
476+
XCTAssertNil(error);
477+
XCTAssertNotNil(textureMessage);
478+
479+
player = videoPlayerPlugin.playersByTextureId[textureMessage.textureId];
480+
XCTAssertNotNil(player);
481+
avPlayer = player.player;
482+
483+
[videoPlayerPlugin dispose:textureMessage error:&error];
484+
XCTAssertNil(error);
485+
}
486+
487+
// [FLTVideoPlayerPlugin dispose:error:] selector is dispatching the [FLTVideoPlayer dispose] call
488+
// with a 1-second delay keeping a strong reference to the player. The polling ensures the player
489+
// was truly deallocated.
490+
[self expectationForPredicate:[NSPredicate predicateWithFormat:@"self != nil"]
491+
evaluatedWithObject:player
492+
handler:nil];
493+
[self waitForExpectationsWithTimeout:10.0 handler:nil];
494+
495+
[avPlayer willChangeValueForKey:@"rate"]; // No assertions needed. Lack of crash is a success.
496+
}
497+
498+
// During the hot reload:
499+
// 1. `[FLTVideoPlayer onTextureUnregistered:]` gets called.
500+
// 2. `[FLTVideoPlayerPlugin initialize:]` gets called.
501+
//
502+
// Both of these methods dispatch [FLTVideoPlayer dispose] on the main thread
503+
// leading to a possible crash when de-registering observers twice.
504+
- (void)testHotReloadDoesNotCrash {
505+
NSObject<FlutterPluginRegistry> *registry =
506+
(NSObject<FlutterPluginRegistry> *)[[UIApplication sharedApplication] delegate];
507+
NSObject<FlutterPluginRegistrar> *registrar =
508+
[registry registrarForPlugin:@"testHotReloadDoesNotCrash"];
509+
510+
__weak FLTVideoPlayer *player = nil;
511+
512+
// Autoreleasepool is needed to simulate conditions of FLTVideoPlayer deallocation.
513+
@autoreleasepool {
514+
FLTVideoPlayerPlugin *videoPlayerPlugin =
515+
(FLTVideoPlayerPlugin *)[[FLTVideoPlayerPlugin alloc] initWithRegistrar:registrar];
516+
517+
FlutterError *error;
518+
[videoPlayerPlugin initialize:&error];
519+
XCTAssertNil(error);
520+
521+
FLTCreateMessage *create = [FLTCreateMessage
522+
makeWithAsset:nil
523+
uri:@"https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4"
524+
packageName:nil
525+
formatHint:nil
526+
httpHeaders:@{}];
527+
FLTTextureMessage *textureMessage = [videoPlayerPlugin create:create error:&error];
528+
XCTAssertNil(error);
529+
XCTAssertNotNil(textureMessage);
530+
531+
player = videoPlayerPlugin.playersByTextureId[textureMessage.textureId];
532+
XCTAssertNotNil(player);
533+
534+
[player onTextureUnregistered:nil];
535+
XCTAssertNil(error);
536+
537+
[videoPlayerPlugin initialize:&error];
538+
XCTAssertNil(error);
539+
}
540+
541+
// [FLTVideoPlayerPlugin dispose:error:] selector is dispatching the [FLTVideoPlayer dispose] call
542+
// with a 1-second delay keeping a strong reference to the player. The polling ensures the player
543+
// was truly deallocated.
544+
[self expectationForPredicate:[NSPredicate predicateWithFormat:@"self != nil"]
545+
evaluatedWithObject:player
546+
handler:nil];
547+
[self waitForExpectationsWithTimeout:10.0
548+
handler:nil]; // No assertions needed. Lack of crash is a success.
549+
}
550+
445551
- (void)validateTransformFixForOrientation:(UIImageOrientation)orientation {
446552
AVAssetTrack *track = [[FakeAVAssetTrack alloc] initWithOrientation:orientation];
447553
CGAffineTransform t = FLTGetStandardizedTransformForTrack(track);

packages/video_player/video_player_avfoundation/ios/Classes/FLTVideoPlayerPlugin.m

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,14 @@ - (FlutterError *_Nullable)onListenWithArguments:(id _Nullable)arguments
505505
/// is useful for the case where the Engine is in the process of deconstruction
506506
/// so the channel is going to die or is already dead.
507507
- (void)disposeSansEventChannel {
508+
// This check prevents the crash caused by removing the KVO observers twice.
509+
// When performing a Hot Restart, the leftover players are disposed once directly
510+
// by [FLTVideoPlayerPlugin initialize:] method and then disposed again by
511+
// [FLTVideoPlayer onTextureUnregistered:] call leading to possible over-release.
512+
if (_disposed) {
513+
return;
514+
}
515+
508516
_disposed = YES;
509517
[_playerLayer removeFromSuperlayer];
510518
[_displayLink invalidate];
@@ -514,6 +522,7 @@ - (void)disposeSansEventChannel {
514522
[currentItem removeObserver:self forKeyPath:@"presentationSize"];
515523
[currentItem removeObserver:self forKeyPath:@"duration"];
516524
[currentItem removeObserver:self forKeyPath:@"playbackLikelyToKeepUp"];
525+
[self.player removeObserver:self forKeyPath:@"rate"];
517526

518527
[self.player replaceCurrentItemWithPlayerItem:nil];
519528
[[NSNotificationCenter defaultCenter] removeObserver:self];

packages/video_player/video_player_avfoundation/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: video_player_avfoundation
22
description: iOS implementation of the video_player plugin.
33
repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_avfoundation
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22
5-
version: 2.4.8
5+
version: 2.4.9
66

77
environment:
88
sdk: ">=2.18.0 <4.0.0"

0 commit comments

Comments
 (0)