Rozdzielenie obaw w aplikacjach Flutter
Engineering at ClickUp

Rozdzielenie obaw w aplikacjach Flutter

Niedawno musiałem wdrożyć onboarding walkthroughs dla /href/ https://clickup.com/ ClickUp /%href/ nowi użytkownicy! Było to naprawdę ważne zadanie, ponieważ wielu nowych użytkowników miało wkrótce odkryć platformę z /href/ https://www.youtube.com/watch?v=AirnL1NxOAw niesamowicie zabawna reklama, którą mieliśmy premierę podczas Super Bowl /%href/ ! ✨ 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 /href/ https://university.clickup.com/ ClickUp University /%href/ 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 ekranu `DetailScreen` z argumentem nawigacji ustawionym na wybrany rok. * `DetailScreen`: pobiera rok z argumentu nawigacji, wywołuje funkcję /href/ https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=2018 aPI datausa.io /%href/ 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

/img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-5-1400x607.png Podejście naiwne Stateful Widżet /%img/ przez ClickUp Najbardziej oczywistą implementacją dla naszej aplikacji jest użycie pojedynczego `StatefulWidget` dla całej logiki. /href/ https://dartpad.dev/embed-flutter.html?id=b37071f4386ecb27c29332e788999612&split=80&theme=dark Kod źródłowy i demo Dart /%href/

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 /href/ https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=2018 aPI datausa.io /%href/ , 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?> _future; void didChangeDependencies() { super.didChangeDependencies(); final year = ModalRoute.of(context)!.settings.arguments as int; if (_year != year) { _future = _loadMeasure(year); } } Future?> _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; 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?>( 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

/img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-6-1400x607.png Przeniesienie kontroli na dostawcę i klienta API /%img/ 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! 👀 /href/ https://dartpad.dev/embed-flutter.html?id=d68322aa5a6e6bdd4d1fbbf8f86aebd3&split=80&theme=dark Kod źródłowy i demo Dart /%href/

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 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; if (data.isNotEmpty) { return Measure.fromJson(data.first as Map); } return null; } ```

Dostawca klienta API

W naszym przykładzie używamy dobrze znanej aplikacji /href/ https://file+.vscode-resource.vscode-webview.net/Users/alois/Desktop/article/clickup-separation\_of\_concern.md dostawca /%href/ aby wstrzyknąć instancję `DataUsaApiClient` do korzenia naszego drzewa. ``` Dostawca( 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(); _future = api.getMeasure(year); } } ```

Klient API Demo

Teraz możemy to wykorzystać! Na wypadek, gdybyś nie wiedział: dowolne klasy /href/ https://dart.dev/samples#interfaces-and-abstract-classes w dart również niejawnie definiują powiązany interfejs /%href/ . 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 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( 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

/img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-7-1400x607.png Zarządzanie oświadczeniami w aplikacjach flutter /%img/ 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ć /href/ https://pub.dev/packages/flutter\_bloc flutter\_bloc /%href/ lub /href/ https://pub.dev/packages/riverpod riverpod /%href/ _(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! /href/ https://dartpad.dev/embed-flutter.html?id=e148980534dae261ef1174a15f7ac5cf&split=80&theme=dark Kod źródłowy i demo Dart /%href/

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 > /href/ > https://pub.dev/packages/equatable > equatable > /%href/ > > lub > /href/ > https://pub.dev/packages/freezed > zamrożone > /%href/ > > pakiety są świetnymi opcjami, które pomogą ci zaimplementować metody `hashCode` i `operator ==`! ``` 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ęła * `LoadingDetailState`: dane są obecnie ładowane * `LoadedDetailState`: dane zostały powodzenie załadowane z powiązanym `measure` * `NoDataDetailState`: dane zostały załadowane, ale nie ma dostępnych danych * `UnknownErrorDetailState`: 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ę > /href/ > https://pub.dev/packages/freezed#unionssealed-classes > Typy unii z zamrożonego pakietu > /%href/ > > do reprezentowania stanów logicznych! Dodaje wiele narzędzi, takich jak metody `copyWith` czy `map`.

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 { DetailNotifier({ required int year, required this.api, }) : super(DetailState.notLoaded(year)); final DataUsaApiClient api; int get year => value.year; Future 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( create: (context) { final notifier = DetailNotifier( year: year, api: context.read(), ); notifier.refresh(); return notifier; }, child: Consumer( 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

/img/ https://clickup.com/blog/wp-content/uploads/2022/03/image-8-1400x607.png Wizualne dedykowane widżety układu w aplikacjach Flutter /%img/ 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 konwertuje `DetailState` 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. /href/ https://dartpad.dev/embed-flutter.html?id=6e4075480df18bfe42b932f419a231ac&split=80&theme=dark Kod źródłowy i demo Dart /%href/

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( create: (context) { final notifier = DetailNotifier( year: year, api: context.read(), ); notifier.refresh(); return notifier; }, child: child: ProxyProvider( 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( 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 /href/ /blog?p=56354 poprawi wydajność /%href/ 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`, aby zasymulować stan układu. ``` ListTile( title: const Text('Otwórz "załadowane" demo'), onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) { return Dostawca.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.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!