View : 사용자에게 보여지는 UI 부분으로 사용자의 동작이나 각정 처리는 ViewModel에 의뢰
ViewModel : Model을 View에 표시하기 위한 처리
-View로부터 의뢰 받은 처리를 Model에 의뢰합니다.
-Model로부터 처리 결과를 View에 통지(자동갱신) 합니다.
-하나의 화면(View)에 하나의 ViewModel 이 일반적입니다.
-여러가지 상태(변수)를 캡슐화 : 화면에 표시할 데이터, 로딩 상태 등이 있습니다.
Model: 데이터와 데이터를 처리하는 부분 (비즈니스 로직)
-ViewModel로부터 의뢰받은 로직을 처리
-DB, File, Web으로부터 데이터를 가져오거나 전송 등 (Repository)
View -> ViewModel(UI 로직) -> Repository(Data 로직) -> DataSource
어플리케이션 상태의 종류
상태 = 데이터 = 변수
즉, 변수를 수정하면 알아서 UI도 바뀌게 합니다.
= IngeritedWidget + (ValueNotifier 또는 ChangeNotifier)
widget.viewModel.addListener(updateUi);
viewModel이 변경되면 updateUi을 호출합니다.
화면이 종료될 때 dispose로 구독을 제거해야 합니다.
잊게되면 메모리 Leak이 발생될 수 있습니다.
class _TodoScreenState extends State<TodoScreen> {
void updateUi() => setState(() {});
void initState() {
super.initState();
widget.viewModel.addListener(updateUi);
}
void dispose() {
widget.viewModel.removeListener(updateUi);
super.dispose();
}
}
ValueNotifier : 단일 값의 변화를 알릴 때 사용.
ChangeNotifier : 더 복잡한 상태 관리를 위해 사용.
역할 : Listenable 객체를 관찰하고 상태가 변경될 때 자동으로 UI를 다시 빌드.
장점 : 간결한 코드: addListener와 removeListener를 직접 관리할 필요 없음.
효율성 : 특정 위젯만 다시 빌드되므로 성능에 유리.
제약 : Listenable 객체에 의존하므로, Listenable이 아닌 상태 관리 솔루션에는 사용할 수 없음.
listenable : 관찰할 Listenable 객체 (필수).
builder : UI를 다시 빌드할 함수.
child : 상태 변경에 영향을 받지 않는 부분을 캐싱하여 성능 최적화 가능.
Widget build(BuildContext context) {
return Scaffold(
body: ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
return Column(
children: [
ElevatedButton(
onPressed: () => viewModel.fetchTodos(),
child: const Text('가져오기'),
),
...viewModel.todos.map((todo) => Text(todo.title)).toList(),
],
);
},
),
);
}
ListenableBuilder 사용해야 할 위젯이 많을 경우
또는 앱의 상태 관리가 복잡해질 경우
Riverpod이나 Provider 같은 상태 관리 패키지를 함께 사용할 수 있습니다.
setState()에 의해 하위 모든 위젯이 다시 그려지는 상태관리를
Provider 사용시 꼭 필요한 위젯만 다시 그릴 수 있습니다.
즉, 전역에서 사용해야 하는 데이터들을 관리합니다.
View(ChangeNotifierProvider) : 위젯 트리의 최상위에 설정하여 ChangeNotifier를 감시
ViewModel(ChangeNotifier) : NotifyListeners() 변경을 통지
즉, Provider는 IngeritedWidget을 만들지 않아도 심플하게 사용하도록 한 것입니다.
IngeritedWidget와 가장 흡사합니다 = 근본
child: Consumer<CounterModel>(
builder: (context, counterModel, child) {
return Text(
'Counter: ${counterModel.counter}',
style: TextStyle(fontSize: 24),
);
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Provider로 상태 접근 가능
Provider.of<CounterModel>(context, listen: false).increment();
},
Consumer
특정 상태의 변경 사항을 감지하여, 원하는 위젯만 다시 빌드합니다.
context를 통해 CounterModel() 가 MyApp()에 주입됩니다.
watch()는 지속적인 관찰을 하고 변경시 build()를 리빌드 합니다.
build() 메서드 내에서 consumer 대신 다음과 같이 사용합니다.
Widget build(BuildContext context) {
final model = context.watch<counterModel>();
read()는 지속적인 관찰이 아닌 단발성 접근에 사용합니다.
즉, initState() 또는 버튼 클릭에 사용됩니다.
context.read<counterModel>().increase();
Provider.of<CounterModel>(context, listen: false).increase();
위 코드 두가지는 동일한 코드입니다.
//main.dart
//기본 제공되는 ChangeNotifierProvider로 Provider<MainViewModel> 주입
//ChangeNotifierProvider 어디에 배치하느냐에 따라 사용범위가 달라짐
//화면 하나 당 1개의 ChangeNotifierProvider를 사용 추천
home: ChangeNotifierProvider<MainViewModel>(
create: (BuildContext context) {
return MainViewModel(
storeRepository: StoreRepositoryImpl(),
locationRepository: LocationRepositoryImpl(),
);
},
child: const MainScreen(),
//MainScreen
class MainScreen extends StatelessWidget {
const MainScreen({super.key});
Widget build(BuildContext context) {
final viewModel = context.watch<MainViewModel>();
return Scaffold(
appBar: AppBar(
title: Text('마스크 재고 있는 곳: ${viewModel.stores.length}곳'),
),
body: ListView(
children:
viewModel.stores.map((store) => StoreItem(store: store)).toList(),
),
);
}
}
동일한 데이터로 이미 생성된 객체를 재사용하려고 할 때 사용합니다.
특정 조건에 따라 객체의 서브클래스를 반환하거나 초기화 로직을 추가해야 할 때 사용합니다.
조건에 따라 새 객체 생성 또는 기존 객체 반환 가능합니다.
Dart에서 객체를 반환할 때 새 인스턴스를 생성하거나 기존 객체를 재활용할 수 있습니다.
class User {
final String name;
static final Map<String, User> _cache = {};
// 팩토리 생성자
factory User(String name) {
// 이미 동일한 name으로 생성된 User가 있다면 재사용
if (_cache.containsKey(name)) {
return _cache[name]!;
} else {
final newUser = User._internal(name);
_cache[name] = newUser;
return newUser;
}
}
// 실제 객체를 생성하는 private 생성자
User._internal(this.name);
}
클래스의 인스턴스를 단 하나만 유지하도록 보장하는 디자인 패턴입니다.
앱 전체에서 동일한 인스턴스를 공유합니다.
주로 공유 리소스(예: 데이터베이스 연결, 설정 정보 등)나 상태를 유지하기 위해 사용합니다.
메모리 낭비 방지하며, 전역적인 데이터나 리소스를 한 곳에서 관리합니다.
class Singleton {
// static 변수로 단 하나의 인스턴스 유지
static final Singleton _instance = Singleton._internal();
// private 생성자
Singleton._internal();
// 외부에서 호출 가능한 싱글톤 인스턴스 제공
factory Singleton() {
return _instance;
}
void someMethod() {
print("싱글톤 메서드 호출");
}
}
소프트웨어 개발에서 데이터 저장소에 접근하는 객체를 추상화하고,
데이터소스 (DB, File 등)와의 통신을 담당하는 객체를 캡슐화하는 디자인 패턴입니다.
Repository 패턴
비즈니스 로직과 데이터를 분리하는 이점
유지 관리성 향상
재사용성 향상(다형성)
캐시가 비어 있으면 데이터를 가져오고, 캐시가 차 있으면 캐시의 데이터를 리턴
테스트 용이성 향상
확장성 향상
데이터 액세스 추상화
Int 와 Double 은 호환이 되지 않기 때문에
API가 Double이라면 Model을 만들 때
임시로 Int 와 Double을 포함하는 num 을 사용하여 위도와 경도를 입력할 수 있습니다.
http.get(url)을 이용합니다.
print('Response body: ${response.body}');
서버에서 API 값을 가져왔는데 한글이 깨질 경우, utf8.decode을 사용합니다.
import 'dart:convert';
print('Response body: ${jsonDecode(utf8.decode(response.bodyBytes))}');
"stores": [{
"addr": "서울특별시 강북구 삼양로 255 (미아동)",
"code": "11819723",
"created_at": "2020/07/03 11:00:00",
"lat": 37.6261612,
"lng": 127.0180494,
"name": "청구약국",
"remain_stat": "plenty",
"stock_at": "2020/07/03 10:40:00",
"type": "01"
},
{...},
{...},
]
where을 이용하여 가져올 데이터를 선택합니다.
true: 모든 데이터를 가져옵니다.
false: 모든 데이터를 가져오지 않습니다.
return jsonList
.where((e) => e['remain_stat'] != null)
.map((e) => MaskStore(
name: e['name'] as String,
address: e['addr'] as String,
distance: 0,
remainStatus: e['remain_stat'] as String,
latitude: e['lat'] as double,
longitude: e['lng'] as double))
.toList();
class MainViewModel with ChangeNotifier {
final StoreRepository _storeRepository;
final LocationRepository _locationRepository;
MainViewModel({
required StoreRepository storeRepository,
required LocationRepository locationRepository,
}) : _storeRepository = storeRepository,
_locationRepository = locationRepository {
fetchStores();
}
// 상태
List<MaskStore> _stores = [];
bool _isLoading = false;
List<MaskStore> get stores => List.unmodifiable(_stores);
bool get isLoading => _isLoading;
void fetchStores() async {
_isLoading = true;
notifyListeners();
final stores = await _storeRepository.getStores();
final location = await _locationRepository.getLocation();
for (var store in stores) {
store.distance = _locationRepository.distanceBetween(
store.latitude,
store.longitude,
location.latitude,
location.longitude,
);
}
stores.sort((a, b) => a.distance.compareTo(b.distance));
_stores = stores;
_isLoading = false;
notifyListeners();
}
}
class StoreRepositoryImpl implements StoreRepository {
Future<List<MaskStore>> getStores() async {
final response = await http.get(Uri.parse(
'https://gist.githubusercontent.com/junsuk5/bb7485d5f70974deee920b8f0cd1e2f0/raw/063f64d9b343120c2cb01a6555cf9b38761b1d94/sample.json'));
final List jsonList = jsonDecode(response.body)['stores'];
return jsonList
.where((e) => e['remain_stat'] != null)
.map((e) => MaskStore(
name: e['name'] as String,
address: e['addr'] as String,
distance: 0,
remainStatus: e['remain_stat'] as String,
latitude: e['lat'] as double,
longitude: e['lng'] as double))
.toList();
}
}
class LocationRepositoryImpl implements LocationRepository {
double distanceBetween(
double startLat, double startLng, double endLat, double endLng) =>
Geolocator.distanceBetween(startLat, startLng, endLat, endLng);
Future<Location> getLocation() async {
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (serviceEnabled) {
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
return const Location(latitude: 0, longitude: 0);
} else if (permission == LocationPermission.deniedForever) {
return const Location(latitude: 0, longitude: 0);
}
// 승인
final position = await Geolocator.getCurrentPosition();
return Location(
latitude: position.latitude,
longitude: position.longitude,
);
}
return const Location(latitude: 0, longitude: 0);
}
}