My new project: Tact, a simple chat app.

NSUserDefaults, default values, and app extensions

November 09, 2015

I just fixed a dumb bug in one of my apps, Prillikivi. It’s a content blocker app, so it has two components: the main app, and the content blocker extension. I made a silly and obvious mistake in how they communicate using NSUserDefaults. Thanks to generous testing help from my users, I was able to conclusively identify, trace and fix it.

It’s one of those mistakes that’s so dumb that it makes you question your whole existence and sanity of being a developer. “If I blunder with basic stuff like this, how can I be any good at anything at all?” I’ll feel better after writing this up, though, so that at least other people, when in the same situation, can google for this and find the cause and fix more quickly :)

The basics

By design, apps and extensions in iOS run in separate processes and can’t directly access each other’s memory or storage, so they need clear mechanisms to talk to each other, communicate user’s actions and preferences. One of these mechanisms is good old NSUserDefaults. You can initialize a shared defaults container in both the app and the extension by having this call in both of them:

let defaults = NSUserDefaults(suiteName: "group.com.example.Yourapp")

Now, when either the app or extension sets a value in the defaults, it’s available to both components. This was all working.

Heading for disaster

NSUserDefaults has a handy concept of “registering defaults”. You can call its registerDefaults API with some values that you wish to be the initial default values. If your code doesn’t set any other values, the values that you set with registerDefaults are used. If your code sets values explicitly, those will override the defaults.

So, in my applicationDidFinishLaunchingWithOptions callback, I had something like this…

let defaults = NSUserDefaults(suiteName: "group.com.example.Myapp")
defaults.registerDefaults([someKey: someValue, otherKey: otherValue])

Then, in my extension’s code in a function that does something useful, I had this check:

let defaults = NSUserDefaults(suiteName: "group.com.example.Myapp")
guard let enabledSomething = defaults.objectForKey(someKey) else { return }

Now, this should never fail because I register the default above, right? If I have set the value somewhere in code explicitly, that value gets used, and if I haven’t set it explicitly, the value that I registered with the above method gets used, right?

And yet, it kept failing the guard condition and bailing out. Why?

Have you figured out the bug by this point just by looking at this info? If so, congratulations. You are smarter than me. You can stop reading and go do something more useful.

For others, though, let’s keep going. So I assume that the value that I register in applicationDidFinishLaunching is available to the extension, but it clearly is not.

Time to look at the documentation for registerDefaults, which says… (emphasis mine)

The contents of the registration domain are not written to disk; you need to call this method each time your application starts.

And there you have it. To make my blunder really obvious, it should say “… are not written to disk, nor are they somehow magically made available to other instances of NSUserDefaults in other processes that happen to be connected to the same app group suite.”

So. When you call registerDefaults in the main app, the user defaults object in your extension doesn’t know anything about that. These defaults objects are connected to the same storage on disk, but they are completely separate in memory, and registerDefaults only affects what’s going on in the memory of one process.

“Of course,” you say. “This is all obvious from the documentation. Why would you even think that registerDefaults in one process affects the other process?”

Well, such is life. You become overconfident and don’t check your assumptions often and deeply enough, and are then bitten by that in the form of such a silly bug.

It’s also about my assumptions and expectations as a developer. I assumed that registerDefaults has the same behavior as, say, setObject:forKey:. The first one doesn’t work across processes, and the second one does (well, it goes through the persistence layer, but the end result is that change made in one process propagates to the other process.) Was it a reasonable assumption that registerDefaults would also work across processes like that? Maybe it should? Who’s to say? I don’t know. In any case, I learned something new, fixed my invalid assumption and stand corrected.

The fix

Now that you know the above, the fix is obvious. Well, there’s many ways to fix this, but the simplest one that I can think of is to simply have another registerDefaults call in the app extension with the desired initial values, before using the defaults in the extension. Maybe it could be a shared piece of code between the app and the extension, but it doesn’t have to be, since often the app and extension actually care about separate keys, and there’s only a small subset that they share, so they might both want to register different initial values.