IOS 7.0 и ARC: UITableView никогда не освобождается после анимации строк

У меня очень простое тестовое приложение с ARC. Один из контроллеров представлений содержит UITableView. После создания анимации строк (insertRowsAtIndexPaths или deleteRowsAtIndexPaths) UITableView (и все ячейки) никогда не освобождается. Если я использую reloadData, он отлично работает. Нет проблем на iOS 6, только iOS 7.0. Любые идеи, как исправить эту утечку памяти?

-(void)expand {

    expanded = !expanded;

    NSArray* paths = [NSArray arrayWithObjects:[NSIndexPath indexPathForRow:0 inSection:0], [NSIndexPath indexPathForRow:1 inSection:0],nil];

    if (expanded) {
        //[table_view reloadData];
        [table_view insertRowsAtIndexPaths:paths withRowAnimation:UITableViewRowAnimationMiddle];
    } else {
        //[table_view reloadData];
        [table_view deleteRowsAtIndexPaths:paths withRowAnimation:UITableViewRowAnimationMiddle];
    }
}

-(int)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {

    return expanded ? 2 : 0;
}

table_view - это класс класса TableView (подкласс UITableView):

@implementation TableView

static int totalTableView;

- (id)initWithFrame:(CGRect)frame style:(UITableViewStyle)style
{
    if (self = [super initWithFrame:frame style:style]) {

        totalTableView++;
        NSLog(@"init tableView (%d)", totalTableView);
    }
    return self;
}

-(void)dealloc {

    totalTableView--;
    NSLog(@"dealloc tableView (%d)", totalTableView);
}

@end

Ответ 1

Хорошо, если вы копаете немного глубже (отключите ARC, подкласс tableview, переопределите методы keep/release/dealloc, затем поместите на них журналы/точки останова), вы обнаружите, что что-то не так происходит в блоке завершения анимации, который, возможно, вызывает утечку.
Похоже, что tableview получает слишком много сохранений из блока завершения после вставки/удаления ячеек в iOS 7, но не на iOS 6 (на iOS 6 UITableView еще не использовалась блочная анимация - вы также можете проверить ее на трассировке стека).

Итак, я пытаюсь взять на себя цикл жизненного цикла завершения анимации tableview из UIView грязным способом: метод swizzling. И это фактически решает проблему.
Но это делает намного больше, поэтому я все еще ищу более сложное решение.

Так расширяйте UIView:

@interface UIView (iOS7UITableViewLeak)
+ (void)fixed_animateWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay options:(UIViewAnimationOptions)options animations:(void (^)(void))animations completion:(void (^)(BOOL finished))completion;
+ (void)swizzleStaticSelector:(SEL)selOrig withSelector:(SEL)selNew;
@end
#import <objc/runtime.h>

typedef void (^CompletionBlock)(BOOL finished);

@implementation UIView (iOS7UITableViewLeak)

+ (void)fixed_animateWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay options:(UIViewAnimationOptions)options animations:(void (^)(void))animations completion:(void (^)(BOOL finished))completion {
    __block CompletionBlock completionBlock = [completion copy];
    [UIView fixed_animateWithDuration:duration delay:delay options:options animations:animations completion:^(BOOL finished) {
        if (completionBlock) completionBlock(finished);
        [completionBlock autorelease];
    }];
}

+ (void)swizzleStaticSelector:(SEL)selOrig withSelector:(SEL)selNew {
    Method origMethod = class_getClassMethod([self class], selOrig);
    Method newMethod = class_getClassMethod([self class], selNew);
    method_exchangeImplementations(origMethod, newMethod);
}

@end

Как вы можете видеть, исходный блок завершения не передается непосредственно в метод animateWithDuration: и он корректно выпущен из блока обертки (отсутствие этого вызывает утечки в табличных представлениях). Я знаю, что это выглядит немного странно, но это решает проблему.

Теперь замените исходную реализацию анимации на новую в App Delegate didFinishLaunchingWithOptions: или где хотите:

[UIView swizzleStaticSelector:@selector(animateWithDuration:delay:options:animations:completion:) withSelector:@selector(fixed_animateWithDuration:delay:options:animations:completion:)];

После этого все вызовы [UIView animateWithDuration:...] приводят к этой модифицированной реализации.

Ответ 2

Я отлаживал утечку памяти в своем приложении, которая оказалась такой же утечкой, и в итоге она достигла того же вывода, что и @gabbayabb - блок завершения анимации, используемый UITableView, никогда не освобождается, и это имеет сильную ссылку на представление таблицы, то есть никогда не освобождается. Шахта произошла с простой пары вызовов [tableView beginUpdates]; [tableView endUpdates];, между которыми нет ничего. Я обнаружил, что отключение анимации ([UIView setAnimationsEnabled:NO]...[UIView setAnimationsEnabled:YES]) вокруг вызовов предотвращает утечку - блок в этом случае вызывается непосредственно UIView и никогда не копируется в кучу и поэтому никогда не создает сильную ссылку на представление таблицы в первую очередь. Если вам не нужна анимация, этот подход должен работать. Если вам нужна анимация, хотя... или подождите, пока Apple ее исправит, и вы живете с утечкой, или попытайтесь решить или смягчить утечку, используя некоторые методы, такие как подход by @gabbayabb выше.

Этот подход работает, завершая блок завершения очень маленьким и управляя ссылками на исходный блок завершения вручную. Я подтвердил это, и исходный блок завершения освобождается (и соответствующим образом выделяет все его сильные ссылки). Небольшой блок-оболочка все еще течет до тех пор, пока Apple не исправит ошибку, но это не сохранит никаких других объектов, поэтому это будет относительно небольшая утечка в сравнении. Тот факт, что этот подход работает, указывает на то, что проблема на самом деле находится в UIView-коде, а не в UITableView, но при тестировании я еще не обнаружил, что любой из других вызовов этого метода теряет свои блоки завершения - кажется, это только UITableView из них. Кроме того, похоже, что анимация UITableView имеет кучу вложенных анимаций (по одному для каждого раздела или строки), и каждый из них имеет ссылку на представление таблицы. С моим более сложным исправлением ниже я обнаружил, что мы принудительно распоряжались примерно двенадцатью пропущенными блоками завершения (для небольшой таблицы) для каждого вызова, чтобы начать /endUpdates.

Версия решения @gabbayabb (но для ARC):

#import <objc/runtime.h>

typedef void (^CompletionBlock)(BOOL finished);

@implementation UIView (iOS7UITableViewLeak)

+ (void)load
{
    if ([UIDevice currentDevice].systemVersion.intValue >= 7)
    {
        Method animateMethod = class_getClassMethod(self, @selector(animateWithDuration:delay:options:animations:completion:));
        Method replacement = class_getClassMethod(self, @selector(_leakbugfix_animateWithDuration:delay:options:animations:completion:));
        method_exchangeImplementations(animateMethod, replacement);
    }
}

+ (void)_leakbugfix_animateWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay
                                options:(UIViewAnimationOptions)options
                             animations:(void (^)(void))animations
                             completion:(void (^)(BOOL finished))completion
{
    CompletionBlock realBlock = completion;

    /* If animations are off, the block is never copied to the heap and the leak does not occur, so ignore that case. */
    if (completion != nil && [UIView areAnimationsEnabled])
    {
        /* Copy to ensure we have a handle to a heap block */
        __block CompletionBlock completionBlock = [completion copy];

        CompletionBlock wrapperBlock = ^(BOOL finished)
        {
            /* Call the original block */
            if (completionBlock) completionBlock(finished);
            /* Nil the last reference so the original block gets dealloced */
            completionBlock = nil;
        };

        realBlock = [wrapperBlock copy];
    }

    /* Call the original method (name changed due to swizzle) with the wrapper block (or the original, if no wrap needed) */
    [self _leakbugfix_animateWithDuration:duration delay:delay options:options animations:animations completion:realBlock];
}

@end

Это в основном идентично решению @gabbayabb, за исключением того, что это делается с помощью ARC, и избегает выполнения какой-либо дополнительной работы, если начальное заполнение начинается с нуля или если анимация отключена. Это должно быть безопасным, и, хотя оно не полностью устраняет утечку, оно резко снижает воздействие.

Если вы хотите попытаться устранить утечку блоков-оболочек, то должно работать следующее:

#import <objc/runtime.h>

typedef void (^CompletionBlock)(BOOL finished);

/* Time to wait to ensure the wrapper block is really leaked */
static const NSTimeInterval BlockCheckTime = 10.0;


@interface _IOS7LeakFixCompletionBlockHolder : NSObject
@property (nonatomic, weak) CompletionBlock block;
- (void)processAfterCompletion;
@end

@implementation _IOS7LeakFixCompletionBlockHolder

- (void)processAfterCompletion
{        
    /* If the block reference is nil, it dealloced correctly on its own, so we do nothing.  If it still here,
     * we assume it was leaked, and needs an extra release.
     */
    if (self.block != nil)
    {
        /* Call an extra autorelease, avoiding ARC attempts to foil it */
        SEL autoSelector = sel_getUid("autorelease");
        CompletionBlock block = self.block;
        IMP autoImp = [block methodForSelector:autoSelector];
        if (autoImp)
        {
            autoImp(block, autoSelector);
        }
    }
}
@end

@implementation UIView (iOS7UITableViewLeak)

+ (void)load
{
    if ([UIDevice currentDevice].systemVersion.intValue >= 7)
    {
        Method animateMethod = class_getClassMethod(self, @selector(animateWithDuration:delay:options:animations:completion:));
        Method replacement = class_getClassMethod(self, @selector(_leakbugfix_animateWithDuration:delay:options:animations:completion:));
        method_exchangeImplementations(animateMethod, replacement);
    }
}

+ (void)_leakbugfix_animateWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay
                                options:(UIViewAnimationOptions)options
                             animations:(void (^)(void))animations
                             completion:(void (^)(BOOL finished))completion
{
    CompletionBlock realBlock = completion;

    /* If animations are off, the block is never copied to the heap and the leak does not occur, so ignore that case. */
    if (completion != nil && [UIView areAnimationsEnabled])
    {
        /* Copy to ensure we have a handle to a heap block */
        __block CompletionBlock completionBlock = [completion copy];

        /* Create a special object to hold the wrapper block, which we can do a delayed perform on */
        __block _IOS7LeakFixCompletionBlockHolder *holder = [_IOS7LeakFixCompletionBlockHolder new];

        CompletionBlock wrapperBlock = ^(BOOL finished)
        {
            /* Call the original block */
            if (completionBlock) completionBlock(finished);
            /* Nil the last reference so the original block gets dealloced */
            completionBlock = nil;

            /* Fire off a delayed perform to make sure the wrapper block goes away */
            [holder performSelector:@selector(processAfterCompletion) withObject:nil afterDelay:BlockCheckTime];
            /* And release our reference to the holder, so it goes away after the delayed perform */
            holder = nil;
        };

        realBlock = [wrapperBlock copy];
        holder.block = realBlock; // this needs to be a reference to the heap block
    }

    /* Call the original method (name changed due to swizzle) with the wrapper block (or the original, if no wrap needed */
    [self _leakbugfix_animateWithDuration:duration delay:delay options:options animations:animations completion:realBlock];
}

@end

Этот подход несколько более опасен. Это то же самое, что и предыдущее решение, за исключением того, что добавляет небольшой объект, который содержит слабую ссылку на блок-оболочку, ждет 10 секунд после завершения анимации и если этот блок-оболочка еще не освобожден (что обычно должно) предполагает, что он просочился и принудительно вызвал дополнительный аутсорсинг. Главная опасность заключается в том, что это предположение неверно, и блок завершения каким-то образом действительно имеет действительную ссылку в другом месте, мы можем вызвать сбой. Это кажется очень маловероятным, так как мы не будем запускать таймер до тех пор, пока не будет вызван исходный блок завершения (что означает, что анимация выполнена), а блоки завершения действительно не должны выжить намного дольше, чем это (и ничего, кроме UIView механизм должен иметь ссылку на него). Существует небольшой риск, но он кажется низким, и это полностью избавляет от утечки.

При дополнительном тестировании я просмотрел значение UIViewAnimationOptions для каждого из вызовов. При вызове UITableView значение параметра равно 0x404, а для всех вложенных анимаций - 0x44. 0x44 в основном UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionOverrideInheritedCurve и кажется ОК - я вижу, что многие другие анимации проходят с таким же значением параметра, а не с утечкой их блоков завершения. 0x404 однако... также имеет UIViewAnimationOptionBeginFromCurrentState, но значение 0x400 эквивалентно (1 < < 10), а задокументированные параметры только поднимаются до (1 < 9) в заголовке UIView.h. Таким образом, UITableView, похоже, использует недокументированную UIViewAnimationOption, и обработка этой опции в UIView вызывает утечку блока завершения (плюс блок завершения всех вложенных анимаций). Это приводит к другому возможному решению:

#import <objc/runtime.h>

enum {
    UndocumentedUITableViewAnimationOption = 1 << 10
};

@implementation UIView (iOS7UITableViewLeak)

+ (void)load
{
    if ([UIDevice currentDevice].systemVersion.intValue >= 7)
    {
        Method animateMethod = class_getClassMethod(self, @selector(animateWithDuration:delay:options:animations:completion:));
        Method replacement = class_getClassMethod(self, @selector(_leakbugfix_animateWithDuration:delay:options:animations:completion:));
        method_exchangeImplementations(animateMethod, replacement);
    }
}

+ (void)_leakbugfix_animateWithDuration:(NSTimeInterval)duration delay:(NSTimeInterval)delay
                                options:(UIViewAnimationOptions)options
                             animations:(void (^)(void))animations
                             completion:(void (^)(BOOL finished))completion
{
    /*
     * Whatever option this is, UIView leaks the completion block, plus completion blocks in all
     * nested animations. So... we will just remove it and risk the consequences of not having it.
     */
    options &= ~UndocumentedUITableViewAnimationOption;
    [self _leakbugfix_animateWithDuration:duration delay:delay options:options animations:animations completion:completion];
}
@end

Этот подход просто исключает недокументированный бит опции и переходит к реальному методу UIView. И это, похоже, работает - UITableView уходит, что означает, что блок завершения освобожден, включая все вложенные блоки завершения анимации. Я понятия не имею, что делает этот вариант, но в свете тестирования вещи, похоже, работают нормально без него. Всегда возможно, что ценность параметра жизненно важна таким образом, который не сразу становится очевидным, что является риском при таком подходе. Это исправление также не является "безопасным" в том смысле, что если Apple исправит свою ошибку, для получения недокументированной опции, восстановленной для анимации табличных представлений, потребуется обновление приложения. Но это предотвращает утечку.

В принципе, хотя... пусть надеется, что Apple исправляет эту ошибку раньше, чем позже.

(Небольшое обновление: сделало одно редактирование, чтобы явно вызвать [wrapperBlock copy] в первом примере - похоже, что ARC не делал этого для нас в сборке Release, и поэтому он разбился, в то время как он работал в сборке Debug.)

Ответ 3

Хорошие новости! Apple исправила эту ошибку с iOS 7.0.3 (выпущена сегодня, 22 октября 2013 г.).

Я тестировал и больше не могу воспроизвести проблему, используя пример проекта @Joachim, предоставленный здесь при запуске iOS 7.0.3: https://github.com/jschuster/RadarSamples/tree/master/TableViewCellAnimationBug

Я также не могу воспроизвести проблему в iOS 7.0.3 в одном из других приложений, которые я разрабатываю, где ошибка вызывала проблемы.

По-прежнему может быть разумным продолжить доставку каких-либо обходных решений на некоторое время, пока большинство пользователей iOS 7 не обновят свои устройства как минимум до 7.0.3 (что может занять пару недель). Ну, это предполагает, что ваши обходные пути безопасны и проверены!