В макете для заголовков используется пользовательский шрифт, и при локальной отладке всё выглядит правильно. Однако после установки App, полученного автоматической сборкой, на тестовое устройство шрифт незаметно заменяется системным. Такие проблемы обычно не приводят к ошибке компиляции: файл шрифта мог не попасть в таргет, в Info.plist мог быть указан неверный относительный путь, а код мог обращаться к имени файла вместо внутреннего PostScript-имени шрифта. Надёжнее всего не продолжать проверять каталоги проекта, а после сборки на облачном Mac провести аудит готового App-пакета.
Что должен проверять шрифтовой CI-гейт
Шрифтовой гейт должен отвечать как минимум на четыре вопроса: существуют ли объявленные файлы, может ли Core Text их прочитать, соответствуют ли внутренние имена соглашениям в коде и не создают ли несколько файлов одинаковые имена. Проверять нужно каталог .app, а не папку Resources в исходном проекте, поскольку принадлежность к таргету и этап Copy Bundle Resources могут изменить итоговый состав пакета.
| Проверка | Что означает ошибка | Рекомендуемое действие |
|---|---|---|
Записи UIAppFonts |
Шрифты не объявлены в конфигурации сборки | Исправить настройки Info для таргета |
| Файлы внутри App-пакета | Шрифты не скопированы в артефакт | Проверить принадлежность к таргету и этап обработки ресурсов |
| PostScript-имена | В коде может использоваться неверное имя | Получить фактическое имя из дескриптора шрифта |
| Уникальность имён | Конфликт семейства или версий шрифта | Удалить дубликаты или явно определить порядок замены |
То, что шрифт «выглядит почти так же», нельзя считать критерием приёмки. На коротком английском тексте подмена может быть незаметна, однако изменение ширины символов влияет на переносы строк, размеры кнопок и сравнение скриншотов.
Рекомендуется хранить в репозитории файл ci/expected-fonts.txt, где в каждой строке указано одно разрешённое PostScript-имя. Это стабильный контракт между обращениями к шрифтам в коде и файлами ресурсов, а не перечень всех шрифтов, установленных на компьютере разработчика.
Получение фактических данных из готового App-пакета
Сначала создайте App-пакет с помощью существующего процесса сборки, а затем передайте его путь проверочному скрипту. Конфигурации Debug и Release могут использовать разные наборы ресурсов, поэтому в релизном конвейере необходимо проверять именно ту конфигурацию, которая предназначена для поставки. Скрипт не должен регистрировать шрифты в системе до проверки, иначе уже установленный на машине одноимённый шрифт может скрыть отсутствие файла в App-пакете.
Следующий скрипт на Swift читает UIAppFonts из Info.plist, проверяет наличие каждого файла и извлекает внутренние имена через Core Text. Сохраните его как ci/check_fonts.swift:
import Foundation
import CoreText
let arguments = CommandLine.arguments
guard arguments.count == 2 else {
FileHandle.standardError.write(Data("usage: check_fonts.swift /path/App.app
".utf8))
exit(64)
}
let appURL = URL(fileURLWithPath: arguments[1], isDirectory: true)
let plistURL = appURL.appendingPathComponent("Info.plist")
guard
let data = try? Data(contentsOf: plistURL),
let plist = try? PropertyListSerialization.propertyList(from: data) as? [String: Any],
let declared = plist["UIAppFonts"] as? [String],
!declared.isEmpty
else {
FileHandle.standardError.write(Data("UIAppFonts is missing or empty
".utf8))
exit(1)
}
var failed = false
var seen = Set<String>()
for relativePath in declared {
let url = appURL.appendingPathComponent(relativePath)
guard FileManager.default.fileExists(atPath: url.path) else {
FileHandle.standardError.write(Data("missing: \(relativePath)
".utf8))
failed = true
continue
}
let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] ?? []
if descriptors.isEmpty {
FileHandle.standardError.write(Data("unreadable: \(relativePath)
".utf8))
failed = true
}
for descriptor in descriptors {
let value = CTFontDescriptorCopyAttribute(descriptor, kCTFontNameAttribute)
guard let name = value as? String, !name.isEmpty else {
FileHandle.standardError.write(Data("unnamed: \(relativePath)
".utf8))
failed = true
continue
}
if !seen.insert(name).inserted {
FileHandle.standardError.write(Data("duplicate: \(name)
".utf8))
failed = true
}
print("\(name) \(relativePath)")
}
}
exit(failed ? 1 : 0)
При запуске укажите фактический путь к артефакту сборки:
xcrun swift ci/check_fonts.swift "$APP_PATH" | sort > build/actual-fonts.txt
diff -u ci/expected-fonts.txt build/actual-fonts.txt
Если эталонный список содержит только имена, к обработке вывода можно добавить cut -f1. Главное, чтобы обнаруженные различия приводили к ненулевому коду завершения и немедленно останавливали конвейер.
Фиксация PostScript-имени вместо имени файла
Headline-Bold.otf — это лишь имя файла на диске, тогда как внутреннее имя шрифта может быть HeadlinePro-Bold. В Font.custom из SwiftUI, при инициализации шрифтов в UIKit и в тестовом коде следует использовать внутреннее имя. Переименование файла шрифта не меняет его автоматически, но при обновлении версии шрифта поставщиком внутреннее имя может измениться.
Явное ревью изменений имён
Если actual-fonts.txt не совпадает со списком разрешённых имён, не следует автоматически принимать новые значения прямо в конвейере. Сначала убедитесь, что изменение связано с запланированным обновлением, а затем найдите все обращения к этому шрифту в проекте. Имена шрифтов можно собрать в одном файле с константами, чтобы они не были разбросаны по коду представлений и тестовым фикстурам.
Один файл шрифта также может содержать несколько дескрипторов. Скрипт должен сохранять все имена, а не только первое. Если команда действительно использует вариативные шрифты, стоит дополнить проверку UI-уровневыми smoke-тестами для часто используемых начертаний и убедиться, что настройки осей соответствуют дизайн-эталону.
Дополнительная проверка во время выполнения и приёмка интерфейса
Статический гейт подтверждает корректность структуры ресурсов, но не гарантирует, что на каждой странице используется нужное начертание. Рекомендуется добавить доступный только тестовому таргету экран с образцами шрифтов: заголовками, основным текстом, цифрами, китайской пунктуацией и длинными английскими словами. Для симулятора с фиксированным размером экрана следует выполнять проверку по скриншотам.
Проверки во время выполнения должны учитывать fontName экземпляра шрифта, а не только point size. Для динамических шрифтов тесты должны охватывать как минимум две категории размера контента и проверять, не обрезается ли текст после увеличения. Если интерфейс допускает откат к системному шрифту при отсутствии нужного файла, эта логика должна быть явно зафиксирована в коде. Отсутствие ключевого брендового шрифта в тестовой среде должно немедленно приводить к ошибке.
При параллельном тестировании каждое задание должно использовать отдельные каталоги сборки и результатов, чтобы список разрешённых имён из одной ветки не перезаписал список другой ветки. Файлы шрифтов являются входными ресурсами, поэтому сборочные скрипты не должны изменять их на месте.
Интеграция проверки в воспроизводимый конвейер
Полная последовательность должна быть такой: очистить изолированный каталог сборки, собрать нужную конфигурацию, найти единственный App-пакет, проверить структуру шрифтов, сравнить её со списком разрешённых имён и только затем запустить UI-тесты. Не используйте неоднозначную конструкцию find | head -1 для выбора артефакта. Если в одном каталоге находится несколько App, можно случайно проверить тестовый хост или устаревшую сборку.
После сбоя гейта следует сохранить actual-fonts.txt, список файлов шрифтов внутри App-пакета и соответствующий фрагмент журнала сборки, но не загружать посторонние учётные данные. При диагностике сначала проверяйте отсутствующие файлы, затем UIAppFonts и только после этого внутренние имена. Такой порядок позволяет быстро отличить ситуацию «шрифт не попал в пакет» от ошибки в имени, используемом кодом.
После одобрения обновления шрифта его файл, список разрешённых имён, централизованные константы имён и эталонные скриншоты следует зафиксировать в одном изменении. Тогда откат любого коммита восстановит полный согласованный набор, а результат сборки на облачном Mac не будет зависеть от шрифтов, заранее установленных на компьютере конкретного разработчика.
Часто задаваемые вопросы
Почему недостаточно проверить наличие шрифта в репозитории?
Файл может находиться в проекте, но не попасть в целевой пакет. Проверять нужно готовый App-пакет и каждую запись массива UIAppFonts.
Совпадает ли имя custom-шрифта в SwiftUI с именем файла?
Не обязательно. Обычно требуется внутреннее имя PostScript, которое следует прочитать из дескриптора и сравнить с утверждённым списком.
На каком этапе CI запускать проверку?
Сразу после создания устанавливаемого App-пакета и до загрузки либо передачи результата на распространение.
Запускайте рабочие процессы на Mac mini в облаке без перерывов
Выберите RunAMac M4, срок аренды и узел размещения, чтобы выполнять задачи разработки, сборки или тестирования на выделенном физическом узле.