해당 포스팅은 유튜브 영화&드라마 리뷰 영상 큐레이션 플랫폼
Plotz
를 개발하면서 도입된 기술 및 방법론에 대한 내용을 다루고 있습니다.
다운로드 링크 : 앱스토어 / 플레이스토어
📝 셀프진단 체크리스
✔️ GetX
상태관리 라이브러리를 이용하고 있다.
✔️ MVVM 아키텍쳐
를 프로젝트에 적용하고 있다.
✔️ 프로젝트에서 관리하는 스크린 위젯
의 개수가 5개 이상이다.
위 체크리스트에 모두 해당하시나요?
그렇다면 이 포스팅에서는 MVVM 아키텍처에서 구조화된 base screen 템플릿 모듈
을 사용하여 화면을 직관적으로 구성하고 개발 생산성을 크게 향상하는 팁을 얻고, 여러분들의 프로젝트에 바로 적용하실 수 있을 겁니다.
본 포스팅에서는 다음 개념들을 다룹니다.
먼저 MVVM
대해 간단히 짚고 갑시다.
MVVM 아키텍처에서는 각 구성 요소가 명확한 역할과 책임을 갖도록 하여 사용자 인터페이스와 비즈니스 로직
을 분리하는 것을 강조합니다. 이를 통해 Presentation 레이어의 역할
과 책임
을 명확히 할 수 있거든요.
Presentation 레이어의 역할
과 책임
은 크게 두 가지로 나눌 수 있습니다.
사용자 인터페이스 구성 및 제공: Presentation 레이어는 UI를 구성하고 사용자에게 제공하는 역할을 수행. 이는 화면의 레이아웃과 디자인을 담당하며, 사용자가 상호작용할 수 있는 인터페이스를 제공.
ViewModel과 상호작용 및 데이터 전달: Presentation 레이어는 ViewModel과 상호작용하며 데이터를 전달하거나 전송하는 역할을 수행. 이를 통해 필요한 데이터를 ViewModel로부터 받아오거나 변경된 데이터를 ViewModel에 전달할 수 있음. 또한, 필요에 따라 UI를 업데이트하여 사용자에게 시각적인 피드백을 제공.
이제 소개할 BaseScreen
모듈은 MVVM
아키텍쳐 관점에 기반하여 개발자가 UI를 편리하고 직관적으로 구성
할 수 있도록 도와주며, View와 ViewModel을 완벽하게 분리
하는 것에 초점을 두고 있습니다.
코드를 먼져 살펴보겠습니다.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
abstract class BaseScreen<T extends GetxController> extends GetView<T> {
const BaseScreen({Key? key}) : super(key: key);
Widget build(BuildContext context) {
if (!vm.initialized) {
initViewModel();
}
return Container(
color: unSafeAreaColor,
child: wrapWithSafeArea
? SafeArea(
top: setTopSafeArea,
bottom: setBottomSafeArea,
child: _buildScaffold(context),
)
: _buildScaffold(context),
);
}
Widget _buildScaffold(BuildContext context) {
return Scaffold(
extendBody: extendBodyBehindAppBar,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
appBar: buildAppBar(context),
body: buildScreen(context),
backgroundColor: screenBackgroundColor,
bottomNavigationBar: buildBottomNavigationBar(context),
floatingActionButtonLocation: floatingActionButtonLocation,
floatingActionButton: buildFloatingActionButton,
);
}
Color? get unSafeAreaColor => Colors.black;
bool get resizeToAvoidBottomInset => true;
Widget? get buildFloatingActionButton => null;
FloatingActionButtonLocation? get floatingActionButtonLocation => null;
bool get extendBodyBehindAppBar => false;
Color? get screenBackgroundColor => Colors.white;
Widget? buildBottomNavigationBar(BuildContext context) => null;
Widget buildScreen(BuildContext context);
PreferredSizeWidget? buildAppBar(BuildContext context) => null;
bool get wrapWithSafeArea => true;
bool get setBottomSafeArea => true;
bool get setTopSafeArea => true;
void initViewModel() {
vm.initialized;
}
T get vm => controller;
}
BaseScreen
클래스는 일반적인 앱 화면의 공통 요소
를 추상화
하여, 다양한 화면에서 공통으로 사용할 수 있는 템플릿입니다. 이 클래스를 사용하면 화면을 구성하는 요소들을 활용하여 일관된 스켈레톤 구조
를 제공함으로써 개발 생산성
을 대폭 향상시킬 수 있습니다.
또한, BaseScreen 클래스는 GetView
를 상속하며, GetxController
를 확장한 타입 T
를 사용합니다. 이를 통해 MVVM 구조에 맞게 ViewModel로 사용되는 GetxController와 1대1 대응
되는 형태로 작동합니다. 이러한 구조를 통해 View에서 손쉽게 데이터를 주고받을 수 있도록 지원합니다.
위와 같이 BaseScreen 클래스는 앱 개발에서 공통적으로 필요한 기능들을 추상화하여 템플릿으로 제공하며, MVVM 아키텍처에 맞는 데이터 흐름을 간편하게 구현할 수 있도록 도와줍니다.
이제 BaseScreen 모듈의 핵심 구성 요소를 하나하나 살펴보도록 하겠습니다.
build(BuildContext context) {
if (!vm.initialized) {
initViewModel();
}
return Container(
color: unSafeAreaColor,
child: wrapWithSafeArea
? SafeArea(
top: setTopSafeArea,
bottom: setBottomSafeArea,
child: _buildScaffold(context),
)
: _buildScaffold(context),
);
}
Widget
이 메서드는 화면의 구성 요소를 생성하며, SafeArea
를 조건부로 사용하여 화면의 내용을 안전 영역으로 감쌀지를 결정할 수 있습니다. 또한, setBottomSafeArea
와 setTopSafeArea
속성은 SafeArea 위젯에 적용되어 상단과 하단의 안전 영역을 설정하는 데 사용됩니다. 하위 클래스에서 이러한 속성을 재정의하여 개별 화면에 맞게 상단과 하단의 안전 영역을 설정할 수 있습니다.
또한, Scaffold
를 Container
위젯으로 감싸고 있고 해당 Container의 color 속성에 unSafeAreaColor
이 적용되어 있어, 위의 코드처럼 unSafeAreaColor 값으로 safeArea 밖의 color 값을 지정할 수 있습니다.
Widget _buildScaffold(BuildContext context) {
return Scaffold(
extendBody: extendBodyBehindAppBar,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
appBar: buildAppBar(context),
body: buildScreen(context),
backgroundColor: screenBackgroundColor,
bottomNavigationBar: buildBottomNavigationBar(context),
floatingActionButtonLocation: floatingActionButtonLocation,
floatingActionButton: buildFloatingActionButton,
);
}
이 메서드는 기본적인 스켈레톤 구조를 제공하는 Scaffold
위젯을 구축합니다. AppBar, Screen Body, BackgroundColor, BottomNavigationBar 및 FloatingActionButton과 같은 여러 구성 요소를 관리합니다.
또한,buildScreen
메서드는 하위 클래스에서 필수적
으로 재정의되어야 하는 메서드입니다.
buildScreen(BuildContext context);
Widget? buildBottomNavigationBar(BuildContext context) => null;
PreferredSizeWidget? buildAppBar(BuildContext context) => null;
Widget
위 3가지 메서드는 모두 추상 메서드로 선언되어 있습니다. 그중에서도 buildScreen 메서드는 기본값
이 설정되어 있지 않고, nullable한 위젯
이 아니기 때문에 반드시 오버라이드되어야 하는 필수 메서드입니다.
? get unSafeAreaColor => Colors.black;
bool get resizeToAvoidBottomInset => true;
Widget? get buildFloatingActionButton => null;
FloatingActionButtonLocation? get floatingActionButtonLocation => null;
bool get extendBodyBehindAppBar => false;
Color? get screenBackgroundColor => Colors.white;
Widget? buildBottomNavigationBar(BuildContext context) => null;
Widget buildScreen(BuildContext context);
PreferredSizeWidget? buildAppBar(BuildContext context) => null;
bool get wrapWithSafeArea => true;
bool get setBottomSafeArea => true;
bool get setTopSafeArea => true;
Color
위 코드에서 사용되는 getter 메서드는 하위 클래스에서 재정의할 수 있으며, 각 화면에 대한 맞춤화된 동작을 제공합니다.
여기서 반복적으로 사용되는 @protected
키워드는 Dart 언어의 메타데이터 어너테이션으로, 해당 속성이나 메소드를 클래스 외부에서 직접 사용하지 못하도록 제한
하는 역할을 합니다. 대신, 해당 속성이나 메소드는 클래스를 상속받은 하위 클래스
에서만 재정의하거나 호출할 수 있습니다.
이렇게 함으로써, 다음과 같은 이점
들을 얻을 수 있습니다.
캡슐화 : 클래스의 내부 구현 세부 사항을 숨기고 외부로 노출되는 인터페이스를 제한할 수 있음. 이를 통해 클래스의 구현을 변경하더라도 외부에서 사용하는 인터페이스는 그대로 유지되어 코드의 유지보수가 쉬워짐.
상속 계층의 명확성 : @protected
로 표시된 속성과 메서드는 하위 클래스에서만 사용 가능하므로, 상속 계층을 따라 어떤 속성과 메서드가 재정의되고 사용되어야 하는지 명확하게 파악할 수 있음. 이를 통해 코드의 가독성과 이해도가 향상됨.
확장 가능성 : @protected
키워드가 적용된 속성과 메서드는 하위 클래스에서 재정의할 수 있으므로, 상속받은 클래스에서 특정 동작을 변경하거나 확장할 수 있음 이로 인해 코드의 유연성과 확장 가능성이 향상됨.
예기치 않은 오류 방지 : 클래스를 잘못 사용하는 것을 방지하고, 클래스 내부의 속성이나 메서드를 외부에서 직접 변경하거나 호출하지 못하도록 제한함으로써, 예기치 않은 오류나 버그를 줄일 수 있음.
또한, 이 구조는 클래스의 멤버 변수가 아니라 getter
를 사용함으로써 필요한 경우에만 해당 값을 계산하고 반환하므로, 전체적인 메모리 사용량을 고려했다고 볼 수 있겠죠.
abstract class BaseScreen<T extends GetxController> extends GetView<T> {
const BaseScreen({Key? key}) : super(key: key);
.....(some code)
T get vm => controller;
위 코드는 BaseScreen 모듈에서 가장 핵심적인 부분입니다. BaseScreen 클래스는 GetView<T>
를 상속하여 ViewModel(GetxController)과 상호작용할 수 있게 됩니다. 여기서 제네릭 타입 T는 GetxController
를 상속받는 타입을 나타냅니다.
NOTE: Generic Type
<T extends GetxController>
는 제네릭 타입을 정의한 부분입니다. T는 제네릭 타입 변수로, 실제 타입이 지정되지 않은 상태에서 일종의타입 플레이스홀더
역할을 합니다.
즉,extends GetxController
부분은 T가GetxController 클래스를 상속한 타입
이어야 함을 나타냅니다.
@protected
어노테이션이 적용된 vm getter 메서드는 BaseScreen 클래스 내부에서 GetxController의 인스턴스에 접근할 수 있는 속성인 controller를 사용합니다. 이를 통해 코드를 명확하게 작성하고 가독성을 높일 수 있습니다. vm
이라는 이름을 사용하여 컨트럴러 인스턴스에 접근할 수 있게 됩니다.
따라서, BaseScreen 클래스를 상속받은 Screen 위젯에서는 ViewModel 인스턴스에 편리하게 접근할 수 있으며, 코드를 논리적이고 가독성 있게 작성할 수 있습니다.
abstract class BaseScreen<T extends GetxController> extends GetView<T> {
const BaseScreen({Key? key}) : super(key: key);
.....
}
@immutable
키워드는 Dart 언어에서 불변(immutable) 객체를 표시하는 어노테이션입니다. 불변 객체란 한 번 생성되면, 그 상태가 변경되지 않는 객체를 말합니다. 그러므로@immutable
키워드를 선언함으로써 클래스 멤버 변수들이final
로 선언되어야 함을 강제하는 역할을 하기도 합니다.
근데 한 가지 의문이 드실겁니다🤔
BaseScreen를 상속받아서 사용할 때 getter 속성을 재정의할 수 있기 때문에 완전히 불변한 객체라고 보기 힘들고, 해당 클래스에서 관리하고 있는 멤버 변수
가 하나도 없기 때문에 final
을 강제할 필요할 필요도 없지 않냐고 제게 반문하실 수 있겠죠.
여러분이 맞습니다.
하지만, 저는 코드를 명시적으로 만들기 위해 @immutable
를 사용하는 것을 선호합니다. 이를 통해 클래스의 멤버 변수들이 final
로 선언되어야 함을 강조하고, 클래스의 성격을 불변성에 가깝게 만들기 위한 목적으로 사용합니다.
사실,@immutable
어노테이션이 없어도 코드의 동작에는 문제가 없습니다. 하지만, 해당 어노테이션을 사용함으로써 코드를 더 명확하게 작성할 수 있습니다.
void initViewModel() {
vm.initialized;
}
거의 다 왔습니다. 마지막으로 initViewModel
메소드에 대해 알아봅시다.
이렇게 BaseScreen 클래스에서 사용하는 initViewModel
메소드는 GetxController가 lazy하게 inject
되는 경우, GetX의 라이프사이클에 유의해야 할 때 중요한 역할을 합니다.
만약 Get.lazyPut
을 사용하여 컨트롤러를 inject하고 있다면, 특정 위젯에서 GetxController의 인스턴스에 접근하기 전까지는 컨트롤러가 inject되지 않도록 설정되어 있기 때문에 initViewModel 메소드를 사용하여 컨트롤러를 강제로 초기화
해야 합니다.
class SplashViewModel extends GetxController {
void someInitialMethod() {
// some events
}
void onInit() {
super.onInit();
someInitialMethod(); // <-- 해당 메소드가 발동이 되어야 함.
}
}
class SplashScreen extends GetView<SplashViewModel> {
const SplashScreen({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return const Center(
child: Text('Splash Screen'),
);
}
}
예를 들어, Splash View와 SplashViewModel이 있다고 가정해 봅시다. Get.lazyPut을 사용하여 컨트롤러를 inject하였고, SplashScreen에서는 SplashViewModel 인스턴스에 접근하지 않습니다. 단지 ViewModel에서 관리되고 있는 someInitialMethod() 메소드가 Splash 화면이 나타날 때 실행되어야 한다는 것이 목표입니다.
하지만, SplashScreen에서 GetxController(SplashViewModel)의 인스턴스에 접근하지 않았기 때문에 GetXController 자체가 초기화되지 않아 someInitialMethod 이벤트도 발생하지 않을 것입니다.
class SplashScreen extends GetView<SplashViewModel> {
const SplashScreen({Key? key}) : super(key: key);
Widget build(BuildContext context) {
/// GetxController 기본 인스턴스에 접근
/// 어떤 인스턴턴스에 접근하던지 상관 없음
controller.initialized;
//controller.isClosed;
//controller.isBlank;
//controller.runtimeType;
return const Center(
child: Text('Splash Screen'),
);
}
}
이럴 경우, 위의 코드에서와 같이 initizlied
라는 GetxController 기본 인스턴스에 접근하여 lazy하게 inject되는 컨트롤러를 강제로 초기화해야 합니다.
결과적으로 화면이 보일 때 무조건 GetxController를 초기화의 보장한다고 볼 수 있습니다.
abstract class BaseScreen<T extends GetxController> extends GetView<T> {
const BaseScreen({Key? key}) : super(key: key);
Widget build(BuildContext context) {
if (!vm.initialized) {
initViewModel();
}
....
void initViewModel() {
vm.initialized;
}
이제 왜 BaseScreen 클래스에서 initViewModel 메소드를 사용하는지 이해가 될 것 입니다.
NOTE
마지막으로 요약하자면, lazy inject되는 GetxController도 화면이 보여짐과 동시에 inject 되는 것을 보장하기 위해 initViewModel()매소드를 사용합니다.
이번 포스팅에서는 BaseScreen
클래스를 기반으로 개발자가 화면 구성을 보다 효율적으로 구현할 수 있도록 돕고, MVVM 구조에서 View와 ViewModel의 역할과 책임을 명확하게 해주는 방법에 대해 알아보았습니다.
설명이 조금 복잡했다면, BaseScreen 예제 코드가 있는 깃헙 레포를 클론 받아 이것저것 만져 보시길 바랍니다. 전혀 어렵진 않습니다.
한번 익숙해진다면 굉장히 편리하고 개발 생산성이 엄청 높아질겁니다😀
다음 포스팅에서는 Provider
상태 관리 라이브러리를 기반으로 한 BaseScreen
클래스를 구성하는 방법에 대해 다루겠습니다.
만약 Scaffold
를 기반으로 한 완전한 앱 화면을 구성하지 않고 단순한 위젯을 빌드하고 컨트롤러와 화면 레이아웃을 분리하고 싶으시면 아래 코드를 사용하시면 됩니다.
abstract class BaseView<T extends BaseViewModel> extends GetView<T> {
const BaseView({Key? key}) : super(key: key);
T get vm => controller;
Widget build(BuildContext context) {
return buildView(context);
}
Widget buildView(BuildContext context);
}
bloc 버전도 기대하고 있ㅅ습니다 선생님