一個應用在進行多語言本地化的時候涉及到大量的工作。因為這一期的主題是字串,所以本文主要探討字串的本地化。字串本地化有兩種方法:修改程式碼或修改 nib 檔案和 storyboard。本文將專注於通過程式碼實現字串的本地化。
NSLocalizedString
NSLocalizedString
這個巨集是字串本地化的核心工具。它還有三個鮮為人知的變體:NSLocalizedStringFromTable
、NSLocalizedStringFromTableInBundle
和 NSLocalizedStringWithDefaultValue
。這些巨集最終都呼叫 NSBundle
的 localizedStringForKey:value:table:
方法來完成任務。
使用這些巨集有兩個好處:一方面相比直接呼叫 localizedStringForKey:value:table:
方法,使用巨集讓程式碼簡單易懂;另一方面,類似 genstrings 這樣的工具能夠監測到這些巨集,從而生成供你翻譯使用的字串檔案。這些工具會解析 .c 和 .m 字尾的檔案,然後為其中每一個需要進行本地化的字串都生成對應條目,並寫入到生成的 .strings 檔案中。
如果想讓 genstrings
檢測自己專案中所有的 .m
字尾檔案,可以執行如下命令:
1 |
find . -name *.m | xargs genstrings -o en.lproj |
-o
選項指定了生成字串檔案的存放目錄,預設情況下檔名是 Localizable.strings
。需要注意的是,genstrings
預設會覆蓋已存在的同名字串檔案。-a
選項可以讓 genstrings
將生成的條目追加到已存在同名檔案的末尾,而不會覆蓋原檔案。
不過一般情況下你也許想將生成檔案放到另一個目錄中,然後使用你喜歡的合併工具將它們與已有檔案合併以保留已翻譯好的條目。
字串檔案的格式非常簡單,都是鍵值對的形式:
1 2 3 4 |
/* Insert new contact button */ "contact-editor.insert-new-contact-button" = "Insert contact"; /* Delete contact button */ "contact-editor.delete-contact-button" = "Delete contact"; |
更復雜的操作比如在需要本地化的字串中插入格式化佔位符等,我們將在稍後談到。
另外,字串檔案現在可以儲存成 UTF-8 格式了,因為 Xcode 在構建過程中能夠將它們轉換成所需的 UTF-16 格式。
應用中哪些字串需要本地化?
一般而言,所有你想以某種形式展現在使用者眼前的字串都需要本地化,包括標籤和按鈕上的文字,或者在執行時通過格式化字串和資料動態生成的字串。
在本地化字串時,根據語法規則為每一種型別的語句定義一個可本地化的字串是非常重要的。假設你在應用中需要顯示「Paul invited you」和「You invited Paul」,那麼只本地化格式化字串「%@ invited %@」看起來是個不錯的選擇,這樣在合適的時候把「you」本地化之後插入進去就可以完成任務。
在英語中這種做法沒什麼問題,但是請謹記,當把這種小伎倆應用到其他語言中時基本都會以失敗而告終。以德語為例,「Paul invited you」譯為「Paul hat dich eingeladen」,而「You invited Paul」則譯為「Du hast Paul eingeladen」。
正確的做法是定義兩個可本地化字串「%@ invited you」和「You invited %@」,只有這樣翻譯器才能正確處理其他語言的特殊語法規則。
永遠不要將句子分解為幾個部分,而要將它們作為一個完整的可本地化字串。如果一個句子與另一個句子的語法規則並不完全一致,那麼即使它們在你的母語中看起來極為相像,也要建立兩個可本地化字串。
字串鍵值最佳實踐
使用 NSLocalizedString
巨集的時候,第一個引數就是為每個特殊字串指定的鍵值(key)。程式設計師經常使用母語中的單詞作為鍵值,這樣乍一看是個便利的方案,但是實際上相當糟糕,會引發非常嚴重的錯誤。
在一個字串檔案中,鍵值需要具有唯一性,因此任何母語中字面上具有唯一性的單詞在翻譯為其他語言的時候也必須具有唯一性。這一點是無法滿足的,因為一個單詞翻譯為其他語言時經常會有多種意思,需要對應到多種文字表示。
以英文單詞「run」為例,作為名詞表示「跑步」,作為動詞表示「奔跑」,在翻譯的時候要加以區別。而且根據上下文的不同,每種具體的譯法在文字上可能還會有細微變化。
一個健身應用在不同的地方用到這個單詞的不同意思是很正常的,但是如果你使用下面的方法來進行本地化:
1 |
NSLocalizedString(@"Run", nil) |
無論第二個引數指定了註釋內容還是留空,你在字串檔案中都只有一個「run」的條目。而在德語中,「run」作名詞時應該譯為「Lauf」,作動詞時則應該譯為「laufen」,或者在特定情況下譯為完全不同的形式比如「loslaufen」和「Los geht’s」。
好的鍵值應該滿足兩個條件:首先鍵值必須在每個具體的上下文中保持唯一性,其次如果我們沒有翻譯特定的那個上下文,那麼它們不會被其他情況覆蓋到而被翻譯。
本文推薦使用如下的名稱空間方法:
1 2 |
NSLocalizedString(@"activity-profile.title.the-run", nil) NSLocalizedString(@"home.button.start-run", nil) |
這樣的鍵值可以區分應用中不同地方出現的單詞,同時提供具體的上下文,比如是標題中的或者按鈕中的。上面的例子裡我們為了簡便忽略了第二個引數,實際使用中如果鍵值本身沒有提供清晰的上下文說明,你可以將進一步的說明作為第二個引數傳入。同時請確保鍵值中只含有 ASCII 字元。
分割字串檔案
正如我們一開始提到的,NSLocalizedString
有一些變體能夠提供更多字串本地化的操作方式。NSLocalizedStringFromTable
接收 key、table 和 comment 這三個引數,其中 table 參數列示該字串對應的一個表格,genstrings
會為表中的每一個條目生成一個以條目名稱(假設為 table-item)命名的獨立字串檔案 table-item.strings
。
這樣你就可以把字串檔案分割成幾個小一些的檔案。在一個龐大的專案或者團隊中工作時,這一點顯得尤為重要。同時這也讓合併原有的和重新生成的字串檔案變得容易一些。
相比在每個地方呼叫下面的語句:
1 |
NSLocalizedStringFromTable(@"home.button.start-run", @"ActivityTracker", @"some comment..") |
你可以自定義一個用於字串本地化的函式來讓工作變得輕鬆一些
1 2 3 |
static NSString * LocalizedActivityTrackerString(NSString *key, NSString *comment) { return [[NSBundle mainBundle] localizedStringForKey:key value:key table:@"ActivityTracker"]; } |
為了給所有呼叫此函式的地方生成字串檔案,你可以在執行 genstrings
的時候加上 -s
選項:
1 |
find . -name *.m | xargs genstrings -o en.lproj -s LocalizedActivityTrackerString |
-s
這個選項指定了本地化函式的共同字首名稱,如果你還定義了LocalizedActivityTrackerStringFromTable
,LocalizedActivityTrackerStringFromTableInBundle
,LocalizedActivityTrackerStringWithDefaultValue等函式,以上命令也會呼叫它們。
運用格式化字串
我們經常需要對一些在執行時才能最終確定下來的字串進行本地化,格式化字串可以完成這項工作。Foundation 在這方面提供了一些非常強大的特性。(可以參考Daniel 的文章獲得更多關於格式化字串的細節)
以字串「Run 1 out of 3 completed.」為例,我們可以這樣構造格式化字串:
1 |
NSString *localizedString = NSLocalizedString(@"activity-profile.label.run %lu out of %lu completed", nil); self.label.text = [NSString localizedStringWithFormat:localizedString, completedRuns, totalRuns]; |
在翻譯的時候經常需要對其中的格式化佔位符進行順序調整以符合語法,幸運的是我們可以在字串檔案中輕鬆地搞定:
1 |
"activity-profile.label.run %lu out of %lu completed" = "Von %2$lu Läufen hast du %$1lu absolviert"; |
上面的德文翻譯得不是非常好,只是單純用來說明調換佔位符順序的功能而已。
如果你需要對簡單的整數或者浮點數進行本地化,你可以使用 localizedStringWithFormat:
這個變體。數字本地化的更高階用法涉及 NSNumberFormatter
,會在本文後面講到。
單複數與陰陽性
在 OS X 10.9 和 iOS 7 中,本地化字串的時候可以使用比替換格式化字串中的佔位符更酷的特性:蘋果官方想處理不同語言中對於名詞複數和不同性別採取的不同變化。
讓我們再看一下之前的例子:@”%lu out of %lu runs completed.” 這個翻譯在「跑多次」的時候才是對的(譯者注:即第二個 %lu 代表的數字大於 1),所以我們不得不定義兩個不同的字串來處理單次和多次的情況:
1 2 |
@"%lu out of one run completed" @"%lu out of %lu runs completed" |
這種做法在英語中是對的,但是在其他很多語言中會出錯。比如希伯來語中名詞有三種形式:第一種是單數和十的倍數,第二種是 2,第三種是其他的複數。克羅埃西亞語中,個位數為 1 的數字有單獨的表示方法:「31 od 32 staze završene」,與之相對的是「5 od 8 staza završene」(注意其中「staze」和「staza」的差別)。很多語言針對非整型數也有不同的表達方式。
想全面瞭解這個問題可以參見基於 Unicode 的語言複數規則。其中涵蓋的變化之博大精深令人歎為觀止。
為了在 10.9 和 iOS 7 平臺上正確處理這個問題,我們需要如下構造可本地化字串:
1 |
NSString localizedStringWithFormat:NSLocalizedString(@"activity-profile.label.%lu out of %lu runs completed"), completedRuns, totalRuns; |
然後我們在 .strings
字尾檔案所處目錄中建立一個同名的 .stringsdict
字尾的檔案,如果前者名為 Localizable.strings
,則後者為 Localizable.stringsdict
。保留 .strings
字尾的字串檔案是必須的,即使它裡面什麼內容也沒有。這個.stringsdict
字尾的字串字典檔案是一個屬性列表(plist
)檔案,比字串檔案複雜得多,換來的是正確處理所有語言的名詞複數問題,而不需要將處理邏輯寫在程式碼中。
下面是一個該檔案的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>activity-profile.label.%lu out of %lu runs completed</key> <dict> <key>NSStringLocalizedFormatKey</key> <string>%lu out of %#@lu_total_runs@ completed</string> <key>lu_total_runs</key> <dict> <key>NSStringFormatSpecTypeKey</key> <string>NSStringPluralRuleType</string> <key>NSStringFormatValueTypeKey</key> <string>lu</string> <key>one</key> <string>%lu run</string> <key>other</key> <string>%lu runs</string> </dict> </dict> </dict> </plist> |
呼叫 localizedStringForKey:value:table:
會返回根據字串字典檔案中的鍵值對進行初始化的字串集合,這些字串都是包含字串字典檔案中資訊的的代理物件(proxy objects)。這些資訊在呼叫 copy
和 mutableCopy
進行字串拷貝的時候會被保留,但是一旦你修改了該字串,這些額外資訊就會丟失。更多細節請參見 OS X 10.9 的 Foundation 發行說明。
字母大小寫
如果你要修改一個使用者可見字串的大小寫,請一定使用包含本地化功能的 NSString
方法變體:lowercaseStringWithLocale:
和 uppercaseStringWithLocale:
。
呼叫這些方法的時候你需要傳入區域設定引數 locale ,這樣就可以將大小寫的改變應用到本地化之後的其他語言版本中。當你使用NSLocalizedString
及其變體的那些巨集時無須擔心本地化後的大小寫問題,因為在方法內部已經自動做了處理,而且在使用者選擇的語言不可用時會使用預設語言來代替。
為了使用者介面的一致性,使用區域設定(locale)來本地化介面的其他部分是一個很好的方法,可以參見後面的小節「選擇正確的區域設定」。
檔案路徑的本地化
一般而言你應該始終用 NSURL
來表現檔案路徑,因為這會讓檔名的本地化變得容易:
ompleted runs Total Runs Output
——————————————————————
0 0+ No runs completed yet
1 1 One run completed
1 2+ One of x runs completed
2+ 2+ x of y runs completed
以上輸出在英語系統中是正確的,但是假設我們換到了阿拉伯語系統中,系統設定被稱為「تفضيلات النظام.app」。
構造這樣一個其他語言的檔名是否包含字尾需要參照使用者 Finder 中的相關選項。如果你需要獲取檔案的型別,也可以這樣呼叫NSURLLocalizedTypeDescriptionKey
來從中獲得。
本地化之後的檔名僅供顯示使用,不能用來訪問實際的檔案資源,可以參考 Daniel 關於常見字串模式的文章 以獲取更多關於路徑的細節。
格式器
在不同的語言中,數字和日期被表現為各種形式。幸好蘋果官方已經提供了處理這些問題的方法,所以我們只需要使用NSNumberFormatter or NSDateFormatter 類來顯示使用者介面中的數字和日期即可。
請記住數字和日期的格式器是可變物件,因此並不執行緒安全。
格式化數字
數字格式器物件有很多配置選項,但大多數情況下你只要使用一種定義好的數字格式就好。畢竟使用數字格式器的原因就是不必再擔心其他語言中特定的數字格式。
對於數字 2.5
,在本文作者的機器上使用不同的格式器會得到不同的輸出:
1 2 3 4 5 6 7 8 |
數字型別 德語結果 阿拉伯語結果 ------------------------------------------------------------------------------------------------------ NSNumberFormatterNoStyle 2 ٢ NSNumberFormatterDecimalStyle 2,5 ٢٫٥ NSNumberFormatterCurrencyStyle 2,50 € ٢٫٥٠٠ د.أ. NSNumberFormatterScientificStyle 2,5E0 ٢٫٥اس٠ NSNumberFormatterPercentStyle 250 % ٢٥٠٪ NSNumberFormatterSpellOutStyle zwei Komma fünf إثنان فاصل خمسة |
在上表中數字格式器的一個很好的特性無法直觀地表現出來:在貨幣和百分數形式中,貨幣單位和百分號前面插入的不是一個普通空格,而是一個不換行空格,因此實際顯示的時候數字和後面的符號不會被顯示在兩行中。(而且這種加空格的顯示不是很酷嗎?)
預設情況下格式器會使用系統設定中指定的區域設定。在「字母大小寫」一節中我們已經說過,根據特定使用者介面的特定要求為格式器指定正確的區域設定是非常重要的,在後面的小節會進一步討論這一點。
格式化日期
與數字的格式化一樣,日期的格式化也非常複雜,因此我們有必要讓 NSDateFormatter
來負責這一點。使用日期格式器的時候你可以選擇蘋果官方提供的適用於所有區域設定的不同日期和時間格式。再強調一遍,選擇匹配介面其他元素的正確區域設定。
有時你想用一種 NSDateFormatter
預設不支援的格式來顯示日期,這時不要使用簡單的格式化字串(這樣做在應用到其他語言中時幾乎肯定會出錯),而要使用 NSDateFormatter
提供的 dateFormatFromTemplate:options:locale:
方法。
假設你想只顯示天和月份的縮寫,系統並沒有提供這樣的預設風格的。所以我們可以自定義格式器:
1 2 3 4 5 6 7 |
NSString *format = [NSDateFormatter dateFormatFromTemplate:@"dMMM" options:0 locale:locale]; NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; [dateFormatter setDateFormat:format]; NSString *output = [dateFormatter stringFromDate:[NSDate date]]; NSLog(@"Today's day and month: %@", output); |
相比使用格式化字串,呼叫這個方法的一大好處就在於輸出結果在其他語言中也肯定是正確的。舉例來說,在美國英語中,我們期望輸出「Feb 2」,而在德語中則應該輸出「2. Feb」。dateFormatFromTemplate:options:locale:
方法使用我們指定的模板和區域設定來構造正確的輸出結果,在美國英語中將模板變為「MMM d」,在德語中則變為「d. MMM」。
想要深入瞭解模板字串中可以使用的佔位符,可以參考Unicode 格式的區域設定資料標記語言文件.
快取格式器物件
因為建立格式器物件是一個非常消耗資源的操作,所以最好將它快取起來以供之後使用:
1 2 3 4 5 6 7 8 9 10 11 |
static NSDateFormatter *formatter; - (NSString *)displayDate:(NSDate *)date { if (!formatter) { formatter = [[NSDateFormatter alloc] init]; formatter.dateStyle = NSDateFormatterShortStyle; formatter.timeStyle = NSDateFormatterNoStyle; } return [formatter stringFromDate:date]; } |
1 |
這裡有一個小的陷阱需要注意:如果使用者修改了區域設定,我們就需要廢棄這個快取。因此我們需要使用NSCurrentLocaleDidChangeNotification註冊一個通知事件: |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
static NSDateFormatter *formatter; - (void)setup { NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter]; [notificationCenter addObserver:self selector:@selector(localeDidChange) name:NSCurrentLocaleDidChangeNotification object:nil]; } - (NSString *)displayDate:(NSDate *)date { if (!formatter) { formatter = [[NSDateFormatter alloc] init]; formatter.dateStyle = NSDateFormatterShortStyle; formatter.timeStyle = NSDateFormatterNoStyle; } return [formatter stringFromDate:date]; } - (void)localeDidChange { formatter = nil; } - (void)dealloc { NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter]; [notificationCenter removeObserver:self name:NSCurrentLocaleDidChangeNotification object:nil]; } |
蘋果官方的資料格式化指南中對此做了註解:
理論上來說你應該使用自動更新的區域設定(
autoupdatingCurrentLocale
),這樣就可以在使用者做更改時生成對應的區域設定檔案,但是這一招對日期格式器不適用。
所以我們不得不使用為區域設定的變更設定通知機制。相比格式化日期的那一小段程式碼,這一段有點長,但是如果你頻繁使用日期格式器,這樣做是值得的。始終牢記在權衡利弊之後再進行改進。 再次強調,格式器不是執行緒安全的。蘋果官方文件中寫道,你可以在多執行緒環境下使用格式器,但是不能有多個執行緒同時修改格式器。如果你想將用到的所有格式器集中在一個物件中,以便在區域設定更改時更方便地廢棄快取,你必須保證只使用一個佇列存放它們從而依次建立和更新。比如你可以使用併發佇列(concurrent queue)和 dispatch_sync
來獲取格式器,在區域設定更改時使用 dispatch_barrier_async
來更新格式器。
解析使用者輸入資料
數字和日期格式器不止可以根據數字和日期物件生成可本地化字串,還能以其他方式工作。每當你需要處理使用者輸入中的數字或日期時,都應該使用合適的格式器類來解析。這是唯一能夠保證使用者輸入能夠按照當前區域設定正確解析的方法。
解析機器生成資料
雖然格式器在處理使用者輸入時很好用,在已知格式的情況下處理機器生成的資料有更好的方法,因為為所有區域設定生成正確輸出的數字和日期格式器有效能上的損失。 舉例來說,如果你從伺服器接收到很多日期字串,在你將它們轉換成日期物件時,日期格式器並不是最好的選擇。蘋果官方的日期格式化指南中提到對於這些固定格式且無需進行本地化的日期,使用 UNIX 提供的 strptime_l(3)
函式更高效:
1 2 3 4 5 |
struct tm sometime; const char *formatString = "%Y-%m-%d %H:%M:%S %z"; (void) strptime_l("2014-02-07 12:00:00 -0700", formatString, &sometime, NULL); NSLog(@"Issue #9 appeared on %@", [NSDate dateWithTimeIntervalSince1970: mktime(&sometime)]); // Output: Issue #9 appeared on 2014-02-07 12:00:00 -0700 |
因為 strptime_l
函式也可以感知使用者的區域設定,所以確保最後一個引數傳入 NULL
以使用標準 POSIX 區域設定。函式中可用的佔位符請參考 strftime 使用者手冊。
除錯本地化字串
應用支援的語言版本越多,確保所有元素都正確顯示就越難。但是這裡有一些預設的使用者選項和工具可以減輕你的負擔。
你可以使用 NSDoubleLocalizedStrings
、AppleTextDirection
和 NSForceRightToLeftWritingDirection
選項保證你的佈局不會因為長字串或者從右往左讀的語言而混亂。NSShowNonLocalizedStrings
和 NSShowNonLocalizableStrings
則可以幫助你找到沒有翻譯的字串和根本沒有制定字串本地化巨集的字串。(所有這些工具的選項都可以通過程式設定或者作為 Xcode 的 Scheme 編輯器啟動選項,如 -NSShowNonLocalizedStrings YES
)
還有兩個選項可以控制語言和區域設定:AppleLanguages
和 AppleLocale
。你可以配置這兩個選項讓應用以不同於當前系統的語言或者區域設定啟動,讓你在測試時不用頻繁對系統設定進行切換。AppleLanguages
選項接收符合 ISO-639 標準的語言程式碼列表作為引數,如下所示:
1 |
AppleLanguages (de, fr, en) |
AppleLocale
則接收符合Unicode 國際元件標準(International Components for Unicode) 的區域設定識別符號作為引數,如下:
1 |
AppleLocale en_US |
或
1 |
AppleLocale en_GR |
如果你翻譯的字串沒有正確顯示,你可以帶上 -lint
選項執行 plutil 命令來檢查一下字串檔案是否有語法錯誤。例如你在行尾漏寫了分號,plutil 會輸出如下警告:
1 2 3 |
$ plutil Localizable.strings 2014-02-04 15:22:40.395 plutil[92263:507] CFPropertyListCreateFromXMLData(): Old-style plist parser: missing semicolon in dictionary on line 6. Parsing will be abandoned. Break on _CFPropertyListMissingSemicolon to debug. Localizable.strings: Unexpected character / at line 1 |
當我們修正了這個錯誤後,plutil 會告訴我們一切正常:
1 2 |
$ plutil Localizable.strings Localizable.strings: OK |
對於支援多種語言的應用,還有一個與除錯無關的小技巧:你可以在 iOS 上自動生成應用在多種語言下的螢幕截圖。因為可以使用UIAutomation
來控制應用,使用 AppleLanguages
在啟動時設定語言,所以整個測試過程可以自動化。GitHub 上的這個專案中可以找到更多細節。
選擇正確的區域設定
在使用日期和數字格式器或者類似 [NSString lowercaseStringWithLocale:]
的方法呼叫時,確保你使用了正確的區域設定是很重要的。如果你想使用系統當前的區域設定,你可以使用 [NSLocale currentLocale]
獲得,但是要注意這不一定與你的應用實際執行時使用的相同。
假設使用者的系統是中文的,但是你的應用只支援英語、德語、西班牙語和法語。這種情況下字串本地化會使用預設的英語來進行,如果你現在使用 [NSLocale currentLocale]
或者使用 [NSNumberFormatter localizedStringFromNumber:numberStyle:]
這種未指定區域設定的格式器類,那麼這些資料會根據中文的區域設定來進行格式化,而介面上的其他字串則都是英語。
最終需要你來決定特定情況下什麼最重要,但是你會想要應用的介面在一些情況下保持一致。為了獲取應用實際使用的而非當前系統的區域設定,我們必須獲取 mainBundle
中的語言屬性來構造區域設定:
1 2 |
NSString *localization = [NSBundle mainBundle].preferredLocalizations.firstObject; NSLocale *locale = [[NSLocale alloc] initWithLocaleIdentifier:localization]; |
在這樣的區域設定下,我們可以將日期格式化為與介面其他元素一致的形式:
1 2 3 4 5 |
NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; formatter.locale = locale; formatter.dateStyle = NSDateFormatterShortStyle; formatter.timeStyle = NSDateFormatterNoStyle; NSString *localizedDate = [formatter stringFromDate:[NSDate date]]; |
結論
任何適用於自己母語的規律都不一定適用於其他語言,在本地化字串時要牢記這一點。眾多框架提供了很多強大的工具將不同語言的複雜性抽象出來,我們只需要一以貫之地運用它們。這會帶來一些額外的工作,但是會為你在製作自己應用的其他語言版本時節約大量的時間