6

I'm using CADisplayLink in my iPhone app.

Here is the relevant code:

SMPTELink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onTick)];
SMPTELink.frameInterval = 2;//30fps 60/n = fps
[SMPTELink addToRunLoop:[NSRunLoop mainRunLoop]
                    forMode:NSDefaultRunLoopMode];

onTick is thus called every frame of 30FPS (1/30th of a second). This works GREAT on iOS6+ - does exactly what I need. However, when I ran my app on an iPhone 4s running iOS5.1, the onTick method ran slightly slower than with the iOS6 counterpart. Almost like it was running it 29FPS. After a little bit, it was out of sync with the iOS6 iPhone 5.

The code in the onTick method is not time consuming (that was one of my thoughts...), and it's not the iPhone because the app runs fine on an iPhone 4s running iOS6.

Does CADisplayLink function differently in iOS5.1? Any possible workarounds/solutions?

Dilip Manek
  • 9,095
  • 5
  • 44
  • 56
objectiveccoder001
  • 2,981
  • 10
  • 48
  • 72

3 Answers3

19

I can't speak to the iOS 5.x v 6.x differences, but when I use CADisplayLink, I never hard code stuff like "move x pixels/points" every iteration, but rather I look at the timestamp (or more accurately, the delta between my initial timestamp and the current timestamp) and calculate the location based upon how much time has elapsed, not by how many frames have passed. That way, frame rates don't affect the speed of the motion, but rather just the smoothness of it. (And the difference between 30 and 29 is likely to be indistinguishable.)

To quote from the CADisplayLink Class Reference:

Once the display link is associated with a run loop, the selector on the target is called when the screen’s contents need to be updated. The target can read the display link’s timestamp property to retrieve the time that the previous frame was displayed. For example, an application that displays movies might use the timestamp to calculate which video frame will be displayed next. An application that performs its own animations might use the timestamp to determine where and how displayed objects appear in the upcoming frame. The duration property provides the amount of time between frames. You can use this value in your application to calculate the frame rate of the display, the approximate time that the next frame will be displayed, and to adjust the drawing behavior so that the next frame is prepared in time to be displayed.


As a random example, here I'm animating a UIBezierPath using the number of seconds that have elapsed as a parameter.

Or, alternatively, if you're dealing with a sequence of UIImage frames, you could calculate the frame number as follows:

@property (nonatomic) CFTimeInterval firstTimestamp;

- (void)handleDisplayLink:(CADisplayLink *)displayLink
{
    if (!self.firstTimestamp)
        self.firstTimestamp = displayLink.timestamp;

    CFTimeInterval elapsed = (displayLink.timestamp - self.firstTimestamp);

    NSInteger frameNumber = (NSInteger)(elapsed * kFramesPerSecond) % kMaxNumberOfFrames;

    // now do whatever you want with this frame number
}

Or, better yet, to avoid risking losing a frame, go ahead and let this run at 60 fps and just determine if the frame needs updating and that way you'll reduce the risk of dropping a frame.

- (void)handleDisplayLink:(CADisplayLink *)displayLink
{
    if (!self.firstTimestamp)
        self.firstTimestamp = displayLink.timestamp;

    CFTimeInterval elapsed = (displayLink.timestamp - self.firstTimestamp);

    NSInteger frameNumber = (NSInteger)(elapsed * kFramesPerSecond) % kMaxNumberOfFrames;

    if (frameNumber != self.lastFrame)
    {
        // do whatever you want with this frame number

        ... 

        // now update the "lastFrame" number property

        self.lastFrame = frameNumber;
    }
}

But frequently, frame numbers aren't needed at all. For example, to move a UIView in a circle, you might do something like:

- (void)handleDisplayLink:(CADisplayLink *)displayLink
{
    if (!self.firstTimestamp)
        self.firstTimestamp = displayLink.timestamp;

    CFTimeInterval elapsed = (displayLink.timestamp - self.firstTimestamp);

    self.animatedView.center = [self centerAtElapsed:elapsed];
}

- (CGPoint)centerAtElapsed:(CFTimeInterval)elapsed
{
    CGFloat radius = self.view.bounds.size.width / 2.0;

    return CGPointMake(radius + sin(elapsed) * radius,
                       radius + cos(elapsed) * radius);
}

By the way, if you use Instruments to measure frame rate, it can seem slower than it really will be on a device. To matt's comment, for accurate frame rates, you should measure it programmatically on an actual device with a release build.

Community
  • 1
  • 1
Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Great idea! So, on the onTick method, I timestamp, then on the next one, I timestamp again - then compare to see if I get 0.03333 seconds difference? What if I don't? Can you elaborate on your concept? – objectiveccoder001 Feb 19 '13 at 16:49
  • @objectiveccoder001 If there's some reason why you absolutely need 30 fps, then, yes, you could do something like that. See my code sample that can determine the frame number based upon the time elapsed. Many animations, though, you don't need to do that, but rather you can just calculate the new position based upon the time elapsed, and there's no reason why it's constrained to 30 fps (vs 29 vs 59 vs etc.). – Rob Feb 19 '13 at 18:46
  • AWESOME! Thank you so much. What does kMaxNumberOfFrames be? – objectiveccoder001 Feb 19 '13 at 18:49
  • The max number in the animation? What if you don't have a max? – objectiveccoder001 Feb 19 '13 at 18:50
  • Realized - I don't need the mod kMaxNumberOfFrames. If I remove that, it'll just give me the elapsed frames. Thanks, again rob! – objectiveccoder001 Feb 19 '13 at 18:55
  • One more question with regards to your edit. If I set the frame rate to 60FPS but keep kFramesPerSecond equal to 30, is the NSInteger framerate in 60 FPS or 30FPS? – objectiveccoder001 Feb 19 '13 at 19:06
  • @objectiveccoder001 Personally, I generally have the `CADisplayLink` just run with it's optimal rate, and if I need a "frame number" because I need to pick an image from an array of images that should change at a certain rate, then I use that `frameNumber` logic. Otherwise, if I'm animating something that offers greater granularity (e.g. a position, a rotation, a size, etc.), then I just let the `CADisplayLink` handler do its stuff without worrying about frame numbers at all. In answer to your question, my `frameNumber` example uses whatever frame rate you specified in `kFramesPerSecond`. – Rob Feb 19 '13 at 19:32
  • 1
    Brilliant. I used your second code example (60fps). Works PERFECTLY. Fixed all my problems that I was experiencing with iOS5. I appreciate it! :) – objectiveccoder001 Feb 19 '13 at 22:37
3

Rob's answer is exactly right. You've no business worrying about the frame rate of CADisplayLink; in fact, you mustn't even expect that the timer will fire with anything like regularity. Your job is to divide up the desired animation in accordance with the desired time scale, and draw the frame you actually get each time the timer fires by adding up the accumulated timestamps.

Here is sample code from my book:

if (self->_timestamp < 0.01) { // pick up and store first timestamp
    self->_timestamp = sender.timestamp;
    self->_frame = 0.0;
} else { // calculate frame
    self->_frame = sender.timestamp - self->_timestamp;
}
sender.paused = YES; // defend against frame loss

[_tran setValue:@(self->_frame) forKey:@"inputTime"];
CGImageRef moi3 = [self->_con createCGImage:_tran.outputImage
                                   fromRect:_moiextent];
self->_iv.image = [UIImage imageWithCGImage:moi3];
CGImageRelease(moi3);

if (self->_frame > 1.0) {
    [sender invalidate];
    self->_frame = 0.0;
    self->_timestamp = 0.0;
}
sender.paused = NO;

In that code, the _frame value runs between 0 (we are just starting the animation) and 1 (we have finished the animation), and in the middle I just do whatever this particular situation requires to draw that frame. To make the animation take longer or shorter, just multiply a scale factor when setting the _frame ivar.

Also note that you must never test in the Simulator, as the results are utterly meaningless. Only the device runs CADisplayLink properly.

(Example comes from here: http://www.apeth.com/iOSBook/ch17.html#_cifilter_transitions)

matt
  • 515,959
  • 87
  • 875
  • 1,141
0

Basic Swift version of the other answers (minus the animation code)

class BasicStopwatch {

    var timer: CADisplayLink!
    var firstTimestamp: CFTimeInterval!
    var elapsedTime: TimeInterval = 0

    let formatter: DateFormatter = {
        let df = DateFormatter()
        df.dateFormat = "mm:ss.SS"
        return df
    }()

    func begin() {
        timer = CADisplayLink(target: self, selector: #selector(tick))
        timer.preferredFramesPerSecond = 10 // adjust as needed
        timer.add(to: .main, forMode: .common)
    }

    @objc func tick() {
        if (self.firstTimestamp == nil) {
            print("Set first timestamp")
            self.firstTimestamp = timer!.timestamp
            return
        }

        elapsedTime = timer.timestamp - firstTimestamp
        /// Print raw elapsed time
        // print(elapsedTime)

        /// print elapsed time
        print(elapsedTimeAsString())

        /// If need to track frames
        // let totalFrames: Double = 20
        // let frameNumber = (elapsedTime * Double(timer!.preferredFramesPerSecond)).truncatingRemainder(dividingBy: totalFrames)
        // print("Frame ", frameNumber)
    }

    func elapsedTimeAsString() -> String {
        return formatter.string(from: Date(timeIntervalSinceReferenceDate: elapsedTime))
    }
}

Usage

let watch = BasicStopwatch()
watch.begin()
Eric Aya
  • 69,473
  • 35
  • 181
  • 253
DogCoffee
  • 19,820
  • 10
  • 87
  • 120