이번엔 본격적으로 구글에서 제공하는 "안드로이드 MVP 디자인 패턴 적용 샘플 어플리케이션"인
https://github.com/googlesamples/android-architecture/tree/todo-mvp/
해당 샘플 코드를 분석하려고 합니다.
앞서 말씀드린 대로, 이번 포스트 시리즈는 앱이 가진 고유의 기능을 분석하다기 보단 해당 샘플로부터 어떻게 MVP 코드를 적용했는가 추출하는데 집중하려 합니다.
이번 포스트는 추출 전 전체적인 앱의 구조 파악입니다.
1. 흐름도
위 이미지는 해당 샘플에 대한 Github에서 제공되는 흐름도입니다.
Activity 안에 Fragment가 있고, View로 사용하네요. 그리고 Activity 범위 안에 Presenter가 있습니다. 그 Presenter에서 여러가지 데이터에 접근할 때, Repository를 통해서만 Local, Remote 데이터 소스에 접근하네요. 따라서 Repository쪽이 Model부분이라고 생각하면 될 것 같습니다.
2. 실제 코드
그럼 이것이 실제로 어떻게 적용되는지 살펴보겠습니다.
자바 소스 코드는 다음과 같은 구조로 이루어집니다.
참고로 이 앱은 tasks.TasksActivity가 런처에 해당합니다. 앱을 키면 첫 번째로 보일 액티비티입니다.
먼저 가장 바깥에 BasePresenter와 BaseView라는 Presenter, View를 이루기 위한 기본적인 추상 메서드가 담긴 인터페이스가 존재합니다.
public interface BasePresenter {
void start();
}
public interface BaseView {
void setPresenter(T presenter);
}
내용은 간단합니다. Presenter가 되려면 start() 메서드를 구현해야 한다, View가 되려면 setPresenter() 메서드를 구현해야 한다. 얘네를 implement 함으로써 Presenter와 View를 정의해줄 수 있게끔 만들었네요.
그럼 그 View와 Presenter는 어디 있을까요? 각 액티비티마다 존재합니다. 대표적으로 tasks 디렉토리를 살펴보면, TasksActivity 뿐 아니라 TasksContract, TasksFragment, TasksPresenter가 존재합니다. 나머지 Scroll~ 과 FilterType은 추가적으로 TasksActivity에서 사용하기 위해 추가한 걸로 보입니다.
TasksContract의 내용은 다음과 같습니다.
/**
* This specifies the contract between the view and the presenter.
*/
public interface TasksContract {
interface View extends BaseView {
void setLoadingIndicator(boolean active);
void showTasks(List tasks);
void showAddTask();
void showTaskDetailsUi(String taskId);
void showTaskMarkedComplete();
void showTaskMarkedActive();
void showCompletedTasksCleared();
void showLoadingTasksError();
void showNoTasks();
void showActiveFilterLabel();
void showCompletedFilterLabel();
void showAllFilterLabel();
void showNoActiveTasks();
void showNoCompletedTasks();
void showSuccessfullySavedMessage();
boolean isActive();
void showFilteringPopUpMenu();
}
interface Presenter extends BasePresenter {
void result(int requestCode, int resultCode);
void loadTasks(boolean forceUpdate);
void addNewTask();
void openTaskDetails(@NonNull Task requestedTask);
void completeTask(@NonNull Task completedTask);
void activateTask(@NonNull Task activeTask);
void clearCompletedTasks();
void setFiltering(TasksFilterType requestType);
TasksFilterType getFiltering();
}
}
Contract 는 인터페이스이며, View와 Presenter를 각각 BaseView, BasePresenter로부터 상속받아 새로운 인터페이스로 정의하고 있네요. View와 Presenter를 이루는 인터페이스들, 그 안에는 각각 필요한 추상 메서드들을 담고 있습니다. 즉 Contract는 바로 둘 사이에 서로가 가진 메서드를 부르기 위한 인터페이스입니다.
따라서 해당 Activity에 대한 View가 되고 싶으면 implements Contract.View 를 통해 해당 추상 메서드들을 구현해야만 합니다. Presenter도 마찬가지구요.
View를 보면, View의 역할 그대로 온갖 게 show 입니다. showTasks는 할 일 목록을 보여줘라 하는 거겠죠. Presenter에서 View의 showTasks를 부르면 화면에 할 일 목록이 뜨는 겁니다.
Presenter는 load, open, clear, set 등 화면에 보여지지 않는 작업들이 포함됩니다. 이 중에는 Repository(Model)에 접근에 데이터를 다루는 메서드들도 포함됩니다.
Fragment인, View를 보겠습니다.
/**
* Display a grid of {@link Task}s. User can choose to view all, active or completed tasks.
*/
public class TasksFragment extends Fragment implements TasksContract.View {
private TasksContract.Presenter mPresenter;
...
public TasksFragment() {
// Requires empty public constructor
}
public static TasksFragment newInstance() {
return new TasksFragment();
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) { ... }
@Override
public void onResume() {
super.onResume();
mPresenter.start();
}
@Override
public void setPresenter(@NonNull TasksContract.Presenter presenter) {
mPresenter = checkNotNull(presenter);
}
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
mPresenter.result(requestCode, resultCode);
}
@Nullable
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) { ... }
...
@Override
public void setLoadingIndicator(final boolean active) {
if (getView() == null) {
return;
}
final SwipeRefreshLayout srl =
(SwipeRefreshLayout) getView().findViewById(R.id.refresh_layout);
// Make sure setRefreshing() is called after the layout is done with everything else.
srl.post(new Runnable() {
@Override
public void run() {
srl.setRefreshing(active);
}
});
}
@Override
public void showTasks(List tasks) {
mListAdapter.replaceData(tasks);
mTasksView.setVisibility(View.VISIBLE);
mNoTasksView.setVisibility(View.GONE);
}
@Override
public void showNoActiveTasks() {
showNoTasksViews(
getResources().getString(R.string.no_tasks_active),
R.drawable.ic_check_circle_24dp,
false
);
}
@Override
public void showNoTasks() {
showNoTasksViews(
getResources().getString(R.string.no_tasks_all),
R.drawable.ic_assignment_turned_in_24dp,
false
);
}
@Override
public void showNoCompletedTasks() {
showNoTasksViews(
getResources().getString(R.string.no_tasks_completed),
R.drawable.ic_verified_user_24dp,
false
);
}
...
}
Fragment지만, implements TasksContract.View 를 통해 추상 메서드들을 오버라이딩, 구현함으로써 해당 액티비티에 대한 View가 됐습니다. 아래 TasksContract.View 인터페이스에 정의된 모든 추상 메서드가 구현되어 있습니다. show~() 들요.
해당 액티비티의 Presenter인 TasksContract.Presenter mPresenter를 멤버 변수로 선언하고, setPresenter() 안에서 mPresenter가 초기화됩니다. 그리고 onResume() 이벤트 발생 시 마다 mPresenter의 start() 메서드를 실행시켜줍니다.
mPresenter가 멤버 변수로 선언되었고, 초기화됐기 때문에, 해당 View에선 mPresenter 멤버 변수를 통해 TasksContract.Presenter 인터페이스의 추상 메서드들을 사용할 수 있습니다.
따라서 View에서 Presenter의 공개 메서드들을 실행할 수 있어요. 그 공개 메서드들은 Contract에 모두 정의되어 있어야 합니다.
Presenter를 보겠습니다.
/**
* Listens to user actions from the UI ({@link TasksFragment}), retrieves the data and updates the
* UI as required.
*/
public class TasksPresenter implements TasksContract.Presenter {
private final TasksRepository mTasksRepository;
private final TasksContract.View mTasksView;
...
public TasksPresenter(@NonNull TasksRepository tasksRepository, @NonNull TasksContract.View tasksView) {
mTasksRepository = checkNotNull(tasksRepository, "tasksRepository cannot be null");
mTasksView = checkNotNull(tasksView, "tasksView cannot be null!");
mTasksView.setPresenter(this);
}
@Override
public void start() {
loadTasks(false);
}
@Override
public void result(int requestCode, int resultCode) { ... }
@Override
public void loadTasks(boolean forceUpdate) { ... }
/**
* @param forceUpdate Pass in true to refresh the data in the {@link TasksDataSource}
* @param showLoadingUI Pass in true to display a loading icon in the UI
*/
private void loadTasks(boolean forceUpdate, final boolean showLoadingUI) { ... }
private void processTasks(List tasks) { ... }
...
@Override
public void addNewTask() {
mTasksView.showAddTask();
}
@Override
public void openTaskDetails(@NonNull Task requestedTask) {
checkNotNull(requestedTask, "requestedTask cannot be null!");
mTasksView.showTaskDetailsUi(requestedTask.getId());
}
@Override
public void completeTask(@NonNull Task completedTask) {
checkNotNull(completedTask, "completedTask cannot be null!");
mTasksRepository.completeTask(completedTask);
mTasksView.showTaskMarkedComplete();
loadTasks(false, false);
}
...
}
View와 마찬가지로 implements TasksContract.Presenter 를 통해 Presenter가 되기 위한 추상 메서드들을 아래에서 모두 오버라이딩해 구현합니다.
그리고 멤버 변수로 TasksContract.View mView와 TasksRepository mTasksRepository를 선언해줌으로써 View와 Repository(Model)에 모두 접근할 수 있게 됐습니다.
생성자를 통해 Repository와 View를 받아오고, 생성자 안에서 17번 줄 mTasksView.setPresenter(this); 을 통해 해당 Presenter 객체 참조를 View로 보냄으로써, View에서 자신이 정의한 Presenter 메서드들을 사용할 수 있게 해줍니다. 위의 View에선 28번 줄 setPresenter() 메서드를 구현할 때 자신의 멤버 변수인 mPresenter에 넘어온 Presenter 참조로 초기화시키죠.
이로써 앞서 설명드린 Presenter의 역할을 톡톡히 해낼 수 있는 기반이 완성됩니다. View를 다룰 땐 mTasksView를 통해, Model에 접근할 땐 mTasksRepository를 통해 이루어집니다.
그 외 private 메서드들은 굳이 공개되지 않아도 되는, 그 곳에서만 쓰일 메서드/변수들이겠죠.
엥 그런데 어디서 이 Presenter를 선언할까요? Fragment(View)는 Activity겠죠.
Presenter도 Activity입니다.
public class TasksActivity extends AppCompatActivity {
...
private TasksPresenter mTasksPresenter;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.tasks_act);
// Set up the toolbar.
...
// Set up the navigation drawer.
...
TasksFragment tasksFragment =
(TasksFragment) getSupportFragmentManager().findFragmentById(R.id.contentFrame);
if (tasksFragment == null) {
// Create the fragment
tasksFragment = TasksFragment.newInstance();
ActivityUtils.addFragmentToActivity(
getSupportFragmentManager(), tasksFragment, R.id.contentFrame);
}
// Create the presenter
mTasksPresenter = new TasksPresenter(
Injection.provideTasksRepository(getApplicationContext()), tasksFragment);
// Load previously saved state, if available.
...
}
@Override
public void onSaveInstanceState(Bundle outState) { ... }
...
}
Activity가 생성될 때, onCreate()에서 Fragment(View)와 Presenter가 생성됩니다.
Presenter를 멤버 변수로 선언했는데, 이는 onSaveInstanceState()를 통해 상태를 저장하고 불러들일 때 Presenter의 값을 불러오기 위한 것이므로 굳이 이럴 필요가 없다면 멤버 변수가 아니어도 됩니다.
Fragment는 평이하게 생성했지만 Presenter를 생성할 때 인수(Repository, View) 중 Repository 부분에 Injection.provideTasksRepository()를 통해서 생성하네요. 이는 유닛 테스트를 위해 이렇게 적용한 것 같은데, 아직 유닛 테스트에 대해선 정확히 알지 못해 이렇다 설명드릴 수가 없어서, 저 또한 공부할 예정입니다 :)
하지만 유닛 테스트를 모르면 Presenter를 생성하지 못하는가? 그것도 아닙니다. Presenter의 생성자대로 생성하면 됩니다. 실제로 해당 Injection의 메서드를 살펴보면,
/**
* Enables injection of mock implementations for
* {@link TasksDataSource} at compile time. This is useful for testing, since it allows us to use
* a fake instance of the class to isolate the dependencies and run a test hermetically.
*/
public class Injection {
public static TasksRepository provideTasksRepository(@NonNull Context context) {
checkNotNull(context);
return TasksRepository.getInstance(FakeTasksRemoteDataSource.getInstance(),
TasksLocalDataSource.getInstance(context));
}
}
그 메서드가 보이죠? 여기서 평이하게 Repository를 생성해 반환하네요. 따라서 다시 Activity에서 Presenter를 생성할 때 Injection.provide~ 메서드를 사용할 필요 없이, 저 반환형대로 쓰시면 됩니다.
이렇게 액티비티마다 View와 Presenter가 존재합니다.
나머지 하나, Model은 말씀드렸던 Repository 부분이겠죠? 위의 소스 코드 구성에서 data 부분을 살펴보겠습니다.
data.Task 는 각 할 일 아이템마다 들어갈 정보를 나타내는 클래스입니다. 할 일 하나당 해당 클래스의 객체 하나씩이에요. 우리한텐 중요하지 않은 애죠. data.source의 TasksDataSource와 TasksRepository가 중요합니다.
TasksRepository를 Model에 접근하기 위해 계속 써왔으니 살펴보겠습니다.
/**
* Concrete implementation to load tasks from the data sources into a cache.
*
* For simplicity, this implements a dumb synchronisation between locally persisted data and data
* obtained from the server, by using the remote data source only if the local database doesn't
* exist or is empty.
*/
public class TasksRepository implements TasksDataSource {
private static TasksRepository INSTANCE = null;
private final TasksDataSource mTasksRemoteDataSource;
private final TasksDataSource mTasksLocalDataSource;
...
// Prevent direct instantiation.
private TasksRepository(@NonNull TasksDataSource tasksRemoteDataSource,
@NonNull TasksDataSource tasksLocalDataSource) {
mTasksRemoteDataSource = checkNotNull(tasksRemoteDataSource);
mTasksLocalDataSource = checkNotNull(tasksLocalDataSource);
}
/**
* Returns the single instance of this class, creating it if necessary.
*
* @param tasksRemoteDataSource the backend data source
* @param tasksLocalDataSource the device storage data source
* @return the {@link TasksRepository} instance
*/
public static TasksRepository getInstance(TasksDataSource tasksRemoteDataSource,
TasksDataSource tasksLocalDataSource) {
if (INSTANCE == null) {
INSTANCE = new TasksRepository(tasksRemoteDataSource, tasksLocalDataSource);
}
return INSTANCE;
}
/**
* Used to force {@link #getInstance(TasksDataSource, TasksDataSource)} to create a new instance
* next time it's called.
*/
public static void destroyInstance() {
INSTANCE = null;
}
/**
* Gets tasks from cache, local data source (SQLite) or remote data source, whichever is
* available first.
*
* Note: {@link LoadTasksCallback#onDataNotAvailable()} is fired if all data sources fail to
* get the data.
*/
@Override
public void getTasks(@NonNull final LoadTasksCallback callback) {
checkNotNull(callback);
// Respond immediately with cache if available and not dirty
if (mCachedTasks != null && !mCacheIsDirty) {
callback.onTasksLoaded(new ArrayList<>(mCachedTasks.values()));
return;
}
if (mCacheIsDirty) {
// If the cache is dirty we need to fetch new data from the network.
getTasksFromRemoteDataSource(callback);
} else {
// Query the local storage if available. If not, query the network.
mTasksLocalDataSource.getTasks(new LoadTasksCallback() {
@Override
public void onTasksLoaded(List tasks) {
refreshCache(tasks);
callback.onTasksLoaded(new ArrayList<>(mCachedTasks.values()));
}
@Override
public void onDataNotAvailable() {
getTasksFromRemoteDataSource(callback);
}
});
}
}
@Override
public void saveTask(@NonNull Task task) { ... }
@Override
public void completeTask(@NonNull Task task) { ... }
@Override
public void completeTask(@NonNull String taskId) { ... }
@Override
public void activateTask(@NonNull Task task) { ... }
...
}
실제 코드에선 cache 기능 구현을 위해 여러 가지 작업을 해놨습니다만, 우린 이 샘플 코드가 어떻게 MVP를 활용했나만 추출하려는 목적이니 큰 부분만 보겠습니다. 참고로 여기선 Local로 데이터베이스에 접근하고, Remote로 외부 서버에 접근하는 척 합니다.
코드를 보면, 해당 Repository 객체는 싱글톤입니다. 앱 실행 중 클래스에 대한 객체가 메모리에 하나밖에 존재하지 않아, 동기화 문제를 일부 해결한 걸로 볼 수 있습니다. 또한 LocalDataSource와 RemoteDataSource에 접근할 수 있도록 멤버 변수로 각각 선언해줬네요. 각 변수의 초기화 또한 해당 싱글톤 객체가 생성될 때 한 번만 수행됩니다. 그러니까, Tasks 액티비티에서 Repository를 생성해버리면 다른 액티비티에선 절대 생성을 안 해요. 하지만 Tasks를 통하지 않고도 다른 액티비티에서 Repository를 생성할 순 있습니다.
그리고 마지막 부분에 여러 오버라이딩 메서드가 있는데, 이 메서드들이 바로 외부 Presenter들로부터 데이터(Model)에 접근할 때 사용할 수 있는 공개 메서드들입니다. 얘네는 implements TasksDataSource를 통해 받아온 애들이죠.
TasksDataSource 인터페이스의 소스 코드는 생략하겠습니다. 간단히, TasksDataSource 인터페이스엔 Presenter에서 필요한 메서드들이 정의됩니다. 그리고 이 TasksDataSource 인터페이스를 Repository 뿐 아니라 LocalDataSource, RemoteDataSource에서도 구현함으로써 실제 데이터 I/O가 이루어집니다. Repository에서 실제 데이터 I/O를 수행할 때 각 Local, Remote 변수를 통해 접근하거든요. 그리고 Callback으로 I/O작업 종료에 대한 처리까지 TasksDataSource 인터페이스에 정의되어 있습니다.
전체적인 구조가 그려지시나요? 개인적으로 이해한 걸 최대한 자세히 쓰려고 노력했는데, 부족한 점이나 틀린 점이 있으면 꼭 지적해주세요.
다음 포스트에선 이번 분석을 토대로 MVP 구조만 따온 저만의 소스 코드 예제를 다룰려고 합니다.
'Programming > Language Tip' 카테고리의 다른 글
안드로이드 - MVP 패턴 1. 개념 (0) | 2017.05.10 |
---|---|
안드로이드 - 서비스와 액티비티간 통신 (0) | 2017.05.08 |
안드로이드 - MVC 패턴 개념, 안드로이드에서 MVC 패턴의 미묘함 (3) | 2017.03.11 |
안드로이드 - 외부 스레드에서 UI 스레드로 (0) | 2017.03.11 |