Vyvíjíme mobilní aplikaci ve Flutteru (3/3)

flutter eman

Víme, jak vytvořit dialogy a jak pracovat s jazykem Dart. V posledním díle série se tak konečně vrhneme na samotnou tvorbu komplexnější aplikace. Ukážeme si taky, jak vyřešit problém s podporou platformě závislé aplikace. Tak jdeme na to!

V minulém díle jsme se podívali pod pokličku frameworku Flutter, představili jsme si jazyk Dart a nakonec jsme si ukázali, jak zavolat nativní rozhraní platformy. V tomto posledním díle se zaměříme na možnosti lokalizace aplikace a velmi okrajově se podíváme na tvorbu layoutu obrazovky. Všechny nabyté informace pak shrneme do výsledné aplikace, kterou na závěr napojíme na službu Firebase. Získáme tak kostru dnes asi nejčastěji používané softwarové architektury v mobilním vývoji – mobilní aplikace v roli klienta získávající data z databáze na serveru.

 

Vyrábíme widgety (Abstract Factory pattern)

V prvním díle jsme si ukázali, jak vytvořit platformě závislý dialog. V prezentovaném řešení jsme na základě zjištění cílové platformy použili příslušnou grafickou komponentu. Toto řešení ale není optimální, protože vede na kód s velkým množstvím if-then-else konstrukcí pro zjištění platformy a následné použití specifické komponenty. Tento problém můžeme odstranit například použitím návrhového vzoru Abstract Factory.

 

Abstract Factory obecně

Návrhový vzor Abstract Factory patří do skupiny vytvářecích návrhových vzorů. Abstract Factory slouží k vytváření skupiny příbuzných produktů a eliminuje používání new. Skupina objektů, které jsou továrnou vytvářeny, jsou pak jasně určeny příslušnou konkrétní továrnou.

  • Abstract Factory – deklaruje rozhraní pro vytváření skupiny produktů
  • Abstract Product – deklaruje rozhraní příslušného produktu
  • Concrete Factory – obsahuje implementaci továrny vytvářející konkrétní produkty
  • Concrete Product – obsahuje implementaci konkrétního produktu

 

Zdroj: https://en.wikipedia.org/wiki/Abstract_factory_pattern

 

Pro lepší pochopení si to ukážeme na konkrétním příkladu ze života. Řekl bych, že je až typický pro tento vzor: Stavíme dům a chceme ho osadit okny a dveřmi z konkrétního materiálu. Na výběr máme dřevo, plast a hliník a jediné, co nechceme, je kombinace různých druhů materiálů.

  • Abstract Factory nabízí vytvoření dveří nebo okna (Abstract Product), ale neurčuje, z jakého materiálu budou.
  • Concrete Factory jsou továrny vyrábějící produkty ze dřeva, z plastu a hliníku a Concrete Product jsou produkty z určitého materiálu.

 

Pro dodávku produktů uděláme objednávku u jedné konkrétní továrny a máme zajištěno, že dodané produkty budou ze stejného materiálu. Odpadá nám tedy opakované ptaní se: “Z jakého materiálu chceme mít okna/dveře?”, ale jen provádíme objednávku u továrny, kterou jsme si vybrali na začátku stavby.

 

Abstract Factory konkrétně

Naše abstraktní továrna bude nabízet rozhraní pro vytváření různých grafických a jiných prvků. O samotnou tvorbu prvků se pak budou starat dvě konkrétní továrny – jedna pro Android a druhá pro iOS styl. Jak ale určit, které prvky vytvářet pomocí továrny, a které už ne? Nabízí se nám několik možností, jak k celému problému přistoupit:

  • vytváření všech grafických komponent
  • vytváření všech komponent, které se nacházejí v balíku material nebo cupertino
  • vytváření jen komponent, které existují v obou grafických mutacích

 

Já jsem se rozhodl použít prostřední možnost, protože se jedná o jistý kompromis mezi ostatními možnostmi. Navíc v případě, že bude v budoucnu doimplementována alternativní komponenta do opačného balíčku, bude začlenění komponenty mnohem jednodušší. Zdrojové soubory by pak nikde mimo souborů obsahující implementace příslušných továren neměly obsahovat import package:flutter/cupertino.dart nebo ipackage:flutter/material.dart.

Při deklaraci jednotlivých metod rozhraní je výhodné použít tzv. “Optional named parameters“, díky kterým změna rozhraní (ve smyslu přidání nového parametru) ovlivní jen konkrétní továrny implementující příslušné rozhraní, ale už ne místa, kde jsou volány metody rozhraní.

Pro ukázku uvedu jen příklad vytváření komponent Scaffold a AppBar. Celé rozhraní můžete najít v souboru widget_factory.dart.

 

abstract class WidgetFactory {
  Widget createScaffold({PreferredSizeWidget appBar, Widget body});

  PreferredSizeWidget createAppBar({Widget title});
  ...
}

 

Implementace konkrétních továren, která zajistí vytvoření příslušné komponenty dle požadovaného stylu:

 

class _AndroidWidgetFactory implements WidgetFactory {
  @override
  Widget createScaffold({PreferredSizeWidget appBar, Widget body}) {
    return Scaffold(appBar: appBar, body: body);
  }

  @override
  PreferredSizeWidget createAppBar({Widget title}) {
    return AppBar(title: title);
  }
  ...
}

class _IOSWidgetFactory implements WidgetFactory {
  @override
  Widget createScaffold({PreferredSizeWidget appBar, Widget body}) {
    return CupertinoPageScaffold(navigationBar: appBar, child: body);
  }

  @override
  PreferredSizeWidget createAppBar({Widget title}) {
    return CupertinoNavigationBar(middle: title, backgroundColor: Colors.white);
  }
  ...
}

 

Všechny potřebné třídy už máme připravené, teď jen potřebujeme přidat logiku pro inicializaci továrny, která bude vytvářet prvky při běhu aplikace. Pro vytvoření továrny jsem použil návrhový vzor Singleton, který nám současně zajistí, že v celé běžící aplikaci budeme používat jen jednu instanci konkrétní továrny. Pro vytváření a elegantní používání Singletonů nabízí Dart tzv. “Factory constructor“. Z příkladu je patrné, že logiku pro zjištění, na jakém systému aplikace běží, máme jen na jednom místě.

 

abstract class WidgetFactory {
  static WidgetFactory _instance;

  factory WidgetFactory() {
    if (_instance == null) {
      if (Platform.isAndroid) {
        _instance = _AndroidWidgetFactory();
      } else if (Platform.isIOS) {
        _instance = _IOSWidgetFactory();
      } else {
        throw UnsupportedError('Unsupported target platform.');
      }
    }
    return _instance;
  }
  ...
}

// using
WidgetFactory().createScaffold(...);

 

Poznámka: V Javě by se k docílení stejného chování jako Factory konstruktoru vytvořila statická metoda getInstance() vracející instanci třídy.

 

Výhody a nevýhody

  • Výhody
    • Snížení redundance kódu
    • Eliminace if-then-else
    • Kontrola vytváření objektů (snazší mockování)
    • Platformě závislá aplikace
    • Přepoužitelnost řešení – stačí vytvořit jen na jednom projektu a pak jde používat i na ostatních

 

  • Nevýhody
    • Nutnost duplikace rozhraní
    • Redundantní kód

 

Lokalizace aplikace

Jedním ze standardů aplikací je, že nabízejí mnoho jazykových mutací, aby oslovily co největší spektrum uživatelů. Ani Flutter není pozadu a nabízí možnosti, jak požadovaných vlastností docílit. Jednou z možností pro podporu lokalizace je použití balíčku intl. Balíček přidává podporu lokalizace do libovolného projektu napsaného v jazyce Dart. Pro definici získávání řetězců v projektu zavádí speciální konstrukci, která zajistí použití správné jazykové mutace. Na základě těchto konstrukcí jsou pomocí speciálního nástroje pro každou mutaci vygenerovány soubory s odpovídajícími řetězci, které slouží pro doplnění překladu. Ty jsou následně přibaleny k výsledné aplikaci. Druhou možností, kterou si zde ukážeme, je definování překladů přímo ve zdrojovém souboru.

Do souboru pubspec.yaml přidáme novou závislost, která nám zajistí podporu pro lokalizaci.

 

dependencies:
  flutter_localizations:
    sdk: flutter
  ...

 

Abychom mohli do naší aplikace přidat podporu libovolného jazyka, musíme přidat dvě třídy jako na ukázce níže. Třída _EventsLocalizationsDelegate slouží k načtení a uchování objektu EventsLocalizations s konkrétní podporovanou lokalizací. Podoba obou tříd je prakticky standardní a na každém projektu bude stejná. Jediné, co se mění, jsou hodnoty definované v atributu _localizedValues. Jedná se mapu map, kde klíče první mapy jsou značky dle registru IANA.

 

enum StringId {
  appTitle,
  homeScreenTitle,
  ...
}

class EventsLocalizations {
  static const LocalizationsDelegate<EventsLocalizations> delegate = const _EventsLocalizationsDelegate();

  static const Map<String, Map<StringId, String>> _localizedValues = {
    'en': {
      StringId.appTitle: 'Eman\'s events',
      StringId.homeScreenTitle: 'Events',
      ...
    },
    'cs': {
      StringId.appTitle: 'Eman\'s events',
      StringId.homeScreenTitle: 'Akce',
      ...
    },
    ...
  };

  static final Iterable<Locale> supportedLocales = _localizedValues.keys.map((languageCode) => Locale(languageCode));

  static EventsLocalizations of(BuildContext context) => Localizations.of<EventsLocalizations>(context, EventsLocalizations);

  final Locale _locale;

  EventsLocalizations(this._locale);

  String get appTitle => _localizedValues[_locale.languageCode][StringId.appTitle];
  String get homeScreenTitle => _localizedValues[_locale.languageCode][StringId.homeScreenTitle];
  // or
  String getString(StringId stringId) => _localizedValues[_locale.languageCode][stringId];
}

class _EventsLocalizationsDelegate extends LocalizationsDelegate<EventsLocalizations> {
  const _EventsLocalizationsDelegate();

  @override
  bool isSupported(Locale locale) => EventsLocalizations._localizedValues.containsKey(locale.languageCode);

  @override
  Future<EventsLocalizations> load(Locale locale) => SynchronousFuture<EventsLocalizations>(EventsLocalizations(locale));

  @override
  bool shouldReload(LocalizationsDelegate<EventsLocalizations> old) => false;
}

 

Na závěr je nutné ještě specifikovat, jaké jazykové mutace aplikace podporuje (parametr supportedLocales widgetu aplikace) a zaregistrovat delegáty všech komponent, kteří se již postarají o zajištění správné lokalizace (pole localizationsDelegates widgetu aplikace).

 

class EventsApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      localizationsDelegates: [
        EventsLocalizations.delegate,
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      supportedLocales: EventsLocalizations.supportedLocales,
      ...
    );
  }
}

 

Pro získání lokalizovaného řetězce pak použijeme jednu z následujících možností podle toho, jaké rozhraní lokalizační třídy jsme si zvolili.

 

EventsLocalizations.of(context).appTitle;
// or
EventsLocalizations.of(context).getString(StringId.appTitle);

 

Poznámka: Aktuální verze Material Components nepodporuje češtinu – způsobuje chybu při vykreslování widgetů.

 

Tvoříme layouty

Jak bylo řečeno minule, i tvorba layoutu obrazovky je prostě skládačka. Skládáme do sebe primitivní widgety a tím vzniká widget, který vypadá a umí to, co jsme na začátku chtěli. Na následující ukázce vytvoření ListView a jeho jednotlivých elementů se podíváme na použití několika druhů základních layoutů. Pro tvorbu složitějšího layoutu se můžete inspirovat přímo na stránkách Flutteru.

 

class EventListState extends State<EventList> {
  final _events = List<Event>();

  @override
  void initState() {
    super.initState();
    _events.addAll(generateWordPairs().take(10).map((pair) => Event(
          pair.asPascalCase,
          DateTime.now(),
          lorem.createParagraph(),
        )));
  }

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        padding: const EdgeInsets.all(20.0),
        itemCount: _events.length * 2,
        itemBuilder: (context, index) {
          if (index.isOdd) {
            return WidgetFactory().createDivider();
          } else {
            return _buildItem(_events[index ~/ 2]);
          }
        });
  }

  Widget _buildItem(Event event) {
    return GestureDetector(
      child: Container(
        decoration: BoxDecoration(color: Colors.transparent),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Container(
              padding: const EdgeInsets.only(bottom: 8.0),
              child: Text(
                event.name,
                style: TextStyle(fontSize: 20.0, color: Colors.black),
              ),
            ),
            Text(
              format.format(event.dateTime),
              style: TextStyle(
                fontSize: 16.0,
                color: Colors.grey[500],
              ),
            ),
          ],
        ),
      ),
      onTap: () => _itemClicked(event),
    );
  }
  ...
}

 

Zaměříme se pouze na metodu _buildItem, která vytváří layout jedné položky seznamu.

  • GestureDetector – widget, který umožňuje zpracovat všechny možné druhy kliknutí, swipenutí a podobně.
  • Container – widget, který se stará o určení pozice potomka. Umožňuje definovat například výšku, šířku, padding nebo margin.
  • Column – widget, který zarovná všechny svoje potomky do jednoho sloupce. Šířka sloupce je dána podle nejširšího potomka a všichni ostatní potomci jsou umístěni na střed.

 

Celý layout si můžeme představit jako strom, kde každý rodičovský uzel určuje chování nebo způsob vykreslování svých potomků.

 

Cloud Firestore databáze

Cloud Firestore je Document Store databáze a jedná se o nástupce původní realtime databáze od Firebase. Pro potřebu ukázky si založíme Firestore databázi, která obsahuje kolekci pod názvem events obsahující několik dokumentů a každý dokument má následující strukturu:

 

name: "...",
date_time: "...",
description: "...",

 

Samotné vytvoření Cloud Firestore databáze a přidání databáze do projektu zde uvádět nebudu, ale stačí se řídit podle přehledného průvodce od Googlu. Po provedení konfigurace už je samotné napojení vskutku hračka. V předchozí ukázce metoda initState vytvářela seznam událostí obsahující náhodná data, která nyní vyměníme za data z databáze. Z instance databáze získáme kolekci events a všechny dokumenty, které obsahuje, namapujeme na modelovou třídu Event. V následující ukázce přistupujeme ke kolekci events jako k proudu dat a pomocí metody listen reagujeme na libovolnou změnu v datech. Schválně si můžete zkusit, jak se bude aplikace chovat, když budete provádět změny dat v databázi.

 

class EventListState extends State<EventList> {
  ...
  @override
  void initState() {
    Firestore.instance
        .collection('events')
        .orderBy('date_time')
        .snapshots()
        .listen((event) => setState(() {
              _events.clear();
              _events.addAll(event.documents.map((snapshot) => Event(
                    snapshot.data['name'],
                    DateTime.parse('${snapshot.data['date_time']}z'),
                    snapshot.data['description'],
                  )));
            }));
  ...
}

 

Závěr

Dnes jsme si ukázali, jakým způsobem vyřešit problém s dodržením stylu konkrétní platformy, a vytvořili jsme si podporu pro multijazyčnou aplikaci. Poté jsme si demonstrovali tvorbu jednoduchého layoutu a na závěr provedli napojení aplikace na Firestore databázi. Celý projekt, který jsme tu tak trochu nenápadně po částech vytvářeli, naleznete na eManím GitHubu, kde si také můžete všimnout, jak historie projektu kopíruje tento článek.

 

Zdroje

Filip Šmíd
Android Developer

RSS