관리 메뉴

나만을 위한 블로그

[Android] MVVM + Rxjava + Retrofit + RecyclerView 같이 사용하기 본문

Android

[Android] MVVM + Rxjava + Retrofit + RecyclerView 같이 사용하기

참깨빵위에참깨빵_ 2021. 11. 29. 20:36
728x90
반응형

이 포스팅은 아래의 포스팅을 바탕으로 작성됐다.

 

https://sarahan774.github.io/thecodingsara/2020-06-22/android/udemy-course-catalin

 

[Udemy 강좌 정리] MVVM + Dagger2 + Unit Testing

Udemy Course Link Github Repository Link

sarahan774.github.io

 

글을 보면 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--

 

Schedulers (RxJava Javadoc 3.1.3)

Wraps an Executor into a new Scheduler instance and delegates schedule() calls to it. If the provided executor doesn't support any of the more specific standard Java executor APIs, cancelling tasks scheduled by this scheduler can't be interrupted when they

reactivex.io

 

그렇다고 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://stackoverflow.com/questions/33415881/retrofit-with-rxjava-schedulers-newthread-vs-schedulers-io

 

Retrofit with Rxjava Schedulers.newThread() vs Schedulers.io()

What are the benefits to use Schedulers.newThread() vs Schedulers.io() in Retrofit network request. I have seen many examples that use io(), but I want to understand why. Example situation: obser...

stackoverflow.com

 

https://altongmon.tistory.com/763

 

[RxJava2] 스케줄러 종류와 사용 법 : newThread(), computation(), io(), trampoline(), single(), from(executor)

공감 및 댓글은 포스팅 하는데  아주아주 큰 힘이 됩니다!! 포스팅 내용이 찾아주신 분들께 도움이 되길 바라며 더 깔끔하고 좋은 포스팅을 만들어 나가겠습니다^^ 이번 포스팅에서는 스케줄러

altongmon.tistory.com

 

https://medium.com/@saishaddai/schedulers-io-versus-schedulers-newthread-5371198f6c43

 

Schedulers.io() versus Schedulers.newThread()

If you are a newbie in RxJava or RxAndroid as me, maybe you saw some examples that uses Schedulers.io() or Schedulers.newThread() in the…

medium.com

 

https://stackoverflow.com/questions/33370339/what-is-the-difference-between-schedulers-io-and-schedulers-computation

 

What is the difference between Schedulers.io() and Schedulers.computation()

I use Observables in couchbase. What is the difference between Schedulers.io() and Schedulers.computation()?

stackoverflow.com

 

반응형
Comments