Привет, Хабр!

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

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

Наводим порядок

Все началось с того, что мы в один момент поняли – в нашем каталоге ассетов творится что-то неконтролируемое:

  • Названия некоторых ассетов не совпадают с названиями в Figma.

  • Некоторые иконки раскрашены, а некоторые - нет.

  • Форматы файлов в некоторых местах неправильные.

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

Мы нашли утилиту для экспорта цветов, картинок и шрифтов из Figma. Это не реклама, нам она действительно помогла, и не рассказать о том, что в итоге нам это дало, просто нельзя. Во-первых, использование этой утилиты обязывает дизайнеров хранить все ассеты в одном месте и соблюдать правила именования. Во-вторых, избавляет разработчика от необходимости определять формат экспортируемого файла; думать, какие настройки использовать; куда сохранять и как называть. К тому же, после экспорта генерируется swift файл с перечислением всех ассетов, что делает их использование еще более простым, поскольку не приходится создавать изображение с помощью UIImage(named:) или #imageLiteral.
Есть и минусы: отсутствует группировка ассетов, работа с ассетами отличается от привычной, нет обновления только скачанных изображений (скрипт умеет выкачивать либо конкретные изображения, либо сразу все).

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

  • Все ассеты теперь делятся на 2 типа. Иконки – это простые векторные изображения черного цвета, экспортируемые в формате PDF (потому что их размер не фиксирован). Иллюстрации – это растровые изображения в формате PNG (потому что использование PDF привело бы увеличения размера асетов и необходимости конвертировать их в PNG).

  • Все ассеты названы в одном стиле и, где нужно, имеют разделение на iOS и Android, разные языки, светлую и темную тему.

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

Сажаем ассеты на диету

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

Далее расскажу о том, как можно уменьшить размер ассетов.

Формат HEIF

HEIF - это формат изображений, разработанный совсем недавно (в 2014 году) и поддерживаемый с 11 версии iOS. Одно из его преимуществ – очень небольшой размер файла. Apple предлагает использовать этот формат для уменьшения размера приложения.

Figma не поддерживает экспорт в формате HEIF, поэтому нам пришлось искать способ конвертации из PNG в этот формат. Это можно сделать очень просто, используя экспорт в стандартном приложении Preview, но нам такой вариант не подошел, так как нужно было автоматизировать этот процесс. Мы попробовали много различных конвертеров, но все они либо конвертировали изображение с большими потерями, либо не учитывали alpha канал, либо оставляли дефекты на изображениях.

В итоге мы написали свой скрипт на Swift, который вы можете использовать для конвертации своих изображений. Но есть один очень важный нюанс – если ваши изображения содержат прозрачные области, могут возникнуть дефекты, которые проявляются уже после компиляции ассетов. Это очень подлое поведение, которое мы заметили не сразу. И, из-за того, что у нас 95% иллюстраций содержат прозрачные области, пришлось отказаться от данного формата.

Код скрипта
#!/usr/bin/swift

import Foundation
import CoreImage
import CoreGraphics
import AVFoundation

guard CommandLine.arguments.count > 1 else {
    fatalError("There is no required argument 'path_to_image'")
}

let imageUrl = URL(fileURLWithPath: CommandLine.arguments[1])

guard let ciImage = CIImage(contentsOf: imageUrl) else {
    fatalError("Couldn't create a CIImage for the given file")
}

let cgImage = CIContext().createCGImage(ciImage, from: ciImage.extent)

let heicImageUrl = imageUrl.deletingPathExtension().appendingPathExtension("heic")

let destinationData = NSMutableData()

guard
    let cgImage = cgImage,
    let destination = CGImageDestinationCreateWithData(
        destinationData,
        AVFileType.heic as CFString,
        1,
        nil
    )
else { fatalError("Couldn't create a CGImage destination") }

let options = [kCGImageDestinationLossyCompressionQuality: 0.85]

CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)

CGImageDestinationFinalize(destination)

try! (destinationData as Data).write(to: heicImageUrl)

try! FileManager.default.removeItem(at: imageUrl)

ImageOptim

ImageOptim - это приложение, объединяющее утилиты для сжатия изображений без потерь. Работает оно только с форматами PNG и JPEG. Приложение достаточно “умное” и выбирает ту утилиту, которая сожмет изображение до наименьшего размера, а если сжатие уже было выполнено, то повторного сжатия не произойдет.

Мы попробовали использовать эту утилиту. Наши изображения она уменьшила примерно на 15%. Но после использования сжатия ассетов, которое делает компилятор, итоговый результат не изменился. Поэтому мы используем только стандартное сжатие. Возможно, в вашем случае результаты будут совершенно другими, поэтому попробовать эту утилиту однозначно стоит.

Сжатие

В каталоге ассетов можно установить сжатие для каждого ассета или для целой группы.

Результаты сжатия каждого типа такие:

Lossless

Сжатие без потерь

Итоговый размер ассетов не меняется. Это значение установлено по-умолчанию.

Lossy (Basic)

Сжатие с потерями

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

GPU Optimized Smallest

Сжатие с потерями, оптимизированное для наименьшего размера приложения

Получаем самый маленький размер изображений, но и низкое качество. Отличие от Lossy в том, что размер еще меньше, а качество немного лучше.

GPU Optimized Best Quality

Сжатие с потерями, оптимизированное для лучшего качества изображения

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

Мы остановились на GPU Optimized Best Quality, так как получили выигрыш в размере скомпилированных ассетов и не потеряли качество изображений.

Итог

Не смотря на то, что нам удалось использовать только стандартное сжатие, это дало ощутимый результат. До сжатия каталог ассетов на iPhone 12 занимал 45Мб, а теперь 28.5Мб (-38%). Но если бы нам удалось использовать HEIF формат, размер бы уменьшился примерно до 24Мб (-50%).

Оптимизируем компиляцию ассетов

Самой неприятной проблемой, с которой мы столкнулись, была долгая компиляция каталога ассетов. Больше всего времени отнимали иллюстрации, которых у нас было более 130 на момент написания статьи.

Немного философии

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

Использовать on-demand ресурсы у нас не получится, потому что ассеты хранятся во фреймворке. К тому же, on-demand ресурсы не гарантируют 100% стабильного скачивания их с сервера, что ставит под удар UI всего приложения.

Поэтому с этой проблемой надо было что-то делать. План был следующий:

  • Убрать фазу компиляции ассетов из сборки проекта.

  • Компилировать ассеты самостоятельно только в случае их изменения.

  • Положить скомпилированные ассеты в бандл приложения при сборке.

Первый и последний шаг тривиальный, поэтому рассмотрим только второй. Для компиляции ассетов мы использовали actool – стандартный компилятор, используемый в xcode. Команда для сборки выглядит так:

xcrun actool \
  --platform iphoneos --platform iphonesimulator \
  --minimum-deployment-target 12.0 \
  --target-device universal \
  --output-format human-readable-text \
  --compile {Path/To/Assets.car} {Path/To/Assets.xcassets}

В результате выполнения команды создается архив Assets.car, который помещается в бандл.

Но на этом история не заканчивается. Мы не хотим хранить большой бинарный файл в истории гита, так как это повлияет на размер репозитория при клонировании и добавит проблем при разрешении конфликтов.

Поэтому мы:

  • Добавили Assets.car в .gitignore.

  • Создали файл Assets.version, в котором хранится текущая версия собранных ассетов. В качестве версии мы используем обычный timestamp. Версия меняется после каждой компиляции если в Assets.xcassets произошли изменения. Этот файл не находится в .gitignore.

  • Создали файл Assets.version.lock, в котором хранится локальная версия собранных ассетов. Этот файл находится в .gitignore.

  • Добавили предусловие для компиляции ассетов – версии в Assets.version и Assets.version.lock должны отличаться.

Как ускорилась компиляция

Сначала пара слов по теории. Компиляция ассетов происходит при сборке проекта после очистки кеша или после генерации проекта с использованием XcodeGen или Tuist, что также инвалидирует кеш.

Теперь представим такую ситуацию: у нас есть каталог ассетов и в него добавили новый ассет. В случае с компиляцией ассетов во время сборки, у каждого разработчика будет отниматься по X секунд при каждом clean + build и после генерации проекта (а еще есть CI, где clean происходит перед каждой сборкой). В случае с компиляцией после добавления ассета, у каждого разработчика отнимется X секунд единовременно.

Итог

В конечном счете мы получили то, что хотели:

  • Контролируемый порядок в каталоге ассетов.

  • Максимально сжатый размер скомпилированных ассетов.

  • Уменьшение времени сборки.

  • Отсутствие увеличения сложности разработки.

  • Полная автоматизация процесса.

Спасибо, что дочитали до конца. Надеемся, наш необычный опыт был вам интересен. Желаю всем компактных ассетов и быстрой сборки!

Подписывайтесь на наш блог и не стесняйтесь писать комментарии.