Flutter 애플리케이션의 우려 사항 분리
Engineering at ClickUp

Flutter 애플리케이션의 우려 사항 분리

최근에는 다음을 위한 온보딩 워크스루를 구현해야 했습니다 ClickUp 신규 사용자! 이것은 많은 신규 사용자가 플랫폼을 발견하려고했기 때문에 정말 중요한 작업이었습니다 슈퍼볼에서 처음 선보인 엄청나게 재미있는 광고 ! ✨

clickUp을 통해

워크스루를 통해 아직 ClickUp을 모르는 수많은 신규 사용자가 애플리케이션의 여러 기능을 사용하는 방법을 빠르게 이해할 수 있습니다. 이는 새로운 기능과 마찬가지로 지속적인 노력입니다 ClickUp University 우리가 추구하는 리소스! 🚀

다행히도 ClickUp Flutter 모바일 애플리케이션의 소프트웨어 아키텍처 덕분에 애플리케이션의 실제 위젯을 재사용하여 이 기능을 매우 빠르게 구현할 수 있었습니다! 즉, 워크스루는 역동적이고 반응성이 뛰어나며 앱의 실제 애플리케이션 화면과 정확히 일치하며 위젯이 진화하더라도 계속 그렇게 될 것입니다.

또한 우려 사항을 적절히 분리했기 때문에 기능을 구현할 수 있었습니다.

여기서 제가 의미하는 바를 살펴봅시다. 🤔

관심사 분리

소프트웨어 아키텍처 설계는 엔지니어링 팀에게 가장 복잡한 주제 중 하나입니다. 모든 책임 중에서도 미래의 소프트웨어 진화를 예측하는 것은 항상 어려운 일입니다. 그렇기 때문에 잘 계층화되고 분리된 아키텍처를 만들면 여러분과 팀원들에게 많은 도움이 될 수 있습니다!

소규모 분리형 시스템을 만들 때의 가장 큰 장점은 의심할 여지없이 테스트 가능성입니다! 그리고 이것이 제가 앱에서 기존 화면의 데모 대안을 만드는 데 도움이 된 것입니다!

단계별 가이드

이제 이러한 원리를 Flutter 애플리케이션에 어떻게 적용할 수 있을까요?

간단한 예시를 통해 ClickUp을 빌드하는 데 사용한 몇 가지 기술을 공유해 보겠습니다.

예시가 너무 간단해서 그 이면에 있는 모든 이점을 다 설명하지는 못하겠지만, 복잡한 코드베이스로 훨씬 더 유지 관리하기 쉬운 Flutter 애플리케이션을 만드는 데 도움이 될 것이라고 믿어 의심치 않습니다. 💡

애플리케이션

예를 들어, 연도별 미국 인구를 표시하는 애플리케이션을 만들어 보겠습니다.

clickUp을 통해

여기에는 두 개의 화면이 있습니다:

  • '홈 화면': 2000년부터 지금까지의 모든 연도를 간단히 나열합니다. 사용자가 연도 타일을 탭하면 선택한 연도로 설정된 내비게이션 아규먼트가 있는 DetailScreen으로 이동합니다.
  • DetailScreen : 내비게이션 아규먼트에서 연도를 가져와서datausa.io API 를 호출하고 JSON 데이터를 구문 분석하여 관련 인구 값을 추출합니다. 데이터를 사용할 수 있는 경우 인구와 함께 라벨이 표시됩니다.

비동기 호출로 가장 흥미로운 DetailScreen 구현에 초점을 맞추겠습니다.

1단계. 순진한 접근 방식

나이브 접근 스테이트풀 위젯

clickUp을 통해

우리 앱의 가장 확실한 구현은 전체 로직에 하나의 StatefulWidget을 사용하는 것입니다. 다트 소스 코드 및 데모

year 탐색 아규먼트에 액세스하기

요청된 연도에 액세스하기 위해 ModalRoute 상속 위젯에서 RouteSettings를 읽습니다.

void didChangeDependencies() {
    super.didChangeDependencies();
    final year = ModalRoute.of(context)!.settings.아규먼트를 int로;
    // ...
}

HTTP 호출

이 예시는 http 패키지에서 get 기능을 호출하여 데이터를 가져옵니다 datausa.io API 를 호출하면 dart:convert 라이브러리의 jsonDecode 메서드로 결과 JSON을 구문 분석하고 _future라는 속성을 가진 상태의 일부로 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 List<dynamic>;
    if (data.isNotEmpty) {
      return data.first;
    }
    null을 반환합니다;

렌더링

위젯 트리를 생성하기 위해 _future 비동기 호출의 현재 상태를 기준으로 자체적으로 다시 빌드하는 FutureBuilder를 사용하고 있습니다.

오버라이드
위젯 빌드(빌드 컨텍스트 컨텍스트) { {
반환 스캐폴드(
    appBar: AppBar(
    제목: 텍스트('Year $_year'),
    ),
    body: FutureBuilder<지도<동적, 동적>?>(
    future: _future,
    빌더: (컨텍스트, 스냅샷) {
        스위치 (스냅샷.연결 상태) {
        case ConnectionState.완료됨:
            최종 오류 = 스냅샷.오류;
            if (error != null) {
                // "오류" 트리를 반환합니다.
            }
            최종 데이터 = 스냅샷.데이터;
            if (data != null) {
                // "결과" 트리를 반환합니다.
            }
            // "빈" 데이터 반환 tree.case ConnectionState.none:
        case ConnectionState.waiting:
        case ConnectionState.active:
            // "로드 중" 데이터 트리를 반환합니다.
        }
    },
    ),
);
}

검토

구현은 짧고 기본 제공 위젯만 사용했지만, 이제 이 화면에 대한 데모 대안(또는 테스트)을 구축하려는 초기 의도를 생각해 보세요. 애플리케이션이 특정 상태로 렌더링되도록 HTTP 호출의 결과를 제어하는 것은 매우 어렵습니다.

바로 이때 '제어의 반전'이라는 개념이 도움이 될 것입니다. 🧐

2단계. 제어의 반전

제공자 및 API 클라이언트로 제어 반전

clickUp을 통해

이 원칙은 초보 개발자에게는 이해하기 어려울 수 있지만(또 설명하기도 어렵습니다), 전체적인 아이디어는 컴포넌트 외부로 우려 사항을 추출하여 동작 선택에 대한 책임이 없도록 하고 대신 이를 위임하는 것입니다.

보다 일반적인 상황에서는 추상화를 만들고 구성 요소에 구현을 주입하여 나중에 필요한 경우 구현을 변경할 수 있도록 하는 것입니다.

하지만 걱정하지 마세요. 다음 예시 이후에는 더 이해가 쉬워질 것입니다! 👀 다트 소스 코드 & 데모

API 클라이언트 오브젝트 생성하기

API에 대한 HTTP 호출을 제어하기 위해 구현을 전용 DataUsaApiClient 클래스로 분리했습니다. 또한 데이터를 더 쉽게 조작하고 유지 관리할 수 있도록 Measure 클래스를 만들었습니다.

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


  최종 문자열 엔드포인트;


  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 List<dynamic>;
    if (data.isNotEmpty) {
      return Measure.fromJson(data.first as Map<String, Object?>);
    }
    null을 반환합니다;
  }

API 클라이언트 제공하기

예시에서는 잘 알려진 제공자 패키지를 사용하여 트리의 루트에 DataUsaApiClient 인스턴스를 삽입합니다.

제공자<DataUsaApiClient>(
    create: (context) => const DataUsaApiClient(),
        child: const MaterialApp(
        홈: HomePage(),
    ),
)

API 클라이언트 사용하기

제공자는 모든 하위 위젯 (처럼 우리 _DetailScreen_)_이 트리에서 가장 가까운 DataUsaApiClient를 읽을 수 있도록 합니다. 그런 다음 실제 HTTP 구현 대신 그 getMeasure 메서드를 사용하여 Future를 시작할 수 있습니다.

@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);
    }
}

데모 API 클라이언트

이제 이 기능을 활용할 수 있습니다!

모르는 경우를 대비하여: 모든 클래스 도 암시적으로 관련 인터페이스를 정의합니다 . 이를 통해 getMeasure 메서드 호출에서 항상 동일한 인스턴스를 반환하는 DataUsaApiClient의 대체 구현을 제공할 수 있습니다.

이 메서드

demoDataUsaApiClient 클래스는 DataUsaApiClient를 구현합니다
  const DemoDataUsaApiClient(this.measure);


  최종 측정 측정;


  오버라이드 문자열 get 엔드포인트 => '';


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

데모 페이지 표시하기

이제 DetailPage의 데모 인스턴스를 표시하기 위한 모든 키가 준비되었습니다!

현재 제공된 DataUsaApiClient 인스턴스를 재정의하고 대신 DemoDataUsaApiClient 인스턴스를 생성하는 제공자에 DetailScreen을 래핑하기만 하면 됩니다!

그러면 DetailScreen이 이 데모 인스턴스를 대신 읽고, HTTP 호출 대신 demoMeasure 데이터를 사용합니다.

ListTile(
    제목: const 텍스트('데모 열기'),
    onTap: () {
        const demoMeasure = Measure(
            year: 2022,
            인구: 425484,
            국가: '미국',
        );
        Navigator.push(
            context,
            MaterialPageRoute(
                설정: RouteSettings(아규먼트: demoMeasure.year),
                builder: (context) {
                    반환 제공자<DataUsaApiClient>(
                        create: (context)=>
                            const DemoDataUsaApiClient(demoMeasure),
                        child: const DetailScreen(),
                    );
                },
            ),
        );
    },
)

검토

이것은 컨트롤의 반전의 좋은 예시입니다._ 우리의 DetailScreen 위젯은 더 이상 데이터를 가져오는 로직을 담당하지 않고, 대신 전용 클라이언트 오브젝트에 이를 위임합니다. 이제 화면의 데모 인스턴스를 만들거나 화면에 대한 위젯 테스트를 구현할 수 있습니다! 멋지네요! 👏

하지만 더 잘할 수 있습니다!

예를 들어 로딩 상태를 시뮬레이션할 수 없으므로 위젯 수준에서 상태 변경을 완전히 제어할 수 없습니다.

3단계. 상태 관리

플래터 애플리케이션의 상태 관리

clickUp을 통해

이것은 Flutter의 뜨거운 주제입니다!

Flutter를 위한 최고의 상태 관리 솔루션을 선택하려는 사람들의 긴 스레드를 이미 읽으셨을 것입니다. 명확히 말씀드리자면, 이 글에서 다루고자 하는 것은 그런 것이 아닙니다. 저희는 비즈니스 로직과 시각적 로직을 분리하기만 하면 괜찮다고 생각합니다! 이러한 레이어를 만드는 것은 유지보수성을 위해 정말 중요합니다. 예시는 단순하지만 실제 애플리케이션에서는 로직이 빠르게 복잡해질 수 있으며, 이렇게 분리하면 순수한 로직 알고리즘을 훨씬 쉽게 찾을 수 있습니다. 이 주제는 종종 상태 관리로 요약합니다.

이 예시에서는 기본 값노티파이제공자를 함께 사용하고 있습니다. 하지만 다음과 같이 사용할 수도 있습니다 flutter_bloc 또는 riverpod (또는 다른 솔루션)_을 사용해도 잘 작동했을 것입니다. 원칙은 동일하게 유지되며 상태와 로직을 분리하기만 하면 다른 솔루션 중 하나에서 코드베이스를 이식하는 것도 가능합니다.

또한 이렇게 분리하면 위젯의 모든 상태를 제어할 수 있으므로 가능한 모든 방법으로 위젯을 모킹할 수 있습니다! 다트 소스 코드 & 데모

전용 상태 만들기

프레임워크의 AsyncSnapshot에 의존하는 대신, 이제 화면 상태를 DetailState 오브젝트로 표현합니다.

값으로 오브젝트를 비교할 수 있도록 hashCodeoperator == 메서드를 구현하는 것도 중요합니다. 이를 통해 두 인스턴스가 서로 다른 것으로 간주되어야 하는지 감지할 수 있습니다.

💡 equatable 또는 동결 패키지는 hashCodeoperator == 메서드를 구현하는 데 도움이 되는 훌륭한 옵션입니다!

추상 클래스 DetailState {
  const DetailState(this.year);
  최종 int 연도;


  오버라이드 불 연산자 ==(오브젝트 다른)=>
      identical(this, other) ||
      (other는 디테일 상태 &&
          런타임 유형 == 다른.런타임 유형 &&
          year == other.year);


  오버라이드int get hashCode => runtimeType.hashCode ^ year;

상태는 항상 연도와 연관되어 있지만 사용자에게 표시하려는 것과 관련하여 네 가지 가능한 상태가 있습니다:

  • notLoadedDetailState`: 데이터 업데이트가 아직 시작되지 않았습니다
  • 로딩 디테일 상태`: 데이터가 현재 로드 중입니다
  • LoadedDetailState: 데이터가 연결된 measure와 함께 성공적으로 로드되었습니다
  • noDataDetailState`: 데이터가 로드되었지만 사용 가능한 데이터가 없습니다
  • unknownErrorDetailState: 알 수 없는오류`로 인해 작업이 실패했습니다
notLoadedDetailState 클래스 디테일 스테이트 확장 {
  const NotLoadedDetailState(int year) : super(year);
}


loadedDetailState 클래스는 DetailState 를 확장합니다 {
  const LoadedDetailState({
    필수 int 연도,
    필수 this.measure,
  }) : super(year);


  최종 측정값을 측정합니다;


  오버라이드불 연산자 ==(오브젝트 다른)=>
      identical(this, other) ||
      (다른 것은 LoadedDetailState && 측정 == 다른.측정);


  오버라이드int get hashCode => 런타임 유형 해시 코드 ^ 측정 해시 코드;
}


noDataDetailState 클래스 확장 DetailState {
  const NoDataDetailState(int year) : super(year);
}


loadingDetailState 클래스는 DetailState를 확장합니다 {
  const LoadingDetailState(int year) : super(year);
}


unknownErrorDetailState 클래스 디테일 스테이트 확장 {
  const UnknownErrorDetailState({
    필수 int 연도,
    필수 이.오류,
  }) : super(year);


  최종 동적 오류;


  오버라이드불 연산자 ==(오브젝트 다른)=>
      identical(this, other) ||
      (other는 UnknownErrorDetailState &&
          year == other.year &&
          오류 == other.error);


  @overrideint get hashCode => 오브젝트.해시(super.hashCode, error.has

이러한 상태는 우리의 사용 사례를 실제로 나타내기 때문에 AsyncSnapshot보다 더 명확합니다. 그리고 다시 말하지만, 이것은 코드를 더 유지 관리하기 쉽게 만듭니다!

💡 저희는 동결된 패키지의 유니온 유형 로직 상태를 표현할 수 있습니다! 복사하기또는지도` 메서드와 같은 많은 유틸리티를 추가합니다.

로직을 알림창에 넣기

이제 상태를 표현할 수 있게 되었으니, 그 인스턴스를 어딘가에 저장해야 하는데, 이것이 바로 DetailNotifier의 목적입니다. 디테일노티파이어는 현재 DetailState 인스턴스를 프로퍼티에 보관하고 상태를 업데이트하는 메서드를 제공합니다.

초기 상태인 NotLoadedDetailStateapi에서 데이터를 로드하고 현재 을 업데이트하는 refresh 메서드를 제공합니다.

detailNotifier 클래스 ValueNotifier<DetailState> 확장 {
  DetailNotifier({
    필수 int year,
    required this.API,
  }) : super(DetailState.notLoaded(year));


  최종 DataUsaApiClient API;


  int get year => 값.year;


  Future<void> refresh() async {
    if (값이! 로딩디테일상태) {
      값 = DetailState.loading(year);
      try {
        최종 결과 = await API.getMeasure(year);
        if (결과 != null) {
          값 = DetailState.loaded(
            year: year,
            측정: 결과
          );
        } else {
          값 = DetailState.noData(year);
        }
      } catch (오류) {
        값 = DetailState.unknownError(
          year: 연도
          error: 오류
        );
      }
    }
  }

보기에 대한 상태를 제공합니다

화면의 상태를 인스턴스화하고 관찰하기 위해 우리는 또한 제공자와 그 ChangeNotifierProvider에 의존합니다. 이러한 종류의 제공자는 생성된 ChangeListener를 자동으로 찾고 변경 알림을 받을 때마다 소비자로부터 리빌드를 트리거합니다 (우리의 알림기 값이 이전 값과 다른 경우).

detailScreen 클래스 StatelessWidget 확장 {
  const DetailScreen({
    키? 키,
  }) : super(키: 키);


  오버라이드
  위젯 빌드(빌드 컨텍스트 컨텍스트) { {
    final year = ModalRoute.of(context)!.settings.arguments as int;
    반환 변경 알림 공급자<디테일 알림 공급자>(
      create: (context) {
        final notifier = DetailNotifier(
          year: year,
          aPI: context.read<DataUsaApiClient>(),
        );
        notifier.refresh();
        notifier를 반환합니다;
      },
      child: Consumer<DetailNotifier>(
        빌더: (컨텍스트, 알림이, 자식) {
             final state = notifier.값;
            // ...
        },
      ),
    );
  }
}

검토

훌륭합니다! 애플리케이션 아키텍처가 꽤 좋아 보이기 시작했습니다. 모든 것이 특정 관심사와 함께 잘 정의된 계층으로 분리되어 있습니다! 🤗

하지만 테스트 가능성을 위해 아직 한 가지 빠진 것이 있는데, 현재 DetailState를 정의하여 연결된 DetailScreen의 상태를 제어하고 싶습니다.

4단계. 시각적 전용 레이아웃 위젯 만들기

Flutter 애플리케이션의 시각적 전용 레이아웃 위젯

clickUp을 통해

마지막 단계에서는 DetailScreen 위젯에 너무 많은 책임을 부여했는데, DetailNotifier의 인스턴스화를 담당했습니다. 그리고 앞서 살펴본 것과 마찬가지로 보기 레이어에서 로직 책임을 피하려고 노력했습니다!

화면 위젯을 위한 다른 레이어를 생성하면 이 문제를 쉽게 해결할 수 있습니다. DetailScreen 위젯을 두 개로 분할합니다:

  • 디테일스크린`은 현재 애플리케이션 상태(탐색, 알림, 상태, 서비스, ...)에서 화면의 다양한 의존성을 설정하는 역할을 담당합니다,
  • 디테일 레이아웃은 단순히디테일 상태`를 위젯의 전용 트리로 변환합니다.

이 두 가지를 결합하면 DetailLayout 데모/테스트 인스턴스를 간단히 생성할 수 있지만 애플리케이션의 실제 사용 사례에는 DetailScreen을 사용할 수 있습니다. 다트 소스 코드 및 데모

전용 레이아웃

더 나은 관심사 분리를 위해 '소비자' 위젯 아래의 모든 것을 전용 '세부 레이아웃' 위젯으로 옮겼습니다. 이 새로운 위젯은 데이터만 소비하며 인스턴스화를 담당하지 않습니다. 읽기 상태를 특정 위젯 트리로 변환할 뿐입니다.

ModalRoute.of호출과ChangeNotifierProvider인스턴스는DetailScreen에 남아 있으며 이 위젯은 미리 구성된 의존성 트리가 있는DetailLayout`을 반환하기만 하면 됩니다!

이 사소한 개선은 제공자 사용과 관련된 것이지만, 모든 하위 위젯이 DetailState를 직접 소비할 수 있도록 ProxyProvider도 추가한 것을 알 수 있습니다. 이렇게 하면 데이터를 더 쉽게 모킹할 수 있습니다.

클래스 DetailScreen extends StatelessWidget {
  const DetailScreen({
    키? 키,
  }) : super(키: 키);


  오버라이드
  위젯 빌드(빌드 컨텍스트 컨텍스트) { {
    final year = ModalRoute.of(context)!.settings.arguments as int;
    반환 변경 알림 공급자<디테일 알림 공급자>(
      create: (context) {
        final notifier = DetailNotifier(
          year: year,
          aPI: context.read<DataUsaApiClient>(),
        );
        notifier.refresh();
        notifier를 반환합니다;
      },
      child: child: ProxyProvider<DetailNotifier, DetailState>(
        업데이트: (컨텍스트, 값, 이전) => 값.값,
        child: const DetailLayout(),
      ),
    );
  }
}


detailLayout 클래스는 StatelessWidget을 확장합니다 {
  const DetailLayout({
    키? 키,
  }) : super(키: 키);


  오버라이드
  위젯 빌드(빌드 컨텍스트 컨텍스트) { {
    return Consumer<DetailState>(
      빌더: (컨텍스트, 상태, 자식) {
        반환 스캐폴드(
          appBar: AppBar(
            제목: 텍스트('Year ${state.year}'),
          ),
          body: () {
              // ...
          }(),
        );
      },
    );
  }

위젯을 전용 클래스로 추출하기

위젯 트리를 전용 클래스로 추출하는 것을 망설이지 마세요! 다음과 같습니다 성능 향상 를 사용하여 코드를 더 유지 관리하기 쉽게 만들 수 있습니다.

예시에서는 연결된 상태 유형 각각에 대해 하나의 시각적 레이아웃 위젯을 만들었습니다:

if (state is NotLoadedDetailState || state is LoadingDetailState) {
    반환 const LoadingDetailLayout();
}
if (state가 LoadedDetailState이면) {
    return LoadedDetailLayout(state: state);
}
if (state가 UnknownErrorDetailState) { {
    return UnknownErrorDetailLayout(state: state);
}
반환 const NoDataDetailLayout();

데모 인스턴스

이제 우리는 화면에 무엇을 모방하고 표시할 수 있는지 완전히 제어할 수 있습니다!

레이아웃의 상태를 시뮬레이션하기 위해 '디테일 레이아웃'을 '제공자<디테일 스테이트>'로 감싸기만 하면 됩니다.

ListTile(
    제목: const 텍스트('로드된" 데모 열기'),
    onTap: () {
        Navigator.push(
        context,
        MaterialPageRoute(
            builder: (context) {
            반환 제공자<DetailState>.값(
                    값: const DetailState.loaded(
                    year: 2022,
                    측정: 측정(
                        year: 2022,
                        인구: 425484,
                        국가: '미국',
                    ),
                    ),
                    child: const DetailLayout(),
                );
            },
        ),
        );
    },
),
ListTile(
    제목: const Text('Open "loading" demo'),
    onTap: () {
        Navigator.push(
        context,
        MaterialPageRoute(
            builder: (context) {
                    반환 제공자<DetailState>.값(
                        값: const DetailState.loading(2022),
                        child: const DetailLayout(),
                    );
                },
            ),
        );
    },
),

결론

유지 관리 가능한 소프트웨어 아키텍처를 만드는 것은 결코 쉬운 일이 아닙니다! 미래의 시나리오를 예측하는 것은 많은 노력이 필요할 수 있지만, 제가 공유한 몇 가지 팁이 앞으로 도움이 되길 바랍니다!

예시는 단순해 보일 수도 있고 지나친 엔지니어링처럼 보일 수도 있지만 앱의 복잡성이 커질수록 이러한 표준이 있으면 많은 도움이 될 것입니다! 💪

Flutter를 재미있게 사용하시고, 블로그를 팔로우하여 이와 같은 기술 관련 글을 더 많이 받아보세요! 계속 지켜봐 주세요!