酢ろぐ!

カレーが嫌いなスマートフォンアプリプログラマのブログ。

お手軽多言語対応!GoogleドキュメントのスプレッドシートからiOSとAndroidの文字列リソースを生成 (2023年10月版)

多言語対応アプリの開発は非常に大変だ。特に、アプリ内で使用される各種テキストを管理する「文字列リソース」の取り扱いについては、更新や検証に多大な労力を要する。この課題を解決するために、csv2strings というスクリプトを公開した。

csv2strings は、Googleドキュメントのスプレッドシートを利用し、iOS用の Localizable.strings と Android用の strings.xml というローカライズファイルを自動生成するRuby製のツールだ。

github.com

2019年7月、前回のブログ記事で、同様のツールを紹介した。しかし、旧バージョンのツールはSwiftを使っているため、コードの変更ごとにコンパイルが必要で、編集作業が煩雑だった。

新しく公開したRuby版の利点は、コードの修正が容易であること、そしてmacOSだけでなく他のOSでも動作する点にある。特にCIサービスでは、Androidアプリの開発環境としてよくUbuntuが使用されるが、以前のmacOS専用のバイナリ形式はこの環境では動かなかった。

iOSアプリ開発に不可欠なライブラリ管理ツール「CocoaPods」はRubyで動作するため、多くの開発者は既にRuby環境を構築済みである。Ubuntu上でもRubyは標準的に利用できるため、追加で特別なインストールを行う必要はない。

文字列リソースの自動生成の背景

そもそも文字列リソースの自動生成を始めた背景としては、あるプロジェクトで対応する言語が急に7つに増えた経験にある。

もともと日本語のみ対応のアプリだったものが、国際市場に進出するため、英語、韓国語、中国語(繁体・簡体)、ドイツ語、イタリア語、フランス語にも対応することになった。

それまでは、Localizable.stringsstrings.xml の作成を手動で行っていたが、それらの文言を外部の翻訳サービスに依頼し、エクセルシートで受け取った後、一つ一つ手作業でコピー&ペーストする作業は、効率が悪くミスも多発していた。

特に英語以外の言語での区別が難しく、コピペミスが起こりやすい状況だったため、チームでアクセス可能なGoogleドキュメントのスプレッドシートに情報を集約し、そこからiOS/Androidのローカライズファイルを自動生成する方法を導入した。

ツールの使用方法

「csv2strings」は、Google Docsのスプレッドシートに記入された情報をcsv形式でダウンロードし、それを基に Localizable.stringsstrings.xml を生成する。機械的に実行されるため、作業の効率化に寄与しエラーを減らすことができる。

具体的な使用例を以下に示す。Google Docsに更新されたテキストを含むcsvファイルをダウンロードし、それを使用してローカライズファイルを生成、そしてアプリの対応するディレクトリにそれらのファイルをコピーしている。

# Google Docsからcsvをダウンロードする
curl -L "https://docs.google.com/spreadsheets/{GUID}&output=csv" -o "${ENV_RESOURCES_DIR}strings/string.csv"
sleep 5

# csvから文字列リソースを生成
ruby csv2strings "${ENV_RESOURCES_DIR}strings/string.csv" "${ENV_RESOURCES_DIR}strings/"

# アプリへ展開
cp $ENV_RESOURCES_DIR/strings/ios/ja/Localizable.strings $PROJECT_DIR/ios/ptcgnote/Resource/ja.lproj/Localizable.strings

このスクリプトを使用することで、文言の変更や多言語対応が機械的に、かつ効率的に行えるようになり、アプリ開発のプロセスが大幅に簡素化できた。

関連記事

過去の投稿で紹介したcsv2stringsの初期バージョン。

blog.ch3cooh.jp

RevenueCatで `The receipt is not valid.` エラーが発生して、共有シークレットを更新してもエラーが解決できなかった

アプリ開発界隈で、課金管理をスムーズに行うために多くの開発者が利用する「RevenueCat」だが、導入する際に「The receipt is not valid.(レシートが無効です)」というエラーが発生する。

これは大抵の場合、「アプリ固有の共有シークレット」が間違って設定されているか、設定自体がなされていないことが原因である。

SwiftyStoreKitからRevenueCatへの移行の経緯

現在、SwiftyStoreKitからRevenueCatへの移行作業に取り組んでいる。

Xcode 14.0でのSwiftyStoreKitが動かなくなる問題で苦労したため、新規の案件ではSwiftyStoreKitを使うのを避けている。Swift Concurrency に対応したStoreKit 2や、StoreKit 1をラッピングしたRevenueCatなどのサービスを利用している。今回はRevenueCatを選択した。

RevenueCatを使っていて嬉しい理由として、コードが非常にシンプルであることだ。以下にサブスクリプションの復元処理(リストア)の一例を挙げる。

private func restoreSubscription() async -> Result<Void, Error> {
    do {
        let purchaserInfo = try await Purchases.shared.restorePurchases()
        if let entitlementInfo = purchaserInfo.entitlements.all["premium"] {
            if entitlementInfo.isActive {
                logger.debug("課金中: \(String(describing: entitlementInfo.expirationDate)) まで")
            } else {
                logger.debug("レシートはあるが有効期間切れで非課金")
            }
        } else {
            logger.debug("レシートがないので非課金")
        }
        return .success(())
    } catch {
        return .failure(error)
    }
}

The receipt is not valid.エラーと解決方法

しかし、復元処理中にThe receipt is not valid.というエラーが発生し、解決までに少々手間取った。この問題は「アプリ固有の共有シークレット」が適切に設定されていないときに起こることが多い。

共有シークレットの設定については、RevenueCatの公式ドキュメントで詳細に説明されている。適切に設定し直したはずなのにエラーが解消されない場合もある。

www.revenuecat.com

Chromeで設定が上手くいかないケース

調査したところ、Chromeを使用してRevenueCatの設定を更新すると、変更が正しく保存されないという問題を発見した。

もしThe receipt is not valid.エラーが続いている場合、ブラウザをSafariに変えて設定を更新すると良いかもしれない。

関連記事

zenn.dev

`bundle exec pod update` 実行時に `bundler: failed to load command: pod` エラーが発生する

2023年10月7日の activesupport のアップデートによって CocoaPods が使えなくなった。すぐに修正されると思うが、直近で困ってる方のために備忘録を残す。

bundler: failed to load command: pod エラーが発生する

bundle update のあと、 bundle exec pod update の実行時に、bundler: failed to load command: pod と CocoaPods の読み込みに失敗するエラーが発生した。

$ bundle exec pod update                        
bundler: failed to load command: pod (/PATH/works/ptcgnote_app/ptcgnote/vendor/bundle/ruby/2.7.0/bin/pod)
Traceback (most recent call last):
    24: from /PATH/.rbenv/versions/2.7.4/bin/bundle:25:in `<main>'
  23: from /PATH/.rbenv/versions/2.7.4/bin/bundle:25:in `load'
   22: from /PATH/.rbenv/versions/2.7.4/lib/ruby/gems/2.7.0/gems/bundler-2.4.20/exe/bundle:29:in `<top (required)>'
  21: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/bundler/friendly_errors.rb:117:in `with_friendly_errors'
    20: from /PATH/.rbenv/versions/2.7.4/lib/ruby/gems/2.7.0/gems/bundler-2.4.20/exe/bundle:37:in `block in <top (required)>'
  19: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/bundler/cli.rb:28:in `start'
   18: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/bundler/vendor/thor/lib/thor/base.rb:485:in `start'
  17: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/bundler/cli.rb:34:in `dispatch'
    16: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/bundler/vendor/thor/lib/thor.rb:392:in `dispatch'
  15: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/bundler/vendor/thor/lib/thor/invocation.rb:127:in `invoke_command'
   14: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/bundler/vendor/thor/lib/thor/command.rb:27:in `run'
  13: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/bundler/cli.rb:492:in `exec'
    12: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/bundler/cli/exec.rb:23:in `run'
  11: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/bundler/cli/exec.rb:58:in `kernel_load'
   10: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/bundler/cli/exec.rb:58:in `load'
   9: from /PATH/works/ptcgnote_app/ptcgnote/vendor/bundle/ruby/2.7.0/bin/pod:25:in `<top (required)>'
     8: from /PATH/works/ptcgnote_app/ptcgnote/vendor/bundle/ruby/2.7.0/bin/pod:25:in `load'
   7: from /PATH/works/ptcgnote_app/ptcgnote/vendor/bundle/ruby/2.7.0/gems/cocoapods-1.13.0/bin/pod:36:in `<top (required)>'
    6: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/rubygems/core_ext/kernel_require.rb:38:in `require'
   5: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/rubygems/core_ext/kernel_require.rb:38:in `require'
     4: from /PATH/works/ptcgnote_app/ptcgnote/vendor/bundle/ruby/2.7.0/gems/cocoapods-1.13.0/lib/cocoapods.rb:9:in `<top (required)>'
   3: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/rubygems/core_ext/kernel_require.rb:38:in `require'
    2: from /PATH/.rbenv/versions/2.7.4/lib/ruby/site_ruby/2.7.0/rubygems/core_ext/kernel_require.rb:38:in `require'
   1: from /PATH/works/ptcgnote_app/ptcgnote/vendor/bundle/ruby/2.7.0/gems/activesupport-7.1.0/lib/active_support/core_ext/array/conversions.rb:8:in `<top (required)>'
/PATH/works/ptcgnote_app/ptcgnote/vendor/bundle/ruby/2.7.0/gems/activesupport-7.1.0/lib/active_support/core_ext/array/conversions.rb:108:in `<class:Array>': undefined method `deprecator' for ActiveSupport:Module (NoMethodError)
Did you mean?  deprecate_constant

エラーメッセージの内容は完全には理解できないが、ActiveSupport ライブラリ内で deprecator というメソッドが未定義であることが記載されている。 deprecatordeprecate_constant に名前が変更されたのかな?

解決方法

Gemfile.lock を確認したところ、activesupport のバージョンが v7.0.8 から v7.1.0 にアップデートされていることを確認した。このアップデートが破壊的な変更を含んでいるのであろう。

---    activesupport (7.0.8)
+++    activesupport (7.1.0)

問題発生の原因がわかった。影響範囲が広い(どの環境でも発生しうる)問題のため、そのうち CocoaPods 側でも対応してくれることだろう。

暫定的な対策としては、Gemfile を編集して activesupport のバージョンを 7.0.8 で固定すると、問題なく CocoaPods が使えるようになる。

source "https://rubygems.org"

gem 'cocoapods'
gem 'fastlane'
gem 'activesupport', '= 7.0.8'

Gemfile を弄れない環境の場合、Bundler の更新は止めておいた方が良さそうだ。

Xcode 15.0 アップデート後、R.swift の ColorResource や ImageResource の extension でエラーが発生する

さくさんは Assets.xcassets や Colors.xcassets にアクセスするために R.swift を愛用している。Xcode 15.0 アップデート後、R.swift の ColorResource や ImageResource の extension でエラーが発生するようになった。

R.swift の ColorResource や ImageResource の extension でエラーが発生する

エラーメッセージには 'name' is inaccessible due to 'fileprivate' protection level と書かれている。

エラーが発生している extension は以下の通りである。

extension ColorResource {
    var color: Color {
        Color(name)
    }
}

該当のIssueによると、Xcode 15の生成するColorResourceがコンフリクトを引き起こしエラーが発生してしまう。したがって、以下の手順でRswiftResources.ColorResourceの拡張を明示的に指定することで、エラーが解消される。

extension RswiftResources.ColorResource {
    var color: Color {
        Color(name)
    }
}

関連記事

Xcode 15.0 アップデート後、CocoaPods経由でインストールした Firebase Apple SDK でコンパイルエラーが発生する

Xcode 15.0へのアップデート後、CocoaPods経由でインストールしたFirebase Apple SDKでコンパイルエラーが発生する。

解決方法

CocoaPods 1.13.0 のリリースされると、この問題は解消される見込みである。ただし v1.13.0がリリースされるまでの間は、ワークアラウンドを利用しなければいけない。Podfileを開き、以下のワークアラウンドのコードを追加する。

post_install do |installer|
  # CocoaPods v1.13.0 がリリースされるまでのワークアラウンド
  installer.pods_project.targets.each do |target|
      target.build_configurations.each do |config|
      xcconfig_path = config.base_configuration_reference.real_path
      xcconfig = File.read(xcconfig_path)
      xcconfig_mod = xcconfig.gsub(/DT_TOOLCHAIN_DIR/, "TOOLCHAIN_DIR")
      File.open(xcconfig_path, "w") { |file| file << xcconfig_mod }
      end
  end
  
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0'
    end
  end
end

関連記事

Cloud Firestore の Collection references は偶数個のセグメントだと例外が発生する

長いあいだ、Swift でのデータ操作が煩雑な Cloud Firestore を使う気になれなかったのだが、いつのまにか Swift Concurrency に対応していた。async / await が使えると話が変わってくる。格段に使いやすくなっていたので、お試しで簡単なアプリを作ってみることにした。

しかし、Firestore へデータを書き込もうとしたところ例外が発生してしまった。

Terminating app due to uncaught exception 'FIRInvalidArgumentException', reason: 'Invalid collection reference. Collection references must have an odd number of segments, but version/1/z5NXjW6CTFdHjlNjsrWQ4XBxcvg2/items has 4'

「無効なコレクション参照です。コレクション参照には奇数のセグメントが必要です。」と言われても、どういうことかわからない…… なぜ偶数なら大丈夫なのか?

まとめ

先に答えを書いておく。

  • Firestore のパス設計は collection/document/collection/document としなければいけない
  • よってコレクションのリファレンスは、奇数のセグメントでなければいけない
    • 例:/version/1/users など
  • よってドキュメントのリファレンスは、偶数のセグメントでなければいけない
    • 例:/version/1 など
  • それ以外の場合、例外 FIRInvalidArgumentException が発生する

経緯

Firestore にデータを保存するため、以下のようなコードを書いた。

let documentRef = firestore.collection("version/1/\(userId)/items").document(item.id)
try await documentRef.setData(
    [
        "title": item.title,
        "create_at": item.createdAt,
        "update_at": item.updatedAt,
    ]
)

このエラーに関する情報は日本語では「【Swift】FireStoreの機能を使用すると、"Invalid document reference.."のエラー」でしか取り上げられていなかったが、回答としては db.collection("User").document(userDataClass.userID).getDocument()db.collection("User").getDocument()に変更したら動くようになったという曖昧な理由であった。

理屈がわからなかったため調べたところ、Firestoreのドキュメントのパス設計は collection/document/collection/document という交互のパターンにしなければいけない。よって、ドキュメントのリファレンスは必ず偶数のセグメント、コレクションのリファレンスは必ず奇数のセグメントを持つことになるため、

  • version/1/\(userId)/items/\(itemId) は、 collection/d/d/collection/d になっていしまうため例外が発生する
  • version/1/users/\(userId)/items/\(itemId) は、 collection/d/collection/d/collection/d なので問題なく書き込みが成功する

まとめに書いたように Firestore にはパス設計が存在するため注意すること。

iOSユニットテストでテスト専用リソースの組み込みと利用方法

たとえば画像識別アプリを開発中には、テスト画像を Bundle からロードして、画像を識別してその結果を評価するユニットテストを作成する。または、HTMLをスクレイピングするアプリを開発中には、ダミーのHTMLを Bundle からロードして、スクレイピング処理が正しく動いているか評価するユニットテストを作成する。

しかし、これらのテスト用のリソースをアプリの本番バイナリには含めたくない。そこで、テスト時だけテスト用のリソースをBundleに組み込むというニーズが生じる。

この記事では、テスト時にのみ利用可能なリソースをどのようにして追加して利用するのかを紹介する。ここでの login.html は、テストバイナリに組み込まれた ダミーの HTML ファイルである。

テスト時のみ利用可能なようにリソースを追加する

テストターゲット XXXXTests フォルダの配下に login.html を追加する。

login.html の Target Membership は、ユニットテストで利用する XXXXTests にのみチェックをつける。

コードの例

テストバンドルの識別子を用いて Bundle オブジェクトを取得し、login.html ファイルにアクセスする。テストバイナリの Bundle IDがjp.ch3cooh.AppTestslogin.html を読み取りたい場合、以下のようなコードとなる。

let bundle = Bundle(identifier: "jp.ch3cooh.AppTests")
let url = bundle?.url(forResource: "login", withExtension: "html")
return try! Data(contentsOf: url!)

まとめ

この方法を使用することで、テスト時のみ特定のリソースにアクセスすることが可能になる。テストの際のモックデータや、テスト画像の読み込みに利用することができる。