【Xcode】Google Spreadsheetでアプリのローカライズ管理

【Xcode】Google Spreadsheetでアプリのローカライズ管理
【Xcode】Google Spreadsheetでアプリのローカライズ管理

iOSアプリを多言語に対応しながら開発している時に、もっとラクな方法はないだろうかとつねづね思っていた。NSLocalizedString では入力補完がないし、項目が多くなると同じ翻訳をしていたことにも気づかずかないありさまとなってしまう。

何か良い方法はないかと探していたところ、Googleのスプレッドシートで管理する方法を見つけることができた。

この方法を自分のiPhoneアプリの開発環境にいれてみたところ、たいへん管理がラクになった。作業効率アップまちがいナシなので、ローカライズ作業に翻弄されている方はこの記事で紹介する方法をぜひ試してみて欲しい。

【完成イメージ】スプレッドシートでアプリのローカライズを管理して効率を上げる方法

画像のようにスプレッドシートでローカライズを管理することを目標にする。そしてスプレッドシートから stringsファイル を書き出し、Xcodeプロジェクトへ半自動でコピーさせる。これを実現するために、Googleドライブをいったん経由することになる。

スプレッドシート → Googleドライブ → Xcodeプロジェクト

まずはスプレッドシートでキーワードと翻訳の管理からやっていこう。

スプレッドシートでローカライズ管理

スプレッドシートでローカライズの管理をするため、次のように列を構成する。今回紹介するプログラムは、この並び順通りにやらないとうまく動かないので注意が必要。

  • A、B、C列は、large、middle、smallと3つのキーワードで管理する。
  • D列はでA、B、Cをコンマで結合する。
  • E列は日本語、F列は英語の翻訳とする。

ここでD列には数式、=A2 & "." & B2 & "." & C2 を入れればA、B、Cの値をコンマ結合してくれる。

Googleドライブに書き出すための準備

ファイルを書き出す場所を指定するために、あらかじめGoogleドライブにフォルダを作っておく。

ブラウザーからGoogleドライブへアクセスし、localize フォルダを作成。さらにそのフォルダ内にen.lprojja.lproj の名前でフォルダを作成する。これらの名前は、自分のXcodeプロジェクトに合わせて決めている。

先ほど作ったGoogleドライブの3つのフォルダのURLを調べる。

https://drive.google.com/drive/folders/xxxxxxxxxxxxxxxx

スプレッドシートからGoogleドライブへ書き出すためには、この xxxxxxxxxxxxxxxx が必要になるのでメモしておく。それと、MacとGoogleドライブを自動で同期できるように「Backup and Sync from Google」をインストールしておこう。

仕事や個人で使えるクラウドストレージ-Googleドライブ

スクリプトエディタでスプレッドシートからGoogleドライブへ書き出すプログラム

スプレッドシートのスクリプトエディタを使って Localizable.strings ファイルと Localize.swift をGoogleドライブに書き出していく。Localize.swiftNSLocalizedString のラッパークラスで、使い方はこの記事の最後に説明する。

スプレッドシートからGoogleドライブへ書き出すために、スクリプトエディタの設定を行う。スプレッドシートのメニューから ツールスクリプトエディタ を選択。次のコードを貼り付ける。

js
function localize() {
  var folder = DriveApp.getFolderById('xxxxxxxxxxxxxxxxxxxxxxxxxx');
  var sheetName = 'SuperMemo'
  var fileName = 'Localizable.strings'
  var jaFolder = DriveApp.getFolderById('xxxxxxxxxxxxxxxxxxxxxxxxxx');
  var enFolder = DriveApp.getFolderById('xxxxxxxxxxxxxxxxxxxxxxxxxx');
  var contentType = 'text/plain';
  var enumFileName = 'Localize.swift';
  var jsonContentType = 'application/json';
  var swiftContentType = 'text/plain';

  var localizeIdNum = 3;
  var jaNum = 4;
  var enNum = 5;
  var nl = '\n';

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

  var jaString, enString;
  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;
    }
    
    var enumStr = periodToCamel(snakeToCamel(localizeId));
    jaString += '/**' + nl + 'Localize.string(.' + enumStr + ', comment:"' + escapeString(data[i][jaNum]) + '")' + nl + '**/' + nl;
    jaString += '"' + localizeId + '" = "' + escapeString(data[i][jaNum]) + '";' + nl + nl;
    
    enString += '"' + localizeId + '" = "' + escapeString(data[i][enNum]) + '";' + nl;

    ids += "    case " + enumStr + " = \"" + localizeId + "\"\n";
  }

  var enumString = notice + "import UIKit\n\nenum Localize: String {\n" + ids + "\n    fileprivate var localized:String {\n        return NSLocalizedString(self.rawValue, comment: self.rawValue)\n    }\n\n    static func string(_ type:Localize, comment: String) -> String {\n        return type.localized\n    }\n}"
  updateFile(swiftContentType, folder, enumFileName, enumString);

  updateFile(contentType, jaFolder, fileName, jaString);
  updateFile(contentType, enFolder, fileName, enString);
}

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();
            }
    );
}
sheetName にはスプレッドシートのシート名を、 DriveApp.getFolderById('xxxxxxxxxxxxxxxx'); には、先ほどGoogleドライブでメモしたIDを入力しておく。スプレッドシートからデータを読み込んで整形し、Localize.swiftLocalizable.strings としてファイルをGoogleドライブへ書き出すプログラムとなっている。

これらのソースはこちらにあった記事を元に、自分なりに改造させてもらった。 多言語リソース管理がめんどくさいので、どうにかして楽がしたいと思った話iOS編-Qiita

書き出すプログラムの実行

それではスクリプトを実行してみよう。関数を選択 から localize を選びクリックする。

設定に間違いがなければ、しばらくするとGoogleドライブにファイルが書き出されているはずだ。いちいちスクリプトエディタを開いて書き出すのは面倒なので、スプレッドシートにボタンを設置しておくと大変便利。

ボタンの作り方は、スプレッドシートのメニュの 挿入図形描画 から適当にボタン画像を作る。 その後、スプレッドシートに挿入されたボタンを右クリックして、右上に表示される「...」をクリックするとメニューが表示されるので スクリプトを割り当て を選択して先ほど作った関数 localize を入力すればOKだ。

シェルスクリプトでファイルの更新を監視、コピーする

Googleドライブに書き出されたファイルを、Xcodeのプロジェクトへ、いちいち手作業でコピペするのはめんどくさい。

そこで、Googleドライブのファイルが更新されたら、Xcodeプロジェクトへ自動でファイルをコピーしてくれるシェルスクリプトを作ってみた。こういった細かな自動化は作業の効率アップになるのでとても重要、少し手間でもやってみよう。

bash
#!/bin/bash
cd `dirname $0`

SOURCE='/Users/xxxxxx/GoogleDrive/localize/'
DIST='/Users/xxxxxx/Projects/Xcode/memo/memo/Localize'

lastTime=0

while true
do
    tmpTime=0
    lastModifiedFile=""

    for file in `\find $SOURCE -maxdepth 2`; do
        modified=$(date -r $file '+%Y%m%d%H%M%S')
        # echo $modified
        if [ $modified -gt $tmpTime ]; then
            tmpTime=$modified
            lastModifiedFile=$file
        fi
    done
    if [ $lastTime -ne $tmpTime ]; then
        echo "更新あり"
        lastTime=$tmpTime
        cp -R $SOURCE $DIST
    fi    
    sleep 3
done

慣れないシェルスクリプトだと難解に感じるかもしれないが、プログラムでやっていることはとてもシンプルで、3秒おきにファイルの更新時間をチェックし、コピーするかしないかを判定していだけだ。SOURCEDIST はプロジェクトの環境に合わせて設定しよう。

NSLocalizedStringをラッピングするLocalize.swift

それでは最後に Localize.swift の使い方を説明する。Localize.swift は簡単に言えば NSLocalizedString を使いやすくするためのラッパークラスだ。enumで定義しているので入力補完が効く。

swift
import UIKit

enum Localize: String {
    case archiveLabelTitle = "archive.label.title"
    case generalAlertCancel = "general.alert.cancel"
    .....

    fileprivate var localized:String {
        return NSLocalizedString(self.rawValue, comment: self.rawValue)
    }

    static func string(_ type:Localize, comment: String) -> String {
        return type.localized
    }
}

使い方は次のとおり。

swift
Localize.string(.generalAlertShare, comment:"シェアする")

結局 NSLocalizedString とあまり変わらない形になってしまったが、スプレッドシートで翻訳を管理できるメリットとenumによるサジェスト効果の恩恵はたいへん大きい

あとあとコードを見返す時に何をやっているのか分からなくなるため、commentは日本語を書いておいた方が無難だろう。しかしcommentをいちいち入力するのは面倒なので、日本語の Localizable.strings に関数をコピペできるようコメントアウトで表記しておいた。

/**
Localize.string(.generalAlertShare, comment:"シェアする")
**/
"general.alert.share" = "シェアする";

.....

関連記事

最後までご覧いただきありがとうございます!

▼ 記事に関するご質問やお仕事のご相談は以下よりお願いいたします。
お問い合わせフォーム