Идеально плавные UITableView

от автора

Несколько лет я занимаюсь промышленной разработкой для прекрасной мобильной платформы — 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 в вашей таблице.

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

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

Запускаем приложение в симуляторе, идём в меню “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 и устройства, грамотно применяя все доступные средства.

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

ссылка на оригинал статьи http://habrahabr.ru/post/264817/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *