小之的架构之路——Android MVVM 面向接口型框架封装和单元测试

大家好,今天给大家带来一个我自己开发改造的 MVVM 封装框架。代码不难,但我更想说一些我在开发这样一个架构过程中的想法和思路,我们不仅要善于作一个搬运工,更要自己多多造轮子,我们程序员就是会折腾嘛。

思维导图

先送上源码地址:WeaponApp

多提一句,这个 App 是我和朋友最近正在努力开发的一款 app,涵盖绝大多数使用场景和技术(RxJava+Retrofit+MVVM+插件化+组件化+全平台分享+服务端)。尽量使用最优雅和最高级的方式来开发业务代码。使用这套框架可以快速构建 app,并能够进行高效的维护。

希望大家可以 star 一下,提一些建议,帮助我们更好地完善它!


在讲具体的实现和思路之前,我们需要多说一些东西,可以说是封装的动机吧,或者可以解释为什么要用面向接口的思想来封装。

去年的时候,MVP在移动端比较火热,一直持续到现在,MVVM作为更为高雅和清晰的开发架构,使用的人不是很多。不像MVP,我在研究的时候,想搜索一些封装的资料,发现多数只能找到dataBinding的资料,但很少有教你怎么封装的。 「Google」爸爸的databinding为我们提供好了轮子,我们实际上按照官方的使用方式来使用MVVM已经是比较简单了,只需要在 View 里构建VM,在VM里维持一个Model引用,进行相关数据的绑定即可。可以说是非常好用了。

那么,为什么要特别地再封装一下呢?

这就和我们设计架构的目的和思路有关了。当然了,还有作为程序员,肯定还是希望能写出最优雅、最简洁、最高级的代码,我们都是偏执狂

设计思路:测试驱动、面向接口、隐蔽实现

首先,我们要明确一点,不论是MVP还是MVVM,它们都不一定会让你用更少的代码来实现一个页面,代码量可能会更多。它们能做到的就是做到数据、逻辑、视图关系的解耦,提升代码的可维护性、可读性、设计性和可测性

MVVM 中,ViewModel 层是 View 和 Model 的中转层,View 专门用来处理 UI 的操作,Model 是一些数据实体,ViewModel 操作一些和数据处理相关的绑定操作,因为 databinding 的双向绑定特性,最好的封装应该是让 View 层只有绑定 ViewModel 和一些必要的 UI 操作,整体的逻辑和思路干净整齐,ViewModel 是一个个功能单一方法的集合。

「单一原则」是我们写代码的时候一定要养成的好习惯,它不仅能帮助我们写出更优雅的代码,也是代码具有可测性、逻辑性和可维护性的要求。

MVVM 单元测试很方便,因为有了双向绑定。只需要测一下 ViewModel 的方法,方法通过了即可验证数据和 UI 逻辑。我们写代码的时候,就应该保持好设计性,尽量做到让代码的可测性很强,保持单一原则,隔离好 View 和 Model 的逻辑,让代码通过验证方法而不需要真正构造 Activity 实例就能有足够的可测性。为了让代码保持可测行,要求我们代码需要具有设计性,而代码的设计性和单一原则又是单元测试的一个本身要求,两者相互影响,相互驱动。

这就是测试驱动开发。

好了,现在我们代码写的也设计性了,方法也够单一了,但单元测试的时候,ViewModel 作为 View 和 Model 的桥梁,它实际上应该持有 View 和 Model 的引用的,可是单元测试构造 Activity 对象不方便,我们既然是要使用单元测试,就应该尽量避免需要打开页面这样的操作,虽然我们有一些非常强大的第三方单元测试框架能够构造 Activity 和 Fragment 甚至可以验证一些 UI 的操作,但总而言之还是一个比较麻烦而妥协的做法,所以我根据AndroidFire这个项目上的 MVP 封装思路,进行了 MVVM 的改造,实现了编译期的多态,通过反射构造类型参数的具体对象,在 Contact 中定义各个层级的接口,ViewModel 进行跨层调用的时候,只关注具体接口的形式,而不关心接口的具体实现和到底是哪个实例实现了他。

这就是面向接口了。

同时,我们隐藏了 databinding 的绑定操作,集成了一些ListViewRecyclerViewViewPager的 databinding 第三方使用库,再通过自定义一些@BindAdapter帮助更好的进行 MVVM 开发。即使开发者之前不了解 databinding,按照我们封装的操作流程,开发界面就像堆砖块一样简单高效。

面向接口的框架在作单元测试的时候,我们只需要自己构建出一个空实现的接口实例,即可跳过一些 View 层的 UI 操作或者 Model 层的请求操作,做到真正意义上的单元测试。

说的很抽象,下一节我们来看一下具体代码。

MVVM 封装核心实现

我们先来看下封装的一些基类设计思路。因为「WeaponApp」的页面全是用 Fragment 进行开发的,只需要一个占坑 Activity 作为容器来展示 Fragment,所以我们只针对 Fragment 进行了基类封装:

public abstract class BaseFragment<VM extends BaseViewModel<? extends BaseView, ? extends BaseModel>,
        M extends BaseModel>
        extends Fragment
        implements BaseView {}

emm...这是什么。。看着这么多泛型叠加,是不是有点头晕,别急,我们从后往前慢慢看。

BaseView 是一个接口,里面定义了一些必须要实现的方法,比如databinding 需要的BR文件,init初始化方法等,最重要的是定义了一个基类类型,表示项目中所有的 Fragment 都是这个接口类型,辅助编译期检查。

M extends BaseModel:定义具体的 Model 类型。

VM extends BaseViewModel<? extends BaseViewModel<? extends BaseView,? extends BaseModel>>: VM 的泛型是比较复杂的,Android 中的列表控件都是需要一个 Adapter ,为了管理这些列表 item 的 VM,并且做到统一处理,所以 BaseViewModel 中的两个泛型类型都是没有 extends 来限制范围的,那么为了区分是页面 VM 还是 item 的 VM。在 BaseFragment 中,通过通配符来限定范围,在编译期提醒开发者。

因为使用了binding-collection-adapter,所以在使用像 ListView,RecyclerView 和 ViewPager 这类控件的时候,是不需要通过 adapter 来进行管理的,全部都是通过 item 的 VM,通过 MVVM 的形式来配置。

好了,看好了类的定义代码,我们来下最关键的onCreateView()方法:

 @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        super.onCreateView(inflater, container, savedInstanceState);
        return initFragment(inflater, container);
    }

继续跟进initFragment方法:

private View initFragment(LayoutInflater inflater, ViewGroup container) {
    if (mViewDataBinding == null) {
        mContext = getActivity();
        mViewDataBinding = DataBindingUtil.inflate(inflater, getLayoutId(), container, false);

       //反射生成泛型类对象
        mViewModel = TUtil.getT(this, 0);
        M model = TUtil.getT(this, 1);

       //VM 和 View 绑定
       if (mViewModel != null) {
           mViewModel.setContext(mContext);
           try {
               Method setModel = mViewModel.getClass().getMethod("setModel",Object.class);
               Method attachView = mViewModel.getClass().getMethod("attachView", Object.class);
               setModel.invoke(mViewModel, model);
               attachView.invoke(mViewModel, this);
           } catch (Exception e) {
               e.printStackTrace();
           }
      }

       //Model 和 VM 绑定
       if (model != null) {
           model.attachViewModel(mViewModel);
       }

       //DataBinding 绑定
       mViewDataBinding.setVariable(getBR(), mViewModel);

       initView();
 }

这里有一些 databinding 的绑定操作,就不多细说了,我们来看下中间的部分。

mViewModel = TUtil.getT(this,0);
M model = TUtil.getT(this,1);

这里的 mViewModel 的类型实际上是 VM,TUtil.getT(this,0)方法的第二个参数传入的是类上定义的泛型位置,比如 VM 在 BaseFragment 中的位置是第一个,那么就传入 0,M 是第二个,那么就传入 1 。该方法将返回具体泛型参数类型的实例。这样做的好处就是我们不需要手动操作构建对象并将引用保存到成员变量上了,只需要定义好具体类型参数的泛型类型,即可通过getViewModel获取 ViewModel 的具体实例。

继续看代码。model.attachViewModel将 ViewModel 绑定到 Model,ViewModel 和 View 的绑定以及将 Model 绑定到 ViewModel 是中间一段代码做到的:

Method setModel = mViewModel.getClass().getMethod("setModel",Object.class);
Method attachView = mViewModel.getClass().getMethod("attachView", Object.class);
setModel.invoke(mViewModel, model);
attachView.invoke(mViewModel, this);

通配符实际上是一种具体但未知类型的类型。ViewModel 的attachViewsetModel方法的参数都是泛型参数,所以这里必须通过反射来获取具体的方法实例,再通过invoke进行调用方法。

举个栗子??

OK,那么我们来看看到底怎么就「傻瓜式」开发了,怎么就单元测试很好使了。比如现在项目中的我的界面,用这个封装框架来写界面的时候,先写一个接口定义类 Contact :

interface MineContact{
    interface View extends BaseView{
        void testType();
    }
    
    abstract class ViewModel extends BaseViewModel<View,MineModel>{
        abstract void onHttpResponse();//数据请求成功回调
        abstract void onHttpError();//数据请求失败回调
    }

    abstract class Model extends BaseModel<ViewModel>{
        abstract void loadData();//请求数据
    }

}

这里定义了 MVVM 三层的类型和接口。当你需要添加接口的时候,只需要在这里添加即可。下面是MineFragmentMineViewModelMineModel的类定义:

//View
public class MineFragment extends BaseFragment<MineViewModel,MineModel> implements MineContact.View{

    private ShareView mShareView;
    @Override
    public int getLayoutId() {
        return R.layout.fragment_mine;
    }

    @Override
    public void initView() {
     
    }

    @Override
    public int getBR() {
        return com.weapon.joker.app.mine.BR.model;
    }

    @Override
    public void testType(){
        
    }
}

//ViewModel
public class MineViewModel extends MineContact.ViewModel{

    public void init(){
        setTestString("反射封装测试成功");
        getView().testType();
        getModel.loadData();
    }

    @Bindable
    public String getTestString(){
        return getModel().testString;
    }

    public void setTestString(String testString){
        getModel().testString = testString;
        notifyPropertyChanged(BR.testString);
    }

    public void onHttpResponse(){}
    public void onHttpError(){}
}

//Model
public class MineModel extends MineContact.Model{
    @Bindable
    public String testString;

    public void loadData(){
        getViewModel().onHttpResponse();
        getViewModel().onHttpError();
    }
}

我们可以看到我们写具体类中,所有类的集成格式是一样的,并且我们内部可以通过我们刚刚在 Contact 中定义的接口进行各个层级之间的通信,在编译期,我们并不用关心各个接口具体的实现是什么,具体的实现将被移步到运行期中,这极大的方便了我们的单元测试,这也是多态和里式替换原则的应用。同时我们发现 MVVM 的很多操作在 ViewModel 层都被隐藏了,如果你想使用 BR 文件,就自己定义相对应的 get 方法,并不需要具体的保存一个 model 的成员变量了。下面我们来看看具体的单元测试该怎么写:

比如我们现在要测试 VM 中的 init 方法,其中的 View 接口 testType() 是一个吐司显示,为了通过这个方法,我们如果构建一个 MineFragment 实例,无疑非常麻烦,但在我们这套封装中,我们只需要这样写即可:

public class Test{
    @Test
    public void main(){
        MineContact.View view = new MineContact.View(){
             @Override
             public void testType() {}
             
             @Override
             public int getLayoutId() {
             return 0;
             }
             
             @Override
             public void initView() {}
             
             @Override
             public int getBR() {
             return 0;
             }  
        };
        
    MineContact.Model model = new MineContact.Model(){
        @Override
        void loadData() {}
    };
    
    MineViewModel vm = new MineViewModel();
    vm.attachView(view);
    vm.setModel(model);
    //调用 init() 方法
    vm.init();
    }
}

我们成功的在单元测试中调用了 VM 的 init 方法,也没有构造真正的 MineFragment,只是自己定义了一个和 MineFragment 同类型的接口,因为面向接口的原因,VM 仍然能对其进行调用操作,我们依然不需要关心 testType() 方法内部到底是不是和 MineFragment 定义的 testType() 方法是不是一样的,因为这里都是 UI 操作,我们不需要在 MVVM 的单元测试中测试它。

MVVM 的强大当然不止于此,还需要读者自己多多发掘。当然,在学习别人的轮子的时候,一定要多多思考,举一反三,不能一味的搬运。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,198评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,663评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,985评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,673评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,994评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,399评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,717评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,407评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,112评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,371评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,891评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,255评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,881评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,010评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,764评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,412评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,299评论 2 260

推荐阅读更多精彩内容

  • 1、概述 Databinding 是一种框架,MVVM是一种模式,两者的概念是不一样的。我的理解DataBindi...
    Kelin阅读 76,552评论 68 520
  • 概述 说到MVVM,大家都会想起前端的MVVM框架,相较于前端MVVM的火热,它在移动开发领域就不那么热门了。Go...
    ditclear阅读 19,922评论 13 67
  • 路过的风景, 看到的人事。 小小的本子, 记录着每天。 日常的繁琐, 消耗着身心。 伸开手扬起, 握不住残沙。 夜...
    泪尽成陌阅读 180评论 0 0
  • 2016年学习的 2016年学习提升编程技能。 2016年更新自己的观念。 2016年明白了要学会爱自己。 201...
    刘挚珂阅读 197评论 0 0