7

This might be an awful bug in iOS 9.3 (release).

When adding a single observer to [NSUserDefaults standardUserDefaults] I've noticed that the responding method -observeValueForKeyPath:ofObject:change:context: is called multiple times.

In the simple example below, every time a UIButton is pressed once, observeValueForKeyPath fires twice. In more complicated examples it fires even more times. It is only present on iOS 9.3 (both on sim and devices).

This can obviously wreak havoc on an app. Anyone else experiencing the same?

// ViewController.m (barebones, single view app)

- (void)viewDidLoad {
    [super viewDidLoad];
    NSLog(@"viewDidLoad");
    [[NSUserDefaults standardUserDefaults] addObserver:self forKeyPath:@"SomeKey" options:NSKeyValueObservingOptionNew context:NULL];
}

- (IBAction)buttonPressed:(id)sender {
    NSLog(@"buttonPressed");
    [[NSUserDefaults standardUserDefaults] setInteger:1 forKey:@"SomeKey"];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSString *,id> *)change context:(void *)context {
    NSLog(@"observeValueForKeyPath: %@", keyPath);
} 
rmaddy
  • 314,917
  • 42
  • 532
  • 579
Matt
  • 2,329
  • 1
  • 21
  • 23
  • Is NSUserDefaults key value observable? I see no evidence that it is. You were doing something you had no warrant to do. You cannot complain if it stops working. – matt Mar 24 '16 at 04:58
  • @matt I did not consider that. However, looking into it I found the following in NSUserDefaults.h: `NSUserDefaults can be observed using Key-Value Observing for any key stored in it.` – Matt Mar 24 '16 at 06:06
  • @Matt, Am I looking in the same `NSUserDefaults.h`? I can't find the comment you posted in this header. – Borys Verebskyi Mar 24 '16 at 09:23
  • @BorisVerebsky what version of Xcode are you checking? Maybe it's new in Xcode 7.3/iOS 9.3. – Matt Mar 24 '16 at 15:22
  • @Matt, XCode 7.2, will double check with XCode 7.3/iOS 9.3 – Borys Verebskyi Mar 24 '16 at 15:24
  • I find explicit statements that key-value observing is legal for particular properties in certain AVFoundation headers such as AVCaptureDevice etc. But I do not find the word "observed" in _any_ NSUserDefaults header. I would be very surprised if KVO was legal on NSUserDefaults, because KVO involves swizzling, and are we really going to swizzle the shared user defaults singleton object? – matt Mar 24 '16 at 15:48
  • @matt did you check iOS 9.3 headers? It looks like Apple has done some work on NSUserDefaults. For instance the header also mentions Shared iPad for Students mode (new in 9.3). They've also marked -synchronize as deprecated. – Matt Mar 24 '16 at 16:02
  • I've been getting the same thing, even with Xcode 7.3.1 beta :( – Omar Apr 24 '16 at 03:31
  • I have the same problem. I also checked header of NSUserDefaults in XCode 7.3 and XCode 7.3.1 and KVO theoretically should be working "NSUserDefaultsDidChangeNotification is posted whenever any user defaults changed within the current process, but is not posted when ubiquitous defaults change, or when an outside process changes defaults. Using key-value observing to register observers for the specific keys of interest will inform you of all updates, regardless of where they're from." Even in playground I tested that observeValueForKeyPath is called twice. – sliwinski.lukas May 04 '16 at 08:25

3 Answers3

5

Yes I am experiencing this as well and it seems to be a bug, below is a quick workaround I’m using for the moment until this is fixed. I hope it helps!

Also to clarify, since iOS 7 KVO has been working great with NSUserDefaults and it certainly appears to be key value observable as Matt stated, it is explicitly written in NSUserDefaults.h in the iOS 9.3 SDK: “NSUserDefaults can be observed using Key-Value Observing for any key stored in it."

#include <mach/mach.h>
#include <mach/mach_time.h>

@property uint64_t newTime;
@property uint64_t previousTime;
@property NSString *previousKeyPath;

-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    //Workaround for possible bug in iOS 9.3 SDK that is causing observeValueForKeyPath to be called multiple times.
    newTime = mach_absolute_time();
    NSLog(@"newTime:%llu", newTime);
    NSLog(@"previousTime:%llu", previousTime);

    //Try to avoid duplicate calls
    if (newTime > (previousTime + 5000000.0) || ![keyPath isEqualToString:previousKeyPath]) {
        if (newTime > (previousTime + 5000000.0)) {
            NSLog(@"newTime > previousTime");
            previousTime = newTime;
            NSLog(@"newTime:%llu", newTime);
            NSLog(@"previousTime:%llu", previousTime);
        }
        if (![keyPath isEqualToString:previousKeyPath]) {
            NSLog(@"new keyPath:%@", keyPath);
            previousKeyPath = keyPath;
            NSLog(@"previousKeyPath is now:%@", previousKeyPath);
        }
        //Proceed with handling changes
        if ([keyPath isEqualToString:@“MyKey"]) {
            //Do something
        }
    }
}
Borys Verebskyi
  • 4,160
  • 6
  • 28
  • 42
seeahr
  • 51
  • 1
  • 1
    Great answer. Works for me! Note how your @“MyKey" contains a weird quote after @ that Xcode is complaining about – Omar Apr 24 '16 at 03:41
  • This does not work in swift 4 and iOS 11.4. Multiple changes are still fired. – Don Miguel Oct 19 '18 at 08:56
2

When adding a single observer to [NSUserDefaults standardUserDefaults] I've noticed that the responding method -observeValueForKeyPath:ofObject:change:context: is called multiple times

This is a known issue and is reported (by Apple) as fixed in iOS 11 and macOS 10.13.

Martin R
  • 529,903
  • 94
  • 1,240
  • 1,382
matt
  • 515,959
  • 87
  • 875
  • 1,141
  • hi, where is doc? – frank Sep 14 '17 at 12:52
  • @frank https://developer.apple.com/library/content/releasenotes/Foundation/RN-Foundation/index.html "This should also correct duplicate notifications of some changes being delivered to observers" — exactly the behavior complained of by the OP. – matt Sep 14 '17 at 14:38
  • Thanks for your reply. – frank Sep 19 '17 at 06:12
  • 1
    I don't think this issue is fixed in MacOS 10.13. My observer is being called 3 times for 1 button click that sets a value in NSUserDefaults. I have verified that the button is only firing once, that the value is set only once and that I registered only once. Checked tried this by building against deployment target 10.13. Makes no difference. – Cliff Ribaudo Oct 02 '17 at 16:41
  • 1
    @CliffRibaudo Thanks for reporting that. Please file a bug report with Apple! I can't be sure that the release notes intend to address this issue, but it sure looks like it. If they have not fixed this, they need to know about it in any case. – matt Oct 02 '17 at 16:51
  • I noticed that the bug was fixed in iOS 12 but still am experiencing it on iOS 11.4 simulators. Didn't check on the phone though. – Valentin Mercier Aug 24 '18 at 12:32
  • This still happens on iOS 11 simulator (6S) and iPhone 6S running 11.4 as well. – Don Miguel Oct 19 '18 at 08:26
  • Seems to be working on iOS 12 but seems to be broken again on iOS 14.2 – bencallis Jan 11 '21 at 17:53
0

Adding this answer for MacOS (10.13) which definitely has the bug getting multiple notifications for KVO of NSUserDefault Keys, and which also addresses deprecations. It is better to use a calculation for elapsed nano seconds that gets it for the machine you are running on. Do it like so:

#include <mach/mach.h>
#include <mach/mach_time.h>
static mach_timebase_info_data_t _sTimebaseInfo;

uint64_t  _newTime, _previousTime, _elapsed, _elapsedNano, _threshold;
NSString  *_previousKeyPath;

-(BOOL)timeThresholdForKeyPathExceeded:(NSString *)key thresholdValue:(uint64_t)threshold
{
   _previousTime = _newTime;
   _newTime = mach_absolute_time();

    if(_previousTime > 0) {
        _elapsed = _newTime - _previousTime;
        _elapsedNano = _elapsed * _sTimebaseInfo.numer / _sTimebaseInfo.denom;
    }

    if(_elapsedNano > threshold || ![key isEqualToString:_previousKeyPath]) {
        if(![key isEqualToString:_previousKeyPath]) _previousKeyPath = key;
            return YES;
        }
        return NO;
    }
}

-(void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
    if(![self timeThresholdForKeyPathExceeded:keyPath thresholdValue:5000000]) return;  // Delete this line of MacOS bug ever fixed
    }
    // Else this is the KeyPath you are looking for Obi Wan, process it.
}

This is based on Listing 2 of this Apple Doc: https://developer.apple.com/library/content/qa/qa1398/_index.html

Cliff Ribaudo
  • 8,932
  • 2
  • 55
  • 78