需求
弃用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}/B6C097B0-5F66-4740-A20A-5FBDAA2EE484@2x.ktx",
"Snapshots/wei.jiang.luanchTest - {DEFAULT GROUP}/83BE4383-44CF-41E0-9E3F-235E6D132B61@2x.ktx",
"Snapshots/wei.jiang.luanchTest - {DEFAULT GROUP}/D2761A77-E07C-4D3B-9551-C90F643218BF@2x.ktx",
"Snapshots/wei.jiang.luanchTest - {DEFAULT GROUP}/A3E1D437-7876-481C-A5AA-4940E69770A6@2x.ktx",
"Snapshots/sceneID:wei.jiang.luanchTest-default",
"Snapshots/sceneID:wei.jiang.luanchTest-default/1970FB13-BDA2-44F0-B998-ECFEDE1068A6@2x.ktx",
"Snapshots/sceneID:wei.jiang.luanchTest-default/2890B753-D9F5-4030-881A-FA35EF24D922@2x.ktx",
"Snapshots/sceneID:wei.jiang.luanchTest-default/downscaled",
"Snapshots/sceneID:wei.jiang.luanchTest-default/downscaled/BBF126DF-765D-4607-A052-02CA0426DAA5@2x.ktx",
"Snapshots/sceneID:wei.jiang.luanchTest-default/downscaled/4E66BDD7-D46C-4F16-917F-BE23E8E0F1B9@2x.ktx"
)
一目了然,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 <Foundation/Foundation.h>
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开屏流程
- 系统storyBoard的开屏占位图(系统launchSreen.storyboard)
- 开屏占位图(代码设置)
- 开屏广告展示(代码设置)
- 开屏结束,更新系统沙盒开屏占位图(代码设置)
更新缓存是在步骤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系统,系统开放了沙盒权限。我们可以每次比对系统沙盒的开屏图和我们需要的开屏图。不一致,就进行替换,修复异常的问题。