저번 앱 소개에 이어서 이번 포스팅에서 만들 것은 국가 데이터를 보여주는 리스트입니다.
MVVM을 적용해 앱을 만들기 전에 MVVM이 무엇인지, 각각의 역할과 사용했을 때의 이점을 살펴보겠습니다.
MVVM 패턴?
- business logic를 뷰와 모델로부터 분리하는 아키텍쳐 패턴입니다.
- 구글이 권장하는 앱 아키텍쳐입니다.
- 앱 데이터와 상태를 저장하는 데 앱 구성요소를 사용하지 않습니다.
- Activity 또는 Fragment에 모든 코드를 작성하지 않습니다.
- 모델에서 가져온 데이터를 통해 UI를 만듭니다.
- 모델은 앱의 데이터 처리를 담당하는 구성요소입니다.
- 모델은 앱의 View 객체 및 앱 구성요소와(Activity, Fragment, Service 등) 독립되어 있으므로 앱의 수명 주기 및 관련 문제의 영향을 받지 않습니다.
MVVM의 역할
model
logic에서 사용하는 application data입니다.
view
View는 화면에 표현되는 레이아웃에 대해 관여합니다.
표시할 텍스트를 결정하는 로직 같은 비즈니스 로직을 제외하고, UI를 다루고 운영체제와 상호작용을 처리합니다.
흔히 UI controller라고 하며 Activity or Fragment가 이에 해당합니다.
viewmodel
model 작업 결과에 의한 데이터를 가지고 있습니다.
이를 가지고 LiveData를 사용해 관련된 Activity or Fragment에 전달합니다.
ViewModel는 데이터에 대한 간단한 계산 및 변환을 수행하여 UI 컨트롤러에서 표시할 데이터를 준비할 수 있습니다.
MVVM 구성 요소의 상호 작용 방식
View는 ViewModel에 대해 "알고",
ViewModel은 Model에 대해 "알지만",
Model은 ViewModel을 인식하지 못하며,
ViewModel은 View를 인식하지 못합니다.
따라서 ViewModel은 View를 Model에서 분리하고 Model이 View와 독립적으로 진화 할 수 있도록 합니다.
???? 무슨말이지? 라는 생각이 들 겁니다. 다음 이미지는 위에서 말한 서로 상호작용하는 방법을 잘 나타냅니다.
그리고 추후 코드에서 어떻게 사용하는지를 보면 위 문장이 더 이해가 잘 될 것입니다.
MVVM 사용시 이점
delevlopment flexibility
협업할 때 도움이 된다.
view, logic이 분리되어있으므로 한 명이 레이아웃, 스크린의 안정화에 대해서 개발하고 있을 때 다른 사람이 데이터 처리하는 로직을 담당할 수 있습니다.
testing
테스트 시나리오 작성, 가짜 객체 생성 처리가 간편해진다. 이는 뷰 모델 자체를 View와 분리했기 때문에 메인 로직인 ViewModel만 테스트할 수 있습니다.
logic separation
각각의 모듈이 특정 기능만 담당해 유지하는 것이 쉬워진다. 이는 코드를 읽고 이해하는 것을 쉽게 합니다.
ViewModel에서 사용하는 LiveData는 무엇?
관찰 가능한 데이터 홀더 클래스(observable data holder class)입니다.
일반 식별 가능한 클래스와(regular observable class) 달리 LiveData는 수명 주기를 인식합니다.
즉, activity, service, fragment 등 다른 앱 구성요소의 수명 주기를 고려합니다. 수명 주기 인식을 통해 LiveData는 활동 수명 주기 상태(active lifecycle state)에 있는 앱 구성요소 관찰자(observer)만 업데이트합니다.
→ activity, service, fragment가 관찰자입니다.(observer)
LiveData Observer는 수명주기가 STARTED또는 RESUMED 상태인 경우 클래스로 표시되는 관찰자를 활성 상태로(active state) 간주합니다. LiveData는 활성 관찰자에게 업데이트에 대해서만 알립니다. 감시 LiveData대상에 등록된 비활성 관찰자에게는 변경 사항에 대한 알림이 표시되지 않습니다.
앱에 MVVM을 적용하기
import 라이브러리
viewmodel과 LiveData를 사용하기 위해 lifecycle 라이브러리를 import 합니다.
def lifecycleExtentionVersion = '1.1.1'
def supportVersion = "29.0.0"
def butterknifeVersion = '10.1.0'
def swipeRefreshLayoutVersion = '1.1.0'
dependencies {
implementation "androidx.swiperefreshlayout:swiperefreshlayout:$swipeRefreshLayoutVersion"
// viewmodel and LiveData
implementation "android.arch.lifecycle:extensions:$lifecycleExtentionVersion"
// swipeRefreshLayout
implementation "com.android.support:design:$supportVersion"
// butterknife
implementation "com.jakewharton:butterknife:$butterknifeVersion"
annotationProcessor "com.jakewharton:butterknife-compiler:$butterknifeVersion"
''''''
}
모델 파일 작업
model/CountryModel.java
public class CountryModel {
// 나라 이름을 가짐
String countryName;
// 나라 수도 이름을 가짐
String capital;
// 나라 국기 이미지 url을 가짐
String flag;
public CountryModel(String countryName, String capital, String flag) {
this.countryName = countryName;
this.capital = capital;
this.flag = flag;
}
public String getCountryName() {
return countryName;
}
public String getCapital() {
return capital;
}
public String getFlag() {
return flag;
}
}
viewmodel 파일 작업
viewmodel/ListViewModel.java
지금은 임의의 데이터를 넣지만 추후에 백엔드 서버 API를 통해 데이터를 가져올 것입니다.
- View는 ViewModel에 대해 "알고" ViewModel은 View를 인식하지 못합니다. 따라서 ViewModel은 View와 분리될 수 있습니다.
public class ListViewModel extends ViewModel {
// 사용자에게 보여줄 국가 데이터
public MutableLiveData<List<CountryModel>> countries = new MutableLiveData<>();
// 국가 데이터를 가져오는 것에 성공했는지를 알려주는 데이터
public MutableLiveData<Boolean> countryLoadError = new MutableLiveData<>();
// 로딩 중인지를 나타내는 데이터
public MutableLiveData<Boolean> loading = new MutableLiveData<>();
// 뷰에서 데이터를 가져오기 위해 호출하는 함수
public void refresh(){
fetchCountries();
}
private void fetchCountries(){
// 리스트에 넣을 임의의 데이터
CountryModel countryModel1 = new CountryModel("Korea", "Seoul", "");
CountryModel countryModel2 = new CountryModel("China", "Beijing", "");
CountryModel countryModel3 = new CountryModel("Japan", "Tokyo", "");
List<CountryModel> list = new ArrayList<>();
list.add(countryModel1);
list.add(countryModel2);
list.add(countryModel3);
list.add(countryModel1);
list.add(countryModel2);
list.add(countryModel3);
list.add(countryModel1);
list.add(countryModel2);
list.add(countryModel3);
// LiveData에 데이터를 넣어줌
// 데이터를 관찰하는 뷰에게 전달됨.
countries.setValue(list);
countryLoadError.setValue(false);
loading.setValue(false);
}
}
국가 정보를 보여줄 리스트 구현
layout 파일 작업
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:id="@+id/swipeRefreshLayout"
tools:context=".view.MainActivity">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/countriesList"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/list_error"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:text="데이터를 가져오는 중에 에러 발생 "
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/loading_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="MissingClass" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
item_country.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent"
android:orientation="horizontal"
android:layout_height="100dp">
<ImageView
android:id="@+id/imageview"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:padding="8dp"/>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2"
android:gravity="center_vertical"
android:orientation="vertical">
<TextView
android:id="@+id/name"
style="@style/Title"
android:text="country"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/capital"
android:text="capital"
style="@style/Text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"/>
</LinearLayout>
</LinearLayout>
recyclerviw adapter 작업
이번에는 이미지 url을 로딩하는 것을 제외했습니다. 다음 포스팅에서 다룰 예정입니다.
public class CoutryListAdapter extends RecyclerView.Adapter<CoutryListAdapter.CountryViewHolder> {
private List<CountryModel> countries;
public CoutryListAdapter(List<CountryModel> countries) {
this.countries = countries;
}
public void updateCountires(List<CountryModel> newCountries){
countries.clear();
countries.addAll(newCountries);
notifyDataSetChanged();
}
@NonNull
@Override
public CountryViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_country, parent, false);
return new CountryViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull CountryViewHolder holder, int position) {
holder.bind(countries.get(position));
}
@Override
public int getItemCount() {
return countries.size();
}
class CountryViewHolder extends RecyclerView.ViewHolder{
@BindView(R.id.imageview)
ImageView countryImage;
@BindView(R.id.name)
TextView countryName;
@BindView(R.id.capital)
TextView countryCapital;
public CountryViewHolder(@NonNull View itemView) {
super(itemView);
ButterKnife.bind(this, itemView);
}
void bind(CountryModel country){
countryName.setText(country.getCountryName());
countryCapital.setText(country.getCapital());
Util.loadImage(countryImage, country.getFlag(), Util.getProgressDrawable(countryImage.getContext()));
}
}
}
MainActivity.java 작업
View는 ViewModel에 대해 "알고",
ViewModel은 Model에 대해 "알지만",
Model은 ViewModel을 인식하지 못하며,
ViewModel은 View를 인식하지 못합니다.
라는 위의 말이 좀 이해가 될 겁니다.
public class MainActivity extends AppCompatActivity {
@BindView(R.id.countriesList)
RecyclerView countriesList;
@BindView(R.id.list_error)
TextView listError;
@BindView(R.id.loading_view)
ProgressBar loadingView;
@BindView(R.id.swipeRefreshLayout)
SwipeRefreshLayout refreshLayout;
// 뷰모델은 뷰가 어디 뷰에 사용될 지 모른다.
// 하지만 뷰는 어떤 뷰모델을 사용할지 알아야한다.
private ListViewModel viewModel;
private CoutryListAdapter adapter = new CoutryListAdapter(new ArrayList<>());
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
ButterKnife.bind(this);
// viewModel 초기화는 ViewModelProviders를 통해서 한다.
// 액티비티 destroy 되고 다시 create 되더라도 뷰모델에 있는 데이터를 보여주기 위함이다.
// 액티비티가 다시 생성되더라도 이전에 생성한 뷰모델 인스턴스을 줄 수 있다.
viewModel = ViewModelProviders.of(this).get(ListViewModel.class);
viewModel.refresh();
countriesList.setLayoutManager(new LinearLayoutManager(this));
countriesList.setAdapter(adapter);
refreshLayout.setOnRefreshListener(() -> {
viewModel.refresh();
refreshLayout.setRefreshing(false);
});
observerViewModel();
}
private void observerViewModel() {
/**
* 뷰(메인 화면)에 라이브 데이터를 붙인다.
* 메인 화면에서 관찰할 데이터를 설정한다.
* 라이브 데이터가 변경됐을 때 변경된 데이터를 가지고 UI를 변경한다.
*/
viewModel.countries.observe(this, countryModels -> {
// 데이터 값이 변할 때마다 호출된다.
if (countryModels != null){
countriesList.setVisibility(View.VISIBLE);
// 어뎁터가 리스트를 수정한다.
adapter.updateCountires(countryModels);
}
});
viewModel.countryLoadError.observe(this, isError -> {
// 에러 메세지를 보여준다.
if (isError != null){
listError.setVisibility(isError? View.VISIBLE: View.GONE);
}
});
viewModel.loading.observe(this, isLoading -> {
if (isLoading!= null){
// 로딩 중이라는 것을 보여준다.
loadingView.setVisibility(isLoading?View.VISIBLE:View.GONE);
// 로딩중일 때 에러 메세지, 국가 리스트는 안 보여준다.
if (isLoading){
listError.setVisibility(View.GONE);
countriesList.setVisibility(View.GONE);
}
}
});
}
}
이렇게 MVVM 구조를 사용해 국가 정보를 리스트로 보여주는 기능을 구현했습니다.
다음은 현재 구조에서 Retrofit + RxJava를 이용해서 국가 데이터를 가져와 리스트에 보여주도록 수정할 것입니다.
전체 소스 확인 : github.com/keepseung/Country/tree/01-Add-MVVM
01-Add-MVVM 브런치에서 확인할 수 있습니다.
다음 포스팅 : develop-writing.tistory.com/38
'Android > Jetpack, Clean Architecture' 카테고리의 다른 글
[안드로이드/Android] Databinding 사용하기 (0) | 2021.01.20 |
---|---|
모던 안드로이드 앱 만들기 (3) - Retrofit, RxJava를 이용한 네트워크 통신 (0) | 2021.01.18 |
모던 안드로이드 앱 만들기 (1) - 소개 (using Java, MVVM, RxJava) (0) | 2021.01.17 |
[Android / Kotlin] DataBinding을 사용해 뷰와 데이터를 연결해주기 (0) | 2020.09.06 |
[Android / Kotlin] DataBinding을 사용해 findViewById()를 대체하기 (0) | 2020.09.06 |