背景

近期遇到了一个需求场景:SDK中webView能够自在切换横屏或竖屏翻开。本文环绕如何完成这个需求打开。

一、通用计划调研

这是一个很常见的事务场景,通常情况下,会经过以下两种办法来完成 :

完成办法一:

  • 在【General】–>【Device Orientation】中勾选一切需求支撑的方向
  • 创建一个基类操控器,在基类操控器中重写两个操控反正屏的办法:
// 支撑设备自动旋转 
- (BOOL)shouldAutorotate {  
    return YES; 
} 
// 支撑竖屏显现
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
    // 设置大部分页面的默许方向
    return UIInterfaceOrientationMaskPortrait; 
}
  • 让一切操控器继承自上述基类,并在需求横屏的操控器中重写上面的两个办法:
// 支撑设备自动旋转 
- (BOOL)shouldAutorotate {
    return YES;
} 
// 支撑横屏显现 
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {  
    // 假如该界面需求支撑反正屏切换  
    return UIInterfaceOrientationMaskLandscape | UIInterfaceOrientationMaskPortrait;  
    // 假如该界面仅支撑横屏  
    // returnUIInterfaceOrientationMaskLandscape; 
} 
// 假如需求横屏的时分,一定要重写这个办法并回来NO 
- (BOOL)prefersStatusBarHidden {  
    return NO; 
}

在当时的事务场景下,这种办法显然不太合理,原因如下:

  1. 要让一切类都继承自一个基类,改动太大

  2. 工程中得勾选一切需求支撑的方向,考虑到是SDK的逻辑,这样强制要求宿主工程修正设置不太实际。

完成办法二:

  • 【General】–>【Device Orientation】只勾选Portrait,即只支撑竖屏
  • 在需求横屏的操控器的viewDidLoad中增加告诉
[[UIDevice currentDevice] beginGeneratingDeviceOrientationNotifications];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(deviceOrientationDidChange) name:UIDeviceOrientationDidChangeNotification object:nil];
  • 监听设备旋转告诉,经过UIView的transform特点做界面旋转,并调整bounds
- (void)deviceOrientationDidChange { 
   if([UIDevice currentDevice].orientation == UIDeviceOrientationPortrait) { 
       [[UIApplication sharedApplication] setStatusBarOrientation:UIInterfaceOrientationPortrait]; 
       [self orientationChange:NO]; 
   } else if ([UIDevice currentDevice].orientation == UIDeviceOrientationLandscapeLeft) { 
   [[UIApplication sharedApplication] setStatusBarOrientation:UIInterfaceOrientationLandscapeRight]; 
   [self orientationChange:YES]; 
   } 
} 
- (void)orientationChange:(BOOL)landscapeRight { 
   if (landscapeRight) { 
       [UIView animateWithDuration:0.2f animations:^{
           self.view.transform = CGAffineTransformMakeRotation(M_PI_2); 
           self.view.bounds = CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, [UIScreen mainScreen].bounds.size.height); 
       }]; 
   } else { 
       [UIView animateWithDuration:0.2f animations:^{ 
           self.view.transform = CGAffineTransformMakeRotation(0); 
           self.view.bounds = CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, [UIScreen mainScreen].bounds.size.height); 
       }]; 
   } 
}

这种办法看起来如同靠谱一些,但是咱们SDK中的webView所在的页面操控器并不是简略地放置一个全屏webView而已,还有自定义的导航栏、工具栏等元素,在旋转并调整self.view.bounds之后,这些子视图的位置巨细都需求做调整。本来布局代码看起来就现已很杂乱,加上这个调整之后又得大动干戈,也是比较费事。

别的就是,UIDeviceOrientationDidChangeNotification这个告诉,是需求设备真的产生物理旋转行为,触发了陀螺仪,体系才会发出,要是用户就是不旋转设备呢?除非增加一些信息引导用户旋转,不然也很难达到效果。

二、实际问题拆解

实际上,SDK先前现已对webView所在的页面操控器做了反正屏界面适配(前面说到的杂乱的布局代码),只需宿主App支撑横屏和竖屏方向,并且设备方向产生改变,就能触发页面的布局适配。因而,当时需求需求解决的其实是以下两个问题:

  1. 在宿主App未支撑的情况下,暂时支撑咱们需求的设备方向
  2. 在设备未产生物理旋转行为的情况下,想办法触发设备方向改变告诉

三、问题解决

1、暂时修正设备支撑方向

开发App时,咱们能够经过在AppDelegate中完成supportedInterfaceOrientations 办法,增加暂时修正App支撑方向的逻辑。这个办法完成的优先级比工程设置的优先级更高。不过,在SDK开发中就没这么简略了,由于无法直接访问到这个类。所以咱们使用了iOS中常用的黑魔法:swizzle,用这种办法hook宿主工程AppDelegate的办法。

首要,在SDK初始化结束时(SDK初始化在didfinishLaunch中调用)发送特定告诉来调起办法交流,确保在完成启动之后才对AppDelegate的办法进行hook:

@implementationRSAppDelegateProxy 
+ (void)load {  
    static dispatch_once_t onceToken; 
    dispatch_once(&onceToken, ^{   
        [[NSNotificationCenter defaultCenter]addObserverForName:kAppReadyToConfigureSDKNotification
                            object:nil 
                            queue:nil 
                            usingBlock:^(NSNotification *notification) {
            [self hookAppDelegate];          
        }];  
    }); 
}

需求注意的是,hook完某个Delegate的办法之后,需求重新将该Delegate设置给具有者,不然,假如Delegate类中自身没有完成咱们hook的办法,那咱们的增加进去的办法会因办法缓存不存在而导致无法正常调用到。

@interface RSAppDelegateProxy : NSObject 
/// 修正支撑的设备方向 
@property (nonatomic, assign, setter=setCurrentSupportOrientationMask:) UIInterfaceOrientationMask currentSupportOrientationMask; 
/// 必要时康复初始AppDelegate支撑的设备方向 
- (void)restoreSupportedOrientationMaskIfNeeded; 
@end   
@interface RSAppDelegateProxy () 
/// 应用署理 
@property (nonatomic, strong) id<UIApplicationDelegate> appDelegate; 
/// 记录原先AppDelegate支撑的设备方向,复原时需求 
@property (nonatomic, assign) UIInterfaceOrientationMask originalSupportOrientationMask; 
@end   
@implementation RSAppDelegateProxy 
... 
- (void)hookAppDelegate {   
    _appDelegate = [UIApplication sharedApplication];   
    [selfhookSupportedInterfaceOrientations];  
    // 重置application delegate, 铲除体系对办法完成的缓存。不然,假如delegate自身没有完成咱们hook的办法,则咱们的办法增加进去后体系不认账   
    application.delegate = nil;   
    application.delegate = _appDelegate; 
} 
- (void)hookSupportedInterfaceOrientations { 
    // 先保存原始的支撑方向,回复方向时需求用到 
    [self saveOriginalSupportOrientation];   
    // 设置当时支撑方向为原始方向,不然启动时原始的支撑方向会失效 
    _currentSupportOrientationMask = _originalSupportOrientationMask;  
    // hook AppDelegate中的办法 
    [self hookSupportedInterfaceOrientationsInAppDelegate]; 
} 
- (void)hookSupportedInterfaceOrientationsInAppDelegate {
    SEL originalSelector = @selector(application:supportedInterfaceOrientationsForWindow:); 
    SEL swizzledSelector = @selector(rs_new_application:supportedInterfaceOrientationsForWindow:); 
    SEL noopSelector = @selector(rs_noop_application:supportedInterfaceOrientationsForWindow:);   
    // 经典的办法交流逻辑,先测验注入新办法,再交流完成  
    [self swizzlingInstance:_appDelegate originalSelector:originalSelector swizzledSelector:swizzledSelector noopSelector:noopSelector]; 
}

hook完成后,App就会一直调用新的办法完成,该完成一直回来RSAppDelegateProxy单例的currentSupportOrientationMask特点,而这个特点对外开放修正,经过修正currentSupportOrientationMask,就能实时修正App支撑的设备方向;当需求康复App原始的支撑方向时,只需将保存的originalSupportOrientationMask赋值给该特点。

/// 新的完成,一直获取 currentSupportOrientationMask 
- (UIInterfaceOrientationMask)rv_new_application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window {
    return [RSAppDelegateProxy sharedInstance].currentSupportOrientationMask; 
}   
/// setter办法 
- (void)setCurrentSupportOrientationMask:(UIInterfaceOrientationMask)currentSupportOrientationMask {
    _currentSupportOrientationMask = currentSupportOrientationMask; 
}   
/// 康复初始AppDelegate支撑的设备方向 
- (void)restoreSupportedOrientationMaskIfNeeded { 
    self.currentSupportOrientationMask = self.originalSupportOrientationMask; 
}

2、触发方向设备改变告诉

关于这个问题,咱们能够经过KVC修正UIDevice的orientation特点来完成:

[[UIDevice currentDevice] setValue:[NSNumber numberWithInt:UIInterfaceOrientationPortrait] forKey:@"orientation"];

考虑到调用的是私有API,上架时可能会被静态扫描代码,改成以下办法:

if ([[UIDevice currentDevice] respondsToSelector:@selector(setOrientation:)]) {  
   // 规避苹果对私有API的静态扫描  
   NSString *selectorStr = [NSString stringWithFormat:@"%@%@%@",@"set",@"Orient",@"ation:"];  
   SEL selector = NSSelectorFromString(selectorStr);  
   NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:[UIDevice instanceMethodSignatureForSelector:selector]];  
   [invocation setSelector:selector];  
   [invocation setTarget:[UIDevice currentDevice]];  
   int val = some_orientation;  
   [invocation setArgument:&val atIndex:2];  
   [invocation invoke]; 
}

从iOS16开始,直接KVC修正UIDevice.orientation的办法现已被弃用,调用时会打印以下过错信息:

[Orientation] BUG IN CLIENT OF UIKIT: Setting UIDevice.orientation is not supported. Please use UIWindowScene.requestGeometryUpdate(_:)

依据过错信息,对iOS16做适配:

if (@available(iOS 16.0, *)) {  
   [self.navigationController setNeedsUpdateOfSupportedInterfaceOrientations];  
   [UIViewController attemptRotationToDeviceOrientation];   
   NSArray *array = [[[UIApplication sharedApplication] connectedScenes] allObjects];  
   UIWindowScene *windowScene = (UIWindowScene *)array[0];  
   UIWindowSceneGeometryPreferencesIOS *preferences = [[UIWindowSceneGeometryPreferencesIOS alloc] init];  
   if (orientation == UIInterfaceOrientationLandscapeRight || orientation == UIInterfaceOrientationLandscapeLeft) {   
       preferences.interfaceOrientations = UIInterfaceOrientationMaskLandscape;  
   } else {   
       preferences.interfaceOrientations = UIInterfaceOrientationMaskPortrait;  
   }  
   [(UIWindowScene *)windowScene requestGeometryUpdateWithPreferences:preferences errorHandler:(NSError * _Nonnull error) {   
       if (error) {    
       NSLog(@"requestGeometryUpdateWithPreferences failed: %@",error.userInfo[@"NSLocalizedDescription"]);   
   }  
   }]; 
} else {
   // KVC 修正UIDevice.orientation 
}

其间有两行代码比较重要:

// iOS16有时分会呈现application:supportedInterfaceOrientationsForWindow: 署理办法不调用的情况,需求增加 
[self.navigationController setNeedsUpdateOfSupportedInterfaceOrientations];   
// iOS16某些情况下,requestGeometryUpdateWithPreferences会回调以下过错信息: 
// Error Domain=UISceneErrorDomain Code=101 "None of the requested orientations are supported by the view controller. Requested: landscapeRight; Supported: portrait" 
// 需求增加以下代码 
[UIViewController attemptRotationToDeviceOrientation];

这样,在暂时修正了App支撑方向的前提下,经过上面的代码就能完成界面切换反正屏的操作。

四、计划完善

到了这儿是不是就OK了?非也,上述计划在AppDemo中运行效果达到预期,但是SDK使用方大多数是Unity游戏,因而得导入Unity游戏中做测验。

1、Unity工程适配

将SDK导入一个竖屏Unity游戏工程,运行后翻开需求横屏的界面,成果产生溃散并打印以下过错:

BUG IN CLIENT OF UIKIT: An exception was thrown while evaluating supported interface orientations. UIViewController.supportedInterfaceOrientations should always return a UIInterfaceOrientationMask. Suppressed exception: "Supported orientations has no common orientation with the application, and [UnityDefaultViewController shouldAutorotate] is returning YES"

意思是说当时App支撑的方向中没有咱们需求的方向。难道是修正暂时支撑方向的逻辑没生效?目前的计划,是需求横屏时将App支撑方向改成横屏。先测验改成支撑一切方向,运行后发现不溃散了,但仍是不生效,requestGeometryUpdateWithPreferences回调中报这个过错:

"None of the requested orientations are supported by the view controller. Requested: landscapeRight; Supported: portrait"

结合前面的报错,在一番探究之后,发现原来Unity中并不使用AppDelegate操控方向的逻辑,而是在UnityViewControllerBase+iOS分类中创建了一系列操控器基类,如第一个报错信息中呈现的UnityDefaultViewController,以及UnityPortraitOnlyViewController 等,这些基类中重写了支撑方向的办法完成:

@implementation UnityDefaultViewController
- (NSUInteger)supportedInterfaceOrientations { 
    NSAssert(UnityShouldAutorotate(), @"UnityDefaultViewController should be used only if unity is set to autorotate"); 
    return EnabledAutorotationInterfaceOrientations(); 
} 
@end

其间:

NSUInteger EnabledAutorotationInterfaceOrientations() { 
    NSUInteger ret = 0; 
    if (UnityIsOrientationEnabled(portrait)) 
        ret |= (1 << UIInterfaceOrientationPortrait); 
    if (UnityIsOrientationEnabled(portraitUpsideDown)) 
        ret |= (1 << UIInterfaceOrientationPortraitUpsideDown); 
    if (UnityIsOrientationEnabled(landscapeLeft)) 
        ret |= (1 << UIInterfaceOrientationLandscapeRight); 
    if (UnityIsOrientationEnabled(landscapeRight)) 
        ret |= (1 << UIInterfaceOrientationLandscapeLeft); 
    return ret; 
}

想进一步检查UnityIsOrientationEnabled()的完成,发现进不去了。不过看到这儿,其完成已有思路了。依照hook AppDelegate办法完成的思路,把这些基类中的 supportedInterfaceOrientations 办法都交流一下。

/// hook UnityViewControllerBase+iOS中的办法 
- (void)hookSupportedInterfaceOrientationsInUnityDefaultViewController { 
    SEL originalSelector = @selector(supportedInterfaceOrientations); 
    SEL swizzledSelector = @selector(rs_new_supportedInterfaceOrientations);
    SEL noopSelector = @selector(rs_noop_supportedInterfaceOrientations);
    // 对UnityViewControllerBase+iOS下的一切基类操控器做swizzle 
    NSArray *classArr = @[@"UnityDefaultViewController",
                        @"UnityPortraitOnlyViewController",
                        @"UnityPortraitUpsideDownOnlyViewController",
                        @"UnityLandscapeLeftOnlyViewController",
                        @"UnityLandscapeRightOnlyViewController" 
    ]; 
    for (NSString *className in classArr) { 
        // hook每一个supportedInterfaceOrientations办法 
        [self swizzlingClass:className originalSelector:originalSelector swizzledSelector:swizzledSelector noopSelector:noopSelector]; 
    } 
}  
/// 在需求转屏时,回来currentSupportOrientationMask,不需求转屏时,调用这些基类的原始完成。 
- (NSUInteger)rs_new_supportedInterfaceOrientations { 
    if ([RSAppDelegateProxy sharedInstance].isSupportedOrientationMaskChanged){    
        // 回来暂时修正的支撑方向 
        return [RSAppDelegateProxy sharedInstance].currentSupportOrientationMask; 
    } else {    
        // 回来各基类的原始完成 
        return [self rs_new_supportedInterfaceOrientations]; 
    } 
} 
- (NSUInteger)rs_noop_supportedInterfaceOrientations { 
    return [RSAppDelegateProxy sharedInstance].originalSupportOrientationMask; 
}

文中代码仅写了首要逻辑,完整计划可参考:Demo地址