Разработка
Одна настройка, два шлюза локализации: локализация iOS-приложения без использования AppleLanguages
Apple предоставляет два очевидных способа добиться этого — и оба оказываются ловушками.
Hello Weather — это не один процесс. Текст отображает само приложение, его виджеты, приложение для Apple Watch и уведомления — и каждый из этих компонентов работает в отдельном процессе, который не разделяет окружение основного приложения. Это становится важно в тот момент, когда вы решаете, что язык должен быть настройкой конкретного приложения: пользователь выбирает его один раз внутри приложения, после чего этот язык применяется везде, где отображается текст, переключается на лету без перезапуска и никогда не наследуется от системного языка телефона. Если человек использует iPhone на немецком, но хочет видеть прогноз погоды на японском, одного нажатия в настройках должно быть достаточно, чтобы японский использовался в самом приложении, в виджете на домашнем экране и на Apple Watch.
Apple предоставляет два очевидных способа добиться этого — и оба оказываются ловушками.
Первый — AppleLanguages, массив в UserDefaults, который iOS читает при выборе языка процесса. Изменение этого значения не подходит сразу по трём причинам: значение кэшируется при запуске, поэтому для применения изменений требуется перезапуск приложения; оно сохраняется между обновлениями приложения способом, который вы не контролируете; это тот же самый ключ, который использует системная настройка языка конкретного приложения в iOS. Если изменять AppleLanguages самостоятельно, ваша настройка начинает конфликтовать с системной.
Второй очевидный вариант — напрямую использовать String(localized:). Он выполняет локализацию через Bundle.main, который, в свою очередь, ориентируется на язык устройства. В результате любой текст, локализованный таким образом, незаметно обходит ваш встроенный переключатель языка. Пользователь выбирает японский, но половина интерфейса остаётся на немецком просто потому, что эта часть текста прошла через неправильный API.
Есть и более фундаментальная проблема: в SwiftUI понятие «язык приложения» на самом деле не едино. Литерал Text("...") локализуется через locale в окружении SwiftUI. А обычная строка String, которую вы создаёте, например, для уведомления или accessibility label, локализуется тем API, который вы вызвали. То есть существует два независимых пути разрешения локализации. Если исправить только один, язык устройства всё равно просочится в интерфейс, который вы считали полностью локализованным. Поэтому архитектурный вопрос нужно решить ещё до начала перевода: где находится единственный источник истины для языка приложения и как направить через него каждую пользовательскую строку?
Решение
Одна настройка отвечает за язык, а весь отображаемый текст проходит через один из двух шлюзов локализации. Настройка представляет собой обычную строку в общем хранилище, за которой стоит enum:
import Foundation
enum Language: String, CaseIterable, Identifiable {
case cs, da, de, el, en, es, fi, fr, hi, hu, id, it
case ja, ko, nb, nl, pl, pt, ro, ru, sv, th, tr, uk, vi
case zhHans = "zh-Hans"
case zhHant = "zh-Hant"
var id: String { rawValue }
var locale: Locale { Locale(identifier: rawValue) }
}
Всего получается 27 вариантов. Enum основан на String, поэтому rawValue одновременно используется и как идентификатор локали, и как значение, сохраняемое в настройках. Для двух вариантов китайского заданы явные raw values, поскольку их идентификаторы — не просто двухбуквенные языковые коды. В самом приложении у каждого варианта также есть displayName с самоназванием языка («Deutsch», «日本語;», «简体中文.»), так пользователю проще найти нужный язык в списке.
Значение по умолчанию выбрано намеренно консервативно. Приложение запускалось постепенно, поэтому по умолчанию всё остаётся на английском, пока пользователь сам не выберет другой язык:
func languageDefault() -> String {
// Detect the device locale and map it here to auto-adopt on first launch.
return Language.en.rawValue
}
Автоматически подхватывать язык устройства при первом запуске — это уже продуктовое решение, которое может привести к дополнительным вопросам поддержки. Позже это легко включить, вернув сопоставленный код языка устройства вместо жёстко заданного en. Консервативный подход гарантирует, что пользователь не увидит язык, который сам не выбирал. Теперь — о двух шлюзах.
Шлюз №1: locale окружения, закреплённый в каждом корне
SwiftUI локализует литералы Text("...") через locale в environment. Если установить этот environment locale в соответствии с выбранной пользователем настройкой, все литералы в дереве представлений автоматически начинают использовать нужный язык. Не требуется wrapper, вспомогательная функция в каждом месте или ручной вызов API локализации. Но есть нюанс: установить locale один раз недостаточно. Виджет работает в другом процессе и ничего не наследует от основного приложения. Поэтому локаль нужно задавать отдельно в корне каждого процесса, который выводит текст. В основном приложении таким корнем является сцена:
import SwiftUI
@MainActor
final class SettingsManager: ObservableObject {
static let shared = SettingsManager()
@Published var language = UserDefaults.standard.string(forKey: "language") ?? "en"
var languageLocale: Locale { Locale(identifier: language) }
}
@main
struct WeatherApp: App {
@ObservedObject private var settings = SettingsManager.shared
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.locale, settings.languageLocale)
}
}
}
Виджет находится в отдельном таргете и запускается с нуля, поэтому ту же локаль необходимо снова задать внутри замыкания содержимого его конфигурации. Никакая информация о корневом каталоге приложения до него не доходит:
import SwiftUI
import WidgetKit
struct CurrentConditionsWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "current", provider: Provider()) { entry in
WidgetEntryView(entry: entry)
.environment(\.locale, SettingsManager.shared.languageLocale)
}
}
}
Обратите внимание: locale задаётся один раз в каждом блоке, но всегда читается из одного и того же SettingsManager.shared. В нашем проекте таких мест примерно 35: корень приложения, каждое entry view виджета, корень watch-приложения, каждое complication view. На первый взгляд такое повторение может выглядеть как плохой запах архитектуры. Но оно решает реальную проблему. Если забыть задать locale в одном виджете, он начнёт отображаться на системном языке устройства. И в симуляторе с английским языком всё может выглядеть совершенно правильно, поэтому ошибка легко попадёт в релиз. Любая поверхность, которая запускается как отдельный процесс, должна заново закрепить environment locale. Это точка, где многопроцессная архитектура современного iOS-приложения встречается с нашей идеей «одна настройка языка».
Шлюз №2: хелпер localized(_:) для обычных String
Представления — лишь половина пользовательских строк. Есть ещё уведомления, метки доступности, значения словарей внутри manager-классов, суффиксы единиц измерения на графиках. Для всего этого нужен настоящий String, а у String нет окружения SwiftUI. Нельзя просто использовать :String(localized: "…"), потому что такой вызов снова ориентируется на язык устройства. Вместо этого используется одна вспомогательная функция, построенная поверх Language:
private let languageLocales: [String: Locale] = Dictionary(
uniqueKeysWithValues: Language.allCases.map { ($0.rawValue, $0.locale) }
)
func localized(_ resource: LocalizedStringResource) -> String {
var resource = resource
let language = UserDefaults.standard.string(forKey: "language") ?? Language.en.rawValue
resource.locale = languageLocales[language] ?? Locale(identifier: language)
return String(localized: resource)
}
Здесь критически важны два решения. Во-первых, параметр имеет тип LocalizedStringResource, а не String. Благодаря этому литералы продолжают автоматически извлекаться в каталога строк. Когда вы пишете: localized("Ranges"), компилятор видит ресурс локализации и добавляет "Ranges" в каталог так же, как если бы это был Text. Если бы параметр был обычным String, автоматическое извлечение потерялось бы. То есть один вызов одновременно обеспечивает разрешение независимо от языка устройства и автоматическое извлечение строки в каталог. Во-вторых, хелпер читает язык из того же общего ключа, который изменяет настройка пользователя, по умолчанию использует английский, назначает resource.locale и только после этого вызывает String(localized:). Именно эта перенастройка локали и является вторым шлюзом. Предварительно созданный словарь languageLocales нужен лишь для того, чтобы не создавать новый Locale при каждом вызове.
Использование функции выглядит совершенно обычно — localized("Ranges") или, например, localized("\(minutes) minutes") для plural ключей. Любой пользовательский текст типа String проходит через эту функцию. Так Text проходит через environment locale, String проходит через localized(_:). Третьего пути нет. И ни один из этих путей не обращается к языку устройства.
Почему это лучше альтернатив
Каждое свойство архитектуры соответствует одному отвергнутому варианту. Переключение языка работает без перезапуска, потому что environment locale читается при каждом рендеринге, а хелпер получает актуальную настройку при каждом вызове. AppleLanguages этого сделать не может, поскольку кэшируется до перезапуска процесса. Нет конфликта с iOS, поскольку мы вообще не изменяем AppleLanguages. Следовательно, внутренняя настройка приложения и системный per-app language selector не перезаписывают друг друга. И встроенный пикер остаётся единственным источником истины, потому что ни один путь локализации не обращается к языку устройства.
Есть один честный недостаток, который стоит упомянуть. Поскольку приложение полностью самостоятельно управляет своим языком, системный переключатель языка для этого приложения в Настройках остаётся видимым, но фактически ничего не делает. Пользователь может изменить язык там — и не увидит никакого эффекта. Мы приняли это как плату за корректную работу собственного выбора языка. Но если вы повторяете такой подход, стоит заранее решить, будете ли объяснять это пользователям внутри приложения.
Самая неприятная особенность String Catalog
Два шлюза направляют каждую строку на правильный язык. Но дальше вступает в работу String Catalog — Localizable.xcstrings. И здесь есть особенно неприятный нюанс: если для текущего языка у ключа отсутствует локализация, система показывает сам ключ, а не английское исходное значение. Многие предполагают, что при отсутствии перевода локализация автоматически откатится к исходному языку. Это не так. Если в японской таблице отсутствует "Air Quality", то пользователь всё равно увидит Air Quality — только потому, что сам ключ выглядит как английская фраза. Но если ключ называется "aqi.title", то пользователь увидит прямо aqi.title. Поэтому каталог фактически работает по принципу «всё или ничего».
Ключ должен быть либо полностью переведён на все поддерживаемые языки, либо считаться потенциальной ошибкой. Мы проверяем это тестом, который напрямую читает исходный каталог:
import Testing
import Foundation
struct StringCatalog: Decodable {
struct Entry: Decodable { let localizations: [String: Localization]? }
struct Localization: Decodable {} // presence is all we check
let strings: [String: Entry]
}
@Suite("String catalog completeness")
struct StringCatalogTests {
static let required: Set<String> = [
"cs", "da", "de", "el", "es", "fi", "fr", "hi", "hu", "id", "it",
"ja", "ko", "nb", "nl", "pl", "pt", "ro", "ru", "sv", "th", "tr",
"uk", "vi", "zh-Hans", "zh-Hant",
]
@Test("Every key is fully translated or fully untranslated")
func keysAreAllOrNothing() throws {
let url = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.appendingPathComponent("Localizable.xcstrings")
let catalog = try JSONDecoder().decode(StringCatalog.self, from: Data(contentsOf: url))
for (key, entry) in catalog.strings {
let present = entry.localizations.map { Set($0.keys) } ?? []
let missing = Self.required.subtracting(present)
#expect(missing.isEmpty || missing == Self.required,
"\"\(key)\" is partially translated; missing: \(missing.sorted())")
}
}
}
Каждый ключ должен присутствовать либо во всех 26 неанглийских языках, либо ни в одном. Вариант «ни в одном» оставлен намеренно для текста, перевод которого отложен и скрыт за feature flag. Любая частичная локализация приводит к падению теста. Localization здесь намеренно пустая структура. Нам не нужны сами значения строк — достаточно проверить, для каких языков вообще существуют записи. Если система локализации не обеспечивает автоматический fallback для каждого ключа, полнота каталога — это не проверка перед релизом. Это инвариант, который должен контролироваться при каждом коммите.
Сборка может удалять ключи, которые не видит
Самая неприятная опасность связана уже с инструментами. Сборка в командной строке может заново сгенерировать каталог и удалить ключи, которые не считает используемыми. Причём вместе с ключами исчезают и переводы. Мы видели, как ключ вроде "1 min", который реально использовался в представлении, но был указан способом, не распознанным процессом командной сборки, потерял все 26 переводов после регенерации файла.
Защита состоит из двух уровней. Первый — механическая проверка diff перед коммитом каталога после сборки:
git diff -- HelloWeather/HelloWeather/Resources/Localizable.xcstrings \ | grep -c '^-.*"value"'
Ненулевой результат означает, что какие-то переведённые значения удаляются. Правильное действие — восстановить файл, а не коммитить изменения.
Второй уровень — sentinel test, который закрепляет несколько особенно уязвимых ключей. Таким образом, случайное удаление ключей превращается из тихой ошибки в громкое падение теста.
@Test("Fragile keys survive catalog regeneration")
func sentinelKeysStayTranslated() throws {
let catalog = try JSONDecoder()
.decode(StringCatalog.self, from: Data(contentsOf: catalogURL))
for key in ["1 min", "5 min", "Rename", "Custom Name"] {
let entry = try #require(catalog.strings[key], "\"\(key)\" pruned from the catalog")
let present = entry.localizations.map { Set($0.keys) } ?? []
#expect(StringCatalogTests.required.subtracting(present).isEmpty)
}
}
К String Catalog нужно относиться одновременно как к сгенерированному файлу и как к вручную редактируемому файлу. Сборка может его переписать, поэтому diff нужно контролировать, а хрупкие ключи — закреплять тестами.
Результаты
- Один источник истины: enum с 27 языками, сохраняемый одной строкой в общем хранилище. По умолчанию используется английский, пока пользователь сам не выберет другой язык.
- Два шлюза локали и ни одного пути к языку устройства.
Textполучает язык через environment locale, а любойStringпроходит черезlocalized(_:). - Около 35 закреплений environment locale в корне приложения, entry view каждого виджета, watch-приложении и complications. Это цена правильной локализации четырёх отдельных процессов.
- Переключение языка без перезапуска и без взаимодействия с
AppleLanguagesили системным переключателем языка приложения. - Полнота обеспечивается тестами, а не дисциплиной разработчиков: каждый ключ либо переведён на все 26 языков, либо не переведён вообще, а отдельные проверки предотвращают удаление строк при сборке.
Уроки
- Определите единственный источник истины до того, как переведёте хотя бы одну строку. Главная сложность локализации приложения — не сами переводы, а определение того, что именно означает «язык приложения», и проведение всего текста через этот источник.
- Шлюзов локали два, а не один.
Textиспользует environment locale, а обычныйString— тот API, который вы вызвали. Если исправить только SwiftUI-окружение, уведомления и метки доступности всё равно могут использовать язык устройства. - Повторное закрепление locale в многопроцессном приложении — не плохой запах, а необходимость. Виджеты и watch-приложение запускаются отдельно и ничего не наследуют.
AppleLanguagesи прямойString(localized:)лучше сознательно запретить. Первый кэшируется на уровне процесса и конфликтует с системными настройками, второй следует языку устройства.- Не рассчитывайте на per-key fallback, пока не доказали, что он существует. Многие системы при отсутствии локализации показывают просто название ключа.
- Каталог одновременно генерируемый и вручную редактируемый. Сборка может удалить незамеченный ключ вместе с переводами, поэтому нужно проверять diff и защищать критичные ключи тестами.
- У любой честной архитектуры есть недостаток, который стоит документировать. В данном случае внутреннее управление языком делает системную строку выбора языка приложения в iOS фактически неработающей, что может путать пользователей.
-
Новости4 недели назадВидео и подкасты о мобильной разработке 2026.32
-
Новости3 недели назадGoogle анонсировал Gemini 3.7 Flash всего через три недели после предыдущего релиза
-
Разработка4 недели назад50 вопросов о System Design, ответы на которые должен знать каждый Senior iOS-разработчик: часть 5
-
Разработка4 недели назад14 лет в Android-разработке, но кажется, что мобильная разработка умирает. Реально ли перейти в серверную разработку на Kotlin?
