Blog Tech di Neosperience: l’Innovazione ai Raggi X

Il paradigma Clean nelle applicazioni Flutter

Written by Fabio Donatelli | Feb 19, 2024 11:00:00 AM

“Clean Architecture” è un paradigma di progettazione software che Robert C. Martin propose sul suo blog “Uncle Bob” e che ad oggi rimane uno dei riferimenti principali del software design orientato agli oggetti.

Questo approccio cerca di promuovere una metodologia di sviluppo del codice scalabile, facile da mantenere e che abbia dipendenze ridotte al minimo. L’idea di fondo consiste nell’organizzare la codebase in strati applicativi distinti, ognuno con responsabilità e dipendenze ben definite.

Possiamo quindi pensare a questo approccio come una serie di buone pratiche che un programmatore dovrebbe seguire al fine di applicare i cosiddetti principi SOLID, un acronimo ben conosciuto nel mondo dell’ingegneria del software. Non è un caso infatti che mettere in pratica il  paradigma clean significhi implementare principi come Single Responsability, Dependency Inversion e Separation of concerns.

Nel mondo dello sviluppo mobile, creare applicazioni scalabili e facili da mantenere è uno degli obiettivi primari dei team mobile in Neosperience: la scelta di adottare un framework UI come Flutter per lo sviluppo di applicazioni cross-platform va proprio in questa direzione.

Ecco perché quando un progetto mobile aumenta di complessità e potrebbe diventare complicato da gestire, la necessità di avere un’architettura strutturata diventa fondamentale per ottenere benefici nel lungo periodo in termini di delivery verso il cliente finale e allo stesso tempo efficacia nella manutenzione del progetto.

In questo articolo vedremo come possiamo applicare il paradigma clean nell’ambito dello sviluppo di un’applicazione Flutter e come l’applicazione dei suoi principi determinano la struttura del progetto e il processo di sviluppo della codebase.

Prendendo spunto da un progetto Flutter realizzato per un nostro cliente e attraverso l’analisi dei vari strati applicativi, vedremo come questo paradigma ci ha aiutato a raggiungere con successo obiettivi in termini di manutenzione, testing e flessibilità.

Applicazione dei principi Clean con Flutter

Con il paradigma clean dobbiamo strutturare i componenti applicativi in moduli per i quali possiamo definire uno scopo ben preciso. L’idea principale che ci sta dietro è separare l’applicazione in tre strati principali distinti: data, domain e presentation.

MVVM

Per definire questi tre strati concettuali nell’ambito del nostro progetto mobile abbiamo deciso di utilizzare il pattern MVVM (Model-View-ViewModel), uno dei design pattern più indicati per lo sviluppo software che prevede la presenza di una user interface (UI).

Spesso questo tipo di pattern prevede di creare un meccanismo di binding dei dati, per cui i cambiamenti sulla UI automaticamente aggiornano i dati sottostanti e viceversa. Dal momento che Flutter nativamente non vincola lo sviluppatore sull’adozione MVVM, abbiamo implementato questo pattern strutturando il codice applicativo in modo appropriato.

Model

Il model rappresenta essenzialmente la business logic e i dati. Esso è responsabile della gestione dei dati e garantisce una consistenza applicativa. Generalmente è indipendente dall’interfaccia utente ed è riutilizzabile per differenti strati presentation.

In Flutter questo strato è costituito essenzialmente da classi e oggetti Dart che mantengono lo stato dell’applicazione e non interagisce direttamente con la UI.

Esempio

class JCashStore {
  String id;
  String name;
  String storeNumber;

  JCashStore({
	required this.id, 
	required this.name, 
	required this.storeNumber,
  });
}

View

Lo strato View si occupa di presentare i dati all’utente e intercettarne le sue interazioni. Questo strato deve essere tenuto il più leggero e agnostico possibile e il suo compito principale deve essere quello di visualizzare le informazioni.

È in questo strato che spesso si implementa il meccanismo del data-binding osservando i cambiamenti di stato del ViewModel e aggiornando la UI di conseguenza.

In Flutter lo strato View è implementato dai cosiddetti widgets, componenti responsabili di renderizzare elementi della UI e catturare le interazioni utente. E’ possibile quindi assemblare e organizzare una serie di widget per rappresentare uno screen completo della nostra UI.

Esempio

class JCashStoreInfoButton extends StatelessWidget {
  final JCashStoreViewModel cashStore;

  JCashStoreInfoButton({required this.cashStore});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      title: Text(cashStore.name),
      subtitle: Text(‘#: ${cashStore.storeNumber}’),
	onPressed: () => viewModel.InfoTap(). //capture user interaction
    );
  }
}

ViewModel

Il ViewModel è lo strato responsabile di intermediare tra la View e il Model. Esso contiene logiche su come le informazioni devono essere presentate, trasforma opportunamente i dati del Model e fornisce una serie di ‘comandi’ ai quali lo strato View può agganciarsi (binding) per interagire con i dati presentati.

Come dicevamo in precedenza Flutter non fornisce un oggetto ViewModel predefinito. Per questo motivo possiamo creare una nostra implementazione o affidarci a framework e librerie esterne come Provider, BloC, Riverpod. Nel nostro caso abbiamo scelto il framework BloC, come vedremo più avanti.

Mapping Clean <> MVVM

A questo punto possiamo mappare i tre strati clean nei nostri strati applicativi secondo il pattern MVVM:.

  • Model → include tutti i componenti e le logiche degli strati Domain e Data ed è implementato nelle cartelle models e data delle rispettive funzionalità
  • View → rappresenta l’intero strato Presentation ed è implementato nelle cartelle ui e blocs delle rispettive funzionalità
  • ViewModel → condivide parte dello strato Presentation e Domain, rappresenta il punto di collegamento tr  tra View e Model implementato nelle cartelle blocs delle rispettive funzionalità.
     

Organizzazione delle singole features

Da notare che la scelta di come suddividere e organizzare i file del progetto non è necessariamente vincolata ad un mapping puntuale dei tre strati clean: trattandosi di un insieme di principi da seguire è possibile rappresentarli generando la propria struttura di cartelle e file a seconda delle proprie esigenze. L’ importante è che la struttura risultante del progetto sia conosciuta e condivisa  per l’intero team di sviluppo.

Mapping degli strati Clean sul pattern MVVM

Presentation

Lo strato di Presentation è rappresentato dalla cartella UI della singola feature, in cui sono inclusi tutti i componenti visuali: screens e singoli elementi di UI custom.

Inoltre condivide la cartella blocs con il Domain layer per la parte di gestione dello stato e esposizione del set di API di interazione con l’interfaccia utente.

Responsabilità:

Presenta le informazioni e cattura le interazioni utente. Include tutti i componenti relativi all’interfaccia utente (screens, widgets) collegati ad un determinato stato applicativo e ne controlla il loro stato.

Componenti:

  • Screens (*.sc.dart): rappresentano gli interi screen dell’interfaccia utente
  • Widgets: rappresentano i singoli elementi visuali dell’applicazione utilizzati per comporre gli screens.
  • Blocs: gestiscono le logiche di presentazione e di interazione con i componenti visuali, comunicano con lo strato Domain per attuare i vari Use Case applicativi. Questi oggetti possono incorporare qualsiasi soluzione di gestione dello stato, nel nostro caso faremo uso della libreria BloC.

Struttura del Presentation Layer per singola funzionalità

Domain

Responsabilità:

Lo strato Domain è anche sinonimo di Business Logic Layer o Use Case Layer. Esso include tutte le logiche e le regole business dell’applicazione. Astrae e incapsula le funzionalità essenziali che sono indipendenti da un particolare framework. Nel nostro caso esso è rappresentato dalle cartelle bloc (in condivisione con Presentation), models e repository (in condivisione con lo strato Data)

Componenti:

  • Models: sono gli oggetti fondamentali per rappresentare i concetti e le entità applicative coinvolte.
  • Use Cases: implementano la gestione dei flussi dati tra i vari Models utilizzando le logiche di business definite dall’interfaccia sul repository. Nel nostro caso questo compito è svolto con l’aiuto della libreria BloC.
  • Logiche di Business (Repository): è l’insieme delle funzionalità core del dominio e definisce le modalità di accesso ai dati tramite interfacce.

Struttura del Domain Layer per singola funzionalità

Data

Responsabilità:

Lo strato Data si occupa di comunicare con le sorgenti dati ‘esterne’ come servizi di rete, database. Gestisce inoltre le operazioni sullo storage applicativo e il recupero dei dati. Nel nostro caso esso è rappresentato dalle cartelle data, models/api (in cui risiedono i modelli delle entità esterne all’applicazione) e repository (in cui vi è l’implementazione concreta delle regole di business definite dal domain layer)

Componenti:

  • Repositories: astraggono le modalità di accesso e salvataggio dei dati applicativi implementando concretamente le interfacce definite dal damain layer.
  • Data Entities: rappresentano le strutture dati nel modo in cui vengono rappresentate nei domini esterni al dominio applicativo.
  • Data Sources: implementano concretamente le operazioni/comunicazioni con database, API e servizi esterni. Nel nostro caso i client http per consumare i servizi esposti da backend.

Struttura del Data Layer per singola funzionalità

In una architettura Clean generalmente il flusso delle dipendenze va dall’esterno verso l’interno: questo significa che gli strati applicativi più interni (es. Domain) sono indipendenti da quelli più esterni. Gli strati più interni infatti definiscono le regole applicative in modo astratto mentre gli strati più esterni ne contengono i dettagli implementativi.

Questo modo di organizzare la codebase comporta benefici in termini di separazione dei compiti, modularità e testabilità dei singoli componenti. Se i singoli strati hanno responsabilità distinte allora è possibile sostituire i singoli strati con altri senza produrre effetti collaterali sui quelli restanti e i vantaggi in termini di flessibilità e adattabilità sono concreti.

Injection dei componenti

Prima di passare ad un esempio concreto è bene dedicare qualche riga sul tema della injection dei componenti. Si tratta di avere un meccanismo centralizzato di creazione delle istanze dei vari componenti senza per forza conoscerne i dettagli implementativi.

Questo meccanismo è conosciuto come Dependency Injection ed è molto utile ad esempio quando vogliamo effettuare unit tests su un determinato layer, per cui possiamo fornire un’istanza mocked dei componenti coinvolti nel test.

Nella nostra applicazione Flutter abbiamo quindi implementato un service locator che gestisce in autonomia la creazione delle istanze dei nostri componenti applicativi. La funzione setup() verrà richiamata nella fase di inizializzazione dell’applicazione Flutter all’interno del main(). In questo modo tutte le istanze dei nostri componenti applicativi sono pronte all’occorrenza.

Esempio
import ‘package:get_it/get_it.dart';
import 'package:juice_shared/shared.dart';
import 'http/http_client.service.dart';

final sl = GetIt.instance;

class Locator {
  static JLogger get logger => sl();
  static JCustomerApiClient get apiClient => sl();
  static JCustomerRepository get customerRepository => sl();
 
  static Future setup() async {
    /* Core Services */
    sl.registerSingleton(
      JLogger(),
    );
    
    /* http client service */
    sl.registerSingleton(
	JCustomerApiClient(Dio(basePath: Constants.apiHost)),
    );

    /* customer repository */
    sl.registerSingleton(
	CustomerRepositoryImpl(customerClientApi: Locator.apiClient),
    );

    .. .. .. .. ..
  }
}

Nota: il package get_it ci aiuta ad implementare il service locator sottostante.

Esempio applicativo

Passiamo quindi ad un esempio concreto estratto dal progetto Flutter di un nostro cliente. Nel caso illustrato andremo a recuperare una lista paginata di clienti tramite una API REST esposta dai nostri servizi cloud e dopo aver recuperato i dati, li faremo transitare attraverso i vari strati applicativi che abbiamo definito in precedenza secondo i principi clean.

Nota: Gli snippet di codice che seguiranno sono solo una parte dell’intera codebase, al fine di illustrare le parti salienti che costituiscono l’approccio clean.

Partiamo dallo strato di Domain in cui andiamo a creare:

  • il model della risposta API
  • Il model del customer.
/* customer_search_response.dart */

part 'customer_search_response.g.dart';
part 'customer_search_response.freezed.dart';

@freezed()
class JCustomerSearchResponse with _$JCustomerSearchResponse {
  factory JCustomerSearchResponse({
    required List results,
    required String? cursor,
  }) = _JCustomerSearchResponse;

  factory JCustomerSearchResponse.fromJson(Map<string, dynamic=""> json) =>
    _$JCustomerSearchResponseFromJson(json);
}

/* customer_list.dart */

part 'customer_list.freezed.dart';
part 'customer_list.g.dart';

@freezed()
class JListCustomer with _$JListCustomer {
  factory JListCustomer({
	String? id,
      String? juiceId,
      bool? invoiceEnabled,
      bool? individualPerson,
      String? name,
      String? customerPriceGroup,
      String? phone,
      String? email,
      bool? blocked,
      String? blockedReason,
      @JsonValue('_score') int? score,
      @Default(0) int pendingInvoicesCount,
      }) = _JListCustomer;

  factory JListCustomer.fromJson(Map<string, dynamic=""> json) =>
      _$JListCustomerFromJson(json);
}

Ora sullo strato Data andremo a creare il nostro Data Source, incaricato di consumare l’API.

Dal momento che faremo uso del package retrofit , ci basterà definire un’interfaccia in cui indicare le operazioni  dovrà fare e in cui possiamo definire i parametri della richiesta e come deserializzare la risposta. Retrofit dal canto suo penserà a crearne l’implementazione concreta del nostro datasource. Inoltre per implementare il client http sottostante utilizzeremo il package Dio.

/* customer.client.dart */

import 'package:retrofit/retrofit.dart';
import 'package:dio/dio.dart';

part 'customer.client.g.dart';

@RestApi()
abstract class JCustomerApiClient {
  factory JCustomerApiClient(Dio dio) = _JCustomerApiClient;

  @GET('/customers')
  Future getCustomers(
    @Query('q') String querySearch,
    @Query('size') int size,
    @Query('cursor') String? cursor,
  );
  
  .. .. ..
}

Ora possiamo tornare sul nostro strato Domain per definire l’operazione di recupero dei clienti attraverso un’interfaccia definita sul repository della funzionalità.

/* customer.repository.dart */

abstract class JCustomerRepository {
  Future<either<jcustomersearchresponse, jexception="">> getCustomers(
      String querySearch, String? cursor);
  ...
}

Procediamo quindi a scrivere un’implementazione concreta del nostro repository clienti sullo strato Data:


import 'package:dartz/dartz.dart';
import 'package:juice_shared/shared.dart';
import '/core/locator.dart';
import '../models/customer_search_response.dart';
import '../data/customer.client.dart';

class JCustomerRepositoryImpl extends JCustomerRepository {
  
  final CustomerApiClient customerClientApi;
  
  JCustomerRepositoryImpl({
    required this.customerClientApi,
  });
  
  
  Future<either<jcustomersearchresponse, jexception="">> getCustomers(
        String querySearch, String? cursor) async {
      try {
        var response =
            await customerClientApi.getCustomers(querySearch, 10, cursor);

        return Left(response);
      } catch (e) {
        return Right(JException.fromException(e));
      }
    }
  
  ...
}

Nota: si fa uso della libreria dartz che tra le sue funzionalità ci permette di utilizzare il costrutto Either, utile per gestire gli errori che possono emergere dagli strati sottostanti e gestirli in modo differente rispetto ai casi di successo.

A questo punto abbiamo completato la scrittura dello strato Data e Domain per la funzionalità di fetch della lista clienti e possiamo iniziare a definire lo strato Presentation.

Nel nostro caso abbiamo:

  • Un set di tre file per gestire lo stato e le logiche per avviare l’operazione di recupero della lista clienti dal repository: di fatto è il nostro ViewModel implementato con il package BloC.
  • Un file dedicato alla costruzione dello screen UI per visualizzare la lista clienti.
/* customer_search_event.dart */

part of 'customer_search_bloc.dart';

@freezed()
class CustomerSearchEvent with _$CustomerSearchEvent {
  const factory CustomerSearchEvent.searchCustomers({
    String? query,
    String? cursor,
    @Default(false) bool resetPreviousList,
  }) = SearchCustomers;
}

/* customer_search_state.dart*/
part of 'customer_search_bloc.dart';

@freezed()
class CustomerSearchState with _$CustomerSearchState {
  const CustomerSearchState._();

  const factory CustomerSearchState() = _CustomerSearchState;

  factory CustomerSearchState.initial() = CustomerSearchInitial;
  factory CustomerSearchState.loading() = CustomerSearchLoading;
  
  const factory CustomerSearchState.loaded({
    required List customers,
    String? cursor,
    String? query,
  }) = CustomerSearchLoaded;
  
  const factory CustomerSearchState.failure({
    required String message,
    List? customers,
    String? query,
  }) = CustomerSearchFailure;
}

/* customer_search_bloc.dart */

import 'package:flutter_bloc/flutter_bloc.dart';
import '../../data/customer.repository.dart';

part 'customer_search_event.dart';
part 'customer_search_state.dart';
part 'customer_search_bloc.freezed.dart';

class CustomerSearchBloc extends Bloc<customersearchevent, customersearchstate=""> {
  
  final JCustomerRepository customerRepository;

  CustomerSearchBloc({
    required this.customerRepository,
  }) : super(CustomerSearchInitial()) {
    on(_onCustomerSearch);
  }
  
  Future _onCustomerSearch(
    SearchCustomers event,
    Emitter emit,
  ) async {
    List customers =
        state.mapOrNull(
                (value) => [],
            loaded: (state) => (state.customers)) ?? [];

    if(event.resetPreviousList || customers.isEmpty || event.query != state.query)     
    {
      emit(CustomerSearchLoading());
      customers = [];
    }

    final result = await customerRepository.getCustomers(
      event.query ?? '',
      event.cursor,
    );

    result.fold(
        (customerList) {
          emit(
              CustomerSearchLoaded(
                customers: customers + customerList.results,
                cursor: customerList.cursor,
                query: event.query,
              )
            );
        },
        (error) =>
            emit(CustomerSearchFailure(message: error.getMessage())));
  }
}

In questo modo ora abbiamo la possibilità di:

  • intercettare le interazioni utente dal momento che li gestiremo come eventi lanciati dalla UI
  • richiamare l’operazione di recupero dei clienti dal relativo repository

Lo screen incaricato di visualizzare la lista clienti renderizza la UI a partire dallo stato applicativo in cui il viewModel si trova (initial, loading, loaded, failure) e per ognuno degli stati censiti costruisce la UI appropriata, utilizzando i widget messi disposizione da Flutter e i widget custom creati ad hoc.

/* customer_search.sc.dart */

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:juice_ui/ui.dart';
import 'widgets/customer-search.dart';

class JSearchCustomerScreen extends StatefulWidget {
  const JSearchCustomerScreen({ super.key });
  
  @override
  State createState() => _JSearchCustomerScreenState();
}

class _JSearchCustomerScreenState extends State {
  
  final late CustomerSearchBloc _vm;;
  
  @override
  void initState() {
    _vm = CustomerSearchBloc(
      customerRepository: Locator.customerRepository,
    );
    
    _fetchCustomers();
    
    super.initState();
  }
  
  void _fetchCustomers({String? query, String? cursor}) {
    _bloc.add(
      CustomerSearchEvent.searchCustomers(
          query: query,
          cursor: cursor,
      ),
    );
  }
  
  @override
  Widget build(BuildContext context) {
    return BlocConsumer<customersearchbloc, customersearchstate="">(
      listener: (context, state) {
        state.whenOrNull(
          () => null, 
          failure: (message, customers, _) {
            if (customers != null) {
              JToastrService()
                .showToast(context, message, JToastStatus.error, true);
            }
          },
        );
      },
      builder: (context, state) => _buildContent(state),
    );
 }
 
  Widget _buildContent(CustomerSearchState state) {
    
    /* customer list UI */

    return Column(
      children: [
	  Expanded(
          child: JCard(
            padding: const EdgeInsets.all(0.0),
            body: JCustomerSearch(
              query: state.query,
              onSubmit: (query) => 
              _fetchCustomers(query: query, cursor: null),
            ),
          ),
        ),
        state.maybeWhen(
          () => const SizedBox(
            height: 0,
          ),
          loading: () => const Expanded(child: Center(child: JSpinner())),
          loaded: (costumers, _, query) {
            if (customers.isEmpty) {
              return Center(
                  child: JH4(
                'Nessun risultato per $query',
                color: JPalette.grey,
              ));
            }
            return const SizedBox(height: 0);
          },
          orElse: () => const SizedBox(height: 0),
        ),
        Expanded(
          child: ListView.separated(
            separatorBuilder: (context, index) => const SizedBox(height: 10.0),
            itemCount:
                state.cursor != null ? 
			state.customers.length + 1 : state.customers.length,
            itemBuilder: (context, i) {
              if (i == state.customers.length) {
                // fetch the next customer page
                _fetchCustomers(query: state.query, cursor: state.cursor);
                return const Padding(
                    padding: EdgeInsets.only(top: 10.0, bottom: 10.0),
                    child: Center(child: JSpinner()),
                );
              }
              /* render a tappable customer card info*/
              return JCustomerCard(
                customer: state.customers[i],
                onTap: () {
                   /* …Go to customer detail screen …*/
                },
              );
            },
          ),
        ),
      ],
    );
  }
}

Anche in questo caso lo screen crea un'istanza del proprio viewModel utilizzando il service locator e la Dependency Injection per iniettare come dipendenza il repository appropriato. Questo assicura un testing efficiente della singola funzionalità ad esempio iniettando un’istanza

Considerazioni finali

L’architettura Clean nelle applicazioni Flutter può apportare benefici concreti ma dovrebbe essere fatta una valutazione accurata per decidere se adottarla o meno, in quanto questo approccio può influenzare anche di molto le scelte delle modalità di sviluppo dell’applicazione. Ci sono vari fattori da prendere in considerazione, ad esempio la grandezza e la complessità dell’applicazione.

Possiamo in ogni caso fare delle considerazioni generali:

  • Velocità di sviluppo: inizialmente lo sviluppo sarà più lento dal momento che il team dovrà tenere sempre a mente se si stanno rispettando i principi clean durante l’intero ciclo di sviluppo. In questi casi condividere uno scaffolding di progetto e una documentazione sulla struttura, aiuterà sicuramente i componenti del team.
  • Overhead di complessità: l’approccio clean non è sicuramente adatto per progetti applicativi piccoli o di minore complessità. App molto semplici da realizzare non avrebbero nessun beneficio dall’introduzione di questo approccio, anzi probabilmente ne subirebbero lo svantaggio di avere una complessità non necessaria, introdotta da layer aggiuntivi e astrazioni varie.
  • Curva di apprendimento: dal momento che un’architettura clean introduce strati e concetti con cui confrontarsi, potrebbe risultare difficoltoso entrare nel mindset necessario, soprattutto per sviluppatori che non hanno  mai avuto a che fare con approcci di questo tipo.

In generale possiamo quindi dire che applicare un approccio Clean alla propria architettura software può essere un’ottima soluzione per la realizzazione di app Flutter, specialmente se ci aspettiamo una crescita dell’applicazione o viene richiesto un alto grado di manutenzione e testabilità.

Come sempre quindi è essenziale un’attenta valutazione per capire come bilanciare i benefici che introdurrebbe questo approccio con i vincoli progettuali.