일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 클래스
- 스택 자바 코드
- rxjava cold observable
- 안드로이드 유닛 테스트
- 안드로이드 유닛 테스트 예시
- 2022 플러터 설치
- jvm 작동 원리
- 플러터 설치 2022
- rxjava hot observable
- 큐 자바 코드
- 안드로이드 레트로핏 사용법
- rxjava disposable
- ANR이란
- jvm이란
- 스택 큐 차이
- ar vr 차이
- 서비스 vs 쓰레드
- 자바 다형성
- 안드로이드 유닛테스트란
- 안드로이드 라이선스 종류
- 안드로이드 os 구조
- android ar 개발
- 안드로이드 라이선스
- 멤버변수
- 안드로이드 레트로핏 crud
- 객체
- 2022 플러터 안드로이드 스튜디오
- 서비스 쓰레드 차이
- Rxjava Observable
- android retrofit login
- Today
- Total
나만을 위한 블로그
[Android] MVVM + Rxjava + Retrofit + RecyclerView 같이 사용하기 본문
이 포스팅은 아래의 포스팅을 바탕으로 작성됐다.
https://sarahan774.github.io/thecodingsara/2020-06-22/android/udemy-course-catalin
글을 보면 Dagger와 butterknife 라이브러리를 사용했는데, 나는 이 두 라이브러리를 사용하고 싶지 않았기 때문에 두 라이브러리를 없앴다. 그러나 이것 때문인지 유닛 테스트 파일은 작동하지 않았다. 도전하고 싶다면 위 링크의 원문을 참고해보면 좋을 듯하다.
먼저 앱 수준 gradle 파일부터 수정한다. 필요한 부분만 적당히 복붙하면 된다.
plugins {
id 'com.android.application'
}
android {
compileSdkVersion 30
buildToolsVersion "30.0.3"
defaultConfig {
applicationId "com.example.countryapp"
minSdkVersion 21
targetSdkVersion 30
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
def lifecycleExtentionVersion = '1.1.1'
def supportVersion = "29.0.0"
def butterknifeVersion = '10.1.0'
def swipeRefreshLayoutVersion = '1.1.0'
def retrofitVersion = '2.3.0'
def glideVersion = '4.9.0'
def rxJavaVersion ='2.1.1'
dependencies {
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'com.google.android.material:material:1.2.1'
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
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"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"
implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion"
implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
implementation "io.reactivex.rxjava2:rxandroid:$rxJavaVersion"
implementation "com.github.bumptech.glide:glide:$glideVersion"
testImplementation "org.mockito:mockito-inline:2.11.0"
testImplementation "android.arch.core:core-testing:1.1.1"
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}
그리고 res/layout 폴더에 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>
패키지 구조는 아래와 같다.
Util 클래스부터 시작한다.
import android.content.Context;
import androidx.swiperefreshlayout.widget.CircularProgressDrawable;
public class Util {
// 이미지 로딩 중 보여줄 원형 프로그레스 바를 만들어 리턴하는 메서드
public static CircularProgressDrawable getProgressDrawable(Context context) {
CircularProgressDrawable progressDrawable = new CircularProgressDrawable(context);
// 길이, 높이 지정
progressDrawable.setStrokeWidth(10f);
progressDrawable.setCenterRadius(50f);
// 프로그레스 바를 작동시키고
progressDrawable.start();
// 이 프로그레스 바를 리턴시킨다
return progressDrawable;
}
}
다음은 model 패키지 안의 파일들이다.
import java.util.List;
import io.reactivex.Single;
import retrofit2.http.GET;
public interface CountriesApi {
@GET("DevTides/countries/master/countriesV2.json")
Single<List<CountryModel>> getCountries();
}
import java.util.List;
import io.reactivex.Single;
import retrofit2.Retrofit;
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory;
import retrofit2.converter.gson.GsonConverterFactory;
public class CountriesService {
// 데이터를 받아올 엔드 포인트
private static final String BASE_URL = "https://raw.githubusercontent.com/";
// 싱글톤 객체
private static CountriesService instance;
// 레트로핏 객체 생성
public CountriesApi api = new Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.build()
.create(CountriesApi.class);
// 레트로핏 싱글톤 객체 생성
public static CountriesService getInstance() {
if (instance == null) {
instance = new CountriesService();
}
return instance;
}
public Single<List<CountryModel>> getCountries() {
return api.getCountries();
}
}
import com.google.gson.annotations.SerializedName;
public class CountryModel {
// 나라 이름
@SerializedName("name")
String countryName;
// 나라 수도 이름
@SerializedName("capital")
String capital;
// 나라 국기 이미지 URL
@SerializedName("flagPNG")
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 패키지 안의 ListViewModel이다.
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.ViewModel;
import com.example.countryapp.model.CountriesService;
import com.example.countryapp.model.CountryModel;
import java.util.List;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.annotations.NonNull;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.observers.DisposableSingleObserver;
import io.reactivex.schedulers.Schedulers;
public class ListViewModel extends ViewModel {
// 사용자에게 보여줄 국가 MutableLiveData
public MutableLiveData<List<CountryModel>> countries = new MutableLiveData<>();
// 국가 데이터를 가져오는 것에 성공했는지를 알려주는 MutableLiveData
public MutableLiveData<Boolean> countryLoadError = new MutableLiveData<>();
// 로딩 중인지를 나타내는 MutableLiveData
public MutableLiveData<Boolean> loading = new MutableLiveData<>();
// 레트로핏 객체를 싱글톤으로 가져옴
public CountriesService countriesService = CountriesService.getInstance();
private final CompositeDisposable disposable = new CompositeDisposable();
// 뷰에서 데이터를 가져오기 위해 호출하는 함수
public void refresh() {
// 서버로부터 데이터를 받아오는 동안 로딩 상태를 보여주기 위해 true 설정
loading.setValue(true);
// CompositeDisposable에 Observable(여기선 Single) 추가
disposable.add(countriesService.getCountries()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeWith(new DisposableSingleObserver<List<CountryModel>>() {
@Override
public void onSuccess(@NonNull List<CountryModel> countryModels) {
// 데이터가 있다면 MutableLiveData<List>에 데이터를 넣고
countries.setValue(countryModels);
// 로딩, 에러 관련 뷰들을 가리기 위해 false 값을 넣는다
countryLoadError.setValue(false);
loading.setValue(false);
}
@Override
public void onError(@NonNull Throwable e) {
// 실패한 경우는 성공했을 때와 반대로 한다
countryLoadError.setValue(true);
loading.setValue(false);
e.printStackTrace();
}
}));
}
@Override
protected void onCleared() {
super.onCleared();
// 앱이 통신 중에 프로세스가 종료될 경우(앱이 destory됨)
// 메모리 손실을 최소화 하기 위해 백그라운드 스레드에서 통신 작업을 중단한다
disposable.clear();
}
}
39번 줄의 subscribeOn() 안에는 원래 newThread()가 있었지만 io()로 바꿨다. 여기부터 개인적으로 공부한 이론적인 내용들이 나오니 관심없으면 스크롤 쭉 내려서 코드만 가져가면 된다.
io()로 바꾼 이유는 newThread()는 이름 그대로 쓰레드를 새로 만들어 작업을 수행하는 스케줄러지만, io()는 쓰레드 풀을 사용해 동기 I/O 작업을 별도로 처리시키는 스케줄러다.
쓰레드를 새로 생성하는 건 안드로이드에선 비용이 많이 들 수도 있는 작업이다. ReactiveX Javadoc에서도 이 스케줄러를 설명하면서 시스템 속도 저하 또는 OutOfMemoryError(=OOM)가 발생할 수 있다고 경고하고 있다.
http://reactivex.io/RxJava/3.x/javadoc/io/reactivex/rxjava3/schedulers/Schedulers.html#newThread--
그렇다고 io()가 newThread()보다 낫다는 건 아니다. 위 링크에서 찾아보면 io() 또한 시스템 속도 저하 또는 OOM을 발생시킬 수 있는 무한한 워커 쓰레드를 만들 수 있다고 경고한다.
그럼에도 io()를 쓴 이유는 새 워커 쓰레드를 요구받을 경우 쓰레드 풀 안에서 대기 중인 쓰레드를 사용하지만, newThread()는 매번 쓰레드를 생성하기 때문에 사용하기 애매해서 io()로 바꿨다. 공부가 부족해서 정확하게 알고 사용한 것이 아닐 수도 있으니 참고하자.
다음은 view 패키지의 CountryListAdapter다.
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;
import com.example.countryapp.R;
import com.example.countryapp.model.CountryModel;
import java.util.List;
public class CountryListAdapter extends RecyclerView.Adapter<CountryListAdapter.CountryViewHolder> {
private final Context context;
private final List<CountryModel> countries;
public CountryListAdapter(Context context, List<CountryModel> countries) {
this.context = context;
this.countries = countries;
}
public void updateCountries(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) {
final CountryModel model = countries.get(position);
holder.countryName.setText(model.getCountryName());
holder.countryCapital.setText(model.getCapital());
// 이미지를 불러오는 동안 placeholder로 사용할 이미지 설정
RequestOptions options = new RequestOptions()
.placeholder(Util.getProgressDrawable(holder.countryImage.getContext())) // 이미지 로딩하는 동안 보여지는 원형 프로그레스
.error(R.mipmap.ic_launcher_round); // url을 로드할 때 error 발생시 보여줄 이미지
// 이미지뷰에 set
Glide.with(context)
.setDefaultRequestOptions(options) // 이미지를 불러오지 못했으면 placeholder 이미지를 보여준다
.load(model.getFlag())
.into(holder.countryImage);
}
@Override
public int getItemCount() {
return countries.size();
}
public static class CountryViewHolder extends RecyclerView.ViewHolder {
private final ImageView countryImage;
private final TextView countryName;
private final TextView countryCapital;
public CountryViewHolder(@NonNull View view) {
super(view);
countryImage = view.findViewById(R.id.imageview);
countryName = view.findViewById(R.id.name);
countryCapital = view.findViewById(R.id.capital);
}
}
}
마지막으로 메인 액티비티 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>
import androidx.appcompat.app.AppCompatActivity;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import android.os.Bundle;
import android.view.View;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.example.countryapp.R;
import com.example.countryapp.viewmodel.ListViewModel;
import java.util.ArrayList;
public class MainActivity extends AppCompatActivity {
private RecyclerView countriesList;
private TextView listError;
private ProgressBar loadingView;
private SwipeRefreshLayout refreshLayout;
private ListViewModel viewModel;
// 리사이클러뷰에 사용할 어댑터
private CountryListAdapter adapter = new CountryListAdapter(this, new ArrayList<>());
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
countriesList = findViewById(R.id.countriesList);
listError = findViewById(R.id.list_error);
loadingView = findViewById(R.id.loading_view);
refreshLayout = findViewById(R.id.swipeRefreshLayout);
viewModel = ViewModelProviders.of(this).get(ListViewModel.class);
viewModel.refresh();
countriesList.setLayoutManager(new LinearLayoutManager(this));
countriesList.setAdapter(adapter);
// 전체 레이아웃을 아래로 당겼을 때의 처리
refreshLayout.setOnRefreshListener(() ->
{
// 새로고침 될 때마다 새로운 데이터를 가져온다
viewModel.refresh();
// 데이터 가져오는 작업이 끝나면 더 이상 새로고침되지 않도록 한다
refreshLayout.setRefreshing(false);
});
observeViewModel();
}
private void observeViewModel() {
viewModel.countries.observe(this, countryModels -> {
if (countryModels != null) {
countriesList.setVisibility(View.VISIBLE);
adapter.updateCountries(countryModels);
}
});
// 국가 데이터를 가져왔는지 여부를 true, false로 보관하는 리스트를 관찰하다가
viewModel.countryLoadError.observe(this, isError -> {
// 에러 메시지가 null이 아니라면
if (isError != null) {
// 에러 메시지에 따라 뷰를 보여줄지 여부를 결정
listError.setVisibility(isError ? View.VISIBLE : View.GONE);
}
});
// 로딩 중인지 여부(true, false)가 담긴 MutableLiveData 안의 값들을 관찰하다가
viewModel.loading.observe(this, isLoading -> {
// isLoading이 null이 아닌 경우
if (isLoading != null) {
// isLoading 값에 따라 뷰를 보여줄지 여부를 결정
loadingView.setVisibility(isLoading ? View.VISIBLE : View.GONE);
// 로딩중일 때 에러 메시지, 국가 리스트는 숨긴다
if (isLoading) {
listError.setVisibility(View.GONE);
countriesList.setVisibility(View.GONE);
}
}
});
}
}
여기까지 복붙한 다음 앱을 실행하면 아마 정상 작동할 것이다.
그러나 유닛 테스트 부분은 작동하지 않을 수 있다. Dagger와 butterknife 라이브러리 사용 부분을 지워서 안 되는 거라고 생각한다.
참고한 사이트)
https://altongmon.tistory.com/763
https://medium.com/@saishaddai/schedulers-io-versus-schedulers-newthread-5371198f6c43