做iOS混合开发,只要产品经理哪天提了句“这个页面转屏之后要自适应”,噩梦基本就开始了。WKWebView、React Native、Flutter,无论你用的是哪套混合方案,横竖屏转换都会把原生层和Web层的边界撕开一个口子:原生已经转过去了,Web内容还停在旧的尺寸;安全区算到了一半,键盘又冒出来挡输入框;明明全App竖屏,视频页偏要在你点击播放的那一刻自己横过来。这些问题不是某一个人的代码写错了,而是iOS的方向控制机制、WebView的渲染方式、跨端框架的消息传递,三层各有一套自己的节奏。
这篇就把我几年里踩过的横竖屏转换相关的坑整理出来。核心内容分成几个大块:一是iOS方向控制机制的底层逻辑,二是页面级方向权限的控制方案,三是WebView旋转后布局、安全区、键盘这批高频问题,四是RN和Flutter这两个跨端框架的特殊坑,最后给一份可以直接对着排查的速查表。适合正在做iOS混合开发、或者正准备接“支持横竖屏”需求的团队参考,新人和有经验的都能找到有用的东西。
1. 方向这事在iOS里到底归谁管
1.1 三个控制器的分工与优先级
先解决一个最基础的问题:iOS里决定一个页面能不能转、可以转到哪个方向,从来不是某一个API说了算,而是三样东西共同决定的。
- Info.plist里的UISupportedInterfaceOrientations:这是App安装包层面的“总宣言”,声明这个App在系统层面允许哪些方向。
- AppDelegate里的supportedInterfaceOrientationsFor window:这是运行时的过滤器,App启动之后每次系统询问方向支持时都会经过它,它返回什么,全局就被限制在什么范围。
- 每个UIViewController自己的supportedInterfaceOrientations和shouldAutorotate:这是页面的“个人意愿”。页面想要横屏,但全局不允许,最终交集为空,系统就会拒绝。
这三层是“层层收敛”的关系。系统先问Info.plist,再问AppDelegate,最后问当前窗口上的顶层控制器,一个不支持,整体就不支持。你可以把Info.plist想成小区的总门禁,AppDelegate是单元门禁,具体页面是房间门禁,三道门都开了,人才能进来。混合开发里最常见的翻车,就是只开了房间门禁,单元门禁没开,页面怎么强制都没反应。
实际开发中有一个典型的误操作:很多人以为在某个页面写了shouldAutorotate返回false就能锁死方向。这句话只对了一半。shouldAutorotate的意思是“当前页面要不要在物理转动设备后自动旋转”,但它不能阻止你用代码强制方向,也管不住系统在某些场景下主动换方向。从iOS 16开始,系统对方向支持的查询机制又细化了一轮,页面想动态更新自己支持的方向时,还需要手动调用setNeedsUpdateOfSupportedInterfaceOrientations来通知系统重新问一遍,这一点很多老项目没有跟上。
还有一点容易被忽略:模态视图(present出来的页面)的方向规则和普通导航push进的不一样。present出来的控制器会独立决定自己的方向,如果它没有实现supportedInterfaceOrientations,系统会往上层找,但找到的常常不是你想的那个“上层”,而是presenting的控制器,这就会导致“我用导航push的页面明明锁了竖屏,present一个半屏选择器后,整个App被带成横屏”这种鬼问题。
1.2 混合容器的感知延迟:房东和房客的错位
WKWebView之所以在横竖屏转换上问题特别多,根子在于它的双进程架构。WKWebView本身是一个UIView,UIKit转屏时会顺手把它的大小改了,但页面里的HTML、CSS、JavaScript运行在另一个独立进程中,它们不会实时感知UIKit发生了什么,只能通过WebView的尺寸变化、resize事件、orientationchange事件这些间接渠道才知道“屏幕方向可能变了”。
说直白一点,原生层是房东,Web页面是房客。房东拆了墙重新分房间,房客本来应该跟着重新摆家具,但房客并不知道旧家具该挪成什么样,得等到房东通知“尺寸变了,你重新排一下布局”才能动手。房东的通知在UIKit里是准时的,可房客接收通知的通道——viewport、resize监听、CSS媒体查询——有一个算一个,全部依赖WebView的frame变化被正确传递。只要中间任何一个环节配置不对,就会出现“原生已经横屏了,Web还在用竖屏的宽度排版”的尴尬画面。
跨端框架就更慢一拍。React Native和Flutter都有自己的bridge或通道,屏幕方向系统事件要先经过原生层的方向控制,再翻译成框架层的事件,最后扩散到业务代码。每个环节都有一点点延迟,累积起来就是几十到几百毫秒的不一致。这个时间差在普通页面上可能看不出来,但凡是旋转后立刻有动画、立刻要刷新布局、立刻要调整安全区的页面,一定会露馅。
2. 页面级方向权限:从全App竖屏到局部横屏
2.1 全局方向开关放哪里最省心
混合开发里最常遇到的需求是:整个App默认竖屏,点开某个视频播放页要自动横屏,或者某个富文本展示页在竖屏和横屏下都要能看。这时候不能每个页面都自己写一套方向控制逻辑,必须收敛到全局去。
我的做法是在AppDelegate里加一个可变的orientationLock属性,然后在系统询问方向时直接返回它:
// AppDelegate.swift var orientationLock: UIInterfaceOrientationMask = .portrait func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { return orientationLock }这是整个方向控制的总闸门。任何页面想改方向,本质上就是改这个开关,然后想办法让系统知道。举个例子,点开视频页时,在点击事件里做两件事:先把这个开关改成.landscape,再用代码旋转设备方向:
func forceLandscape() { guard let delegate = UIApplication.shared.delegate as? AppDelegate else { return } delegate.orientationLock = .landscape UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation") UIViewController.attemptRotationToDeviceOrientation() }两行代码不少人都写过,但很多人漏了最后那行attemptRotationToDeviceOrientation。它的作用是主动提醒系统重新评估当前设备方向。如果用户手里拿着的本来就是横着的手机,系统转过一次,之后一直盯着方向,你改了orientationLock系统却不一定会立刻重新问;你主动调用一下,系统才意识到“哦,方向支持列表变了,我该再问一次了”。不写这行,视频页经常出现“点了播放,但屏幕还是竖的,必须手动转一下手机才变横”的诡异体验。
退出全屏时恢复方向也有时序讲究。我得提醒一句:先让页面dismiss,再改回orientationLock,顺序反了会闪黑屏。实际经验是dismiss完成后再调用forcePortrait。另外,在强制横屏操作前要确保当前顶层页面的shouldAutorotate返回true,否则UIDevice的setValue会被系统当成无效操作,方向和内容对不上。
这样设计的优点是把方向策略收敛到了一个点上,后续不管加多少页面,只要在需要特殊方向的页面入口处调用对应方法即可。缺点是它依赖AppDelegate的静态配置,如果App里有多个window,或者用到了第三方SDK,逻辑上要格外小心,因为第三方SDK很有可能也自己hook了supportedInterfaceOrientationsFor方法,两套逻辑撞在一起就会出难以排查的bug。
2.2 模态页面和复杂容器下的方向穿透
控制层设计好之后,接下来要考虑的是模态页面。
我在真实项目里踩过一个坑:一个页面A是竖屏列表,从A里present出视频播放页B,B在viewDidLoad里设置了orientationLock = .landscape。结果App在视频播放时横屏了,但一旦dismiss B,整个App就“卡”在了横屏,回不去竖屏。最后定位发现,问题出在dismiss的时机:B的viewWillDisappear里恢复orientationLock = .portrait不生效,因为系统在dismiss动画过程中已经按B的方向做了一次转场,改完开关之后没有再用attemptRotationToDeviceOrientation触发系统重新判定。
解决方式也很简单:恢复方向放在presenting页面的viewDidAppear里,或者用presentationControllerDidDismiss回调,总之要在转场彻底结束后再改方向开关。
另外一个隐藏很深的问题:模态页面的方向不是看presenting页面的意愿,而是看presented页面。如果某个页面用UIModalPresentationPageSheet方式present出来一个半屏面板,面板会跟着主页面转,但面板内部如果还做了独立的WebView,横屏之后安全区和键盘的错位会叠加,一般建议在这种半屏场景里明确锁定方向,不要放任自由旋转。
还有UITabBarController和UINavigationController这类容器控制器,它们的supportedInterfaceOrientations默认取的是当前选中子控制器。如果子控制器没有实现对应接口,或者你的容器是自定义的,就要自己写穿透逻辑。我之前在做导航控制器时发现,直接给UINavigationController写分类覆盖supportedInterfaceOrientations可能会影响系统内部一些特殊控制器,所以最佳实践是项目里统一使用自定义的BaseNavigationViewController,在它内部做方向穿透,而不是全局改分类。
2.3 可复用的方向控制协议
长期做混合开发,我最后沉淀了一套代码,核心就一个协议:
protocol OrientationControllable { var supportedOrientations: UIInterfaceOrientationMask { get } }所有需要特殊方向的页面遵循这个协议返回自己的方向,容器控制器负责把当前选中页面的方向问出来,AppDelegate最终统一收敛:
// 导航控制器内部做方向穿透 extension UINavigationController { open override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if let vc = visibleViewController as? OrientationControllable { return vc.supportedOrientations } return .portrait } } // AppDelegate里取到顶层控制器后判断 func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { guard let rootVC = window?.rootViewController, let topVC = rootVC.topMostViewController() else { return .portrait } if let controllable = topVC as? OrientationControllable { return controllable.supportedOrientations } return .portrait }解释一下这套逻辑:visibleViewController可能是任意遵循了协议的页面,如果它没实现协议,就默认只支持竖屏。这样全App新页面默认全部竖屏,只有写了协议的页面能自己决定横竖屏,既不会影响老页面,也不会忘记处理新页面。topMostViewController是一个递归找到最顶层展示控制器的扩展方法,要处理好presentedViewController、UINavigationController、UITabBarController三层嵌套关系,否则拿不到真正的业务页面。这个方案我用了很长时间,稳。
3. WebView旋转后的三座大山:布局、安全区、键盘
3.1 布局错乱与frame重置
现在到了混合开发的重灾区。WKWebView里嵌入的H5页面,转屏幕之后最常见的现象有两种:一是横屏后内容左对齐,右侧一大片空白;二是内容被缩得比竖屏还小,或者出现滚动冲突。
第一种基本可以锁定在viewport上。H5页面如果在head里没写viewport或者写得不完整,iOS Safari默认按980px宽度渲染页面。竖屏时宽度刚好勉强适配,横屏后980px比实际宽度小,页面就被挤到一边。要解决,先给HTML加上一个标准的viewport标记:
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />viewport-fit=cover这个参数尤其重要,它告诉WebView内容区域要撑满包括安全区在内的整个屏幕,否则页面在刘海屏下面还会被限制出一块空白。很多团队升级了iOS新机型以后才发现历史页面四周出现白边,就是因为早期项目只写了width=device-width,没有加viewport-fit。
第二种就需要原生侧配合了。WKWebView在旋转时的frame变化一般是由外层视图的autoresizing带动的,但自定义布局时常常忽略了这一点,导致WebView的frame停在旧的尺寸。我一般会在控制器里重写viewWillTransition方法,手动把WebView的frame和旋转后的目标尺寸对齐:
- (void)viewWillTransitionToSize:(CGSize)size withTransitionCoordinator:(id<UIViewControllerTransitionCoordinator>)coordinator { [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; [coordinator animateAlongsideTransition:^(id<UIViewControllerTransitionCoordinatorContext> _Nonnull context) { self.webView.frame = CGRectMake(0, 0, size.width, size.height); } completion:nil]; }注意这里用的是viewWillTransition传入的size,而不是self.view.bounds。旋转过程中view.bounds的更新时机不是百分百靠谱,手动转屏动画里和系统SDK协调用同一个size会更安全。
还要提一个容易忽略的细节:在旋转的转场动画里修改webView.frame时,如果webView内部有正在执行中的CSS动画,可能会造成动画中断或者闪烁。稳妥的方法是在页面里监听resize事件,旋转结束后统一重绘;原生侧只在转场结束后去更新scrollView的inset。
3.2 viewport和CSS的适配细节
viewport说完了,再说CSS侧。H5页面在横竖屏状态下要自适应,最核心的是不要用绝对px写布局宽度。很多页面竖屏看着好好的,一横屏全乱,就是因为头部导航、列表卡片全部是定宽写的。建议的做法是:
- 根字号用JS动态计算,页面内部全部用rem做相对尺寸。
- 大块容器不写固定宽度,用max-width: 100vw,或者百分比。
- 需要区分横竖屏样式的组件,用CSS媒体查询:
@media screen and (orientation: landscape) { .video-wrapper { width: 100vw; height: 100vh; } }不过这里有个坑:iOS的CSS媒体查询在旋转的瞬间,orientation取到的可能还是旧值,导致横屏样式延迟生效,或者横屏样式套到了竖屏上。为了规避这个问题,我的习惯是:媒体查询只用来做“方向相关”的大样式,比如背景图和字体大小,真正影响布局的尺寸判断,全部交给JavaScript去算innerWidth和innerHeight,再通过切换class来驱动样式:
.is-landscape .video-wrapper { width: 100vh; height: 100vw; }为啥要这样?因为CSS的媒体查询是“方向”语义,它在iPad分屏、软键盘弹起这些场景下会变得不可靠。比如横屏状态下弹出软键盘,view的高度会变小,但orientation还是landscape,此时如果布局依赖orientation,就会错。用innerWidth和innerHeight去判断实际可用空间,才是最终被渲染出来的真实布局尺寸。
说到vh和vw,iOS的WebView在横竖屏下还有一个历史遗留问题:竖屏时100vh正常,横屏时100vh往往会超出可视区域一截,因为底部工具栏和浏览器安全区的影响。现在用viewport-fit=cover配合env(safe-area-inset-bottom)可以抵消一部分,但最稳的还是什么都不信任,直接在JS里用window.innerHeight去设置关键容器的高度。
3.3 JS侧方向感知:到底该监听哪个事件
H5页面想要在旋转时做出反应,有三个事件/API可以选:orientationchange、resize、matchMedia。很多人随手写了orientationchange,然后发现横竖屏切换时它确实触发了,但有时候获取innerWidth还是旧值,于是各种延迟300毫秒、500毫秒去取,属于碰运气。
我比较推荐用matchMedia监听方向,但同时在回调里再确认一次真实尺寸:
const portraitMql = window.matchMedia('(orientation: portrait)'); function handleOrientationChange(e) { const isPortrait = e.matches || window.innerHeight > window.innerWidth; document.body.classList.toggle('is-portrait', isPortrait); requestAnimationFrame(() => { // 此时innerWidth/innerHeight已经是新值 refreshLayout(); }); } portraitMql.addEventListener('change', handleOrientationChange);为什么不用orientationchange?因为它在iPad分屏、WebView嵌入、以及某些安卓WebView上支持不可靠,matchMedia是各个浏览器更一致的方式。为什么不在回调里直接读innerWidth?因为事件触发那一刻WebView的布局可能还没完成,requestAnimationFrame里再读会更稳妥。如果页面里有依赖特定尺寸的第三方库(比如图表、富文本编辑器),refreshLayout里还要带上从上一帧到当前帧的尺寸变化,做一次防抖,避免多次重复初始化。
另外,JS侧对安全区的适配也值得提一下。viewport-fit=cover写好之后,CSS里可以用env(safe-area-inset-*)来读取安全区,但这个函数在旧版系统上返回0,而且只能在CSS属性里用,JS拿不到。如果页面需要根据安全区动态计算滚动容器的底部间距,比较好的做法是:原生在页面加载完成后和每次安全区变化时,都通过evaluateJavaScript把safeAreaInsets写入window上的一个全局变量,页面里的组件再监听一个自定义事件即可拿到最新值。我在4.1里讲的就是这个流程。
4. 安全区、状态栏和键盘:旋转后永远乱成一团
4.1 安全区正确读取时机与顺序
先说安全区。竖屏状态下iPhone刘海屏底部有34pt的安全区,横屏状态下左右两侧各多出21pt的圆角避让区。如果你的页面用fixed定位做底部按钮,旋转之后按钮很容易嵌进圆角区域,看起来像被屏幕“咬”了一口。
原生控制器里拿到安全区最晚的时机是viewSafeAreaInsetsDidChange。注意,这不是viewWillTransition触发时的那一下,而是安全区真正更新后的回调:
override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() updateWebViewInsets() }在旋转过程中,view.safeAreaInsets这个属性实际上会变好几次,如果在viewWillTransition里读,读到的可能是旧值。正确做法是依赖viewSafeAreaInsetsDidChange,并且在这个回调里把新的safeAreaInsets通过evaluateJavaScript注入给H5。
如果H5页面本身也有基于safe-area的布局,我一般把注入JavaScript和原生frame更新放在同一批处理里:
private func updateWebViewInsets() { let left = view.safeAreaInsets.left let right = view.safeAreaInsets.right let top = view.safeAreaInsets.top let bottom = view.safeAreaInsets.bottom let js = "window.__safeAreaInsets = {left:\(left), right:\(right), top:\(top), bottom:\(bottom)}; window.dispatchEvent(new Event('safeareachange'));" webView.evaluateJavaScript(js, completionHandler: nil) }这里dispatchEvent一个自定义事件,页面里所有对安全区敏感的组件都去监听它,做到一处变化全局响应。为什么要通过事件而不是让页面自己读?因为JS的自定义事件可以保证所有组件在同一帧拿到相同的数值,避免出现有的组件已经更新、有的还在等setTimeout的中间状态。
4.2 键盘遮挡输入框的处理
混合开发里,H5页面里的input聚焦后弹键盘,然后旋转屏幕,输入框被键盘挡住,这是出现频率极高又特别难排查的问题。竖屏时键盘高度比较大,但输入框往往在页面下半部分;横屏时键盘高度变小,但输入框的位置也变了,再加上安全区、刘海屏的存在,很容易出现键盘弹出来后看不见光标的情况。
原生侧我一般这样处理键盘通知:
- (void)keyboardWillChange:(NSNotification *)notification { CGRect endFrame = [notification.userInfo[UIKeyboardFrameEndUserInfoKey] CGRectValue]; CGRect convertedFrame = [self.view convertRect:endFrame fromView:self.view.window]; // 以当前view为参考系,算出键盘顶部到view底部的距离 CGFloat keyboardHeight = self.view.frame.size.height - convertedFrame.origin.y; UIEdgeInsets insets = UIEdgeInsetsMake(0, 0, keyboardHeight, 0); self.webView.scrollView.contentInset = insets; self.webView.scrollView.scrollIndicatorInsets = insets; }这里有个细节:键盘高度不要直接用endFrame.size.height,因为在横屏状态下,键盘的frame是屏幕坐标系,不能直接当“遮挡高度”用。先用convertRect从window坐标系转换到当前view坐标系,再算差值,得到的结果才是真实遮挡高度。如果底部还有安全区,需要额外再减掉safeAreaInsets.bottom,不然输入框会被多顶上去一截。
还有一个经验:键盘动画的时长和曲线,可以从notification.userInfo里取UIKeyboardAnimationDurationUserInfoKey和UIKeyboardAnimationCurveUserInfoKey,设置contentInset时套用同样的动画参数。否则键盘是动画起来的,而你的contentInset瞬间跳变,看起来会特别生硬。H5侧也需要配合——input聚焦后,如果页面有position: fixed的底部导航,iOS键盘弹出会触发viewport resize,fixed元素可能会被顶起来。最稳妥的方案是input聚焦时给body加一个class,隐藏fixed底部导航,失焦时再恢复。这样键盘动画期间不会有元素飞上飞下。
4.3 状态栏样式的旋转联动
最后一个不容易注意到的问题是状态栏。iOS状态栏有preferredStatusBarStyle这个属性,竖屏和横屏想让状态栏呈现不同的文字颜色,直接在控制器的preferredStatusBarStyle里写死是不行的,因为旋转发生时系统不一定会重新读取它。
需要在旋转转场里主动触发一次状态栏更新:
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) coordinator.animate(alongsideTransition: { _ in self.setNeedsStatusBarAppearanceUpdate() }, completion: nil) }setNeedsStatusBarAppearanceUpdate的调用时机放在animateAlongsideTransition里,是为了让状态栏的切换和旋转动画同步发生,否则会先完成旋转、状态栏再跳一下,视觉上很掉价。如果你在控制器里重写了preferredStatusBarStyle,这个方法执行后系统会重新读取一遍,所以只要在里边根据当前方向做出不同返回就行。
5. RN和Flutter:跨端框架里的特殊方向问题
5.1 React Native:Dimensions在旋转后为什么“偷懒”
React Native做混合开发,核心方向问题一般集中在Dimensions上。很多人在竖屏时用Dimensions.get('window').width去定列表宽,然后横向旋转,发现布局没有自动更新。原因是RN里Dimensions.get返回的是某一个时刻的尺寸快照,旋转事件不会主动让这个值变掉。
解决方式是用useWindowDimensions这个hook,它会监听窗口尺寸变化,并触发组件重新渲染:
import { useWindowDimensions } from 'react-native'; function ArticleList() { const { width, height } = useWindowDimensions(); const isLandscape = width > height; return ( // 根据isLandscape调整布局 ); }如果你在class组件里,不能直接用hook,需要手动注册Dimensions的事件监听:
Dimensions.addEventListener('change', ({ window }) => { const { width, height } = window; // 更新state或手动重排 });注意在组件卸载时移除监听,不然旋转一次就多一个回调,页面多了之后性能会肉眼可见地劣化。这里有一个RN和其他方案的差异:RN的旋转事件在原生层和JS层之间还要过一次bridge,所以有时原生已经旋转了,JS里的事件还没到。解决思路是不要依赖单一事件时机,页面在渲染时主动读取一次当前宽高,事件只是用来通知“可能需要重新读了”。另外,SafeAreaView在不同方向下的表现也需要手动确认,iOS横屏时RN的SafeAreaView默认会跟随安全区变化,但如果外层包了ScrollView,ScrollView的contentInset要单独设置。
我在RN项目里还遇到过一种情况:横屏后,Modal组件里的内容没有跟着旋转,原因是Modal在iOS上走的是独立的UIWindow,方向权限被父页面带走了。这种情况要在Modal内部再单独声明supportedInterfaceOrientations,或者在打开Modal之前把方向锁好。如果产品要求“不管主页是什么方向,弹窗都必须竖屏”,那直接在Modal里返回.portrait是最省事的写法。
5.2 Flutter:SystemChrome只控制了一半
Flutter的方向控制核心API是SystemChrome.setPreferredOrientations,很多教程会写:
SystemChrome.setPreferredOrientations([ DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight, ]);这行代码在纯Flutter工程里基本是好用的,但一旦你是“原生壳 + Flutter模块”这种混合架构,问题就来了。SystemChrome只能影响FlutterViewController自身的方向偏好,而iOS系统最终要不要旋转,还要看FlutterViewController这个VC的supportedInterfaceOrientations返回了什么。如果这个VC没有配置,两个方向设置就会互相矛盾,表现是“Flutter页面里设置了横屏,屏幕却纹丝不动”。
我的处理方案是:Flutter容器VC的supportedInterfaceOrientations动态返回一个委托方向,这个方向由原生侧决定;Flutter侧调用SystemChrome后,通过消息通道告知原生侧自己期待的方向,原生侧再更新orientationLock并调用attemptRotationToDeviceOrientation。相当于把Flutter的“意见”翻译成原生层能听懂的方向指令。
还有一个容易被坑的点:setPreferredOrientations传空数组在iOS上会没有效果,需要显式列出想支持的方向。想把方向从横屏恢复成全部方向,也要显式传入所有方向而不是空数组。这个API在Android和iOS上的语义有一点点差异,Android传空数组可能表示“不锁定”,iOS不会这么解释,跨端代码里一定要写成平台分叉。
5.3 跨端方向状态同步的设计思路
结合RN和Flutter的经验,我最后总结出一个通用思路:不要指望各层自己感知方向,而是做一个统一的方向状态源。
- 原生层在系统旋转发生时,将最新方向、宽高、安全区写入一份全局配置。
- 无论WebView、RN还是Flutter,都从这份配置里取数据作为页面布局依据。
- 原生侧在方向变化时向各层广播事件,各层收到广播后重新从全局配置拉取数据。
这样做的根本原因在于,混合开发场景下,每一次方向变化影响的不只是一个层。原生控制器的viewport变了,WebView的frame变了,JS里的innerWidth变了,RN里Dimensions变了,Flutter里的MediaQuery也变了,如果每个层都自己感知异步变化,很难保证最终效果一致。统一状态源至少能把“变化”这件事收敛到一条线上,排查问题时思路就清楚很多:先看原生层有没有把数据写对,再看各层有没有在收到广播后正确消费。
6. 排查思路与问题速查表
6.1 三步定位到底是哪一层在作妖
混合开发方向问题最头疼的不是修,是先定位。我惯用三步法。
第一步,用空白原生VC测试。把一个没有WebView、没有跨端组件的裸页面push进去,让它支持全方向。如果原生页都不转,说明全局方向控制层有问题,优先查AppDelegate和Info.plist;如果原生页正常,说明问题在WebView或跨端框架层。
第二步,用Safari直接打开同一份H5页面。Safari行为和WKWebView高度接近,如果Safari正常但App里的WebView不正常,问题就锁定在WebView配置上,优先检查viewport、frame和注入脚本。如果Safari也不正常,那问题基本在H5代码本身,原生侧不用背锅。
第三步,在关键节点打日志。需要打日志的地方包括:AppDelegate的supportedInterfaceOrientationsFor、页面的shouldAutorotate、viewWillTransition、JS里的orientationchange/resize、原生传方向通知的时机。把这几个日志按时间顺序排在一起,基本一眼就能看出谁晚了一步、谁中间断了。这一步看似土,其实是最快找到真凶的办法,很多时候比翻代码猜来猜去高效得多。
6.2 高频问题速查表
| 现象 | 常见原因 | 建议排查方向 |
|---|---|---|
| 全App横竖屏无规则切换 | Info.plist方向集合未限制 | 检查UISupportedInterfaceOrientations配置 |
| 某个页面无论如何不转屏 | AppDelegate全局mask未放开 | 检查orientationLock是否被写死为portrait |
| 旋转后H5左侧大量空白 | viewport缺失或没有viewport-fit=cover | 补全viewport并设置fit=cover |
| WebView转屏后frame错位 | 外层布局未跟随旋转或webView frame未更新 | viewWillTransition中重置frame |
| 旋转后CSS布局用旧宽度 | JS未监听resize/方向事件 | 使用matchMedia + requestAnimationFrame兜底 |
| 键盘遮挡输入框 | 键盘高度计算方式错误 | 用convertRect转换坐标系后再算遮挡高度 |
| 模态页dismiss后回不到竖屏 | 方向开关恢复时机过早 | 在dismiss完成回调里恢复并attemptRotation |
| RN横屏后list宽度不变 | 使用了Dimensions.get快照 | 改用useWindowDimensions或注册监听 |
| Flutter嵌入原生后无法横屏 | 原生VC方向mask未开放 | 容器VC的supportedInterfaceOrientations需要同步 |
| iPad分屏下方向判断错误 | 用orientation而非实际宽高 | 统一用innerWidth/innerHeight判断 |
这个表是我自己项目里整理出来的,不能说覆盖所有场景,但覆盖了日常90%以上的问题。遇到问题时先对表,有点像做体检,能省掉很多重复劳动。如果能再配合6.1的三步定位法,大多数问题半小时内都能定位到具体层。
6.3 长期有效的几个设计习惯
走到最后,把真正能减少未来踩坑的几条设计习惯写出来。算不上什么高深理论,都是拿时间和bug换来的教训。
第一,方向控制的入口要收敛。不要每个页面自己写shouldAutorotate,最好都通过同一个控制层去改方向开关,不然项目跑半年之后,你根本不知道是哪个页面把方向改了。
第二,旋转后的布局更新不要依赖单个事件。JS里能同时用matchMedia、resize、requestAnimationFrame兜底,就尽量都用,事件偶尔会缺席,但最终渲染结果不能错。
第三,一定要真机测试。模拟器里键盘、安全区、方向转场的表现和真机差异非常大,尤其刘海屏横屏下的圆角避让区,不在真机上根本看不到。有条件的话把测试机型的iOS版本覆盖到新旧各一台,不同系统版本之间方向控制的行为也有细微差别。
第四,也是我觉得最值钱的一条:把方向变化当成一次“全链路状态刷新”,而不是某一个页面的局部事件。原生控制器、WebView frame、JS布局、安全区、键盘,这些都要串在同一个流程里更新。混合开发横竖屏问题看起来很零散,归根结底都是因为各层状态不同步。如果项目启动时能提前约定一个统一的方向状态通道,后面至少能少肝一个星期的bug。