Pull to refresh

Управляем зависимостями в iOS-приложениях правильно: Typhoon Tips & Tricks

Reading time 14 min
Views 8.2K


В предыдущих частях цикла мы рассмотрели основные аспекты работы Typhoon и подготовились к полноценному применению его на практике. Тем не менее, помимо затронутых тем, фреймворк предоставляет большое количество других функций.

В этой статье мы рассмотрим следующие особенности Typhoon Framework:
  • Автоинъекция (также известная как autowiring),
  • Автоматический выбор из альтернативных реализаций одного TyphoonDefinition,
  • Особенности работы с TyphoonConfig
  • Использование TyphoonPatcher для организации интеграционных тестов,
  • Применение runtime-атрибутов при создании объектов,
  • Реализация фабрик на базе TyphoonAssembly,
  • Постпроцессинг объектов, создаваемых при помощи Typhoon,
  • Инструменты для написания асинхронных тестов.

Цикл «Управляем зависимостями в iOS-приложениях правильно»



Автоинъекции/Autowire


Зачастую, особенно в небольших проектах, не хватает времени на реализацию полноценного слоя TyphoonAssembly уровня контроллеров, в то время как сервисный слой уже готов. В таком случае может быть целесообразным использование автоинъекций, так же известных как autowiring. Посмотрим на простой пример конфигурации экрана просмотра почтового сообщения:
@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

И единственная на данный момент TyphoonAssembly в приложении, указанная в Info.plist:
@implementation RCMHelperAssembly
@implementation RCMHelperAssembly

- (RCMMessageRendererBase *)messageRenderer {
    return [TyphoonDefinition withClass:[RCMMessageRendererBase class]];
}

- (id <RCMMessageService>)messageService {
    return [TyphoonDefinition withClass:[RCMMessageServiceBase class]];
}

@end

Попробуем запустить приложение с такой конфигурацией:


Как видим, нужные зависимости были подставлены автоматически. Обращаю внимание на то, что при использовании автоинъекции не требуется писать метод, отдающий TyphoonDefinition для ViewController'а. Также стоит отметить, что такой подход работает только при создании UIViewController из TyphoonStoryboard.

Похожий подход может быть использован и при написании интеграционных тестов — вместо того, чтобы вручную создавать зависимости тестируемого объекта, можно автоматически подставить их из определенной 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

Аналогичным образом зависимости подставляются в UIViewController, созданный вручную, либо из xib.

Как и у любой технологии, у autowire есть как достоинства:
  • Экономия времени за счет отсутствия необходимости реализовывать некоторые assembly,
  • Более информативные интерфейсы объектов — сразу же видно, какие зависимости подставляются при помощи Typhoon, какие — самостоятельно,
  • Если какая-либо из автоматически подставляемых зависимостей объекта не найдена в фабрике, crash произойдет сразу же (в случае ручной подстановки это может вообще пройти незамеченным).

так и недостатки:
  • Привязка к Typhoon уходит за пределы assembly и затрагивает конкретные классы,
  • Просмотрев структуру модулей TyphoonAssembly проекта, нельзя судить о его архитектуре в целом.

Правило хорошего кода, выработанное нами в Rambler&Co — лучше потратить некоторое время и подготовить хорошо структурированные модули уровня Presentation, в которых будут содержаться definition'ы для всех ViewController'ов, а возможности autowire использовать только в интеграционных тестах. Наличие хорошо документированной при помощи TyphoonAssembly структуры проекта во многом превосходит все достоинства автоинъекции.

TyphoonDefinition+Option


В предыдущей статье мы рассматривали пример использования двух разных реализаций одной TyphoonAssembly — боевой и фейковой. Тем не менее, иногда такой подход равносилен стрельбе из пушек по воробьям — а Typhoon предоставляет нам гораздо более изящные способы решения проблемы.

Рассмотрим еще один кейс из Рамблер.Почты:
Команда QA попросила добавить в приложение специальное debug-меню, позволяющее работать с логами, узнавать текущий номер билда и прочие подобные вещи. Экран настроек — это таблица, которая собирается из коллекции ViewModel'ей отдельным классом RCMSettingsConfigurator. У этого класса две реализации — Base и Debug, которые включаются соответствующими build scheme. Перед нами встал выбор из трех вариантов реализации этой задачи:
  • Создавать конфигуратор вручную при помощи #ifdef'ов, определяющих значения препроцессорной директивы,
  • Написать две реализации assembly, создающей объекты для user story настроек,
  • Использовать категорию TyphoonDefinition+Option.

Первый вариант, конечно, не выбор настоящих ниндзя (ну не дело это, активно использовать #ifdef'ы в коде мобильного приложения). Второй вариант — это та самая вышеупомянутая пушка, нацеленная на невинных воробьев. Третий способ с одной стороны очень прост в реализации, с другой — достаточно гибко расширяется. Остановимся на нем подробнее.

Для начала посмотрим на интерфейс категории, используя методы которой, мы можем получать определенные definition'ы в зависимости от значения параметра, подставленного в поле option:
@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

К примеру, в рассматриваемом кейсе это выглядит следующим образом:
- (id <RCMSettingsConfigurator>)settingsConfigurator
- (id <RCMSettingsConfigurator>)settingsConfigurator {
    return [TyphoonDefinition withOption:@(DEBUG)
                                 	yes:[self debugSettingsConfigurator]
                                  	no:[self baseSettingsConfigurator]];
}

Использование объекта TyphoonOptionMatcher позволяет работать и с более сложными условиями:
- (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]];
	}];
}

Еще одна возможность — использовать параметр option в качестве ключа для поиска требуемого TyphoonDefinition:
- (id <RCMSettingsConfigurator>)settingsConfiguratorWithOption:(id)option
- (id <RCMSettingsConfigurator>)settingsConfiguratorWithOption:(id)option {
    return [TyphoonDefinition withOption:option matcher:^(TyphoonOptionMatcher *matcher) {
    	[matcher useDefinitionWithKeyMatchedOptionValue];
	}];
    // При option = @"debugSettingsConfigurator" вернет definition из метода - debugSettingsConfigurator
}

Конечно, злоупотреблять этой возможностью тоже не стоит — если альтернативные реализации нужны сразу для большого количества объектов одного уровня абстракции, имеет смысл подменять целый модуль TyphoonAssembly.

TyphoonConfig и TyphoonTypeConverter


В самой первой статье в одном из примеров использования Typhoon я уже упоминал TyphoonConfig, использовав его для инъекции URL серверного API в один из network-клиентов. Пришло время взглянуть на него повнимательнее.

Поддерживаемые форматы конфигурационных файлов:
  • plist,
  • properties,
  • json

Примитивные типы (числа, BOOL, строки) указываются «как есть»:
{
    "config": {
        "defaultFontSize": 17,
        "openLinksInExternalBrowser" : NO
	}
}

Typhoon позволяет оперировать и некоторыми другими объектами: NSURL, UIColor, NSNumber, UIImage. В таком случае используется специальный синтаксис:
{
	"config": {
    	    "baseURL": NSURL(https:// mail.rambler.ru),
            "logoImage" : UIImage(rambler-mail-logo-new)
	}
}

Кроме того, при необходимости мы можем добавить собственный TypeConverter и описать в конфигурационном файле объекты любого другого класса. К примеру, мы хотим инкапсулировать все детали стиля приложения в одном объекте — 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;
}

И теперь стиль для приложения мы можем задавать в следующем виде:
{
	"config": {
    	    "defaultStyle": RCMStyle(#8732A9;17;HelveticeNeue-Regular),
            "anotherStyle" : RCMStyle(#AABBCC;15;SanFrancisco)
	}
}

Таким образом, если в одну и ту же сущность передаются сразу несколько параметров из конфигурационного файла, стоит задуматься об объединении их в отдельный модельный объект и написании для него TypeConverter'а.

TyphoonPatcher


Основное отличие интеграционных и unit тестов в том, что в первом случае мы тестируем взаимодействие отдельных модулей приложения друг с другом, а во втором — каждый конкретный модуль в отрыве от всех остальных. Так вот, Typhoon просто восхитителен для организации интеграционных тестов.

К примеру, в нашем проекте есть следующая цепочка зависимостей:
RCMPushNotificationCenter -> RCMPushService -> RCMNetworkClient

Мы хотим протестировать поведение RCMPushNotificationCenter в зависимости от различных результатов обращения к серверу. Вместо того, чтобы вручную создавать тестируемый объект, подставлять в него stub'овый RCMPushService и подменять реализации его методов, мы можем воспользоваться уже готовой инфраструктурой TyphoonAssembly:
- (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];
	}];
}

Объект TyphoonPatcher позволяет нам пропатчить метод, отдающий TyphoonDefinition, в любом из модулей TyphoonAssembly. В передаваемом TyphoonPatcher блоке можно не просто передавать другой инстанс класса, но и использовать mock'и, реализуемые различными фреймворками.

Runtime arguments


Typhoon позволяет инстанциировать объекты не только с заранее заданными зависимостями, но и с использованием runtime параметров. Понадобиться это может, к примеру, при реализации абстрактной фабрики. Рассмотрим пример:

У нас есть RCMMessageViewController, обязательной зависимостью которого является объект сообщения — RCMMessage:
- (void)setUp
@interface RCMMessageViewController : UIViewController

- (instancetype)initWithMessage:(RCMMessage *)message;
@property (nonatomic, strong) id <RCMMessageService> messageService;

@end

Объект message неизвестен на момент регистрации TyphoonDefinition'ов при активации TyphoonAssembly — поэтому нам нужно уметь создавать его на лету. Для этого в TyphoonAssembly соответствующей 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]];
	}];
}

Вынесем этот метод в отдельный протокол, к примеру, RCMMessageControllerFactory, и проинжектим его в роутер:
- (id<RCMFoldersRouter>)foldersRouter
- (id<RCMFoldersRouter>)foldersRouter {
    return [TyphoonDefinition withClass:[RCMFoldersRouterBase class] configuration:^(TyphoonDefinition *definition) {
    	[definition injectProperty:@selector(messageControllerFactory)
                              with:self];
	}];
}

И добавим в роутер реализацию создания этого контроллера:
- (void)showMessageViewControllerFromSourceController
- (void)showMessageViewControllerFromSourceController:(UIViewController *)sourceViewController
                                      	withMessage:(id <RCMMessageReadableProtocol>)message {
    RCMMessageViewController *messageViewController = [self.messageControllerFactory messageViewControllerWithMessage:message];
	...
}

Стоит упомянуть и несколько ограничений этой техники:
  • Runtime аргументы обязательно должны представлять собой объекты. Примитивы при необходимости могут быть завернуты в NSValue,
  • Переданные фабрике объекты должны использоваться в своем первоначальном виде, их состояние изменять нельзя,
  • Стоит аккуратно использовать в сочетании с циклическими зависимостями. Runtime аргументы должны быть переданы всем объектам зависимости, иначе она не решится правильным образом.

Factory Definitions


В некоторых ситуациях бывает удобно зарегистрировать TyphoonDefinition, умеющий генерировать другие definition'ы. Объясню на конкретном примере:

За создание пользовательских аватарок отвечает специальная фабрика — RCMTextAvatarFactory:
@interface RCMTextAvatarFactory : NSObject
@interface RCMTextAvatarFactory : NSObject
- (RCMTextAvatar *)avatarWithName:(NSString *)name;
@end

Аватарки, создаваемые этой фабрикой, необходимо передавать в другие объекты. Реализуется это следующим образом — для начала регистрируется definition для фабрики:
- (RCMTextAvatarFactory *)textAvatarFactory
- (RCMTextAvatarFactory *)textAvatarFactory {
    return [TyphoonDefinition withClass:[RCMTextAvatarFactory class]];
}

И затем регистрируются definition'ы для создаваемых этой фабрикой сущностей:
- (RCMTextAvatar *)textAvatarForUserName:(NSString *)userName
- (RCMTextAvatar *)textAvatarForUserName:(NSString *)userName {
    return [TyphoonDefinition withFactory:[self textAvatarFactory] selector:@selector(avatarWithName:) parameters:^(TyphoonMethod *factoryMethod) {
    	[factoryMethod injectParameterWith:userName];
	}];
}

Кстати, эта возможность позволяет плавно мигрировать с использования сервис-локатора, если вы этим грешили, на Typhoon. Первым шагом будет регистрация локатора в качестве фабрики, а вторым — реализация TyphoonDefinition'ов для сервисов с использованием factoryMethod'ов:
- (id <RCMMessageService>)messageService
- (id <RCMMessageService>)messageService {
    return [TyphoonDefinition withFactory:[self serviceLocator] selector:@selector(messageService)];
}


TyphoonInstancePostProcessor/TyphoonDefinitionPostProcessor


Эти протоколы используются для создания так называемых инфраструктурных компонентов. Если assembly возвращает такой объект, он обрабатывается отлично от обычных definition’ов.

Использование TyphoonInstancePostProcessor позволяет нам вклиниться в момент возврата контейнером инстансов создаваемых зависимостей и каким-нибудь образом их обработать. К примеру, это можно использовать для логирования всех обращений к определенным объектам, скажем, к networkService'ам:

Для начала напишем простой декоратор, выводящий в лог все сообщения, посылаемые объекту:
@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

Теперь нужно создать объект, реализующий протокол TyphoonInstancePostProcessor — его задачей будет определять, какому из полученных им объектов требуется добавить дополнительное поведение, и декорировать их:
@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

И последний шаг — зарегистрировать RCMLoggingInstancePostProcessor в одной из TyphoonAssembly. Сам объект не участвует в процессе инъекции зависимостей и живет сам по себе. Его жизненный цикл привязан ко времени жизни TyphoonComponentFactory.
@implementation RCMApplicationAssembly
@implementation RCMApplicationAssembly
- (id)loggingProcessor {
    return [TyphoonDefinition withClass:[RCMLoggingInstancePostProcessor class]];
}
...
@end

Теперь все создаваемые Typhoon'ом зависимости будут проходить через RCMLoggingInstancePostProcessor — а те из них, кто реализует протокол RCMService — оборачиваться в NSProxy.

Другой инфраструктурный компонент, TyphoonDefinitionPostProcessor, позволяет обрабатывать все зарегистрированные definition'ы до того, как будут созданы описываемые ими объекты. Таким образом, мы можем любым образом конфигурировать и пересобирать переданные такому процессору TyphoonDefinition'ы:
- (void)postProcessDefinition:(TyphoonDefinition *)definition replacement:(TyphoonDefinition **)definitionToReplace withFactory:(TyphoonComponentFactory *)factory;

В качестве примеров использования этого компонента можно привести уже упомянутые в статье TyphoonPatcher и TyphoonConfigPostProcessor.

Асинхронное тестирование


Для тех, кто по какой-то причине не может или не хочет использовать XCTestExpectation, Typhoon предлагает свой набор методов для реализации тестирования асинхронных вызовов. Рассмотрим в качестве примера тест синхронизации почтовых сборщиков:
- (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);
	}];
}

Стандартный timeout, добавленный разработчиками — семь секунд, условие проверяется каждую секунду. Если оно не будет выполнено — тест провалится с соответствующим exception'ом. При необходимости можно использовать и свой timeout:
TyphoonTestUtils wait:30.0f secondsForCondition:^BOOL
[TyphoonTestUtils wait:30.0f secondsForCondition:^BOOL{
        typhoon_asynch_condition(resultCount > 0);
	} andPerformTests:^{
        XCTAssertNil(responseError);
        XCTAssertEqual(resultCount, kExpectedMailBoxCount);
}];


Заключение


В этом материале мы рассмотрели большое количество различных возможностей Typhoon Framework — автоинъекцию, использование конфигурационных файлов, хелперы для проведения интеграционного тестирования и многое другое. Владение этими техниками позволит вам решить больше задач без изобретения своих велосипедов, пусть даже они и не будут использоваться каждый день.

В следующей части цикла мы вкратце рассмотрим две других реализации Dependency Injection контейнеров для Cocoa — Objection и BloodMagic. Ну и небольшая новость напоследок — мы с моим коллегой Германом Сапрыкиным вошли в команду разработчиков Typhoon, так что фреймворк стал еще чуть более отечественным.

Цикл «Управляем зависимостями в iOS-приложениях правильно»



Полезные ссылки


Tags:
Hubs:
+9
Comments 1
Comments Comments 1

Articles

Information

Website
rambler-co.ru
Registered
Employees
1,001–5,000 employees
Location
Россия