繁体字対応済みのアプリに簡体字を足す ― 怖くて一度やめかけた話

目次

「中国語対応」は1回では終わらなかった

少し前に、自分の月謝管理アプリを繁体字中国語(台湾・香港向け)に対応させた。無事リリースできて、台湾のストアで公開もされた。

それで「中国語対応」は終わったつもりでいた。でも違った。

繁体字でカバーできるのは台湾・香港。でも 中国本土・シンガポール・マレーシアの華人社会は簡体字を使う。同じ「中国語」でも、文字体系が違う。教室の先生向けのアプリを作っている身としては、シンガポールやマレーシアの音楽教室・武術教室の老師にも届けたい。

だから今度は簡体字対応に挑戦することにした。「繁体字ができたんだから簡体字も同じでしょ」と、軽く考えていた。これが、思ったよりずっと怖い作業だった。


最初の恐怖:「上書きしたら台湾版が壊れるのでは?」

簡体字対応を始めて、いきなり不安にぶつかった。

私のアプリの言語ファイル(Flutter の ARB ファイル)は、こういう構成になっていた:

app_ja.arb        日本語
app_en.arb        英語
app_es.arb        スペイン語
app_ko.arb        韓国語
app_zh.arb        中国語(中身は繁体字)
app_zh_Hant.arb   繁体字

ここで気づいた。簡体字版を作るとき、app_zh.arb(中身が繁体字)をどうすればいいのか。これを簡体字で上書きしたら、 すでに公開して台湾の人が使っている繁体字版が壊れるんじゃないか?

正直、ここで一度「もう簡体字はやめよう」と思った。「衝突するなら、フランス語とか、他の被らない言語にしよう」と。まだほとんど作業していなかったので、引き返すなら今だ、と。

実際、引き返す判断そのものは悪くないと思う。「壊れるかもしれない道」より「安全な道」を選ぶのは、エンジニアとして正しい。でも今回は、 本当に壊れるのかを確認してから決めるべきだった。


確認したら「足し算」だった

不安なまま進むのは危険なので、まず現状を「見える化」することにした。

Flutter の自動生成ファイル(app_localizations.dart)を grep で覗いてみた。すると、言語を振り分けるロジックがこうなっていた:

switch (locale.languageCode) {
  case 'zh':
    switch (locale.scriptCode) {
      case 'Hant':
        return AppLocalizationsZhHant();   // 繁体字
    }
    break;
}
// scriptCode がなければ languageCode だけで判定
switch (locale.languageCode) {
  case 'zh':
    return AppLocalizationsZh();  // zh デフォルト
}

これを読んで、ようやく安心できた。scriptCode(Hant=繁体字 / Hans=簡体字)で ちゃんと区別する作りになっていたのだ。

簡体字の ARB を足して再生成すると、ここに case 'Hans': が追加されるだけ。case 'Hant':(繁体字)の行は1文字も変わらない。

台湾ユーザー(zh-Hant) → scriptCode='Hant' → 繁体字(変化なし)
中国ユーザー(zh-Hans) → scriptCode='Hans' → 簡体字(新規追加)

つまり「上書きで壊れる」のではなく「足し算で増えるだけ」だった。怖がっていた衝突は、確認したら存在しなかった。

ここで学んだのは、 「怖い」と「危険」は違うということ。怖いのは、仕組みを知らないから。仕組みを確認すれば、怖さは消える。確認もせずに「フランス語にしよう」と逃げなくて本当によかった。


罠その1:languageCode では繁簡を区別できない

安心して進め始めたら、次の罠にハマった。

通貨表示のコードに、繁体字対応のとき書いた分岐があった:

switch (languageCode) {
  case 'zh':
    return 'NT\$${...}';   // 台湾ドル
}

ここで気づいた。languageCode は、繁体字も簡体字も両方 'zh'。区別がつかない。

このままだと、中国・シンガポール・マレーシアの先生にも「NT$(台湾ドル)」が表示されてしまう。シンガポールの先生が学費を記録したのに台湾ドル表記、では困る。

解決策は、languageCode ではなく scriptCode で分けること:

case 'zh':
  if (locale.scriptCode == 'Hant') {
    return 'NT\$${...}';   // 繁体字=台湾、今まで通り
  }
  return ...;              // 簡体字=別の表示

scriptCode の存在を、この時はっきり意識した。「言語」と「文字体系」は別物。日本語しか普段使わないと、この感覚はなかなか身につかない。


罠その2:分岐が3箇所バラバラに必要だった

「scriptCode で分ければいいんだ」と分かったものの、 その分岐が必要な場所が、コードのあちこちに散らばっていた。

  1. UI翻訳(ARBファイル) ― 設定画面の「设置 / 設定」など
  2. 通貨表示(_formatPrice) ― NT$ を出すか数字だけにするか
  3. サンプルデータ(getSampleStudents) ― デモ画面の生徒名(陈俊宏 / 陳俊宏)

それぞれ別のファイル、別の関数。1箇所直して「終わった」と思ったら、まだ2箇所残っている。grep で1つずつ探して、呼び出し元まで遡って、引数に scriptCode を渡すように配線し直す。地味で、神経を使う作業だった。

特にサンプルデータは、getSampleStudents → forLocale → 呼び出し元5ファイル、と数珠つなぎになっていて、 5ファイルすべてに同じ修正を入れる必要があった。1つでも漏れると、台湾の先生のデモ画面に簡体字の名前が出てしまう。


罠その3:デモと本番で通貨の扱いが違った

これは自分で気づけて、危なかったところ。

デモ画面の通貨を「簡体字は数字のみ」に直して満足していたとき、ふと思った。「デモ画面は直したけど、 実際に先生が使う本番のスタンプ画面はどうなってるんだっけ?」

確認したら、本番は全然違う仕組みだった。デモは手書きで「NT$」と決めていたが、本番は NumberFormat.currency という、ロケールから通貨を自動判定する仕組みを使っていた。

そして思い出した。前にスペイン語対応したとき、メキシコ・アルゼンチンのユーザーで通貨が自動で切り替わるようにしていた。つまり本番は、最初から各国通貨に対応する正しい設計だったのだ。

ここで学んだのは、 「デモを直したから本番も大丈夫」とは限らないということ。役割が違う画面は、実装も違う。自分のアプリでも、隅々まで把握できているわけじゃない。「あれ、ここはどうなってる?」と立ち止まる癖が、こういうとき効く。


罠その4:字体変換じゃ足りない

簡体字 ARB を作るとき、最初は「繁体字を字体変換するだけ」と思っていた。でも違った。

繁体字と簡体字は、単に字の形が違うだけじゃない。 単語そのものが違うことがある:

意味繁体字簡体字
カレンダー行事曆日历
エクスポート匯出导出
データ資料数据
ソフトウェア軟體软件

「行事曆」を機械的に字体変換すると「行事历」になる。でも中国語ネイティブにとって自然なのは「日历」。字体変換ツールでは、ここは正しく直らない。意味を理解して訳す必要があった。


まとめ:怖かったけど、確認すれば足し算だった

繁体字対応済みのアプリに簡体字を足す。終わってみれば、要点はシンプルだった:

  • 怖さの正体は「仕組みを知らないこと」。確認すれば、衝突ではなく足し算だと分かる
  • languageCode では繁簡を区別できない。scriptCode(Hant/Hans)で分ける
  • 分岐は1箇所じゃない。UI・通貨・データ、それぞれに必要。grep で全部洗い出す
  • デモと本番は別実装のことがある。「ここはどうなってる?」と立ち止まる
  • 字体変換じゃ足りない。単語ごと変わる箇所は意味で訳す

一番伝えたいのは、最初の「やめてフランス語にしよう」のところ。あのとき逃げずに、 怖さの正体を確認してから判断したのがよかった。確認したら、怖くなくなった。

多言語対応は、言語が増えるほど「既存を壊さないか」が怖くなる。でも、たいていは正しく作られていれば足し算で済む。怖いときこそ、逃げる前に grep して仕組みを見る。それだけで、進めるかどうかが冷静に判断できる。

簡体字版は今、App Store と Google Play で審査中。シンガポールやマレーシアの教室の先生に届くと思うと、怖さを乗り越えてよかったと思う。


月謝袋アプリ ― 教室の先生のための学費管理アプリ
SINQWELL
GitHub: sinqwell-app/monthly-fee-stamp-app

目次