美文网首页
IOS基础:版本适配

IOS基础:版本适配

作者: 时光啊混蛋_97boy | 来源:发表于2020-10-19 17:37 被阅读0次

    原创:知识点总结性文章
    创作不易,请珍惜,之后会持续更新,不断完善
    个人比较喜欢做笔记和写总结,毕竟好记性不如烂笔头哈哈,这些文章记录了我的IOS成长历程,希望能与大家一起进步
    温馨提示:由于简书不支持目录跳转,大家可通过command + F 输入目录标题后迅速寻找到你所需要的内容

    目录

    • 一、iPhoneX屏幕适配
      • 1、适配原则
      • 2、举例分析
      • 3、顶部的适配
      • 4、列表底部加载控件的的处理
    • 二、新技术
      • 1、SceneDelegate
      • 2、Dark Mode
      • 3、Sign In with Apple
    • 三、API 适配
    • 四、方法弃用
    • 五、工程适配
    • Demo
    • 参考文献

    一、iPhoneX屏幕适配

    1、适配原则

    过去,我们拿到的手机是方方正正的矩形,所以整个屏幕都可以看做是安全区域Safe Area,而如今由于iPhone X屏幕上的“刘海”以及屏幕四周采用圆角的设计,需要设计师对绘图区域做出调整。苹果给出的安全区域如下,页面内容不能超出安全区域(Safe Area):

    安全区域

    下面我们以通讯录和News应用为例看下iPhoneX模拟器中原生应用对于这块全面屏如何适配的:

    通讯录

    通过例子我们可以发现主要的三点原则:

    1. 带有空间按钮的顶部导航栏(NavigationBar)要处在“刘海”下面。
    2. 底部导航栏(Tabbar)不能在虚拟横条下面,也就是说要和屏幕底部保持距离。
    3. 可滚动的列表整块屏幕都是可展示的,但是滚动条要和顶部和底部保持距离不能超出。

    基本以上三点原则可以概括为一句话,所有不可滚动的控件推荐在安全区域内展示,可滚动的控件整个屏幕都可以用来展示。

    2、举例分析

    拿下面这个APP作为例子进行适配。

    未经过适配的APP的在iPhoneX上表现

    可见问题如下:

    1. 顶部导航栏和底部导航栏超出安全区域
    2. 没有导航栏的列表没有全屏展现
    3. 吸底按钮超出安全区域
    4. 顶部导航栏UI错乱
    5. 列表加载控件在安全区域外部展示

    这对于产品在iPhoneX中的体验来说将会是极大的灾难,没有充分利用iPhoneX的全面屏。经过适配后,解决了16:9展示,导航栏错位等问题,现在的问题主要集中在列表底部加载控件的问题等问题上。

    经过适配后,解决了16:9展示,导航栏错位等问题

    3、顶部的适配

    如果是采用系统默认的底部导航栏,没有采用自定义的方式,底部导航栏iOS系统级就做了处理,会保证在Tabbar是在安全区域之内。所以只需要关心顶部的适配问题,以前通过加减20来覆盖或者避免状态栏的代码都会在iPhoneX上出问题:

    加减20来覆盖或者避免状态栏的代码都会在iPhoneX上出问题

    状态栏高度不是20了,iOS11安全区的提出,在iPhoneX上状态栏的高变为44,代码中需要通过[UIApplication sharedApplication].statusBarFrame.size.height获取状态栏高度。

    [self.tableView mas_remakeConstraints:^(MASConstraintMaker *make) {
        make.top.equalTo(self.view.mas_top).offset([UIApplication sharedApplication].statusBarFrame.size.height);
        make.bottom.equalTo(self.view.mas_bottom);
        make.left.equalTo(self.view.mas_left);
        make.right.equalTo(self.view.mas_right);
    }];
    
    适配后

    因为iOS11的automaticallyAdjustsScrollViewInsets属性废弃了,所以会出现ScorllView下沉20的现象:

    ScorllView下沉20

    可以调用scrollview新的api——contentInsetAdjustmentBehavior

    self.automaticallyAdjustsScrollViewInsets = NO;
    if (@available(iOS 11.0, *)) 
    {
        self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
    }
    
    scrollview新的api

    由于在X下安全区域的出现,顶部异形区域不建议覆盖,会造成视觉的差异。

    顶部异形区域不建议覆盖

    根据设备高度来判断iPhoneX,从而来避免这种情况:

    [self.tableView mas_remakeConstraints:^(MASConstraintMaker *make) {
        if (CGRectGetHeight([UIScreen mainScreen].bounds) == 812.0) 
        {
            if (@available(iOS 11.0, *))
           {
                make.top.equalTo(self.view.mas_safeAreaLayoutGuideTop);
            }
        } 
        else 
        {
            make.top.equalTo(self.view.mas_top);
        }
        make.bottom.equalTo(self.view.mas_bottom);
        make.left.equalTo(self.view.mas_left);
        make.right.equalTo(self.view.mas_right);
    }];
    
    根据设备高度来判断iPhoneX

    如果用了MJRefreshiPhoneX下列表顶部会出现这样的情况,顶部刷新控件会有露出,UI不美观。

    MJRefresh

    如果设置contentInsetAdjustmentBehaviorUIScrollViewContentInsetAdjustmentNever,并且设置顶部距离为导航栏距离,又会造成全面屏展示不充分。

    - (void)viewDidLoad
    {
        [super viewDidLoad];
        <!--省略部分代码-->
        if (@available(iOS 11.0, *))
        {
            self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
        }
        <!--省略部分代码-->
    }
    
    - (void)viewWillLayoutSubviews
    {
        [super viewWillLayoutSubviews];
        [self.tableView mas_remakeConstraints:^(MASConstraintMaker *make) {
            make.top.equalTo(self.view.mas_top).offset(self.view.layoutMargins.top);
            <!--省略部分代码-->
        }];
    }
    
    v2-全面屏展示不充分

    建议的适配方式,根据具体情况来设置contentInset的值:

    - (void)viewDidLoad
    {
        [super viewDidLoad];
        <!--省略部分代码-->
        if (@available(iOS 11.0, *))
        {
            self.tableView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
        }
        <!--省略部分代码-->
    }
    
    - (void)viewWillLayoutSubviews
    {
        [super viewWillLayoutSubviews];
        if (CGRectGetHeight([UIScreen mainScreen].bounds) == 812.0)
        {
            // 只在iPhoneX下适配
            if (@available(iOS 11.0, *))
            {
                self.tableView.contentInset = UIEdgeInsetsMake(self.view.safeAreaInsets.top, 0, 0, 0);
            }
        }
        [self.tableView mas_remakeConstraints:^(MASConstraintMaker *make) {
            make.top.equalTo(self.view.mas_top).offset(0);
            <!--省略部分代码-->
        }];
    }
    

    使用以上代码,或者UI设计顶部刷新控件样式都可以解决该问题。

    适配顶部刷新控件

    4、列表底部加载控件的的处理

    如果contentInsetAdjustmentBehavior设置为UIScrollViewContentInsetAdjustmentNever,那么出现的问题是,底部加载控件会在安全区域意外露出。

    安全区域意外露出

    那么怎么处理这种情况才会更好些呢,本文给的解决方案是给底部加载控件加一个遮罩,而这个遮罩是根据tableView的偏移量来展示的,最后的效果如下:

    - (void)setLoadFooter
    {
        self.tableView.mj_footer = [MJRefreshBackStateFooter footerWithRefreshingTarget:self refreshingAction:@selector(loadCommentData)];
        self.tableView.mj_footer.backgroundColor = [UIColor orangeColor];
        self.tableView.mj_footer.maskView = [[UIView alloc] init];
        self.tableView.mj_footer.maskView.backgroundColor = [UIColor whiteColor];
        [self.tableView addObserver:self forKeyPath:@"contentOffset" options:NSKeyValueObservingOptionNew context:nil];
    }
    
    - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context
    {
        if (object == self.tableView && [keyPath isEqualToString:@"contentOffset"])
        {
            if (@available(iOS 11.0, *))
            {
                // 判断设备为iPhoneX时,并且contentInsetAdjustmentBehavior不为UIApplicationBackgroundFetchIntervalNever
                if (CGRectGetHeight([UIScreen mainScreen].bounds) == 812.0 && self.tableView.contentInsetAdjustmentBehavior == UIScrollViewContentInsetAdjustmentAutomatic)
                {
                    CGFloat distanceToSafeBottom = (self.tableView.contentOffset.y + CGRectGetHeight(self.tableView.frame) - self.view.safeAreaInsets.bottom) - self.tableView.contentSize.height;
                    if (distanceToSafeBottom < 0)
                    {
                        self.tableView.mj_footer.maskView.frame = CGRectZero;
                    }
                    else
                    {
                        CGFloat showFooterHeight = distanceToSafeBottom;
                        if (showFooterHeight > CGRectGetHeight(self.tableView.mj_footer.bounds))
                        {
                            showFooterHeight = CGRectGetHeight(self.tableView.mj_footer.bounds);
                        }
                        if (self.tableView.mj_footer.state != MJRefreshStateRefreshing)
                        {
                            self.tableView.mj_footer.maskView.frame = CGRectMake(0, 0, CGRectGetWidth(self.tableView.mj_footer.bounds), showFooterHeight);
                        }
                    }
                }
            }
        }
    }
    
    底部加载控件加一个遮罩

    二、新技术

    1、SceneDelegate

    a、适配方案

    xcode11创建项目新增SceneDelegate文件,AppDelegate文件结构也发生变化,在AppDelegate.h文件中没有了window属性,而是在sceneDelegate.h中,可见AppDelegate不管理window而是交给SceneDelegate。由于这些是ios13新增,所以SceneDelegate在ios13以下的系统是不支持。所以xcode11创建的项目如要做一下处理:

    • 如果App不需要支持多个scene,同时兼容ios13以下,可以删除info.plist文件中的Application Scene Manifest的配置数据。在AppDelegate.h中添加window属性,同时删除UISceneSession的生命周期方法,和以前的使用方式一样。
    • App不需要支持scene,如果不删除Application Scene Manifest这个配置,则需要针对ios13在Scene中配置和ios13以下在AppDelegate中做两套配置。
    • 如果在ios13中支持多scene,原先AppDelegate的生命周期方法不再起作用,需要在SceneDelegate中使用UIScene提供的生命周期方法。
    b、支持多scene

    ❶ 开启支持多scene

    开启支持多scene

    ❷ ios13中对info.plist文件进行了修改,多了一个参数用于配置分屏Application Scene Manifest,由于刚才勾选分屏,所以enable Multipe Windows被自动设置为YES

    info.plist

    AppDelegate.m中多了UISceneSession的生命周期的代理方法。默认在info.plist中进行了配置, 不用实现该方法也没有关系。如果没有配置就需要实现这个方法并返回一个UISceneConfiguration对象。

    //.h文件
    @interface AppDelegate : UIResponder <UIApplicationDelegate>
    
    @end
    
    //.m文件
    #import "AppDelegate.h"
    
    @interface AppDelegate ()
    
    @end
    
    @implementation AppDelegate
    
    
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
        // Override point for customization after application launch.
        return YES;
    }
    
    
    #pragma mark - UISceneSession lifecycle
    
    // 如果没有在APP的Info.plist文件中包含scene的配置数据,或者要动态更改场景配置数据,需要实现此方法
    // UIKit会在创建新scene前调用此方法
    // 参数options是一个UISceneConnectionOptions类,它包含了为什么要创建一个新的scene的信息,根据参数信息判断是否要创建一个新的scene
    // 方法会返回一个UISceneConfiguration对象,其包含其中包含场景详细信息,包括要创建的场景类型,用于管理场景的委托对象以及包含要显示的初始视图控制器的情节提要
    // 如果未实现此方法,则必须在应用程序的Info.plist文件中提供场景配置数据
    - (UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession options:(UISceneConnectionOptions *)options {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create the new scene with.
        /** 配置参数中Application Session Role 是个数组,每一项有三个参数。
          * Configuration Name:当前配置的名字
          * Delegate Class Name:与哪个Scene代理对象关联
          * StoryBoard name:这个Scene使用的哪个storyboard
          * 代理方法中调用的是配置名为Default Configuration的Scene,则系统就会自动去调用SceneDelegate这个类,这样SceneDelegate和AppDelegate产生了关联
          */
        return [[UISceneConfiguration alloc] initWithName:@"Default Configuration" sessionRole:connectingSceneSession.role];
    }
    
    // 在分屏中关闭其中一个或多个scene时候回调用。
    - (void)application:(UIApplication *)application didDiscardSceneSessions:(NSSet<UISceneSession *> *)sceneSessions {
        // Called when the user discards a scene session.
        // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
        // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
    }
    
    @end
    

    ❹ ios13在SceneDelegate中不使用storyboard创建。

    - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions {
        
        //在这里手动创建新的window
        if (scene)
        {
            UIWindowScene *windowScene = (UIWindowScene *)scene;
            self.window = [[UIWindow alloc] initWithWindowScene:windowScene];
            self.window.frame = CGRectMake(0, 0, [UIScreen mainScreen].bounds.size.width, [UIScreen mainScreen].bounds.size.height);
            self.window.rootViewController = [ViewController new];
            [self.window makeKeyAndVisible];
        }
    }
    
    c、不支持多scene

    删除掉SceneDelegate.hSceneDelegate.m文件,同时将Info.plist文件中的Application Scene Manifest全部删除掉,最后AppDelegate修改如下:

    // .h文件
    @interface AppDelegate : UIResponder <UIApplicationDelegate>
    
    @property (strong, nonatomic) UIWindow * window;
    
    @end
    
    // .m文件
    @implementation AppDelegate
    
    - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
    {
        UIImagePickerControllerDemo *rootVC = [[UIImagePickerControllerDemo alloc] init];
        UINavigationController *mainNC = [[UINavigationController alloc] initWithRootViewController:rootVC];
        
        self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
        self.window.backgroundColor = [UIColor whiteColor];
        self.window.rootViewController = mainNC;
        [self.window makeKeyAndVisible];
        
        return YES;
    }
    
    @end
    

    2、Dark Mode

    iOS 13 推出暗黑模式,UIKit提供新的系统颜色和 api 来适配不同颜色模式,xcassets 对素材适配也做了调整。Dark Mode 不是必须适配,但前提是你需要确保应用在切换主题后不会影响到用户使用(比如说文字和背景颜色相同可能会影响使用)。如果不打算适配 Dark Mode,可以直接在Info.plist中添加一栏:User Interface Style : Light,即可在应用内禁用暗黑模式。

    另外,即使设置了颜色方案,申请权限的系统弹窗还是会依据系统的颜色进行显示,自己创建的UIAlertController就不会。

    3、Sign In with Apple

    在 iOS 13 中苹果推出一种在 App 和网站上快速、便捷登录的方式,如果你的应用使用了第三方或社交账号登录服务(如FacebookGoogleTwitter、微信等)来设置或验证用户的主账号,就必须把 Sign In With Apple 作为同等的选项添加到应用上。如果是下面这些类型的应用则不需要添加:

    • 仅仅使用公司内部账号来注册和登录的应用
    • 要求用户使用现有的教育或企业账号进行登录的教育、企业或商务类型的应用
    • 使用政府或业界支持的公民身份识别系统或电子标识对用户进行身份验证的应用
    • 特定第三方服务的应用,用户需要直接登录其邮箱、社交媒体或其他第三方帐户才能访问其内容

    三、API 适配

    a、私有方法 KVC 可能导致崩溃

    在 iOS 13 中部分方法属性不允许使用 valueForKeysetValue:forKey:来获取或者设置私有属性,具体表现为在运行时会直接崩溃,并提示以下崩溃信息:

    *** Terminating app due to uncaught exception 'NSGenericException', reason: 'Access to UISearchBar's _searchField ivar is prohibited. This is an application bug'
    

    目前整理的会导致崩溃的私有 api 和对应替代方案如下:

    // 崩溃 api
    UITextField *textField = [searchBar valueForKey:@"_searchField"];
    
    // 替代方案 1,使用 iOS 13 的新属性 searchTextField
    searchBar.searchTextField.placeholder = @"search";
    
    // 替代方案 2,遍历获取指定类型的属性
    - (UIView *)findViewWithClassName:(NSString *)className inView:(UIView *)view
    {
        Class specificView = NSClassFromString(className);
        if ([view isKindOfClass:specificView])
        {
            return view;
        }
    
        if (view.subviews.count > 0)
        {
            for (UIView *subView in view.subviews)
            {
                UIView *targetView = [self findViewWithClassName:className inView:subView];
                if (targetView != nil)
                {
                    return targetView;
                }
            }
        }
        
        return nil;
    }
    
    // 调用方法
    UITextField *textField = [self findViewWithClassName:@"UITextField" inView:_searchBar];
    
    // 崩溃 api
    [searchBar setValue:@"取消" forKey:@"_cancelButtonText"];
    
    // 替代方案,用同上的方法找到子类中 UIButton 类型的属性,然后设置其标题
    UIButton *cancelButton = [self findViewWithClassName:NSStringFromClass([UIButton class]) inView:searchBar];
    [cancelButton setTitle:@"取消" forState:UIControlStateNormal];
    
    // 崩溃 api。获取 _placeholderLabel 不会崩溃,但是获取 _placeholderLabel 里的属性就会
    [textField setValue:[UIColor blueColor] forKeyPath:@"_placeholderLabel.textColor"];
    [textField setValue:[UIFont systemFontOfSize:20] forKeyPath:@"_placeholderLabel.font"];
    
    // 替代方案 1,去掉下划线,访问 placeholderLabel
    [textField setValue:[UIColor blueColor] forKeyPath:@"placeholderLabel.textColor"];
    [textField setValue:[UIFont systemFontOfSize:20] forKeyPath:@"placeholderLabel.font"];
    
    // 替代方案 2
    textField.attributedPlaceholder = [[NSAttributedString alloc] initWithString:@"输入" attributes:@{
        NSForegroundColorAttributeName: [UIColor blueColor],
        NSFontAttributeName: [UIFont systemFontOfSize:20]
    }];
    
    b、推送的 deviceToken 获取到的格式发生变化

    原本可以直接将NSData类型的deviceToken转换成NSString字符串,然后替换掉多余的符号即可:

    - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
     {
        NSString *token = [deviceToken description];
        for (NSString *symbol in @[@" ", @"<", @">", @"-"])
        {
            token = [token stringByReplacingOccurrencesOfString:symbol withString:@""];
        }
        NSLog(@"deviceToken:%@", token);
    }
    

    在 iOS 13 中,这种方法已经失效,NSData类型的 deviceToken 转换成的字符串变成了:

    {length = 32, bytes = 0xd7f9fe34 69be14d1 fa51be22 329ac80d ... 5ad13017 b8ad0736 } 
    

    所以需要进行一次数据格式处理,友盟提供了一种做法,可以适配新旧系统:

    #include <arpa/inet.h>
    - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
    {
        if (![deviceToken isKindOfClass:[NSData class]]) return;
        const unsigned *tokenBytes = [deviceToken bytes];
        // 数据格式处理
        NSString *hexToken = [NSString stringWithFormat:@"%08x%08x%08x%08x%08x%08x%08x%08x",
                              ntohl(tokenBytes[0]), ntohl(tokenBytes[1]), ntohl(tokenBytes[2]),
                              ntohl(tokenBytes[3]), ntohl(tokenBytes[4]), ntohl(tokenBytes[5]),
                              ntohl(tokenBytes[6]), ntohl(tokenBytes[7])];
        NSLog(@"deviceToken:%@", hexToken);
    }
    

    但是注意到这种方法限定了长度,因此可以对数据格式处理部分进行优化:

    - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
    {
        if (![deviceToken isKindOfClass:[NSData class]])
        {
            return;
        }
        const unsigned char *tokenBytes = deviceToken.bytes;
        NSInteger count = deviceToken.length;
        
        // 数据格式处理
        NSMutableString *hexToken = [NSMutableString string];
        for (int i = 0; i < count; ++i)
        {
            [hexToken appendFormat:@"%02x", tokenBytes[I]];
        }
        NSLog(@"deviceToken:%@", hexToken);
    }
    
    c、模态视图的默认样式发生改变

    在 iOS 13,使用 presentViewController 方式打开模态视图,默认的如下图所示的视差效果,通过下滑返回。

    模态视图的默认样式发生改变

    这是因为苹果将 UIViewControllermodalPresentationStyle 属性的默认值改成了新加的一个枚举值UIModalPresentationAutomatic,对于多数 UIViewController,此值会映射成UIModalPresentationPageSheet

    需要注意,这种效果弹出来的页面导航栏部分是会被砍掉的,在 storyboard 中也可以看到,页面布局时需要注意导航栏的内容不要被遮挡。

    页面导航栏部分

    还有一点注意的是,我们原来以全屏的样式弹出一个页面,那么将这个页面弹出的那个ViewController会依次调用 viewWillDisappearviewDidDisappear。然后在这个页面被dismiss的时候,将他弹出的那个ViewControllerviewWillAppearviewDidAppear会被依次调用。然而使用默认的视差效果弹出页面,将他弹出的那个 ViewController 并不会调用这些方法,原先写在这四个函数中的代码以后都有可能会存在问题。

    针对这个问题,如果视差效果的样式可以接受的话,就不需要修改。如果需要改回全屏显示的界面,需要手动设置弹出样式:

    - (UIModalPresentationStyle)modalPresentationStyle 
    {
        return UIModalPresentationFullScreen;
    } 
    
    d、UISearchBar 黑线处理导致崩溃

    之前为了处理搜索框的黑线问题,通常会遍历 searchBarsubViews,找到并删除UISearchBarBackground

    for (UIView *view in _searchBar.subviews.lastObject.subviews)
    {
        if ([view isKindOfClass:NSClassFromString(@"UISearchBarBackground")])
        {
            [view removeFromSuperview];
            break;
        }
    }
    

    在 iOS13 中这么做会导致 UI 渲染失败,然后直接崩溃,崩溃信息如下:

    *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Missing or detached view for search bar layout'
    

    所以可以设置 UISearchBar 的背景图片为空:

    [_searchBar setBackgroundImage:[UIImage new]];
    
    e、UITabBarButton 不同状态下结构不同

    在 iOS 13 中,UITabBarButton 的控件结构会随着其选中状态的变化而变化,主要体现为 UITabBarSwappableImageViewUITabBarButtonLabel 的位置变化。在选中时和以前一样,是 UITabBarButton的子控件,而在未选中状态下放到了 UIVisualEffectView_UIVisualEffectContentView 里面。

    UITabBarButton 不同状态下结构不同

    我们在自定义 UITabBar 时,通常会遍历 UITabBarButton 的子控件获取 UITabBarSwappableImageView,比如自定义红点时添加到这个 ImageView 的右上角,这在 iOS 13 中可能就会导致异常。

    可以通过使用递归遍历 UITabBarButton 的所有 subviews获取 UITabBarSwappableImageView。另外需要注意,未选中状态下,添加的红点会和 tabBar 的图片一样变成灰色,这一点应该也是因为其结构变化造成的。

    灰色红点

    如果想要和以前一样未选中时也是红色,也很简单,把红点添加到 UITabBarButton 上,位置再根据 UITabBarSwappableImageView 调整即可。

    f、UINavigationBar 设置按钮边距导致崩溃

    从 iOS 11 开始,UINavigationBar 使用了自动布局,左右两边的按钮到屏幕之间会有 16 或 20 的边距。

    按钮边距

    为了避免点击到间距的空白处没有响应,通常做法是:定义一个 UINavigationBar 子类,重写 layoutSubviews 方法,在此方法里遍历 subviews 获取 _UINavigationBarContentView,并将其 layoutMargins 设置为 UIEdgeInsetsZero

    - (void)layoutSubviews
    {
        [super layoutSubviews];
        
        for (UIView *subview in self.subviews)
        {
            if ([NSStringFromClass([subview class]) containsString:@"_UINavigationBarContentView"])
            {
                subview.layoutMargins = UIEdgeInsetsZero;
                break;
            }
        }
    }
    

    然而,这种做法在 iOS 13 中会导致崩溃,崩溃信息如下:

    *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Client error attempting to change layout margins of a private view'
    

    所以可以使用设置 frame 的方式,让 _UINavigationBarContentView 向两边伸展,从而抵消两边的边距。

    - (void)layoutSubviews
    {
        [super layoutSubviews];
        
        for (UIView *subview in self.subviews)
        {
            if ([NSStringFromClass([subview class]) containsString:@"_UINavigationBarContentView"])
            {
                if ([UIDevice currentDevice].systemVersion.floatValue >= 13.0)
                {
                    UIEdgeInsets margins = subview.layoutMargins;
                    subview.frame = CGRectMake(-margins.left, -margins.top, margins.left + margins.right + subview.frame.size.width, margins.top + margins.bottom + subview.frame.size.height);
                }
                else
                {
                    subview.layoutMargins = UIEdgeInsetsZero;
                }
                break;
            }
        }
    }
    
    g、子线程修改界面导致崩溃(相册首次授权回调必现)

    在使用相册时我们会调用[PHPhotoLibrary requestAuthorization:]方法获取权限,获取的结果会通过一个带有 PHAuthorizationStatus 信息的 block 进行回调。

    [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
        // 根据 status 判断不同状态
    }];
    

    在 iOS 13 中,如果在第一次获取权限的回调中直接修改界面,会导致崩溃,崩溃信息如下:

    This application is modifying the autolayout engine from a background thread after the engine was accessed from the main thread. This can lead to engine corruption and weird crashes.
    
    *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Modifications to the layout engine must not be performed from a background thread after it has been accessed from the main thread.'
    

    正如崩溃信息所言,不只是相册授权回调线程,其他子线程修改界面都有一定概率导致崩溃。解决方案很简单,在 Xcode 中调试运行时,子线程修改界面会有紫色感叹号标出,注意修改成回到主线程即可。

    h、默认弹出样式打开的页面在 WKWebView 中获取照片崩溃

    如果以默认的 UIModalPresentationPageSheet 样式弹出一个 ViewController,并使用 WKWebView 通过 HTML 获取系统照片:

    [_webView loadHTMLString:@"<input accept='image/*' type='file'>" baseURL:nil];
    

    在点击选择按钮时,会出现崩溃,崩溃信息如下:

    *** Terminating app due to uncaught exception 'NSGenericException', reason: 'Your application has presented a UIDocumentMenuViewController (<UIDocumentMenuViewController: 0x101226860>). In its current trait environment, the modalPresentationStyle of a UIDocumentMenuViewController with this style is UIModalPresentationPopover. You must provide location information for this popover through the view controller's popoverPresentationController. You must provide either a sourceView and sourceRect or a barButtonItem.  If this information is not known when you present the view controller, you may provide it in the UIPopoverPresentationControllerDelegate method -prepareForPopoverPresentation.'
    

    具体原因是,点击获取系统照片时,会弹出一个模态视图的样式为 UIModalPresentationPopoverUIDocumentMenuViewController,这种样式下,如果其父 UIViewController 以非全屏方式 present 的,那么就需要像 iPad 一样指定其 sourceViewsourceRect,或者指定一个 barButtonItem,否则会出现上述崩溃。而使用 UIModalPresentationFullScreen 的方式弹出的话就不会有这个问题。

    第一种解决方案是指定sourceViewsourceRectbarButtonItem 同理:

    - (void)presentViewController:(UIViewController *)viewControllerToPresent animated:(BOOL)flag completion:(void (^)(void))completion
    {
        [self setUIDocumentMenuViewControllerSoureViewsIfNeeded:viewControllerToPresent];
        [super presentViewController:viewControllerToPresent animated:flag completion:completion];
    }
    
    - (void)setUIDocumentMenuViewControllerSoureViewsIfNeeded:(UIViewController *)viewControllerToPresent
    {
        if (@available(iOS 13, *))
        {
            if([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone && [viewControllerToPresent isKindOfClass:UIDocumentPickerViewController.class])
            {
                viewControllerToPresent.popoverPresentationController.sourceView = self.webView;
                viewControllerToPresent.popoverPresentationController.sourceRect = CGRectMake(15, 5, 1, 1); // 具体看按钮的位置
            }
        }
    }
    
    // 如果顶层有 UINavigationController 的话,需要如下指定
    - (void)presentViewController:(UIViewController *)viewControllerToPresent animated:(BOOL)flag completion:(void (^)(void))completion
    {
        if([self.viewControllers.lastObject isKindOfClass:WKWebViewController.class])
        {
            WKWebViewController *vc = self.viewControllers.lastObject;
            [vc setUIDocumentMenuViewControllerSoureViewsIfNeeded:viewControllerToPresent];
        }
        [super presentViewController:viewControllerToPresent animated:flag completion:completion];
    }
    

    第二种方法就是使用全屏的方式弹出(实践证明默认弹出样式在横屏下是全屏的不会崩):

    - (UIModalPresentationStyle)modalPresentationStyle 
    {
        return UIModalPresentationFullScreen;
    }
    

    四、方法弃用

    a、UIWebView 将被禁止提交审核

    2020 年 4 月开始不再接受包含 UIWebView的新应用提交,2020 年 12 月开始不再接受包含 UIWebView的应用更新提交。

    所以用 WKWebView 替代 UIWebView,确保所有 UIWebViewapi 都要移除。

    b、使用 UISearchDisplayController 导致崩溃

    在 iOS 13 中,如果还继续使用 UISearchDisplayController 会直接导致崩溃,崩溃信息如下:

    *** Terminating app due to uncaught exception 'NSGenericException', reason: 'UISearchDisplayController is no longer supported when linking against this version of iOS. Please migrate your application to UISearchController.' 
    

    使用 UISearchController 替换 UISearchBar + UISearchDisplayController 的组合方案。

    c、MPMoviePlayerController 被弃用

    在 iOS 9 之前播放视频可以使用 MediaPlayer.framework 中的MPMoviePlayerController类来完成,它支持本地视频和网络视频播放。但是在 iOS 9 开始被弃用,如果在 iOS 13 中继续使用的话会直接抛出异常:

    *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'MPMoviePlayerController is no longer available. Use AVPlayerViewController in AVKit.'
    

    改为使用 AVFoundation 里的 AVPlayer 作为视频播放控件。


    五、工程适配

    a、蓝牙权限字段更新导致崩溃以及提交审核失败

    在 iOS 13 中,苹果将原来蓝牙申请权限用的NSBluetoothPeripheralUsageDescription字段,替换为 NSBluetoothAlwaysUsageDescription字段。如果在 iOS 13 中使用旧的权限字段获取蓝牙权限,会导致崩溃,崩溃信息如下:

    This app has crashed because it attempted to access privacy-sensitive data without a usage description.  The app's Info.plist must contain an NSBluetoothAlwaysUsageDescription key with a string value explaining to the user how the app uses this data.
    

    另外,如果将没有新字段的包提交审核,将会收到包含 ITMS-90683 的邮件,并提示审核不通过。解决方案就是在 Info.plist 中把两个字段都加上。

    b、CNCopyCurrentNetworkInfo 使用要求更严格

    从 iOS 12 开始,CNCopyCurrentNetworkInfo 函数需要开启 Access WiFi Information的功能后才会返回正确的值。在 iOS 13 中,这个函数的使用要求变得更严格,根据CNCopyCurrentNetworkInfo文档说明,应用还需要符合下列三项条件中的至少一项才能得到正确的值:

    • 使用 Core Location 的应用, 并获得定位服务权限。
    • 使用 NEHotspotConfiguration 来配置 WiFi 网络的应用。
    • 目前正处于启用状态的 VPN 应用。

    苹果作出这项改变主要为了保障用户的安全,因为根据 MAC 地址容易推算出用户当前所处的地理位置。同样,蓝牙设备也具有MAC 地址,所以苹果也为蓝牙添加了新的权限。

    根据应用需求,添加三项要求其中一项。可以选择第一项获取定位权限,因为添加的成本不会太大,只需要用户允许应用使用定位服务即可。

    c、LaunchImage 被弃用

    iOS 8 之前我们是在LaunchImage 来设置启动图,每当苹果推出新的屏幕尺寸的设备,我们需要assets里面放入对应的尺寸的启动图,这是非常繁琐的一个步骤。因此在 iOS 8 苹果引入了 LaunchScreen,可以直接在 Storyboard 上设置启动界面样式,可以很方便适配各种屏幕。

    从2020年4月开始,所有支持 iOS 13 的 App 必须提供LaunchScreen.storyboard,否则将无法提交到 App Store进行审批。

    d、UISegmentedControl 默认样式改变

    默认样式变为白底黑字,如果设置修改过颜色的话,页面需要修改。原本设置选中颜色的 tintColor 已经失效,新增了 selectedSegmentTintColor 属性用以修改选中的颜色。

    e、Xcode 11 创建的工程在低版本设备上运行黑屏

    使用 Xcode 11 创建的工程,运行设备选择 iOS 13.0 以下的设备,运行应用时会出现黑屏。这是因为 Xcode 11 默认是会创建通过 UIScene 管理多个 UIWindow 的应用,工程中除了 AppDelegate 外会多一个 SceneDelegate,这是为了iPadOS的多进程准备的,也就是说 UIWindow 不再是 UIApplication 中管理,但是旧版本根本没有 UIScene。解决方案是在 AppDelegate 的头文件加上:

    @property (strong, nonatomic) UIWindow *window;
    

    Demo

    Demo在我的Github上,欢迎下载。
    VersionAdaptationDemo

    参考文献

    iOS学习——布局利器Masonry框架源码深度剖析
    AsyncDisplayKit
    iOS11及iPhoneX适配-思源探索方案
    xcode11 新增文件SceneDelegate
    iOS 13 适配要点总结

    相关文章

      网友评论

          本文标题:IOS基础:版本适配

          本文链接:https://www.haomeiwen.com/subject/ijclmktx.html