具有 DisplayLink 异常行为的核心图形

Core Graphics with DisplayLink Unexpected Behavior

我正在尝试学习 Core Graphics,但无法理解我编写的代码的行为,该代码使用子类化的 UIView 和 draw(_ rect:) 函数的重写。

我写了一个基本的弹跳球演示。任意数量的随机球都是以随机位置和速度创建的。然后它们在屏幕上弹跳。

我的问题是球看起来移动的方式出乎意料并且有很多闪烁。下面是循环遍历所有球的顺序:

  1. 检查碰撞。
  2. 如果与墙发生碰撞,速度乘以-1。
  3. 按球速增加球位置。

我目前没有清除上下文,所以我希望现有的球保持原样。相反,它们似乎随着移动的球平稳地滑动。 我想了解为什么会这样。

这是当前代码如何以 4 fps 运行的图像,以便您可以看到形状是如何绘制和来回移动的:

这是我的代码:

    class ViewController: UIViewController {
    
    let myView = MyView()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        view.backgroundColor = .systemBlue
        myView.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(myView)
        
        NSLayoutConstraint.activate([
            myView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            myView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            myView.widthAnchor.constraint(equalTo: view.widthAnchor),
            myView.heightAnchor.constraint(equalTo: view.heightAnchor)
        ])
        
        createDisplayLink(fps: 60)
    }
    
    func createDisplayLink(fps: Int) {
        let displaylink = CADisplayLink(target: self,
                                        selector: #selector(step))
        
        displaylink.preferredFramesPerSecond = fps
        
        displaylink.add(to: .current,
                        forMode: RunLoop.Mode.default)
    }
    
    @objc func step(displaylink: CADisplayLink) {
        myView.setNeedsDisplay()
    }
}

class MyView: UIView {
    
    let numBalls = 5
    var balls = [Ball]()
    
    override init(frame:CGRect) {
        super.init(frame:frame)
        
        for _ in 0..<numBalls {
            balls.append(
                Ball(
                    ballPosition: Vec2(x: CGFloat.random(in: 0...UIScreen.main.bounds.width), y: CGFloat.random(in: 0...UIScreen.main.bounds.height)),
                    ballSpeed: Vec2(x: CGFloat.random(in: 0.5...2), y: CGFloat.random(in: 0.5...2))))
        }
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func draw(_ rect: CGRect) {
        
        guard let context = UIGraphicsGetCurrentContext() else { return }
        
        for i in 0..<numBalls {
            if balls[i].ballPosition.x > self.bounds.width - balls[i].ballSize || balls[i].ballPosition.x < 0 {
                balls[i].ballSpeed.x *= -1
            }
            
            balls[i].ballPosition.x += balls[i].ballSpeed.x
            
            if balls[i].ballPosition.y > self.bounds.height - balls[i].ballSize || balls[i].ballPosition.y < 0 {
                balls[i].ballSpeed.y *= -1
                
            }
            balls[i].ballPosition.y += balls[i].ballSpeed.y
        }
        
        for i in 0..<numBalls {
            context.setFillColor(UIColor.red.cgColor)
            context.setStrokeColor(UIColor.green.cgColor)
            context.setLineWidth(0)
            
            let rectangle = CGRect(x: balls[i].ballPosition.x, y: balls[i].ballPosition.y, width: balls[i].ballSize, height: balls[i].ballSize)
            context.addEllipse(in: rectangle)
            context.drawPath(using: .fillStroke)
        }
    }
}

这里有很多误区,我会尽量一一解答:

  • CADisplayLink 不保证它会每 1/60 秒调用一次您的步骤方法。 属性 被称为 preferred 每秒帧数是有原因的。这只是向系统提示您想要什么。它可能不会经常打电话给你,无论如何都会有一些错误。

  • 要手动执行您自己的动画,您需要查看给定帧实际附加的时间,并使用它来确定事物的位置。 CADisplayLink 包含一个 timestamp 让您知道。你不能只增加速度。你需要用速度乘以实际时间来确定距离。

  • “我目前没有清除上下文,所以我希望现有的球保持原样。”每次调用 draw(rect:) 时,您都会收到一个新的上下文。您有责任为当前帧绘制所有内容。帧之间没有持久性。 (Core Animation 通常通过高效地将 CALayer 组合在一起来提供这些功能;但是您选择使用 Core Graphics,并且每次都需要在那里绘制所有内容。我们通常不会这样使用 Core Graphics。)

  • myView.setNeedsDisplay() 并不意味着“立即绘制此帧”。意思是“下次你要画的时候,这个视图需要重画”。根据 CADisplayLink 触发的确切时间,您可能会丢帧,也可能不会。使用 Core Graphics,您需要在调用 setNeedsDisplay() 之前更新所有圆圈的位置。然后 draw(rect:) 应该只是绘制它们,而不是计算它们是什么。 (虽然 CADisplayLink 实际上是为与 CALayers 一起工作而设计的,而 NSView 绘图并不是为经常更新而设计的,因此要保持流畅可能仍然有点棘手。)

创建这个系统的更正常的方法是为每个球生成一个 CAShapeLayer 并将它们放置在 NSView 的图层上。然后在 CADisplayLink 回调中,您将根据下一帧的时间戳调整它们的位置。或者,您可以设置一个重复的 NSTimer 或 DispatchTimerSource(而不是 CADisplayLink),其速度远低于屏幕刷新速度(例如 1/20 秒),并在该回调中移动图层位置。这会很好而且简单,并且避免 CADisplayLink 的复杂性(它更强大,但希望您使用时间戳并考虑其他软实时问题)。