Making Tab-Switching and Scrolling Faster in OmniFocus for Mac

I’ve been on crash-fix-duty for a while with the Mac version of OmniFocus, and just as I was climbing out and starting work on a new feature, I got sidetracked into working on performance.

Well, I didn’t get sidetracked so much as I sidetracked myself. I said, “Hey, Dave, can I work on this instead?” And Dave said, “Go for it!” (Dave Messent is the OmniFocus product manager, and a swell person, and a fellow Seahawks fan in a company full of soccer fiends.)

The specific performance issues I wanted to work on were tab switching and scrolling. I figured I knew what the problems were: Auto Layout and Key-Value Observing (KVO).

The reason I thought that: the table cell views for OmniFocus are complex. Each cell view has multiple text fields, a stack view (with a stack view inside it), a status circle, a flag button, a disclosure button, and so on. The layout is fluid, and there must be significant costs in laying all these out (especially because string measurement, which is slow, is a big part of layout) — and there must also be costs in setting up and tearing down KVO for all the various properties for each row.

So I launched Instruments, chose the Time Profiler instrument, and started running it while switching tabs and while scrolling — because one thing I’ve learned from experience is that Instruments will tell you the truth, and sometimes the truth is surprising.

The Truth

I was partly right. Layout is slow and takes a bunch of time — but, more specifically, it’s the NSStackViews (including a nested stack view). Changing the visibility priority of a view is especially expensive.

Setting up and tearing down the KVO observations isn’t a big deal, though. I was wrong about that. Updating the contents of a cell view is a big deal, however, and this is done when cells are recycled — but this is mainly a big deal because it triggers layout, which is slow.

So here’s what Instruments told me: NSStackView is slow (at least in our case).

But it also told me something else that completely surprised me: the app was spending a whole bunch of time setting and tearing down NSTrackingAreas.

Since I figured that would be the easier thing to deal with, I started there.

Highlight All the Things

When you move the mouse pointer around — in any app — you’ll notice the mouse cursor changing. The thing underneath the mouse pointer may highlight. Controls may appear and disappear.

One of my favorite examples of this in BBEdit: when the cursor is over the far left side of the window, the cursor becomes a right-pointing cursor, and when you drag-select it works by lines. (Paragraphs, effectively, if you’re writing prose.)

This — selecting-by-line — is a nice feature (that I wish Xcode had), and the change to the right-pointing cursor is what makes it discoverable. (Small world note: Jim Correia used to work on BBEdit, and now his office is a few steps away from mine, and he works on OmniFocus.)

In OmniFocus we highlight some controls, and unhide some other controls, when the mouse is over them. To do this we set up an NSTrackingArea — which tracks the movement of the mouse — for each of the different controls that get highlighted or unhidden.

There could be dozens of these, and the app ends up doing a surprising amount of work setting these up and tearing them down — which it has to do often while scrolling, as views go offscreen and come onscreen.

Turns out!

The Cure

Me: “Doctor, it hurts when I go like this.”

Doctor: “Well, don’t go like that.”

How about, instead of all these NSTrackingAreas, we use just one big one, and the area matches exactly the visible rect of the content outline view?

It seems like this would solve the problem by the simple fact that setting up one, once, has to be faster than setting and tearing dozens as you switch tabs and scroll. And it’s true: it is faster.

But it has a drawback: instead of a tracking area for each control that needs one — and the relatively simple code needed for this — we now have a single tracking area and we have to write code that detects what’s under the mouse and tells that view to do the right thing.

Which isn’t as hard as it sounds. Here are the highlights of the solution:

On the content outline view (an NSOutlineView subclass), we set up the single NSTrackingArea:

self.trackingArea = [[NSTrackingArea alloc] initWithRect:self.visibleRect options:NSTrackingMouseEnteredAndExited | NSTrackingMouseMoved | NSTrackingActiveAlways | NSTrackingInVisibleRect owner:self userInfo:nil];

Note the NSTrackingInVisibleRect part — that makes it so that it follows the visibleRect of the outline. This means we don’t have to continually reset the NSTrackingArea’s rect as the view is scrolled. Very convenient.

I added to the outline view a few methods that get called by mouse-tracking: mouseEntered:, mouseExited:, mouseMoved:, and cursorUpdate:.

These methods detect two things: the hovered-over cell view and the hovered-over control inside the view. Two properties — hoveredCellView and hoveredView — are maintained.

The mouseEntered: and similar messages take an NSEvent, and the event has a locationInWindow property which tells us where the mouse is. By using -[NSView convertPoint:fromView:] and NSPointInRect we can find out which view the mouse is over.

(It loops over the cell view’s subviews recursively to find the leaf view. Which is fast. It doesn’t sound fast, but it is.)

Not every view wants to highlight or unhide. I set up an informal protocol — a category on NSView — called OFIMouseTracking. It has several methods:

- (void)mouseTrackerDidEnter;
- (void)mouseTrackerDidExit;
- (BOOL)wantsMouseTrackerMessages;

The nice thing about informal protocols is that, since it’s really just a category, you can provide default implementations. In this case the first two are no-ops and the third returns NO. In other words, most views don’t care.

But the ones that do care respond YES from wantsMouseTrackerMessage, and they get the mouseTrackerDidEnter and mouseTrackerDidExit messages as the content outline view’s corresponding hoveredCellView and hoveredView properties change. And they do the right things (highlighting and unhighlighting; showing and hiding).

Piece of cake.

But What About the Dog’s Ear?

This all sounds great — but it wasn’t working entirely correctly. On the upper-right, above the status circle, is the flag button, which reminds us of a dog’s ear, so we call it a dog’s ear. (That’s life here in the lab.) That flag button should highlight not when the mouse is over the view — which is a rectangle, as all views are — but when it’s over the view and inside the actual dog’s ear shape.

What to do?

I figured that, since it was already working before, when we had lots of NSTrackingAreas, we must have already solved the problem, because the problem’s the same whether it’s one big tracking area or a whole bunch of small ones. The solution has to be that we’re checking to see if the mouse is within the bounds of a specific bezier path.

I was right: we had solved the problem, with a class called OFIBoundsChecker. It’s a very small class that takes a block that generates the path on demand, and has a method — -[OFIBoundsChecker isPoint:inVirtualBoundsForView:] — that returns YES if the given point (the converted mouse location) is inside the dog’s ear (or whatever) path.

All that stuff had already been set up for the views that were already using it. Cool.

So I added a fourth method to the OFIMouseTracking informal protocol:

- (OFIBoundsChecker *)mouseTrackerBoundsChecker;

Back to the outline view: when it’s checking to see if the mouse is over a view, and it determines that yes, it is, it then asks the view for its mouseTrackerBoundsChecker. If it returns nil, then it just uses the view’s rectangle. But if it returns a bounds checker, then it asks the bounds checker if the mouse is inside, and then does the right thing with maintaining the hoveredView property.

And now we have a functioning dog’s ear. Whew.

Next Steps

In the end, this amounts to a modest-but-respectable performance enhancement when scrolling and switching tabs. It’s expected to ship as part of OmniFocus 2.2.4.

While this was totally worth doing, it’s not the whole story, and the bug is still open. The next step will probably be to swap out the NSStackViews in the content outline for something faster.

(But I can’t promise that that exact change will happen, or when. There is always so much work to do, and, in software, everything is provisional until shipping.)

Bonus

I also did a bunch of micro-optimizations. Sometimes if you string enough of these together they add up to a noticeable improvement. In this case, I think they help, but the NSTrackingArea change is the big one.

One of my favorite micro-optimizations was utterly simple. I changed code of this form…

[cachedObjects enumerateObjectsUsingBlock:^(ODOObject *oneObject, BOOL *stop) {
    if (![predicate evaluateWithObject:oneObject]) {
        [nonMatchingObjects addObject:oneObject];
        }
    }];

…to…

for (ODOObject *oneObject in cachedObjects]) {
    if (![predicate evaluateWithObject:oneObject]) {
        [nonMatchingObjects addObject:oneObject];
    }
}

Why would I do such a thing? Don’t we all love blocks? Aren’t loops old-school?

Here’s why: this simple change took the enclosing method’s time from 1.5% to 0.7% of time spent during tab switching.

Totally worth it.

And, if you ask me, the loop version has fewer entities and is easier to read, and I like that.

(For extra credit, read Mike Ash’s Friday Q&A 2010-04-09: Comparison of Objective-C Enumeration Techniques.)

P.S. To Swift fans: yes, that might be even more readable as let nonMatchingObjects = cachedObjects.filter { !predicate.evaluateWithObject($0) }. But Swift at Omni is a whole other topic. (Spoiler: there is some!)