Skip to content

Instantly share code, notes, and snippets.

@steipete
Last active September 17, 2020 23:24
Show Gist options
  • Save steipete/8df39fea0d39680a7a6b to your computer and use it in GitHub Desktop.
Save steipete/8df39fea0d39680a7a6b to your computer and use it in GitHub Desktop.
Hunting down a regression in interface rotation on iOS 8 with multiple windows. (rdar://19592583)
This is the code path that changed the status bar orientation on iOS 7:
* thread #1: tid = 0x698dbf, 0x00085830 WindowRotationIssue`-[AppDelegate application:willChangeStatusBarOrientation:duration:](self=0x7a041f90, _cmd=0x0137a18d, application=0x79e39cc0, newStatusBarOrientation=UIInterfaceOrientationPortraitUpsideDown, duration=0.80000000000000004) + 96 at AppDelegate.m:23, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
* frame #0: 0x00085830 WindowRotationIssue`-[AppDelegate application:willChangeStatusBarOrientation:duration:](self=0x7a041f90, _cmd=0x0137a18d, application=0x79e39cc0, newStatusBarOrientation=UIInterfaceOrientationPortraitUpsideDown, duration=0.80000000000000004) + 96 at AppDelegate.m:23
frame #1: 0x00bb6ab5 UIKit`-[UIApplication setStatusBarOrientation:animationParameters:notifySpringBoardAndFence:] + 242
frame #2: 0x00bfa8e4 UIKit`-[UIWindow _setRotatableClient:toOrientation:updateStatusBar:duration:force:isRotating:] + 4761
frame #3: 0x00bf9646 UIKit`-[UIWindow _setRotatableClient:toOrientation:updateStatusBar:duration:force:] + 82
frame #4: 0x00bf9518 UIKit`-[UIWindow _setRotatableViewOrientation:updateStatusBar:duration:force:] + 117
frame #5: 0x00bf95a0 UIKit`-[UIWindow _setRotatableViewOrientation:duration:force:] + 67
frame #6: 0x00bf863a UIKit`__57-[UIWindow _updateToInterfaceOrientation:duration:force:]_block_invoke + 120
frame #7: 0x00bf859c UIKit`-[UIWindow _updateToInterfaceOrientation:duration:force:] + 400
frame #8: 0x00bf8cd6 UIKit`-[UIWindow _updateInterfaceOrientationFromDeviceOrientation:] + 1346
frame #9: 0x00bf878d UIKit`-[UIWindow _updateInterfaceOrientationFromDeviceOrientationIfRotationEnabled:] + 94
frame #10: 0x00bf8399 UIKit`-[UIWindow _handleDeviceOrientationChange:] + 122
frame #11: 0x00188049 Foundation`__57-[NSNotificationCenter addObserver:selector:name:object:]_block_invoke + 40
frame #12: 0x00797f04 CoreFoundation`__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ + 20
frame #13: 0x006efefb CoreFoundation`_CFXNotificationPost + 2859
frame #14: 0x000c1e41 Foundation`-[NSNotificationCenter postNotificationName:object:userInfo:] + 98
frame #15: 0x00e0b5cf UIKit`-[UIDevice setOrientation:animated:] + 295
frame #16: 0x00bc64fb UIKit`-[UIApplication handleEvent:withNewEvent:] + 806
frame #17: 0x00bc7555 UIKit`-[UIApplication sendEvent:] + 85
frame #18: 0x00bb4250 UIKit`_UIApplicationHandleEvent + 683
Things got quite a bit more complex in iOS 8:
(Notice the new _UIWindowRotationAnimationController)
* thread #1: tid = 0x69b266, 0x0009e830 WindowRotationIssue`-[AppDelegate application:willChangeStatusBarOrientation:duration:](self=0x7af199e0, _cmd=0x017c1468, application=0x7b312ea0, newStatusBarOrientation=UIInterfaceOrientationLandscapeRight, duration=0) + 96 at AppDelegate.m:23, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
* frame #0: 0x0009e830 WindowRotationIssue`-[AppDelegate application:willChangeStatusBarOrientation:duration:](self=0x7af199e0, _cmd=0x017c1468, application=0x7b312ea0, newStatusBarOrientation=UIInterfaceOrientationLandscapeRight, duration=0) + 96 at AppDelegate.m:23
frame #1: 0x00ebed3f UIKit`-[UIApplication setStatusBarOrientation:animationParameters:notifySpringBoardAndFence:] + 248
frame #2: 0x00f13cc6 UIKit`__78-[UIWindow _rotateWindowToOrientation:updateStatusBar:duration:skipCallbacks:]_block_invoke + 329
frame #3: 0x013f7062 UIKit`__58-[_UIWindowRotationAnimationController animateTransition:]_block_invoke + 45
frame #4: 0x013f7019 UIKit`-[_UIWindowRotationAnimationController animateTransition:] + 475
frame #5: 0x00f1110f UIKit`-[UIWindow _rotateToBounds:withAnimator:transitionContext:] + 877
frame #6: 0x00f13a21 UIKit`-[UIWindow _rotateWindowToOrientation:updateStatusBar:duration:skipCallbacks:] + 2143
frame #7: 0x00f158a8 UIKit`-[UIWindow _setRotatableClient:toOrientation:applyTransformToWindow:updateStatusBar:duration:force:isRotating:] + 6839
frame #8: 0x00f13120 UIKit`-[UIWindow _setRotatableClient:toOrientation:updateStatusBar:duration:force:isRotating:] + 128
frame #9: 0x00f13099 UIKit`-[UIWindow _setRotatableClient:toOrientation:updateStatusBar:duration:force:] + 84
frame #10: 0x00f12e7f UIKit`-[UIWindow _setRotatableViewOrientation:updateStatusBar:duration:force:] + 138
frame #11: 0x00f12fec UIKit`-[UIWindow _setRotatableViewOrientation:duration:force:] + 68
frame #12: 0x00f11cba UIKit`__57-[UIWindow _updateToInterfaceOrientation:duration:force:]_block_invoke + 130
frame #13: 0x00f11c17 UIKit`-[UIWindow _updateToInterfaceOrientation:duration:force:] + 425
frame #14: 0x00f124d8 UIKit`-[UIWindow _updateInterfaceOrientationFromDeviceOrientation:] + 1397
frame #15: 0x00f11f5c UIKit`-[UIWindow _updateInterfaceOrientationFromDeviceOrientationIfRotationEnabled:] + 93
frame #16: 0x00f119fb UIKit`-[UIWindow _handleDeviceOrientationChange:] + 122
frame #17: 0x0012bc49 Foundation`__57-[NSNotificationCenter addObserver:selector:name:object:]_block_invoke + 40
frame #18: 0x008bf4a4 CoreFoundation`__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ + 20
frame #19: 0x007ad03b CoreFoundation`_CFXNotificationPost + 3051
frame #20: 0x0011b246 Foundation`-[NSNotificationCenter postNotificationName:object:userInfo:] + 98
frame #21: 0x01187040 UIKit`-[UIDevice setOrientation:animated:] + 307
frame #22: 0x00ed2f09 UIKit`-[UIApplication handleEvent:withNewEvent:] + 1364
frame #23: 0x00ed34ac UIKit`-[UIApplication sendEvent:] + 85
frame #24: 0x00ebbbe3 UIKit`_UIApplicationHandleEvent + 704
@steipete
Copy link
Author

So when the rotation event is dispatched, [UIDevice setOrientation:animated:] gets called. It checks if there are any device orientation observers (which is the case if there are any UIWindows), then does some other checks including some special code for springboard, before sending the UIDeviceOrientationDidChangeNotification.

Every window is a listener to this notification. it evaluates the UIDeviceOrientationRotateAnimatedUserInfoKey payload and calls [UIWindow updateInterfaceOrientationFromDeviceOrientationIfRotationEnabled:].

updateInterfaceOrientationFromDeviceOrientationIfRotationEnabled does what we'd expect from the name, it's a small shim that checks isInterfaceAutorotationDisabled and then calls [UIWindow _updateInterfaceOrientationFromDeviceOrientation:].

Looking at this method we see that quite a bit on the internals changed; as rotating a window on iOS 8 now changes the frame and no longer just applies a transform as it did on 7. Interesting tidbit: Apple uses a UIIntegralTransform() function to round it's CGAffineTransform values.

When you set a rootViewController to an UIWindow, it becomes it's delegate as well (delegate isn't public though, but this happens implicit).

The difference between 7 and 8 for the regression I am hunting is that on 7 things stop in -[UIWindow _updateToInterfaceOrientation:duration:force:], while it continues the path to -[UIWindow _setRotatableViewOrientation:duration:force:] on 8.

To make things bit more complicated, the interesting logic is actually inside a block called in there (__57__UIWindow__updateToInterfaceOrientation_duration_force___block_invoke)

When breaking in - (BOOL)_shouldAutorotateToInterfaceOrientation:(UIInterfaceOrientation)orientation we notice that this is called once on iOS 7 where it returns NO (as expected); but twice on iOS 8 where it returns YES for our hidden window (as the delegate/rootViewController in there doesn't limit rotation)

That was actually the hint I needed - looks like this whole process is called only on they kewWindow for 7 but on all windows for 8. In fact, not even _handleDeviceOrientationChange is called on our hidden window in 7.

Let's investigate where this is registered on NSNotification. On iOS 7 this happens in -[UIWindow setAutorotates:forceUpdateInterfaceOrientation:] (it's also referenced in endDisablingInterfaceAutorotationAnimated but that's not relevant for now)

So let's step in where this is being called!

iOS 7:

* thread #1: tid = 0x6fbb52, 0x000a462a WindowRotationIssue`-[DebugWindow setAutorotates:forceUpdateInterfaceOrientation:](self=0x78e79b50, _cmd=0x013a286f, autorotates='\x01', force='\x01') + 42 at AppDelegate.m:67, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
  * frame #0: 0x000a462a WindowRotationIssue`-[DebugWindow setAutorotates:forceUpdateInterfaceOrientation:](self=0x78e79b50, _cmd=0x013a286f, autorotates='\x01', force='\x01') + 42 at AppDelegate.m:67
    frame #1: 0x00c198e6 UIKit`-[UIWindow setDelegate:] + 449
    frame #2: 0x00cf3b77 UIKit`-[UIViewController _tryBecomeRootViewControllerInWindow:] + 180
    frame #3: 0x00c0f474 UIKit`-[UIWindow addRootViewControllerViewIfPossible] + 591
    frame #4: 0x00c0f5ef UIKit`-[UIWindow _setHidden:forced:] + 312
    frame #5: 0x00c0f86b UIKit`-[UIWindow _orderFrontWithoutMakingKey] + 49
    frame #6: 0x00c1a3c8 UIKit`-[UIWindow makeKeyAndVisible] + 65

iOS 8:

* thread #1: tid = 0x6fd742, 0x0001a62a WindowRotationIssue`-[DebugWindow setAutorotates:forceUpdateInterfaceOrientation:](self=0x7b341240, _cmd=0x0174a672, autorotates='\x01', force='\x01') + 42 at AppDelegate.m:67, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
  * frame #0: 0x0001a62a WindowRotationIssue`-[DebugWindow setAutorotates:forceUpdateInterfaceOrientation:](self=0x7b341240, _cmd=0x0174a672, autorotates='\x01', force='\x01') + 42 at AppDelegate.m:67
    frame #1: 0x00e9338d UIKit`-[UIWindow setDelegate:] + 554
    frame #2: 0x00f945b1 UIKit`-[UIViewController _tryBecomeRootViewControllerInWindow:] + 184
    frame #3: 0x00e873c3 UIKit`-[UIWindow setRootViewController:] + 1050
    frame #4: 0x0001a07d WindowRotationIssue`-[AppDelegate application:didFinishLaunchingWithOptions:](self=0x7b33be10, _cmd=0x0173d98f, application=0x7b230520, launchOptions=0x00000000) + 493 at AppDelegate.m:17
    frame #5: 0x00e3197c UIKit`-[UIApplication _handleDelegateCallbacksWithOptions:isSuspended:restoreState:] + 291
    frame #6: 0x00e32687 UIKit`-[UIApplication _callInitializationDelegatesForMainScene:transitionContext:] + 2869
    frame #7: 0x00e35c0d UIKit`-[UIApplication _runWithMainScene:transitionContext:completion:] + 1639
    frame #8: 0x00e4e7d0 UIKit`__84-[UIApplication _handleApplicationActivationWithScene:transitionContext:completion:]_block_invoke + 59
    frame #9: 0x00e3481f UIKit`-[UIApplication workspaceDidEndTransaction:] + 155
    frame #10: 0x0400e9de FrontBoardServices`__37-[FBSWorkspace clientEndTransaction:]_block_invoke_2 + 71
    frame #11: 0x0400e46f FrontBoardServices`__40-[FBSWorkspace _performDelegateCallOut:]_block_invoke + 54
    frame #12: 0x04020425 FrontBoardServices`__31-[FBSSerialQueue performAsync:]_block_invoke + 26
    frame #13: 0x007941c0 CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__ + 16

AND

(lldb) bt
* thread #1: tid = 0x6fd742, 0x0001a62a WindowRotationIssue`-[DebugWindow setAutorotates:forceUpdateInterfaceOrientation:](self=0x7ae5eac0, _cmd=0x0174a672, autorotates='\x01', force='\x01') + 42 at AppDelegate.m:67, queue = 'com.apple.main-thread', stop reason = breakpoint 3.1
  * frame #0: 0x0001a62a WindowRotationIssue`-[DebugWindow setAutorotates:forceUpdateInterfaceOrientation:](self=0x7ae5eac0, _cmd=0x0174a672, autorotates='\x01', force='\x01') + 42 at AppDelegate.m:67
    frame #1: 0x00e9338d UIKit`-[UIWindow setDelegate:] + 554
    frame #2: 0x00f945b1 UIKit`-[UIViewController _tryBecomeRootViewControllerInWindow:] + 184
    frame #3: 0x00e873c3 UIKit`-[UIWindow setRootViewController:] + 1050
    frame #4: 0x00019d27 WindowRotationIssue`-[ViewController addWindowForHUD](self=0x7b342a70, _cmd=0x0001a8ec) + 183 at ViewController.m:43
    frame #5: 0x00019b08 WindowRotationIssue`-[ViewController viewDidLoad](self=0x7b342a70, _cmd=0x01767bf4) + 392 at ViewController.m:32
    frame #6: 0x00f8f2a4 UIKit`-[UIViewController loadViewIfRequired] + 771
    frame #7: 0x00f8f595 UIKit`-[UIViewController view] + 35

So while 7 seems to trigger autorotation when we call makeKeyAndVisible, iOS 8 does this earlier as soon as a rootViewController is set. setDelegate: is called in both paths and decides if it should configure autorotation.

Looking at -[UIWindow setDelegate:], things became quite a bit more complex in 8 as well. However, this calls - (void)setAutorotates:(BOOL)autorotates forceUpdateInterfaceOrientation:(BOOL)force in all cases... so this can't be it either.

Playing with my example project reveal something different; delegate is actually not always set when we set the rootViewController - only sometimes! On iOS 7 it's nil unless we become the keyWindow... on 8 it's being set inside _tryBecomeRootViewControllerInWindow which is called in setRootViewController:

* thread #1: tid = 0x70af47, 0x000ad5f2 WindowRotationIssue`-[DebugWindow setDelegate:](self=0x7aedd970, _cmd=0x00b222f5, delegate=0x7af808a0) + 66 at AppDelegate.m:72, queue = 'com.apple.main-thread', stop reason = breakpoint 4.1
  * frame #0: 0x000ad5f2 WindowRotationIssue`-[DebugWindow setDelegate:](self=0x7aedd970, _cmd=0x00b222f5, delegate=0x7af808a0) + 66 at AppDelegate.m:72
    frame #1: 0x010275b1 UIKit`-[UIViewController _tryBecomeRootViewControllerInWindow:] + 184
    frame #2: 0x00f1a3c3 UIKit`-[UIWindow setRootViewController:] + 1050
    frame #3: 0x000acc67 WindowRotationIssue`-[ViewController addWindowForHUD](self=0x7af7e000, _cmd=0x000ad8bc) + 183 at ViewController.m:43
    frame #4: 0x000aca48 WindowRotationIssue`-[ViewController viewDidLoad](self=0x7af7e000, _cmd=0x017fabf4) + 392 at ViewController.m:32
    frame #5: 0x010222a4 UIKit`-[UIViewController loadViewIfRequired] + 771

Looking at -[UIWindow setRootViewController:] there's a new UIApplicationLinkedOnOrAfter check for iOS 8 that... guess what... calls tryBecomeRootViewControllerInWindow: if running iOS 8.

So now we know why this is happening and at least this part seems intentional - however the rotation consequences seem less so. But it was fun to dig deep into rotation handling and it gave me a much better idea about how the system works.

@steipete
Copy link
Author

@steipete
Copy link
Author

Came up with a workaround, but it's not pretty. https://gist.github.com/steipete/d928debb92e86de89eb2

@carllindberg
Copy link

I thought that in iOS8, the root view controller (via the -shouldAutorotate method) controls whether its window rotates. In iOS7 or before, windows never rotated -- the rotation transform was applied to the root view controller's view. Would it not be possible to implement that method to return NO if self.view.window.hidden is YES ? I have had the opposite problem -- a window was not rotating when we wanted it to, which happened because there was a period of time where the window had no root view controller and that was when rotation happened. Setting a placeholder root view controller allowed it to rotate.

@carllindberg
Copy link

Also, the root view controller should have its view loaded. I don't think setRootViewController: automatically does that anymore. I saw behavioral differences between when I set a loaded vs not-yet-loaded view controller as the root.

@grzegorzduda
Copy link

Setting rootViewController = nil (or any other value), will not remove any of the modal view controllers presented by the root view controller (or its descendants) from the view hierarchy. Those view controllers neither will be deallocated.

I guess there is some memory leak, most probably in the UIKit, perhaps this could be a clue:
http://stackoverflow.com/questions/26763020/leaking-views-when-changing-rootviewcontroller-inside-transitionwithview

What happens here is that presenting view controller (e.g. root view controller) is removed from the view hierarchy, but it is not deallocated as presented modal view controller holds strong reference to it.

If root view controller is stored in a property (e.g. realViewController), and we set rootViewController = nil, then rootViewController = realViewController (window.hidden = NO), previously used presenting view controller will not be added back to the view hierarchy and presented view controller will remain untouched (may be seen as leaking modal view controller). This side effect will be visible only for view controllers which do not occupy the whole screen (e.g. popovers, view controllers presented with UIModalPresentationFormSheet, etc.). User would see view controller and black screen behind it. Seems that the only way to deallocate modal view controller, is to dismiss it.

It looks like there is another UIKit bug which requires workaround, and workaround proposed by @steipete should be used very carefully. Please correct me if I am wrong.

@jpohhh
Copy link

jpohhh commented Feb 27, 2015

@grzegorzduda you're correct, I've been seeing this since 8.0. Specifically, a UISplitViewController with a presented view controller leaking both the SplitViewController and the presented view controller, until the next time another UISplitViewController (not just any UIViewController!) becomes the window's root view controller.

@carllindberg
Copy link

I have not gotten windows to deallocate easily in iOS8 at all if they have presented view controllers. I think that is a separate issue than this gist though. It seems any presented view controller has an inherent retain loop with the window, and dismissing the view controllers seems to be the only way to get them removed.

There is a separate problem with UISplitViewController, in that it installs a private _shouldPreventRotationHook block in the UIWindow, which has a strong retain to the UISplitViewController, and does not clean it up (nor does UIWindow release it when the window gets dealloced). That sounds like it might be the same issue (it would take another UISplitViewController to overwrite the original block). I think that may have been fixed in iOS 8.3, at least the UIWindow dealloc portion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment