středa 26. prosince 2012

Lokalizace data a času v Androidu

Jedna ze sympatických věcí při vývoji pro Android je od základu zabudovaná podpora pro lokalizaci aplikace. Vlastně nejen podpora - programátor je v podstatě jemně tlačen k tomu, aby veškeré texty, které se zobrazují na uživatelském rozhraní, neumisťoval natvrdo do kódu javovských tříd nebo XML layoutů. I v případě, že daná aplikace bude ve výsledku pravděpodobně pouze jednojazyčná (zpravidla anglicky), je přeci jen dobrým zvykem oddělit data od aplikační logiky a grafické reprezentace a texty umisťovat do standardního XMLka res/values/strings.xml.


Babylónské zmatení


V každém případě je třeba pamatovat na to, že lokalizace nekončí pouze u překladů textových hlášek, ale že zahrnuje i způsob, jakým jsou formátovány datum a čas nebo třeba peněžní částky. Způsobů, jakými lze po celém světě zapsat jeden časový okamžik, je překvapivě velké množství, a to i za předpokladu, že se vyhneme jazykově specifickým jménům měsíce a dne v týdnu a budeme pouze číselně zohledňovat rok, měsíc, den v měsíci, hodinu a minutu.

Už hned rok lze zapsat buď dvěma anebo čtyřmi číslicemi. Číslo dne a měsíce lze v případně jednociferné hodnoty zapisovat s počáteční nulou či bez ní. Pak je tu samozřejmě otázka oddělovače - pomlčka, lomítko, či tečka? A pokud tečka, tak s mezerou za ní nebo bez? A to nejlepší nakonec - v jakém pořadí vlastně tyhle tři hodnoty zapsat? Jako Čechovi mi přijde zcestné jakékoliv jiné pořadí než to nejlogičtější - od nejmenšího k největšímu (v krajním případně v opačném), ale kupodivu existují i státy, kde letos měli Štědrý den na 12. 24. 2012. Nemluvě o státech, kde týden nezačíná naprosto logicky v pondělí, ale z nějakých pochybných důvodů v neděli (i jako ateista vím, že Bůh si přece dával oraz v neděli a ne v sobotu), ale to v tuhle chvíli raději přejděme.

S časem je to možná o trochu jednodušší, ale o to podivnější. Přestože den má +- 24 hodin, tak beru, že na ciferník se jich tolik moc dobře nevejde (byť klasik by nesouhlasil: Byl jasný, studený dubnový den a hodiny odbíjely třináctou...). Ale proboha, který šílenec vymyslel, že 12:30 PM je dříve než 1:30 PM?? Kdo nevěří, nechť vyzkouší následující kód. Za pozornost v něm také stojí fakt, že hodnota 11 na pozici měsíce opravdu znamená prosinec. Cenu za trolling pro člověka, který tohle do JDK protlačil.
Calendar c1 = Calendar.getInstance();
c1.set(2012, 11, 26, 12, 30);

Calendar c2 = Calendar.getInstance();
c2.set(2012, 11, 26, 13, 30);

DateFormat format = new SimpleDateFormat("hh:mm aa", Locale.US);
System.out.printf("%s < %s: %b",
    format.format(c1.getTime()), format.format(c2.getTime()), c1.before(c2));
Výstup:
12:30 PM < 01:30 PM: true

Kudy ven?

Problém je, tak jako v životě, nejjednodušší obejít, ale výsledek v takovém případě za moc nestojí. Můžete jakékoliv formátování prohlásit za zbytečnost a všude jednoduše natlačit java.util.Date.toString(), jenže s výstupem typu Wed Dec 26 20:42:15 GMT+00:00 2012 bez ohledu na jazykové nastavení telefonu vás vaši uživatelé pošlou kamsi. Navíc standardní dialogy pro výběr data a času jsou v Androidu automaticky lokalizované, i když aplikace je sama o sobě anglicky, takže je vhodné zobrazovat příslušné views ve stejném nebo obdobném formátu. Použít SimpleDateFormat s ručně daným formátem je vzhledem k výše popsanému nesmyslné, takže jak na to?

V U.O.me jsme postupovali tak, že jsme vytvořili dedikovanou třídu s metodami pro formátování pouze data, pouze času a data i času (každá ve dvou variantách pro Date a Calendar). Pro jejich implementaci jsme použili tři různé instance DateFormatu získané přes dostupné factory metody, které automaticky použijí locale (národní prostředí) dané nastavením daného telefonu. Důvod, proč jsme tuto třídu neudělali komplet statickou, je ten, že statické konstanty žijí po celou dobu běhu aplikace, tudíž změna systémových nastavení by se neprojevila, dokud by aplikace nebyla restartována. A to může být v některých zařízeních trochu problém, takže čistější řešení je v každé aktivitě formatter znovu instancovat.

import java.text.DateFormat;

public class DateTimeFormatter {

    private final DateFormat dateFormat = DateFormat.getDateInstance();
    private final DateFormat timeFormat = DateFormat.getTimeInstance();
    private final DateFormat dateTimeFormat = DateFormat.getDateTimeInstance();

    public String formatDate(Date date) {
        return dateFormat.format(date);
    }

    public String formatDate(Calendar date) {
        return formatDate(date.getTime());
    }

    public String formatTime(Date time) {
        return timeFormat.format(time);
    }

    public String formatTime(Calendar time) {
        return formatTime(time.getTime());
    }

    public String formatDateTime(Date dateTime) {
        return dateTimeFormat.format(dateTime);
    }

    public String formatDateTime(Calendar dateTime) {
        return formatDateTime(dateTime.getTime());
    }
}

Zde se velmi nepěkně projevuje, jak blbě je javovské API pro datum a čas pojmenováno. Proč se třída, která reprezentuje datum a čas, jmenuje Date a metoda, kterou objekt typu Date získáme z Calendaru, se jmenuje getTime()?? Na druhou stranu, kdyby tohle byly jeho jediné chyby... Samozřejmě tu máme mnohem lepší alternativy v podobě Joda Time, případně JSR 310, ale na Androidu je s přibalováním knihoven poněkud problém, protože zvětšovat kvůli třem použitím dvou tříd výsledné APK o 500 kB se nevyplatí.

Respektujte systémová nastavení


Nicméně tento kód fungoval poměrně dobře a byli jsme s ním nějakou dobu spokojeni. Nedávno se ovšem na Google Play objevil poměrně překvapivý, ale podnětný komentář. Uživatel si v něm "stěžoval" na to, že aplikace ignoruje systémové nastavení pro formát data a času. Po prozkoumání možností nastavení jsem zjistil, že kromě standardního výběru locale se v telefonu opravdu ještě nacházejí volby pro již zmiňované pořadí den-měsíc-rok a pro 12/24hodinový režim času. Mohlo by se zdát, že to je prkotina, kterou se nemá cenu zabývat, ale faktem je, že tato nastavení ovlivňují vzhled dialogů pro výběr data a času napříč systémem. Pokud tedy uživatel má při zadávání data měsíc v prvním sloupci, je rozumné mu ho také následně zobrazit na prvním místě.


Po chvíli googlení se ukázalo, že metody pro získání těchto nastavení se nacházejí ve třídě android.text.format.DateFormat, jejíž metody vracejí instance typu java.text.DateFormat. Wow. Punk is not dead! A to jsem ještě nezmínil druhý způsob, jakým lze zvolený formát data získat - naprosto killer metoda getDateFormatOrder(Context context), jejíž návratový typ je char[]. Ano, uhádli jste správně, opravdu vrací pole tří znaků - ['d', 'M', 'y'] uspořádané podle aktuálního nastavení! Tohle už nelze nazvat jinak než jako retro. :)

Ale vážně, úprava předchozího kódu tím pádem byla naštěstí snadná, jediná výrazná změna byla, že formatter nyní vyžaduje v konstruktoru oblíbený Context. Kromě toho byl také formát pro "datumočas" odstraněn (neexistuje pro něj factory metoda) a formátování je tedy prováděno pro datum a čas samostatně a výsledné stringy se pak jednoduše spojí.
import java.text.DateFormat;

public class DateTimeFormatter {

    private final DateFormat dateFormat;
    private final DateFormat timeFormat;

    public DateTimeFormatter(Context context) {
        this.dateFormat = android.text.format.DateFormat.getDateFormat(context);
        this.timeFormat = android.text.format.DateFormat.getTimeFormat(context);
    }

    public String formatDate(Date date) {
        return dateFormat.format(date);
    }

    public String formatTime(Date time) {
        return timeFormat.format(time);
    }

    public String formatDateTime(Date dateTime) {
        return formatDate(dateTime) + " " + formatTime(dateTime);
    }

    // metody pro Calendar zustavaji stejne
}
Už jen drobností je pak zobrazení dialogů pro výběr času v 12 či 24hodinovém režimu na základě nastavení:
TimePickerDialog timePickerDialog = new TimePickerDialog(context, callBack,
    hourOfDay, minute, android.text.format.DateFormat.is24HourFormat(context));

3 komentáře:

  1. navrzeny DateTimeFormatter neni threadsafe, takze je nutne pri kazdem pouziti vytvaret novou instanci, jak pises v clanku. Pri tom se vytvori i tri instace DateFormat, typicky pouzijes jen jednu z nich, takze neefektivni (DateFormat ma imho pomaly konstruktor)

    OdpovědětSmazat
    Odpovědi
    1. Podle mě threadsafe je vzhledem k tomu, že je immutable (pokud teda neuvažujeme, že vstupní Date a Calendar jsou mutable). Důvod proč vytvářím při každém použití (resp. v každé aktivitě) novou instanci, jsem zmiňoval v článku - nebyl by problém udělat singleton (ať už klasicky anebo definicí v subclasse android.app.Application), ale jde o to, že v případě změny locale telefonu by pak bylo třeba celou aplikaci natvrdo restartovat, což je pro uživatele otrava. Takhle se změna projeví hned přechodem na jinou aktivitu.

      Pokud jde o zbytečné instancování DateFormatu, tak je pravda, že by to šlo udělat i efektivněji (factory vracející pouze požadovaný typ formátu). Jenže pro "datumočas" by stejně bylo potřeba buď spojit 2 DateFormaty (jako výše) anebo si ručně vyrábět SimpleDateFormat na základě zmiňovaných metod getDateFormatOrder() a is24HourFormat() (což je opruz). Každopádně performance optimalizace zatím neřešíme.

      Smazat
    2. DateFormat neni threadsafe, takze ani DateTimeFormatter neni threadsafe. Ale jinak souhlasim. Chapu ze ve vasem pripade optimalizovt je trochu predcasne, ja jsem vsak z webovych aplikaci zvykly, ze se cas formatuje radove tisickrat za sekundu a optimalizovat takove veci se musi.

      Smazat