Programming/Language Tip

안드로이드 - MVP 패턴 2. 분석

awesometic 2017. 5. 12. 16:02
반응형

 이번엔 본격적으로 구글에서 제공하는 "안드로이드 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 구조만 따온 저만의 소스 코드 예제를 다룰려고 합니다.

반응형