Architettura Flutter: Provider vs BLoC
Pubblicato: 2020-04-17Scrivere app con Flutter crea grandi opportunità per la scelta dell'architettura. Come spesso accade, la migliore risposta alla domanda "Quale dovrei scegliere?" è "Dipende". Quando ottieni quella risposta, puoi essere certo di aver trovato un esperto di programmazione.
In questo articolo, esamineremo le schermate più popolari nelle applicazioni mobili e le implementeremo nelle due architetture Flutter più popolari: Provider e BLoC . Di conseguenza, impareremo i pro ei contro di ciascuna soluzione, che ci aiuteranno a scegliere la giusta architettura Flutter per il nostro prossimo modulo o applicazione.
Breve introduzione all'architettura Flutter
La scelta dell'architettura per un progetto di sviluppo Flutter è di grande importanza, principalmente per il fatto che abbiamo a che fare con un paradigma di programmazione dichiarativo meno comunemente usato. Questo cambia completamente l'approccio alla gestione dello stato con cui gli sviluppatori nativi Android o iOS avevano familiarità, scrivendo il codice in modo imperativo. I dati disponibili in un punto dell'applicazione non sono così facili da ottenere in un altro. Non abbiamo riferimenti diretti ad altre viste nell'albero, da cui potremmo ricavare il loro stato attuale.
Cos'è Provider in Flutter
Come suggerisce il nome, Provider è un'architettura Flutter che fornisce l'attuale modello di dati nel luogo in cui attualmente ne abbiamo bisogno. Contiene alcuni dati e notifica agli osservatori quando si verifica una modifica. In Flutter SDK, questo tipo è chiamato ChangeNotifier . Affinché l'oggetto di tipo ChangeNotifier sia disponibile per altri widget, è necessario ChangeNotifierProvider . Fornisce oggetti osservati per tutti i suoi discendenti. L'oggetto che è in grado di ricevere i dati correnti è Consumer , che ha un'istanza di ChangeNotifier nel parametro della sua funzione di build che può essere utilizzata per alimentare le viste successive con i dati.
Cos'è BLoC in Flutter
Business Logic Components è un'architettura Flutter molto più simile alle soluzioni popolari nei dispositivi mobili come MVP o MVVM. Fornisce la separazione del livello di presentazione dalle regole di business logic. Questa è un'applicazione diretta dell'approccio dichiarativo che Flutter enfatizza fortemente, cioè UI = f (stato) . BLoC è un luogo in cui vanno gli eventi dall'interfaccia utente. All'interno di questo livello, come risultato dell'applicazione delle regole di business a un determinato evento, BLoC risponde con uno stato specifico, che poi torna all'interfaccia utente. Quando il livello di visualizzazione riceve un nuovo stato, ricostruisce la sua vista in base a ciò che richiede lo stato corrente.
Curioso sullo sviluppo di Flutter?
Guarda le nostre soluzioniCome creare un elenco in Flutter
Un elenco scorrevole è probabilmente una delle visualizzazioni più popolari nelle applicazioni mobili. Pertanto, scegliere la giusta architettura Flutter potrebbe essere cruciale in questo caso. In teoria, visualizzare l'elenco stesso non è difficile. La situazione si complica quando, ad esempio, aggiungiamo la possibilità di eseguire una determinata azione su ciascun elemento. Ciò dovrebbe causare un cambiamento in diversi punti dell'app. Nel nostro elenco, saremo in grado di selezionare ciascuno degli elementi e ciascuno di quelli selezionati verrà visualizzato in un elenco separato su una schermata diversa.

Pertanto, dobbiamo memorizzare gli elementi che sono stati selezionati, in modo che possano essere visualizzati su una nuova schermata. Inoltre, dovremo ricostruire la vista ogni volta che si tocca la casella di controllo, per mostrare effettivamente il segno di spunta/deseleziona.
Il modello dell'elemento dell'elenco sembra molto semplice:
classe SocialMedia { ID int; Titolo della stringa; String iconAsset; bool è il preferito; Social Media( {@required this.id, @richiesto questo.titolo, @required this.iconAsset, this.isFavorite = false}); void setFavorite(bool isFavorite) { this.isFavorite = isFavourite; } }
Come creare una lista con Provider
Nel modello Provider, il modello precedente deve essere archiviato in un oggetto. L'oggetto dovrebbe estendere ChangeNotifier per poter accedere a SocialMedia da un'altra posizione nell'app.
class SocialMediaModel estende ChangeNotifier { elenco finale<SocialMedia> _socialMedia = [ /* alcuni oggetti dei social media */ ]; UnmodifiableListView<SocialMedia> ottieni preferiti { return UnmodifiableListView(_socialMedia.where((item) => item.isFavourite)); } UnmodifiableListView<SocialMedia> ottieni tutto { return UnmodifiableListView(_socialMedia); } void setFavorite(int itemId, bool isChecked) { _socialmedia .firstWhere((item) => item.id == itemId) .setFavorite(èSelezionato); notificaListeners(); }
Qualsiasi modifica in questo oggetto, che richiederà la ricostruzione nella vista, deve essere segnalata utilizzando notificationListeners() . Nel caso del metodo setFavourite() per indicare a Flutter di eseguire nuovamente il rendering del frammento dell'interfaccia utente, questo osserverà la modifica in questo oggetto.
Ora possiamo passare alla creazione dell'elenco. Per riempire ListView di elementi, dovremo arrivare all'oggetto SocialMediaModel , che memorizza un elenco di tutti gli elementi. Puoi farlo in due modi:
- Provider.of<ModelType>(contesto, ascolto: false)
- Consumatore
Il primo fornisce l'oggetto osservato e permette di decidere se l'azione eseguita sull'oggetto deve ricostruire il widget corrente, utilizzando il parametro listen . Questo comportamento sarà utile nel nostro caso.
class SocialMediaListScreen estende StatelessWidget { SocialMediaListScreen(); @oltrepassare Creazione widget (contesto BuildContext) { var socialMedia = Provider.of<SocialMediaModel>(contesto, ascolta: false); return ListView( bambini: socialMedia.all .map((item) => CheckboxSocialMediaItem(item: item)) .elencare(), ); } }
Abbiamo bisogno di un elenco di tutti i social media ma non è necessario ricostruire l'intero elenco. Diamo un'occhiata all'aspetto del widget dell'elemento dell'elenco.
class CheckboxSocialMediaItem estende StatelessWidget { elemento SocialMedia finale; CheckboxSocialMediaItem({Key key, @required this.item}): super(key: key); @oltrepassare Creazione widget (contesto BuildContext) { ritorno imbottitura( padding: const EdgeInsets.all(Dimens.paddingDefault), bambino: Riga( figli: [ Consumatore<Modello di social media>( costruttore: (contesto, modello, figlio) { restituisci casella di controllo( valore: item.isFavorite, onChanged: (isChecked) => model.setFavorite(item.id, isChecked), ); }, ), SocialMediaItem( articolo: articolo, ) ], ), ); } }
Ascoltiamo la modifica del valore della casella di controllo e aggiorniamo il modello in base allo stato del controllo. Il valore della casella di controllo stesso viene impostato utilizzando la proprietà del modello di dati. Ciò significa che dopo la selezione, il modello cambierà il campo isFavorite in true . Tuttavia, la vista non presenterà questa modifica finché non ricostruiremo la casella di controllo. Qui, un oggetto Consumer viene fornito con l'aiuto. Fornisce l'oggetto osservato e ricostruisce tutti i suoi discendenti dopo aver ricevuto informazioni sulla modifica nel modello.
Vale la pena posizionare Consumer solo dove è necessario aggiornare il widget per evitare visualizzazioni di ricostruzione non necessarie. Tieni presente che se, ad esempio, la selezione della casella di controllo attiverà alcune azioni aggiuntive come la modifica del titolo dell'articolo, il Consumatore dovrebbe essere spostato più in alto nell'albero dei widget, in modo da diventare il genitore del widget responsabile della visualizzazione del titolo . In caso contrario, la visualizzazione del titolo non verrà aggiornata.
La creazione di uno schermo di social media preferito sarà simile. Otterremo un elenco di articoli preferiti utilizzando Provider .
class FavoritesListScreen estende StatelessWidget { PreferitiListScreen(); @oltrepassare Creazione widget (contesto BuildContext) { var list = Provider.of<SocialMediaModel>(contesto, ascolta: false).favoriti; return ListView( bambini: elenco .map((elemento) => Padding( padding: const EdgeInsets.all(Dimens.paddingDefault), bambino: SocialMediaItem(item: item))) .elencare(), ); } }
Quando viene chiamato il metodo build , il provider restituirà l'elenco corrente dei social media preferiti.
Come creare una lista con BLoC
Nella nostra semplice applicazione, finora abbiamo due schermate. Ognuno di loro avrà il proprio oggetto BLoC . Tuttavia, tieni presente che gli elementi selezionati nella schermata principale devono apparire nell'elenco dei social media preferiti. Pertanto, dobbiamo in qualche modo trasferire gli eventi di selezione delle caselle di controllo al di fuori dello schermo. La soluzione consiste nel creare un oggetto BLoC aggiuntivo che gestirà gli eventi che influiscono sullo stato di molte schermate. Chiamiamolo BLoC globale. Quindi, gli oggetti BLoC assegnati alle singole schermate ascolteranno le modifiche negli stati BLoC globali e risponderanno di conseguenza.
Prima di creare un oggetto BLoC , dovresti prima pensare a quali eventi la vista sarà in grado di inviare al livello BLoC e a quali stati risponderà. Nel caso di BLoC globale, gli eventi e gli stati saranno i seguenti:
classe astratta SocialMediaEvent {} class CheckboxChecked estende SocialMediaEvent { final bool isChecked; final int itemId; CheckboxChecked(this.isChecked, this.itemId); } classe astratta SocialMediaState {} class ListPresented estende SocialMediaState { Elenco finale<SocialMedia> elenco; ListPresented(this.list); }
L'evento CheckboxChecked deve trovarsi nel BLoC globale, perché influenzerà lo stato di molte schermate, non solo di una. Quando si tratta di stati, ne abbiamo uno in cui l'elenco è pronto per essere visualizzato. Dal punto di vista globale del BLoC , non è necessario creare più stati. Entrambe le schermate dovrebbero visualizzare l'elenco e dovrebbero occuparsene i singoli BLoC dedicati alla schermata specifica. L'implementazione dello stesso BLoC globale sarà simile a questa:
la classe SocialMediaBloc estende Bloc<SocialMediaEvent, SocialMediaState> { repository finale di SimpleSocialMediaRepository; SocialMediaBloc(questo.repository); @oltrepassare SocialMediaState get initialState => ListPresented(repository.getSocialMedia); @oltrepassare Stream<SocialMediaState> mapEventToState(SocialMediaEvent event) async* { if (l'evento è CheckboxChecked) { yield _mapCheckboxCheckedToState(evento); } } SocialMediaState _mapCheckboxCheckedToState(CheckboxChecked event) { elenco aggiornato finale = (stato come ListPresented).list; elenco aggiornato .firstWhere((item) => item.id == event.itemId) .setFavorite(event.isChecked); return ListPresented(updatedList); } }
Lo stato iniziale è ListPresented : assumiamo di aver già ricevuto dati dal repository. Abbiamo solo bisogno di rispondere a un evento: CheckboxChecked . Quindi aggiorneremo l'elemento selezionato usando il metodo setFavourite e invieremo la nuova lista racchiusa nello stato ListPresented .
Ora dobbiamo inviare l'evento CheckboxChecked quando si tocca la casella di controllo. Per fare ciò, avremo bisogno di un'istanza di SocialMediaBloc in un luogo in cui possiamo allegare la richiamata onChanged . Possiamo ottenere questa istanza usando BlocProvider : sembra simile a Provider dal modello discusso sopra. Affinché un tale BlocProvider funzioni, più in alto nell'albero dei widget, è necessario inizializzare l'oggetto BLoC desiderato. Nel nostro esempio, verrà eseguito nel metodo principale:
void main() => runApp(BlocProvider( crea: (contesto) { return SocialMediaBloc(SimpleSocialMediaRepository()); }, figlio: ArchitecturesSampleApp()));
Grazie a ciò, nel codice della lista principale, possiamo facilmente chiamare BLoC usando BlocProvider.of() e inviargli un evento usando il metodo add :
la classe SocialMediaListScreen estende StatefulWidget { _SocialMediaListState createState() => _SocialMediaListState(); } class _SocialMediaListState estende State<SocialMediaListScreen> { @oltrepassare Creazione widget (contesto BuildContext) { return BlocBuilder<SocialMediaListBloc, SocialMediaListState>( costruttore: (contesto, stato) { if (lo stato è MainListLoaded) { return ListView( bambini: state.socialMedia .map((item) => CheckboxSocialMediaItem( articolo: articolo, onCheckboxChanged: (isChecked) => BlocProvider.of<SocialMediaBloc>(contesto) .add(CheckboxChecked(isChecked, item.id)), )) .elencare(), ); } altro { return Center(figlio: Text(Strings.emptyList)); } }, ); } }
Abbiamo già la propagazione dell'evento CheckboxChecked a BLoC , sappiamo anche come BLoC risponderà a un tale evento. Ma in realtà... cosa causerà la ricostruzione dell'elenco con la casella di controllo già selezionata? Global BLoC non supporta la modifica degli stati dell'elenco, perché è gestito da singoli oggetti BLoC assegnati alle schermate. La soluzione è l'ascolto precedentemente menzionato di un BLoC globale per cambiare lo stato e rispondere in base a questo stato. Di seguito, il BLoC dedicato alla lista dei principali social media con una checkbox:

classe SocialMediaListBloc estende il blocco<SocialMediaListEvent, SocialMediaListState> { mainBloc SocialMediaBloc finale; SocialMediaListBloc({@required this.mainBloc}) { mainBloc.listen((stato) { if (lo stato è ListPresented) { add(ScreenStart(state.list)); } }); } @oltrepassare SocialMediaListState get initialState => MainListEmpty(); @oltrepassare Stream<SocialMediaListState> mapEventToState( SocialMediaListEvent) asincrono* { interruttore (event.runtimeType) { caso ScreenStart: yield MainListLoaded((evento come ScreenStart).list); rompere; } } }
Quando SocialMediaBloc restituisce lo stato ListPresented , SocialMediaListBloc riceverà una notifica. Si noti che ListPresented trasmette un elenco. È quello che contiene informazioni aggiornate sul controllo dell'elemento con la casella di controllo.
Allo stesso modo, possiamo creare un BLoC dedicato alla schermata dei social media preferiti:
class FavouritesListBloc estende Bloc<FavoritesListEvent, FavouritesListSate> { mainBloc SocialMediaBloc finale; PreferitiListBloc({@required this.mainBloc}) { mainBloc.listen((stato) { if (lo stato è ListPresented) { add(PreferitiScreenStart(state.list)); } }); } @oltrepassare PreferitiListSate get initialState => PreferitiListEmpty(); @oltrepassare Stream<FavoriteListSate> mapEventToState(FavoritesListEvent event) async* { if (l'evento è FavoriteScreenStart) { var favouritesList = event.list.where((item) => item.isFavourite).toList(); yield Elenco Preferiti Caricato(Elenco Preferiti); } } }
La modifica dello stato nel BLoC globale comporta l'attivazione dell'evento FavouritesScreenStart con l'elenco corrente. Quindi, gli elementi contrassegnati come preferiti vengono filtrati e un tale elenco viene visualizzato sullo schermo.
Come creare un modulo con molti campi in Flutter
I moduli lunghi possono essere complicati, soprattutto quando i requisiti presuppongono diverse varianti di convalida o alcune modifiche sullo schermo dopo aver inserito il testo. Nella schermata di esempio, abbiamo un modulo composto da diversi campi e il pulsante "AVANTI". I campi verranno automaticamente convalidati e il pulsante disabilitato fino a quando il modulo non sarà completamente valido. Dopo aver cliccato sul pulsante si aprirà una nuova schermata con i dati inseriti nel form.
Dobbiamo convalidare ogni campo e controllare l'intera correzione del modulo per impostare correttamente lo stato del pulsante. Quindi, i dati raccolti dovranno essere archiviati per la schermata successiva.

Come creare un form con molti campi con Provider
Nella nostra applicazione avremo bisogno di un secondo ChangeNotifier , dedicato alle schermate delle informazioni personali. Possiamo quindi utilizzare il MultiProvider , dove forniamo un elenco di oggetti ChangeNotifier . Saranno disponibili per tutti i discendenti di MultiProvider .
class ArchitecturesSampleApp estende StatelessWidget { repository finale di SimpleSocialMediaRepository; ArchitecturesSampleApp({Key key, this.repository}): super(key: key); @oltrepassare Creazione widget (contesto BuildContext) { restituisce Multiprovider( fornitori: [ ChangeNotifierProvider<SocialMediaModel>( creare: (contesto) => SocialMediaModel(repository), ), ChangeNotifierProvider<PersonalDataNotifier>( creare: (contesto) => PersonalDataNotifier(), ) ], bambino: MaterialApp( titolo: Strings.architecturesSampleApp, debugShowCheckedModeBanner: falso, home: StartScreen(), percorsi: <String, WidgetBuilder>{ Routes.socialMedia: (contesto) => SocialMediaScreen(), Routes.favourites: (contesto) => FavouritesScreen(), Routes.personalDataForm: (contesto) => PersonalDataScreen(), Routes.personalDataInfo: (contesto) => PersonalDataInfoScreen() }, ), ); } }
In questo caso, PersonalDataNotifier agirà come un livello di logica aziendale: convaliderà i campi, avrà accesso al modello di dati per il suo aggiornamento e aggiornerà i campi da cui dipenderà la vista.
Il modulo stesso è un'API molto interessante di Flutter, in cui possiamo allegare automaticamente le convalide utilizzando il validatore di proprietà e salvare i dati dal modulo al modello utilizzando il callback onSaved . Delegheremo le regole di convalida a PersonalDataNotifier e quando il modulo sarà corretto, gli passeremo i dati inseriti.
La cosa più importante in questa schermata sarà ascoltare una modifica in ogni campo e abilitare o disabilitare il pulsante, a seconda del risultato della convalida. Useremo callback onChange dall'oggetto Form . In esso, controlleremo prima lo stato di convalida e quindi lo passeremo a PersonalDataNotifier .
Modulo( chiave: _formKey, autovalida: vero, onChanged: () => _onFormChanged(personalDataNotifier), bambino: void _onFormChanged(PersonalDataNotifier personalDataNotifier) { var isValid = _formKey.currentState.validate(); personalDataNotifier.onFormChanged(isValid); }
In PersonalDataNotifier , prepareremo la variabile isFormValid . Lo modificheremo (non dimenticare di chiamare notificationListeners() ) e nella vista cambieremo lo stato del pulsante in base al suo valore. Ricordarsi di ottenere l'istanza Notifier con il parametro listen: true – in caso contrario, la nostra vista non verrà ricostruita e lo stato del pulsante rimarrà invariato.
var personalDataNotifier = Provider.of<PersonalDataNotifier>(contesto, ascolto: true);
In realtà, dato che utilizziamo personalDataNotifier in altri luoghi, dove non è necessario ricaricare la vista, la riga precedente non è ottimale e dovrebbe avere il parametro listen impostato su false . L'unica cosa che vogliamo ricaricare è il pulsante, così possiamo avvolgerlo in un classico Consumer :
Consumatore<Notificatore di dati personali>( costruttore: (contesto, notificante, figlio) { return pulsante rialzato( figlio: Text(Strings.addressNext), onPressed: notifier.isFormValid ? /* azione quando il pulsante è abilitato */ : nullo, colore: Colori.blu, disabiliColore: Colori.grigio, ); }, )
Grazie a ciò, non forziamo il ricaricamento di altri componenti ogni volta che utilizziamo un notificatore.
Nella visualizzazione dei dati personali non ci saranno più problemi: abbiamo accesso a PersonalDataNotifier e da lì possiamo scaricare il modello aggiornato.
Come creare un modulo con molti campi con BLoC
Per la schermata precedente avevamo bisogno di due oggetti BLoC . Quindi, quando aggiungiamo un altro "doppio schermo", ne avremo quattro in tutto. Come nel caso di Provider , possiamo gestirlo con MultiBlocProvider , che funziona in modo quasi identico.
void main() => runApp( MultiBlocProvider(fornitori: [ Fornitore di blocchi( creare: (contesto) => SocialMediaBloc(SimpleSocialMediaRepository()), ), Fornitore di blocchi( crea: (contesto) => SocialMediaListBloc( mainBloc: BlocProvider.of<SocialMediaBloc>(contesto))), Fornitore di blocchi( creare: (contesto) => PersonalDataBloc(), ), Fornitore di blocchi( crea: (contesto) => PersonalDataInfoBloc( mainBloc: BlocProvider.of<PersonalDataBloc>(contesto)), ) ], figlio: ArchitecturesSampleApp()), );
Come nel modello BLoC , è meglio iniziare con i possibili stati e azioni.
classe astratta PersonalDataState {} la classe NextButtonDisabled estende PersonalDataState {} la classe NextButtonEnabled estende PersonalDataState {} class InputFormCorrect estende PersonalDataState { modello finale dei Dati Personali; InputFormCorrect(questo.modello); }
Ciò che cambia in questa schermata è lo stato del pulsante. Abbiamo quindi bisogno di stati separati per questo. Inoltre, lo stato InputFormCorrect ci consentirà di inviare i dati raccolti dal modulo.
classe astratta PersonalDataEvent {} classe FormInputChanged estende PersonalDataEvent { final bool è valido; FormInputChanged(this.isValid); } classe FormCorrect estende PersonalDataEvent { modulo Dati Personali finaleDati; FormCorrect(this.formData); }
L'ascolto delle modifiche nel modulo è fondamentale, da qui l'evento FormInputChanged . Quando il modulo è corretto, verrà inviato l'evento FormCorrect .
Quando si tratta di convalide, c'è una grande differenza qui se lo confronti con Provider. Se volessimo racchiudere tutta la logica di convalida nel livello BLoC , avremmo molti eventi per ciascuno dei campi. Inoltre, molti stati richiederebbero che la vista mostri i messaggi di convalida.
Questo è, ovviamente, possibile, ma sarebbe come una lotta contro l'API TextFormField invece di usarne i vantaggi. Pertanto, se non ci sono ragioni chiare, puoi lasciare le convalide nel livello di visualizzazione.
Lo stato del pulsante dipenderà dallo stato inviato alla vista da BLoC :
BlocBuilder<Blocco dati personali, Stato dati personali>( costruttore: (contesto, stato) { return pulsante rialzato( figlio: Text(Strings.addressNext), onPressed: lo stato è NextButtonEnabled ? /* azione quando il pulsante è abilitato */ : nullo, colore: Colori.blu, disabiliColore: Colori.grigio, ); })
La gestione degli eventi e la mappatura agli stati in PersonalDataBloc saranno le seguenti:
@oltrepassare Stream<PersonalDataState> mapEventToState(PersonalDataEvent event) async* { if (l'evento è FormCorrect) { yield InputFormCorrect(event.formData); } altrimenti se (l'evento è FormInputChanged) { yield mapFormInputChangedToState(evento); } } PersonalDataState mapFormInputChangedToState (evento FormInputChanged) { se (evento.è valido) { return NextButtonEnabled(); } altro { return NextButtonDisabled(); } }
Per quanto riguarda la schermata con il riepilogo dei dati anagrafici, la situazione è simile all'esempio precedente. Il BLoC allegato a questa schermata recupererà le informazioni sul modello dal BLoC della schermata del modulo.
classe PersonalDataInfoBloc estende il blocco<PersonalDataInfoEvent, PersonalDataInfoState> { blocco dati personali finale blocco principale; PersonalDataInfoBloc({@required this.mainBloc}) { mainBloc.listen((stato) { if (lo stato è InputFormCorrect) { add(PersonalDataInfoScreenStart(state.model)); } }); } @oltrepassare PersonalDataInfoState get initialState => InfoEmpty(); @oltrepassare Stream<PersonalDataInfoState> mapEventToState(PersonalDataInfoEvent event) async* { if (l'evento è PersonalDataInfoScreenStart) { yield InfoLoaded(event.model); } } }
Architettura svolazzante: appunti da ricordare
Gli esempi sopra riportati sono sufficienti per mostrare che ci sono chiare differenze tra le due architetture. BLoC separa molto bene il livello di visualizzazione dalla logica aziendale. Ciò comporta una migliore riusabilità e testabilità. Sembra che per gestire casi semplici, sia necessario scrivere più codice che in Provider . Come sapete, in tal caso, questa architettura Flutter diventerà più utile all'aumentare della complessità dell'applicazione.
Vuoi creare un'app orientata al futuro per la tua azienda?
Mettiamoci in contattoIl provider separa anche bene l'interfaccia utente dalla logica e non forza la creazione di stati separati con ogni interazione dell'utente, il che significa che spesso non è necessario scrivere una grande quantità di codice per gestire un caso semplice. Ma questo può causare problemi nei casi più complessi.
Clicca qui per vedere l'intero progetto.