一个iPad偶现无法横竖屏切换的问题分析

背景

给公司负责的项目做 iPad 兼容, 但在开发过程中一直偶现 iPad 突然无法支持横竖屏旋转, 难以排查出问题根源.

问题分析

现象特征分析

  • 当出现 iPad 无法旋转时, push或者 pop 到支持横竖屏的页面无法恢复. 只能重启恢复.
  • 只有appdelegate 的window无法旋转, 其他window 能正常响应旋转.

初步问题排查

在iOS 中, 页面是否支持旋转取决于 window 和 ViewController:

// window 支持的方向
- (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window {
    if (window == self.window) { // 如果是keywindow
        return UIInterfaceOrientationMaskAllButUpsideDown;
    } else {
        return UIInterfaceOrientationMaskPortrait;
    }
}

// UIViewController 支持的方向
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
    return UIInterfaceOrientationMaskAllButUpsideDown;
}

app 旋转的决策流程简单的说就是会先问window支持的方向, 再问window.rootViewController支持的方向. 那么最初判断可能是由于 app 中存在多window导致keyWindow异常, 进而影响 app 的旋转判断. 但在旋转的方法中加入日志后, 我发现旋转异常时, keyWindow是正常的, 和预期不一致.
这个时候, 排查陷入了僵局. 由于在出现旋转异常时, 正常应该被调用的supportedInterfaceOrientations也没有被调用, 说明也不是ViewController 的旋转实现有问题.
app端能介入旋转过程的方法都没有被系统调用, 这个时候该怎么排查原因呢?

逆向分析

UIKit 的代码对我们来说是黑盒, 那么当底层没有调用 app 相关的代码时, 我们的排查手段就变得非常有限. 幸运的是, 我自己在排查 iPad 上其他问题的时候, 偶然发现 iPad 旋转异常的一个复现路径. 这个复现路径不能稳定复现, 但复现概率也变高了许多.
有了这个复现路径, 虽然依然无法直接从两个旋转方法直接入手查原因, 但是, 反向思考一下, 为什么异常的时候, app不会收到旋转回调呢?
从这个角度看, 我们就可以分析一下, 正常系统底层调用旋转回调的堆栈和异常时候的差异.
为了方便看出差异性, 我们选择对比 push 时候的堆栈差异性(push 时候也会询问页面的旋转朝向支持). 下面是正常时的堆栈:


我们对几个关键的私有方法加上断点:
断点

注意, 添加Symbolic breakpoint的时候一定不能出现多余的空格, 会导致断点添加失败

一番测试后, 我们发现-[UIWindow _updateOrientationPreferencesAnimated:]是最后一个被调用的方法, 很明显, 这个方法里面有部分逻辑导致-[UIWindow _supportedInterfaceOrientationsConsultingApp:]没有被调用. 我们再看看正常的时候这个方法是在哪个位置调用 app 的实现:
我们把所有方法调用都打上断点, 然后再看看是在哪个环节跳出没能进入调用.


最终我们发现方法是在isInterfaceAutorotationDisabled之后跳出的:

从汇编代码可以看出, 系统是在调用这个方法后返回了 YES 导致了代码跳出. 从方法名字上, 我们也可以推测出这个方法用来决定window 是否可以旋转. 那为啥这个方法会返回YES 呢? 我们可以对这个方法继续打断点来分析, 但我们之前采用挨个方法打断点的方式是因为不了解系统的旋转决策逻辑, 现在我们已经缩小了范围, 可以采用另外一个方式直接分析这个方法.

使用 Hopper 可以用来逆向分析一些二进制代码, 但是问题来了, 我们怎么获取 UIKit 的二进制文件呢? 实际上Xcode 都自带有系统核心库的二进制文件, 我们可以从中拷贝出来.

大部分Xcode 版本的 UIKitCore 地址.
XCode 14:
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore
Xcode 15:
/Users/{USERNAME}/Library/Developer/Xcode/iOS DeviceSupport/{IPHONE_MODEL} ({IOS_VERSION})/Symbols/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore

我们用 Hopper 分析一下 UIKitCore, 找到isInterfaceAutorotationDisabled的二进制实现:


利用 Hopper 的翻译汇编代码的能力, 我们可以得到isInterfaceAutorotationDisabled的实现.

需要注意的是, Hopper的翻译不一定可靠, 需要结合汇编代码来看

我们发现有两个逻辑分支可能返回 YES. 再回到 app, 我们看下是走了哪个分支.


从运行断点来看, app是走到上面的返回 YES 的逻辑. 此时我们打印一下寄存器的值:

我们可以发现是 r8 非 0, 所以返回了 YES, 并且 r8 的值其实是window->_windowFlags, 那么此时 _windowFlags的值为啥是 0x8000, 这代表什么意义呢(一开始我们猜测这个属性是个 bool 值)? 同时翻译代码中偏移的0x78000又是为啥呢?

这个时候我们就又需要另外一个工具——limneos, 这是一个存有 iOS 所有历史版本的系统类的 dump 数据的网站.
我们利用这个工具看下 UIWindow 的 dump 数据:

image.png

我们可以发现_windowFlags其实是一个struct, 偏移0x78000表示读取disableAutorotationCount的值. _windowFlags的值是0x8000表示disableAutorotationCount的值是1. (按位与获取值)
查到这里, 我们其实已经初步有个头绪了, 由于UIWindow 记录的阻止旋转的计数不为1, 所以UIWindow 阻止了旋转.
那什么行为会改变这个值呢?

UIWindow的旋转安全机制

UIWindow怎样锁死旋转

在前面我们已经分析出来问题的表象——UIWindow 无法旋转是被标记为 disableAutorotation. 那么现在的问题就是为啥会被标记为不支持旋转呢? 这里通常有三个办法:

  1. google大法.

    直接使用相关关键字 google 搜索看看是不是有其他人也遇到过类似的情况. 比如这次我就是通过搜索isInterfaceAutorotationDisabled搜索到这篇文章. 这篇文章提供了两个很重要的信息:

    1. UIWindow会通过beginDisablingInterfaceAutorotationendDisablingInterfaceAutorotationAnimated:来禁用和启用旋转.

    2. UIPageViewController 的翻页会调用上面两个方法, 进而影响到 window 的旋转能力.

  2. 类属性/方法分析.

    从前面我们的分析上看, 可以得到两个很重要的关键字, Disable 和 Autorotation. 我们可以直接利用limneos看看有没有相关的方法.

    -(void)endDisablingInterfaceAutorotation;
    -(BOOL)isInterfaceAutorotationDisabled;
    -(void)beginDisablingInterfaceAutorotation;
    -(void)endDisablingInterfaceAutorotationAnimated:(BOOL)arg1 ;
    

    通过搜索, 我们找到了以上几个看起来关联度很高的方法. 尤其是begin 和 end开头的两个方法.

  3. 二进制码中暴力查找.

    在其他方法没有明显思路的时候, 就只能依靠Hopper 在二进制文件中暴力检索相关字眼或者值. 由于大部分方法调用会在汇编码旁边有调用方法名注释, 我们可以依赖这个信息进行检索. 如果没有明确的函数查找目标, 可以通过一些特殊的取值搜索.


    比如这个 case , 我们的关注点是 UIWindow 何时会修改disableAutorotationCount, 那么我们就可以搜索0x78000. 通过搜索, 我找到了UIWindow 有三个会使用这个数值的方法:

    -(BOOL)isInterfaceAutorotationDisabled;
    -(void)beginDisablingInterfaceAutorotation;
    -(void)endDisablingInterfaceAutorotationAnimated:(BOOL)arg1;
    

    但总体而言, 这个方法的效率较低, 算是最后的办法.

UIWindow 何时会锁死旋转

综合上面获得的信息, 我们把排查重点放在了beginDisablingInterfaceAutorotationendDisablingInterfaceAutorotationAnimated:两个方法. 通过hook 这两个方法, 我们发现:

  • 页面跳转(push/pop)前会调用beginDisablingInterfaceAutorotation, 跳转结束后会调用endDisablingInterfaceAutorotationAnimated:

  • UIPageViewController在翻页前会调用beginDisablingInterfaceAutorotation, 翻页结束后会调用endDisablingInterfaceAutorotationAnimated:

到这里我们可以推测出一个结论: UIWindow为了避免在动画过程中旋转触发重新布局导致异常, 会临时锁死旋转.

UIWindow为何没有恢复旋转

进行到这里, 我们距离真相已经越来越近. 现在就需要判断在哪个环节出错, 导致begin 和 end 的调用数量不匹配. 通过复现 bug, 以及对这两个方法的 hook, 我们发现, 在旋转异常的时候, UIPageViewController只调用了 begin 没有调用 end.


正常情况下, UIPageViewController在翻页后, 会通过上图的堆栈路径来调用 UIWindow 的endDisablingInterfaceAutorotationAnimated:. 和最初的排查思路一样, 我们发现在异常发生的时候, 这个调用停留在-[UIViewController _endDisablingInterfaceAutorotation]. 那么在这个方法里面发生了什么导致UIWindow 的 end 方法没有被调用呢?

我们看下这个方法的实现, 我们发现这个方法很简单, 其实就是:

- (void)_endDisablingInterfaceAutorotation {
    [self.view.window endDisablingInterfaceAutorotation];
}

那么显而易见的, 这个时候self.view.window是 nil, 导致UIWindow 的 end 方法没有被调用. 这里的 window 是 app 的 keywindow 不可能被销毁, 那么只可能 pageViewController 被移除出视图树, 导致其拿不到所在的 window.
这个时候, 我想起代码中旋转的实现中, 是会将原 pageVC 移除出视图树, 再设置为 nil.
执行完这个方法的时候, pageVC 并没有被销毁, 但却已经被移除出视图树了.
至此, iPad上偶现的无法旋转的问题就找出原因了——pageVC在翻页的过程中触发了旋转, 老的pageVC 被移除出视图树, 导致pageVC 没有成对调用 UIWindow 的 beginDisablingInterfaceAutorotationendDisablingInterfaceAutorotationAnimated: 方法.

问题修复

查出了问题的根源, 那么接下来就是问题的修复. 我们先看看其他人的解决方案:


这个解决方案有两个问题. 首先是调用了系统的私有 api, 有拒审风险. 其次是在 dealloc 直接调用UIWindow 的endDisablingInterfaceAutorotationAnimated 并不一定能解决问题.

通过分析[UIPageViewController _endDisablingInterfaceAutorotation], 我们发现 end 其实是一个循环设值的行为. 并且, 直接对 UIWindow 设置end, 可能会导致其他位置触发的 begin 被提前结束锁定.
其实, 从我们前面的分析可知, 最终UIPageViewController 没有触发UIWindow 的 end, 是因为其不在视图树上找不到对应的 window 了. 那么我们只需要在原来移除出视图树的方式, 改为设置 pageVC 不可见,并延时0.5s后再将其移除出视图树. 最终验证这个方法是可行的. 也避免了审核风险.

最后

如果这个文章帮到了你, 麻烦点个赞噢.

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容