Flutter 확장 가능한 앱 뼈대 만들기 어플리케이션 기본 구조 완전 정복

Flutter 확장 가능한 앱 뼈대 만들기 어플리케이션 기본 구조 완전 정복
Cozy CodingPosted On Jul 7, 202410 min read

플러터 3.22.1, Dart 3.4.1, Mac OS, VS Code.

  • 설정. 의존성, 코드 스니펫 및 스크립트.
  • 애플리케이션 기본 구조. DI, ENV, 폴더 구조, 네트워킹, 자산 및 간단한 디자인 시스템.
  • 상태 관리. Redux.
  • 네비게이션. AutoRoute 사용.
  • 기능. 간단한 앱 기능 구현.
  • 단위 테스트. Mockito 사용.

플러터 커뮤니티는 놀라울 만큼 강력합니다. pub.dev에는 많은 멋진 패키지가 있습니다. 그러나 프로젝트에 가장 적합한 최고의 도구와 라이브러리를 선택하는 것은 어떻게 해야 할까요? 그것은 달려있습니다.

DI

오브젝트를 만들고 저장하며 연결하는 방법입니다. GetIt + Injectable을 사용할 것이며, Injectable은 GetIt di 컨테이너를 생성하는 도구입니다.

// lib/main.dart

Future<void> main() async {
  runZonedGuarded(
    () async {
      await runAppPreLaunchHandlers(); // get init 호출
      WidgetsFlutterBinding.ensureInitialized();

      runApp(
        RecordApp(
          store: createStore(), // Redux 레퍼런스 참조
        ),
      );

      await runAppPostLaunchHandlers();
    },
    (error, stackTrace) async {
      getIt<Analytics>().trackError(
        AnalyticsEventName.zoneGuardedError,
        stackTrace,
        error: error,
      );
    },
  );
}
// lib/core/DI/get_it_initializer.dart

@InjectableInit(
  initializerName: r'$initGetIt',
  preferRelativeImports: true,
  asExtension: true,
)
void configureDependencies() async {
  await getIt.$initGetIt();
}
// lib/core/DI/get_it.dart

final GetIt getIt = GetIt.instance;
// lib/core/repositories/env_vars.dart

import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:injectable/injectable.dart';
import 'package:record_app/flutter_gen/assets.gen.dart';

@singleton
class EnvVars {
  @FactoryMethod(preResolve: true)
  static Future<EnvVars> create() async {
    await dotenv.load(fileName: _getEnvFileName());
    return Future.value(EnvVars());
  }

  String get baseUrl => dotenv.get('base_api_url');
}

String _getEnvFileName() {
  String? env = const String.fromEnvironment('ENV', defaultValue: 'dev');
  switch (env) {
    case 'dev':
      return Assets.env.aEnvDebug;  // assets/env/.env.debug
    case 'prod':
      return Assets.env.aEnvProd; // assets/env/.env.prod
    case 'profile':
      return Assets.env.aEnvProfile; // assets/env/.env.profile
    default:
      return Assets.env.aEnvDebug;
  }
}

assets/env/.env.x 파일에 넣어주세요.

base_api_url = "http://localhost:8080";

네트워크 레이어로 가는데, Dio + Retrofit을 선택했습니다. 다시 말해 Retrofit은 dio용 코드 생성 도구로, metadata 주석 내에서 아주 좋은 뼈대를 제공합니다.

// lib/core/DI/third_party/dio_inject.dart

@module
abstract class DioModuleTag {
  @Named("BaseUrl")
  String get baseUrl => getIt<EnvVars>().baseUrl;

  @singleton
  Dio dio(@Named('BaseUrl') String url) => Dio(BaseOptions(baseUrl: url));
}

API 선언하기

// lib/core/services/api/record_app_rest_api.dart

import 'package:dio/dio.dart';
// ignore: depend_on_referenced_packages
import 'package:retrofit/retrofit.dart';

part 'record_app_rest_api.g.dart';

@RestApi()
abstract class RecordAppRestApi {
  factory RecordAppRestApi(Dio dio) = _RecordAppRestApi;

  @POST('/user-query')
  @MultiPart()
  Future<void> userQuery(
    @Part() String name,
    @Part() String phone,
    @Part() List<MultipartFile> files,
  );
}

그리고 등록하기

// lib/core/DI/third_party/retrofit_rest_api_inject.dart

@module
abstract class RecordAppRestApiModuleTag {
  RecordAppRestApi build(Dio dio) => RecordAppRestApi(dio);
}

설정 문서에서 생성기를 실행하십시오.

이미 getIt.RecordAppRestApi()를 사용하여 RecordAppRestApi 객체에 접근할 수 있습니다. 축하합니다!

디자인 시스템

이제 디자인 시스템에 관해 이야기해보겠습니다. 기본적으로 DS는 구체적인 값에 신경을 덜 쓰고 사전 정의된 추상화 세트로 작업할 수 있도록 도와주는 깔끔한 래퍼입니다. 회사의 규모에 따라 다르지만, 기본 사항을 알아보고 부드럽게 진행해 보겠습니다.

Atoms:

// lib/core/design_system/atoms/size_type.dart

enum SizeType {
  xxl,
  xl,
  l,
  m,
  s,
  xs,
}
// lib/core/design_system/atoms/spacings.dart

class Spacings {
  Spacings._();

  // Mobile Spacings
  static const double mobileXXl = 22.0;
  static const double mobileXl = 18.0;
  static const double mobileL = 14.0;
  static const double mobileM = 10.0;
  static const double mobileS = 8.0;
  static const double mobileXs = 6.0;

  // Tablet Spacings
  static const double tabletXXl = 26.0;
  static const double tabletXl = 20.0;
  static const double tabletL = 16.0;
  static const double tabletM = 12.0;
  static const double tabletS = 10.0;
  static const double tabletXs = 8.0;

  // Desktop Spacings
  static const double desktopXXl = 28.0;
  static const double desktopXl = 22.0;
  static const double desktopL = 18.0;
  static const double desktopM = 14.0;
  static const double desktopS = 12.0;
  static const double desktopXs = 10.0;
}
// lib/core/design_system/atoms/text_type.dart

enum TextType {
  heading1,
  heading2,
  heading3,
  body1,
  body2,
  body3,
}
// lib/core/design_system/atoms/text_styles.dart

class TextStyles {
  TextStyles._();

  // Mobile
  static const TextStyle mobileHeading1 = TextStyle(
    fontSize: 26.0,
    fontWeight: FontWeight.bold,
  );

  static const TextStyle mobileHeading2 = TextStyle(
    fontSize: 22.0,
    fontWeight: FontWeight.bold,
  );

  static const TextStyle mobileBody1 = TextStyle(
    fontSize: 18.0,
    fontWeight: FontWeight.bold,
  );

  ....

  // Tablet
  static const TextStyle tabletHeading1 = TextStyle(
    fontSize: 28.0,
    fontWeight: FontWeight.bold,
  );
  .....
}

Molecules:

// lib/core/design_system/molecules/spacing_fun.dart

double spacingFun(BuildContext context, SizeType type) {
  switch (type) {
    case SizeType.xxl:
      return onDevice(
        context,
        desktop: (context) => Spacings.desktopXXl,
        tablet: (context) => Spacings.tabletXXl,
        mobile: (context) => Spacings.mobileXXl,
      );
    case SizeType.xl:
      return onDevice(
        context,
        desktop: (context) => Spacings.desktopXl,
        tablet: (context) => Spacings.tabletXl,
...
// lib/core/design_system/molecules/text_style_fun.dart

TextStyle textStyleFun(
  BuildContext context,
  TextType type,
  Color color,
) {
  final TextStyle style;
  switch (type) {
    case TextType.heading1:
      style = onDevice(
        context,
        desktop: (context) => TextStyles.desktopHeading1,
        tablet: (context) => TextStyles.tabletHeading1,
        mobile: (context) => TextStyles.mobileHeading1,
      );
    case TextType.heading2:
      style = onDevice(
        context,
        desktop: (context) => TextStyles.mobileHeading2,
      );
    ....

  return style.copyWith(color: color);
}
// lib/core/design_system/molecules/theme_data_func.dart

import 'package:flutter/material.dart';

ThemeData themeDataFun() {
  return ThemeData(
    useMaterial3: true,
    fontFamily: 'Roboto',
    colorScheme: const ColorScheme(
      brightness: Brightness.light,
      primary: Color.fromARGB(255, 0, 90, 193), // 필요시 atoms로 추출
      onPrimary: Color.fromARGB(255, 255, 255, 255),
      onPrimaryContainer: Color.fromARGB(255, 213, 213, 213),
      secondary: Color.fromARGB(255, 249, 246, 246),
      onSecondary: Color.fromARGB(255, 0, 0, 0),
      error: Color.fromARGB(255, 193, 0, 16),
      onError: Color.fromARGB(255, 255, 255, 255),
      surface: Color.fromARGB(255, 239, 240, 251),
      onSurface: Color.fromARGB(255, 0, 0, 0),
      shadow: Color.fromARGB(187, 0, 0, 0),
    ),
  );
}

그리고 사용자 정의 색상 네이밍 경우:

// lib/core/design_system/color_sheme_ds_extensions.dart

extension ColorShemeDsExtensions on ColorScheme {
  Color get shadowLight => shadow.withAlpha(125);
}

사용법

쉬운 사용을 위한 하나 더 작은 API

// lib/core/design_system/build_context_ds_extensions.dart

extension BuildContextDsExtensions on BuildContext {
  ColorScheme get colorScheme => Theme.of(this).colorScheme;

  TextStyle textStyle(TextType style, Color color) {
    return textStyleFun(this, style, color);
  }

  double spacing(SizeType size) {
    return spacingFun(this, size);
  }
}

좋아요! 이제 커스텀 컴포넌트를 확인해보세요 👨🏽‍🎨

// 구성 요소 예시

body: Container(
  color: context.colorScheme.surface,
  child: Padding(
    padding: EdgeInsets.only(
      left: context.spacing(SizeType.m),
      right: context.spacing(SizeType.),
    ),
    child: Center(
      child: Text(
        '텍스트',
        style: context.textStyle(
          TextType.body2,
          context.colorScheme.onSurface,
        ),
      ),
    ),
  ),

응용 프로그램 위젯도 업데이트할 수 있습니다.

// lib/app.dart

class RecordApp extends StatelessWidget {
  final Store<AppState> store;

  const RecordApp({
    required this.store,
    super.key,
  });

  @override
  Widget build(BuildContext context) {
    return StoreProvider(
      store: store,
      child: Layout(
        child: MaterialApp.router(
          theme: themeDataFun(),
          // 라우팅 문서 참조
          routerConfig: getIt<AppRouter>().config(
            navigatorObservers: () => [
              AutoRouteObserver(),
            ],
          ),
        ),
      ),
    );
  }
}

요약:

당신의 어플리케이션을 작은 모듈로 분할하면 유지보수와 확장이 쉬워집니다.

일반적인 문제들은 플러터 커뮤니티에 의해 강화된 일반적인 해결책을 가지고 있어요. 🔻 flutter-web은 패키지 간에 덜 지원받고 있어요

자동 생성은 개발을 가속화시키고 코드를 최상의 방법으로 정렬해줍니다.

감사합니다, 그리고 더 많은 기사를 기대해주세요 👏🏽