需求
棄用LaunchImage啟動(dòng)圖方式,改用Launch Screen.storyBoard啟動(dòng)圖方式,同時(shí)不對(duì)開屏廣告造成影響。
官方介紹
https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/launch-screen/
注:該方案僅適用iOS13.0及以上版本。
iOS 12及以下系統(tǒng)沙盒目錄(Library/Caches/Snapshots)為不可讀、不可寫、可刪除(但是開發(fā)者無權(quán)刪除)權(quán)限,故本套方案不起作用。
若調(diào)試中出現(xiàn)問題,可卸載app,重啟手機(jī),重新裝載app測(cè)試。
背景
現(xiàn)階段網(wǎng)上流行的storyboard開屏:
1. 開屏圖片從主目錄讀取,一張圖適配所有界面。首先這種方式是不存在緩存的,實(shí)時(shí)取肯定是準(zhǔn)確的圖。但是作為業(yè)務(wù)方,這種滿足不了自定義的四季樣式,還會(huì)存在拉伸。
2. 圖片存在XCAsset中,每次使用完,都會(huì)刪除沙盒目錄(Library/SplashBoard)文件,雖然有版本限制,但是大概率存在黑/白屏風(fēng)險(xiǎn),也是不達(dá)標(biāo)。
PS:該文方案都是以xcasset緩存開屏為基礎(chǔ)的,首次storyboard替換LuanchImage,且未做沙盒緩存清空的,參考文章。如果之前對(duì)沙盒有操作的,可能會(huì)有異常。
Apple會(huì)將Launch Screen.storyBoard作為與圖片類型類似的二進(jìn)制文件,進(jìn)行加載,執(zhí)行是在main函數(shù)之前,所以不參與業(yè)務(wù)代碼控制。適配就不做多余的闡述了。
一、問題
在iOS應(yīng)用程序中修改了啟動(dòng)屏幕LaunchScreen.storyboad中的某些內(nèi)容時(shí),我都會(huì)遇到一個(gè)問題:系統(tǒng)會(huì)緩存啟動(dòng)圖像,即使刪除了該應(yīng)用程序,它實(shí)際上也很難清除原來的緩存,猜測(cè)會(huì)有多級(jí)緩存。
二、分析
我們可以改動(dòng)的緩存只有本地的沙盒目錄(/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導(dǎo)出轉(zhuǎn)換后綴,可以看到就是我們要的啟動(dòng)圖,因?yàn)槲疫@里是用的多個(gè)模擬器所以會(huì)有多張,正常的會(huì)只有一張。
注:如果項(xiàng)目工程是以xcode11方式新建的話,就需要處理UIScene的截圖,我們的項(xiàng)目沒有用到UIScene方式,所以沒有做相應(yīng)處理。
三、解決
思路:
推測(cè)系統(tǒng)在沙盒目錄有圖的時(shí)候,會(huì)從沙盒拿圖。所以我們?cè)诒3衷心夸浀那闆r下,只做圖片內(nèi)容的替換(有坑,有同事之前一直用的主目錄方式&&有過沙盒目錄的刪除操作,替換到這種方式每次首次讀圖都會(huì)空白屏)。
1.取圖:
每次展示自定義啟動(dòng)頁時(shí),優(yōu)先從Snapshots里拿image進(jìn)行展示(無圖是從storyBoard拿圖),進(jìn)行無縫銜接;
每次啟動(dòng)時(shí),根據(jù)UI指定的布局,生成一張圖片,作為展位圖展示。(開屏啟動(dòng)時(shí),有系統(tǒng)狀態(tài)條變化,直接獲取luanchScreen.storyboard的圖片會(huì)有狀態(tài)條高度缺失引起圖片生成異常)
2.替換:
當(dāng)次展示完啟動(dòng)圖時(shí),進(jìn)行更新(將storyBoard的image同步到Snapshots)。避免重復(fù)無用操作做了版本控制。
當(dāng)次展示完啟動(dòng)圖時(shí),進(jìn)行更新(將我們生成的圖片存入SnapShots的沙盒目錄下)。避免重復(fù)無用操作做了版本控制。
不多說了,直接上代碼吧。
//
// 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的啟動(dòng)加載方式
*/
@interfaceMJLaunchScreenTool : NSObject
/*
* 獲取沙盒/SplashBoard/Snapshots目錄下的啟動(dòng)圖(不推薦,有statusBar改動(dòng)會(huì)有異常)
*/
+ (UIView *)getCacheLaunchImageByLirbrary;
/*
* 獲取LaunchScreen.storyBoard對(duì)應(yīng)啟動(dòng)圖(推薦,忽略statusbar影響)
*/
+ (UIView *)getLaunchImageIngoreStatusBar:(BOOL)isHighQuailty;
/*
* 更替修正storyboard的緩存啟動(dòng)圖(storyboard作啟動(dòng)圖的情況下,不可刪除)
*/
+ (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獲取啟動(dòng)圖(不建議使用,如果外部有改動(dòng)statusbar獲取的圖片可能會(huì)出問題)
+ (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];
//內(nèi)容區(qū)域
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為安全區(qū)域適配,無安全區(qū)域的屏幕不展示底部34,只展示頂部90高度,
//用容器containView去裁剪帶安全區(qū)域的圖片,否則iOS13以下系統(tǒng)的6p、7p、8p處理會(huì)有問題
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;
}
//從沙盒獲取啟動(dòng)圖
+ (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 緩存清理、重置(版本更新時(shí)需調(diào)用)
//部分機(jī)型可能出現(xiàn)緩存同時(shí)用到2張截圖的情況
//所以在不改動(dòng)系統(tǒng)原有緩存數(shù)的情況下 僅做替換 防止出錯(cuò)
+ (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方式啟動(dòng)圖,寫入緩存失敗");
}else{
MJXLOG_INFO(@"storyBoard方式啟動(dòng)圖,寫入緩存成功,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以上系統(tǒng)
snapShotPath = [NSString stringWithFormat:@"%@/Library/SplashBoard/Snapshots/%@ - {DEFAULT GROUP}/",NSHomeDirectory(),[NSBundle mainBundle].bundleIdentifier];
}
return snapShotPath;
}
// 獲取緩存的啟動(dòng)圖路徑多圖數(shù)組
+ (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"]) {
//完整路徑數(shù)組
[shotArray addObject: [NSString stringWithFormat:@"%@%@",snapShotPath,shotNameStr]];
}
}
if (shotArray.count > 0) {
return shotArray;
}
return nil;
}
#pragma mark - 備份路徑(業(yè)務(wù)需求)
+ (UIImage *)getMJPathSplashCacheImage{
NSString *mjSplashCacheImagePath = [self getMJSplashBoardCacheImagePath];
// 根據(jù)版本信息,判斷是否需要觸發(fā)更新操作
BOOL hasUpdate = NO;
NSUserDefaults *userDefault = [NSUserDefaults standardUserDefaults];
NSString *splashVersion = [userDefault objectForKey:kSplashBoard_Version];
// MOJI_VERSION為外部定義的版本號(hào) 也可以是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. 版本更新邏輯
在開屏展示完成的時(shí)候,調(diào)用更新沙盒緩存的開屏截圖邏輯。
觸發(fā)更新的時(shí)機(jī):
- 新用戶首次安裝
- 版本更新后,首次啟動(dòng)
- 非首次安裝,每次比對(duì)沙盒緩存圖片的md5和當(dāng)次開屏生成圖片的md5,若不一致時(shí),觸發(fā)更新;
2. 占位圖調(diào)用邏輯
UIImage *image = [MJLaunchScreenTool getLaunchImageIngoreStatusBar:YES];
_launchImageView.image = image;
3. 更新緩存(啟動(dòng)圖結(jié)束使用之后,調(diào)用)
[MJLaunchScreenTool updateSplashBoardCache:YES];
我們app開屏流程
- 系統(tǒng)storyBoard的開屏占位圖(系統(tǒng)launchSreen.storyboard)
- 開屏占位圖(代碼設(shè)置)
- 開屏廣告展示(代碼設(shè)置)
- 開屏結(jié)束,更新系統(tǒng)沙盒開屏占位圖(代碼設(shè)置)
更新緩存是在步驟4進(jìn)行操作的。
2020.06.04更新
測(cè)試中,我們發(fā)現(xiàn)現(xiàn)有的開屏流程存在問題。
問題1
在首次覆蓋安裝后,當(dāng)次的熱啟動(dòng)開屏?xí)霈F(xiàn),新老開屏閃變的問題。
流程1階段的時(shí)候,展示的是舊的開屏占位圖;
流程2階段的時(shí)候,展示的是新的開屏占位圖;
就出現(xiàn)了:
舊占位圖==>新占位圖==>開屏廣告圖片==>開屏消失的情況
猜想
1. 系統(tǒng)覆蓋安裝時(shí),由于上一個(gè)版本,系統(tǒng)沙盒(Library/SplashBoard)緩存了開屏。覆蓋安裝后,沙盒緩存還是上一次的開屏圖片;啟動(dòng)時(shí),系統(tǒng)依舊從沙盒拿取上一次開屏,加載到內(nèi)存,作為當(dāng)次開屏占位圖。
2. 但是殺死app后,系統(tǒng)重新從沙盒(Library/SplashBoard)拿圖,加載到內(nèi)存,這時(shí)候沙盒圖片已經(jīng)被我們更新。相當(dāng)于清理了緩存,相當(dāng)于更新了storyboard開屏占位圖。
思考
我們可以換個(gè)思路考慮,這個(gè)問題僅僅在 “首次覆蓋安裝” ,而且 “同為storyboard方式作為開屏方式” 的時(shí)候才會(huì)出現(xiàn)。
那我們是不是可以針對(duì) 首次覆蓋安裝 + 兩次皆為storyboard方式作為開屏 的情況做單獨(dú)處理呢?
思路
1. 首次覆蓋安裝的情況特殊處理:
版本號(hào)不一致時(shí),本次會(huì)copy系統(tǒng)沙盒(Library/SplashBoard)開屏圖至自定義沙盒路徑(我這里定義的是Library/JWSplashBoard)備份,且當(dāng)次開屏占位圖為該備份圖,在備份完之后,更新系統(tǒng)沙盒開屏圖片(刪除舊圖,添加新圖)。
2. 非首次啟動(dòng)時(shí),默認(rèn)之前處理方式
每次取系統(tǒng)沙盒路徑,并刪除備份占位圖(Library/JWSplashBoard路徑下)。
問題2
問題一的處理,只考慮到在系統(tǒng)沙盒目錄下,系統(tǒng)只為我們存了一張開屏圖。
但實(shí)際上,這個(gè)數(shù)目是不確定的,之后在我們其他項(xiàng)目團(tuán)隊(duì)的app上,復(fù)現(xiàn)多張開屏圖。且通過就widget方式打開app時(shí),系統(tǒng)就會(huì)在沙盒目錄下新增一張開屏圖(這張圖可能為正常,也可能為異常)。
解決:
我們引入新的解決方法,每次啟動(dòng)開屏展示完成后,都會(huì)對(duì)系統(tǒng)沙盒目錄的開屏圖進(jìn)行MD5比對(duì),若不一致,則更新。
這樣即使第一次打開app時(shí),出現(xiàn)了異常開屏圖,我們會(huì)在之后的代碼中將他進(jìn)行修復(fù)。
也就是說異常只會(huì)出現(xiàn)一次,至于這一次為什么會(huì)出現(xiàn)異常。因?yàn)檫@部分代碼蘋果未對(duì)我們開源,我們不清楚具體邏輯,無法修改,屬于蘋果公司內(nèi)部的問題。
但我們同事在跟蘋果團(tuán)隊(duì)的溝通中,對(duì)方表示這種問題只會(huì)出現(xiàn)在開發(fā)環(huán)境,線上無問題。
實(shí)際上,蘋果的線上環(huán)境也有問題,之前偶現(xiàn)過今日頭條的App Store版本會(huì)出現(xiàn)黑色圖塊。
測(cè)試點(diǎn):
區(qū)分版本
- iOS12以下系統(tǒng),系統(tǒng)沙盒目錄相關(guān)權(quán)限未開放,完全由系統(tǒng)把控,我們無法對(duì)此版本做補(bǔ)丁。
- iOS13系統(tǒng),系統(tǒng)開放了沙盒權(quán)限。我們可以每次比對(duì)系統(tǒng)沙盒的開屏圖和我們需要的開屏圖。不一致,就進(jìn)行替換,修復(fù)異常的問題。