Rozdzielenie obaw w aplikacjach Flutter
Engineering at ClickUp

Rozdzielenie obaw w aplikacjach Flutter

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 ekranu DetailScreen 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

Podejście naiwne Stateful Widżet

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

Przeniesienie kontroli na dostawcę i klienta API

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

Zarządzanie oświadczeniami w aplikacjach flutter

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 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ę Typy unii z zamrożonego pakietu> 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<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

Wizualne dedykowane widżety układu w aplikacjach Flutter

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. 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!