iOS启动图异常探索方案-附有Demo

5,905 阅读10分钟

前言

公司APP在线上正常运营着,半年之前,突然收到要改变APP的启动页面,当时以为就是换张启动图就OK啦,实则是碰到了很多问题,比如最严重的是启动黑屏,以及启动图不及时更新等!

我们公司产品出现问题如下:

当时想到的办法是删除缓存,每次发包之前更改图片的名字[图片名字是version版本号有所关联命名],这种方式解决了95%的可能性,但是偶尔还会重现启动黑屏,而且一旦重现,每次都会变成必现,这对于APP体验来说,绝对是致命的一击!

为什么是半年之前的需求,做好了还要写这篇博客呢,因为前些日子,想到了半年前的方案还有所欠缺,所以就开始了问题的继续深究,全面解决这个难点!

背景

2019年WWDC会议上,苹果宣布提交审核的APP应用都必须要使用storyboard来配置启动图,原本规定是3月底结束,通过上面截图延长到6月底结束啦. 相关文档说明

接着上面引言公司出现的问题来说!

iOS启动图在APP后续的不断迭代中, 会出现不断更换启动图的需求! 启动图作为App展示给用户第一道窗口,如果出现白屏/黑屏或者没有及时更新,会严重影响App的体验感.

本项目原方案

原方案采取每次发新包之前都会更改图片名称【图片名称与版本号相关联】,将启动图片由Asset转入到工程目录下,删除上一个版本的启动图缓存目录,迫使系统重新生成启动图。但是本项目也采用了启动用一个控制器作为转场,控制器的内容也是要更改的图片,解决偶尔的白屏现象。

在application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) 中加入启动逻辑setLaunchPage()方法

然后查看setLaunchPage()方法

func setLaunchPage() {
     let transitionalVC = LaunchTransitionalController()
     self.window = UIWindow(frame: UIScreen.main.bounds)
     self.window?.backgroundColor = UIColor.white
     self.window?.rootViewController = transitionalVC
     self.window?.makeKeyAndVisible()
        
     /// 清理启动页缓存
     self.clearLaunchScreenCache()
     /// 淡出的过渡页
     self.setLaunchTransitionalPage()
        
}

然后查看clearLaunchScreenCache()方法如何删除缓存的?

// 清理启动页缓存
  func clearLaunchScreenCache() {
      let key = "UpdateLaunchScreen_\(UIApplication.bl_appVersion)"
      let isUpdated = UserDefaults.standard.bool(forKey: key)
      if isUpdated {return}
      do {
         //这个是有问题的,待会会讲到,因为启动截图根据系统的不同目录也会不一样
         try FileManager.default.removeItem(atPath: NSHomeDirectory()+"/Library/SplashBoard")
         UserDefaults.standard.set(true, forKey: key)
       } catch {
         print("Failed to delete launch screen cache: \(error)")
        }
  }

紧接着让转场控制器移除,重新让window的rootViewController设置为UITabBarController

/// 淡出的过渡页
    func setLaunchTransitionalPage() {
        let tabController = ***TabBarController()
        self.window?.rootViewController = tabController
        
        let transitionalVC = LaunchTransitionalController()
        transitionalVC.view.frame = UIScreen.main.bounds
        self.window?.addSubview(transitionalVC.view)
        UIView.animate(withDuration: 0.5, animations: {
            transitionalVC.launchImageView.alpha = 0
            
        }) { (_) in
            transitionalVC.view.removeFromSuperview()
        }
        //页面逻辑
        self.setMainPage(tabController)
    }

下面我们看下说到的转场控制器LaunchTransitionalController,里面做了什么?

class LaunchTransitionalController: UIViewController {
    
    lazy var launchImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.image = R.image.***_Screen_v**()
        return imageView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = UIColor.clear
        view.addSubview(launchImageView)
        launchImageView.snp.makeConstraints { (make) in
            make.edges.equalToSuperview()
        }
    }
}

上面就是项目的原方案,此方案通过接近大半年的验证,证明是95%左右是解决了启动图更改的问题?但是也会偶尔在特定手机上出现上面开始截图的那种,就很危险。本着求根溯源的科学态度,也参考技术博客,特地在最近彻底修复本问题。

问题定位

为什么原本的项目方案不能彻底解决问题呢?下面来一一分析可能存在的问题在哪?

可能第一反应是缓存,或者特定机型上才会出现的,针对这一系列的猜想,当时测试了很多demo以及方案。现在罗列一下猜想和验证:

  1. clean缓存,仍旧重现,排除编译缓存的问题
  2. 给启动图的imageView添加背景色,启动显示出了背景色,但是图片未显示,所以排除了布局问题导致启动不显示问题
  3. 将Assets的启动图片迁徙到工程目录下,就是项目原方案,此方案出现启动图概率降低,但是会出现白屏或者黑屏,此方案也不能根本解决问题
  4. 修改图片名字,每次发包之前,但是还是偶现,此方案也不能根本解决问题
  5. 卸载重新安装应用,依然出现问题

经过一系列的问题排除,最终认为是苹果系统本身问题导致。

方案探寻

1. 为探寻启动图问题,创建一个空工程,配置好启动图。

2. 在【Edit Scheme】 - 【Run】 - 选择Launch 将其设置为【wait for executable to be launched】,紧接着打开Mac 的应用程序活动监视器,搜索SpringBoard

运行LaunchNewApp工程

3. 当应用安装后,SpringBoard异步发起截图请求,通过系统第三方SplashBoard.framework生成截图,然后写入沙盒目录,沙盒目录如下【系统是13.3系统】

 沙盒目录下查看到了系统生成的启动图文件,其格式是KTX。

4. 应用程序启动时会检查当前是否有可用的启动图,如果没有可用的启动图,预热SplashBoard,生成新的启动图,并缓存到沙盒目录;如果有可用的启动图,无需预热SplashBoard,直接使用可用的启动图。

5. 根据以上方案分析结果,知道启动时加载启动图的大致流程:

  • 查找沙盒目录中是否存在可用的缓存启动图,如果有则直接使用,否则执行下一步
  • 根据LaunchScreen.storyboard生成新的启动图,并将其缓存到沙盒/Library/SplashBoard/Snapshots/ - {DEFAULT GROUP}/中

针对上面的流程分析:做出相应的解决方案分析:

  1. 清空启动图缓存目录,迫使系统重新生成启动图文件,但是仍旧出现白屏/黑屏,方案无效
  2. 因为研发人员已经知道启动图缓存目录,所以是否可以自己生成启动图放到缓存目录,让系统认为可用的缓存启动图:
  • 清空缓存目录,直接放入随意命名的图片,验证无效,系统下次启动或者挂起时,依然出现问题,验证无效
  • 替换缓存启动图文件,保证目录下所有文件名不变,但是内容是替换之后的,验证有效【下面demo会论证】

Tips:

经过方案探索,多机型多系统测试,对启动图的在不同机型不同系统的差异性做了简单的归纳:

缓存路径:
iOS13.0 及以上:Library/SplashBoard/Snapshots/${PRODUCT_BUNDLE_IDENTIFIER} - {DEFAULT GROUP};
iOS13.0 以下:Library/Caches/Snapshots/${PRODUCT_BUNDLE_IDENTIFIER};图片格式:
iOS10.0 及以上:KTX;
iOS10.0 以下:PNG。系统缓存图目录读写权限:
iOS10.0 及以上:有权限;
iOS10.0 以下:无权限。

解决方案

用户安装应用,系统会自动生成启动图并缓存至沙盒目录,接着用户启动应用时,通过代码将沙盒目录下缓存的启动图文件全部替换成通过代码生成的启动图。

因为本项目是只会竖屏启动页,所以本Demo是以竖屏的替换为主,关于横屏的替换只需要判断

屏幕宽>屏幕高==横屏屏幕高>屏幕宽==竖屏

Demo里面包含了两个主要工具类【采用Swift语言编写】可直接拖拽到工程中使用。

  • HCCLanunchImageManager: 提供启动图的工具类方法
  • HCCLaunchImageHelper: 提供沙盒存储路径和存储逻辑

Demo的主界面如下图:

一、storyboard替换

1. 点击storyboard替换按钮,查看代码里面做了什么?【LaunchScreen.storyboard->DynamicLaunchScreen.storyboard】

@objc func sbChangeAction() {
        guard let image = HCCLanunchImageManager.snapShotStoryboard(sbName: "DynamicLaunchScreen") else { return }
        HCCLanunchImageManager.changeLaunchImage(selectImage: image)
        self.dismiss(animated: true) {
            self.exitApp()
        }
  }

查看DynamicLaunchScreen.storyboard文件

2. 对DynamicLaunchScreen.storyboard控制器截图代码:

static func snapShotStoryboard(sbName: String) -> UIImage? {
     guard !sbName.isEmpty else { return nil}
     let storyboard = UIStoryboard.init(name: sbName, bundle: nil)
     guard let vc = storyboard.instantiateInitialViewController() else { return  nil }
     vc.view.frame = UIScreen.main.bounds
     UIGraphicsBeginImageContextWithOptions(vc.view.frame.size, false, UIScreen.main.scale)
     guard let context =  UIGraphicsGetCurrentContext() else { return nil}
     vc.view.layer.render(in: context)
     let image = UIGraphicsGetImageFromCurrentImageContext()
     UIGraphicsEndImageContext()
     return image
 }

3.  通过上面snapShotStoryboard类方法截图生成新的启动图,然后调用HCCLanunchImageManager.changeLaunchImage(selectImage: image)

static func changeLaunchImage(selectImage: UIImage) {
        //生成与屏幕大小的图片
        let selectedImage = resizeImage(image: selectImage)
        //block回调多次,用以替换系统启动图【每次回调成功,是一次替换成功】
        HCCLaunchImageHelper.replaceLaunchImage(replaceImage: selectedImage, compressionQuality: 0.8) { (oldImage, newImage) -> Bool in
            return checkImage(aImage: oldImage, sizeEqualToImage: newImage)
        }
    }

4. 下面我们主要是看一下HCCLaunchImageHelper.replaceLaunchImage方法,完成替换图片

//替换启动图
    static func replaceLaunchImage(replaceImage: UIImage?, compressionQuality: CGFloat, validateBlock: validateBlock) {
        guard let replaceImg = replaceImage else { return }
        //转为JPEG
        guard let imageData = replaceImg.jpegData(compressionQuality: compressionQuality) else { return }
        
        //检查图片尺寸是否等同屏幕分辨率
        let isSame = checkImageScreenSize(image: replaceImg)
        if !isSame {return }
        
        //获取系统缓存路径
        let cacheDir = launchImageCacheDirectory()
        if cacheDir?.isEmpty ?? true {return}
        
        //工作目录
        let cacheDirPath: NSString = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first! as NSString
        let tempDir = cacheDirPath.appendingPathComponent("_tmpLaunchImageCaches")
        
        //清理工作目录
        let fm = FileManager.default
        if fm.fileExists(atPath: tempDir) {
            do{
                //尝试删除
                try fm.removeItem(atPath: tempDir)
            }catch{
            }
        }
        
        //移动系统缓存
        try! fm.moveItem(atPath: cacheDir!, toPath: tempDir)
        
        //操作工作记录,记录需要操作的图片名字
        var imageNames = [String]()
        let names = try! fm.contentsOfDirectory(atPath: tempDir)
        for i in 0..<names.count {
            if self.isSnapShotName(names[i]) {
                imageNames.append(names[i])
            }
        }
        
        //写入替换图片
        let tempDirP = tempDir as NSString
        for i in 0..<imageNames.count {
            let filePath = tempDirP.appendingPathComponent(imageNames[i])
            var result = true
            let cachedImgData = NSData(contentsOfFile: filePath)
            let cacheImg = imageFromData(cachedImgData!)
            if (cacheImg != nil) {
                result = validateBlock(cacheImg!, replaceImg)
            }
            if result {
                do{
                    //尝试写入
                    let fileURL = URL.init(fileURLWithPath: filePath)
                    try imageData.write(to: fileURL)
                    print("成功了")
                }catch let error{
                    print("失败了\(error)")
                }
            }
        }
        
        //还原系统缓存目录
        try! fm.moveItem(atPath: tempDir, toPath: cacheDir!)
        
        //清理缓存目录
        if fm.fileExists(atPath: tempDir) {
            do {
                try fm.removeItem(atPath: tempDir)
            } catch  {
            }
        }
                
    }

4.1 获取系统缓存路径【与系统有关】iOS13以上与iOS13以下路径是不一样的。

//系统启动图缓存路径
    static func launchImageCacheDirectory() -> String? {
        guard let bundleId = Bundle.main.object(forInfoDictionaryKey: "CFBundleIdentifier") else { return nil }
        let fileManager = FileManager.default
        //iOS 13
        if #available(iOS 13.0, *) {
            let libraryDirectory = NSSearchPathForDirectoriesInDomains(.libraryDirectory, .userDomainMask, true).first
            let libraryPath = libraryDirectory! as NSString
            let snaPath = libraryPath.appending("/SplashBoard/Snapshots/\(bundleId) - {DEFAULT GROUP}")
            if fileManager.fileExists(atPath: snaPath) {
                return snaPath
            }
            
        } else {
            let cacheDirectory = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first
            let cachePath = cacheDirectory! as NSString
            let snap = cachePath.appendingPathComponent("Snapshots") as NSString
            let snapPath = snap.appendingPathComponent(bundleId as! String)
            if fileManager.fileExists(atPath: snapPath) {
                return snapPath
            }
            
        }
        return nil
    }

4.2 临时在/Library/Caches目录下创建临时文件夹_tmpLaunchImageCaches,用来存储/Library/SplashBoard的启动图,原本放在/Library/SplashBoard的启动图通过try! fm.moveItem(atPath: cacheDir!, toPath: tempDir) 方法就放到了/Library/Caches/_tmpLaunchImageCaches目录下

      //工作目录
       let cacheDirPath: NSString = NSSearchPathForDirectoriesInDomains(.cachesDirectory, .userDomainMask, true).first! as NSString
        let tempDir = cacheDirPath.appendingPathComponent("_tmpLaunchImageCaches")
        
        //清理工作目录
        let fm = FileManager.default
        if fm.fileExists(atPath: tempDir) {
            do{
                //尝试删除
                try fm.removeItem(atPath: tempDir)
            }catch{
            }
        }
        
        //移动系统缓存
        try! fm.moveItem(atPath: cacheDir!, toPath: tempDir)

然后跑起代码,将breakPoint打在

查看/Library/SplashBoard目录下

通过moveItem临时将移入到_tmpLaunchImageCaches目录下:

4.3 最关键的一步:写入替换图片。

//写入替换图片
        let tempDirP = tempDir as NSString
        for i in 0..<imageNames.count {
            let filePath = tempDirP.appendingPathComponent(imageNames[i])
            var result = true
            let cachedImgData = NSData(contentsOfFile: filePath)
            let cacheImg = imageFromData(cachedImgData!)
            if (cacheImg != nil) {
                result = validateBlock(cacheImg!, replaceImg)
            }
            if result {
                do{
                    //尝试写入
                    let fileURL = URL.init(fileURLWithPath: filePath)
                    try imageData.write(to: fileURL)
                    print("成功了")
                }catch let error{
                    print("失败了\(error)")
                }
            }
        }

查看打印

4.4 替换照片成功后,将/Library/Caches/_tmpLaunchImageCaches目录下移入到系统启动图的默认路径下/Library/SplashBoard,并清除临时目录_tmpLaunchImageCaches文件夹。

      //还原系统缓存目录
        try! fm.moveItem(atPath: tempDir, toPath: cacheDir!)
        
        //清理缓存目录
        if fm.fileExists(atPath: tempDir) {
            do {
                try fm.removeItem(atPath: tempDir)
            } catch  {
            }
        }

拓展:

1. 将imageDatah合成为image

//获取image对象
    static func imageFromData(_ data: NSData) -> UIImage? {
        guard let sourceImg = CGImageSourceCreateWithData(data, nil) else { return nil }
        let imageRef: CGImage? = CGImageSourceCreateImageAtIndex(sourceImg, 0, nil)
        if imageRef != nil {
            let originImage = UIImage.init(cgImage: imageRef!)
            return originImage;
        }
         return nil
    }

2. 生成与屏幕大小的图片

    //生成与屏幕大小的图片
    static func resizeImage(image: UIImage) -> UIImage {
        let imageSize = __CGSizeApplyAffineTransform(image.size, CGAffineTransform(scaleX: image.scale, y: image.scale))
        let contextSize: CGSize = contextSizeFormate()
        if !__CGSizeEqualToSize(imageSize, contextSize) {
            UIGraphicsBeginImageContext(contextSize)
            let ratio = max(contextSize.width / image.size.width, contextSize.height / image.size.height)
            let rect = CGRect.init(x: 0, y: 0, width: image.size.width * ratio, height: image.size.height * ratio)
            image.draw(in: rect)
            let resizeImage = UIGraphicsGetImageFromCurrentImageContext()
            UIGraphicsEndImageContext()
            return resizeImage!
        }
        return image
   }

二、相册选取替换

图片选择,将获取的图片设置为要替换的启动图片

func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
        let selectImage: UIImage = info[.originalImage] as! UIImage
        HCCLanunchImageManager.changeLaunchImage(selectImage: selectImage)
        picker.dismiss(animated: true) {
            self.exitApp()
        }
    }

然后HCCLanunchImageManager.changeLaunchImage的逻辑与storyboard的逻辑一模一样。

demo效果图如下图【swift版本】--------Demo地址

公司项目替换方案

1. 将HCCLanunchImageManager和HCCLaunchImageHelper工具类方法拖入到工程目录下

然后将一开始的代码,删掉LaunchTransitionalController代码,在版本号更新之后,迫使系统强制截图,并将rootViewController 设置为***TabBarController

func setLaunchTransitionalPage() {
        let tabController = ***TabBarController()
        self.window = UIWindow(frame: UIScreen.main.bounds)
        self.window?.rootViewController = tabController
        self.window?.makeKeyAndVisible()
//        let transitionalVC = LaunchTransitionalController()
//        transitionalVC.view.frame = UIScreen.main.bounds
//        self.window?.addSubview(transitionalVC.view)
//        UIView.animate(withDuration: 0.5, animations: {
//            transitionalVC.launchImageView.alpha = 0
//
//        }) { (_) in
//            transitionalVC.view.removeFromSuperview()
//        }
        
        self.setMainPage(tabController)
    }

当版本号更新之后,强制生成新的启动图

/ 清理启动页缓存
    func clearLaunchScreenCache() {
        
        let key = "UpdateLaunchScreen_\(UIApplication.bl_appVersion)"
        let isUpdated = UserDefaults.standard.bool(forKey: key)
        if isUpdated {return}
        guard let image = HCCLanunchImageManager.snapShotStoryboard(sbName: "LaunchScreen") else { return }
        HCCLanunchImageManager.changeLaunchImage(selectImage: image)
    }

通过上面的代码,经过测试多次验证,不会出现前言的那种黑屏/白屏问题,也就是彻底解决了项目中半年来的困扰。

机会❤️❤️❤️🌹🌹🌹

如果想和我一起共建抖音,成为一名bytedancer,Come on。期待你的加入!!!

截屏2022-06-08 下午6.09.11.png

总结

本篇博客原本不想书写的,因为当时想到的方案是删除缓存,修改图片名称以及迫使系统重新生成启动图,但是后来看到百度一篇博客,也想到了本项目也有该问题,就开始了彻底的研究探究之路。

博客主要是用于启动图无法渲染,不更新,黑屏/白屏问题,能够让应用恢复到渲染和更新最新的启动图状态,也特别感谢百度的技术思路分享,让我对App的启动图有了更深的理解,也彻底解决了公司项目的问题。不过也希望苹果本身可以尽快的修改该系统机制的bug,不要给研发留掉头发的可能。

本人会不断的更新有营养的,有自己见解和体会的博客,如果想要一起成长,欢迎点赞与关注本人以及留言。谢谢!!!