Несколько лет я занимаюсь промышленной разработкой для прекрасной мобильной платформы?—?iOS. За это время я видел много разных приложений, и людей, которые эти приложения делают.

Хорошие разработчики для устройств Apple есть, но я всё равно часто замечаю, что кто-то не знает, как использовать весь потенциал одних из самых популярных мобильных устройств на рынке для создания действительно плавных приложений.

Здесь я постараюсь рассказать о всех приёмах, которые стоит использовать, если вы хотите максимально увеличить производительность отображения информации в UITableView.

Сложность использования материала и его глубина будут увеличиваться далее по тексту, так что начнём мы с вещей, которые большинство всё же знает, и будем продвигаться к менее очевидным приёмам и аспектам работы iOS и UIKit.

Стандартные механизмы


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

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

Подробно останаливаться здесь я не буду по причине наличия неплохого пояснения в документации. Аналогично это работает и для хедеров/футеров секций в таблице.

Скажу лишь, что метод tableView:cellForRowAtIndexPath:, который должен быть реализован у dataSource таблицы, вызывается для каждой ячейки, и должен работать как можно быстрее. Поэтому здесь необходимо лишь максимально быстро вернуть переиспользованный экземпляр ячейки; не надо в этом методе выполнять биндинг данных в эту ячейку, потому что таблица подгружает новые ячейки немного заранее, и в этом месте ячейка еще не видна на экране (но находится уже возле границы).

Для биндинга данных в ячейку используйте метод tableView:willDisplayCell:forRowAtIndexPath: у экземпляра delegate вашей таблицы. Он вызывается прямо перед показом конкретной ячейки внутри bounds таблицы.

Второй, на первый взгляд, тоже кажется несложным в использовании, но ещё пару лет назад часто можно было заметить непонимание принципа его работы, поэтому остановимся на нём подробнее.

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

Как мы знаем, UITableView?—?это наследник класса UIScrollView, расширяющий его функциональность. А любой экземпляр класса UIScrollView для прокрутки расположенного внутри него контента использует такие понятия, как contentSize, contentOffset и другие. И для правильного отображения индикаторов прокрутки значение переменной contentSize, с помощью которого UIScrollView понимает, какого размера контент на самом деле.

Но что же не так с таблицами? В таблице, как описано выше, никогда не находятся все её ячейки сразу, вместо этого они переиспользуются таким образом, чтобы на таблице одновременно находились лишь те ячейки, которые сейчас полностью (или частично) находятся внутри bounds таблицы.

Тем не менее, таблица всегда знает, какой размер занимает весь контент, который она может отобразить, благодаря тому, что её delegate имеет возможность реализовать метод tableView:heightForRowAtIndexPath: и вернуть значение высоты для каждой ячейки, и настолько быстро, насколько это возможно.

Важный момент здесь заключается в том, что для расчёта высоты нельзя инициализировать ячейку (или брать инициализированный экземпляр), выставлять ей необходимые данные, выполнять layout каждой subview внутри ячейки и после всех этих манипуляций возвращать получившееся значение.

Скорость работы этих процедур абсолютно неприемлема, и, в зависимости от сложности и насыщенности самих ячеек, это просадит великолепные 60 fps, стандартные для iOS устройств, до 15–20, что будет вызывать неприятные ощущения при прокрутке даже на маленькой скорости.

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

Расчёт высоты ячейки
+ (CGFloat)preferredHeightForAdapter:(SFSTableViewCellAdapter *)adapter andWidth:(CGFloat)width {
    if ([adapter isKindOfClass:[SFSNotificationCellAdapter class]]) {
        SFSNotificationCellAdapter *cellAdapter = (SFSNotificationCellAdapter *) adapter;
        CGFloat totalHeight = _topAvatarPadding + _subtitleTopBottomPadding;

        CGFloat textWidthAvailable = width - _avatarSideSize - (_leftRightPadding * 2.0f) - _avatarTextGap;
        textWidthAvailable -= [[cellAdapter actionButtonTitle] length] > 0 ? _avatarTextGap : 0.0f;

        if ([[cellAdapter actionButtonTitle] length] > 0) {
            CGFloat buttonWidth = [[cellAdapter actionButtonTitle]
                    boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)
                                 options:NSStringDrawingUsesFontLeading | NSStringDrawingUsesLineFragmentOrigin
                              attributes:@{
                                      NSFontAttributeName : _actionButtonTitleFont()
                              }
                                 context:NULL].size.width;

            textWidthAvailable -= buttonWidth + (2.0f * _leftRightPadding); //button have insets
        }

        CGFloat totalTextHeightAddition = 0.0f;

        CGFloat textStringHeight = 0.0f;
        if ([[cellAdapter textString] length] > 0) {
            textStringHeight += [[cellAdapter textString]
                    boundingRectWithSize:CGSizeMake(textWidthAvailable, CGFLOAT_MAX)
                                 options:NSStringDrawingUsesFontLeading | NSStringDrawingUsesLineFragmentOrigin
                                 context:NULL].size.height;
        }
        totalTextHeightAddition += textStringHeight;
        totalTextHeightAddition += [[cellAdapter subtitleString] length] > 0 ? _subtitleTopBottomPadding : 0.0f;

        CGFloat subtitleStringHeight = 0.0f;

        if ([[cellAdapter subtitleString] length] > 0) {
            subtitleStringHeight += [[cellAdapter subtitleString]
                    boundingRectWithSize:CGSizeMake(textWidthAvailable, CGFLOAT_MAX)
                                 options:NSStringDrawingUsesFontLeading | NSStringDrawingUsesLineFragmentOrigin
                                 context:NULL].size.height;
        }

        totalTextHeightAddition += subtitleStringHeight;

        totalHeight += fmaxf(totalTextHeightAddition, _avatarSideSize);

        return ceilf(totalHeight);
    }

    return [super preferredHeightForAdapter:adapter andWidth:width];
}


А вот так это используется непосредственно для возвращения высоты ячейки таблице:

Возвращение высоты
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
    return ceilf([SFSNotificationCell preferredHeightForAdapter:_notificationsAdapters[(NSUInteger) [indexPath row]]
                                                       andWidth:[tableView bounds].size.width]);
}


Приятно ли такое писать каждый раз? Большинство скажет, что не очень. Но никто и не обещал, что будет легко. Конечно, можно придумать собственные механизмы и наборы классов для расчёта layout, которые будут более простые в использовании, позволят писать более чистый код, но это непростая работа, и не на каждом проекте можно себе это позволить. Пример такого подхода вы можете найти в коде iOS версии Telegram.

Начиная с iOS 8 нам доступен способ автоматически рассчитывать высоты ячеек, вообще не реализуя упомянутый метод в delegate таблицы. Для этого используется механизм AutoLayout и установленное значение переменной rowHeight таблицы в значение UITableViewAutomaticDimension. Более подробно можно прочитать в замечательном ответе на StackOverflow.

Но прежде чем вы примете решение, какой же способ использовать, примите во внимание, что самый производительный?—?первый, ручной способ расчёта высоты.

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

А что же с AutoLayout? Неужто он выполняет расчеты так долго? Возможно, вы удивитесь, но да. Безумно долго, если вы хотите иметь действительно плавную прокрутку на всех актуальных устройствах Apple, срок жизни которых достаточно высок (особенно в сравнении с Android). Причем, чем больше различных subview добавлено в ваши ячейки, тем медленнее будет работать прокрутка.

Что получаем взамен? Отсутствие ручного расчёта высоты, конечно. Можно не тратить время на обдумывание и реализацию, просто делаете клац-клац в Interface Builder и всё.

Причина относительно низкой производительности AutoLayout в том, что под капотом она имеет систему решения линейных уравнений Cassowary. И чем больше элементов лежит в вашей ячейке, тем больше уравнений приходится решать для расчёта высоты будущей ячейки.

Что быстрее: сложить/вычесть/умножить/поделить несколько чисел, или решать системы линейных уравнений? А теперь представьте, что пользователь очень быстро листает таблицу, и для каждой ячейки выполняются все эти безумные расчёты механизма AutoLayout?—?шутка ли?

Итак, правильный способ применения оптимизаций, уже реализованных со стороны инженеров Apple:
  • Переиспользуйте ячейки: для каждого конкретного типа ячейки в вашей таблице должен быть лишь один экземпляр этого типа.
  • Не настраивайте ячейку в методе cellForRowAtIndexPath:, т.к. в этот момент она еще не отображена на экране. Используйте вместого этого метод tableView:willDisplayCell:forRowAtIndexPath: у delegate таблицы.
  • Вычисляйте высоту ячеек максимально быстрым по времени работы способом. Это очень рутинный процесс для разработчика, но он дает потрясающие результаты, особенно на больших количествах ячеек, или сложных элементах внутри таблицы.

Нам нужно идти глубже


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

В этот момент очень легко получить подтормаживающие таблицы, хотя все вышеперечисленные пункты выполнены. Чем больше UIView расположено внутри ячейки, тем больше просаживается FPS при листании таблиц. Но на самом деле, проблема не в количестве subviews, а в том, как они рисуются.

Давайте обратим внимание на свойство UIView под названием opaque. В описании говорится, что оно помогает “системе отрисовки” определить, является ли наша вьюшка прозрачной, или же нет. Если она непрозрачна?—?это позволяет iOS провести оптимизации при отрисовке и увеличить производительность.

Нам нужна производительность, не так ли? Пользователи могут листать таблицы очень интенсивно, использовать функцию scrollsToTop, да и устройство у них может быть не самое последнее и производительное, поэтому ячейки должны уметь рисоваться крайне быстро. Быстрее, чем большинство “обычных” вьюшек в приложении.

Одной из самых медленных операций при отрисовке контента является blending?—?смешивание. Смешивание выполняется с помощью GPU устройства, так как именно эта аппаратная часть сконструирована для таких операций (в том числе).

Как вы уже поняли, одним из способов увеличить производительность отрисовки является сокращение количества операций смешивания. Но чтобы что-то уменьшить, необходимо сначала это что-то измерить. Давайте попробуем.

Запустите ваше приложение в симуляторе, затем выберите пункт “Color Blended Layers” в меню “Debug”. Теперь симулятор будет раскрашивать всё в два цвета: зелёный и красный.

Зелёным цветом выделены области, где не происходит смешивания цветов, и это хорошо.
Красным отмечены области, где iOS приходится смешивать цвета при отрисовке.



Как видите, есть минимум два места в ячейке, для которых производится смешивание цветов, но визуально оно незаметно (и не нужно!).

С каждым из таких случаев нужно разбираться отдельно, применяя разные решения. В моем случае достаточно просто выставить белый фон этим двум элементам и всё станет хорошо.

Но бывают случаи сложнее. Посмотрите на скриншоты ниже: длинные тексты скрываются за градиентами (как в приложении Tumblr), но при этом смешивания цветов не происходит.



Если для отображения градиента использовать CAGradientLayer, то FPS при прокрутке таблицы с 60 упадёт до 25–30 в среднем и 15 минимум на iPhone 6, а при быстрой прокрутке будут заметны неприятные лаги.

Так происходит как раз из-за необходимости производить качественное смешивание содержимого двух разных слоев (один слой от UILabel?—?CATextLayer, а другой?—?наш CAGradientLayer).

При правильном использовании ресурсов CPU и GPU устройства, они всегда нагружены примерно одинаково, а FPS держится на уровне 60 кадров в секунду. Выглядит это примерно так:



Проблемы начинаются тогда, когда устройству необходимо производить много тяжелых операций смешивания?—?GPU нагрузится под завязку, а CPU будет простаивать и не приносить абсолютно никакой пользы.

Большинство разработчиков столкнулись с этой проблемой осенью 2010 года?—?сразу после выхода iPhone 4. Тогда Apple представила революционный дисплей Retina и… абсолютно нереволюционный GPU. Его, конечно, в целом хватало, но словить ситуацию с чрезмерно нагруженным GPU и простаивающим CPU было намного легче, чем когда бы то ни было.

Отголоски этого решения можно увидеть в поведении iPhone 4 на iOS 7?—?там лагают все приложения без исключений, даже самые простые. Хотя, если применить все советы из этой статьи, то даже в таких условиях можно будет получить 60 FPS, хоть и с трудом.

Так что же с этим делать? В принципе, решение напрашивается само собой: давайте рисовать с помощью CPU! Это разгрузит графический чип, чтобы он смог выполнять смешивание там, где без него не обойтись ну-совсем-никак.

Например, у вас есть какие-то анимации с полупрозрачными элементами, и они сделаны с помощью CALayer`ов.

Делается это ручной отрисовкой в методе drawRect: с помощью CoreGraphics:

Ручная отрисовка
- (void)drawRect:(CGRect)rect {
    [super drawRect:rect];

    if ([_adapter isKindOfClass:[SFSHideableTextContainerAdapter class]]) {
        SFSHideableTextContainerAdapter *viewAdapter = (SFSHideableTextContainerAdapter *) _adapter;

        struct CGContext *context = UIGraphicsGetCurrentContext();
        //background
        CGContextSetFillColorWithColor(context, [_bgColor() CGColor]);
        CGContextFillRect(context, rect);

        //text
        CGRect textRect = [self bounds];

        if (_decorateWithLine) {
            textRect.size.width -= _lineWidth + _lineGap;

            textRect.origin.x += _lineWidth + _lineGap;
            textRect.origin.y += _lineCap;

            //line
            CGContextSetStrokeColorWithColor(context, _lineColor().CGColor);
            CGContextSetLineWidth(context, _lineWidth);

            CGContextMoveToPoint(context, 0.0f, 0.0f);
            CGContextAddLineToPoint(context, 0.0f, ceilf([self bounds].size.height));

            CGContextStrokePath(context);
        }

        [_textString drawWithRect:CGRectIntegral(textRect)
                          options:NSStringDrawingUsesFontLeading | NSStringDrawingUsesLineFragmentOrigin
                       attributes:@{
                               NSParagraphStyleAttributeName : _style,
                               NSFontAttributeName : ([viewAdapter textFont] ?
                                       [viewAdapter textFont] : _defaultTextFont()),
                               NSForegroundColorAttributeName : _textColor()
                       }
                          context:NULL];

        //gradient
        if (_canBeExpanded && !_shouldBeExpanded) {
            //draw!
            CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
            CGFloat gradientLocations[] = {0, 1};
            CGGradientRef gradient = CGGradientCreateWithColors(colorSpace,
                    (__bridge CFArrayRef) @[(__bridge id) [_gradientTopColor() CGColor],
                            (__bridge id) [_gradientBottomColor() CGColor]], gradientLocations);

            CGPoint startPoint = CGPointMake(ceilf(CGRectGetMidX(rect)),
                    ceilf(CGRectGetMaxY(rect) - [([viewAdapter textFont] ?
                            [viewAdapter textFont] : _defaultTextFont()) lineHeight] - (_decorateWithLine ? _lineCap : 0.0f)));

            CGPoint endPoint = CGPointMake(ceilf(CGRectGetMidX(rect)),
                    ceilf(CGRectGetMaxY(rect)));

            CGContextDrawLinearGradient(context, gradient, startPoint, endPoint, 0);
        }
    }
}


Приятный ли это код? Да даже я скажу, что не очень. Более того, таким образом не работают стандартные процедуры кеширования, реализованные Apple при отрисовке некоторых UIView. Но именно так мы получаем отсутствие смешивания, разгружаем GPU и в итоге делаем нашу таблицу плавнее.

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

Ключ к успеху в оптимизации процессов смешивания лишь в балансе нагруженности CPU и GPU.

Кратко о том, что нужно предпринять для оптимизации отрисовки ваших данных внутри таблицы:
  • Устраняйте области, где iOS производит ненужное смешивание: не используйте прозрачные фоны там, где это возможно, проверяйте это с помощью iPhone Simulator или Instruments; градиенты лучше делать без прозрачности, если доступно.
  • Оптимизируйте баланс нагруженности CPU и GPU устройства: решите, для каких красивостей вам не обойтись без графического чипа, а какую часть отрисовки можно посчитать на CPU.
  • Пишите специализированный код для каждого типа ячеек.

Охота на пиксели


Вы знаете, как выглядят пиксели? В смысле, как они выглядят в физическом виде? Уверен, что знаете, но всё же напомню:



Разные экраны сделаны по-разному, но есть одна деталь, которая во всех устройствах Apple (да почти во всём мире) имеет место быть.

Дело в том, что каждый пиксель экрана физически состоит из трёх субпикселей: красного, зелёного и синего. То есть это?—?не атомарная единица, хоть она и является таковой для прикладных разработчиков. Или не является?

До времен iPhone 4 и Retina экранов любой физический пиксель мог быть описан парой координат в целых числах. С появлением экрана с более высокой плотностью пикселей, Apple сохранила обратную совместимость и ввела понятие экранных точек. Экранные точки могут быть описаны как целыми числами, так и дробными.

В идеальном мире (какой мы с вами и хотим построить) экранные точки адресуются в физические пиксели таким образом, что линии не проходят посередине физического пикселя, и не заставляют iOS выполнять сглаживание. Оно уместно при работе с текстом, но может быть нежелательным, если вы рисуете прямую линию.

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

Как получить проблемы с нежелательным сглаживанием? Чаще всего вариантов немного: либо вы используете рассчитанные кодом координаты вьюшек, которые вышли дробными, либо у вас неправильного размера картинки для экранов высокого разрешения (вместо 60x60 для Retina у вас 60x61).

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

Запускаем приложение в симуляторе, идём в меню “Debug”, выбираем пункт “Color Misaligned Images”.

На этот раз двумя цветами выделяются такие области: розовым?—?“половинные” пиксели (те места, где выполняется сглаживание), жёлтым?—?изображения, которые не совпадают по размерам с той областью, в которой они были отрисованы.



К жёлтым областям мы еще вернёмся, а сейчас поговорим про розовые.

Как найти место в коде, из-за которого происходит такое? Я всегда использую ручной layout с вкраплениями ручной отрисовки (см. выше), поэтому найти дробные координаты обычно не составляет труда. Если вы используете Inteface Builder, то я вам по-доброму сочувствую.

Вообще, справиться с этим достаточно просто: после вычисления координат округляйте их с помощью ceilf, floorf, CGRectIntegral.

По результатам охоты посоветую делать следующее:
  • Не используйте дробные координаты точек, дробные значения высоты/ширины визуальных элементов.
  • Следите за своими ресурсами: картинки должны быть pixel-perfect, иначе для экранов высокого разрешения они постоянно будут приходиться на середины пикселей.
  • Постоянно проверяйте, всё ли у вас хорошо. Ситуация меняется намного чаще, чем в случае со смешиванием цветов, описанном выше.

Асинхронный UI


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

Для начала поговорим о вещах, которые нужно делать асинхронно, а потом?—?про те, которые можно.

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

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

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

Собственно, оптимизация в данном случае проста?—?нужно выполнять в фоновом потоке все те операции, которые не позволят вам вернуть ячейку быстро, если делать это в главном потоке.

Загружайте картинки в фоне, скругляте им углы там же, а после в главном потоке устанавливайте в ячейку уже загруженную и обработанную картинку (которая совпадает по размерам с прямоугольником, в котором ее будут отображать?—?это ускорит отрисовку).

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

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

Помните, что ячейку нужно возвращать молниеносно.

Есть ситуации, когда всё вышеперечисленное не помогает. Когда не хватает ресурсов GPU (iPhone 4 + iOS7), когда очень много контента в ячейке, когда есть анимации, и нельзя всё рисовать в drawRect:, и приходится использовать везде CALayer`ы.

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

В качестве примера можно привести приложение Facebook, которое делает именно так. Для того, чтобы обнаружить это, пролистайте ленту достаточно далеко вниз, после чего нажмите на статус бар. Список мгновенно пролистается наверх, при этом будет заметно, что контент в ячейках не рендерится. А если быть точнее?—?не успевает.

Вы можете поступить так же, и сделать это достаточно просто. Для этого вы должны для всех CALayer в своей ячейке установить drawsAsynchronously в YES.

Чтобы проверить, имеет ли это смысл, можно поступить следующим образом.

Запустите приложение в симуляторе, в меню “Debug” выберите пункт “Color Offscreen-Rendered”. Теперь жёлтым цветом будут выделены области, которые были отрисованы в фоновом потоке.



Если вы включили для какого-то слоя этот режим, но он не подсветился желтым, значит рендеринг там выполняется достаточно быстро, и проблем не будет, даже если это выключить.

В остальном можно использовать Time Profiler в Instruments для обнаружения долгих вызовов отрисовки, чтобы потом сделать её асинхронной.

Давайте выпишем действия, которые надо предпринять, чтобы сделать быстрый асинхронный UI:
  • Определите, что мешает вам возвращать таблице ячейки моментально.
  • Перенесите выполнение долгих операций в фон с последующим обновлением отрисованной ячейки с новыми данными (выделенными ссылками, хештегами и т.п.).
  • В самом крайнем случае переводите свои CALayer`ы на асинхронную отрисовку контента (даже простой текст или изображения)?—?это увеличит производительность при больших скоростях прокрутки таблицы.

Итог


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

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

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

Спасибо за ваше время.

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


  1. rule
    18.08.2015 09:27
    +4

    Так что же с этим делать? В принципе, решение напрашивается само собой: давайте рисовать с помощью CPU! Это разгрузит графический чип, чтобы он смог выполнять смешивание там, где без него не обойтись ну-совсем-никак.


    Делать нужно с точностью наоборот. Так как GPU разработан быстро работать со смешиванием цветов и ооооооочень быстрой записи в видеопамять.

    Собвственно:
    struct CGContext *context = UIGraphicsGetCurrentContext();

    Вот этим вы инициировали доступ к графической памяти и все операции оттисков делаете не в CPU а в GPU сразу в буфер видеопамяти.

    Проблема в том, что
    Если для отображения градиента использовать CAGradientLayer,

    скорость упадет из-за того, что перерисовка будет происходит при каждом сколе.
    То что вы перерыли метод оттисков можно было сделать просто уставив свойство слоя: shouldRasterize = YES.
    На WWDC много видео, именно этот случай там с примерами с бабочками был пару лет назад.

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


    1. plasm
      18.08.2015 09:38

      > Так как GPU разработан быстро работать со смешиванием цветов и ооооооочень быстрой записи в видеопамять.

      Проблема в том, что чип у iPhone 4, например, с этим уже не справляется на новых iOS. А CPU простаивает. И зачем нам в такой ситуации дальше грузить захлебывающийся GPU? Чтобы еще больше фризов получить?

      > То что вы перерыли метод оттисков можно было сделать просто уставив свойство слоя: shouldRasterize = YES.

      Это не помогает. В случае со всякими clipped фигурами помогло бы, но здесь проблема в блендинге, который можно не делать.
      shouldRasterize просто уменьшит работу при блендинге, в зависимости от rasterizationScale, но не уберёт её вовсе.


      1. rule
        18.08.2015 11:42

        блендинг делать только при появлении «грязных регионов», избавитесь от этого и всё


    1. plasm
      18.08.2015 09:40

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

      Само собой. Я не призываю выкинуть Autolyaout, речь про места, куда стоит посмотреть, и что потом можно с этим сделать.
      Большинство описанных вещей очень дорогие чтобы с места бежать и делать это всё.


    1. plasm
      18.08.2015 09:49
      +1

      > Вот этим вы инициировали доступ к графической памяти и все операции оттисков делаете не в CPU а в GPU сразу в буфер видеопамяти.

      Насколько я помню, операции Core Graphics исполняются на CPU. После чего передаются в GPU (одной картинкой, без смешивания — как раз то, о чём я и говорил).

      В связи с этим, кстати, часто можно найти вопросы на StackOverflow про то, что у людей Core Graphics очень много процессорного времени съедает.

      Так или иначе, речь здесь была про то, что с помощью drawRect мы лишаем GPU необходимости выполнять смешивание. Этого достаточно, чтобы его разгрузить.


      1. rule
        18.08.2015 11:46
        +1

        Так или иначе, речь здесь была про то, что с помощью drawRect мы лишаем GPU необходимости выполнять смешивание.


        Итак в том то и дело, что смешивание вы делаете в GPU, только один раз, так как смешивание происходит в видеопамяти. Вот если вы создадите фреймбуферщ в виде участка оперативной памяти — создадите там битпам, сделаете блэндинг математикой — это будет в CPU. А так если вы не заметили — ничего этого не происходит — вы отдали это на съедение CG. который смешивание делает в видеопамяти с помощью GPU. А производительность у вас повысилась, потому-что слой у вас перерисовываемая при скроллинге, а кастовая вью нет, так как регион фреймбуферщ в видеопамяти не «загрязняется».


        1. plasm
          18.08.2015 12:25
          +1

          Теперь я лучше вас понял, спасибо.
          То, что смешивание происходит один раз — действительно точнее.


  1. GxocT
    18.08.2015 11:15
    -2

    Тем не менее, таблица всегда знает, какой размер занимает весь контент, который она может отобразить, благодаря тому, что её delegate имеет возможность реализовать метод tableView:heightForRowAtIndexPath: и вернуть значение высоты для каждой ячейки, и настолько быстро, насколько это возможно.

    Таблица не знает какой размер занимает весь контент. Метод tableView:heightForRowAtIndexPath: вызывается для каждой ячейки перед ее появлением на экран. Поэтому при скролле таблицы размер и позиция полосы прокрутки может меняться.


    1. plasm
      18.08.2015 11:18

      Вы путаете с estimateHeight. Скролл не дергается при прокрутке если estimateHeight не реализовывать (а реализовать только heightForRow).

      Цитата из документации: «There are performance implications to using tableView:heightForRowAtIndexPath: instead of the rowHeight property. Every time a table view is displayed, it calls tableView:heightForRowAtIndexPath: on the delegate for each of its rows, which can result in a significant performance problem with table views having a large number of rows (approximately 1000 or more).»

      Пройдите хоть по ссылкам, которые я оставил в тексте.


  1. valeriyvan
    18.08.2015 11:23
    +1

    > Переиспользуйте ячейки: для каждого конкретного типа ячейки в вашей таблице должен быть лишь один экземпляр этого типа.
    Допустим, вся таблица состоит из одинаковых ячеек. На экране видны одновременно 5 ячеек. Как для всех этих пяти ячеек может использоваться один единственный экземпляр класса ячейки?

    Когда таблица начнет скролиться, класс таблицы уберет ячейку с экрана, но не будет освобожать память занимаемую объектом, а поместит ячейку в очередь переиспользуемых ячеек. cellForRowAtIndexPath должен в начале своей работы попросить ячейку из очереди переиспользумых ячеек (queue of reusable cells). Если делать это при помощи метода dequeueReusableCellWithIdentifier:, метод вернет nil если очередь пуста. И только тогда вы делаете новый экземпляр класса ячейки. Если для извлечения из очереди использовать метод dequeueReusableCellWithIdentifier:forIndexPath:, этот метод за вас сделает новую ячейку (зарегистрированного заранее класса) если очередь пуста.

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


    1. plasm
      18.08.2015 11:25

      > Будет, как минимум, столько, сколько помещается одновременно на экране.

      Я действительно криво написал, спасибо. Я хотел сказать про то, что самому не надо их плодить :)


      1. lexusathabr
        18.08.2015 12:46

        Кроме того, было бы неплохо для поддержки асинхронного UI переопределить в классе ячейки prepareForReuse и занулять контент, чтобы в новых ячейках не отображались артефакты старых.


        1. plasm
          18.08.2015 12:47

          Лично у меня вообще свои базовые вью и ячейки, там это есть (как и адаптеры и проч.)


  1. Krypt
    18.08.2015 12:41

    Приведённый вами вариант предрасчёта высоты сложных ячеек мне очень не нравится.

    Из-за него
    1) приходится делать двойную работу, к тому же 1 раз полностью вручную.
    2) Вас ожидает много неприятных сюрпризов, когда вы попытаетесь рассчитать высоту многострочного текста. Собственно даже в вашем примере расчёт высоты сделан ошибкой. Добавите в ячейку 100 строк и вы её увидите.
    3) Значительно возрастает вероятность багов: По-первых, значительно больший объём кода. Во-вторых, вам надо держать синхронизированными реальный интерфейс (который, скорее всего, в xib) и ваш код. В-третьих — пункт 2)
    4) Требует значительное время на нудную работу.


    1. plasm
      18.08.2015 12:46

      1 и 3 — у меня всегда ручной layout, поэтому таких проблем нет.
      4 — тут всё описанное нудное, но работает. Ребята из Телеграма сделали набор своих классов и так работать приятнее.


      1. Krypt
        18.08.2015 12:58

        Кстати говоря, вы упустили
        — tableView:estimatedHeightForRowAtIndexPath:
        в своём описании.

        Мне больше нравится (впрочем, пока не реализованный нигде) вариант с комбинацией estimatedHeightForRowи созданием ячеек при запросе размера. Это даст некоторый оверхед по памяти, но незначительный — по моим наблюдением если в классе реализован метод estimatedHeightForCell сам heightForCell опрашивает размеры только на экран вперёд.
        Очевидный минус — дрожащий скрол при прокрутке из-за отсутствия актуального размера контента.


        1. plasm
          18.08.2015 13:00

          Как раз из за скрола не упомянул. Пока сам не нашёл хорошего способа это красиво использовать. Но упоминал тут в комментариях.


          1. Krypt
            18.08.2015 13:06

            В любом случае, полезная штука. по наблюдениям дрожания нет совсем, если у вас 50+ ячеек в таблице и максимальная высота ячейки не больше, чем двойная минимальная. (в принципе, возвращать можно любое значение в диапазоне между минимумом и максимумом)


  1. ASkvortsov
    18.08.2015 13:04

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

    Мне кажется, или расчет размеров текста в десятки (если не в сотни) раз тяжелее тригонометрии?

    С биндингом данных в willDisplayCell вообще никак не могу согласиться, потому что ситуация, когда вызывается cellForRowAtIndexPath, но не вызывается willDisplayCell — крайне редка, а вот множественный вызов willDisplayCell для одной и той же ячейке вполне возможен. Не давайте «вредных» советов.


    1. plasm
      18.08.2015 13:08

      Вычисление точки на окружности (например для показа шильдика на круглой Аватарке) может просадить 5-6 фпс при быстром скроллинге.

      Про биндинг в willDisplayCell — нужно ведь предусматривать множественный биндинг, это несложно :) Не думаю, что это вредный совет, если человек читал доки.


      1. ASkvortsov
        18.08.2015 14:30

        Вычисление точки на окружности (например для показа шильдика на круглой Аватарке) может просадить 5-6 фпс при быстром скроллинге.

        Я что-то не понял, вы согласны или не согласны с тем, что расчет размеров текста гораздо тяжелее тригонометрии? Если согласны — не понимаю, к чему этот аргумент про фпс, если не согласны — сочту за должное оставить вас при своем мнении в этом, казалось бы очевидном, вопросе.

        Про биндинг в willDisplayCell — нужно ведь предусматривать множественный биндинг, это несложно :) Не думаю, что это вредный совет, если человек читал доки.

        Вы делали исследование частоты случаев и вообще ситуаций, когда willDisplayCell не вызывается после cellForRowAtIndexPath? Лично я ни разу такого не встречал, да и вообще логика подсказывает, что максимальный вред от биндинга данных в cellForRowAtIndexPath будет состоять в «лишней» подгрузке данных только на 1 (одну) ячейку, что, согласитесь, огромным оверхедом не является. Биндинг по определению не должен быть тяжелым (иначе нужно его делать асинхронным), и основная нагрузка будет при лэйауте и отрисовке, которые не будут выполнены до реального показа ячейки. Заключая вышесказанное, я считаю, что проблема высосана из пальца и вынос биндинга данных в willDisplayCell (тем более что там нужно не забыть поставить защиту от двойного биндинга) не принесет ровно никакой пользы, а учитывая дополнительное возможное место привнесения бага в виде двойного биндинга — только вред.


        1. plasm
          18.08.2015 14:36

          расчет размеров текста гораздо тяжелее тригонометрии

          Да конечно это тяжелее, что вы! Вопрос только в том, что на тексте сэкономить нельзя, а на тригонометрии бывает можно.
          Про то что эти 6 фпс нужны далеко не всегда — наверное, не всегда. Но когда нужны — вспомнить об этом стоит.

          только на 1 (одну) ячейку, что, согласитесь, огромным оверхедом не является

          Когда вы так говорите, в мире плачет один iPod Touch под iOS 7.

          Я, пожалуй, повторю, что всё что я написал не стоит бежать и делать всем подряд, типа, это какая-то революция.
          Это вещи, на которые стоит обратить внимание если всё очень плохо, а нужно сделать 60 фпс даже там, где сама iOS лагает.


          1. ASkvortsov
            18.08.2015 14:45

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

            Вот только когда расчет текста занимает условно 1 мс, а тригонометрия 10 нс — тут реально сэкономить на тригонометрии, только если количество тригонометрических операций исчисляется десятками.

            Когда вы так говорите, в мире плачет один iPod Touch под iOS 7.

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

            Я, пожалуй, повторю, что всё что я написал не стоит бежать и делать всем подряд, типа, это какая-то революция.

            Я ни в коем случае не пытаюсь сказать, что вы попытались сделать революцию и у вас не получилось. Я всего лишь указываю на недочеты в статье, которые считаю вредными для целевой аудитории (читай, новичков, которые кроме IB ничего не трогали).


            1. plasm
              18.08.2015 14:52

              Не нужно передергивать, если у вас биндинг 1 (одной) ячейки занимает достаточно ресурсов, чтобы вызвать тормоза


              Не, не, я серьёзно — на iPod Touch под семеркой все плохо просто тотально. Там почти всё занимает невероятное количество времени. И тригонометрия там тоже влияет.

              Про целевую аудиторию я уже и сам не знаю, что думать. Что-то я не вижу в продакшене плавных приложений (кроме телеграма, инстаграма, вконтакте).

              У меня iPhone 6 plus и порой даже там скроллинг лагает у многих приложений (Medium, Tumblr, Twitch, Lifesum, Skype), что уж говорить о пользователях iPhone 4.


              1. ASkvortsov
                18.08.2015 15:07

                Не, не, я серьёзно — на iPod Touch под семеркой все плохо просто тотально. Там почти всё занимает невероятное количество времени. И тригонометрия там тоже влияет.


                Ок, повторю в третий раз. Биндинг не включает в себя лэйаут. В биндинге нет и не должно быть никакой тригонометрии. В идеальном (наилучшем с точки зрения плавности скролла) случае биндинг должен в себя включать только вызовы retain и release, в неидеальном можно также делать в нем любую подготовку данных (например, сборка строки из нескольких подстрок) до тех пор, пока она не влияет на производительность, а как только начнет — делать это асинхронно или подготавливать заранее в другом месте.

                Про целевую аудиторию я уже и сам не знаю, что думать. Что-то я не вижу в продакшене плавных приложений (кроме телеграма, инстаграма, вконтакте).

                У меня iPhone 6 plus и порой даже там скроллинг лагает у многих приложений (Medium, Tumblr, Twitch, Lifesum, Skype), что уж говорить о пользователях iPhone 4.


                Лично я работал в скайпе и могу со 100%-й уверенностью сказать, что тормоза в нем возникают отнюдь не из-за неправильного использования UITableView. Насчет других — у меня есть предположение, что наличию плавного скролла там мешает не отсутствие знаний у разработчиков, а отсутствия желания убрать все тормоза (в том числе из-за того, что многие люди не замечают мелких лагов, и да, они тоже пользуются iOS и даже разрабатывают под нее).


  1. RedRover
    18.08.2015 13:51

    del


    1. plasm
      18.08.2015 13:55

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

      По поводу путаницы — у меня один из классов имеет базовый метод configureCell: и наследники всё делают в нем, а когда он там вызывается — в cellForRow или willDisplay уже неважно.

      Вообще, много из описанного тут на грани с хаками, и обычно не требуется. Но иногда приходится считать спички :)


  1. egormerkushev
    24.08.2015 23:20

    Насколько нормально выставлять ячейке

    self.clipsToBounds = YES;
    

    Просто столкнулся с тем, что selectedBackgroundView имеет frame на 1pt больше и на 0.5pt выше (на обычной ретине), чем чем bounds ячейки: {{0, -0.5}, {375, 81}} вместо {{0, 0}, {375, 80}}. Скажется ли обрезка на производительности?


    1. Krypt
      25.08.2015 01:25

      Не думаю, что clipsToBounds способен хоть что-то затормозить.

      А вообще вы лечите не то и не тем. Это место под разделитель ячеек. Если вам не нужен разделитель — отключите его в настройках таблицы. Скорее дополнительный отступ исчезнет.


      1. egormerkushev
        25.08.2015 01:37

        В том то и дело, что перекрытие ячеек остается когда разделитель отключен.


        1. Krypt
          25.08.2015 13:13

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

          Но всё равно, странная проблема. Надо бы исследовать. Но мне пока не до неё :)


  1. alkozin
    25.08.2015 12:48

    Спасибо, было очень много полезного. Пересмотрел свой подход к таблицам.
    Только я бы заменил

    if ([adapter isKindOfClass:[SFSNotificationCellAdapter class]]) {
    


    На вызов метода у SFSNotificationCellAdapter который бы возвращал высоту. Методы бы переопределял у разных адаптеров.