框架架构:指定 NSBundle 在单例初始化期间加载 .plist 文件

Framework Architecture: Specify NSBundle to load .plist file from during singleton init

tl;dr

如何创建一个能够在初始化期间从特定位置(不是从框架的包)读取.plist的单例(它是框架的一部分) ?

解决方案发布在下方并基于已接受的答案。

设置说明

我的 iOS 应用程序使用专有的 UsefulKit.framework 所有通用代码都在其中。

框架有一个ConfigurationManager(Singleton),负责从.plist加载一些设置(例如Base URL、API Keys等)在初始化期间 (RAII) 并向有兴趣读取应用程序范围设置的其他组件提供 + (id)valueForKey:(NSString *)key; API。

ConfigurationManager 存储一个默认名称 .plist,它希望在初始化期间加载(请参阅下面的问题 #3),即 EnvironmentConfiguration-Default.plist

经理从 [NSBundle bundleForClass:[self class]] 加载 .plist,在 经理成为 [=17= 的一部分之前它曾经工作正常 ].当它是主应用程序的一部分时,它在同一个包中有各自的 .plist 并且能够通过名称找到它。请参阅下面 ConfigurationManager.m 中的代码。

NSString * const kDefaultEnvironmentConfigurationFileName = @"EnvironmentConfiguration-Default";

@interface ConfigurationManager ()

@property (nonatomic, strong) NSMutableDictionary *environmentInfo;

@end

@implementation ConfigurationManager

+ (instancetype)sharedInstance {
    static ConfigurationManager *sharedEnvironment;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        if (!sharedEnvironment) {
            sharedEnvironment = [self new];
        }
    });
    return sharedEnvironment;
}

- (instancetype)init {
    self = [super init];
    if (self) {
        self.environmentInfo = [NSMutableDictionary new];
        [self loadEnvironment];
    }
    return self;
}

- (void)loadEnvironment {
    [self.environmentInfo removeAllObjects];
    [self loadDefaultEnvironmentConfiguration];
}

- (void)loadDefaultEnvironmentConfiguration {
    NSBundle* bundle = [NSBundle bundleForClass:[self class]];
    NSString *defaultPlistPath = [bundle pathForResource:kDefaultEnvironmentConfigurationFileName ofType:@"plist"];

    assert(defaultPlistPath != nil); // <=== code crashes here

    //
    // processing the plist file here...
    //
}

// ...
// some code omitted
// ...

@end

问题

现在,当它是一部分时 UsefulKit.framework 该方法不起作用。如果我将 EnvironmentConfiguration-Default.plist 与框架捆绑在一起,它就会工作,但我不想这样做,因为可能使用该框架的应用程序之间的配置不同。应用程序必须具有相应的 .plist 并使用框架的 ConfigurationManager 来访问设置。

此代码也不适用于框架 Xcode 项目中的单元测试目标。我将 EnvironmentConfiguration-Default.plist 文件放入测试目标包中并编写了这个单元测试:

- (void)testConfigurationManagerInstantiation  {
    [ConfigurationManager sharedInstance];
}

...代码在 -loadDefaultEnvironmentConfiguration 处崩溃(见上文)。

调试上述方法我看到这个:

- (void)loadDefaultEnvironmentConfiguration {
    NSBundle* bundle = [NSBundle bundleForClass:[self class]];

    // Printing description of bundle:
    // NSBundle </Users/admin/Library/Developer/Xcode/DerivedData/MyWorkspace-asazpgalibrpubbrimxpbrebqdww/Build/Products/Debug-iphonesimulator/UsefulKit.framework> (loaded)

    NSString *defaultPlistPath = [[NSBundle bundleForClass:[self class]] pathForResource:kDefaultEnvironmentConfigurationFileName ofType:@"plist"];

    // Printing description of defaultPlistPath:
    // <nil>

这个包绝对不是我的 .plist 所在的包。所以,我开始怀疑我在架构上做错了什么。

问题

  1. 由于 ConfigurationManager 使用单例模式构建,我无法通过 Constructor Injection 注入包。事实上,我想不出任何类型的依赖注入会起到 "nicely" 的作用。我想念什么吗?可能是 static 客户端应用分配路径的 var?

  2. 框架可以在内部搜索一些其他包吗?

  3. EnvironmentConfiguration-Default.plist 的名称被硬编码到 ConfigurationManager 的内部,这对我来说很奇怪,b/c 其他开发人员必须知道它并进行设置,但是,我在许多第 3 方框架(GoogleAnalytics、UrbanAirhip、Fabric)中看到了这种情况,框架希望在特定位置找到 .plist(框架版本之间通常不同)。因此,开发人员应该阅读文档并准备环境作为框架集成的一部分。

欢迎提出任何更改架构的建议。

解决方案

以下内容高度基于 @NSGod 发布的建议,非常感谢!我会将这种方法称为某种(静态的?)依赖注入。

ConfigurationManager.m:

static NSBundle * defaultConfigurationBundle = nil;

@implementation ConfigurationManager

+ (void)initialize {
    if (self == [ConfigurationManager class]) {
        /// Defaults to main bundle
        [[ConfigurationManager class] setDefaultConfigurationBundle:[NSBundle mainBundle]];
    }
}

+ (instancetype)sharedInstance {
    static ConfigurationManager *sharedEnvironment;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        if (!sharedEnvironment) {
            sharedEnvironment = [self new];
        }
    });
    return sharedEnvironment;
}

+ (void)setDefaultConfigurationBundle:(NSBundle *)bundle {
    @synchronized(self) {
        defaultConfigurationBundle = bundle;
    }
}
// ...
@end

ConfigurationManager.h:

@interface ConfigurationManager : NSObject

// ...

/**
 @brief Specify default NSBundle, other than [NSBundle mainBundle] (which is used, otherwise) where .plist configuration file is expected to be found during initialization.
 @discussion For some purpose (e.g. unit-testing) there might be cases, where forcing other NSBundle usage is required. The value, assigned in this method might be [NSBundle bundleForClass:[self class]], to get the bundle for caller.
 @attention This method must be called before any other method in this class for assignment to take effect, because default bundle setup happens  during class instantiation.
 @param An NSBundle to read Default .plist from.
 */
+ (void)setDefaultConfigurationBundle:(NSBundle *)bundle;

// ...
@end

在调用站点上:

@implementation ConfigurationManagerTests

- (void)setUp {
    [super setUp];

    /// Prepare test case with correct bundle
    [ConfigurationManager setDefaultConfigurationBundle:[NSBundle bundleForClass:[self class]]];
}

- (void)testConfigurationManagerInstantiation  {
    // call sequence:
    // 1. +initialize
    // 2. +setDefaultConfigurationBundle
    // 3. +sharedInstance
    XCTAssertNoThrow([ConfigurationManager sharedInstance]);
}
// ...
@end

该方法允许简化应用程序目标的框架使用(mainBundle.plist 所在的位置),因此到目前为止,仅单元测试需要 +setDefaultConfigurationBundle

其实仔细想想,如果想让框架和应用程序通信,只需要改一行代码:

- (void)loadDefaultEnvironmentConfiguration {
    // NSBundle* bundle = [NSBundle bundleForClass:[self class]];
    NSBundle* bundle = [NSBundle mainBundle];

ConfigurationManager class 是您的主应用程序的一部分时,[NSBundle bundleForClass:[self class]] return 编辑了主应用程序包(即与 returned by [NSBundle mainBundle]。当你将 ConfigurationManager class 移动到一个框架(也可以被认为是一个包)时,[NSBundle bundleForClass:[self class]] 开始 returning NSBundle 用于您的框架而不是主应用程序包。

当您从您的框架内调用 [NSBundle mainBundle] 时,它将 return 任何正在使用该框架的应用程序。

或者,您可以使用 class 方法来设置将在初始化期间使用的默认值。

比如在你的ConfigurationManagerclasspublic界面中:

@interface ConfigurationManager : NSObject

+ (void)setDefaultConfigurationPath:(NSString *)aPath;

@end

ConfigurationManager.m中:

static NSString *defaultConfigurationPath = nil;

@implementation ConfigurationManager

+ (void)setDefaultConfigurationPath:(NSString *)aPath {
     @synchronized(self) {
         defaultConfigurationPath = aPath; 
     }
}
// additional methods
- (void)loadDefaultEnvironmentConfiguration {
    NSDictionary *dic = [NSDictionary
                 dictionaryWithContentsOfFile:defaultConfigurationPath];


    //
    // processing the plist file here...
    //
}

@end

通过声明 defaultConfigurationPath static,您可以使它成为一个 "class" 变量而不是实例变量。因此,您甚至在创建 class 的实例之前就使用 class 方法更改其值。我相信代码应该和 ARC 一样工作,尽管我不是很肯定(我自己仍然习惯手动引用计数)。

您的主应用程序应确保在任何人调用 [ConfigurationManager sharedInstance] 之前使用正确的路径调用 [ConfigurationManager setDefaultConfigurationPath:]。执行此操作的最佳位置是应用程序委托的 +initialize 方法,这是最先调用的方法之一:

+ (void)initialize {
   NSString *path; // get path for plist
   [ConfigurationManager setDefaultConfigurationPath:path];
}