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 het
DetailScreen` 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
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
enoperator ==
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 begonnenLoadingDetailState
: de gegevens worden momenteel geladen- loadedDetailState
: de gegevens zijn succesvol geladen met een bijbehorende
maatregel` NoDataDetailState
: de gegevens zijn geladen, maar er zijn geen gegevens beschikbaarUnknownErrorDetailState
: de bewerking is mislukt vanwege een onbekendefout
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
ofmap
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
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 eenDetailState
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!