Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit 4159c2b

Browse files
authored
Make iOS FlutterViewController stop sending inactive/pause on app lifecycle events when not visible (#12128)
1 parent 709fc6e commit 4159c2b

3 files changed

Lines changed: 165 additions & 82 deletions

File tree

shell/platform/darwin/ios/framework/Source/FlutterViewController.mm

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -537,23 +537,32 @@ - (void)applicationBecameActive:(NSNotification*)notification {
537537
TRACE_EVENT0("flutter", "applicationBecameActive");
538538
if (_viewportMetrics.physical_width)
539539
[self surfaceUpdated:YES];
540-
[[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.resumed"];
540+
[self goToApplicationLifecycle:@"AppLifecycleState.resumed"];
541541
}
542542

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

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

554554
- (void)applicationWillEnterForeground:(NSNotification*)notification {
555555
TRACE_EVENT0("flutter", "applicationWillEnterForeground");
556-
[[_engine.get() lifecycleChannel] sendMessage:@"AppLifecycleState.inactive"];
556+
[self goToApplicationLifecycle:@"AppLifecycleState.inactive"];
557+
}
558+
559+
// Make this transition only while this current view controller is visible.
560+
- (void)goToApplicationLifecycle:(nonnull NSString*)state {
561+
// Accessing self.view will create the view. Check whether the view is organically loaded
562+
// first before checking whether the view is attached to window.
563+
if (self.isViewLoaded && self.view.window) {
564+
[[_engine.get() lifecycleChannel] sendMessage:state];
565+
}
557566
}
558567

559568
#pragma mark - Touch event handling

testing/scenario_app/ios/Scenarios/Scenarios/ScreenBeforeFlutter.m

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
@implementation ScreenBeforeFlutter
99

10-
FlutterEngine* _engine;
10+
@synthesize engine = _engine;
1111

1212
- (id)initWithEngineRunCompletion:(void (^)(void))engineRunCompletion {
1313
self = [super init];

testing/scenario_app/ios/Scenarios/ScenariosTests/AppLifecycleTests.m

Lines changed: 151 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,28 @@
66
#import <XCTest/XCTest.h>
77
#import "ScreenBeforeFlutter.h"
88

9+
@interface XCAppLifecycleTestExpectation : XCTestExpectation
10+
11+
- (instancetype)initForLifecycle:(NSString*)expectedLifecycle forStep:(NSString*)step;
12+
@property(nonatomic, readonly, copy) NSString* expectedLifecycle;
13+
14+
@end
15+
16+
@implementation XCAppLifecycleTestExpectation
17+
18+
@synthesize expectedLifecycle = _expectedLifecycle;
19+
- (instancetype)initForLifecycle:(NSString*)expectedLifecycle forStep:(NSString*)step {
20+
// The step is here because the callbacks into the handler which checks these expectations isn't
21+
// synchronous with the executions in the test, so it's hard to find the cause in the test
22+
// otherwise.
23+
self = [super initWithDescription:[NSString stringWithFormat:@"Expected state %@ during step %@",
24+
expectedLifecycle, step]];
25+
_expectedLifecycle = [expectedLifecycle copy];
26+
return self;
27+
}
28+
29+
@end
30+
931
@interface AppLifecycleTests : XCTestCase
1032
@end
1133

@@ -16,7 +38,7 @@ - (void)setUp {
1638
self.continueAfterFailure = NO;
1739
}
1840

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

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

3456
NSMutableArray* lifecycleExpectations = [NSMutableArray arrayWithCapacity:10];
35-
NSMutableArray* lifecycleEvents = [NSMutableArray arrayWithCapacity:10];
36-
37-
[lifecycleExpectations addObject:[[XCTestExpectation alloc]
38-
initWithDescription:@"A loading FlutterViewController goes "
39-
@"through AppLifecycleState.inactive"]];
40-
[lifecycleExpectations
41-
addObject:[[XCTestExpectation alloc]
42-
initWithDescription:
43-
@"A loading FlutterViewController goes through AppLifecycleState.resumed"]];
4457

58+
// Expected sequence from showing the FlutterViewController is inactive and resumed.
59+
[lifecycleExpectations addObjectsFromArray:@[
60+
[[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.inactive"
61+
forStep:@"showing a FlutterViewController"],
62+
[[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.resumed"
63+
forStep:@"showing a FlutterViewController"]
64+
]];
65+
66+
// Holding onto this FlutterViewController is consequential here. Since a released
67+
// FlutterViewController wouldn't keep listening to the application lifecycle events and produce
68+
// false positives for the application lifecycle tests further below.
4569
FlutterViewController* flutterVC = [rootVC showFlutter];
4670
[engine.lifecycleChannel setMessageHandler:^(id message, FlutterReply callback) {
4771
if (lifecycleExpectations.count == 0) {
4872
XCTFail(@"Unexpected lifecycle transition: %@", message);
73+
return;
74+
}
75+
XCAppLifecycleTestExpectation* nextExpectation = [lifecycleExpectations objectAtIndex:0];
76+
if (![[nextExpectation expectedLifecycle] isEqualToString:message]) {
77+
XCTFail(@"Expected lifecycle %@ but instead received %@", [nextExpectation expectedLifecycle],
78+
message);
79+
return;
4980
}
50-
[lifecycleEvents addObject:message];
51-
[[lifecycleExpectations objectAtIndex:0] fulfill];
81+
82+
[nextExpectation fulfill];
5283
[lifecycleExpectations removeObjectAtIndex:0];
5384
}];
5485

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

6289
// Now dismiss the FlutterViewController again and expect another inactive and paused.
63-
[lifecycleExpectations
64-
addObject:[[XCTestExpectation alloc]
65-
initWithDescription:@"A dismissed FlutterViewController goes through "
66-
@"AppLifecycleState.inactive"]];
67-
[lifecycleExpectations
68-
addObject:[[XCTestExpectation alloc]
69-
initWithDescription:@"A dismissed FlutterViewController goes through "
70-
@"AppLifecycleState.paused"]];
90+
[lifecycleExpectations addObjectsFromArray:@[
91+
[[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.inactive"
92+
forStep:@"dismissing a FlutterViewController"],
93+
[[XCAppLifecycleTestExpectation alloc]
94+
initForLifecycle:@"AppLifecycleState.paused"
95+
forStep:@"dismissing a FlutterViewController"]
96+
]];
7197
[flutterVC dismissViewControllerAnimated:NO completion:nil];
72-
[self waitForExpectations:lifecycleExpectations timeout:5];
73-
expectedStates = @[
74-
@"AppLifecycleState.inactive", @"AppLifecycleState.resumed", @"AppLifecycleState.inactive",
75-
@"AppLifecycleState.paused"
76-
];
77-
XCTAssertEqualObjects(lifecycleEvents, expectedStates,
78-
@"AppLifecycleState transitions while dismissing not as expected");
98+
[self waitForExpectations:lifecycleExpectations timeout:5 enforceOrder:YES];
7999

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

85-
// However the production is temporarily wrong. https://github.com/flutter/flutter/issues/37226.
86-
// It will be fixed in a next PR that removes the wrong asserts.
87-
[lifecycleExpectations
88-
addObject:
89-
[[XCTestExpectation alloc]
90-
initWithDescription:@"Current implementation sends another AppLifecycleState event"]];
105+
// These operations are synchronous so if they trigger any lifecycle events, they should trigger
106+
// failures in the message handler immediately.
91107
[[NSNotificationCenter defaultCenter]
92108
postNotificationName:UIApplicationWillResignActiveNotification
93109
object:nil];
94-
[lifecycleExpectations
95-
addObject:
96-
[[XCTestExpectation alloc]
97-
initWithDescription:@"Current implementation sends another AppLifecycleState event"]];
98110
[[NSNotificationCenter defaultCenter]
99111
postNotificationName:UIApplicationDidEnterBackgroundNotification
100112
object:nil];
101-
[lifecycleExpectations
102-
addObject:
103-
[[XCTestExpectation alloc]
104-
initWithDescription:@"Current implementation sends another AppLifecycleState event"]];
105113
[[NSNotificationCenter defaultCenter]
106114
postNotificationName:UIApplicationWillEnterForegroundNotification
107115
object:nil];
108-
[lifecycleExpectations
109-
addObject:
110-
[[XCTestExpectation alloc]
111-
initWithDescription:@"Current implementation sends another AppLifecycleState event"]];
112116
[[NSNotificationCenter defaultCenter]
113117
postNotificationName:UIApplicationDidBecomeActiveNotification
114118
object:nil];
115119

116120
// There's no timing latch for our semi-fake background-foreground cycle so launch the
117121
// FlutterViewController again to check the complete event list again.
118-
[lifecycleExpectations addObject:[[XCTestExpectation alloc]
119-
initWithDescription:@"A second FlutterViewController goes "
120-
@"through AppLifecycleState.inactive"]];
121-
[lifecycleExpectations
122-
addObject:[[XCTestExpectation alloc]
123-
initWithDescription:
124-
@"A second FlutterViewController goes through AppLifecycleState.resumed"]];
122+
123+
// Expect only lifecycle events from showing the FlutterViewController again, not from any
124+
// backgrounding/foregrounding.
125+
[lifecycleExpectations addObjectsFromArray:@[
126+
[[XCAppLifecycleTestExpectation alloc]
127+
initForLifecycle:@"AppLifecycleState.inactive"
128+
forStep:@"showing a FlutterViewController a second time after backgrounding"],
129+
[[XCAppLifecycleTestExpectation alloc]
130+
initForLifecycle:@"AppLifecycleState.resumed"
131+
forStep:@"showing a FlutterViewController a second time after backgrounding"]
132+
]];
125133
flutterVC = [rootVC showFlutter];
134+
[self waitForExpectations:lifecycleExpectations timeout:5 enforceOrder:YES];
135+
136+
// Dismantle.
137+
[engine.lifecycleChannel setMessageHandler:nil];
138+
[flutterVC dismissViewControllerAnimated:NO completion:nil];
139+
[engine setViewController:nil];
140+
}
141+
142+
- (void)testVisibleFlutterViewControllerRespondsToApplicationLifecycle {
143+
XCTestExpectation* engineStartedExpectation = [self expectationWithDescription:@"Engine started"];
144+
145+
// Let the engine finish booting (at the end of which the channels are properly set-up) before
146+
// moving onto the next step of showing the next view controller.
147+
ScreenBeforeFlutter* rootVC = [[ScreenBeforeFlutter alloc] initWithEngineRunCompletion:^void() {
148+
[engineStartedExpectation fulfill];
149+
}];
150+
151+
[self waitForExpectationsWithTimeout:5 handler:nil];
152+
153+
UIApplication* application = UIApplication.sharedApplication;
154+
application.delegate.window.rootViewController = rootVC;
155+
FlutterEngine* engine = rootVC.engine;
156+
157+
NSMutableArray* lifecycleExpectations = [NSMutableArray arrayWithCapacity:10];
158+
159+
// Expected sequence from showing the FlutterViewController is inactive and resumed.
160+
[lifecycleExpectations addObjectsFromArray:@[
161+
[[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.inactive"
162+
forStep:@"showing a FlutterViewController"],
163+
[[XCAppLifecycleTestExpectation alloc] initForLifecycle:@"AppLifecycleState.resumed"
164+
forStep:@"showing a FlutterViewController"]
165+
]];
166+
167+
FlutterViewController* flutterVC = [rootVC showFlutter];
168+
[engine.lifecycleChannel setMessageHandler:^(id message, FlutterReply callback) {
169+
if (lifecycleExpectations.count == 0) {
170+
XCTFail(@"Unexpected lifecycle transition: %@", message);
171+
return;
172+
}
173+
XCAppLifecycleTestExpectation* nextExpectation = [lifecycleExpectations objectAtIndex:0];
174+
if (![[nextExpectation expectedLifecycle] isEqualToString:message]) {
175+
XCTFail(@"Expected lifecycle %@ but instead received %@", [nextExpectation expectedLifecycle],
176+
message);
177+
return;
178+
}
179+
180+
[nextExpectation fulfill];
181+
[lifecycleExpectations removeObjectAtIndex:0];
182+
}];
183+
184+
[self waitForExpectations:lifecycleExpectations timeout:5];
185+
186+
// Now put the FlutterViewController into background.
187+
[lifecycleExpectations addObjectsFromArray:@[
188+
[[XCAppLifecycleTestExpectation alloc]
189+
initForLifecycle:@"AppLifecycleState.inactive"
190+
forStep:@"putting FlutterViewController to the background"],
191+
[[XCAppLifecycleTestExpectation alloc]
192+
initForLifecycle:@"AppLifecycleState.paused"
193+
forStep:@"putting FlutterViewController to the background"]
194+
]];
195+
[[NSNotificationCenter defaultCenter]
196+
postNotificationName:UIApplicationWillResignActiveNotification
197+
object:nil];
198+
[[NSNotificationCenter defaultCenter]
199+
postNotificationName:UIApplicationDidEnterBackgroundNotification
200+
object:nil];
201+
[self waitForExpectations:lifecycleExpectations timeout:5];
202+
203+
// Now restore to foreground
204+
[lifecycleExpectations addObjectsFromArray:@[
205+
[[XCAppLifecycleTestExpectation alloc]
206+
initForLifecycle:@"AppLifecycleState.inactive"
207+
forStep:@"putting FlutterViewController back to foreground"],
208+
[[XCAppLifecycleTestExpectation alloc]
209+
initForLifecycle:@"AppLifecycleState.resumed"
210+
forStep:@"putting FlutterViewController back to foreground"]
211+
]];
212+
[[NSNotificationCenter defaultCenter]
213+
postNotificationName:UIApplicationWillEnterForegroundNotification
214+
object:nil];
215+
[[NSNotificationCenter defaultCenter]
216+
postNotificationName:UIApplicationDidBecomeActiveNotification
217+
object:nil];
126218
[self waitForExpectations:lifecycleExpectations timeout:5];
127-
expectedStates = @[
128-
@"AppLifecycleState.inactive", @"AppLifecycleState.resumed", @"AppLifecycleState.inactive",
129-
@"AppLifecycleState.paused",
130-
131-
// The production code currently misbehaves. https://github.com/flutter/flutter/issues/37226.
132-
// It will be fixed in a next PR that removes the wrong asserts.
133-
@"AppLifecycleState.inactive", @"AppLifecycleState.paused", @"AppLifecycleState.inactive",
134-
@"AppLifecycleState.resumed",
135-
136-
// We only added 2 from re-launching the FlutterViewController
137-
// and none from the background-foreground cycle.
138-
@"AppLifecycleState.inactive", @"AppLifecycleState.resumed"
139-
];
140-
XCTAssertEqualObjects(
141-
lifecycleEvents, expectedStates,
142-
@"AppLifecycleState transitions while presenting a second time not as expected");
143219

144220
// Dismantle.
145221
[engine.lifecycleChannel setMessageHandler:nil];
146222
[flutterVC dismissViewControllerAnimated:NO completion:nil];
147-
flutterVC = nil;
148223
[engine setViewController:nil];
149-
[rootVC dismissViewControllerAnimated:NO completion:nil];
150-
rootVC = nil;
151224
}
225+
152226
@end

0 commit comments

Comments
 (0)