Singleton 属性 returns 不同的值取决于调用

Singleton property returns different values depending on invocation

背景

在我的应用程序中,我 class 调用了 FavoritesController 来管理 object 用户标记为收藏夹的内容,然后在整个应用程序中使用此收藏夹状态。 FavoritesController 被设计为单例 class 因为整个应用程序中有许多 UI 元素需要知道 'favorite status' 用于 object 在不同的地方,还有网络请求需要能够发出信号,表明收藏夹需要在服务器要求时失效。

当服务器响应 404 错误时会发生此失效部分,表明必须从用户的收藏夹中删除收藏夹 object。网络获取功能抛出错误,触发 FavoritesController 删除 object,然后向感兴趣的各方发送通知,告知他们需要刷新。

问题

当使用单元测试检查 404 实现的质量时,所有方法都按预期触发 - 错误被抛出并被捕获,FavoritesController 删除 object 并发送通知。但在某些情况下,已删除的收藏夹仍然存在 – 但这取决于从何处完成查询!

如果我在单例中查询,删除就成功了,但如果我从使用单例的 class 查询,删除就不会发生。

设计细节

其他信息

我尝试过的步骤

代码

FavoritesController 主要方法

- (void) serverCanNotFindFavorite:(NSInteger)siteID {

    NSLog(@"Server can't find favorite");
    NSDictionary * removedFavorite = [NSDictionary dictionaryWithDictionary:[self favoriteWithID:siteID]];
    NSUInteger index = [self indexOfFavoriteWithID:siteID];
    [self debugLogFavorites];

    dispatch_async(dispatch_get_main_queue(), ^{

        [self removeFromFavorites:siteID completion:^(BOOL success) {
            if (success) {
                NSNotification * note = [NSNotification notificationWithName:didRemoveFavoriteNotification object:nil userInfo:@{@"site" : removedFavorite, @"index" : [NSNumber numberWithUnsignedInteger:index]}];
                NSLog(@"Will post notification");
            
                [self debugLogFavorites];
                [self debugLogUserDefaultsFavorites];
                [[NSNotificationCenter defaultCenter] postNotification:note];
                NSLog(@"Posted notification with name: %@", didRemoveFavoriteNotification);
            }
        }];
    });

}

- (void) removeFromFavorites:(NSInteger)siteID completion:(completionBlock) completion {
    if ([self isFavorite:siteID]) {
        NSMutableArray * newFavorites = [NSMutableArray arrayWithArray:self.favorites];
    
        NSIndexSet * indices = [newFavorites indexesOfObjectsPassingTest:^BOOL(NSDictionary * entryUnderTest, NSUInteger idx, BOOL * _Nonnull stop) {
            NSNumber * value = (NSNumber *)[entryUnderTest objectForKey:@"id"];
            if ([value isEqualToNumber:[NSNumber numberWithInteger:siteID]]) {
                return YES;
            }
            return NO;
        }];
    
        __block NSDictionary* objectToRemove = [[newFavorites objectAtIndex:indices.firstIndex] copy];
    
        dispatch_async(dispatch_get_main_queue(), ^{
            NSLog(@"Will remove %@", objectToRemove);
            [newFavorites removeObject:objectToRemove];
            [self setFavorites:[NSArray arrayWithArray:newFavorites]];

            if ([self isFavorite:siteID]) {
                NSLog(@"Failed to remove!");
            
                if (completion) {
                    completion(NO);
                }
            } else {
                NSLog(@"Removed OK");
                
                if (completion) {
                    completion(YES);
                }
            }
        });
    
    } else {
        NSLog(@"Tried removing site %li which is not a favorite", (long)siteID);
        if (completion) {
            completion(NO);
        }
    }
}

- (NSArray *) favorites
{
    @synchronized(self) {
        if (!internalFavorites) {
            static dispatch_once_t onceToken;
            dispatch_once(&onceToken, ^{
                self->internalFavorites = [self.defaults objectForKey:k_key_favorites];
            });
            if (!internalFavorites) {
                internalFavorites = [NSArray array];
            }
        }
        
        return internalFavorites;
    }

}

- (void) setFavorites:(NSArray *)someFavorites {

    @synchronized(self) {
        internalFavorites = someFavorites;
    [self.defaults setObject:internalFavorites forKey:k_key_favorites];
    }


}

- (void) addToFavorites:(NSInteger)siteID withName:(NSString *)siteName {
    if (![self isFavorite:siteID]) {
        NSDictionary * newFavorite = @{
                                       @"name"  : siteName,
                                       @"id"    : [NSNumber numberWithInteger:siteID]
                                   };
        dispatch_async(dispatch_get_main_queue(), ^{
            NSArray * newFavorites = [self.favorites arrayByAddingObject:newFavorite];
            [self setFavorites:newFavorites];

        });
    
        NSLog(@"Added site %@ with id %ld to favorites", siteName, (long)siteID);
    
    } else {
        NSLog(@"Tried adding site as favorite a second time");
    }
}

- (BOOL) isFavorite:(NSInteger)siteID
{
 
    @synchronized(self) {
        
        NSNumber * siteNumber = [NSNumber numberWithInteger:siteID];
        NSArray * favs = [NSArray arrayWithArray:self.favorites];
        if (favs.count == 0) {
            NSLog(@"No favorites");
            return NO;
        }
        
        NSIndexSet * indices = [favs indexesOfObjectsPassingTest:^BOOL(NSDictionary * entryUnderTest, NSUInteger idx, BOOL * _Nonnull stop) {
            if ([[entryUnderTest objectForKey:@"id"] isEqualToNumber:siteNumber]) {
                return YES;
            }
            
            return NO;
        }];
        
        if (indices.count > 0) {
            return YES;
        }
    }
    
    return NO;
}

FavoritesController 的单例实现

- (instancetype) init {
    static PKEFavoritesController *initedObject;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        initedObject = [super init];
        self.defaults = [NSUserDefaults standardUserDefaults];
    });
    return initedObject;
}

+ (instancetype) sharedController
{
    return [self new];
}

单元测试代码

func testObsoleteFavoriteRemoval() {
    
    let addToFavorites = self.expectation(description: "addToFavorites")
    let networkRequest = self.expectation(description: "network request")
    
    unowned let favs = PKEFavoritesController.shared()
    favs.clearFavorites()
    
    XCTAssertFalse(favs.isFavorite(313), "Should not be favorite initially")
    
    if !favs.isFavorite(313) {
        NSLog("Adding 313 to favorites")
        favs.add(toFavorites: 313, withName: "Skatås")
    }
    
    let notification = self.expectation(forNotification: NSNotification.Name("didRemoveFavoriteNotification"), object: nil) { (notification) -> Bool in
        NSLog("Received notification: \(notification.name.rawValue)")

        return true
    }
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        NSLog("Verifying 313 is favorite")
        XCTAssertTrue(favs.isFavorite(313))
        addToFavorites.fulfill()
    }
    
    self.wait(for: [addToFavorites], timeout: 5)
    
    NSLog("Will trigger removal for 313")
    let _ = SkidsparAPI.fetchRecentReports(forSite: 313, session: SkidsparAPI.session()) { (reports) in
        NSLog("Network request completed")
        networkRequest.fulfill()
    }
    

    self.wait(for: [networkRequest, notification], timeout: 10)

    XCTAssertFalse(favs.isFavorite(313), "Favorite should be removed after a 404 error from server")
    
}

为了给出我的答案的上下文,这是建议更改时相关代码的样子:

- (NSArray *)favorites {
    @synchronized(internalFavorites) {
        if (!internalFavorites) {
            static dispatch_once_t onceToken;
            dispatch_once(&onceToken, ^{
                internalFavorites = [self.defaults objectForKey:k_key_favorites];
            });
            if (!internalFavorites) {
                internalFavorites = [NSArray array];
            }
        }
    }

    return internalFavorites;
}

我对 @synchronized(internalFavorites) 之后的检查 if (!internalFavorites) { 表示怀疑,因为这意味着期望 @synchronized 通过 nilresults in a noop.

这意味着对 favoritessetFavorites 的多次调用可能会以有趣的方式发生,因为它们实际上不会同步。给 @sychronized 一个要同步的实际对象对于线程安全至关重要。在 self 上同步很好,但是对于特定的 class,您必须小心不要在 self 上同步太多东西,否则您将不可避免地造成不必要的阻塞。为 @sychronized 提供简单的 NSObjects 是缩小保护范围的好方法。

以下是避免使用 self 作为锁定的方法。

- (instancetype)init {
    static PKEFavoritesController *initedObject;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        initedObject = [super init];
        self.lock = [NSObject new];
        self.defaults = [NSUserDefaults standardUserDefaults];
    });
    return initedObject;
}

+ (instancetype)sharedController {
    return [self new];
}

- (NSArray *)favorites {
    @synchronized(_lock) {
        if (!internalFavorites) {
            static dispatch_once_t onceToken;
            dispatch_once(&onceToken, ^{
                self->internalFavorites = [self.defaults objectForKey:k_key_favorites];
            });
            if (!internalFavorites) {
                internalFavorites = [NSArray array];
            }
        }
    }

    return internalFavorites;
}

关于测试运行之间的异常,在NSUserDefaults上调用synchronize肯定会有帮助,因为更改默认值的调用是异步的,这意味着涉及其他线程。也有大约 3 层缓存,特别是为了 运行 宁测试的目的 synchronize 应该确保在 Xcode 停止测试之前事情已经完全干净地提交 运行。文档非常突然地坚持认为这不是必要的调用,但如果确实没有必要,它就不会存在 :-)。在我的第一个 iOS 项目中,我们总是在每次默认值更改后调用 synchronize...因此,我认为文档对 Apple 工程师来说更有吸引力。很高兴这种直觉对你有所帮助。