Flutter | 플러터 상태관리 패키지 사용해보기 - bloc - 1. 리스트 목록에서 좋아하는 목록 만들기(저장 및 삭제)
플러터를 사용하다 보면 여러 페이지들의 데이터가 동기화가 필요하고 지금 페이지에서 처리한 데이터가 다른페이지에 적용이 되어야 할때가 있습니다. 이럴때 사용하는게 상태관리입니다. 현재 플러터에는 주로 4가지 상태관리 패키지를 사용합니다. 현재 사용자들에게서 논란이 많은 GetX, 많은 분들이 사용하는 Provider, Provider 개발자가 만든 신흥 강자 revierpod, 그리고 중대형 프로젝트에서 주로 쓰이지만 다른것들에 비해 조금 어려운 bloc까지, 이 4가지가 많이 사용되는데요. 다른것들은 주로 사용해 봤지만 bloc는 사용해보지 않아서 한번 해보고자 합니다. 많은분들이 1가지만 배워도 할 수있나, 아니면 다른것들도 배워야 하나 싶으실텐데 저는 지금 급한거라면 다른걸 배우기보다 지금 사용하는걸 이용하시는걸 추천드리고 시간이 있다면 다양하게 배워보시는걸 추천드립니다. 그 이유는 장단점을 떠나서 여러가지를 할 줄 알아야 그 패키지를 파악할 수 있고 본인이 할 프로젝트에 직접 선택해서 사용하는게 더 도움이 될것이라 생각합니다. 그래서 저는 이번에 처음 해보는 bloc를 공부해보자 합니다. 우선 더코딩파파님의 유튜브를 참고하여 기본적인것을 배워보고 개발하는 남자님의 유튜브를 이번에 보다보니 잘 섹션별로 나눠서 설명해주신게 보여서 참고해서 사용해 볼까 합니다. 그럼 우선 더코딩파파님의 유튜브 내용을 참고해서 시작해 보겠습니다. bloc에 대해 궁금하신 분들은 제 글을 보고 따라하기보다는 두 분의 유튜브를 보는게 더 좋을것 같습니다. 오늘의 내용은 우선 Stream을 통해 기본적인 것들을 알아보겠습니다. 약 3년전 영상의 내용이다 보니 조금 다른부분이 있습니다. null safety 전이기도 하기 때문에 기본적인 틀과 어떻게 사용하는지 참고만 하시면 됩니다.
기본적으로 리스트로 메뉴들이 주어지고 리스트를 눌러서 저장을 하면 리스트를 보여주는 페이지에서 볼수 있게 만들어 보겠습니다. 그리고 목록에서는 리스트를 눌러서 저장과 삭제가 가능하게 만들것입니다. 그리고 저장된 리스트를 보는 페이지에서는 리스트 누르면 저장된 리스트에서 삭제가 되도록 만들어 보겠습니다. 현재는 flutter의 codelabs에서 infinite list가 없어서 해당 부분을 검색해서 main.dart만 가져왔습니다.(https://github.com/omrobbie/flutter-startup-namer/blob/master/lib/main.dart) 해당 주소 참고하시면 될 것 같습니다.
english_words 패키지를 사용하면 랜덤한 단어를 추출해서 보여주는 것을 볼 수 있습니다. 이제 MyApp을 제외한 부분을 다른 파일로 옮겨주도록 하겠습니다. lib 아래에 src폴더를 만들어 줍니다. random_words.dart 파일로 만들어 주겠습니다. 그리고 필요한 부분들을 import 해주시면 됩니다. 그 후 실행해 보면 정상 작동 하는것을 볼 수 있습니다.
final index = i ~/ 2;
여기서 몇가지 코드를 보자면 _buildSuggestions에서 index = i ~/ 2;는 i를 2로 나눴을때 몫을 나타냅다. 말하자면 builder의 index인 i에서 홀수는 Divider로 보여주고 나머지 짝수를 다시 0,1,2,3...으로 차례대로 나열시키는 것입니다.
if (index >= _suggestion.length) {
_suggestion.addAll(generateWordPairs().take(10));
}
그 밑에 generateWordPairs().take()는 _suggestion의 길이보다 index가 커지면 10개의 단어를 가져와서 넣어주는 것입니다. 말하자면 10개씩 단어를 불러오고 무한히 생성됩니다.
return _buildRow(_suggestion[index]);
그리고 ListTile로 불러온 _suggestion을 index로 보여주는 것 입니다. 무한대로 늘어나므로 builder에서 에러가 생기지 않습니다.
final bool alreadySaved = _saved.contains(pair);
그리고 contains라는 함수는 _saved에 포함되어있냐는 뜻으로 true 또는 false의 값을 가지게 됩니다. 그래서 이를 통해 하트의 색을 바꿔주고 저장시키거나 저장에서 삭제해줍니다.
setState(() {
if (alreadySaved) {
_saved.remove(pair);
} else {
_saved.add(pair);
}
});
또한 이 부분에서 setState로 감싸주는데 stateful 위젯으로 변화가 필요한 부분이기 때문에 값도 setState로 state를 다시 그려줍니다. setState가 없다면 Set인 _saved해준부분에 값이 변화가 없습니다. setState를 통해 화면고 값을 변화시켜줍니다.
그리고 이번에는 src 안에 saved_list.dart 파일을 만들어 주고 SavedList라는 클래스를 StatefulWidget로 만들어 줍니다. 그리고 final tiles부터 하단에 return Scaffold까지 saved_list.dart의 build에 옮겨 줍니다. (context){}의 부분을 (context) => SavedList(saved: _saved)로 바꿔 주시고 _pushSaved 함수가 있는 AppBar에 Navigator.of(context).push 부분을 복사해서 (){}안에 넣어주시면 됩니다. 그리고 _pushSaved 함수는 삭제해주셔도 됩니다. SavedList에서 _biggerFont 부분은 TextStyle에서 fontSize만 18로 해주시면 됩니다. 여기서 ListTile을 나타내는 부분은 random_words.dart의 _buildSuggestions와 _buildRow와 비슷하게 해주시면 됩니다. _buildSuggestions를 그대로 복사해서 함수명만 _buildList로 변경해 줍니다. 그리고 if(index>=_suggestion.length){} 부분은 삭제해주시면 됩니다.
return _buildRow(widget.saved.toList()[index]);
return에 _suggestion을 widget.saved.toList()[index]로 변경 해주시면 됩니다.
Widget _buildRow(WordPair pair) {
return ListTile(
title: Text(pair.asPascalCase, style: const TextStyle(fontSize: 18,),),
onTap: (){
setState(() {
widget.saved.remove(pair);
});
},
);
}
_buildRow는 간단하게 ListTile과 Text의 내용과 onTap에 setState 안에 widget.saved.remove(pair);만 남겨주시면 됩니다. 있고 없고에 따라 변하는 부분이 필요가 없습니다. 이렇게만 하면 에러가 나는데 그 이유는 ListTile에서 itemCount를 지정해 주지 않아서 범위 이상을 보여주는데 그때의 return값이 saved[index]의 값이 없어서 그렇습니다.
itemCount: widget.saved.length*2,
ListTile에 itemCount에 widget.saved.length*2를 넣어줘야 합니다. divider와 ListTile이 같이 필요하기 때문에 saved의 길이보다 2배가 필요합니다. 여기까지하면 저장목록에서 삭제후 돌아오면 목록이 저장된것같이 보이지만 목록중 하나를 눌러주면 다시 화면이 변하는 것을 알 수 있습니다. 이를 수정해 줘야 하는데 여기서 원래 더코딩파파님의 영상을 보면 saved가 보내줄때 레퍼런스를 보내서 정상작동(이 부분은 맞음, 돌려주는 값이 없는데도 해당 부분을 지워고 값이 바뀜)하는데 저는 되지 않았습니다.
onPressed: () {
Navigator.of(context)
.push(
MaterialPageRoute(
builder: (context) => SavedList(saved: _saved),
),
).then((_) {
setState(() {});
});
},
그래서 저는 MaterialPageRoute뒤에 then()에 setState를 넣어서 화면이 돌아왔을때 화면 갱신을 해줘서 해결했습니다. 댓글로 보아서 윈도우에서 문제일수도 있어서 남겨두겠습니다.
현재까지의 전체 코드입니다.
import 'package:bloc_example/src/random_words.dart';
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Startup Name Generator',
theme: ThemeData(
primaryColor: Colors.green
),
home: const RandomWords(),
);
}
}
import 'package:bloc_example/src/saved_list.dart';
import 'package:english_words/english_words.dart';
import 'package:flutter/material.dart';
class RandomWords extends StatefulWidget {
const RandomWords({super.key});
@override
State<RandomWords> createState() => RandomWordsState();
}
class RandomWordsState extends State<RandomWords> {
final List<WordPair> _suggestion = <WordPair>[]; // 전체 랜덤 단어
final _biggerFont = const TextStyle(fontSize: 18.0);
final Set<WordPair> _saved = <
WordPair>{}; // 선택된 단어들을 저장하는 곳 | Set<WordPair>() 이 형태가 <WordPair>{} 이 형태로 변경됨
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Startup Name Generator'),
actions: [
IconButton(
icon: const Icon(Icons.list),
onPressed: () {
Navigator.of(context)
.push(
MaterialPageRoute(
builder: (context) => SavedList(saved: _saved),
),
)
.then((_) {
setState(() {});
});
},
),
],
),
body: _buildSuggestions(),
);
}
Widget _buildSuggestions() {
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemBuilder: (context, i) {
if (i.isOdd) return const Divider(); // isOdd 는 홀수
final index = i ~/ 2;
if (index >= _suggestion.length) {
_suggestion.addAll(generateWordPairs().take(10));
}
return _buildRow(_suggestion[index]);
},
);
}
Widget _buildRow(WordPair pair) {
final bool alreadySaved = _saved.contains(pair);
return ListTile(
title: Text(
pair.asPascalCase,
style: _biggerFont,
),
trailing: Icon(
alreadySaved ? Icons.favorite : Icons.favorite_border,
color: alreadySaved ? Colors.red : null,
),
onTap: () {
setState(() {
if (alreadySaved) {
_saved.remove(pair);
} else {
_saved.add(pair);
}
});
},
);
}
}
import 'package:english_words/src/word_pair.dart';
import 'package:flutter/material.dart';
class SavedList extends StatefulWidget {
const SavedList({Key? key, required this.saved}) : super(key: key);
final Set<WordPair> saved;
@override
State<SavedList> createState() => _SavedListState();
}
class _SavedListState extends State<SavedList> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Saved Suggestions'),
),
body: _buildList(),
);
}
Widget _buildList() {
return ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: widget.saved.length*2,
itemBuilder: (context, i) {
if (i.isOdd) return const Divider(); // isOdd 는 홀수
final index = i ~/ 2;
return _buildRow(widget.saved.toList()[index]);
},
);
}
Widget _buildRow(WordPair pair) {
return ListTile(
title: Text(pair.asPascalCase, style: const TextStyle(fontSize: 18,),),
onTap: (){
setState(() {
widget.saved.remove(pair);
});
},
);
}
}
우리는 지금까지 setState를 통해서 화면의 전체를 다시 그려줬습니다. 화면을 setState를 통해 변경하고 넘겨줬고 다시 변경된 값을 돌려받아서 다시 setState를 통해 화면을 계속 다시 그려줬습니다. 만약 여러개의 page에서 사용한다면 계속 주소를 다시 보내주고 리소스를 많이 씁니다. 이제는 bloc를 통한 상태관리로 해당 값을 화면 전체를 다시 그리는게 아닌 저장공간안의 주소에 있는 값만 바꾸고 그 값이 변경된 부분만을 바꿔서 사용하도록 해보겠습니다. 여러페이지에서 하더라 이를 통해 리소스를 아낄수 있고 더 빠르고 가벼운 동작을 할 수 있을 것입니다.
src 폴더 아래에 bloc 폴더를 만들어 주고 saved_bloc.dart를 만들어 줍니다. 필요한 english_words를 import 해줍니다.
class SavedBloc {
Set<WordPair> saved = <WordPair>{};
}
그리고 SavedBloc 클래스를 만들어 주겠습니다. 그리고 bloc에서 저장해둘 saved를 만들어 주겠습니다. 그리고 어디서든 접근 가능하도록 인스턴스를 만들어 주겠습니다.
var savedBloc = SavedBloc();
그리고 Stream을 만들어 줘야 합니다. StreamController를 이용하면 됩니다.
final _savedController = StreamController<Set<WordPair>>();
그리고 이 streamController을 만들어주면 꼭 같이 close도 만들어 줘야 합니다.
dispose(){
_savedController.close();
}
bloc를 쓸때 stream을 계속 써주면 메모리 누수가 생길 수 있어서 close를 해줘야 할 시기가 필요합니다. 이제 이 controller를 통해 스트림을 가져올수 있는 function을 만들어 주겠습니다.
Stream<Set<WordPair>> getSavedStream (){
return _savedController.stream;
}
이때 아무 데이터를 보내주지 않고 Stream만을 받아오기 때문에 다음과 같은 형식으로 바꿔도 됩니다.
get getSavedStream {
return _savedController.stream;
}
이렇게 사용하면 dart에서 아무데이터도 안보내고 어떤데이터를 받아오는지 알기때문에 변경할 수 있고 여기서 더 짧게 아래와 같이 바꿀 수 있습니다.
get savedStream => _savedController.stream;
다른 파일에서 접근 가능한것을 볼 수 있습니다. 그리고 이제 다른 페이지에서 변경을 하면 stream을 통해서 다른곳들의 데이터도 변경을 해주고 controller에 알려줘야 합니다.
addToOrRemoveFromSavedList(WordPair item){
if(saved.contains(item)){
saved.remove(item);
}else{
saved.add(item);
}
//데이터가 변경 됐다고 stream에 알려줌
_savedController.sink.add(saved);
}
값이 있으면 삭제하고 없으면 추가해준 뒤에 sink를 해주시면 됩니다. 이제 ListView에서 변경된 값을 줄텐데 ListView를 StreamBuilder로 감싸주면 됩니다.
StreamBuilder<Set<WordPair>>
그리고 stream에는 stream을 연결해 주시면 됩니다.
stream: savedBloc.savedStream,
아래에 보면 snapshot이 있는데 데이터가 변경될때마다 snapshot이 도착하는 도착할때마다 listView를 갱신해 줍니다. 그리고 저장된 부분을 관리하는 부분인 _buildRow로 snapshot.data를 넘겨줍니다.
return _buildRow(snapshot.data,_suggestion[index]);
그리고 상단에서 Set으로 생성한 _saved를 삭제해주고 _buildRow에서 _saved를 사용했던 부분들을 saved로 변경시켜 줍니다.
Widget _buildRow(Set<WordPair> saved, WordPair pair) {
final bool alreadySaved = saved.contains(pair);
return ListTile(
title: Text(
pair.asPascalCase,
style: _biggerFont,
),
trailing: Icon(
alreadySaved ? Icons.favorite : Icons.favorite_border,
color: alreadySaved ? Colors.red : null,
),
onTap: () {
setState(() {
if (alreadySaved) {
saved.remove(pair);
} else {
saved.add(pair);
}
});
},
);
}
하지만 여기서 또한 여기서 setState를 사용하지 않아도 되고 그 안에 saved.remove와 saved.add도 bloc에서 관리하므로 이부분도 변경해 줍니다.
onTap: () {
savedBloc.addToOrRemoveFromSavedList(pair);
},
그리고 페이지 이동시 값을 넘겨주는 부분도 saved_list.dart에서 삭제해 주시면 됩니다. 이 파일에서도 widget.saved 사용하던 부분을 변경해 주면 됩니다. 하나씩 불러오지 말고 ListView를 StreamBuilder로 감싸주시면 됩니다. stream도 똑같이 savedBloc.savedStream을 넣어주시면 됩니다. 그리고 itemCount도 변경해 줍니다.
itemCount: snapshot.data!.length*2,
null이 아니기 때문에 !가 들어가줘야 합니다. _buildRow로 넘겨주는 부분도 widget.saved에서 snapshot.data!로 해주시면 됩니다.
return _buildRow(snapshot.data!.toList()[index]);
하단의 _buildRow에서도 onTap을 addToOrRemoveFromSavedList 함수로 바꿔주시면 됩니다.
onTap: () {
savedBloc.addToOrRemoveFromSavedList(pair);
},
이렇게까지 해주고 실행을 하면 random_words.dart에서 return _buildRow에서 에러가 나는것을 볼 수 있는데, 처음 페이지가 시작되면 바로 Stream이 생성되고 텅빈 상태에서 넘어오기때문에 data가 null이기 때문에 에러가 생기는 것입니다. 이를 위해서 snapshot이 미쳐 다 받기전에 기본값을 줘서 해결해 보겠습니다.
final saved = snapshot.data ?? <WordPair>{};
builder안에 return 위에 해당 부분을 넣어주고, _buildRow에 snapshot.data대신에 saved를 보내주면 됩니다. 거기에 _savedController에서 broadcase를 추가해 주면 됩니다. 간단하게 broadcast는 스냅샷을 보내줘야 할때 하나가 아닌 여러개를 한꺼번에 보내주는 것입니다. 만약 그냥 사용하게 되면 Stream has already been listened to 에러가 나오는 것을 확인할 수 있습니다. 또 saved_list.dart에서도 에러가 나는데 해당 부분을 위해서 다음과 같이 추가해 줍니다.
var saved = <WordPair>{};
if (snapshot.hasData) {
saved.addAll(snapshot.data!);
} else {
savedBloc.addCurrentSaved;
}
builder 안에 return 위에 작성해 주는데 첫페이지가 아니고 버튼을 눌러 나온 페이지므로 이렇게 추가해 주면 됩니다.
get addCurrentSaved => _savedController.sink.add(saved);
또 saved_bloc.dart에 해당 코드를 추가해줘서 현재 상태를 읽어서 값을 읽은 뒤 builder를 불러와주면 됩니다. 실행해 보면 정상 작동 하는것을 알 수 있었습니다. 하면서도 어려운 부분이 있고 아직 이해가 가지 않는 부분이 많아서 더 많은 공부가 필요할것 같습니다. 다음에는 개발하는남자님의 유튜브의 bloc 강의를 참고해서 배워보도록 하겠습니다.