前言
現(xiàn)在大部分的智能移動設備通過自動旋轉(zhuǎn),能夠自動切換去呈現(xiàn)最適合當前屏幕顯示的內(nèi)容,無疑大大提升了使用者的用戶體驗。不過作為開發(fā)者,想要達到完美的適配效果,還是要下一番功夫鉆研嘗試才能做得的。筆者就根據(jù)自己適配屏幕自動旋轉(zhuǎn)的工作經(jīng)驗,在此做一點總結(jié)。
硬件原理
為了檢測設備(最關鍵的就是面子——屏幕)當前在三維空間中的朝向,現(xiàn)在的智能設備都內(nèi)置了加速計。這一部分完全參照來源【1】:
通過感知特定方向的慣性力總量,加速計可以測量出加速度和重力,ios設備內(nèi)的加速計是一個三軸加速計,這意味著它能夠檢測出三維空間中的運動或重力引力。因此加速計不但可以指示握持電話的方式(如自動旋轉(zhuǎn)功能),而且如果電話放在桌子上的話還可以指示電話的正面朝上還是朝下。
加速計可以測量g引力(g代表重力),因此加速計返回值為1.0時,表示在特定的方向上感知到1g。
- 如果是靜止握持iphone而沒有任何運動,那么地球引力對其施加的力大約為1g
- 如果是縱向豎直握持,那么設備會檢測并報告在其y軸上施加的力大約為1g
- 如果是以一定的角度握持,那么1g的力會分布到不同的軸上,這取決于握持的方式,在以45度握持時,1g的力會均勻的分解到兩個軸上。如果檢測到加速計值遠大于1g,那么可以判斷是突然運動,,正常使用時加速計在任何一個軸上都不會檢測到遠大于1g的值,如果搖動、墜落或投擲設備,那么加速計便會在一個或多個軸上檢測到很大的力。
下圖所示加速計所使用的三軸結(jié)構(gòu)

當然,如今的智能手機里往往不光內(nèi)置了加速計,往往還有陀螺儀。這一方面的知識就由大家自行去挖掘吧,很多游戲都是利用它去實現(xiàn)很自然的操作感。
軟件適配
朝向定義
既然硬件能獲取到當前屏幕的朝向,蘋果的SDK也一定會為開發(fā)者提供接口指定有哪些朝向可選,以及如何獲取到當前朝向。在 UIDevice.h 以及 UIApplication.h 中可見其定義如下:
7種設備朝向:
typedef NS_ENUM(NSInteger, UIDeviceOrientation) {
UIDeviceOrientationUnknown,
UIDeviceOrientationPortrait, // Device oriented vertically, home button on the bottom
UIDeviceOrientationPortraitUpsideDown, // Device oriented vertically, home button on the top
UIDeviceOrientationLandscapeLeft, // Device oriented horizontally, home button on the right
UIDeviceOrientationLandscapeRight, // Device oriented horizontally, home button on the left
UIDeviceOrientationFaceUp, // Device oriented flat, face up
UIDeviceOrientationFaceDown // Device oriented flat, face down
} __TVOS_PROHIBITED;
5種界面朝向:
// Note that UIInterfaceOrientationLandscapeLeft is equal to UIDeviceOrientationLandscapeRight (and vice versa).
// This is because rotating the device to the left requires rotating the content to the right.
typedef NS_ENUM(NSInteger, UIInterfaceOrientation) {
UIInterfaceOrientationUnknown = UIDeviceOrientationUnknown,
UIInterfaceOrientationPortrait = UIDeviceOrientationPortrait,
UIInterfaceOrientationPortraitUpsideDown = UIDeviceOrientationPortraitUpsideDown,
UIInterfaceOrientationLandscapeLeft = UIDeviceOrientationLandscapeRight,
UIInterfaceOrientationLandscapeRight = UIDeviceOrientationLandscapeLeft
} __TVOS_PROHIBITED;
可見二者的枚舉值相互之間對應得上。
另外還有可組合使用的OrientationMask定義,通常在頁面聲明支持的朝向時用到,后面再展開討論。
typedef NS_OPTIONS(NSUInteger, UIInterfaceOrientationMask) {
UIInterfaceOrientationMaskPortrait = (1 << UIInterfaceOrientationPortrait),
UIInterfaceOrientationMaskLandscapeLeft = (1 << UIInterfaceOrientationLandscapeLeft),
UIInterfaceOrientationMaskLandscapeRight = (1 << UIInterfaceOrientationLandscapeRight),
UIInterfaceOrientationMaskPortraitUpsideDown = (1 << UIInterfaceOrientationPortraitUpsideDown),
UIInterfaceOrientationMaskLandscape = (UIInterfaceOrientationMaskLandscapeLeft | UIInterfaceOrientationMaskLandscapeRight),
UIInterfaceOrientationMaskAll = (UIInterfaceOrientationMaskPortrait | UIInterfaceOrientationMaskLandscapeLeft | UIInterfaceOrientationMaskLandscapeRight | UIInterfaceOrientationMaskPortraitUpsideDown),
UIInterfaceOrientationMaskAllButUpsideDown = (UIInterfaceOrientationMaskPortrait | UIInterfaceOrientationMaskLandscapeLeft | UIInterfaceOrientationMaskLandscapeRight),
} __TVOS_PROHIBITED;
朝向獲取和設置
有了朝向的定義,該如何獲取當前的朝向取值呢?
如果是要獲取設備朝向,可以直接通過 UIDevice 實例的屬性
// return current device orientation. this will return UIDeviceOrientationUnknown unless device orientation notifications are being generated.
@property(nonatomic,readonly) UIDeviceOrientation orientation __TVOS_PROHIBITED;
需要注意注釋的內(nèi)容,也就是必須首先在 UIDevice 朝向通知生成之后才可以正常獲取朝向數(shù)據(jù)。
也就是要監(jiān)聽UIDevice拋出的系統(tǒng)通知 UIDeviceOrientationDidChangeNotification
[[NSNotificationCenter defaultCenter]addObserver:self
selector:@selector(updateOrientation:)
name:UIDeviceOrientationDidChangeNotification object:nil];
不過這里其實有一點小坑,那就是還有一對關鍵的接口蘋果沒有直接告訴你,那就是
- (void)beginGeneratingDeviceOrientationNotifications __TVOS_PROHIBITED; // nestable
- (void)endGeneratingDeviceOrientationNotifications __TVOS_PROHIBITED;
必須要在調(diào)用前者之后,才會在每次設備朝向變化時觸發(fā) UIDeviceOrientationDidChangeNotification 通知。
不過沒有必要的話,也要及時調(diào)用后者去結(jié)束對加速計數(shù)據(jù)的獲取,默默的為用戶電池續(xù)航助力。
類似的,也同樣可以通過監(jiān)聽下面兩個通知去獲取UIInterfaceOrientation的變化:
UIKIT_EXTERN NSString *const UIApplicationWillChangeStatusBarOrientationNotification __TVOS_PROHIBITED; // userInfo contains NSNumber with new orientation
UIKIT_EXTERN NSString *const UIApplicationDidChangeStatusBarOrientationNotification __TVOS_PROHIBITED; // userInfo contains NSNumber with old orientation
二者的差異關鍵是在notification的userInfo中攜帶的值,一個是新的朝向值,一個是舊的朝向值,可不要搞反了哦。
再有是通過UIApplication的下面這個屬性也可以獲取界面朝向。
// Explicit setting of the status bar orientation is more limited in iOS 6.0 and later.
@property(readwrite, nonatomic) UIInterfaceOrientation statusBarOrientation NS_DEPRECATED_IOS(2_0, 9_0) __TVOS_PROHIBITED;
有的同學可能會有疑問,DeviceOrientation 和 StatusBarOrientation是否可以等同使用?關于這個問題,有句話說的好:
紙上得來終覺淺,絕知此事要躬行
動手試一試就會明白,二者實則有著本質(zhì)不同。
真相在此:前者是指示設備朝向,而后者則是指示當前界面中狀態(tài)欄的朝向;在[UIDevice beginGeneratingDeviceOrientationNotifications]之后,每次設備旋轉(zhuǎn),都會有UIDeviceOrientationDidChangeNotification的通知生成,而 UIApplicationWillChangeStatusBarOrientationNotification 則是當前顯示controller支持對應的InterfaceOrientation時才會觸發(fā)。
所以可能會出現(xiàn)這種情況,DeviceOrientation 值 為UIDeviceOrientationLandscapeLeft,但InterfaceOrientation 值卻是 UIInterfaceOrientationPortrait,下圖就是典型的例子:

另外,某些應用場景下,還需要去手動設置屏幕旋轉(zhuǎn),比如播放器往往都既支持自動旋轉(zhuǎn)屏幕去切換全屏播放,同時也允許用戶去手動切換全屏或小屏播放。但翻看了半天API描述和文檔,要么就是不提供接口,要么就是警告設置受限,那要怎么做呢?其實很簡單,只要兩行代碼搞定:
NSNumber *value = @(UIInterfaceOrientationPortrait);//或者別的想要的值
[[UIDevice currentDevice] setValue:value forKey:@"orientation"];
App及頁面適配
- App全局配置
App中全局配置支持朝向的地方,最方便的就是在工程的Target中了,如圖所示:

理所當然全局配置其優(yōu)先級當然是最高的,即使某個頁面聲明支持某Orientation,但全局配置中并沒有選中對應的Device Orientation,是不會起效的。
-
單個頁面配置
具體到某個頁面(controller)層級的配置,UIViewController提供了如下的回調(diào)方法// New Autorotation support. - (BOOL)shouldAutorotate NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED; - (UIInterfaceOrientationMask)supportedInterfaceOrientations NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED; // Returns interface orientation masks. - (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation NS_AVAILABLE_IOS(6_0) __TVOS_PROHIBITED;
第一個方法在首次進入controller以及屏幕方向未鎖定且觸發(fā)旋轉(zhuǎn)時會被系統(tǒng)調(diào)用(且不重寫的話,默認返回值為YES),如果返回NO,那么表明該頁面不支持對屏幕旋轉(zhuǎn)做適配;若返回YES,則表明支持旋轉(zhuǎn),但具體適配了哪幾個朝向,則依賴于supportedInterfaceOrientations 方法的返回值,也就是UIInterfaceOrientationMask類型的Option組合。
看起來并不復雜對不對?在設定了App的全局配置,并在相應的controller中實現(xiàn)了這些回調(diào)之后發(fā)現(xiàn),有同學可能會失望地發(fā)現(xiàn),設備旋轉(zhuǎn)時這些方法卻并沒有期望地那樣被調(diào)到,這是為什么呢?
通過反復驗證,發(fā)現(xiàn)其實系統(tǒng)確實會調(diào)用這個方法,但默認執(zhí)行粒度是到系統(tǒng)級的 Container View Controller(UINavigationController/UITabBarController)為止(其實直接掛在UIWindow上作為其rootViewController的UIViewController對象的 shouldAutoRotate 方法也會得到調(diào)用,但畢竟大多數(shù)情況下,我們不會用這么簡單的組合結(jié)構(gòu)的)。所以我們額外需要實現(xiàn)的一步,就是轉(zhuǎn)發(fā)這個調(diào)用消息到我們真正想要處理的那個controller上。當然,可以通過hook系統(tǒng)類的對應方法去做實現(xiàn),但筆者采用的是在自行定義的UINavigationController繼承類中重寫這些方法:
#pragma mark Orientation
- (BOOL)shouldAutorotate
{
BOOL shouldAutorotate = NO;
UIViewController *viewController;
if (IOS_VERSION_FLOAT_VALUE >= 8.0)
{
viewController = [self visibleViewController];
}
else
{
viewController = [self topViewController];
}
if ([viewController isKindOfClass:K12RootViewController.class] && ((K12RootViewController *)viewController).visibleNav) {
viewController = ((K12RootViewController *)viewController).visibleNav;
}
if (viewController.ht_currentChildViewController) {
viewController = viewController.ht_currentChildViewController;
}
if ([viewController isKindOfClass:[UIViewController class]])
{
shouldAutorotate = [(UIViewController *)viewController shouldAutorotate];
}
//彈框也要支持旋轉(zhuǎn)
if ([viewController isKindOfClass:K12PlayerController.class] || ((IOS_VERSION_FLOAT_VALUE >= 8.0) ? [viewController isKindOfClass:UIAlertController.class] : NO)) {
return YES;
}
else {
return NO;
}
return shouldAutorotate;;
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations
{
NSUInteger supportedInterfaceOrientations = UIInterfaceOrientationMaskPortrait;
UIViewController *viewController;
if (IOS_VERSION_FLOAT_VALUE >= 8.0)
{
viewController = [self visibleViewController];
}
else
{
viewController = [self topViewController];
}
if ([viewController isKindOfClass:K12RootViewController.class] && ((K12RootViewController *)viewController).visibleNav) {
viewController = ((K12RootViewController *)viewController).visibleNav;
}
if (viewController.ht_currentChildViewController) {
viewController = viewController.ht_currentChildViewController;
}
//向UIAlertController發(fā)送supportedInterfaceOrientations消息會crash……
if ([viewController isKindOfClass:UIAlertController.class]) {
return UIInterfaceOrientationMaskAllButUpsideDown;
}
if ([viewController isKindOfClass:[UIViewController class]])
{
supportedInterfaceOrientations = [(UIViewController *)viewController supportedInterfaceOrientations];
}
return supportedInterfaceOrientations;
}
可以看到其中有各種各樣case的處理,原因就是除了播放頁面支持豎屏、左橫屏以及右橫屏(UIInterfaceOrientationMaskAllButUpsideDown)之外,我們產(chǎn)品中的其他頁面都是只支持橫屏顯示的(UIInterfaceOrientationMaskPortrait),同時在當前頁面上有UIAlertController(iOS8 之后)彈出時,也要設置其支持跟隨屏幕旋轉(zhuǎn)。
類似的,如果 UIWindow 對象的 rootViewController 是 UITabBarController 的話,則需要轉(zhuǎn)發(fā)消息給其 selectedViewController 屬性對象,具體實現(xiàn)就不再贅言啦。
- 踩過的坑
說起來,項目開發(fā)中不踩點坑簡直對不起程序猿這個title啊 —— 前面提到過
...掛在UIWindow上作為其rootViewController的UIViewController對象的 shouldAutoRotate 方法也會得到調(diào)用
這里往往會隱藏一個問題,默認在AppDelegate.m中,我們會這樣做:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
...
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.k12RootController = [[K12RootViewController alloc] init];
K12NavigationController *navController = [[K12NavigationController alloc] initWithRootViewController: self.k12RootController];
self.window.rootViewController = navController;
[self.window makeKeyAndVisible];
...
}
當然,這看起來沒有問題。但是假如App中還存在別的 UIWindow 對象呢?旋轉(zhuǎn)時,它的rootViewController 的 shouldAutoRotate 方法也將被調(diào)用,若沒有重寫過,則其默認返回YES;如果與其他 UIWindow對象(特別是keyWindow) 所呈現(xiàn)的最頂部頁面的返回值不一致,就會出現(xiàn)一些神奇的表現(xiàn),如下圖所示:

切換到橫屏下時,狀態(tài)欄居然消失了!!該情況的出現(xiàn),就是因為在該答題頁面的上一個頁面(播放頁面)中使用了一個第三方組件去繪制Menu,而其設計存在瑕疵,在生成Menu對象而非顯示時就已經(jīng)生成了一個UIWindow對象并持有了它。然后在進入答題頁面時,雖然對應的controller的 shouldAutoRotate 方法返回了 NO,但Menu對應的UIWindow對象其rootViewController默認返回YES,導致出現(xiàn)頁面保持豎屏顯示,但狀態(tài)欄響應了旋轉(zhuǎn)的奇怪現(xiàn)象。
這個問題最終還是通過hook掉 UIViewController 的shouldAutoRotate 方法,去追蹤究竟是哪個controller對象返回了默認值 YES 才最終大白天下。這也提醒我們,對開源庫的品質(zhì)也是謹慎對待的,往往太復雜業(yè)務場景,還是需要自己去定制功能才能滿足。
總結(jié)
這篇文章也算是在參與某產(chǎn)品開發(fā)過程中,屏幕旋轉(zhuǎn)適配過程中,踩了不少坑之后經(jīng)驗教訓的一個總結(jié)。當然,想要實現(xiàn)頁面的橫豎屏切換效果,并不是只有這一條路徑,還可以通過UIView的transform屬性去實現(xiàn),不過那就是另一個話題啦 。
@property(nonatomic) CGAffineTransform transform; // default is CGAffineTransformIdentity. animatable
ヾ( ̄▽ ̄)ByeBye