SwiftUI安装后首次运行出现卡顿问题
SSwwiiffttUUII

SwiftUI安装后首次运行出现卡顿问题

问题描述

在《方方块》游戏中,Xcode首次安装游戏后,第一次将方块放置到棋盘上时,就会出现卡顿的情况,后面的方块放置以及重新打开游戏,不会复现卡顿问题。

这一问题的推测是懒加载或者跟内存有关。

经过Xcode输出发现,可能跟下面的isGameOver方法有关。

func isGameOver() -> Bool {
  // 棋盘尺寸
  let rows = grid.count
  let cols = grid[0].count
  // 遍历当前的方块
  for block in CurrentBlock.compactMap({ $0 }) {
      print("进入循环测试卡顿")
      // 方块行列
      let blockRows = block.shape.count
      let blockCols = block.shape[0].count
      // 在棋盘的每个位置尝试放置方块
      for startRow in 0...(rows - blockRows) {
          for startCol in 0...(cols - blockCols) {
              if canPlaceBlock(block, atRow: startRow, col: startCol) {
                  print("当前方块可以放置,游戏继续。")
                  return false
              }
          }
      }
  }
  print("没有方块可以放置,游戏结束。")
  return true
}

当方块放置后,视图出现卡顿,卡顿1-2秒后,输出:

当前方块可以放置,游戏继续。

因此,推断代码跟isGameOver方法有关系。

考虑到卡顿出现在“当前方块可以放置,游戏继续。”之前,因此推测是isGameOver方法的执行时间较长,特别是在 canPlaceBlock 这个循环内的计算可能导致了主线程阻塞。

尝试思路

在游戏启动时,手动预加载数据:

// 在游戏启动时调用
func preloadGameData() {
    _ = isGameOver() // 预加载逻辑
    print("游戏数据已预加载")
    DispatchQueue.global(qos: .background).async {
      _ = UserDefaults.standard.object(forKey: "some_key") // 预读取,减少主线程卡顿
    }
    print("确保 UserDefaults 放在后台线程")
}

VStack {
    Text("游戏结束")
    Button("重新开始") {
        // 重新开始游戏
        restartGame()
    }
}
.onAppear(perform: preloadGameData)

这一预加载代码实现了两个功能,一个是预加载isGameOver方法,另一个是预加载UserDefaults,避免第一次访问产生额外的开销。

使用这一预加载代码,重新测试游戏,发现问题仍然存在。

拆分代码

根据Xcode的输出,发现canPlaceBlock正常返回true后,出现卡顿,然后输出:“当前方块可以放置,游戏继续。”

因此,考虑拆分if canPlaceBlock方法:

if canPlaceBlock(block, atRow: startRow, col: startCol) {
    print("当前方块可以放置,游戏继续。")
    return false
}

拆分为:

let isCanPlaceBlock = canPlaceBlock(block, atRow: startRow, col: startCol)
print("isCanPlaceBlock:\(isCanPlaceBlock)")
print("进入判断")
if isCanPlaceBlock {
    print("当前方块可以放置,游戏继续。")
    return false
}

当canPlaceBlock方法正常返回,然后出现卡顿,那么问题可能就不在这个地方。

拆分代码后,重新运行,Xcode在进入canPlaceBlock方法并返回true后,出现卡顿,同时Xcode输出报错信息:

Invalid frame dimension (negative or non-finite).

在输出一连串报错信息后,输出:

需要清除的行: []
rowsToClear.sorted().reversed():[]
rowsToClear.sorted().enumerated():[]
rowsToClear.sorted().reversed().enumerated():[]
进入循环测试卡顿
进入canPlaceBlock方法

经过代码定位,发现输出报错信息后的输出内容为clearFullRowsAndColumns方法,这个方法的作用是用于消除整行/整列的方法。

这也意味着当前卡顿原因从isGameOver方法变成了clearFullRowsAndColumns方法。

通过查询发现,“Invalid frame dimension (negative or non-finite)”报错意味着SwiftUI在渲染视图时遇到了非法的尺寸,,例如 负数、高度/宽度为 NaN(非数值)或无限大。

排查调用方法

但目前还是没有定位到具体的代码,因此尝试在clearFullRowsAndColumns方法中,添加输出定位。

// 消除整行/整列的方块
func clearFullRowsAndColumns() {
    print("进入clearFullRowsAndColumns方法")
    var newGrid = grid
    print("创建newGrid")
    // 找到需要消除的行
    var rowsToClear: Set<Int> = []
    print("新增rowsToClear方法")
    for row in 0..<9 {
        if newGrid[row].allSatisfy({ $0 == 1 }) {
            rowsToClear.insert(row)
        }
    }
    print("补充rowsToClear方法")
    // 找到需要消除的列
    print("新增colsToClear方法")
    var colsToClear: Set<Int> = []
    for col in 0..<9 {
        if (0..<9).allSatisfy({ newGrid[$0][col] == 1 }) {
            colsToClear.insert(col)
        }
    }
    print("补充colsToClear方法")
    if !rowsToClear.isEmpty || !colsToClear.isEmpty {
        if appStorage.Music {
            sound.playSound(named: "clearSoundeffects")
        }
    }
    print("需要清除的行: \(rowsToClear.sorted())")
    // 其他代码
}

重新测试,发现应用仍然卡顿,并且在卡顿的过程中没有输出clearFullRowsAndColumns方法中的print。

因此,我把问题重新定位到调用clearFullRowsAndColumns方法的地方:

DraggableBlockView(block: block,
  GestureOffset: GestureOffset,
   onDrop: {start, end, geo  in
    print("放置方块")
    print("gridOrigin:\(gridOrigin)")
    placeBlock(block, start, end, geo, item)
    // 放置方块
    print("设置shadowPosition和shadowBlock为nil")
    shadowPosition = nil
    shadowBlock = nil
    // 消除行、列的方块
    print("进入clearFullRowsAndColumns方法")
    clearFullRowsAndColumns()
    // 其他代码
})

在放置方块时,会进入到onDrop方法,然后在onDrop方法中调用clearFullRowsAndColumns方法。

在设置print输出后,重新运行Xcode安装应用。

经过定位发现,当卡顿结束后,输出

print("设置shadowPosition和shadowBlock为nil")

在卡顿前输出的是:

放置方块
gridOrigin:(7.666666666666666, 216.33333333333331)
进入placeBlock方法
start:(25.666661580403655, 51.666666666666686)
end:(-1.0, -127.33334350585938)
origins:(147.66666666666666, 626.3333333333333)
gridOrigin:(7.666666666666666, 216.33333333333331)
touchOffsetX:147.66666666666666
touchOffsetY:626.3333333333333
row:5, col: 3
进入canPlaceBlock方法
计算出的 row:5, col:3
当前可放置的方块为:Block(shape: [[1, 0], [1, 1]])
可放置行数为:5
可放置列数为:3
返回true

Xcode在placeBlock方法中输出到一半停止了,因此推断问题跟placeBlock方法有关。

给placeBlock方法添加输出定位:

// 放置方块
func placeBlock(_ block: Block, _ start: CGPoint, _ end: CGSize, _ origins: CGPoint, _ indices : Int) {
  if isCanPlaceBlock{
      // 其他代码
      print("棋盘遍历完成")
      // 播放放置方块音效
      print("播放音效")
      if appStorage.Music {
          sound.playSound(named: "blockSound")
      }
      // 计算得分
      print("计算得分")
    } else {
        print("无法放置方块")
    }
}

经过添加print后,Xcode重新安装应用,定位到卡顿代码:

播放音效
--- 发生卡顿 ---
计算得分

这也就意味着可能是sound.playSound方法导致的问题。

为了进一步明确,在隐藏sound.playSound方法后,重新使用Xcode安装应用。

卡顿问题得到解决。

问题分析

最终,通过层层的代码筛选和设置print定位,发现卡顿的原因是由于AVAudioPlayer创建导致的卡顿。

由于 AVAudioPlayer 的初始化 (AVAudioPlayer(contentsOf:)) 是 IO 操作,会造成主线程的短暂卡顿,特别是在应用刚安装后第一次读取音频文件时。

在Xcode中,找到SoundManager方法,添加print输出测试:

class SoundManager {
    static let shared = SoundManager() // 单例
    var player: AVAudioPlayer?
    
    func playSound(named soundName: String) {
        print("进入playSound方法")
        if let url = Bundle.main.url(forResource: soundName, withExtension: "mp3") {
          print("查找\(soundName)文件")
          do {
              print("尝试设置AVAudioPlayer")
              self.player = try AVAudioPlayer(contentsOf: url)
              print("播放\(soundName)文件")
              self.player?.play()
          } catch {
              print("播放失败: \(error.localizedDescription)")
          }
        } else {
            print("找不到音效文件: \(soundName)")
        }
    }
}

最终,定位问题为try AVAudioPlayer(contentsOf: url)代码,当Xcode输出“尝试设置AVAudioPlayer”后,出现卡顿。卡顿结束,输出“播放blockSound文件”。

总结

最终的结局方案就是,预加载 AVAudioPlayer,避免重复初始化。

import AVFoundation

class SoundManager {
    static let shared = SoundManager() // 单例

    private var players: [String: AVAudioPlayer] = [:]

    private init() {
        preloadSounds(["blockSound"]) // 预加载音效
    }

    private func preloadSounds(_ soundNames: [String]) {
        for soundName in soundNames {
            if let url = Bundle.main.url(forResource: soundName, withExtension: "mp3") {
                do {
                    let player = try AVAudioPlayer(contentsOf: url)
                    player.prepareToPlay() // 预加载到缓存
                    players[soundName] = player
                    print("预加载音效: \(soundName)")
                } catch {
                    print("预加载失败: \(soundName), 错误: \(error.localizedDescription)")
                }
            } else {
                print("找不到音效文件: \(soundName)")
            }
        }
    }

    func playSound(named soundName: String) {
        DispatchQueue.global(qos: .userInitiated).async {
            if let player = self.players[soundName] {
                player.currentTime = 0
                DispatchQueue.main.async {
                    player.play()
                }
            } else {
                print("音效未加载: \(soundName)")
            }
        }
    }
}

在preloadSounds中添加需要加载的音频文件。

在Xcode入口文件中,设置共享示例:

import SwiftUI

@main
struct FaockApp: App {
    @StateObject private var sound = SoundManager.shared
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(sound)
        }
    }
}

在视图文件中,使用@ EnvironmentObject注入。

struct Game: View {
  @EnvironmentObject var sound: SoundManager  // 通过 Sound 注入
  func clearFullRowsAndColumns() {
    // 其他代码
    sound.playSound(named: "clearSoundeffects")
  }
  var body: some View {
    ... 
  }
}
#Preview {
  Game(viewStep: .constant(1))
    .environmentObject(SoundManager.shared)
}

通过预加载音频文件:游戏启动时就创建 AVAudioPlayer,解码一次,避免首次播放时解码导致卡顿。

问题得到解决。

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注