Gadeninアプリを入れる

設計判断読了 14

Gadeninのグローバル対応 (1) — iOSアプリの英語対応は「翻訳」ではなかった話

Gadenin APP 1.3.0 を審査に出した。目玉は英語対応だ。AI に英訳させれば終わりだと思っていたが、重かったのはテーブル設計と、地域という概念の作り直しと、法律まわりだった。

シリーズ「Gadeninを世界へ」 第 1 回: iOSアプリ の英語対応 (この記事) 第 2 回: WEB の英語対応 — SEO 設計から始まる

Gadenin APP 1.3.0 を App Store の審査に提出した。

今回の 1.3.0 で大きく取り組んだのが、Gadenin の英語対応だ。

これまでは日本国内の家庭菜園ユーザーを中心に考えて開発してきた。でも、植物を育て、写真を撮り、水やりや施肥、病害虫、収穫を記録するという体験そのものは、日本に限ったものではない。

だったら Gadenin を海外でも使えるようにしたい。なんなら個人的には海外ユーザーがどうやって自分と同じミニトマトを栽培している様子もみたい。そんなところから英語対応を始めた。

ただ、実際にやってみると、APP を英語対応する難しさと、WEB を英語対応する難しさはかなり違った

今回はまず APP の話を書く。WEB の話は次回にする。


翻訳すればいいのでは、と思っていた

最初に取り組んだのは iOS アプリだ。

一見すると、

日本語の文言を英語に翻訳すればいいのでは?

と思う。

もちろん UI の翻訳は必要だ。「植物を追加する」「栽培記録を追加する」「水やり」「施肥」「収穫」といった画面上の文言を英語化していく。

しかし、実際にはそれだけでは終わらなかった。

UI の文言は 1,295 キーぶんある。これを ja / en / ko の 3 ロケールで持つようにした。ko は今のところ空文字の受け皿で、選択肢にも出していない。将来やるときに器だけ用意してある状態だ。

この 1,295 キーの英訳自体は、AI にやらせて問題なかった。むしろ人間が 1 つずつ訳すより早いし、揺れも少ない。

問題はその外側にあった。

マスタデータをどうするか — 特に品種

Gadenin には「作物」「病害虫」「症状」「品種」など、アプリが持っている大量のマスタデータがある。

UI を英語にしても、

Tomato

おすすめ品種
・桃太郎
・麗夏
・アイコ
...

のような状態では、本当の意味で海外向けになったとは言えない。

特に難しかったのが品種だ。

現在 Gadenin に登録されている品種マスタは、日本国内で流通しているものが中心だ。5,000 件以上ある。日本では当たり前の品種でも、海外ではほとんど流通していなかったり、逆に海外では非常に有名なのに Gadenin には登録されていない品種が大量にある。

これは「翻訳」の問題ではない。データそのものを国際化する必要があるという話だ。

今回はまず Gadenin を英語で利用できるところまでを優先し、海外の品種マスタを網羅するところまでは行わなかった。海外品種については、今後 Gadenin の利用地域を広げながら少しずつ整備していく予定だ。

その代わりに、割り切った設計判断をした。後で書く。

日本語ユーザーの表示が 1 文字も変わっていないことを、どう保証するか

多言語化でいちばん怖いのは、既存の日本語ユーザーの見え方を壊すことだ。文言をソースコードから JSON へ移す作業は、機械的に見えて事故が起きやすい。

そこで、日本語の表示文言を全部スナップショットとして採る仕組みを作った。

  • ソースコードの中の日本語の文字列リテラル (コメントは除く)
  • JSX のテキストノードのうち日本語を含むもの
  • ja の JSON の値

この 3 つの和集合を正規化して並べたものを基準にする。文言をソースから JSON へ移しただけなら和集合は変わらないので、スナップショットは動かない。動いたら「文言そのものが変わった」ということなので、回帰の疑いとして扱う。

これを CI に入れた。おかげで移行中ずっと「日本語は無傷」と言い切れた。実際、途中で 1 度だけ差分が出て、それは意図した削除だった。意図しない差分は最後まで 0 件だった。

この仕組みを作るのに時間はかかったが、これが無ければ怖くて進められなかったと思っている。

ついでに決めたのが、ドメイン層とアプリケーション層は翻訳を知らないというルールだ。これらの層は「エラーコード」を返し、表示層がそのコードを文言に変換する。ESLint のルールで、ドメイン層から i18n のモジュールを import できないようにした。

ビジネスルールが「日本語の文字列」を持ってしまうと、そのルール自体が言語に縛られる。DDD をやっている以上、ここは譲れなかった。

テーブル設計 — 既存のデータを 1 バイトも壊さずに列を足す

ここがいちばん神経を使った。

Gadenin のバックエンドは Cloudflare Workers + D1 で、Gadenin for WEB も同じデータベースを読んでいる。作物図鑑 436 ページと病害虫辞典 98 ページは、この DB から生成されている。

つまり、テーブルを触ると WEB が壊れる。

自分に課した制約は 2 つ。

  1. 加法的な変更しかしない。 既存列の削除・改名・意味変更は 0 件
  2. locale を指定しない応答は 1 バイトも変えない。 キーの有無・値・型・並び順まで現行と同一

2 つ目が効いた。WEB は Accept-Language?locale= も送らずに API を呼んでいる。応答の形が変われば、そのまま本番の公開ページが壊れる。

これを口約束にしないために、契約テストを書いた。

移行前のコードから、公開 API の応答を全部バイト列で採ってフィクスチャに固める。実装後、locale 無指定で同じリクエストを投げて、JSON.stringify の文字列比較で照合する。toEqual は使わない。キーの並び順を見ないからだ。

さらに、公開 GET ルートにフィクスチャが無ければテストが落ちるようにした。新しいルートを足した人が契約テストに載せ忘れることを防ぐためだ。

もう 1 つ、「借家」という考え方を入れた。作物マスターに英名の列を足したのだが、この表はもともと別のワークストリームが持っているものだ。勝手に育てると、あとで衝突する。

そこで、列は作物マスターにあるが、書き込むのは多言語対応の 1 つの関数だけとした。それ以外の場所から書かないことを、ソースを走査する静的なテストで機械的に守る。

人間の約束ではなく、テストで守る。これは後から効いてくると思っている。

「都道府県」を「国 + 行政区画」に作り直す

機能面でいちばん大きかったのがこれだ。

Gadenin には「栽培地域」という設定がある。近くの人の栽培記録を見つけたり、天気を出したりするのに使う。これがずっと都道府県 1 段だった。日本しか想定していなかったから当然だ。

海外に出すなら、ここを「国 + 行政区画」の 2 段にしないといけない。

ただし、日本のユーザーにとっては今までどおり「都道府県を選ぶモーダル」であってほしい。47 都道府県の並び順も、表示名も、保存される値も、移行前と 1 文字も変えない。これは最初に決めた。海外対応のために国内のユーザーが不便になるのは本末転倒だ。

なので、国が日本のときは国の段を畳んで、現行と同じ 1 段リストに見えるようにした。国を変えたいときだけ国の行をタップして 2 段目を開く。

行政区画の呼び名は国によって違う

ここで気づいたことがある。「都道府県」に相当する呼び名が、国によってまるで違う。

  • アメリカは State
  • イギリスは Region
  • 韓国は Province / Metropolitan City

アプリ側に対応表を持たせると、国を足すたびにアプリを更新することになる。なのでサーバーが subdivision_label として返し、アプリはそれをそのまま表示する形にした。

見出しも「📍 {label}を選択」というテンプレートにしてある。日本では label が「都道府県」なので、結果として移行前と一字一句同じ「📍 都道府県を選択」になる。

気候帯という概念

地域を国 + 行政区画にすると、栽培カレンダーの扱いが問題になる。日本の関東基準のカレンダーを、そのままオーストラリアの人に見せるわけにはいかない。南半球では季節が逆だ。

そこで気候帯という概念を入れた。北半球の温帯・暖地、南半球の温帯・暖地、それと日本の関東。5 つだ。国と行政区画から気候帯を引いて、その気候帯のカレンダーを出す。

日本のカレンダーの正本は既存の列のままにして、新しい表は触らない。ここでも加法的にした。

決まらなければ出さない、というルールも入れた。地域が未設定のときに日本の値を既定にすると、半年ずれたカレンダーを出すことになる。それなら出さないほうがましだ。

設定画面の UI で 1 回やらかした

国のピッカーを作ったとき、1 枚のシートの中に「国の一覧」と「行政区画の一覧」を上下に並べてしまった。

結果、国の一覧が 2 行しか見えない狭い領域でスクロールすることになり、しかも下の行政区画リストと背景も行の形も同じなので、地続きに見えた。Austria / Australia / Don't set one / Busan … が 1 本のリストに見える状態だ。

1 枚のシートで 2 つのリストが面を取り合っていたのが原因だった。国の選択を別ページに分けて、1 度に描くリストを常に 1 つにしたら解決した。

こういうのは実機で見ないと分からない。テストは全部通っていた。

品種は英語ユーザーに出さない、という判断

さっきの品種問題に戻る。

英語ユーザーがトマトを登録したとき、日本の品種一覧を出しても意味がない。読めないし、手に入らない。

なので、非日本語では作物は英名だけを出し、品種名は出さないことにした。品種は自由記入にする。日本人が「アイコ」と登録した Plant を海外のユーザーが見たときも、作物名の英名だけが出て、品種名は出ない。

英名が無い作物のときは、日本語名にも学名にもフォールバックしない。何も出さない。読めないものを出すくらいなら空欄のほうがいい、と判断した。

割り切りではあるが、中途半端に日本語を混ぜるより誠実だと思っている。

課金をグローバル対応する

ここは思ったより素直だった。ただ 1 つだけ規律を決めた。

アプリ内にも法務文書にも、通貨と金額を 1 つも書かない。

価格は RevenueCat が返す priceString だけを情報源にする。ストアが表示する現地通貨・現地価格が正だ。ドキュメントにも書かない。テストで「本文に ¥$ や数字の価格が出てこないこと」を固定した。

日本円で書いてしまうと、アメリカのユーザーに嘘を表示することになる。しかも為替でずれる。だったら最初から書かないほうがいい。

法律まわり — ここがいちばん重かった

正直、ここを甘く見ていた。

翻訳ではなく、別の文書として書く

プライバシーポリシーと利用規約は、英語版を翻訳で作ってはいけない。GDPR と CCPA が求める開示が、日本語版には入っていないからだ。

  • データ主体の権利 (アクセス・訂正・削除・ポータビリティ・処理の制限・異議)
  • 越境移転
  • 端末に何を保存しているか
  • 削除請求の窓口
  • 「個人情報を販売しない」の明示

これらを英語版の側で持つ必要がある。だから英語版は独立した文書として書いた。バージョンも日本語版と別に進む。

書いてあることが実装と違っていた

そして、ここでいちばん効いたのが実装との照合だった。

書いた英語版を、実装と 1 つずつ突き合わせた。結果、不一致が 13 件、裏が取れないものが 10 件出てきた。

主なものを挙げる。

  • 「認証トークンは Keychain / Keystore に保存」と書いてあったが、実際は AsyncStorage に平文の JSON だった。しかも expo-secure-store は依存にすら入っていなかった
  • 「メールアドレスを保存する」と書いてあったが、実際は保存していない。一方で Apple には EMAIL スコープを要求していた。「要求して受け取って捨てている」が実態だった
  • 「位置情報は端末内で完結する」と書いてあったが、逆ジオコーディングで OS (Apple / Google) に座標が渡っていた
  • 「バックアップは最大 30 日」と書いてあったが、その根拠がリポジトリのどこにも無かった。バックアップの実装も定期実行も 0 件
  • 「サポート資料は返信に必要な期間だけ保持」と書いてあったが、問い合わせデータに削除経路が存在しなかった。アカウントを削除しても消えない
  • 「言ってもらえれば計測を止める」と書いてあったが、停止するトグルもユーザー別の抑止も実装が無かった

これは全部、こちらが「そうあるべき」と思って書いた文章だった。実装を見ずに書くと、こうなる。

実装を直すか、文章を直すか

ここで判断が要る。全部が「文章を直せばいい」話ではない。

トークンの平文保存は、実装を直した。「Gadenin は暗号化していません」と書いて出すのは違うと思ったからだ。expo-secure-store を入れて Keychain / Keystore に移した。既存ユーザーがサインアウトされないよう、旧保存先から読んで移し替える経路も付けた。

一方で、問い合わせデータの削除経路と analytics の停止は、今回は実装せず、実態どおりに書いた。「消えません」「止められません」と書くのは気が引けるが、履行できない約束を書くほうが悪い。

SCC や十分性認定といった法的な仕組みへの言及は、裏が取れなかったので落とした。各社と DPA を結んでいるかを確認できていない以上、書けない。書けたのは検証できた事実だけだ。日本から運営していること、PostHog は EU ホストであること、Sentry はドイツリージョンであること。この 2 つは設定を実測して確認した。

一人運用の免責

これも書いておきたい。

会社員をやりながら 1 人で運用しているので、背負える義務には限界がある

書いた文書を読み直したら、責任制限の条項が 1 行も無かった。損害賠償の上限も、無保証の条項も無い。一方で「言ってもらえれば止めます」「全世界のどの国の人の請求にも応じます」といった約束は入っていた。

なので、こう直した。

  • 現状有姿での提供と、明示・黙示の無保証
  • 賠償額の上限を、直近 12 か月に実際に支払われた購読料の額とする
  • 間接損害・逸失利益・データ消失の除外
  • 投稿を公開前に審査しないこと、常時監視の義務を負わないこと
  • サービスを終了できること

ただし、全部免責にはしなかった。日本の消費者契約法では、事業者の故意・重過失による損害を全部免責する条項は無効になる。全部を免れようとすると、条項ごと効かなくなる。なので「故意または重大な過失による損害には、上記の免責および上限を適用しない」を残した。

GDPR の 1 か月の応答期限も、特商法の開示義務も、消費者の法定の権利も、削っていない。ここは削れない。

守るべきものは守って、背負わなくていいものは降ろす。その線引きに、いちばん頭を使った。

App Store に公開する — Localization は「日本を選ぶだけ」ではなかった

アプリ本体だけではない。App Store に掲載する、

  • アプリ名
  • サブタイトル
  • 説明文
  • キーワード
  • スクリーンショット

もローカライズする必要がある。英語圏のユーザーが App Store を開いたとき、日本語のスクリーンショットが並んでいては意味がない。

ここまでは想像していた。想像していなかったのは、この先だ。

ローカライズと配信地域は別物

App Store Connect で英語のローカライズを足しても、それだけでは新しい国には出ない。ローカライズは「どの言語で掲載文を見せるか」を決めるだけで、どの国に出るかは配信地域の設定が決めている。

これは最初、逆に理解していた。

主言語というフォールバック

さらに大事なのが主言語 (Primary Language) だ。

App Store は、そのストアフロントの言語のローカライズが無いと、主言語にフォールバックする。Gadenin の主言語は日本語だった。つまり英語のローカライズを 1 つ足しただけでは、ドイツやブラジルのユーザーには日本語の掲載文が出る。

しかも調べてみたら、Gadenin はすでに 175 の国と地域に配信されていた。日本のみだと思い込んでいたのだが、実際には App Store の既定で全世界に出ていた。掲載文が日本語だけだったから、誰の目にも留まっていなかっただけだ。

ということは、主言語を英語に変えるだけで、残り 170 か国以上の掲載文が一気に英語になる。今回いちばん効く一手がこれだった。

そして主言語が変えられない

ところが、主言語を英語に変えようとしたらエラーになった。

プライマリロケールを保存できませんでした。最初にすべての必要なスクリーンショットを、この言語の各バージョンに追加する必要があります。

英語 4 ローカライズぶんのスクリーンショットは全部入れてある。それでも通らない。

原因はこうだった。ローカライズとスクリーンショットは、アプリ単位ではなくバージョン単位で持っている。 英語のスクリーンショットを入れたのは、編集できる 1.3.0 だけだ。すでにリリース済みの 1.2.0 は凍結されていて、後から英語を足せない。

主言語の変更は「その言語のスクリーンショットが全バージョンに揃っているか」を見る。だから 1.2.0 が残っている限り通らない。

解決は 1 つしかない。1.3.0 をリリースしてから主言語を変える。 1.3.0 が配信されれば 1.2.0 は検証の対象から外れる。

これは事前に想像できなかった。実際にエラーを踏んで、旧バージョンの言語一覧を見に行って、やっと分かった。

英語は 1 つではない

もう 1 つ。App Store の英語は U.S. / U.K. / Australia / Canada の 4 つに分かれている

これは単なる綴りの違いではなく、それぞれが独立した 100 バイトのキーワード欄を持つ。つまり 4 つ登録すれば、検索に拾われる語が実質 4 倍になる。

しかも語彙が本当に違う。allotment はイギリスとオーストラリアの制度で、自治体から借りる区画のことだ。アメリカ人はまず使わない。veg patch も英国的な言い方で、北米なら backyard のほうが自然だ。

なので、アプリ名とサブタイトルは 4 つとも共通にして、キーワードだけ土地に合わせて変えた。キーワード欄はユーザーに見えないので、変えてもブランドの見え方が割れない。

ちなみに App Store の検索インデックスに入るのは、アプリ名・サブタイトル・キーワードの 3 つだけだ。説明文は入らない。これを知らずに説明文にキーワードを詰めても効かない。

ついでに言うと、ニュージーランド向けの英語ローカライズは存在しない。だから NZ は主言語にフォールバックする。こういう穴があちこちにある。

EU に出すには事業者情報を公開しないといけない

そして最後の判断がこれだった。

EU のデジタルサービス法 (DSA) により、EU で配信するにはトレーダー (事業者) かどうかを申告しないといけない。サブスクリプションを売っている以上、Gadenin はトレーダーに該当する。

問題は、トレーダーとして登録すると 氏名・住所・電話番号が EU の App Store の製品ページに公開されることだ。個人開発者の場合、自宅の住所と電話番号がそのまま公開されることになる。

バーチャルオフィスを契約すれば避けられる。月 4,000〜5,000 円くらいが相場で、電話番号まで含めるともう少しかかる。

これは今回見送ることにした。EU 27 か国を配信地域から外して、まず英語圏だけで出す。 反応を見てから、必要になったら住所を用意して EU を開ける。配信地域の変更は審査を伴わず即時に効くので、いつでも戻せる。

日本の特商法の表記にも同じ情報が要る。今の日本語版は「請求があれば遅滞なく開示します」の形になっているが、電話番号への言及が無いことに今回気づいた。ここは近いうちに直さないといけない。

こうした対応を終え、今回 Gadenin APP 1.3.0 を App Store の審査に提出した。


APP をやってみて思ったこと

英語対応は、翻訳の作業だと思っていた。実際には、自分のシステムがどれだけ日本を前提にしていたかを 1 つずつ剥がしていく作業だった。

「都道府県」は日本の概念だし、栽培カレンダーは日本の気候の話だし、品種マスタは日本の品種だし、特商法は日本の法律だ。それぞれについて「これは普遍的か、日本固有か」を判断して、固有ならどう一般化するかを決める。翻訳は、その全部が終わったあとの最後の 1 工程でしかなかった。

いちばんの収穫は、プライバシーポリシーと実装を突き合わせたことかもしれない。13 件の不一致が出てきて、そのうち 1 件はトークンを平文で保存しているという、単純にセキュリティの問題だった。多言語対応をやらなければ、たぶん気づかないままだった。

そして次は Gadenin for WEB だ。

同じ「英語対応」という言葉を使うけれど、WEB でやることは APP とはまるで違った。本文を翻訳する前に、英語ページをどの URL で公開するのかから考えないといけない。

その話は次回に書く。

関連記事

Gadenin 栽培記録アプリ・無料アプリを入れる