Schnellkontakt

Initiativbewerbungen bitte an: jobs@icnh.de

Presse und Projektanfragen: info@icnh.de

Telefon

L18N

Stefan Matthias Aust · 14. Okt 2020 · 8 min read

L18N

So sieht ein beispielhaftes Flutter-UI mit fest verdrahteten Texten aus:

Widget build(BuildContext context) {
  return HermesScaffold(
    title: "WunschZustellung",
    body:
      ...
      StyledText(
        text: "Bestellungen noch bis {b {0}} möglich",
        arguments: ["23:00 Uhr"],
        textAlign: TextAlign.center,
      ),
      ...
  );
}

Der StyledText ist ein eigenes Widget, das Argumente (beliebigen Typs, auf die dann ein toString angewendet wird) an bestimmten durch {N} markierten Stellen einfügen kann und gegenüber Text zusätzlich Fett- und Kursivschrift (und optional auch eigene Stile) unterstützt.

Will ich das Internationalisieren (I18N), um dann es lokalisieren (L10N) zu können, müssen alle konstanten Strings aus dem UI raus. Insbesondere kann ich keine String-Interpolation (wie z.B. 'Hallo $name') benutzen. Daher habe ich auch direkt mit dem StyledText begonnen. Zu beachten ist außerdem, dass je nach L10N die Argumente möglichweise in unterschiedlicher Reihenfolge angezeigt werden müssen. Auch das bildet StyledText bereits ab.

Die Locale, die die aktuell eingestellte Sprache definiert, finde ich im BuildContext, d.h. diesen muss ich ab sofort herumreichen.

Um möglichst wenig schreiben zu müssen, habe ich mir eine globale Funktion L wie folgt definiert:

AppLocalizations L(BuildContext context) {
  return Localizations.of(context, AppLocalizations);
}

Das ist einfach Teil des notwendigen "boiler plates", um zu einem Objekt zu kommen, welches ich über einen sogenannten LocalizationsDelegate in den Kontext eingehängt habe – irgendwo weit oben in der Widget-Hierarchie beim Start der App.

  ...
  return MaterialApp(
    ...
    localizationsDelegates: [
      AppLocalizationsDelegate(context),
    ],
  );
  ...

Ein AppLocalizationsDelegate nutzt den BuildContext, um das Asset Bundle zu finden und dort zur Locale passenden Strings-Dateien. Ich lade dabei zunächst eine Datei für die Sprache (z.B. strings/de.txt und dann eine für das Land, z.B. strings/de_AT.txt), die die erste ganz oder teilweise überschreiben darf.

Bei der Crew-App habe ich mir außerdem die Mühe gemacht, für jeden String eine Konstante zu definieren, die einen passenden Präfix hat.

Übertragen sähe das so aus:

Widget build(BuildContext context) {
  return HermesScaffold(
    title: L(context).bookableServicesTitle,
    body:
      ...
      StyledText(
        text: L(context).bookableServicesOrderPossibleUntilX,
        arguments: ["23:00 Uhr"],
        textAlign: TextAlign.center,
      ),
      ...
  );
}

Dafür musste ich ganz viele Methoden wie die folgenden beiden in AppLocalizations schreiben:

class AppLocalizations {
  ...
  String get bookableServicesTitle = getValue("bookableServicesTitle");
  String get bookableServicesOrderPossibleUntilX = getValue("bookableServicesOrderPossibleUntilX");
  ...
}

Ich bin mir nicht sicher, ob sich das bewährt und gut skaliert. Vorteil ist natürlich, dass man sich bei den Keys nicht verschreiben kann, aber die flache Liste wird sehr schnell unübersichtlich und gute Namen zu finden, ist auch nicht einfach.

Inzwischen denke ich daher, ich kann die Strings auch direkt im UI benutzen und ich definiere dann L um, damit den Kontext und den Schlüssel übergeben bekommt:

Widget build(BuildContext context) {
    return HermesScaffold(
        title: L(context, "bookableServicesTitle"),
        body:
            ...
            StyledText(
                text: L(context, "bookableServicesOrderPossibleUntilX"),
                arguments: ["23:00 Uhr"],
                textAlign: TextAlign.center,
          ),
          ...
    );
}

Statt symbolischer Namen stelle ich mal die Idee zur Diskussion, direkt die Texte in der Standard-Sprache zu benutzen. Dann kann man eine Art NullLocalizations haben, wo L einfach den Schlüssel zurück gibt und hat extra ohne Aufwand die App schon mal internationalisiert, ohne sie zwangsläufig auch lokalisieren zu müssen.

Widget build(BuildContext context) {
    return HermesScaffold(
        title: L(context, "WunschZustellung"),
        body:
            ...
            StyledText(
                text: L(context, "Bestellungen noch bis {b {0}} möglich"),
                arguments: ["23:00 Uhr"],
                textAlign: TextAlign.center,
            ),
          ...
    );
}

class NullLocalizations {
    String getValue(String key) => key;
}

Ich habe bei der Crew-App die Übersetzung in einer JSON-Datei gepflegt. Ich glaube, eine flache Properties-Datei (mit Kommentaren) wäre besser gewesen:

; aus bookable_service.dart
WunschZustellung = WunschZustellung
Bestellungen noch bis {b {0}} möglich = Bestellungen noch bis {b {0}} möglich

Ohne explizite Anführungszeichen sind aber Leerzeichen am Anfang oder Ende die Hölle. Und der Schlüssel darf kein = enthalten. Gerade bei der Idee mit NullLocalizations vielleicht wieder aufwendiger.

Eine derartige Properties-Datei könnte man automatisch generieren, indem man nach L(context, ...) im Quelltext sucht. Außerdem könnte man sicherstellen, dass immer alle Strings in allen Sprachen vorhanden sind, indem man mit einer existierenden Datei vergleicht. Bleibt nur das Problem, wie man das ordnet und gruppiert und ob man das überhaupt macht oder einfach alles alphabetisch sortiert.

Datumsangaben

Die 23:00 Uhr sind noch mal ein eigenes Thema. Wenn wir diese auch korrekt je nach Ländereinstellung formatieren wollen, brauchen wir zunächst ein länderspezifisches Format und dann eine Funktion, die das Format umsetzt.

Unter der Annahme, wir haben den Wert nicht als Strings sondern als DateTime, sieht das so aus:

StyledText(
    text: L(context, "Bestellungen noch bis {b {0}} möglich"),
    arguments: [
        L(context).format(L(context, "HH:mm Uhr"), value),
    ],
    textAlign: TextAlign.center,
),

Wobei dann HH:mm Uhr für's Englische z.B. als h:mm a o'clock übersetzt wird. Und eigentlich ist das gar nicht korrekt, weil ich wohl das Betriebsystem befragen muss, ob der Anwender 12-Stunden oder 24-Stunden Zeitanzeige haben möchte. Herje. Wie das in Flutter geht, weiß ich gar nicht.

Wahrscheinlich will man das Uhr bzw. o'clock auch in das {b {0}} als {b {0} Uhr} mit reinziehen. Dafür habe ich ja extra meinen StyledText.

Die format-Methode habe ich in AppLocalizations eingefügt, weil Wochentags- oder Monatsnamen natürlich sprachspezifisch sind. Und dateFormat(L(context).locale, "HH:mm", ...) erschien mir noch aufwendiger als der jetzige Ansatz.

Pluralformen

Der letzte wichtige Punkt sind Pluralformen, darüber habe ich bei der Crew-App hinweggesehen. Jedes Land, so hatte ich das damals bei Eventim gelernt, hat da eigene zum Teil sehr kompliziete Regeln, die ich damals dort auch alle entsprechend implementiert hatte.

Für Deutsch und Englisch würde aber dies reichen:

class AppLocalizations {
    ...
    String plural(String key, int count) {
        return getValue(key + _pluralSuffix(count));
    }

    String _pluralSuffix(int count) {
        return count == 1 ? "_one" : "_other";
    }
    ...

Und dann:

StyledText(
    text: L(context).plural("Du hast {0} neue Sendungen", count), 
    arguments: [count],
);

Mit

Du hast {0} neue Sendungen_one = Du hast 1 neue Sendung
Du hast {0} neue Sendungen_other = Du hast {0} neue Sendungen

muss man dann in den Strings beide Formen angeben. Mit one und other orientiere ich mich dabei an dem ICU Message Format, welches die Schlüsselwörter zero, one, two, few und many sowie other definiert.

Und dann lautet die Regel für russisch z.B. das one bei allen mit Rest 1 durch 10 teilbaren Zahlen genommen wird, außer es ist 11. Für Rest 2 bis 4 außer 12 bis 14 ist es few und größer 5 oder für 12 bis 14 ist es many. Andernfalls ist es other.

Andere I18N-Pakete gehen hier anders vor, ich denke aber, dass ICU die allumfassendeste Lösung ist – war es jedenfalls 2015 im Eventim-Kontext.

Die ICU definiert auch noch eine Lösung, wenn man männlich und weibliche Formen braucht. So habe ich z.B. neulich gelernt, dass es immer Barista im Singular heißt, mehrere männliche Kaffeezubereiter sind aber Baristi und mehrere weibliche Bariste. Was man macht, wenn beide Geschlechter vorhanden sind, weiß ich nicht.