Pull to refresh
0
True Engineering
Лаборатория технологических инноваций

Преодолеваем скрытые опасности KVO в Objective C

Reading time 21 min
Views 26K
The major difference between a thing that might go wrong and a thing that cannot possibly go wrong is that when a thing that cannot possibly go wrong goes wrong it usually turns out to be impossible to get at or repair.
— Douglas Adams


Objective C существует уже с 1983 года и является ровесником C++. Однако, в отличие от последнего он начал приобретать популярность только в 2008 году, после выхода iOS 2.0 — новой версии операционной системы для революционного iPhone, включавшей приложение AppStore, позволяющее пользователям приобретать приложения, создаваемые сторонними разработчиками.
Дальнейший успех Objective C обеспечивался не только популярность устройств на базе iOS и относительной легкостью продаж через AppStore, но и значительными усилиями компании Apple по совершенствованию как стандартных библиотек, так и самого языка.
Согласно рейтингу TIOBE к началу 2013 года Objective C обогнал по популярности C++ и занял третье место, уступая только C и Java.

На сегодняшний день Objective C включает и такие относительно старые функции как KVC и KVO, существовавшие еще за 4 года до выхода первого iPhone, и такие новые возможности как блоки (blocks, появившиеся в Mac OS 10.6 и iOS 4) и автоматический подсчет ссылок (ARC, доступный в Mac OS 10.7 и iOS 5), которые позволяют с легкостью решать задачи, вызывавшие серьезные трудности ранее.

KVO — это технология, позволяющая незамедлительно реагировать в одном объекте (наблюдателе) на изменения состояния другого объекта (наблюдаемого), без внесения знаний о типе наблюдателя в реализации наблюдаемого объекта. В Objective C, наряду с KVO, существует несколько способов решения этой задачи:

1. Делегирование — распространенный паттерн объектно-ориентированного программирования, состоящий в том, что объекту передается ссылка на произвольный объект (называемый делегатом), реализующий определенный протокол — фиксированный набор селекторов. После этого реализация объекта «вручную» посылает своему делегату соответствующие случаю сообщения. Например, UIScrollView оповещает своего делегата об изменении значения своего свойства contentOffset, вызывая селектор scrollViewDidScroll:.
Рекомендуется одним из параметров всех селекторов протокола делать ссылку на сам вызывающий его объект, чтобы в случае, когда один и тот же объект является делегатом нескольких объектов одного класса, иметь возможность различить, от кого из них пришло сообщение.

2. Target-action. Отличие этой техники от делегирования заключается в том, что вместо реализации «делегатом» определенного протокола, вместе с ним передается его селектор, который и будет вызван при определенном событии. Эта техника чаще всего используется наследниками UIControl, например, объекту UISwitch можно задать пару target-action для вызова при переключении этого контрола пользователем (событии UIControlEventValueChanged). Такое решение более удобно, нежели делегирование, в случае, когда один объект-«цель» должен реагировать на одинаковые события от разных источников (например, нескольких UISwitch).

3. Callback block. Это решение состоит в том, что наблюдаемому объекту передается ссылка не на сам объект-наблюдатель, а на блок. Как правило этот блок создается в том же месте, где и устанавливается. При этом реализация блока способна захватывать значения локальных переменных того scope, где он определен, избавляя от необходимости добавлять отдельный метод и восстанавливать контекст внутри его реализации.
Важным отличием этого подхода от предыдущих является то, что если ссылка на делегат или target является слабой (weak reference), то ссылка на блок является сильной (обычно она же оказывается единственной), и программисту нужно каждый раз при реализации блоков заботиться о том, чтобы блок захватывал объекты по слабым ссылкам. Иначе это может привести к циклическим сильным связям и утечкам памяти.
Так же, как и в первых двух техниках, одним из аргументов блока рекомендуется делать ссылку на вызывающий его объект, но по несколько иной причине. Несмотря на то, что блок и так может захватить эту ссылку из контекста, при этом легко по ошибке захватить объект по сильной ссылке, либо захватить nil, которым эта ссылка была инициализирована.

4. NSNotificationCenter позволяет из любого метода любого класса отправлять оповещения (NSNotification), состоящие из строкового имени и произвольного объекта. Такое оповещение будет получено любыми объектами, подписавшимися на оповещения с таким именем и (опционально) объектом. Подписка на оповещения реализуется либо по принципу target-action, либо при помощи callback block.
В отличие от предыдущих подходов, использование NSNotificationCenter приводит к более слабым зависимостям между объектами и позволяет без дополнительных усилий подписывать несколько объектов на одно и то же оповещение.

5. NSKeyValueObserving является неформальным протоколом, реализованным в классе NSObject, позволяющим подписать произвольный объект (наблюдатель) на изменения значения по указанному key path указанного другого объекта (наблюдаемого), вызвав на нем селектор addObserver:forKeyPath:options:context:. После этого при каждом изменении значения наблюдатель будет получать сообщение observeValueForKeyPath:ofObject:change:context:, аналогично паттерну делегирования.
Таким образом KVO позволяет подписывать неограниченное число объектов на изменения не только отдельного атрибута, но и значений по составному key path наблюдаемого объекта, как правило без каких-либо модификаций последнего.

Несмотря на очевидную мощь KVO, он не пользуется большой популярностью среди разработчиков, и зачастую к нему относятся как к крайней мере, прибегая только когда другие решения недоступны. Чтобы попытаться понять (и исправить) причины такой нелюбви, рассмотрим пару примеров использования KVO.

Предположим, у нас есть класс ETRDocument, имеющий атрибуты title и isFavorite

@interface ETRDocument : NSObject
@property (nonatomic, copy) NSString *title;
@property (nonatomic) BOOL isFavorite;
@end


и мы хотим реализовать табличную ячейку, отображающую информацию о документе

@class ETRDocument;
@interface ETRDocumentCell : UITableViewCell
@property (nonatomic, strong) ETRDocument *document;
@property (nonatomic, strong) IBOutlet UILabel *titleLabel;
@property (nonatomic, strong) IBOutlet UIButton *isFavoriteButton;
- (IBAction)toggleIsFavorite;
@end

@implementation ETRDocumentCell
- (void)updateIsFavoriteButton
{
    self.isFavoriteButton.selected = self.document.isFavorite;
}
- (void)toggleIsFavorite
{
    self.document.isFavorite = !self.document.isFavorite;
    [self updateIsFavoriteButton];
}
- (void)setDocument:(ETRDocument *)document
{
    _document = document;
    self.titleLabel.text = self.document.title;
    [self updateIsFavoriteButton];
}
@end


Предположим, мы обнаруживаем, что значение isFavorite может быть изменено не только нажатием кнопки, но и некоторым внешним по отношению к ячейке способом. Это никак не отразится на внешнем виде ячейки, что следует исправить. Мы могли бы при изменении isFavorite вручную находить ячейки и обновлять их, вызывая updateIsFavoriteButton, но это создало бы лишние связи между классами и нарушило инкапсуляцию нашей ячейки. Поэтому мы решаем подписать саму ячейку на изменения в документе. Мы могли бы сделать ее делегатом документа, или посылать нотификации при изменении isFavorite, однако если мы вместо этого используем KVO, нам не нужно будет делать в классе документа никаких изменений: вся дополнительная логика будет инкапсулирована в классе ячейки.

- (void)startObservingIsFavorite
{
    [self.document addObserver:self
                    forKeyPath:@"isFavorite"
                       options:0
                       context:NULL];
}
- (void)stopObservingIsFavorite
{
    [self.document removeObserver:self
                       forKeyPath:@"isFavorite"];
}
- (void)setDocument:(ETRDocument *)document
{
    [self stopObservingIsFavorite];
    _document = document;
    [self startObservingIsFavorite];
    self.titleLabel.text = self.document.title;
    [self updateIsFavoriteButton];
}
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    [self updateIsFavoriteButton];
}


Запускаем — всё работает, ячейка реагирует на изменение isFavorite. Мы даже можем убрать вызов updateIsFavoriteButton из toggleIsFavorite. Однако стоит закрыть таблицу и изменить значение isFavorite у одного из документов, как приложение падает с EXC_BAD_ACCESS.
Что же произошло? Попробуем включить NSZombieEnabled и повторить действия. На этот раз мы получаем более осмысленное сообщение при падении:
*** -[ETRDocumentCell retain]: message sent to deallocated instance 0x8bcda20

Действительно, заглянув в документацию по KVO мы увидим следующее:
Note: The key-value observing addObserver:forKeyPath:options:context: method does not maintain strong references to the observing object, the observed objects, or the context. You should ensure that you maintain strong references to the observing, and observed, objects, and the context as necessary.

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

Контекст для KVO является обычным указателем из языка C. Даже если он указывает на объект Objective C, KVO не будет рассматривать его как таковой: не будет слать ему сообщений или отслеживать его время жизни. Следовательно, если контекст будет удален, то в observeValueForKeyPath будет передана «висячая» ссылка, и попытка передать по ней сообщение приведет к последствиям, аналогичным тем, которые имеем мы. Однако мы не использовали в нашем примере контекст. Более того, далее станет ясно, что контекст имеет несколько иное «истинное» предназначение.

Если удаленным окажется наблюдаемый объект, то вместо того, чтобы прекратить наблюдение (ведь никакие значения меняться больше не могут), в консоль будет выведено предупреждение:

An instance 0xac62490 of class ETRDocument was deallocated while key value observers were still registered
with it. Observation info was leaked, and may even become mistakenly attached to some other object.
Set a breakpoint on NSKVODeallocateBreak to stop here in the debugger. Here's the current observation info:
<NSKeyValueObservationInfo 0xaaa77e0> (
<NSKeyValueObservance 0xaaa77a0: Observer: 0xaaa2100, Key path: isFavorite, Options:
<New: NO, Old: NO, Prior: NO> Context: 0x0, Property: 0xabf12e0>
)

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

Если же будет удален наблюдатель, KVO сохранит «висячую» ссылку на него (что соответствует модификатору unsafe_unretained в терминологии ARC), и будет посылать по ней сообщения при изменениях. Именно это и происходит в нашем примере. Возможно, в последующих версиях поведение «unsafe_unretained» будет заменено на более безопасное «weak», и «висячие» ссылки на наблюдателей будут автоматически обнуляться.
Чтобы исправить это падение, достаточно вызвать stopObservingIsFavorite из dealloc.

Существует способ несколько упростить логику нашей ячейки. Вместо наблюдения по key path «isFavorite» документа, ячейка может наблюдать key path «document.isFavorite» на самой себе. В результате ячейка будет оповещена как при изменении атрибута isFavorite в связанном документа, так и при изменении своей ссылки на документ. При этом по-прежнему необходимо вызывать removeObserver из dealloc, но не нужно прекращать и начинать наблюдение каждый раз при смене текущего документа.
Можно пойти дальше, и наблюдать не только isFavorite, но и title. Это избавит нас от переопределения setDocument:, но столкнет с еще одним неудобством KVO:

@implementation ETRDocumentCell
- (void)awakeFromNib
{
    [super awakeFromNib];
    [self addObserver:self
           forKeyPath:@"document.isFavorite"
              options:0
              context:NULL];
    [self addObserver:self
           forKeyPath:@"document.title"
              options:0
              context:NULL];
}
- (void)dealloc
{
    [self removeObserver:self
              forKeyPath:@"document.isFavorite"];
    [self removeObserver:self
              forKeyPath:@"document.title"];
}
- (void)toggleIsFavorite
{
    self.document.isFavorite = !self.document.isFavorite;
}
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    if ([keyPath isEqualToString:@"document.isFavorite"]) {
        self.isFavoriteButton.selected = self.document.isFavorite;
    } else if ([keyPath isEqualToString:@"document.title"]) {
        self.titleLabel.text = self.document.title;
    }
}
@end


Старый (но не очень добрый) разбор случаев в одном методе со сравнением строк, продублированных в двух других местах. Это не только некрасиво, но и чревато ошибками, как и любая другая «копипаста».

На этом можно бы и остановиться, понадеявшись, что ничего плохого не произойдет, и всё будет работать. И сейчас оно действительно будет работать. Но рано или поздно что-то плохое все-таки может случиться, и после пары часов в отладчике мы укрепимся в убеждении, что с KVO лучше не связываться.
Что же может произойти? Немного усложним наш пример, и предположим, что мы решили сделать еще одну таблицу для отображения наших документов, но с чуть более «навороченными» ячейками, которые так же будут содержать заголовок документа и такую же кнопку, но наряду с другими изменениями будут менять цвет фона в зависимости от того, является ли документ избранным.
Чтобы уже проделанная работа не пропала даром, мы решаем унаследовать новую ячейку от старой.
А чтобы менять фон ячейки, мы используем ту же технику KVO:

@implementation ETRAdvancedDocumentCell
- (void)awakeFromNib
{
    [super awakeFromNib];
    [self addObserver:self
           forKeyPath:@"document.isFavorite"
              options:0
              context:NULL];
}
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    [self updateBackgroundColor];
}
...


Отлично, фон меняет цвет. Вот только кнопка перестала выделяться, заголовок перестал обновляться, да и updateBackgroundColor вызывается как-то уж слишком часто. Очевидно, ETRAdvancedDocumentCell получает сообщения observeValueForKeyPath, относящиеся как к собственному наблюдению, так и к наблюдениям ETRDocumentCell. Что же на этот счет написано в документации? В комментарии внутри кода одного из примеров находим следующие строки:
Be sure to call the superclass's implementation *if it implements it*.
NSObject does not implement the method.

Мы, конечно же, знаем, что ETRDocumentCell реализует observeValueForKeyPath, а значит нужно вызывать
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context] из ETRAdvancedDocumentCell.

Но вызовом реализации из родительского класса всё не ограничивается. Следует обработать изменения, на которые подписан сам ETRAdvancedDocumentCell, и передать родительскому классу только прочие изменения. Очевидно, одними проверками значений keyPath и object не обойтись: родительский класс подписан на точно тот же самый keyPath (document.isFavorite) того же самого объекта (self). Именно здесь проявляется то самое «истинное» предназначение аргумента context.

static void* ETRAdvancedDocumentCellIsFavoriteContext = &ETRAdvancedDocumentCellIsFavoriteContext;
@implementation ETRAdvancedDocumentCell
- (void)awakeFromNib
{
    [super awakeFromNib];
    [self addObserver:self
           forKeyPath:@"document.isFavorite"
              options:0
              context:ETRAdvancedDocumentCellIsFavoriteContext];
}
- (void)dealloc
{
    [self removeObserver:self
              forKeyPath:@"document.isFavorite"
                 context:ETRAdvancedDocumentCellIsFavoriteContext];
}
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    if (context == ETRAdvancedDocumentCellIsFavoriteContext) {
        [self updateBackgroundColor];
    } else {
        [super observeValueForKeyPath:keyPath
                             ofObject:object
                               change:change
                              context:context];
    }
}
...


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

Очевидно, что прекращать наблюдение следует тоже с указанием контекста. Любопытен тот факт, что соответствующий метод был добавлен только в iOS 5, и до этого существовал только вариант без аргумента context. Это делало невозможным корректное прекращение одного из неотличимых по прочим параметрам наблюдений.

Но как же быть с ETRDocumentCell: нужно ли вызывать super из него? Реализует ли класс UITableViewCell селектор observeValueForKeyPath? Можно прибегнуть к методу проб и ошибок, попытаться вызвать super, получить ожидаемое падение с исключением

*** Terminating app due to uncaught exception 'NSInternalInconsistencyException',
reason: '<ETRDocumentCell: 0x8d3c540; baseClass = UITableViewCell;
frame = (0 0; 320 64); autoresize = W; layer = <CALayer: 0x8d3c730>>:
An -observeValueForKeyPath:ofObject:change:context: message was received but not handled.
Key path: document.title
Observed object: <ETRDocumentCell: 0x8d3c540; baseClass = UITableViewCell;
frame = (0 0; 320 64); autoresize = W; layer = <CALayer: 0x8d3c730>>
Change: {
kind = 1;
}
Context: 0x0'
*** First throw call stack:
(
0 CoreFoundation 0x0173b5e4 __exceptionPreprocess + 180
1 libobjc.A.dylib 0x014be8b6 objc_exception_throw + 44
2 CoreFoundation 0x0173b3bb +[NSException raise:format:] + 139
3 Foundation 0x0118863f -[NSObject(NSKeyValueObserving) observeValueForKeyPath:ofObject:change:context:] + 94
4 ETRKVO 0x00002e35 -[ETRDocumentCell observeValueForKeyPath:ofObject:change:context:] + 229
5 Foundation 0x0110d8c7 NSKeyValueNotifyObserver + 362
6 Foundation 0x0110f206 NSKeyValueDidChange + 458


и убрать вызов обратно. Но где гарантия того, что родительский класс не начнет (или наоборот перестанет) реализовывать observeValueForKeyPath в следующей версии? Даже если родительский класс реализуете вы сами, вы рискуете забыть добавить или убрать вызов super в дочерних классах. Наиболее надежным решением было бы выполнять соответствующую проверку во время исполнения. Делается это вовсе не с помощью вызова [super respondsToSelector:...], который всегда вернет YES, так как наш класс не переопределяет respondsToSelector:, и вызывать его на super — все равно, что вызывать на self. Делается это с помощью чуть более длинного выражения [[ETRDocumentCell superclass] instancesRespondToSelector:...]. Но как выясняется, документация нас обманывает, и [[NSObject class] instancesRespondToSelector:@selector(observeValueForKeyPath:ofObject:change:context:)] возвращает YES, при чем соответствующая реализация как раз таки и ответственна за приведенное выше исключение. Выходит, что у нас есть два варианта: либо никогда не вызывать super и рисковать сломать логику родительского класса, либо вызывать super только для наблюдений, гарантированно не вызванных наших кодом, рискуя получить исключение, пропустив что-нибудь лишнее.

static void* ETRDocumentCellIsFavoriteContext = &ETRDocumentCellIsFavoriteContext;
static void* ETRDocumentCellTitleContext = &ETRDocumentCellTitleContext;
@implementation ETRDocumentCell
- (void)awakeFromNib
{
    [super awakeFromNib];
    [self addObserver:self
           forKeyPath:@"document.isFavorite"
              options:0
              context:ETRDocumentCellIsFavoriteContext];
    [self addObserver:self
           forKeyPath:@"document.title"
              options:0
              context:ETRDocumentCellTitleContext];
}
- (void)dealloc
{
    [self removeObserver:self
              forKeyPath:@"document.isFavorite"
                 context:ETRDocumentCellIsFavoriteContext];
    [self removeObserver:self
              forKeyPath:@"document.title"
                 context:ETRDocumentCellTitleContext];
}
- (void)toggleIsFavorite
{
    self.document.isFavorite = !self.document.isFavorite;
}
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    if (context == ETRDocumentCellIsFavoriteContext) {
        self.isFavoriteButton.selected = self.document.isFavorite;
    } else if (context == ETRDocumentCellTitleContext) {
        self.titleLabel.text = self.document.title;
    } else {
        [super observeValueForKeyPath:keyPath
                             ofObject:object
                               change:change
                              context:context];
    }
}
@end


Из приведенного примера следует, что для корректной реализации KVO необходимо сделать множество нетривиальных и неочевидных действий. При чем они должны быть сделаны консистентным образом на всех уровнях наследования, что не находится во власти разработчика, если какие-то из этих уровней реализованы в стандартных либо сторонних библиотеках, либо если сам продукт является библиотекой, предполагающей наследование от некоторых из ее классов.
Кроме того, программисту необходимо четко отслеживать в классе наблюдателя все активные наблюдения, чтобы гарантированно обработать их в observeValueForKeyPath и остановить их в нужный момент (например, при удалении наблюдателя). Это осложняется разнесенность связанного кода по нескольким местам (определение контекстов, добавление, удаление и обработка наблюдений) и усугубляется тем фактом, что проверить наличие наблюдения невозможно, а попытка остановить несуществующее наблюдение приводит к исключению:

*** Terminating app due to uncaught exception 'NSRangeException',
reason: 'Cannot remove an observer <ETRAdvancedDocumentCell 0x1566cdd0>
for the key path «document.title» from <ETRAdvancedDocumentCell 0x1566cdd0>
because it is not registered as an observer.'

Зачастую можно встретить UIViewController'ы, добавляющие себя в качестве наблюдателя внутри реализации одного из методов viewDidLoad, vewDidUnload, viewWillAppear, viewDidAppear, viewWillDisappear или viewDidDisappear, и прекращающие наблюдения в другом из этих методов. При этом никто не гарантирует строгую парность этих вызовов, особенно при использовании custom container view controllers, особенно с shouldAutomaticallyForwardAppearanceMethods, возвращающим NO. В частности, логика этих вызовов для контроллеров, содержащихся в стеке UINavigationController, изменилась в iOS 7 с введением интерактивного жеста перехода назад по стеку навигации. Да и ссылка на объект, передаваемый в качестве наблюдаемого, может измениться между этими вызовами.
В результате некоторые разработчики даже всерьез предлагают использовать решения вроде следующего:

@try {
    [self.document removeObserver:self
                       forKeyPath:@"isFavorite"
                          context:DocumentCellIsFavoriteContext];
}
@catch (NSException *exception) {}


Когда я вижу нечто подобное, я вспоминаю, как в детстве я писал на Visual Basic строчку «On Error Resume Next», и мои «творения» чудесным образом переставали падать.

Из всего написанного следует, что KVO — очень мощная технология, доступ к которой мы имеем посредством API, который не только неудобен, но и смертельно опасен для использующих его приложений. Подобные ситуации не редки в сфере программирования, и верным выходом из них служит написание более удобного и безопасного интерфейса, изолирующего и нейтрализующего все недостатки внутри своей реализации.

В случае KVO корень проблем как с наследованием, так и с removeObserver, заключается в том, что отдельно взятое наблюдение теряет для программиста свою идентичность после его добавления. Вместо того, чтобы прекращать «конкретно вот это наблюдение», разработчик оказывается вынужден требовать прекратить «какое-нибудь наблюдение, соответствующее указанным критериям». При этом таких наблюдений может быть несколько, или не быть вовсе. То же самое происходит и в реализации observeValueForKeyPath: когда недостаточно различать наблюдения по объекту и ключу, приходится прибегать к специфическим контекстам. Но даже контекст определяет не конкретный акт добавления наблюдения, а всего лишь строку кода, в которой он совершается. Если одна и та же строка кода будет вызвана два раза с теми же наблюдателем, наблюдаемым объектом и key path, нельзя будет различить последствия этих двух вызовов. Аналогичным образом, проблемы при наследовании вызываются еще и тем, что родительский и дочерний классы оказываются связаны в деталях реализации ими KVO (которые должны быть надежно инкапсулированы), поскольку их объект — это один и тот же наблюдатель с точки зрения KVO.

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

@interface ETRKVO : NSObject
@property (nonatomic, unsafe_unretained, readonly) id subject;
@property (nonatomic, copy, readonly) NSString *keyPath;
@property (nonatomic, copy) void (^block)(ETRKVO *kvo, NSDictionary *change);
- (id)initWithSubject:(id)subject
              keyPath:(NSString *)keyPath
              options:(NSKeyValueObservingOptions)options
                block:(void (^)(ETRKVO *kvo, NSDictionary *change))block;
- (void)stopObservation;
@end

static void* ETRKVOContext = &ETRKVOContext;
@implementation ETRKVO
- (id)initWithSubject:(id)subject
              keyPath:(NSString *)keyPath
              options:(NSKeyValueObservingOptions)options
                block:(void (^)(ETRKVO *kvo, NSDictionary *change))block
{
    self = [super init];
    if (self) {
        _subject = subject;
        _keyPath = [keyPath copy];
        _block = [block copy];
        [subject addObserver:self
                  forKeyPath:keyPath
                     options:options
                     context:ETRKVOContext];
    }
    return self;
}
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    if (context == ETRKVOContext) {
        if (self.block)
            self.block(self, change);
    } // NSObject does not implement observeValueForKeyPath
}
- (void)stopObservation
{
    [self.subject removeObserver:self
                      forKeyPath:self.keyPath
                         context:ETRKVOContext];
    _subject = nil;
}
- (void)dealloc
{
    [self stopObservation];
}
@end


Альтернативные решения можно найти в библиотеке ReactiveCocoa, претендующей на радикальный сдвиг парадигмы программирования на Objective C, и в несколько устаревшем MAKVONotificationCenter.
Кроме того, аналогичные изменения по тем же причинами были сделаны в NSNotificationCenter: в iOS 4 был добавлен метод addObserverForName:object:queue:usingBlock:, возвращающий объект, идентифицирующий подписку на оповещения.

Интерфейс ETRKVO можно несколько упростить, рассмотрев поведение аргументов options и change.
NSKeyValueObservingOptions является битовой маской, которая может объединять следующие флаги:
  • NSKeyValueObservingOptionNew
  • NSKeyValueObservingOptionOld
  • NSKeyValueObservingOptionInitial
  • NSKeyValueObservingOptionPrior


Первые два указывают на то, что в аргументе change должны присутствовать старое и новое значения наблюдаемого атрибута. Никаких отрицательных последствий это вызывать не может, если не считать незначительное замедление.
Указание NSKeyValueObservingOptionInitial приводит к тому, что observeValueForKeyPath будет вызван сразу же при добавлении наблюдения, что, вообще говоря, бесполезно.
Указание NSKeyValueObservingOptionPrior приводит к тому, что observeValueForKeyPath будет вызван не только после изменения значения, но и перед ним. При этом новое значение передано не будет, даже если указан флаг NSKeyValueObservingOptionNew. Необходимость в этом можно встретить крайне редко, и скорее всего она возникает только в процессе реализации какого-нибудь «костыля».
Следовательно, можно всегда передавать в качестве опций (NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld).

Аргумент (NSDictionary *)change может содержать следующие ключи:
  • NSKeyValueChangeNewKey
  • NSKeyValueChangeOldKey
  • NSKeyValueChangeKindKey
  • NSKeyValueChangeIndexesKey
  • NSKeyValueChangeNotificationIsPriorKey


Первые два содержат те самые старое и новое значения, которые можно запросить соответствующими опциями. Значения скалярных типов оборачиваются в NSNumber либо NSValue, а вместо nil передается синглтонный объект [NSNull null].
Следующие два нужны только при наблюдении за мутабельной коллекцией, что скорее всего является плохой идеей.
Последний ключ передается только при предшествующем изменению вызове, выполняемом при наличии опции NSKeyValueObservingOptionPrior.
Следовательно, можно рассматривать только ключи NSKeyValueChangeNewKey и NSKeyValueChangeOldKey, и передавать блоку их значения в развернутом виде.
Таким образом, ETRKVO можно изменить следующим образом:

- (id)initWithSubject:(id)subject
              keyPath:(NSString *)keyPath
                block:(void (^)(ETRKVO *kvo, id oldValue, id newValue))block
{
    self = [super init];
    if (self) {
        _subject = subject;
        _keyPath = [keyPath copy];
        _block = [block copy];
        [subject addObserver:self
                  forKeyPath:keyPath
                     options:(NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld)
                     context:ETRKVOContext];
    }
    return self;
}
- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    if (context == ETRKVOContext) {
        if (self.block) {
            id oldValue = change[NSKeyValueChangeOldKey];
            if (oldValue == [NSNull null])
                oldValue = nil;
            id newValue = change[NSKeyValueChangeNewKey];
            if (newValue == [NSNull null])
                newValue = nil;
            self.block(self, oldValue, newValue);
        }
    } // NSObject does not implement observeValueForKeyPath
}

При желании его можно оформить в виде категории над NSObject, избавившись от первого аргумента:

- (ETRKVO *)observeKeyPath:(NSString *)keyPath
                 withBlock:(void (^)(ETRKVO *kvo, id oldValue, id newValue))block;


Поскольку часто keyPath является названием property, совпадающим с соответствующим getter'ом, вместо строки keyPath удобнее использовать селектор этого getter'а. При этом будет работать autocompletion, и меньше будет вероятность допустить ошибку при написании, либо при переименовании property.

- (ETRKVO *)observeSelector:(SEL)selector
                  withBlock:(void (^)(ETRKVO *kvo, id oldValue, id newValue))block
{
    return [[ETRKVO alloc] initWithSubject:self
                                   keyPath:NSStringFromSelector(selector)
                                     block:block];
}


Перепишем наши ячейки с использованием этого класса и категории

@interface ETRDocumentCell ()
@property (nonatomic, strong) ETRKVO* isFavoriteKVO;
@property (nonatomic, strong) ETRKVO* titleKVO;
@end

@implementation ETRDocumentCell
- (void)awakeFromNib
{
    [super awakeFromNib];
    typeof(self) __weak weakSelf = self;
    self.isFavoriteKVO = [self observeKeyPath:@"document.isFavorite"
                                    withBlock:^(ETRKVO *kvo, id oldValue, id newValue)
                          {
                              weakSelf.isFavoriteButton.selected = weakSelf.document.isFavorite;
                          }];
    self.titleKVO = [self observeKeyPath:@"document.title"
                               withBlock:^(ETRKVO *kvo, id oldValue, id newValue)
                     {
                         weakSelf.titleLabel.text = weakSelf.document.title;
                     }];
}
- (void)dealloc
{
    [self.isFavoriteKVO stopObservation];
    [self.titleKVO stopObservation];
}
- (void)toggleIsFavorite
{
    self.document.isFavorite = !self.document.isFavorite;
}
@end

@interface ETRAdvancedDocumentCell ()
@property (nonatomic, strong) ETRKVO* advancedIsFavoriteKVO;
@end

@implementation ETRAdvancedDocumentCell
- (void)awakeFromNib
{
    [super awakeFromNib];
    typeof(self) __weak weakSelf = self;
    self.advancedIsFavoriteKVO = [self observeKeyPath:@"document.isFavorite"
                                            withBlock:^(ETRKVO *kvo, id oldValue, id newValue)
                                  {
                                      [weakSelf updateBackgroundColor];
                                  }];
}
- (void)dealloc
{
    [self.advancedIsFavoriteKVO stopObservation];
}
...


Полную реализацию ETRKVO вместе с примером можно скачать здесь

Единственным неочевидным приемом здесь является использование weakSelf для предотвращения утечек памяти. Если бы блоки захватывали self по сильной ссылке, образовывался бы цикл сильных ссылок: ETRDocumentCell → isFavoriteKVO → блок → ETRDocumentCell. Однако если вы активно используете блоки, захват объектов по слабым ссылкам уже должен войти у вас в привычку.

Стоит заметить, что хотя объекты класса ETRKVO и удаляются после того, как ячейки теряют ссылки на них (удаляются сами), и при подсчете ссылок не возникает эффектов вроде ожидания сборки мусора, удаление тем не менее может произойти не сразу, если ссылка попала в autorelease pool. Поэтому следует всегда вручную вызывать stopObservation до того, как окажутся удалены объект ETRKVO или наблюдаемый объект. При использовании одной и той же property для последовательности различных наблюдений, удобно вызывать stopObservation в ее сеттере.

- (void)setIsFavoriteKVO:(ETRKVO *)isFavoriteKVO
{
    [_isFavoriteKVO stopObservation];
    _isFavoriteKVO = isFavoriteKVO;
}
- (void)dealloc
{
    self.isFavoriteKVO = nil;
}


Требования по ручному прекращению наблюдений можно было бы ослабить, если бы автоматический подсчет ссылок умел обнулять слабые ссылки KVO compliant способом, то есть так, чтобы об этом оказывались оповещены объекты, наблюдающие за их значениями. На данный момент, в iOS 7, это невозможно (если не рассматривать «грязных трюков» вроде подмены реализации метода dealloc).

Не следует забывать, что обработчик изменения вызывается в том же потоке исполнения, в котором происходит это изменение. Если наблюдение за объектом, который может быть изменен из другого потока, оправдано, а не является следствием легкомысленного отношения к многопоточности, то код обработчика обычно следует обернуть в dispatch_async. При этом следует обратить особое внимание на то, чтобы внешний блок не захватывал по сильным ссылками объекты, относящиеся к строго определенному потоку (например UIView, UIViewController или NSManagedObject), так как это может привести к так называемой deallocation problem.

Если обработчик не срабатывает при изменении наблюдаемого значения, скорее всего наблюдаемый атрибут не является KVO compliant. Как сделать его таковым, исчерпывающе описано в разделах документации KVO Compliance и Registering Dependent Keys. Стоит отдельно оговорить, что даже если вы не используете у property стандартный (синтезированный) property setter, а определяете setter сами, эта property останется KVO compliant.

Даже будучи осведомленными о всех потенциальных опасностях KVO, и нейтрализовав часть из них, не следует бездумно использовать KVO при любом случае. Злоупотребление любой технологией приводит к явлению, называемому «[Имя технологии] hell». Хоть связи между объектами, создаваемые с помощью KVO, и выглядят очень слабыми, выйдя из-под контроля, они могут очень больно ударить. В нашем случае «KVO hell» может выражаться в непредсказуемых лавинообразных срабатываниях обработчиков наблюдений, приводящих к неожиданным последствиям и убивающих производительность, или даже в циклических вызовах, завершающихся переполнением стека.

  1. TIOBE Programming Community Index for November 2013
  2. Key-Value Coding Programming Guide
  3. Key-Value Observing Programming Guide
  4. Blocks Programming Topics
  5. Transitioning to ARC Release Notes
  6. Concepts in Objective-C Programming: Delegates and Data Sources
  7. Programming with Objective-C: Working with Protocols
  8. Concepts in Objective-C Programming: Target-Action
  9. stackoverflow: How to cancel NSBlockOperation
  10. Notification Programming Topics
  11. NSKeyValueObserving Protocol Reference
  12. iOS Debugging Magic
  13. NSHipster: Key-Value Observing
  14. ReactiveCocoa
  15. MAKVONotificationCenter
  16. Weak properties KVO compliance
  17. Method Swizzling
  18. Grand Central Dispatch (GCD) Reference
  19. Simple and Reliable Threading with NSOperation
Tags:
Hubs:
+11
Comments 6
Comments Comments 6

Articles

Information

Website
www.trueengineering.ru
Registered
Founded
Employees
101–200 employees
Location
Россия