
Managing dependencies correctly in iOS apps: Typhoon Tips & Tricks

In the previous parts of the series, we examined the main aspects of Typhoon's work and prepared for its full practical application. However, in addition to the topics covered, the framework provides a large number of other functions.
In this article, we will consider the following features of the Typhoon Framework:
- Auto Injection (also known as autowiring)
- Automatic selection from alternative implementations of one TyphoonDefinition ,
- Features of working with TyphoonConfig
- Using TyphoonPatcher to organize integration tests ,
- Using runtime attributes when creating objects,
- Implementation of factories based on TyphoonAssembly ,
- Postprocessing objects created with Typhoon,
- Tools for writing asynchronous tests .
The cycle "Managing Dependencies in iOS Applications Correctly"
- Introducing Typhoon
- Typhoon device
- Modularity Typhoon
- Typhoon Tips & Tricks
- Alternatives to Typhoon
- (Optional) Rambler.iOS # 3. Dependency Injection on iOS. Slides
- (Optional) Rambler.iOS # 3. Dependency Injection on iOS. Video
Autoinjection / Autowire
Often, especially in small projects, there is not enough time to implement a full controller-level TyphoonAssembly layer, while the service layer is already ready. In this case, it may be appropriate to use auto-injections, also known as autowiring. Let's look at a simple example of the configuration of the email message viewing screen:
@interface RCMMessageViewController: UIViewController
#import
@protocol RCMMessageService;
@class RCMMessageRendererBase;
@interface RCMMessageViewController : UIViewController
@property (strong, nonatomic) InjectedProtocol(RCMMessageService) messageService;
@property (strong, nonatomic) InjectedClass(RCMMessageRendererBase) renderer;
@end
And the only TyphoonAssembly currently in the application specified in Info.plist :
@implementation RCMHelperAssembly
@implementation RCMHelperAssembly
- (RCMMessageRendererBase *)messageRenderer {
return [TyphoonDefinition withClass:[RCMMessageRendererBase class]];
}
- (id )messageService {
return [TyphoonDefinition withClass:[RCMMessageServiceBase class]];
}
@end
Let's try to run the application with this configuration:

As you can see, the necessary dependencies were substituted automatically. I draw attention to the fact that when using auto-injection, you do not need to write a method that returns TyphoonDefinition to the ViewController . It is also worth noting that this approach only works when creating a UIViewController from TyphoonStoryboard .
A similar approach can be used when writing integration tests - instead of manually creating dependencies of the tested object, you can automatically substitute them from a specific TyphoonAssembly :
@interface RCMMessageServiceBaseTests: XCTestCase
#import
@interface RCMMessageServiceBaseTests : XCTestCase
@property (nonatomic, strong) InjectedProtocol(RCMMessageService) messageService;
@end
@implementation RCMMessageServiceBaseTests
- (void)setUp {
[super setUp];
[[[RCMServiceComponentsAssemblyBase new] activate] inject:self];
}
- (void)testThatServiceObtainsMessage {
// ...
}
@end
Similarly, dependencies are substituted into a UIViewController , created manually or from xib.
Like any technology, autowire has its advantages:
- Save time by not having to implement some assembly,
- More informative object interfaces - you can immediately see which dependencies are substituted using Typhoon, which ones are independent,
- If any of the automatically substituted object dependencies is not found in the factory, crash will happen immediately (in the case of manual substitution, this may go unnoticed at all).
and disadvantages:
- Typhoon binding goes beyond assembly to specific classes,
- Having looked at the structure of the TyphoonAssembly modules of a project, you cannot judge its architecture as a whole.
The rule of good code that we developed at Rambler & Co is better to spend some time and prepare well-structured modules of the Presentation level, which will contain definitions for all ViewControllers, and use autowire features only in integration tests. The presence of a well-documented project structure using TyphoonAssembly greatly exceeds all the advantages of auto-injection.
TyphoonDefinition + Option
In a previous article, we examined an example of using two different implementations of the same TyphoonAssembly - combat and fake. However, sometimes this approach is tantamount to shooting sparrows from cannons - and Typhoon provides us with much more elegant ways to solve the problem.
Consider another case from Rambler . Mail :
The QA team asked to add a special debug menu to the application that allows you to work with logs, find out the current build number and other similar things. The settings screen is a table that is assembled from the ViewModel collection by a separate class RCMSettingsConfigurator. This class has two implementations - Base and Debug, which are included by the corresponding build scheme. We faced a choice of three options for implementing this task:
- Create a configurator manually using #ifdefs defining the values of the preprocessor directive,
- Write two implementations of assembly, creating objects for user story settings,
- Use the TyphoonDefinition + Option category .
The first option, of course, is not the choice of real ninjas (well, that’s not the point, actively use #ifdef 's in the code of the mobile application). The second option - this is the same aforementioned gun aimed at innocent sparrows. The third method, on the one hand, is very simple to implement, on the other hand, it is quite flexible expanding. Let us dwell on it in more detail.
First, let's look at the category interface, using the methods of which, we can get certain definitions depending on the value of the parameter substituted in the option field:
@interface TyphoonDefinition (Option)
@interface TyphoonDefinition (Option)
+ (id)withOption:(id)option yes:(id)yesInjection no:(id)noInjection;
+ (id)withOption:(id)option matcher:(TyphoonMatcherBlock)matcherBlock;
+ (id)withOption:(id)option matcher:(TyphoonMatcherBlock)matcherBlock autoInjectionConfig:(void(^)(id config))configBlock;
@end
For example, in this case, it looks like this:
- (id ) settingsConfigurator
- (id )settingsConfigurator {
return [TyphoonDefinition withOption:@(DEBUG)
yes:[self debugSettingsConfigurator]
no:[self baseSettingsConfigurator]];
}
Using the TyphoonOptionMatcher object allows you to work with more complex conditions:
- (id ) settingsConfiguratorWithOption: (id) option
- (id )settingsConfiguratorWithOption:(id)option {
return [TyphoonDefinition withOption:option matcher:^(TyphoonOptionMatcher *matcher) {
[matcher caseEqual:@"qa-team" use:[self qaSettingsConfigurator]];
[matcher caseEqual:@"big-bosses" use:[self bigBossesSettingsConfigurator]];
[matcher caseEqual:@"ios-dream-team" use:[self iosTeamSettingsConfigurator]];
[matcher caseMemberOfClass:[RCMConfiguratorOption class] use:[self settingsConfiguratorWithOption:option]];
[matcher defaultUse:[self defaultSettingsConfigurator]];
}];
}
Another possibility is to use the option parameter as a key to find the required TyphoonDefinition :
- (id ) settingsConfiguratorWithOption: (id) option
- (id )settingsConfiguratorWithOption:(id)option {
return [TyphoonDefinition withOption:option matcher:^(TyphoonOptionMatcher *matcher) {
[matcher useDefinitionWithKeyMatchedOptionValue];
}];
// При option = @"debugSettingsConfigurator" вернет definition из метода - debugSettingsConfigurator
}
Of course, this feature should not be abused either - if alternative implementations are needed immediately for a large number of objects of one level of abstraction, it makes sense to replace the whole TyphoonAssembly module .
TyphoonConfig and TyphoonTypeConverter
In the very first article in one example of using Typhoon, I mentioned TyphoonConfig , using it to inject the URL of the server API into one of the network clients. It's time to take a closer look at him.
Supported configuration file formats:
- the plist ,
- properties ,
- json
Primitive types (numbers, BOOL, strings) are indicated "as is":
{
"config": {
"defaultFontSize": 17,
"openLinksInExternalBrowser" : NO
}
}
Typhoon allows you to operate with some other objects: NSURL , UIColor , NSNumber , UIImage . In this case, special syntax is used:
{
"config": {
"baseURL": NSURL(https:// mail.rambler.ru),
"logoImage" : UIImage(rambler-mail-logo-new)
}
}
In addition, if necessary, we can add our own TypeConverter and describe objects of any other class in the configuration file. For example, we want to encapsulate all the details of the application style in one object - RCMStyleModel :
@interface RCMStyleTypeConverter: NSObject
typedef NS_ENUM(NSUInteger, RCMStyleComponent) {
RCMStylePrimaryColorComponent = 0,
RCMStyleDefaultFontSizeComponent = 1,
RCMStyleDefaultFontNameComponent = 2
};
@interface RCMStyleTypeConverter : NSObject
@end
@implementation RCMStyleTypeConverter
- (NSString *)supportedType {
return @"RCMStyle";
}
- (id)convert:(NSString *)stringValue {
NSArray *styleComponents = [stringValue componentsSeparatedByString:@";"];
NSString *colorString = styleComponents[RCMStylePrimaryColorComponent];
UIColor *primaryColor = [self colorFromHexString:colorString];
NSString *defaultFontSizeString = styleComponents[RCMStyleDefaultFontSizeComponent];
CGFloat defaultFontSize = [defaultFontSizeString floatValue];
NSString *defaultFontName = styleComponents[RCMStyleDefaultFontNameComponent];
UIFont *defaultFont = [UIFont fontWithName:defaultFontName size:defaultFontSize];
RCMStyleModel *styleModel = [[RCMStyleModel alloc] init];
styleModel.primaryColor = primaryColor;
styleModel.defaultFontSize = defaultFontSize;
styleModel.defaultFont = defaultFont;
return styleModel;
}
And now we can set the style for the application in the following form:
{
"config": {
"defaultStyle": RCMStyle(#8732A9;17;HelveticeNeue-Regular),
"anotherStyle" : RCMStyle(#AABBCC;15;SanFrancisco)
}
}
Thus, if several parameters from the configuration file are transferred to the same entity at once, it is worth considering combining them into a separate model object and writing a TypeConverter for it.
Typhoonpatcher
The main difference between integration and unit tests is that in the first case we test the interaction of individual application modules with each other, and in the second, each specific module is separate from all the others. So, Typhoon is just amazing for organizing integration tests.
For example, our project has the following chain of dependencies:
RCMPushNotificationCenter -> RCMPushService -> RCMNetworkClient
We want to test the behavior of RCMPushNotificationCenter depending on various results of accessing the server. Instead of manually creating a test object, substituting a stub RCMPushService into it and substituting implementations of its methods, we can use the already prepared TyphoonAssembly infrastructure:
- (void) setUp
- (void)setUp {
[super setUp];
NSArray *collaboratingAssemblies = @[[RCMClientAssembly new], [RCMCoreComponentsAssembly new]];
TyphoonAssembly *serviceComponents = [[RCMServiceComponentsAssemblyBase new] activateWithCollaboratingAssemblies:collaboratingAssemblies];
self.pushNotificationCenter = [serviceComponents pushNotificationCenter];
TyphoonPatcher *patcher = [[TyphoonPatcher alloc] init];
[patcher patchDefinitionWithSelector:@selector(networkClient) withObject:^id{
return [RCMFakeNetworkClient new];
}];
}
The TyphoonPatcher object allows us to patch the method that returns the TyphoonDefinition in any of the TyphoonAssembly modules . In the block transmitted by TyphoonPatcher, you can not just transfer another instance of the class, but also use mocks implemented by various frameworks.
Runtime arguments
Typhoon allows you to instantiate objects not only with predefined dependencies, but also using runtime parameters. This may be needed, for example, when implementing an abstract factory. Let
's look at an example: We have RCMMessageViewController , the mandatory dependency of which is a message object - RCMMessage :
- (void) setUp
@interface RCMMessageViewController : UIViewController
- (instancetype)initWithMessage:(RCMMessage *)message;
@property (nonatomic, strong) id messageService;
@end
The message object is unknown at the time of registration of TyphoonDefinitions when TyphoonAssembly is activated - so we need to be able to create it on the fly. To do this, write the following method in TyphoonAssembly of the corresponding user story:
- (UIViewController *) messageViewControllerWithMessage: (RCMMessage *) message
- (UIViewController *)messageViewControllerWithMessage:(RCMMessage *)message {
return [TyphoonDefinition withClass:[RCMMessageViewController class] configuration:^(TyphoonDefinition *definition) {
[definition useInitializer:@selector(initWithMessage:) parameters:^(TyphoonMethod *initializer) {
[initializer injectParameterWith:message];
}];
[definition injectProperty:@selector(messageService)
with:[self.serviceComponents messageService]];
}];
}
We take this method to a separate protocol, for example, RCMMessageControllerFactory , and inject it into the router:
- (id) foldersRouter
- (id)foldersRouter {
return [TyphoonDefinition withClass:[RCMFoldersRouterBase class] configuration:^(TyphoonDefinition *definition) {
[definition injectProperty:@selector(messageControllerFactory)
with:self];
}];
}
And add to the router the implementation of creating this controller:
- (void) showMessageViewControllerFromSourceController
- (void)showMessageViewControllerFromSourceController:(UIViewController *)sourceViewController
withMessage:(id )message {
RCMMessageViewController *messageViewController = [self.messageControllerFactory messageViewControllerWithMessage:message];
...
}
It is worth mentioning a few limitations of this technique:
- Runtime arguments must be objects . Primitives can be wrapped in NSValue if necessary ,
- The objects transferred to the factory must be used in their original form, their condition must not be changed ,
- It should be used carefully in combination with cyclic dependencies . Runtime arguments must be passed to all dependency objects, otherwise it will not be resolved in the right way.
Factory definitions
In some situations, it is convenient to register TyphoonDefinition , which can generate other definitions. I will explain with a specific example:
A special factory - RCMTextAvatarFactory is responsible for creating custom avatars :
@interface RCMTextAvatarFactory: NSObject
@interface RCMTextAvatarFactory : NSObject
- (RCMTextAvatar *)avatarWithName:(NSString *)name;
@end
Avatars created by this factory must be transferred to other objects. This is implemented as follows - for a start, a definition for the factory is registered:
- (RCMTextAvatarFactory *) textAvatarFactory
- (RCMTextAvatarFactory *)textAvatarFactory {
return [TyphoonDefinition withClass:[RCMTextAvatarFactory class]];
}
And then definitions for the entities created by this factory are registered:
- (RCMTextAvatar *) textAvatarForUserName: (NSString *) userName
- (RCMTextAvatar *)textAvatarForUserName:(NSString *)userName {
return [TyphoonDefinition withFactory:[self textAvatarFactory] selector:@selector(avatarWithName:) parameters:^(TyphoonMethod *factoryMethod) {
[factoryMethod injectParameterWith:userName];
}];
}
By the way, this feature allows you to smoothly migrate from the service locator , if you have sinned this, to Typhoon. The first step is to register the locator as a factory, and the second is to implement TyphoonDefinitions for services using factoryMethods :
- (id ) messageService
- (id )messageService {
return [TyphoonDefinition withFactory:[self serviceLocator] selector:@selector(messageService)];
}
TyphoonInstancePostProcessor / TyphoonDefinitionPostProcessor
These protocols are used to create the so-called infrastructure components. If assembly returns such an object, it is processed differently from normal definitions.
Using TyphoonInstancePostProcessor allows us to wedge in at the moment the container returns the instances of the created dependencies and somehow process them. For example, this can be used to log all calls to certain objects, say networkService :
First, write a simple decorator that logs all messages sent to the object:
@interface RCMDecoratedService: NSProxy
@interface RCMDecoratedService : NSProxy
+ (instancetype)decoratedServiceWith:(NSObject *)service;
@end
@interface RCMDecoratedService()
@property (strong, nonatomic) NSObject *service;
@end
@implementation RCMDecoratedService
- (instancetype)initWithService:(NSObject *)service {
self.service = service;
return self;
}
+ (instancetype)decoratedServiceWith:(NSObject *)service {
return [[self alloc] initWithService:service];
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
return [self.service methodSignatureForSelector:sel];
}
- (void)forwardInvocation:(NSInvocation *)invocation {
NSLog(invocation.debugDescription);
[invocation invokeWithTarget:self.service];
}
@end
Now you need to create an object that implements the TyphoonInstancePostProcessor protocol - its task will be to determine which of the objects it received needs to add additional behavior, and decorate them:
@interface RCMLoggingInstancePostProcessor: NSObject
@interface RCMLoggingInstancePostProcessor : NSObject
@end
@implementation RCMLoggingInstancePostProcessor
- (id)postProcessInstance:(id)instance {
if ([self isAppropriateInstance:instance]) {
RCMDecoratedService *decoratedService = [RCMDecoratedService decoratedServiceWith:instance];
return decoratedService;
}
return instance;
}
- (BOOL)isAppropriateInstance:(id)instance {
if ([instance conformsToProtocol:@protocol(RCMService)]) {
return YES;
}
return NO;
}
@end
And the last step is to register RCMLoggingInstancePostProcessor in one of TyphoonAssembly . The object itself is not involved in the dependency injection process and lives on its own. Its life cycle is tied to the TyphoonComponentFactory lifetime .
@implementation RCMApplicationAssembly
@implementation RCMApplicationAssembly
- (id)loggingProcessor {
return [TyphoonDefinition withClass:[RCMLoggingInstancePostProcessor class]];
}
...
@end
Now all the dependencies created by Typhoon will pass through the RCMLoggingInstancePostProcessor - and those who implement the RCMService protocol will turn into NSProxy .
Another infrastructure component, TyphoonDefinitionPostProcessor , allows you to process all registered definitions before the objects they describe are created. Thus, we can in any way configure and rebuild the TyphoonDefinitions passed to such a processor :
- (void)postProcessDefinition:(TyphoonDefinition *)definition replacement:(TyphoonDefinition **)definitionToReplace withFactory:(TyphoonComponentFactory *)factory;
As examples of the use of this component, we can cite the TyphoonPatcher and TyphoonConfigPostProcessor already mentioned in the article .
Asynchronous testing
For those who for some reason cannot or do not want to use XCTestExpectation , Typhoon offers its own set of methods for implementing testing of asynchronous calls. Consider, as an example, the mail collector synchronization test:
- (void) testThatServiceSynchronizeMailBoxesList
- (void)testThatServiceSynchronizeMailBoxesList {
// given
NSInteger const kExpectedMailBoxCount = 4;
[OHHTTPStubs stubRequestsPassingTest:REQUEST_TEST_YES
withStubResponse:TEST_RESPONSE_WITH_FILE(@"mailboxes_success")];
__block NSInteger resultCount;
__block NSError *responseError = nil;
// when
[self.mailBoxService synchronizeMailBoxesWithCompletionBlock:^(NSError *error) {
responseError = error;
NSFetchedResultsController *controller = [self.mailBoxService fetchedResultsControllerWithAllMailBoxes];
resultCount = controller.fetchedObjects.count;
}];
// then
[TyphoonTestUtils waitForCondition:^BOOL{
typhoon_asynch_condition(resultCount > 0);
} andPerformTests:^{
XCTAssertNil(responseError);
XCTAssertEqual(resultCount, kExpectedMailBoxCount);
}];
}
The standard timeout added by the developers is seven seconds, the condition is checked every second. If it fails, the test will fail with the corresponding exception. If necessary, you can use your timeout:
TyphoonTestUtils wait: 30.0f secondsForCondition: ^ BOOL
[TyphoonTestUtils wait:30.0f secondsForCondition:^BOOL{
typhoon_asynch_condition(resultCount > 0);
} andPerformTests:^{
XCTAssertNil(responseError);
XCTAssertEqual(resultCount, kExpectedMailBoxCount);
}];
Conclusion
In this article, we examined a large number of different features of the Typhoon Framework - auto-injection, the use of configuration files, helpers for integration testing, and much more. Owning these techniques will allow you to solve more problems without inventing your own bicycles, even if they will not be used every day.
In the next part of the series, we will briefly look at two other implementations of Dependency Injection containers for Cocoa - Objection and BloodMagic . And finally, a little news - my colleague German Saprykin and I joined the Typhoon development team, so the framework became even a little more domestic.
The cycle "Managing Dependencies in iOS Applications Correctly"
- Introducing Typhoon
- Typhoon device
- Modularity Typhoon
- Typhoon Tips & Tricks
- Alternatives to Typhoon
- (Optional) Rambler.iOS # 3. Dependency Injection on iOS. Slides
- (Optional) Rambler.iOS # 3. Dependency Injection on iOS. Video