Scheiding van zorgen in fluttertoepassingen
Engineering at ClickUp

Scheiding van zorgen in fluttertoepassingen

Onlangs moest ik onboarding walkthroughs implementeren voor ClickUp nieuwkomers! Dit was echt een belangrijke Taak omdat veel nieuwe gebruikers op het punt stonden het platform te ontdekken met de ongelooflijk grappige advertentie die we tijdens de Super Bowl in première lieten gaan ! ✨

via ClickUp

Met de walkthrough kunnen onze vele nieuwe gebruikers, die ClickUp misschien nog niet kennen, snel begrijpen hoe ze verschillende functies van de applicatie kunnen gebruiken. Het is een voortdurende inspanning, net als de nieuwe ClickUp University bron die we nastreven! 🚀

Gelukkig kon ik dankzij de softwarearchitectuur achter de ClickUp Flutter mobiele applicatie deze functie vrij snel implementeren, zelfs door de echte widgets van de applicatie te hergebruiken! Dit betekent dat de walkthrough dynamisch en responsief is en precies overeenkomt met de echte applicatieschermen van de app - en dat zal zo blijven, zelfs als de widgets evolueren.

Ik was ook in staat om de functie te implementeren door de juiste scheiding van belangen.

Laten we eens kijken wat ik bedoel. 🤔

Scheiding van zorgen

Het ontwerpen van een softwarearchitectuur is een van de meest complexe onderwerpen voor engineering teams. Naast alle verantwoordelijkheden is het altijd moeilijk om te anticiperen op toekomstige software-evoluties. Daarom kan het creëren van een goed gelaagde en ontkoppelde architectuur jou en je teamgenoten met veel dingen helpen!

Het belangrijkste voordeel van het maken van kleine ontkoppelde systemen is ongetwijfeld de testbaarheid! En dit heeft me geholpen om een demo-alternatief te maken van de bestaande schermen van de app!

Een stap-voor-stap handleiding

Hoe kunnen we deze principes toepassen op een Flutter-applicatie?

We delen een paar technieken die we gebruiken om ClickUp te bouwen met een eenvoudig voorbeeld.

Het voorbeeld is zo eenvoudig dat het misschien niet alle voordelen erachter laat zien, maar geloof me, het zal je helpen om veel beter onderhoudbare Flutter-applicaties te maken met complexe codebases. 💡

De toepassing

Als voorbeeld maken we een applicatie die de populatie van de Verenigde Staten voor elk jaar weergeeft.

via ClickUp

We hebben hier twee schermen:

  • homeScreen: gewoon een lijst met alle jaren van 2000 tot nu. Als de gebruiker op een jaartegel tikt, navigeert hij naar hetDetailScreen` met een navigatieargument dat is ingesteld op het geselecteerde jaar.
  • DetailScreen: haalt het jaar op uit het navigatieargument, roept hetdatausa.io API voor dit jaar en parseert de JSON-gegevens om de bijbehorende waarde van de populatie te krijgen. Als er gegevens beschikbaar zijn, wordt er een label met de populatie weergegeven.

We zullen ons richten op de DetailScreen implementatie omdat deze de meest interessante is met zijn asynchrone aanroep.

Stap 1. Naïeve benadering

/$$$img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-5-1400x607.png Naïeve benadering Stateful Widget /%img/

via ClickUp

De meest voor de hand liggende implementatie voor onze app is het gebruik van een enkele StatefulWidget voor de hele logica. Dart broncode en demo

Toegang tot het jaar navigatie argument

Om toegang te krijgen tot het gevraagde jaar lezen we de RouteSettings uit de ModalRoute geërfde widget.

id didChangeDependencies() {
    super.didChangeDependencies();
    eindjaar = ModalRoute.of(context)!.instellingen.argumenten als int;
    // ...
}

HTTP-oproep

In dit voorbeeld wordt de get functie uit het http pakket aangeroepen om de gegevens uit de datausa.io API , parseert de resulterende JSON met de jsonDecode methode van de dart:convert bibliotheek en houdt Future als onderdeel van de status met een eigenschap genaamd _future.

late Future<Map<dynamic, dynamic>?> _future;


id didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.arguments als int;
    als (_year != jaar) {
      _future = _loadMeasure(year);
    }
}


Future<Map<dynamisch>?> _loadMeasure(int year) async {
    _year = jaar;
    uiteindelijke uri = Uri.parse(
        'https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=$jaar');
    definitief resultaat = await get(uri);
    final body = jsonDecode(result.body);
    final data = body['data'] als Lijst<dynamisch>;
    if (data.isNotEmpty) {
      return data.first;
    }
    return null;

renderen

Om de boom van de widgets te maken, gebruiken we een FutureBuilder, die zichzelf herbouwt aan de hand van de huidige status van onze _future asynchrone oproep.

@override
Widget build(BuildContext context) {
return Scaffold(
    appBar: AppBar(
    titel: Tekst('Jaar $_jaar'),
    ),
    lichaam: FutureBuilder<In kaart brengen<dynamisch>?>(
    toekomst: _future,
    builder: (context, snapshot) {
        switch (snapshot.connectionState) {
        geval ConnectionState.done:
            final error = snapshot.fout;
            if (fout !.= null) {
                // return "error" tree.
            }
            final data = snapshot.data;
            if (data != null) {
                // return "resultaat" tree.
            }
            // retourneer "lege" gegevens tree.case ConnectionState.none:
        case ConnectionState.waiting:
        case ConnectionState.active:
            // return "loading" data tree.
        }
    },
    ),
);
}

Review

Oké, de implementatie is kort en gebruikt alleen ingebouwde widgets, maar denk nu aan onze oorspronkelijke bedoeling: demo-alternatieven (of tests) bouwen voor dit scherm. Het is erg moeilijk om het resultaat van de HTTP-aanroep te controleren om de toepassing te dwingen in een bepaalde staat te renderen.

Dat is waar het concept van inversie van controle ons zal helpen. 🧐

Stap 2. Omkering van controle

Omkering van besturing naar provider en API client

via ClickUp

Dit principe kan moeilijk te begrijpen zijn voor nieuwe ontwikkelaars (en ook moeilijk uit te leggen), maar het algemene idee is om de zorgen buiten onze componenten te plaatsen, zodat ze niet verantwoordelijk zijn voor het kiezen van het gedrag, en het in plaats daarvan te delegeren.

In een meer gebruikelijke situatie bestaat het gewoon uit het maken van abstracties en het injecteren van implementaties in onze componenten zodat hun implementatie later veranderd kan worden indien nodig.

Maar maak je geen zorgen, het zal duidelijker worden na ons volgende voorbeeld! 👀 Dart broncode en demo

Een API client object maken

Om de HTTP-aanroep naar onze API te regelen, hebben we onze implementatie geïsoleerd in een speciale DataUsaApiClient klasse. We hebben ook een Measure klasse gemaakt om gegevens gemakkelijker te kunnen manipuleren en onderhouden.

klasse DataUsaApiClient {
  const DataUsaApiClient({
    this.endpoint = 'https://datausa.io/api/data',
  });


  uiteindelijke reeks Eindpunt;


  Future<Measure?> getMeasure(int year) async {
    uiteindelijke uri =
        Uri.parse('$endpoint?drilldowns=Nation&measures=Population&year=$year');
    eindresultaat = await get(uri);
    uiteindelijke body = jsonDecode(result.body);
    final data = body['data'] als Lijst<dynamisch>;
    als (data.isNotEmpty) {
      return Measure.fromJson(data.first als in kaart brengen<String, Object?>);
    }
    return null;
  }

Een API client aanbieden

Voor ons voorbeeld gebruiken we de bekende provider pakket om een DataUsaApiClient Instance in de root van onze tree te injecteren.

Provider<DataUsaApiClient>(
    create: (context) => const DataUsaApiClient(),
        kind: const MaterialApp(
        startpagina: HomePage(),
    ),
)

De client API gebruiken

De provider staat elke afgeleide widget (_zoals onze _DetailScreen_) toe om de dichtstbijzijnde DataUsaApiClient boven in de boom te lezen. We kunnen dan de getMeasure methode gebruiken om onze Future te starten, in plaats van de eigenlijke HTTP implementatie.

@overridevoid didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.arguments als int;
    if (_year != year) {
      _year = jaar;
      finale api = context.read<DataUsaApiClient>();
      _future = api.getMeasure(year);
    }
}

Demo API client

Nu kunnen we hier ons voordeel mee doen!

Voor het geval je het nog niet wist: elke klasse in dart definiëren ook impliciet een bijbehorende interface . Hierdoor kunnen we een alternatieve implementatie van DataUsaApiClient bieden die altijd dezelfde Instance teruggeeft van de getMeasure methode aanroepen.

Deze methode

klasse DemoDataUsaApiClient implementeert DataUsaApiClient {
  const DemoDataUsaApiClient(this.measure);


  uiteindelijke maatregel;


  @overrideString get eindpunt => '';


  @override
  Future<Maat?> getMeasure(int jaar) {
    return Future.value(measure);
  }

Een demopagina weergeven

We hebben nu alle sleutels om een demo Instance van de DetailPage weer te geven!

We overschrijven gewoon de momenteel geleverde DataUsaApiClient instance door onze DetailScreen in een provider te wikkelen die in plaats daarvan een DemoDataUsaApiClient instance aanmaakt!

En dat is het - ons DetailScreen leest in plaats daarvan deze demo Instance en gebruikt onze demoMeasure gegevens in plaats van een HTTP-oproep.

Lijsttegel(
    titel: const Tekst('Open demo'),
    onTap: () {
        const demoMeasure = Measure(
            jaar: 2022,
            populatie: 425484,
            natie: "Verenigde Staten",
        );
        Navigator.push(
            context,
            MateriaalPaginaRoute(
                instellingen: RouteSettings(arguments: demoMeasure.year),
                builder: (context) {
                    return Provider<DataUsaApiClient>(
                        aanmaken: (context) =>
                            const DemoDataUsaApiClient(demoMeasure),
                        kind: const DetailScreen(),
                    );
                },
            ),
        );
    },
)

Review

Dit is een geweldig voorbeeld van Inversion of control. Onze DetailScreen widget is niet meer verantwoordelijk voor de logica van het verkrijgen van de gegevens, maar delegeert dit in plaats daarvan naar een speciaal client object. En we zijn nu in staat om demo instances van het scherm te maken, of om widget tests voor ons scherm te implementeren! Geweldig! 👏

Maar we kunnen nog beter doen!

Omdat we niet in staat zijn om bijvoorbeeld een laadstatus te simuleren, hebben we geen volledige controle over elke toestandsverandering op het niveau van onze widget.

Stap 3. Toestandsbeheer

/$$$img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-7-1400x607.png Toestandsbeheer in fluttertoepassingen /%img/

via ClickUp

Dit is een hot topic in Flutter!

Ik weet zeker dat je al lange threads hebt gelezen van mensen die proberen de beste state-management oplossing voor Flutter te kiezen. En om duidelijk te zijn, dat is niet wat we in dit artikel zullen doen. Naar onze mening, zolang je je business logica scheidt van je visuele logica, zit je goed! Het creëren van deze lagen is echt belangrijk voor de onderhoudbaarheid. Ons voorbeeld is eenvoudig, maar in echte toepassingen kan logica snel complex worden en zo'n scheiding maakt het een stuk eenvoudiger om je pure logica-algoritmen terug te vinden. Dit onderwerp wordt vaak samengevat als staatsbeheer.

In dit voorbeeld gebruiken we een basis ValueNotifier naast een Provider. Maar we hadden ook fladderaar of rivierpoot (of een andere oplossing), en het zou ook geweldig gewerkt hebben. De principes blijven hetzelfde en zolang je je toestanden en logica gescheiden hebt, is het zelfs mogelijk om je codebase over te zetten van een van de andere oplossingen.

Deze scheiding helpt ons ook om elke toestand van onze widgets te controleren, zodat we ze op elke mogelijke manier kunnen bespotten! Dart broncode en demo

Een speciale status aanmaken

In plaats van te vertrouwen op de AsyncSnapshot van het framework, representeren we onze schermstatus nu als een DetailState object.

Het is ook belangrijk om de hashCode en operator == methodes te implementeren om ons object vergelijkbaar te maken door waarde. Hierdoor kunnen we detecteren of twee Instances als verschillend moeten worden beschouwd.

💡 De gelijkwaardig of bevroren pakketten zijn geweldige opties om je te helpen de hashCode en operator == methoden te implementeren!

abstracte klasse DetailState {
  const DetailState(this.year);
  final int jaar;


  @overridebool operator ==(Object ander) =>
      identiek(dit, ander) ||
      (other is DetailState &&
          runtimeType == other.runtimeType &&
          year == other.year);


  @overrideint get hashCode => runtimeType.hashCode ^ jaar;

Onze status is altijd geassocieerd met een jaar, maar we hebben ook vier verschillende mogelijke statussen met betrekking tot wat we aan de gebruiker willen laten zien:

  • NotLoadedDetailState: de gegevensupdate is nog niet begonnen
  • LoadingDetailState: de gegevens worden momenteel geladen
  • loadedDetailState: de gegevens zijn succesvol geladen met een bijbehorendemaatregel`
  • NoDataDetailState: de gegevens zijn geladen, maar er zijn geen gegevens beschikbaar
  • UnknownErrorDetailState: de bewerking is mislukt vanwege een onbekende fout
klasse NotLoadedDetailState breidt DetailState uit {
  const NotLoadedDetailState(int year) : super(year);
}


klasse LoadedDetailState uitbreidt DetailState {
  const LoadedDetailState({
    vereist int jaar,
    vereist this.measure,
  }) : super(year);


  laatste maatregel maatregel;


  @overridebool operator ==(Object ander) =>
      identiek(dit, ander) ||
      (other is LoadedDetailState && measure == other.measure);


  @overrideint get hashCode => runtimeType.hashCode ^ measure.hashCode;
}


klasse NoDataDetailState breidt DetailState uit {
  const NoDataDetailState(int year) : super(year);
}


klasse LoadingDetailState breidt DetailState uit {
  const LoadingDetailState(int year) : super(year);
}


klasse UnknownErrorDetailState breidt DetailState uit {
  const UnknownErrorDetailState({
    vereist int jaar,
    required this.error,
  }) : super(year);


  laatste dynamische fout;


  @overridebool operator ==(Object ander) =>
      identiek(dit, ander) ||
      (other is UnknownErrorDetailState &&
          year == other.year &&
          fout == other.error);


  @overrideint get hashCode => Object.hash(super.hashCode, fout.heeft

Deze toestanden zijn duidelijker dan een AsyncSnapshot, omdat het echt onze use cases weergeeft. En nogmaals, dit maakt onze code beter onderhoudbaar!

💡 We raden de Union-types uit het bevroren pakket om je logische toestanden weer te geven! Het voegt veel utilities toe zoals de copyWith of map methoden.

De logica in een Notifier plaatsen

Nu we een representatie van onze status hebben, moeten we een Instance ervan ergens opslaan-dat is het doel van de DetailNotifier. Deze bewaart de huidige DetailState Instance in zijn value eigenschap en biedt methoden om de status bij te werken.

We bieden een NotLoadedDetailState initiële status en een refresh methode om gegevens van de api te laden en de huidige waarde bij te werken.

class DetailNotifier extends ValueNotifier<DetailState> {
  DetailNotifier({
    vereist int jaar,
    vereist this.api,
  }) : super(DetailState.notLoaded(year));


  uiteindelijke DataUsaApiClient api;


  int get year => waarde.jaar;


  Future<void> verversen() async {
    als (waarde is! LoadingDetailState) {
      waarde = DetailState.loading(year);
      try {
        resultaat = await api.getMeasure(year);
        if (resultaat != null) {
          waarde = DetailState.loaded(
            jaar: jaar,
            maat: resultaat,
          );
        } anders {
          waarde = DetailState.noData(jaar);
        }
      } vangst (fout) {
        waarde = DetailState.unknownError(
          jaar: jaar,
          fout: fout,
        );
      }
    }
  }

Geef een status voor de weergave

Om de staat van ons scherm te instantiëren en observeren, vertrouwen we ook op de provider en zijn ChangeNotifierProvider. Dit soort provider zoekt automatisch naar elke ChangeListener die is aangemaakt en zal een rebuild triggeren van de Consumer elke keer als deze een melding krijgt van een verandering (als onze notifier waarde anders is dan de vorige).

klasse DetailScreen breidt StatelessWidget uit {
  const DetailScreen({
    Sleutel: sleutel,
  }) : super(sleutel: sleutel);


  @override
  Widget bouwen(BuildContext context) {
    uiteindelijke jaar = ModalRoute.of(context)!.instellingen.argumenten als int;
    return ChangeNotifierProvider<DetailNotifier>(
      aanmaken: (context) {
        uiteindelijke melder = DetailNotifier(
          jaar: jaar,
          api: context.read<DataUsaApiClient>(),
        );
        notifier.refresh();
        notifier retourneren;
      },
      kind: Consumer<DetailNotifier>(
        builder: (context, notifier, child) {
             uiteindelijke status = notifier.waarde;
            // ...
        },
      ),
    );
  }
}

Review

Geweldig! De architectuur van onze applicatie begint er goed uit te zien. Alles is onderverdeeld in goed gedefinieerde lagen, met specifieke aandachtspunten! 🤗

Er ontbreekt echter nog één ding voor de testbaarheid: we willen de huidige DetailState definiëren om de status van ons bijbehorende DetailScreen te regelen.

Stap 4. Visual dedicated layout widget

Visual dedicated layout widgets in Flutter-toepassingen

via ClickUp

In de laatste stap gaven we een beetje te veel verantwoordelijkheid aan onze DetailScreen widget: het was verantwoordelijk voor het instantiëren van de DetailNotifier. En zoals we eerder hebben gezien, proberen we logische verantwoordelijkheid op de weergave laag te vermijden!

We kunnen dit eenvoudig oplossen door een andere laag te maken voor ons schermwidget: we splitsen ons DetailScreen widget in tweeën:

  • DetailScreen is verantwoordelijk voor de instelling van de verschillende afhankelijkheid van ons scherm vanuit de huidige applicatietoestand (navigatie, notifiers, state, services, ...),
  • DetailLayout zet eenvoudig een DetailState om in een specifieke boom van widgets.

Door de twee te combineren, kunnen we eenvoudig DetailLayout demo/test instanties maken, maar met DetailScreen voor het echte gebruik in onze applicatie. Dart broncode en demo

Speciale layout

Voor een betere scheiding van zorgen hebben we alles onder de Consumer widget verplaatst naar een speciale DetailLayout widget. Deze nieuwe widget verbruikt alleen gegevens en is niet verantwoordelijk voor instantiëring. Het converteert alleen de leestoestand naar een specifieke widgetstructuur.

De ModalRoute.of aanroep en de ChangeNotifierProvider Instance blijven in het DetailScreen en deze widget retourneert eenvoudigweg de DetailLayout met een vooraf geconfigureerde afhankelijkheidstructuur!

Deze kleine verbetering is specifiek voor het gebruik van de provider, maar je zult merken dat we ook een ProxyProvider hebben toegevoegd zodat elke afgeleide widget direct een DetailState kan consumeren. Dit maakt het eenvoudiger om gegevens te bespotten.

klasse DetailScreen breidt StatelessWidget uit {
  const DetailScreen({
    Sleutel: sleutel,
  }) : super(sleutel: sleutel);


  @override
  Widget bouwen(BuildContext context) {
    uiteindelijke jaar = ModalRoute.of(context)!.instellingen.argumenten als int;
    return ChangeNotifierProvider<DetailNotifier>(
      aanmaken: (context) {
        uiteindelijke melder = DetailNotifier(
          jaar: jaar,
          api: context.read<DataUsaApiClient>(),
        );
        notifier.refresh();
        notifier retourneren;
      },
      kind: kind: ProxyProvider<DetailNotifier, DetailState>(
        update: (context, waarde, vorige) => waarde.value,
        kind: const DetailLayout(),
      ),
    );
  }
}


klasse DetailLayout uitbreidt StatelessWidget {
  const DetailLayout({
    Sleutel: sleutel,
  }) : super(sleutel: sleutel);


  @override
  Widget bouwen(BuildContext context) {
    return Consumer<DetailState>(
      builder: (context, state, child) {
        return Scaffold(
          appBar: AppBar(
            titel: Tekst('Jaar ${staat.jaar}'),
          ),
          lichaam: () {
              // ...
          }(),
        );
      },
    );
  }

Widgets extraheren als specifieke klassen

Aarzel nooit om een widgetboom uit te pakken in een specifieke klasse! Het zal de prestaties verbeteren en de code beter onderhoudbaar maken.

In ons voorbeeld hebben we een visual layout widget gemaakt voor elk van de geassocieerde toestandstypen:

if (status is NotLoadedDetailState || status is LoadingDetailState) {
    return const LoadingDetailLayout();
}
if (state is LoadedDetailState) {
    return LoadedDetailLayout(status: status);
}
if (state is UnknownErrorDetailState) {
    return UnknownErrorDetailLayout(state: state);
}
return const NoDataDetailLayout();

Demo instanties

Nu hebben we volledige controle over wat we kunnen bespotten en weergeven op ons scherm!

We hoeven alleen maar een DetailLayout in te pakken met een Provider<DetailState> om de status van de layout te simuleren.

Lijsttegel(
    titel: const Tekst('Open "geladen" demo'),
    onTap: () {
        Navigator.push(
        context,
        MaterialPageRoute(
            builder: (context) {
            return Provider<DetailState>.waarde(
                    waarde: const DetailState.loaded(
                    jaar: 2022,
                    maatregel: Maatregel(
                        jaar: 2022,
                        populatie: 425484,
                        natie: "Verenigde Staten",
                    ),
                    ),
                    kind: const DetailLayout(),
                );
            },
        ),
        );
    },
),
Lijsttegel(
    titel: const Tekst('Open "loading" demo'),
    onTap: () {
        Navigator.push(
        context,
        MaterialPageRoute(
            builder: (context) {
                    return Provider<DetailState>.waarde(
                        waarde: const DetailState.loading(2022),
                        child: const DetailLayout(),
                    );
                },
            ),
        );
    },
),

Conclusie

Het maken van een onderhoudbare softwarearchitectuur is zeker niet eenvoudig! Anticiperen op toekomstige scenario's kan veel inspanning vergen, maar ik hoop dat de paar tips die ik deelde je in de toekomst zullen helpen!

De voorbeelden zien er misschien eenvoudig uit - het lijkt misschien alsof we over-engineering toepassen - maar als de complexiteit van je app groeit, zullen deze standaarden je enorm helpen! 💪

Veel plezier met Flutter, en volg de blog voor meer technische artikelen zoals deze! Blijf op de hoogte!