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()));
}
}