8 мая в 16:37

Использование NSProxy класса на простом примере

Всем привет. Сегодня поговорим о практическом использовании NSProxy класса, пост будет небольшим, думаю многим новичкам будет интересно почитать.

Немного оффтопа.

В общем, по распределению в компании я попал на легаси проект на Objc, где massive view controller считался эталом архитектуры. Конечно же без нормальной спеки и тестов. За недолгую карьеру разработчика у меня выработалось пару правил для таких вот проектов, первое — никакого рефакторинга с моей стороны без указания сверху, второе — трогать существующий код только в крайней необходимости. Всё это на почве того, что в таких проектах очень сложно отловить регрессию (а может я просто ленивый?).

Ладно, для меня этот легаси проект примечателем тем, что в плане архитектуры там ее как таковой нет, контроллеры по 1000 строк, короче, проект идеален для экспериментов — хуже точно уже не сделать, к тому же отсутствие жестких дэдлайнов только к этому располагает :)

В общем выпал на меня тикет по расширению существующего функционала связанного с гугл картами (Google maps). Код проекта довольно запутан, сильно связан, поэтому решил ничего не рефакторить и вообще не прикасаться из-за боязни что-то сломать.

Сам тикет собственно прост — добавить новый тип точек на карте и на тап по ним выполнять определенные действия. Всё просто. Давайте поэтапно рассмотрим эволюцию решения этого задания и где нас тут выручил NSProxy класс.

Решение 1 (в лоб)


Конечно, для новичка особенно, в голову приходит мысль, просто в уже существующий контроллер добавить пару методов, первый для запроса точек через WebService и второй метод для отображения полученных точек на карте. Те кто не знаком с гугл картами, при тапе на маркере карты у её делегата срабатывает метод

— (BOOL)mapView:(GMSMapView *)mapView didTapMarker:(GMSMarker *)marker { }

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

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

Решение 2


Создать отдельный контроллер (не путать с View контроллером), в котором будет происходить запрос точек и их отрисовка на переданной в контроллер гугл карте, и в который будем делегировать каллбэк от тапа по маркеру из существующего контроллера. Это бы выглядело следующим образом (код для нового функционала в старом контроллере):

- (void)viewDidLoad {
    [super viewDidLoad];

    self.mapView.delegate = self;
    self.newLogicController = [[NewLogicController alloc] initWithMapView:self.mapView];
    [self.newLogicController load];
}

- (BOOL)mapView:(GMSMapView *)mapView didTapMarker:(GMSMarker *)marker {	
	if ([self canHandleMarker:marker]) {
		// old logic
	} else {
		// new logic
		NewLogicController *controller = self.newLogicController;
		return [controller mapView:mapView didTapMarker:marker];
	}
}

То есть создаем контроллер и потом просто делегируем ему тапы по карте для новых добавленных точек. Вроде просто, выгдядит неплохо, но вот этот if для ветвления старой и новой логики мне не нравится (может дело вкуса или опять же скука?). Хотя if-ов в реальном приложении было намного больше и со сложной логикой. Было принято решение как можно меньше трогать существующий код, для этого нужно было как-то указать гугл карте, что у нее может быть несколько делегатов. Идея была в том, чтобы делегат при вызове у него метода сам определял вообще для него ли был сделан вызов, если не для него, то пробросить вызов следующему делегату, если же для него, то обработать вызов и прекратить цепочку вызовов. Тут мы плавно перетекаем к следующему решению.

Решение 3


Суть идеи создать класс, который будет поддерживать протокол делегата гугл карты, но на самом деле он не будет содержать его реализацию, а просто будет делегировать вызовы реальным делегатам. Причем, в моём случае, сначала делегировать вызов неосновному делегату (то есть тот, что с новой логикой), у уже потом, если тот ответит, что его не поддерживает, основному делегату.

Нам для этого идеально подходит NSProxy класс, напомню, это абстрактный класс, который не имеет супер класса (привет NSObject), так как он абстрактный, то для его использования необходимо от него отнаследоваться и реализовать абстрактные методы. Собственно, документация нам явно говорит какие методы мы обязаны реализовать, это пара методов:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel;
- (void)forwardInvocation:(NSInvocation *)invocation;

Эти методы будут вызваны когда вашему наследнику прокси класса послали любое сообщение, причем будут вызваны в том же порядке в котором они написаны. В этих методах ваш прокси как раз и должен пробросить вызовы на другие реальные объекты. На самом деле есть более короткий путь проксировки через метод:

- (id)forwardingTargetForSelector:(SEL)aSelector;

если из него вернуть объект для которого предназначен вызов, то методы:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel;
- (void)forwardInvocation:(NSInvocation *)invocation;

не будут вызваны, если же вы вернете nil, то наступает черед этих методов.

Конечный упрощенный вид прокси класса будет выглядеть примерно следующим образом:

@interface GoogleMapViewProxy : NSProxy <GMSMapViewDelegate>

@property (nonatomic, weak) id fakeDelegate;
@property (nonatomic, weak) id originDelegate;

- (instancetype)init;

@end

@implementation GoogleMapViewProxy

- (instancetype)init {
    return self;
}

- (id)forwardingTargetForSelector:(SEL)aSelector {
    if ([self.fakeDelegate respondsToSelector:aSelector]) {
        return self.fakeDelegate;
    } else if ([self.originDelegate respondsToSelector:aSelector]) {
        return self.originDelegate;
    } else {
        return nil;
    }
}

- (BOOL)respondsToSelector:(SEL)aSelector {
    if ([self.fakeDelegate respondsToSelector:aSelector]) {
        return YES;
    } else if ([self.originDelegate respondsToSelector:aSelector]) {
        return YES;
    } else {
        return NO;
    }
}

- (void)forwardInvocation:(NSInvocation *)invocation {
    [invocation invokeWithTarget:self.originDelegate];
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    return [self.originDelegate methodSignatureForSelector:sel];
}

@end

Всё важное происходит в методе:

- (id)forwardingTargetForSelector:(SEL)aSelector;

хотя этот код мы можем с легкостью перенести в:

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel;

просто тогда цепочка вызовов будет немного длиннее о чем я писал выше.

Как мы видим, если новый делегат отвечает на сообщение, то вызов делегируется ему, если нет, то просто вызываем оригинал (в этом прокси не учтен момент когда наш новый делегат реализует методы, которые не реализует оригинальный делегат, в данной реализации мы упадем в doesNotRecognizeSelector, для моего случая такой ситуации не случится, поэтому я опустил этот момент).

Итоговая версия кода в контроллере со старой логикой остается почти без изменений:

- (void)viewDidLoad {
    [super viewDidLoad];

    self.mapView.delegate = self;
    self.newLogicController = [[NewLogicController alloc] initWithMapView:self.mapView];
    [self.newLogicController load];
}

и код в контроллере с новой логикой:

@interface NewLogicController () <GMSMapViewDelegate>

@property (nonatomic) NSMutableArray<GMSMarker *> *markers;
@property (nonatomic) GoogleMapViewProxy *mapProxy;

@end

@implementation NewLogicController

- (instancetype)initWithMapView:(GMSMapView *)mapView {
    self = [super init];
    if (self) {
        _markers = [NSMutableArray array];

        _mapProxy = [[GoogleMapViewProxy alloc] init];
        _mapProxy.fakeDelegate = self;
        _mapProxy.originDelegate = mapView.delegate;

        mapView.delegate = _mapProxy;// Важный момент тут, мы подменяем основной делегат на наш прокси
    }

    return self;
}

- (BOOL)mapView:(GMSMapView *)mapView didTapMarker:(GMSMarker *)marker {
    if ([self.markers containsObject:marker]) {
    	// новая логика
        return NO;
    }

    // пробрасываем основному делегату так как этот вызов не для нас
    return [self.mapProxy.originDelegate mapView:mapView didTapMarker:marker];
}

- (void)load { 
    // ...
}

@end

Итоги


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

Пост получился небольшим, поэтому поделюсь еще интересным случаем где можно применить NSProxy — чтобы отказаться от нудных проверок типа:

id<SomeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(someMethod:)]) {
    [delegate someMethod:self];
}

то есть сразу писать без боязни упасть в doesNotRecognizeSelector:

[self.delegate someMethod:self]; 

Не буду повторяться как этого добиться, всё очень подробно разобрано в статье от Peter Steinberger.

Всем печенек и спасибо за внимание.

Update.


Рассмотренный в статье прием использования NSProxy класса будет уместно скорее только в хрупком легаси проекте. Поэтому на проектах в стадии активной разработки не стоит брать на вооружение данный трюк.
Михаил Демидов @house2008
карма
34,0
рейтинг 3,2
iOS developer
Самое читаемое Разработка

Комментарии (6)

  • 0
    Возможно строчку
    self.mapView.delegate = self;
    

    лучше убрать из класса контроллера, потому что это конфьюзит в свете того, что делегат у mapView перебивается в классе прокси.
    • 0
      На самом деле я специально это добавил, чтобы читатель точно понял какой делегат мы подменяем.
  • 0
    А зачем тут прокси? NewLogicController всё-равно переопределяет делегат MapView и решает, куда дальше прокидывать вызов. Он и может сразу вызывать originalDelegate, вместо прокси. В чем его смысл, кроме как «было интересно попробовать»?
    • 0
      Всё верно говорите. Тут скорее образовательный пример. В проекте есть более сложный и уместный прокси класс, но его реализация достаточно сложна и специфична (частичное использование API из runtime/objc.h), где прокси как раз сам пробрасывает сообщения всем кому может. Всё таки статья расчитана на начинающих, поэтому не хотелось лишних усложнений. Опять же, к сожалению, прокидывать приходится обратно, так как метод требует возвращаемое значение, то есть не void типа.
      • +1
        Я тоже такой вопрос хотел задать, но затем пришло осознание, что ведь в данном случае прокси позволяет вызвать методы у тех, у кого они определены (с приоритетом, конечно же), и без него NewLogicController обязан определить ВСЕ методы делегата и пробросить их дальше, чтобы ничего не потерять. Прокси же автоматически вызовет методы, которых нет в NewLogicController, у старого вью контроллера.

        Не то чтобы это было хорошо, все-таки, неявность очень сильно увеличивает время понимания кода, но зато показывает, как, действительно, красиво можно решать проблему.
        • 0
          Всё абсолютно так и есть. Используя прокси, мы не «паримся», что можем поломать оригинальный делегат. Я в прошлом комментарии почему-то этого не упомянул… Спасибо.

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.