iOSアプリケーションの国際化と地域化

 海外事業向けのiOSアプリケーション開発を担当している西山(@yuseinishiyama)です。クックパッドは現在、海外複数カ国に向けてサービスを展開しています。

 海外事業向けのiOSアプリケーションは、英語、スペイン語、インドネシア語、タイ語、ベトナム語、アラビア語をサポートしています。今後、サポートする言語は更に増えていく予定です。

 これまで、複数の言語に対応するための国際化(internationalization)と地域化(localization)を行ってきました。ここでは、その中で得た知見を以下の4つのパートに分けて共有したいと思います。

  • コンテンツとUIの言語の決定
  • RTL対応
  • 翻訳フロー
  • 翻訳に関するTips

 ちなみに、当該プロジェクトがサポートしているiOSバージョンはiOS8以上です。そのため、iOS9以降でしかサポートされない機能については触れません。

 また、我々のサービスの性質上、精度の高いローカライゼーションを実現する必要があり、標準的な方法に背いた強引な方法を取っている箇所がいくつかあります。必ずしも全てのサービスで同様の対応が必要とは思いません。あくまで1つの事例として参考にしてください。

 以下、「Cookpadアプリ」は海外事業向けのアプリケーションのことを指します。

コンテンツとUIの言語の決定

 本節では、どのような思想に基いて、CookpadアプリがコンテンツとUIの言語を決定しているかについて説明します。

Cookpadアプリおける「言語」の意味

 Cookpadアプリにおいて、「言語」は以下の2つの意味を持ちます。

  • コンテンツの言語
  • UIの言語

 UIの言語は基本的には翻訳に関わる問題です。一方で、コンテンツの言語はそうではありません。それぞれのレシピは、特定の言語圏のユーザーがその言語で投稿したレシピであるため、言語によってコンテンツそのものが異なります。スペイン語圏のユーザーがインドネシア語のコンテンツを見たとしても、ほとんど意味を成しません。理解できないだけでなく、地域特有の材料などが手に入らないため、内容も役に立たないからです。次の画像は、あるインドネシア語のレシピをアプリで表示している様子です。

f:id:yuseinishiyama:20160510150316p:plain

 本節では、Cookpadアプリがどのようにコンテンツの言語とUIの言語を決定しているかについて説明します。

コンテンツの言語を決定する

 現在のCookpadアプリでは1つのアカウントは1つのコンテンツの言語に結びつく仕様となっています*1。これは、前述の理由から、1人のユーザーが複数の言語を跨いでコンテンツを閲覧する可能性は低いと考えているためです。そのため、適切なコンテンツの言語が最初に決定される必要があります。

 本項では、いかにしてコンテンツの言語を決定するかについて説明します。

デバイスの言語やリージョンの設定は信頼できない

 適切なコンテンツの言語を選択する上で、まず参考になりそうなのが言語やリージョンの設定です。言語やリージョンの設定とは、設定のGeneral > Language & Regionからアクセスできる項目のことを指します。

f:id:yuseinishiyama:20160510150138p:plain

 アプリで使用される言語は以下の手順で決定されます。

  1. Preferred Languagesの先頭の言語にアプリが対応しているか調べる。対応していれば、その言語を使用する。
  2. 対応していなければ、Preferred Languages内の次の言語にアプリが対応しているか調べる。対応していれば、その言語を利用する。これを、対応している言語が見つかるまで繰り返す。
  3. Preferred Languages内にアプリがサポートしている言語が見つからなければ、アプリのデフォルトの言語が使用される。

 これらの値は、コード上からも取得可能です、次のコードではPreferred Languagesの先頭の言語を取得しています。ちなみに、NSLocale.preferredLanguages()が返す値は、iOS9から変更されました。iOS8までは、["en"]のような値を返していましたが、iOS9からは、["en-GB"]というように言語とリージョンの組み合わせを返却するようになりました。

let mostPreferredLanguage = NSLocale.preferredLanguages().first?.componentsSeparatedByString("-").first

 ところで、言語やリージョンの設定の値は信用できるものなのでしょうか。全てのユーザーが自分にとって適切な値に設定しているのでしょうか。

 各地のメンバーと調査した結果、言語やリージョンの設定があまりあてにならないということが分かりました。例えば、インドネシアでは多くのユーザーが言語設定を英語のまま使っていることが判明しました*2。端末の流通経路によっては、デフォルトの言語やリージョンの設定が適切とは限らず、また、それらの変更方法を知らないユーザーも多くいるためでしょう。

 そのため、コンテンツの言語の選択にデバイスの言語やリージョンの設定をそのまま適用することはせず、ユーザーがアプリ内でコンテンツの言語を明示的に選択する仕様となりました。

 次のgifアニメはアプリ内で言語を選択している様子です。選択した言語にあわせて、UIの言語やレイアウトも動的に変更されています。例えば、アラビア語選択時は戻るボタンの位置が右側になっています。これらの挙動の実現方法については後述します。

f:id:yuseinishiyama:20160510150319g:plain

コンテンツの言語を予測する

 ユーザーが任意で言語を選択できるとはいえ、誤った言語が選択されないように配慮するべきです。そこで、適切なコンテンツの言語を予測し、それがデフォルトで選択されるようにしました。

 先述の通り、デバイスの言語設定はあまり信頼できるものではありません。一方で、SIMから取得できる国情報はより信頼できるものであるはずです。そこで、コンテンツの言語の予測には、MCC(Mobile Country Code)から得られる国コードを、言語設定や地域設定より優先して使用するようにしました。

 次の例では、SIMから国コードを取得しています。取得した値は、ISO 3166-1で表現された文字列です。

import Foundation
import CoreTelephony

struct NetworkInfoUtils {
    static func isoCountryCode() -> String? {
        let networkInfo = CTTelephonyNetworkInfo()
        let provider = networkInfo.subscriberCellularProvider
        return provider?.isoCountryCode // ISO 3166-1
    }
}

 この値が取得できなかった時に、初めて言語設定や地域設定を参照します。次のCountryCodePredictor型のinferredISOCountryCode()メソッドは、予測された国コードを返却しますが、まずSIMから得た国コードを参照し、次に地域設定から取得できる国コードを参照しています。

import Foundation

struct CountryCodePredictor {
    static func inferredISOCountryCode() -> String? {
        return countryCodeFromNetwork() ?? countryCodeFromLocale() ?? nil
    }

    private static func countryCodeFromNetwork() -> String? {
        return NetworkInfoUtils.isoCountryCode()
    }

    private static func countryCodeFromLocale() -> String? {
        let locale = NSLocale.currentLocale()
        return locale.objectForKey(NSLocaleCountryCode) as? String
    }
}

地域に応じてコンテンツを最適化する

 ちなみに、取得した国コードはヘッダを通じてAPIにも送っています。これは地域によって、検索結果などを最適化するためです。

 例えば、同じスペイン語圏であっても、スペインとメキシコではもちろん料理が異なります。アクセス先の地域の料理が優先的に表示されるようにしています。次の画像のうち、1つ目はスペインからの、2つ目はメキシコからの検索結果の例です。

f:id:yuseinishiyama:20160510150507p:plain f:id:yuseinishiyama:20160510150508p:plain

UIの言語を決定する

 既に述べたとおり、コンテンツの言語とUIの言語は別の概念です。そのため、コンテンツとUIで別の言語を使用することも可能です。しかし、Cookpadアプリではユーザーが選んだコンテンツの言語にUIの言語も合わせるという方針を取ることにしました。

 本項では、そのような方針に至るまでの経緯と、その実現方法について説明します。

UIの言語をコンテンツの言語に一致させる

 UIの言語を決定する上で、Appleの思想に則った最も適切な方針はデバイスの言語設定を尊重することでしょう。先ほどの、言語とリージョンの設定画面にも次のような説明文があります。

Apps and website will use the first language in this list that they support.

f:id:yuseinishiyama:20160510150317p:plain

 しかし、既に述べた通り、デバイスの言語設定は適切とは限りません。もちろん、母国語でない言語を理解できるユーザーや、言語が理解できなくとも操作方法を推察することができるユーザーも多数存在するでしょう。しかし、「世界中の人々に向けて毎日の料理を楽しみにするサービスを提供していく」という我々のミッションの性質上、このようなユーザー以外の人々の課題も解決しなければいけません。UIの言語もユーザーが「確実に」理解できるものであるべきです。

 ここまでで説明したように、コンテンツの言語はユーザーが明示的に選択しているため、ユーザーが理解可能な言語であることはほぼ間違いありません。そこで、UIの言語をコンテンツの言語と一致させることにしました。

UIの言語を動的に切り替える

 ローカライズされた文字列を取得するには、NSLocalizedString(_:comment:)関数を使用します。第1引数に指定したキーに対応する、ローカライズされた文字列を取得することができます。

NSLocalizedString("key", comment: "comment")

しかし、NSLocalizedString(_:comment:)関数はデバイスの言語設定を参照して、どの言語のリソースファイルを使用するかを決定するため、アプリ内で言語スイッチを持つ場合に、これをそのまま使用することはできません。

 あり得る手段の1つとしては、実行時にアプリの言語を強制的に上書きするというものです。

NSUserDefaults.standardUserDefaults().setObject(["es"], forKey: "AppleLanguages")
NSUserDefaults.standardUserDefaults().synchronize()

 しかし、この方法にはアプリの再起動が必要であるという致命的な欠点があります。WWDC14のセッション"Advanced Topics in Internationalization"でも次のように説明されています。

Changing the language preference requires restarting apps

 利用開始後、早々にアプリから再起動を促されるという体験はユーザーに与える印象をかなり悪くすると思われます。そこで、NSLocalizedString(_:comment:)関数をラップした、任意のリソースファイルから文字列を取得する関数を実装しました。ここで、FoundationNSLocalizedString(_:comment:)関数と重複するにも関わらず、アプリ本体の名前空間に同名の関数を定義している理由は後述します。

func NSLocalizedString(key: String,
                       tableName: String? = nil,
                       bundle: NSBundle = NSBundle.mainBundle(),
                       value: String = "",
                       comment: String) -> String {
    var bundleToUse = bundle
    if
        let languageCode = (ユーザーが選択した言語の言語コード)
        let path = bundle.pathForResource(
            languageCode,
            ofType: "lproj"),
        let bundle = NSBundle(path: path) {
            bundleToUse = bundle
    }

    return Foundation.NSLocalizedString(key,
                                        tableName: tableName,
                                        bundle: bundleToUse,
                                        value: value,
                                        comment: comment)
}

 ところで、このようにリソースファイルへのパスをアプリ内で組み立てることをAppleは推奨していません。WWDC15のセッション"What's New in Internationalization"では次のように説明されています。

Don’t access the directories yourself

 同様のことを行う場合は、非推奨の方法であるということに留意しましょう。

RTL対応

 本節では、CookpadアプリのRTL対応について説明します。

RTLとは

 アラビア語やヘブライ語のような言語が採用している、右から左へ書くシステムのことをRTL(Right to Left)といいます。ちなみに、英語のような、左から右へ書くシステムはLTR(Left to Right)です。

 RTLの言語を母語とするユーザーは5億人ほどいると言われています。そのためアプリケーションをRTLに対応させることは、ユーザーの獲得という観点から見て非常に重要です。Cookpadアプリはアラビア語に対応しているので、RTLの対応は必須でした。

AutoLayoutによるRTL対応

 AutoLayoutを利用していれば、RTLに対応することは難しくありません。制約を設定する際のNSLayoutAttribute型の値として、.Left.Rightではなく、.Leading.Trailingを指定するだけで大部分がRTLに対応済みとなります。LTR環境において、.Leadingは左を、.Trailingは右を指しますが、RTL環境では、.Leadingが右を、.Trailingが左を指すようになります。

 GUI上で、.Leading.Trailingを指定する場合は、Respect language directionという項目にチェックをいれます。

f:id:yuseinishiyama:20160510150416p:plain

手動でRTLに対応する

 AutoLayoutはデバイスの言語設定を参照してレイアウトの方向を決定します。つまり、AutoLayoutによるRTL対応は、デバイスの言語がRTLでなければ機能しません。

 先述の通り、Cookpadアプリは言語スイッチをアプリ内に持っているため、デバイスの言語はRTLではないが、ユーザーが選択した言語はRTLであるという可能性があります。結果として、AutoLayoutに頼らない手動のRTLを実装することになりました。

デバイスの言語
RTL LTR
選択された言語 RTL RTL(AutoLayout) RTL(手動)
LTR RTL(AutoLayout) LTR

 デバイスの言語を明示的にRTLにしているユーザーが、LTRの言語を選択するのはレアケースと考えています。そのため、今のところ、RTLを強制的にLTRにすることはしていません。

要素を反転させる

 手動でRTL対応を行うため、UIViewクラスのtransformプロパティを使用して、ビューを反転させることにしました。以下のようにすると、ビューは左右反転します。UIViewControllerクラスのviewプロパティの値に対して同様の操作を行えば、画面上の要素の左右の順序が入れ替わります。

transform = CGAffineTransformMakeScale(-1, 1)

 次の画像のうち、1つ目はオリジナルのもの、2つ目はルートビューを反転させたものです。レイアウトの方向がRTLになっていることが分かります。

f:id:yuseinishiyama:20160510150311p:plain

f:id:yuseinishiyama:20160510150314p:plain

 しかし、画像を見ても明らかな通り、テキストや画像も左右に反転してしまいます。これらの要素は、個別に、もう一度反転させることで適切な状態になります。

f:id:yuseinishiyama:20160510150315p:plain

翻訳フロー

 機能追加毎に6つの言語への翻訳を行うコストは決して少なくはありません。適切な翻訳フローを設けなければ、翻訳のためのタスクがブロックとなり、スムーズな開発を妨げる可能性があります。

 本節では、Cookpadアプリの翻訳がどのようなフローに則って行われているかについて説明します。

ブランチ戦略

 翻訳は各地のメンバーが行っています。PR毎に全ての翻訳を完了させるということも可能ですが、時差の都合上、翻訳待ちの状態のPRが多くなりがちです。そこで、各トピックブランチでは英語の文言を設定するだけにしました。developブランチには英語の文言だけが存在する変更がマージされ、リリースブランチ上で残りの言語の翻訳を行います。

 このようなフローを取るためにブランチモデルは、git-flowを採用しました。

リリースブランチでの翻訳作業

 本項では、リリースブランチでの翻訳作業について説明します。

エクスポート

 まず、ベースとなる言語(ここでは英語)のXLIFF(XML Localisation Interchange File Format)ファイルをエクスポートします。XLIFFはローカライゼーションのための標準規格で、Xcode 6からサポートされるようになりました。

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
    <file original="Global/SupportingFiles/en.lproj/Localizable.strings" source-language="en" datatype="plaintext">
    <header>
      <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="7.3" build-num="7D175"/>
    </header>
    <body>
      <trans-unit id="application_config.new_version_available.button_title">
        <source>Open in App Store</source>
        <note>No comment provided by engineer.</note>
      </trans-unit>
      <trans-unit id="application_config.new_version_available.message">
        <source>A new version is available in the App Store. Please update before continuing to use the app.</source>
        <note>No comment provided by engineer.</note>
            <note>No comment provided by engineer.</note>
      </trans-unit>
      (省略)
    </body>
  </file>
</xliff>

 このファイルはGUI上からもエクスポートすることができますが、xcodebuildを利用すれば、コマンドラインからもエクスポート可能です。自動化のタスクに組み込むなどすると良いでしょう。

xcodebuild -exportLocalizations -localizationPath <エクスポート先> -project <プロジェクト名>

 ちなみに、エクスポートする際に、Xcodeはソースコードを解析して、存在するNSLocalizedString(_:comment:)のキーを確認します。この解析時に、XcodeはNSLocalizedStringという文字列を探して、その後に続く文字列リテラルからキーを取得しているようです。先ほど、NSLocalizedString(_:comment:)のラッパーの関数名に、そのままNSLocalizedStringを使っていたのはこのためです。もし、次のように別名の関数を用意していた場合、エクスポートは上手く機能しません。

func MyLocalizedString(key: String, comment: String) -> String {
    return NSLocalizedString(key, comment: comment)
}

MyLocalizedString("key", comment: "comment")

OneSkyを用いた翻訳

 このプロジェクトでは、全プラットフォームで翻訳作業にOneSkyというサービスを利用しています。OneSkyはXLIFFフォーマットをサポートしているため、OneSkyに上記の手順で生成したベースとなる言語ののXLIFFファイルをアップロードすることができます。アップロード作業も公式のクライアントを用いれば、コマンドライン上から行うことができます。

 アップロードすると、文言が翻訳可能な状態となるので、後は各地のメンバーに翻訳作業を依頼します。OneSky内で翻訳について翻訳者と議論したり、別のプラットフォームで使用している文言を参照したり、翻訳の進捗状況を視覚的に確認することもできます。

f:id:yuseinishiyama:20160510150415p:plain

 OneSky内で翻訳を外注することもできますが、我々はあくまでツールとして利用し、翻訳自体は各地のメンバーが行っています。単なる翻訳ではなく、サービスの思想を汲んだ上で適切な意訳を行うことができるということが、翻訳を外注しないことの最大のメリットでしょう。

インポート

 翻訳が完了したら、OneSkyから全ての言語用のXLIFFファイルをダウンロードしてプロジェクトにインポートします。Cookpadアプリの場合は、6つの言語をサポートしているので、6つのXLIFFファイルをインポートすることになります。

 エクスポートと同じく、インポートもGUI、コマンドラインの両方から行えます。

xcodebuild -importLocalizations -localizationPath <インポート対象> -project <プロジェクト名>

翻訳に関するTips

 本節では翻訳に関するTipsについて説明します。

適切なNSLocalizedStringのキーを設定する

 NSLocalizedString(_:comment:)のキーとして設定する値は非常に重要です。適切なキーを設定しなければ、あっという間に管理不能な状態になります。

 次のような、キーと値の組み合わせは良くありません。"latest"のような似通った表現が出てきた場合に、どこでどちらが使われているのか分からなくなる可能性があります。また、英語において、全て"new"と表現することができる箇所が、他の言語でも1つの用語で表現できるとは限りません。

"new" = "new";

 キーは常にコンテキストが分かるような値にしましょう。また、同じ単語であっても、コンテキストが異なれば、別のものとして定義するべきです。

"home.feed.new" = "new";

変数の順序に気をつける

 書式に変数を埋め込む形の翻訳対象の文字列があったとします。

"Copy %@’s %@" = "Copying %@’s %@";

 このように複数の変数がある場合、必ずしも他の言語でも上手く機能するとは限りません。例えば、これをそのままドイツ語にすると次のようになりますが、これは誤りです。ドイツ語では1番目の変数と2番目の変数の順序が逆である必要があります。

"Copy %@’s %@" = "%@ von %@ kopieren";

 この問題を回避するために、書式中の変数に対して、順序を指定することができます。

"Copy %@’s %@" = "%$2@ von %$1@ kopieren";

デバッグ

 本項ではデバッグに関するTipsを紹介します。

Double Length Pseudolanguage

 言語が増えれば増えるほど、文字列の長さを予測することは難しくなります。ある言語では1行で収まるような文言でも、他の言語では複数行となるといったようなことは頻繁に起こります。常に想定より長い文字列を表示できるようなレイアウトを組むべきです。

 「Double Length Pseudolanguage」というデバッグオプションを使用すれば、全てのローカライズされた文字列が2回繰り返される状態を再現できます。

f:id:yuseinishiyama:20160510150313p:plain

これによって、想定より長い文字列が入った場合にレイアウトが崩れるケースを検出できるかもしれません。次の例では、"bookmark"、"Create Recipe"などの翻訳対象の文字列が全て2回繰り返されており、ここから想定より長い文字列を表示すると一部のレイアウトが崩れることが検出できます。

f:id:yuseinishiyama:20160510150312p:plain

Right to Left Pseudolanguage

 「Right to Left Pseudolanguage」というデバッグオプションを使用すれば、擬似的なRTLの状態を再現することが可能です。

f:id:yuseinishiyama:20160510150505p:plain

 まだRTLの言語をサポートしていないアプリでも、RTLになった際にどのようなレイアウトになるか確認することができます。次の例では、言語は英語ですが、レイアウトはRTLになっています。

f:id:yuseinishiyama:20160510150419p:plain

NSShowNonLocalizedStrings

 NSShowNonLocalizedStringsを起動時のオプションに指定すれば、未翻訳の文字列のキーが大文字で表示されます。

f:id:yuseinishiyama:20160510150414p:plain

 例えば、次の例では、検索バー内のプレースホルダーが未翻訳のため、そのキーが大文字で表示されています。この結果、デバッグ時に翻訳忘れに気付きやすくなります。

f:id:yuseinishiyama:20160510150412p:plain

おわりに

 本記事では、CookpadアプリにおけるiOSアプリケーションの国際化と地域化について説明しました。

 言語毎にコンテンツやアカウントが異なるサービスというのは珍しく無いはずです。これをきっかけに、iOSアプリにおける言語の扱いに関する議論が盛んになれば、と思います。

 また、普遍的に役立つトピックもあれば、Cookpadアプリ固有のトピックもあったかと思います。最初に補足したように、いくつかの点で標準的な方法から逸脱した手段を取っています。我々のサービスでは、要求を満たすためにこうした手段を取りましたが、その代償として多少のメンテナンスコストの増加は避けられませんでした。もし、同様のことを行うのであれば、それが本当に必要かどうか、そしてそのコストがどれくらいかを検討するのにこの記事が参考になれば幸いです。

 このようにサービスの海外展開では、海外の文化、ユーザーのモバイル利用環境など様々な事柄を考慮しなければなりません。私たちは、こうした困難に積極的に立ち向かい、海外でクックパッドのサービスを展開することに協力してくれるモバイルエンジニアを積極募集中です!

弊社採用ページ(海外グループ iOS/Android アプリエンジニア)

*1:ローンチ後、Google Analyticsのデータからインドネシアからの多くのアクセスが、言語設定が英語のデバイスからであることが明らかになった。

*2:2016年5月10日時点の仕様。今後、変更される可能性がある。