前陣子工作時,替公司開發了一套工具,實作了 app 內切換語言的功能。

一般來說,在 iOS app 內語言都是跟隨手機系統語言,但是會有滿多用戶會希望手機語言與 app 內語言不同。

網路上有許多第三方套件可以做到 app 內換語言,但使用方式多半會與內建的 NSLocalizedString 不同,對於既有的程式碼,改動成本相對高。

這時我們可以使用 Method Swizzling 這個技術來替換 NSLocalizedString 底層的實作方式減少改動成本。

多國語言字串

NSLocalizedString 簡介

在 iOS app 中,可以透過 NSLocalizedString 來讀取 app 內所包裝的不同語言字串。

舉例來說,如果我們有以下 .strings 檔:

// Localizable.strings (en)
"phone_number" = "Phone Number";

// Localizable.strings (zh-Hant)
"phone_number" = "電話號碼";

然後我們就可以在 app 內使用以下語法對 phoneNumberLabel 進行文字的設定:

phoneNumberLabel.text = NSLocalizedString("phone_number", comment: "")

這時如果使用者手機語言為正體中文,phoneNumberLabel 就會顯示 電話號碼;同理,若手機語言為英文,則會顯示 Phone Number

iOS 系統如何選擇顯示語言

一般來說,從 iOS 系統會從 app 有支援的語言中,找出第一個在系統語言偏好列表中的語言的作為 app 內使用的語言。但如果沒有共通的語言的話則會使用開發語言

舉幾個例子

手機語言偏好 App 支援語言 App 內使用語言
英文 英文(開發語言)、正體中文 英文
正體中文 英文(開發語言)、正體中文 正體中文
德文、正體中文、英文 英文(開發語言)、正體中文 正體中文
德文、英文、正體中文 英文(開發語言)、正體中文 英文
法文 英文(開發語言)、正體中文 英文 = 開發語言


程式上來說,我們可以透過 Bundle.preferredLocalizations 取得系統決定的語言。

使用方法如下:

let currentLanguage = Bundle.main.preferredLocalizations.first

解構 NSLocalizedString

Objective-C 中的 NSLocalizedString

根據 Apple 所提供的文件,在 Objective-C 裡面,NSLocalizedString 是個 macro。它的實作則是呼叫 main bundle 的 localizedStringForKey:value:table:

Return Value

The result of invoking localizedStringForKey:value:table: on the main bundle passing nil as the table.

Swift 中的 NSLocalizedString

Swift 中的 NSLocalizedString 則是一個 global function:

func NSLocalizedString(_ key: String,
						   tableName: String? = nil,
						   bundle: Bundle = Bundle.main,
						   value: String = "",
						   comment: String) -> String

同樣根據 Apple 提供的文件,也是從 main bundle 中拿取字串。

所以我們可以透過 swizzle Bundle.localizedString(forKey:value:table:) 的實作內容來達成 swizzling NSLocalizedString 的效果。

實作 App 內切換語言

首先,我們需要實作可以讓使用者從 app 有支援的語言中選取一種的介面。

這部分就不在此贅述,這邊列出幾個實作重點:

  • Bundle.main.localizations → 取得 app 有支援的語言代碼列表
  • Locale(identifier: langCode).localizedString(forLanguageCode: langCode) → 將改語言以該語言的字串顯示,例如:
    • langCode = en → English
    • langCode = fr → français

在此假設使用者在上述介面中選擇偏好的語言會被存入 preferredLanguage 這個變數中。

如果使用者沒有手動選擇,則這個變數的值為 Bundle.main.preferredLocalizations.first!,也就是系統所選用的語言。

取得特定語言的字串

因為 .strings 檔案是放置在 .lproj 資料夾底下,所以我們可以透過指定 Bundle 拿取資源的路徑來達成取得指定語言字串的效果。

extension Bundle {
    func localizedString(forKey key: String, language languageCode: String, value: String?, table tableName: String?) -> String {
        guard
            let lprojPath = path(forResource: languageCode, ofType: "lproj"),
            let bundle = Bundle(path: lprojPath)
        else { fatalError("Language '\(languageCode)' is not supported") }

        return bundle.localizedString(forKey: key, value: value, table: tableName)
    }
}

這樣一來,我們就可以透過以下方式取得 preferredLanguage 所指定語言版本的字串:

preferredLanguage = "zh-Hant"
Bundle.main.localizedString(forKey: "phone_number", language: preferredLanguage, value: nil, table: nil) // "電話號碼"

preferredLanguage = "en"
Bundle.main.localizedString(forKey: "phone_number", language: preferredLanguage, value: nil, table: nil) // "Phone Number"

定義新的 NSLocalizedString

先前提到 NSLocalizedString 其實就是呼叫 Bundle.main.localizedString(forKey:value:table:) 的結果。

搭配前一小節內容,可以先定義出會參考 preferredLanguagelocalizedString(forKey:value:table:) 版本。

extension Bundle {
    @objc private func modifiedLocalizedString(forKey key: String, value: String?, table: String?) -> String {
        return localizedString(forKey: key, language: preferredLanguage, value: value, table: table)
    }
}

接下來我們只需要將 modifiedLocalizedString(forKey:value:table:) 以及 localizedString(forKey:value:table:) 利用 method swizzling 調換其中的實作內容,就可以使 NSLocalizedString 根據 preferredLanguage 回傳對應語言的字串了。

extension Bundle {
	class func swizzleLocalizedStringMethodImplementations() {
        let cls: AnyClass = self
        let originalMethod = class_getInstanceMethod(cls, #selector(localizedString(forKey:value:table:)))!
        let modifiedMethod = class_getInstanceMethod(cls, #selector(modifiedLocalizedString(forKey:value:table:)))!
        method_exchangeImplementations(originalMethod, modifiedMethod)
    }
}

還沒結束!

一旦呼叫了 swizzleLocalizedStringMethodImplementations, 原本localizedString(forKey:value:table:) 的實作內容會與 modifiedLocalizedString(forKey:value:table:) 的內容互換。

所以必須修改 localizedString(forKey:language:value:table) 讓其呼叫原始的 localizedString(forKey:value:table:),否則會有無窮迴圈產生

  func localizedString(forKey key: String, language languageCode: String, value: String?, table tableName: String?) -> String {
      guard
          let lprojPath = path(forResource: languageCode, ofType: "lproj"),
          let bundle = Bundle(path: lprojPath)
      else { fatalError("Language '\(languageCode)' is not supported") }

-     return bundle.localizedString(forKey: key, value: value, table: tableName)
+     return modifiedLocalizedString(forKey: key, value: value, table: tableName)
  }

這樣一來才算是真正完成。

完成後程式碼如下:

extension Bundle {
    class func swizzleLocalizedStringMethodImplementations() {
        let cls: AnyClass = self
        let originalMethod = class_getInstanceMethod(cls, #selector(localizedString(forKey:value:table:)))!
        let modifiedMethod = class_getInstanceMethod(cls, #selector(modifiedLocalizedString(forKey:value:table:)))!
        method_exchangeImplementations(originalMethod, modifiedMethod)
    }

    @objc private func modifiedLocalizedString(forKey key: String, value: String?, table: String?) -> String {
        return localizedString(forKey: key, language: preferredLanguage, value: value, table: table)
    }

    private func localizedString(forKey key: String, language languageCode: String, value: String?, table tableName: String?) -> String {
        guard
            let lprojPath = path(forResource: languageCode, ofType: "lproj"),
            let bundle = Bundle(path: lprojPath)
            else { fatalError("Language '\(languageCode)' is not supported") }

        return bundle.modifiedLocalizedString(forKey: key, value: value, table: tableName)
    }
}

呼叫 swizzleLocalizedStringMethodImplementations

最後,在 App 一啓動時套用剛才所寫的實作置換,就大功告成。

// AppDelegate.swift
class AppDelegate: UIResponder, UIApplicationDelegate {
	...
	func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
		Bundle.swizzleLocalizedStringMethodImplementations()
		...
		return true
    }
	...
}

小結

除非不得已,在開發中不建議使用 method swizzling 或是其他 Objective-C runtime 相關功能。 這些工具自然好用,但是會減少程式碼的易讀性,同時也失去了透過編譯器檢查型別等等好處。 仔細想想,通常都可以有更好解決方式。

至於我會選用這樣的方式實作,主要是希望 app 其他部分的程式碼可以維持與系統預設語法相同,這樣未來比較不會因為忘了要用特殊語法而產生 bug — 也算是利大於弊啦。

延伸閱讀

Method Swizzling - NSHipster (個人認為)必讀的 Method Swizzling 教學

iOS 界的毒瘤:Method Swizzle 對於 Method Swizzling 很詳細的一篇分析(簡體中文文章)