背景
近期遇到了一个需求场景:SDK中webView可以自由切换横屏或竖屏打开。本文围绕如何实现这个需求展开。
一、通用方案调研
这是一个很常见的业务场景,通常情况下,会通过以下两种方式来实现 :
实现方式一:
- 在【General】-->【Device Orientation】中勾选所有需要支持的方向
- 创建一个基类控制器,在基类控制器中重写两个控制横竖屏的方法:
objectivec
// 支持设备自动旋转
- (BOOL)shouldAutorotate {
return YES;
}
// 支持竖屏显示
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
return UIInterfaceOrientationMaskPortrait;
}
- 让所有控制器继承自上述基类,并在需要横屏的控制器中重写上面的两个方法:
objectivec
// 支持设备自动旋转
- (BOOL)shouldAutorotate {
return YES;
}
// 支持横屏显示
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
// 如果该界面需要支持横竖屏切换
return UIInterfaceOrientationMaskLandscape | UIInterfaceOrientationMaskPortrait;
// 如果该界面仅支持横屏
// returnUIInterfaceOrientationMaskLandscape;
}
// 如果需要横屏的时候,一定要重写这个方法并返回NO
- (BOOL)prefersStatusBarHidden {
return NO;
}
在当前的业务场景下,这种方式显然不太合理,原因如下:
-
要让所有类都继承自一个基类,改动太大
-
工程中得勾选所有需要支持的方向,考虑到是SDK的逻辑,这样强制要求宿主工程修改设置不太现实,绝大部分游戏都是固定方向的。
实现方式二:
- 【General】-->【Device Orientation】只勾选Portrait,即只支持竖屏
- 在需要横屏的控制器的viewDidLoad中添加通知
objectivec
[[UIDevice currentDevice] beginGeneratingDeviceOrientationNotifications];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(deviceOrientationDidChange) name:UIDeviceOrientationDidChangeNotification object:nil];
- 监听设备旋转通知,通过UIView的transform属性做界面旋转,并调整bounds
objectivec
- (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而已,还有自定义的导航栏,工具栏等UI元素,所以在旋转并调整self.view.bounds之后,这些子视图的位置大小都需要做调整。本来布局代码看起来就已经很复杂,加上这个调整之后又得大动干戈,显然也比较麻烦。
另外就是,UIDeviceOrientationDidChangeNotification这个通知是需要设备真的发生物理旋转行为,触发了陀螺仪,系统才会发出,要是用户就是不旋转设备呢?除非添加一些信息引导用户旋转,否则也很难达到效果。
二、实际问题拆解
实际上,SDK已经对webView所在的页面控制器做了横竖屏界面适配,只要宿主App支持横屏和竖屏方向,并且设备方向发生变化,就能触发页面的布局适配。因此,当前需求需要解决的其实是以下两个问题:
- 在宿主App未支持的情况下,临时支持我们需要的设备方向
- 在设备未发生物理旋转行为的情况下,想办法触发设备方向变化通知
三、问题解决
1、临时修改设备支持方向
开发App时,我们可以通过在AppDelegate中实现supportedInterfaceOrientations 方法,增加临时修改的支持方向的逻辑,因为这个方法实现的优先级会比工程设置的优先级高。不过,在SDK开发中就没这么简单了,因为无法直接访问到这个类。因此我们使用了iOS种常用的黑魔法:swizzle,用这种方式hook宿主工程AppDelegate的方法。
首先,在SDK初始化结束时(SDK初始化在didfinishLaunch中调用)发送通知来调起方法交换,确保在完成启动之后才对AppDelegate的方法进行hook:
objectivec
@implementation RSAppDelegateProxy
+ (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的方法,则我们的添加进去的方法会因方法缓存不存在而导致无法正常调用到。
objectivec
@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];
[self hookSupportedInterfaceOrientations];
// 重置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赋值给该属性。
objectivec
/// 新的实现,始终获取 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属性来实现:
objectivec
[[UIDevice currentDevice] setValue:[NSNumber numberWithInt:UIInterfaceOrientationPortrait] forKey:@"orientation"];
考虑到调用的是私有API,上架时可能会被静态扫描代码,改成以下方式:
ini
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的方式已经被弃用,调用时会打印以下错误信息:
swift
[Orientation] BUG IN CLIENT OF UIKIT: Setting UIDevice.orientation is not supported. Please use UIWindowScene.requestGeometryUpdate(_:)
根据错误信息,对iOS16做适配:
swift
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
}
其中有两行代码比较重要:
csharp
// 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游戏工程,运行后打开需要横屏的界面,结果发生崩溃并打印以下错误:
objectivec
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回调中报这个错误:
css
"None of the requested orientations are supported by the view controller. Requested: landscapeRight; Supported: portrait"
结合前面的报错,在一番探究之后,发现原来Unity中并不使用AppDelegate控制方向的逻辑,而是在UnityViewControllerBase+iOS分类中创建了一系列控制器基类,如第一个报错信息中出现的UnityDefaultViewController,以及UnityPortraitOnlyViewController 等,这些基类中重写了支持方向的方法实现:
scss
@implementation UnityDefaultViewController
- (NSUInteger)supportedInterfaceOrientations {
NSAssert(UnityShouldAutorotate(), @"UnityDefaultViewController should be used only if unity is set to autorotate");
return EnabledAutorotationInterfaceOrientations();
}
@end
其中:
scss
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 方法都交换一下。
scss
/// 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地址