🔥

Google スプレッドシートを使ったFlutter/Webのローカライズ管理

2023/10/23に公開

センキャクでは、多言語対応できるようにアプリ・Webともにテキストは別管理のシートから、それぞれ言語ファイルを書き出して、コード上でそのままテキストを使わないようにしています。

今回は、Flutter・Webで利用できるローカライズ処理について説明します。

目次

  • シートの構成
  • Flutterのローカライズ設定
  • Flutter用の書き出し処理
  • Webのローカライズ設定
  • Web用の書き出し処理

シートの構成

シートは、言語のキー・言語ごとのテキスト・テキストの説明の形式で作ります。

変数,日本語,英語,説明
naviSchedule,スケジュールSchedule,navigation tab button label "Schedule"
naviHome,ホーム,Home,navigation tab button label "Home"
naviCustomer,顧客管理,Customer,navigation tab button label "Customer"
send,送信する,Send,send

このシートをもとに、Flutter用はL10n、Webはi18nに従ったファイルを生成して対応します。

Flutter用の書き出し処理

ローカライズ用のファイル

Flutterのローカライズ用ファイルは、arbの拡張子で、JSONで定義します。対応するロケールを、@@localeで定義し、プロパティ名にキー名を入れ、プロパティのないようにローカライズのテキストを設定します。@キー名は、キーに対するオプションで、説明や置き換え用のプレイスホルダーを定義します。

{
  "@@locale": "ja",
  "naviSchedule": "スケジュール",
  "@naviSchedule": {
    "description": "navigation tab button label \"Schedule\""
  },
  "naviHome": "ホーム",
  "@naviHome": {
    "description": "navigation tab button label \"Home\""
  },
  "naviCustomer": "顧客管理",
  "@naviCustomer": {
    "description": "navigation tab button label \"Customer\""
  },
  "validateErrorMaxLengthInvalid": "{max}文字以内で入力してください",
  "@validateErrorMaxLengthInvalid": {
    "description": "text max length invalid",
    "placeholders": {
      "max": {
        "type": "String",
        "example": "max"
      }
    }
  },
}

シートのダウンロード

先ほどのシートからこの形式で書き出せるように、スクリプトを用意します。JSONフォーマットになるので、取り扱いやすいように、TypeScriptで作成しています。

const url = `https://sheets.googleapis.com/v4/spreadsheets/${DOCUMENT_ID}/values/sheet`;

const loadSheet = async () => {
  const response = await fetch(url);
  const document = await response.json();

  // ファイルの書き出し
  createLocalizeFile(document.values);
}

loadSheet();

arbファイルの出力

変数を埋め込めるように、{}で囲んでいる部分を入力パラメータとして処理するようにして、文字列で埋め込めるようにしています。

const localizePath = "packages/app/lib/l10n/";

const checkInputParameter = (text: String) => {
  const matches = text.match(/\{\w+\}/g);
  if (!matches || matches.length === 0) return undefined;
  const params = matches
    .map((t) => t.match(/\w+/))
    .flatMap((a) => a) as string[];

  var place: {
    [key: string]: any;
  } = {};
  params.forEach((p) => {
    place[p] = {
      type: "String",
      example: p,
    };
  });

  return place;
};

const createLocalizeFile = (values: any) => {
  const jaLines = values
    .map((line: string[]) => {
      const [key, ja, __, description] = line;
      var value: any = {};
      value[key] = ja;
      const placeholders = checkInputParameter(ja);
      value[`@${key}`] = {
        description,
        placeholders,
      };
      return value;
    })
    .reduce((previousValue: any, currentValue: any) => {
      return { ...previousValue, ...currentValue };
    });

  const ja = { "@@locale": "ja", ...jaLines };
  fs.writeFileSync(`${localizePath}app_ja.arb`, JSON.stringify(ja));

  const enLines = values
    .map((line: string[]) => {
      const [key, __, en, description] = line;
      var value: any = {};
      value[key] = en;
      const placeholders = checkInputParameter(en);
      value[`@${key}`] = {
        description,
        placeholders,
      };
      return value;
    })
    .reduce((previousValue: any, currentValue: any) => {
      return { ...previousValue, ...currentValue };
    });

  const en = { "@@locale": "en", ...enLines };
  fs.writeFileSync(`${localizePath}app_en.arb`, JSON.stringify(en));
};

実行環境の整備

作成したTypeScriptを実行できるように、package.jsonを用意します。yarn localizationを実行すると、arbファイルを書き出されます。

{
  "name": "denmo-app",
  "private": true,
  "scripts": {
    "localization": "yarn ts-node scripts/l10n/index.ts && yarn jsonlint -i packages/app/lib/l10n/app_en.arb && yarn jsonlint -i packages/app/lib/l10n/app_ja.arb"
  },
  "dependencies": {
    "@types/node-fetch": "^2",
    "node-fetch": "^2"
  },
  "devDependencies": {
    "jsonlint": "^1.6.3",
    "ts-node": "^10.9.1",
    "typescript": "^4.8.3"
  }
}

Flutterのローカライズ設定

l10nのパッケージ追加

pubspec.yamlのdependenciesにflutter_localizationsを追加します。

dependencies:
  ...
  flutter_localizations:
    sdk: flutter

arbからdartの書き出し

flutterには、gen-l10nのオプションがあり、シートから作成したarbファイルを指定して、dartファイルを書き出します。

flutter gen-l10n --output-class=L10n --template-arb-file=app_ja.arb --output-localization-file=l10n.dart --preferred-supported-locales=ja --no-synthetic-package

実行すると、l10nディレクトリ内にl10n_en.dart・l10n_ja.dart・l10n.dartが生成されます。

プロジェクト内で利用する

書き出されたl10n.dartを読み込んで、localizationsDelegatesとsupportedLocalesのプロパティを設定します。これで、ローカライズのファイルが読み込まれるようになります。

import 'package:app/l10n/l10n.dart';

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Demo',
        localizationsDelegates: L10n.localizationsDelegates,
        supportedLocales: L10n.supportedLocales,
        home: DemoWidget(),
      ),
    );
  }
}

Widget内でテキストを反映するには、以下のようになります。contextからlocaleに合わせたL10nを取得できるので、プロパティから利用したテキストを指定します。

import 'package:app/l10n/l10n.dart';

class DemoWidget extends StatelessWidget {
  const DemoWidget({super.key});
  
  
  Widget build(BuildContext context) {
    final l10n = L10n.of(context);
  
    return Text(l10n.naviHome);
  }
}

Web用の書き出し処理

Flutterで書き出したものと同じ要領で、ファイルを書き出します。読み込み処理自体は全く同じなので、フォーマットだけ変えます。今回、変数の展開ができるように別のファイルを出力して、メソッドとして利用できるようにします。

const localizePath = "../../packages/shared/src/i18n/";

const template = `import { Translation } from "./translation";
export const translation:Translation = `;

const createInputParameterFunction = (key: string, text: string) => {
  const matches = text.match(/\{\w+\}/g);
  if (!matches || matches.length === 0) return undefined;
  const params = matches.map((t) => t.match(/\w+/)).flatMap((a) => a) as string[];

  const functions: string[] = [];
  params.forEach((p) => {
    functions.push();
  });

  return `export const ${camelCase(key)} = (t:Translation, params: { ${params
    .map((p) => `${p}: string;`)
    .join("")} }) => t.${key}${params.map((p) => `.replaceAll("{${p}}", params.${p})`).join("")};`;
};

const createLocalizeFile = async (values:any) => {
  const types = values.map((line: string[]) => {
    const [key, ja, ___, description] = line;
    return `
    /**
     * ${ja.replace(/\s/g, "")}: ${description}
     */
    ${key}: string;
`;
  });
  fs.writeFileSync(`${localizePath}translation.ts`, `export interface Translation { ${types.join("\n")} }`);

  const functions = values
    .map((line: string[]) => {
      const [key, ja, ___, description] = line;
      const func = createInputParameterFunction(key, ja);
      if (!func) return undefined;
      return `
    /**
     * ${ja.replace(/\s/g, "")}: ${description}
     */
    ${func}
`;
    })
    .filter((f: string | undefined) => f !== undefined);
  const functionTemplate = `import { Translation } from "./translation";

${functions.join("\n")}
`;
  fs.writeFileSync(`${localizePath}translateFunction.ts`, functionTemplate);

  const jaLines = values
    .map((line: string[]) => {
      const [key, ja] = line;
      const value: any = {};
      value[key] = ja;
      return value;
    })
    .reduce((previousValue: any, currentValue: any) => {
      return { ...previousValue, ...currentValue };
    });

  fs.writeFileSync(`${localizePath}ja.ts`, `${template}${JSON.stringify(jaLines)};`);

  const enLines = values
    .map((line: string[]) => {
      const [key, __, en] = line;
      const value: any = {};
      value[key] = en;
      return value;
    })
    .reduce((previousValue: any, currentValue: any) => {
      return { ...previousValue, ...currentValue };
    });

  fs.writeFileSync(`${localizePath}en.ts`, `${template}${JSON.stringify(enLines)};`);
};

Webのローカライズ設定

実行すると、言語ごとのtsファイルと、言語用のintefaceを定義したtranslation.ts、変数を埋め込めるようにしたtranslateFunction.tsを書き出します。

next.jsから使用されている言語を読み取り、言語ファイルを返すhooksを作成します。

import { useRouter } from "next/router";

import { translation as en } from "../i18n/en";
import { translation as ja } from "../i18n/ja";
import { Translation } from "../i18n/translation";

export const useLocale = (): { locale?: string; t: Translation } => {
  const { locale } = useRouter();
  const t = locale === "en" ? en : ja;
  return { locale, t };
};

使用する場合は、以下のようにuseLocaleから言語を取り出して、HTML内に埋め込むように使います。

import { useLocale } from "@demo/shared/src/hooks/useLocale";

export default function SignIn() {
  const { t } = useLocale();
  
  return <>{t.naviHome}</>
}

変数を埋め込む場合は以下になります。

import { useLocale } from "@demo/shared/src/hooks/useLocale";
import { validateErrorMaxLengthInvalid } from "@demo/shared/src/i18n/translateFunction";

export default function SignIn() {
  const { t } = useLocale();
  
  return <>{validateErrorMaxLengthInvalid(t, { max: 5 })}</>
}

おわりに

FlutterとWebで利用できるローカライズ処理を説明しました。コードからテキストをなくせる、ボタン名・バリデーションの文言の統一などメリットが多いので、ローカライズしない場合でも利用すると便利だと思います。
ぜひ、プロジェクトで活用してください。

センキャクでは一緒にプロダクト開発をしてくれる仲間を絶賛募集しています。少しでもご興味ある方はこちらから。

センキャク Tech Blog

Discussion