📜 ⬆️ ⬇️

Manage dependencies in iOS applications correctly: Typhoon Tips & Tricks



In the previous parts of the cycle, we looked at the main aspects of Typhoon’s work and prepared to fully apply it in practice. However, in addition to the topics covered, the framework provides a large number of other functions.

In this article, we will look at the following Typhoon Framework features:

Cycle "Manage dependencies in iOS applications correctly"



Autoinjection / Autowire


Often, especially in small projects, there is not enough time to implement the full-layer TyphoonAssembly level controllers, while the service layer is ready. In such a case, it may be advisable to use autoinjections, also known as autowiring. Let's take a look at a simple example of configuring an email viewing screen:
@interface RCMMessageViewController: UIViewController
#import <Typhoon/TyphoonAutoInjection.h> @protocol RCMMessageService; @class RCMMessageRendererBase; @interface RCMMessageViewController : UIViewController @property (strong, nonatomic) InjectedProtocol(RCMMessageService) messageService; @property (strong, nonatomic) InjectedClass(RCMMessageRendererBase) renderer; @end 

And currently the only TyphoonAssembly in the application, specified in Info.plist :
@implementation RCMHelperAssembly
 @implementation RCMHelperAssembly - (RCMMessageRendererBase *)messageRenderer { return [TyphoonDefinition withClass:[RCMMessageRendererBase class]]; } - (id <RCMMessageService>)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 automatically substituted. I draw your attention to the fact that when using autoinjection, you do not need to write a method that gives TyphoonDefinition to 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 a test object, you can automatically substitute them from a specific TyphoonAssembly :
@interface RCMMessageServiceBaseTests: XCTestCase
 #import <Typhoon/TyphoonAutoInjection.h> @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 inserted into a manually created UIViewController , or from xib.

Like any technology, autowire has both advantages:

and disadvantages:

The rule of good code that we developed at Rambler & Co is to spend some time better and prepare well-structured Presentation modules, which will contain definitions for all ViewControllers, and use autowire capabilities only in integration tests. The presence of a well-documented project structure with TyphoonAssembly greatly exceeds all the advantages of autoinjection.

TyphoonDefinition + Option


In the previous article, we looked at an example of using two different implementations of the same TyphoonAssembly - combat and fake. However, sometimes this approach is tantamount to shooting sparrows with guns - 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, which allows you to work with logs, find out the current build number and other similar things. The settings screen is a table that is collected from the ViewModel collection by a separate class RCMSettingsConfigurator . This class has two implementations, Base and Debug, which are enabled by the corresponding build scheme. We faced a choice of three options for the implementation of this task:

The first option, of course, is not the choice of real ninjas (well, this is not the case, actively use #ifdef 's in the code of the mobile application). The second option is the same aforementioned gun aimed at innocent sparrows. The third method, on the one hand, is very simple to implement, on the other, it expands quite flexibly. Let us dwell on it in more detail.

To begin with, we will look at the interface of the category, 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<TyphoonAutoInjectionConfig> config))configBlock; @end 

For example, in this case it looks like this:
- (id <RCMSettingsConfigurator>) settingsConfigurator
 - (id <RCMSettingsConfigurator>)settingsConfigurator { return [TyphoonDefinition withOption:@(DEBUG) yes:[self debugSettingsConfigurator] no:[self baseSettingsConfigurator]]; } 

Using the object TyphoonOptionMatcher allows you to work with more complex conditions:
- (id <RCMSettingsConfigurator>) settingsConfiguratorWithOption: (id) option
 - (id <RCMSettingsConfigurator>)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 the key to find the required TyphoonDefinition :
- (id <RCMSettingsConfigurator>) settingsConfiguratorWithOption: (id) option
 - (id <RCMSettingsConfigurator>)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 the same level of abstraction, it makes sense to replace the whole TyphoonAssembly module.

TyphoonConfig and TyphoonTypeConverter


In the very first article in one of the examples of using Typhoon, I already 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 it.

Supported configuration file formats:

Primitive types (numbers, BOOL, strings) are specified "as is":
 { "config": { "defaultFontSize": 17, "openLinksInExternalBrowser" : NO } } 

Typhoon allows you to operate with some other objects: NSURL , UIColor , NSNumber , UIImage . In this case, a 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 style of the application in one object - RCMStyleModel :
@interface RCMStyleTypeConverter: NSObject <TyphoonTypeConverter>
 typedef NS_ENUM(NSUInteger, RCMStyleComponent) { RCMStylePrimaryColorComponent = 0, RCMStyleDefaultFontSizeComponent = 1, RCMStyleDefaultFontNameComponent = 2 }; @interface RCMStyleTypeConverter : NSObject <TyphoonTypeConverter> @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 of the application as follows:
 { "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, you should think about combining them into a separate model object and writing TypeConverter for it.

TyphoonPatcher


The main difference between integration and unit tests is that in the first case we test the interaction of the individual application modules with each other, and in the second - each specific module in isolation from all the others. So, Typhoon is simply amazing for organizing integration tests.

For example, in our project there is the following chain of dependencies:
RCMPushNotificationCenter -> RCMPushService -> RCMNetworkClient

We want to test the behavior of RCMPushNotificationCenter depending on the different results of accessing the server. Instead of manually creating a test object, inserting a stub RCMPushService into it and replacing 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<RCMServiceComponents> *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 renders TyphoonDefinition to any of the TyphoonAssembly modules. In the block transmitted by TyphoonPatcher, you can not only transfer another instance of the class, but also use mocks implemented by various frameworks.

Runtime arguments


Typhoon allows instantiating objects not only with predefined dependencies, but also using runtime parameters. It may be necessary, for example, when implementing an abstract factory. Consider an example:

We have RCMMessageViewController , the mandatory dependency of which is the message object - RCMMessage :
- (void) setUp
 @interface RCMMessageViewController : UIViewController - (instancetype)initWithMessage:(RCMMessage *)message; @property (nonatomic, strong) id <RCMMessageService> messageService; @end 

The message object is unknown at the time of TyphoonDefinition's registration when TyphoonAssembly is activated - so we need to be able to create it on the fly. To do this in TyphoonAssembly corresponding user story write the following method:
- (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]]; }]; } 

Let's put this method into a separate protocol, for example, RCMMessageControllerFactory , and inject it into the router:
- (id <RCMFoldersRouter>) foldersRouter
 - (id<RCMFoldersRouter>)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 <RCMMessageReadableProtocol>)message { RCMMessageViewController *messageViewController = [self.messageControllerFactory messageViewControllerWithMessage:message]; ... } 

It is worth mentioning several limitations of this technique:

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 - to start, the definition for the factory is registered:
- (RCMTextAvatarFactory *) textAvatarFactory
 - (RCMTextAvatarFactory *)textAvatarFactory { return [TyphoonDefinition withClass:[RCMTextAvatarFactory class]]; } 

And then definitions are recorded for the entities created by this factory:
- (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 seamlessly migrate from using the service locator , if you have sinned with it, on Typhoon. The first step is to register the locator as a factory, and the second is to implement TyphoonDefinitions for services using factoryMethods :
- (id <RCMMessageService>) messageService
 - (id <RCMMessageService>)messageService { return [TyphoonDefinition withFactory:[self serviceLocator] selector:@selector(messageService)]; } 


TyphoonInstancePostProcessor / TyphoonDefinitionPostProcessor


These protocols are used to create so-called infrastructure components. If assembly returns such an object, it is handled differently from ordinary definitions.

Using TyphoonInstancePostProcessor allows us to hook in at the time of the return of the created instances by the container and process them in some way. For example, this can be used to log all calls to certain objects, say, to a networkService :

To begin with, let's write a simple decorator that logs all messages sent to the object:
@interface RCMDecoratedService: NSProxy
 @interface RCMDecoratedService : NSProxy + (instancetype)decoratedServiceWith:(NSObject <RCMService>*)service; @end @interface RCMDecoratedService() @property (strong, nonatomic) NSObject <RCMService> *service; @end @implementation RCMDecoratedService - (instancetype)initWithService:(NSObject <RCMService> *)service { self.service = service; return self; } + (instancetype)decoratedServiceWith:(NSObject <RCMService>*)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 receives, add additional behavior, and decorate them:
@interface RCMLoggingInstancePostProcessor: NSObject <TyphoonInstancePostProcessor>
 @interface RCMLoggingInstancePostProcessor : NSObject <TyphoonInstancePostProcessor> @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 the RCMLoggingInstancePostProcessor in one of the TyphoonAssembly . The object itself does not participate in the dependency injection process and lives on its own. Its life cycle is tied to the time of the TyphoonComponentFactory life.
@implementation RCMApplicationAssembly
 @implementation RCMApplicationAssembly - (id)loggingProcessor { return [TyphoonDefinition withClass:[RCMLoggingInstancePostProcessor class]]; } ... @end 

Now all dependencies created by Typhoon will pass through RCMLoggingInstancePostProcessor - and those who implement the RCMService protocol will turn around in NSProxy .

Another infrastructure component, TyphoonDefinitionPostProcessor , allows all registered definitions to be processed before the objects described by them are created. Thus, we can in any way configure and re-compile the TyphoonDefinitions transferred to such a processor:
 - (void)postProcessDefinition:(TyphoonDefinition *)definition replacement:(TyphoonDefinition **)definitionToReplace withFactory:(TyphoonComponentFactory *)factory; 

As examples of using this component, you 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 to implement asynchronous call testing. Consider as an example a synchronization test for mail collectors:
- (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 is not executed, the test will fail with the corresponding exception. If necessary, you can use your own 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 looked at a large number of different types of Typhoon Framework — autoinjection, the use of configuration files, helpers for integration testing, and much more. Possession of these techniques will allow you to solve more problems without inventing your bikes, even if they will not be used every day.

In the next part of the cycle, we briefly review 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 has become a little more domestic.

Cycle "Manage dependencies in iOS applications correctly"



useful links


Source: https://habr.com/ru/post/264683/


All Articles