Niedawno musiałem wdrożyć onboarding walkthroughs dla ClickUp nowi użytkownicy! Było to naprawdę ważne zadanie, ponieważ wielu nowych użytkowników miało wkrótce odkryć platformę z niesamowicie zabawna reklama, którą mieliśmy premierę podczas Super Bowl ! ✨
przez ClickUp
Przewodnik pozwala naszym licznym nowym użytkownikom, którzy być może nie znają jeszcze ClickUp, szybko zrozumieć, jak korzystać z kilku funkcji aplikacji. Jest to ciągły wysiłek, podobnie jak nowy ClickUp University zasób, do którego dążymy! 🚀
Na szczęście architektura oprogramowania stojącego za aplikacją mobilną ClickUp Flutter pozwoliła mi dość szybko wdrożyć tę funkcję, nawet poprzez ponowne wykorzystanie rzeczywistych widżetów z aplikacji! Oznacza to, że przewodnik jest dynamiczny, responsywny i dokładnie pasuje do rzeczywistych ekranów aplikacji - i nadal będzie, nawet gdy widżety będą ewoluować.
Byłem również w stanie zaimplementować tę funkcję dzięki odpowiedniemu rozdzieleniu obaw.
Zobaczmy, co mam na myśli. 🤔
Rozdzielenie obaw
Projektowanie architektury oprogramowania jest jednym z najbardziej złożonych tematów dla zespołów inżynierskich. Wśród wszystkich obowiązków zawsze trudno jest przewidzieć przyszłe ewolucje oprogramowania. Dlatego właśnie stworzenie dobrze warstwowej i rozdzielonej architektury może pomóc Tobie i Twoim kolegom z zespołu w wielu kwestiach!
Główną zaletą tworzenia małych, odizolowanych systemów jest bez wątpienia testowalność! I to właśnie pomogło mi stworzyć alternatywę demo istniejących ekranów z aplikacji!
Przewodnik krok po kroku
Teraz, jak moglibyśmy zastosować te zasady do aplikacji Flutter?
Podzielimy się kilkoma technikami, których używamy do budowy ClickUp z prostym przykładem walkthrough.
Przykład jest tak prosty, że może nie wyjaśnić wszystkich kryjących się za nim zalet, ale uwierz mi, pomoże ci stworzyć o wiele łatwiejsze w utrzymaniu aplikacje Flutter ze złożonymi bazami kodu. 💡
Aplikacja
Jako przykład stworzymy aplikację, która wyświetla populację USA dla każdego roku.
via ClickUp
Mamy tutaj dwa ekrany:
HomeScreen
: po prostu lista wszystkich lat od 2000 do teraz. Gdy użytkownik dotknie kafelka roku, przejdzie do ekranuDetailScreen
z argumentem nawigacji ustawionym na wybrany rok.DetailScreen
: pobiera rok z argumentu nawigacji, wywołuje funkcjęaPI datausa.io dla tego roku i analizuje dane JSON, aby wyodrębnić powiązaną wartość populacji. Jeśli dane są dostępne, wyświetlana jest etykieta z populacją.
Skupimy się na implementacji DetailScreen
, ponieważ jest ona najbardziej odsetkowa ze względu na jej asynchroniczne wywołanie.
Krok 1. Podejście naiwne
przez ClickUp
Najbardziej oczywistą implementacją dla naszej aplikacji jest użycie pojedynczego StatefulWidget
dla całej logiki.
Kod źródłowy i demo Dart
Dostęp do argumentu nawigacyjnego year
Aby uzyskać dostęp do żądanego roku, odczytujemy RouteSettings
z odziedziczonego widżetu ModalRoute
.
void didChangeDependencies() {
super.didChangeDependencies();
final year = ModalRoute.of(context)!.settings.arguments as int;
// ...
}
Wywołanie HTTP
Ten przykład wywołuje funkcję get
z pakietu http
, aby pobrać dane z pliku
aPI datausa.io
, parsuje wynikowy JSON za pomocą metody jsonDecode
z biblioteki dart:convert
i przechowuje Future
jako część stanu z właściwością o nazwie _future
.
late Future<Map<dynamic, dynamic>?> _future;
void didChangeDependencies() {
super.didChangeDependencies();
final year = ModalRoute.of(context)!.settings.arguments as int;
if (_year != year) {
_future = _loadMeasure(year);
}
}
Future<Map<dynamic, dynamic>?> _loadMeasure(int year) async {
_year = year;
final uri = Uri.parse(
'https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=$year');
final result = await get(uri);
final body = jsonDecode(result.body);
final data = body['data'] as Lista<dynamiczna>;
if (data.isNotEmpty) {
return data.first;
}
return null;
Rendering
Aby utworzyć drzewo widżetów, używamy FutureBuilder
, który odbudowuje się w odniesieniu do bieżącego stanu naszego asynchronicznego wywołania _future
.
@override
Widżet build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Year $_year',)
),
body: FutureBuilder<Mapa<dynamiczna, dynamiczna>?>(
future: _future,
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.zrobione:
final error = snapshot.error;
if (error != null) {
// return "error" tree.
}
final data = snapshot.data;
if (data != null) {
// zwróć drzewo "wynik".
}
// return "empty" data tree.case ConnectionState.none:
case ConnectionState.waiting:
case ConnectionState.active:
// return "loading" data tree.
}
},
),
);
}
Review
Ok, implementacja jest krótka i wykorzystuje tylko wbudowane widżety, ale teraz pomyśl o naszym początkowym zamiarze: budowaniu alternatywnych wersji demonstracyjnych (lub testów) dla tego ekranu. Bardzo trudno jest kontrolować wynik wywołania HTTP, aby zmusić aplikację do renderowania w określonym stanie.
Tutaj pomoże nam koncepcja inwersji kontroli. 🧐
Krok 2. Odwrócenie kontroli
przez ClickUp
Zasada ta może być trudna do zrozumienia dla nowych programistów (a także trudna do wyjaśnienia), ale ogólną ideą jest wyciągnięcie obaw poza nasze komponenty - tak, aby nie były one odpowiedzialne za wybór zachowania - i zamiast tego oddelegowanie go.
W bardziej powszechnej sytuacji polega to po prostu na tworzeniu abstrakcji i wstrzykiwaniu implementacji do naszych komponentów, aby ich implementacja mogła zostać później zmieniona w razie potrzeby.
Ale nie martw się, będzie to miało więcej sensu po naszym następnym przykładzie! 👀 Kod źródłowy i demo Dart
Tworzenie obiektu klienta API
Aby kontrolować wywołanie HTTP do naszego API, odizolowaliśmy naszą implementację w dedykowanej klasie DataUsaApiClient
. Stworzyliśmy również klasę Measure
, aby ułatwić manipulowanie danymi i ich utrzymanie.
class DataUsaApiClient {
const DataUsaApiClient({
this.endpoint = 'https://datausa.io/api/data',
});
final ciąg endpoint;
Future<Measure?> getMeasure(int year) async {
final uri =
Uri.parse('$endpoint?drilldowns=Nation&measures=Population&year=$year');
final result = await get(uri);
final body = jsonDecode(result.body);
final data = body['data'] as Lista<dynamiczna>;
if (data.isNotEmpty) {
return Measure.fromJson(data.first as Map<String, Object?>);
}
return null;
}
Dostawca klienta API
W naszym przykładzie używamy dobrze znanej aplikacji
dostawca
aby wstrzyknąć instancję DataUsaApiClient
do korzenia naszego drzewa.
Dostawca<DataUsaApiClient>(
create: (context) => const DataUsaApiClient(),
child: const MaterialApp(
home: HomePage(),
),
)
Korzystanie z klienta API
Dostawca pozwala każdemu widżetowi potomnemu (_jak nasz _DetailScreen_
) odczytać najbliższy DataUsaApiClient
w drzewie. Następnie możemy użyć jego metody getMeasure
do uruchomienia naszej Future
, zamiast rzeczywistej implementacji HTTP.
@overridevoid didChangeDependencies() {
super.didChangeDependencies();
final year = ModalRoute.of(context)!.settings.arguments as int;
if (_year != year) {
_year = year;
final api = context.read<DataUsaApiClient>();
_future = api.getMeasure(year);
}
}
Klient API Demo
Teraz możemy to wykorzystać!
Na wypadek, gdybyś nie wiedział: dowolne klasy
w dart również niejawnie definiują powiązany interfejs
. Pozwala nam to dostarczyć alternatywną implementację DataUsaApiClient
, która zawsze zwraca tę samą instancję z wywołań metody getMeasure
.
Ta metoda
class DemoDataUsaApiClient implements DataUsaApiClient {
const DemoDataUsaApiClient(this.measure);
final Measure measure;
@overrideString get endpoint => '';
@override
Future<Measure?> getMeasure(int year) {
return Future.value(measure);
}
Wyświetlanie strony demonstracyjnej
Mamy teraz wszystkie klucze do wyświetlenia demonstracyjnej instancji DetailPage
!
Po prostu nadpisujemy obecnie dostarczaną instancję DataUsaApiClient
, opakowując nasz DetailScreen
w dostawcę, który zamiast tego tworzy instancję DemoDataUsaApiClient
!
I to wszystko - nasz DetailScreen
odczytuje zamiast tego tę instancję demo i używa naszych danych demoMeasure
zamiast wywołania HTTP.
ListTile(
title: const Text('Otwórz demo'),
onTap: () {
const demoMeasure = Measure(
year: 2022,
populacja: 425484,
naród: "Stany Zjednoczone",
);
Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(arguments: demoMeasure.year),
builder: (context) {
return Dostawca<DataUsaApiClient>(
create: (context) =>
const DemoDataUsaApiClient(demoMeasure),
child: const DetailScreen(),
);
},
),
);
},
)
Review
To świetny przykład Inversion of control. Nasz widżet DetailScreen
nie jest już odpowiedzialny za logikę pobierania danych, ale oddelegowany do dedykowanego obiektu klienta. Teraz możemy tworzyć instancje demonstracyjne ekranu lub zaimplementować testy widżetu dla naszego ekranu! Super! 👏
Ale możemy zrobić to jeszcze lepiej!
Ponieważ nie jesteśmy w stanie symulować na przykład stanu ładowania, nie mamy pełnej kontroli nad zmianą stanu na poziomie naszego widżetu.
Krok 3. Zarządzanie stanem
przez ClickUp
To gorący temat we Flutterze!
Jestem pewien, że czytałeś już długie wątki ludzi, którzy próbują wybrać najlepsze rozwiązanie do zarządzania stanem dla Fluttera. I żeby było jasne, nie do tego będziemy dążyć w tym artykule. Naszym zdaniem, tak długo jak oddzielasz logikę biznesową od logiki wizualnej, wszystko jest w porządku! Tworzenie tych warstw jest naprawdę ważne dla łatwości utrzymania. Nasz przykład jest prosty, ale w prawdziwych aplikacjach logika może szybko stać się złożona, a taka separacja znacznie ułatwia znalezienie algorytmów czystej logiki. Temat ten jest często podsumowywany jako state management.
W tym przykładzie używamy podstawowego ValueNotifier
wraz z Provider
. Ale mogliśmy również użyć
flutter_bloc
lub
riverpod
(lub inne rozwiązanie), i też działałoby świetnie. Zasady pozostają takie same i tak długo, jak oddzieliłeś swoje stany i logikę, możliwe jest nawet przeniesienie bazy kodu z jednego z innych rozwiązań.
Ta separacja pomaga nam również kontrolować dowolny stan naszego widżetu, dzięki czemu możemy kpić z niego w każdy możliwy sposób! Kod źródłowy i demo Dart
Tworzenie dedykowanego stanu
Zamiast polegać na AsyncSnapshot
z frameworka, reprezentujemy teraz nasz stan ekranu jako obiekt DetailState
.
Ważne jest również zaimplementowanie metod hashCode
i operator ==
, aby nasz obiekt był porównywalny pod względem wartości. Pozwala nam to wykryć, czy dwie instancje powinny być uważane za różne.
💡 Metoda equatable> lub zamrożone> pakiety są świetnymi opcjami, które pomogą ci zaimplementować metody
hashCode
ioperator ==
!
abstract class DetailState {
const DetailState(this.year);
final int year;
@overridebool operator ==(Object other) =>
identical(this, other) ||
(other is DetailState &&
runtimeType == other.runtimeType &&
year == other.year);
@overrideint get hashCode => runtimeType.hashCode ^ year;
Nasz stan jest zawsze powiązany z rokiem
, ale mamy też cztery różne możliwe stany dotyczące tego, co chcemy pokazać użytkownikowi:
NotLoadedDetailState
: aktualizacja danych jeszcze się nie rozpoczęłaLoadingDetailState
: dane są obecnie ładowaneLoadedDetailState
: dane zostały powodzenie załadowane z powiązanymmeasure
NoDataDetailState
: dane zostały załadowane, ale nie ma dostępnych danychUnknownErrorDetailState
: operacja nie powiodła się z powodu nieznanego błędu
class NotLoadedDetailState extends DetailState {
const NotLoadedDetailState(int year) : super(year);
}
class LoadedDetailState extends DetailState {
const LoadedDetailState({
required int year,
wymagane this.measure,
}) : super(year);
final Measure measure;
@overridebool operator ==(Object other) =>
identical(this, other) ||
(other is LoadedDetailState && measure == other.measure);
@overrideint get hashCode => runtimeType.hashCode ^ measure.hashCode;
}
class NoDataDetailState extends DetailState {
const NoDataDetailState(int year) : super(year);
}
class LoadingDetailState extends DetailState {
const LoadingDetailState(int year) : super(year);
}
class UnknownErrorDetailState extends DetailState {
const UnknownErrorDetailState({
required int year,
required this.error,
}) : super(year);
końcowy błąd dynamiczny;
@overridebool operator ==(Object other) =>
identical(this, other) ||
(other is UnknownErrorDetailState &&
year == other.year &&
error == other.error);
@overrideint get hashCode => Object.hash(super.hashCode, error.has
Te stany są bardziej przejrzyste niż AsyncSnapshot
, ponieważ naprawdę reprezentują nasze przypadki użycia. I znowu, dzięki temu nasz kod jest łatwiejszy w utrzymaniu!
💡 Gorąco polecamy metodę Typy unii z zamrożonego pakietu> do reprezentowania stanów logicznych! Dodaje wiele narzędzi, takich jak metody
copyWith
czymap
.
Umieszczanie logiki w notyfikatorze
Teraz, gdy mamy reprezentację naszego stanu, musimy gdzieś przechowywać jego instancję - taki jest cel DetailNotifier
. Będzie on przechowywał aktualną instancję DetailState
w swojej właściwości value
i będzie dostawcą metod do aktualizacji stanu.
Dostarczamy stan początkowy NotLoadedDetailState
i metodę refresh
do ładowania danych z api
i aktualizowania bieżącej wartości
.
class DetailNotifier extends ValueNotifier<DetailState> {
DetailNotifier({
required int year,
required this.api,
}) : super(DetailState.notLoaded(year));
final DataUsaApiClient api;
int get year => value.year;
Future<void> refresh() async {
if (wartość is! LoadingDetailState) {
value = DetailState.loading(year);
try {
final result = await api.getMeasure(year);
if (result != null) {
value = DetailState.loaded(
year: year,
measure: wynik,
);
} else {
value = DetailState.noData(year);
}
} catch (błąd) {
value = DetailState.unknownError(
year: year,
error: error,
);
}
}
}
Dostawca stanu dla widoku
Aby utworzyć instancję i obserwować stan naszego ekranu, polegamy również na dostawcy i jego ChangeNotifierProvider
. Ten rodzaj dostawcy automatycznie szuka każdego utworzonego ChangeListener
i wyzwala przebudowę od Consumer
za każdym razem, gdy zostanie powiadomiony o zmianie (gdy nasza wartość notyfikatora różni się od poprzedniej).
class DetailScreen extends StatelessWidget {
const DetailScreen({
Klucz: klucz,
}) : super(klucz: klucz);
@override
Widżet build(BuildContext context) {
final year = ModalRoute.of(context)!.settings.arguments as int;
return ChangeNotifierProvider<DetailNotifier>(
create: (context) {
final notifier = DetailNotifier(
year: year,
api: context.read<DataUsaApiClient>(),
);
notifier.refresh();
return notifier;
},
child: Consumer<DetailNotifier>(
builder: (context, notifier, child) {
final state = notifier.value;
// ...
},
),
);
}
}
Review
Świetnie! Nasza architektura aplikacji zaczyna wyglądać całkiem nieźle. Wszystko jest podzielone na dobrze zdefiniowane warstwy, z określonymi obawami! 🤗
Wciąż jednak brakuje jednej rzeczy do testowalności, chcemy zdefiniować bieżący DetailState
, aby kontrolować stan naszego powiązanego DetailScreen
.
Krok 4. Wizualny dedykowany widżet układu
przez ClickUp
W ostatnim kroku daliśmy trochę za dużo odpowiedzialności naszemu widżetowi DetailScreen
: był on odpowiedzialny za instancję DetailNotifier
. I tak jak widzieliśmy wcześniej, staramy się unikać jakiejkolwiek odpowiedzialności logicznej w warstwie widoku!
Możemy to łatwo rozwiązać, tworząc kolejną warstwę dla naszego widżetu ekranu: podzielimy nasz widżet DetailScreen
na dwa:
DetailScreen
jest odpowiedzialny za ustawienie różnych zależności naszego ekranu od aktualnego stanu aplikacji (nawigacja, notyfikatory, stan, usługi, ...),DetailLayout
po prostu konwertujeDetailState
na dedykowane drzewo widżetów.
Łącząc te dwa rozwiązania, będziemy mogli po prostu tworzyć instancje demonstracyjne/testowe DetailLayout
, ale mając DetailScreen
dla rzeczywistych przypadków użycia w naszej aplikacji.
Kod źródłowy i demo Dart
Dedykowany układ
Aby lepiej rozdzielić problemy, przenieśliśmy wszystko pod widżet Consumer
do dedykowanego widżetu DetailLayout
. Ten nowy widżet tylko konsumuje dane i nie jest odpowiedzialny za żadne instancje. Po prostu konwertuje stan odczytu na określone drzewo widżetów.
Wywołanie ModalRoute.of
i instancja ChangeNotifierProvider
pozostają w DetailScreen
, a ten widżet po prostu zwraca DetailLayout
ze wstępnie skonfigurowanym drzewem zależności!
To drobne ulepszenie jest specyficzne dla użycia dostawcy, ale zauważysz, że dodaliśmy również ProxyProvider
, aby każdy widżet potomny mógł bezpośrednio konsumować DetailState
. Ułatwi to wyśmiewanie danych.
class DetailScreen extends StatelessWidget {
const DetailScreen({
Klucz? klucz,
}) : super(klucz: klucz);
@override
Widżet build(BuildContext context) {
final year = ModalRoute.of(context)!.settings.arguments as int;
return ChangeNotifierProvider<DetailNotifier>(
create: (context) {
final notifier = DetailNotifier(
year: year,
api: context.read<DataUsaApiClient>(),
);
notifier.refresh();
return notifier;
},
child: child: ProxyProvider<DetailNotifier, DetailState>(
update: (context, value, previous) => value.value,
child: const DetailLayout(),
),
);
}
}
class DetailLayout extends StatelessWidget {
const DetailLayout({
Klucz: klucz,
}) : super(klucz: klucz);
@override
Widżet build(BuildContext context) {
return Consumer<DetailState>(
builder: (context, state, child) {
return Scaffold(
appBar: AppBar(
tytuł: Text('Year ${state.year}',
),
body: () {
// ...
}(),
);
},
);
}
Wyodrębnianie widżetów jako dedykowanych klas
Nigdy nie wahaj się wyodrębnić drzewa widżetów do dedykowanej klasy! Spowoduje to poprawi wydajność i sprawiają, że kod jest łatwiejszy w utrzymaniu.
W naszym przykładzie stworzyliśmy jeden widżet układu wizualnego dla każdego z powiązanych typów stanów:
if (state is NotLoadedDetailState || state is LoadingDetailState) {
return const LoadingDetailLayout();
}
if (state is LoadedDetailState) {
return LoadedDetailLayout(state: state);
}
if (state is UnknownErrorDetailState) {
return UnknownErrorDetailLayout(state: state);
}
return const NoDataDetailLayout();
Instancje demonstracyjne
Teraz mamy pełną kontrolę nad tym, co możemy wyśmiewać i wyświetlać na ekranie!
Musimy tylko zawinąć DetailLayout
z Provider<DetailState>
, aby zasymulować stan układu.
ListTile(
title: const Text('Otwórz "załadowane" demo'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Dostawca<DetailState>.wartość(
wartość: const DetailState.loaded(
year: 2022,
measure: Measure(
year: 2022,
populacja: 425484,
naród: "Stany Zjednoczone",
),
),
child: const DetailLayout(),
);
},
),
);
},
),
ListTile(
title: const Text('Open "loading" demo'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Dostawca<DetailState>.value(
wartość: const DetailState.loading(2022),
child: const DetailLayout(),
);
},
),
);
},
),
Wnioski
Stworzenie łatwej w utrzymaniu architektury oprogramowania na pewno nie jest łatwe! Przewidywanie przyszłych scenariuszy może wymagać dużego wysiłku, ale mam nadzieję, że kilka udostępnianych przeze mnie wskazówek pomoże ci w przyszłości!
Przykłady mogą wyglądać na proste - może nawet wydawać się, że przesadzamy z inżynierią - ale wraz ze wzrostem złożoności aplikacji, posiadanie tych standardów bardzo ci pomoże! 💪
Miłej zabawy z Flutter i śledź bloga, aby uzyskać więcej artykułów technicznych, takich jak ten! Bądź na bieżąco!