GOOGLE TODO-MVP 学习笔记

GOOGLE TODO-MVP 学习笔记

背景(可忽略):《GOOGLE TODO-MVP 学习笔记》这篇文章主要会记录自己在根据TODO-MVP这个项目学习MVP的过程中的一些心得和想法,一是为了自己记录下来,二是为了说出来,增强自己的理解。
由于时间及经验有限,文中可能存在错误与不足,欢迎大家指出,我会第一时间对文章进行修改纠正。

如果对MVP模式不是很了解的,可以先去看看相关文章,这里推荐diygreen的两篇文章,MVP详解上下。
google 项目地址:https://github.com/googlesamples/android-architecture/
选择不同的分支,本文的是TODO-MVP,也是最基础的。

本文主要讲了两个部分

  1. 在TODO-MVP中是如何实现MVP的
  2. 一个简单的单元测试

TODO-MVP

先来一个整体的概览:

整个项目结构特别清晰,最外层是五个文件夹,两个代码目录,三个测试目录,之前看文章有说四个测试目录的,不过个人不是很认同。其中在main文件夹下是我们主要的代码(找不到的请切换到Project结构),展开的部分就是,可以看到是按照业务模块划分的,从上到下依次是添加模块数据层统计模块详细模块展示模块工具类PV基类,名字起的有些随意,再看每一个包中的具体类,以tasks为例:

  • ScrollChildSwipeRefreshLayout----自定义View
  • TasksActivity-----------------------------负责创建V,P
  • TasksContract---------------------------接口,V,P接口的纽带
  • TasksFilterType-------------------------枚举类
  • TasksFragment-------------------------View层实现类
  • TasksPresenter-------------------------Presenter层实现类

先以代码的方式了解下View层和Presenter层是如果创建并工作的,先来看看Activity:


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.tasks_act);

        //UI相关初始化,忽略
        //通过工具类创建一个Fragment,是View层的实现类
        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
        // 创建一个Presenter
        mTasksPresenter = new TasksPresenter(
                Injection.provideTasksRepository(getApplicationContext()), tasksFragment);

        // Load previously saved state, if available.
        //恢复界面中Task的类别
        if (savedInstanceState != null) {
            TasksFilterType currentFiltering =
                    (TasksFilterType) savedInstanceState.getSerializable(CURRENT_FILTERING_KEY);
            mTasksPresenter.setFiltering(currentFiltering);
        }
    }

可以看到在Activity初始化的时候分别创建了一个Fragment(View的实现类),一个TasksPresenter(Presenter的实现类),注意Presenter在构建的时候需要传入一个View对象。接下来看看Presenter初始化的时候都做了些什么:


    <-- Injection -- >
    public class Injection {
        public static TasksRepository provideTasksRepository(@NonNull Context context) {
            checkNotNull(context);
            return TasksRepository.getInstance(FakeTasksRemoteDataSource.getInstance(),
                    TasksLocalDataSource.getInstance(context));
        }
    }
    <-- Taskspresenter -- >
     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!");
        //为View层设置对应的Presenter层对象
        mTasksView.setPresenter(this);
    }

先通过Injection的静态方法provideTasksRepository()创建一个TasksRepository(Model层的实现类),之后将其与Fragment通过构造函数传递到Presenter中,这样在P层初始化的时候就持有了M和V的对象。之后会通过View.setPresenter(P)方法为View层设置对应的Presenter。看一下Fragment中的代码:


    <-- BaseView -- >
    public interface BaseView<T> {
        void setPresenter(T presenter);
    }
    <-- TasksFragment -- >

    private TasksContract.Presenter mPresenter;
    @Override
    public void setPresenter(@NonNull TasksContract.Presenter presenter) {
        mPresenter = checkNotNull(presenter);
    }

先在View的基类中声明抽象的设置方法,然后在Presenter初始化的时候将Presenter注入到View中。

总结一下:

  1. 在Activity创建的时候创建一个View对象,一个Presenter对象。
  2. 在创建presenter的时候将一个Model,上一步中创建好的View,通过构造函数注入到Presenter中。
  3. 在Presenter的构造方法中,通过View.setPresenter(P)方法,将Presenter设置到View中。

接下来用一个简单的例子来走一遍整体流程,以添加一个loadTasks为例:

第一步: 在TasksFragment的onResume()方法中,Presenter层开始工作。

    <-- TasksFragment -->
    @Override
    public void onResume() {
        super.onResume();
        mPresenter.start();
    }

第二步: TasksPresenter.start()方法中调用了loadTasks()方法,我们需要在TasksContract.Presenter中去规定这个方法,然后再在TasksPresenter中去实现它。


    <-- TasksPresenter -->
    @Override
    public void start() {
        loadTasks(false);
    }

    <-- TasksContract.Presenter -->
     void loadTasks(boolean forceUpdate);

    <-- TasksPresenter -->
    private boolean mFirstLoad = true;
     @Override
    public void loadTasks(boolean forceUpdate) {
        // Simplification for sample: a network reload will be forced on first load.
        loadTasks(forceUpdate || mFirstLoad, true);
        mFirstLoad = false;
    }

    private void loadTasks(boolean forceUpdate, final boolean showLoadingUI) {
        if (showLoadingUI) {
            mTasksView.setLoadingIndicator(true);
        }
        if (forceUpdate) {
            mTasksRepository.refreshTasks();
        }

        // The network request might be handled in a different thread so make sure Espresso knows
        // that the app is busy until the response is handled.
        EspressoIdlingResource.increment(); // App is busy until further notice
        //调用TasksRepository.getTasks方法去获取数据
        mTasksRepository.getTasks(new TasksDataSource.LoadTasksCallback() {
            @Override
            public void onTasksLoaded(List<Task> tasks) {
                List<Task> tasksToShow = new ArrayList<Task>();

                // This callback may be called twice, once for the cache and once for loading
                // the data from the server API, so we check before decrementing, otherwise
                // it throws "Counter has been corrupted!" exception.
                if (!EspressoIdlingResource.getIdlingResource().isIdleNow()) {
                    EspressoIdlingResource.decrement(); // Set app as idle.
                }
                // We filter the tasks based on the requestType
                //筛选想要类型的Task
                for (Task task : tasks) {
                    switch (mCurrentFiltering) {
                        case ALL_TASKS:
                            tasksToShow.add(task);
                            break;
                        case ACTIVE_TASKS:
                            if (task.isActive()) {
                                tasksToShow.add(task);
                            }
                            break;
                        case COMPLETED_TASKS:
                            if (task.isCompleted()) {
                                tasksToShow.add(task);
                            }
                            break;
                        default:
                            tasksToShow.add(task);
                            break;
                    }
                }
                // The view may not be able to handle UI updates anymore
                if (!mTasksView.isActive()) {
                    return;
                }
                if (showLoadingUI) {
                    mTasksView.setLoadingIndicator(false);
                }

                processTasks(tasksToShow);
            }

            @Override
            public void onDataNotAvailable() {
                // The view may not be able to handle UI updates anymore
                if (!mTasksView.isActive()) {
                    return;
                }
                mTasksView.showLoadingTasksError();
            }
        });
    }

第三步:调用TasksRepository.getTasks()方法,所以需要在TasksDataSource中添加getTasks,然后让TasksRepository去实现这个方法,在这个方法中调用具体的数据层的实现类mTasksRemoteDataSource,mTasksLocalDataSource中的getTasks,之后通过传递过来的接口将数据返回到Presenter中。

    
    <-- TasksRepository -->
    //具体的远程数据实现类
    private final TasksDataSource mTasksRemoteDataSource;
    //具体的本地数据实现类
    private final TasksDataSource mTasksLocalDataSource;
    //内存缓存
    Map<String, Task> mCachedTasks;

     @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<Task> tasks) {
                    refreshCache(tasks);
                    callback.onTasksLoaded(new ArrayList<>(mCachedTasks.values()));
                }
                @Override
                public void onDataNotAvailable() {
                    getTasksFromRemoteDataSource(callback);
                }
            });
        }
    }

第四步:调用mTasksView.showTasks()展示数据,所以需要在TasksContract.View中定义方法 void showTasks(List<Task> tasks);然后在TasksFragment中去实现。


    <-- TasksPresneter -->
     private void processTasks(List<Task> tasks) {
        if (tasks.isEmpty()) {
            // Show a message indicating there are no tasks for that filter type.
            processEmptyTasks();
        } else {
            // Show the list of tasks
            //显示查询回来的数据
            mTasksView.showTasks(tasks);
            // Set the filter label's text.
            showFilterLabel();
        }
    }
    <-- TasksFragment -->
    @Override
    public void showTasks(List<Task> tasks) {
        mListAdapter.replaceData(tasks);

        mTasksView.setVisibility(View.VISIBLE);
        mNoTasksView.setVisibility(View.GONE);
    }

这样一个流程就跑通了,从在View中调用Presenter方法去请求数据,Presenter中调用Model方法去获取数据,Model在调用具体实现方法,获取数据之后,将数据通过接口返回到Presenter中,之后再调用View的方法展示数据。
但是,如果是按照上面的顺序去写代码的话,肯定会觉得这实在是太复杂了,多写好多东西,所以个人猜测应该不是按照上面的方式去写的,猜测应该是这样:

第一步:在TasksContract.Presenter中去写一个方法让它去加载数据,比如LoadTasks();
第二步:在TasksDataSource中写一个方法让它去获取数据,比如getTasks(),之后再定义一个接口,用于传递数据,抽象一个方法参数是Task集合,一个简单的接口回调。
第三步:在TasksContract.View中写一个方法,用于展示数据,参数肯定是要展示的数据了,比如showTasks(tasks);
第四步:写各自的实现方法。在View中不用考虑数据是怎么来的,只管UI的变化就好;在Presenter中不用管怎么展示,怎么获取数据,只管应该找谁要数据,之后处理一下,交给View去显示就好了;在Model中,只需要得到数据,传递给Presenter就可以了,其他的完全不用操心。

在每一个自己的层级中做自己应该做的事情,并且对其他的东西尽量少的了解,尽可能的不出现干涉,专注做自己的事情,这样代码写起来其实会清晰很多,更加富有条理,而且在以后的扩展或者修改会变得更加的容易,而不会有那种牵一发而动全身的感觉。

不知道各位对上面第二种写代码的方法觉得怎么样,个人认为,当接口方法确定了之后,其实整个开发工作基本上就完成百分之七十了,在View中不用去考虑业务逻辑,不用去考虑UI的变化,因为数据传递过来之后所有的事情就都已经确定了,在Presenter中不用去考虑数据的来源,在Model中不去考虑数据的预处理和变换,将所有需要做的功能或者是动作都尽可能的细化,细化到每一层的每一个方法中,在一个方法中只做一件事情,其他的并不知道,也不需要知道,剩下的工作就是简单的填充代码了。

一个简单的单元测试

其实在TODO-MVP中测试的代码要比正式的代码要多,虽然没有具体数过,不过从目录数量来看就已经证明了一点,测试真的很重要,我之前从来没有写过任何测试代码,也没有专门学过,只是在平时看了几篇测试相关的文章,太深的讲不了,太浅的说着也没意思,就拿一个例子来说,当然,在说具体的例子之前,如果各位对相关的单元测试的知识不是很了解的话,推荐大家几篇文章 。
邹小创,相关测试文章十一篇,由浅入深,通俗易懂。
键盘男,介绍一些实际测试中的经验。
单元测试利器-Mockito 中文文档介绍Mockito相关API和使用方法,很全面。
看过以上大神的文章之后,自己再随便浏览一些相关文章,基本上就没问题了。

接下来就是例子了,代码是在test文件夹下tasks包中的TasksPresenter类


    <--TasksPresenterTest>
    private static List<Task> TASKS;

    @Mock
    private TasksRepository mTasksRepository;

    @Mock
    private TasksContract.View mTasksView;

    /**
     * {@link ArgumentCaptor} is a powerful Mockito API to capture argument values and use them to
     * perform further actions or assertions on them.
     */
    @Captor
    private ArgumentCaptor<LoadTasksCallback> mLoadTasksCallbackCaptor;

    private TasksPresenter mTasksPresenter;

    @Before
    public void setupTasksPresenter() {
        // Mockito has a very convenient way to inject mocks by using the @Mock annotation. To
        // inject the mocks in the test the initMocks method needs to be called.
        MockitoAnnotations.initMocks(this);

        // Get a reference to the class under test
        mTasksPresenter = new TasksPresenter(mTasksRepository, mTasksView);

        // The presenter won't update the view unless it's active.
        when(mTasksView.isActive()).thenReturn(true);

        // We start the tasks to 3, with one active and two completed
        TASKS = Lists.newArrayList(new Task("Title1", "Description1"),
                new Task("Title2", "Description2", true), new Task("Title3", "Description3", true));
    }

如果上面的代码,看不明白,那还是去阅读我刚才推荐的文章,这里就简单的说一下,先是mock了两个对象,相关的model和view。通过@Before 注解,在所有的测试方法做初始化,依据mock的对象,创建一个presenter,设置测试桩,初始化数据。
接下来只看一个方法:


    <--TasksPresenterTest>
    @Test
    public void loadAllTasksFromRepositoryAndLoadIntoView() {
        // Given an initialized TasksPresenter with initialized tasks
        // When loading of Tasks is requested
        //提供一个筛选的Task类型
        mTasksPresenter.setFiltering(TasksFilterType.ALL_TASKS);
        //加载数据
        mTasksPresenter.loadTasks(true);
        // Callback is captured and invoked with stubbed tasks
        //验证model的加载数据的方法是否执行,并且对入参进行捕获
        verify(mTasksRepository).getTasks(mLoadTasksCallbackCaptor.capture());
        //设置接口回调传递回来的数据
        mLoadTasksCallbackCaptor.getValue().onTasksLoaded(TASKS);

        // Then progress indicator is shown
        //验证mock view的方法执行顺序,创建一个inorder对象
        InOrder inOrder = inOrder(mTasksView);
        //验证view.setLoadingIndicator(true)是否执行
        inOrder.verify(mTasksView).setLoadingIndicator(true);
        // Then progress indicator is hidden and all tasks are shown in UI
        //验证view.setLoadingIndicator(false)是否执行
        inOrder.verify(mTasksView).setLoadingIndicator(false);
        //创建一个参数捕获器
        ArgumentCaptor<List> showTasksArgumentCaptor = ArgumentCaptor.forClass(List.class);
        //验证view.showTasks()方法是否执行,并且执行对其参数进行捕获
        verify(mTasksView).showTasks(showTasksArgumentCaptor.capture());
        //断言 判断捕获的参数也就是传入showTasks方法中的list的size是否为3
        assertTrue(showTasksArgumentCaptor.getValue().size() == 3);
    }

代码中对每一句都进行了注释,很好理解,这里对参数捕获器说一下,最开始我不明白这个东西是个什么玩意,怎么工作的,网上查就说是参数捕获器,能够捕捉到一个方法的入参的相关信息。然后自己就照着demo写,写完发现一运行报错了,如果是验证方法, 或者断音之类的问题,会有相关提示的,我这报错没有啊。


我还以为是为代码写的有问题,就把google里的代码拷过来,运行,还是不行,这就奇怪了,为什么同样的的代码在别人那就没问题,在我这就报错那,之后就开始排查问题,先一句句从下往上注释掉,运行,看看是哪句出的问题,被我发现了是这句verify(mTasksView).showTasks(showTasksArgumentCaptor.capture());,这我就更不明白了,这个是展示数据的,肯定没问题的啊,然后就去原代码中去排查,从上到下看了一遍,没问题,又看了一遍没问题,然后又回到测试代码,各种改,想看看是哪的问题,就上面的那几句测试代码,我玩了半天,还是没有找到为什么,不行了,估计是自己对mock这个东西不是很了解,就去查网上的资料,看各种译文,实例文章,介绍文章。当看到下面内容的时候我好想似乎明白了些什么。

是不是view.showTasks()方法没有执行啊,那样的话参数就不会被捕获,所以就去之前正式代码中添加打印语句,发现,确实没有执行,为什么没有执行那,继续往上找,在数据遍历的时候:

我擦嘞,我居然把集合写错了,可能是敲的时候没注意直接就确定了,也没看是哪一个了,改过来之后再运行,总终于成功了,就这么个问题,搞了我一天半,不过经历了这么个事情之后,我发现对于这些基本的测试桩,验证,断言,顺序执行,熟悉的不要不要的,真是没有磨难就没有进步啊。

这样一个简单的单元测试就完成了,测试内容那就是presenter加载数据,验证model获取数据,设置接口回调参数,验证view方法执行顺序,验证view方法是否执行,捕获参数,比对参数内数据。

多说几句

说一下我在学习这个项目的一些心得体会吧:

  1. 代码不是看的,一定要敲。不知道大家怎么去学习别人的项目,在我看来,最好的学习方式,就是把别人的项目敲一遍,看的时候有可能不过脑,敲的时候就肯定得思考了,为什么这么分包,应该怎么调用,之类的。
  2. 什么东西都不要浮于表面。因为现在已经不是那个,我见过,我了解的时代了。要尽可能的做到,我知道,我熟悉,我敲过,我写过相关案例, 我看过源码,我了解底层实现。
  3. 关于这个项目还有一部分没有介绍,那就是UI测试,目前正在看资料,之后应该会出一篇,不过应该是在整个项目都敲完的时候了。其实个人感觉,单元测试这个东西,重在经验,入门其实很容易,五六个注解,三四个方法,打桩,验证,断言,任何一个人估计半天到一天应该都差不多,感觉更重要的是正式代码的书写,如果正式代码写的不好,测试代码写都不能写,更别说验证了,而且测试经验很重要,只有经历足够多的测试案例,才会真正掌握单元测试的精髓吧,所谓的测试驱动开发,想想就觉得好激动。
  4. 我这才刚入门,还差得远那。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容