iOS
翻訳
多言語化

多言語リソース管理がめんどくさいので、どうにかして楽がしたいと思った話 iOS編

More than 1 year has passed since last update.

この記事はRetty Inc. Advent Calendar 2017 13日目です。

昨日は @yongyu-liHow to build an "infinite" list in ReactNative でした。

昨年はAWS Cognitoを実践で使うときのハマりどころという記事を書きました。
今年の題材は、Rettyでは日本語、英語、広東語、タイ語に対応していることもあり、iOSでめんどくさい多言語対応をなるべく楽にするような仕組みを構築した話を書きたいと思います。
iOSエンジニアやってますが、昨年も今年もiOSのコードはほぼ皆無です。

iOSで多言語対応を行う普通の方法

iOSではデフォルトで言語リソースを管理する仕組みがあります。

一般的な方法で行った場合

  1. 翻訳のマスターをスプレッドシートやエクセルで用意する
  2. それぞれの言語ごとにLocalizable.stringsを用意する(中身はKey-Valueな感じのテキストデータ)
  3. コード中にNSLocalizedString("key", comment: "")のような形で埋め込んでいく
  4. storyboardでも言語リソースを用意すれば翻訳を適用することが可能(ただし使い勝手はよくない)
  5. ビルドして翻訳が反映されているか確認する

めんどくさいポイント

  • 翻訳箇所のコードが冗長
  • 名前決めるのがめんどくさい
  • 反映するのにいちいち作業が発生する
  • タイポとかミス起きやすい
  • マスターとstringファイルが別

入力しやすくするプラグインや、翻訳リソース管理のサービスなどを使うことにより幾つかの問題点は解決することができます。

Rettyの場合

ところで上で書いた方法ですが、言語は端末の言語設定に依存しています。
Rettyの場合、言語設定はFacebookのようにアプリ内で設定することができます。
この場合デフォルトの方式は使えず、自前で仕組みを用意する必要がありました。

どうにかする

ざっくり方針

Google Spreadsheetを使う
Google Apps Scriptでspreadsheetからアプリ用のリソースファイルを生成する
Googleのバックアップと同期を使ってローカルに自動DL
フォルダ監視を行い、変更があった場合にプロジェクトファイルに自動同期させる

Google Spreadsheet

シートは3枚にしました。
言語リソースにはユニークなkeyとそれぞれの言語のvalueが必要になります。

master

keyを作るためのmasterを用意しました。
large,middle,smallの3階層にして、largeとmiddleはmasterに登録されたものを必ず使用しています。
smallはよく使うものだけmasterに登録して、基本的にはその場でユニークなものを採用しています。

largeはページもしくはトーストなどの大きな機能単位、middleはラベルやボタンといったパーツ、smallは具体的な内容を指すようにしました。

これにより命名規則が守られやすくなり、IDの重複なども考えなくて良くなりました。
名前を考えるのも割りとらくになりました。

Read Me

使ってもらう上で、後から入ってきた人のためだったりエンジニア以外も使うことを考慮してRead Meを用意しています。
仕様上の注意やリソースファイルを生成するためのボタンなどをおいています。
最初はマスターファイルが変更されるたびに書き出しを行っていたのですが、作業中のものが書き出されてしまう問題もあり、明示的に書き出しを実行するようにしました。

localize

下のような構成になっています。
緑のラインでIDを決定、赤の部分は重複チェックを行っています。
白い部分が実際の翻訳文です。
DuplicateがIDの重複チェック、mergeは翻訳された文言が全て同じ場合warningがあがるようにしています。

71b9fc96-cbbd-49d4-236f-f66827ba9996.png

Google Apps Scriptでデータを書き出す

ツール -> スクリプトエディタ と進むと Google Apps Script の編集画面に移動できます。

保存用のフォルダを作ってIDをメモする

13bf87f1-3c46-c4ab-0191-c59856261cff.png

GoogelDriveでフォルダに移動するとURLがこのようになりますが、foldersあとのハッシュのようなものがフォルダIDになります。

スクリプトエディタ

基本はただのJSです。
スプレッドシートにアクセスするための便利な関数などが用意されています。

6b1e91a6-653f-8d61-470e-9d9c8123ea13.png

実際のコードはこんな感じになっています。

スクリプト
function localize() {
  var folder = DriveApp.getFolderById('hogehoge');
  var sheetName = 'localize'
  var jaFileName = 'ja.strings';
  var enFileName = 'en.strings';
  var zhhkFileName = 'zh-HK.strings';
  var thFileName = 'TH.strings';
  var contentType = 'text/plain';
  var enumFileName = 'Localize.swift';
  var jsonContentType = 'application/json';
  var swiftContentType = 'text/plain';

  var localizeIdNum = 6;
  var jaNum = 8;
  var enNum = 9;
  var zhhkNum = 10;
  var thNum = 11;
  var nl = '\n';

  var spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spreadsheet.getSheetByName(sheetName);
  var data = sheet.getDataRange().getValues();

  var jaString, enString, zhhkString, thString;
  var notice = "// This file is automatically generated from googlespredsheet.\n// Please do not edit directly\n\n"
  jaString = enString = zhhkString = thString = notice;

  var ids = "";
  for (i in data) {
    var localizeId = data[i][localizeIdNum];
    if (localizeId == "localize_id" || localizeId == "") {
      continue;
    }
    jaString += '"' + localizeId + '" = "' + escapeString(data[i][jaNum]) + '";' + nl;
    enString += '"' + localizeId + '" = "' + escapeString(data[i][enNum]) + '";' + nl;
    zhhkString += '"' + localizeId + '" = "' + escapeString(data[i][zhhkNum]) + '";' + nl;
    thString += '"' + localizeId + '" = "' + escapeString(data[i][thNum]) + '";' + nl;

    ids += "    case " + periodToCamel(snakeToCamel(localizeId)) + " = \"" + localizeId + "\"\n"
  }

  var enumString = notice + "enum Localize: String {\n" + ids + "\n    func localized() -> String {\n        return self.rawValue.localized()\n    }\n}"
  updateFile(swiftContentType, folder, enumFileName, enumString);

  updateFile(contentType, folder, jaFileName, jaString);
  updateFile(contentType, folder, enFileName, enString);
  updateFile(contentType, folder, zhhkFileName, zhhkString);
  updateFile(contentType, folder, thFileName, thString);
}

function updateFile (contentType, folder, filename, string) {
  try {
    // filename is unique, so we can get first element of iterator
    var file = folder.getFilesByName(filename).next()
    file.setContent(string)
  } catch(e) {
    folder.createFile(filename, string, contentType)
  }
}

function escapeString (string) {
  return string.replace(/"/g, '\\"');
}

function snakeToCamel (p) {
    return p.replace(/_./g,
            function(s) {
                return s.charAt(1).toUpperCase();
            }
    );
}

function periodToCamel (p) {
    return p.replace(/\../g,
            function(s) {
                return s.charAt(1).toUpperCase();
            }
    );
}

シートの構造にかなり依存したコードなんですがそこは目をつぶってもらうとして、localizeを実行することにより、指定したフォルダに、各言語用のstringsファイルとenumが定義されたswiftファイルが生成されます。
swiftファイルの使い方が後ほどにするとして、生成したファイルをプロジェクトに反映させます。

生成したファイルをプロジェクトに反映

最初は普通にDLして反映させようとしたのですが、GoogleDriveはまとめてダウンロードしようとすると圧縮されてDLされるのですが、その時なぜか[ja.strings.txt]みたいに.txt拡張子がついてきてしまいます。

わざわざ変換するのがめんどくさい!

ということで、楽をする方法を考えました。

なぜかGoogleのバックアップと同期という微妙な名前のアプリなのですがこちらをインストールします。
https://www.google.com/intl/ja_ALL/drive/download/

インストールしてGoogleDriveから先ほど言語ファイルが入ったフォルダを同期するようにします。

注意点として、多人数でやる場合はフォルダを共有も当然ですが共有された側はフォルダをマイドライブに追加してもらう必要があります。
また、Googleのバックアップと同期を設定する際にMacに作成されるフォルダが「Google ドライブ」と日本語がまじったりスペースが交じるのですがこれを「GoogleDrive」に変更しておいてください。

プロジェクトに反映

fswatchを使います。
brew install fswatch でインストール出来ます。

fswatch -r [macのgoogle driveのpath] | xargs -n1 -I{} cp {} [反映先フォルダのpath]
こちらのコマンドを実行している間、言語リソースが変更されると自動的にプロジェクトに反映されるようになります。

実際に適用する

さあやっと、swiftの世界にやってきました。

言語リソースを適用する

先程あったように、NSLocalizedStringでは対応できないため、Stringのextensionにlocalizedというのを生やして対応しています。
"hogehoge".localized()でアプリ内の選択言語を見て対応する言語リソースを表示するようになっています。

ここで先ほど作られたLocalize.swiftが出てきます。
しかしこれだと、"hogehoge"の部分はコピペで入れる必要があり、タイポした時など対応する言語リソースがない場合key名がそのまま出てしまうなどの問題が残っています。
また、このために気軽にkey名を変えることもやりにくくなります。

enumを使う

ここで先ほど作られていた Localize.swift が出てきます。

Localize.swift
// This file is automatically generated from googlespredsheet.
// Please do not edit directly

enum Localize: String {
    case generalLabelCancel = "general.label.cancel"
    case generalLabelDelete = "general.label.delete"
    case generalLabelBrowsingHistory = "general.label.browsing_history"

    func localized() -> String {
        return self.rawValue.localized()
    }
}

このようなシンプルなenumとfuncが1つという構成になっています。

"hogehoge".localized() これが、

Localize.hogehoge.localized() こうなります。

enumに書き出すことによりサジェストも効くようになり、ページ名 = ViewController名から探せるので簡単にリソースにたどり着けるようになります。
また、タイポや言語リソースの内容が間違っていたりrenameされた場合はビルドエラーになるため事前に問題が検知できるようになりました。

まとめ

いかがだったでしょうか?
これまでは、反映はひとつひとつ手で行っていたり最終的に正しいものがどれかが曖昧だったりしたのですがすっきり管理できるようになり、手間も大幅に減らすことができました。

現在は同じ仕組みを利用してログの管理も行おうとしているとこです。
Spreadsheetを使えば、構築の手間も大幅に減らせますしGoogle Apps Scriptやslack連携などを使うことにより割りと高度なこともできたりします。
必要になればもっとしっかりとしたシステムに載せ替えるかもしれませんが、小さく始めるのにはこの組み合わせはオススメです。

今回はiOS用に作りましたが、AndroidやWEBアプリケーションでも使えるようにしたり言語リソース管理ももっと一元化できるようにしていけたらなあと思っています。

明日は @r4-keisukeElasticsearchでRettyのサジェスト検索を作ったときの苦労話 です。お楽しみに!