Практика разработки

Проверка ресурсов и регистрации шрифтов iOS в CI

Проверка ресурсов и регистрации шрифтов iOS в CI

В макете для заголовков используется пользовательский шрифт, и при локальной отладке всё выглядит правильно. Однако после установки 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, срок аренды и узел размещения, чтобы выполнять задачи разработки, сборки или тестирования на выделенном физическом узле.

Выбрать тариф аренды