Приветствую всех iOS-разработчиков! Меня зовут Петрос, и уже 7 лет я создаю мобильные приложения, сталкиваясь с множеством графических багов и производительных проблем. За это время я прошёл через множество проектов — от стартапов до крупных компаний, и каждый раз графические глюки заставляли меня искать эффективные решения.

Я поделюсь, как пофиксить отрисовку. Вам понадобятся знания основ Swift, CPU, GPU (про них поговорим) и немного юмора. Пройдёмся по примерам багов отображения на iPhone 16 Pro.

Примеры будем смотреть на картинках с красным мотоциклом. Красный байк был мечтой айосеров-старожилов в Альфе и даже стал локальным мемом. На собеседованиях мы часто спрашиваем, есть ли у кандидата красный мотоцикл (если да — это идеальный мэтч).

Что такое CPU и GPU и с чем их едят

У телефона есть всего два компонента:

  1. CPU — Central Processing Unit, мозг, который делает просчёты, запускает алгоритмы, вычисляет размеры представлений и много чего другого. 

  2. GPU — Graphics Processing Unit, отвечает за графику и пиксели, которые зажигаются на экране; отрисовывает то, что ему сказал CPU. 

Давайте рассмотрим, как работает GPU на iPhone 16 Pro с разрешением экрана 2622х1206, а это более 3 миллионов пикселей. Каждый пиксель состоит из 3 субпикселей: красного, зелёного и голубого. Выходит, у нас более 9,5 миллионов субпикселей. Не забываем, что частота обновления кадра айфона 16-ой модели — 120 кадров в секунду (!).

Иными словами: GPU должен за 0,008 секунды отрисовать 9,5 миллионов субпикселей.

Очевидно, что это очень много работы. У GPU и так колоссальный объём задач, а мы, разработчики, должны понять, как эту работу облегчить. В этом нам помогут дебаг-инструменты — с ними можно оптимизировать слои, которые съедают время на отрисовку. Оптимизируя графику, вы, возможно, почувствуете себя шеф-поваром, который пытается приготовить идеальное блюдо, только вместо ингредиентов у вас пиксели и код.

Для начала посмотрим на схеме, как это работает. 

Диаграмма сложная, но главная её мысль в том, что GPU — низкоуровневое железо прямо над дисплеем. Над GPU есть OpenGL — Open Graphics Library. Интерфейс был создан в 90-ых, чтобы упростить общение с GPU; до неё разработчики писали код для каждого GPU отдельно. В iOS 12 и вместо OpenGL будет новомодный эпловский Metal.

Также полезно знать, что библиотеки Core Graphics, Core Animation и Core Image тесно связаны друг с другом. 

Теперь давайте обсудим настройки, которые вы найдёте, если выберете Debug в симуляторе iOS в Xcode:

  • Slow Animations — замедляет все анимации по системе.

  • Graphics Quality Override — увеличивает и уменьшает разрешение экрана.

  • Open System Log — открывает системный лог.

  • Simulate Memory Warning — функция, когда система по-доброму говорит нашему приложению: «Братишка, у меня заканчивается память, возможно, у тебя есть от чего избавиться, какие-то модели, которые тебе сейчас не нужны?». С новыми телефонами, где много оперативной памяти, обычно этого не случается.

Остались ещё 4 настройки (флаги) которые мы пропустили в Debug-меню: 

  1. Blended Layers

  2. Copied Images

  3. Misaligned Images

  4. Off-screen Rendering

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

Для сравнения посмотрим, как выглядит наш главный экран без включения дебаг-флагов:

Blended Layers — cмешанные слои

Вначале, познакомимся с парочкой терминов

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

Texture, Bitmap или CALyer: текстура, битмап или наш слой. Мы будем использовать эти слова как взаимозаменяемые, они обозначают прямоугольную площадь из RGBA-значений (RGBA — красный, зеленый, голубой, альфа-канал (прозрачность пикселя).

Теперь ближе к практике.

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

GPU рисует пиксели одного кадра один раз (не может вернуться и дорисовать).

Рассмотрим пример с одной текстурой. 

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

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

Процесс, когда GPU складывает, совмещает слои друг с другом называется смешиванием (blending). GPU работает с разными типами смешивания, по умолчанию используется Normal Blend Mode. Каждый тип смешивания работает по своей формуле. Спойлер: самая бесполезная формула для запоминания — формула работы Normal Blend Mode:

Посмотрим, как формула работает на примере с двумя текстурами. 

Допустим, текстура 1 непрозрачная, то есть её альфа равна единице. В таком случае мы ожидаем, чтобы на экран попала именно текстура 1. Что происходит с формулой? Второе слагаемое будет равно нулю: 1 – Ta = 1 – 1 = 0. Результирующий пиксель — это T* Ta, то есть текстура 1. Если мы хотим увидеть второй слой, происходит аналогичная ситуация: первое слагаемое будет равно нулю. 

Если для вас тут слишком много математики, просто поймите, что это вычисление происходит раз в 0,008 секунды для одного пикселя, а у нас их больше 3 миллионов. 

Излишнее смешивание слоёв и два способа от него избавиться

Теперь посмотрим, как это выглядит в Xcode. Если мы включим настройку в симуляторе, несмешанные слои будут подсвечены зелёным, смешанные — красным.

Итак, мы увидели, где произошло излишнее смешивание. Многие вьюшки, которые подсвечены красным, имеют залитый фон, значит это смешивание можно исправить. Есть два способа это пофиксить. 

Первый способ

Мы подсказываем GPU: «Слушай, этот слой не надо ни с чем смешивать, он непрозрачный, его можно копировать сразу на экран». 

import UIKit

let someLayer: CALayer
// ...
// abracadabra
// ...
someLayer.isOpaque = true
// Я теперь непрозрачный!

Этот способ может не сработать, если у вас есть сorner-радиус слоя. 

Второй способ

Он будет полезен в коллекциях, в которых вы видите смешивание слоев у представлений со скруглёнными уголками, и у вас могут или уже проседают драгоценные FPS. Вместо того, чтобы использовать представление как есть, мы можем взять его и нарисовать ручками — превратить в картинку с уже скруглёнными углами. Переопределяем draw(_:). 

import UIKit

final class MotorcycleView: UIView {

  struct Appearance {
    let radius: CGFloat = 10
  }

  override func draw(_ rect: CGRect) {
    let appearance = Appearance()
    let borderPath = UIBezierPath(roundedRect: self.bounds, cornerRadius: appearance.radius)
    UIColor.white.setFill()
    borderPath.fill()
  }
}

Тут как с формулой Normal Blend Mode: выглядит страшнее, чем есть на самом деле. Мы можем просто вручную отрисовать карточку. В моём примере рисуется карточка радиуса 10. Способ хорошо работает в коллекциях, потому что мы заменяем смешивание на одну картинку. Но, нужно учесть, что draw(_ rect: CGRect) выполняется на CPU и может не подойти для каждого сценария.

Так или иначе, самая быстрая отрисовка — это та, которую вы не делаете.

Многим не нравится использовать редкий draw(_ rect: CGRect), поэтому можете посмотреть в сторону подхода с созданием битмапы с помощью UIGraphicsBeginImageContextWithOptions(), взять результирующее изображение и засетить в contents у CALayer. Тестируйте и замеряйте!

Copied Images: избавляемся от скопированных изображений

Об этом флаге мало информации. Если вы хотите изучить её, вам нужно вернуться к iOS времен Objective-C или отыскать WWDC 2017 года. Apple в презентации объясняет настройку примерно так: картинка оказалась «странной» для GPU, поэтому ему пришлось отдельно её скопировать, перерисовать и перенести на экран.

На самом деле GPU этого не делает, GPU «глупый» — рисует что ему дают нарисовать. Копированием изображений занимается Render Server — системный процесс, отвечающий за общение CPU и GPU, который и делает большую часть грязной работы по подготовке слоёв к показу. Но для понимания этого материала можно считать что GPU и Render Server — одна сущность.

Скопированные изображения подсветятся бирюзовым, если вы включите флаг Copied Images. 

Вот все причины, почему возникают скопированные изображения:

  • Формат изображения не PNG, JPEG, а PDF, BMP, WEBP, PSD и т.д.

  • Отсутствуют @2x, @3x варианты изображения в ассетах

  • Цветовая палитра у изображения была не sRGB (Adobe RGB,CMYK и т.д.)

  • Bit-Depth была не 8 а 16, 32-bit per channel, HDR-изображения

  • Изображение имело нестандартные alpha-значения (не в [0, 1])

  • Изображение слишком большое или с неверным соотношением сторон

  • Metadata изображения имеет противоречащие значения

Что делать, если изображение скопировано

Вариант 0. Проверить изображение на «странность». Это способ для ассетов, когда изображение приходит не с сервера. 

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

import UIKit

func stripMetadata(from image: UIImage) -> UIImage? {
  guard let data = image.jpegData(compressionQuality: 1.0),
        let source = CGImageSourceCreateWithData(data as CFData, nil),
        let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
          return nil
        }
  return UIImage(cgImage: cgImage
}

Этот вариант самый нетрудозатратный, но, соответственно, может не помочь. 

Вариант 2.

import UIKit

func prepareForDisplay(from image: UIImage, removingAlphaChannel: Bool = true) -> UIImage {
  let format = UIGraphicsImageRendererFormat.default()
  format.opaque = removingAlphaChannel
  let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
  let newImage = renderer.image { context in 
    image.draw(at: .zero)
  }
  return newImage
}

Перерисовать изображение на неглавном потоке. 

Функция у UIGraphicsImageRenderer так и делает. prepareForDisplay можно вызвать на неглавном потоке, а результат newImage засетить на главном. Иными словами, мы освобождаем GPU от копирования изображения и сами делаем то, из-за чего GPU откладывал свою главную задачу — отрисовку. 

Вариант 3. PrepareForDisplay — оптимизированная перерисовка из прошлого варианта для iOS 15. 

import UIKit

func prepareForDisplay(image: UIImage, completion: @escaping (UIImage?) -> Void) {
  image.prepareForDisplay { imageReadyToDisplay in
    completion(imageReadyToDisplay)
  }
}

Учтите, что imageReadyToDisplay нельзя кэшировать, значение предназначено только для отображения.

Выравниваем изображения Misaligned Images

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

Первый случай: изображение масштабируется, поворачивается, растягивается, и к нему применяется CG Affine Transform. 

Второй случай — рамка изображения имеет нецелые значения: x, y, width, height не 100 или 200, а, например, 100,25. Тогда каждый пиксель экрана приходится не на пиксель текстуры, а где-то между. GPU должен нарисовать 10,5 пикселей, но может только 10 или 11. Поэтому он применяет экранное сглаживание — anti-aliasing: берёт пиксель текстуры, пиксель фона и отрисовывает средний цвет в промежутке.

Третье исключение я нашёл экспериментальным путём: невыровненным будет изображение, когда соотношение сторон картинки не равно соотношению сторон: ratio(image.size) != ratio(imageView.frame.size).

Если вы включите в дебаг-меню настройку Misaligned Images, жёлтым подсветятся невыровненные слои. 

Избавиться от этого просто даже без написания кода. Включаем флаг Color Misaligned Images и проверяем изображение на CGAffineTransform. Если трансформаций нет, скорее всего, что-то не так с размером. 

Внеэкранная отрисовка Offscreen Rendering

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

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

Offscreen Rendering  как метод кэширования может быть даже полезен при сложном смешивании. Поговорим об этом подробнее. 

Допустим, нам нужна анимация множественных слоёв. Стандартное поведение GPU: он смешает слои на каждое изменение кадра, положения или рамки слоёв без Offscreen Rendering. Если включить Offscreen Rendering, GPU возьмёт композицию из слоёв, нарисует в отдельном окошке, сделает фоточку и сохранит в кэш. В следующий раз, когда GPU будет делать анимацию слоёв, он просто использует кэш без повторного смешивания.

Давайте посмотрим, как это работает.

import UIKit

let someLayer = CALayer()
someLayer.shouldRasterize = true
someLayer.rasterizationScale = UIScreen.main.scale

Мы создаём слой, применяем к нему свойства shouldRasterize и ставим rasterizationScale — масштаб картинки, которая сохраняется в кэше. Приём не подойдёт для коллекций, потому что будет перебор по кэшу. Но он хорош для вьюшек, у которых анимируется позиция, но не содержимое. 

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

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

Ну, и ограничения по памяти никто не отменял. Apple намекнула, что у кэша есть два размера экрана, на котором всё рисуется, то есть кэш легко переполнить. 

У внеэкранной отрисовки три причины: тени, маски и визуальные эффекты.

Тени

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

Мы смотрим на красный мотоцикл и видим тёмный ореол, потому что дали GPU объект и сказали: «Рисуй тени». С нашей стороны всё просто, но что же происходит под капотом, почему это не тривиальный случай?

Рассмотрим, как рисуются тени на большинстве OpenGL-платформ. Когда GPU доходит до вьюшки с тенью, он создаёт новое «окно» куда будет рисовать, копирует в это окно красный мотоцикл (тени пока нет), полностью затемняет красный мотоцикл, делает его блюр и сверху накладывает красный мотоцикл ещё раз. 

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

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

import UIKit

let frame = CGRect(x: 0, y: 0, width: 100, height: 80)
let someLayer = CALayer()
someLayer.frame = frame
someLayer.shadowOffset = .zero
someLayer.shadowColor = UIColor.black.cgColor
someLayer.shadowRadius = 4

// Добавь меня, чтобы упростить жизнь GPU
let bezierPath = UIBezierPath(rect: frame)
let path = bezierPath.cgPath
someLayer.shadowPath = path

Тень задана в нескольких местах: shadowColor, shadowOffset, shadowRadius, и также указан путь этой тени shadowPath для GPU. В таком случае тень подготавливает CPU, а мы убираем эту ответственность с GPU.

Маски

С масками проблема в том, что GPU просто отображает то, что ему дают, и не может пройтись по маске и вырезать то, что ему нужно. 

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

От этого можно избавиться, но первое — нужно понять, на самом ли деле маска неотъемлема? Часто бывает так, что мы пытаемся сделать маску с обычным закруглением или с чем-то, что можно заменить drawRect. Но если это маска с обычным закруглением, мы можем задать cornerRadius.

import UIKit

let someLayer = CALayer()
someLayer.cornerRadius = 10
someLayer.cornerCurve = .continuous
someLayer.maskedCorners = [.layerMaxXMaxYCorner, .layerMinXMaxYCorner]

// и может масочку если надо обрезать излишки контента в уголках
// но по этим уголкам останется off-screen rendering
someLayer.masksToBounds = true

Если у вас свойство masksToBounds = true, Offscreen Rendering всё равно будет возникать, но только на уголках, потому что GPU должен понять, как смешивать срезанный угол с тем, что за ним. 

Visual Effects

Купертиновцы часто упоминают о двух визуальных эффектах — Blur и Vibrancy. Их применение к представлениям приведёт к внеэкранной отрисовке, но чаще всего она ожидаема.

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

Подойдём к вопросу ещё практичнее: как найти внеэкранную отрисовку? 

В Xcode включайте настройку Show Layers. Она покажет все слои, которые есть в иерархии. 

Далее выберите слои, рядом с которыми есть фиолетовая лампочка — это означает, что там есть возможность оптимизации. С правой стороны можно увидеть дебаг-меню и секции с информацией. В одной из них можно заметить: Offscreens 2 — дважды сделаны Offscreen Mask.

Любопытно, что эти все флаги можно посмотреть в действии на самой iOS! Можно даже просто открыть сам симулятор, включить флаги и узреть эту новогоднюю ёлку:

Вывод

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

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

P.S. Не забывайте следить за обновлениями iOS и Xcode, ведь мир технологий постоянно меняется, и всегда есть что-то новое, чему можно научиться и что применить на практике. Удачи в дебаге и успешной разработки!

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