iOS Launch Screen.storyBoard白屏/黑屏问题修复

需求

弃用LaunchImage启动图方式,改用Launch Screen.storyBoard启动图方式,同时不对开屏广告造成影响。

官方介绍

https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/launch-screen/

注:该方案仅适用iOS13.0及以上版本。

iOS 12及以下系统沙盒目录(Library/Caches/Snapshots)为不可读、不可写、可删除(但是开发者无权删除)权限,故本套方案不起作用。

若调试中出现问题,可卸载app,重启手机,重新装载app测试。

背景

现阶段网上流行的storyboard开屏:

1. 开屏图片从主目录读取,一张图适配所有界面。首先这种方式是不存在缓存的,实时取肯定是准确的图。但是作为业务方,这种满足不了自定义的四季样式,还会存在拉伸。
2. 图片存在XCAsset中,每次使用完,都会删除沙盒目录(Library/SplashBoard)文件,虽然有版本限制,但是大概率存在黑/白屏风险,也是不达标。

PS:该文方案都是以xcasset缓存开屏为基础的,首次storyboard替换LuanchImage,且未做沙盒缓存清空的,参考文章。如果之前对沙盒有操作的,可能会有异常。

Apple会将Launch Screen.storyBoard作为与图片类型类似的二进制文件,进行加载,执行是在main函数之前,所以不参与业务代码控制。适配就不做多余的阐述了。

一、问题

在iOS应用程序中修改了启动屏幕LaunchScreen.storyboad中的某些内容时,我都会遇到一个问题:系统会缓存启动图像,即使删除了该应用程序,它实际上也很难清除原来的缓存,猜测会有多级缓存。

二、分析

我们可以改动的缓存只有本地的沙盒目录(/Library/SplashBoard的Snapshots),打印的日志:

2020-05-19 09:25:38.138233+0800 luanchTest[3892:1751265] cache Path == /var/mobile/Containers/Data/Application/BCA1FD18-2A24-43A9-B844-65A5D38A5B9D/Library/SplashBoard,subpath == (
Snapshots,
"Snapshots/wei.jiang.luanchTest - {DEFAULT GROUP}",
"Snapshots/wei.jiang.luanchTest - {DEFAULT GROUP}/[email protected]",
"Snapshots/wei.jiang.luanchTest - {DEFAULT GROUP}/[email protected]",
"Snapshots/wei.jiang.luanchTest - {DEFAULT GROUP}/[email protected]",
"Snapshots/wei.jiang.luanchTest - {DEFAULT GROUP}/[email protected]",
"Snapshots/sceneID:wei.jiang.luanchTest-default",
"Snapshots/sceneID:wei.jiang.luanchTest-default/[email protected]",
"Snapshots/sceneID:wei.jiang.luanchTest-default/[email protected]",
"Snapshots/sceneID:wei.jiang.luanchTest-default/downscaled",
"Snapshots/sceneID:wei.jiang.luanchTest-default/downscaled/[email protected]",
"Snapshots/sceneID:wei.jiang.luanchTest-default/downscaled/[email protected]"
)

一目了然,Snapshots就是我们操作的文件夹,将ktx导出转换后缀,可以看到就是我们要的启动图,因为我这里是用的多个模拟器所以会有多张,正常的会只有一张。

注:如果项目工程是以xcode11方式新建的话,就需要处理UIScene的截图,我们的项目没有用到UIScene方式,所以没有做相应处理。

三、解决

思路:

推测系统在沙盒目录有图的时候,会从沙盒拿图。所以我们在保持原有目录的情况下,只做图片内容的替换(有坑,有同事之前一直用的主目录方式&&有过沙盒目录的删除操作,替换到这种方式每次首次读图都会空白屏)。

1.取图:

每次展示自定义启动页时,优先从Snapshots里拿image进行展示(无图是从storyBoard拿图),进行无缝衔接;

每次启动时,根据UI指定的布局,生成一张图片,作为展位图展示。(开屏启动时,有系统状态条变化,直接获取luanchScreen.storyboard的图片会有状态条高度缺失引起图片生成异常)

2.替换:

当次展示完启动图时,进行更新(将storyBoard的image同步到Snapshots)。避免重复无用操作做了版本控制。

当次展示完启动图时,进行更新(将我们生成的图片存入SnapShots的沙盒目录下)。避免重复无用操作做了版本控制。

不多说了,直接上代码吧。

//
//  MJLaunchScreenTool.h
//  MojiWeather
//
//  Created by wei.jiang on 2020/5/14.
//  Copyright © 2020 Moji Fengyun Technology Co., Ltd. All rights reserved.
//

#import 

NS_ASSUME_NONNULL_BEGIN
/*
* 配套LaunchScreen.storyBoard的启动加载方式
*/
@interfaceMJLaunchScreenTool : NSObject

/*
*  获取沙盒/SplashBoard/Snapshots目录下的启动图(不推荐,有statusBar改动会有异常)
*/
+ (UIView *)getCacheLaunchImageByLirbrary;

/*
*  获取LaunchScreen.storyBoard对应启动图(推荐,忽略statusbar影响)
*/
+ (UIView *)getLaunchImageIngoreStatusBar:(BOOL)isHighQuailty;

/*
* 更替修正storyboard的缓存启动图(storyboard作启动图的情况下,不可删除)
*/
+ (void)updateSplashBoardCache:(BOOL)fetImageFromStoryBoard;

@end
NS_ASSUME_NONNULL_END
//
//  MJLaunchScreenTool.m
//  MojiWeather
//
//  Created by wei.jiang on 2020/5/14.
//  Copyright © 2020 Moji Fengyun Technology Co., Ltd. All rights reserved.
//

#import "MJLaunchScreenTool.h"
#import "UIView+ScreenShot.h"
#import "MJLottieAnimationManager.h"

#define kSplashBoard_Version @"kSplashBoard_Version"
#define kMJSplashBoardCopyImageName @"mj_cover_install_first_image.png"
@implementation MJLaunchScreenTool

//从storyboard获取启动图(不建议使用,如果外部有改动statusbar获取的图片可能会出问题)
+ (UIView *)getLaunchImageByStoreBoard{
    UIStoryboard *launchScreenStoryBoard = [UIStoryboard storyboardWithName:@"LaunchScreen" bundle:[NSBundle mainBundle]];
    UIView *view = [launchScreenStoryBoard.instantiateInitialViewController view];
    return view;
}

+ (UIView *)getLaunchImageIngoreStatusBar:(BOOL)isHighQuailty{
    CGFloat scaleNumer = 1;
    if (isHighQuailty) {
        scaleNumer = 2;
    }
    CGFloat launchScreenWidth = SCREEN_WIDTH * scaleNumer;
    CGFloat launchScreenHeight = SCREEN_HEIGHT * scaleNumer;
    
    UIView *launchView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, launchScreenWidth, launchScreenHeight)];
    launchView.clipsToBounds = YES;
    launchView.backgroundColor = [UIColor whiteColor];
    //背景
    UIImageView *bgView = [[UIImageView alloc] initWithFrame:launchView.bounds];
    bgView.image = [UIImage imageNamed:@"splashBg_winter"];
    bgView.contentMode = UIViewContentModeScaleAspectFill;
    [launchView addSubview:bgView];
    //内容区域
    UIImage *contentImage = [UIImage imageNamed:@"splashContent_winter"];
    UIImageView *contentView = [[UIImageView alloc] initWithImage:contentImage];
    CGFloat scale = contentImage.size.width/contentImage.size.height;
    CGFloat contentHeight = ((scale != 0)?(launchView.width/scale):contentImage.size.height);
    CGFloat contentTopInSafeArea = 18;//距离安全顶部的距离
    CGFloat contentTop = ([MojiGlobal getStatusBarHeightBySafeArea] + contentTopInSafeArea)*scaleNumer;
    contentView.frame = CGRectMake(0, contentTop, launchView.width, contentHeight);
    contentView.contentMode = UIViewContentModeScaleAspectFit;
    [launchView addSubview:contentView];
    //底部slogan
    //图片高度124 ,底部34为安全区域适配,无安全区域的屏幕不展示底部34,只展示顶部90高度,
    //用容器containView去裁剪带安全区域的图片,否则iOS13以下系统的6p、7p、8p处理会有问题
    CGFloat bottomImageHeight = 90.0 * scaleNumer;
    CGFloat containViewHeight = bottomImageHeight + [MojiGlobal getBottomSafeHeight]*scaleNumer;
    UIView *bottomContainView = [[UIView alloc] initWithFrame:CGRectMake(0, launchScreenHeight - containViewHeight, launchScreenWidth, containViewHeight)];
    bottomContainView.clipsToBounds = YES;
    bottomContainView.backgroundColor = [UIColor clearColor];
    UIImageView *bottomImageView = [[UIImageView alloc] initWithImage:[UIImage imageNamed:@"splashSloganMJ_winter"]];
    bottomImageView.contentMode = UIViewContentModeScaleAspectFill;
    bottomImageView.frame = CGRectMake(0, 0, launchScreenWidth, 124 * scaleNumer);
    [bottomContainView addSubview:bottomImageView];
    [launchView addSubview:bottomContainView];
    return launchView;
}

//从沙盒获取启动图
+ (UIView *)getCacheLaunchImageByLirbrary{
    if (![self isAvailable]) {
        return [self getLaunchImageIngoreStatusBar:NO];
    }
    NSString *cacheLaunchPath = [[self getCacheLaunchImageArrayPath] firstObject];
    UIImage *image = [[UIImage alloc] initWithContentsOfFile:cacheLaunchPath];
    UIImageView *imageView = [[UIImageView alloc] initWithImage:image];
    imageView.frame = SCREEN_FRAME;
    return imageView;
}

#pragma mark - launchscreen.storyBoard 缓存清理、重置(版本更新时需调用)
//部分机型可能出现缓存同时用到2张截图的情况
//所以在不改动系统原有缓存数的情况下 仅做替换 防止出错
+ (void)updateSplashBoardCache:(BOOL)fetImageFromStoryBoard{
    if (![self isAvailable]) {
        NSString *cache = [NSString stringWithFormat:@"%@/Library/Caches/Snapshots/",NSHomeDirectory()];
        MJXLOG_INFO(@"Library/Caches/Snapshots 是否为可写目录 : %d",[[NSFileManager defaultManager] isWritableFileAtPath:cache]);
        MJXLOG_INFO(@"Library/Caches/Snapshots 是否为可读目录 : %d",[[NSFileManager defaultManager] isReadableFileAtPath:cache]);
        MJXLOG_INFO(@"Library/Caches/Snapshots 是否为可删除目录 : %d",[[NSFileManager defaultManager] isDeletableFileAtPath:cache]);
        return;
    }
    UIImage *mjLaunchImage = [self getMJPathSplashCacheImage];
    NSData *mjImageData = UIImagePNGRepresentation(mjLaunchImage);
    
    NSArray *cacheLauchPaths = [self getCacheLaunchImageArrayPath];
    if (mjImageData && cacheLauchPaths.count > 0) {
        // 校检md5 不一致则替换
        for (NSString *path in cacheLauchPaths) {
            NSData *systemCacheData = [NSData dataWithContentsOfFile:path];
            
            if (![[systemCacheData mjl_MD5String] isEqualToString:[mjImageData mjl_MD5String]]) {
                if (![mjImageData writeToFile:path atomically:YES]) {
                    MJXLOG_INFO(@"storyBoard方式启动图,写入缓存失败");
                }else{
                    MJXLOG_INFO(@"storyBoard方式启动图,写入缓存成功,files == %@",[[NSFileManager defaultManager] subpathsAtPath:[self splashShotCachePath]]);
                }
            }
        }
    }
}


#pragma mark - 路径
+ (NSString *)splashShotCachePath{
    NSString *snapShotPath = nil;
    if ([UIDevice currentDevice].systemVersion.floatValue < 13.0) {
        snapShotPath = [NSString stringWithFormat:@"%@/Library/Caches/Snapshots/%@/",NSHomeDirectory(),[NSBundle mainBundle].bundleIdentifier];
    }else{
        //13.0以上系统
        snapShotPath = [NSString stringWithFormat:@"%@/Library/SplashBoard/Snapshots/%@ - {DEFAULT GROUP}/",NSHomeDirectory(),[NSBundle mainBundle].bundleIdentifier];
    }
    return snapShotPath;
}

// 获取缓存的启动图路径多图数组
+ (NSArray *)getCacheLaunchImageArrayPath{
    NSFileManager *defaultManager = [NSFileManager defaultManager];
    //splashBoard的缓存截图路径
    NSString * snapShotPath = [self splashShotCachePath];
    MJXLOG_INFO(@"library splashBoard path == %@, subFiles == %@",snapShotPath,[defaultManager subpathsAtPath:snapShotPath]);
    
    NSArray *snapShots = [defaultManager subpathsAtPath:snapShotPath];
    NSMutableArray *shotArray = [NSMutableArray array];
    for (NSString *shotNameStr in snapShots) {
        if ([shotNameStr hasSuffix:@".ktx"]) {
            //完整路径数组
            [shotArray addObject: [NSString stringWithFormat:@"%@%@",snapShotPath,shotNameStr]];
        }
    }
    
    if (shotArray.count > 0) {
        return shotArray;
    }
    return nil;
}

#pragma mark - 备份路径(业务需求)
+ (UIImage *)getMJPathSplashCacheImage{
    NSString *mjSplashCacheImagePath = [self getMJSplashBoardCacheImagePath];
    
    // 根据版本信息,判断是否需要触发更新操作
    BOOL hasUpdate = NO;
    NSUserDefaults *userDefault = [NSUserDefaults standardUserDefaults];
    NSString *splashVersion = [userDefault objectForKey:kSplashBoard_Version];
    // MOJI_VERSION为外部定义的版本号 也可以是bundleVersion
    if (![splashVersion isEqualToString:MOJI_VERSION] || !splashVersion) {
        hasUpdate = YES;
    }

    UIImage *image;
    if (hasUpdate) {
        //更新图片
        image = [self.class transformLaunchViewToImageView];
        NSData *imageData = UIImagePNGRepresentation(image);
        if ([imageData writeToFile:mjSplashCacheImagePath atomically:YES]) {
            MJXLOG_INFO(@"splashBoard版本更新,自定义cache路径更新成功,路径:%@",mjSplashCacheImagePath);
            [userDefault setObject:MOJI_VERSION forKey:kSplashBoard_Version];
            [userDefault synchronize];
        }else{
            MJXLOG_INFO(@"splashBoard版本更新,自定义cache路径更新失败,路径:%@",mjSplashCacheImagePath);
        }
    }else{
        NSFileManager *defaultManager = [NSFileManager defaultManager];
        if ([defaultManager fileExistsAtPath:mjSplashCacheImagePath]) {
            //路径有图片
            image = [UIImage imageWithContentsOfFile:mjSplashCacheImagePath];
        }else{
            //路径无图片
            image = [self.class transformLaunchViewToImageView];
            NSData *imageData = UIImagePNGRepresentation(image);
            if ([imageData writeToFile:mjSplashCacheImagePath atomically:YES]) {
                MJXLOG_INFO(@"splashBoard路径无图片,自定义cache路径更新成功,路径:%@",mjSplashCacheImagePath);
            }else{
                MJXLOG_INFO(@"splashBoard路径无图片,自定义cache路径更新失败,路径:%@",mjSplashCacheImagePath);
                [defaultManager removeItemAtPath:mjSplashCacheImagePath error:nil];
            }
        }
    }
    return image;
}

+ (NSString *)getMJSplashBoardCacheImagePath{
    NSString *copyDirectoryPath = [NSString stringWithFormat:@"%@/Library/MJSplashBoard",NSHomeDirectory()];
    
    NSFileManager *defaultManager = [NSFileManager defaultManager];
    BOOL isDir = false;
    BOOL isDirExist = [defaultManager fileExistsAtPath:copyDirectoryPath
                                        isDirectory:&isDir];
    if (!isDirExist || !isDir) {
        [defaultManager createDirectoryAtPath:copyDirectoryPath
                  withIntermediateDirectories:YES
                                   attributes:nil
                                        error:nil];
    }
    NSString *copyFullPath = [NSString stringWithFormat:@"%@/%@",copyDirectoryPath,kMJSplashBoardCopyImageName];
    return copyFullPath;
}

+ (BOOL)isAvailable{
    if ([UIDevice currentDevice].systemVersion.floatValue >= 13.0) {
        return YES;
    }
    return NO;
}

#pragma mark - 生成image
+ (UIImage *)transformLaunchViewToImageView{
    UIView *launchView = [self getLaunchImageIngoreStatusBar:YES];
    UIGraphicsBeginImageContext(launchView.bounds.size);
    [launchView.layer renderInContext:UIGraphicsGetCurrentContext()];
    UIImage *launchImage = UIGraphicsGetImageFromCurrentImageContext();
    UIGraphicsEndImageContext();
    return launchImage;
}

@end

四、用法

1. 版本更新逻辑

在开屏展示完成的时候,调用更新沙盒缓存的开屏截图逻辑。

触发更新的时机:

  • 新用户首次安装
  • 版本更新后,首次启动
  • 非首次安装,每次比对沙盒缓存图片的md5和当次开屏生成图片的md5,若不一致时,触发更新;

2. 占位图调用逻辑

UIImage *image = [MJLaunchScreenTool getLaunchImageIngoreStatusBar:YES];
_launchImageView.image = image;

3. 更新缓存(启动图结束使用之后,调用)

[MJLaunchScreenTool updateSplashBoardCache:YES];

我们app开屏流程

  1. 系统storyBoard的开屏占位图(系统launchSreen.storyboard)
  2. 开屏占位图(代码设置)
  3. 开屏广告展示(代码设置)
  4. 开屏结束,更新系统沙盒开屏占位图(代码设置)

更新缓存是在步骤4进行操作的。

2020.06.04更新

测试中,我们发现现有的开屏流程存在问题。

问题1

在首次覆盖安装后,当次的热启动开屏会出现,新老开屏闪变的问题。

流程1阶段的时候,展示的是旧的开屏占位图;
流程2阶段的时候,展示的是新的开屏占位图;
就出现了:
旧占位图==>新占位图==>开屏广告图片==>开屏消失的情况

猜想

1. 系统覆盖安装时,由于上一个版本,系统沙盒(Library/SplashBoard)缓存了开屏。覆盖安装后,沙盒缓存还是上一次的开屏图片;启动时,系统依旧从沙盒拿取上一次开屏,加载到内存,作为当次开屏占位图。
2. 但是杀死app后,系统重新从沙盒(Library/SplashBoard)拿图,加载到内存,这时候沙盒图片已经被我们更新。相当于清理了缓存,相当于更新了storyboard开屏占位图。

思考

我们可以换个思路考虑,这个问题仅仅在 “首次覆盖安装” ,而且 “同为storyboard方式作为开屏方式” 的时候才会出现。
那我们是不是可以针对 首次覆盖安装 + 两次皆为storyboard方式作为开屏 的情况做单独处理呢?

思路

1. 首次覆盖安装的情况特殊处理:

版本号不一致时,本次会copy系统沙盒(Library/SplashBoard)开屏图至自定义沙盒路径(我这里定义的是Library/JWSplashBoard)备份,且当次开屏占位图为该备份图,在备份完之后,更新系统沙盒开屏图片(删除旧图,添加新图)。

2. 非首次启动时,默认之前处理方式

每次取系统沙盒路径,并删除备份占位图(Library/JWSplashBoard路径下)。

问题2

问题一的处理,只考虑到在系统沙盒目录下,系统只为我们存了一张开屏图。

但实际上,这个数目是不确定的,之后在我们其他项目团队的app上,复现多张开屏图。且通过就widget方式打开app时,系统就会在沙盒目录下新增一张开屏图(这张图可能为正常,也可能为异常)。

解决:

我们引入新的解决方法,每次启动开屏展示完成后,都会对系统沙盒目录的开屏图进行MD5比对,若不一致,则更新

这样即使第一次打开app时,出现了异常开屏图,我们会在之后的代码中将他进行修复。

也就是说异常只会出现一次,至于这一次为什么会出现异常。因为这部分代码苹果未对我们开源,我们不清楚具体逻辑,无法修改,属于苹果公司内部的问题。

但我们同事在跟苹果团队的沟通中,对方表示这种问题只会出现在开发环境,线上无问题。

实际上,苹果的线上环境也有问题,之前偶现过今日头条的App Store版本会出现黑色图块。

测试点:

区分版本

  • iOS12以下系统,系统沙盒目录相关权限未开放,完全由系统把控,我们无法对此版本做补丁。
  • iOS13系统,系统开放了沙盒权限。我们可以每次比对系统沙盒的开屏图和我们需要的开屏图。不一致,就进行替换,修复异常的问题。

--------------------------完结撒花-----------------------

适配参考:iOS13---LaunchScreen.storyboard 启动图屏幕适配「一」

你可能感兴趣的:(iOS Launch Screen.storyBoard白屏/黑屏问题修复)