国际版App的多语言启动优化

526 阅读4分钟

性能优化最简单粗暴并且最实用的方法就是:缓存、缓存、缓存...

背景

目前我们App支持 19种语言,支持多模块的独立翻译。 对于多语言功能大家可能关注的还是功能实现,忽略了其中性能优化部分,这篇文章会介绍下,我们App采用的优化策略。

1.png

先聊聊本地转国际版的过程

第一版:

由于已有代码已经很庞大,大部分文本都是直接赋值,为了快速出第一版,我们采用了 全量代码扫描 + 动态替换 + 运行时提取文案 (运行时提取有个问题,容易漏文案,但是初期扫描完整体有接近 8000个 String Key,根本翻译不过来,采用运行时提取后,只剩400个待翻译文案,后续自测又补了50多个漏的文案。 第一版只有2周的时间开发👻)。

因为翻译团队是外包的,所以不能等他们给结果后再适配,这边初步采用 机器翻译,先进行本地运行查看UI效果(翻译准确性等翻译团队给结果后在联调)。

第二版:

在已有的基础上,没有改动的代码,其实不用在提取文案了,但是我们还需要继续集成已有的模块,这时候采用对 新增模块 使用代码扫描,直接提取文案,比运行时稳妥,不用担心代码没触发的问题。

第三版:

整体稳定的情况下,使用 手工整理 + Google在线文档 + 脚本生成 Strings 文件

一、优化-获取用户首选语言

按以往的经验,是不是每次启动都执行一下 - (NSString *)getAppDefaultLanguage 可以了?

/// 根据用户系统设置,获取App支持的默认语言
- (NSString *)getAppDefaultLanguage {
    /**
     https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPInternational/LanguageandLocaleIDs/LanguageandLocaleIDs.html#//apple_ref/doc/uid/10000171i-CH15-SW1
     de-US,
     ko-US,
     zh-Hans-US,
     en
     */
    NSArray<NSString *> *appleLanguages = [[NSUserDefaults standardUserDefaults] stringArrayForKey:kIMYAppleLanguagesKey];
    NSString *hitLanguage = nil;
    for (NSString *anyLanguage in appleLanguages) {
        NSString *languageID = anyLanguage;
        NSString *path = [[NSBundle mainBundle] pathForResource:languageID ofType:@"lproj"];
        if (!path) {
            // 移除国家字段,再次尝试
            languageID = [self getLanguageWithoutNation:languageID];
            path = [[NSBundle mainBundle] pathForResource:languageID ofType:@"lproj"];
        }
        if (path != nil) {
            hitLanguage = [languageID copy];
            break;
        }
    }
    // 找不到语言包则,默认使用 en
    if (!hitLanguage) {
        hitLanguage = @"en";
    }
    return hitLanguage;
}

其实考虑用户一般不会切换系统语言,所以我们完全可以缓存起来,只需要第一次启动执行。

- (void)setup {
    NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
    // 获取用户设置的语言
    NSString *language = [ud stringForKey:kIMYCurrentLanguageKey];
    // 如果无用户设置的key,则寻找最适合的默认语言
    if (!language) {
        language = [ud stringForKey:kIMYDefaultLanguageKey];
    }
    if (!language) {
        // 遍历App存在的语言包,返回最合适的语言
        language = [self getAppDefaultLanguage];
        // 存储默认语言,避免每次都进行本地查找
        [ud setObject:language forKey:kIMYDefaultLanguageKey];
    }
    // 获取远程语言包
    NSString *remotePath = [ud stringForKey:kIMYRemoteLanguageKey];
    _remoteRootPath = [remotePath stringByReplacingOccurrencesOfString:@"@root@" withString:NSString.imy_documentsDirectory];
    // 设置当前App使用的语言
    [self setCurrentLanguage:language storage:NO forced:NO];
}

二、优化-宏命令

我们内部使用宏来包裹不同翻译Key,这样开发同学可以不用单独在用 Format 进行包装。

这边使用了 RACEXTScope.h 头文件下的宏判断,区分参数个数,分别调用不同API,避免每次都调用 String Format 处理字符串

这个宏来自 ReactiveCocoa, 再溯源应该是来自 P99 :gitlab.inria.fr/gustedt/p99

有兴趣的同学可以自己去看下,P99 中的宏太丰富了~

2.png

三、优化-获取翻译文案(不加锁)

由于系统的API - (NSString *)localizedStringForKey:(NSString *)key value:(nullable NSString *)value table:(nullable NSString *)tableName NS_FORMAT_ARGUMENT(1); 已有缓存,所以我们没必要自己再做一层。

不使用任何锁

我们App允许用户随意切换语言,并且不重启App,所以对于底层这种API 就会有多线程安全的要求。

3.gif

那我们是如何做到不使用锁,也能满足不Crash的呢?

多线程会影响到的 主要是 NSBundle 对象,主要流程如下:

/// 设置当前语言bundle
- (void)setCurrentLanguage:(NSString *)language {
    _bundle = [NSBundle bundleWithPath:...];
}

/// 获取翻译文案,可在任意线程下执行
- (NSString *)localizedStringForKey:(NSString * const)key
                              value:(NSString * const)value
                              table:(NSString * const)tableName {
    ...
    NSString *string = [_bundle localizedStringForKey:key value:@"**!!**" table:tableName];
    ...
}

这边利用了一个特性就是 单线程赋值 (都在主线程),如果存在多线程赋值,则这个优化无效(该加锁还是要加锁,不管是用属性atomic,还是别的锁)

/// 设置当前语言bundle
- (void)setCurrentLanguage:(NSString *)language {
	
    // 先用临时变量存储现在的 bundle 对象。
    id oldBundle = _bundle;

    _bundle = [NSBundle bundleWithPath:...];

    send language did changed notification.

    // block 强持有 oldBundle, 1秒后会跟随block执行完 一起释放
    // 不crash的前提 就是 get方法获取到 旧_bundle 地址后 然后刚好在执行 set中间等待时间相差在 1s内 (如果都卡成这样了, 其实 async block 也不会执行的,有用iPhone6 大量压测过,未出现闪退)
    async 1s block {
            [oldBundle class];
    }

}

/// 获取翻译文案,可在任意线程下执行
/// 目前有个前提 就是两个线程执行时间在1s内
- (NSString *)localizedStringForKey:(NSString * const)key
                              value:(NSString * const)value
                              table:(NSString * const)tableName {
    ...
    NSString *string = [_bundle localizedStringForKey:key value:@"**!!**" table:tableName];
    ...
}

这部分优化成果

累计下来大概能提升启动时间 5~10ms 左右,各App逻辑有差异,不能代表全部。

其他可优化的内容:

language change UI监听逻辑,刷新逻辑,RTL布局修改等..