Android MVVM (AAC) LiveData RecyclerView

By | 2021년 11월 17일
Table of Contents

Android MVVM (AAC) LiveData RecyclerView

참조

build.gradle

android {
    // ......
    dataBinding {
        enabled = true
    }
}

ViewModel 생성

ViewModel 을 생성한다.
MutableLiveData 를 생성하면, MutableLiveData 의 데이타가 변경시 이벤트가 발생한다.

단순히 이벤트를 발생시키는게 아니라,
Lifecycle 에 맞춰서 발생시킨다.

이벤트 발생이 활성상태(started, resumed)로 지연되는 것이 아니라,
데이타 업데이트 자체가 활성상태로 지연된다.
따라서, 앱이 백그라운드에서 그대로 종류해 버리는 경우처럼,
불필요한 업데이트 및 이벤트발생을 막아준다.

곳곳에 분산되어 있는 DB/API 호출 코드들이 ViewModel 안으로 모이게 된다.

public class FavoriteFolderViewModel extends AndroidViewModel {

    private static final String TAG = "FavoriteFolderViewModel";
    private MutableLiveData<ArrayList<FavoriteFolder>> favoriteFolders;
    AppDataBase appDataBase;

    public FavoriteFolderViewModel(@NonNull Application application) {
        super(application);
        favoriteFolders = new MutableLiveData<>();
    }

    public void setAppDataBase(AppDataBase appDataBase) {
        this.appDataBase = appDataBase;
    }

    public MutableLiveData<ArrayList<FavoriteFolder>> getFavoriteFolders() {
        return favoriteFolders;
    }

    public void requestList() {
        appDataBase.RoomDao().getFolderList(0)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .doOnSuccess(items -> {
                    Log.d(TAG, "Total count : " + items.size());
                    favoriteFolders.setValue((ArrayList<FavoriteFolder>) items);
                })
                .doOnError(throwable -> Log.e(TAG, "Error : " + throwable.toString()))
                .subscribe();
    }

    public void requestInsert(FavoriteFolder favoriteFolder) {
        appDataBase.RoomDao().insertFolder(favoriteFolder)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .doOnSuccess(id -> {
                    Log.d(TAG, "Data inserted : " + id);
                    requestList();
                })
                .doOnError(throwable -> {
                    Log.e(TAG, "Error : " + throwable.toString());
                })
                .subscribe();
    }

    public void requestUpdate() {
        //
    }

    public void requestDelete(int id) {
        appDataBase.RoomDao().deleteFolder(id)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .doOnSuccess(affectedRows -> {
                    if (affectedRows == 0) {
                        return;
                    }

                    Log.d(TAG, "Data deleted : " + id);
                    requestList();
                })
                .doOnError(throwable -> Log.e(TAG, "Error : " + throwable.toString()))
                .subscribe();
    }
}

Fragment

LiveData 에 Observer 를 붙여주고, 데이타 변경시 이벤트를 수신하고, UI 에 반영한다.

public class RestaurantInfoAddBottomSheetFragment extends BottomSheetDialogFragment {
    // ......
    FavoriteFolderViewModel favoriteFolderViewModel;

    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        // ......
        favoriteFolderViewModel = new ViewModelProvider(this).get(FavoriteFolderViewModel.class);
        favoriteFolderViewModel.setAppDataBase(appDataBase);
        adapter.setViewModel(favoriteFolderViewModel);
        Observer<ArrayList<FavoriteFolder>> observer = favoriteFolders -> {
            if (favoriteFolders.size() == 0) {
                FavoriteFolder item = new FavoriteFolder(
                        getResources().getString(R.string.base_folder),
                        getResources().getString(R.string.hidden),
                        1);
                favoriteFolderViewModel.requestInsert(item);

                return;
            }
            adapter.setItems(favoriteFolders);
            // DiffUtil 이 단순 비교만 하는게 아니라, UI 업데이트할 목록까지 만들어주므로
            // 별도 업데이트는 필요없다.
            // adapter.notifyDataSetChanged();
        };
        favoriteFolderViewModel.getFavoriteFolders().observe(this, observer);
        // ......
    }
}

MutableLiveData

값을 업데이트하는 함수는 setValue/postValue 두가지가 있다.

setValue 는 Main thread 에서만 호출할 수 있으며, 동기방식으로, 순서가 보장된다.

postValue 는 백그라운드에서도 실행되며, 비동기방식으로, 순서는 보장되지 않는다.

ListAdapter

RecyclerViewAdapter 를 이용하게되면,
중간에 데이터를 추가하거나 수정/삭제시에,
RecyclerView 를 새로고침해주는 로직이 매우 복잡해진다.

ListAdapter 를 이용하게되면 UI 업데이트가 자동화되어 소스가 간단해진다.

Entity

Entity 에 Entity 비교를 위한 메소드를 추가해 준다.

@Entity(tableName = "tbl_favorite_folder", indices = {@Index(value = {"id"}, unique = true)})
public class FavoriteFolder {
    // ......
    public static DiffUtil.ItemCallback<FavoriteFolder> DIFF_CALLBACK = new  DiffUtil.ItemCallback<FavoriteFolder>() {
        @Override
        public boolean areItemsTheSame(@NonNull FavoriteFolder oldItem, @NonNull FavoriteFolder newItem) {
            return oldItem.id == newItem.id;
        }

        @Override
        public boolean areContentsTheSame(@NonNull FavoriteFolder oldItem, @NonNull FavoriteFolder newItem) {
            return oldItem.equals(newItem);
        }

    };

    @Override
    public boolean equals(Object obj) {
        if (obj == this)
            return true;
        FavoriteFolder myEntity = (FavoriteFolder) obj;
        return myEntity.id == this.id && myEntity.name.equals(this.name) && myEntity.status.equals(this.status) && myEntity.hasCount == this.hasCount;
    }
    // ......
}

ListAdapter

public class FavoriteFolderListAdapter extends ListAdapter<FavoriteFolder, FavoriteFolderListAdapter.ItemViewHolder> {

    Context context;
    boolean isModifyEnabled = false;
    FavoriteFolderViewModel viewModel;

    public FavoriteFolderListAdapter() {
        super(FavoriteFolder.DIFF_CALLBACK);
    }

    public void setViewModel(FavoriteFolderViewModel viewModel) {
        this.viewModel = viewModel;
    }

    public void setModifyEnabled(boolean modifyEnabled) {
        isModifyEnabled = modifyEnabled;
    }

    @NonNull
    @Override
    public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {

        context = parent.getContext();
        LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View view = inflater.inflate(R.layout.recyclerview_item_restaurant_add, parent, false);

        return new ItemViewHolder(view);
    }

    @SuppressLint("DefaultLocale")
    @Override
    public void onBindViewHolder(@NonNull ItemViewHolder holder, int position) {
        FavoriteFolder myEntity = getItem(position);

        holder.name.setText(myEntity.getName());
        holder.hasCount.setText(String.format("%d/%d", myEntity.getHasCount(), Constant.MAX_COUNT_IN_FOLDER));
        holder.status.setText(myEntity.getStatus());

        if (myEntity.getStatus().equals(context.getString(R.string.hidden))) {
            holder.status.setTextColor(Color.parseColor("#0000FF"));
        } else {
            holder.status.setTextColor(Color.parseColor("#000000"));
        }

        if (isModifyEnabled) {
            holder.modifyFolder.setVisibility(View.VISIBLE);
            holder.deleteFolder.setVisibility(View.VISIBLE);
        } else {
            holder.modifyFolder.setVisibility(View.INVISIBLE);
            holder.deleteFolder.setVisibility(View.INVISIBLE);
        }
    }

    class ItemViewHolder extends RecyclerView.ViewHolder {
        TextView name;
        TextView hasCount;
        TextView status;
        ImageButton modifyFolder;
        ImageButton deleteFolder;

        ItemViewHolder(View itemView) {
            super(itemView);

            name = itemView.findViewById(R.id.restaurant_group_title);
            hasCount = itemView.findViewById(R.id.restaurant_group_count);
            status = itemView.findViewById(R.id.restaurant_group_status);

            modifyFolder = itemView.findViewById(R.id.modify_folder);
            deleteFolder = itemView.findViewById(R.id.delete_folder);

            deleteFolder.setOnClickListener(v -> deleteFolder(getLayoutPosition()));
        }
    }

    private void deleteFolder(int position) {

        AlertDialog.Builder builder = new AlertDialog.Builder(new ContextThemeWrapper(context, R.style.AlertDialogTheme));
        builder.setMessage(R.string.warn_delete_folder);
        builder.setCancelable(true);
        builder.setPositiveButton(R.string.ok,
                (dialog, id) -> {
                    dialog.cancel();
                    viewModel.requestDelete(getItem(position).getId());
                });
        builder.setNegativeButton(R.string.cancel,
                (dialog, id) -> dialog.cancel());

        AlertDialog alertDialog = builder.create();
        alertDialog.show();
    }
}

Fragment

adapter.submitList(favoriteFolders); 에 의해,
기존 리스트와 새로 전달받은 리스트를 비교하여,
변경된 부분만 UI 리로드해준다.

public class RestaurantInfoAddBottomSheetFragment extends BottomSheetDialogFragment {
    @Override
    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
        super.onViewCreated(view, savedInstanceState);

        appDataBase = ((RestaurantInfo) requireActivity()).getAppDataBase();

        favoriteFolderViewModel = new ViewModelProvider(this).get(FavoriteFolderViewModel.class);
        favoriteFolderViewModel.setAppDataBase(appDataBase);
        adapter.setViewModel(favoriteFolderViewModel);
        Observer<ArrayList<FavoriteFolder>> observer = favoriteFolders -> {
            if (favoriteFolders.size() == 0) {
                FavoriteFolder item = new FavoriteFolder(
                        getResources().getString(R.string.base_folder),
                        getResources().getString(R.string.hidden),
                        1);
                favoriteFolderViewModel.requestInsert(item);

                return;
            }

            // ListAdapter의 submitList는 기존 데이터와 비교하여 변경된 부분만 업데이트 시킨다. (Differ 사용)
            adapter.submitList(favoriteFolders);
        };
        favoriteFolderViewModel.getFavoriteFolders().observe(this, observer);
    }
}

Data Binding

Layout 파일의 최상단에 layout 을 추가한다.

data 태그를 추가하고 Entity 를 추가한다.

<?xml version="1.0" encoding="utf-8"?>

<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <data>
        <variable
            name="item"
            type="kr.pe.skyer9.muglangguide.db.FavoriteFolder"/>
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

Binding 클래스명은 Layout 파일명에 Binding 을 붙여주는 형식이다.

View view = inflater.inflate(R.layout.favorite_folder_item, parent, false);

public FavoriteFolderItemBinding binding;

binding.setItem(favoriteFolder); 해주는 것만으로 프로그래밍이 필요없는 단순 코딩들을 생략할 수 있다.

    @NonNull
    @Override
    public ItemViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {

        context = parent.getContext();
        LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View view = inflater.inflate(R.layout.favorite_folder_item, parent, false);

        return new ItemViewHolder(view);
    }

    @Override
    public void onBindViewHolder(@NonNull ItemViewHolder holder, int position) {
        FavoriteFolder favoriteFolder = getItem(position);
        if(favoriteFolder != null) {
            holder.onBind(favoriteFolder);
        }
    }

    class ItemViewHolder extends RecyclerView.ViewHolder {

        public FavoriteFolderItemBinding binding;

        ItemViewHolder(View itemView) {
            super(itemView);
            binding = DataBindingUtil.bind(itemView);
        }

        @SuppressLint("DefaultLocale")
        void onBind(FavoriteFolder favoriteFolder) {
            binding.setItem(favoriteFolder);

            binding.restaurantGroupCount.setText(String.format("%d/%d", favoriteFolder.getHasCount(), Constant.MAX_COUNT_IN_FOLDER));
            if (favoriteFolder.getStatus().equals(context.getString(R.string.hidden))) {
                binding.restaurantGroupStatus.setTextColor(Color.parseColor("#0000FF"));
            } else {
                binding.restaurantGroupStatus.setTextColor(Color.parseColor("#000000"));
            }

            if (isModifyEnabled) {
                binding.modifyFolder.setVisibility(View.VISIBLE);
                binding.deleteFolder.setVisibility(View.VISIBLE);
            } else {
                binding.modifyFolder.setVisibility(View.INVISIBLE);
                binding.deleteFolder.setVisibility(View.INVISIBLE);
            }

            binding.deleteFolder.setOnClickListener(v -> deleteFolder(getLayoutPosition()));
        }
    }

답글 남기기