Skip to content

Commit

Permalink
Make iOS FlutterViewController stop sending inactive/pause on app lif…
Browse files Browse the repository at this point in the history
…ecycle events when not visible (flutter#12128)
  • Loading branch information
xster authored Sep 17, 2019
1 parent 709fc6e commit 4159c2b
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 82 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -537,23 +537,32 @@ - (void)applicationBecameActive:(NSNotification*)notification {
TRACE_EVENT0("flutter", "applicationBecameActive");
if (_viewportMetrics.physical_width)
[self surfaceUpdated:YES];
[[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.resumed"];
[self goToApplicationLifecycle:@"AppLifecycleState.resumed"];
}

- (void)applicationWillResignActive:(NSNotification*)notification {
TRACE_EVENT0("flutter", "applicationWillResignActive");
[self surfaceUpdated:NO];
[[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.inactive"];
[self goToApplicationLifecycle:@"AppLifecycleState.inactive"];
}

- (void)applicationDidEnterBackground:(NSNotification*)notification {
TRACE_EVENT0("flutter", "applicationDidEnterBackground");
[[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.paused"];
[self goToApplicationLifecycle:@"AppLifecycleState.paused"];
}

- (void)applicationWillEnterForeground:(NSNotification*)notification {
TRACE_EVENT0("flutter", "applicationWillEnterForeground");
[[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.inactive"];
[self goToApplicationLifecycle:@"AppLifecycleState.inactive"];
}

// Make this transition only while this current view controller is visible.
- (void)goToApplicationLifecycle:(nonnull NSString*)state {
// Accessing self.view will create the view. Check whether the view is organically loaded
// first before checking whether the view is attached to window.
if (self.isViewLoaded && self.view.window) {
[[_engine.get() lifecycleChannel] sendMessage:state];
}
}

#pragma mark - Touch event handling
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

@implementation ScreenBeforeFlutter

FlutterEngine* _engine;
@synthesize engine = _engine;

- (id)initWithEngineRunCompletion:(void (^)(void))engineRunCompletion {
self = [super init];
Expand Down
228 changes: 151 additions & 77 deletions testing/scenario_app/ios/Scenarios/ScenariosTests/AppLifecycleTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@
#import <XCTest/XCTest.h>
#import "ScreenBeforeFlutter.h"

@interface XCAppLifecycleTestExpectation : XCTestExpectation

- (instancetype)initForLifecycle:(NSString*)expectedLifecycle forStep:(NSString*)step;
@property(nonatomic, readonly, copy) NSString* expectedLifecycle;

@end

@implementation XCAppLifecycleTestExpectation

@synthesize expectedLifecycle = _expectedLifecycle;
- (instancetype)initForLifecycle:(NSString*)expectedLifecycle forStep:(NSString*)step {
// The step is here because the callbacks into the handler which checks these expectations isn't
// synchronous with the executions in the test, so it's hard to find the cause in the test
// otherwise.
self = [super initWithDescription:[NSString stringWithFormat:@"Expected state %@ during step %@",
expectedLifecycle, step]];
_expectedLifecycle = [expectedLifecycle copy];
return self;
}

@end

@interface AppLifecycleTests : XCTestCase
@end

Expand All @@ -16,7 +38,7 @@ - (void)setUp {
self.continueAfterFailure = NO;
}

- (void)testLifecycleChannel {
- (void)testDismissedFlutterViewControllerNotRespondingToApplicationLifecycle {
XCTestExpectation* engineStartedExpectation = [self expectationWithDescription:@"Engine started"];

// Let the engine finish booting (at the end of which the channels are properly set-up) before
Expand All @@ -32,121 +54,173 @@ - (void)testLifecycleChannel {
FlutterEngine* engine = rootVC.engine;

NSMutableArray* lifecycleExpectations = [NSMutableArray arrayWithCapacity:10];
NSMutableArray* lifecycleEvents = [NSMutableArray arrayWithCapacity:10];

[lifecycleExpectations addObject:[[XCTestExpectation alloc]
initWithDescription:@"A loading FlutterViewController goes "
@"through AppLifecycleState.inactive"]];
[lifecycleExpectations
addObject:[[XCTestExpectation alloc]
initWithDescription:
@"A loading FlutterViewController goes through AppLifecycleState.resumed"]];

// Expected sequence from showing the FlutterViewController is inactive and resumed.
[lifecycleExpectations addObjectsFromArray:@[
[[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.inactive"
forStep:@"showing a FlutterViewController"],
[[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.resumed"
forStep:@"showing a FlutterViewController"]
]];

// Holding onto this FlutterViewController is consequential here. Since a released
// FlutterViewController wouldn't keep listening to the application lifecycle events and produce
// false positives for the application lifecycle tests further below.
FlutterViewController* flutterVC = [rootVC showFlutter];
[engine.lifecycleChannel setMessageHandler:^(id message, FlutterReply callback) {
if (lifecycleExpectations.count == 0) {
XCTFail(@"Unexpected lifecycle transition: %@", message);
return;
}
XCAppLifecycleTestExpectation* nextExpectation = [lifecycleExpectations objectAtIndex:0];
if (![[nextExpectation expectedLifecycle] isEqualToString:message]) {
XCTFail(@"Expected lifecycle %@ but instead received %@", [nextExpectation expectedLifecycle],
message);
return;
}
[lifecycleEvents addObject:message];
[[lifecycleExpectations objectAtIndex:0] fulfill];

[nextExpectation fulfill];
[lifecycleExpectations removeObjectAtIndex:0];
}];

[self waitForExpectations:lifecycleExpectations timeout:5];

// Expected sequence from showing the FlutterViewController is inactive and resumed.
NSArray* expectedStates = @[ @"AppLifecycleState.inactive", @"AppLifecycleState.resumed" ];
XCTAssertEqualObjects(lifecycleEvents, expectedStates,
@"AppLifecycleState transitions while presenting not as expected");
// The expectations list isn't dequeued by the message handler yet.
[self waitForExpectations:lifecycleExpectations timeout:5 enforceOrder:YES];

// Now dismiss the FlutterViewController again and expect another inactive and paused.
[lifecycleExpectations
addObject:[[XCTestExpectation alloc]
initWithDescription:@"A dismissed FlutterViewController goes through "
@"AppLifecycleState.inactive"]];
[lifecycleExpectations
addObject:[[XCTestExpectation alloc]
initWithDescription:@"A dismissed FlutterViewController goes through "
@"AppLifecycleState.paused"]];
[lifecycleExpectations addObjectsFromArray:@[
[[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.inactive"
forStep:@"dismissing a FlutterViewController"],
[[XCAppLifecycleTestExpectation alloc]
initForLifecycle:@"AppLifecycleState.paused"
forStep:@"dismissing a FlutterViewController"]
]];
[flutterVC dismissViewControllerAnimated:NO completion:nil];
[self waitForExpectations:lifecycleExpectations timeout:5];
expectedStates = @[
@"AppLifecycleState.inactive", @"AppLifecycleState.resumed", @"AppLifecycleState.inactive",
@"AppLifecycleState.paused"
];
XCTAssertEqualObjects(lifecycleEvents, expectedStates,
@"AppLifecycleState transitions while dismissing not as expected");
[self waitForExpectations:lifecycleExpectations timeout:5 enforceOrder:YES];

// Now put the app in the background (while the engine is still running) and bring it back to
// the foreground. Granted, we're not winning any awards for hyper-realism but at least we're
// checking that we aren't observing the UIApplication notifications and double registering
// for AppLifecycleState events.

// However the production is temporarily wrong. https://github.com/flutter/flutter/issues/37226.
// It will be fixed in a next PR that removes the wrong asserts.
[lifecycleExpectations
addObject:
[[XCTestExpectation alloc]
initWithDescription:@"Current implementation sends another AppLifecycleState event"]];
// These operations are synchronous so if they trigger any lifecycle events, they should trigger
// failures in the message handler immediately.
[[NSNotificationCenter defaultCenter]
postNotificationName:UIApplicationWillResignActiveNotification
object:nil];
[lifecycleExpectations
addObject:
[[XCTestExpectation alloc]
initWithDescription:@"Current implementation sends another AppLifecycleState event"]];
[[NSNotificationCenter defaultCenter]
postNotificationName:UIApplicationDidEnterBackgroundNotification
object:nil];
[lifecycleExpectations
addObject:
[[XCTestExpectation alloc]
initWithDescription:@"Current implementation sends another AppLifecycleState event"]];
[[NSNotificationCenter defaultCenter]
postNotificationName:UIApplicationWillEnterForegroundNotification
object:nil];
[lifecycleExpectations
addObject:
[[XCTestExpectation alloc]
initWithDescription:@"Current implementation sends another AppLifecycleState event"]];
[[NSNotificationCenter defaultCenter]
postNotificationName:UIApplicationDidBecomeActiveNotification
object:nil];

// There's no timing latch for our semi-fake background-foreground cycle so launch the
// FlutterViewController again to check the complete event list again.
[lifecycleExpectations addObject:[[XCTestExpectation alloc]
initWithDescription:@"A second FlutterViewController goes "
@"through AppLifecycleState.inactive"]];
[lifecycleExpectations
addObject:[[XCTestExpectation alloc]
initWithDescription:
@"A second FlutterViewController goes through AppLifecycleState.resumed"]];

// Expect only lifecycle events from showing the FlutterViewController again, not from any
// backgrounding/foregrounding.
[lifecycleExpectations addObjectsFromArray:@[
[[XCAppLifecycleTestExpectation alloc]
initForLifecycle:@"AppLifecycleState.inactive"
forStep:@"showing a FlutterViewController a second time after backgrounding"],
[[XCAppLifecycleTestExpectation alloc]
initForLifecycle:@"AppLifecycleState.resumed"
forStep:@"showing a FlutterViewController a second time after backgrounding"]
]];
flutterVC = [rootVC showFlutter];
[self waitForExpectations:lifecycleExpectations timeout:5 enforceOrder:YES];

// Dismantle.
[engine.lifecycleChannel setMessageHandler:nil];
[flutterVC dismissViewControllerAnimated:NO completion:nil];
[engine setViewController:nil];
}

- (void)testVisibleFlutterViewControllerRespondsToApplicationLifecycle {
XCTestExpectation* engineStartedExpectation = [self expectationWithDescription:@"Engine started"];

// Let the engine finish booting (at the end of which the channels are properly set-up) before
// moving onto the next step of showing the next view controller.
ScreenBeforeFlutter* rootVC = [[ScreenBeforeFlutter alloc] initWithEngineRunCompletion:^void() {
[engineStartedExpectation fulfill];
}];

[self waitForExpectationsWithTimeout:5 handler:nil];

UIApplication* application = UIApplication.sharedApplication;
application.delegate.window.rootViewController = rootVC;
FlutterEngine* engine = rootVC.engine;

NSMutableArray* lifecycleExpectations = [NSMutableArray arrayWithCapacity:10];

// Expected sequence from showing the FlutterViewController is inactive and resumed.
[lifecycleExpectations addObjectsFromArray:@[
[[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.inactive"
forStep:@"showing a FlutterViewController"],
[[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.resumed"
forStep:@"showing a FlutterViewController"]
]];

FlutterViewController* flutterVC = [rootVC showFlutter];
[engine.lifecycleChannel setMessageHandler:^(id message, FlutterReply callback) {
if (lifecycleExpectations.count == 0) {
XCTFail(@"Unexpected lifecycle transition: %@", message);
return;
}
XCAppLifecycleTestExpectation* nextExpectation = [lifecycleExpectations objectAtIndex:0];
if (![[nextExpectation expectedLifecycle] isEqualToString:message]) {
XCTFail(@"Expected lifecycle %@ but instead received %@", [nextExpectation expectedLifecycle],
message);
return;
}

[nextExpectation fulfill];
[lifecycleExpectations removeObjectAtIndex:0];
}];

[self waitForExpectations:lifecycleExpectations timeout:5];

// Now put the FlutterViewController into background.
[lifecycleExpectations addObjectsFromArray:@[
[[XCAppLifecycleTestExpectation alloc]
initForLifecycle:@"AppLifecycleState.inactive"
forStep:@"putting FlutterViewController to the background"],
[[XCAppLifecycleTestExpectation alloc]
initForLifecycle:@"AppLifecycleState.paused"
forStep:@"putting FlutterViewController to the background"]
]];
[[NSNotificationCenter defaultCenter]
postNotificationName:UIApplicationWillResignActiveNotification
object:nil];
[[NSNotificationCenter defaultCenter]
postNotificationName:UIApplicationDidEnterBackgroundNotification
object:nil];
[self waitForExpectations:lifecycleExpectations timeout:5];

// Now restore to foreground
[lifecycleExpectations addObjectsFromArray:@[
[[XCAppLifecycleTestExpectation alloc]
initForLifecycle:@"AppLifecycleState.inactive"
forStep:@"putting FlutterViewController back to foreground"],
[[XCAppLifecycleTestExpectation alloc]
initForLifecycle:@"AppLifecycleState.resumed"
forStep:@"putting FlutterViewController back to foreground"]
]];
[[NSNotificationCenter defaultCenter]
postNotificationName:UIApplicationWillEnterForegroundNotification
object:nil];
[[NSNotificationCenter defaultCenter]
postNotificationName:UIApplicationDidBecomeActiveNotification
object:nil];
[self waitForExpectations:lifecycleExpectations timeout:5];
expectedStates = @[
@"AppLifecycleState.inactive", @"AppLifecycleState.resumed", @"AppLifecycleState.inactive",
@"AppLifecycleState.paused",

// The production code currently misbehaves. https://github.com/flutter/flutter/issues/37226.
// It will be fixed in a next PR that removes the wrong asserts.
@"AppLifecycleState.inactive", @"AppLifecycleState.paused", @"AppLifecycleState.inactive",
@"AppLifecycleState.resumed",

// We only added 2 from re-launching the FlutterViewController
// and none from the background-foreground cycle.
@"AppLifecycleState.inactive", @"AppLifecycleState.resumed"
];
XCTAssertEqualObjects(
lifecycleEvents, expectedStates,
@"AppLifecycleState transitions while presenting a second time not as expected");

// Dismantle.
[engine.lifecycleChannel setMessageHandler:nil];
[flutterVC dismissViewControllerAnimated:NO completion:nil];
flutterVC = nil;
[engine setViewController:nil];
[rootVC dismissViewControllerAnimated:NO completion:nil];
rootVC = nil;
}

@end

0 comments on commit 4159c2b

Please sign in to comment.