Files
keyboard/Shared/KBLocalizationManager.m
CodeST eaf512be7f 语言逻辑处理
我的项目里有5个国家的语言,如果用户在app里手动切换了国家语言,只要不卸载app,用户在手机设置切换语言,app的语言不要变;如果app被删
  除,重新安装,app语言要跟随手机设置的语言(如果语言对不上,app就显示英语)
  用户没有在app里手动设置过国家语言,用户在手机设置界面切换国家,app要跟随手机设置的语言(如果语言对不上,app就显示英语)。
2026-03-05 17:42:50 +08:00

475 lines
22 KiB
Objective-C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// KBLocalizationManager.m
// 多语言管理实现
//
#import "KBLocalizationManager.h"
#import <Security/Security.h>
#import "KBConfig.h"
// Darwin 跨进程通知语言变更App <-> 扩展)
static NSString * const kKBDarwinLocalizationChanged = @"com.loveKey.nyx.loc.changed";
/// 语言常量定义
KBLanguageCode const KBLanguageCodeEnglish = @"en";
KBLanguageCode const KBLanguageCodeTraditionalChinese = @"zh-Hant";
KBLanguageCode const KBLanguageCodeSpanish = @"es";
KBLanguageCode const KBLanguageCodePortuguese = @"pt-PT";
KBLanguageCode const KBLanguageCodeIndonesian = @"id";
/// 默认支持语言列表(集中配置)
NSArray<KBLanguageCode> *KBDefaultSupportedLanguageCodes(void) {
static NSArray<KBLanguageCode> *codes;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
codes = @[
KBLanguageCodeEnglish,
KBLanguageCodeSpanish,
KBLanguageCodeIndonesian,
KBLanguageCodePortuguese,
KBLanguageCodeTraditionalChinese
];
});
return codes;
}
/// 语言变更通知名称
NSString * const KBLocalizationDidChangeNotification = @"KBLocalizationDidChangeNotification";
// 通过共享钥匙串跨 Target 持久化语言选择
static NSString * const kKBLocService = @"com.loveKey.nyx.loc";
static NSString * const kKBLocAccount = @"lang"; // 保存 UTF8 的语言代码
// 首次安装/首次启动标记(仅主 App 侧使用标准 UserDefaults卸载后会被清除用于抵消 Keychain 在卸载重装后仍保留的问题)
static NSString * const kKBLocDidLaunchKey = @"com.loveKey.nyx.loc.did_launch";
// 主 App 启动标记(存 App Group键盘扩展可读用于“扩展先启动”场景下避免读取卸载重装遗留的 Keychain 语言。
static NSString * const kKBLocAppBootstrappedKey = @"com.loveKey.nyx.loc.app_bootstrapped";
// 用户是否在 App 内手动选择过语言(存 App Group卸载重装会清理满足“重装后跟随系统”
static NSString * const kKBLocUserSelectedLanguageKey = @"com.loveKey.nyx.loc.user_selected_language";
@interface KBLocalizationManager ()
@property (nonatomic, copy, readwrite) KBLanguageCode currentLanguageCode; // 当前语言代码
@property (nonatomic, strong) NSBundle *langBundle; // 对应语言的 .lproj 资源包
- (NSString *)normalizeLanguageCode:(NSString *)code;
- (NSString *)supportedLanguageCodeForCandidate:(nullable NSString *)code;
- (void)kb_onSystemLocaleMaybeChanged:(NSNotification *)note;
@end
// 避免 +shared 初始化阶段递归触发自身:
// 这里提供一个 C 级别的工具函数,构建钥匙串查询,不依赖实例或 +shared。
static inline NSMutableDictionary *KBLocBaseKCQuery(void) {
NSMutableDictionary *q = [@{ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
(__bridge id)kSecAttrService: kKBLocService,
(__bridge id)kSecAttrAccount: kKBLocAccount } mutableCopy];
if (KB_KEYCHAIN_ACCESS_GROUP.length > 0) {
q[(__bridge id)kSecAttrAccessGroup] = KB_KEYCHAIN_ACCESS_GROUP;
}
return q;
}
static void KBLocDarwinCallback(CFNotificationCenterRef center,
void *observer,
CFStringRef name,
const void *object,
CFDictionaryRef userInfo) {
// 语言更新后,扩展侧需要主动 reloadApp 侧收到也无害(会被 resolved==current short-circuit
dispatch_async(dispatch_get_main_queue(), ^{
KBLocalizationManager *m = [KBLocalizationManager shared];
[m reloadFromSharedStorageIfNeeded];
});
}
@implementation KBLocalizationManager
/// 是否运行在 App Extension如键盘扩展里。
/// 说明:扩展与主 App 共享 Keychain但不应参与“首次启动”判定避免扩展抢先写入标记导致主 App 不再清理历史 Keychain。
+ (BOOL)kb_isRunningInAppExtension {
return ([[NSBundle mainBundle] objectForInfoDictionaryKey:@"NSExtension"] != nil);
}
+ (NSUserDefaults *)kb_appGroupUserDefaults {
NSUserDefaults *ud = [[NSUserDefaults alloc] initWithSuiteName:AppGroup];
return ud ?: [NSUserDefaults standardUserDefaults];
}
+ (BOOL)kb_isAppBootstrapped {
// App Group 在卸载重装后会被清理;扩展通过该标记判断“主 App 是否已在本次安装中启动过”。
return [[self kb_appGroupUserDefaults] boolForKey:kKBLocAppBootstrappedKey];
}
+ (void)kb_markAppBootstrappedIfNeeded {
NSUserDefaults *ud = [self kb_appGroupUserDefaults];
if ([ud boolForKey:kKBLocAppBootstrappedKey]) { return; }
[ud setBool:YES forKey:kKBLocAppBootstrappedKey];
[ud synchronize];
}
+ (BOOL)kb_userDidSelectLanguage {
NSUserDefaults *ud = [self kb_appGroupUserDefaults];
return [ud boolForKey:kKBLocUserSelectedLanguageKey];
}
+ (void)kb_setUserDidSelectLanguage:(BOOL)selected {
NSUserDefaults *ud = [self kb_appGroupUserDefaults];
[ud setBool:selected forKey:kKBLocUserSelectedLanguageKey];
[ud synchronize];
}
+ (instancetype)shared {
static KBLocalizationManager *m; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{
m = [KBLocalizationManager new];
// 默认支持语言;可在启动时由外部重置
m.supportedLanguageCodes = KBDefaultSupportedLanguageCodes();
BOOL isAppExtension = [[self class] kb_isRunningInAppExtension];
BOOL wasAppBootstrapped = [[self class] kb_isAppBootstrapped];
BOOL isAppBootstrapped = wasAppBootstrapped;
if (!isAppExtension) {
// 仅主 App 写入该标记;扩展只读取
[[self class] kb_markAppBootstrappedIfNeeded];
isAppBootstrapped = YES;
}
// 新安装判定(优先使用 App Group 标记):卸载重装后 App Group 会被清理,但 Keychain 可能仍残留。
// 仅主 App 在检测到“本次安装从未启动过”时清理 Keychain并写入系统择优结果供扩展读取。
BOOL isFreshInstall = (!isAppExtension && !wasAppBootstrapped);
if (isFreshInstall) {
[[self class] kc_write:nil];
// 新安装确保“未手动选择”状态App Group 卸载重装会清理,这里再兜底一次)
[[self class] kb_setUserDidSelectLanguage:NO];
}
// 兼容旧逻辑:仍保留标准 UserDefaults 的首次启动标记(仅主 App但不再作为关键判定条件。
BOOL isFirstLaunch = (!isAppExtension) ? [[self class] kb_markLaunchedAndReturnIsFirstLaunch] : NO;
// 语言选择策略(满足产品规则):
// 1) 用户在 App 内手动选过语言 -> 锁定(不跟随系统);通过 Keychain 跨 Target 同步
// 2) 用户未手动选择过 -> 跟随系统当前语言(首选列表第一个),不读取/不写入 Keychain
BOOL userSelected = [[self class] kb_userDidSelectLanguage];
NSString *saved = nil;
if (userSelected) {
// 扩展在“主 App 尚未启动过”的场景下,忽略 Keychain防止卸载重装遗留旧值在扩展先启动时生效
if (!(isAppExtension && !isAppBootstrapped)) {
saved = (isFreshInstall ? nil : [[self class] kc_read]);
}
} else {
// 未手动选择:清理可能遗留的 Keychain例如旧版本曾写入“系统默认”导致后续不再跟随系统
if (!isAppExtension) {
NSString *legacy = [[self class] kc_read];
if (legacy.length > 0) {
[[self class] kc_write:nil];
}
}
}
#if DEBUG
NSLog(@"[KBLoc] init bundle=%@ isExt=%d appBoot=%d wasBoot=%d fresh=%d firstLaunch=%d userSelected=%d preferred=%@ keychainSaved=%@ supported=%@",
NSBundle.mainBundle.bundleIdentifier ?: @"",
isAppExtension, isAppBootstrapped, wasAppBootstrapped, isFreshInstall, isFirstLaunch,
userSelected,
[NSLocale preferredLanguages],
saved ?: @"(nil)",
m.supportedLanguageCodes ?: @[]);
#endif
// 计算最终语言:优先手动选择;否则跟随系统当前语言(不在 5 种内则英文)
NSString *resolved = [m supportedLanguageCodeForCandidate:saved];
if (saved.length > 0 && resolved.length == 0) {
// 若保存了不支持的语言码(历史遗留),视为“未手动选择”,清理并回退系统
[[self class] kc_write:nil];
[[self class] kb_setUserDidSelectLanguage:NO];
saved = nil;
resolved = @"";
}
if (resolved.length == 0) {
resolved = [m bestSupportedLanguageForPreferred:[NSLocale preferredLanguages]] ?: KBLanguageCodeEnglish;
}
[m applyLanguage:resolved];
#if DEBUG
NSLog(@"[KBLoc] resolved=%@ current=%@ bundle=%@",
resolved ?: @"",
m.currentLanguageCode ?: @"",
m.langBundle.bundlePath ?: @"");
#endif
// 监听 Darwin 跨进程通知语言变更用于“App 内切语言 -> 立即刷新键盘”。
CFNotificationCenterAddObserver(CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge const void *)(m),
KBLocDarwinCallback,
(__bridge CFStringRef)kKBDarwinLocalizationChanged,
NULL,
CFNotificationSuspensionBehaviorDeliverImmediately);
// 系统语言/地区变化:如果未手动锁定语言,则跟随系统刷新
[[NSNotificationCenter defaultCenter] addObserver:m
selector:@selector(kb_onSystemLocaleMaybeChanged:)
name:NSCurrentLocaleDidChangeNotification
object:nil];
});
return m;
}
#pragma mark - 对外 API
- (void)setSupportedLanguageCodes:(NSArray<NSString *> *)supportedLanguageCodes {
// 归一化:去重、去空
NSMutableOrderedSet *set = [NSMutableOrderedSet orderedSet];
for (NSString *c in supportedLanguageCodes) {
if (c.length) { [set addObject:c]; }
}
_supportedLanguageCodes = set.array.count ? (NSArray<KBLanguageCode> *)set.array : @[ KBLanguageCodeEnglish ];
// 若当前语言不再受支持,则按最佳匹配切回(不持久化,仅内存),并广播变更
if (self.currentLanguageCode.length && ![set containsObject:self.currentLanguageCode]) {
NSString *best = [self bestSupportedLanguageForPreferred:@[self.currentLanguageCode]];
[self applyLanguage:best ?: _supportedLanguageCodes.firstObject];
[[NSNotificationCenter defaultCenter] postNotificationName:KBLocalizationDidChangeNotification object:nil];
}
}
- (void)setCurrentLanguageCode:(NSString *)code persist:(BOOL)persist {
if (code.length == 0) return; // 忽略空值
// 仅允许切到“受支持语言”;不支持(如 zh-Hans时按最佳匹配回退默认 en
NSString *resolved = [self supportedLanguageCodeForCandidate:code];
if (resolved.length == 0) {
resolved = [self bestSupportedLanguageForPreferred:@[code]] ?: (self.supportedLanguageCodes.firstObject ?: KBLanguageCodeEnglish);
}
if ([resolved isEqualToString:self.currentLanguageCode]) return; // 无变更
[self applyLanguage:resolved];
if (persist) {
[[self class] kb_setUserDidSelectLanguage:YES];
[[self class] kc_write:self.currentLanguageCode]; // 需同步到 App/扩展
// 通知对端进程刷新语言(特别是键盘扩展正在显示时)
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge CFStringRef)kKBDarwinLocalizationChanged,
NULL, NULL, true);
}
[[NSNotificationCenter defaultCenter] postNotificationName:KBLocalizationDidChangeNotification object:nil];
}
- (void)resetToSystemLanguage {
[[self class] kb_setUserDidSelectLanguage:NO];
[[self class] kc_write:nil];
NSString *best = [self bestSupportedLanguageForPreferred:[NSLocale preferredLanguages]] ?: KBLanguageCodeEnglish;
if ([best isEqualToString:self.currentLanguageCode]) {
return;
}
[self applyLanguage:best];
// 通知对端进程刷新语言(特别是键盘扩展正在显示时)
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge CFStringRef)kKBDarwinLocalizationChanged,
NULL, NULL, true);
[[NSNotificationCenter defaultCenter] postNotificationName:KBLocalizationDidChangeNotification object:nil];
}
- (NSString *)localizedStringForKey:(NSString *)key {
return [self localizedStringForKey:key table:nil value:key];
}
- (NSString *)localizedStringForKey:(NSString *)key table:(NSString *)table value:(NSString *)value {
if (key.length == 0) return @"";
NSBundle *bundle = self.langBundle ?: NSBundle.mainBundle;
NSString *tbl = table ?: @"Localizable";
// 使用 bundle API避免 NSLocalizedString 被系统语言钉死
NSString *str = [bundle localizedStringForKey:key value:value table:tbl];
return str ?: (value ?: key);
}
- (NSString *)bestSupportedLanguageForPreferred:(NSArray<NSString *> *)preferred {
if (self.supportedLanguageCodes.count == 0) return KBLanguageCodeEnglish;
// 需求:只使用“系统当前语言”(首选语言列表的第一个)。
// 若当前语言不在项目支持的 5 种语言内,则回退英文;不要从后续备选语言里再挑一个支持语言。
NSString *primary = preferred.firstObject ?: @"";
if (primary.length == 0) {
for (NSString *s in self.supportedLanguageCodes) {
if ([s.lowercaseString isEqualToString:@"en"]) { return s; }
}
return self.supportedLanguageCodes.firstObject ?: KBLanguageCodeEnglish;
}
NSArray<NSString *> *primaryOnly = @[ primary ];
// 1) 完全匹配
for (NSString *p in primaryOnly) {
NSString *pLC = p.lowercaseString;
for (NSString *s in self.supportedLanguageCodes) {
if ([pLC isEqualToString:s.lowercaseString]) { return s; }
}
}
// 2) 前缀匹配:如 en-GB -> en, es-MX -> es
for (NSString *p in primaryOnly) {
NSString *pLC = p.lowercaseString;
for (NSString *s in self.supportedLanguageCodes) {
NSString *sLC = s.lowercaseString;
if ([pLC hasPrefix:[sLC stringByAppendingString:@"-"]] || [pLC hasPrefix:[sLC stringByAppendingString:@"_"]]) {
return s;
}
// also allow reverse: when supported is regional (rare)
if ([sLC hasPrefix:[pLC stringByAppendingString:@"-"]] || [sLC hasPrefix:[pLC stringByAppendingString:@"_"]]) {
return s;
}
}
}
// 2.5) 特殊处理葡语pt-BR / pt-PT / pt -> pt-PT若受支持
for (NSString *p in primaryOnly) {
NSString *pLC = p.lowercaseString;
if ([pLC isEqualToString:@"pt"] || [pLC hasPrefix:@"pt-"] || [pLC hasPrefix:@"pt_"]) {
for (NSString *s in self.supportedLanguageCodes) {
if ([s.lowercaseString isEqualToString:@"pt-pt"]) { return s; }
}
}
}
// 3) 特殊处理中文:将 zh-Hant/zh-TW/zh-HK 映射到 zh-Hant若受支持
for (NSString *p in primaryOnly) {
NSString *pLC = p.lowercaseString;
if ([pLC hasPrefix:@"zh-hant"] || [pLC hasPrefix:@"zh-tw"] || [pLC hasPrefix:@"zh-hk"]) {
for (NSString *s in self.supportedLanguageCodes) {
if ([s.lowercaseString isEqualToString:@"zh-hant"]) { return s; }
}
}
}
// 4) 兜底:若英文受支持则固定回退英文,否则取第一个
for (NSString *s in self.supportedLanguageCodes) {
if ([s.lowercaseString isEqualToString:@"en"]) { return s; }
}
return self.supportedLanguageCodes.firstObject ?: KBLanguageCodeEnglish;
}
- (NSString *)currentLanguageHeaderValue {
return self.currentLanguageCode ?: KBLanguageCodeEnglish;
}
#pragma mark - 内部实现
- (NSString *)normalizeLanguageCode:(NSString *)code {
if (code.length == 0) return @"";
NSString *normalizedCode = code;
NSString *lower = normalizedCode.lowercaseString;
if ([lower isEqualToString:@"pt"] || [lower hasPrefix:@"pt-"] || [lower hasPrefix:@"pt_"]) {
normalizedCode = @"pt-PT";
}
lower = normalizedCode.lowercaseString;
if ([lower hasPrefix:@"zh-hant"] || [lower hasPrefix:@"zh_hant"] || [lower hasPrefix:@"zh-tw"] || [lower hasPrefix:@"zh_hk"]) {
normalizedCode = KBLanguageCodeTraditionalChinese;
}
return normalizedCode;
}
- (NSString *)supportedLanguageCodeForCandidate:(NSString *)code {
NSString *normalized = [self normalizeLanguageCode:code];
if (normalized.length == 0) return @"";
for (NSString *s in self.supportedLanguageCodes) {
if ([s.lowercaseString isEqualToString:normalized.lowercaseString]) {
return s; // 返回“工程内规范码”(保留大小写/连字符风格)
}
}
return @"";
}
- (void)applyLanguage:(NSString *)code {
NSString *normalizedCode = [self normalizeLanguageCode:code];
_currentLanguageCode = [normalizedCode copy];
// 基于当前 TargetApp 或扩展)的主 bundle 加载 .lproj 资源
NSString *path = [NSBundle.mainBundle pathForResource:normalizedCode ofType:@"lproj"];
if (!path) {
// 尝试去区域后缀:如 en-GB -> en
NSString *shortCode = [[normalizedCode componentsSeparatedByCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"-_"]] firstObject];
if (shortCode.length > 0) {
path = [NSBundle.mainBundle pathForResource:shortCode ofType:@"lproj"];
}
}
if (path) {
self.langBundle = [NSBundle bundleWithPath:path];
} else {
self.langBundle = NSBundle.mainBundle; // 兜底
}
}
#pragma mark - 钥匙串读写App/扩展共享)
+ (BOOL)kb_markLaunchedAndReturnIsFirstLaunch {
// 注意:这里只能用主 App 自己的标准 UserDefaults。
// 不要用 App Group否则键盘扩展可能会抢先写入“已启动”标记导致主 App 无法在首次启动时清理历史 Keychain。
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
if ([ud boolForKey:kKBLocDidLaunchKey]) { return NO; }
[ud setBool:YES forKey:kKBLocDidLaunchKey];
[ud synchronize];
return YES;
}
+ (BOOL)kc_write:(NSString *)lang {
NSMutableDictionary *q = KBLocBaseKCQuery();
SecItemDelete((__bridge CFDictionaryRef)q);
if (lang.length == 0) return YES; // 等价于删除
NSData *data = [lang dataUsingEncoding:NSUTF8StringEncoding];
q[(__bridge id)kSecValueData] = data ?: [NSData data];
q[(__bridge id)kSecAttrAccessible] = (__bridge id)kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly;
OSStatus st = SecItemAdd((__bridge CFDictionaryRef)q, NULL);
return (st == errSecSuccess);
}
+ (NSString *)kc_read {
NSMutableDictionary *q = KBLocBaseKCQuery();
q[(__bridge id)kSecReturnData] = @YES;
q[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
CFTypeRef dataRef = NULL; OSStatus st = SecItemCopyMatching((__bridge CFDictionaryRef)q, &dataRef);
if (st != errSecSuccess || !dataRef) return nil;
NSData *data = (__bridge_transfer NSData *)dataRef;
if (data.length == 0) return nil;
NSString *lang = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
return lang;
}
- (void)kb_onSystemLocaleMaybeChanged:(NSNotification *)note {
// 只有未手动选择语言时才需要跟随系统变化;手动选择情况下 reload 会优先读取保存值,不会被系统变化影响。
[self reloadFromSharedStorageIfNeeded];
}
- (void)reloadFromSharedStorageIfNeeded {
BOOL userSelected = [[self class] kb_userDidSelectLanguage];
BOOL isAppExtension = [[self class] kb_isRunningInAppExtension];
BOOL isAppBootstrapped = [[self class] kb_isAppBootstrapped];
NSString *target = @"";
if (userSelected) {
// 扩展在主 App 未启动前:不读取 Keychain可能遗留旧值保持当前按系统择优的结果。
if (isAppExtension && !isAppBootstrapped) {
#if DEBUG
NSLog(@"[KBLoc] reload skip (app not bootstrapped yet) bundle=%@ current=%@",
NSBundle.mainBundle.bundleIdentifier ?: @"",
self.currentLanguageCode ?: @"");
#endif
return;
}
NSString *saved = [[self class] kc_read];
if (saved.length == 0) {
// 标记为手动选择但无保存值:降级为“跟随系统”
[[self class] kb_setUserDidSelectLanguage:NO];
} else {
NSString *resolved = [self supportedLanguageCodeForCandidate:saved];
if (resolved.length == 0) {
// 保存了不支持的语言码(历史遗留):清理并降级为“跟随系统”
[[self class] kc_write:nil];
[[self class] kb_setUserDidSelectLanguage:NO];
} else {
target = resolved;
}
}
}
if (target.length == 0) {
// 未手动选择:始终按系统当前语言(首选列表第一个)择优
target = [self bestSupportedLanguageForPreferred:[NSLocale preferredLanguages]] ?: KBLanguageCodeEnglish;
}
if ([target isEqualToString:self.currentLanguageCode]) {
return;
}
[self applyLanguage:target];
// 系统跟随模式:主 App 语言变化时需要通知键盘扩展即时刷新(避免键盘仍停留旧语言)。
if (!userSelected && !isAppExtension) {
CFNotificationCenterPostNotification(CFNotificationCenterGetDarwinNotifyCenter(),
(__bridge CFStringRef)kKBDarwinLocalizationChanged,
NULL, NULL, true);
}
[[NSNotificationCenter defaultCenter] postNotificationName:KBLocalizationDidChangeNotification object:nil];
}
@end