Проверка экспорта JPEG для iOS: лимит байтов и явный отказ
При загрузке аватара локальное превью выглядит нормально, но сервер отклоняет файл. Часто дело не в расширении, а в том, что размер закодированного JPEG в байтах превышает лимит API. Фиксированное значение качества помогает лишь для части изображений и ничего не гарантирует. На облачном Mac можно сначала оформить логику экспорта в виде отдельного проверочного скрипта, а затем перенести те же контракты в тестовый таргет iOS.
Сначала задайте лимит: качество не определяет долю размера
Если API ограничивает размер в байтах, клиенту тоже следует хранить лимит как целое число, чтобы не смешивать десятичные и двоичные единицы. Значение качества — это входной параметр кодировщика, а не процент от размера выходного файла. Даже при одинаковых размерах в пикселях однотонный фон и детализированная текстура сжимаются совершенно по-разному.
В этой статье предполагается, что ориентация изображения уже нормализована, прозрачность обработана, а цветовое пространство выбрано. На этапе экспорта размеры в пикселях не меняются. Алгоритм перебирает конечный набор уровней качества от высокого к низкому и возвращает первый результат, укладывающийся в лимит. Это лишь «допустимый вариант с наивысшим приоритетом среди заданных уровней», а не претензия на поиск оптимального качества во всём непрерывном диапазоне.
Размер файла — обязательное условие проверки результата, а визуальное качество — отдельный критерий приёмки. Соблюдение лимита ещё не означает, что контуры текста, оттенки кожи или градиенты выглядят приемлемо.
Замените бесконечное снижение качества конечным перебором
В терминале удалённой macOS сохраните следующий код в файл jpeg-budget.swift. Для запуска нужен настроенный и работоспособный инструментарий Swift. Скрипт использует только системные фреймворки, не читает личные фотографии и не меняет настройки машины.
import Foundation
import CoreGraphics
import ImageIO
import UniformTypeIdentifiers
enum ExportError: Error {
case invalidBudget
case encoding
case budgetUnreachable
case decoding
}
func encodeJPEG(_ image: CGImage, quality: Double) throws -> Data {
let buffer = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
buffer as CFMutableData,
UTType.jpeg.identifier as CFString,
1,
nil
) else {
throw ExportError.encoding
}
let properties = [
kCGImageDestinationLossyCompressionQuality: quality
] as CFDictionary
CGImageDestinationAddImage(destination, image, properties)
guard CGImageDestinationFinalize(destination) else {
throw ExportError.encoding
}
return buffer as Data
}
func exportJPEG(
_ image: CGImage,
maxBytes: Int
) throws -> (data: Data, quality: Double) {
guard maxBytes > 0 else {
throw ExportError.invalidBudget
}
let levels = [0.9, 0.8, 0.7, 0.6, 0.5]
for quality in levels {
let data = try encodeJPEG(image, quality: quality)
if data.count <= maxBytes {
return (data, quality)
}
}
throw ExportError.budgetUnreachable
}
func verify(_ data: Data, width: Int, height: Int) throws {
guard let source = CGImageSourceCreateWithData(
data as CFData, nil
),
CGImageSourceGetType(source) == UTType.jpeg.identifier as CFString,
let decoded = CGImageSourceCreateImageAtIndex(source, 0, nil),
decoded.width == width,
decoded.height == height else {
throw ExportError.decoding
}
}
let space = CGColorSpace(name: CGColorSpace.sRGB)!
let context = CGContext(
data: nil,
width: 320,
height: 240,
bitsPerComponent: 8,
bytesPerRow: 0,
space: space,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)!
context.setFillColor(
CGColor(red: 0.2, green: 0.4, blue: 0.6, alpha: 1)
)
context.fill(CGRect(x: 0, y: 0, width: 320, height: 240))
let image = context.makeImage()!
let result = try exportJPEG(image, maxBytes: 1_000_000)
precondition(result.data.count <= 1_000_000)
try verify(result.data, width: 320, height: 240)
do {
_ = try exportJPEG(image, maxBytes: 1)
preconditionFailure("Expected budget rejection")
} catch ExportError.budgetUnreachable {
}
Выполните xcrun swift jpeg-budget.swift. При успехе скрипт ничего не выводит, при ошибке завершается с ненулевым кодом. Однотонное изображение здесь служит для проверки самого процесса, а не для оценки сжатия реальных изображений. Принудительное извлечение опциональных значений в коде допустимо только для этой фиксированной тестовой фикстуры. Код приложения, принимающий внешние изображения, должен возвращать явные ошибки.
Проверяйте сценарии отказа, а не только успешный экспорт
Скрипт различает недопустимый лимит, ошибку кодирования и невозможность уложиться в лимит. Нельзя сводить все три случая к созданию пустого файла: недопустимый лимит означает ошибку вызова, при сбое кодирования нужно исследовать входные данные или кодировщик, а невозможность соблюсти лимит должна обрабатываться согласно логике продукта.
Дополнительные тесты должны как минимум охватывать нулевой и отрицательный лимиты, лимит, точно равный размеру результата в байтах, и ситуацию, когда ни один уровень качества не подходит. Равенство должно считаться успехом. Не стоит напрямую утверждать, что «лимит на один байт меньше результата с максимальным качеством обязательно приведёт к отказу»: следующие уровни всё ещё могут дать подходящий результат.
Отдельно проверьте ветви поиска
Вынесите фактическое кодирование в подменяемую функцию и проверяйте поиск на результатах заранее заданной длины. Если подходит первый уровень, функция должна вызываться только один раз; если подходит промежуточный — дальнейшее кодирование должно прекращаться; если лимит превышен на всех уровнях — число попыток должно совпадать с числом кандидатов. Так можно проверить работу досрочного выхода, не рассчитывая на то, что конкретное изображение случайно даст нужный размер.
Отказ от непроверенного двоичного поиска позволяет не закладывать скрытое предположение о строгой монотонности размера файла. Затраты на конечный перебор понятны, но большие изображения кодируются повторно. Поэтому также следует ограничить число пикселей на входе и не обрабатывать слишком много изображений одновременно.
Подготовьте тестовые изображения, выявляющие проблемы
В наборе должны быть однотонные изображения, текст с тонкими штрихами, градиенты и высокодетализированные текстуры. Их можно генерировать программно или использовать фиксированные образцы, которые команде разрешено хранить. Для каждого образца фиксируйте размеры и краткое описание содержимого. Не генерируйте новые случайные изображения при каждом запуске без сохранения начального значения генератора.
Помимо проверки Data.count, заново прочитайте результат и убедитесь в правильности формата и размеров в пикселях. Повреждённый файл может быть маленьким, но это не делает его допустимым результатом. JPEG использует сжатие с потерями, поэтому нельзя требовать попиксельного совпадения после декодирования. Если проекту нужна автоматическая оценка визуальной погрешности, зафиксируйте пространство, в котором выполняются расчёты, и допустимые отклонения, а также сохраняйте образцы для ручной проверки.
Одно и то же изображение не обязано давать одинаковый хеш в разных версиях системы. Надёжнее проверять правильность формата, неизменность размеров, соблюдение лимита байтов и ограниченность числа попыток поиска, а фактически выбранный уровень качества записывать для последующего анализа.
Подключите логику к экспорту iOS и регулярным проверкам
Когда скрипт на Mac пройдёт проверки, поместите функции в модуль, доступный для повторного использования в приложении, и запустите те же фикстуры в целевой среде выполнения iOS. Результат кодирования системными фреймворками может различаться: успех на macOS сам по себе не доказывает корректность на iOS.
Если ни один уровень не подошёл, интерфейс должен явно сообщить, что экспорт с текущими условиями невозможен. Если разрешено уменьшение изображения, задайте отдельную последовательность размеров и минимальную длину стороны, а пользователя явно предупредите. Не меняйте размеры незаметно внутри поиска по качеству. Если перед загрузкой изображение ещё включается в запрос, отдельно проверяйте лимит файла изображения и лимит всего тела запроса.
В итоге достаточно сохранять идентификатор входной фикстуры, версию системы, набор уровней-кандидатов, выбранное качество и размер результата в байтах. Записывать исходные личные фотографии не нужно. Когда ограничение по размеру, визуальные требования и поведение при отказе проверяются отдельно, проблемы экспорта не скрываются за бесконечным снижением одного параметра качества.
Часто задаваемые вопросы
Уменьшит ли качество 0,5 размер JPEG вдвое?
Нет. Это параметр кодирования, а не коэффициент уменьшения файла. Проверять лимит нужно по Data.count после завершения кодирования.
Что делать, если ни один уровень качества не подходит?
Завершить поиск с определённой ошибкой. Уменьшение размеров в пикселях должно быть отдельным этапом с собственным нижним пределом.
Заменяет ли проверка на Mac тестирование в iOS?
Нет. На Mac удобно проверить алгоритм поиска, но тот же контракт необходимо проверить в целевой среде iOS. Побайтовое совпадение JPEG между системами не требуется.
Проверьте удалённый Mac на своих задачах
Арендуйте отдельный Mac mini M4 и работайте в macOS через графический интерфейс или командную строку. Подходит, чтобы проверить подключение и рабочий процесс. Для длительных автономных задач или профессионального внешнего оборудования сравните с покупкой собственного Mac.
Арендовать отдельный Mac mini