前言

如今,许多应用也变得更加人性化,考虑到国际友人的需要,添加了国际化多语言的处理。同时也有许多应用如网易邮箱大师、支付宝、微信等为了满足部分用户的需求,添加了应用内多语言切换的功能,使得用户可以不通过设置系统语言随心所欲得切换应用内语言。那么这种不重启App就随意切换应用语言的功能是如何实现的呢?本文将对 这一问题进行探讨。

系统实现多语言的过程

  • iOS系统首先搜索用户的语言偏好设置(设置-通用-语言与地区)
  • 检测你的应用是否支持用户的语言,先用偏好设置(NSUserdefault)的第一个语言,检测应用是否包含该语言对应的文件夹(后缀是.lproj,文件名部分,英语为en,中文简体为zh-Hans)如果存在,那就是该语言,否则用偏好设置第二个语言来匹配。重复该过程。
  • 一旦系统为应用确定了语言,对应的.lproj文件夹就会用作本地化资源

探究过程

大多数App自定义的国际化文件使用localizable.string,并且在代码里使用NSLocalizedString(key, comment)进行国际化文案处理,那么国际化的文字则完全根据系统语言执行。这个时候如果需要实现应用内的国际化多语言切换,就需要特殊处理。

方案一: 暴力修改NSUserDefault中的值

再愁一眼上面说到的系统国际化多语言的实现过程,既然iOS系统默认会选择NSUserdefault对应数组中的第一个元素作为语言的选择,那么我尝试将该数组中的元素调整下位置,看下是否可行?

我们先在demo中提前做一下基本的多语言本地化的处理(准备工作)。

代码国际化资源
代码国际化资源

xib国际化资源
xib国际化资源

界面上所有的国际化文案(代码和xib)已经就位,那么开始整起。

主界面如图:

主界面-中文
主界面-中文

首先主界面上有两个button,上面的button是代码进行初始化的(如下),所以相应国际化文案的加载位于Localizable.strings

//  ViewController.m
[self.changeBtn setTitle:NSLocalizedString(@"邮箱大师", @"") forState:UIControlStateNormal];

下面的button跟随黄色viewxib中进行初始化(如下),所以相应国际化文案的加载位于TestView.strings

/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "Sic-kz-b1y"; */
"Sic-kz-b1y.normalTitle" = "邮箱大师_xib";

下面的讨论我们重点关心这两个button的国际化文案变化。

- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent*)event中添加了以下内容

// Viewcontroller.m
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent*)event
{
    NSUserDefaults *userDefault = [NSUserDefaults standardUserDefaults];
    NSArray* languages = [userDefault objectForKey:@"AppleLanguages"];
    NSString *current = [languages objectAtIndex:0];
    NSLog(@"支持的语言:%@,当前语言:%@",languages,current);
}

点击空白处,打印如下:

中文点击空白处
中文点击空白处

这个时候我们将系统切换为英文,重新启动App 主界面文案和点击空白处打印如下:

主界面-英文
主界面-英文
点击空白处
点击空白处

根据打印情况,确实是这样的,NSUserDefaults中的AppleLanguages数组中的第一个元素值跟系统语言相同,并且决定着当前应用的默认语言。

这和我们猜测到的情况相同,那么换回英文系统,开始动刀!

这里我们又添加一个SettingViewController,如下

设置界面-中文
设置界面-中文

在这个SettingViewController中有两个按钮,上面是中文设置按钮,下面是英文设置按钮,我们分别在这两个按钮中添加如下方法

// SettingViewController.m
// 切换为中文
- (void)chineseClicked {
    NSUserDefaults *userDefault = [NSUserDefaults standardUserDefaults];
    NSArray* languages = [userDefault objectForKey:@"AppleLanguages"];
    NSMutableArray *arrM = [languages mutableCopy];
    arrM[0] = @"zh-Hans-US"; // 强制重新更换一个新的数组,数组的第一个元素为中文
    [userDefault setObject:arr forKey:@"AppleLanguages"];
    // 重置rootViewController,达到重启app的目的
    AppDelegate *delegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    [delegate resetMainVC];
}
// 切换为英文
- (void)englishClicked {
    NSUserDefaults *userDefault = [NSUserDefaults standardUserDefaults];
    NSArray* languages = [userDefault objectForKey:@"AppleLanguages"];
    NSMutableArray *arrM = [languages mutableCopy];
    arrM[0] = @"en-US"; // 强制重新更换一个新的数组,数组的第一个元素为英文
    [userDefault setObject:arr forKey:@"AppleLanguages"];
    // 重置rootViewController,达到重启app的目的
    AppDelegate *delegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    [delegate resetMainVC];
}

系统为英文情况下,运行app,并测试。

暴力切换
暴力切换

这时我们会惊奇得发现,语言可以随意切换并且UserDefault中的AppleLanguages数组被完全改变成我们自己想要的了,而且应用的默认语言也改变成了我们想要的中文!

但是这种状态能不能维持还需要额外的检查,也就是说我们需要在各种场景下检测AppleLanguages又有没有被系统默认重置回去。

经过测试发现,更换系统语言,是不会再次影响这个数组的值,除非你再次通过同样的方法进行替换或者直接卸载重装应用。但是这并不能保证其他的流程能否重置这个值,苹果官方也没有确定的说明。

总结: 这种简单粗暴替换的方案原理上是可行的,但是没有trick的点,而且由于是直接动刀系统默认配置,这就可能造成一些未知的bug发生,不推荐使用。

方案二: 替换系统宏

为什么要替换系统的宏呢?

首先我们跳进NSLocalizedString的四个相关宏系统为其定义的地方,发现跳进了NSBundle.h文件,其定义如下:

图8
图8

上面两个宏最终会调用指定NSBundle.mainBundlelocalizedStringForKey方法,而下面的宏则可以指定bundle,指定文件。

下面两种宏的使用都easy,因为你完全可以根据指定不同语言的bundle文件其加载多语言文案,但是如果在已有项目中大量使用了上面的两种宏,并且分散在各个文件中 那就会非常头疼,因为你没有具体指定是哪个语言的文件,因为此时的文件系统中存在这样的结构:

图9
图9

可以猜测到的是,如果不具体指定文件的话,那么系统默认会在mainBundle中根据方案一中的UserDefault的对应值去查找使用,那么应用语言还是会跟着系统走。 所以我们需要在这个地方动点手脚,对系统宏进行处理,在不改变项目中已有代码的情况下,实现自由切换。由此,可以想到的解决思路就是宏替换

继续拿到方案一中的工程(系统为英文),开始我们的设计。就拿第一个宏NSLocalizedString(key, comment)举例,在主界面controller中,进行以下操作

// ViewController.m
...
#undef NSLocalizedString
#define NSLocalizedString(key, comment) \
[self localizedStringForKey:(key)]
...
- (NSString *)localizedStringForKey:(NSString *)key
{
    return  [[self customLanguageBundle] localizedStringForKey:(key) value:@"" table:nil];
}

- (NSBundle *)customLanguageBundle
{
    NSBundle *bundle = [NSBundle mainBundle];
    NSString *currentLanguage = [[NSUserDefaults standardUserDefaults] objectForKey:@"NEAppLanguage"]; // 从存储中取值
    NSString *path = [bundle pathForResource:currentLanguage ofType:@"lproj"];
    if (path) {
        bundle = [NSBundle bundleWithPath:path];
    }
    return bundle;
}
...
// SettingViewController.m
// 切换为中文
- (void)chineseClicked {
    [[NSUserDefaults standardUserDefaults] setObject:@"zh-Hans" forKey:@"NEAppLanguage"];
    // 重置rootViewController,达到重启app的目的
    AppDelegate *delegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    [delegate resetMainVC];
}
// 切换为英文
- (void)englishClicked {
    [[NSUserDefaults standardUserDefaults] setObject:@"en" forKey:@"NEAppLanguage"];
    AppDelegate *delegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    // 重置rootViewController,达到重启app的目的
    AppDelegate *delegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    [delegate resetMainVC];
}

上面的代码中,首先我们undef掉了系统的宏NSLocalizedString,紧接着我们又重新define了一个同名的宏,只不过这次让它去调用我们自定义的方法localizedStringForKey, 在这个自定义方法里,我们首先去向存储查找当前用户设置的App语言,然后去mainBundle中查找有没有这个语言对应的lproj文件。如果存在,则使用该文件,否则交给系统(mainBundle)自己去管理决定。

接着,启动App,测试该方案。

xib不正常
xib不正常

纳尼!吃鲸!不应该全是正常显示的嘛!!!为啥代码设置的文案,完全显示正常。而xib加载的国际化文案,则不会正常显示

且慢,先解释下代码文案的加载过程。这里成功的关键是我们帮助原本不清楚具体使用哪个lproj文件的系统宏找到了具体的方向,让它不再靠猜寻路。

xib为什么会异常呢?经过一番细心调试,最终发现问题出现在…

看黑板!划重点!

之前自定义的宏调用的方法根本没有执行!xib中的文案加载,根本不走系统宏!!!

它们应该调用的是更偏底部的接口,也就是说替换系统宏对XibStoryboard根本无效,只会对代码调用有效。

总结:这种方案通过替换系统宏实现,对于那些庞大且使用纯代码进行国际化处理的项目很实用,但是那些有多套Xib国际化方案的项目就失效了。

看来我们需要一种更加全面的方法来解决这个问题!

方案3: 利用runtime巧妙拦截

细心的你会发现,方案2中提到的四种宏最终都会统一调用NSBundle.h的一个方法

/* Method for retrieving localized strings. */
- (NSString *)localizedStringForKey:(NSString *)key value:(nullable NSString *)value table:(nullable NSString *)tableName;

调试发现,不仅代码调用会到这个地方,xib文件最终也会指向这个方法,看来此方法应该就是国际化调用顺序的最终去向。既然那样,为什么我们不能够在这个最终的方法中进行拦截,让bundle正确的去加载lproj文件呢?

首先,我们先来看下这个方法各个参数的含义:

  • key: 需要本地化的字符串对应的键值
  • value: 默认值,如果本地化资源文件中没有找到对应的 key 则返回这个值
  • tableName: 指定在相应的lproj文件中的tableName.string文件中查找文字资源,如果传入null,则在Localizable.strings中查找

这三个参数貌似并不是我们需要的东西,因为他们不能决定具体lproj到底是哪个?而此方法又是NSBundle的对象方法,事实上系统调用到这个地方的时候,其实已经做出了决定到底使用的是哪个lproj(其实就是该方法的调用者)。

那么,如何巧妙的绕过这个坑,让此方法的调用者调用失效又重新指定调用呢?

对,使用Runtime!

我们可以这样做,自定义一个继承自NSBundle的子类,我们称它为LanguageBundle,在运行时利用object_setClassNSBundle替换为LanguageBundle 在这个LanguageBundle中重写上面的那个localizedStringForKey方法,然后在这个重写的方法里拦截系统的调用。这个LanguageBundle的作用如下:

  • 替换NSBundle,让原本是localizedStringForKey的调用者的NSBundle,换为了LanguageBundle
  • LanguageBundle本身又是NSBundle的子类,继承了包括localizedStringForKey在内的所有属性和方法,那么该替换的侵害性可以不计
  • 在调用到localizedStringForKey的时候,可以对其实现高度定制,同时又可以让其父类NSBundle依旧执行该方法,只需super调用即可

同样还是系统为英文的情况,具体代码如下:

// AppDelegate.m
#import <objc/runtime.h>
#import "LanguageBundle.h"
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    object_setClass([NSBundle mainBundle], [LanguageBundle class]);
    return YES;
}

...

// LanguageBundle.m
- (NSString *)localizedStringForKey:(NSString *)key value:(NSString *)value table:(NSString *)tableName{
    NSBundle *bundle = [NSBundle mainBundle];
    NSString *currentLanguage = [[NSUserDefaults standardUserDefaults] objectForKey:@"NEAppLanguage"]; // 从存储中取值
    NSString *path = [bundle pathForResource:currentLanguage ofType:@"lproj"];
    if (path) {
        return [[NSBundle bundleWithPath:path] localizedStringForKey:key value:value table:tableName];
    } else {
        return [super localizedStringForKey:key value:value table:tableName];
    }
}

// SettingViewController.m
// 切换为中文
- (void)chineseClicked {
    [[NSUserDefaults standardUserDefaults] setObject:@"zh-Hans" forKey:@"NEAppLanguage"];
    // 重置rootViewController,达到重启app的目的
    AppDelegate *delegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    [delegate resetMainVC];
}
// 切换为英文
- (void)englishClicked {
    [[NSUserDefaults standardUserDefaults] setObject:@"en" forKey:@"NEAppLanguage"];
    AppDelegate *delegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    // 重置rootViewController,达到重启app的目的
    AppDelegate *delegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    [delegate resetMainVC];
}

再次运行App,按钮状态如下:

使用runtime
使用runtime

显示正常!

又经过一番调试发现,无论是代码还是Xib,最终都会调用到LanguageBundle中重写的方法localizedStringForKey中去。第二种方案遇到的问题都完美解决!

总结:这种方案比较trick,能适用各种场景,代码侵入性小且效果完美(对纯代码和Xib都会生效),非常推荐这种方案!

可能会遇到的坑

1、各个iOS版本中的地区代码差异

iOS9之前:
zh-Hans: 简体
zh-Hant: 繁体

iOS9之后:
zh-Hans-CN: 简体(改变)
zh-Hant-CN: 繁体(改变)

如上所示在iOS9之后,地区代码发生了改变例如简体中文在iOS9之前为zh-Hans,而iOS9之后为zh-Hans-CN,后面的CN为具体地区代码。 所以在拿到currentLanguage返回是zh-Hans-CN,这时候会找不到对应的bundle文件,因为本地的文件夹名字还是zh-Hans,所以可以通过NSStringhasPrefix:方法,所以在拿到currentLanguage的时候先进行一步判断,再返回zh-Hans

2、使用static初始化的NSString

如果你的项目里面有很多这样的NSString声明方式,就应该小心了,例如下面这段代码

// ViewController.m
static NSString *buttonTitleText;
...
- (void)viewDidLoad {
    [super viewDidLoad];
    buttonTitleText = NSLocalizedString(@"邮箱大师", @"");
    [self.changeBtn setTitle:buttonTitleText forState:UIControlStateNormal];
}

在这种初始化方式下,buttonTitleText只会初始化一次。因此无论如何切换语言,该变量依旧是第一次初始化的值,造成多语言切换效果失效。 所以如果确实要保持统一的话,我比较推荐使用宏的形式。

#define buttonTitleText NSLocalizedString(@"邮箱大师", @"")

不重启App的UI文案刷新机制

需要知道的是,如果一个控件的文案已经被加到内存了,那么除非再重新设置该控件文案,否则它是不会再改变的。

例如在一个controller中,这样设置

- (void)viewDidLoad {
    [super viewDidLoad];
    [self.changeBtn setTitle:NSLocalizedString(@"邮箱大师", @"")  forState:UIControlStateNormal];
}

此时你再push到另一个controller中进行语言的切换。这时候你回到该界面,这个button的文案不会变化。原因就是该button的文案设置已经被加载到内存中去。

那么该问题如何解决呢?

1、发送通知

在多语言切换的位置发送通知,然后所有可能在内存中的controller注册监听这个通知,调用刷新方法,重新设置控件文案。例如:

// ViewController.m
- (void)viewDidLoad {
    [super viewDidLoad];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(refreshUI) name:@"AppLanguageChangeSender" object:nil];
    [self.changeBtn setTitle:buttonTitleText forState:UIControlStateNormal];
}

- (void)refreshUI
{
    [self.changeBtn setTitle:buttonTitleText forState:UIControlStateNormal];
}

...

// LanguageManager.m
// 此类控制多语言切换
- (void)languageChangeFinished
{
    [[NSNotificationCenter defaultCenter] postNotificationName:@"AppLanguageChangeSender" object:nil];
}

这样的话,需要在每个可能位于内存中的controller中都这样写,如果项目庞大的话,维护性非常差,不推荐这样做。

2、重启rootController

还可以这样做,例如我上面各个方案中的刷新方式,AppDelegate中开放一个接口- (void)resetMainVC,在切换语言完成后调用该方法

// AppDelegate.m
- (void)resetMainVC
{
    self.window.rootViewController = nil;
    ViewController *mainVC = [[ViewController alloc] init];
    self.window.rootViewController = [[UINavigationController alloc] initWithRootViewController:mainVC];
}

// LanguageManager.m
// 此类控制多语言切换
- (void)languageChangeFinished
{
    AppDelegate *delegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
    [delegate resetMainVC];
}

总结

如果要达到更好的效果,建议创建一个专门管理App语言切换的单例类,在语言切换完成后,UI文案的刷新机制也应该针对自己的项目合理的进行设计。本文只是起到一个抛砖引玉的作用,想要将该功能设计得更完美,还是需要多加完善的。