Conversation
| <string>com.apple.share-services</string> | ||
| </dict> | ||
| </dict> | ||
| </plist> |
There was a problem hiding this comment.
Замечания и предложения по улучшению:
-
Ограниченность правила активации:
- Сейчас расширение появится для текста и «web page» (Safari-специфичный тип). Если вы хотите получать обычные URL из большинства приложений (Messages, Mail, Notes, Files и т. д.), добавьте
- NSExtensionActivationSupportsWebURLWithMaxCount = 1
- Если вы не обрабатываете URL вовсе, уберите NSExtensionActivationSupportsWebPageWithMaxCount, чтобы не «подсвечиваться» зря в Safari.
- Сейчас расширение появится для текста и «web page» (Safari-специфичный тип). Если вы хотите получать обычные URL из большинства приложений (Messages, Mail, Notes, Files и т. д.), добавьте
-
Явность поддерживаемых типов:
- Если планируется работа с файлами/изображениями/видео — добавьте соответствующие ключи:
- NSExtensionActivationSupportsImageWithMaxCount
- NSExtensionActivationSupportsMovieWithMaxCount
- NSExtensionActivationSupportsAudioWithMaxCount
- NSExtensionActivationSupportsFileWithMaxCount
- Иначе — держите правило максимально узким (например, только Text), чтобы не попадать в лишние сценарии.
- Если планируется работа с файлами/изображениями/видео — добавьте соответствующие ключи:
-
Количество элементов:
- Вы задали maxCount = 1 только для WebPage. Если код внутри ожидает ровно один элемент, убедитесь, что вы корректно обрабатываете кейсы, когда прилетает больше одного вложения (даже если правило не идеально отфильтровало). Либо явно добавьте/уточните maxCount для нужных типов, либо переходите на NSPredicate-правило.
-
Точность фильтрации через NSPredicate:
- Сложные условия (например, «ровно один URL ИЛИ текст без вложений») лучше описывать NSPredicate-строкой в NSExtensionActivationRule. Это дает более детальный контроль и уменьшает «шум» в системном листе шаринга. Если сейчас достаточно простых типов — можно оставить как есть, но имейте в виду переход на предикат при усложнении логики.
-
Principal class:
- Убедитесь, что класс ShareViewController:
- Находится в таргете расширения.
- Видим для Obj‑C runtime (обычно достаточно наследования от NSObject/UIViewController; при кастомных именованиях можно добавить @objc(ShareViewController)).
- Если используете SwiftUI, убедитесь, что контроллер корректно хостит SwiftUI-вью и не зависит от ресурсов, отсутствующих в бандле расширения.
- Убедитесь, что класс ShareViewController:
-
Покрытие сценариев за пределами Safari:
- Ключ WebPageWithMaxCount работает по сути для Safari. Если цель — появляться в большинстве приложений при шаринге ссылок, без WebURLWithMaxCount вы будете пропускать значительную часть сценариев.
-
Диагностика на этапе интеграции:
- Для быстрой проверки «проводки» можно временно поставить NSExtensionActivationRule = TRUEPREDICATE, убедиться, что расширение поднимается, а затем вернуть точные условия.
Итог: уточните список реально поддерживаемых типов и отразите это в NSExtensionActivationRule (минимум добавьте NSExtensionActivationSupportsWebURLWithMaxCount = 1, если нужны обычные URL). При усложнении условий переключайтесь на NSPredicate для более точного фильтра. Убедитесь, что PrincipalClass корректно доступен из бандла расширения.
| <string>group.com.amikhaylin.PomPadDo</string> | ||
| </array> | ||
| </dict> | ||
| </plist> |
There was a problem hiding this comment.
Ниже — только то, что может быть проблемой или требует улучшения.
-
Убедитесь, что файл действительно подключен как entitlements-файл:
- Имя файла должно иметь расширение .entitlements, а не .plist.
- В Build Settings каждого нужного таргета (и для Debug, и для Release) проверьте CODE_SIGN_ENTITLEMENTS указывает на этот файл.
-
Подпись и портал разработчика:
- App Group group.com.amikhaylin.PomPadDo должен быть создан в Apple Developer Portal и привязан к нужным App ID.
- Профили подписи должны быть обновлены/пересозданы, иначе на устройстве получите mismatch по entitlements.
-
Все таргеты, которые будут использовать общий контейнер, должны иметь одинаковый App Group:
- Основное приложение + все расширения (Widget, Intents, Share и т.д.).
- Если какой-то из них не имеет эту группу, FileManager.containerURL(...) и UserDefaults(suiteName:) вернут nil.
-
macOS/Catalyst: без App Sandbox эта группа будет игнорироваться.
- Добавьте com.apple.security.app-sandbox = true для соответствующих таргетов macOS/Catalyst.
-
Кодовая сторона (устойчивость):
- Не делайте force unwrap для UserDefaults(suiteName:) и FileManager.default.containerURL(forSecurityApplicationGroupIdentifier:). В превью/тестах/при сбое в подписи они могут вернуть nil — логируйте/фейлите только в DEBUG.
- Имеет смысл добавить runtime-проверку в DEBUG и чёткое сообщение, если контейнер не получен.
-
Разделение сред:
- Рассмотрите отдельные группы для dev/stage/prod (например, group.com.amikhaylin.PomPadDo.dev), переключаемые через .xcconfig. Это спасает от «засорения» боевых данных тестовыми.
-
WatchOS нюанс:
- App Group не шарит файлы между iPhone и Apple Watch (разные девайсы). Для синхронизации с часами используйте WatchConnectivity. App Group актуален для расширений на том же устройстве (например, Widget).
-
Миграции данных:
- Если переносите хранилище в App Group (например, с UserDefaults по умолчанию на shared UserDefaults), продумайте одноразовую миграцию, иначе пользователи «потеряют» старые данные.
-
Проверка сборки:
- После сборки проверьте фактические entitlements: codesign -d --entitlements :- /path/to/App.app (или к расширению) и убедитесь, что группа действительно вшита.
-
Нотация идентификатора:
- Старайтесь придерживаться единообразного стиля (обычно нижний регистр) и не менять регистр в разных местах проекта — идентификаторы чувствительны к точному совпадению.
|
|
||
| link = url | ||
| } | ||
| } |
There was a problem hiding this comment.
Ниже — только существенные замечания и предложения улучшений.
-
Отмена через cancelRequest: сейчас создается кастомная ошибка. Для корректной семантики “пользователь отменил” используйте стандартный NSError с доменом/кодом расширений. Например: NSError(domain: NSExtensionErrorDomain, code: NSExtensionErrorCode.userCanceled.rawValue, userInfo: nil). Это важно для хост-приложения и системных логов.
-
Надежность сохранения SwiftData:
- После modelContext.insert(task) явно вызывайте save() и обрабатывайте ошибку перед completeRequest. В расширении процесс может быстро выгружаться, на автосейв лучше не полагаться.
- При ошибке сохранения корректно завершайте через cancelRequest(withError:), чтобы не создать ложное впечатление, что “сохранилось”.
-
Изоляция по актору:
- Пометьте View и экшены как @mainactor (или хотя бы методы, где меняется @State и используется modelContext/NSExtensionContext). Это снимет потенциальные гонки, особенно после await в loadWebpageMetadata, и успокоит статический анализатор.
-
Нейминг и читаемость:
- В loadWebpageMetadata(for context:) параметр затеняет одноименное свойство. Переименуйте параметр в extensionContext.
- В saveAction переменная task конфликтует по смыслу с Swift Concurrency “Task” и .task модификатором. Лучше назвать todo или item.
-
Обработка вложений:
- Код, судя по комментариям, ожидает только URL. На практике в Share Extension часто прилетают: public.url, public.plain-text, fragmentы текста, изображения. Имеет смысл:
- Попробовать URL, затем падать обратно на текст (plainText) и заполнять text им.
- Если URL нет, но есть текст — всё равно дать сохранить.
- Если есть несколько вложений — брать первое подходящее по приоритету.
- Если расширения для firstAttachment/ofType и loadURL не покрывают эти кейсы — расширьте.
- Код, судя по комментариям, ожидает только URL. На практике в Share Extension часто прилетают: public.url, public.plain-text, fragmentы текста, изображения. Имеет смысл:
-
UX/валидация ввода:
- Запретите “Save”, если text пустой или из пробелов; подсказка/placeholder для TextEditor улучшит UX.
- Пока грузится metadata — либо покажите ProgressView, либо предварительно заполните text чем-то быстрым (например, host из URL), а потом обновите заголовком.
- Хорошая идея — дизейблить “Save” до окончания первой попытки метаданных либо до того, как пользователь что-то введет.
-
LPMetadataProvider:
- Добавьте защиту от долгих запросов/отмены: проверяйте Task.isCancelled и корректно выходите. В случае CancellationError не печатайте ошибки.
- На ошибке сейчас просто print(error). Используйте os.Logger, чтобы не засорять консоль продакшена. И желательно деградировать к базовому заполнению (например, text = url.host ?? url.absoluteString).
-
Превью-изображение:
- Порядок модификаторов лучше сделать как .resizable().scaledToFill().frame(...).clipped(). Иначе возможны артефакты кадрирования/неожиданная геометрия.
- Добавьте .accessibilityLabel для картинки.
-
Layout TextEditor в HStack:
- Вероятно потребуется .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) у TextEditor, чтобы он не схлопывался из-за фиксированного превью.
- Подумайте о .scrollContentBackground(.hidden) и отдельном фоне, если нужен визуальный паритет со стандартным контроллером Share.
-
Модель данных:
- Если возможно, храните ссылку как URL в модели (SwiftData умеет), а не String. Это даст типовую безопасность и меньше ручной сериализации.
- Тримминг текста перед сохранением (whitespacesAndNewlines).
-
Импорт и мелочи:
- UniformTypeIdentifiers в этом файле напрямую не используется — можно удалить импорт (если утилиты расширений в другом файле — ок, но держите импорт там).
- В completeRequest можно передавать nil вместо пустого массива, если возвращать нечего.
- Локализация строк “Cancel”/“Save”.
-
Интеграция с основной БД:
- Убедитесь, что контейнер SwiftData для расширения настроен на App Group, иначе сохранения будут уходить в отдельное хранилище расширения и не появятся в основном приложении.
Эти правки повысят надежность (сохранение/отмена), устойчивость к разным типам шэринга, улучшат UX и уберут потенциальные проблемы с акторами и лэйаутом.
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Ниже только существенные замечания и рекомендации по улучшению/исправлению.
Критичные проблемы
- Нет импорта UIKit. Класс наследуется от UIViewController и использует UIHostingController, но UIKit закомментирован. Это не скомпилируется. Уберите import Social и верните import UIKit. Social.framework сейчас не нужен и помечен как deprecated для Share Extensions.
- Жизненный цикл дочернего контроллера. После addChild(contentView) нужно вызывать contentView.didMove(toParent: self), иначе нарушен контракт контейнерного контроллера.
- SwiftData в экстеншне: конфигурация хранилища.
- Если цель — шарить данные с основным приложением, нужно хранить базу в App Group, иначе у экстеншна будет свой отдельный стор. Задайте URL из контейнера App Group (или соответствующий параметр groupContainer, если используете этот API).
- Не полагайтесь на дефолтный путь. Примерно: получить URL через FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.your.id") и передать его в ModelConfiguration(url:).
- Избегайте fatalError при создании контейнера в экстеншне. Лучше показать пользователю понятную ошибку и завершить контекст: extensionContext.cancelRequest(withError:).
- Завершение экстеншна. Убедитесь, что из SwiftUI-экрана в конце вызывается extensionContext.completeRequest(returningItems:completionHandler:) или cancelRequest(withError:). Без этого система может убить экстеншн по таймауту.
- NSItemProvider.loadURL: вы используете неизвестный async-API loadItem(forTypeIdentifier:) как await. В стандартном SDK этот метод колбэчный. Сделайте собственный async-обертку через withCheckedThrowingContinuation, как вы сделали для картинок, или используйте современные typed-API (см. ниже).
Надежность и совместимость
- Поиск вложений: сейчас вы проверяете только первый NSExtensionItem. В шэршите часто бывает несколько inputItems и несколько attachments. Лучше пройти по всем и взять первое подходящее:
- Итерируйте по всем inputItems и вложениям, проверяя hasItemConforming…
- Используйте typed-варианты API, где возможно: hasItemConforming(to: UTType) вместо строкового hasItemConformingToTypeIdentifier.
- Загрузка URL:
- Для файлов разумнее сначала пробовать loadInPlaceFileRepresentation(for:) (даёт file URL без копирования), а для веб-URL fallback на loadItem/данные.
- Будьте готовы, что провайдер вернёт не URL, а, например, NSString со строкой URL — добавьте распознавание таких кейсов (String/NSData -> URL).
- Загрузка изображений:
- Предпочтительно использовать canLoadObject(ofClass:) и loadObject(ofClass: UIImage.self) — это снимает с вас декодирование и лучше работает с разными типами (HEIC/PNG/JPEG).
- Если остаётесь на loadDataRepresentation(for: .image), оставьте как есть, но добавьте ограничение размера/ресемплинг больших изображений, чтобы не перегружать экстеншн по памяти.
- Потоки/акторы: колбэки NSItemProvider не гарантируют главный поток. Если из результата обновляете UI/модель, переключайтесь на MainActor в нужных местах.
- Ошибки. ShareExtensionError лучше сделать LocalizedError с понятными описаниями для показа пользователю (и логов). Сейчас .itemTypecastFailed(.image) не даёт дружественного текста.
Архитектура/устойчивость
- Инициализация ModelContainer:
- Сейчас контейнер создаётся как let-свойство с немедленной инициализацией. Сделайте lazy var инициализацию, чтобы не тратить время/память, если контроллер внезапно выгружается до показа UI.
- Сведите конфигурацию SwiftData (Schema, ModelConfiguration) в общий модуль, который использует и приложение, и экстеншн. Это снизит риск размытия схемы между таргетами.
- Обработка сбоев вместо крашей: fatalError в экстеншне — плохой UX. Любые неожиданные ошибки — показать компактный экран ошибки и корректно отменить запрос.
Мелкие замечания
- Явно распаковуйте extensionContext: лучше читается guard let context = self.extensionContext else { … }.
- При добавлении UIHostingController можно задать backgroundColor у корневого view (иногда иначе виден “черный фон” на переходах).
- firstAttachment(ofType:) возвращает только NSItemProvider; возможно, удобнее сразу вернуть загруженный объект/данные с учётом типа (например, URL или UIImage) вторыми методами, чтобы не дублировать логику загрузки в вызывающем коде.
Намётки правок (без лишней болтовни)
- Импорты:
- Удалить import Social.
- Раскомментировать import UIKit.
- Добавить contentView.didMove(toParent: self) после добавления дочернего VC.
- Конфигурация SwiftData с App Group:
- Получить URL общего контейнера App Group и передать в ModelConfiguration(url: …). Обрабатывать ошибки без fatalError и завершать экстеншн.
- NSItemProvider:
- Переписать loadURL на withCheckedThrowingContinuation обертку для loadItem или использовать loadInPlaceFileRepresentation для файлов, плюс фолбэк на строку.
- Для изображений — loadObject(ofClass: UIImage.self) с canLoadObject проверкой.
- NSExtensionContext:
- firstAttachment(ofType:) — проход по всем inputItems/attachments.
- ShareExtensionError: реализовать LocalizedError.
- В конечной точке сценария — обязательно completeRequest/cancelRequest.
| }; | ||
| 4D2867632D61F5C20064ADF3 /* Build configuration list for PBXNativeTarget "PomPadDo.mobile" */ = { | ||
| isa = XCConfigurationList; | ||
| buildConfigurations = ( |
There was a problem hiding this comment.
Ниже — только те замечания, которые реально могут повлиять на сборку, корректность расширения и последующую публикацию.
Критичные проблемы
- IPHONEOS_DEPLOYMENT_TARGET = 26.2 в новом таргете PomPadDo.mobile.share — это неверное значение (такой версии iOS не существует). Поставьте реальную минимальную версию iOS, согласованную с основным приложением (например 16.0/17.0/18.0). Сейчас таргет не соберётся/не подпишется корректно.
- MARKETING_VERSION = 26.1 у расширения не согласована с остальными таргетами (у них 1.0). Приведите к единому семантическому versioning (либо поднимите всем, либо используйте отдельную схему версионирования осознанно).
- В таргете расширения нет APPLICATION_EXTENSION_API_ONLY = YES. Это обязательно для app extension, чтобы отловить запрещённые API (UIApplication.shared и т.п.) на этапе компиляции.
- Отображаемое имя расширения. INFOPLIST_KEY_CFBundleDisplayName = PomPadDo.mobile.share — это то, что увидит пользователь в системном шите. Лучше задать человекочитаемое имя (например «В PomPadDo» или «PomPadDo Share»).
- Info.plist: вы одновременно указываете GENERATE_INFOPLIST_FILE = YES и INFOPLIST_FILE = PomPadDo.mobile.share/Info.plist. Оставьте один источник правды. Если храните собственный Info.plist, выключите автогенерацию и убедитесь, что:
- Прописан NSExtension с корректным NSExtensionPointIdentifier (для Share Extension это com.apple.share-services).
- Задан NSExtensionPrincipalClass или NSExtensionMainStoryboard (если используете SwiftUI — обычно это хостинг-контроллер вашего корневого вью).
- Указаны нужные NSExtensionAttributes и правило активации NSExtensionActivationRule.
- Подпись. В новом таргете не задан DEVELOPMENT_TEAM. Если вы рассчитываете на проектный дефолт — проверьте, что он реально выставлен на уровне проекта. Иначе автоматический кодсайн расширения будет падать.
- App Groups. Для обмена данными между приложением и шаринг-расширением нужны App Groups в энтайтламентах обоих таргетов. Сейчас в новом таргете есть entitlements-файл, но нет упоминания про группы. Если планируете сохранять/читать общие данные (SwiftData/CoreData/файлы/Keychain), добавьте общий App Group и синхронизируйте в обоих таргетах.
- Пустые билд-фазы Sources/Resources у PomPadDo.mobile.share. Даже если вы используете «синхронизированные группы» Xcode, убедитесь, что:
- В Sources действительно попал файл точки входа расширения (ShareViewController/ShareView и т.п.).
- Подключены необходимые ресурсы (Assets, локализации) и фреймворки (SwiftUI, UniformTypeIdentifiers, LinkPresentation и т.д. — обычно подтягиваются автоматически при импорте).
- Иначе соберётся «пустой» appex без точки входа.
Архитектура и согласованность
- PRODUCT_BUNDLE_IDENTIFIER = com.amikhaylin.PomPadDo.PomPadDo-mobile-share. Технически дефис допустим, но для единообразия и читабельности лучше использовать точечное пространство имён, например com.amikhaylin.PomPadDo.share или com.amikhaylin.PomPadDo.ShareExtension.
- TARGETED_DEVICE_FAMILY = "1,2". Синхронизируйте с хост-приложением: если мобильное приложение только для iPhone, расширение тоже можно ограничить «1». Если обоих — оставляйте «1,2».
- SWIFT_VERSION = 5.0 у расширения, тогда как проект, судя по Xcode 16.x, фактически компилируется Swift 5.9/5.10. Лучше выровнять с остальными таргетами или вовсе не переопределять, чтобы не получить неожиданные ворнинги/диагностику.
- SWIFT_APPROACHABLE_CONCURRENCY и SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY включены только для расширения. Либо включайте их консистентно для всех таргетов, либо отключите в расширении, чтобы не поймать рассинхронизацию поведения компилятора.
- PBXTargetDependency на расширение из главного приложения, при том что appex уже добавлен в «Embed Foundation Extensions», избыточен. Встраивание appex само добавляет зависимость на сборку. Можно удалить PBXTargetDependency, чтобы упростить граф зависимостей.
- PBXFileSystemSynchronizedBuildFileExceptionSet:
- Есть набор 4D7AF8122F32417F00FBFFFA, привязанный к группе PomPadDo.mobile, но относится к таргету расширения. Это выглядит как случайная привязка к неправильной группе. Держите исключения для одного таргета в соответствующем корневом group (Shared или PomPadDo.mobile.share), чтобы Xcode не «перетасовывал» membership неожиданно.
- Проверьте, что в расширение не «протекли» файлы, использующие запрещённые API приложения (например, ваш Utils/NotificationManager может использовать UIApplication — для extension это нельзя). Либо исключите такие файлы из membership расширения, либо выделите «extension-safe» обёртки.
- Frameworks в таргете расширения пуст — это ок, если вы используете только Swift модульные зависимости, которые подтянутся автоматически. Но если используете пакеты/библиотеки (например, ваш SwiftDataTransferrable), не забудьте добавить product dependency в packageProductDependencies у расширения.
- Known regions: добавлен Base в PBXProject. Убедитесь, что соответствующие Base.lproj реально добавлены, иначе получите ворнинги локализации.
Практические правки (рекомендуемый минимум)
- Для таргета PomPadDo.mobile.share:
- APPLICATION_EXTENSION_API_ONLY = YES
- IPHONEOS_DEPLOYMENT_TARGET = 17.0 (или ваш проектный минимум)
- MARKETING_VERSION = 1.0 (или общий для проекта)
- DEVELOPMENT_TEAM = 9Z68336878 (если это дефолт команды)
- Выберите один вариант Info.plist: либо уберите GENERATE_INFOPLIST_FILE, либо уберите INFOPLIST_FILE и вынесенные ключи — и убедитесь, что NSExtension настроен для share extension.
- Задайте человекочитаемый INFOPLIST_KEY_CFBundleDisplayName (например «PomPadDo»).
- Добавьте APPLICATION_GROUPS в entitlements и синхронизируйте с хостом.
- Приведите исключения PBXFileSystemSynchronizedBuildFileExceptionSet к ожидаемым группам и исключите из membership расширения всё, что использует не extension-safe API.
- Пересмотрите необходимость PBXTargetDependency от хоста к appex — вероятно, можно удалить.
- Проверьте, что у расширения есть как минимум 1 исходный файл с точкой входа, и он действительно попадает в Sources этого таргета.
Что проверить дополнительно вручную
- Что расширение видит общий стор (через App Group) и корректно работает с вашим storage-слоем (SwiftData/CoreData/файлы), не лезет в контейнер приложения напрямую.
- Что на симуляторе и на девайсе appex корректно появляется в системном шаринге (правильное правило активации NSExtensionActivationRule).
- Что размер appex разумный (не протащили тяжёлые ресурсы из основного таргета по ошибке).
Исправив перечисленное, вы избавитесь от фэйлов сборки/подписания и от потенциального ревью-реджекта за использование не extension-safe API.
| import Combine | ||
|
|
||
| enum FocusTimerState: String { | ||
| case idle |
There was a problem hiding this comment.
Наблюдения и предложения по улучшению:
-
Импорт Combine
- Если Combine в этом файле не используется — удалите импорт.
- Если вы добавляете Combine только ради таймера (Timer.publish), в iOS 17+/Swift Concurrency лучше использовать Task + Clock/Task.sleep или AsyncTimerSequence (swift-async-algorithms). Это проще, не требует AnyCancellable и лучше сочетается с Observation.
- Если всё‑таки используете Combine, убедитесь, что:
- Подписки хранятся в стабильном reference‑типе (final class), а не в @observable struct, иначе AnyCancellable потеряется при копировании.
- Отписываетесь вовремя (очищаете набор cancellables) и избегаете retain‑циклов (weak self в sink).
- Мутации состояния происходят на главном потоке (@mainactor), иначе SwiftUI/Observation может получать гонки.
-
Сочетание Observation и Combine
- Не смешивайте @observable и @Published/ObservableObject в одном модельном типе без крайней необходимости. Выберите один подход. Для iOS 17+ предпочтительнее Observation.
- Если используете Combine‑паблишеры только для связи со SwiftUI, часто хватит .onReceive в View, без протаскивания AnyCancellable в модель.
-
FocusTimerState
- Задумайтесь, нужен ли rawValue = String. Часто достаточно простого enum без rawValue. String‑raw значения хрупкие при рефакторинге.
- Если требуется персистенция/логирование — лучше Codable с явным контролем над версиями. При декодировании добавьте обработку неизвестных значений (например, маппить на .idle).
- Если есть конечный автомат (idle/running/paused/finished), имеет смысл:
- Явно зафиксировать допустимые переходы (например, методом canTransition(to:) или инкапсулировать логику переходов в модель).
- Добавить удобные вычисления, типа isActive (running || paused), вместо работы со строковыми rawValue.
-
Потокобезопасность и аннотации
- Если модель/состояние меняются из UI, пометьте модель @mainactor. Это избавит от неожиданных предупреждений и гонок при работе с Observation/SwiftUI.
-
Организация кода
- Импорты держите минимальными и локальными. Если Combine нужен только в реализации таймера — импортируйте его там, где он реально используется.
- Добавьте модульные тесты на переходы состояний таймера (особенно на граничные случаи: повторный старт, пауза после завершения, и т.д.).
Если покажете остальную часть файла (модель таймера, хранение cancellables/Task, точки мутаций), смогу дать более точечные рекомендации по архитектуре и управлению временем/отписками.
| import Combine | ||
|
|
||
| struct Common { | ||
| static func formatSeconds(_ seconds: Int) -> String { |
There was a problem hiding this comment.
Ниже — только предметные замечания по диффу и типичному содержимому такого утилитарного файла.
-
Лишние импорты:
- Combine добавлен, но по фрагменту он не используется. Уберите, чтобы не тянуть лишние зависимости и ускорить билд.
- Аналогично, если в этом файле реально не нужны SwiftData-типы, вынесите Common в отдельный файл без import SwiftData. Утилиты должны зависеть минимумом.
-
Антипаттерн “Common”:
- Общие «свалки» растут бесконтрольно и ухудшают связность. Переименуйте в что-то предметное (например, TimeFormatting) или, лучше, используйте расширения соответствующих типов.
-
Сигнатура и типы:
- formatSeconds(_ seconds: Int) ограничивает вас целыми секундами. Рассмотрите TimeInterval (Double) или стандартный Duration (Swift 5.9+) — гибче для будущего (доли секунды, точность).
- Если по домену допустимы отрицательные значения/большие интервалы — определите поведение (обрезать до 0, показывать знак, поддерживать часы/дни).
-
Локализация и форматирование:
- Если строка UI-направленная, предпочтительнее DateComponentsFormatter — он заботится о локали и нулях:
- allowedUnits: [.hour, .minute, .second] по необходимости
- unitsStyle: .positional
- zeroFormattingBehavior: .pad
- Если строго «мм:сс» и нужен предсказуемый вывод без оверхеда — вручную через String(format: "%02d:%02d"), но зафиксируйте требования к локали/часам в тестах и документации.
- Если строка UI-направленная, предпочтительнее DateComponentsFormatter — он заботится о локали и нулях:
-
Производительность:
- Не создавайте DateComponentsFormatter на каждый вызов. Кешируйте статически:
static let mmss: DateComponentsFormatter = { ... }() - Если вызывается на таймере с высокой частотой, ручное форматирование через деление и String(format:) будет быстрее.
- Не создавайте DateComponentsFormatter на каждый вызов. Кешируйте статически:
-
Дизайн API:
- Избегайте статических «утилитных» типов. Лучше расширение:
- extension Duration { var mmss: String { ... } }
- или extension TimeInterval { var mmss: String { ... } }
- Современный путь — собственный FormatStyle, чтобы использовать .formatted():
- Text(duration, format: .mmss)
- Если оставляете «простую» функцию — дайте более точное имя: formatSecondsToMMSS или format(duration:units:style:).
- Избегайте статических «утилитных» типов. Лучше расширение:
-
Доступность/инстанцирование:
- Если тип остаётся «неймспейсом», сделайте его enum без кейсов, чтобы исключить создание экземпляров.
- Явно задайте access control (internal/public) — по умолчанию internal может быть неочевиден для межмодульного использования.
-
Тесты и крайние случаи:
- Тесты для 0, 1, 59, 60, 61, -1, больших значений (часы/дни), и стабильности локали.
- Если поддерживаете часы, проверьте переход 3599→3600.
Пример улучшённой реализации (минимум зависимостей, быстрый путь, с часами и отрицательными значениями):
-
Вариант через расширение TimeInterval:
- extension TimeInterval {
func formattedMMSS(includeHours: Bool = false) -> String {
var t = Int(self.rounded())
let sign = t < 0 ? "-" : ""
t = abs(t)
let s = t % 60
let m = (t / 60) % 60
let h = t / 3600
if includeHours || h > 0 {
return "(sign)(String(format: "%d:%02d:%02d", h, m, s))"
} else {
return "(sign)(String(format: "%02d:%02d", m, s))"
}
}
}
- extension TimeInterval {
-
Вариант через DateComponentsFormatter c кешированием:
- enum TimeFormatting {
private static let mmss: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.minute, .second]
f.unitsStyle = .positional
f.zeroFormattingBehavior = .pad
return f
}()
private static let hhmmss: DateComponentsFormatter = {
let f = DateComponentsFormatter()
f.allowedUnits = [.hour, .minute, .second]
f.unitsStyle = .positional
f.zeroFormattingBehavior = .pad
return f
}()
static func format(seconds: TimeInterval, includeHours: Bool = false) -> String {
let absVal = abs(seconds)
let base = includeHours || absVal >= 3600 ? hhmmss : mmss
let sign = seconds < 0 ? "-" : ""
return sign + (base.string(from: absVal) ?? "00:00")
}
}
- enum TimeFormatting {
Если проект на Swift 5.9+, рассмотрите кастомный FormatStyle для Duration — он хорошо вписывается в .formatted() и SwiftUI Text.
Итог по диффу: импорт Combine (и, вероятно, SwiftData) в этом файле лишний; «Common» стоит переосмыслить; улучшите сигнатуру и локализацию форматирования, добавьте кеширование форматтера и тесты на крайние случаи.
Share extension for iOS (Safari)