import SwiftUI import ObjectiveC /// App 支持的界面语言。`system` = 跟随系统;其余对应 .lproj / String Catalog 语言。 enum AppLanguage: String, CaseIterable, Identifiable { case system case zhHans = "zh-Hans" case en case ja case ko var id: String { rawValue } /// 选择器里展示的名字。各语言用其**本族语**显示(行业惯例,不本地化),仅「跟随系统」随 App 语言。 var displayName: String { switch self { case .system: return String(appLoc: "跟随系统") case .zhHans: return "简体中文" case .en: return "English" case .ja: return "日本語" case .ko: return "한국어" } } /// nil = 跟随系统;否则为 .lproj / Locale 标识。 var localeIdentifier: String? { self == .system ? nil : rawValue } /// 语言选择器图标。各语言用本族语代表字区分(中 / A / あ / 가), /// 「跟随系统」非具体语言,用地球符号。代表字与 `displayName` 一样不本地化。 enum PickerIcon: Equatable { case symbol(String) // SF Symbol 名 case glyph(String) // 本族语代表字 } var pickerIcon: PickerIcon { switch self { case .system: return .symbol("globe") case .zhHans: return .glyph("中") case .en: return .glyph("A") case .ja: return .glyph("あ") case .ko: return .glyph("가") } } } /// 全 App 单例。负责:持久化选择、维护当前语言的 lproj bundle 与 locale。 /// - `Text("…")` 走根视图注入的环境 `\.locale`(+ Bundle 重定向)即时切换; /// - `String(appLoc:)` 显式绑定本管理器的 bundle/locale,不受 `.current` 限制,同样即时切换。 /// 切换后由根视图 `.id(current)` 触发整树重建,无需重启。 @Observable final class LanguageManager { static let shared = LanguageManager() private let storageKey = "appLanguage" private(set) var current: AppLanguage /// 当前语言对应的 .lproj bundle(system 或缺失时为 .main)。缓存,切换时更新。 private(set) var lprojBundle: Bundle = .main /// 当前解析后的 locale(system 时为 .autoupdatingCurrent)。 private(set) var resolvedLocale: Locale = .autoupdatingCurrent /// 供 SwiftUI 环境使用(日期/数字格式化 + Text 语言)。 var locale: Locale { resolvedLocale } private init() { let saved = UserDefaults.standard.string(forKey: storageKey) current = AppLanguage(rawValue: saved ?? "") ?? .system apply() } func set(_ language: AppLanguage) { guard language != current else { return } current = language UserDefaults.standard.set(language.rawValue, forKey: storageKey) // 同步 AppleLanguages:保证下次冷启动解析正确,并与系统「设置 → App → 语言」一致。 if let id = language.localeIdentifier { UserDefaults.standard.set([id], forKey: "AppleLanguages") } else { UserDefaults.standard.removeObject(forKey: "AppleLanguages") } apply() } private func apply() { if let id = current.localeIdentifier { resolvedLocale = Locale(identifier: id) if let path = Bundle.main.path(forResource: id, ofType: "lproj"), let b = Bundle(path: path) { lprojBundle = b } else { lprojBundle = .main } } else { resolvedLocale = .autoupdatingCurrent lprojBundle = .main } Bundle.redirectMain(to: current.localeIdentifier) // 同步 nonisolated 快照,供 String(appLoc:) 在非 MainActor 上下文读取。 appLocBundle = lprojBundle appLocLocale = resolvedLocale } } /// nonisolated 快照:`String(appLoc:)` 可能在非 MainActor 上下文被调用 /// (LocalizedError.errorDescription、nonisolated 枚举 label、static 解析器…)。 /// 只由 `LanguageManager.apply()`(MainActor)写入,切换语言时刷新;读为快照,无竞态影响。 nonisolated(unsafe) private var appLocBundle: Bundle = .main nonisolated(unsafe) private var appLocLocale: Locale = .autoupdatingCurrent extension String { /// 尊重「我的 · 语言」选择的本地化(可即时切换)。 /// 等价 `String(localized:)`,但显式绑定当前所选语言的 bundle + locale, /// 因此不受 `Locale.current`(系统/启动时语言)限制。 nonisolated init(appLoc key: String.LocalizationValue) { self = String(localized: key, bundle: appLocBundle, locale: appLocLocale) } } // MARK: - Bundle 运行时重定向(供 Text / NSLocalizedString 双保险) /// 关联对象 key(需稳定地址,文件级全局即可)。 private var redirectBundleKey: UInt8 = 0 /// 把 `Bundle.main` 的字符串查表重定向到指定语言的 .lproj。 private final class LocalizedMainBundle: Bundle, @unchecked Sendable { override func localizedString(forKey key: String, value: String?, table tableName: String?) -> String { if let target = objc_getAssociatedObject(self, &redirectBundleKey) as? Bundle { return target.localizedString(forKey: key, value: value, table: tableName) } return super.localizedString(forKey: key, value: value, table: tableName) } } extension Bundle { /// language == nil → 跟随系统(走默认解析)。 static func redirectMain(to language: String?) { if !(Bundle.main is LocalizedMainBundle) { object_setClass(Bundle.main, LocalizedMainBundle.self) } let target: Bundle? if let language, let path = Bundle.main.path(forResource: language, ofType: "lproj"), let bundle = Bundle(path: path) { target = bundle } else { target = nil } objc_setAssociatedObject(Bundle.main, &redirectBundleKey, target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } }